diff --git a/res/values-ca/strings.xml b/res/values-ca/strings.xml index b7732d68..a1e821b2 100644 --- a/res/values-ca/strings.xml +++ b/res/values-ca/strings.xml @@ -88,7 +88,7 @@ o comprar-lo.

Llicència

-

Copyright (c) 2013 – 2014 Richard Hirner (bitfire web engineering). Tots els drets reservats. +

Copyright (c) 2013 – 2014 Ricki Hirner (bitfire web engineering). Tots els drets reservats. Aquest programa i tots els materials que l\'acompanyen estan disponibles sota els termes de la GNU Public License v3.0 que acompanya aquesta distribució i està disponible a http://www.gnu.org/licenses/gpl.html. Respecte al Google Play, Samsung diff --git a/res/values-de/strings.xml b/res/values-de/strings.xml index 31f61826..40eec9b3 100644 --- a/res/values-de/strings.xml +++ b/res/values-de/strings.xml @@ -82,7 +82,7 @@ für DAVdroid spenden oder die App kaufen.

Lizenz

-

Copyright (c) 2013 – 2014 Richard Hirner (bitfire web engineering), alle Rechte +

Copyright (c) 2013 – 2014 Ricki Hirner (bitfire web engineering), alle Rechte vorbehalten. Dieses Programm ist freie Software. Sie können es unter den Bedingungen der GNU General Public License Version 3, wie von der Free Software Foundation veröffentlicht, weitergeben und/oder modifizieren. Sofern Google Play oder Samsung Store andere Bedingungen benötigen, gelten für über den jeweiligen Markt heruntergeladene diff --git a/res/values-fr/strings.xml b/res/values-fr/strings.xml index 9aafd2d3..1169b9c7 100644 --- a/res/values-fr/strings.xml +++ b/res/values-fr/strings.xml @@ -65,7 +65,7 @@ Si vous voulez aider ce projet faites un don à DAVdroid ou achetez le

License

-

Copyright (c) 2013 – 2014 Richard Hirner (bitfire web engineering). All rights reserved. +

Copyright (c) 2013 – 2014 Ricki Hirner (bitfire web engineering). All rights reserved. Ce programme et les documents qui l\'accompagnent sont mis à disposition sous les termes de la Licence Public GNU v3.0 qui accompagne cette distribution, et est disponible à http://www.gnu.org/licenses/gpl.html. En ce qui concerne Google Play ou Samsung Store, les conditions respectives s\'appliquent pour les versions qui sont téléchargées via ces services.

diff --git a/res/values/strings.xml b/res/values/strings.xml index 9d4b63f1..28d644e9 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -87,7 +87,7 @@ or purchasing it.

License

-

Copyright (c) 2013 – 2014 Richard Hirner (bitfire web engineering). All rights reserved. +

Copyright (c) 2013 – 2014 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. As far as Google Play, Samsung diff --git a/src/at/bitfire/davdroid/resource/RemoteCollection.java b/src/at/bitfire/davdroid/resource/RemoteCollection.java index 4007f7d5..6495101b 100644 --- a/src/at/bitfire/davdroid/resource/RemoteCollection.java +++ b/src/at/bitfire/davdroid/resource/RemoteCollection.java @@ -48,7 +48,7 @@ public abstract class RemoteCollection { public RemoteCollection(CloseableHttpClient httpClient, String baseURL, String user, String password, boolean preemptiveAuth) throws URISyntaxException { this.httpClient = httpClient; - collection = new WebDavResource(httpClient, new URI(baseURL), user, password, preemptiveAuth, true); + collection = new WebDavResource(httpClient, new URI(baseURL), user, password, preemptiveAuth); } diff --git a/src/at/bitfire/davdroid/syncadapter/EnterCredentialsFragment.java b/src/at/bitfire/davdroid/syncadapter/EnterCredentialsFragment.java index c4f6eb2f..fc001455 100644 --- a/src/at/bitfire/davdroid/syncadapter/EnterCredentialsFragment.java +++ b/src/at/bitfire/davdroid/syncadapter/EnterCredentialsFragment.java @@ -125,14 +125,15 @@ public class EnterCredentialsFragment extends Fragment implements TextWatcher { editUserName.getText().length() > 0 && editPassword.getText().length() > 0; - // check host name - try { - URI uri = new URI(URIUtils.sanitize(protocol + editBaseURL.getText().toString())); - if (StringUtils.isBlank(uri.getHost())) + if (ok) + // check host name + try { + URI uri = new URI(URIUtils.sanitize(protocol + editBaseURL.getText().toString())); + if (StringUtils.isBlank(uri.getHost())) + ok = false; + } catch (URISyntaxException e) { ok = false; - } catch (URISyntaxException e) { - ok = false; - } + } MenuItem item = menu.findItem(R.id.next); item.setEnabled(ok); diff --git a/src/at/bitfire/davdroid/syncadapter/QueryServerDialogFragment.java b/src/at/bitfire/davdroid/syncadapter/QueryServerDialogFragment.java index c165fc85..878e0207 100644 --- a/src/at/bitfire/davdroid/syncadapter/QueryServerDialogFragment.java +++ b/src/at/bitfire/davdroid/syncadapter/QueryServerDialogFragment.java @@ -117,7 +117,7 @@ public class QueryServerDialogFragment extends DialogFragment implements LoaderC try { // (1/5) detect capabilities WebDavResource base = new WebDavResource(httpClient, new URI(serverInfo.getProvidedURL()), serverInfo.getUserName(), - serverInfo.getPassword(), serverInfo.isAuthPreemptive(), true); + serverInfo.getPassword(), serverInfo.isAuthPreemptive()); base.options(); serverInfo.setCardDAV(base.supportsDAV("addressbook")); diff --git a/src/at/bitfire/davdroid/webdav/DavHttpClient.java b/src/at/bitfire/davdroid/webdav/DavHttpClient.java index ff7137d5..b4d1ee16 100644 --- a/src/at/bitfire/davdroid/webdav/DavHttpClient.java +++ b/src/at/bitfire/davdroid/webdav/DavHttpClient.java @@ -52,6 +52,7 @@ public class DavHttpClient { .setConnectionManager(connectionManager) .setDefaultRequestConfig(defaultRqConfig) .setRetryHandler(DavHttpRequestRetryHandler.INSTANCE) + .setRedirectStrategy(DavRedirectStrategy.INSTANCE) .setUserAgent("DAVdroid/" + Constants.APP_VERSION) .disableCookieManagement(); diff --git a/src/at/bitfire/davdroid/webdav/DavRedirectStrategy.java b/src/at/bitfire/davdroid/webdav/DavRedirectStrategy.java new file mode 100644 index 00000000..502e9e6e --- /dev/null +++ b/src/at/bitfire/davdroid/webdav/DavRedirectStrategy.java @@ -0,0 +1,94 @@ +package at.bitfire.davdroid.webdav; + +import java.net.URI; +import java.net.URISyntaxException; + +import android.util.Log; +import ch.boye.httpclientandroidlib.Header; +import ch.boye.httpclientandroidlib.HttpRequest; +import ch.boye.httpclientandroidlib.HttpResponse; +import ch.boye.httpclientandroidlib.ProtocolException; +import ch.boye.httpclientandroidlib.RequestLine; +import ch.boye.httpclientandroidlib.client.RedirectStrategy; +import ch.boye.httpclientandroidlib.client.methods.HttpUriRequest; +import ch.boye.httpclientandroidlib.client.methods.RequestBuilder; +import ch.boye.httpclientandroidlib.client.protocol.HttpClientContext; +import ch.boye.httpclientandroidlib.protocol.HttpContext; + +/** + * Custom Redirect Strategy that handles 30x for CalDAV/CardDAV-specific requests correctly + */ +public class DavRedirectStrategy implements RedirectStrategy { + private final static String TAG = "davdroid.DavRedirectStrategy"; + final static DavRedirectStrategy INSTANCE = new DavRedirectStrategy(); + + protected final static String REDIRECTABLE_METHODS[] = { + "OPTIONS", "GET", "PUT", "DELETE" + }; + + + @Override + public HttpUriRequest getRedirect(HttpRequest request, HttpResponse response, HttpContext context) throws ProtocolException { + RequestLine line = request.getRequestLine(); + + String location = getLocation(request, response, context).toString(); + Log.i(TAG, "Following redirection: " + line.getMethod() + " " + line.getUri() + " -> " + location); + + return RequestBuilder.copy(request) + .setUri(location) + .removeHeaders("Content-Length") // Content-Length will be set again automatically, if required; + // remove it now to avoid duplicate header + .build(); + } + + /** + * Determines whether a response indicates a redirection and if it does, whether to follow this redirection. + * PROPFIND and REPORT must handle redirections explicitely because multi-status processing requires knowledge of the content location. + * @return true for 3xx responses on OPTIONS, GET, PUT, DELETE requests that have a valid Location header; false otherwise + */ + @Override + public boolean isRedirected(HttpRequest request, HttpResponse response, HttpContext context) throws ProtocolException { + if (response.getStatusLine().getStatusCode()/100 == 3) { + boolean redirectable = false; + for (String method : REDIRECTABLE_METHODS) + if (method.equalsIgnoreCase(request.getRequestLine().getMethod())) { + redirectable = true; + break; + } + return redirectable && getLocation(request, response, context) != null; + } + return false; + } + + /** + * Gets the destination of a redirection + * @return absolute URL of new location; null if not available + */ + static URI getLocation(HttpRequest request, HttpResponse response, HttpContext context) { + Header locationHdr = response.getFirstHeader("Location"); + if (locationHdr == null) { + Log.e(TAG, "Received redirection without Location header, ignoring"); + return null; + } + try { + URI location = new URI(locationHdr.getValue()); + + // some servers don't return absolute URLs as required by RFC 2616 + if (!location.isAbsolute()) { + Log.w(TAG, "Received invalid redirection with relative URL, repairing"); + + // determine original URL + final HttpClientContext clientContext = HttpClientContext.adapt(context); + final URI originalURI = new URI(clientContext.getTargetHost() + request.getRequestLine().getUri()); + + // determine new location relative to original URL + location = originalURI.resolve(location); + } + return location; + } catch (URISyntaxException e) { + Log.e(TAG, "Received redirection from/to invalid URL, ignoring", e); + } + return null; + } + +} diff --git a/src/at/bitfire/davdroid/webdav/WebDavResource.java b/src/at/bitfire/davdroid/webdav/WebDavResource.java index 9cc0c5d0..870e90f8 100644 --- a/src/at/bitfire/davdroid/webdav/WebDavResource.java +++ b/src/at/bitfire/davdroid/webdav/WebDavResource.java @@ -95,19 +95,16 @@ public class WebDavResource { protected HttpClientContext context; - public WebDavResource(CloseableHttpClient httpClient, URI baseURL, boolean trailingSlash) throws URISyntaxException { + public WebDavResource(CloseableHttpClient httpClient, URI baseURL) throws URISyntaxException { this.httpClient = httpClient; location = baseURL.normalize(); - if (trailingSlash && !location.getRawPath().endsWith("/")) - location = new URI(location.getScheme(), location.getSchemeSpecificPart() + "/", null); - context = HttpClientContext.create(); context.setCredentialsProvider(new BasicCredentialsProvider()); } - public WebDavResource(CloseableHttpClient httpClient, URI baseURL, String username, String password, boolean preemptive, boolean trailingSlash) throws URISyntaxException { - this(httpClient, baseURL, trailingSlash); + public WebDavResource(CloseableHttpClient httpClient, URI baseURL, String username, String password, boolean preemptive) throws URISyntaxException { + this(httpClient, baseURL); HttpHost host = new HttpHost(baseURL.getHost(), baseURL.getPort(), baseURL.getScheme()); context.getCredentialsProvider().setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(username, password)); @@ -250,67 +247,75 @@ public class WebDavResource { /* collection operations */ public void propfind(HttpPropfind.Mode mode) throws IOException, DavException, HttpException { - HttpPropfind propfind = new HttpPropfind(location, mode); - CloseableHttpResponse response = httpClient.execute(propfind, context); - try { - checkResponse(response); - - 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(); + 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); - 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 PROPFIND", ex); - } - processMultiStatus(multistatus); + 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(); + + try { + checkResponse(response); // will also handle Content-Location + processMultiStatus(response); } finally { response.close(); } } public void multiGet(DavMultiget.Type type, String[] names) throws IOException, DavException, HttpException { - List hrefs = new LinkedList(); - for (String name : names) - hrefs.add(location.resolve(name).getRawPath()); - DavMultiget multiget = DavMultiget.newRequest(type, hrefs.toArray(new String[0])); + CloseableHttpResponse response = null; - Serializer serializer = new Persister(); - StringWriter writer = new StringWriter(); - try { - 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"); - } - - HttpReport report = new HttpReport(location, writer.toString()); - CloseableHttpResponse response = httpClient.execute(report, context); - try { - checkResponse(response); - - 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(); + // 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 hrefs = new LinkedList(); + for (String name : names) + hrefs.add(location.resolve(name).getRawPath()); + DavMultiget multiget = DavMultiget.newRequest(type, hrefs.toArray(new String[0])); - DavMultistatus multiStatus; + StringWriter writer = new StringWriter(); try { - multiStatus = serializer.read(DavMultistatus.class, content, false); + Serializer serializer = new Persister(); + serializer.write(multiget, writer); } catch (Exception ex) { - throw new DavException("Couldn't parse Multi-Status response on REPORT multi-get", ex); + Log.e(TAG, "Couldn't create XML multi-get request", ex); + throw new DavException("Couldn't create multi-get request"); } - processMultiStatus(multiStatus); + + // 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(); + + try { + checkResponse(response); // will also handle Content-Location + processMultiStatus(response); } finally { response.close(); } @@ -383,8 +388,19 @@ public class WebDavResource { /* helpers */ - protected static void checkResponse(HttpResponse response) throws HttpException { + 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) + try { + // Content-Location was set, update location correspondingly + location = location.resolve(new URI(contentLocationHdr.getValue())); + Log.d(TAG, "Set Content-Location to " + location); + } catch (URISyntaxException e) { + Log.w(TAG, "Ignoring invalid Content-Location", e); + } } protected static void checkResponse(StatusLine statusLine) throws HttpException { @@ -404,14 +420,30 @@ public class WebDavResource { } } - protected void processMultiStatus(DavMultistatus multistatus) throws HttpException, DavException { - if (multistatus.response == null) // empty response + 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 members = new LinkedList(); - for (DavResponse singleResponse : multistatus.response) { + for (DavResponse singleResponse : multiStatus.response) { URI href; try { href = location.resolve(URIUtils.sanitize(singleResponse.getHref().href)); @@ -423,12 +455,28 @@ public class WebDavResource { // about which resource is this response? WebDavResource referenced = null; + + // "this" resource is either at "location" … if (location.equals(href)) { // -> ourselves referenced = this; + } else { + // … or at location + "/" (in case of a collection where the server has implicitly appended the trailing slash) + if (!location.getRawPath().endsWith("/")) // this is only possible if location doesn't have a trailing slash + try { + URI locationAsCollection = new URI(location.getScheme(), location.getAuthority(), location.getPath() + "/", location.getQuery(), null); + if (locationAsCollection.equals(href)) { + Log.d(TAG, "Server implicitly appended trailing slash to " + locationAsCollection); + referenced = this; + } + } catch (URISyntaxException e) { + Log.wtf(TAG, "Couldn't understand our own URI", e); + } - } else { // -> about a member - referenced = new WebDavResource(this, href); - members.add(referenced); + // otherwise, the referenced resource is a member + if (referenced == null) { + referenced = new WebDavResource(this, href); + members.add(referenced); + } } for (DavPropstat singlePropstat : singleResponse.getPropstat()) { diff --git a/test/robohydra/plugins/redirect/index.js b/test/robohydra/plugins/redirect/index.js index b9c86634..65a1a482 100644 --- a/test/robohydra/plugins/redirect/index.js +++ b/test/robohydra/plugins/redirect/index.js @@ -4,11 +4,23 @@ exports.getBodyParts = function(conf) { return { heads: [ new RoboHydraHead({ - path: "/redirect", + path: "/redirect/301", + handler: function(req,res,next) { + res.statusCode = 301; + var location = req.queryParams['to'] || '/assets/test.random'; + res.headers = { + Location: location + } + res.end(); + } + }), + new RoboHydraHead({ + path: "/redirect/302", handler: function(req,res,next) { res.statusCode = 302; + var location = req.queryParams['to'] || '/assets/test.random'; res.headers = { - location: 'http://www.example.com' + Location: location } res.end(); } diff --git a/test/src/at/bitfire/davdroid/webdav/test/WebDavResourceTest.java b/test/src/at/bitfire/davdroid/webdav/test/WebDavResourceTest.java index a200dcde..aff0d5f2 100644 --- a/test/src/at/bitfire/davdroid/webdav/test/WebDavResourceTest.java +++ b/test/src/at/bitfire/davdroid/webdav/test/WebDavResourceTest.java @@ -27,6 +27,7 @@ import at.bitfire.davdroid.webdav.DavHttpClient; import at.bitfire.davdroid.webdav.DavMultiget; import at.bitfire.davdroid.webdav.HttpException; import at.bitfire.davdroid.webdav.HttpPropfind; +import at.bitfire.davdroid.webdav.HttpPropfind.Mode; import at.bitfire.davdroid.webdav.NotFoundException; import at.bitfire.davdroid.webdav.PreconditionFailedException; import at.bitfire.davdroid.webdav.WebDavResource; @@ -52,13 +53,13 @@ public class WebDavResourceTest extends InstrumentationTestCase { assetMgr = getInstrumentation().getContext().getResources().getAssets(); - simpleFile = new WebDavResource(httpClient, new URI(ROBOHYDRA_BASE + "assets/test.random"), false); + simpleFile = new WebDavResource(httpClient, new URI(ROBOHYDRA_BASE + "assets/test.random")); - davCollection = new WebDavResource(httpClient, new URI(ROBOHYDRA_BASE + "dav"), true); + davCollection = new WebDavResource(httpClient, new URI(ROBOHYDRA_BASE + "dav/")); davNonExistingFile = new WebDavResource(davCollection, "collection/new.file"); davExistingFile = new WebDavResource(davCollection, "collection/existing.file"); - davInvalid = new WebDavResource(httpClient, new URI(ROBOHYDRA_BASE + "dav-invalid"), true); + davInvalid = new WebDavResource(httpClient, new URI(ROBOHYDRA_BASE + "dav-invalid/")); } @Override @@ -112,6 +113,7 @@ public class WebDavResourceTest extends InstrumentationTestCase { try { simpleFile.propfind(HttpPropfind.Mode.CURRENT_USER_PRINCIPAL); fail(); + } catch(DavException ex) { } assertNull(simpleFile.getCurrentUserPrincipal()); @@ -154,8 +156,14 @@ public class WebDavResourceTest extends InstrumentationTestCase { /* test normal HTTP/WebDAV */ - public void testFollowGetRedirections() throws URISyntaxException, IOException, DavException, HttpException { - WebDavResource redirection = new WebDavResource(httpClient, new URI(ROBOHYDRA_BASE + "redirect"), false); + public void testRedirections() throws URISyntaxException, IOException, DavException, HttpException { + // PROPFIND redirection + WebDavResource redirection = new WebDavResource(httpClient, new URI(ROBOHYDRA_BASE + "redirect/301?to=/dav/")); + redirection.propfind(Mode.CURRENT_USER_PRINCIPAL); + assertEquals("/dav/", redirection.getLocation().getPath()); + + // normal GET redirection + redirection = new WebDavResource(httpClient, new URI(ROBOHYDRA_BASE + "redirect/301")); redirection.get(); } @@ -167,7 +175,7 @@ public class WebDavResourceTest extends InstrumentationTestCase { } public void testGetHttpsWithSni() throws URISyntaxException, HttpException, IOException, DavException { - WebDavResource file = new WebDavResource(httpClient, new URI("https://sni.velox.ch"), false); + WebDavResource file = new WebDavResource(httpClient, new URI("https://sni.velox.ch")); boolean sniWorking = false; try {