Skip to content
File

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

typescript387 lines
1import { generateEtag, ifNoneMatchBlocks, strongEtagMatches } from "@/worker/dav/etag";
2import { findLockConflict, findLockConflictOnResource } from "@/worker/dav/locks";
3import { isSameOrDescendantPath, resourceHref } from "@/worker/dav/paths";
4import type { FileDavDoDb } from "@/worker/db/file-dav-do/client";
5import {
6 activeLocks,
7 blobId,
8 childByName,
9 createPendingUpload,
10 deleteMovedLockRoots,
11 deleteSubtree,
12 ensureRoot,
13 getPendingUpload,
14 insertBlob,
15 insertChange,
16 markPendingUpload,
17 moveNode,
18 nodeAtPath,
19 nodeById,
20 parentForPath,
21 touchNode,
22 updateBlobRefcount,
23 uploadId,
24 upsertFileNode,
25 treeFromLimited,
26} from "@/worker/db/file-dav-do/repository";
27import {
28 blobGcCandidates,
29 copyTree,
30 fileBlobKey,
31 fileIfHeaderMatches,
32 fileDavError,
33 segmentsFromHref,
34} from "@/worker/objects/file-dav/helpers";
35import type {
36 AbortWriteInput,
37 BeginWriteInput,
38 BeginWriteResult,
39 CommitWriteInput,
40 CommitWriteResult,
41 CopyInput,
42 DeleteInput,
43 FileDavError,
44 MoveInput,
45} from "@/worker/objects/file-dav/types";
46 
47export function beginWrite(db: FileDavDoDb, input: BeginWriteInput): BeginWriteResult | FileDavError {
48 ensureRoot(db, input.subjectId, input.nowMs);
49 if (input.path.segments.length === 0 || input.path.collectionHint) {
50 return fileDavError(405, "method_not_allowed", "PUT target must be a file path");
51 }
52 if (input.contentLength !== null && input.contentLength > input.maxFileBytes) {
53 return fileDavError(413, "payload_too_large", "File exceeds configured maximum size");
54 }
55 
56 const existing = nodeAtPath(db, input.path, input.nowMs, input.subjectId);
57 if (existing?.node.kind === "collection")
58 return fileDavError(405, "method_not_allowed", "Cannot PUT over a collection");
59 if (input.ifMatch && (!existing || !strongEtagMatches(input.ifMatch, existing.node.etag))) {
60 return fileDavError(412, "precondition_failed", "If-Match precondition failed");
61 }
62 if (input.ifNoneMatch && existing && ifNoneMatchBlocks(input.ifNoneMatch, existing.node.etag)) {
63 return fileDavError(412, "precondition_failed", "If-None-Match precondition failed");
64 }
65 
66 const parent = parentForPath(db, input.path, input.nowMs, input.subjectId);
67 if (!parent) return fileDavError(409, "conflict", "Parent collection does not exist");
68 const href = resourceHref(input.path.segments, false);
69 const locks = activeLocks(db, input.nowMs);
70 if (
71 !fileIfHeaderMatches({
72 db,
73 header: input.ifHeader,
74 targetHref: href,
75 targetEtag: existing?.node.etag ?? null,
76 locks,
77 nowMs: input.nowMs,
78 subjectId: input.subjectId,
79 })
80 ) {
81 return fileDavError(412, "precondition_failed", "If precondition failed");
82 }
83 const conflict =
84 findLockConflict(locks, href, input.lockTokens, null) ??
85 (!existing ? findLockConflictOnResource(locks, parent.parent.href, input.lockTokens, null) : null);
86 if (conflict) return fileDavError(423, "locked", "Resource is locked");
87 
88 const nextBlobId = blobId();
89 const nextUploadId = uploadId();
90 createPendingUpload(db, {
91 uploadId: nextUploadId,
92 nodeId: existing?.node.id ?? null,
93 path: href,
94 blobId: nextBlobId,
95 blobKey: fileBlobKey(input.storageId, nextBlobId),
96 createdAtMs: input.nowMs,
97 expiresAtMs: input.nowMs + 15 * 60_000,
98 expectedState: {
99 expectedNodeId: existing?.node.id ?? null,
100 expectedEtag: existing?.node.etag ?? null,
101 expectedVersion: existing?.node.version ?? null,
102 ifMatch: input.ifMatch,
103 ifNoneMatch: input.ifNoneMatch,
104 ifHeader: input.ifHeader,
105 lockTokens: input.lockTokens,
106 },
107 });
108 return { ok: true, uploadId: nextUploadId, blobId: nextBlobId, blobKey: fileBlobKey(input.storageId, nextBlobId) };
109}
110 
111export function commitWrite(db: FileDavDoDb, input: CommitWriteInput): CommitWriteResult | FileDavError {
112 const pending = getPendingUpload(db, input.uploadId);
113 if (!pending || pending.state !== "pending") return fileDavError(409, "conflict", "Upload is not pending");
114 const path = { href: pending.path, segments: segmentsFromHref(pending.path), collectionHint: false };
115 const parent = parentForPath(db, path, input.nowMs, input.subjectId);
116 if (!parent) {
117 markPendingUpload(db, pending.uploadId, "aborted");
118 return fileDavError(409, "conflict", "Parent collection does not exist");
119 }
120 const existing = pending.nodeId ? nodeById(db, pending.nodeId) : childByName(db, parent.parent.node.id, parent.name);
121 if (existing && existing.kind === "collection") {
122 markPendingUpload(db, pending.uploadId, "aborted");
123 return fileDavError(405, "method_not_allowed", "Cannot PUT over a collection");
124 }
125 const revalidation = revalidatePendingUpload({
126 db,
127 pending,
128 existing,
129 parentHref: parent.parent.href,
130 targetHref: pending.path,
131 nowMs: input.nowMs,
132 subjectId: input.subjectId,
133 });
134 if (revalidation) {
135 markPendingUpload(db, pending.uploadId, "aborted");
136 return revalidation;
137 }
138 
139 insertBlob(db, {
140 blobId: pending.blobId,
141 blobKey: pending.blobKey,
142 size: input.size,
143 r2Etag: input.r2Etag,
144 sha256: input.sha256 ?? null,
145 refcount: 1,
146 createdAtMs: input.nowMs,
147 });
148 const result = upsertFileNode(db, {
149 existing,
150 parentId: parent.parent.node.id,
151 name: parent.name,
152 blobId: pending.blobId,
153 size: input.size,
154 contentType: input.contentType,
155 contentLanguage: input.contentLanguage,
156 etag: generateEtag(),
157 nowMs: input.nowMs,
158 });
159 if (result.oldBlobId) updateBlobRefcount(db, result.oldBlobId, -1);
160 touchNode(db, parent.parent.node.id, input.nowMs);
161 markPendingUpload(db, pending.uploadId, "committed");
162 insertChange(db, {
163 nodeId: result.node.id,
164 href: pending.path,
165 changeType: result.created ? "created" : "updated",
166 changedAtMs: input.nowMs,
167 });
168 return { ok: true, created: result.created, node: result.node, blobGc: blobGcCandidates(db) };
169}
170 
171function revalidatePendingUpload(input: {
172 db: FileDavDoDb;
173 pending: ReturnType<typeof getPendingUpload>;
174 existing: ReturnType<typeof nodeById> | undefined;
175 parentHref: string;
176 targetHref: string;
177 nowMs: number;
178 subjectId: string;
179}): FileDavError | null {
180 const expected = input.pending?.expectedState;
181 if (!expected) return null;
182 if ((expected.expectedNodeId ?? null) !== (input.existing?.id ?? null)) {
183 return fileDavError(412, "precondition_failed", "Target changed between begin and commit");
184 }
185 if (input.existing) {
186 if (expected.expectedEtag !== null && expected.expectedEtag !== input.existing.etag) {
187 return fileDavError(412, "precondition_failed", "Target etag changed between begin and commit");
188 }
189 if (expected.expectedVersion !== null && expected.expectedVersion !== input.existing.version) {
190 return fileDavError(412, "precondition_failed", "Target version changed between begin and commit");
191 }
192 }
193 if (expected.ifMatch && (!input.existing || !strongEtagMatches(expected.ifMatch, input.existing.etag))) {
194 return fileDavError(412, "precondition_failed", "If-Match precondition failed at commit");
195 }
196 if (expected.ifNoneMatch && input.existing && ifNoneMatchBlocks(expected.ifNoneMatch, input.existing.etag)) {
197 return fileDavError(412, "precondition_failed", "If-None-Match precondition failed at commit");
198 }
199 const locks = activeLocks(input.db, input.nowMs);
200 if (
201 !fileIfHeaderMatches({
202 db: input.db,
203 header: expected.ifHeader,
204 targetHref: input.targetHref,
205 targetEtag: input.existing?.etag ?? null,
206 locks,
207 nowMs: input.nowMs,
208 subjectId: input.subjectId,
209 })
210 ) {
211 return fileDavError(412, "precondition_failed", "If precondition failed at commit");
212 }
213 const conflict =
214 findLockConflict(locks, input.targetHref, expected.lockTokens, null) ??
215 (!input.existing ? findLockConflictOnResource(locks, input.parentHref, expected.lockTokens, null) : null);
216 if (conflict) return fileDavError(423, "locked", "Resource is locked at commit");
217 return null;
218}
219 
220export function abortWrite(db: FileDavDoDb, input: AbortWriteInput): { ok: true } {
221 markPendingUpload(db, input.uploadId, "aborted");
222 return { ok: true };
223}
224 
225export function deleteNode(
226 db: FileDavDoDb,
227 input: DeleteInput,
228): { ok: true; blobGc: { blobId: string; blobKey: string }[] } | FileDavError {
229 const target = nodeAtPath(db, input.path, input.nowMs, input.subjectId);
230 if (!target) return fileDavError(404, "not_found", "Resource not found");
231 if (target.node.rootMarker === "root") return fileDavError(403, "forbidden", "Cannot delete the file root");
232 if (input.ifMatch && !strongEtagMatches(input.ifMatch, target.node.etag)) {
233 return fileDavError(412, "precondition_failed", "If-Match precondition failed");
234 }
235 if (input.ifNoneMatch && ifNoneMatchBlocks(input.ifNoneMatch, target.node.etag)) {
236 return fileDavError(412, "precondition_failed", "If-None-Match precondition failed");
237 }
238 const { exceeded } = treeFromLimited(db, target, "infinity", input.maxNodes);
239 if (exceeded) return fileDavError(403, "forbidden", "Recursive DELETE exceeds configured maximum");
240 const locks = activeLocks(db, input.nowMs);
241 if (
242 !fileIfHeaderMatches({
243 db,
244 header: input.ifHeader,
245 targetHref: target.href,
246 targetEtag: target.node.etag,
247 locks,
248 nowMs: input.nowMs,
249 subjectId: input.subjectId,
250 })
251 ) {
252 return fileDavError(412, "precondition_failed", "If precondition failed");
253 }
254 const parentHref = target.segments.length > 0 ? resourceHref(target.segments.slice(0, -1), true) : null;
255 const conflict =
256 findLockConflict(locks, target.href, input.lockTokens, null) ??
257 (parentHref ? findLockConflictOnResource(locks, parentHref, input.lockTokens, null) : null);
258 if (conflict) return fileDavError(423, "locked", "Resource is locked");
259 const deleted = deleteSubtree(db, target, input.maxNodes);
260 if (!deleted.ok) return fileDavError(403, "forbidden", "Recursive DELETE exceeds configured maximum");
261 touchNode(db, target.node.parentId, input.nowMs);
262 insertChange(db, { nodeId: null, href: target.href, changeType: "deleted", changedAtMs: input.nowMs });
263 return { ok: true, blobGc: blobGcCandidates(db) };
264}
265 
266export function copyNode(
267 db: FileDavDoDb,
268 input: CopyInput,
269): { ok: true; created: boolean; blobGc: { blobId: string; blobKey: string }[] } | FileDavError {
270 const source = nodeAtPath(db, input.from, input.nowMs, input.subjectId);
271 if (!source) return fileDavError(404, "not_found", "Source resource not found");
272 if (input.to.segments.length === 0) return fileDavError(403, "forbidden", "Cannot COPY to the file root");
273 const destHref = resourceHref(input.to.segments, source.node.kind === "collection");
274 if (destHref === source.href) return fileDavError(403, "forbidden", "Cannot COPY a resource onto itself");
275 if (source.node.kind === "collection" && isSameOrDescendantPath(destHref, source.href)) {
276 return fileDavError(403, "forbidden", "Cannot COPY a collection into itself");
277 }
278 if (input.ifMatch && !strongEtagMatches(input.ifMatch, source.node.etag)) {
279 return fileDavError(412, "precondition_failed", "If-Match precondition failed");
280 }
281 if (input.ifNoneMatch && ifNoneMatchBlocks(input.ifNoneMatch, source.node.etag)) {
282 return fileDavError(412, "precondition_failed", "If-None-Match precondition failed");
283 }
284 const parent = parentForPath(db, input.to, input.nowMs, input.subjectId);
285 if (!parent) return fileDavError(409, "conflict", "Destination parent does not exist");
286 const existing = nodeAtPath(db, input.to, input.nowMs, input.subjectId);
287 if (existing && !input.overwrite) return fileDavError(412, "precondition_failed", "Destination exists");
288 const sourceTree = treeFromLimited(db, source, input.depth, input.maxNodes);
289 if (sourceTree.exceeded) return fileDavError(403, "forbidden", "Recursive COPY exceeds configured maximum");
290 if (existing && treeFromLimited(db, existing, "infinity", input.maxNodes).exceeded) {
291 return fileDavError(403, "forbidden", "Recursive overwrite exceeds configured maximum");
292 }
293 const locks = activeLocks(db, input.nowMs);
294 if (
295 !fileIfHeaderMatches({
296 db,
297 header: input.ifHeader,
298 targetHref: source.href,
299 targetEtag: source.node.etag,
300 locks,
301 nowMs: input.nowMs,
302 subjectId: input.subjectId,
303 })
304 ) {
305 return fileDavError(412, "precondition_failed", "If precondition failed");
306 }
307 const conflict =
308 findLockConflict(locks, destHref, input.lockTokens, null) ??
309 findLockConflictOnResource(locks, parent.parent.href, input.lockTokens, null);
310 if (conflict) return fileDavError(423, "locked", "Destination is locked");
311 if (existing) {
312 const deleted = deleteSubtree(db, existing, input.maxNodes);
313 if (!deleted.ok) return fileDavError(403, "forbidden", "Recursive overwrite exceeds configured maximum");
314 }
315 
316 copyTree(db, source, parent.parent.node.id, parent.name, input.depth, input.nowMs, input.maxNodes);
317 touchNode(db, parent.parent.node.id, input.nowMs);
318 insertChange(db, { nodeId: null, href: destHref, changeType: "created", changedAtMs: input.nowMs });
319 return { ok: true, created: !existing, blobGc: blobGcCandidates(db) };
320}
321 
322export function moveNodeEntry(
323 db: FileDavDoDb,
324 input: MoveInput,
325): { ok: true; created: boolean; blobGc: { blobId: string; blobKey: string }[] } | FileDavError {
326 const source = nodeAtPath(db, input.from, input.nowMs, input.subjectId);
327 if (!source) return fileDavError(404, "not_found", "Source resource not found");
328 if (source.node.rootMarker === "root" || input.to.segments.length === 0) {
329 return fileDavError(403, "forbidden", "Cannot MOVE the file root");
330 }
331 if (input.ifMatch && !strongEtagMatches(input.ifMatch, source.node.etag)) {
332 return fileDavError(412, "precondition_failed", "If-Match precondition failed");
333 }
334 if (input.ifNoneMatch && ifNoneMatchBlocks(input.ifNoneMatch, source.node.etag)) {
335 return fileDavError(412, "precondition_failed", "If-None-Match precondition failed");
336 }
337 if (isSameOrDescendantPath(resourceHref(input.to.segments, true), source.href)) {
338 return fileDavError(403, "forbidden", "Cannot MOVE a collection into itself");
339 }
340 const parent = parentForPath(db, input.to, input.nowMs, input.subjectId);
341 if (!parent) return fileDavError(409, "conflict", "Destination parent does not exist");
342 const existing = nodeAtPath(db, input.to, input.nowMs, input.subjectId);
343 if (existing && !input.overwrite) return fileDavError(412, "precondition_failed", "Destination exists");
344 const destHref = resourceHref(input.to.segments, source.node.kind === "collection");
345 if (treeFromLimited(db, source, "infinity", input.maxNodes).exceeded) {
346 return fileDavError(403, "forbidden", "Recursive MOVE exceeds configured maximum");
347 }
348 if (existing && treeFromLimited(db, existing, "infinity", input.maxNodes).exceeded) {
349 return fileDavError(403, "forbidden", "Recursive overwrite exceeds configured maximum");
350 }
351 const locks = activeLocks(db, input.nowMs);
352 if (
353 !fileIfHeaderMatches({
354 db,
355 header: input.ifHeader,
356 targetHref: source.href,
357 targetEtag: source.node.etag,
358 locks,
359 nowMs: input.nowMs,
360 subjectId: input.subjectId,
361 })
362 ) {
363 return fileDavError(412, "precondition_failed", "If precondition failed");
364 }
365 const sourceParentHref = source.segments.length > 0 ? resourceHref(source.segments.slice(0, -1), true) : null;
366 const conflict =
367 findLockConflict(locks, source.href, input.lockTokens, null) ??
368 (sourceParentHref ? findLockConflictOnResource(locks, sourceParentHref, input.lockTokens, null) : null);
369 if (conflict) return fileDavError(423, "locked", "Source is locked");
370 const destConflict =
371 findLockConflict(locks, destHref, input.lockTokens, null) ??
372 findLockConflictOnResource(locks, parent.parent.href, input.lockTokens, null);
373 if (destConflict) return fileDavError(423, "locked", "Destination is locked");
374 if (existing) {
375 const deleted = deleteSubtree(db, existing, input.maxNodes);
376 if (!deleted.ok) return fileDavError(403, "forbidden", "Recursive overwrite exceeds configured maximum");
377 }
378 const oldParentId = source.node.parentId;
379 moveNode(db, source.node.id, parent.parent.node.id, parent.name, input.nowMs);
380 // RFC 4918 7.5: locks at the source URL become unmapped after MOVE; drop them.
381 deleteMovedLockRoots(db, source.href);
382 touchNode(db, oldParentId, input.nowMs);
383 if (oldParentId !== parent.parent.node.id) touchNode(db, parent.parent.node.id, input.nowMs);
384 insertChange(db, { nodeId: source.node.id, href: destHref, changeType: "moved", changedAtMs: input.nowMs });
385 return { ok: true, created: !existing, blobGc: blobGcCandidates(db) };
386}