/* 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/. */
const PREFIX_BINDINGS = {
c: "urn:ietf:params:xml:ns:caldav",
cs: "http://calendarserver.org/ns/",
d: "DAV:",
i: "http://apple.com/ns/ical/",
};
const NAMESPACE_STRING = Object.entries(PREFIX_BINDINGS)
.map(([prefix, url]) => `xmlns:${prefix}="${url}"`)
.join(" ");
import { Assert } from "resource://testing-common/Assert.sys.mjs";
import { CommonUtils } from "resource://services-common/utils.sys.mjs";
import { HttpServer } from "resource://testing-common/httpd.sys.mjs";
const logger = console.createInstance({
prefix: "CalDAVServer",
maxLogLevel: "Log",
});
// The response bodies Google sends if you exceed its rate limit.
const MULTIGET_RATELIMIT_ERROR = `
`;
const PROPFIND_RATELIMIT_ERROR = `
GData
rateLimitExceeded
Some text we're not looking at anyway
`;
export var CalDAVServer = {
items: new Map(),
deletedItems: new Map(),
changeCount: 0,
server: null,
isOpen: false,
/**
* The "current-user-privilege-set" in responses. Set to null to have no privilege set.
*/
privileges: "",
open(username, password) {
this.server = new HttpServer();
this.server.start(-1);
this.isOpen = true;
this.username = username;
this.password = password;
this.server.registerPathHandler("/ping", this.ping);
this.reset();
},
reset() {
this.items.clear();
this.deletedItems.clear();
this.changeCount = 0;
this.privileges = "";
this.resetHandlers();
},
resetHandlers() {
this.server.registerPathHandler("/.well-known/caldav", this.wellKnown.bind(this));
this.server.registerPathHandler("/principals/", this.principals.bind(this));
this.server.registerPathHandler("/principals/me/", this.myPrincipal.bind(this));
this.server.registerPathHandler("/calendars/me/", this.myCalendars.bind(this));
this.server.registerPathHandler(this.path, this.directoryHandler.bind(this));
this.server.registerPrefixHandler(this.path, this.itemHandler.bind(this));
},
close() {
if (!this.isOpen) {
return Promise.resolve();
}
return new Promise(resolve =>
this.server.stop({
onStopped: () => {
this.isOpen = false;
resolve();
},
})
);
},
get origin() {
return `http://localhost:${this.server.identity.primaryPort}`;
},
get path() {
return "/calendars/me/test/";
},
get url() {
return `${this.origin}${this.path}`;
},
get altPath() {
return "/addressbooks/me/default/";
},
get altURL() {
return `${this.origin}${this.altPath}`;
},
checkAuth(request, response) {
if (!this.username || !this.password) {
return true;
}
if (!request.hasHeader("Authorization")) {
response.setStatusLine("1.1", 401, "Unauthorized");
response.setHeader("WWW-Authenticate", `Basic realm="test"`);
return false;
}
const value = request.getHeader("Authorization");
if (!value.startsWith("Basic ")) {
response.setStatusLine("1.1", 401, "Unauthorized");
response.setHeader("WWW-Authenticate", `Basic realm="test"`);
return false;
}
const [username, password] = atob(value.substring(6)).split(":");
if (username != this.username || password != this.password) {
response.setStatusLine("1.1", 401, "Unauthorized");
response.setHeader("WWW-Authenticate", `Basic realm="test"`);
return false;
}
return true;
},
ping(request, response) {
response.setStatusLine("1.1", 200, "OK");
response.setHeader("Content-Type", "text/plain");
response.write("pong");
},
wellKnown(request, response) {
response.setStatusLine("1.1", 301, "Moved Permanently");
response.setHeader("Location", "/principals/");
},
principals(request, response) {
if (!this.checkAuth(request, response)) {
return;
}
const input = new DOMParser().parseFromString(
CommonUtils.readBytesFromInputStream(request.bodyInputStream),
"text/xml"
);
const propNames = this._inputProps(input);
const propValues = {
"d:current-user-principal": "/principals/me/",
};
response.setStatusLine("1.1", 207, "Multi-Status");
response.setHeader("Content-Type", "text/xml");
response.write(
`
/principals/
${this._outputProps(propNames, propValues)}
`.replace(/>\s+<")
);
},
myPrincipal(request, response) {
if (!this.checkAuth(request, response)) {
return;
}
const input = new DOMParser().parseFromString(
CommonUtils.readBytesFromInputStream(request.bodyInputStream),
"text/xml"
);
const propNames = this._inputProps(input);
const propValues = {
"d:resourcetype": "",
"c:calendar-home-set": "/calendars/me/",
"c:calendar-user-address-set": `mailto:me@invalid`,
"c:schedule-inbox-URL": "/calendars/me/inbox/",
"c:schedule-outbox-URL": "/calendars/me/inbox/",
};
response.setStatusLine("1.1", 207, "Multi-Status");
response.setHeader("Content-Type", "text/xml");
response.write(
`
/principals/me/
${this._outputProps(propNames, propValues)}
`.replace(/>\s+<")
);
},
myCalendars(request, response) {
if (!this.checkAuth(request, response)) {
return;
}
if (request.method == "OPTIONS") {
response.setStatusLine("1.1", 200, "OK");
response.setHeader("DAV", "1,2,3, calendar-access, calendar-schedule");
return;
}
const input = new DOMParser().parseFromString(
CommonUtils.readBytesFromInputStream(request.bodyInputStream),
"text/xml"
);
const propNames = this._inputProps(input);
const propValues = {
"d:resourcetype": "",
"d:displayname": "CalDAV Test",
"i:calendar-color": "#ff8000",
"d:current-user-privilege-set": this.privileges,
};
response.setStatusLine("1.1", 207, "Multi-Status");
response.setHeader("Content-Type", "text/xml");
response.write(
`
/addressbooks/me/
${this._outputProps(propNames, {
"d:resourcetype": "",
"d:displayname": "#calendars",
})}
${this.path}
${this._outputProps(propNames, propValues)}
`.replace(/>\s+<")
);
},
/** Handle any requests to the calendar itself. */
directoryHandler(request, response) {
if (!this.checkAuth(request, response)) {
return;
}
if (request.method == "OPTIONS") {
response.setStatusLine("1.1", 204, "No Content");
return;
}
let input = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
logger.log("C: " + input);
input = new DOMParser().parseFromString(input, "text/xml");
switch (input.documentElement.localName) {
case "calendar-query":
Assert.equal(request.method, "REPORT");
Assert.equal(input.documentElement.namespaceURI, PREFIX_BINDINGS.c);
this.calendarQuery(input, response);
return;
case "calendar-multiget":
Assert.equal(request.method, "REPORT");
Assert.equal(input.documentElement.namespaceURI, PREFIX_BINDINGS.c);
this.calendarMultiGet(input, response);
return;
case "propfind":
Assert.equal(request.method, "PROPFIND");
Assert.equal(input.documentElement.namespaceURI, PREFIX_BINDINGS.d);
this.propFind(input, request.hasHeader("Depth") ? request.getHeader("Depth") : 0, response);
return;
case "sync-collection":
Assert.equal(request.method, "REPORT");
Assert.equal(input.documentElement.namespaceURI, PREFIX_BINDINGS.d);
this.syncCollection(input, response);
return;
}
Assert.report(true, undefined, undefined, "Should not have reached here");
response.setStatusLine("1.1", 404, "Not Found");
response.setHeader("Content-Type", "text/plain");
response.write(`No handler found for <${input.documentElement.localName}>`);
},
calendarQuery(input, response) {
const propNames = this._inputProps(input);
let output = ``;
for (const [href, item] of this.items) {
output += this._itemResponse(href, item, propNames);
}
output += ``;
response.setStatusLine("1.1", 207, "Multi-Status");
response.setHeader("Content-Type", "text/xml");
response.write(output.replace(/>\s+<"));
logger.log("S: " + output.replace(/>\s+<"));
},
async calendarMultiGet(input, response) {
const propNames = this._inputProps(input);
let output = ``;
for (let href of input.querySelectorAll("href")) {
href = href.textContent;
const item = this.items.get(href);
if (item) {
output += this._itemResponse(href, item, propNames);
}
}
output += ``;
response.setStatusLine("1.1", 207, "Multi-Status");
response.setHeader("Content-Type", "text/xml");
response.write(output.replace(/>\s+<"));
logger.log("S: " + output.replace(/>\s+<"));
},
propFind(input, depth, response) {
if (this.throwRateLimitErrors) {
response.setStatusLine("1.1", 403, "Forbidden");
response.setHeader("Content-Type", "text/xml");
response.write(PROPFIND_RATELIMIT_ERROR);
logger.log("S: " + PROPFIND_RATELIMIT_ERROR);
return;
}
const propNames = this._inputProps(input);
const propValues = {
"d:resourcetype": "",
"d:owner": "/principals/me/",
"d:current-user-principal": "/principals/me/",
"d:current-user-privilege-set": this.privileges,
"d:supported-report-set":
"" +
"",
"c:supported-calendar-component-set": "",
"d:getcontenttype": "text/calendar; charset=utf-8",
"c:calendar-home-set": `/calendars/me/`,
"c:calendar-user-address-set": `mailto:me@invalid`,
"c:schedule-inbox-url": `/calendars/me/inbox/`,
"c:schedule-outbox-url": `/calendars/me/outbox/`,
"cs:getctag": this.changeCount,
"d:getetag": this.changeCount,
};
let output = `
${this.path}
${this._outputProps(propNames, propValues)}
`;
if (depth == 1) {
for (const [href, item] of this.items) {
output += this._itemResponse(href, item, propNames);
}
}
output += ``;
response.setStatusLine("1.1", 207, "Multi-Status");
response.setHeader("Content-Type", "text/xml");
response.write(output.replace(/>\s+<"));
logger.log("S: " + output.replace(/>\s+<"));
},
syncCollection(input, response) {
if (this.throwRateLimitErrors) {
response.setStatusLine("1.1", 403, "Forbidden");
response.setHeader("Content-Type", "text/xml");
response.write(MULTIGET_RATELIMIT_ERROR);
logger.log("S: " + MULTIGET_RATELIMIT_ERROR);
return;
}
// The maximum number of responses to make at any one request.
const pageSize = 3;
// The last-seen token. Changes before this won't be returned.
let token = 0;
// Which page of responses to return.
let page = 0;
const tokenStr = input.querySelector("sync-token")?.textContent.replace(/.*\//g, "");
if (tokenStr?.includes("#")) {
[token, page] = tokenStr.split("#");
token = parseInt(token, 10);
page = parseInt(page, 10);
} else if (tokenStr) {
token = parseInt(tokenStr, 10);
}
const nextPage = page + 1;
// Collect all responses, even if we know some won't be returned.
// This is a test, who cares about performance?
const propNames = this._inputProps(input);
const responses = [];
for (const [href, item] of this.items) {
if (item.changed > token) {
responses.push(this._itemResponse(href, item, propNames));
}
}
for (const [href, deleted] of this.deletedItems) {
if (deleted > token) {
responses.push(`
HTTP/1.1 404 Not Found
${href}
HTTP/1.1 418 I'm a teapot
`);
}
}
let output = ``;
// Use only the responses that match those requested.
output += responses.slice(page * pageSize, nextPage * pageSize).join("");
if (responses.length > nextPage * pageSize) {
output += `
HTTP/1.1 507 Insufficient Storage
${this.path}
`;
output += `http://mochi.test/sync/${token}#${nextPage}`;
} else {
output += `http://mochi.test/sync/${this.changeCount}`;
}
output += ``;
response.setStatusLine("1.1", 207, "Multi-Status");
response.setHeader("Content-Type", "text/xml");
response.write(output.replace(/>\s+<"));
logger.log("S: " + output.replace(/>\s+<"));
},
_itemResponse(href, item, propNames) {
const propValues = {
"c:calendar-data": item.ics,
"d:getetag": item.etag,
"d:getcontenttype": "text/calendar; charset=utf-8; component=VEVENT",
};
const outString = `
${href}
${this._outputProps(propNames, propValues)}
`;
return outString;
},
_inputProps(input) {
const props = input.querySelectorAll("prop > *");
const propNames = [];
for (const p of props) {
Assert.equal(p.childElementCount, 0);
switch (p.localName) {
case "calendar-home-set":
case "calendar-user-address-set":
case "schedule-inbox-URL":
case "schedule-outbox-URL":
case "supported-calendar-component-set":
case "calendar-data":
Assert.equal(p.namespaceURI, PREFIX_BINDINGS.c);
propNames.push(`c:${p.localName}`);
break;
case "getctag":
Assert.equal(p.namespaceURI, PREFIX_BINDINGS.cs);
propNames.push(`cs:${p.localName}`);
break;
case "getetag":
case "owner":
case "current-user-principal":
case "current-user-privilege-set":
case "supported-report-set":
case "displayname":
case "resourcetype":
case "sync-token":
case "getcontenttype":
Assert.equal(p.namespaceURI, PREFIX_BINDINGS.d);
propNames.push(`d:${p.localName}`);
break;
case "calendar-color":
Assert.equal(p.namespaceURI, PREFIX_BINDINGS.i);
propNames.push(`i:${p.localName}`);
break;
default:
Assert.report(true, undefined, undefined, `Unknown property requested: ${p.nodeName}`);
break;
}
}
return propNames;
},
_outputProps(propNames, propValues) {
let output = "";
const found = [];
const notFound = [];
for (const p of propNames) {
if (p in propValues && propValues[p] != null) {
found.push(`<${p}>${propValues[p]}${p}>`);
} else {
notFound.push(`<${p}/>`);
}
}
if (found.length > 0) {
output += `
${found.join("\n")}
HTTP/1.1 200 OK
`;
}
if (notFound.length > 0) {
output += `
${notFound.join("\n")}
HTTP/1.1 404 Not Found
`;
}
return output;
},
/** Handle any requests to calendar items. */
itemHandler(request, response) {
if (!this.checkAuth(request, response)) {
return;
}
if (!/\/[\w-]+\.ics$/.test(request.path)) {
response.setStatusLine("1.1", 404, "Not Found");
response.setHeader("Content-Type", "text/plain");
response.write(`Item not found at ${request.path}`);
return;
}
switch (request.method) {
case "GET":
this.getItem(request, response);
return;
case "PUT":
this.putItem(request, response);
return;
case "DELETE":
this.deleteItem(request, response);
return;
}
Assert.report(true, undefined, undefined, "Should not have reached here");
response.setStatusLine("1.1", 405, "Method Not Allowed");
response.setHeader("Content-Type", "text/plain");
response.write(`Method not allowed: ${request.method}`);
},
async getItem(request, response) {
const item = this.items.get(request.path);
if (!item) {
response.setStatusLine("1.1", 404, "Not Found");
response.setHeader("Content-Type", "text/plain");
response.write(`Item not found at ${request.path}`);
return;
}
response.setStatusLine("1.1", 200, "OK");
response.setHeader("Content-Type", "text/calendar");
response.setHeader("ETag", item.etag);
response.write(item.ics);
},
async putItem(request, response) {
if (request.hasHeader("If-Match")) {
const item = this.items.get(request.path);
if (!item || item.etag != request.getHeader("If-Match")) {
response.setStatusLine("1.1", 412, "Precondition Failed");
return;
}
}
response.processAsync();
const ics = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
await this.putItemInternal(request.path, ics);
response.setStatusLine("1.1", 204, "No Content");
response.finish();
},
async putItemInternal(name, ics) {
if (!name.startsWith("/")) {
name = this.path + name;
}
const hash = await crypto.subtle.digest("sha-1", new TextEncoder().encode(ics));
const etag = Array.from(new Uint8Array(hash), c => c.toString(16).padStart(2, "0")).join("");
this.items.set(name, { etag, ics, changed: ++this.changeCount });
this.deletedItems.delete(name);
},
deleteItem(request, response) {
this.deleteItemInternal(request.path);
response.setStatusLine("1.1", 204, "No Content");
},
deleteItemInternal(name) {
if (!name.startsWith("/")) {
name = this.path + name;
}
this.items.delete(name);
this.deletedItems.set(name, ++this.changeCount);
},
};