Skip to content
File

Blob: src/worker/dav/if-header.ts

typescript174 lines
1export interface IfCondition {
2 not: boolean;
3 kind: "lock-token" | "state-token" | "etag";
4 value: string;
5}
6 
7export interface IfList {
8 resourceHref?: string;
9 conditions: IfCondition[];
10}
11 
12export interface ParsedIfHeader {
13 lists: IfList[];
14}
15 
16export type IfHeaderResult = { ok: true; header: ParsedIfHeader } | { ok: false; message: string };
17 
18class IfCursor {
19 index = 0;
20 
21 constructor(readonly value: string) {}
22 
23 done(): boolean {
24 return this.index >= this.value.length;
25 }
26 
27 peek(): string {
28 return this.value[this.index] ?? "";
29 }
30 
31 skipWs(): void {
32 while (/\s/.test(this.peek())) this.index += 1;
33 }
34 
35 consume(char: string): boolean {
36 this.skipWs();
37 if (this.peek() !== char) return false;
38 this.index += 1;
39 return true;
40 }
41 
42 readUntil(end: string): string | null {
43 const start = this.index;
44 const endIndex = this.value.indexOf(end, start);
45 if (endIndex === -1) return null;
46 this.index = endIndex + end.length;
47 return this.value.slice(start, endIndex);
48 }
49 
50 readWord(): string {
51 const start = this.index;
52 while (/[A-Za-z]/.test(this.peek())) this.index += 1;
53 return this.value.slice(start, this.index);
54 }
55}
56 
57function parseCondition(cursor: IfCursor): IfCondition | null {
58 cursor.skipWs();
59 let not = false;
60 const wordStart = cursor.index;
61 const word = cursor.readWord();
62 if (word.toLowerCase() === "not") {
63 not = true;
64 cursor.skipWs();
65 } else {
66 cursor.index = wordStart;
67 }
68 
69 const opener = cursor.peek();
70 if (opener === "<") {
71 cursor.index += 1;
72 const value = cursor.readUntil(">");
73 if (!value) return null;
74 return { not, kind: value.startsWith("opaquelocktoken:") ? "lock-token" : "state-token", value };
75 }
76 if (opener === "[") {
77 cursor.index += 1;
78 const value = cursor.readUntil("]");
79 if (!value) return null;
80 return { not, kind: "etag", value };
81 }
82 return null;
83}
84 
85export function parseIfHeader(value: string | null): IfHeaderResult {
86 if (!value) return { ok: true, header: { lists: [] } };
87 
88 const cursor = new IfCursor(value);
89 const lists: IfList[] = [];
90 let currentResource: string | undefined;
91 
92 while (!cursor.done()) {
93 cursor.skipWs();
94 if (cursor.done()) break;
95 
96 if (cursor.peek() === "<") {
97 cursor.index += 1;
98 const href = cursor.readUntil(">");
99 if (!href) return { ok: false, message: "Invalid tagged If header" };
100 currentResource = href;
101 continue;
102 }
103 
104 if (!cursor.consume("(")) return { ok: false, message: "Invalid If header list" };
105 const conditions: IfCondition[] = [];
106 while (!cursor.done()) {
107 cursor.skipWs();
108 if (cursor.consume(")")) break;
109 const condition = parseCondition(cursor);
110 if (!condition) return { ok: false, message: "Invalid If header condition" };
111 conditions.push(condition);
112 }
113 if (conditions.length === 0) return { ok: false, message: "Empty If header list" };
114 lists.push({ resourceHref: currentResource, conditions });
115 }
116 
117 return { ok: true, header: { lists } };
118}
119 
120export function positiveLockTokens(header: ParsedIfHeader): string[] {
121 const tokens = new Set<string>();
122 for (const list of header.lists) {
123 for (const condition of list.conditions) {
124 if (!condition.not && condition.kind === "lock-token") tokens.add(condition.value);
125 }
126 }
127 return [...tokens];
128}
129 
130export function positiveEtags(header: ParsedIfHeader): string[] {
131 const etags = new Set<string>();
132 for (const list of header.lists) {
133 for (const condition of list.conditions) {
134 if (!condition.not && condition.kind === "etag") etags.add(condition.value);
135 }
136 }
137 return [...etags];
138}
139 
140export function normalizeIfResourceHref(href: string): string {
141 try {
142 return new URL(href).pathname;
143 } catch {
144 return href;
145 }
146}
147 
148export function ifHeaderMatchesStateAndEtags(input: {
149 header: ParsedIfHeader;
150 targetHref: string;
151 targetEtag: string | null;
152 getEtag?: (href: string) => string | null;
153 getStateToken?: (href: string) => string | null;
154}): boolean {
155 if (input.header.lists.length === 0) return true;
156 
157 for (const list of input.header.lists) {
158 const conditionHref = list.resourceHref ? normalizeIfResourceHref(list.resourceHref) : input.targetHref;
159 const listMatches = list.conditions.every((condition) => {
160 const matches =
161 condition.kind === "etag"
162 ? (conditionHref === input.targetHref ? input.targetEtag : (input.getEtag?.(conditionHref) ?? null)) ===
163 condition.value
164 : condition.kind === "state-token"
165 ? (input.getStateToken?.(conditionHref) ?? null) === condition.value
166 : false;
167 return condition.not ? !matches : matches;
168 });
169 if (listMatches) return true;
170 }
171 
172 return false;
173}