Skip to content
File

Blob: src/worker/db/card-dav-do/repository.ts

typescript429 lines
1import { and, desc, eq, gt, inArray, or, sql } from "drizzle-orm";
2 
3import { randomHex } from "@/worker/auth/bytes";
4import { addressbookHref as bookHref, addressObjectHref as objectHref } from "@/worker/carddav/paths";
5import type { CardDavFilterTest, CardDavSearchFilter } from "@/worker/carddav/types";
6import type { ParsedVCard } from "@/worker/carddav/vcard";
7import {
8 davSyncToken,
9 objectNameFromCollectionHref,
10 parseDavSyncToken,
11 parseDavSyncTokenStrict,
12 validDavCollectionName,
13} from "@/worker/db/dav-collections";
14import type { CardDavDoDb } from "@/worker/db/card-dav-do/client";
15import {
16 addressbooks,
17 addressChanges,
18 addressDeadProps,
19 addressIndex,
20 addressObjects,
21 cardMeta,
22 type AddressbookRow,
23 type AddressDeadPropRow,
24 type AddressIndexRow,
25 type AddressObjectRow,
26} from "@/worker/db/card-dav-do/schema";
27import type { DavChangeType } from "@/worker/db/types";
28 
29export { createCardDavDoDb } from "./client";
30 
31export interface CardDavResource {
32 kind: "home" | "addressbook" | "object";
33 href: string;
34 id: string;
35 syncToken?: string;
36 book?: AddressbookRow;
37 object?: AddressObjectRow;
38 index?: AddressIndexRow;
39 deadProps: AddressDeadPropRow[];
40}
41 
42function addressbookId(): string {
43 return `abook_${randomHex(16)}`;
44}
45 
46export function addressObjectId(): string {
47 return `addr_${randomHex(16)}`;
48}
49 
50export function validAddressbookName(name: string): boolean {
51 return validDavCollectionName(name);
52}
53 
54export function addressSyncToken(seq: number): string {
55 return davSyncToken(seq);
56}
57 
58export function addressIndexRow(
59 objectId: string,
60 addressbookId: string,
61 parsed: ParsedVCard,
62): typeof addressIndex.$inferInsert {
63 return {
64 objectId,
65 addressbookId,
66 uid: parsed.uid,
67 fn: parsed.fn,
68 nFamily: parsed.nFamily,
69 nGiven: parsed.nGiven,
70 org: parsed.org,
71 emails: parsed.emails,
72 tels: parsed.tels,
73 };
74}
75 
76export function ensureDefaultAddressbook(db: CardDavDoDb, subjectId: string, nowMs: number): AddressbookRow {
77 const existing = addressbookByName(db, "default");
78 if (existing) return existing;
79 db.insert(cardMeta).values({ key: "subject_id", value: subjectId, updatedAtMs: nowMs }).onConflictDoNothing().run();
80 db.insert(addressbooks)
81 .values({
82 id: addressbookId(),
83 name: "default",
84 displayName: "Default",
85 description: null,
86 createdAtMs: nowMs,
87 modifiedAtMs: nowMs,
88 syncSeq: 0,
89 })
90 .run();
91 return addressbookByName(db, "default")!;
92}
93 
94export function ensureAddressbook(
95 db: CardDavDoDb,
96 subjectId: string,
97 nowMs: number,
98 bookName: string,
99): AddressbookRow | null {
100 if (bookName === "default") return ensureDefaultAddressbook(db, subjectId, nowMs);
101 return addressbookByName(db, bookName) ?? null;
102}
103 
104export function listAddressbooks(db: CardDavDoDb, subjectId: string, nowMs: number): AddressbookRow[] {
105 ensureDefaultAddressbook(db, subjectId, nowMs);
106 return db.select().from(addressbooks).orderBy(addressbooks.createdAtMs).all();
107}
108 
109export function addressbookById(db: CardDavDoDb, id: string): AddressbookRow | undefined {
110 return db.query.addressbooks.findFirst({ where: eq(addressbooks.id, id) }).sync();
111}
112 
113export function addressbookByName(db: CardDavDoDb, name: string): AddressbookRow | undefined {
114 return db.query.addressbooks.findFirst({ where: eq(addressbooks.name, name) }).sync();
115}
116 
117export function createAddressbook(
118 db: CardDavDoDb,
119 input: {
120 subjectId: string;
121 name: string;
122 displayName: string;
123 description: string | null;
124 nowMs: number;
125 },
126): AddressbookRow {
127 ensureDefaultAddressbook(db, input.subjectId, input.nowMs);
128 const id = addressbookId();
129 db.insert(addressbooks)
130 .values({
131 id,
132 name: input.name,
133 displayName: input.displayName,
134 description: input.description,
135 createdAtMs: input.nowMs,
136 modifiedAtMs: input.nowMs,
137 syncSeq: 0,
138 })
139 .run();
140 return addressbookById(db, id)!;
141}
142 
143export function updateAddressbook(
144 db: CardDavDoDb,
145 input: { id: string; displayName?: string; description?: string | null; nowMs: number },
146): AddressbookRow | undefined {
147 const existing = addressbookById(db, input.id);
148 if (!existing) return undefined;
149 db.update(addressbooks)
150 .set({
151 displayName: input.displayName ?? existing.displayName,
152 description: input.description === undefined ? existing.description : input.description,
153 modifiedAtMs: input.nowMs,
154 syncSeq: sql`${addressbooks.syncSeq} + 1`,
155 })
156 .where(eq(addressbooks.id, input.id))
157 .run();
158 return addressbookById(db, input.id);
159}
160 
161export function deleteAddressbook(db: CardDavDoDb, id: string): boolean {
162 const existing = addressbookById(db, id);
163 if (!existing) return false;
164 const objectIds = db
165 .select({ id: addressObjects.id })
166 .from(addressObjects)
167 .where(eq(addressObjects.addressbookId, id))
168 .all();
169 db.transaction((tx) => {
170 tx.delete(addressDeadProps)
171 .where(and(eq(addressDeadProps.resourceKind, "addressbook"), eq(addressDeadProps.resourceId, id)))
172 .run();
173 if (objectIds.length > 0) {
174 tx.delete(addressDeadProps)
175 .where(
176 and(
177 eq(addressDeadProps.resourceKind, "object"),
178 inArray(
179 addressDeadProps.resourceId,
180 objectIds.map((row) => row.id),
181 ),
182 ),
183 )
184 .run();
185 }
186 tx.delete(addressbooks).where(eq(addressbooks.id, id)).run();
187 });
188 return true;
189}
190 
191export function homeResource(db: CardDavDoDb): CardDavResource {
192 return { kind: "home", href: "/addressbooks/", id: "home", deadProps: deadProps(db, "home", "home") };
193}
194 
195export function bookResource(db: CardDavDoDb, book: AddressbookRow): CardDavResource {
196 return {
197 kind: "addressbook",
198 href: bookHref(book.name),
199 id: book.id,
200 syncToken: addressSyncToken(latestChangeSeq(db)),
201 book,
202 deadProps: deadProps(db, "addressbook", book.id),
203 };
204}
205 
206export function objectResource(db: CardDavDoDb, book: AddressbookRow, object: AddressObjectRow): CardDavResource {
207 return {
208 kind: "object",
209 href: objectHref(book.name, object.name),
210 id: object.id,
211 book,
212 object,
213 index: db.query.addressIndex.findFirst({ where: eq(addressIndex.objectId, object.id) }).sync(),
214 deadProps: deadProps(db, "object", object.id),
215 };
216}
217 
218export interface ObjectResourcesResult {
219 resources: CardDavResource[];
220 truncated: boolean;
221}
222 
223export function objectResources(
224 db: CardDavDoDb,
225 book: AddressbookRow,
226 maxResults: number,
227 filters: CardDavSearchFilter[] = [],
228 filterTest: CardDavFilterTest = "anyof",
229): ObjectResourcesResult {
230 const { rows, truncated } = filteredObjects(db, book.id, maxResults, filters, filterTest);
231 return { resources: rows.map((row) => objectResource(db, book, row)), truncated };
232}
233 
234export function objectByName(db: CardDavDoDb, bookId: string, name: string): AddressObjectRow | undefined {
235 return db.query.addressObjects
236 .findFirst({ where: and(eq(addressObjects.addressbookId, bookId), eq(addressObjects.name, name)) })
237 .sync();
238}
239 
240export function objectByUid(db: CardDavDoDb, bookId: string, uid: string): AddressObjectRow | undefined {
241 return db.query.addressObjects
242 .findFirst({ where: and(eq(addressObjects.addressbookId, bookId), eq(addressObjects.uid, uid)) })
243 .sync();
244}
245 
246export function objectNameFromHref(book: AddressbookRow, href: string, acceptedOrigins?: string[]): string | null {
247 return objectNameFromCollectionHref(bookHref(book.name), href, acceptedOrigins);
248}
249 
250export function objectResourceFromHref(
251 db: CardDavDoDb,
252 book: AddressbookRow,
253 href: string,
254 acceptedOrigins?: string[],
255): CardDavResource | null {
256 const name = objectNameFromHref(book, href, acceptedOrigins);
257 if (!name) return null;
258 const object = objectByName(db, book.id, name);
259 return object ? objectResource(db, book, object) : null;
260}
261 
262export function latestChangeSeq(db: CardDavDoDb): number {
263 return (
264 db.select({ seq: addressChanges.seq }).from(addressChanges).orderBy(desc(addressChanges.seq)).limit(1).all()[0]
265 ?.seq ?? 0
266 );
267}
268 
269export interface ChangesSinceResult {
270 changes: {
271 seq: number;
272 addressbookId: string | null;
273 href: string;
274 changeType: DavChangeType;
275 changedAtMs: number;
276 }[];
277 truncated: boolean;
278}
279 
280export function changesSince(
281 db: CardDavDoDb,
282 book: AddressbookRow,
283 token: string | null,
284 maxResults: number,
285): ChangesSinceResult {
286 const since = parseDavSyncToken(token);
287 const rows = db
288 .select()
289 .from(addressChanges)
290 .where(and(eq(addressChanges.addressbookId, book.id), gt(addressChanges.seq, since)))
291 .orderBy(addressChanges.seq)
292 .limit(maxResults + 1)
293 .all();
294 const truncated = rows.length > maxResults;
295 return { changes: truncated ? rows.slice(0, maxResults) : rows, truncated };
296}
297 
298export function syncTokenSeq(token: string | null): number | null {
299 return parseDavSyncTokenStrict(token);
300}
301 
302export function recordChange(
303 db: CardDavDoDb,
304 bookId: string,
305 href: string,
306 changeType: DavChangeType,
307 nowMs: number,
308): void {
309 db.insert(addressChanges).values({ addressbookId: bookId, href, changeType, changedAtMs: nowMs }).run();
310 db.update(addressbooks)
311 .set({ modifiedAtMs: nowMs, syncSeq: sql`${addressbooks.syncSeq} + 1` })
312 .where(eq(addressbooks.id, bookId))
313 .run();
314}
315 
316function deadProps(db: CardDavDoDb, kind: "home" | "addressbook" | "object", resourceId: string): AddressDeadPropRow[] {
317 return db
318 .select()
319 .from(addressDeadProps)
320 .where(and(eq(addressDeadProps.resourceKind, kind), eq(addressDeadProps.resourceId, resourceId)))
321 .all();
322}
323 
324function filteredObjects(
325 db: CardDavDoDb,
326 bookId: string,
327 maxResults: number,
328 filters: CardDavSearchFilter[],
329 filterTest: CardDavFilterTest = "anyof",
330): { rows: AddressObjectRow[]; truncated: boolean } {
331 if (filters.length === 0) {
332 const rows = db
333 .select()
334 .from(addressObjects)
335 .where(eq(addressObjects.addressbookId, bookId))
336 .limit(maxResults + 1)
337 .all();
338 const truncated = rows.length > maxResults;
339 return { rows: truncated ? rows.slice(0, maxResults) : rows, truncated };
340 }
341 const clauses = filters.map((filter) => filterClause(filter));
342 // RFC 6352 10.5: combine prop-filter clauses with anyof (OR) or allof (AND).
343 const combined = filterTest === "anyof" ? or(...clauses) : and(...clauses);
344 const indexes = db
345 .select()
346 .from(addressIndex)
347 .where(and(eq(addressIndex.addressbookId, bookId), combined))
348 .limit(maxResults + 1)
349 .all();
350 const truncated = indexes.length > maxResults;
351 const limited = truncated ? indexes.slice(0, maxResults) : indexes;
352 if (limited.length === 0) return { rows: [], truncated };
353 const rows = db
354 .select()
355 .from(addressObjects)
356 .where(
357 inArray(
358 addressObjects.id,
359 limited.map((row) => row.objectId),
360 ),
361 )
362 .all();
363 return { rows, truncated };
364}
365 
366function containsCaseInsensitive(column: unknown, text: string) {
367 return sql`instr(lower(coalesce(${column}, '')), ${text.toLowerCase()}) > 0`;
368}
369 
370function notNullAndNotEmpty(column: unknown) {
371 return sql`coalesce(${column}, '') != ''`;
372}
373 
374function isNullOrEmpty(column: unknown) {
375 return sql`coalesce(${column}, '') = ''`;
376}
377 
378function jsonArrayHasValues(column: unknown) {
379 return sql`coalesce(${column}, '[]') not in ('[]', '')`;
380}
381 
382function jsonArrayIsEmpty(column: unknown) {
383 return sql`coalesce(${column}, '[]') in ('[]', '')`;
384}
385 
386function filterClause(filter: CardDavSearchFilter) {
387 if (filter.kind === "exists") return existsClause(filter.prop, filter.defined);
388 return textClause(filter.prop, filter.text);
389}
390 
391function textClause(prop: CardDavSearchFilter["prop"], text: string) {
392 switch (prop) {
393 case "UID":
394 return containsCaseInsensitive(addressIndex.uid, text);
395 case "FN":
396 return containsCaseInsensitive(addressIndex.fn, text);
397 case "N":
398 return or(
399 containsCaseInsensitive(addressIndex.nFamily, text),
400 containsCaseInsensitive(addressIndex.nGiven, text),
401 );
402 case "ORG":
403 return containsCaseInsensitive(addressIndex.org, text);
404 case "EMAIL":
405 return containsCaseInsensitive(addressIndex.emails, text);
406 case "TEL":
407 return containsCaseInsensitive(addressIndex.tels, text);
408 }
409}
410 
411function existsClause(prop: CardDavSearchFilter["prop"], defined: boolean) {
412 switch (prop) {
413 case "UID":
414 return defined ? notNullAndNotEmpty(addressIndex.uid) : isNullOrEmpty(addressIndex.uid);
415 case "FN":
416 return defined ? notNullAndNotEmpty(addressIndex.fn) : isNullOrEmpty(addressIndex.fn);
417 case "N":
418 return defined
419 ? or(notNullAndNotEmpty(addressIndex.nFamily), notNullAndNotEmpty(addressIndex.nGiven))
420 : and(isNullOrEmpty(addressIndex.nFamily), isNullOrEmpty(addressIndex.nGiven));
421 case "ORG":
422 return defined ? notNullAndNotEmpty(addressIndex.org) : isNullOrEmpty(addressIndex.org);
423 case "EMAIL":
424 return defined ? jsonArrayHasValues(addressIndex.emails) : jsonArrayIsEmpty(addressIndex.emails);
425 case "TEL":
426 return defined ? jsonArrayHasValues(addressIndex.tels) : jsonArrayIsEmpty(addressIndex.tels);
427 }
428}