File
Blob: src/worker/objects/file-dav/locks.ts
| 1 | import { generateEtag } from "@/worker/dav/etag"; |
| 2 | import { |
| 3 | findLockConflictOnResource, |
| 4 | findNewLockConflict, |
| 5 | lockAppliesToHref, |
| 6 | normalizeLockToken, |
| 7 | } from "@/worker/dav/locks"; |
| 8 | import { resourceHref } from "@/worker/dav/paths"; |
| 9 | import type { FileDavDoDb } from "@/worker/db/file-dav-do/client"; |
| 10 | import { |
| 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"; |
| 22 | import type { FileLockRow, FileNodeRow } from "@/worker/db/file-dav-do/schema"; |
| 23 | import { fileDavError, fileHref, fileIfHeaderMatches, timeoutMs } from "@/worker/objects/file-dav/helpers"; |
| 24 | import type { FileDavError, LockInput, UnlockInput } from "@/worker/objects/file-dav/types"; |
| 25 | |
| 26 | export 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 | |
| 123 | export 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 | } |