summaryrefslogtreecommitdiffstats
path: root/comm/calendar/providers/ics/CalICSProvider.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'comm/calendar/providers/ics/CalICSProvider.jsm')
-rw-r--r--comm/calendar/providers/ics/CalICSProvider.jsm447
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;
+ }
+}