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.
573 lines (508 loc) • 22.1 kB
JavaScript
/*
* @package jsDAV
* @subpackage DAV
* @copyright Copyright(c) 2011 Ajax.org B.V. <info AT ajax DOT org>
* @author Mike de Boer <info AT mikedeboer DOT nl>
* @license http://github.com/mikedeboer/jsDAV/blob/master/LICENSE MIT License
*/
"use strict";
var jsDAV = require("./../../jsdav");
var jsDAV_Server = require("./../server");
var jsDAV_ServerPlugin = require("./../plugin");
var jsDAV_iFile = require("./../interfaces/iFile");
var jsDAV_iCollection = require("./../interfaces/iCollection");
var jsDAV_SimpleCollection = require("./../simpleCollection");
var jsDAVACL_iPrincipal = require("./../../DAVACL/interfaces/iPrincipal");
//var jsCalDAV_iCalendar = require("./../../CalDAV/interfaces/iCalendar");
var jsCardDAV_iAddressBook = require("./../../CardDAV/interfaces/iAddressBook");
var jsCardDAV_iCard = require("./../../CardDAV/interfaces/iCard");
var Exc = require("./../../shared/exceptions");
var Util = require("./../../shared/util");
var Fs = require("fs");
var Path = require("path");
var Url = require("url");
var Qs = require("querystring");
var Async = require("asyncjs");
var Formidable = require("formidable");
/**
* Browser Plugin
*
* This plugin provides a html representation, so that a WebDAV server may be accessed
* using a browser.
*
* The class intercepts GET requests to collection resources and generates a simple
* html index.
*/
var jsDAV_Browser_Plugin = module.exports = jsDAV_ServerPlugin.extend({
/**
* Plugin name
*
* @var String
*/
name: "browser",
/**
* List of default icons for nodes.
*
* This is an array with class / interface names as keys, and asset names
* as values.
*
* The evaluation order is reversed. The last item in the list gets
* precendence.
*
* @var array
*/
iconMap: {
"icons/file": jsDAV_iFile,
"icons/collection": jsDAV_iCollection,
"icons/principal": jsDAVACL_iPrincipal,
// "icons/calendar": jsCalDAV_iCalendar,
"icons/addressbook": jsCardDAV_iAddressBook,
"icons/card": jsCardDAV_iCard
},
/**
* The file extension used for all icons
*
* @var string
*/
iconExtension: ".png",
/**
* reference to handler class
*
* @var jsDAV_Handler
*/
handler: null,
initialize: function(handler) {
this.handler = handler;
var enablePost = handler.server.options.enablePost;
this.enablePost = typeof enablePost == "boolean" ? enablePost : true;
var enableAssets = handler.server.options.enableAssets;
this.enableAssets = typeof enableAssets == "boolean" ? enableAssets : true;
handler.addEventListener("beforeMethod", this.httpGetInterceptor.bind(this));
handler.addEventListener("onHTMLActionsPanel", this.htmlActionsPanel.bind(this));
if (this.enablePost)
handler.addEventListener("unknownMethod", this.httpPOSTHandler.bind(this));
},
/**
* This method intercepts GET requests to collections and returns the html
*
* @param {String} method
* @return bool
*/
httpGetInterceptor: function(e, method) {
if (method != "GET")
return e.next();
// We're not using straight-up $_GET, because we want everything to be
// unit testable.
var getVars = Url.parse(this.handler.httpRequest.url, true).query;
if (getVars.jsdavAction && getVars.jsdavAction == "asset" && getVars.assetName)
return this.serveAsset(e, getVars.assetName);
var uri = this.handler.getRequestUri();
var self = this;
this.handler.getNodeForPath(uri, function(err, node) {
if (err || node.hasFeature(jsDAV_iFile))
return e.next();
self.generateDirectoryIndex(uri, function(err, sIndex) {
if (err)
return e.next(err);
e.stop();
self.handler.httpResponse.writeHead(200, {"Content-Type":"text/html; charset=utf-8"});
self.handler.httpResponse.end(sIndex);
});
});
},
/**
* Handles POST requests for tree operations
*
* This method is not yet used.
*
* @param {String} method
* @return bool
*/
httpPOSTHandler: function(e, method, uri) {
if (method != "POST")
return e.next();
var contentType = this.handler.httpRequest.headers["content-type"];
contentType = contentType.split(";")[0];
if (contentType != "application/x-www-form-urlencoded" &&
contentType != "multipart/form-data") {
return e.next();
}
var self = this;
var form = new Formidable.IncomingForm();
form.uploadDir = self.handler.server.tmpDir;
// Fields that we expect to come in:
var postVars = {};
form.on("field", function(fieldName, fieldValue) {
postVars[fieldName] = Util.trim(fieldValue);
});
form.on("file", function(field, fileObj) {
postVars.file = fileObj;
});
form.on("error", e.next.bind(e));
form.on("aborted", function() {
e.next(new Exc.BadRequest());
});
form.on("end", function() {
var action = postVars.jsdavAction;
if (!action)
return e.next();
function finish(err) {
if (err)
return e.next(err);
e.stop();
self.handler.httpResponse.writeHead(302, {
"location": self.handler.httpRequest.url
});
self.handler.httpResponse.end();
}
self.handler.dispatchEvent("onBrowserPostAction", uri, action, postVars, function(stop) {
if (!stop) {
var name = postVars.name;
if (action == "mkcol" && name) {
// Using basename() because we won't allow slashes
var folderName = Util.splitPath(name)[1] || name;
self.handler.createDirectory(uri + "/" + folderName, finish);
}
else if (action == "put") {
var file = postVars.file;
var newName = name
? name
: file.name ? file.name : Util.splitPath(file.path)[1];
// Making sure we only have a 'basename' component
newName = Util.splitPath(newName)[1] || newName;
uri = uri + "/" + newName;
var parts = Util.splitPath(uri);
var dir = parts[0];
var name = parts[1];
self.handler.getNodeForPath(dir, function(err, parent) {
if (err)
return e.next(err);
var dataOrStream;
if (parent.createFileStreamRaw) {
dataOrStream = Fs.createReadStream(file.path);
createFile();
}
else {
// IMPORTANT: This does NOT support streaming
// copy/ move, possible memory hazard.
Fs.readFile(file.path, function(err, buf) {
if (err)
return e.next(err);
dataOrStream = buf;
createFile();
});
}
function createFile() {
self.handler.createFile(uri, dataOrStream, "binary", function(err) {
if (err)
return e.next(err);
// Clean up:
Fs.unlink(file.path, finish);
});
}
});
}
}
else if (stop !== true)
e.next();
else
finish();
});
})
form.parse(self.handler.httpRequest);
},
/**
* Escapes a {String} for html.
*
* @param {String} value
* @return void
*/
escapeHTML: function(value) {
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
},
/**
* Generates the html directory index for a given url
*
* @param {String} path
* @return string
*/
generateDirectoryIndex: function(path, cbindex) {
var self = this;
var html = "<html>\n\
<head>\n\
<title>Index for " + this.escapeHTML(path) + "/ - jsDAV " + jsDAV_Server.VERSION + "</title>\n\
<style type=\"text/css\">body { Font-family: arial} h1 { font-size: 150% }</style>\n" +
(this.enableAssets
? '<link rel="shortcut icon" href="' + this.getAssetUrl("favicon.ico") + '" type="image/vnd.microsoft.icon" />\n'
: "") +
"</head>\n\
<body>\n\
<h1>Index for " + this.escapeHTML(path) + "/</h1>\n\
<table>\n\
<tr><th width=\"24\"></th><th>Name</th><th>Type</th><th>Size</th><th>Last modified</th></tr>\n\
<tr><td colspan=\"5\"><hr /></td></tr>\n";
self.handler.getPropertiesForPath(path, [
"{DAV:}displayname",
"{DAV:}resourcetype",
"{DAV:}getcontenttype",
"{DAV:}getcontentlength",
"{DAV:}getlastmodified"
], 1, function(err, files) {
if (err)
return cbindex(err);
self.handler.getNodeForPath(path, function(err, parent) {
if (err)
return cbindex(err);
var file, icon, name, displayName, type, size, lastmodified, fullPath, i, l, node;
if (path) {
var parentUri = Util.splitPath(path)[0];
fullPath = encodeURI(self.handler.server.getBaseUri() + parentUri);
icon = self.enableAssets
? '<a href="' + fullPath + '"><img src="' + self.getAssetUrl("icons/parent" + self.iconExtension) + '" width="24" alt="Parent" /></a>'
: "";
html += "<tr>\n\
<td>" + icon + "</td>\n\
<td><a href=\"" + fullPath + "\">..</a></td>\n\
<td>[parent]</td>\n\
<td></td>\n\
<td></td>\n\
</tr>";
}
Async.list(Object.keys(files))
.each(function(filename, next) {
var file = files[filename];
// This is the current directory, we can skip it
if (Util.rtrim(file["href"], "/") == path)
return next();
name = encodeURI(Util.splitPath(file["href"])[1] || "");
type = null;
if (file["200"]["{DAV:}resourcetype"]) {
type = file["200"]["{DAV:}resourcetype"].getValue();
// resourcetype can have multiple values
if (!Array.isArray(type))
type = [type];
for (i = 0, l = type.length; i < l; ++i) {
// Some name mapping is preferred
switch(type[i]) {
case "{DAV:}collection" :
type[i] = "Collection";
break;
case "{DAV:}principal" :
type[i] = "Principal";
break;
case "{urn:ietf:params:xml:ns:carddav}addressbook" :
type[i] = "Addressbook";
break;
case "{urn:ietf:params:xml:ns:caldav}calendar" :
type[i] = "Calendar";
break;
case "{urn:ietf:params:xml:ns:caldav}schedule-inbox" :
type[i] = "Schedule Inbox";
break;
case "{urn:ietf:params:xml:ns:caldav}schedule-outbox" :
type[i] = "Schedule Outbox";
break;
case "{http://calendarserver.org/ns/}calendar-proxy-read" :
type[i] = "Proxy-Read";
break;
case "{http://calendarserver.org/ns/}calendar-proxy-write" :
type[i] = "Proxy-Write";
break;
}
}
type = type.join(", ");
}
// If no resourcetype was found, we attempt to use
// the contenttype property
if (!type && !Util.empty(file["200"]["{DAV:}getcontenttype"])) {
type = file["200"]["{DAV:}getcontenttype"];
}
if (!type)
type = "Unknown";
type = self.escapeHTML(type.replace(/;.*$/, ""));
size = file["200"]["{DAV:}getcontentlength"];
if (!size || isNaN(parseInt(size)))
size = "";
lastmodified = !Util.empty(file["200"]["{DAV:}getlastmodified"])
? Util.dateFormat(file["200"]["{DAV:}getlastmodified"].getTime(), Util.DATE_RFC822)
: "";
fullPath = encodeURI("/" + Util.trim(self.handler.server.getBaseUri() + (path ? path + "/" : "") + name, "/"));
displayName = typeof file["200"]["{DAV:}displayname"] == "string"
? file["200"]["{DAV:}displayname"]
: name;
icon = "";
if (self.enableAssets) {
self.handler.getNodeForPath((path ? path + "/" : "") + name, function(err, node) {
if (err)
return addEntry();
for (var iconName in self.iconMap) {
if (node.hasFeature(self.iconMap[iconName])) {
icon = '<a href="' + fullPath + '"><img src="' +
self.getAssetUrl(iconName + self.iconExtension) +
'" alt="" width="24" /></a>';
//break;
}
}
addEntry();
});
}
else
addEntry();
function addEntry() {
html += "<tr>\
<td>" + icon + "</td>\n\
<td><a href=\"" + fullPath + "\">" + self.escapeHTML(displayName) + "</a></td>\n\
<td>" + type + "</td>\n\
<td>" + size + "</td>\n\
<td>" + lastmodified + "</td>\n\
</tr>\n";
next();
}
})
.end(function() {
html += "<tr><td colspan=\"5\"><hr /></td></tr>\n";
var output = {};
if (self.enablePost) {
self.handler.dispatchEvent("onHTMLActionsPanel", parent, output, function() {
html += (output["html"] || "");
onEnd();
});
}
else
onEnd();
function onEnd() {
html += "</table>\n\
<address>Generated by jsDAV " + jsDAV_Server.VERSION + " (c)" + (new Date()).getFullYear() + " <a href=\"http://github.com/mikedeboer/jsdav\">http://github.com/mikedeboer/jsDAV</a></address>\n\
</body>\n\
</html>\n";
cbindex(null, html);
}
});
});
});
},
/**
* This method is used to generate the 'actions panel' output for
* collections.
*
* This specifically generates the interfaces for creating new files, and
* creating new directories.
*
* @param DAV\INode $node
* @param {mixed} $output
* @return void
*/
htmlActionsPanel: function(e, node, output) {
if (!node.hasFeature(jsDAV_iCollection))
return e.next();
// We also know fairly certain that if an object is a non-extended
// SimpleCollection, we won't need to show the panel either.
if (node.hasFeature(jsDAV_SimpleCollection))
return e.next();
output.html = '<tr><td colspan="2"><form method="post" action="">' +
'<h3>Create new folder</h3>' +
'<input type="hidden" name="jsdavAction" value="mkcol" />' +
'Name: <input type="text" name="name" /><br />' +
'<input type="submit" value="create" />' +
'</form>' +
'<form method="post" action="" enctype="multipart/form-data">' +
'<h3>Upload file</h3>' +
'<input type="hidden" name="jsdavAction" value="put" />' +
'Name (optional): <input type="text" name="name" /><br />' +
'File: <input type="file" name="file" /><br />' +
'<input type="submit" value="upload" />' +
'</form>' +
'</td></tr>';
e.stop();
},
/**
* This method takes a path/name of an asset and turns it into url
* suiteable for http access.
*
* @param {String} $assetName
* @return string
*/
getAssetUrl: function(assetName) {
return this.handler.server.getBaseUri() + "?jsdavAction=asset&assetName=" + encodeURIComponent(assetName);
},
/**
* This method returns a local pathname to an asset.
*
* @param {String} assetName
* @return string
*/
getLocalAssetPath: function(assetName) {
// Making sure people aren't trying to escape from the base path.
if (assetName.split("/").indexOf("..") > -1)
throw new Exc.jsDAV_Exception('Incorrect asset path');
return __dirname + "/assets/" + assetName;
},
/**
* This method reads an asset from disk and generates a full http response.
*
* @param {String} assetName
* @return void
*/
serveAsset: function(e, assetName) {
var assetPath;
try {
assetPath = this.getLocalAssetPath(decodeURIComponent(assetName));
}
catch (ex) {
return e.next(ex);
}
var self = this;
if (!this.assetCache)
this.assetCache = {};
if (!this.assetCache[assetPath]) {
Fs.stat(assetPath, function(err, stat) {
if (err)
return e.next(new Exc.NotFound("Could not find an asset with this name"));
self.assetCache[assetPath] = stat;
serveAsset(stat);
});
}
else
serveAsset(this.assetCache[assetPath]);
function serveAsset(stat) {
// Rudimentary mime type detection
var mime;
switch (Path.extname(assetPath).toLowerCase()) {
case ".ico" :
mime = "image/vnd.microsoft.icon";
break;
case ".png" :
mime = "image/png";
break;
default:
mime = "application/octet-stream";
break;
}
var headWritten = false;
function writeHeadOnce() {
if (headWritten)
return headWritten;
self.handler.httpResponse.writeHead(200, {
"content-type": mime,
"content-length": stat.size,
"cache-control": "public, max-age=1209600"
});
return (headWritten = true);
}
var stream = Fs.createReadStream(assetPath);
stream.on("data", function(data) {
writeHeadOnce();
self.handler.httpResponse.write(data);
});
stream.on("error", function(err) {
if (headWritten)
return;
e.next(err);
});
stream.on("end", function() {
e.stop();
self.handler.httpResponse.end();
});
}
}
});