summaryrefslogtreecommitdiffstats
path: root/comm/calendar/providers/caldav
diff options
context:
space:
mode:
Diffstat (limited to 'comm/calendar/providers/caldav')
-rw-r--r--comm/calendar/providers/caldav/CalDavCalendar.jsm2464
-rw-r--r--comm/calendar/providers/caldav/CalDavProvider.jsm426
-rw-r--r--comm/calendar/providers/caldav/components.conf14
-rw-r--r--comm/calendar/providers/caldav/modules/CalDavRequest.jsm1211
-rw-r--r--comm/calendar/providers/caldav/modules/CalDavRequestHandlers.jsm1091
-rw-r--r--comm/calendar/providers/caldav/modules/CalDavSession.jsm573
-rw-r--r--comm/calendar/providers/caldav/modules/CalDavUtils.jsm110
-rw-r--r--comm/calendar/providers/caldav/moz.build25
-rw-r--r--comm/calendar/providers/caldav/public/calICalDavCalendar.idl20
-rw-r--r--comm/calendar/providers/caldav/public/moz.build10
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"