UNPKG

jsdav-ext

Version:

jsDAV allows you to easily add WebDAV support to a NodeJS application. jsDAV is meant to cover the entire standard, and attempts to allow integration using an easy to understand API.

760 lines (666 loc) 26.9 kB
/* * @package jsDAV * @subpackage CardDAV * @copyright Copyright(c) 2013 Mike de Boer. <info AT mikedeboer DOT nl> * @author Mike de Boer <info AT mikedeboer DOT nl> * @license http://github.com/mikedeboer/jsDAV/blob/master/LICENSE MIT License */ "use strict"; var jsDAV_Plugin = require("./../DAV/plugin"); var jsDAV_Property_Href = require("./../DAV/property/href"); var jsDAV_Property_HrefList = require("./../DAV/property/hrefList"); var jsDAV_Property_iHref = require("./../DAV/interfaces/iHref"); var jsCardDAV_iAddressBook = require("./interfaces/iAddressBook"); var jsCardDAV_iCard = require("./interfaces/iCard"); var jsCardDAV_iDirectory = require("./interfaces/iDirectory"); var jsCardDAV_UserAddressBooks = require("./userAddressBooks"); var jsCardDAV_AddressBookQueryParser = require("./addressBookQueryParser"); var jsDAVACL_iPrincipal = require("./../DAVACL/interfaces/iPrincipal"); var jsVObject_Reader = require("./../VObject/reader"); var AsyncEventEmitter = require("./../shared/asyncEvents").EventEmitter; var Exc = require("./../shared/exceptions"); var Util = require("./../shared/util"); var Xml = require("./../shared/xml"); var Async = require("asyncjs"); /** * CardDAV plugin * * The CardDAV plugin adds CardDAV functionality to the WebDAV server */ var jsCardDAV_Plugin = module.exports = jsDAV_Plugin.extend({ /** * Plugin name * * @var String */ name: "carddav", /** * Url to the addressbooks */ ADDRESSBOOK_ROOT: "addressbooks", /** * xml namespace for CardDAV elements */ NS_CARDDAV: "urn:ietf:params:xml:ns:carddav", /** * Add urls to this property to have them automatically exposed as * 'directories' to the user. * * @var array */ directories: null, /** * Handler class * * @var jsDAV_Handler */ handler: null, /** * Initializes the plugin * * @param DAV\Server server * @return void */ initialize: function(handler) { this.directories = []; // Events handler.addEventListener("beforeGetProperties", this.beforeGetProperties.bind(this)); handler.addEventListener("afterGetProperties", this.afterGetProperties.bind(this)); handler.addEventListener("updateProperties", this.updateProperties.bind(this)); handler.addEventListener("report", this.report.bind(this)); handler.addEventListener("onHTMLActionsPanel", this.htmlActionsPanel.bind(this), AsyncEventEmitter.PRIO_HIGH); handler.addEventListener("onBrowserPostAction", this.browserPostAction.bind(this), AsyncEventEmitter.PRIO_HIGH); handler.addEventListener("beforeWriteContent", this.beforeWriteContent.bind(this)); handler.addEventListener("beforeCreateFile", this.beforeCreateFile.bind(this)); // Namespaces Xml.xmlNamespaces[this.NS_CARDDAV] = "card"; // Mapping Interfaces to {DAV:}resourcetype values handler.resourceTypeMapping["{" + this.NS_CARDDAV + "}addressbook"] = jsCardDAV_iAddressBook; handler.resourceTypeMapping["{" + this.NS_CARDDAV + "}directory"] = jsCardDAV_iDirectory; // Adding properties that may never be changed handler.protectedProperties.push( "{" + this.NS_CARDDAV + "}supported-address-data", "{" + this.NS_CARDDAV + "}max-resource-size", "{" + this.NS_CARDDAV + "}addressbook-home-set", "{" + this.NS_CARDDAV + "}supported-collation-set" ); handler.protectedProperties = Util.makeUnique(handler.protectedProperties); handler.propertyMap["{http://calendarserver.org/ns/}me-card"] = jsDAV_Property_Href; this.handler = handler; }, /** * Returns a list of supported features. * * This is used in the DAV: header in the OPTIONS and PROPFIND requests. * * @return array */ getFeatures: function() { return ["addressbook"]; }, /** * Returns a list of reports this plugin supports. * * This will be used in the {DAV:}supported-report-set property. * Note that you still need to subscribe to the 'report' event to actually * implement them * * @param {String} uri * @return array */ getSupportedReportSet: function(uri, callback) { var self = this; this.handler.getNodeForPath(uri, function(err, node) { if (err) return callback(err); if (node.hasFeature(jsCardDAV_iAddressBook) || node.hasFeature(jsCardDAV_iCard)) { return callback(null, [ "{" + self.NS_CARDDAV + "}addressbook-multiget", "{" + self.NS_CARDDAV + "}addressbook-query" ]); } return callback(null, []); }); }, /** * Adds all CardDAV-specific properties * * @param {String} path * @param DAV\INode node * @param {Array} requestedProperties * @param {Array} returnedProperties * @return void */ beforeGetProperties: function(e, path, node, requestedProperties, returnedProperties) { var self = this; if (node.hasFeature(jsDAVACL_iPrincipal)) { // calendar-home-set property var addHome = "{" + this.NS_CARDDAV + "}addressbook-home-set"; if (requestedProperties[addHome]) { var principalId = node.getName(); var addressbookHomePath = this.ADDRESSBOOK_ROOT + "/" + principalId + "/"; delete requestedProperties[addHome]; returnedProperties["200"][addHome] = jsDAV_Property_Href.new(addressbookHomePath); } var directories = "{" + this.NS_CARDDAV + "}directory-gateway"; if (this.directories && requestedProperties[directories]) { delete requestedProperties[directories]; returnedProperties["200"][directories] = jsDAV_Property_HrefList.new(this.directories); } } if (node.hasFeature(jsCardDAV_iCard)) { // The address-data property is not supposed to be a 'real' // property, but in large chunks of the spec it does act as such. // Therefore we simply expose it as a property. var addressDataProp = "{" + this.NS_CARDDAV + "}address-data"; if (requestedProperties[addressDataProp]) { delete requestedProperties[addressDataProp]; node.get(function(err, val) { if (err) return e.next(err); returnedProperties["200"][addressDataProp] = val.toString("utf8"); afterICard(); }); } else afterICard(); } else afterICard(); function afterICard() { if (node.hasFeature(jsCardDAV_UserAddressBooks)) { var meCardProp = "{http://calendarserver.org/ns/}me-card"; if (requestedProperties[meCardProp]) { self.handler.getProperties(node.getOwner(), ["{http://ajax.org/2005/aml}vcard-url"], function(err, props) { if (err) return e.next(err); if (props["{http://ajax.org/2005/aml}vcard-url"]) { returnedProperties["200"][meCardProp] = jsDAV_Property_Href.new( props["{http://ajax.org/2005/aml}vcard-url"] ); delete requestedProperties[meCardProp]; } e.next(); }); } else e.next(); } else e.next(); } }, /** * This event is triggered when a PROPPATCH method is executed * * @param {Array} mutations * @param {Array} result * @param DAV\INode node * @return bool */ updateProperties: function(e, mutations, result, node) { if (!node.hasFeature(jsCardDAV_UserAddressBooks)) return e.next(); var meCard = "{http://calendarserver.org/ns/}me-card"; // The only property we care about if (!mutations[meCard]) return e.next(); var value = mutations[meCard]; delete mutations[meCard]; if (value.hasFeature(jsDAV_Property_iHref)) { value = this.handler.calculateUri(value.getHref()); } else if (!value) { result["400"][meCard] = null; return e.stop(); } this.server.updateProperties(node.getOwner(), {"{http://ajax.org/2005/aml}vcard-url": value}, function(err, innerResult) { if (err) return e.next(err); var closureResult = false; var props; for (var status in innerResult) { props = innerResult[status]; if (props["{http://ajax.org/2005/aml}vcard-url"]) { result[status][meCard] = null; status = parseInt(status); closureResult = (status >= 200 && status < 300); } } if (!closureResult) return e.stop(); e.next(); }); }, /** * This functions handles REPORT requests specific to CardDAV * * @param {String} reportName * @param DOMNode dom * @return bool */ report: function(e, reportName, dom) { switch(reportName) { case "{" + this.NS_CARDDAV + "}addressbook-multiget" : this.addressbookMultiGetReport(e, dom); break; case "{" + this.NS_CARDDAV + "}addressbook-query" : this.addressBookQueryReport(e, dom); break; default : return e.next(); } }, /** * This function handles the addressbook-multiget REPORT. * * This report is used by the client to fetch the content of a series * of urls. Effectively avoiding a lot of redundant requests. * * @param DOMNode dom * @return void */ addressbookMultiGetReport: function(e, dom) { var properties = Object.keys(Xml.parseProperties(dom)); var hrefElems = dom.getElementsByTagNameNS("urn:DAV", "href"); var propertyList = {}; var self = this; Async.list(hrefElems) .each(function(elem, next) { var uri = self.handler.calculateUri(elem.firstChild.nodeValue); //propertyList[uri] self.handler.getPropertiesForPath(uri, properties, 0, function(err, props) { if (err) return next(err); Util.extend(propertyList, props); next(); }); }) .end(function(err) { if (err) return e.next(err); var prefer = self.handler.getHTTPPrefer(); e.stop(); self.handler.httpResponse.writeHead(207, { "content-type": "application/xml; charset=utf-8", "vary": "Brief,Prefer" }); self.handler.httpResponse.end(self.handler.generateMultiStatus(propertyList, prefer["return-minimal"])); }); }, /** * This method is triggered before a file gets updated with new content. * * This plugin uses this method to ensure that Card nodes receive valid * vcard data. * * @param {String} path * @param jsDAV_iFile node * @param resource data * @return void */ beforeWriteContent: function(e, path, node) { if (!node.hasFeature(jsCardDAV_iCard)) return e.next(); var self = this; this.handler.getRequestBody("utf8", null, false, function(err, data) { if (err) return e.next(err); try { self.validateVCard(data); } catch (ex) { return e.next(ex); } e.next(); }); }, /** * This method is triggered before a new file is created. * * This plugin uses this method to ensure that Card nodes receive valid * vcard data. * * @param {String} path * @param resource data * @param jsDAV_iCollection parentNode * @return void */ beforeCreateFile: function(e, path, data, enc, parentNode) { if (!parentNode.hasFeature(jsCardDAV_iAddressBook)) return e.next(); try { this.validateVCard(data); } catch (ex) { return e.next(ex); } e.next(); }, /** * Checks if the submitted iCalendar data is in fact, valid. * * An exception is thrown if it's not. * * @param resource|string data * @return void */ validateVCard: function(data) { // If it's a stream, we convert it to a string first. if (Buffer.isBuffer(data)) data = data.toString("utf8"); var vobj; try { vobj = jsVObject_Reader.read(data); } catch (ex) { throw new Exc.UnsupportedMediaType("This resource only supports valid vcard data. Parse error: " + ex.message); } if (vobj.name != "VCARD") throw new Exc.UnsupportedMediaType("This collection can only support vcard objects."); if (!vobj.UID) throw new Exc.BadRequest("Every vcard must have a UID."); }, /** * This function handles the addressbook-query REPORT * * This report is used by the client to filter an addressbook based on a * complex query. * * @param DOMNode dom * @return void */ addressbookQueryReport: function(e, dom) { var query = jsCardDAV_AddressBookQueryParser.new(dom); try { query.parse(); } catch(ex) { return e.next(ex); } var depth = this.handler.getHTTPDepth(0); if (depth === 0) { this.handler.getNodeForPath(this.handler.getRequestUri(), function(err, node) { if (err) return e.next(err); afterCandidates([node]); }) } else { this.handler.server.tree.getChildren(this.handler.getRequestUri(), function(err, children) { if (err) return e.next(err); afterCandidates(children); }); } var self = this; function afterCandidates(candidateNodes) { var validNodes = []; Async.list(candidateNodes) .each(function(node, next) { if (!node.hasFeature(jsCardDAV_iCard)) return next(); node.get(function(err, blob) { if (err) return next(err); if (!self.validateFilters(blob.toString("utf8"), query.filters, query.test)) return next(); validNodes.push(node); if (query.limit && query.limit <= validNodes.length) { // We hit the maximum number of items, we can stop now. return next(Async.STOP); } next(); }); }) .end(function(err) { if (err) return e.next(err); var result = {}; Async.list(validNodes) .each(function(validNode, next) { var href = self.handler.getRequestUri(); if (depth !== 0) href = href + "/" + validNode.getName(); self.handler.getPropertiesForPath(href, query.requestedProperties, 0, function(err, props) { if (err) return next(err); Util.extend(result, props); next(); }); }) .end(function(err) { if (err) return e.next(err); e.stop(); var prefer = self.handler.getHTTPPRefer(); self.handler.httpResponse.writeHead(207, { "content-type": "application/xml; charset=utf-8", "vary": "Brief,Prefer" }); self.handler.httpResponse.end(self.handler.generateMultiStatus(result, prefer["return-minimal"])); }); }); } }, /** * Validates if a vcard makes it throught a list of filters. * * @param {String} vcardData * @param {Array} filters * @param {String} test anyof or allof (which means OR or AND) * @return bool */ validateFilters: function(vcardData, filters, test) { var vcard; try { vcard = jsVObject_Reader.read(vcardData); } catch (ex) { return false; } if (!filters) return true; var filter, isDefined, success, vProperties, results, texts; for (var i = 0, l = filters.length; i < l; ++i) { filter = filters[i]; isDefined = vcard.get(filter.name); if (filter["is-not-defined"]) { if (isDefined) success = false; else success = true; } else if ((!filter["param-filters"] && !filter["text-matches"]) || !isDefined) { // We only need to check for existence success = isDefined; } else { vProperties = vcard.select(filter.name); results = []; if (filter["param-filters"]) results.push(this.validateParamFilters(vProperties, filter["param-filters"], filter.test)); if (filter["text-matches"]) { texts = vProperties.map(function(vProperty) { return vProperty.value; }); results.push(this.validateTextMatches(texts, filter["text-matches"], filter.test)); } if (results.length === 1) { success = results[0]; } else { if (filter.test == "anyof") success = results[0] || results[1]; else success = results[0] && results[1]; } } // else // There are two conditions where we can already determine whether // or not this filter succeeds. if (test == "anyof" && success) return true; if (test == "allof" && !success) return false; } // foreach // If we got all the way here, it means we haven't been able to // determine early if the test failed or not. // // This implies for 'anyof' that the test failed, and for 'allof' that // we succeeded. Sounds weird, but makes sense. return test === "allof"; }, /** * Validates if a param-filter can be applied to a specific property. * * @todo currently we're only validating the first parameter of the passed * property. Any subsequence parameters with the same name are * ignored. * @param {Array} vProperties * @param {Array} filters * @param {String} test * @return bool */ validateParamFilters: function(vProperties, filters, test) { var filter, isDefined, success, j, l2, vProperty; for (var i = 0, l = filters.length; i < l; ++i) { filter = filters[i]; isDefined = false; for (j = 0, l2 = vProperties.length; j < l2; ++j) { vProperty = vProperties[j]; isDefined = !!vProperty.get(filter.name); if (isDefined) break; } if (filter["is-not-defined"]) { success = !isDefined; // If there's no text-match, we can just check for existence } else if (!filter["text-match"] || !isDefined) { success = isDefined; } else { success = false; for (j = 0, l2 = vProperties.length; j < l2; ++j) { vProperty = vProperties[j]; // If we got all the way here, we'll need to validate the // text-match filter. success = Util.textMatch(vProperty.get(filter.name).value, filter["text-match"].value, filter["text-match"]["match-type"]); if (success) break; } if (filter["text-match"]["negate-condition"]) success = !success; } // else // There are two conditions where we can already determine whether // or not this filter succeeds. if (test == "anyof" && success) return true; if (test == "allof" && !success) return false; } // If we got all the way here, it means we haven't been able to // determine early if the test failed or not. // // This implies for 'anyof' that the test failed, and for 'allof' that // we succeeded. Sounds weird, but makes sense. return test == "allof"; }, /** * Validates if a text-filter can be applied to a specific property. * * @param {Array} texts * @param {Array} filters * @param {String} test * @return bool */ validateTextMatches: function(texts, filters, test) { var success, filter, j, l2, haystack; for (var i = 0, l = filters.length; i < l; ++i) { filter = filters[i]; success = false; for (j = 0, l2 = texts.length; j < l2; ++j) { haystack = texts[j]; success = Util.textMatch(haystack, filter.value, filter["match-type"]); // Breaking on the first match if (success) break; } if (filter["negate-condition"]) success = !success; if (success && test == "anyof") return true; if (!success && test == "allof") return false; } // If we got all the way here, it means we haven't been able to // determine early if the test failed or not. // // This implies for 'anyof' that the test failed, and for 'allof' that // we succeeded. Sounds weird, but makes sense. return test == "allof"; }, /** * This event is triggered after webdav-properties have been retrieved. * * @return bool */ afterGetProperties: function(e, uri, properties) { // If the request was made using the SOGO connector, we must rewrite // the content-type property. By default jsDAV will send back // text/x-vcard; charset=utf-8, but for SOGO we must strip that last // part. if (!properties["200"]["{DAV:}getcontenttype"]) return e.next(); var ua = this.handler.httpRequest.headers["user-agent"]; if (!ua || ua.indexOf("Thunderbird") === -1) return e.next(); if (properties["200"]["{DAV:}getcontenttype"].indexOf("text/x-vcard") === 0) properties["200"]["{DAV:}getcontenttype"] = "text/x-vcard"; e.next(); }, /** * This method is used to generate HTML output for the * Sabre\DAV\Browser\Plugin. This allows us to generate an interface users * can use to create new calendars. * * @param DAV\INode node * @param {String} output * @return bool */ htmlActionsPanel: function(e, node, output) { if (!node.hasFeature(jsCardDAV_UserAddressBooks)) return e.next(); output.html = '<tr><td colspan="2"><form method="post" action="">' + '<h3>Create new address book</h3>' + '<input type="hidden" name="jsdavAction" value="mkaddressbook" />' + '<label>Name (uri):</label> <input type="text" name="name" /><br />' + '<label>Display name:</label> <input type="text" name="{DAV:}displayname" /><br />' + '<input type="submit" value="create" />' + '</form>' + '</td></tr>'; e.stop(); }, /** * This method allows us to intercept the 'mkcalendar' sabreAction. This * action enables the user to create new calendars from the browser plugin. * * @param {String} uri * @param {String} action * @param {Array} postVars * @return bool */ browserPostAction: function(e, uri, action, postVars) { if (action != "mkaddressbook") return e.next(); var resourceType = ["{DAV:}collection", "{urn:ietf:params:xml:ns:carddav}addressbook"]; var properties = {}; if (postVars["{DAV:}displayname"]) properties["{DAV:}displayname"] = postVars["{DAV:}displayname"]; this.handler.createCollection(uri + "/" + postVars.name, resourceType, properties, function(err) { if (err) return e.next(err); e.stop(); }); } });