diff options
Diffstat (limited to 'comm/calendar/providers/caldav/modules/CalDavRequest.jsm')
-rw-r--r-- | comm/calendar/providers/caldav/modules/CalDavRequest.jsm | 1211 |
1 files changed, 1211 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; +} |