Skip to content
File

Blob: src/worker/dav/collection-object.ts

typescript230 lines
1import { davError, davResponse, emptyResponse } from "@/worker/dav/http";
2import { deadPropInstructions, type CollectionDavPath } from "@/worker/dav/collection-handler";
3import { davHtmlListingResponse, type DavListingEntry } from "@/worker/dav/html-listing";
4import {
5 readCollectionPropfind,
6 readCollectionProppatch,
7 readDavConditionalHeaders,
8 type DavConditionalHeaders,
9 type DavReadResult,
10} from "@/worker/dav/readers";
11import {
12 davObjectResponse,
13 davResultError,
14 protectedProppatchMultistatus,
15 proppatchMultistatus,
16 type DavResultError,
17} from "@/worker/dav/responses";
18import type { DavResourcePropKind } from "@/worker/dav/props";
19import type { DavRequestContext } from "@/worker/dav/runtime";
20import type { PropfindRequest } from "@/worker/xml/dav-request";
21 
22export type DavOperationResult<T extends object = object> = ({ ok: true } & T) | DavResultError;
23type CollectionListingPath<TCollectionKind extends string> =
24 | { kind: "home" }
25 | { kind: TCollectionKind; collectionName: string };
26type CollectionObjectPath = {
27 kind: "object";
28 collectionName: string;
29 objectName: string;
30};
31 
32export interface CollectionObjectAdapter<
33 TCollectionKind extends string,
34 TResource extends { href: string },
35 TObject extends { body: BodyInit; size: number; etag: string },
36 TPutExtra extends object = Record<string, never>,
37> {
38 objectContentType: string;
39 objectName: string;
40 objectPathName: string;
41 putObjectPathName: string;
42 deleteObjectPathName: string;
43 hrefForPath(path: CollectionDavPath<TCollectionKind>): string;
44 resourceKindForPath(path: CollectionDavPath<TCollectionKind>): DavResourcePropKind;
45 maxObjectBytes(context: DavRequestContext): number;
46 preparePut(context: DavRequestContext): DavReadResult<TPutExtra> | Promise<DavReadResult<TPutExtra>>;
47 describe(
48 context: DavRequestContext,
49 path: CollectionDavPath<TCollectionKind>,
50 depth: "0" | "1",
51 ): Promise<DavOperationResult<{ resources: TResource[] }>>;
52 propfindMultistatus(context: DavRequestContext, resources: TResource[], request: PropfindRequest): string;
53 proppatch(
54 context: DavRequestContext,
55 path: CollectionDavPath<TCollectionKind>,
56 input: {
57 instructions: ReturnType<typeof deadPropInstructions>;
58 conditions: DavConditionalHeaders;
59 nowMs: number;
60 },
61 ): Promise<DavOperationResult>;
62 getObject(
63 context: DavRequestContext,
64 path: Extract<CollectionDavPath<TCollectionKind>, { kind: "object" }>,
65 ): Promise<DavOperationResult<{ object: TObject }>>;
66 putObject(
67 context: DavRequestContext,
68 path: Extract<CollectionDavPath<TCollectionKind>, { kind: "object" }>,
69 input: {
70 body: ArrayBuffer;
71 conditions: DavConditionalHeaders;
72 maxBytes: number;
73 nowMs: number;
74 } & TPutExtra,
75 ): Promise<DavOperationResult<{ created: boolean; object: { etag: string } }>>;
76 deleteObject(
77 context: DavRequestContext,
78 path: Extract<CollectionDavPath<TCollectionKind>, { kind: "object" }>,
79 input: {
80 conditions: DavConditionalHeaders;
81 nowMs: number;
82 },
83 ): Promise<DavOperationResult>;
84}
85 
86export interface CollectionHtmlListingAdapter<TCollectionKind extends string, TResource extends { href: string }> {
87 title(resource: TResource): string;
88 parentHref(path: CollectionListingPath<TCollectionKind>): string | null;
89 entry(resource: TResource): DavListingEntry;
90}
91 
92function propfindResourceKind<TCollectionKind extends string>(path: CollectionDavPath<TCollectionKind>): string {
93 return path.kind === "object" ? "object" : path.kind;
94}
95 
96function isCollectionObjectPath<TCollectionKind extends string>(
97 path: CollectionDavPath<TCollectionKind>,
98): path is CollectionObjectPath {
99 return path.kind === "object";
100}
101 
102export async function handleCollectionPropfind<
103 TCollectionKind extends string,
104 TResource extends { href: string },
105 TObject extends { body: BodyInit; size: number; etag: string },
106 TPutExtra extends object,
107>(
108 context: DavRequestContext,
109 path: CollectionDavPath<TCollectionKind>,
110 adapter: CollectionObjectAdapter<TCollectionKind, TResource, TObject, TPutExtra>,
111): Promise<Response> {
112 const propfind = await readCollectionPropfind(context.c, propfindResourceKind(path));
113 if (!propfind.ok) return propfind.response;
114 const result = await adapter.describe(context, path, propfind.value.depth);
115 if (!result.ok) return davResultError(result);
116 return davResponse(adapter.propfindMultistatus(context, result.resources, propfind.value.request), 207);
117}
118 
119export async function handleCollectionProppatch<
120 TCollectionKind extends string,
121 TResource extends { href: string },
122 TObject extends { body: BodyInit; size: number; etag: string },
123 TPutExtra extends object,
124>(
125 context: DavRequestContext,
126 path: CollectionDavPath<TCollectionKind>,
127 adapter: CollectionObjectAdapter<TCollectionKind, TResource, TObject, TPutExtra>,
128): Promise<Response> {
129 const resourceKind = adapter.resourceKindForPath(path);
130 const proppatch = await readCollectionProppatch(context.c, resourceKind);
131 if (!proppatch.ok) return proppatch.response;
132 const href = adapter.hrefForPath(path);
133 const protectedResponse = protectedProppatchMultistatus(href, proppatch.value, resourceKind);
134 if (protectedResponse) return protectedResponse;
135 const conditions = readDavConditionalHeaders(context.c);
136 if (!conditions.ok) return conditions.response;
137 const result = await adapter.proppatch(context, path, {
138 instructions: deadPropInstructions(proppatch.value),
139 conditions: conditions.value,
140 nowMs: Date.now(),
141 });
142 return result.ok ? proppatchMultistatus(href, proppatch.value) : davResultError(result);
143}
144 
145export async function handleCollectionObjectGetHead<
146 TCollectionKind extends string,
147 TResource extends { href: string },
148 TObject extends { body: BodyInit; size: number; etag: string },
149 TPutExtra extends object,
150>(
151 context: DavRequestContext,
152 path: CollectionDavPath<TCollectionKind>,
153 adapter: CollectionObjectAdapter<TCollectionKind, TResource, TObject, TPutExtra>,
154): Promise<Response> {
155 if (!isCollectionObjectPath(path)) return davError(405, `GET requires ${adapter.objectName}`);
156 const result = await adapter.getObject(context, path);
157 if (!result.ok) return davResultError(result);
158 return davObjectResponse(context.c.req.method, result.object, adapter.objectContentType);
159}
160 
161export async function handleCollectionGetHead<
162 TCollectionKind extends string,
163 TResource extends { href: string },
164 TObject extends { body: BodyInit; size: number; etag: string },
165 TPutExtra extends object,
166>(
167 context: DavRequestContext,
168 path: CollectionDavPath<TCollectionKind>,
169 adapter: CollectionObjectAdapter<TCollectionKind, TResource, TObject, TPutExtra>,
170 listing: CollectionHtmlListingAdapter<TCollectionKind, TResource>,
171): Promise<Response> {
172 if (isCollectionObjectPath(path)) return await handleCollectionObjectGetHead(context, path, adapter);
173 const result = await adapter.describe(context, path, "1");
174 if (!result.ok) return davResultError(result);
175 const target = result.resources[0];
176 if (!target) return davError(404, "Resource not found");
177 return await davHtmlListingResponse(
178 context.c,
179 {
180 title: listing.title(target),
181 href: target.href,
182 parentHref: listing.parentHref(path),
183 entries: result.resources.slice(1).map(listing.entry),
184 },
185 context.c.req.method,
186 );
187}
188 
189export async function handleCollectionObjectPut<
190 TCollectionKind extends string,
191 TResource extends { href: string },
192 TObject extends { body: BodyInit; size: number; etag: string },
193 TPutExtra extends object,
194>(
195 context: DavRequestContext,
196 path: CollectionDavPath<TCollectionKind>,
197 adapter: CollectionObjectAdapter<TCollectionKind, TResource, TObject, TPutExtra>,
198): Promise<Response> {
199 if (!isCollectionObjectPath(path)) return davError(405, `PUT requires ${adapter.putObjectPathName}`);
200 const extra = await adapter.preparePut(context);
201 if (!extra.ok) return extra.response;
202 const conditions = readDavConditionalHeaders(context.c);
203 if (!conditions.ok) return conditions.response;
204 const result = await adapter.putObject(context, path, {
205 ...extra.value,
206 body: await context.c.req.arrayBuffer(),
207 conditions: conditions.value,
208 nowMs: Date.now(),
209 maxBytes: adapter.maxObjectBytes(context),
210 });
211 return result.ok ? emptyResponse(result.created ? 201 : 204, { ETag: result.object.etag }) : davResultError(result);
212}
213 
214export async function handleCollectionObjectDelete<
215 TCollectionKind extends string,
216 TResource extends { href: string },
217 TObject extends { body: BodyInit; size: number; etag: string },
218 TPutExtra extends object,
219>(
220 context: DavRequestContext,
221 path: CollectionDavPath<TCollectionKind>,
222 adapter: CollectionObjectAdapter<TCollectionKind, TResource, TObject, TPutExtra>,
223): Promise<Response> {
224 if (!isCollectionObjectPath(path)) return davError(405, `DELETE requires ${adapter.deleteObjectPathName}`);
225 const conditions = readDavConditionalHeaders(context.c);
226 if (!conditions.ok) return conditions.response;
227 const result = await adapter.deleteObject(context, path, { conditions: conditions.value, nowMs: Date.now() });
228 return result.ok ? emptyResponse(204) : davResultError(result);
229}