diff options
Diffstat (limited to 'comm/calendar/providers/ics/CalICSProvider.jsm')
-rw-r--r-- | comm/calendar/providers/ics/CalICSProvider.jsm | 447 |
1 files changed, 447 insertions, 0 deletions
diff --git a/comm/calendar/providers/ics/CalICSProvider.jsm b/comm/calendar/providers/ics/CalICSProvider.jsm new file mode 100644 index 0000000000..1c5df4efa0 --- /dev/null +++ b/comm/calendar/providers/ics/CalICSProvider.jsm @@ -0,0 +1,447 @@ +/* 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 = ["CalICSProvider"]; + +var { setTimeout } = ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs"); + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +var { CalDavGenericRequest, CalDavPropfindRequest } = ChromeUtils.import( + "resource:///modules/caldav/CalDavRequest.jsm" +); + +// NOTE: This module should not be loaded directly, it is available when +// including calUtils.jsm under the cal.provider.ics namespace. + +/** + * @implements {calICalendarProvider} + */ +var CalICSProvider = { + QueryInterface: ChromeUtils.generateQI(["calICalendarProvider"]), + + get type() { + return "ics"; + }, + + get displayName() { + return cal.l10n.getCalString("icsName"); + }, + + get shortName() { + return "ICS"; + }, + + 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 ICSDetector(username, password, savePassword); + + // To support ics files hosted by simple HTTP server, attempt HEAD/GET + // before PROPFIND. + for (let method of [ + "attemptHead", + "attemptGet", + "attemptDAVLocation", + "attemptPut", + "attemptLocalFile", + ]) { + try { + cal.LOG(`[CalICSProvider] 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. + let message = `[CalICSProvider] 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}`; + + // 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 CalICSProvider to detect ICS calendars for a given username, + * password, location, etc. + * + * @implements {nsIAuthPrompt2} + * @implements {nsIAuthPromptProvider} + * @implements {nsIInterfaceRequestor} + */ +class ICSDetectionSession { + QueryInterface = ChromeUtils.generateQI([ + Ci.nsIAuthPrompt2, + Ci.nsIAuthPromptProvider, + Ci.nsIInterfaceRequestor, + ]); + + isDetectionSession = true; + + /** + * Create a new ICS detection session. + * + * @param {string} aSessionId - The session id, used in the password manager. + * @param {string} aName - The user-readable description of this session. + * @param {string} aPassword - The password for the session. + * @param {boolean} aSavePassword - Whether to save the password. + */ + constructor(aSessionId, aUserName, aPassword, aSavePassword) { + this.id = aSessionId; + this.name = aUserName; + this.password = aPassword; + this.savePassword = aSavePassword; + } + + /** + * Implement nsIInterfaceRequestor. + * + * @param {nsIIDRef} aIID - The IID of the interface being requested. + * @returns {ICSAutodetectSession | null} Either this object QI'd to the IID, or null. + * Components.returnCode is set accordingly. + * @see {nsIInterfaceRequestor} + */ + getInterface(aIID) { + try { + // Try to query the this object for the requested interface but don't + // throw if it fails since that borks the network code. + return this.QueryInterface(aIID); + } catch (e) { + Components.returnCode = e; + } + return null; + } + + /** + * @see {nsIAuthPromptProvider} + */ + getAuthPrompt(aReason, aIID) { + try { + return this.QueryInterface(aIID); + } catch (e) { + throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE); + } + } + + /** + * @see {nsIAuthPrompt2} + */ + asyncPromptAuth(aChannel, aCallback, aContext, aLevel, aAuthInfo) { + setTimeout(() => { + if (this.promptAuth(aChannel, aLevel, aAuthInfo)) { + aCallback.onAuthAvailable(aContext, aAuthInfo); + } else { + aCallback.onAuthCancelled(aContext, true); + } + }, 0); + } + + /** + * @see {nsIAuthPrompt2} + */ + promptAuth(aChannel, aLevel, aAuthInfo) { + if (!this.password) { + return false; + } + + if ((aAuthInfo.flags & aAuthInfo.PREVIOUS_FAILED) == 0) { + aAuthInfo.username = this.name; + aAuthInfo.password = this.password; + + if (this.savePassword) { + cal.auth.passwordManagerSave( + this.name, + this.password, + aChannel.URI.prePath, + aAuthInfo.realm + ); + } + return true; + } + + aAuthInfo.username = null; + aAuthInfo.password = null; + if (this.savePassword) { + cal.auth.passwordManagerRemove(this.name, aChannel.URI.prePath, aAuthInfo.realm); + } + return false; + } + + /** @see {CalDavSession} */ + async prepareRequest(aChannel) {} + async prepareRedirect(aOldChannel, aNewChannel) {} + async completeRequest(aResponse) {} +} + +/** + * Used by the CalICSProvider to detect ICS calendars for a given location, + * username, password, etc. The protocol for detecting ICS calendars is DAV + * (pure DAV, not CalDAV), but we use some of the CalDAV code here because the + * code is not currently organized to handle pure DAV and CalDAV separately + * (e.g. CalDavGenericRequest, CalDavPropfindRequest). + */ +class ICSDetector { + /** + * Create a new ICS 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.session = new ICSDetectionSession(cal.getUUID(), username, password, savePassword); + } + + /** + * Attempt to detect calendars at the given location using CalDAV PROPFIND. + * + * @param {nsIURI} location - The location to attempt. + * @returns {Promise<calICalendar[] | null>} An array of calendars or null. + */ + async attemptDAVLocation(location) { + let props = ["D:getcontenttype", "D:resourcetype", "D:displayname", "A:calendar-color"]; + 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(`[calICSProvider] ${target.spec} did not respond properly to PROPFIND`); + return null; + } + + let resprops = response.firstProps; + let resourceType = resprops["D:resourcetype"] || new Set(); + + if (resourceType.has("C:calendar") || resprops["D:getcontenttype"] == "text/calendar") { + cal.LOG(`[calICSProvider] ${target.spec} is a calendar`); + return [this.handleCalendar(target, resprops)]; + } else if (resourceType.has("D:collection")) { + return this.handleDirectory(target); + } + + return null; + } + + /** + * Attempt to detect calendars at the given location using a CalDAV generic + * request and a method like "HEAD" or "GET". + * + * @param {string} method - The request method to use, e.g. "GET" or "HEAD". + * @param {nsIURI} location - The location to attempt. + * @returns {Promise<calICalendar[] | null>} An array of calendars or null. + */ + async _attemptMethod(method, location) { + let request = new CalDavGenericRequest(this.session, null, method, location, { + Accept: "text/calendar, application/ics, text/plain;q=0.9", + }); + + // `request.commit()` can throw; errors should be caught by calling functions. + let response = await request.commit(); + + // The content type header may include a charset, so use 'string.includes'. + if (response.ok) { + let header = response.getHeader("Content-Type"); + + if ( + header.includes("text/calendar") || + header.includes("application/ics") || + (response.text && response.text.includes("BEGIN:VCALENDAR")) + ) { + let target = response.uri; + cal.LOG(`[calICSProvider] ${target.spec} has valid content type (via ${method} request)`); + return [this.handleCalendar(target)]; + } + } + return null; + } + + get attemptHead() { + return this._attemptMethod.bind(this, "HEAD"); + } + + get attemptGet() { + return this._attemptMethod.bind(this, "GET"); + } + + /** + * Attempt to detect calendars at the given location using a CalDAV generic + * request and "PUT". + * + * @param {nsIURI} location - The location to attempt. + * @returns {Promise<calICalendar[] | null>} An array of calendars or null. + */ + async attemptPut(location) { + let request = new CalDavGenericRequest( + this.session, + null, + "PUT", + location, + { "If-Match": "nothing" }, + "", + "text/plain" + ); + // `request.commit()` can throw; errors should be caught by calling functions. + let response = await request.commit(); + let target = response.uri; + + if (response.conflict) { + // The etag didn't match, which means we can generally write here but our crafted etag + // is stopping us. This means we can assume there is a calendar at the location. + cal.LOG( + `[calICSProvider] ${target.spec} responded to a dummy ETag request, we can` + + " assume it is a valid calendar location" + ); + return [this.handleCalendar(target)]; + } + + return null; + } + + /** + * Attempt to detect a calendar for a file URI (`file:///path/to/file.ics`). + * If a directory in the path does not exist return null. Whether the file + * exists or not, return a calendar for the location (the file will be + * created if it does not exist). + * + * @param {nsIURI} location - The location to attempt. + * @returns {calICalendar[] | null} An array containing a calendar or null. + */ + async attemptLocalFile(location) { + if (location.schemeIs("file")) { + let fullPath = location.QueryInterface(Ci.nsIFileURL).file.path; + let pathToDir = PathUtils.parent(fullPath); + let dirExists = await IOUtils.exists(pathToDir); + + if (dirExists || pathToDir == "") { + let calendar = this.handleCalendar(location); + if (calendar) { + // Check whether we have write permission on the calendar file. + // Calling stat on a non-existent file is an error so we check for + // it's existence first. + let { permissions } = (await IOUtils.exists(fullPath)) + ? await IOUtils.stat(fullPath) + : await IOUtils.stat(pathToDir); + + calendar.readOnly = (permissions ^ 0o200) == 0; + return [calendar]; + } + } else { + cal.LOG(`[calICSProvider] ${location.spec} includes a directory that does not exist`); + } + } else { + cal.LOG(`[calICSProvider] ${location.spec} is not a "file" URI`); + } + return null; + } + + /** + * Utility function to make a new attempt to detect calendars after the + * previous PROPFIND results contained "D:resourcetype" with "D:collection". + * + * @param {nsIURI} location - The location to attempt. + * @returns {Promise<calICalendar[] | null>} An array of calendars or null. + */ + async handleDirectory(location) { + let props = [ + "D:getcontenttype", + "D:current-user-privilege-set", + "D:displayname", + "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; + + let calendars = []; + for (let [href, resprops] of Object.entries(response.data)) { + if (resprops["D:getcontenttype"] != "text/calendar") { + continue; + } + + let uri = Services.io.newURI(href, null, target); + calendars.push(this.handleCalendar(uri, resprops)); + } + + cal.LOG(`[calICSProvider] ${target.spec} is a directory, found ${calendars.length} calendars`); + + return calendars.length ? calendars : null; + } + + /** + * Set up and return a new ICS calendar object. + * + * @param {nsIURI} uri - The location of the calendar. + * @param {Set} [props] - For CalDav calendars, these are the props + * parsed from the response. + * @returns {calICalendar} A new calendar. + */ + handleCalendar(uri, props = new Set()) { + let displayName = props["D:displayname"]; + let color = props["A:calendar-color"]; + if (!displayName) { + let lastPath = uri.filePath.split("/").filter(Boolean).pop() || ""; + let fileName = lastPath.split(".").slice(0, -1).join("."); + displayName = fileName || lastPath || uri.spec; + } + + let calendar = cal.manager.createCalendar("ics", uri); + calendar.setProperty("color", color || cal.view.hashColor(uri.spec)); + calendar.name = displayName; + calendar.id = cal.getUUID(); + + // 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; + } +} |