/* 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 EXPORTED_SYMBOLS = ["CardDAVServer"];
const PREFIX_BINDINGS = {
card: "urn:ietf:params:xml:ns:carddav",
cs: "http://calendarserver.org/ns/",
d: "DAV:",
};
const NAMESPACE_STRING = Object.entries(PREFIX_BINDINGS)
.map(([prefix, url]) => `xmlns:${prefix}="${url}"`)
.join(" ");
const { Assert } = ChromeUtils.importESModule(
"resource://testing-common/Assert.sys.mjs"
);
const { CommonUtils } = ChromeUtils.importESModule(
"resource://services-common/utils.sys.mjs"
);
const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
var CardDAVServer = {
books: {
"/addressbooks/me/default/": "Not This One",
"/addressbooks/me/test/": "CardDAV Test",
},
cards: new Map(),
movedCards: new Map(),
deletedCards: new Map(),
changeCount: 0,
server: null,
isOpen: false,
open(username, password, port = -1) {
this.server = new HttpServer();
this.server.start(port);
this.port = this.server.identity.primaryPort;
this.isOpen = true;
this.username = username;
this.password = password;
this.server.registerPathHandler("/ping", this.ping);
this.reset();
},
reopen() {
this.server.start(this.port);
this.isOpen = true;
},
reset() {
this.cards.clear();
this.deletedCards.clear();
this.changeCount = 0;
this.resetHandlers();
},
resetHandlers() {
// Address book discovery.
this.server.registerPathHandler("/", this.wellKnown.bind(this));
this.server.registerPathHandler(
"/.well-known/carddav",
this.wellKnown.bind(this)
);
this.server.registerPathHandler("/principals/", this.principals.bind(this));
this.server.registerPathHandler(
"/principals/me/",
this.myPrincipal.bind(this)
);
this.server.registerPathHandler(
"/addressbooks/me/",
this.myAddressBooks.bind(this)
);
// Address book interaction.
for (let path of Object.keys(this.books)) {
this.server.registerPathHandler(path, this.directoryHandler.bind(this));
this.server.registerPrefixHandler(path, this.cardHandler.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 "/addressbooks/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;
}
let value = request.getHeader("Authorization");
if (!value.startsWith("Basic ")) {
response.setStatusLine("1.1", 401, "Unauthorized");
response.setHeader("WWW-Authenticate", `Basic realm="test"`);
return false;
}
let [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;
}
let input = new DOMParser().parseFromString(
CommonUtils.readBytesFromInputStream(request.bodyInputStream),
"text/xml"
);
let propNames = this._inputProps(input);
let 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;
}
let input = new DOMParser().parseFromString(
CommonUtils.readBytesFromInputStream(request.bodyInputStream),
"text/xml"
);
let propNames = this._inputProps(input);
let propValues = {
"d:resourcetype": "",
"card:addressbook-home-set": "/addressbooks/me/",
};
response.setStatusLine("1.1", 207, "Multi-Status");
response.setHeader("Content-Type", "text/xml");
response.write(
`
/principals/me/
${this._outputProps(propNames, propValues)}
`.replace(/>\s+<")
);
},
myAddressBooks(request, response) {
if (!this.checkAuth(request, response)) {
return;
}
let input = new DOMParser().parseFromString(
CommonUtils.readBytesFromInputStream(request.bodyInputStream),
"text/xml"
);
let propNames = this._inputProps(input);
response.setStatusLine("1.1", 207, "Multi-Status");
response.setHeader("Content-Type", "text/xml");
let output = `
/addressbooks/me/
${this._outputProps(propNames, {
"d:resourcetype": "",
"d:displayname": "#addressbooks",
})}
`;
for (let [path, name] of Object.entries(this.books)) {
output += `
${path}
${this._outputProps(propNames, {
"d:resourcetype": "",
"d:displayname": name,
"d:current-user-privilege-set":
"",
})}
`;
}
output += ``;
response.write(output.replace(/>\s+<"));
},
/** Handle any requests to the address book itself. */
directoryHandler(request, response) {
if (!this.checkAuth(request, response)) {
return;
}
let isRealDirectory = request.path == this.path;
let input = new DOMParser().parseFromString(
CommonUtils.readBytesFromInputStream(request.bodyInputStream),
"text/xml"
);
switch (input.documentElement.localName) {
case "addressbook-query":
Assert.equal(request.method, "REPORT");
Assert.equal(input.documentElement.namespaceURI, PREFIX_BINDINGS.card);
this.addressBookQuery(input, response, isRealDirectory);
return;
case "addressbook-multiget":
Assert.equal(request.method, "REPORT");
Assert.equal(input.documentElement.namespaceURI, PREFIX_BINDINGS.card);
this.addressBookMultiGet(input, response, isRealDirectory);
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,
isRealDirectory
);
return;
case "sync-collection":
Assert.equal(request.method, "REPORT");
Assert.equal(input.documentElement.namespaceURI, PREFIX_BINDINGS.d);
this.syncCollection(input, response, isRealDirectory);
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}>`);
},
addressBookQuery(input, response, isRealDirectory) {
if (this.mimicYahoo) {
response.setStatusLine("1.1", 400, "Bad Request");
return;
}
let propNames = this._inputProps(input);
let output = ``;
if (isRealDirectory) {
for (let [href, card] of this.cards) {
output += this._cardResponse(href, card, propNames);
}
}
output += ``;
response.setStatusLine("1.1", 207, "Multi-Status");
response.setHeader("Content-Type", "text/xml");
response.write(output.replace(/>\s+<"));
},
addressBookMultiGet(input, response, isRealDirectory) {
let propNames = this._inputProps(input);
let output = ``;
if (isRealDirectory) {
for (let href of input.querySelectorAll("href")) {
href = href.textContent;
if (this.movedCards.has(href)) {
href = this.movedCards.get(href);
}
let card = this.cards.get(href);
if (card) {
output += this._cardResponse(href, card, propNames);
}
}
}
output += ``;
response.setStatusLine("1.1", 207, "Multi-Status");
response.setHeader("Content-Type", "text/xml");
response.write(output.replace(/>\s+<"));
},
propFind(input, depth, response, isRealDirectory) {
let propNames = this._inputProps(input);
if (this.mimicYahoo && !propNames.includes("cs:getctag")) {
response.setStatusLine("1.1", 400, "Bad Request");
return;
}
let propValues = {
"cs:getctag": this.changeCount,
"d:displayname": isRealDirectory ? "CardDAV Test" : "Not This One",
"d:resourcetype": "",
"d:current-user-privilege-set": "",
};
if (!this.mimicYahoo) {
propValues["d:sync-token"] = `http://mochi.test/sync/${this.changeCount}`;
}
let output = `
${isRealDirectory ? this.path : this.altPath}
${this._outputProps(propNames, propValues)}
`;
if (depth == 1 && isRealDirectory) {
for (let [href, card] of this.cards) {
output += this._cardResponse(href, card, propNames);
}
}
output += ``;
response.setStatusLine("1.1", 207, "Multi-Status");
response.setHeader("Content-Type", "text/xml");
response.write(output.replace(/>\s+<"));
},
syncCollection(input, response, isRealDirectory) {
let token = input
.querySelector("sync-token")
.textContent.replace(/\D/g, "");
if (!token) {
response.setStatusLine("1.1", 400, "Bad Request");
return;
}
let propNames = this._inputProps(input);
let output = ``;
if (isRealDirectory) {
for (let [href, card] of this.cards) {
if (card.changed > token) {
output += this._cardResponse(
href,
card,
propNames,
!this.mimicGoogle
);
}
}
for (let [href, deleted] of this.deletedCards) {
if (deleted > token) {
output += `
HTTP/1.1 404 Not Found
${href}
HTTP/1.1 418 I'm a teapot
`;
}
}
}
output += `http://mochi.test/sync/${this.changeCount}
`;
response.setStatusLine("1.1", 207, "Multi-Status");
response.setHeader("Content-Type", "text/xml");
response.write(output.replace(/>\s+<"));
},
_cardResponse(href, card, propNames, includeAddressData = true) {
let propValues = {
"d:getetag": card.etag,
"d:resourcetype": null,
};
if (includeAddressData) {
propValues["card:address-data"] = card.vCard;
}
let outString = `
${href}
${this._outputProps(propNames, propValues)}
`;
return outString;
},
_inputProps(input) {
let props = input.querySelectorAll("prop > *");
let propNames = [];
for (let p of props) {
Assert.equal(p.childElementCount, 0);
switch (p.localName) {
case "address-data":
case "addressbook-home-set":
Assert.equal(p.namespaceURI, PREFIX_BINDINGS.card);
propNames.push(`card:${p.localName}`);
break;
case "getctag":
Assert.equal(p.namespaceURI, PREFIX_BINDINGS.cs);
propNames.push(`cs:${p.localName}`);
break;
case "current-user-principal":
case "current-user-privilege-set":
case "displayname":
case "getetag":
case "resourcetype":
case "sync-token":
Assert.equal(p.namespaceURI, PREFIX_BINDINGS.d);
propNames.push(`d:${p.localName}`);
break;
default:
Assert.report(
true,
undefined,
undefined,
`Unknown property requested: ${p.nodeName}`
);
break;
}
}
return propNames;
},
_outputProps(propNames, propValues) {
let output = "";
let found = [];
let notFound = [];
for (let p of propNames) {
if (p in propValues && propValues[p] !== undefined) {
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 address book cards. */
cardHandler(request, response) {
if (!this.checkAuth(request, response)) {
return;
}
let isRealDirectory = request.path.startsWith(this.path);
if (!isRealDirectory || !/\/[\w-]+\.vcf$/.test(request.path)) {
response.setStatusLine("1.1", 404, "Not Found");
response.setHeader("Content-Type", "text/plain");
response.write(`Card not found at ${request.path}`);
return;
}
switch (request.method) {
case "GET":
this.getCard(request, response);
return;
case "PUT":
this.putCard(request, response);
return;
case "DELETE":
this.deleteCard(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}`);
},
getCard(request, response) {
let card = this.cards.get(request.path);
if (!card) {
response.setStatusLine("1.1", 404, "Not Found");
response.setHeader("Content-Type", "text/plain");
response.write(`Card not found at ${request.path}`);
return;
}
response.setStatusLine("1.1", 200, "OK");
response.setHeader("Content-Type", "text/vcard");
response.setHeader("ETag", card.etag);
response.write(card.vCard);
},
putCard(request, response) {
if (request.hasHeader("If-Match")) {
let card = this.cards.get(request.path);
if (!card || card.etag != request.getHeader("If-Match")) {
response.setStatusLine("1.1", 412, "Precondition Failed");
return;
}
}
let vCard = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
if (this.mimicGoogle && !/^N[;:]/im.test(vCard)) {
response.setStatusLine("1.1", 400, "Bad Request");
return;
}
this.putCardInternal(request.path, vCard);
response.setStatusLine("1.1", 204, "No Content");
if (this.responseDelay) {
response.processAsync();
this.responseDelay.promise.then(() => {
delete this.responseDelay;
response.finish();
});
}
},
putCardInternal(name, vCard) {
if (!name.startsWith("/")) {
name = this.path + name;
}
if (this.modifyCardOnPut && !this.cards.has(name)) {
vCard = vCard.replace(/UID:(\S+)/, (match, uid) => {
let newUID = [...uid].reverse().join("");
let newName = this.path + newUID + ".vcf";
this.movedCards.set(name, newName);
name = newName;
return "UID:" + newUID + "\r\nX-MODIFIED-BY-SERVER:1";
});
}
if (this.mimicGoogle && vCard.includes("\nPHOTO")) {
let [, version] = vCard.match(/VERSION:([34]\.0)/);
if (version && version != "3.0") {
let start = vCard.indexOf("\nPHOTO") + 1;
let end = vCard.indexOf("\n", start) + 1;
while (vCard[end] == " ") {
end = vCard.indexOf("\n", end) + 1;
}
vCard = vCard.substring(0, start) + vCard.substring(end);
}
}
let etag = "" + vCard.length;
this.cards.set(name, { etag, vCard, changed: ++this.changeCount });
this.deletedCards.delete(name);
},
deleteCard(request, response) {
this.deleteCardInternal(request.path);
response.setStatusLine("1.1", 204, "No Content");
if (this.responseDelay) {
response.processAsync();
this.responseDelay.promise.then(() => {
delete this.responseDelay;
response.finish();
});
}
},
deleteCardInternal(name) {
if (!name.startsWith("/")) {
name = this.path + name;
}
this.cards.delete(name);
this.deletedCards.set(name, ++this.changeCount);
},
};