Files
calendar-lite/src/org/gingercal/Dav.java
T

380 lines
17 KiB
Java

package org.gingercal;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.gingercal.Model.Cal;
import org.gingercal.Model.Ev;
import org.gingercal.Xml.Node;
/** CalDAV client: discovery, calendar listing, event query and simple writes. */
public class Dav {
public static final String D = "DAV:";
public static final String C = "urn:ietf:params:xml:ns:caldav";
public static final String CS = "http://calendarserver.org/ns/";
public static final String A = "http://apple.com/ns/ical/";
final Http http;
final String url;
final String auth;
public Dav(Http http, String serverUrl, String user, String password) {
this.http = http;
this.url = normalize(serverUrl);
try {
this.auth = "Basic " + B64.encode((user + ":" + password).getBytes("UTF-8"));
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
}
public static String normalize(String u) {
u = u.trim();
if (!u.toLowerCase(Locale.US).startsWith("http://") && !u.toLowerCase(Locale.US).startsWith("https://")) {
u = "https://" + u;
}
while (u.endsWith("/") && u.indexOf('/', 8) < u.length() - 1) u = u.substring(0, u.length() - 1);
return u;
}
static String origin(String u) {
URI x = URI.create(u);
return x.getScheme() + "://" + x.getRawAuthority();
}
/** scheme://host:port with default ports filled in, for comparing redirect targets. */
static String originKey(String u) {
URI x = URI.create(u);
String s = x.getScheme() == null ? "" : x.getScheme().toLowerCase(Locale.US);
int port = x.getPort();
if (port < 0) port = s.equals("https") ? 443 : 80;
String host = x.getHost() == null ? "" : x.getHost().toLowerCase(Locale.US);
return s + "://" + host + ":" + port;
}
public static String abs(String base, String href) {
if (href.startsWith("http://") || href.startsWith("https://")) return href;
return URI.create(base).resolve(href).toString();
}
// -------------------------------------------------------------- requests
public static class Response {
public String href;
public final Map<String, Node> props = new HashMap<String, Node>();
public Node prop(String ns, String name) {
return props.get(ns + name);
}
public String text(String ns, String name) {
Node n = prop(ns, name);
return n == null ? null : n.text();
}
}
/**
* Sends a request, following redirects on the same server (Apache won't for PROPFIND/REPORT).
* Redirects elsewhere, typically Nextcloud forcing its public https:// address, can't work
* on Android 2.3, so they fail with an explanation instead of a doomed TLS 1.2 attempt.
*/
Http.Resp send(String method, String target, String depth, String body, String[] extra)
throws IOException {
String u = target;
List<String> visited = new ArrayList<String>();
for (int hop = 0; hop < 6; hop++) {
visited.add(u);
List<String> h = new ArrayList<String>();
h.add("Authorization");
h.add(auth);
if (depth != null) {
h.add("Depth");
h.add(depth);
}
if (extra != null) for (int i = 0; i < extra.length; i++) h.add(extra[i]);
String ct = body == null ? null
: method.equals("PUT") ? "text/calendar; charset=utf-8" : "application/xml; charset=utf-8";
Http.Resp r = http.exec(method, u, h.toArray(new String[h.size()]), body, ct);
if ((r.code == 301 || r.code == 302 || r.code == 303 || r.code == 307 || r.code == 308)
&& r.location != null) {
String next = abs(u, r.location);
checkRedirect(u, next, visited, r.code);
u = next;
continue;
}
if (r.code == 401) {
throw new DavException(401, L.t("Login refused (401). Check the user name and use a Nextcloud app password (Settings > Security > Devices & sessions)."));
}
if (r.code == 403 && body != null && method.equals("PUT")) {
throw new DavException(403, L.t("Not allowed to write to this calendar (403). It may be read-only/shared."));
}
return r;
}
throw new DavException(DavException.REDIRECT, L.t("Too many redirects from the server."));
}
static void checkRedirect(String from, String to, List<String> visited, int code) throws DavException {
String a = originKey(from), b = originKey(to);
if (a.startsWith("http:") && b.startsWith("https:")) {
throw new DavException(DavException.REDIRECT, L.f("The server redirected http:// to %s (Nextcloud forces HTTPS / HSTS). Android 2.3 can't follow that: use the legacy-TLS bridge address instead (see README, 'Forced HTTPS / HSTS').", origin(to)));
}
if (!a.equals(b)) {
throw new DavException(DavException.REDIRECT, L.f("The server redirected to %s, not the address set in the app. Nextcloud is sending its public URL; check the bridge's proxy_redirect line (README, 'Forced HTTPS / HSTS').", origin(to)));
}
if (visited.contains(to)) {
throw new DavException(DavException.REDIRECT, L.f("Redirect loop (HTTP %d back to the same address). Nextcloud forces HTTPS but the bridge reaches it over plain HTTP: switch the bridge to proxy_pass https:// (README, 'Forced HTTPS / HSTS').", code));
}
}
List<Response> multistatus(String method, String target, String depth, String body) throws IOException {
Http.Resp r = send(method, target, depth, body, null);
if (r.code != 207) {
throw new DavException(r.code, L.f("%s %s failed: HTTP %d%s", method, URI.create(target).getPath(), r.code,
r.code == 404 ? L.t(" (not found)") : r.code == 405 ? L.t(" (not a WebDAV URL?)") : ""));
}
Node ms = Xml.parse(r.body);
List<Response> out = new ArrayList<Response>();
for (Node rn : ms.children(D, "response")) {
Response res = new Response();
Node href = rn.child(D, "href");
res.href = href == null ? "" : href.text();
for (Node ps : rn.children(D, "propstat")) {
Node st = ps.child(D, "status");
if (st != null && st.text().indexOf(" 200") < 0) continue;
Node prop = ps.child(D, "prop");
if (prop == null) continue;
for (Node p : prop.kids) res.props.put(p.ns + p.name, p);
}
out.add(res);
}
return out;
}
static String propfind(String props) {
return "<?xml version=\"1.0\" encoding=\"utf-8\"?>"
+ "<d:propfind xmlns:d=\"DAV:\" xmlns:c=\"" + C + "\" xmlns:cs=\"" + CS + "\" xmlns:a=\"" + A + "\">"
+ "<d:prop>" + props + "</d:prop></d:propfind>";
}
static String hrefIn(Response r, String ns, String name) {
Node n = r.prop(ns, name);
if (n == null) return null;
Node h = n.find(D, "href");
return h == null ? null : h.text();
}
// ------------------------------------------------------------- discovery
/** Finds the calendar-home-set URL for the logged-in user. */
public String calendarHome() throws IOException {
List<String> candidates = new ArrayList<String>();
String path = URI.create(url).getPath();
String o = origin(url);
if (path == null || path.length() <= 1) {
candidates.add(o + "/remote.php/dav/");
candidates.add(o + "/.well-known/caldav");
candidates.add(o + "/");
} else {
candidates.add(url.endsWith("/") ? url : url + "/");
}
String principal = null;
IOException last = null;
for (String c : candidates) {
try {
List<Response> rs = multistatus("PROPFIND", c, "0", propfind("<d:current-user-principal/>"));
for (Response r : rs) {
String h = hrefIn(r, D, "current-user-principal");
if (h != null) principal = abs(c, h);
}
if (principal != null) break;
} catch (DavException e) {
if (e.code == 401 || e.code == DavException.REDIRECT) throw e;
last = e;
}
}
if (principal == null) {
throw last != null ? last : new DavException(0, L.t("Server did not report a CalDAV principal"));
}
List<Response> rs = multistatus("PROPFIND", principal, "0", propfind("<c:calendar-home-set/>"));
for (Response r : rs) {
String h = hrefIn(r, C, "calendar-home-set");
if (h != null) return abs(principal, h);
}
return principal; // some servers keep calendars directly under the principal
}
public List<Cal> calendars(String home) throws IOException {
List<Response> rs = multistatus("PROPFIND", home, "1", propfind(
"<d:resourcetype/><d:displayname/><a:calendar-color/><cs:getctag/>"
+ "<c:supported-calendar-component-set/><d:current-user-privilege-set/>"));
List<Cal> out = new ArrayList<Cal>();
for (Response r : rs) {
Node rt = r.prop(D, "resourcetype");
if (rt == null || rt.child(C, "calendar") == null) continue;
Node comps = r.prop(C, "supported-calendar-component-set");
if (comps != null && !comps.kids.isEmpty()) {
boolean ev = false;
for (Node k : comps.kids) ev |= k.name.equals("comp") && k.attr("name").equalsIgnoreCase("VEVENT");
if (!ev) continue;
}
Cal c = new Cal();
c.href = abs(home, r.href);
String dn = r.text(D, "displayname");
c.name = dn == null || dn.length() == 0 ? lastSegment(c.href) : dn;
c.color = parseColor(r.text(A, "calendar-color"), c.color);
String ctag = r.text(CS, "getctag");
c.ctag = ctag == null ? "" : ctag;
Node priv = r.prop(D, "current-user-privilege-set");
if (priv != null) {
c.writable = priv.find(D, "write") != null || priv.find(D, "write-content") != null
|| priv.find(D, "all") != null;
}
out.add(c);
}
return out;
}
static String lastSegment(String href) {
String h = href.endsWith("/") ? href.substring(0, href.length() - 1) : href;
return Uri.decode(h.substring(h.lastIndexOf('/') + 1));
}
static int parseColor(String s, int def) {
if (s == null) return def;
s = s.trim();
if (!s.startsWith("#")) return def;
try {
if (s.length() == 7) return 0xFF000000 | Integer.parseInt(s.substring(1), 16);
if (s.length() == 9) return 0xFF000000 | Integer.parseInt(s.substring(1, 7), 16);
} catch (NumberFormatException ignored) {
}
return def;
}
// ------------------------------------------------------------------ events
public List<Ev> events(Cal cal, long from, long to) throws IOException {
String f = ICal.utc(from), t = ICal.utc(to);
String body = "<?xml version=\"1.0\" encoding=\"utf-8\"?>"
+ "<c:calendar-query xmlns:d=\"DAV:\" xmlns:c=\"" + C + "\">"
+ "<d:prop><d:getetag/><c:calendar-data><c:expand start=\"" + f + "\" end=\"" + t + "\"/>"
+ "</c:calendar-data></d:prop>"
+ "<c:filter><c:comp-filter name=\"VCALENDAR\"><c:comp-filter name=\"VEVENT\">"
+ "<c:time-range start=\"" + f + "\" end=\"" + t + "\"/>"
+ "</c:comp-filter></c:comp-filter></c:filter></c:calendar-query>";
List<Response> rs = multistatus("REPORT", cal.href, "1", body);
List<Ev> out = new ArrayList<Ev>();
for (Response r : rs) {
String data = r.text(C, "calendar-data");
if (data == null || data.length() == 0) continue;
String etag = r.text(D, "getetag");
String href = abs(cal.href, r.href);
List<ICal.VEvent> vs = ICal.parse(data);
boolean recurring = vs.size() > 1;
for (ICal.VEvent v : vs) if (v.rrule != null || v.recurrenceId != null || v.hasRdate) recurring = true;
// Overridden instances (when the server didn't expand) replace generated ones.
Map<Long, ICal.VEvent> overrides = new HashMap<Long, ICal.VEvent>();
for (ICal.VEvent v : vs) {
if (v.recurrenceId != null && v.rrule == null && hasMasterWithRule(vs)) {
try {
overrides.put(ICal.parseTime(v.recurrenceId, null, null)[0], v);
} catch (RuntimeException ignored) {
}
}
}
for (ICal.VEvent v : vs) {
if (overrides.containsValue(v)) continue;
for (long[] occ : ICal.occurrences(v, from, to)) {
ICal.VEvent src = overrides.containsKey(occ[0]) ? overrides.get(occ[0]) : v;
Ev e = new Ev();
e.cal = cal.href;
e.href = href;
e.etag = etag == null ? "" : etag;
e.uid = src.uid;
e.title = src.summary;
e.location = src.location;
e.descr = src.description;
e.start = src == v ? occ[0] : src.start;
e.end = src == v ? occ[1] : src.end;
e.allDay = src.allDay;
e.recurring = recurring;
out.add(e);
}
}
}
return out;
}
static boolean hasMasterWithRule(List<ICal.VEvent> vs) {
for (ICal.VEvent v : vs) if (v.rrule != null) return true;
return false;
}
/** GET a single object. Returns {ics, etag}. */
public String[] get(String href) throws IOException {
Http.Resp r = send("GET", href, null, null, null);
if (r.code != 200) throw new DavException(r.code, L.f("Could not load event: HTTP %d", r.code));
return new String[] {r.body, r.etag};
}
public void create(String calHref, String uid, String ics) throws IOException {
String href = abs(calHref.endsWith("/") ? calHref : calHref + "/", uid + ".ics");
Http.Resp r = send("PUT", href, null, ics, new String[] {"If-None-Match", "*"});
if (r.code != 201 && r.code != 204 && r.code != 200) {
throw new DavException(r.code, L.f("Saving failed: HTTP %d", r.code));
}
}
public void update(String href, String etag, String ics) throws IOException {
String[] h = etag == null || etag.length() == 0 ? null : new String[] {"If-Match", etag};
Http.Resp r = send("PUT", href, null, ics, h);
if (r.code == 412) throw new DavException(412, L.t("Event was changed on the server meanwhile. Sync and try again."));
if (r.code != 201 && r.code != 204 && r.code != 200) {
throw new DavException(r.code, L.f("Saving failed: HTTP %d", r.code));
}
}
public void delete(String href, String etag) throws IOException {
String[] h = etag == null || etag.length() == 0 ? null : new String[] {"If-Match", etag};
Http.Resp r = send("DELETE", href, null, null, h);
if (r.code == 412) throw new DavException(412, L.t("Event was changed on the server meanwhile. Sync and try again."));
if (r.code != 204 && r.code != 200 && r.code != 404) {
throw new DavException(r.code, L.f("Delete failed: HTTP %d", r.code));
}
}
public void close() {
http.close();
}
/** Minimal percent-decoder (java.net.URLDecoder would turn '+' into spaces). */
static final class Uri {
static String decode(String s) {
try {
byte[] buf = new byte[s.length()];
int n = 0;
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '%' && i + 2 < s.length()) {
buf[n++] = (byte) Integer.parseInt(s.substring(i + 1, i + 3), 16);
i += 2;
} else if (c < 128) {
buf[n++] = (byte) c;
} else {
return s;
}
}
return new String(buf, 0, n, "UTF-8");
} catch (Exception e) {
return s;
}
}
}
}