diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /comm/calendar/providers/ics | |
parent | Initial commit. (diff) | |
download | thunderbird-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.mjs | 1235 | ||||
-rw-r--r-- | comm/calendar/providers/ics/CalICSProvider.jsm | 447 | ||||
-rw-r--r-- | comm/calendar/providers/ics/components.conf | 14 | ||||
-rw-r--r-- | comm/calendar/providers/ics/moz.build | 16 |
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") |