import { generateEtag } from "@/worker/dav/etag"; import { findLockConflictOnResource, findNewLockConflict, lockAppliesToHref, normalizeLockToken, } from "@/worker/dav/locks"; import { resourceHref } from "@/worker/dav/paths"; import type { FileDavDoDb } from "@/worker/db/file-dav-do/client"; import { activeLocks, createEmptyFileNode, deleteLock, getLock, insertLock, nodeAtPath, nodeById, parentForPath, refreshLock, touchNode, } from "@/worker/db/file-dav-do/repository"; import type { FileLockRow, FileNodeRow } from "@/worker/db/file-dav-do/schema"; import { fileDavError, fileHref, fileIfHeaderMatches, timeoutMs } from "@/worker/objects/file-dav/helpers"; import type { FileDavError, LockInput, UnlockInput } from "@/worker/objects/file-dav/types"; export function lock( db: FileDavDoDb, input: LockInput, ): { ok: true; lock: FileLockRow; node: FileNodeRow; href: string; created: boolean } | FileDavError { if (input.refresh) { // RFC 4918 9.10.2: empty body LOCK is refresh only. Exactly one submitted // lock token must resolve to an existing lock that applies to the request URI. const requestTarget = nodeAtPath(db, input.path, input.nowMs, input.subjectId); const requestHref = requestTarget?.href ?? resourceHref(input.path.segments, false); const locks = activeLocks(db, input.nowMs); if ( !fileIfHeaderMatches({ db, header: input.ifHeader, targetHref: requestHref, targetEtag: requestTarget?.node.etag ?? null, locks, nowMs: input.nowMs, subjectId: input.subjectId, }) ) { return fileDavError(412, "precondition_failed", "If precondition failed"); } const applicable: FileLockRow[] = []; for (const token of input.lockTokens) { const stored = getLock(db, normalizeLockToken(token)); if (stored && lockAppliesToHref(stored, requestHref)) applicable.push(stored); } if (applicable.length === 0) { return fileDavError(412, "precondition_failed", "Lock token does not apply to request URI"); } if (applicable.length > 1) { return fileDavError(400, "bad_request", "LOCK refresh requires exactly one applicable lock token"); } const existingLock = applicable[0]!; const refreshed = refreshLock(db, existingLock.token, timeoutMs(input.nowMs, input.timeoutSeconds)); if (!refreshed) return fileDavError(412, "precondition_failed", "Lock token does not exist"); const node = refreshed.rootNodeId ? nodeById(db, refreshed.rootNodeId) : undefined; if (!node) return fileDavError(404, "not_found", "Locked resource not found"); return { ok: true, lock: refreshed, node, href: refreshed.rootPath, created: false }; } let target = nodeAtPath(db, input.path, input.nowMs, input.subjectId); let created = false; const requestHref = target?.href ?? resourceHref(input.path.segments, false); const locks = activeLocks(db, input.nowMs); if ( !fileIfHeaderMatches({ db, header: input.ifHeader, targetHref: requestHref, targetEtag: target?.node.etag ?? null, locks, nowMs: input.nowMs, subjectId: input.subjectId, }) ) { return fileDavError(412, "precondition_failed", "If precondition failed"); } if (!target) { const parent = parentForPath(db, input.path, input.nowMs, input.subjectId); if (!parent) return fileDavError(409, "conflict", "Parent collection does not exist"); const membershipConflict = findLockConflictOnResource(locks, parent.parent.href, input.lockTokens, null); if (membershipConflict) return fileDavError(423, "locked", "Parent collection is locked"); const node = createEmptyFileNode(db, { parentId: parent.parent.node.id, name: parent.name, contentType: "application/octet-stream", etag: generateEtag(), nowMs: input.nowMs, }); touchNode(db, parent.parent.node.id, input.nowMs); target = { node, href: fileHref(input.path, node), segments: input.path.segments }; created = true; } // RFC 4918 7.3 / 7.4: a new exclusive LOCK must fail if any conflicting lock // overlaps, even if the client submits the conflicting token. Submitted tokens // only authorize writes on existing locks, not the creation of new ones. const conflict = findNewLockConflict(locks, target.href, input.scope); if (conflict) return fileDavError(423, "locked", "Resource is already locked"); const token = `opaquelocktoken:${crypto.randomUUID()}`; insertLock(db, { token, rootNodeId: target.node.id, rootPath: target.href, ownerXml: input.ownerXml ?? '', principalSubjectId: input.subjectId, scope: input.scope, depth: input.depth, createdAtMs: input.nowMs, expiresAtMs: timeoutMs(input.nowMs, input.timeoutSeconds), }); return { ok: true, lock: getLock(db, token)!, node: target.node, href: target.href, created }; } export function unlock(db: FileDavDoDb, input: UnlockInput): { ok: true } | FileDavError { activeLocks(db, input.nowMs); const storedLock = getLock(db, normalizeLockToken(input.lockToken)); if (!storedLock) return fileDavError(409, "conflict", "Lock token does not exist"); if (storedLock.principalSubjectId !== input.subjectId) { return fileDavError(403, "forbidden", "Lock belongs to another principal"); } const target = nodeAtPath(db, input.path, input.nowMs, input.subjectId); const href = target?.href ?? resourceHref(input.path.segments, false); if (!lockAppliesToHref(storedLock, href)) { return fileDavError(409, "conflict", "Lock token does not apply to request URI"); } deleteLock(db, storedLock.token); return { ok: true }; }