diff options
Diffstat (limited to '')
-rw-r--r-- | comm/calendar/providers/caldav/CalDavProvider.jsm | 426 |
1 files changed, 426 insertions, 0 deletions
diff --git a/comm/calendar/providers/caldav/CalDavProvider.jsm b/comm/calendar/providers/caldav/CalDavProvider.jsm new file mode 100644 index 0000000000..940e64337d --- /dev/null +++ b/comm/calendar/providers/caldav/CalDavProvider.jsm @@ -0,0 +1,426 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +var EXPORTED_SYMBOLS = ["CalDavProvider"]; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { DNS } = ChromeUtils.import("resource:///modules/DNS.jsm"); + +var { CalDavPropfindRequest } = ChromeUtils.import("resource:///modules/caldav/CalDavRequest.jsm"); + +var { CalDavDetectionSession } = ChromeUtils.import("resource:///modules/caldav/CalDavSession.jsm"); + +// NOTE: This module should not be loaded directly, it is available when +// including calUtils.jsm under the cal.provider.caldav namespace. + +/** + * @implements {calICalendarProvider} + */ +var CalDavProvider = { + QueryInterface: ChromeUtils.generateQI(["calICalendarProvider"]), + + get type() { + return "caldav"; + }, + + get displayName() { + return cal.l10n.getCalString("caldavName"); + }, + + get shortName() { + return "CalDAV"; + }, + + deleteCalendar(aCalendar, aListener) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + + async detectCalendars( + username, + password, + location = null, + savePassword = false, + extraProperties = {} + ) { + let uri = cal.provider.detection.locationToUri(location); + if (!uri) { + throw new Error("Could not infer location from username"); + } + + let detector = new CalDavDetector(username, password, savePassword); + + for (let method of [ + "attemptGoogleOauth", + "attemptLocation", + "dnsSRV", + "wellKnown", + "attemptRoot", + ]) { + try { + cal.LOG(`[CalDavProvider] Trying to detect calendar using ${method} method`); + let calendars = await detector[method](uri); + if (calendars) { + return calendars; + } + } catch (e) { + // e may be an Error object or a response object like CalDavSimpleResponse. + // It can even be a string, as with the OAuth2 error below. + let message = `[CalDavProvider] Could not detect calendar using method ${method}`; + + let errorDetails = err => + ` - ${err.fileName || err.filename}:${err.lineNumber}: ${err} - ${err.stack}`; + + let responseDetails = response => ` - HTTP response status ${response.status}`; + + // A special thing the OAuth2 code throws. + if (e == '{ "error": "cancelled"}') { + cal.WARN(message + ` - OAuth2 '${e}'`); + throw new cal.provider.detection.CanceledError("OAuth2 prompt canceled"); + } + + // We want to pass on any autodetect errors that will become results. + if (e instanceof cal.provider.detection.Error) { + cal.WARN(message + errorDetails(e)); + throw e; + } + + // Sometimes e is a CalDavResponseBase that is an auth error, so throw it. + if (e.authError) { + cal.WARN(message + responseDetails(e)); + throw new cal.provider.detection.AuthFailedError(); + } + + if (e instanceof Error) { + cal.WARN(message + errorDetails(e)); + } else if (typeof e.status == "number") { + cal.WARN(message + responseDetails(e)); + } else { + cal.WARN(message); + } + } + } + return []; + }, +}; + +/** + * Used by the CalDavProvider to detect CalDAV calendars for a given username, + * password, location, etc. + */ +class CalDavDetector { + /** + * Create a new caldav detector. + * + * @param {string} username - A username. + * @param {string} password - A password. + * @param {boolean} savePassword - Whether to save the password or not. + */ + constructor(username, password, savePassword) { + this.username = username; + this.session = new CalDavDetectionSession(username, password, savePassword); + } + + /** + * Attempt to detect calendars at the given location. + * + * @param {nsIURI} location - The location to attempt. + * @returns {Promise<calICalendar[] | null>} An array of calendars or null. + */ + attemptLocation(location) { + if (location.filePath == "/") { + // The location is the root, don't try to detect the collection, let the + // other handlers take care of it. + return Promise.resolve(null); + } + return this.detectCollection(location); + } + + /** + * Attempt to detect calendars at the given location using DNS lookups. + * + * @param {nsIURI} location - The location to attempt. + * @returns {Promise<calICalendar[] | null>} An array of calendars or null. + */ + async dnsSRV(location) { + if (location.filePath != "/") { + // If there is already a path specified, then no need to use DNS lookups. + return null; + } + + let dnshost = location.host; + let secure = location.schemeIs("http") ? "" : "s"; + let dnsres = await DNS.srv(`_caldav${secure}._tcp.${dnshost}`); + + if (!dnsres.length) { + let basedomain; + try { + basedomain = Services.eTLD.getBaseDomain(location); + } catch (e) { + // If we can't get a base domain just skip it. + } + + if (basedomain && basedomain != location.host) { + cal.LOG(`[CalDavProvider] ${location.host} has no SRV entry, trying ${basedomain}`); + dnsres = await DNS.srv(`_caldav${secure}._tcp.${basedomain}`); + dnshost = basedomain; + } + } + + if (!dnsres.length) { + return null; + } + dnsres.sort((a, b) => a.prio - b.prio || b.weight - a.weight); + + // Determine path from TXT, if available. + let pathres = await DNS.txt(`_caldav${secure}._tcp.${dnshost}`); + pathres.sort((a, b) => a.prio - b.prio || b.weight - a.weight); + pathres = pathres.filter(result => result.data.startsWith("path=")); + // Get the string after `path=`. + let path = pathres.length ? pathres[0].data.substr(5) : ""; + + let calendars; + if (path) { + // If the server has SRV and TXT entries, we already have a full context path to test. + let uri = `http${secure}://${dnsres[0].host}:${dnsres[0].port}${path}`; + cal.LOG(`[CalDavProvider] Trying ${uri} from SRV and TXT response`); + calendars = await this.detectCollection(Services.io.newURI(uri)); + } + + if (!calendars) { + // Either the txt record doesn't point to a path (in which case we need to repeat with + // well-known), or no calendars could be detected at that location (in which case we + // need to repeat with well-known). + + let baseloc = Services.io.newURI( + `http${secure}://${dnsres[0].host}:${dnsres[0].port}/.well-known/caldav` + ); + cal.LOG(`[CalDavProvider] Trying ${baseloc.spec} from SRV response with .well-known`); + + calendars = await this.detectCollection(baseloc); + } + + return calendars; + } + + /** + * Attempt to detect calendars using a `.well-known` URI. + * + * @param {nsIURI} location - The location to attempt. + * @returns {Promise<calICalendar[] | null>} An array of calendars or null. + */ + async wellKnown(location) { + let wellKnownUri = Services.io.newURI("/.well-known/caldav", null, location); + cal.LOG(`[CalDavProvider] Trying .well-known URI without dns at ${wellKnownUri.spec}`); + return this.detectCollection(wellKnownUri); + } + + /** + * Attempt to detect calendars using a root ("/") URI. + * + * @param {nsIURI} location - The location to attempt. + * @returns {Promise<calICalendar[] | null>} An array of calendars or null. + */ + attemptRoot(location) { + let rootUri = Services.io.newURI("/", null, location); + return this.detectCollection(rootUri); + } + + /** + * Attempt to detect calendars using Google OAuth. + * + * @param {nsIURI} calURI - The location to attempt. + * @returns {Promise<calICalendar[] | null>} An array of calendars or null. + */ + async attemptGoogleOauth(calURI) { + let usesGoogleOAuth = cal.provider.detection.googleOAuthDomains.has(calURI.host); + if (!usesGoogleOAuth) { + // Not using Google OAuth that we know of, but we could check the mx entry. + // If mail is handled by Google then this is likely a Google Apps domain. + let mxRecords = await DNS.mx(calURI.host); + usesGoogleOAuth = mxRecords.some(r => /\bgoogle\.com$/.test(r.host)); + } + + if (usesGoogleOAuth) { + // If we were given a full URL to a calendar, try to use it. + let spec = this.username + ? `https://apidata.googleusercontent.com/caldav/v2/${encodeURIComponent( + this.username + )}/user` + : calURI.spec; + let uri = Services.io.newURI(spec); + return this.handlePrincipal(uri); + } + return null; + } + + /** + * Utility function to detect whether a calendar collection exists at a given + * location and return it if it exists. + * + * @param {nsIURI} location - The location to attempt. + * @returns {Promise<calICalendar[] | null>} An array of calendars or null. + */ + async detectCollection(location) { + let props = [ + "D:resourcetype", + "D:owner", + "D:displayname", + "D:current-user-principal", + "D:current-user-privilege-set", + "A:calendar-color", + "C:calendar-home-set", + ]; + + cal.LOG(`[CalDavProvider] Checking collection type at ${location.spec}`); + let request = new CalDavPropfindRequest(this.session, null, location, props); + + // `request.commit()` can throw; errors should be caught by calling functions. + let response = await request.commit(); + let target = response.uri; + + if (response.authError) { + throw new cal.provider.detection.AuthFailedError(); + } else if (!response.ok) { + cal.LOG(`[CalDavProvider] ${target.spec} did not respond properly to PROPFIND`); + return null; + } + + let resprops = response.firstProps; + let resourceType = resprops["D:resourcetype"]; + + if (resourceType.has("C:calendar")) { + cal.LOG(`[CalDavProvider] ${target.spec} is a calendar`); + return [this.handleCalendar(target, resprops)]; + } else if (resourceType.has("D:principal")) { + cal.LOG(`[CalDavProvider] ${target.spec} is a principal, looking at home set`); + let homeSet = resprops["C:calendar-home-set"]; + let homeSetUrl = Services.io.newURI(homeSet, null, target); + return this.handleHomeSet(homeSetUrl); + } else if (resprops["D:current-user-principal"]) { + cal.LOG( + `[CalDavProvider] ${target.spec} is something else, looking at current-user-principal` + ); + let principalUrl = Services.io.newURI(resprops["D:current-user-principal"], null, target); + return this.handlePrincipal(principalUrl); + } else if (resprops["D:owner"]) { + cal.LOG(`[CalDavProvider] ${target.spec} is something else, looking at collection owner`); + let principalUrl = Services.io.newURI(resprops["D:owner"], null, target); + return this.handlePrincipal(principalUrl); + } + + return null; + } + + /** + * Utility function to make a new attempt to detect calendars after the + * previous PROPFIND results contained either "D:current-user-principal" + * or "D:owner" props. + * + * @param {nsIURI} location - The location to attempt. + * @returns {Promise<calICalendar[] | null>} An array of calendars or null. + */ + async handlePrincipal(location) { + let props = ["D:resourcetype", "C:calendar-home-set"]; + let request = new CalDavPropfindRequest(this.session, null, location, props); + cal.LOG(`[CalDavProvider] Checking collection type at ${location.spec}`); + + // `request.commit()` can throw; errors should be caught by calling functions. + let response = await request.commit(); + let homeSets = response.firstProps["C:calendar-home-set"]; + let target = response.uri; + + if (response.authError) { + throw new cal.provider.detection.AuthFailedError(); + } else if (!response.firstProps["D:resourcetype"].has("D:principal")) { + cal.LOG(`[CalDavProvider] ${target.spec} is not a principal collection`); + return null; + } else if (homeSets) { + let calendars = []; + for (let homeSet of homeSets) { + cal.LOG(`[CalDavProvider] ${target.spec} has a home set at ${homeSet}, checking that`); + let homeSetUrl = Services.io.newURI(homeSet, null, target); + let discoveredCalendars = await this.handleHomeSet(homeSetUrl); + if (discoveredCalendars) { + calendars.push(...discoveredCalendars); + } + } + return calendars.length ? calendars : null; + } else { + cal.LOG(`[CalDavProvider] ${target.spec} doesn't have a home set`); + return null; + } + } + + /** + * Utility function to make a new attempt to detect calendars after the + * previous PROPFIND results contained a "C:calendar-home-set" prop. + * + * @param {nsIURI} location - The location to attempt. + * @returns {Promise<calICalendar[] | null>} An array of calendars or null. + */ + async handleHomeSet(location) { + let props = [ + "D:resourcetype", + "D:displayname", + "D:current-user-privilege-set", + "A:calendar-color", + ]; + let request = new CalDavPropfindRequest(this.session, null, location, props, 1); + + // `request.commit()` can throw; errors should be caught by calling functions. + let response = await request.commit(); + let target = response.uri; + + if (response.authError) { + throw new cal.provider.detection.AuthFailedError(); + } + + let calendars = []; + for (let [href, resprops] of Object.entries(response.data)) { + if (resprops["D:resourcetype"].has("C:calendar")) { + let hrefUri = Services.io.newURI(href, null, target); + calendars.push(this.handleCalendar(hrefUri, resprops)); + } + } + cal.LOG(`[CalDavProvider] ${target.spec} is a home set, found ${calendars.length} calendars`); + + return calendars.length ? calendars : null; + } + + /** + * Set up and return a new caldav calendar object. + * + * @param {nsIURI} uri - The location of the calendar. + * @param {Set} props - The calendar properties parsed from the + * response. + * @returns {calICalendar} A new calendar. + */ + handleCalendar(uri, props) { + let displayName = props["D:displayname"]; + let color = props["A:calendar-color"]; + if (!displayName) { + let fileName = decodeURI(uri.spec).split("/").filter(Boolean).pop(); + displayName = fileName || uri.spec; + } + + // Some servers provide colors as an 8-character hex string. Strip the alpha component. + color = color?.replace(/^(#[0-9A-Fa-f]{6})[0-9A-Fa-f]{2}$/, "$1"); + + let calendar = cal.manager.createCalendar("caldav", uri); + calendar.setProperty("color", color || cal.view.hashColor(uri.spec)); + calendar.name = displayName; + calendar.id = cal.getUUID(); + calendar.setProperty("username", this.username); + calendar.wrappedJSObject.session = this.session.toBaseSession(); + + // Attempt to discover if the user is allowed to write to this calendar. + let privs = props["D:current-user-privilege-set"]; + if (privs && privs instanceof Set) { + calendar.readOnly = !["D:write", "D:write-content", "D:write-properties", "D:all"].some( + priv => privs.has(priv) + ); + } + return calendar; + } +} |