import { generateEtag, ifNoneMatchBlocks, strongEtagMatches } from "@/worker/dav/etag"; import { findLockConflict, findLockConflictOnResource } from "@/worker/dav/locks"; import { isSameOrDescendantPath, resourceHref } from "@/worker/dav/paths"; import type { FileDavDoDb } from "@/worker/db/file-dav-do/client"; import { activeLocks, blobId, childByName, createPendingUpload, deleteMovedLockRoots, deleteSubtree, ensureRoot, getPendingUpload, insertBlob, insertChange, markPendingUpload, moveNode, nodeAtPath, nodeById, parentForPath, touchNode, updateBlobRefcount, uploadId, upsertFileNode, treeFromLimited, } from "@/worker/db/file-dav-do/repository"; import { blobGcCandidates, copyTree, fileBlobKey, fileIfHeaderMatches, fileDavError, segmentsFromHref, } from "@/worker/objects/file-dav/helpers"; import type { AbortWriteInput, BeginWriteInput, BeginWriteResult, CommitWriteInput, CommitWriteResult, CopyInput, DeleteInput, FileDavError, MoveInput, } from "@/worker/objects/file-dav/types"; export function beginWrite(db: FileDavDoDb, input: BeginWriteInput): BeginWriteResult | FileDavError { ensureRoot(db, input.subjectId, input.nowMs); if (input.path.segments.length === 0 || input.path.collectionHint) { return fileDavError(405, "method_not_allowed", "PUT target must be a file path"); } if (input.contentLength !== null && input.contentLength > input.maxFileBytes) { return fileDavError(413, "payload_too_large", "File exceeds configured maximum size"); } const existing = nodeAtPath(db, input.path, input.nowMs, input.subjectId); if (existing?.node.kind === "collection") return fileDavError(405, "method_not_allowed", "Cannot PUT over a collection"); if (input.ifMatch && (!existing || !strongEtagMatches(input.ifMatch, existing.node.etag))) { return fileDavError(412, "precondition_failed", "If-Match precondition failed"); } if (input.ifNoneMatch && existing && ifNoneMatchBlocks(input.ifNoneMatch, existing.node.etag)) { return fileDavError(412, "precondition_failed", "If-None-Match precondition failed"); } const parent = parentForPath(db, input.path, input.nowMs, input.subjectId); if (!parent) return fileDavError(409, "conflict", "Parent collection does not exist"); const href = resourceHref(input.path.segments, false); const locks = activeLocks(db, input.nowMs); if ( !fileIfHeaderMatches({ db, header: input.ifHeader, targetHref: href, targetEtag: existing?.node.etag ?? null, locks, nowMs: input.nowMs, subjectId: input.subjectId, }) ) { return fileDavError(412, "precondition_failed", "If precondition failed"); } const conflict = findLockConflict(locks, href, input.lockTokens, null) ?? (!existing ? findLockConflictOnResource(locks, parent.parent.href, input.lockTokens, null) : null); if (conflict) return fileDavError(423, "locked", "Resource is locked"); const nextBlobId = blobId(); const nextUploadId = uploadId(); createPendingUpload(db, { uploadId: nextUploadId, nodeId: existing?.node.id ?? null, path: href, blobId: nextBlobId, blobKey: fileBlobKey(input.storageId, nextBlobId), createdAtMs: input.nowMs, expiresAtMs: input.nowMs + 15 * 60_000, expectedState: { expectedNodeId: existing?.node.id ?? null, expectedEtag: existing?.node.etag ?? null, expectedVersion: existing?.node.version ?? null, ifMatch: input.ifMatch, ifNoneMatch: input.ifNoneMatch, ifHeader: input.ifHeader, lockTokens: input.lockTokens, }, }); return { ok: true, uploadId: nextUploadId, blobId: nextBlobId, blobKey: fileBlobKey(input.storageId, nextBlobId) }; } export function commitWrite(db: FileDavDoDb, input: CommitWriteInput): CommitWriteResult | FileDavError { const pending = getPendingUpload(db, input.uploadId); if (!pending || pending.state !== "pending") return fileDavError(409, "conflict", "Upload is not pending"); const path = { href: pending.path, segments: segmentsFromHref(pending.path), collectionHint: false }; const parent = parentForPath(db, path, input.nowMs, input.subjectId); if (!parent) { markPendingUpload(db, pending.uploadId, "aborted"); return fileDavError(409, "conflict", "Parent collection does not exist"); } const existing = pending.nodeId ? nodeById(db, pending.nodeId) : childByName(db, parent.parent.node.id, parent.name); if (existing && existing.kind === "collection") { markPendingUpload(db, pending.uploadId, "aborted"); return fileDavError(405, "method_not_allowed", "Cannot PUT over a collection"); } const revalidation = revalidatePendingUpload({ db, pending, existing, parentHref: parent.parent.href, targetHref: pending.path, nowMs: input.nowMs, subjectId: input.subjectId, }); if (revalidation) { markPendingUpload(db, pending.uploadId, "aborted"); return revalidation; } insertBlob(db, { blobId: pending.blobId, blobKey: pending.blobKey, size: input.size, r2Etag: input.r2Etag, sha256: input.sha256 ?? null, refcount: 1, createdAtMs: input.nowMs, }); const result = upsertFileNode(db, { existing, parentId: parent.parent.node.id, name: parent.name, blobId: pending.blobId, size: input.size, contentType: input.contentType, contentLanguage: input.contentLanguage, etag: generateEtag(), nowMs: input.nowMs, }); if (result.oldBlobId) updateBlobRefcount(db, result.oldBlobId, -1); touchNode(db, parent.parent.node.id, input.nowMs); markPendingUpload(db, pending.uploadId, "committed"); insertChange(db, { nodeId: result.node.id, href: pending.path, changeType: result.created ? "created" : "updated", changedAtMs: input.nowMs, }); return { ok: true, created: result.created, node: result.node, blobGc: blobGcCandidates(db) }; } function revalidatePendingUpload(input: { db: FileDavDoDb; pending: ReturnType; existing: ReturnType | undefined; parentHref: string; targetHref: string; nowMs: number; subjectId: string; }): FileDavError | null { const expected = input.pending?.expectedState; if (!expected) return null; if ((expected.expectedNodeId ?? null) !== (input.existing?.id ?? null)) { return fileDavError(412, "precondition_failed", "Target changed between begin and commit"); } if (input.existing) { if (expected.expectedEtag !== null && expected.expectedEtag !== input.existing.etag) { return fileDavError(412, "precondition_failed", "Target etag changed between begin and commit"); } if (expected.expectedVersion !== null && expected.expectedVersion !== input.existing.version) { return fileDavError(412, "precondition_failed", "Target version changed between begin and commit"); } } if (expected.ifMatch && (!input.existing || !strongEtagMatches(expected.ifMatch, input.existing.etag))) { return fileDavError(412, "precondition_failed", "If-Match precondition failed at commit"); } if (expected.ifNoneMatch && input.existing && ifNoneMatchBlocks(expected.ifNoneMatch, input.existing.etag)) { return fileDavError(412, "precondition_failed", "If-None-Match precondition failed at commit"); } const locks = activeLocks(input.db, input.nowMs); if ( !fileIfHeaderMatches({ db: input.db, header: expected.ifHeader, targetHref: input.targetHref, targetEtag: input.existing?.etag ?? null, locks, nowMs: input.nowMs, subjectId: input.subjectId, }) ) { return fileDavError(412, "precondition_failed", "If precondition failed at commit"); } const conflict = findLockConflict(locks, input.targetHref, expected.lockTokens, null) ?? (!input.existing ? findLockConflictOnResource(locks, input.parentHref, expected.lockTokens, null) : null); if (conflict) return fileDavError(423, "locked", "Resource is locked at commit"); return null; } export function abortWrite(db: FileDavDoDb, input: AbortWriteInput): { ok: true } { markPendingUpload(db, input.uploadId, "aborted"); return { ok: true }; } export function deleteNode( db: FileDavDoDb, input: DeleteInput, ): { ok: true; blobGc: { blobId: string; blobKey: string }[] } | FileDavError { const target = nodeAtPath(db, input.path, input.nowMs, input.subjectId); if (!target) return fileDavError(404, "not_found", "Resource not found"); if (target.node.rootMarker === "root") return fileDavError(403, "forbidden", "Cannot delete the file root"); if (input.ifMatch && !strongEtagMatches(input.ifMatch, target.node.etag)) { return fileDavError(412, "precondition_failed", "If-Match precondition failed"); } if (input.ifNoneMatch && ifNoneMatchBlocks(input.ifNoneMatch, target.node.etag)) { return fileDavError(412, "precondition_failed", "If-None-Match precondition failed"); } const { exceeded } = treeFromLimited(db, target, "infinity", input.maxNodes); if (exceeded) return fileDavError(403, "forbidden", "Recursive DELETE exceeds configured maximum"); const locks = activeLocks(db, input.nowMs); if ( !fileIfHeaderMatches({ db, header: input.ifHeader, targetHref: target.href, targetEtag: target.node.etag, locks, nowMs: input.nowMs, subjectId: input.subjectId, }) ) { return fileDavError(412, "precondition_failed", "If precondition failed"); } const parentHref = target.segments.length > 0 ? resourceHref(target.segments.slice(0, -1), true) : null; const conflict = findLockConflict(locks, target.href, input.lockTokens, null) ?? (parentHref ? findLockConflictOnResource(locks, parentHref, input.lockTokens, null) : null); if (conflict) return fileDavError(423, "locked", "Resource is locked"); const deleted = deleteSubtree(db, target, input.maxNodes); if (!deleted.ok) return fileDavError(403, "forbidden", "Recursive DELETE exceeds configured maximum"); touchNode(db, target.node.parentId, input.nowMs); insertChange(db, { nodeId: null, href: target.href, changeType: "deleted", changedAtMs: input.nowMs }); return { ok: true, blobGc: blobGcCandidates(db) }; } export function copyNode( db: FileDavDoDb, input: CopyInput, ): { ok: true; created: boolean; blobGc: { blobId: string; blobKey: string }[] } | FileDavError { const source = nodeAtPath(db, input.from, input.nowMs, input.subjectId); if (!source) return fileDavError(404, "not_found", "Source resource not found"); if (input.to.segments.length === 0) return fileDavError(403, "forbidden", "Cannot COPY to the file root"); const destHref = resourceHref(input.to.segments, source.node.kind === "collection"); if (destHref === source.href) return fileDavError(403, "forbidden", "Cannot COPY a resource onto itself"); if (source.node.kind === "collection" && isSameOrDescendantPath(destHref, source.href)) { return fileDavError(403, "forbidden", "Cannot COPY a collection into itself"); } if (input.ifMatch && !strongEtagMatches(input.ifMatch, source.node.etag)) { return fileDavError(412, "precondition_failed", "If-Match precondition failed"); } if (input.ifNoneMatch && ifNoneMatchBlocks(input.ifNoneMatch, source.node.etag)) { return fileDavError(412, "precondition_failed", "If-None-Match precondition failed"); } const parent = parentForPath(db, input.to, input.nowMs, input.subjectId); if (!parent) return fileDavError(409, "conflict", "Destination parent does not exist"); const existing = nodeAtPath(db, input.to, input.nowMs, input.subjectId); if (existing && !input.overwrite) return fileDavError(412, "precondition_failed", "Destination exists"); const sourceTree = treeFromLimited(db, source, input.depth, input.maxNodes); if (sourceTree.exceeded) return fileDavError(403, "forbidden", "Recursive COPY exceeds configured maximum"); if (existing && treeFromLimited(db, existing, "infinity", input.maxNodes).exceeded) { return fileDavError(403, "forbidden", "Recursive overwrite exceeds configured maximum"); } const locks = activeLocks(db, input.nowMs); if ( !fileIfHeaderMatches({ db, header: input.ifHeader, targetHref: source.href, targetEtag: source.node.etag, locks, nowMs: input.nowMs, subjectId: input.subjectId, }) ) { return fileDavError(412, "precondition_failed", "If precondition failed"); } const conflict = findLockConflict(locks, destHref, input.lockTokens, null) ?? findLockConflictOnResource(locks, parent.parent.href, input.lockTokens, null); if (conflict) return fileDavError(423, "locked", "Destination is locked"); if (existing) { const deleted = deleteSubtree(db, existing, input.maxNodes); if (!deleted.ok) return fileDavError(403, "forbidden", "Recursive overwrite exceeds configured maximum"); } copyTree(db, source, parent.parent.node.id, parent.name, input.depth, input.nowMs, input.maxNodes); touchNode(db, parent.parent.node.id, input.nowMs); insertChange(db, { nodeId: null, href: destHref, changeType: "created", changedAtMs: input.nowMs }); return { ok: true, created: !existing, blobGc: blobGcCandidates(db) }; } export function moveNodeEntry( db: FileDavDoDb, input: MoveInput, ): { ok: true; created: boolean; blobGc: { blobId: string; blobKey: string }[] } | FileDavError { const source = nodeAtPath(db, input.from, input.nowMs, input.subjectId); if (!source) return fileDavError(404, "not_found", "Source resource not found"); if (source.node.rootMarker === "root" || input.to.segments.length === 0) { return fileDavError(403, "forbidden", "Cannot MOVE the file root"); } if (input.ifMatch && !strongEtagMatches(input.ifMatch, source.node.etag)) { return fileDavError(412, "precondition_failed", "If-Match precondition failed"); } if (input.ifNoneMatch && ifNoneMatchBlocks(input.ifNoneMatch, source.node.etag)) { return fileDavError(412, "precondition_failed", "If-None-Match precondition failed"); } if (isSameOrDescendantPath(resourceHref(input.to.segments, true), source.href)) { return fileDavError(403, "forbidden", "Cannot MOVE a collection into itself"); } const parent = parentForPath(db, input.to, input.nowMs, input.subjectId); if (!parent) return fileDavError(409, "conflict", "Destination parent does not exist"); const existing = nodeAtPath(db, input.to, input.nowMs, input.subjectId); if (existing && !input.overwrite) return fileDavError(412, "precondition_failed", "Destination exists"); const destHref = resourceHref(input.to.segments, source.node.kind === "collection"); if (treeFromLimited(db, source, "infinity", input.maxNodes).exceeded) { return fileDavError(403, "forbidden", "Recursive MOVE exceeds configured maximum"); } if (existing && treeFromLimited(db, existing, "infinity", input.maxNodes).exceeded) { return fileDavError(403, "forbidden", "Recursive overwrite exceeds configured maximum"); } const locks = activeLocks(db, input.nowMs); if ( !fileIfHeaderMatches({ db, header: input.ifHeader, targetHref: source.href, targetEtag: source.node.etag, locks, nowMs: input.nowMs, subjectId: input.subjectId, }) ) { return fileDavError(412, "precondition_failed", "If precondition failed"); } const sourceParentHref = source.segments.length > 0 ? resourceHref(source.segments.slice(0, -1), true) : null; const conflict = findLockConflict(locks, source.href, input.lockTokens, null) ?? (sourceParentHref ? findLockConflictOnResource(locks, sourceParentHref, input.lockTokens, null) : null); if (conflict) return fileDavError(423, "locked", "Source is locked"); const destConflict = findLockConflict(locks, destHref, input.lockTokens, null) ?? findLockConflictOnResource(locks, parent.parent.href, input.lockTokens, null); if (destConflict) return fileDavError(423, "locked", "Destination is locked"); if (existing) { const deleted = deleteSubtree(db, existing, input.maxNodes); if (!deleted.ok) return fileDavError(403, "forbidden", "Recursive overwrite exceeds configured maximum"); } const oldParentId = source.node.parentId; moveNode(db, source.node.id, parent.parent.node.id, parent.name, input.nowMs); // RFC 4918 7.5: locks at the source URL become unmapped after MOVE; drop them. deleteMovedLockRoots(db, source.href); touchNode(db, oldParentId, input.nowMs); if (oldParentId !== parent.parent.node.id) touchNode(db, parent.parent.node.id, input.nowMs); insertChange(db, { nodeId: source.node.id, href: destHref, changeType: "moved", changedAtMs: input.nowMs }); return { ok: true, created: !existing, blobGc: blobGcCandidates(db) }; }