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.
782 lines (672 loc) • 28.3 kB
JavaScript
/*
* @package jsDAV
* @subpackage CalDAV
* @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 jsCalDAV_iCalendar = require("./interfaces/iCalendar");
var jsCalDAV_iCalendarObject = require("./interfaces/iCalendarObject");
var jsDAVACL_iPrincipal = require("./../DAVACL/interfaces/iPrincipal");
var jsVObject_Reader = require("./../VObject/reader").new();
var jsCalDAV_CalendarQueryParser = require("./calendarQueryParser");
var jsCalDAV_CalendarQueryValidator = require("./calendarQueryValidator");
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");
var NS_CALDAV = "urn:ietf:params:xml:ns:caldav";
var NS_CALENDARSERVER = "http://calendarserver.org/ns/";
// Namespaces
Xml.xmlNamespaces[NS_CALDAV] = "cal";
Xml.xmlNamespaces[NS_CALENDARSERVER] = "cs";
Xml.xmlNamespaces["urn:DAV"] = "dav";
/**
* CalDAV plugin
*
* The CalDAV plugin adds CalDAV functionality to the WebDAV server
*/
var jsCalDAV_Plugin = module.exports = jsDAV_Plugin.extend({
/**
* Plugin name
*
* @var String
*/
name: "caldav",
/**
* Url to the calendars
*/
CALENDAR_ROOT: "calendars",
/**
* xml namespace for CalDAV elements
*/
NS_CALDAV: NS_CALDAV,
NS_CALENDARSERVER: NS_CALENDARSERVER,
/**
* Handler class
*
* @var jsDAV_Handler
*/
handler: null,
/**
* Use this method to tell the server this plugin defines additional
* HTTP methods.
*
* This method is passed a uri. It should only return HTTP methods that are
* available for the specified uri.
*
* @param string uri
* @return array
*/
getHTTPMethods: function(uri) {
// The MKCALENDAR is only available on unmapped uri's, whose
// parents extend IExtendedCollection
// @TODO: check as described above, need to make getHTTPMethods async
return ["MKCALENDAR"];
},
/**
* Returns a list of supported features.
*
* This is used in the DAV: header in the OPTIONS and PROPFIND requests.
*
* @return array
*/
getFeatures: function() {
return ["calendar-access"];
},
/**
* 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);
var reports = [];
if (node.hasFeature(jsCalDAV_iCalendar) || node.hasFeature(jsCalDAV_iCalendarObject)) {
reports.push("{" + self.NS_CALDAV + "}calendar-multiget");
reports.push("{" + self.NS_CALDAV + "}calendar-query");
}
if (node.hasFeature(jsCalDAV_iCalendar)){
reports.push("{" + self.NS_CALDAV + "}free-busy-query");
}
return callback(null, reports);
});
},
/**
* Initializes the plugin
*
* @param DAV\Server server
* @return void
*/
initialize: function(handler) {
this.directories = [];
// Events
handler.addEventListener("unknownMethod", this.unknownMethod.bind(this))
handler.addEventListener("report", this.report.bind(this));
handler.addEventListener("beforeGetProperties", this.beforeGetProperties.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));
// handler.addEventListener("beforeMethod", this.beforeMethod.bind(this));
// Mapping Interfaces to {DAV:}resourcetype values
handler.resourceTypeMapping["{" + this.NS_CALDAV + "}calendar"] = jsCalDAV_iCalendar;
// Adding properties that may never be changed
handler.protectedProperties.push(
"{" + this.NS_CALDAV + "}supported-calendar-component-set",
"{" + this.NS_CALDAV + "}supported-calendar-data",
"{" + this.NS_CALDAV + "}max-resource-size",
"{" + this.NS_CALDAV + "}min-date-time",
"{" + this.NS_CALDAV + "}max-date-time",
"{" + this.NS_CALDAV + "}max-instances",
"{" + this.NS_CALDAV + "}max-attendees-per-instance",
"{" + this.NS_CALDAV + "}calendar-home-set",
"{" + this.NS_CALDAV + "}supported-collation-set",
"{" + this.NS_CALDAV + "}calendar-data"
);
handler.protectedProperties = Util.makeUnique(handler.protectedProperties);
handler.propertyMap["{http://calendarserver.org/ns/}me-card"] = jsDAV_Property_Href;
this.handler = handler;
},
unknownMethod: function(method, uri, callback) {
if (method === "MKCALENDAR"){
this.httpMkCalendar(uri);
return callback(true);
}
return callback(false);
},
/**
* This functions handles REPORT requests specific to CalDAV
*
* @param {String} reportName
* @param DOMNode dom
* @return bool
*/
report: function(e, reportName, dom) {
switch(reportName) {
case "{" + this.NS_CALDAV + "}calendar-multiget" :
this.calendarMultiGetReport(e, dom);
break;
case "{" + this.NS_CALDAV + "}calendar-query" :
this.calendarQueryReport(e, dom);
break;
/*case "{" + this.NS_CALDAV + "}free-busy-query" :
this.freeBusyQueryReport(e, dom);
break;*/
default :
return e.next();
}
},
httpMkCalendar: function(uri){
// @TODO: JS ME
// Due to unforgivable bugs in iCal, we're completely disabling MKCALENDAR support
// for clients matching iCal in the user agent
//$ua = $this->server->httpRequest->getHeader('User-Agent');
//if (strpos($ua,'iCal/')!==false) {
// throw new \Sabre\DAV\Exception\Forbidden('iCal has major bugs in it\'s RFC3744 support. Therefore we are left with no other choice but disabling this feature.');
//}
// $body = $this->server->httpRequest->getBody(true);
// $properties = array();
// if ($body) {
// $dom = DAV\XMLUtil::loadDOMDocument($body);
// foreach($dom->firstChild->childNodes as $child) {
// if (DAV\XMLUtil::toClarkNotation($child)!=='{DAV:}set') continue;
// foreach(DAV\XMLUtil::parseProperties($child,$this->server->propertyMap) as $k=>$prop) {
// $properties[$k] = $prop;
// }
// }
// }
// $resourceType = array('{DAV:}collection','{urn:ietf:params:xml:ns:caldav}calendar');
// $this->server->createCollection($uri,$resourceType,$properties);
// $this->server->httpResponse->sendStatus(201);
// $this->server->httpResponse->setHeader('Content-Length',0);
this.handler.httpResponse.writeHead(500);
this.handler.httpResponse.end("Not Implemented Yet");
},
/**
* Adds all CalDAV-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 calHome = "{" + this.NS_CALDAV + "}calendar-home-set";
if (requestedProperties[calHome]) {
var principalId = node.getName();
var calendarHomePath = this.CALENDAR_ROOT + "/" + principalId + "/";
delete requestedProperties[calHome];
returnedProperties["200"][calHome] = jsDAV_Property_Href.new(calendarHomePath);
}
e.next();
}
if (node.hasFeature(jsCalDAV_iCalendarObject)) {
// 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 calendarDataProp = "{" + this.NS_CALDAV + "}calendar-data";
if (requestedProperties[calendarDataProp]) {
delete requestedProperties[calendarDataProp];
node.get(function(err, val) {
if (err)
return e.next(err);
returnedProperties["200"][calendarDataProp] = val.toString("utf8");
e.next();
});
} else
e.next();
} else
e.next();
},
/**
* This function handles the calendar-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
*/
calendarMultiGetReport: 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 Calendar nodes receive valid
* ical data.
*
* @param {String} path
* @param jsDAV_iFile node
* @param resource data
* @return void
*/
beforeWriteContent: function(e, path, node) {
if (!node.hasFeature(jsCalDAV_iCalendar))
return e.next();
var self = this;
this.handler.getRequestBody("utf8", null, false, function(err, data) {
if (err)
return e.next(err);
try {
self.validateICal(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 Calendar nodes receive valid
* ical data.
*
* @param {String} path
* @param resource data
* @param jsDAV_iCollection parentNode
* @return void
*/
beforeCreateFile: function(e, path, data, enc, parentNode) {
if (!parentNode.hasFeature(jsCalDAV_iCalendar)) {
return e.next();
}
var self = this;
this.handler.getRequestBody("utf8", null, false, function(err, data) {
if (err)
return e.next(err);
try {
self.validateICal(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
*/
validateICal: 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 vcalendar data. Parse error: " + ex.message);
}
if (vobj.name != "VCALENDAR")
throw new Exc.UnsupportedMediaType("This collection can only support vcalendar objects.");
},
/**
* This function handles the calendar-query REPORT
*
* This report is used by the client to filter an calendar based on a
* complex query.
*
* @param e
* @param {Node} dom
* @returns {*}
*/
calendarQueryReport: function(e, dom) {
var query = jsCalDAV_CalendarQueryParser.new(dom);
var validator = jsCalDAV_CalendarQueryValidator.new();
var self = this;
var depth = this.handler.getHTTPDepth(0);
try {
query.parse();
}
catch (ex) {
return e.next(ex);
}
this.handler.getNodeForPath(this.handler.getRequestUri(), function(err, node) {
if (err)
return e.next(err);
// The calendarobject was requested directly. In this case we handle
// this locally.
if (depth == 0 && node.hasFeature(jsCalDAV_iCalendarObject)) {
afterCandidates([ node ]);
}
// If we're dealing with a calendar, the calendar itself is responsible
// for the calendar-query.
else if (node.hasFeature(jsCalDAV_iCalendar)) {
node.calendarQuery(query.filters, function(err, items){
if (err)
return e.next(err);
afterCandidates(items);
});
}
else {
e.next(Exc.notImplementedYet());
}
});
function afterCandidates(candidateNodes) {
var validNodes = [];
Async.list(candidateNodes).each(function(node, next) {
if (!node.hasFeature(jsCalDAV_iCalendarObject)) {
// somehow we got here not a calendar object...
next();
}
else {
// Step 1 - is depth == 0 calendar node is requested pass it through validator
// if not (depth == 1) just save a node as valid (it is from calendar query)
if (depth == 0) {
node.get(function(err, blob) {
if (err)
return next(err);
var vObject = jsVObject_Reader.read(blob.toString("utf8"));
if (!validator.validate(vObject, query.filters))
return next();
validNodes.push(node);
next();
});
}
else {
validNodes.push(node);
next();
}
}
})
.end(function(err) {
if (err)
return e.next(err);
// validNodes contains result of a calendar query or a single valid node...
var result = {};
Async.list(validNodes).each(function(validNode, next) {
var href = self.handler.getRequestUri();
if (depth !== 0)
href = href + "/" + validNode.getName();
// get each node properties, pass to result object
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();
// if (this.handler.httpRequest.headers["user-agent"].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(jsCalDAV_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="mkcalendar" />' +
// '<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 != "mkcalendar")
// return e.next();
// var resourceType = ["{DAV:}collection", "{urn:ietf:params:xml:ns:carddav}calendar"];
// 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();
// });
// }
});