diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /comm/calendar/providers/caldav/modules | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'comm/calendar/providers/caldav/modules')
4 files changed, 2985 insertions, 0 deletions
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); +} |