summaryrefslogtreecommitdiffstats
path: root/comm/calendar/providers/ics
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
commit6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /comm/calendar/providers/ics
parentInitial commit. (diff)
downloadthunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz
thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'comm/calendar/providers/ics')
-rw-r--r--comm/calendar/providers/ics/CalICSCalendar.sys.mjs1235
-rw-r--r--comm/calendar/providers/ics/CalICSProvider.jsm447
-rw-r--r--comm/calendar/providers/ics/components.conf14
-rw-r--r--comm/calendar/providers/ics/moz.build16
4 files changed, 1712 insertions, 0 deletions
diff --git a/comm/calendar/providers/ics/CalICSCalendar.sys.mjs b/comm/calendar/providers/ics/CalICSCalendar.sys.mjs
new file mode 100644
index 0000000000..df5eab830b
--- /dev/null
+++ b/comm/calendar/providers/ics/CalICSCalendar.sys.mjs
@@ -0,0 +1,1235 @@
+/* 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 { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { CalReadableStreamFactory } = ChromeUtils.import(
+ "resource:///modules/CalReadableStreamFactory.jsm"
+);
+
+// This is a non-sync ics file. It reads the file pointer to by uri when set,
+// then writes it on updates. External changes to the file will be
+// ignored and overwritten.
+//
+// XXX Should do locks, so that external changes are not overwritten.
+
+function icsNSResolver(prefix) {
+ const ns = { D: "DAV:" };
+ return ns[prefix] || null;
+}
+
+function icsXPathFirst(aNode, aExpr, aType) {
+ return cal.xml.evalXPathFirst(aNode, aExpr, icsNSResolver, aType);
+}
+
+var calICSCalendarClassID = Components.ID("{f8438bff-a3c9-4ed5-b23f-2663b5469abf}");
+var calICSCalendarInterfaces = [
+ "calICalendar",
+ "calISchedulingSupport",
+ "nsIChannelEventSink",
+ "nsIInterfaceRequestor",
+ "nsIStreamListener",
+ "nsIStreamLoaderObserver",
+];
+
+/**
+ * @implements {calICalendar}
+ * @implements {calISchedulingSupport}
+ * @implements {nsIChannelEventSink}
+ * @implements {nsIInterfaceRequestor}
+ * @implements {nsIStreamListener}
+ * @implements {nsIStreamLoaderObserver}
+ */
+export class CalICSCalendar extends cal.provider.BaseClass {
+ classID = calICSCalendarClassID;
+ QueryInterface = cal.generateQI(calICSCalendarInterfaces);
+ classInfo = cal.generateCI({
+ classID: calICSCalendarClassID,
+ contractID: "@mozilla.org/calendar/calendar;1?type=ics",
+ classDescription: "Calendar ICS provider",
+ interfaces: calICSCalendarInterfaces,
+ });
+
+ #hooks = null;
+ #memoryCalendar = null;
+ #modificationActions = [];
+ #observer = null;
+ #uri = null;
+ #locked = false;
+ #unmappedComponents = [];
+ #unmappedProperties = [];
+
+ // Public to allow access by calCachedCalendar
+ _queue = [];
+
+ constructor() {
+ super();
+
+ this.initProviderBase();
+ this.initICSCalendar();
+ }
+
+ initICSCalendar() {
+ this.#memoryCalendar = Cc["@mozilla.org/calendar/calendar;1?type=memory"].createInstance(
+ Ci.calICalendar
+ );
+
+ this.#memoryCalendar.superCalendar = this;
+ this.#observer = new calICSObserver(this);
+ this.#memoryCalendar.addObserver(this.#observer); // XXX Not removed
+ }
+
+ //
+ // calICalendar interface
+ //
+ get type() {
+ return "ics";
+ }
+
+ get canRefresh() {
+ return true;
+ }
+
+ get uri() {
+ return this.#uri;
+ }
+
+ set uri(uri) {
+ if (this.#uri?.spec == uri.spec) {
+ return;
+ }
+
+ this.#uri = uri;
+ this.#memoryCalendar.uri = this.#uri;
+
+ if (this.#uri.schemeIs("http") || this.#uri.schemeIs("https")) {
+ this.#hooks = new httpHooks(this);
+ } else if (this.#uri.schemeIs("file")) {
+ this.#hooks = new fileHooks();
+ } else {
+ this.#hooks = new dummyHooks();
+ }
+ }
+
+ getProperty(aName) {
+ switch (aName) {
+ case "requiresNetwork":
+ return !this.uri.schemeIs("file");
+ }
+
+ return super.getProperty(aName);
+ }
+
+ get supportsScheduling() {
+ return true;
+ }
+
+ getSchedulingSupport() {
+ return this;
+ }
+
+ // Always use the queue, just to reduce the amount of places where
+ // this.mMemoryCalendar.addItem() and friends are called. less
+ // copied code.
+ addItem(aItem) {
+ return this.adoptItem(aItem.clone());
+ }
+
+ // Used to allow the cachedCalendar provider to hook into adoptItem() before
+ // it returns.
+ _cachedAdoptItemCallback = null;
+
+ async adoptItem(aItem) {
+ if (this.readOnly) {
+ throw new Components.Exception("Calendar is not writable", Ci.calIErrors.CAL_IS_READONLY);
+ }
+
+ let adoptCallback = this._cachedAdoptItemCallback;
+
+ let item = await new Promise(resolve => {
+ this.startBatch();
+ this._queue.push({
+ action: "add",
+ item: aItem,
+ listener: item => {
+ this.endBatch();
+ resolve(item);
+ },
+ });
+ this.#processQueue();
+ });
+
+ if (adoptCallback) {
+ await adoptCallback(item.calendar, Cr.NS_OK, Ci.calIOperationListener.ADD, item.id, item);
+ }
+ return item;
+ }
+
+ // Used to allow the cachedCalendar provider to hook into modifyItem() before
+ // it returns.
+ _cachedModifyItemCallback = null;
+
+ async modifyItem(aNewItem, aOldItem) {
+ if (this.readOnly) {
+ throw new Components.Exception("Calendar is not writable", Ci.calIErrors.CAL_IS_READONLY);
+ }
+
+ let modifyCallback = this._cachedModifyItemCallback;
+ let item = await new Promise(resolve => {
+ this.startBatch();
+ this._queue.push({
+ action: "modify",
+ newItem: aNewItem,
+ oldItem: aOldItem,
+ listener: item => {
+ this.endBatch();
+ resolve(item);
+ },
+ });
+ this.#processQueue();
+ });
+
+ if (modifyCallback) {
+ await modifyCallback(item.calendar, Cr.NS_OK, Ci.calIOperationListener.MODIFY, item.id, item);
+ }
+ return item;
+ }
+
+ /**
+ * Delete the provided item.
+ *
+ * @param {calIItemBase} aItem
+ * @returns {Promise<void>}
+ */
+ deleteItem(aItem) {
+ if (this.readOnly) {
+ throw new Components.Exception("Calendar is not writable", Ci.calIErrors.CAL_IS_READONLY);
+ }
+
+ return new Promise(resolve => {
+ this._queue.push({
+ action: "delete",
+ item: aItem,
+ listener: resolve,
+ });
+ this.#processQueue();
+ });
+ }
+
+ /**
+ * @param {string} aId
+ * @returns {Promise<calIItemBase?>}
+ */
+ getItem(aId) {
+ return new Promise(resolve => {
+ this._queue.push({
+ action: "get_item",
+ id: aId,
+ listener: resolve,
+ });
+ this.#processQueue();
+ });
+ }
+
+ /**
+ * @param {number} aItemFilter
+ * @param {number} aCount
+ * @param {calIDateTime} aRangeStart
+ * @param {calIDateTime} aRangeEndEx
+ * @returns {ReadableStream<calIItemBase>}
+ */
+ getItems(aItemFilter, aCount, aRangeStart, aRangeEndEx) {
+ let self = this;
+ return CalReadableStreamFactory.createBoundedReadableStream(
+ aCount,
+ CalReadableStreamFactory.defaultQueueSize,
+ {
+ start(controller) {
+ self._queue.push({
+ action: "get_items",
+ exec: async () => {
+ for await (let value of cal.iterate.streamValues(
+ self.#memoryCalendar.getItems(aItemFilter, aCount, aRangeStart, aRangeEndEx)
+ )) {
+ controller.enqueue(value);
+ }
+ controller.close();
+ },
+ });
+ self.#processQueue();
+ },
+ }
+ );
+ }
+
+ refresh() {
+ this._queue.push({ action: "refresh", forceRefresh: false });
+ this.#processQueue();
+ }
+
+ startBatch() {
+ this.#observer.onStartBatch(this);
+ }
+
+ endBatch() {
+ this.#observer.onEndBatch(this);
+ }
+
+ #forceRefresh() {
+ this._queue.push({ action: "refresh", forceRefresh: true });
+ this.#processQueue();
+ }
+
+ #prepareChannel(channel, forceRefresh) {
+ channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
+ channel.notificationCallbacks = this;
+
+ // Allow the hook to do its work, like a performing a quick check to
+ // see if the remote file really changed. Might save a lot of time
+ this.#hooks.onBeforeGet(channel, forceRefresh);
+ }
+
+ #createMemoryCalendar() {
+ // Create a new calendar, to get rid of all the old events
+ // Don't forget to remove the observer
+ if (this.#memoryCalendar) {
+ this.#memoryCalendar.removeObserver(this.#observer);
+ }
+ this.#memoryCalendar = Cc["@mozilla.org/calendar/calendar;1?type=memory"].createInstance(
+ Ci.calICalendar
+ );
+ this.#memoryCalendar.uri = this.#uri;
+ this.#memoryCalendar.superCalendar = this;
+ }
+
+ #doRefresh(force) {
+ let channel = Services.io.newChannelFromURI(
+ this.#uri,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+ this.#prepareChannel(channel, force);
+
+ let streamLoader = Cc["@mozilla.org/network/stream-loader;1"].createInstance(
+ Ci.nsIStreamLoader
+ );
+
+ // Lock other changes to the item list.
+ this.#lock();
+
+ try {
+ streamLoader.init(this);
+ channel.asyncOpen(streamLoader);
+ } catch (e) {
+ // File not found: a new calendar. No problem.
+ cal.LOG("[calICSCalendar] Error occurred opening channel: " + e);
+ this.#unlock();
+ }
+ }
+
+ // nsIChannelEventSink implementation
+ asyncOnChannelRedirect(aOldChannel, aNewChannel, aFlags, aCallback) {
+ this.#prepareChannel(aNewChannel, true);
+ aCallback.onRedirectVerifyCallback(Cr.NS_OK);
+ }
+
+ // nsIStreamLoaderObserver impl
+ // Listener for download. Parse the downloaded file
+
+ onStreamComplete(loader, ctxt, status, resultLength, result) {
+ let cont = false;
+
+ if (Components.isSuccessCode(status)) {
+ // Allow the hook to get needed data (like an etag) of the channel
+ cont = this.#hooks.onAfterGet(loader.request);
+ cal.LOG("[calICSCalendar] Loading ICS succeeded, needs further processing: " + cont);
+ } else {
+ // Failure may be due to temporary connection issue, keep old data to
+ // prevent potential data loss if it becomes available again.
+ cal.LOG("[calICSCalendar] Unable to load stream - status: " + status);
+
+ // Check for bad server certificates on SSL/TLS connections.
+ cal.provider.checkBadCertStatus(loader.request, status, this);
+ }
+
+ if (!cont) {
+ // no need to process further, we can use the previous data
+ // HACK Sorry, but offline support requires the items to be signaled
+ // even if nothing has changed (especially at startup)
+ this.#observer.onLoad(this);
+ this.#unlock();
+ return;
+ }
+
+ // Clear any existing events if there was no result
+ if (!resultLength) {
+ this.#createMemoryCalendar();
+ this.#memoryCalendar.addObserver(this.#observer);
+ this.#observer.onLoad(this);
+ this.#unlock();
+ return;
+ }
+
+ // This conversion is needed, because the stream only knows about
+ // byte arrays, not about strings or encodings. The array of bytes
+ // need to be interpreted as utf8 and put into a javascript string.
+ let str;
+ try {
+ str = new TextDecoder().decode(Uint8Array.from(result));
+ } catch (e) {
+ this.#observer.onError(
+ this.superCalendar,
+ Ci.calIErrors.CAL_UTF8_DECODING_FAILED,
+ e.toString()
+ );
+ this.#observer.onError(this.superCalendar, Ci.calIErrors.READ_FAILED, "");
+ this.#unlock();
+ return;
+ }
+
+ this.#createMemoryCalendar();
+
+ this.#observer.onStartBatch(this);
+ this.#memoryCalendar.addObserver(this.#observer);
+
+ // Wrap parsing in a try block. Will ignore errors. That's a good thing
+ // for non-existing or empty files, but not good for invalid files.
+ // That's why we put them in readOnly mode
+ let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+ let self = this;
+ let listener = {
+ // calIIcsParsingListener
+ onParsingComplete(rc, parser_) {
+ try {
+ for (let item of parser_.getItems()) {
+ self.#memoryCalendar.adoptItem(item);
+ }
+ self.#unmappedComponents = parser_.getComponents();
+ self.#unmappedProperties = parser_.getProperties();
+ cal.LOG("[calICSCalendar] Parsing ICS succeeded for " + self.uri.spec);
+ } catch (exc) {
+ cal.LOG("[calICSCalendar] Parsing ICS failed for \nException: " + exc);
+ self.#observer.onError(self.superCalendar, exc.result, exc.toString());
+ self.#observer.onError(self.superCalendar, Ci.calIErrors.READ_FAILED, "");
+ }
+ self.#observer.onEndBatch(self);
+ self.#observer.onLoad(self);
+
+ // Now that all items have been stuffed into the memory calendar
+ // we should add ourselves as observer. It is important that this
+ // happens *after* the calls to adoptItem in the above loop to prevent
+ // the views from being notified.
+ self.#unlock();
+ },
+ };
+ parser.parseString(str, listener);
+ }
+
+ async #writeICS() {
+ cal.LOG("[calICSCalendar] Commencing write of ICS Calendar " + this.name);
+ if (!this.#uri) {
+ throw Components.Exception("mUri must be set", Cr.NS_ERROR_FAILURE);
+ }
+ this.#lock();
+ try {
+ await this.#makeBackup();
+ await this.#doWriteICS();
+ } catch (e) {
+ this.#unlock(Ci.calIErrors.MODIFICATION_FAILED);
+ }
+ }
+
+ /**
+ * Write the contents of an ICS serializer to an open channel as an ICS file.
+ *
+ * @param {calIIcsSerializer} serializer - The serializer to write
+ * @param {nsIChannel} channel - The destination upload or file channel
+ */
+ async #writeSerializerToChannel(serializer, channel) {
+ if (channel.URI.schemeIs("file")) {
+ // We handle local files separately, as writing to an nsIChannel has the
+ // potential to fail partway and can leave a file truncated, resulting in
+ // data loss. For local files, we have the option to do atomic writes.
+ try {
+ const file = channel.QueryInterface(Ci.nsIFileChannel).file;
+
+ // The temporary file permissions will become the file permissions since
+ // we move the temp file over top of the file itself. Copy the file
+ // permissions or use a restrictive default.
+ const tmpFilePermissions = file.exists() ? file.permissions : 0o600;
+
+ // We're going to be writing to an arbitrary point in the user's file
+ // system, so we want to be very careful that we're not going to
+ // overwrite any of their files.
+ const tmpFilePath = await IOUtils.createUniqueFile(
+ file.parent.path,
+ `${file.leafName}.tmp`,
+ tmpFilePermissions
+ );
+
+ const outString = serializer.serializeToString();
+ await IOUtils.writeUTF8(file.path, outString, {
+ tmpPath: tmpFilePath,
+ });
+ } catch (e) {
+ this.#observer.onError(
+ this.superCalendar,
+ Ci.calIErrors.MODIFICATION_FAILED,
+ `Failed to write to calendar file ${channel.URI.spec}: ${e.message}`
+ );
+
+ // Writing the file has failed; refresh and signal error to all
+ // modifying operations.
+ this.#unlock(Ci.calIErrors.MODIFICATION_FAILED);
+ this.#forceRefresh();
+
+ return;
+ }
+
+ // Write succeeded and we can clean up. We can reuse the channel, as the
+ // last-modified time on the file will still be accurate.
+ this.#hooks.onAfterPut(channel, () => {
+ this.#unlock();
+ this.#observer.onLoad(this);
+ Services.startup.exitLastWindowClosingSurvivalArea();
+ });
+
+ return;
+ }
+
+ channel.notificationCallbacks = this;
+ let uploadChannel = channel.QueryInterface(Ci.nsIUploadChannel);
+
+ // Set the content of the upload channel to our ICS file.
+ let icsStream = serializer.serializeToInputStream();
+ uploadChannel.setUploadStream(icsStream, "text/calendar", -1);
+
+ channel.asyncOpen(this);
+ }
+
+ async #doWriteICS() {
+ cal.LOG("[calICSCalendar] Writing ICS File " + this.uri.spec);
+
+ let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance(
+ Ci.calIIcsSerializer
+ );
+ for (let comp of this.#unmappedComponents) {
+ serializer.addComponent(comp);
+ }
+
+ for (let prop of this.#unmappedProperties) {
+ switch (prop.propertyName) {
+ // we always set the current name and timezone:
+ case "X-WR-CALNAME":
+ case "X-WR-TIMEZONE":
+ break;
+ default:
+ serializer.addProperty(prop);
+ break;
+ }
+ }
+
+ let prop = cal.icsService.createIcalProperty("X-WR-CALNAME");
+ prop.value = this.name;
+ serializer.addProperty(prop);
+ prop = cal.icsService.createIcalProperty("X-WR-TIMEZONE");
+ prop.value = cal.timezoneService.defaultTimezone.tzid;
+ serializer.addProperty(prop);
+
+ // Get items directly from the memory calendar, as we're locked now and
+ // calling this.getItems{,AsArray}() will return immediately
+ serializer.addItems(
+ await this.#memoryCalendar.getItemsAsArray(
+ Ci.calICalendar.ITEM_FILTER_TYPE_ALL | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL,
+ 0,
+ null,
+ null
+ )
+ );
+
+ let inLastWindowClosingSurvivalArea = false;
+ try {
+ // All events are returned. Now set up a channel and a
+ // streamloader to upload. onStopRequest will be called
+ // once the write has finished
+ let channel = Services.io.newChannelFromURI(
+ this.#uri,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+
+ // Allow the hook to add things to the channel, like a
+ // header that checks etags
+ let notChanged = this.#hooks.onBeforePut(channel);
+ if (notChanged) {
+ // Prevent Thunderbird from exiting entirely until we've finished
+ // uploading one way or another
+ Services.startup.enterLastWindowClosingSurvivalArea();
+ inLastWindowClosingSurvivalArea = true;
+
+ this.#writeSerializerToChannel(serializer, channel);
+ } else {
+ this.#observer.onError(
+ this.superCalendar,
+ Ci.calIErrors.MODIFICATION_FAILED,
+ "The calendar has been changed remotely. Please reload and apply your changes again!"
+ );
+
+ this.#unlock(Ci.calIErrors.MODIFICATION_FAILED);
+ }
+ } catch (ex) {
+ if (inLastWindowClosingSurvivalArea) {
+ Services.startup.exitLastWindowClosingSurvivalArea();
+ }
+
+ this.#observer.onError(
+ this.superCalendar,
+ ex.result,
+ "The calendar could not be saved; there was a failure: 0x" + ex.result.toString(16)
+ );
+ this.#observer.onError(this.superCalendar, Ci.calIErrors.MODIFICATION_FAILED, "");
+ this.#unlock(Ci.calIErrors.MODIFICATION_FAILED);
+
+ this.#forceRefresh();
+ }
+ }
+
+ // nsIStreamListener impl
+ // For after publishing. Do error checks here
+ onStartRequest(aRequest) {}
+
+ onDataAvailable(aRequest, aInputStream, aOffset, aCount) {
+ // All data must be consumed. For an upload channel, there is
+ // no meaningful data. So it gets read and then ignored
+ let scriptableInputStream = Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
+ Ci.nsIScriptableInputStream
+ );
+ scriptableInputStream.init(aInputStream);
+ scriptableInputStream.read(-1);
+ }
+
+ onStopRequest(aRequest, aStatusCode) {
+ let httpChannel;
+ let requestSucceeded = false;
+ try {
+ httpChannel = aRequest.QueryInterface(Ci.nsIHttpChannel);
+ requestSucceeded = httpChannel.requestSucceeded;
+ } catch (e) {
+ // This may fail if it was not a http channel, handled later on.
+ }
+
+ if (httpChannel) {
+ cal.LOG("[calICSCalendar] channel.requestSucceeded: " + requestSucceeded);
+ }
+
+ if (
+ (httpChannel && !requestSucceeded) ||
+ (!httpChannel && !Components.isSuccessCode(aRequest.status))
+ ) {
+ this.#observer.onError(
+ this.superCalendar,
+ Components.isSuccessCode(aRequest.status) ? Ci.calIErrors.DAV_PUT_ERROR : aRequest.status,
+ "Publishing the calendar file failed\n" +
+ "Status code: " +
+ aRequest.status.toString(16) +
+ "\n"
+ );
+ this.#observer.onError(this.superCalendar, Ci.calIErrors.MODIFICATION_FAILED, "");
+
+ // The PUT has failed; refresh and signal error to all modifying operations
+ this.#forceRefresh();
+ this.#unlock(Ci.calIErrors.MODIFICATION_FAILED);
+
+ Services.startup.exitLastWindowClosingSurvivalArea();
+
+ return;
+ }
+
+ // Allow the hook to grab data of the channel, like the new etag
+ this.#hooks.onAfterPut(aRequest, () => {
+ this.#unlock();
+ this.#observer.onLoad(this);
+ Services.startup.exitLastWindowClosingSurvivalArea();
+ });
+ }
+
+ async #processQueue() {
+ if (this._isLocked) {
+ return;
+ }
+
+ let task;
+ let refreshAction = null;
+ while ((task = this._queue.shift())) {
+ switch (task.action) {
+ case "add":
+ this.#lock();
+ this.#memoryCalendar.addItem(task.item).then(async item => {
+ task.item = item;
+ this.#modificationActions.push(task);
+ await this.#writeICS();
+ });
+ return;
+ case "modify":
+ this.#lock();
+ this.#memoryCalendar.modifyItem(task.newItem, task.oldItem).then(async item => {
+ task.item = item;
+ this.#modificationActions.push(task);
+ await this.#writeICS();
+ });
+ return;
+ case "delete":
+ this.#lock();
+ this.#memoryCalendar.deleteItem(task.item).then(async () => {
+ this.#modificationActions.push(task);
+ await this.#writeICS();
+ });
+ return;
+ case "get_item":
+ this.#memoryCalendar.getItem(task.id).then(task.listener);
+ break;
+ case "get_items":
+ task.exec();
+ break;
+ case "refresh":
+ refreshAction = task;
+ break;
+ }
+
+ if (refreshAction) {
+ cal.LOG(
+ "[calICSCalendar] Refreshing " +
+ this.name +
+ (refreshAction.forceRefresh ? " (forced)" : "")
+ );
+ this.#doRefresh(refreshAction.forceRefresh);
+
+ // break queue processing here and wait for refresh to finish
+ // before processing further operations
+ break;
+ }
+ }
+ }
+
+ #lock() {
+ this.#locked = true;
+ }
+
+ #unlock(errCode) {
+ cal.ASSERT(this.#locked, "unexpected!");
+
+ this.#modificationActions.forEach(action => {
+ let listener = action.listener;
+ if (typeof listener == "function") {
+ listener(action.item);
+ } else if (listener) {
+ let args = action.opCompleteArgs;
+ cal.ASSERT(args, "missing onOperationComplete call!");
+ if (Components.isSuccessCode(args[1]) && errCode && !Components.isSuccessCode(errCode)) {
+ listener.onOperationComplete(args[0], errCode, args[2], args[3], null);
+ } else {
+ listener.onOperationComplete(...args);
+ }
+ }
+ });
+ this.#modificationActions = [];
+
+ this.#locked = false;
+ this.#processQueue();
+ }
+
+ // Visible for testing.
+ get _isLocked() {
+ return this.#locked;
+ }
+
+ /**
+ * @see nsIInterfaceRequestor
+ * @see calProviderUtils.jsm
+ */
+ getInterface = cal.provider.InterfaceRequestor_getInterface;
+
+ /**
+ * Make a backup of the (remote) calendar
+ *
+ * This will download the remote file into the profile dir.
+ * It should be called before every upload, so every change can be
+ * restored. By default, it will keep 3 backups. It also keeps one
+ * file each day, for 3 days. That way, even if the user doesn't notice
+ * the remote calendar has become corrupted, he will still lose max 1
+ * day of work.
+ *
+ * @returns {Promise} A promise that is settled once backup completed.
+ */
+ #makeBackup() {
+ return new Promise((resolve, reject) => {
+ // Uses |pseudoID|, an id of the calendar, defined below
+ function makeName(type) {
+ return "calBackupData_" + pseudoID + "_" + type + ".ics";
+ }
+
+ // This is a bit messy. createUnique creates an empty file,
+ // but we don't use that file. All we want is a filename, to be used
+ // in the call to copyTo later. So we create a file, get the filename,
+ // and never use the file again, but write over it.
+ // Using createUnique anyway, because I don't feel like
+ // re-implementing it
+ function makeDailyFileName() {
+ let dailyBackupFile = backupDir.clone();
+ dailyBackupFile.append(makeName("day"));
+ dailyBackupFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, parseInt("0600", 8));
+ dailyBackupFileName = dailyBackupFile.leafName;
+
+ // Remove the reference to the nsIFile, because we need to
+ // write over the file later, and you never know what happens
+ // if something still has a reference.
+ // Also makes it explicit that we don't need the file itself,
+ // just the name.
+ dailyBackupFile = null;
+
+ return dailyBackupFileName;
+ }
+
+ function purgeBackupsByType(files, type) {
+ // filter out backups of the type we care about.
+ let filteredFiles = files.filter(file =>
+ file.name.includes("calBackupData_" + pseudoID + "_" + type)
+ );
+ // Sort by lastmodifed
+ filteredFiles.sort((a, b) => a.lastmodified - b.lastmodified);
+ // And delete the oldest files, and keep the desired number of
+ // old backups
+ for (let i = 0; i < filteredFiles.length - numBackupFiles; ++i) {
+ let file = backupDir.clone();
+ file.append(filteredFiles[i].name);
+
+ try {
+ file.remove(false);
+ } catch (ex) {
+ // This can fail because of some crappy code in
+ // nsIFile. That's not the end of the world. We can
+ // try to remove the file the next time around.
+ }
+ }
+ }
+
+ function purgeOldBackups() {
+ // Enumerate files in the backupdir for expiry of old backups
+ let files = [];
+ for (let file of backupDir.directoryEntries) {
+ if (file.isFile()) {
+ files.push({ name: file.leafName, lastmodified: file.lastModifiedTime });
+ }
+ }
+
+ if (doDailyBackup) {
+ purgeBackupsByType(files, "day");
+ } else {
+ purgeBackupsByType(files, "edit");
+ }
+ }
+
+ function copyToOverwriting(oldFile, newParentDir, newName) {
+ try {
+ let newFile = newParentDir.clone();
+ newFile.append(newName);
+
+ if (newFile.exists()) {
+ newFile.remove(false);
+ }
+ oldFile.copyTo(newParentDir, newName);
+ } catch (e) {
+ cal.ERROR("[calICSCalendar] Backup failed, no copy: " + e);
+ // Error in making a daily/initial backup.
+ // not fatal, so just continue
+ }
+ }
+
+ let backupDays = Services.prefs.getIntPref("calendar.backup.days", 1);
+ let numBackupFiles = Services.prefs.getIntPref("calendar.backup.filenum", 3);
+
+ let backupDir;
+ try {
+ backupDir = cal.provider.getCalendarDirectory();
+ backupDir.append("backup");
+ if (!backupDir.exists()) {
+ backupDir.create(Ci.nsIFile.DIRECTORY_TYPE, parseInt("0755", 8));
+ }
+ } catch (e) {
+ // Backup dir wasn't found. Likely because we are running in
+ // xpcshell. Don't die, but continue the upload.
+ cal.ERROR("[calICSCalendar] Backup failed, no backupdir:" + e);
+ resolve();
+ return;
+ }
+
+ let pseudoID;
+ try {
+ pseudoID = this.getProperty("uniquenum2");
+ if (!pseudoID) {
+ pseudoID = new Date().getTime();
+ this.setProperty("uniquenum2", pseudoID);
+ }
+ } catch (e) {
+ // calendarmgr not found. Likely because we are running in
+ // xpcshell. Don't die, but continue the upload.
+ cal.ERROR("[calICSCalendar] Backup failed, no calendarmanager:" + e);
+ resolve();
+ return;
+ }
+
+ let doInitialBackup = false;
+ let initialBackupFile = backupDir.clone();
+ initialBackupFile.append(makeName("initial"));
+ if (!initialBackupFile.exists()) {
+ doInitialBackup = true;
+ }
+
+ let doDailyBackup = false;
+ let backupTime = this.getProperty("backup-time2");
+ if (!backupTime || new Date().getTime() > backupTime + backupDays * 24 * 60 * 60 * 1000) {
+ // It's time do to a daily backup
+ doDailyBackup = true;
+ this.setProperty("backup-time2", new Date().getTime());
+ }
+
+ let dailyBackupFileName;
+ if (doDailyBackup) {
+ dailyBackupFileName = makeDailyFileName(backupDir);
+ }
+
+ let backupFile = backupDir.clone();
+ backupFile.append(makeName("edit"));
+ backupFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, parseInt("0600", 8));
+
+ purgeOldBackups();
+
+ // Now go download the remote file, and store it somewhere local.
+ let channel = Services.io.newChannelFromURI(
+ this.#uri,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+ channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
+ channel.notificationCallbacks = this;
+
+ let downloader = Cc["@mozilla.org/network/downloader;1"].createInstance(Ci.nsIDownloader);
+ let listener = {
+ onDownloadComplete(opdownloader, request, ctxt, status, result) {
+ if (!Components.isSuccessCode(status)) {
+ reject();
+ return;
+ }
+ if (doInitialBackup) {
+ copyToOverwriting(result, backupDir, makeName("initial"));
+ }
+ if (doDailyBackup) {
+ copyToOverwriting(result, backupDir, dailyBackupFileName);
+ }
+ resolve();
+ },
+ };
+
+ downloader.init(listener, backupFile);
+ try {
+ channel.asyncOpen(downloader);
+ } catch (e) {
+ // For local files, asyncOpen throws on new (calendar) files
+ // No problem, go and upload something
+ cal.ERROR("[calICSCalendar] Backup failed in asyncOpen:" + e);
+ resolve();
+ }
+ });
+ }
+}
+
+/**
+ * @implements {calIObserver}
+ */
+class calICSObserver {
+ #calendar = null;
+
+ constructor(calendar) {
+ this.#calendar = calendar;
+ }
+
+ onStartBatch(aCalendar) {
+ this.#calendar.observers.notify("onStartBatch", [aCalendar]);
+ }
+
+ onEndBatch(aCalendar) {
+ this.#calendar.observers.notify("onEndBatch", [aCalendar]);
+ }
+
+ onLoad(aCalendar) {
+ this.#calendar.observers.notify("onLoad", [aCalendar]);
+ }
+
+ onAddItem(aItem) {
+ this.#calendar.observers.notify("onAddItem", [aItem]);
+ }
+
+ onModifyItem(aNewItem, aOldItem) {
+ this.#calendar.observers.notify("onModifyItem", [aNewItem, aOldItem]);
+ }
+
+ onDeleteItem(aDeletedItem) {
+ this.#calendar.observers.notify("onDeleteItem", [aDeletedItem]);
+ }
+
+ onError(aCalendar, aErrNo, aMessage) {
+ this.#calendar.readOnly = true;
+ this.#calendar.notifyError(aErrNo, aMessage);
+ }
+
+ onPropertyChanged(aCalendar, aName, aValue, aOldValue) {
+ this.#calendar.observers.notify("onPropertyChanged", [aCalendar, aName, aValue, aOldValue]);
+ }
+
+ onPropertyDeleting(aCalendar, aName) {
+ this.#calendar.observers.notify("onPropertyDeleting", [aCalendar, aName]);
+ }
+}
+
+/*
+ * Transport Abstraction Hooks
+ *
+ * These hooks provide a way to do checks before or after publishing an
+ * ICS file. The main use will be to check etags (or some other way to check
+ * for remote changes) to protect remote changes from being overwritten.
+ *
+ * Different protocols need different checks (webdav can do etag, but
+ * local files need last-modified stamps), hence different hooks for each
+ * types
+ */
+
+// dummyHooks are for transport types that don't have hooks of their own.
+// Also serves as poor-mans interface definition.
+class dummyHooks {
+ onBeforeGet(aChannel, aForceRefresh) {
+ return true;
+ }
+
+ /**
+ * @returns {boolean} false if the previous data should be used (the datastore
+ * didn't change, there might be no data in this GET), true
+ * in all other cases
+ */
+ onAfterGet(aChannel) {
+ return true;
+ }
+
+ onBeforePut(aChannel) {
+ return true;
+ }
+
+ onAfterPut(aChannel, aRespFunc) {
+ aRespFunc();
+ return true;
+ }
+}
+
+class httpHooks {
+ #calendar = null;
+ #etag = null;
+ #lastModified = null;
+
+ constructor(calendar) {
+ this.#calendar = calendar;
+ }
+
+ onBeforeGet(aChannel, aForceRefresh) {
+ let httpchannel = aChannel.QueryInterface(Ci.nsIHttpChannel);
+ httpchannel.setRequestHeader("Accept", "text/calendar,text/plain;q=0.8,*/*;q=0.5", false);
+
+ if (this.#etag && !aForceRefresh) {
+ // Somehow the webdav header 'If' doesn't work on apache when
+ // passing in a Not, so use the http version here.
+ httpchannel.setRequestHeader("If-None-Match", this.#etag, false);
+ } else if (!aForceRefresh && this.#lastModified) {
+ // Only send 'If-Modified-Since' if no ETag is available
+ httpchannel.setRequestHeader("If-Modified-Since", this.#lastModified, false);
+ }
+
+ return true;
+ }
+
+ onAfterGet(aChannel) {
+ let httpchannel = aChannel.QueryInterface(Ci.nsIHttpChannel);
+ let responseStatus = 0;
+ let responseStatusCategory = 0;
+
+ try {
+ responseStatus = httpchannel.responseStatus;
+ responseStatusCategory = Math.floor(responseStatus / 100);
+ } catch (e) {
+ // Error might have been a temporary connection issue, keep old data to
+ // prevent potential data loss if it becomes available again.
+ cal.LOG("[calICSCalendar] Unable to get response status.");
+ return false;
+ }
+
+ if (responseStatus == 304) {
+ // 304: Not Modified
+ // Can use the old data, so tell the caller that it can skip parsing.
+ cal.LOG("[calICSCalendar] Response status 304: Not Modified. Using the existing data.");
+ return false;
+ } else if (responseStatus == 404) {
+ // 404: Not Found
+ // This is a new calendar. Shouldn't try to parse it. But it also
+ // isn't a failure, so don't throw.
+ cal.LOG("[calICSCalendar] Response status 404: Not Found. This is a new calendar.");
+ return false;
+ } else if (responseStatus == 410) {
+ cal.LOG("[calICSCalendar] Response status 410, calendar is gone. Disabling the calendar.");
+ this.#calendar.setProperty("disabled", "true");
+ return false;
+ } else if (responseStatusCategory == 4 || responseStatusCategory == 5) {
+ cal.LOG(
+ "[calICSCalendar] Response status " +
+ responseStatus +
+ ", temporarily disabling calendar for safety."
+ );
+ this.#calendar.setProperty("disabled", "true");
+ this.#calendar.setProperty("auto-enabled", "true");
+ return false;
+ }
+
+ try {
+ this.#etag = httpchannel.getResponseHeader("ETag");
+ } catch (e) {
+ // No etag header. Now what?
+ this.#etag = null;
+ }
+
+ try {
+ this.#lastModified = httpchannel.getResponseHeader("Last-Modified");
+ } catch (e) {
+ this.#lastModified = null;
+ }
+
+ return true;
+ }
+
+ onBeforePut(aChannel) {
+ if (this.#etag) {
+ let httpchannel = aChannel.QueryInterface(Ci.nsIHttpChannel);
+
+ // Apache doesn't work correctly with if-match on a PUT method,
+ // so use the webdav header
+ httpchannel.setRequestHeader("If", "([" + this.#etag + "])", false);
+ }
+ return true;
+ }
+
+ onAfterPut(aChannel, aRespFunc) {
+ let httpchannel = aChannel.QueryInterface(Ci.nsIHttpChannel);
+ try {
+ this.#etag = httpchannel.getResponseHeader("ETag");
+ aRespFunc();
+ } catch (e) {
+ // There was no ETag header on the response. This means that
+ // putting is not atomic. This is bad. Race conditions can happen,
+ // because there is a time in which we don't know the right
+ // etag.
+ // Try to do the best we can, by immediately getting the etag.
+ let etagListener = {};
+ let self = this; // need to reference in callback
+
+ etagListener.onStreamComplete = function (
+ aLoader,
+ aContext,
+ aStatus,
+ aResultLength,
+ aResult
+ ) {
+ let multistatus;
+ try {
+ let str = new TextDecoder().decode(Uint8Array.from(aResult));
+ multistatus = cal.xml.parseString(str);
+ } catch (ex) {
+ cal.LOG("[calICSCalendar] Failed to fetch channel etag");
+ }
+
+ self.#etag = icsXPathFirst(
+ multistatus,
+ "/D:propfind/D:response/D:propstat/D:prop/D:getetag"
+ );
+ aRespFunc();
+ };
+ let queryXml = '<D:propfind xmlns:D="DAV:"><D:prop><D:getetag/></D:prop></D:propfind>';
+
+ let etagChannel = cal.provider.prepHttpChannel(
+ aChannel.URI,
+ queryXml,
+ "text/xml; charset=utf-8",
+ this
+ );
+ etagChannel.setRequestHeader("Depth", "0", false);
+ etagChannel.requestMethod = "PROPFIND";
+ let streamLoader = Cc["@mozilla.org/network/stream-loader;1"].createInstance(
+ Ci.nsIStreamLoader
+ );
+
+ cal.provider.sendHttpRequest(streamLoader, etagChannel, etagListener);
+ }
+ return true;
+ }
+
+ // nsIProgressEventSink
+ onProgress(aRequest, aProgress, aProgressMax) {}
+ onStatus(aRequest, aStatus, aStatusArg) {}
+
+ getInterface(aIid) {
+ if (aIid.equals(Ci.nsIProgressEventSink)) {
+ return this;
+ }
+ throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
+ }
+}
+
+class fileHooks {
+ #lastModified = null;
+
+ onBeforeGet(aChannel, aForceRefresh) {
+ return true;
+ }
+
+ /**
+ * @returns {boolean} false if the previous data should be used (the datastore
+ * didn't change, there might be no data in this GET), true
+ * in all other cases
+ */
+ onAfterGet(aChannel) {
+ let filechannel = aChannel.QueryInterface(Ci.nsIFileChannel);
+ if (this.#lastModified && this.#lastModified == filechannel.file.lastModifiedTime) {
+ return false;
+ }
+ this.#lastModified = filechannel.file.lastModifiedTime;
+ return true;
+ }
+
+ onBeforePut(aChannel) {
+ let filechannel = aChannel.QueryInterface(Ci.nsIFileChannel);
+ if (this.#lastModified && this.#lastModified != filechannel.file.lastModifiedTime) {
+ return false;
+ }
+ return true;
+ }
+
+ onAfterPut(aChannel, aRespFunc) {
+ let filechannel = aChannel.QueryInterface(Ci.nsIFileChannel);
+ this.#lastModified = filechannel.file.lastModifiedTime;
+ aRespFunc();
+ return true;
+ }
+}
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;
+ }
+}
diff --git a/comm/calendar/providers/ics/components.conf b/comm/calendar/providers/ics/components.conf
new file mode 100644
index 0000000000..fd05b7f7f6
--- /dev/null
+++ b/comm/calendar/providers/ics/components.conf
@@ -0,0 +1,14 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/
+
+Classes = [
+ {
+ 'cid': '{f8438bff-a3c9-4ed5-b23f-2663b5469abf}',
+ 'contract_ids': ['@mozilla.org/calendar/calendar;1?type=ics'],
+ 'esModule': 'resource:///modules/CalICSCalendar.sys.mjs',
+ 'constructor': 'CalICSCalendar',
+ },
+]
diff --git a/comm/calendar/providers/ics/moz.build b/comm/calendar/providers/ics/moz.build
new file mode 100644
index 0000000000..6ec4226df7
--- /dev/null
+++ b/comm/calendar/providers/ics/moz.build
@@ -0,0 +1,16 @@
+# vim: set filetype=python:
+# 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/.
+
+EXTRA_JS_MODULES += [
+ "CalICSCalendar.sys.mjs",
+ "CalICSProvider.jsm",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+with Files("**"):
+ BUG_COMPONENT = ("Calendar", "Provider: ICS/WebDAV")