Skip to content
File

Blob: src/worker/db/dav-collections.ts

typescript84 lines
1export type DeadPropInstruction =
2 | { kind: "set"; nsUri: string; localName: string; xmlValue: string }
3 | { kind: "remove"; nsUri: string; localName: string };
4 
5export function validDavCollectionName(name: string): boolean {
6 return name.length > 0 && name.length <= 128 && name !== "." && name !== ".." && !/[\\/]/.test(name);
7}
8 
9export function davSyncToken(seq: number): string {
10 return `sync:${seq}`;
11}
12 
13export function parseDavSyncToken(token: string | null): number {
14 if (!token) return 0;
15 const match = /^sync:(\d+)$/.exec(token);
16 return match ? Number.parseInt(match[1]!, 10) : 0;
17}
18 
19export function parseDavSyncTokenStrict(token: string | null): number | null {
20 if (!token) return 0;
21 const match = /^sync:(\d+)$/.exec(token);
22 return match ? Number.parseInt(match[1]!, 10) : null;
23}
24 
25export function objectNameFromCollectionHref(
26 collectionHref: string,
27 href: string,
28 acceptedOrigins?: string[],
29): string | null {
30 let pathname: string;
31 const absolute = href.startsWith("http://") || href.startsWith("https://");
32 try {
33 if (absolute) {
34 const parsed = new URL(href);
35 // RFC 4791 7.9 / RFC 6352 8.7: absolute hrefs in multiget must resolve to
36 // the same subject host. Reject foreign authorities outright so a request
37 // cannot leak local objects under a different origin's name.
38 if (acceptedOrigins && acceptedOrigins.length > 0 && !acceptedOrigins.includes(parsed.origin)) {
39 return null;
40 }
41 pathname = parsed.pathname;
42 } else {
43 pathname = href;
44 }
45 } catch {
46 return null;
47 }
48 if (!pathname.startsWith(collectionHref)) return null;
49 const rawName = pathname.slice(collectionHref.length);
50 if (!rawName || rawName.includes("/")) return null;
51 try {
52 return decodeURIComponent(rawName);
53 } catch {
54 return null;
55 }
56}
57 
58export function applyDeadPropInstructions(
59 instructions: DeadPropInstruction[],
60 handlers: {
61 set: (instruction: Extract<DeadPropInstruction, { kind: "set" }>) => void;
62 remove: (instruction: Extract<DeadPropInstruction, { kind: "remove" }>) => void;
63 },
64): void {
65 for (const instruction of instructions) {
66 if (instruction.kind === "set") handlers.set(instruction);
67 else handlers.remove(instruction);
68 }
69}
70 
71// Extract text content from a serialized prop element such as
72// "<D:displayname>Family</D:displayname>". The serializer preserves nested
73// markup, so we strip the outermost element and decode common entities.
74export function extractPropTextValue(xmlValue: string): string {
75 const match = /^<[^>]+>([\s\S]*)<\/[^>]+>$/.exec(xmlValue.trim());
76 const inner = match ? (match[1] ?? "") : xmlValue;
77 return inner
78 .replace(/&amp;/g, "&")
79 .replace(/&lt;/g, "<")
80 .replace(/&gt;/g, ">")
81 .replace(/&quot;/g, '"')
82 .replace(/&apos;/g, "'");
83}