releases-comm-central/calendar/test/CalDAVServer.sys.mjs

626 строки
19 KiB
JavaScript
Исходник Обычный вид История

/* 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(" ");
Bug 1824260 - esmify calendar/ (apart from Ical.jsm). r=aleca,leftmostcat Ical.jsm doesn't want to be converted over so leaving it for now. Will figure out how to resolve the issues there, or take bug 1824264 in next step. Differential Revision: https://phabricator.services.mozilla.com/D204606 --HG-- rename : calendar/base/modules/calCalendarDeactivator.jsm => calendar/base/modules/calCalendarDeactivator.sys.mjs rename : calendar/base/modules/calExtract.jsm => calendar/base/modules/calExtract.sys.mjs rename : calendar/base/modules/calHashedArray.jsm => calendar/base/modules/calHashedArray.sys.mjs rename : calendar/base/modules/calRecurrenceUtils.jsm => calendar/base/modules/calRecurrenceUtils.sys.mjs rename : calendar/base/src/calCachedCalendar.js => calendar/base/src/CalCachedCalendar.sys.mjs rename : calendar/base/src/CalMetronome.jsm => calendar/base/src/CalMetronome.sys.mjs rename : calendar/base/src/CalReadableStreamFactory.jsm => calendar/base/src/CalReadableStreamFactory.sys.mjs rename : calendar/base/src/CalTimezone.jsm => calendar/base/src/CalTimezone.sys.mjs rename : calendar/base/src/CalTransactionManager.jsm => calendar/base/src/CalTransactionManager.sys.mjs rename : calendar/extract/CalExtractParser.jsm => calendar/extract/CalExtractParser.sys.mjs rename : calendar/extract/CalExtractParserService.jsm => calendar/extract/CalExtractParserService.sys.mjs rename : calendar/itip/CalItipMessageSender.jsm => calendar/itip/CalItipMessageSender.sys.mjs rename : calendar/itip/CalItipOutgoingMessage.jsm => calendar/itip/CalItipOutgoingMessage.sys.mjs rename : calendar/providers/caldav/CalDavProvider.jsm => calendar/providers/caldav/CalDavProvider.sys.mjs rename : calendar/providers/caldav/modules/CalDavRequest.jsm => calendar/providers/caldav/modules/CalDavRequest.sys.mjs rename : calendar/providers/caldav/modules/CalDavRequestHandlers.jsm => calendar/providers/caldav/modules/CalDavRequestHandlers.sys.mjs rename : calendar/providers/caldav/modules/CalDavSession.jsm => calendar/providers/caldav/modules/CalDavSession.sys.mjs rename : calendar/providers/caldav/modules/CalDavUtils.jsm => calendar/providers/caldav/modules/CalDavUtils.sys.mjs rename : calendar/providers/ics/CalICSProvider.jsm => calendar/providers/ics/CalICSProvider.sys.mjs rename : calendar/providers/storage/CalStorageCachedItemModel.jsm => calendar/providers/storage/CalStorageCachedItemModel.sys.mjs rename : calendar/providers/storage/CalStorageDatabase.jsm => calendar/providers/storage/CalStorageDatabase.sys.mjs rename : calendar/providers/storage/CalStorageItemModel.jsm => calendar/providers/storage/CalStorageItemModel.sys.mjs rename : calendar/providers/storage/CalStorageMetaDataModel.jsm => calendar/providers/storage/CalStorageMetaDataModel.sys.mjs rename : calendar/providers/storage/CalStorageModelBase.jsm => calendar/providers/storage/CalStorageModelBase.sys.mjs rename : calendar/providers/storage/CalStorageModelFactory.jsm => calendar/providers/storage/CalStorageModelFactory.sys.mjs rename : calendar/providers/storage/CalStorageOfflineModel.jsm => calendar/providers/storage/CalStorageOfflineModel.sys.mjs rename : calendar/providers/storage/CalStorageStatements.jsm => calendar/providers/storage/CalStorageStatements.sys.mjs rename : calendar/providers/storage/calStorageHelpers.jsm => calendar/providers/storage/calStorageHelpers.sys.mjs rename : calendar/providers/storage/calStorageUpgrade.jsm => calendar/providers/storage/calStorageUpgrade.sys.mjs rename : calendar/test/CalDAVServer.jsm => calendar/test/CalDAVServer.sys.mjs rename : calendar/test/CalendarTestUtils.jsm => calendar/test/CalendarTestUtils.sys.mjs rename : calendar/test/CalendarUtils.jsm => calendar/test/CalendarUtils.sys.mjs rename : calendar/test/ICSServer.jsm => calendar/test/ICSServer.sys.mjs rename : calendar/test/ItemEditingHelpers.jsm => calendar/test/ItemEditingHelpers.sys.mjs extra : amend_source : 180eac675e4893d841f439bd0f6e1d4ed292c783
2024-03-16 03:45:58 +03:00
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 = `<?xml version="1.0" encoding="UTF-8"?>
<D:error xmlns:D="DAV:"/>
`;
const PROPFIND_RATELIMIT_ERROR = `<?xml version="1.0" encoding="UTF-8"?>
<errors xmlns="http://schemas.google.com/g/2005">
<error>
<domain>GData</domain>
<code>rateLimitExceeded</code>
<internalReason>Some text we're not looking at anyway</internalReason>
</error>
</errors>
`;
Bug 1824260 - esmify calendar/ (apart from Ical.jsm). r=aleca,leftmostcat Ical.jsm doesn't want to be converted over so leaving it for now. Will figure out how to resolve the issues there, or take bug 1824264 in next step. Differential Revision: https://phabricator.services.mozilla.com/D204606 --HG-- rename : calendar/base/modules/calCalendarDeactivator.jsm => calendar/base/modules/calCalendarDeactivator.sys.mjs rename : calendar/base/modules/calExtract.jsm => calendar/base/modules/calExtract.sys.mjs rename : calendar/base/modules/calHashedArray.jsm => calendar/base/modules/calHashedArray.sys.mjs rename : calendar/base/modules/calRecurrenceUtils.jsm => calendar/base/modules/calRecurrenceUtils.sys.mjs rename : calendar/base/src/calCachedCalendar.js => calendar/base/src/CalCachedCalendar.sys.mjs rename : calendar/base/src/CalMetronome.jsm => calendar/base/src/CalMetronome.sys.mjs rename : calendar/base/src/CalReadableStreamFactory.jsm => calendar/base/src/CalReadableStreamFactory.sys.mjs rename : calendar/base/src/CalTimezone.jsm => calendar/base/src/CalTimezone.sys.mjs rename : calendar/base/src/CalTransactionManager.jsm => calendar/base/src/CalTransactionManager.sys.mjs rename : calendar/extract/CalExtractParser.jsm => calendar/extract/CalExtractParser.sys.mjs rename : calendar/extract/CalExtractParserService.jsm => calendar/extract/CalExtractParserService.sys.mjs rename : calendar/itip/CalItipMessageSender.jsm => calendar/itip/CalItipMessageSender.sys.mjs rename : calendar/itip/CalItipOutgoingMessage.jsm => calendar/itip/CalItipOutgoingMessage.sys.mjs rename : calendar/providers/caldav/CalDavProvider.jsm => calendar/providers/caldav/CalDavProvider.sys.mjs rename : calendar/providers/caldav/modules/CalDavRequest.jsm => calendar/providers/caldav/modules/CalDavRequest.sys.mjs rename : calendar/providers/caldav/modules/CalDavRequestHandlers.jsm => calendar/providers/caldav/modules/CalDavRequestHandlers.sys.mjs rename : calendar/providers/caldav/modules/CalDavSession.jsm => calendar/providers/caldav/modules/CalDavSession.sys.mjs rename : calendar/providers/caldav/modules/CalDavUtils.jsm => calendar/providers/caldav/modules/CalDavUtils.sys.mjs rename : calendar/providers/ics/CalICSProvider.jsm => calendar/providers/ics/CalICSProvider.sys.mjs rename : calendar/providers/storage/CalStorageCachedItemModel.jsm => calendar/providers/storage/CalStorageCachedItemModel.sys.mjs rename : calendar/providers/storage/CalStorageDatabase.jsm => calendar/providers/storage/CalStorageDatabase.sys.mjs rename : calendar/providers/storage/CalStorageItemModel.jsm => calendar/providers/storage/CalStorageItemModel.sys.mjs rename : calendar/providers/storage/CalStorageMetaDataModel.jsm => calendar/providers/storage/CalStorageMetaDataModel.sys.mjs rename : calendar/providers/storage/CalStorageModelBase.jsm => calendar/providers/storage/CalStorageModelBase.sys.mjs rename : calendar/providers/storage/CalStorageModelFactory.jsm => calendar/providers/storage/CalStorageModelFactory.sys.mjs rename : calendar/providers/storage/CalStorageOfflineModel.jsm => calendar/providers/storage/CalStorageOfflineModel.sys.mjs rename : calendar/providers/storage/CalStorageStatements.jsm => calendar/providers/storage/CalStorageStatements.sys.mjs rename : calendar/providers/storage/calStorageHelpers.jsm => calendar/providers/storage/calStorageHelpers.sys.mjs rename : calendar/providers/storage/calStorageUpgrade.jsm => calendar/providers/storage/calStorageUpgrade.sys.mjs rename : calendar/test/CalDAVServer.jsm => calendar/test/CalDAVServer.sys.mjs rename : calendar/test/CalendarTestUtils.jsm => calendar/test/CalendarTestUtils.sys.mjs rename : calendar/test/CalendarUtils.jsm => calendar/test/CalendarUtils.sys.mjs rename : calendar/test/ICSServer.jsm => calendar/test/ICSServer.sys.mjs rename : calendar/test/ItemEditingHelpers.jsm => calendar/test/ItemEditingHelpers.sys.mjs extra : amend_source : 180eac675e4893d841f439bd0f6e1d4ed292c783
2024-03-16 03:45:58 +03:00
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: "<d:privilege><d:all/></d:privilege>",
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 = "<d:privilege><d:all/></d:privilege>";
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": "<href>/principals/me/</href>",
};
response.setStatusLine("1.1", 207, "Multi-Status");
response.setHeader("Content-Type", "text/xml");
response.write(
`<multistatus xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>
<response>
<href>/principals/</href>
${this._outputProps(propNames, propValues)}
</response>
</multistatus>`.replace(/>\s+</g, "><")
);
},
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": "<principal/>",
"c:calendar-home-set": "<d:href>/calendars/me/</d:href>",
"c:calendar-user-address-set": `<d:href preferred="1">mailto:me@invalid</d:href>`,
"c:schedule-inbox-URL": "<d:href>/calendars/me/inbox/</d:href>",
"c:schedule-outbox-URL": "<d:href>/calendars/me/inbox/</d:href>",
};
response.setStatusLine("1.1", 207, "Multi-Status");
response.setHeader("Content-Type", "text/xml");
response.write(
`<multistatus xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>
<response>
<href>/principals/me/</href>
${this._outputProps(propNames, propValues)}
</response>
</multistatus>`.replace(/>\s+</g, "><")
);
},
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": "<collection/><c:calendar/>",
"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(
`<multistatus xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>
<response>
<href>/addressbooks/me/</href>
${this._outputProps(propNames, {
"d:resourcetype": "<collection/>",
"d:displayname": "#calendars",
})}
</response>
<response>
<href>${this.path}</href>
${this._outputProps(propNames, propValues)}
</response>
</multistatus>`.replace(/>\s+</g, "><")
);
},
/** 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 = `<multistatus xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>`;
for (const [href, item] of this.items) {
output += this._itemResponse(href, item, propNames);
}
output += `</multistatus>`;
response.setStatusLine("1.1", 207, "Multi-Status");
response.setHeader("Content-Type", "text/xml");
response.write(output.replace(/>\s+</g, "><"));
logger.log("S: " + output.replace(/>\s+</g, "><"));
},
async calendarMultiGet(input, response) {
const propNames = this._inputProps(input);
let output = `<multistatus xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>`;
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 += `</multistatus>`;
response.setStatusLine("1.1", 207, "Multi-Status");
response.setHeader("Content-Type", "text/xml");
response.write(output.replace(/>\s+</g, "><"));
logger.log("S: " + output.replace(/>\s+</g, "><"));
},
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:collection/><c:calendar/>",
"d:owner": "/principals/me/",
"d:current-user-principal": "<href>/principals/me/</href>",
"d:current-user-privilege-set": this.privileges,
"d:supported-report-set":
"<d:supported-report><d:report><c:calendar-multiget/></d:report></d:supported-report>" +
"<d:supported-report><d:report><sync-collection/></d:report></d:supported-report>",
"c:supported-calendar-component-set": "",
"d:getcontenttype": "text/calendar; charset=utf-8",
"c:calendar-home-set": `<d:href>/calendars/me/</d:href>`,
"c:calendar-user-address-set": `<d:href preferred="1">mailto:me@invalid</d:href>`,
"c:schedule-inbox-url": `<d:href>/calendars/me/inbox/</d:href>`,
"c:schedule-outbox-url": `<d:href>/calendars/me/outbox/</d:href>`,
"cs:getctag": this.changeCount,
"d:getetag": this.changeCount,
};
let output = `<multistatus xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>
<response>
<href>${this.path}</href>
${this._outputProps(propNames, propValues)}
</response>`;
if (depth == 1) {
for (const [href, item] of this.items) {
output += this._itemResponse(href, item, propNames);
}
}
output += `</multistatus>`;
response.setStatusLine("1.1", 207, "Multi-Status");
response.setHeader("Content-Type", "text/xml");
response.write(output.replace(/>\s+</g, "><"));
logger.log("S: " + output.replace(/>\s+</g, "><"));
},
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(`<response>
<status>HTTP/1.1 404 Not Found</status>
<href>${href}</href>
<propstat>
<prop/>
<status>HTTP/1.1 418 I'm a teapot</status>
</propstat>
</response>`);
}
}
let output = `<multistatus xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>`;
// Use only the responses that match those requested.
output += responses.slice(page * pageSize, nextPage * pageSize).join("");
if (responses.length > nextPage * pageSize) {
output += `<response>
<status>HTTP/1.1 507 Insufficient Storage</status>
<href>${this.path}</href>
</response>`;
output += `<sync-token>http://mochi.test/sync/${token}#${nextPage}</sync-token>`;
} else {
output += `<sync-token>http://mochi.test/sync/${this.changeCount}</sync-token>`;
}
output += `</multistatus>`;
response.setStatusLine("1.1", 207, "Multi-Status");
response.setHeader("Content-Type", "text/xml");
response.write(output.replace(/>\s+</g, "><"));
logger.log("S: " + output.replace(/>\s+</g, "><"));
},
_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 = `<response>
<href>${href}</href>
${this._outputProps(propNames, propValues)}
</response>`;
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 += `<propstat>
<prop>
${found.join("\n")}
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>`;
}
if (notFound.length > 0) {
output += `<propstat>
<prop>
${notFound.join("\n")}
</prop>
<status>HTTP/1.1 404 Not Found</status>
</propstat>`;
}
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);
},
};