Skip to content
File

Blob: src/worker/objects/file-dav/locks.ts

typescript138 lines
1import { generateEtag } from "@/worker/dav/etag";
2import {
3 findLockConflictOnResource,
4 findNewLockConflict,
5 lockAppliesToHref,
6 normalizeLockToken,
7} from "@/worker/dav/locks";
8import { resourceHref } from "@/worker/dav/paths";
9import type { FileDavDoDb } from "@/worker/db/file-dav-do/client";
10import {
11 activeLocks,
12 createEmptyFileNode,
13 deleteLock,
14 getLock,
15 insertLock,
16 nodeAtPath,
17 nodeById,
18 parentForPath,
19 refreshLock,
20 touchNode,
21} from "@/worker/db/file-dav-do/repository";
22import type { FileLockRow, FileNodeRow } from "@/worker/db/file-dav-do/schema";
23import { fileDavError, fileHref, fileIfHeaderMatches, timeoutMs } from "@/worker/objects/file-dav/helpers";
24import type { FileDavError, LockInput, UnlockInput } from "@/worker/objects/file-dav/types";
25 
26export function lock(
27 db: FileDavDoDb,
28 input: LockInput,
29): { ok: true; lock: FileLockRow; node: FileNodeRow; href: string; created: boolean } | FileDavError {
30 if (input.refresh) {
31 // RFC 4918 9.10.2: empty body LOCK is refresh only. Exactly one submitted
32 // lock token must resolve to an existing lock that applies to the request URI.
33 const requestTarget = nodeAtPath(db, input.path, input.nowMs, input.subjectId);
34 const requestHref = requestTarget?.href ?? resourceHref(input.path.segments, false);
35 const locks = activeLocks(db, input.nowMs);
36 if (
37 !fileIfHeaderMatches({
38 db,
39 header: input.ifHeader,
40 targetHref: requestHref,
41 targetEtag: requestTarget?.node.etag ?? null,
42 locks,
43 nowMs: input.nowMs,
44 subjectId: input.subjectId,
45 })
46 ) {
47 return fileDavError(412, "precondition_failed", "If precondition failed");
48 }
49 const applicable: FileLockRow[] = [];
50 for (const token of input.lockTokens) {
51 const stored = getLock(db, normalizeLockToken(token));
52 if (stored && lockAppliesToHref(stored, requestHref)) applicable.push(stored);
53 }
54 if (applicable.length === 0) {
55 return fileDavError(412, "precondition_failed", "Lock token does not apply to request URI");
56 }
57 if (applicable.length > 1) {
58 return fileDavError(400, "bad_request", "LOCK refresh requires exactly one applicable lock token");
59 }
60 const existingLock = applicable[0]!;
61 const refreshed = refreshLock(db, existingLock.token, timeoutMs(input.nowMs, input.timeoutSeconds));
62 if (!refreshed) return fileDavError(412, "precondition_failed", "Lock token does not exist");
63 const node = refreshed.rootNodeId ? nodeById(db, refreshed.rootNodeId) : undefined;
64 if (!node) return fileDavError(404, "not_found", "Locked resource not found");
65 return { ok: true, lock: refreshed, node, href: refreshed.rootPath, created: false };
66 }
67 
68 let target = nodeAtPath(db, input.path, input.nowMs, input.subjectId);
69 let created = false;
70 const requestHref = target?.href ?? resourceHref(input.path.segments, false);
71 const locks = activeLocks(db, input.nowMs);
72 if (
73 !fileIfHeaderMatches({
74 db,
75 header: input.ifHeader,
76 targetHref: requestHref,
77 targetEtag: target?.node.etag ?? null,
78 locks,
79 nowMs: input.nowMs,
80 subjectId: input.subjectId,
81 })
82 ) {
83 return fileDavError(412, "precondition_failed", "If precondition failed");
84 }
85 
86 if (!target) {
87 const parent = parentForPath(db, input.path, input.nowMs, input.subjectId);
88 if (!parent) return fileDavError(409, "conflict", "Parent collection does not exist");
89 const membershipConflict = findLockConflictOnResource(locks, parent.parent.href, input.lockTokens, null);
90 if (membershipConflict) return fileDavError(423, "locked", "Parent collection is locked");
91 const node = createEmptyFileNode(db, {
92 parentId: parent.parent.node.id,
93 name: parent.name,
94 contentType: "application/octet-stream",
95 etag: generateEtag(),
96 nowMs: input.nowMs,
97 });
98 touchNode(db, parent.parent.node.id, input.nowMs);
99 target = { node, href: fileHref(input.path, node), segments: input.path.segments };
100 created = true;
101 }
102 
103 // RFC 4918 7.3 / 7.4: a new exclusive LOCK must fail if any conflicting lock
104 // overlaps, even if the client submits the conflicting token. Submitted tokens
105 // only authorize writes on existing locks, not the creation of new ones.
106 const conflict = findNewLockConflict(locks, target.href, input.scope);
107 if (conflict) return fileDavError(423, "locked", "Resource is already locked");
108 const token = `opaquelocktoken:${crypto.randomUUID()}`;
109 insertLock(db, {
110 token,
111 rootNodeId: target.node.id,
112 rootPath: target.href,
113 ownerXml: input.ownerXml ?? '<D:owner xmlns:D="DAV:"/>',
114 principalSubjectId: input.subjectId,
115 scope: input.scope,
116 depth: input.depth,
117 createdAtMs: input.nowMs,
118 expiresAtMs: timeoutMs(input.nowMs, input.timeoutSeconds),
119 });
120 return { ok: true, lock: getLock(db, token)!, node: target.node, href: target.href, created };
121}
122 
123export function unlock(db: FileDavDoDb, input: UnlockInput): { ok: true } | FileDavError {
124 activeLocks(db, input.nowMs);
125 const storedLock = getLock(db, normalizeLockToken(input.lockToken));
126 if (!storedLock) return fileDavError(409, "conflict", "Lock token does not exist");
127 if (storedLock.principalSubjectId !== input.subjectId) {
128 return fileDavError(403, "forbidden", "Lock belongs to another principal");
129 }
130 const target = nodeAtPath(db, input.path, input.nowMs, input.subjectId);
131 const href = target?.href ?? resourceHref(input.path.segments, false);
132 if (!lockAppliesToHref(storedLock, href)) {
133 return fileDavError(409, "conflict", "Lock token does not apply to request URI");
134 }
135 deleteLock(db, storedLock.token);
136 return { ok: true };
137}