You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
etesync-android/app/src/main/java/at/bitfire/davdroid/webdav/WebDavResource.java

574 lines
20 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/*
* Copyright (c) 2013 2015 Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.davdroid.webdav;
import android.util.Log;
import org.apache.commons.lang.StringUtils;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpHost;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.StatusLine;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.AuthCache;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpDeleteHC4;
import org.apache.http.client.methods.HttpGetHC4;
import org.apache.http.client.methods.HttpOptionsHC4;
import org.apache.http.client.methods.HttpPutHC4;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.client.utils.URIUtilsHC4;
import org.apache.http.entity.ByteArrayEntityHC4;
import org.apache.http.impl.auth.BasicSchemeHC4;
import org.apache.http.impl.client.BasicAuthCache;
import org.apache.http.impl.client.BasicCredentialsProviderHC4;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.message.BasicLineParserHC4;
import org.apache.http.util.EntityUtilsHC4;
import org.simpleframework.xml.Serializer;
import org.simpleframework.xml.core.Persister;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringWriter;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import at.bitfire.davdroid.URIUtils;
import at.bitfire.davdroid.resource.Event;
import at.bitfire.davdroid.webdav.DavProp.Comp;
import ezvcard.VCardVersion;
import lombok.Cleanup;
import lombok.Getter;
import lombok.ToString;
/**
* Represents a WebDAV resource (file or collection).
* This class is used for all CalDAV/CardDAV communcation.
*/
@ToString
public class WebDavResource {
private static final String TAG = "davdroid.WebDavResource";
public enum Property {
CURRENT_USER_PRINCIPAL, // resource detection
ADDRESSBOOK_HOMESET, CALENDAR_HOMESET,
CONTENT_TYPE, READ_ONLY, // WebDAV (common)
DISPLAY_NAME, DESCRIPTION, ETAG,
IS_COLLECTION, CTAG, // collections
IS_CALENDAR, COLOR, TIMEZONE, // CalDAV
IS_ADDRESSBOOK, VCARD_VERSION // CardDAV
}
public enum PutMode {
ADD_DONT_OVERWRITE,
UPDATE_DONT_OVERWRITE
}
// location of this resource
@Getter protected URI location;
// DAV capabilities (DAV: header) and allowed DAV methods (set for OPTIONS request)
protected Set<String> capabilities = new HashSet<String>(),
methods = new HashSet<String>();
// DAV properties
protected HashMap<Property, String> properties = new HashMap<Property, String>();
@Getter protected List<String> supportedComponents;
// list of members (only for collections)
@Getter protected List<WebDavResource> members;
// content (available after GET)
@Getter protected byte[] content;
protected CloseableHttpClient httpClient;
protected HttpClientContext context;
public WebDavResource(CloseableHttpClient httpClient, URI baseURI) {
this.httpClient = httpClient;
location = baseURI;
context = HttpClientContext.create();
context.setCredentialsProvider(new BasicCredentialsProviderHC4());
}
public WebDavResource(CloseableHttpClient httpClient, URI baseURI, String username, String password, boolean preemptive) {
this(httpClient, baseURI);
context.getCredentialsProvider().setCredentials(
AuthScope.ANY,
new UsernamePasswordCredentials(username, password)
);
if (preemptive) {
HttpHost host = new HttpHost(baseURI.getHost(), baseURI.getPort(), baseURI.getScheme());
Log.d(TAG, "Using preemptive authentication (not compatible with Digest auth)");
AuthCache authCache = context.getAuthCache();
if (authCache == null)
authCache = new BasicAuthCache();
authCache.put(host, new BasicSchemeHC4());
context.setAuthCache(authCache);
}
}
public WebDavResource(WebDavResource parent) { // copy constructor: based on existing WebDavResource, reuse settings
// reuse httpClient, context and location (no deep copy)
httpClient = parent.httpClient;
context = parent.context;
location = parent.location;
}
public WebDavResource(WebDavResource parent, URI url) {
this(parent);
location = parent.location.resolve(url);
}
/**
* Creates a WebDavResource representing a member of the parent collection.
* @param parent Parent collection
* @param member File name of the member. This may contain ":" without leading "./"!
* @throws URISyntaxException
*/
public WebDavResource(WebDavResource parent, String member) throws URISyntaxException {
this(parent);
location = parent.location.resolve(URIUtils.parseURI(member, true));
}
public WebDavResource(WebDavResource parent, String member, String ETag) throws URISyntaxException {
this(parent, member);
properties.put(Property.ETAG, ETag);
}
/* feature detection */
public void options() throws URISyntaxException, IOException, HttpException {
HttpOptionsHC4 options = new HttpOptionsHC4(location);
@Cleanup CloseableHttpResponse response = httpClient.execute(options, context);
checkResponse(response);
Header[] allowHeaders = response.getHeaders("Allow");
for (Header allowHeader : allowHeaders)
methods.addAll(Arrays.asList(allowHeader.getValue().split(", ?")));
Header[] capHeaders = response.getHeaders("DAV");
for (Header capHeader : capHeaders)
capabilities.addAll(Arrays.asList(capHeader.getValue().split(", ?")));
}
public boolean supportsDAV(String capability) {
return capabilities.contains(capability);
}
public boolean supportsMethod(String method) {
return methods.contains(method);
}
/* file hierarchy methods */
public String getName() {
String[] names = StringUtils.split(location.getPath(), "/");
return names[names.length - 1];
}
/* property methods */
public URI getCurrentUserPrincipal() throws URISyntaxException {
String principal = properties.get(Property.CURRENT_USER_PRINCIPAL);
return principal != null ? URIUtils.parseURI(principal, false) : null;
}
public URI getAddressbookHomeSet() throws URISyntaxException {
String homeset = properties.get(Property.ADDRESSBOOK_HOMESET);
return homeset != null ? URIUtils.parseURI(homeset, false) : null;
}
public URI getCalendarHomeSet() throws URISyntaxException {
String homeset = properties.get(Property.CALENDAR_HOMESET);
return homeset != null ? URIUtils.parseURI(homeset, false) : null;
}
public String getContentType() {
return properties.get(Property.CONTENT_TYPE);
}
public void setContentType(String mimeType) {
properties.put(Property.CONTENT_TYPE, mimeType);
}
public boolean isReadOnly() {
return properties.containsKey(Property.READ_ONLY);
}
public String getDisplayName() {
return properties.get(Property.DISPLAY_NAME);
}
public String getDescription() {
return properties.get(Property.DESCRIPTION);
}
public String getCTag() {
return properties.get(Property.CTAG);
}
public void invalidateCTag() {
properties.remove(Property.CTAG);
}
public String getETag() {
return properties.get(Property.ETAG);
}
public boolean isCalendar() {
return properties.containsKey(Property.IS_CALENDAR);
}
public String getColor() {
return properties.get(Property.COLOR);
}
public String getTimezone() {
return properties.get(Property.TIMEZONE);
}
public boolean isAddressBook() {
return properties.containsKey(Property.IS_ADDRESSBOOK);
}
public VCardVersion getVCardVersion() {
String versionStr = properties.get(Property.VCARD_VERSION);
return (versionStr != null) ? VCardVersion.valueOfByStr(versionStr) : null;
}
/* collection operations */
public void propfind(HttpPropfind.Mode mode) throws URISyntaxException, IOException, DavException, HttpException {
@Cleanup CloseableHttpResponse response = null;
// processMultiStatus() requires knowledge of the actual content location,
// so we have to handle redirections manually and create a new request for the new location
for (int i = context.getRequestConfig().getMaxRedirects(); i > 0; i--) {
HttpPropfind propfind = new HttpPropfind(location, mode);
response = httpClient.execute(propfind, context);
if (response.getStatusLine().getStatusCode()/100 == 3) {
location = DavRedirectStrategy.getLocation(propfind, response, context);
Log.i(TAG, "Redirection on PROPFIND; trying again at new content URL: " + location);
// don't forget to throw away the unneeded response content
HttpEntity entity = response.getEntity();
if (entity != null) { @Cleanup InputStream content = entity.getContent(); }
} else
break; // answer was NOT a redirection, continue
}
if (response == null)
throw new DavNoContentException();
checkResponse(response); // will also handle Content-Location
processMultiStatus(response);
}
public void multiGet(DavMultiget.Type type, String[] names) throws URISyntaxException, IOException, DavException, HttpException {
@Cleanup CloseableHttpResponse response = null;
// processMultiStatus() requires knowledge of the actual content location,
// so we have to handle redirections manually and create a new request for the new location
for (int i = context.getRequestConfig().getMaxRedirects(); i > 0; i--) {
// build multi-get XML request
List<String> hrefs = new LinkedList<String>();
for (String name : names)
// name may contain "%" which have to be encoded → use non-quoting URI constructor and getRawPath()
// name may also contain ":", so prepend "./" because even the non-quoting URI constructor parses after constructing
// DAVdroid ensures that collections always have a trailing slash, so "./" won't go down in directory hierarchy
hrefs.add(location.resolve(new URI(null, null, "./" + name, null)).getRawPath());
DavMultiget multiget = DavMultiget.newRequest(type, hrefs.toArray(new String[0]));
StringWriter writer = new StringWriter();
try {
Serializer serializer = new Persister();
serializer.write(multiget, writer);
} catch (Exception ex) {
Log.e(TAG, "Couldn't create XML multi-get request", ex);
throw new DavException("Couldn't create multi-get request");
}
// submit REPORT request
HttpReport report = new HttpReport(location, writer.toString());
response = httpClient.execute(report, context);
if (response.getStatusLine().getStatusCode()/100 == 3) {
location = DavRedirectStrategy.getLocation(report, response, context);
Log.i(TAG, "Redirection on REPORT multi-get; trying again at new content URL: " + location);
// don't forget to throw away the unneeded response content
HttpEntity entity = response.getEntity();
if (entity != null) { @Cleanup InputStream content = entity.getContent(); }
} else
break; // answer was NOT a redirection, continue
}
if (response == null)
throw new DavNoContentException();
checkResponse(response); // will also handle Content-Location
processMultiStatus(response);
}
/* resource operations */
public void get(String acceptedType) throws URISyntaxException, IOException, HttpException, DavException {
HttpGetHC4 get = new HttpGetHC4(location);
get.addHeader("Accept", acceptedType);
@Cleanup CloseableHttpResponse response = httpClient.execute(get, context);
checkResponse(response);
HttpEntity entity = response.getEntity();
if (entity == null)
throw new DavNoContentException();
content = EntityUtilsHC4.toByteArray(entity);
}
// returns the ETag of the created/updated resource, if available (null otherwise)
public String put(byte[] data, PutMode mode) throws URISyntaxException, IOException, HttpException {
HttpPutHC4 put = new HttpPutHC4(location);
put.setEntity(new ByteArrayEntityHC4(data));
switch (mode) {
case ADD_DONT_OVERWRITE:
put.addHeader("If-None-Match", "*");
break;
case UPDATE_DONT_OVERWRITE:
put.addHeader("If-Match", (getETag() != null) ? getETag() : "*");
break;
}
if (getContentType() != null)
put.addHeader("Content-Type", getContentType());
@Cleanup CloseableHttpResponse response = httpClient.execute(put, context);
checkResponse(response);
Header eTag = response.getLastHeader("ETag");
if (eTag != null)
return eTag.getValue();
return null;
}
public void delete() throws URISyntaxException, IOException, HttpException {
HttpDeleteHC4 delete = new HttpDeleteHC4(location);
if (getETag() != null)
delete.addHeader("If-Match", getETag());
@Cleanup CloseableHttpResponse response = httpClient.execute(delete, context);
checkResponse(response);
}
/* helpers */
protected void checkResponse(HttpResponse response) throws HttpException {
checkResponse(response.getStatusLine());
// handle Content-Location header (see RFC 4918 5.2 Collection Resources)
Header contentLocationHdr = response.getFirstHeader("Content-Location");
if (contentLocationHdr != null) {
// Content-Location was set, update location correspondingly
location = location.resolve(contentLocationHdr.getValue());
Log.d(TAG, "Set Content-Location to " + location);
}
}
protected static void checkResponse(StatusLine statusLine) throws HttpException {
int code = statusLine.getStatusCode();
if (code/100 == 1 || code/100 == 2) // everything OK
return;
String reason = code + " " + statusLine.getReasonPhrase();
switch (code) {
case HttpStatus.SC_UNAUTHORIZED:
throw new NotAuthorizedException(reason);
case HttpStatus.SC_NOT_FOUND:
throw new NotFoundException(reason);
case HttpStatus.SC_PRECONDITION_FAILED:
throw new PreconditionFailedException(reason);
default:
throw new HttpException(code, reason);
}
}
protected void processMultiStatus(HttpResponse response) throws IOException, HttpException, DavException {
if (response.getStatusLine().getStatusCode() != HttpStatus.SC_MULTI_STATUS)
throw new DavNoMultiStatusException();
HttpEntity entity = response.getEntity();
if (entity == null)
throw new DavNoContentException();
@Cleanup InputStream content = entity.getContent();
DavMultistatus multiStatus;
try {
Serializer serializer = new Persister();
multiStatus = serializer.read(DavMultistatus.class, content, false);
} catch (Exception ex) {
throw new DavException("Couldn't parse Multi-Status response on REPORT multi-get", ex);
}
if (multiStatus.response == null) // empty response
throw new DavNoContentException();
// member list will be built from response
List<WebDavResource> members = new LinkedList<WebDavResource>();
// iterate through all resources (either ourselves or member)
for (DavResponse singleResponse : multiStatus.response) {
URI href;
try {
href = location.resolve(URIUtils.parseURI(singleResponse.getHref().href, false));
} catch(Exception ex) {
Log.w(TAG, "Ignoring illegal member URI in multi-status response", ex);
continue;
}
Log.d(TAG, "Processing multi-status element: " + href);
// process known properties
HashMap<Property, String> properties = new HashMap<Property, String>();
List<String> supportedComponents = null;
byte[] data = null;
for (DavPropstat singlePropstat : singleResponse.getPropstat()) {
StatusLine status = BasicLineParserHC4.parseStatusLine(singlePropstat.status, new BasicLineParserHC4());
// ignore information about missing properties etc.
if (status.getStatusCode()/100 != 1 && status.getStatusCode()/100 != 2)
continue;
DavProp prop = singlePropstat.prop;
if (prop.currentUserPrincipal != null && prop.currentUserPrincipal.getHref() != null)
properties.put(Property.CURRENT_USER_PRINCIPAL, prop.currentUserPrincipal.getHref().href);
if (prop.currentUserPrivilegeSet != null) {
// privilege info available
boolean mayAll = false,
mayBind = false,
mayUnbind = false,
mayWrite = false,
mayWriteContent = false;
for (DavProp.Privilege privilege : prop.currentUserPrivilegeSet) {
if (privilege.getAll() != null) mayAll = true;
if (privilege.getBind() != null) mayBind = true;
if (privilege.getUnbind() != null) mayUnbind = true;
if (privilege.getWrite() != null) mayWrite = true;
if (privilege.getWriteContent() != null) mayWriteContent = true;
}
if (!mayAll && !mayWrite && !(mayWriteContent && mayBind && mayUnbind))
properties.put(Property.READ_ONLY, "1");
}
if (prop.addressbookHomeSet != null && prop.addressbookHomeSet.getHref() != null)
properties.put(Property.ADDRESSBOOK_HOMESET, URIUtils.ensureTrailingSlash(prop.addressbookHomeSet.getHref().href));
if (prop.calendarHomeSet != null && prop.calendarHomeSet.getHref() != null)
properties.put(Property.CALENDAR_HOMESET, URIUtils.ensureTrailingSlash(prop.calendarHomeSet.getHref().href));
if (prop.displayname != null)
properties.put(Property.DISPLAY_NAME, prop.displayname.getDisplayName());
if (prop.resourcetype != null) {
if (prop.resourcetype.getCollection() != null) {
properties.put(Property.IS_COLLECTION, "1");
// is a collection, ensure trailing slash
href = URIUtils.ensureTrailingSlash(href);
}
if (prop.resourcetype.getAddressbook() != null) { // CardDAV collection properties
properties.put(Property.IS_ADDRESSBOOK, "1");
if (prop.addressbookDescription != null)
properties.put(Property.DESCRIPTION, prop.addressbookDescription.getDescription());
if (prop.supportedAddressData != null)
for (DavProp.AddressDataType dataType : prop.supportedAddressData)
if ("text/vcard".equalsIgnoreCase(dataType.getContentType()))
// ignore "3.0" as it MUST be supported anyway
if ("4.0".equals(dataType.getVersion()))
properties.put(Property.VCARD_VERSION, VCardVersion.V4_0.getVersion());
}
if (prop.resourcetype.getCalendar() != null) { // CalDAV collection propertioes
properties.put(Property.IS_CALENDAR, "1");
if (prop.calendarDescription != null)
properties.put(Property.DESCRIPTION, prop.calendarDescription.getDescription());
if (prop.calendarColor != null)
properties.put(Property.COLOR, prop.calendarColor.getColor());
if (prop.calendarTimezone != null)
try {
properties.put(Property.TIMEZONE, Event.TimezoneDefToTzId(prop.calendarTimezone.getTimezone()));
} catch(IllegalArgumentException e) {
}
if (prop.supportedCalendarComponentSet != null) {
supportedComponents = new LinkedList<String>();
for (Comp component : prop.supportedCalendarComponentSet)
supportedComponents.add(component.getName());
}
}
}
if (prop.getctag != null)
properties.put(Property.CTAG, prop.getctag.getCTag());
if (prop.getetag != null)
properties.put(Property.ETAG, prop.getetag.getETag());
if (prop.calendarData != null && prop.calendarData.ical != null)
data = prop.calendarData.ical.getBytes();
else if (prop.addressData != null && prop.addressData.vcard != null)
data = prop.addressData.vcard.getBytes();
}
// about which resource is this response?
if (location.equals(href) || URIUtils.ensureTrailingSlash(location).equals(href)) { // about ourselves
this.properties.putAll(properties);
if (supportedComponents != null)
this.supportedComponents = supportedComponents;
this.content = data;
} else { // about a member
WebDavResource member = new WebDavResource(this, href);
member.properties = properties;
member.supportedComponents = supportedComponents;
member.content = data;
members.add(member);
}
}
this.members = members;
}
}