diff options
Diffstat (limited to '')
-rw-r--r-- | comm/calendar/providers/caldav/CalDavCalendar.jsm | 2464 | ||||
-rw-r--r-- | comm/calendar/providers/caldav/CalDavProvider.jsm | 426 | ||||
-rw-r--r-- | comm/calendar/providers/caldav/components.conf | 14 | ||||
-rw-r--r-- | comm/calendar/providers/caldav/modules/CalDavRequest.jsm | 1211 | ||||
-rw-r--r-- | comm/calendar/providers/caldav/modules/CalDavRequestHandlers.jsm | 1091 | ||||
-rw-r--r-- | comm/calendar/providers/caldav/modules/CalDavSession.jsm | 573 | ||||
-rw-r--r-- | comm/calendar/providers/caldav/modules/CalDavUtils.jsm | 110 | ||||
-rw-r--r-- | comm/calendar/providers/caldav/moz.build | 25 | ||||
-rw-r--r-- | comm/calendar/providers/caldav/public/calICalDavCalendar.idl | 20 | ||||
-rw-r--r-- | comm/calendar/providers/caldav/public/moz.build | 10 |
10 files changed, 5944 insertions, 0 deletions
diff --git a/comm/calendar/providers/caldav/CalDavCalendar.jsm b/comm/calendar/providers/caldav/CalDavCalendar.jsm new file mode 100644 index 0000000000..a2bf7f0467 --- /dev/null +++ b/comm/calendar/providers/caldav/CalDavCalendar.jsm @@ -0,0 +1,2464 @@ +/* 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 = ["CalDavCalendar"]; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +var { + CalDavGenericRequest, + CalDavLegacySAXRequest, + CalDavItemRequest, + CalDavDeleteItemRequest, + CalDavPropfindRequest, + CalDavHeaderRequest, + CalDavPrincipalPropertySearchRequest, + CalDavOutboxRequest, + CalDavFreeBusyRequest, +} = ChromeUtils.import("resource:///modules/caldav/CalDavRequest.jsm"); + +var { CalDavEtagsHandler, CalDavWebDavSyncHandler, CalDavMultigetSyncHandler } = ChromeUtils.import( + "resource:///modules/caldav/CalDavRequestHandlers.jsm" +); + +var { CalDavSession } = ChromeUtils.import("resource:///modules/caldav/CalDavSession.jsm"); +var { CalReadableStreamFactory } = ChromeUtils.import( + "resource:///modules/CalReadableStreamFactory.jsm" +); + +var XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>\n'; +var MIME_TEXT_XML = "text/xml; charset=utf-8"; + +var cIOL = Ci.calIOperationListener; + +function CalDavCalendar() { + this.initProviderBase(); + this.unmappedProperties = []; + this.mUriParams = null; + this.mItemInfoCache = {}; + this.mDisabledByDavError = false; + this.mCalHomeSet = null; + this.mInboxUrl = null; + this.mOutboxUrl = null; + this.mCalendarUserAddress = null; + this.mCheckedServerInfo = null; + this.mPrincipalUrl = null; + this.mSenderAddress = null; + this.mHrefIndex = {}; + this.mAuthScheme = null; + this.mAuthRealm = null; + this.mObserver = null; + this.mFirstRefreshDone = false; + this.mOfflineStorage = null; + this.mQueuedQueries = []; + this.mCtag = null; + this.mProposedCtag = null; + + // By default, support both events and todos. + this.mGenerallySupportedItemTypes = ["VEVENT", "VTODO"]; + this.mSupportedItemTypes = this.mGenerallySupportedItemTypes.slice(0); + this.mACLProperties = {}; +} + +// used for etag checking +var CALDAV_MODIFY_ITEM = "modify"; +var CALDAV_DELETE_ITEM = "delete"; + +var calDavCalendarClassID = Components.ID("{a35fc6ea-3d92-11d9-89f9-00045ace3b8d}"); +var calDavCalendarInterfaces = [ + "calICalDavCalendar", + "calICalendar", + "calIChangeLog", + "calIFreeBusyProvider", + "calIItipTransport", + "calISchedulingSupport", + "nsIInterfaceRequestor", +]; +CalDavCalendar.prototype = { + __proto__: cal.provider.BaseClass.prototype, + classID: calDavCalendarClassID, + QueryInterface: cal.generateQI(calDavCalendarInterfaces), + classInfo: cal.generateCI({ + classID: calDavCalendarClassID, + contractID: "@mozilla.org/calendar/calendar;1?type=caldav", + classDescription: "Calendar CalDAV back-end", + interfaces: calDavCalendarInterfaces, + }), + + // An array of components that are supported by the server. The default is + // to support VEVENT and VTODO, if queries for these components return a 4xx + // error, then they will be removed from this array. + mGenerallySupportedItemTypes: null, + mSupportedItemTypes: null, + suportedItemTypes: null, + get supportedItemTypes() { + return this.mSupportedItemTypes; + }, + + get isCached() { + return this != this.superCalendar; + }, + + mLastRedirectStatus: null, + + ensureTargetCalendar() { + if (!this.isCached && !this.mOfflineStorage) { + // If this is a cached calendar, the actual cache is taken care of + // by the calCachedCalendar facade. In any other case, we use a + // memory calendar to cache things. + this.mOfflineStorage = Cc["@mozilla.org/calendar/calendar;1?type=memory"].createInstance( + Ci.calISyncWriteCalendar + ); + + this.mOfflineStorage.superCalendar = this; + this.mObserver = new calDavObserver(this); + this.mOfflineStorage.addObserver(this.mObserver); + this.mOfflineStorage.setProperty("relaxedMode", true); + } + }, + + get id() { + return this.mID; + }, + set id(val) { + let setter = this.__proto__.__proto__.__lookupSetter__("id"); + val = setter.call(this, val); + + if (this.id) { + // Recreate the session ID that was used when discovering this calendar, + // as the password is stored with it. This only matters for OAuth + // calendars, in all other cases the password is stored by username. + this.session = new CalDavSession( + this.getProperty("username") || this.getProperty("sessionId") || this.id, + this.name + ); + } + }, + + // calIChangeLog interface + get offlineStorage() { + return this.mOfflineStorage; + }, + + set offlineStorage(storage) { + this.mOfflineStorage = storage; + this.fetchCachedMetaData(); + }, + + resetLog() { + if (this.isCached && this.mOfflineStorage) { + this.mOfflineStorage.startBatch(); + try { + for (let itemId in this.mItemInfoCache) { + this.mOfflineStorage.deleteMetaData(itemId); + delete this.mItemInfoCache[itemId]; + } + } finally { + this.mOfflineStorage.endBatch(); + } + } + }, + + get offlineCachedProperties() { + return [ + "mAuthScheme", + "mAuthRealm", + "mHasWebdavSyncSupport", + "mCtag", + "mWebdavSyncToken", + "mSupportedItemTypes", + "mPrincipalUrl", + "mCalHomeSet", + "mShouldPollInbox", + "mHasAutoScheduling", + "mHaveScheduling", + "mCalendarUserAddress", + "mOutboxUrl", + "hasFreeBusy", + ]; + }, + + get checkedServerInfo() { + if (Services.io.offline) { + return true; + } + return this.mCheckedServerInfo; + }, + + set checkedServerInfo(val) { + this.mCheckedServerInfo = val; + }, + + saveCalendarProperties() { + let properties = {}; + for (let property of this.offlineCachedProperties) { + if (this[property] !== undefined) { + properties[property] = this[property]; + } + } + this.mOfflineStorage.setMetaData("calendar-properties", JSON.stringify(properties)); + }, + restoreCalendarProperties(data) { + let properties = JSON.parse(data); + for (let property of this.offlineCachedProperties) { + if (properties[property] !== undefined) { + this[property] = properties[property]; + } + } + // migration code from bug 1299610 + if ("hasAutoScheduling" in properties && properties.hasAutoScheduling !== undefined) { + this.mHasAutoScheduling = properties.hasAutoScheduling; + } + }, + + // in calIGenericOperationListener aListener + replayChangesOn(aChangeLogListener) { + if (this.checkedServerInfo) { + this.safeRefresh(aChangeLogListener); + } else { + // If we haven't refreshed yet, then we should check the resource + // type first. This will call refresh() again afterwards. + this.checkDavResourceType(aChangeLogListener); + } + }, + setMetaData(id, path, etag, isInboxItem) { + if (this.mOfflineStorage.setMetaData) { + if (id) { + let dataString = [etag, path, isInboxItem ? "true" : "false"].join("\u001A"); + this.mOfflineStorage.setMetaData(id, dataString); + } else { + cal.LOG("CalDAV: cannot store meta data without an id"); + } + } else { + cal.ERROR("CalDAV: calendar storage does not support meta data"); + } + }, + + /** + * Ensure that cached items have associated meta data, otherwise server side + * changes may not be reflected + */ + async ensureMetaData() { + let refreshNeeded = false; + + for await (let items of cal.iterate.streamValues( + this.mOfflineStorage.wrappedJSObject.getItems( + Ci.calICalendar.ITEM_FILTER_ALL_ITEMS, + 0, + null, + null + ) + )) { + for (let item of items) { + if (!(item.id in this.mItemInfoCache)) { + let path = this.getItemLocationPath(item); + cal.LOG("Adding meta-data for cached item " + item.id); + this.mItemInfoCache[item.id] = { + etag: null, + isNew: false, + locationPath: path, + isInboxItem: false, + }; + this.mHrefIndex[this.mLocationPath + path] = item.id; + refreshNeeded = true; + } + } + } + + if (refreshNeeded) { + // resetting the cached ctag forces an item refresh when + // safeRefresh is called later + this.mCtag = null; + this.mProposedCtag = null; + } + }, + + fetchCachedMetaData() { + cal.LOG("CalDAV: Retrieving server info from cache for " + this.name); + let cacheIds = this.mOfflineStorage.getAllMetaDataIds(); + let cacheValues = this.mOfflineStorage.getAllMetaDataValues(); + + for (let count = 0; count < cacheIds.length; count++) { + let itemId = cacheIds[count]; + let itemData = cacheValues[count]; + if (itemId == "ctag") { + this.mCtag = itemData; + this.mProposedCtag = null; + this.mOfflineStorage.deleteMetaData("ctag"); + } else if (itemId == "webdav-sync-token") { + this.mWebdavSyncToken = itemData; + this.mOfflineStorage.deleteMetaData("sync-token"); + } else if (itemId == "calendar-properties") { + this.restoreCalendarProperties(itemData); + this.setProperty("currentStatus", Cr.NS_OK); + if (this.mHaveScheduling || this.hasAutoScheduling || this.hasFreeBusy) { + cal.freeBusyService.addProvider(this); + } + } else { + let itemDataArray = itemData.split("\u001A"); + let etag = itemDataArray[0]; + let resourcePath = itemDataArray[1]; + let isInboxItem = itemDataArray[2]; + if (itemDataArray.length == 3) { + this.mHrefIndex[resourcePath] = itemId; + let locationPath = resourcePath.substr(this.mLocationPath.length); + let item = { + etag, + isNew: false, + locationPath, + isInboxItem: isInboxItem == "true", + }; + this.mItemInfoCache[itemId] = item; + } + } + } + + this.ensureMetaData(); + }, + + // + // calICalendar interface + // + + // readonly attribute AUTF8String type; + get type() { + return "caldav"; + }, + + mDisabledByDavError: true, + + mCalendarUserAddress: null, + get calendarUserAddress() { + return this.mCalendarUserAddress; + }, + + mPrincipalUrl: null, + get principalUrl() { + return this.mPrincipalUrl; + }, + + get canRefresh() { + // A cached calendar doesn't need to be refreshed. + return !this.isCached; + }, + + // mUriParams stores trailing ?parameters from the + // supplied calendar URI. Needed for (at least) Cosmo + // tickets + mUriParams: null, + + get uri() { + return this.mUri; + }, + + set uri(aUri) { + this.mUri = aUri; + }, + + get calendarUri() { + let calSpec = this.mUri.spec; + let parts = calSpec.split("?"); + if (parts.length > 1) { + calSpec = parts.shift(); + this.mUriParams = "?" + parts.join("?"); + } + if (!calSpec.endsWith("/")) { + calSpec += "/"; + } + return Services.io.newURI(calSpec); + }, + + setCalHomeSet(removeLastPathSegment) { + if (removeLastPathSegment) { + let split1 = this.mUri.spec.split("?"); + let baseUrl = split1[0]; + if (baseUrl.charAt(baseUrl.length - 1) == "/") { + baseUrl = baseUrl.substring(0, baseUrl.length - 2); + } + let split2 = baseUrl.split("/"); + split2.pop(); + this.mCalHomeSet = Services.io.newURI(split2.join("/") + "/"); + } else { + this.mCalHomeSet = this.calendarUri; + } + }, + + mOutboxUrl: null, + get outboxUrl() { + return this.mOutboxUrl; + }, + + mInboxUrl: null, + get inboxUrl() { + return this.mInboxUrl; + }, + + mHaveScheduling: false, + mShouldPollInbox: true, + get hasScheduling() { + // Whether to use inbox/outbox scheduling + return this.mHaveScheduling; + }, + set hasScheduling(value) { + this.mHaveScheduling = + Services.prefs.getBoolPref("calendar.caldav.sched.enabled", false) && value; + }, + mHasAutoScheduling: false, // Whether server automatically takes care of scheduling + get hasAutoScheduling() { + return this.mHasAutoScheduling; + }, + + hasFreebusy: false, + + mAuthScheme: null, + + mAuthRealm: null, + + mFirstRefreshDone: false, + + mQueuedQueries: null, + + mCtag: null, + mProposedCtag: null, + + mOfflineStorage: null, + // Contains the last valid synctoken returned + // from the server with Webdav Sync enabled servers + mWebdavSyncToken: null, + // Indicates that the server supports Webdav Sync + // see: http://tools.ietf.org/html/draft-daboo-webdav-sync + mHasWebdavSyncSupport: false, + + get authRealm() { + return this.mAuthRealm; + }, + + /** + * Builds a correctly encoded nsIURI based on the baseUri and the insert + * string. The returned uri is basically the baseURI + aInsertString + * + * @param {string} aInsertString - String to append to the base uri, for example, + * when creating an event this would be the + * event file name (event.ics). If null, an empty + * string is used. + * @param {nsIURI} aBaseUri - Base uri, if null, this.calendarUri will be used. + */ + makeUri(aInsertString, aBaseUri) { + let baseUri = aBaseUri || this.calendarUri; + // Build a string containing the full path, decoded, so it looks like + // this: + // /some path/insert string.ics + let decodedPath = this.ensureDecodedPath(baseUri.pathQueryRef + (aInsertString || "")); + + // Build the nsIURI by specifying a string with a fully encoded path + // the end result will be something like this: + // http://caldav.example.com:8080/some%20path/insert%20string.ics + return Services.io.newURI( + baseUri.prePath + this.ensureEncodedPath(decodedPath) + (this.mUriParams || "") + ); + }, + + get mLocationPath() { + return this.ensureDecodedPath(this.calendarUri.pathQueryRef); + }, + + getItemLocationPath(aItem) { + if (aItem.id && aItem.id in this.mItemInfoCache && this.mItemInfoCache[aItem.id].locationPath) { + // modifying items use the cached location path + return this.mItemInfoCache[aItem.id].locationPath; + } + // New items just use id.ics + return aItem.id + ".ics"; + }, + + getProperty(aName) { + if (aName in this.mACLProperties && this.mACLProperties[aName]) { + return this.mACLProperties[aName]; + } + + switch (aName) { + case "organizerId": + if (this.calendarUserAddress) { + return this.calendarUserAddress; + } // else use configured email identity + break; + case "organizerCN": + return null; // xxx todo + case "itip.transport": + if (this.hasAutoScheduling || this.hasScheduling) { + return this.QueryInterface(Ci.calIItipTransport); + } // else use outbound email-based iTIP (from cal.provider.BaseClass) + break; + case "capabilities.tasks.supported": + return this.supportedItemTypes.includes("VTODO"); + case "capabilities.events.supported": + return this.supportedItemTypes.includes("VEVENT"); + case "capabilities.autoschedule.supported": + return this.hasAutoScheduling; + case "capabilities.username.supported": + return true; + } + return this.__proto__.__proto__.getProperty.apply(this, arguments); + }, + + promptOverwrite(aMethod, aItem, aListener, aOldItem) { + let overwrite = cal.provider.promptOverwrite(aMethod, aItem, aListener, aOldItem); + if (overwrite) { + if (aMethod == CALDAV_MODIFY_ITEM) { + this.doModifyItem(aItem, aOldItem, aListener, true); + } else { + this.doDeleteItem(aItem, aListener, true, false, null); + } + } else { + this.getUpdatedItem(aItem, aListener); + } + }, + + mItemInfoCache: null, + + mHrefIndex: null, + + get supportsScheduling() { + return true; + }, + + getSchedulingSupport() { + return this; + }, + + /** + * addItem() + * we actually use doAdoptItem() + * + * @param aItem item to add + */ + async addItem(aItem) { + return this.adoptItem(aItem); + }, + + // Used to allow the cachedCalendar provider to hook into adoptItem() before + // it returns. + _cachedAdoptItemCallback: null, + + /** + * adoptItem() + * we actually use doAdoptItem() + * + * @param aItem item to check + */ + async adoptItem(aItem) { + let adoptCallback = this._cachedAdoptItemCallback; + return new Promise((resolve, reject) => { + this.doAdoptItem(aItem.clone(), { + get wrappedJSObject() { + return this; + }, + async onOperationComplete(calendar, status, opType, id, detail) { + if (adoptCallback) { + await adoptCallback(calendar, status, opType, id, detail); + } + return Components.isSuccessCode(status) ? resolve(detail) : reject(detail); + }, + }); + }); + }, + + /** + * Performs the actual addition of the item to CalDAV store + * + * @param aItem item to add + * @param aListener listener for method completion + * @param aIgnoreEtag flag to indicate ignoring of Etag + */ + doAdoptItem(aItem, aListener, aIgnoreEtag) { + let notifyListener = (status, detail, pure = false) => { + let method = pure ? "notifyPureOperationComplete" : "notifyOperationComplete"; + this[method](aListener, status, cIOL.ADD, aItem.id, detail); + }; + if (aItem.id == null && aItem.isMutable) { + aItem.id = cal.getUUID(); + } + + if (aItem.id == null) { + notifyListener(Cr.NS_ERROR_FAILURE, "Can't set ID on non-mutable item to addItem"); + return; + } + + if (!cal.item.isItemSupported(aItem, this)) { + notifyListener(Cr.NS_ERROR_FAILURE, "Server does not support item type"); + return; + } + + let parentItem = aItem.parentItem; + parentItem.calendar = this.superCalendar; + + let locationPath = this.getItemLocationPath(parentItem); + let itemUri = this.makeUri(locationPath); + cal.LOG("CalDAV: itemUri.spec = " + itemUri.spec); + + let serializedItem = this.getSerializedItem(aItem); + + let sendEtag = aIgnoreEtag ? null : "*"; + let request = new CalDavItemRequest(this.session, this, itemUri, aItem, sendEtag); + + request.commit().then( + response => { + let status = Cr.NS_OK; + let detail = parentItem; + + // Translate the HTTP status code to a status and message for the listener + if (response.ok) { + cal.LOG(`CalDAV: Item added to ${this.name} successfully`); + + let uriComponentParts = this.makeUri() + .pathQueryRef.replace(/\/{2,}/g, "/") + .split("/").length; + let targetParts = response.uri.pathQueryRef.split("/"); + targetParts.splice(0, uriComponentParts - 1); + + this.mItemInfoCache[parentItem.id] = { locationPath: targetParts.join("/") }; + // TODO: onOpComplete adds the item to the cache, probably after getUpdatedItem! + + // Some CalDAV servers will modify items on PUT (add X-props, + // for instance) so we'd best re-fetch in order to know + // the current state of the item + // Observers will be notified in getUpdatedItem() + this.getUpdatedItem(parentItem, aListener); + return; + } else if (response.serverError) { + status = Cr.NS_ERROR_NOT_AVAILABLE; + detail = "Server Replied with " + response.status; + } else if (response.status) { + // There is a response status, but we haven't handled it yet. Any + // error occurring here should consider being handled! + cal.ERROR( + "CalDAV: Unexpected status adding item to " + + this.name + + ": " + + response.status + + "\n" + + serializedItem + ); + + status = Cr.NS_ERROR_FAILURE; + detail = "Server Replied with " + response.status; + } + + // Still need to visually notify for uncached calendars. + if (!this.isCached && !Components.isSuccessCode(status)) { + this.reportDavError(Ci.calIErrors.DAV_PUT_ERROR, status, detail); + } + + // Finally, notify listener. + notifyListener(status, detail, true); + }, + e => { + notifyListener(Cr.NS_ERROR_NOT_AVAILABLE, "Error preparing http channel: " + e); + } + ); + }, + + // Used to allow the cachedCalendar provider to hook into modifyItem() before + // it returns. + _cachedModifyItemCallback: null, + + /** + * modifyItem(); required by calICalendar.idl + * we actually use doModifyItem() + * + * @param aItem item to check + */ + async modifyItem(aNewItem, aOldItem) { + let modifyCallback = this._cachedModifyItemCallback; + return new Promise((resolve, reject) => { + this.doModifyItem( + aNewItem, + aOldItem, + { + get wrappedJSObject() { + return this; + }, + async onOperationComplete(calendar, status, opType, id, detail) { + if (modifyCallback) { + await modifyCallback(calendar, status, opType, id, detail); + } + return Components.isSuccessCode(status) ? resolve(detail) : reject(detail); + }, + }, + false + ); + }); + }, + + /** + * Modifies existing item in CalDAV store. + * + * @param aItem item to check + * @param aOldItem previous version of item to be modified + * @param aListener listener from original request + * @param aIgnoreEtag ignore item etag + */ + doModifyItem(aNewItem, aOldItem, aListener, aIgnoreEtag) { + let notifyListener = (status, detail, pure = false) => { + let method = pure ? "notifyPureOperationComplete" : "notifyOperationComplete"; + this[method](aListener, status, cIOL.MODIFY, aNewItem.id, detail); + }; + if (aNewItem.id == null) { + notifyListener(Cr.NS_ERROR_FAILURE, "ID for modifyItem doesn't exist or is null"); + return; + } + + let wasInboxItem = this.mItemInfoCache[aNewItem.id].isInboxItem; + + let newItem_ = aNewItem; + aNewItem = aNewItem.parentItem.clone(); + if (newItem_.parentItem != newItem_) { + aNewItem.recurrenceInfo.modifyException(newItem_, false); + } + aNewItem.generation += 1; + + let eventUri = this.makeUri(this.mItemInfoCache[aNewItem.id].locationPath); + let modifiedItemICS = this.getSerializedItem(aNewItem); + + let sendEtag = aIgnoreEtag ? null : this.mItemInfoCache[aNewItem.id].etag; + let request = new CalDavItemRequest(this.session, this, eventUri, aNewItem, sendEtag); + + request.commit().then( + response => { + let status = Cr.NS_OK; + let detail = aNewItem; + + let shouldNotify = true; + if (response.ok) { + cal.LOG("CalDAV: Item modified successfully on " + this.name); + + // Some CalDAV servers will modify items on PUT (add X-props, for instance) so we'd + // best re-fetch in order to know the current state of the item Observers will be + // notified in getUpdatedItem() + this.getUpdatedItem(aNewItem, aListener); + + // SOGo has calendarUri == inboxUri so we need to be careful about deletions + if (wasInboxItem && this.mShouldPollInbox) { + this.doDeleteItem(aNewItem, null, true, true, null); + } + shouldNotify = false; + } else if (response.conflict) { + // promptOverwrite will ask the user and then re-request + this.promptOverwrite(CALDAV_MODIFY_ITEM, aNewItem, aListener, aOldItem); + shouldNotify = false; + } else if (response.serverError) { + status = Cr.NS_ERROR_NOT_AVAILABLE; + detail = "Server Replied with " + response.status; + } else if (response.status) { + // There is a response status, but we haven't handled it yet. Any error occurring + // here should consider being handled! + cal.ERROR( + "CalDAV: Unexpected status modifying item to " + + this.name + + ": " + + response.status + + "\n" + + modifiedItemICS + ); + + status = Cr.NS_ERROR_FAILURE; + detail = "Server Replied with " + response.status; + } + + if (shouldNotify) { + // Still need to visually notify for uncached calendars. + if (!this.isCached && !Components.isSuccessCode(status)) { + this.reportDavError(Ci.calIErrors.DAV_PUT_ERROR, status, detail); + } + + notifyListener(status, detail, true); + } + }, + () => { + notifyListener(Cr.NS_ERROR_NOT_AVAILABLE, "Error preparing http channel"); + } + ); + }, + + /** + * deleteItem(); required by calICalendar.idl + * the actual deletion is done in doDeleteItem() + * + * @param {calIItemBase} item The item to delete + * + * @returns {Promise<void>} + */ + async deleteItem(item) { + return this.doDeleteItem(item, false, null, null); + }, + + /** + * Deletes item from CalDAV store. + * + * @param {calIItemBase} item Item to delete. + * @param {boolean} ignoreEtag Ignore item etag. + * @param {boolean} fromInbox Delete from inbox rather than calendar. + * @param {string} uri Uri of item to delete. + * + * @returns {Promise<void>} + */ + async doDeleteItem(item, ignoreEtag, fromInbox, uri) { + let onError = async (status, detail) => { + // Still need to visually notify for uncached calendars. + if (!this.isCached) { + this.reportDavError(Ci.calIErrors.DAV_REMOVE_ERROR, status, detail); + } + this.notifyOperationComplete(null, status, cIOL.DELETE, null, detail); + return Promise.reject(new Components.Exception(detail, status)); + }; + + if (item.id == null) { + return onError(Cr.NS_ERROR_FAILURE, "ID doesn't exist for deleteItem"); + } + + let eventUri; + if (uri) { + eventUri = uri; + } else if (fromInbox || this.mItemInfoCache[item.id].isInboxItem) { + eventUri = this.makeUri(this.mItemInfoCache[item.id].locationPath, this.mInboxUrl); + } else { + eventUri = this.makeUri(this.mItemInfoCache[item.id].locationPath); + } + + if (eventUri.pathQueryRef == this.calendarUri.pathQueryRef) { + return onError( + Cr.NS_ERROR_FAILURE, + "eventUri and calendarUri paths are the same, will not go on to delete entire calendar" + ); + } + + if (this.verboseLogging()) { + cal.LOG("CalDAV: Deleting " + eventUri.spec); + } + + let sendEtag = ignoreEtag ? null : this.mItemInfoCache[item.id].etag; + let request = new CalDavDeleteItemRequest(this.session, this, eventUri, sendEtag); + + let response; + try { + response = await request.commit(); + } catch (e) { + return onError(Cr.NS_ERROR_NOT_AVAILABLE, "Error preparing http channel"); + } + + if (response.ok) { + if (!fromInbox) { + let decodedPath = this.ensureDecodedPath(eventUri.pathQueryRef); + delete this.mHrefIndex[decodedPath]; + delete this.mItemInfoCache[item.id]; + cal.LOG("CalDAV: Item deleted successfully from calendar " + this.name); + + if (this.isCached) { + this.notifyOperationComplete(null, Cr.NS_OK, cIOL.DELETE, null, null); + return null; + } + // If the calendar is not cached, we need to remove + // the item from our memory calendar now. The + // listeners will be notified there. + return this.mOfflineStorage.deleteItem(item); + } + return null; + } else if (response.conflict) { + // item has either been modified or deleted by someone else check to see which + cal.LOG("CalDAV: Item has been modified on server, checking if it has been deleted"); + let headRequest = new CalDavGenericRequest(this.session, this, "HEAD", eventUri); + let headResponse = await headRequest.commit(); + + if (headResponse.notFound) { + // Nothing to do. Someone else has already deleted it + this.notifyPureOperationComplete(null, Cr.NS_OK, cIOL.DELETE, null, null); + return null; + } else if (headResponse.serverError) { + return onError(Cr.NS_ERROR_NOT_AVAILABLE, "Server Replied with " + headResponse.status); + } else if (headResponse.status) { + // The item still exists. We need to ask the user if he + // really wants to delete the item. Remember, we only + // made this request since the actual delete gave 409/412 + let item = await this.getItem(item.id); + return cal.provider.promptOverwrite(CALDAV_DELETE_ITEM, item) + ? this.doDeleteItem(item, true, false, null) + : null; + } + } else if (response.serverError) { + return onError(Cr.NS_ERROR_NOT_AVAILABLE, "Server Replied with " + response.status); + } else if (response.status) { + cal.ERROR( + "CalDAV: Unexpected status deleting item from " + + this.name + + ": " + + response.status + + "\n" + + "uri: " + + eventUri.spec + ); + } + return onError(Cr.NS_ERROR_FAILURE, "Server Replied with status " + response.status); + }, + + /** + * Add an item to the target calendar + * + * @param path Item path MUST NOT BE ENCODED + * @param calData iCalendar string representation of the item + * @param aUri Base URI of the request + * @param aListener Listener + */ + async addTargetCalendarItem(path, calData, aUri, etag, aListener) { + let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser); + // aUri.pathQueryRef may contain double slashes whereas path does not + // this confuses our counting, so remove multiple successive slashes + let strippedUriPath = aUri.pathQueryRef.replace(/\/{2,}/g, "/"); + let uriPathComponentLength = strippedUriPath.split("/").length; + try { + parser.parseString(calData); + } catch (e) { + // Warn and continue. + // TODO As soon as we have activity manager integration, + // this should be replace with logic to notify that a + // certain event failed. + cal.WARN("Failed to parse item: " + calData + "\n\nException:" + e); + return; + } + // with CalDAV there really should only be one item here + let items = parser.getItems(); + let propertiesList = parser.getProperties(); + let method; + for (let prop of propertiesList) { + if (prop.propertyName == "METHOD") { + method = prop.value; + break; + } + } + let isReply = method == "REPLY"; + let item = items[0]; + + if (!item) { + cal.WARN("Failed to parse item: " + calData); + return; + } + + item.calendar = this.superCalendar; + if (isReply && this.isInbox(aUri.spec)) { + if (this.hasScheduling) { + this.processItipReply(item, path); + } + cal.WARN("REPLY method but calendar does not support scheduling"); + return; + } + + // Strip of the same number of components as the request + // uri's path has. This way we make sure to handle servers + // that pass paths like /dav/user/Calendar while + // the request uri is like /dav/user@example.org/Calendar. + let resPathComponents = path.split("/"); + resPathComponents.splice(0, uriPathComponentLength - 1); + let locationPath = resPathComponents.join("/"); + let isInboxItem = this.isInbox(aUri.spec); + + if (this.mHrefIndex[path] && !this.mItemInfoCache[item.id]) { + // If we get here it means a meeting has kept the same filename + // but changed its uid, which can happen server side. + // Delete the meeting before re-adding it + this.deleteTargetCalendarItem(path); + } + + if (this.mItemInfoCache[item.id]) { + this.mItemInfoCache[item.id].isNew = false; + } else { + this.mItemInfoCache[item.id] = { isNew: true }; + } + this.mItemInfoCache[item.id].locationPath = locationPath; + this.mItemInfoCache[item.id].isInboxItem = isInboxItem; + + this.mHrefIndex[path] = item.id; + this.mItemInfoCache[item.id].etag = etag; + + if (this.isCached) { + this.setMetaData(item.id, path, etag, isInboxItem); + + // If we have a listener, then the caller will take care of adding the item + // Otherwise, we have to do it ourself + // XXX This is quite fragile, but saves us a double modify/add + + if (aListener) { + await new Promise(resolve => { + let wrappedListener = { + onGetResult(...args) { + aListener.onGetResult(...args); + }, + onOperationComplete(...args) { + // We must use wrappedJSObject to receive a returned Promise. + let promise = aListener.wrappedJSObject.onOperationComplete(...args); + if (promise) { + promise.then(resolve); + } else { + resolve(); + } + }, + }; + + // In the cached case, notifying operation complete will add the item to the cache + if (this.mItemInfoCache[item.id].isNew) { + this.notifyOperationComplete(wrappedListener, Cr.NS_OK, cIOL.ADD, item.id, item); + } else { + this.notifyOperationComplete(wrappedListener, Cr.NS_OK, cIOL.MODIFY, item.id, item); + } + }); + return; + } + } + + // Either there's no listener, or we're uncached. + + if (this.mItemInfoCache[item.id].isNew) { + await this.mOfflineStorage.adoptItem(item).then( + () => aListener?.onOperationComplete(item.calendar, Cr.NS_OK, cIOL.ADD, item.id, item), + e => aListener?.onOperationComplete(null, e.result, null, null, e) + ); + } else { + await this.mOfflineStorage.modifyItem(item, null).then( + item => aListener?.onOperationComplete(item.calendar, Cr.NS_OK, cIOL.MODIFY, item.id, item), + e => aListener?.onOperationComplete(null, e.result, null, null, e) + ); + } + }, + + /** + * Deletes an item from the target calendar + * + * @param path Path of the item to delete, must not be encoded + */ + async deleteTargetCalendarItem(path) { + let foundItem = await this.mOfflineStorage.getItem(this.mHrefIndex[path]); + let wasInboxItem = this.mItemInfoCache[foundItem.id].isInboxItem; + if ((wasInboxItem && this.isInbox(path)) || (wasInboxItem === false && !this.isInbox(path))) { + cal.LOG("CalDAV: deleting item: " + path + ", uid: " + foundItem.id); + delete this.mHrefIndex[path]; + delete this.mItemInfoCache[foundItem.id]; + if (this.isCached) { + this.mOfflineStorage.deleteMetaData(foundItem.id); + } + await this.mOfflineStorage.deleteItem(foundItem); + } + }, + + /** + * Perform tasks required after updating items in the calendar such as + * notifying the observers and listeners + * + * @param aChangeLogListener Change log listener + * @param calendarURI URI of the calendar whose items just got + * changed + */ + finalizeUpdatedItems(aChangeLogListener, calendarURI) { + cal.LOG( + "aChangeLogListener=" + + aChangeLogListener + + "\n" + + "calendarURI=" + + (calendarURI ? calendarURI.spec : "undefined") + + " \n" + + "iscached=" + + this.isCached + + "\n" + + "this.mQueuedQueries.length=" + + this.mQueuedQueries.length + ); + if (this.isCached && aChangeLogListener) { + aChangeLogListener.onResult({ status: Cr.NS_OK }, Cr.NS_OK); + } else { + this.mObservers.notify("onLoad", [this]); + } + + if (this.mProposedCtag) { + this.mCtag = this.mProposedCtag; + this.mProposedCtag = null; + } + + this.mFirstRefreshDone = true; + while (this.mQueuedQueries.length) { + let query = this.mQueuedQueries.pop(); + let { filter, count, rangeStart, rangeEnd } = query; + query.onStream(this.mOfflineStorage.getItems(filter, count, rangeStart, rangeEnd)); + } + if (this.hasScheduling && !this.isInbox(calendarURI.spec)) { + this.pollInbox(); + } + }, + + /** + * Notifies the caller that a get request has failed. + * + * @param errorMsg Error message + * @param aListener (optional) Listener of the request + * @param aChangeLogListener (optional)Listener for cached calendars + */ + notifyGetFailed(errorMsg, aListener, aChangeLogListener) { + cal.WARN("CalDAV: Get failed: " + errorMsg); + + // Notify changelog listener + if (this.isCached && aChangeLogListener) { + aChangeLogListener.onResult({ status: Cr.NS_ERROR_FAILURE }, Cr.NS_ERROR_FAILURE); + } + + // Notify operation listener + this.notifyOperationComplete(aListener, Cr.NS_ERROR_FAILURE, cIOL.GET, null, errorMsg); + // If an error occurs here, we also need to unqueue the + // requests previously queued. + while (this.mQueuedQueries.length) { + this.mQueuedQueries.pop().onError(new Components.Exception(errorMsg, Cr.NS_ERROR_FAILURE)); + } + }, + + /** + * Retrieves a specific item from the CalDAV store. + * Use when an outdated copy of the item is in hand. + * + * @param aItem item to fetch + * @param aListener listener for method completion + */ + getUpdatedItem(aItem, aListener, aChangeLogListener) { + if (aItem == null) { + this.notifyOperationComplete( + aListener, + Cr.NS_ERROR_FAILURE, + cIOL.GET, + null, + "passed in null item" + ); + return; + } + + let locationPath = this.getItemLocationPath(aItem); + let itemUri = this.makeUri(locationPath); + + let multiget = new CalDavMultigetSyncHandler( + [this.ensureDecodedPath(itemUri.pathQueryRef)], + this, + this.makeUri(), + null, + false, + aListener, + aChangeLogListener + ); + multiget.doMultiGet(); + }, + + // Promise<calIItemBase|null> getItem(in string id); + async getItem(aId) { + return this.mOfflineStorage.getItem(aId); + }, + + // ReadableStream<calIItemBase> getItems(in unsigned long filter, + // in unsigned long count, + // in calIDateTime rangeStart, + // in calIDateTime rangeEnd) + getItems(filter, count, rangeStart, rangeEnd) { + if (this.isCached) { + if (this.mOfflineStorage) { + return this.mOfflineStorage.getItems(...arguments); + } + return CalReadableStreamFactory.createEmptyReadableStream(); + } else if ( + this.checkedServerInfo || + this.getProperty("currentStatus") == Ci.calIErrors.READ_FAILED + ) { + return this.mOfflineStorage.getItems(...arguments); + } + let self = this; + return CalReadableStreamFactory.createBoundedReadableStream( + count, + CalReadableStreamFactory.defaultQueueSize, + { + async start(controller) { + return new Promise((resolve, reject) => { + self.mQueuedQueries.push({ + filter, + count, + rangeStart, + rangeEnd, + failed: false, + onError(e) { + this.failed = true; + reject(e); + }, + async onStream(stream) { + for await (let items of cal.iterate.streamValues(stream)) { + if (this.failed) { + break; + } + controller.enqueue(items); + } + if (!this.failed) { + controller.close(); + resolve(); + } + }, + }); + }); + }, + } + ); + }, + + fillACLProperties() { + let orgId = this.calendarUserAddress; + if (orgId) { + this.mACLProperties.organizerId = orgId; + } + + if (this.mACLEntry && this.mACLEntry.hasAccessControl) { + let ownerIdentities = this.mACLEntry.getOwnerIdentities(); + if (ownerIdentities.length > 0) { + let identity = ownerIdentities[0]; + this.mACLProperties.organizerId = identity.email; + this.mACLProperties.organizerCN = identity.fullName; + this.mACLProperties["imip.identity"] = identity; + } + } + }, + + safeRefresh(aChangeLogListener) { + let notifyListener = status => { + if (this.isCached && aChangeLogListener) { + aChangeLogListener.onResult({ status }, status); + } + }; + + if (!this.mACLEntry) { + let self = this; + let opListener = { + QueryInterface: ChromeUtils.generateQI(["calIOperationListener"]), + onGetResult(calendar, status, itemType, detail, items) { + cal.ASSERT(false, "unexpected!"); + }, + onOperationComplete(opCalendar, opStatus, opType, opId, opDetail) { + self.mACLEntry = opDetail; + self.fillACLProperties(); + self.safeRefresh(aChangeLogListener); + }, + }; + + this.aclManager.getCalendarEntry(this, opListener); + return; + } + + this.ensureTargetCalendar(); + + if (this.mAuthScheme == "Digest") { + // the auth could have timed out and be in need of renegotiation we can't risk several + // calendars doing this simultaneously so we'll force the renegotiation in a sync query, + // using OPTIONS to keep it quick + let headchannel = cal.provider.prepHttpChannel(this.makeUri(), null, null, this); + headchannel.requestMethod = "OPTIONS"; + headchannel.open(); + headchannel.QueryInterface(Ci.nsIHttpChannel); + try { + if (headchannel.responseStatus != 200) { + throw new Error("OPTIONS returned unexpected status code: " + headchannel.responseStatus); + } + } catch (e) { + cal.WARN("CalDAV: Exception: " + e); + notifyListener(Cr.NS_ERROR_FAILURE); + } + } + + // Call getUpdatedItems right away if its the first refresh *OR* if webdav Sync is enabled + // (It is redundant to send a request to get the collection tag (getctag) on a calendar if + // it supports webdav sync, the sync request will only return data if something changed). + if (!this.mCtag || !this.mFirstRefreshDone || this.mHasWebdavSyncSupport) { + this.getUpdatedItems(this.calendarUri, aChangeLogListener); + return; + } + let request = new CalDavPropfindRequest(this.session, this, this.makeUri(), ["CS:getctag"]); + + request.commit().then(response => { + cal.LOG(`CalDAV: Status ${response.status} checking ctag for calendar ${this.name}`); + + if (response.status == -1) { + notifyListener(Cr.NS_OK); + return; + } else if (response.notFound) { + cal.LOG(`CalDAV: Disabling calendar ${this.name} due to 404`); + notifyListener(Cr.NS_ERROR_FAILURE); + return; + } else if (response.ok && this.mDisabledByDavError) { + // Looks like the calendar is there again, check its resource + // type first. + this.checkDavResourceType(aChangeLogListener); + return; + } else if (!response.ok) { + cal.LOG("CalDAV: Failed to get ctag from server for calendar " + this.name); + notifyListener(Cr.NS_OK); + return; + } + + let ctag = response.firstProps["CS:getctag"]; + if (!ctag || ctag != this.mCtag) { + // ctag mismatch, need to fetch calendar-data + this.mProposedCtag = ctag; + this.getUpdatedItems(this.calendarUri, aChangeLogListener); + if (this.verboseLogging()) { + cal.LOG("CalDAV: ctag mismatch on refresh, fetching data for calendar " + this.name); + } + } else { + if (this.verboseLogging()) { + cal.LOG("CalDAV: ctag matches, no need to fetch data for calendar " + this.name); + } + + // Notify the listener, but don't return just yet... + notifyListener(Cr.NS_OK); + + // ...we may still need to poll the inbox + if (this.firstInRealm()) { + this.pollInbox(); + } + } + }); + }, + + refresh() { + this.replayChangesOn(null); + }, + + firstInRealm() { + let calendars = cal.manager.getCalendars(); + for (let i = 0; i < calendars.length; i++) { + if (calendars[i].type != "caldav" || calendars[i].getProperty("disabled")) { + continue; + } + // XXX We should probably expose the inner calendar via an + // interface, but for now use wrappedJSObject. + let calendar = calendars[i].wrappedJSObject; + if (calendar.mUncachedCalendar) { + calendar = calendar.mUncachedCalendar; + } + if (calendar.uri.prePath == this.uri.prePath && calendar.authRealm == this.mAuthRealm) { + if (calendar.id == this.id) { + return true; + } + break; + } + } + return false; + }, + + /** + * Get updated items + * + * @param {nsIURI} aUri - The uri to request the items from. + * NOTE: This must be the uri without any uri + * params. They will be appended in this function. + * @param aChangeLogListener - (optional) The listener to notify for cached + * calendars. + */ + getUpdatedItems(aUri, aChangeLogListener) { + if (this.mDisabledByDavError) { + // check if maybe our calendar has become available + this.checkDavResourceType(aChangeLogListener); + return; + } + + if (this.mHasWebdavSyncSupport) { + let webDavSync = new CalDavWebDavSyncHandler(this, aUri, aChangeLogListener); + webDavSync.doWebDAVSync(); + return; + } + + let queryXml = + XML_HEADER + + '<D:propfind xmlns:D="DAV:">' + + "<D:prop>" + + "<D:getcontenttype/>" + + "<D:resourcetype/>" + + "<D:getetag/>" + + "</D:prop>" + + "</D:propfind>"; + + let requestUri = this.makeUri(null, aUri); + let handler = new CalDavEtagsHandler(this, aUri, aChangeLogListener); + + let onSetupChannel = channel => { + channel.requestMethod = "PROPFIND"; + channel.setRequestHeader("Depth", "1", false); + }; + let request = new CalDavLegacySAXRequest( + this.session, + this, + requestUri, + queryXml, + MIME_TEXT_XML, + handler, + onSetupChannel + ); + + request.commit().catch(() => { + if (aChangeLogListener && this.isCached) { + aChangeLogListener.onResult( + { status: Cr.NS_ERROR_NOT_AVAILABLE }, + Cr.NS_ERROR_NOT_AVAILABLE + ); + } + }); + }, + + /** + * @see nsIInterfaceRequestor + * @see calProviderUtils.jsm + */ + getInterface: cal.provider.InterfaceRequestor_getInterface, + + // + // Helper functions + // + + oauthConnect(authSuccessCb, authFailureCb, aRefresh = false) { + // Use the async prompter to avoid multiple primary password prompts + let self = this; + let promptlistener = { + onPromptStartAsync(callback) { + this.onPromptAuthAvailable(callback); + }, + onPromptAuthAvailable(callback) { + self.oauth.connect( + () => { + authSuccessCb(); + if (callback) { + callback.onAuthResult(true); + } + }, + () => { + authFailureCb(); + if (callback) { + callback.onAuthResult(false); + } + }, + true, + aRefresh + ); + }, + onPromptCanceled: authFailureCb, + onPromptStart() {}, + }; + let asyncprompter = Cc["@mozilla.org/messenger/msgAsyncPrompter;1"].getService( + Ci.nsIMsgAsyncPrompter + ); + asyncprompter.queueAsyncAuthPrompt(self.uri.spec, false, promptlistener); + }, + + /** + * Called when a response has had its URL redirected. Shows a dialog + * to allow the user to accept or reject the redirect. If they accept, + * change the calendar's URI to the target URI of the redirect. + * + * @param {PropfindResponse} response - Response to handle. Typically a + * PropfindResponse but could be any + * subclass of CalDavResponseBase. + * @returns {boolean} True if the user accepted the redirect. + * False, if the calendar should be disabled. + */ + openUriRedirectDialog(response) { + let args = { + calendarName: this.name, + originalURI: response.nsirequest.originalURI.spec, + targetURI: response.uri.spec, + returnValue: false, + }; + + cal.window + .getCalendarWindow() + .openDialog( + "chrome://calendar/content/calendar-uri-redirect-dialog.xhtml", + "Calendar:URIRedirectDialog", + "chrome,modal,titlebar,resizable,centerscreen", + args + ); + + if (args.returnValue) { + this.uri = response.uri; + this.setProperty("uri", response.uri.spec); + } + + return args.returnValue; + }, + + /** + * Checks that the calendar URI exists and is a CalDAV calendar. This is the beginning of a + * chain of asynchronous calls. This function will, when done, call the next function related to + * checking resource type, server capabilities, etc. + * + * checkDavResourceType * You are here + * checkServerCaps + * findPrincipalNS + * checkPrincipalsNameSpace + * completeCheckServerInfo + */ + checkDavResourceType(aChangeLogListener) { + this.ensureTargetCalendar(); + + let request = new CalDavPropfindRequest(this.session, this, this.makeUri(), [ + "D:resourcetype", + "D:owner", + "D:current-user-principal", + "D:current-user-privilege-set", + "D:supported-report-set", + "C:supported-calendar-component-set", + "CS:getctag", + ]); + + request.commit().then( + response => { + cal.LOG(`CalDAV: Status ${response.status} on initial PROPFIND for calendar ${this.name}`); + + // If the URI was redirected, and the user rejects the redirect, disable the calendar. + if (response.redirected && !this.openUriRedirectDialog(response)) { + this.setProperty("disabled", "true"); + this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_ABORT); + return; + } + + if (response.clientError) { + // 4xx codes, which is either an authentication failure or something like method not + // allowed. This is a failure worth disabling the calendar. + this.setProperty("disabled", "true"); + this.setProperty("auto-enabled", "true"); + this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_ABORT); + return; + } else if (response.serverError) { + // 5xx codes, a server error. This could be a temporary failure, i.e a backend + // server being disabled. + cal.LOG( + "CalDAV: Server not available " + + request.responseStatus + + ", abort sync for calendar " + + this.name + ); + this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_ABORT); + return; + } + + let wwwauth = request.getHeader("Authorization"); + this.mAuthScheme = wwwauth ? wwwauth.split(" ")[0] : "none"; + + if (this.mUriParams) { + this.mAuthScheme = "Ticket"; + } + cal.LOG(`CalDAV: Authentication scheme for ${this.name} is ${this.mAuthScheme}`); + + // We only really need the authrealm for Digest auth since only Digest is going to time + // out on us + if (this.mAuthScheme == "Digest") { + let realmChop = wwwauth.split('realm="')[1]; + this.mAuthRealm = realmChop.split('", ')[0]; + cal.LOG("CalDAV: realm " + this.mAuthRealm); + } + + if (!response.text || response.notFound) { + // No response, or the calendar no longer exists. + cal.LOG("CalDAV: Failed to determine resource type for" + this.name); + this.completeCheckServerInfo(aChangeLogListener, Ci.calIErrors.DAV_NOT_DAV); + return; + } + + let multistatus = response.xml; + if (!multistatus) { + cal.LOG(`CalDAV: Failed to determine resource type for ${this.name}`); + this.completeCheckServerInfo(aChangeLogListener, Ci.calIErrors.DAV_NOT_DAV); + return; + } + + // check for webdav-sync capability + // http://tools.ietf.org/html/draft-daboo-webdav-sync + if (response.firstProps["D:supported-report-set"]?.has("D:sync-collection")) { + cal.LOG("CalDAV: Collection has webdav sync support"); + this.mHasWebdavSyncSupport = true; + } + + // check for server-side ctag support only if webdav sync is not available + let ctag = response.firstProps["CS:getctag"]; + if (!this.mHasWebdavSyncSupport && ctag) { + // We compare the stored ctag with the one we just got, if + // they don't match, we update the items in safeRefresh. + if (ctag == this.mCtag) { + this.mFirstRefreshDone = true; + } + + this.mProposedCtag = ctag; + if (this.verboseLogging()) { + cal.LOG(`CalDAV: initial ctag ${ctag} for calendar ${this.name}`); + } + } + + // Use supported-calendar-component-set if the server supports it; some do not. + let supportedComponents = response.firstProps["C:supported-calendar-component-set"]; + if (supportedComponents?.size) { + this.mSupportedItemTypes = [...this.mGenerallySupportedItemTypes].filter(itype => { + return supportedComponents.has(itype); + }); + cal.LOG( + `Adding supported items: ${this.mSupportedItemTypes.join(",")} for calendar: ${ + this.name + }` + ); + } + + // check if current-user-principal or owner is specified; might save some work finding + // the principal URL. + let owner = response.firstProps["D:owner"]; + let cuprincipal = response.firstProps["D:current-user-principal"]; + if (cuprincipal) { + this.mPrincipalUrl = cuprincipal; + cal.LOG( + "CalDAV: Found principal url from DAV:current-user-principal " + this.mPrincipalUrl + ); + } else if (owner) { + this.mPrincipalUrl = owner; + cal.LOG("CalDAV: Found principal url from DAV:owner " + this.mPrincipalUrl); + } + + let resourceType = response.firstProps["D:resourcetype"] || new Set(); + if (resourceType.has("C:calendar")) { + // This is a valid calendar resource + if (this.mDisabledByDavError) { + this.mDisabledByDavError = false; + } + + let privs = response.firstProps["D:current-user-privilege-set"]; + // Don't clear this.readOnly, only set it. The user may have write + // privileges but not want to use them. + if (!this.readOnly && privs && privs instanceof Set) { + this.readOnly = !["D:write", "D:write-content", "D:write-properties", "D:all"].some( + priv => privs.has(priv) + ); + } + + this.setCalHomeSet(true); + this.checkServerCaps(aChangeLogListener); + } else if (resourceType.has("D:collection")) { + // Not a CalDAV calendar + cal.LOG(`CalDAV: ${this.name} points to a DAV resource, but not a CalDAV calendar`); + this.completeCheckServerInfo(aChangeLogListener, Ci.calIErrors.DAV_DAV_NOT_CALDAV); + } else { + // Something else? + cal.LOG( + `CalDAV: No resource type received, ${this.name} doesn't seem to point to a DAV resource` + ); + this.completeCheckServerInfo(aChangeLogListener, Ci.calIErrors.DAV_NOT_DAV); + } + }, + e => { + cal.LOG(`CalDAV: Error during initial PROPFIND for calendar ${this.name}: ${e}`); + this.completeCheckServerInfo(aChangeLogListener, Ci.calIErrors.DAV_NOT_DAV); + } + ); + }, + + /** + * Checks server capabilities. + * + * checkDavResourceType + * checkServerCaps * You are here + * findPrincipalNS + * checkPrincipalsNameSpace + * completeCheckServerInfo + */ + checkServerCaps(aChangeLogListener, calHomeSetUrlRetry) { + let request = new CalDavHeaderRequest(this.session, this, this.makeUri(null, this.mCalHomeSet)); + + request.commit().then( + response => { + if (!response.ok) { + if (!calHomeSetUrlRetry && response.notFound) { + // try again with calendar URL, see https://bugzilla.mozilla.org/show_bug.cgi?id=588799 + cal.LOG( + "CalDAV: Calendar homeset was not found at parent url of calendar URL" + + ` while querying options ${this.name}, will try calendar URL itself now` + ); + this.setCalHomeSet(false); + this.checkServerCaps(aChangeLogListener, true); + } else { + cal.LOG( + `CalDAV: Unexpected status ${response.status} while querying options ${this.name}` + ); + this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_FAILURE); + } + + // No further processing needed, we have called subsequent (async) functions above. + return; + } + + if (this.verboseLogging()) { + cal.LOG("CalDAV: DAV features: " + [...response.features.values()].join(", ")); + } + + if (response.features.has("calendar-auto-schedule")) { + if (this.verboseLogging()) { + cal.LOG(`CalDAV: Calendar ${this.name} supports calendar-auto-schedule`); + } + this.mHasAutoScheduling = true; + // leave outbound inbox/outbox scheduling off + } else if (response.features.has("calendar-schedule")) { + if (this.verboseLogging()) { + cal.LOG(`CalDAV: Calendar ${this.name} generally supports calendar-schedule`); + } + this.hasScheduling = true; + } + + if (this.hasAutoScheduling || response.features.has("calendar-schedule")) { + // XXX - we really shouldn't register with the fb service if another calendar with + // the same principal-URL has already done so. We also shouldn't register with the + // fb service if we don't have an outbox. + if (!this.hasFreeBusy) { + // This may have already been set by fetchCachedMetaData, we only want to add + // the freebusy provider once. + this.hasFreeBusy = true; + cal.freeBusyService.addProvider(this); + } + this.findPrincipalNS(aChangeLogListener); + } else { + cal.LOG("CalDAV: Server does not support CalDAV scheduling."); + this.completeCheckServerInfo(aChangeLogListener); + } + }, + e => { + cal.LOG(`CalDAV: Error checking server capabilities for calendar ${this.name}: ${e}`); + this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_FAILURE); + } + ); + }, + + /** + * Locates the principal namespace. This function should solely be called + * from checkServerCaps to find the principal namespace. + * + * checkDavResourceType + * checkServerCaps + * findPrincipalNS * You are here + * checkPrincipalsNameSpace + * completeCheckServerInfo + */ + findPrincipalNS(aChangeLogListener) { + if (this.principalUrl) { + // We already have a principal namespace, use it. + this.checkPrincipalsNameSpace([this.principalUrl], aChangeLogListener); + return; + } + + let homeSet = this.makeUri(null, this.mCalHomeSet); + let request = new CalDavPropfindRequest(this.session, this, homeSet, [ + "D:principal-collection-set", + ]); + + request.commit().then( + response => { + if (response.ok) { + let pcs = response.firstProps["D:principal-collection-set"]; + let nsList = pcs ? pcs.map(path => this.ensureDecodedPath(path)) : []; + + this.checkPrincipalsNameSpace(nsList, aChangeLogListener); + } else { + cal.LOG( + "CalDAV: Unexpected status " + + response.status + + " while querying principal namespace for " + + this.name + ); + this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_FAILURE); + } + }, + e => { + cal.LOG(`CalDAV: Failed to propstat principal namespace for calendar ${this.name}: ${e}`); + this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_FAILURE); + } + ); + }, + + /** + * Checks the principals namespace for scheduling info. This function should + * solely be called from findPrincipalNS + * + * checkDavResourceType + * checkServerCaps + * findPrincipalNS + * checkPrincipalsNameSpace * You are here + * completeCheckServerInfo + * + * @param aNameSpaceList List of available namespaces + */ + checkPrincipalsNameSpace(aNameSpaceList, aChangeLogListener) { + let doesntSupportScheduling = () => { + this.hasScheduling = false; + this.mInboxUrl = null; + this.mOutboxUrl = null; + this.completeCheckServerInfo(aChangeLogListener); + }; + + if (!aNameSpaceList.length) { + if (this.verboseLogging()) { + cal.LOG( + "CalDAV: principal namespace list empty, calendar " + + this.name + + " doesn't support scheduling" + ); + } + doesntSupportScheduling(); + return; + } + + // We want a trailing slash, ensure it. + let nextNS = aNameSpaceList.pop().replace(/([^\/])$/, "$1/"); // eslint-disable-line no-useless-escape + let requestUri = Services.io.newURI(this.calendarUri.prePath + this.ensureEncodedPath(nextNS)); + let requestProps = [ + "C:calendar-home-set", + "C:calendar-user-address-set", + "C:schedule-inbox-URL", + "C:schedule-outbox-URL", + ]; + + let request; + if (this.mPrincipalUrl) { + request = new CalDavPropfindRequest(this.session, this, requestUri, requestProps); + } else { + let homePath = this.ensureEncodedPath(this.mCalHomeSet.spec.replace(/\/$/, "")); + request = new CalDavPrincipalPropertySearchRequest( + this.session, + this, + requestUri, + homePath, + "C:calendar-home-set", + requestProps + ); + } + + request.commit().then( + response => { + let homeSetMatches = homeSet => { + let normalized = homeSet.replace(/([^\/])$/, "$1/"); // eslint-disable-line no-useless-escape + let chs = this.mCalHomeSet; + return normalized == chs.path || normalized == chs.spec; + }; + let createBoxUrl = path => { + if (!path) { + return null; + } + let newPath = this.ensureDecodedPath(path); + // Make sure the uri has a / at the end, as we do with the calendarUri. + if (newPath.charAt(newPath.length - 1) != "/") { + newPath += "/"; + } + return this.mUri.mutate().setPathQueryRef(newPath).finalize(); + }; + + if (!response.ok) { + cal.LOG( + `CalDAV: Bad response to in/outbox query, status ${response.status} for ${this.name}` + ); + doesntSupportScheduling(); + return; + } + + // If there are multiple home sets, we need to match the email addresses for scheduling. + // If there is only one, assume its the right one. + // TODO with multiple address sets, we should just use the ACL manager. + let homeSets = response.firstProps["C:calendar-home-set"]; + if (homeSets.length == 1 || homeSets.some(homeSetMatches)) { + for (let addr of response.firstProps["C:calendar-user-address-set"]) { + if (addr.match(/^mailto:/i)) { + this.mCalendarUserAddress = addr; + } + } + + this.mInboxUrl = createBoxUrl(response.firstProps["C:schedule-inbox-URL"]); + this.mOutboxUrl = createBoxUrl(response.firstProps["C:schedule-outbox-URL"]); + + if (!this.mInboxUrl || this.calendarUri.spec == this.mInboxUrl.spec) { + // If the inbox matches the calendar uri (i.e SOGo), then we + // don't need to poll the inbox. + this.mShouldPollInbox = false; + } + } + + if (!this.calendarUserAddress || !this.mInboxUrl || !this.mOutboxUrl) { + if (aNameSpaceList.length) { + // Check the next namespace to find the info we need. + this.checkPrincipalsNameSpace(aNameSpaceList, aChangeLogListener); + } else { + if (this.verboseLogging()) { + cal.LOG( + "CalDAV: principal namespace list empty, calendar " + + this.name + + " doesn't support scheduling" + ); + } + doesntSupportScheduling(); + } + } else { + // We have everything, complete. + this.completeCheckServerInfo(aChangeLogListener); + } + }, + e => { + cal.LOG(`CalDAV: Failure checking principal namespace for calendar ${this.name}: ${e}`); + doesntSupportScheduling(); + } + ); + }, + + /** + * This is called to complete checking the server info. It should be the + * final call when checking server options. This will either report the + * error or if it is a success then refresh the calendar. + * + * checkDavResourceType + * checkServerCaps + * findPrincipalNS + * checkPrincipalsNameSpace + * completeCheckServerInfo * You are here + */ + completeCheckServerInfo(aChangeLogListener, aError = Cr.NS_OK) { + if (Components.isSuccessCode(aError)) { + this.saveCalendarProperties(); + this.checkedServerInfo = true; + this.setProperty("currentStatus", Cr.NS_OK); + if (this.isCached) { + this.safeRefresh(aChangeLogListener); + } else { + this.refresh(); + } + } else { + this.reportDavError(aError); + if (this.isCached && aChangeLogListener) { + aChangeLogListener.onResult({ status: Cr.NS_ERROR_FAILURE }, Cr.NS_ERROR_FAILURE); + } + } + }, + + /** + * Called to report a certain DAV error. Strings and modification type are + * handled here. + */ + reportDavError(aErrNo, status, extraInfo) { + let mapError = {}; + mapError[Ci.calIErrors.DAV_NOT_DAV] = "dav_notDav"; + mapError[Ci.calIErrors.DAV_DAV_NOT_CALDAV] = "dav_davNotCaldav"; + mapError[Ci.calIErrors.DAV_PUT_ERROR] = "itemPutError"; + mapError[Ci.calIErrors.DAV_REMOVE_ERROR] = "itemDeleteError"; + mapError[Ci.calIErrors.DAV_REPORT_ERROR] = "disabledMode"; + + let mapModification = {}; + mapModification[Ci.calIErrors.DAV_NOT_DAV] = false; + mapModification[Ci.calIErrors.DAV_DAV_NOT_CALDAV] = false; + mapModification[Ci.calIErrors.DAV_PUT_ERROR] = true; + mapModification[Ci.calIErrors.DAV_REMOVE_ERROR] = true; + mapModification[Ci.calIErrors.DAV_REPORT_ERROR] = false; + + let message = mapError[aErrNo]; + let localizedMessage; + let modificationError = mapModification[aErrNo]; + + if (!message) { + // Only notify if there is a message for this error + return; + } + localizedMessage = cal.l10n.getCalString(message, [this.mUri.spec]); + this.mDisabledByDavError = true; + this.notifyError(aErrNo, localizedMessage); + this.notifyError( + modificationError ? Ci.calIErrors.MODIFICATION_FAILED : Ci.calIErrors.READ_FAILED, + this.buildDetailedMessage(status, extraInfo) + ); + }, + + buildDetailedMessage(status, extraInfo) { + if (!status) { + return ""; + } + + let props = Services.strings.createBundle("chrome://calendar/locale/calendar.properties"); + let statusString; + try { + statusString = props.GetStringFromName("caldavRequestStatusCodeString" + status); + } catch (e) { + // Fallback on generic string if no string is defined for the status code + statusString = props.GetStringFromName("caldavRequestStatusCodeStringGeneric"); + } + return ( + props.formatStringFromName("caldavRequestStatusCode", [status]) + + ", " + + statusString + + "\n\n" + + (extraInfo ? extraInfo : "") + ); + }, + + // + // calIFreeBusyProvider interface + // + + getFreeBusyIntervals(aCalId, aRangeStart, aRangeEnd, aBusyTypes, aListener) { + // We explicitly don't check for hasScheduling here to allow free-busy queries + // even in case sched is turned off. + if (!this.outboxUrl || !this.calendarUserAddress) { + cal.LOG( + "CalDAV: Calendar " + + this.name + + " doesn't support scheduling;" + + " freebusy query not possible" + ); + aListener.onResult(null, null); + return; + } + + if (!this.firstInRealm()) { + // don't spam every known outbox with freebusy queries + aListener.onResult(null, null); + return; + } + + // We tweak the organizer lookup here: If e.g. scheduling is turned off, then the + // configured email takes place being the organizerId for scheduling which need + // not match against the calendar-user-address: + let orgId = this.getProperty("organizerId"); + if (orgId && orgId.toLowerCase() == aCalId.toLowerCase()) { + aCalId = this.calendarUserAddress; // continue with calendar-user-address + } + + // the caller prepends MAILTO: to calid strings containing @ + // but apple needs that to be mailto: + let aCalIdParts = aCalId.split(":"); + aCalIdParts[0] = aCalIdParts[0].toLowerCase(); + if (aCalIdParts[0] != "mailto" && aCalIdParts[0] != "http" && aCalIdParts[0] != "https") { + aListener.onResult(null, null); + return; + } + + let organizer = this.calendarUserAddress; + let recipient = aCalIdParts.join(":"); + let fbUri = this.makeUri(null, this.outboxUrl); + + let request = new CalDavFreeBusyRequest( + this.session, + this, + fbUri, + organizer, + recipient, + aRangeStart, + aRangeEnd + ); + + request.commit().then( + response => { + if (!response.xml || response.status != 200) { + cal.LOG( + "CalDAV: Received status " + response.status + " from freebusy query for " + this.name + ); + aListener.onResult(null, null); + return; + } + + let fbTypeMap = { + UNKNOWN: Ci.calIFreeBusyInterval.UNKNOWN, + FREE: Ci.calIFreeBusyInterval.FREE, + BUSY: Ci.calIFreeBusyInterval.BUSY, + "BUSY-UNAVAILABLE": Ci.calIFreeBusyInterval.BUSY_UNAVAILABLE, + "BUSY-TENTATIVE": Ci.calIFreeBusyInterval.BUSY_TENTATIVE, + }; + + let status = response.firstRecipient.status; + if (!status || !status.startsWith("2")) { + cal.LOG(`CalDAV: Got status ${status} in response to freebusy query for ${this.name}`); + aListener.onResult(null, null); + return; + } + + if (!status.startsWith("2.0")) { + cal.LOG(`CalDAV: Got status ${status} in response to freebusy query for ${this.name}`); + } + + let intervals = response.firstRecipient.intervals.map(data => { + let fbType = fbTypeMap[data.type] || Ci.calIFreeBusyInterval.UNKNOWN; + return new cal.provider.FreeBusyInterval(aCalId, fbType, data.begin, data.end); + }); + + aListener.onResult(null, intervals); + }, + e => { + cal.LOG(`CalDAV: Failed freebusy request for ${this.name}: ${e}`); + aListener.onResult(null, null); + } + ); + }, + + /** + * Extract the path from the full spec, if the regexp failed, log + * warning and return unaltered path. + */ + extractPathFromSpec(aSpec) { + // The parsed array should look like this: + // a[0] = full string + // a[1] = scheme + // a[2] = everything between the scheme and the start of the path + // a[3] = extracted path + let a = aSpec.match("(https?)(://[^/]*)([^#?]*)"); + if (a && a[3]) { + return a[3]; + } + cal.WARN("CalDAV: Spec could not be parsed, returning as-is: " + aSpec); + return aSpec; + }, + /** + * This is called to create an encoded path from a unencoded path OR + * encoded full url + * + * @param aString {string} un-encoded path OR encoded uri spec. + */ + ensureEncodedPath(aString) { + if (aString.charAt(0) != "/") { + aString = this.ensureDecodedPath(aString); + } + let uriComponents = aString.split("/"); + uriComponents = uriComponents.map(encodeURIComponent); + return uriComponents.join("/"); + }, + + /** + * This is called to get a decoded path from an encoded path or uri spec. + * + * @param {string} aString - Represents either a path + * or a full uri that needs to be decoded. + * @returns {string} A decoded path. + */ + ensureDecodedPath(aString) { + if (aString.charAt(0) != "/") { + aString = this.extractPathFromSpec(aString); + } + + let uriComponents = aString.split("/"); + for (let i = 0; i < uriComponents.length; i++) { + try { + uriComponents[i] = decodeURIComponent(uriComponents[i]); + } catch (e) { + cal.WARN("CalDAV: Exception decoding path " + aString + ", segment: " + uriComponents[i]); + } + } + return uriComponents.join("/"); + }, + isInbox(aString) { + // Note: If you change this, make sure it really returns a boolean + // value and not null! + return ( + (this.hasScheduling || this.hasAutoScheduling) && + this.mInboxUrl != null && + aString.startsWith(this.mInboxUrl.spec) + ); + }, + + /** + * Query contents of scheduling inbox + * + */ + pollInbox() { + // If polling the inbox was switched off, no need to poll the inbox. + // Also, if we have more than one calendar in this CalDAV account, we + // want only one of them to be checking the inbox. + if ( + (!this.hasScheduling && !this.hasAutoScheduling) || + !this.mShouldPollInbox || + !this.firstInRealm() + ) { + return; + } + + this.getUpdatedItems(this.mInboxUrl, null); + }, + + // + // take calISchedulingSupport interface base implementation (cal.provider.BaseClass) + // + + async processItipReply(aItem, aPath) { + // modify partstat for in-calendar item + // delete item from inbox + let self = this; + let modListener = {}; + modListener.QueryInterface = ChromeUtils.generateQI(["calIOperationListener"]); + modListener.onOperationComplete = function ( + aCalendar, + aStatus, + aOperationType, + aItemId, + aDetail + ) { + cal.LOG(`CalDAV: status ${aStatus} while processing iTIP REPLY for ${self.name}`); + // don't delete the REPLY item from inbox unless modifying the master + // item was successful + if (aStatus == 0) { + // aStatus undocumented; 0 seems to indicate no error + let delUri = self.calendarUri + .mutate() + .setPathQueryRef(self.ensureEncodedPath(aPath)) + .finalize(); + self.doDeleteItem(aItem, null, true, true, delUri); + } + }; + + let itemToUpdate = await this.mOfflineStorage.getItem(aItem.id); + + if (aItem.recurrenceId && itemToUpdate.recurrenceInfo) { + itemToUpdate = itemToUpdate.recurrenceInfo.getOccurrenceFor(aItem.recurrenceId); + } + let newItem = itemToUpdate.clone(); + + for (let attendee of aItem.getAttendees()) { + let att = newItem.getAttendeeById(attendee.id); + if (att) { + newItem.removeAttendee(att); + att = att.clone(); + att.participationStatus = attendee.participationStatus; + newItem.addAttendee(att); + } + } + self.doModifyItem( + newItem, + itemToUpdate.parentItem /* related to bug 396182 */, + modListener, + true + ); + }, + + canNotify(aMethod, aItem) { + // canNotify should return false if the imip transport should takes care of notifying cal + // users + if (this.getProperty("forceEmailScheduling")) { + return false; + } + if (this.hasAutoScheduling || this.hasScheduling) { + // we go with server's scheduling capabilities here - we take care for exceptions if + // schedule agent is set to CLIENT in sendItems() + switch (aMethod) { + // supported methods as per RfC 6638 + case "REPLY": + case "REQUEST": + case "CANCEL": + case "ADD": + return true; + default: + cal.LOG( + "Not supported method " + + aMethod + + " detected - falling back to email based scheduling." + ); + } + } + return false; // use outbound iTIP for all + }, + + // + // calIItipTransport interface + // + + get scheme() { + return "mailto"; + }, + + mSenderAddress: null, + get senderAddress() { + return this.mSenderAddress || this.calendarUserAddress; + }, + set senderAddress(aString) { + this.mSenderAddress = aString; + }, + + sendItems(aRecipients, aItipItem, aFromAttendee) { + function doImipScheduling(aCalendar, aRecipientList) { + let result = false; + let imipTransport = cal.provider.getImipTransport(aCalendar); + let recipients = []; + aRecipientList.forEach(rec => recipients.push(rec.toString())); + if (imipTransport) { + cal.LOG( + "Enforcing client-side email scheduling instead of server-side scheduling" + + " for " + + recipients.join() + ); + result = imipTransport.sendItems(aRecipientList, aItipItem, aFromAttendee); + } else { + cal.ERROR( + "No imip transport available for " + + aCalendar.id + + ", failed to notify" + + recipients.join() + ); + } + return result; + } + + if (this.getProperty("forceEmailScheduling")) { + return doImipScheduling(this, aRecipients); + } + + if (this.hasAutoScheduling || this.hasScheduling) { + // let's make sure we notify calendar users marked for client-side scheduling by email + let recipients = []; + for (let item of aItipItem.getItemList()) { + if (aItipItem.receivedMethod == "REPLY") { + if (item.organizer.getProperty("SCHEDULE-AGENT") == "CLIENT") { + recipients.push(item.organizer); + } + } else { + let atts = item.getAttendees().filter(att => { + return att.getProperty("SCHEDULE-AGENT") == "CLIENT"; + }); + for (let att of atts) { + recipients.push(att); + } + } + } + if (recipients.length) { + // We return the imip scheduling status here as any remaining calendar user will be + // notified by the server without receiving a status in the first place. + // We maybe could inspect the scheduling status of those attendees when + // re-retriving the modified event and try to do imip schedule on any status code + // other then 1.0, 1.1 or 1.2 - but I leave without that for now. + return doImipScheduling(this, recipients); + } + return true; + } + + // from here on this code for explicit caldav scheduling + if (aItipItem.responseMethod == "REPLY") { + // Get my participation status + let attendee = aItipItem.getItemList()[0].getAttendeeById(this.calendarUserAddress); + if (!attendee) { + return false; + } + // work around BUG 351589, the below just removes RSVP: + aItipItem.setAttendeeStatus(attendee.id, attendee.participationStatus); + } + + for (let item of aItipItem.getItemList()) { + let requestUri = this.makeUri(null, this.outboxUrl); + let request = new CalDavOutboxRequest( + this.session, + this, + requestUri, + this.calendarUserAddress, + aRecipients, + item + ); + + request.commit().then( + response => { + if (!response.ok) { + cal.LOG(`CalDAV: Sending iTIP failed with status ${response.status} for ${this.name}`); + } + + let lowerRecipients = new Map(aRecipients.map(recip => [recip.id.toLowerCase(), recip])); + let remainingAttendees = []; + for (let [recipient, status] of Object.entries(response.data)) { + if (status.startsWith("2")) { + continue; + } + + let att = lowerRecipients.get(recipient.toLowerCase()); + if (att) { + remainingAttendees.push(att); + } + } + + if (this.verboseLogging()) { + cal.LOG( + "CalDAV: Failed scheduling delivery to " + + remainingAttendees.map(att => att.id).join(", ") + ); + } + + if (remainingAttendees.length) { + // try to fall back to email delivery if CalDAV-sched didn't work + let imipTransport = cal.provider.getImipTransport(this); + if (imipTransport) { + if (this.verboseLogging()) { + cal.LOG(`CalDAV: sending email to ${remainingAttendees.length} recipients`); + } + imipTransport.sendItems(remainingAttendees, aItipItem, aFromAttendee); + } else { + cal.LOG("CalDAV: no fallback to iTIP/iMIP transport for " + this.name); + } + } + }, + e => { + cal.LOG(`CalDAV: Failed itip request for ${this.name}: ${e}`); + } + ); + } + return true; + }, + + mVerboseLogging: undefined, + verboseLogging() { + if (this.mVerboseLogging === undefined) { + this.mVerboseLogging = Services.prefs.getBoolPref("calendar.debug.log.verbose", false); + } + return this.mVerboseLogging; + }, + + getSerializedItem(aItem) { + let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance( + Ci.calIIcsSerializer + ); + serializer.addItems([aItem]); + let serializedItem = serializer.serializeToString(); + if (this.verboseLogging()) { + cal.LOG("CalDAV: send: " + serializedItem); + } + return serializedItem; + }, +}; + +function calDavObserver(aCalendar) { + this.mCalendar = aCalendar; +} + +calDavObserver.prototype = { + mCalendar: null, + mInBatch: false, + + // calIObserver: + onStartBatch(calendar) { + this.mCalendar.observers.notify("onStartBatch", [calendar]); + this.mInBatch = true; + }, + onEndBatch(calendar) { + this.mCalendar.observers.notify("onEndBatch", [calendar]); + this.mInBatch = false; + }, + onLoad(calendar) { + this.mCalendar.observers.notify("onLoad", [calendar]); + }, + onAddItem(aItem) { + this.mCalendar.observers.notify("onAddItem", [aItem]); + }, + onModifyItem(aNewItem, aOldItem) { + this.mCalendar.observers.notify("onModifyItem", [aNewItem, aOldItem]); + }, + onDeleteItem(aDeletedItem) { + this.mCalendar.observers.notify("onDeleteItem", [aDeletedItem]); + }, + onPropertyChanged(aCalendar, aName, aValue, aOldValue) { + this.mCalendar.observers.notify("onPropertyChanged", [aCalendar, aName, aValue, aOldValue]); + }, + onPropertyDeleting(aCalendar, aName) { + this.mCalendar.observers.notify("onPropertyDeleting", [aCalendar, aName]); + }, + + onError(aCalendar, aErrNo, aMessage) { + this.mCalendar.readOnly = true; + this.mCalendar.notifyError(aErrNo, aMessage); + }, +}; diff --git a/comm/calendar/providers/caldav/CalDavProvider.jsm b/comm/calendar/providers/caldav/CalDavProvider.jsm new file mode 100644 index 0000000000..940e64337d --- /dev/null +++ b/comm/calendar/providers/caldav/CalDavProvider.jsm @@ -0,0 +1,426 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +var EXPORTED_SYMBOLS = ["CalDavProvider"]; + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { DNS } = ChromeUtils.import("resource:///modules/DNS.jsm"); + +var { CalDavPropfindRequest } = ChromeUtils.import("resource:///modules/caldav/CalDavRequest.jsm"); + +var { CalDavDetectionSession } = ChromeUtils.import("resource:///modules/caldav/CalDavSession.jsm"); + +// NOTE: This module should not be loaded directly, it is available when +// including calUtils.jsm under the cal.provider.caldav namespace. + +/** + * @implements {calICalendarProvider} + */ +var CalDavProvider = { + QueryInterface: ChromeUtils.generateQI(["calICalendarProvider"]), + + get type() { + return "caldav"; + }, + + get displayName() { + return cal.l10n.getCalString("caldavName"); + }, + + get shortName() { + return "CalDAV"; + }, + + deleteCalendar(aCalendar, aListener) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + + async detectCalendars( + username, + password, + location = null, + savePassword = false, + extraProperties = {} + ) { + let uri = cal.provider.detection.locationToUri(location); + if (!uri) { + throw new Error("Could not infer location from username"); + } + + let detector = new CalDavDetector(username, password, savePassword); + + for (let method of [ + "attemptGoogleOauth", + "attemptLocation", + "dnsSRV", + "wellKnown", + "attemptRoot", + ]) { + try { + cal.LOG(`[CalDavProvider] Trying to detect calendar using ${method} method`); + let calendars = await detector[method](uri); + if (calendars) { + return calendars; + } + } catch (e) { + // e may be an Error object or a response object like CalDavSimpleResponse. + // It can even be a string, as with the OAuth2 error below. + let message = `[CalDavProvider] Could not detect calendar using method ${method}`; + + let errorDetails = err => + ` - ${err.fileName || err.filename}:${err.lineNumber}: ${err} - ${err.stack}`; + + let responseDetails = response => ` - HTTP response status ${response.status}`; + + // A special thing the OAuth2 code throws. + if (e == '{ "error": "cancelled"}') { + cal.WARN(message + ` - OAuth2 '${e}'`); + throw new cal.provider.detection.CanceledError("OAuth2 prompt canceled"); + } + + // We want to pass on any autodetect errors that will become results. + if (e instanceof cal.provider.detection.Error) { + cal.WARN(message + errorDetails(e)); + throw e; + } + + // Sometimes e is a CalDavResponseBase that is an auth error, so throw it. + if (e.authError) { + cal.WARN(message + responseDetails(e)); + throw new cal.provider.detection.AuthFailedError(); + } + + if (e instanceof Error) { + cal.WARN(message + errorDetails(e)); + } else if (typeof e.status == "number") { + cal.WARN(message + responseDetails(e)); + } else { + cal.WARN(message); + } + } + } + return []; + }, +}; + +/** + * Used by the CalDavProvider to detect CalDAV calendars for a given username, + * password, location, etc. + */ +class CalDavDetector { + /** + * Create a new caldav detector. + * + * @param {string} username - A username. + * @param {string} password - A password. + * @param {boolean} savePassword - Whether to save the password or not. + */ + constructor(username, password, savePassword) { + this.username = username; + this.session = new CalDavDetectionSession(username, password, savePassword); + } + + /** + * Attempt to detect calendars at the given location. + * + * @param {nsIURI} location - The location to attempt. + * @returns {Promise<calICalendar[] | null>} An array of calendars or null. + */ + attemptLocation(location) { + if (location.filePath == "/") { + // The location is the root, don't try to detect the collection, let the + // other handlers take care of it. + return Promise.resolve(null); + } + return this.detectCollection(location); + } + + /** + * Attempt to detect calendars at the given location using DNS lookups. + * + * @param {nsIURI} location - The location to attempt. + * @returns {Promise<calICalendar[] | null>} An array of calendars or null. + */ + async dnsSRV(location) { + if (location.filePath != "/") { + // If there is already a path specified, then no need to use DNS lookups. + return null; + } + + let dnshost = location.host; + let secure = location.schemeIs("http") ? "" : "s"; + let dnsres = await DNS.srv(`_caldav${secure}._tcp.${dnshost}`); + + if (!dnsres.length) { + let basedomain; + try { + basedomain = Services.eTLD.getBaseDomain(location); + } catch (e) { + // If we can't get a base domain just skip it. + } + + if (basedomain && basedomain != location.host) { + cal.LOG(`[CalDavProvider] ${location.host} has no SRV entry, trying ${basedomain}`); + dnsres = await DNS.srv(`_caldav${secure}._tcp.${basedomain}`); + dnshost = basedomain; + } + } + + if (!dnsres.length) { + return null; + } + dnsres.sort((a, b) => a.prio - b.prio || b.weight - a.weight); + + // Determine path from TXT, if available. + let pathres = await DNS.txt(`_caldav${secure}._tcp.${dnshost}`); + pathres.sort((a, b) => a.prio - b.prio || b.weight - a.weight); + pathres = pathres.filter(result => result.data.startsWith("path=")); + // Get the string after `path=`. + let path = pathres.length ? pathres[0].data.substr(5) : ""; + + let calendars; + if (path) { + // If the server has SRV and TXT entries, we already have a full context path to test. + let uri = `http${secure}://${dnsres[0].host}:${dnsres[0].port}${path}`; + cal.LOG(`[CalDavProvider] Trying ${uri} from SRV and TXT response`); + calendars = await this.detectCollection(Services.io.newURI(uri)); + } + + if (!calendars) { + // Either the txt record doesn't point to a path (in which case we need to repeat with + // well-known), or no calendars could be detected at that location (in which case we + // need to repeat with well-known). + + let baseloc = Services.io.newURI( + `http${secure}://${dnsres[0].host}:${dnsres[0].port}/.well-known/caldav` + ); + cal.LOG(`[CalDavProvider] Trying ${baseloc.spec} from SRV response with .well-known`); + + calendars = await this.detectCollection(baseloc); + } + + return calendars; + } + + /** + * Attempt to detect calendars using a `.well-known` URI. + * + * @param {nsIURI} location - The location to attempt. + * @returns {Promise<calICalendar[] | null>} An array of calendars or null. + */ + async wellKnown(location) { + let wellKnownUri = Services.io.newURI("/.well-known/caldav", null, location); + cal.LOG(`[CalDavProvider] Trying .well-known URI without dns at ${wellKnownUri.spec}`); + return this.detectCollection(wellKnownUri); + } + + /** + * Attempt to detect calendars using a root ("/") URI. + * + * @param {nsIURI} location - The location to attempt. + * @returns {Promise<calICalendar[] | null>} An array of calendars or null. + */ + attemptRoot(location) { + let rootUri = Services.io.newURI("/", null, location); + return this.detectCollection(rootUri); + } + + /** + * Attempt to detect calendars using Google OAuth. + * + * @param {nsIURI} calURI - The location to attempt. + * @returns {Promise<calICalendar[] | null>} An array of calendars or null. + */ + async attemptGoogleOauth(calURI) { + let usesGoogleOAuth = cal.provider.detection.googleOAuthDomains.has(calURI.host); + if (!usesGoogleOAuth) { + // Not using Google OAuth that we know of, but we could check the mx entry. + // If mail is handled by Google then this is likely a Google Apps domain. + let mxRecords = await DNS.mx(calURI.host); + usesGoogleOAuth = mxRecords.some(r => /\bgoogle\.com$/.test(r.host)); + } + + if (usesGoogleOAuth) { + // If we were given a full URL to a calendar, try to use it. + let spec = this.username + ? `https://apidata.googleusercontent.com/caldav/v2/${encodeURIComponent( + this.username + )}/user` + : calURI.spec; + let uri = Services.io.newURI(spec); + return this.handlePrincipal(uri); + } + return null; + } + + /** + * Utility function to detect whether a calendar collection exists at a given + * location and return it if it exists. + * + * @param {nsIURI} location - The location to attempt. + * @returns {Promise<calICalendar[] | null>} An array of calendars or null. + */ + async detectCollection(location) { + let props = [ + "D:resourcetype", + "D:owner", + "D:displayname", + "D:current-user-principal", + "D:current-user-privilege-set", + "A:calendar-color", + "C:calendar-home-set", + ]; + + cal.LOG(`[CalDavProvider] Checking collection type at ${location.spec}`); + let request = new CalDavPropfindRequest(this.session, null, location, props); + + // `request.commit()` can throw; errors should be caught by calling functions. + let response = await request.commit(); + let target = response.uri; + + if (response.authError) { + throw new cal.provider.detection.AuthFailedError(); + } else if (!response.ok) { + cal.LOG(`[CalDavProvider] ${target.spec} did not respond properly to PROPFIND`); + return null; + } + + let resprops = response.firstProps; + let resourceType = resprops["D:resourcetype"]; + + if (resourceType.has("C:calendar")) { + cal.LOG(`[CalDavProvider] ${target.spec} is a calendar`); + return [this.handleCalendar(target, resprops)]; + } else if (resourceType.has("D:principal")) { + cal.LOG(`[CalDavProvider] ${target.spec} is a principal, looking at home set`); + let homeSet = resprops["C:calendar-home-set"]; + let homeSetUrl = Services.io.newURI(homeSet, null, target); + return this.handleHomeSet(homeSetUrl); + } else if (resprops["D:current-user-principal"]) { + cal.LOG( + `[CalDavProvider] ${target.spec} is something else, looking at current-user-principal` + ); + let principalUrl = Services.io.newURI(resprops["D:current-user-principal"], null, target); + return this.handlePrincipal(principalUrl); + } else if (resprops["D:owner"]) { + cal.LOG(`[CalDavProvider] ${target.spec} is something else, looking at collection owner`); + let principalUrl = Services.io.newURI(resprops["D:owner"], null, target); + return this.handlePrincipal(principalUrl); + } + + return null; + } + + /** + * Utility function to make a new attempt to detect calendars after the + * previous PROPFIND results contained either "D:current-user-principal" + * or "D:owner" props. + * + * @param {nsIURI} location - The location to attempt. + * @returns {Promise<calICalendar[] | null>} An array of calendars or null. + */ + async handlePrincipal(location) { + let props = ["D:resourcetype", "C:calendar-home-set"]; + let request = new CalDavPropfindRequest(this.session, null, location, props); + cal.LOG(`[CalDavProvider] Checking collection type at ${location.spec}`); + + // `request.commit()` can throw; errors should be caught by calling functions. + let response = await request.commit(); + let homeSets = response.firstProps["C:calendar-home-set"]; + let target = response.uri; + + if (response.authError) { + throw new cal.provider.detection.AuthFailedError(); + } else if (!response.firstProps["D:resourcetype"].has("D:principal")) { + cal.LOG(`[CalDavProvider] ${target.spec} is not a principal collection`); + return null; + } else if (homeSets) { + let calendars = []; + for (let homeSet of homeSets) { + cal.LOG(`[CalDavProvider] ${target.spec} has a home set at ${homeSet}, checking that`); + let homeSetUrl = Services.io.newURI(homeSet, null, target); + let discoveredCalendars = await this.handleHomeSet(homeSetUrl); + if (discoveredCalendars) { + calendars.push(...discoveredCalendars); + } + } + return calendars.length ? calendars : null; + } else { + cal.LOG(`[CalDavProvider] ${target.spec} doesn't have a home set`); + return null; + } + } + + /** + * Utility function to make a new attempt to detect calendars after the + * previous PROPFIND results contained a "C:calendar-home-set" prop. + * + * @param {nsIURI} location - The location to attempt. + * @returns {Promise<calICalendar[] | null>} An array of calendars or null. + */ + async handleHomeSet(location) { + let props = [ + "D:resourcetype", + "D:displayname", + "D:current-user-privilege-set", + "A:calendar-color", + ]; + let request = new CalDavPropfindRequest(this.session, null, location, props, 1); + + // `request.commit()` can throw; errors should be caught by calling functions. + let response = await request.commit(); + let target = response.uri; + + if (response.authError) { + throw new cal.provider.detection.AuthFailedError(); + } + + let calendars = []; + for (let [href, resprops] of Object.entries(response.data)) { + if (resprops["D:resourcetype"].has("C:calendar")) { + let hrefUri = Services.io.newURI(href, null, target); + calendars.push(this.handleCalendar(hrefUri, resprops)); + } + } + cal.LOG(`[CalDavProvider] ${target.spec} is a home set, found ${calendars.length} calendars`); + + return calendars.length ? calendars : null; + } + + /** + * Set up and return a new caldav calendar object. + * + * @param {nsIURI} uri - The location of the calendar. + * @param {Set} props - The calendar properties parsed from the + * response. + * @returns {calICalendar} A new calendar. + */ + handleCalendar(uri, props) { + let displayName = props["D:displayname"]; + let color = props["A:calendar-color"]; + if (!displayName) { + let fileName = decodeURI(uri.spec).split("/").filter(Boolean).pop(); + displayName = fileName || uri.spec; + } + + // Some servers provide colors as an 8-character hex string. Strip the alpha component. + color = color?.replace(/^(#[0-9A-Fa-f]{6})[0-9A-Fa-f]{2}$/, "$1"); + + let calendar = cal.manager.createCalendar("caldav", uri); + calendar.setProperty("color", color || cal.view.hashColor(uri.spec)); + calendar.name = displayName; + calendar.id = cal.getUUID(); + calendar.setProperty("username", this.username); + calendar.wrappedJSObject.session = this.session.toBaseSession(); + + // Attempt to discover if the user is allowed to write to this calendar. + let privs = props["D:current-user-privilege-set"]; + if (privs && privs instanceof Set) { + calendar.readOnly = !["D:write", "D:write-content", "D:write-properties", "D:all"].some( + priv => privs.has(priv) + ); + } + return calendar; + } +} diff --git a/comm/calendar/providers/caldav/components.conf b/comm/calendar/providers/caldav/components.conf new file mode 100644 index 0000000000..118aaa065c --- /dev/null +++ b/comm/calendar/providers/caldav/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': '{a35fc6ea-3d92-11d9-89f9-00045ace3b8d}', + 'contract_ids': ['@mozilla.org/calendar/calendar;1?type=caldav'], + 'jsm': 'resource:///modules/CalDavCalendar.jsm', + 'constructor': 'CalDavCalendar', + }, +]
\ No newline at end of file diff --git a/comm/calendar/providers/caldav/modules/CalDavRequest.jsm b/comm/calendar/providers/caldav/modules/CalDavRequest.jsm new file mode 100644 index 0000000000..7778e42953 --- /dev/null +++ b/comm/calendar/providers/caldav/modules/CalDavRequest.jsm @@ -0,0 +1,1211 @@ +/* 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 { CalDavTagsToXmlns, CalDavNsUnresolver } = ChromeUtils.import( + "resource:///modules/caldav/CalDavUtils.jsm" +); + +var { CalDavSession } = ChromeUtils.import("resource:///modules/caldav/CalDavSession.jsm"); + +/* exported CalDavGenericRequest, CalDavLegacySAXRequest, CalDavItemRequest, + CalDavDeleteItemRequest, CalDavPropfindRequest, CalDavHeaderRequest, + CalDavPrincipalPropertySearchRequest, CalDavOutboxRequest, CalDavFreeBusyRequest */ + +const EXPORTED_SYMBOLS = [ + "CalDavGenericRequest", + "CalDavLegacySAXRequest", + "CalDavItemRequest", + "CalDavDeleteItemRequest", + "CalDavPropfindRequest", + "CalDavHeaderRequest", + "CalDavPrincipalPropertySearchRequest", + "CalDavOutboxRequest", + "CalDavFreeBusyRequest", +]; + +const XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>\n'; +const MIME_TEXT_CALENDAR = "text/calendar; charset=utf-8"; +const MIME_TEXT_XML = "text/xml; charset=utf-8"; + +/** + * Base class for a caldav request. + * + * @implements {nsIChannelEventSink} + * @implements {nsIInterfaceRequestor} + */ +class CalDavRequestBase { + QueryInterface = ChromeUtils.generateQI(["nsIChannelEventSink", "nsIInterfaceRequestor"]); + + /** + * Creates a new base response, this should mainly be done using the subclass constructor + * + * @param {CalDavSession} aSession - The session to use for this request + * @param {?calICalendar} aCalendar - The calendar this request belongs to (can be null) + * @param {nsIURI} aUri - The uri to request + * @param {?string} aUploadData - The data to upload + * @param {?string} aContentType - The MIME content type for the upload data + * @param {?Function<nsIChannel>} aOnSetupChannel - The function to call to set up the channel + */ + constructor( + aSession, + aCalendar, + aUri, + aUploadData = null, + aContentType = null, + aOnSetupChannel = null + ) { + if (typeof aUploadData == "function") { + aOnSetupChannel = aUploadData; + aUploadData = null; + aContentType = null; + } + + this.session = aSession; + this.calendar = aCalendar; + this.uri = aUri; + this.uploadData = aUploadData; + this.contentType = aContentType; + this.onSetupChannel = aOnSetupChannel; + this.response = null; + this.reset(); + } + + /** + * @returns {object} The class of the response for this request + */ + get responseClass() { + return CalDavSimpleResponse; + } + + /** + * Resets the channel for this request + */ + reset() { + this.channel = cal.provider.prepHttpChannel( + this.uri, + this.uploadData, + this.contentType, + this, + null, + this.session.isDetectionSession + ); + } + + /** + * Retrieves the given request header. Requires the request to be committed. + * + * @param {string} aHeader - The header to retrieve + * @returns {?string} The requested header, or null if unavailable + */ + getHeader(aHeader) { + try { + return this.response.nsirequest.getRequestHeader(aHeader); + } catch (e) { + return null; + } + } + + /** + * Executes the request with the configuration set up in the constructor + * + * @returns {Promise} A promise that resolves with a subclass of CalDavResponseBase + * which is based on |responseClass|. + */ + async commit() { + await this.session.prepareRequest(this.channel); + + if (this.onSetupChannel) { + this.onSetupChannel(this.channel); + } + + if (cal.verboseLogEnabled && this.uploadData) { + let method = this.channel.requestMethod; + cal.LOGverbose(`CalDAV: send (${method} ${this.uri.spec}): ${this.uploadData}`); + } + + let ResponseClass = this.responseClass; + this.response = new ResponseClass(this); + this.response.lastRedirectStatus = null; + this.channel.asyncOpen(this.response.listener, this.channel); + + await this.response.responded; + + let action = await this.session.completeRequest(this.response); + if (action == CalDavSession.RESTART_REQUEST) { + this.reset(); + return this.commit(); + } + + if (cal.verboseLogEnabled) { + let text = this.response.text; + if (text) { + cal.LOGverbose("CalDAV: recv: " + text); + } + } + + return this.response; + } + + /** Implement nsIInterfaceRequestor */ + getInterface(aIID) { + /** + * Attempt to call nsIInterfaceRequestor::getInterface on the given object, and return null + * if it fails. + * + * @param {object} aObj - The object to call on. + * @returns {?*} The requested interface object, or null. + */ + function tryGetInterface(aObj) { + try { + let requestor = aObj.QueryInterface(Ci.nsIInterfaceRequestor); + return requestor.getInterface(aIID); + } catch (e) { + return null; + } + } + + // Special case our nsIChannelEventSink, can't use tryGetInterface due to recursion errors + if (aIID.equals(Ci.nsIChannelEventSink)) { + return this.QueryInterface(Ci.nsIChannelEventSink); + } + + // First check if the session has what we need. It may have an auth prompt implementation + // that should go first. Ideally we should move the auth prompt to the session anyway, but + // this is a task for another day (tm). + let iface = tryGetInterface(this.session) || tryGetInterface(this.calendar); + if (iface) { + return iface; + } + throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE); + } + + /** Implement nsIChannelEventSink */ + asyncOnChannelRedirect(aOldChannel, aNewChannel, aFlags, aCallback) { + /** + * Copy the given header from the old channel to the new one, ignoring missing headers + * + * @param {string} aHdr - The header to copy + */ + function copyHeader(aHdr) { + try { + let hdrValue = aOldChannel.getRequestHeader(aHdr); + if (hdrValue) { + aNewChannel.setRequestHeader(aHdr, hdrValue, false); + } + } catch (e) { + if (e.result != Cr.NS_ERROR_NOT_AVAILABLE) { + // The header could possibly not be available, ignore that + // case but throw otherwise + throw e; + } + } + } + + let uploadData, uploadContent; + let oldUploadChannel = cal.wrapInstance(aOldChannel, Ci.nsIUploadChannel); + let oldHttpChannel = cal.wrapInstance(aOldChannel, Ci.nsIHttpChannel); + if (oldUploadChannel && oldHttpChannel && oldUploadChannel.uploadStream) { + uploadData = oldUploadChannel.uploadStream; + uploadContent = oldHttpChannel.getRequestHeader("Content-Type"); + } + + cal.provider.prepHttpChannel(null, uploadData, uploadContent, this, aNewChannel); + + // Make sure we can get/set headers on both channels. + aNewChannel.QueryInterface(Ci.nsIHttpChannel); + aOldChannel.QueryInterface(Ci.nsIHttpChannel); + + try { + this.response.lastRedirectStatus = oldHttpChannel.responseStatus; + } catch (e) { + this.response.lastRedirectStatus = null; + } + + // If any other header is used, it should be added here. We might want + // to just copy all headers over to the new channel. + copyHeader("Depth"); + copyHeader("Originator"); + copyHeader("Recipient"); + copyHeader("If-None-Match"); + copyHeader("If-Match"); + copyHeader("Accept"); + + aNewChannel.requestMethod = oldHttpChannel.requestMethod; + this.session.prepareRedirect(aOldChannel, aNewChannel).then(() => { + aCallback.onRedirectVerifyCallback(Cr.NS_OK); + }); + } +} + +/** + * The caldav response base class. Should be subclassed, and works with xpcom network code that uses + * nsIRequest. + */ +class CalDavResponseBase { + /** + * Constructs a new caldav response + * + * @param {CalDavRequestBase} aRequest - The request that initiated the response + */ + constructor(aRequest) { + this.request = aRequest; + + this.responded = new Promise((resolve, reject) => { + this._onresponded = resolve; + this._onrespondederror = reject; + }); + this.completed = new Promise((resolve, reject) => { + this._oncompleted = resolve; + this._oncompletederror = reject; + }); + } + + /** The listener passed to the channel's asyncOpen */ + get listener() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + /** @returns {nsIURI} The request URI */ + get uri() { + return this.nsirequest.URI; + } + + /** @returns {boolean} True, if the request was redirected */ + get redirected() { + return this.uri.spec != this.nsirequest.originalURI.spec; + } + + /** @returns {number} The http response status of the request */ + get status() { + try { + return this.nsirequest.responseStatus; + } catch (e) { + return -1; + } + } + + /** The http status category, i.e. the first digit */ + get statusCategory() { + return (this.status / 100) | 0; + } + + /** If the response has a success code */ + get ok() { + return this.statusCategory == 2; + } + + /** If the response has a client error (4xx) */ + get clientError() { + return this.statusCategory == 4; + } + + /** If the response had an auth error */ + get authError() { + // 403 is technically "Forbidden", but for our terms it is the same + return this.status == 401 || this.status == 403; + } + + /** If the response has a conflict code */ + get conflict() { + return this.status == 409 || this.status == 412; + } + + /** If the response indicates the resource was not found */ + get notFound() { + return this.status == 404; + } + + /** If the response has a server error (5xx) */ + get serverError() { + return this.statusCategory == 5; + } + + /** + * Raise an exception if one of the handled 4xx and 5xx occurred. + */ + raiseForStatus() { + if (this.authError) { + throw new HttpUnauthorizedError(this); + } else if (this.conflict) { + throw new HttpConflictError(this); + } else if (this.notFound) { + throw new HttpNotFoundError(this); + } else if (this.serverError) { + throw new HttpServerError(this); + } + } + + /** The text response of the request */ + get text() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + /** @returns {DOMDocument} A DOM document with the response xml */ + get xml() { + if (this.text && !this._responseXml) { + try { + this._responseXml = cal.xml.parseString(this.text); + } catch (e) { + return null; + } + } + + return this._responseXml; + } + + /** + * Retrieve a request header + * + * @param {string} aHeader - The header to retrieve + * @returns {string} The header value + */ + getHeader(aHeader) { + try { + return this.nsirequest.getResponseHeader(aHeader); + } catch (e) { + return null; + } + } +} + +/** + * Thrown when the response had an authorization error (status 401 or 403). + */ +class HttpUnauthorizedError extends Error { + constructor(message) { + super(message); + this.name = "HttpUnauthorizedError"; + } +} + +/** + * Thrown when the response has a conflict code (status 409 or 412). + */ +class HttpConflictError extends Error { + constructor(message) { + super(message); + this.name = "HttpConflictError"; + } +} + +/** + * Thrown when the response indicates the resource was not found (status 404). + */ +class HttpNotFoundError extends Error { + constructor(message) { + super(message); + this.name = "HttpNotFoundError"; + } +} + +/** + * Thrown when the response has a server error (status 5xx). + */ +class HttpServerError extends Error { + constructor(message) { + super(message); + this.name = "HttpServerError"; + } +} + +/** + * A simple caldav response using nsIStreamLoader + */ +class CalDavSimpleResponse extends CalDavResponseBase { + QueryInterface = ChromeUtils.generateQI(["nsIStreamLoaderObserver"]); + + get listener() { + if (!this._listener) { + this._listener = cal.provider.createStreamLoader(); + this._listener.init(this); + } + return this._listener; + } + + get text() { + if (!this._responseText) { + this._responseText = new TextDecoder().decode(Uint8Array.from(this.result)) || ""; + } + return this._responseText; + } + + /** Implement nsIStreamLoaderObserver */ + onStreamComplete(aLoader, aContext, aStatus, aResultLength, aResult) { + this.resultLength = aResultLength; + this.result = aResult; + + this.nsirequest = aLoader.request.QueryInterface(Ci.nsIHttpChannel); + + if (Components.isSuccessCode(aStatus)) { + this._onresponded(this); + } else { + // Check for bad server certificates on SSL/TLS connections. + // this.request is CalDavRequestBase instance and it contains calICalendar property + // which is needed for checkBadCertStatus. CalDavRequestBase.calendar can be null, + // this possibility is handled in BadCertHandler. + cal.provider.checkBadCertStatus(aLoader.request, aStatus, this.request.calendar); + this._onrespondederror(this); + } + } +} + +/** + * A generic request method that uses the CalDavRequest/CalDavResponse infrastructure + */ +class CalDavGenericRequest extends CalDavRequestBase { + /** + * Constructs the generic caldav request + * + * @param {CalDavSession} aSession - The session to use for this request + * @param {calICalendar} aCalendar - The calendar this request belongs to + * @param {string} aMethod - The HTTP method to use + * @param {nsIURI} aUri - The uri to request + * @param {?object} aHeaders - An object with headers to set + * @param {?string} aUploadData - Optional data to upload + * @param {?string} aUploadType - Content type for upload data + */ + constructor( + aSession, + aCalendar, + aMethod, + aUri, + aHeaders = {}, + aUploadData = null, + aUploadType = null + ) { + super(aSession, aCalendar, aUri, aUploadData, aUploadType, channel => { + channel.requestMethod = aMethod; + + for (let [name, value] of Object.entries(aHeaders)) { + channel.setRequestHeader(name, value, false); + } + }); + } +} + +/** + * Legacy request handlers request that uses an external request listener. Used for transitioning + * because once I started refactoring calDavRequestHandlers.js I was on the verge of refactoring the + * whole caldav provider. Too risky right now. + */ +class CalDavLegacySAXRequest extends CalDavRequestBase { + /** + * Constructs the legacy caldav request + * + * @param {CalDavSession} aSession - The session to use for this request + * @param {calICalendar} aCalendar - The calendar this request belongs to + * @param {nsIURI} aUri - The uri to request + * @param {?string} aUploadData - Optional data to upload + * @param {?string} aUploadType - Content type for upload data + * @param {?object} aHandler - The external request handler, e.g. + * CalDavEtagsHandler, + * CalDavMultigetSyncHandler, + * CalDavWebDavSyncHandler. + * @param {?Function<nsIChannel>} aOnSetupChannel - The function to call to set up the channel + */ + constructor( + aSession, + aCalendar, + aUri, + aUploadData = null, + aUploadType = null, + aHandler = null, + aOnSetupChannel = null + ) { + super(aSession, aCalendar, aUri, aUploadData, aUploadType, aOnSetupChannel); + this._handler = aHandler; + } + + /** + * @returns {object} The class of the response for this request + */ + get responseClass() { + return LegacySAXResponse; + } +} + +/** + * Response class for legacy requests. Contains a listener that proxies the + * external request handler object (e.g. CalDavMultigetSyncHandler, + * CalDavWebDavSyncHandler, CalDavEtagsHandler) in order to resolve or reject + * the promises for the response's "responded" and "completed" status. + */ +class LegacySAXResponse extends CalDavResponseBase { + /** @returns {nsIStreamListener} The listener passed to the channel's asyncOpen */ + get listener() { + if (!this._listener) { + this._listener = { + QueryInterface: ChromeUtils.generateQI(["nsIRequestObserver", "nsIStreamListener"]), + + onStartRequest: aRequest => { + try { + let result = this.request._handler.onStartRequest(aRequest); + this._onresponded(); + return result; + } catch (e) { + this._onrespondederror(e); + return null; + } + }, + onStopRequest: (aRequest, aStatusCode) => { + try { + let result = this.request._handler.onStopRequest(aRequest, aStatusCode); + this._onresponded(); + return result; + } catch (e) { + this._onrespondederror(e); + return null; + } + }, + onDataAvailable: this.request._handler.onDataAvailable.bind(this.request._handler), + }; + } + return this._listener; + } + + /** @returns {string} The text response of the request */ + get text() { + return this.request._handler.logXML; + } +} + +/** + * Upload an item to the caldav server + */ +class CalDavItemRequest extends CalDavRequestBase { + /** + * Constructs an item request + * + * @param {CalDavSession} aSession - The session to use for this request + * @param {calICalendar} aCalendar - The calendar this request belongs to + * @param {nsIURI} aUri - The uri to request + * @param {calIItemBase} aItem - The item to send + * @param {?string} aEtag - The etag to check. The special value "*" + * sets the If-None-Match header, otherwise + * If-Match is set to the etag. + */ + constructor(aSession, aCalendar, aUri, aItem, aEtag = null) { + aItem = fixGoogleDescription(aItem, aUri); + let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance( + Ci.calIIcsSerializer + ); + serializer.addItems([aItem], 1); + let serializedItem = serializer.serializeToString(); + + super(aSession, aCalendar, aUri, serializedItem, MIME_TEXT_CALENDAR, channel => { + if (aEtag == "*") { + channel.setRequestHeader("If-None-Match", "*", false); + } else if (aEtag) { + channel.setRequestHeader("If-Match", aEtag, false); + } + }); + } + + /** + * @returns {object} The class of the response for this request + */ + get responseClass() { + return ItemResponse; + } +} + +/** + * The response for uploading an item to the server + */ +class ItemResponse extends CalDavSimpleResponse { + /** If the response has a success code */ + get ok() { + // We should not accept a 201 status here indefinitely: it indicates a server error of some + // kind that we want to know about. It's convenient to accept it for now since a number of + // server impls don't get this right yet. + return this.status == 204 || this.status == 201 || this.status == 200; + } +} + +/** + * A request for deleting an item from the server + */ +class CalDavDeleteItemRequest extends CalDavRequestBase { + /** + * Constructs an delete item request + * + * @param {CalDavSession} aSession - The session to use for this request + * @param {calICalendar} aCalendar - The calendar this request belongs to + * @param {nsIURI} aUri - The uri to request + * @param {?string} aEtag - The etag to check, or null to + * unconditionally delete + */ + constructor(aSession, aCalendar, aUri, aEtag = null) { + super(aSession, aCalendar, aUri, channel => { + if (aEtag) { + channel.setRequestHeader("If-Match", aEtag, false); + } + channel.requestMethod = "DELETE"; + }); + } + + /** + * @returns {object} The class of the response for this request + */ + get responseClass() { + return DeleteItemResponse; + } +} + +/** + * The response class to deleting an item + */ +class DeleteItemResponse extends ItemResponse { + /** If the response has a success code */ + get ok() { + // Accepting 404 as success because then the item is already deleted + return this.status == 204 || this.status == 200 || this.status == 404; + } +} + +/** + * A dav PROPFIND request to retrieve specific properties of a dav resource. + */ +class CalDavPropfindRequest extends CalDavRequestBase { + /** + * Constructs a propfind request + * + * @param {CalDavSession} aSession - The session to use for this request + * @param {calICalendar} aCalendar - The calendar this request belongs to + * @param {nsIURI} aUri - The uri to request + * @param {string[]} aProps - The properties to request, including + * namespace prefix. + * @param {number} aDepth - The depth for the request, defaults to 0 + */ + constructor(aSession, aCalendar, aUri, aProps, aDepth = 0) { + let xml = + XML_HEADER + + `<D:propfind ${CalDavTagsToXmlns("D", ...aProps)}><D:prop>` + + aProps.map(prop => `<${prop}/>`).join("") + + "</D:prop></D:propfind>"; + + super(aSession, aCalendar, aUri, xml, MIME_TEXT_XML, channel => { + channel.setRequestHeader("Depth", aDepth, false); + channel.requestMethod = "PROPFIND"; + }); + + this.depth = aDepth; + } + + /** + * @returns {object} The class of the response for this request + */ + get responseClass() { + return PropfindResponse; + } +} + +/** + * The response for a PROPFIND request + */ +class PropfindResponse extends CalDavSimpleResponse { + get decorators() { + /** + * Retrieves the trimmed text content of the node, or null if empty + * + * @param {Element} node - The node to get the text content of + * @returns {?string} The text content, or null if empty + */ + function textContent(node) { + let text = node.textContent; + return text ? text.trim() : null; + } + + /** + * Returns an array of string with each href value within the node scope + * + * @param {Element} parent - The node to get the href values in + * @returns {string[]} The array with trimmed text content values + */ + function href(parent) { + return [...parent.querySelectorAll(":scope > href")].map(node => node.textContent.trim()); + } + + /** + * Returns the single href value within the node scope + * + * @param {Element} node - The node to get the href value in + * @returns {?string} The trimmed text content + */ + function singleHref(node) { + let hrefval = node.querySelector(":scope > href"); + return hrefval ? hrefval.textContent.trim() : null; + } + + /** + * Returns a Set with the respective element local names in the path + * + * @param {string} path - The css path to search + * @param {Element} parent - The parent element to search in + * @returns {Set<string>} A set with the element names + */ + function nodeNames(path, parent) { + return new Set( + [...parent.querySelectorAll(path)].map(node => { + let prefix = CalDavNsUnresolver(node.namespaceURI) || node.prefix; + return prefix + ":" + node.localName; + }) + ); + } + + /** + * Returns a Set for the "current-user-privilege-set" properties. If a 404 + * status is detected, null is returned indicating the server does not + * support this directive. + * + * @param {string} path - The css path to search + * @param {Element} parent - The parent element to search in + * @param {string} status - The status of the enclosing <propstat> + * @returns {Set<string>} + */ + function privSet(path, parent, status = "") { + return status.includes("404") ? null : nodeNames(path, parent); + } + + /** + * Returns a Set with the respective attribute values in the path + * + * @param {string} path - The css path to search + * @param {string} attribute - The attribute name to retrieve for each node + * @param {Element} parent - The parent element to search in + * @returns {Set<string>} A set with the attribute values + */ + function attributeValue(path, attribute, parent) { + return new Set( + [...parent.querySelectorAll(path)].map(node => { + return node.getAttribute(attribute); + }) + ); + } + + /** + * Return the result of either function a or function b, passing the node + * + * @param {Function} a - The first function to call + * @param {Function} b - The second function to call + * @param {Element} node - The node to call the functions with + * @returns {*} The return value of either a() or b() + */ + function either(a, b, node) { + return a(node) || b(node); + } + + return { + "D:principal-collection-set": href, + "C:calendar-home-set": href, + "C:calendar-user-address-set": href, + "D:current-user-principal": singleHref, + "D:current-user-privilege-set": privSet.bind(null, ":scope > privilege > *"), + "D:owner": singleHref, + "D:supported-report-set": nodeNames.bind(null, ":scope > supported-report > report > *"), + "D:resourcetype": nodeNames.bind(null, ":scope > *"), + "C:supported-calendar-component-set": attributeValue.bind(null, ":scope > comp", "name"), + "C:schedule-inbox-URL": either.bind(null, singleHref, textContent), + "C:schedule-outbox-URL": either.bind(null, singleHref, textContent), + }; + } + /** + * Quick access to the properties of the PROPFIND request. Returns an object with the hrefs as + * keys, and an object with the normalized properties as the value. + * + * @returns {object} The object + */ + get data() { + if (!this._data) { + this._data = {}; + for (let response of this.xml.querySelectorAll(":scope > response")) { + let href = response.querySelector(":scope > href").textContent; + this._data[href] = {}; + + // This will throw 200's and 400's in one pot, but since 400's are empty that is ok + // for our needs. + for (let propStat of response.querySelectorAll(":scope > propstat")) { + let status = propStat.querySelector(":scope > status").textContent; + for (let prop of propStat.querySelectorAll(":scope > prop > *")) { + let prefix = CalDavNsUnresolver(prop.namespaceURI) || prop.prefix; + let qname = prefix + ":" + prop.localName; + if (qname in this.decorators) { + this._data[href][qname] = this.decorators[qname](prop, status) || null; + } else { + this._data[href][qname] = prop.textContent.trim() || null; + } + } + } + } + } + return this._data; + } + + /** + * Shortcut for the properties of the first response, useful for depth=0 + */ + get firstProps() { + return Object.values(this.data)[0]; + } + + /** If the response has a success code */ + get ok() { + return this.status == 207 && this.xml; + } +} + +/** + * An OPTIONS request for retrieving the DAV header + */ +class CalDavHeaderRequest extends CalDavRequestBase { + /** + * Constructs the options request + * + * @param {CalDavSession} aSession - The session to use for this request + * @param {calICalendar} aCalendar - The calendar this request belongs to + * @param {nsIURI} aUri - The uri to request + */ + constructor(aSession, aCalendar, aUri) { + super(aSession, aCalendar, aUri, channel => { + channel.requestMethod = "OPTIONS"; + }); + } + + /** + * @returns {object} The class of the response for this request + */ + get responseClass() { + return DAVHeaderResponse; + } +} + +/** + * The response class for the dav header request + */ +class DAVHeaderResponse extends CalDavSimpleResponse { + /** + * Returns a Set with the DAV features, not including the version + */ + get features() { + if (!this._features) { + let dav = this.getHeader("dav") || ""; + let features = dav.split(/,\s*/); + features.shift(); + this._features = new Set(features); + } + return this._features; + } + + /** + * The version from the DAV header + */ + get version() { + let dav = this.getHeader("dav"); + return parseInt(dav.substr(0, dav.indexOf(",")), 10); + } +} + +/** + * Request class for principal-property-search queries + */ +class CalDavPrincipalPropertySearchRequest extends CalDavRequestBase { + /** + * Constructs a principal-property-search query. + * + * @param {CalDavSession} aSession - The session to use for this request + * @param {calICalendar} aCalendar - The calendar this request belongs to + * @param {nsIURI} aUri - The uri to request + * @param {string} aMatch - The href to search in + * @param {string} aSearchProp - The property to search for + * @param {string[]} aProps - The properties to retrieve + * @param {number} aDepth - The depth of the query, defaults to 1 + */ + constructor(aSession, aCalendar, aUri, aMatch, aSearchProp, aProps, aDepth = 1) { + let xml = + XML_HEADER + + `<D:principal-property-search ${CalDavTagsToXmlns("D", aSearchProp, ...aProps)}>` + + "<D:property-search>" + + "<D:prop>" + + `<${aSearchProp}/>` + + "</D:prop>" + + `<D:match>${cal.xml.escapeString(aMatch)}</D:match>` + + "</D:property-search>" + + "<D:prop>" + + aProps.map(prop => `<${prop}/>`).join("") + + "</D:prop>" + + "</D:principal-property-search>"; + + super(aSession, aCalendar, aUri, xml, MIME_TEXT_XML, channel => { + channel.setRequestHeader("Depth", aDepth, false); + channel.requestMethod = "REPORT"; + }); + } + + /** + * @returns {object} The class of the response for this request + */ + get responseClass() { + return PropfindResponse; + } +} + +/** + * Request class for calendar outbox queries, to send or respond to invitations + */ +class CalDavOutboxRequest extends CalDavRequestBase { + /** + * Constructs an outbox request + * + * @param {CalDavSession} aSession - The session to use for this request + * @param {calICalendar} aCalendar - The calendar this request belongs to + * @param {nsIURI} aUri - The uri to request + * @param {string} aOrganizer - The organizer of the request + * @param {string} aRecipients - The recipients of the request + * @param {string} aResponseMethod - The itip response method, e.g. REQUEST,REPLY + * @param {calIItemBase} aItem - The item to send + */ + constructor(aSession, aCalendar, aUri, aOrganizer, aRecipients, aResponseMethod, aItem) { + aItem = fixGoogleDescription(aItem, aUri); + let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance( + Ci.calIIcsSerializer + ); + serializer.addItems([aItem], 1); + + let method = cal.icsService.createIcalProperty("METHOD"); + method.value = aResponseMethod; + serializer.addProperty(method); + + super( + aSession, + aCalendar, + aUri, + serializer.serializeToString(), + MIME_TEXT_CALENDAR, + channel => { + channel.requestMethod = "POST"; + channel.setRequestHeader("Originator", aOrganizer, false); + for (let recipient of aRecipients) { + channel.setRequestHeader("Recipient", recipient, true); + } + } + ); + } + + /** + * @returns {object} The class of the response for this request + */ + get responseClass() { + return OutboxResponse; + } +} + +/** + * Response class for the caldav outbox request + */ +class OutboxResponse extends CalDavSimpleResponse { + /** + * An object with the recipients as keys, and the request status as values + */ + get data() { + if (!this._data) { + this._data = {}; + // TODO The following queries are currently untested code, as I don't have + // a caldav-sched server available. If you find someone who does, please test! + for (let response of this.xml.querySelectorAll(":scope > response")) { + let recipient = response.querySelector(":scope > recipient > href").textContent; + let status = response.querySelector(":scope > request-status").textContent; + this.data[recipient] = status; + } + } + return this._data; + } + + /** If the response has a success code */ + get ok() { + return this.status == 200 && this.xml; + } +} + +/** + * Request class for freebusy queries + */ +class CalDavFreeBusyRequest extends CalDavRequestBase { + /** + * Creates a freebusy request, for the specified range + * + * @param {CalDavSession} aSession - The session to use for this request + * @param {calICalendar} aCalendar - The calendar this request belongs to + * @param {nsIURI} aUri - The uri to request + * @param {string} aOrganizer - The organizer of the request + * @param {string} aRecipient - The attendee to look up + * @param {calIDateTime} aRangeStart - The start of the range + * @param {calIDateTime} aRangeEnd - The end of the range + */ + constructor(aSession, aCalendar, aUri, aOrganizer, aRecipient, aRangeStart, aRangeEnd) { + let vcalendar = cal.icsService.createIcalComponent("VCALENDAR"); + cal.item.setStaticProps(vcalendar); + + let method = cal.icsService.createIcalProperty("METHOD"); + method.value = "REQUEST"; + vcalendar.addProperty(method); + + let freebusy = cal.icsService.createIcalComponent("VFREEBUSY"); + freebusy.uid = cal.getUUID(); + freebusy.stampTime = cal.dtz.now().getInTimezone(cal.dtz.UTC); + freebusy.startTime = aRangeStart.getInTimezone(cal.dtz.UTC); + freebusy.endTime = aRangeEnd.getInTimezone(cal.dtz.UTC); + vcalendar.addSubcomponent(freebusy); + + let organizer = cal.icsService.createIcalProperty("ORGANIZER"); + organizer.value = aOrganizer; + freebusy.addProperty(organizer); + + let attendee = cal.icsService.createIcalProperty("ATTENDEE"); + attendee.setParameter("PARTSTAT", "NEEDS-ACTION"); + attendee.setParameter("ROLE", "REQ-PARTICIPANT"); + attendee.setParameter("CUTYPE", "INDIVIDUAL"); + attendee.value = aRecipient; + freebusy.addProperty(attendee); + + super(aSession, aCalendar, aUri, vcalendar.serializeToICS(), MIME_TEXT_CALENDAR, channel => { + channel.requestMethod = "POST"; + channel.setRequestHeader("Originator", aOrganizer, false); + channel.setRequestHeader("Recipient", aRecipient, false); + }); + + this._rangeStart = aRangeStart; + this._rangeEnd = aRangeEnd; + } + + /** + * @returns {object} The class of the response for this request + */ + get responseClass() { + return FreeBusyResponse; + } +} + +/** + * Response class for the freebusy request + */ +class FreeBusyResponse extends CalDavSimpleResponse { + /** + * Quick access to the freebusy response data. An object is returned with the keys being + * recipients: + * + * { + * "mailto:user@example.com": { + * status: "HTTP/1.1 200 OK", + * intervals: [ + * { type: "BUSY", begin: ({calIDateTime}), end: ({calIDateTime or calIDuration}) }, + * { type: "FREE", begin: ({calIDateTime}), end: ({calIDateTime or calIDuration}) } + * ] + * } + * } + */ + get data() { + /** + * Helper to get the trimmed text content + * + * @param {Element} aParent - The parent node to search in + * @param {string} aPath - The css query path to serch + * @returns {string} The trimmed text content + */ + function querySelectorText(aParent, aPath) { + let node = aParent.querySelector(aPath); + return node ? node.textContent.trim() : ""; + } + + if (!this._data) { + this._data = {}; + for (let response of this.xml.querySelectorAll(":scope > response")) { + let recipient = querySelectorText(response, ":scope > recipient > href"); + let status = querySelectorText(response, ":scope > request-status"); + let caldata = querySelectorText(response, ":scope > calendar-data"); + let intervals = []; + if (caldata) { + let component; + try { + component = cal.icsService.parseICS(caldata); + } catch (e) { + cal.LOG("CalDAV: Could not parse freebusy data: " + e); + continue; + } + + for (let fbcomp of cal.iterate.icalComponent(component, "VFREEBUSY")) { + let fbstart = fbcomp.startTime; + if (fbstart && this.request._rangeStart.compare(fbstart) < 0) { + intervals.push({ + type: "UNKNOWN", + begin: this.request._rangeStart, + end: fbstart, + }); + } + + for (let fbprop of cal.iterate.icalProperty(fbcomp, "FREEBUSY")) { + let type = fbprop.getParameter("FBTYPE"); + + let parts = fbprop.value.split("/"); + let begin = cal.createDateTime(parts[0]); + let end; + if (parts[1].startsWith("P")) { + // this is a duration + end = begin.clone(); + end.addDuration(cal.createDuration(parts[1])); + } else { + // This is a date string + end = cal.createDateTime(parts[1]); + } + + intervals.push({ type, begin, end }); + } + + let fbend = fbcomp.endTime; + if (fbend && this.request._rangeEnd.compare(fbend) > 0) { + intervals.push({ + type: "UNKNOWN", + begin: fbend, + end: this.request._rangeEnd, + }); + } + } + } + this._data[recipient] = { status, intervals }; + } + } + return this._data; + } + + /** + * The data for the first recipient, useful if just one recipient was requested + */ + get firstRecipient() { + return Object.values(this.data)[0]; + } +} + +/** + * Set item description to a format Google Calendar understands if the item + * will be uploaded to Google Calendar. + * + * @param {calIItemBase} aItem - The item we may want to modify. + * @param {nsIURI} aUri - The URI the item will be uploaded to. + * @returns {calItemBase} - A calendar item with appropriately-set description. + */ +function fixGoogleDescription(aItem, aUri) { + if (aUri.spec.startsWith("https://apidata.googleusercontent.com/caldav/")) { + // Google expects item descriptions to be bare HTML in violation of spec, + // rather than using the standard Alternate Text Representation. + aItem = aItem.clone(); + aItem.descriptionText = aItem.descriptionHTML; + + // Mark items we've modified for Google compatibility for informational + // purposes. + aItem.setProperty("X-MOZ-GOOGLE-HTML-DESCRIPTION", true); + } + + return aItem; +} diff --git a/comm/calendar/providers/caldav/modules/CalDavRequestHandlers.jsm b/comm/calendar/providers/caldav/modules/CalDavRequestHandlers.jsm new file mode 100644 index 0000000000..c5055d1a1f --- /dev/null +++ b/comm/calendar/providers/caldav/modules/CalDavRequestHandlers.jsm @@ -0,0 +1,1091 @@ +/* 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 { CalDavLegacySAXRequest } = ChromeUtils.import("resource:///modules/caldav/CalDavRequest.jsm"); + +/* exported CalDavEtagsHandler, CalDavWebDavSyncHandler, CalDavMultigetSyncHandler */ + +const EXPORTED_SYMBOLS = [ + "CalDavEtagsHandler", + "CalDavWebDavSyncHandler", + "CalDavMultigetSyncHandler", +]; + +const XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>\n'; +const MIME_TEXT_XML = "text/xml; charset=utf-8"; + +/** + * Accumulate all XML response, then parse with DOMParser. This class imitates + * nsISAXXMLReader by calling startDocument/endDocument and startElement/endElement. + */ +class XMLResponseHandler { + constructor() { + this._inStream = Cc["@mozilla.org/scriptableinputstream;1"].createInstance( + Ci.nsIScriptableInputStream + ); + this._xmlString = ""; + } + + /** + * @see nsIStreamListener + */ + onDataAvailable(request, inputStream, offset, count) { + this._inStream.init(inputStream); + // What we get from inputStream is BinaryString, decode it to UTF-8. + this._xmlString += new TextDecoder("UTF-8").decode( + this._binaryStringToTypedArray(this._inStream.read(count)) + ); + } + + /** + * Log the response code and body. + * + * @param {number} responseStatus + */ + logResponse(responseStatus) { + if (this.calendar.verboseLogging()) { + cal.LOG(`CalDAV: recv (${responseStatus}): ${this._xmlString}`); + } + } + + /** + * Parse this._xmlString with DOMParser, then create a TreeWalker and start + * walking the node tree. + */ + async handleResponse() { + let parser = new DOMParser(); + let doc; + try { + doc = parser.parseFromString(this._xmlString, "application/xml"); + } catch (e) { + cal.ERROR("CALDAV: DOMParser parse error: ", e); + this.fatalError(); + } + + let treeWalker = doc.createTreeWalker(doc.documentElement, NodeFilter.SHOW_ELEMENT); + this.startDocument(); + await this._walk(treeWalker); + await this.endDocument(); + } + + /** + * Reset this._xmlString. + */ + resetXMLResponseHandler() { + this._xmlString = ""; + } + + /** + * Converts a binary string into a Uint8Array. + * + * @param {BinaryString} str - The string to convert. + * @returns {Uint8Array}. + */ + _binaryStringToTypedArray(str) { + let arr = new Uint8Array(str.length); + for (let i = 0; i < str.length; i++) { + arr[i] = str.charCodeAt(i); + } + return arr; + } + + /** + * Walk the tree node by node, call startElement and endElement when appropriate. + */ + async _walk(treeWalker) { + let currentNode = treeWalker.currentNode; + if (currentNode) { + this.startElement("", currentNode.localName, currentNode.nodeName, ""); + + // Traverse children first. + let firstChild = treeWalker.firstChild(); + if (firstChild) { + await this._walk(treeWalker); + // TreeWalker has reached a leaf node, reset the cursor to continue the traversal. + treeWalker.currentNode = firstChild; + } else { + this.characters(currentNode.textContent); + await this.endElement("", currentNode.localName, currentNode.nodeName); + return; + } + + // Traverse siblings next. + let nextSibling = treeWalker.nextSibling(); + while (nextSibling) { + await this._walk(treeWalker); + // TreeWalker has reached a leaf node, reset the cursor to continue the traversal. + treeWalker.currentNode = nextSibling; + nextSibling = treeWalker.nextSibling(); + } + + await this.endElement("", currentNode.localName, currentNode.nodeName); + } + } +} + +/** + * This is a handler for the etag request in calDavCalendar.js' getUpdatedItem. + * It uses XMLResponseHandler to parse the items and compose the resulting + * multiget. + */ +class CalDavEtagsHandler extends XMLResponseHandler { + /** + * @param {calDavCalendar} aCalendar - The (unwrapped) calendar this request belongs to. + * @param {nsIURI} aBaseUri - The URI requested (i.e inbox or collection). + * @param {*=} aChangeLogListener - (optional) for cached calendars, the listener to notify. + */ + constructor(aCalendar, aBaseUri, aChangeLogListener) { + super(); + this.calendar = aCalendar; + this.baseUri = aBaseUri; + this.changeLogListener = aChangeLogListener; + + this.itemsReported = {}; + this.itemsNeedFetching = []; + } + + skipIndex = -1; + currentResponse = null; + tag = null; + calendar = null; + baseUri = null; + changeLogListener = null; + logXML = ""; + + itemsReported = null; + itemsNeedFetching = null; + + QueryInterface = ChromeUtils.generateQI(["nsIRequestObserver", "nsIStreamListener"]); + + /** + * @see nsIRequestObserver + */ + onStartRequest(request) { + let httpchannel = request.QueryInterface(Ci.nsIHttpChannel); + + let responseStatus; + try { + responseStatus = httpchannel.responseStatus; + } catch (ex) { + cal.WARN("CalDAV: No response status getting etags for calendar " + this.calendar.name); + } + + if (responseStatus == 207) { + // We only need to parse 207's, anything else is probably a + // server error (i.e 50x). + httpchannel.contentType = "application/xml"; + } else { + cal.LOG("CalDAV: Error fetching item etags"); + this.calendar.reportDavError(Ci.calIErrors.DAV_REPORT_ERROR); + if (this.calendar.isCached && this.changeLogListener) { + this.changeLogListener.onResult({ status: Cr.NS_ERROR_FAILURE }, Cr.NS_ERROR_FAILURE); + } + } + } + + async onStopRequest(request, statusCode) { + let httpchannel = request.QueryInterface(Ci.nsIHttpChannel); + + let responseStatus; + try { + responseStatus = httpchannel.responseStatus; + } catch (ex) { + cal.WARN("CalDAV: No response status getting etags for calendar " + this.calendar.name); + } + + this.logResponse(responseStatus); + + if (responseStatus != 207) { + // Not a successful response, do nothing. + if (this.calendar.isCached && this.changeLogListener) { + this.changeLogListener.onResult({ status: Cr.NS_ERROR_FAILURE }, Cr.NS_ERROR_FAILURE); + } + return; + } + + await this.handleResponse(); + + // Now that we are done, check which items need fetching. + this.calendar.superCalendar.startBatch(); + + let needsRefresh = false; + try { + for (let path in this.calendar.mHrefIndex) { + if (path in this.itemsReported || path.substr(0, this.baseUri.length) == this.baseUri) { + // If the item is also on the server, check the next. + continue; + } + // If an item has been deleted from the server, delete it here too. + // Since the target calendar's operations are synchronous, we can + // safely set variables from this function. + let foundItem = await this.calendar.mOfflineStorage.getItem(this.calendar.mHrefIndex[path]); + + if (foundItem) { + let wasInboxItem = this.calendar.mItemInfoCache[foundItem.id].isInboxItem; + if ( + (wasInboxItem && this.calendar.isInbox(this.baseUri.spec)) || + (wasInboxItem === false && !this.calendar.isInbox(this.baseUri.spec)) + ) { + cal.LOG("Deleting local href: " + path); + delete this.calendar.mHrefIndex[path]; + await this.calendar.mOfflineStorage.deleteItem(foundItem); + needsRefresh = true; + } + } + } + } finally { + this.calendar.superCalendar.endBatch(); + } + + // Avoid sending empty multiget requests update views if something has + // been deleted server-side. + if (this.itemsNeedFetching.length) { + let multiget = new CalDavMultigetSyncHandler( + this.itemsNeedFetching, + this.calendar, + this.baseUri, + null, + false, + null, + this.changeLogListener + ); + multiget.doMultiGet(); + } else { + if (this.calendar.isCached && this.changeLogListener) { + this.changeLogListener.onResult({ status: Cr.NS_OK }, Cr.NS_OK); + } + + if (needsRefresh) { + this.calendar.mObservers.notify("onLoad", [this.calendar]); + } + + // but do poll the inbox + if (this.calendar.mShouldPollInbox && !this.calendar.isInbox(this.baseUri.spec)) { + this.calendar.pollInbox(); + } + } + } + + /** + * @see XMLResponseHandler + */ + fatalError() { + cal.WARN("CalDAV: Fatal Error parsing etags for " + this.calendar.name); + } + + /** + * @see XMLResponseHandler + */ + characters(aValue) { + if (this.calendar.verboseLogging()) { + this.logXML += aValue; + } + if (this.tag) { + this.currentResponse[this.tag] += aValue; + } + } + + startDocument() { + this.hrefMap = {}; + this.currentResponse = {}; + this.tag = null; + } + + endDocument() {} + + startElement(aUri, aLocalName, aQName, aAttributes) { + switch (aLocalName) { + case "response": + this.currentResponse = {}; + this.currentResponse.isCollection = false; + this.tag = null; + break; + case "collection": + this.currentResponse.isCollection = true; + // falls through + case "href": + case "getetag": + case "getcontenttype": + this.tag = aLocalName; + this.currentResponse[aLocalName] = ""; + break; + } + if (this.calendar.verboseLogging()) { + this.logXML += "<" + aQName + ">"; + } + } + + endElement(aUri, aLocalName, aQName) { + switch (aLocalName) { + case "response": { + this.tag = null; + let resp = this.currentResponse; + if ( + resp.getetag && + resp.getetag.length && + resp.href && + resp.href.length && + resp.getcontenttype && + resp.getcontenttype.length && + !resp.isCollection + ) { + resp.href = this.calendar.ensureDecodedPath(resp.href); + + if (resp.getcontenttype.substr(0, 14) == "message/rfc822") { + // workaround for a Scalix bug which causes incorrect + // contenttype to be returned. + resp.getcontenttype = "text/calendar"; + } + if (resp.getcontenttype == "text/vtodo") { + // workaround Kerio weirdness + resp.getcontenttype = "text/calendar"; + } + + // Only handle calendar items + if (resp.getcontenttype.substr(0, 13) == "text/calendar") { + if (resp.href && resp.href.length) { + this.itemsReported[resp.href] = resp.getetag; + + let itemUid = this.calendar.mHrefIndex[resp.href]; + if (!itemUid || resp.getetag != this.calendar.mItemInfoCache[itemUid].etag) { + this.itemsNeedFetching.push(resp.href); + } + } + } + } + break; + } + case "href": + case "getetag": + case "getcontenttype": { + this.tag = null; + break; + } + } + if (this.calendar.verboseLogging()) { + this.logXML += "</" + aQName + ">"; + } + } + + processingInstruction(aTarget, aData) {} +} + +/** + * This is a handler for the webdav sync request in calDavCalendar.js' + * getUpdatedItem. It uses XMLResponseHandler to parse the items and compose the + * resulting multiget. + */ +class CalDavWebDavSyncHandler extends XMLResponseHandler { + /** + * @param {calDavCalendar} aCalendar - The (unwrapped) calendar this request belongs to. + * @param {nsIURI} aBaseUri - The URI requested (i.e inbox or collection). + * @param {*=} aChangeLogListener - (optional) for cached calendars, the listener to notify. + */ + constructor(aCalendar, aBaseUri, aChangeLogListener) { + super(); + this.calendar = aCalendar; + this.baseUri = aBaseUri; + this.changeLogListener = aChangeLogListener; + + this.itemsReported = {}; + this.itemsNeedFetching = []; + } + + currentResponse = null; + tag = null; + calendar = null; + baseUri = null; + newSyncToken = null; + changeLogListener = null; + logXML = ""; + isInPropStat = false; + changeCount = 0; + unhandledErrors = 0; + itemsReported = null; + itemsNeedFetching = null; + additionalSyncNeeded = false; + + QueryInterface = ChromeUtils.generateQI(["nsIRequestObserver", "nsIStreamListener"]); + + async doWebDAVSync() { + if (this.calendar.mDisabledByDavError) { + // check if maybe our calendar has become available + this.calendar.checkDavResourceType(this.changeLogListener); + return; + } + + let syncTokenString = "<sync-token/>"; + if (this.calendar.mWebdavSyncToken && this.calendar.mWebdavSyncToken.length > 0) { + let syncToken = cal.xml.escapeString(this.calendar.mWebdavSyncToken); + syncTokenString = "<sync-token>" + syncToken + "</sync-token>"; + } + + let queryXml = + XML_HEADER + + '<sync-collection xmlns="DAV:">' + + syncTokenString + + "<sync-level>1</sync-level>" + + "<prop>" + + "<getcontenttype/>" + + "<getetag/>" + + "</prop>" + + "</sync-collection>"; + + let requestUri = this.calendar.makeUri(null, this.baseUri); + + if (this.calendar.verboseLogging()) { + cal.LOG(`CalDAV: send (REPORT ${requestUri.spec}): ${queryXml}`); + } + cal.LOG("CalDAV: webdav-sync Token: " + this.calendar.mWebdavSyncToken); + + let onSetupChannel = channel => { + // The depth header adheres to an older version of the webdav-sync + // spec and has been replaced by the <sync-level> tag above. + // Unfortunately some servers still depend on the depth header, + // therefore we send both (yuck). + channel.setRequestHeader("Depth", "1", false); + channel.requestMethod = "REPORT"; + }; + let request = new CalDavLegacySAXRequest( + this.calendar.session, + this.calendar, + requestUri, + queryXml, + MIME_TEXT_XML, + this, + onSetupChannel + ); + + await request.commit().catch(() => { + // Something went wrong with the OAuth token, notify failure + if (this.calendar.isCached && this.changeLogListener) { + this.changeLogListener.onResult( + { status: Cr.NS_ERROR_NOT_AVAILABLE }, + Cr.NS_ERROR_NOT_AVAILABLE + ); + } + }); + } + + /** + * @see nsIRequestObserver + */ + onStartRequest(request) { + let httpchannel = request.QueryInterface(Ci.nsIHttpChannel); + + let responseStatus; + try { + responseStatus = httpchannel.responseStatus; + } catch (ex) { + cal.WARN("CalDAV: No response status doing webdav sync for calendar " + this.calendar.name); + } + + if (responseStatus == 207) { + // We only need to parse 207's, anything else is probably a + // server error (i.e 50x). + httpchannel.contentType = "application/xml"; + } + } + + async onStopRequest(request, statusCode) { + let httpchannel = request.QueryInterface(Ci.nsIHttpChannel); + + let responseStatus; + try { + responseStatus = httpchannel.responseStatus; + } catch (ex) { + cal.WARN("CalDAV: No response status doing webdav sync for calendar " + this.calendar.name); + } + + this.logResponse(responseStatus); + + if (responseStatus == 207) { + await this.handleResponse(); + } else if ( + (responseStatus == 403 && this._xmlString.includes(`<D:error xmlns:D="DAV:"/>`)) || + responseStatus == 429 + ) { + // We're hitting the rate limit. Don't attempt to refresh now. + cal.WARN("CalDAV: rate limit reached, server returned status code: " + responseStatus); + if (this.calendar.isCached && this.changeLogListener) { + // Not really okay, but we have to return something and an error code puts us in a bad state. + this.changeLogListener.onResult({ status: Cr.NS_ERROR_FAILURE }, Cr.NS_ERROR_FAILURE); + } + } else if ( + this.calendar.mWebdavSyncToken != null && + responseStatus >= 400 && + responseStatus <= 499 + ) { + // Invalidate sync token with 4xx errors that could indicate the + // sync token has become invalid and do a refresh. + cal.LOG( + "CalDAV: Resetting sync token because server returned status code: " + responseStatus + ); + this.calendar.mWebdavSyncToken = null; + this.calendar.saveCalendarProperties(); + this.calendar.safeRefresh(this.changeLogListener); + } else { + cal.WARN("CalDAV: Error doing webdav sync: " + responseStatus); + this.calendar.reportDavError(Ci.calIErrors.DAV_REPORT_ERROR); + if (this.calendar.isCached && this.changeLogListener) { + this.changeLogListener.onResult({ status: Cr.NS_ERROR_FAILURE }, Cr.NS_ERROR_FAILURE); + } + } + } + + /** + * @see XMLResponseHandler + */ + fatalError() { + cal.WARN("CalDAV: Fatal Error doing webdav sync for " + this.calendar.name); + } + + /** + * @see XMLResponseHandler + */ + characters(aValue) { + if (this.calendar.verboseLogging()) { + this.logXML += aValue; + } + this.currentResponse[this.tag] += aValue; + } + + startDocument() { + this.hrefMap = {}; + this.currentResponse = {}; + this.tag = null; + this.calendar.superCalendar.startBatch(); + } + + async endDocument() { + if (this.unhandledErrors) { + this.calendar.superCalendar.endBatch(); + this.calendar.reportDavError(Ci.calIErrors.DAV_REPORT_ERROR); + if (this.calendar.isCached && this.changeLogListener) { + this.changeLogListener.onResult({ status: Cr.NS_ERROR_FAILURE }, Cr.NS_ERROR_FAILURE); + } + return; + } + + if (this.calendar.mWebdavSyncToken == null && !this.additionalSyncNeeded) { + // null token means reset or first refresh indicating we did + // a full sync; remove local items that were not returned in this full + // sync + for (let path in this.calendar.mHrefIndex) { + if (!this.itemsReported[path]) { + await this.calendar.deleteTargetCalendarItem(path); + } + } + } + this.calendar.superCalendar.endBatch(); + + if (this.itemsNeedFetching.length) { + let multiget = new CalDavMultigetSyncHandler( + this.itemsNeedFetching, + this.calendar, + this.baseUri, + this.newSyncToken, + this.additionalSyncNeeded, + null, + this.changeLogListener + ); + multiget.doMultiGet(); + } else { + if (this.newSyncToken) { + this.calendar.mWebdavSyncToken = this.newSyncToken; + this.calendar.saveCalendarProperties(); + cal.LOG("CalDAV: New webdav-sync Token: " + this.calendar.mWebdavSyncToken); + + if (this.additionalSyncNeeded) { + let wds = new CalDavWebDavSyncHandler( + this.calendar, + this.baseUri, + this.changeLogListener + ); + wds.doWebDAVSync(); + return; + } + } + this.calendar.finalizeUpdatedItems(this.changeLogListener, this.baseUri); + } + } + + startElement(aUri, aLocalName, aQName, aAttributes) { + switch (aLocalName) { + case "response": // WebDAV Sync draft 3 + this.currentResponse = {}; + this.tag = null; + this.isInPropStat = false; + break; + case "propstat": + this.isInPropStat = true; + break; + case "status": + if (this.isInPropStat) { + this.tag = "propstat_" + aLocalName; + } else { + this.tag = aLocalName; + } + this.currentResponse[this.tag] = ""; + break; + case "href": + case "getetag": + case "getcontenttype": + case "sync-token": + this.tag = aLocalName.replace(/-/g, ""); + this.currentResponse[this.tag] = ""; + break; + } + if (this.calendar.verboseLogging()) { + this.logXML += "<" + aQName + ">"; + } + } + + async endElement(aUri, aLocalName, aQName) { + switch (aLocalName) { + case "response": // WebDAV Sync draft 3 + case "sync-response": { + // WebDAV Sync draft 0,1,2 + let resp = this.currentResponse; + if (resp.href && resp.href.length) { + resp.href = this.calendar.ensureDecodedPath(resp.href); + } + + if ( + (!resp.getcontenttype || resp.getcontenttype == "text/plain") && + resp.href && + resp.href.endsWith(".ics") + ) { + // If there is no content-type (iCloud) or text/plain was passed + // (iCal Server) for the resource but its name ends with ".ics" + // assume the content type to be text/calendar. Apple + // iCloud/iCal Server interoperability fix. + resp.getcontenttype = "text/calendar"; + } + + // Deleted item + if ( + resp.href && + resp.href.length && + resp.status && + resp.status.length && + resp.status.indexOf(" 404") > 0 + ) { + if (this.calendar.mHrefIndex[resp.href]) { + this.changeCount++; + await this.calendar.deleteTargetCalendarItem(resp.href); + } else { + cal.LOG("CalDAV: skipping unfound deleted item : " + resp.href); + } + // Only handle Created or Updated calendar items + } else if ( + resp.getcontenttype && + resp.getcontenttype.substr(0, 13) == "text/calendar" && + resp.getetag && + resp.getetag.length && + resp.href && + resp.href.length && + (!resp.status || // Draft 3 does not require + resp.status.length == 0 || // a status for created or updated items but + resp.status.indexOf(" 204") || // draft 0, 1 and 2 needed it so treat no status + resp.status.indexOf(" 200") || // Apple iCloud returns 200 status for each item + resp.status.indexOf(" 201")) + ) { + // and status 201 and 204 the same + this.itemsReported[resp.href] = resp.getetag; + let itemId = this.calendar.mHrefIndex[resp.href]; + let oldEtag = itemId && this.calendar.mItemInfoCache[itemId].etag; + + if (!oldEtag || oldEtag != resp.getetag) { + // Etag mismatch, getting new/updated item. + this.itemsNeedFetching.push(resp.href); + } + } else if (resp.status && resp.status.includes(" 507")) { + // webdav-sync says that if a 507 is encountered and the + // url matches the request, the current token should be + // saved and another request should be made. We don't + // actually compare the URL, its too easy to get this + // wrong. + + // The 507 doesn't mean the data received is invalid, so + // continue processing. + this.additionalSyncNeeded = true; + } else if ( + resp.status && + resp.status.indexOf(" 200") && + resp.href && + resp.href.endsWith("/") + ) { + // iCloud returns status responses for directories too + // so we just ignore them if they have status code 200. We + // want to make sure these are not counted as unhandled + // errors in the next block + } else if ( + (resp.getcontenttype && resp.getcontenttype.startsWith("text/calendar")) || + (resp.status && !resp.status.includes(" 404")) + ) { + // If the response element is still not handled, log an + // error only if the content-type is text/calendar or the + // response status is different than 404 not found. We + // don't care about response elements on non-calendar + // resources or whose status is not indicating a deleted + // resource. + cal.WARN("CalDAV: Unexpected response, status: " + resp.status + ", href: " + resp.href); + this.unhandledErrors++; + } else { + cal.LOG( + "CalDAV: Unhandled response element, status: " + + resp.status + + ", href: " + + resp.href + + " contenttype:" + + resp.getcontenttype + ); + } + break; + } + case "sync-token": { + this.newSyncToken = this.currentResponse[this.tag]; + break; + } + case "propstat": { + this.isInPropStat = false; + break; + } + } + this.tag = null; + if (this.calendar.verboseLogging()) { + this.logXML += "</" + aQName + ">"; + } + } + + processingInstruction(aTarget, aData) {} +} + +/** + * This is a handler for the multiget request. It uses XMLResponseHandler to + * parse the items and compose the resulting multiget. + */ +class CalDavMultigetSyncHandler extends XMLResponseHandler { + /** + * @param {string[]} aItemsNeedFetching - Array of items to fetch, an array of + * un-encoded paths. + * @param {calDavCalendar} aCalendar - The (unwrapped) calendar this request belongs to. + * @param {nsIURI} aBaseUri - The URI requested (i.e inbox or collection). + * @param {*=} aNewSyncToken - (optional) New Sync token to set if operation successful. + * @param {boolean=} aAdditionalSyncNeeded - (optional) If true, the passed sync token is not the + * latest, another webdav sync run should be + * done after completion. + * @param {*=} aListener - (optional) The listener to notify. + * @param {*=} aChangeLogListener - (optional) For cached calendars, the listener to + * notify. + */ + constructor( + aItemsNeedFetching, + aCalendar, + aBaseUri, + aNewSyncToken, + aAdditionalSyncNeeded, + aListener, + aChangeLogListener + ) { + super(); + this.calendar = aCalendar; + this.baseUri = aBaseUri; + this.listener = aListener; + this.newSyncToken = aNewSyncToken; + this.changeLogListener = aChangeLogListener; + this.itemsNeedFetching = aItemsNeedFetching; + this.additionalSyncNeeded = aAdditionalSyncNeeded; + } + + currentResponse = null; + tag = null; + calendar = null; + baseUri = null; + newSyncToken = null; + listener = null; + changeLogListener = null; + logXML = null; + unhandledErrors = 0; + itemsNeedFetching = null; + additionalSyncNeeded = false; + timer = null; + + QueryInterface = ChromeUtils.generateQI(["nsIRequestObserver", "nsIStreamListener"]); + + doMultiGet() { + if (this.calendar.mDisabledByDavError) { + // check if maybe our calendar has become available + this.calendar.checkDavResourceType(this.changeLogListener); + return; + } + + let batchSize = Services.prefs.getIntPref("calendar.caldav.multigetBatchSize", 100); + let hrefString = ""; + while (this.itemsNeedFetching.length && batchSize > 0) { + batchSize--; + // ensureEncodedPath extracts only the path component of the item and + // encodes it before it is sent to the server + let locpath = this.calendar.ensureEncodedPath(this.itemsNeedFetching.pop()); + hrefString += "<D:href>" + cal.xml.escapeString(locpath) + "</D:href>"; + } + + let queryXml = + XML_HEADER + + '<C:calendar-multiget xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">' + + "<D:prop>" + + "<D:getetag/>" + + "<C:calendar-data/>" + + "</D:prop>" + + hrefString + + "</C:calendar-multiget>"; + + let requestUri = this.calendar.makeUri(null, this.baseUri); + if (this.calendar.verboseLogging()) { + cal.LOG(`CalDAV: send (REPORT ${requestUri.spec}): ${queryXml}`); + } + + let onSetupChannel = channel => { + channel.requestMethod = "REPORT"; + channel.setRequestHeader("Depth", "1", false); + }; + let request = new CalDavLegacySAXRequest( + this.calendar.session, + this.calendar, + requestUri, + queryXml, + MIME_TEXT_XML, + this, + onSetupChannel + ); + + request.commit().catch(() => { + // Something went wrong with the OAuth token, notify failure + if (this.calendar.isCached && this.changeLogListener) { + this.changeLogListener.onResult( + { status: Cr.NS_ERROR_NOT_AVAILABLE }, + Cr.NS_ERROR_NOT_AVAILABLE + ); + } + }); + } + + /** + * @see nsIRequestObserver + */ + onStartRequest(request) { + let httpchannel = request.QueryInterface(Ci.nsIHttpChannel); + + let responseStatus; + try { + responseStatus = httpchannel.responseStatus; + } catch (ex) { + cal.WARN("CalDAV: No response status doing multiget for calendar " + this.calendar.name); + } + + if (responseStatus == 207) { + // We only need to parse 207's, anything else is probably a + // server error (i.e 50x). + httpchannel.contentType = "application/xml"; + } else { + let errorMsg = + "CalDAV: Error: got status " + + responseStatus + + " fetching calendar data for " + + this.calendar.name + + ", " + + this.listener; + this.calendar.notifyGetFailed(errorMsg, this.listener, this.changeLogListener); + } + } + + async onStopRequest(request, statusCode) { + let httpchannel = request.QueryInterface(Ci.nsIHttpChannel); + + let responseStatus; + try { + responseStatus = httpchannel.responseStatus; + } catch (ex) { + cal.WARN("CalDAV: No response status doing multiget for calendar " + this.calendar.name); + } + + this.logResponse(responseStatus); + + if (responseStatus != 207) { + // Not a successful response, do nothing. + if (this.calendar.isCached && this.changeLogListener) { + this.changeLogListener.onResult({ status: Cr.NS_ERROR_FAILURE }, Cr.NS_ERROR_FAILURE); + } + return; + } + + if (this.unhandledErrors) { + this.calendar.superCalendar.endBatch(); + this.calendar.notifyGetFailed("multiget error", this.listener, this.changeLogListener); + return; + } + if (this.itemsNeedFetching.length == 0) { + if (this.newSyncToken) { + this.calendar.mWebdavSyncToken = this.newSyncToken; + this.calendar.saveCalendarProperties(); + cal.LOG("CalDAV: New webdav-sync Token: " + this.calendar.mWebdavSyncToken); + } + } + await this.handleResponse(); + if (this.itemsNeedFetching.length > 0) { + cal.LOG("CalDAV: Still need to fetch " + this.itemsNeedFetching.length + " elements."); + this.resetXMLResponseHandler(); + let timerCallback = { + requestHandler: this, + notify(timer) { + // Call multiget again to get another batch + this.requestHandler.doMultiGet(); + }, + }; + this.timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + this.timer.initWithCallback(timerCallback, 0, Ci.nsITimer.TYPE_ONE_SHOT); + } else if (this.additionalSyncNeeded) { + let wds = new CalDavWebDavSyncHandler(this.calendar, this.baseUri, this.changeLogListener); + wds.doWebDAVSync(); + } else { + this.calendar.finalizeUpdatedItems(this.changeLogListener, this.baseUri); + } + } + + /** + * @see XMLResponseHandler + */ + fatalError(error) { + cal.WARN("CalDAV: Fatal Error doing multiget for " + this.calendar.name + ": " + error); + } + + /** + * @see XMLResponseHandler + */ + characters(aValue) { + if (this.calendar.verboseLogging()) { + this.logXML += aValue; + } + if (this.tag) { + this.currentResponse[this.tag] += aValue; + } + } + + startDocument() { + this.hrefMap = {}; + this.currentResponse = {}; + this.tag = null; + this.logXML = ""; + this.calendar.superCalendar.startBatch(); + } + + endDocument() { + this.calendar.superCalendar.endBatch(); + } + + startElement(aUri, aLocalName, aQName, aAttributes) { + switch (aLocalName) { + case "response": + this.currentResponse = {}; + this.tag = null; + this.isInPropStat = false; + break; + case "propstat": + this.isInPropStat = true; + break; + case "status": + if (this.isInPropStat) { + this.tag = "propstat_" + aLocalName; + } else { + this.tag = aLocalName; + } + this.currentResponse[this.tag] = ""; + break; + case "calendar-data": + case "href": + case "getetag": + this.tag = aLocalName.replace(/-/g, ""); + this.currentResponse[this.tag] = ""; + break; + } + if (this.calendar.verboseLogging()) { + this.logXML += "<" + aQName + ">"; + } + } + + async endElement(aUri, aLocalName, aQName) { + switch (aLocalName) { + case "response": { + let resp = this.currentResponse; + if (resp.href && resp.href.length) { + resp.href = this.calendar.ensureDecodedPath(resp.href); + } + if ( + resp.href && + resp.href.length && + resp.status && + resp.status.length && + resp.status.indexOf(" 404") > 0 + ) { + if (this.calendar.mHrefIndex[resp.href]) { + await this.calendar.deleteTargetCalendarItem(resp.href); + } else { + cal.LOG("CalDAV: skipping unfound deleted item : " + resp.href); + } + // Created or Updated item + } else if ( + resp.getetag && + resp.getetag.length && + resp.href && + resp.href.length && + resp.calendardata && + resp.calendardata.length + ) { + let oldEtag; + let itemId = this.calendar.mHrefIndex[resp.href]; + if (itemId) { + oldEtag = this.calendar.mItemInfoCache[itemId].etag; + } else { + oldEtag = null; + } + if (!oldEtag || oldEtag != resp.getetag || this.listener) { + await this.calendar.addTargetCalendarItem( + resp.href, + resp.calendardata, + this.baseUri, + resp.getetag, + this.listener + ); + } else { + cal.LOG("CalDAV: skipping item with unmodified etag : " + oldEtag); + } + } else { + cal.WARN( + "CalDAV: Unexpected response, status: " + + resp.status + + ", href: " + + resp.href + + " calendar-data:\n" + + resp.calendardata + ); + this.unhandledErrors++; + } + break; + } + case "propstat": { + this.isInPropStat = false; + break; + } + } + this.tag = null; + if (this.calendar.verboseLogging()) { + this.logXML += "</" + aQName + ">"; + } + } + + processingInstruction(aTarget, aData) {} +} diff --git a/comm/calendar/providers/caldav/modules/CalDavSession.jsm b/comm/calendar/providers/caldav/modules/CalDavSession.jsm new file mode 100644 index 0000000000..c94bfdaff7 --- /dev/null +++ b/comm/calendar/providers/caldav/modules/CalDavSession.jsm @@ -0,0 +1,573 @@ +/* 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 { OAuth2 } = ChromeUtils.import("resource:///modules/OAuth2.jsm"); +var { setTimeout } = ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs"); +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +const lazy = {}; + +ChromeUtils.defineModuleGetter(lazy, "OAuth2Providers", "resource:///modules/OAuth2Providers.jsm"); + +/** + * Session and authentication tools for the caldav provider + */ + +const EXPORTED_SYMBOLS = ["CalDavDetectionSession", "CalDavSession"]; +/* exported CalDavDetectionSession, CalDavSession */ + +const OAUTH_GRACE_TIME = 30 * 1000; + +class CalDavOAuth extends OAuth2 { + /** + * Returns true if the token has expired, or will expire within the grace time. + */ + get tokenExpired() { + let now = new Date().getTime(); + return this.tokenExpires - OAUTH_GRACE_TIME < now; + } + + /** + * Retrieves the refresh token from the password manager. The token is cached. + */ + get refreshToken() { + cal.ASSERT(this.id, `This ${this.constructor.name} object has no id.`); + if (!this._refreshToken) { + let pass = { value: null }; + try { + cal.auth.passwordManagerGet(this.id, pass, this.origin, this.pwMgrId); + } catch (e) { + // User might have cancelled the primary password prompt, that's ok + if (e.result != Cr.NS_ERROR_ABORT) { + throw e; + } + } + this._refreshToken = pass.value; + } + return this._refreshToken; + } + + /** + * Saves the refresh token in the password manager + * + * @param {string} aVal - The value to set + */ + set refreshToken(aVal) { + try { + if (aVal) { + cal.auth.passwordManagerSave(this.id, aVal, this.origin, this.pwMgrId); + } else { + cal.auth.passwordManagerRemove(this.id, this.origin, this.pwMgrId); + } + } catch (e) { + // User might have cancelled the primary password prompt, that's ok + if (e.result != Cr.NS_ERROR_ABORT) { + throw e; + } + } + this._refreshToken = aVal; + } + + /** + * Wait for the calendar window to appear. + * + * This is a workaround for bug 901329: If the calendar window isn't loaded yet the master + * password prompt will show just the buttons and possibly hang. If we postpone until the window + * is loaded, all is well. + * + * @returns {Promise} A promise resolved without value when the window is loaded + */ + waitForCalendarWindow() { + return new Promise(resolve => { + // eslint-disable-next-line func-names, require-jsdoc + function postpone() { + let win = cal.window.getCalendarWindow(); + if (!win || win.document.readyState != "complete") { + setTimeout(postpone, 0); + } else { + resolve(); + } + } + setTimeout(postpone, 0); + }); + } + + /** + * Promisified version of |connect|, using all means necessary to gracefully display the + * authentication prompt. + * + * @param {boolean} aWithUI - If UI should be shown for authentication + * @param {boolean} aRefresh - Force refresh the token TODO default false + * @returns {Promise} A promise resolved when the OAuth process is completed + */ + promiseConnect(aWithUI = true, aRefresh = true) { + return this.waitForCalendarWindow().then(() => { + return new Promise((resolve, reject) => { + let self = this; + let asyncprompter = Cc["@mozilla.org/messenger/msgAsyncPrompter;1"].getService( + Ci.nsIMsgAsyncPrompter + ); + asyncprompter.queueAsyncAuthPrompt(this.id, false, { + onPromptStartAsync(callback) { + this.onPromptAuthAvailable(callback); + }, + + onPromptAuthAvailable(callback) { + self.connect( + () => { + if (callback) { + callback.onAuthResult(true); + } + resolve(); + }, + () => { + if (callback) { + callback.onAuthResult(false); + } + reject(); + }, + aWithUI, + aRefresh + ); + }, + onPromptCanceled: reject, + onPromptStart() {}, + }); + }); + }); + } + + /** + * Prepare the given channel for an OAuth request + * + * @param {nsIChannel} aChannel - The channel to prepare + */ + async prepareRequest(aChannel) { + if (!this.accessToken || this.tokenExpired) { + // The token has expired, we need to reauthenticate first + cal.LOG("CalDAV: OAuth token expired or empty, refreshing"); + await this.promiseConnect(); + } + + let hdr = "Bearer " + this.accessToken; + aChannel.setRequestHeader("Authorization", hdr, false); + } + + /** + * Prepare the redirect, copying the auth header to the new channel + * + * @param {nsIChannel} aOldChannel - The old channel that is being redirected + * @param {nsIChannel} aNewChannel - The new channel to prepare + */ + async prepareRedirect(aOldChannel, aNewChannel) { + try { + let hdrValue = aOldChannel.getRequestHeader("Authorization"); + if (hdrValue) { + aNewChannel.setRequestHeader("Authorization", hdrValue, false); + } + } catch (e) { + if (e.result != Cr.NS_ERROR_NOT_AVAILABLE) { + // The header could possibly not be available, ignore that + // case but throw otherwise + throw e; + } + } + } + + /** + * Check for OAuth auth errors and restart the request without a token if necessary + * + * @param {CalDavResponseBase} aResponse - The response to inspect for completion + * @returns {Promise} A promise resolved when complete, with + * CalDavSession.RESTART_REQUEST or null + */ + async completeRequest(aResponse) { + // Check for OAuth errors + let wwwauth = aResponse.getHeader("WWW-Authenticate"); + if (this.oauth && wwwauth && wwwauth.startsWith("Bearer") && wwwauth.includes("error=")) { + this.oauth.accessToken = null; + + return CalDavSession.RESTART_REQUEST; + } + return null; + } +} + +/** + * Authentication provider for Google's OAuth. + */ +class CalDavGoogleOAuth extends CalDavOAuth { + /** + * Constructs a new Google OAuth authentication provider + * + * @param {string} sessionId - The session id, used in the password manager + * @param {string} name - The user-readable description of this session + */ + constructor(sessionId, name) { + /* eslint-disable no-undef */ + super("https://www.googleapis.com/auth/calendar", { + authorizationEndpoint: "https://accounts.google.com/o/oauth2/auth", + tokenEndpoint: "https://www.googleapis.com/oauth2/v3/token", + clientId: OAUTH_CLIENT_ID, + clientSecret: OAUTH_HASH, + }); + /* eslint-enable no-undef */ + + this.id = sessionId; + this.origin = "oauth:" + sessionId; + this.pwMgrId = "Google CalDAV v2"; + + this._maybeUpgrade(name); + + this.requestWindowTitle = cal.l10n.getAnyString( + "global", + "commonDialogs", + "EnterUserPasswordFor2", + [name] + ); + this.extraAuthParams = [["login_hint", name]]; + } + + /** + * If no token is found for "Google CalDAV v2", this is either a new session (in which case + * it should use Thunderbird's credentials) or it's already using Thunderbird's credentials. + * Detect those situations and switch credentials if necessary. + */ + _maybeUpgrade() { + if (!this.refreshToken) { + const issuerDetails = lazy.OAuth2Providers.getIssuerDetails("accounts.google.com"); + this.clientId = issuerDetails.clientId; + this.consumerSecret = issuerDetails.clientSecret; + + this.origin = "oauth://accounts.google.com"; + this.pwMgrId = "https://www.googleapis.com/auth/calendar"; + } + } +} + +/** + * Authentication provider for Fastmail's OAuth. + */ +class CalDavFastmailOAuth extends CalDavOAuth { + /** + * Constructs a new Fastmail OAuth authentication provider + * + * @param {string} sessionId - The session id, used in the password manager + * @param {string} name - The user-readable description of this session + */ + constructor(sessionId, name) { + /* eslint-disable no-undef */ + super("https://www.fastmail.com/dev/protocol-caldav", { + authorizationEndpoint: "https://api.fastmail.com/oauth/authorize", + tokenEndpoint: "https://api.fastmail.com/oauth/refresh", + clientId: OAUTH_CLIENT_ID, + clientSecret: OAUTH_HASH, + usePKCE: true, + }); + /* eslint-enable no-undef */ + + this.id = sessionId; + this.origin = "oauth:" + sessionId; + this.pwMgrId = "Fastmail CalDAV"; + + this._maybeUpgrade(name); + + this.requestWindowTitle = cal.l10n.getAnyString( + "global", + "commonDialogs", + "EnterUserPasswordFor2", + [name] + ); + this.extraAuthParams = [["login_hint", name]]; + } + + /** + * If no token is found for "Fastmail CalDAV", this is either a new session (in which case + * it should use Thunderbird's credentials) or it's already using Thunderbird's credentials. + * Detect those situations and switch credentials if necessary. + */ + _maybeUpgrade() { + if (!this.refreshToken) { + const issuerDetails = lazy.OAuth2Providers.getIssuerDetails("www.fastmail.com"); + this.clientId = issuerDetails.clientId; + + this.origin = "oauth://www.fastmail.com"; + this.pwMgrId = "https://www.fastmail.com/dev/protocol-caldav"; + } + } +} + +/** + * A modified version of CalDavGoogleOAuth for testing. This class mimics the + * real class as closely as possible. + */ +class CalDavTestOAuth extends CalDavGoogleOAuth { + constructor(sessionId, name) { + super(sessionId, name); + + // Override these values with test values. + this.authorizationEndpoint = + "http://mochi.test:8888/browser/comm/mail/components/addrbook/test/browser/data/redirect_auto.sjs"; + this.tokenEndpoint = + "http://mochi.test:8888/browser/comm/mail/components/addrbook/test/browser/data/token.sjs"; + this.scope = "test_scope"; + this.clientId = "test_client_id"; + this.consumerSecret = "test_scope"; + + // I don't know why, but tests refuse to work with a plain HTTP endpoint + // (the request is redirected to HTTPS, which we're not listening to). + // Just use an HTTPS endpoint. + this.redirectionEndpoint = "https://localhost"; + } + + _maybeUpgrade() { + if (!this.refreshToken) { + const issuerDetails = lazy.OAuth2Providers.getIssuerDetails("mochi.test"); + this.clientId = issuerDetails.clientId; + this.consumerSecret = issuerDetails.clientSecret; + + this.origin = "oauth://mochi.test"; + this.pwMgrId = "test_scope"; + } + } +} + +/** + * A session for the caldav provider. Two or more calendars can share a session if they have the + * same auth credentials. + */ +class CalDavSession { + QueryInterface = ChromeUtils.generateQI(["nsIInterfaceRequestor"]); + + /** + * Dictionary of hostname => auth adapter. Before a request is made to a hostname + * in the dictionary, the auth adapter will be called to modify the request. + */ + authAdapters = {}; + + /** + * Constant returned by |completeRequest| when the request should be restarted + * + * @returns {number} The constant + */ + static get RESTART_REQUEST() { + return 1; + } + + /** + * Creates a new caldav session + * + * @param {string} aSessionId - The session id, used in the password manager + * @param {string} aName - The user-readable description of this session + */ + constructor(aSessionId, aName) { + this.id = aSessionId; + this.name = aName; + + // Only create an auth adapter if we're going to use it. + XPCOMUtils.defineLazyGetter( + this.authAdapters, + "apidata.googleusercontent.com", + () => new CalDavGoogleOAuth(aSessionId, aName) + ); + XPCOMUtils.defineLazyGetter( + this.authAdapters, + "caldav.fastmail.com", + () => new CalDavFastmailOAuth(aSessionId, aName) + ); + XPCOMUtils.defineLazyGetter( + this.authAdapters, + "mochi.test", + () => new CalDavTestOAuth(aSessionId, aName) + ); + } + + /** + * Implement nsIInterfaceRequestor. The base class has no extra interfaces, but a subclass of + * the session may. + * + * @param {nsIIDRef} aIID - The IID of the interface being requested + * @returns {?*} Either this object QI'd to the IID, or null. + * Components.returnCode is set accordingly. + */ + 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; + } + + /** + * Calls the auth adapter for the given host in case it exists. This allows delegating auth + * preparation based on the host, e.g. for OAuth. + * + * @param {string} aHost - The host to check the auth adapter for + * @param {string} aMethod - The method to call + * @param {...*} aArgs - Remaining args specific to the adapted method + * @returns {*} Return value specific to the adapter method + */ + async _callAdapter(aHost, aMethod, ...aArgs) { + let adapter = this.authAdapters[aHost] || null; + if (adapter) { + return adapter[aMethod](...aArgs); + } + return null; + } + + /** + * Prepare the channel for a request, e.g. setting custom authentication headers + * + * @param {nsIChannel} aChannel - The channel to prepare + * @returns {Promise} A promise resolved when the preparations are complete + */ + async prepareRequest(aChannel) { + return this._callAdapter(aChannel.URI.host, "prepareRequest", aChannel); + } + + /** + * Prepare the given new channel for a redirect, e.g. copying headers. + * + * @param {nsIChannel} aOldChannel - The old channel that is being redirected + * @param {nsIChannel} aNewChannel - The new channel to prepare + * @returns {Promise} A promise resolved when the preparations are complete + */ + async prepareRedirect(aOldChannel, aNewChannel) { + return this._callAdapter(aNewChannel.URI.host, "prepareRedirect", aOldChannel, aNewChannel); + } + + /** + * Complete the request based on the results from the response. Allows restarting the session if + * |CalDavSession.RESTART_REQUEST| is returned. + * + * @param {CalDavResponseBase} aResponse - The response to inspect for completion + * @returns {Promise} A promise resolved when complete, with + * CalDavSession.RESTART_REQUEST or null + */ + async completeRequest(aResponse) { + return this._callAdapter(aResponse.request.uri.host, "completeRequest", aResponse); + } +} + +/** + * A session used to detect a caldav provider when subscribing to a network calendar. + * + * @implements {nsIAuthPrompt2} + * @implements {nsIAuthPromptProvider} + * @implements {nsIInterfaceRequestor} + */ +class CalDavDetectionSession extends CalDavSession { + QueryInterface = ChromeUtils.generateQI([ + Ci.nsIAuthPrompt2, + Ci.nsIAuthPromptProvider, + Ci.nsIInterfaceRequestor, + ]); + + isDetectionSession = true; + + /** + * Create a new caldav detection session. + * + * @param {string} aUserName - The username for the session. + * @param {string} aPassword - The password for the session. + * @param {boolean} aSavePassword - Whether to save the password. + */ + constructor(aUserName, aPassword, aSavePassword) { + super(aUserName, aUserName); + this.password = aPassword; + this.savePassword = aSavePassword; + } + + /** + * Returns a plain (non-autodect) caldav session based on this session. + * + * @returns {CalDavSession} A caldav session. + */ + toBaseSession() { + return new CalDavSession(this.id, this.name); + } + + /** + * @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); + } + }); + } + + /** + * @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; + } +} + +// Before you spend time trying to find out what this means, please note that +// doing so and using the information WILL cause Google to revoke Lightning's +// privileges, which means not one Lightning user will be able to connect to +// Google Calendar via CalDAV. This will cause unhappy users all around which +// means that the Lightning developers will have to spend more time with user +// support, which means less time for features, releases and bugfixes. For a +// paid developer this would actually mean financial harm. +// +// Do you really want all of this to be your fault? Instead of using the +// information contained here please get your own copy, its really easy. +/* eslint-disable */ +// prettier-ignore +(zqdx=>{zqdx["\x65\x76\x61\x6C"](zqdx["\x41\x72\x72\x61\x79"]["\x70\x72\x6F\x74"+ +"\x6F\x74\x79\x70\x65"]["\x6D\x61\x70"]["\x63\x61\x6C\x6C"]("uijt/PBVUI`CBTF`VS"+ +"J>#iuuqt;00bddpvout/hpphmf/dpn0p0#<uijt/PBVUI`TDPQF>#iuuqt;00xxx/hpphmfbqjt/dp"+ +"n0bvui0dbmfoebs#<uijt/PBVUI`DMJFOU`JE>#831674:95649/bqqt/hpphmfvtfsdpoufou/dpn"+ +"#<uijt/PBVUI`IBTI>#zVs7YVgyvsbguj7s8{1TTfJR#<",_=>zqdx["\x53\x74\x72\x69\x6E"+ +"\x67"]["\x66\x72\x6F\x6D\x43\x68\x61\x72\x43\x6F\x64\x65"](_["\x63\x68\x61\x72"+ +"\x43\x6F\x64\x65\x41\x74"](0)-1),this)[""+"\x6A\x6F\x69\x6E"](""))})["\x63\x61"+ +"\x6C\x6C"]((this),Components["\x75\x74\x69\x6c\x73"]["\x67\x65\x74\x47\x6c\x6f"+ +"\x62\x61\x6c\x46\x6f\x72\x4f\x62\x6a\x65\x63\x74"](this)) +/* eslint-enable */ diff --git a/comm/calendar/providers/caldav/modules/CalDavUtils.jsm b/comm/calendar/providers/caldav/modules/CalDavUtils.jsm new file mode 100644 index 0000000000..63b50b7fb3 --- /dev/null +++ b/comm/calendar/providers/caldav/modules/CalDavUtils.jsm @@ -0,0 +1,110 @@ +/* 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"); + +/** + * Various utility functions for the caldav provider + */ + +/* exported CalDavXmlns, CalDavTagsToXmlns, CalDavNsUnresolver, CalDavNsResolver, CalDavXPath, + * CalDavXPathFirst */ +const EXPORTED_SYMBOLS = [ + "CalDavXmlns", + "CalDavTagsToXmlns", + "CalDavNsUnresolver", + "CalDavNsResolver", + "CalDavXPath", + "CalDavXPathFirst", +]; + +/** + * Creates an xmlns string with the requested namespace prefixes + * + * @param {...string} aRequested - The requested namespace prefixes + * @returns {string} An xmlns string that can be inserted into xml documents + */ +function CalDavXmlns(...aRequested) { + let namespaces = []; + for (let namespace of aRequested) { + let nsUri = CalDavNsResolver(namespace); + if (namespace) { + namespaces.push(`xmlns:${namespace}='${nsUri}'`); + } + } + + return namespaces.join(" "); +} + +/** + * Helper function to gather namespaces from QNames or namespace prefixes, plus a few extra for the + * remaining request. + * + * @param {...string} aTags - Either QNames, or just namespace prefixes to be resolved. + * @returns {string} The complete namespace string + */ +function CalDavTagsToXmlns(...aTags) { + let namespaces = new Set(aTags.map(tag => tag.split(":")[0])); + return CalDavXmlns(...namespaces.values()); +} + +/** + * Resolve the namespace URI to one of the prefixes used in our codebase + * + * @param {string} aNamespace - The namespace URI to resolve + * @returns {?string} The namespace prefix we use + */ +function CalDavNsUnresolver(aNamespace) { + const prefixes = { + "http://apple.com/ns/ical/": "A", + "DAV:": "D", + "urn:ietf:params:xml:ns:caldav": "C", + "http://calendarserver.org/ns/": "CS", + }; + return prefixes[aNamespace] || null; +} + +/** + * Resolve the namespace URI from one of the prefixes used in our codebase + * + * @param {string} aPrefix - The namespace prefix we use + * @returns {?string} The namespace URI for the prefix + */ +function CalDavNsResolver(aPrefix) { + /* eslint-disable id-length */ + const namespaces = { + A: "http://apple.com/ns/ical/", + D: "DAV:", + C: "urn:ietf:params:xml:ns:caldav", + CS: "http://calendarserver.org/ns/", + }; + /* eslint-enable id-length */ + + return namespaces[aPrefix] || null; +} + +/** + * Run an xpath expression on the given node, using the caldav namespace resolver + * + * @param {Element} aNode - The context node to search from + * @param {string} aExpr - The XPath expression to search for + * @param {?XPathResult} aType - (optional) Force a result type, must be an XPathResult constant + * @returns {Element[]} Array of found elements + */ +function CalDavXPath(aNode, aExpr, aType) { + return cal.xml.evalXPath(aNode, aExpr, CalDavNsResolver, aType); +} + +/** + * Run an xpath expression on the given node, using the caldav namespace resolver. Returns the first + * result. + * + * @param {Element} aNode - The context node to search from + * @param {string} aExpr - The XPath expression to search for + * @param {?XPathResult} aType - (optional) Force a result type, must be an XPathResult constant + * @returns {?Element} The found element, or null. + */ +function CalDavXPathFirst(aNode, aExpr, aType) { + return cal.xml.evalXPathFirst(aNode, aExpr, CalDavNsResolver, aType); +} diff --git a/comm/calendar/providers/caldav/moz.build b/comm/calendar/providers/caldav/moz.build new file mode 100644 index 0000000000..eecaa153ab --- /dev/null +++ b/comm/calendar/providers/caldav/moz.build @@ -0,0 +1,25 @@ +# 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/. + +DIRS += ["public"] + +EXTRA_JS_MODULES += [ + "CalDavCalendar.jsm", + "CalDavProvider.jsm", +] + +XPCOM_MANIFESTS += [ + "components.conf", +] + +EXTRA_JS_MODULES.caldav += [ + "modules/CalDavRequest.jsm", + "modules/CalDavRequestHandlers.jsm", + "modules/CalDavSession.jsm", + "modules/CalDavUtils.jsm", +] + +with Files("**"): + BUG_COMPONENT = ("Calendar", "Provider: CalDAV") diff --git a/comm/calendar/providers/caldav/public/calICalDavCalendar.idl b/comm/calendar/providers/caldav/public/calICalDavCalendar.idl new file mode 100644 index 0000000000..c9533470df --- /dev/null +++ b/comm/calendar/providers/caldav/public/calICalDavCalendar.idl @@ -0,0 +1,20 @@ +/* -*- Mode: C++; tab-width: 20; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* 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/. */ + +#include "calICalendar.idl" +#include "calIOperation.idl" + + +/** Adds CalDAV specific capabilities to calICalendar. + */ +[scriptable, uuid(88F6FB22-C172-11DC-A8D1-00197EA74E11)] +interface calICalDavCalendar : calICalendar +{ + /** + * The calendar's RFC 2617 authentication realm + */ + readonly attribute AUTF8String authRealm; + +}; diff --git a/comm/calendar/providers/caldav/public/moz.build b/comm/calendar/providers/caldav/public/moz.build new file mode 100644 index 0000000000..e8c0600501 --- /dev/null +++ b/comm/calendar/providers/caldav/public/moz.build @@ -0,0 +1,10 @@ +# 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/. + +XPIDL_SOURCES += [ + "calICalDavCalendar.idl", +] + +XPIDL_MODULE = "caldav" |