Skip to content
File

Blob: src/worker/routes/api/files.ts

typescript242 lines
1import { Hono } from "hono";
2import { describeRoute, validator } from "hono-openapi";
3 
4import type { ParsedIfHeader } from "@/worker/dav/if-header";
5import { maxDavDepthInfinityNodes } from "@/worker/dav/limits";
6import { normalizeFilesPath, type NormalizedDavPath } from "@/worker/dav/paths";
7import {
8 FileDeleteRequestSchema,
9 FileListQuerySchema,
10 FileListResponseSchema,
11 FileMetadataQuerySchema,
12 FileMetadataResponseSchema,
13 FileMkdirRequestSchema,
14 FileRenameRequestSchema,
15} from "@/worker/openapi/schemas";
16import type { FileDavError, FileDavNodeView } from "@/worker/objects/file-dav-object";
17import { describeJson, jsonErrorResponse } from "@/worker/routes/api/openapi";
18import { subjectForSession } from "@/worker/routes/api/session";
19import type { AppContext, AppEnv } from "@/worker/types";
20import { enqueueGc } from "@/worker/webdav/gc";
21import { jsonError } from "@/worker/util/response";
22 
23const fileErrorStatuses = [400, 401, 403, 404, 405, 409, 423, 500] as const;
24const emptyIfHeader: ParsedIfHeader = { lists: [] };
25 
26function fileApiError(error: FileDavError): Response {
27 return jsonError(error.code, error.message, error.status);
28}
29 
30function normalizeApiFilePath(input: string | null | undefined): NormalizedDavPath | Response {
31 const value = input?.trim();
32 const pathname =
33 !value || value === "/"
34 ? "/files/"
35 : value === "/files" || value.startsWith("/files/")
36 ? value
37 : value.startsWith("/")
38 ? `/files${value}`
39 : `/files/${value}`;
40 const normalized = normalizeFilesPath(pathname);
41 if (!normalized.ok) return jsonError("invalid_file_path", normalized.message, normalized.status);
42 return normalized.path;
43}
44 
45function fileMetadataResponse(view: FileDavNodeView) {
46 return {
47 href: view.href,
48 path: view.href,
49 name: view.node.name,
50 kind: view.node.kind,
51 size: view.node.size,
52 content_type: view.node.contentType,
53 content_language: view.node.contentLanguage,
54 etag: view.node.etag,
55 created_at: new Date(view.node.createdAtMs).toISOString(),
56 modified_at: new Date(view.node.modifiedAtMs).toISOString(),
57 };
58}
59 
60async function currentSubject(c: AppContext) {
61 const subject = await subjectForSession(c);
62 if (!subject) return null;
63 return subject;
64}
65 
66export function createFileApiRoutes(): Hono<AppEnv> {
67 const api = new Hono<AppEnv>();
68 
69 api.get(
70 "/metadata",
71 describeJson("Get file metadata", "files", FileMetadataResponseSchema, 200, fileErrorStatuses),
72 validator("query", FileMetadataQuerySchema),
73 async (c) => {
74 const subject = await currentSubject(c);
75 if (!subject) return jsonError("not_found", "Authenticated subject not found.", 404);
76 const path = normalizeApiFilePath(c.req.valid("query").path);
77 if (path instanceof Response) return path;
78 
79 const result = await c.env.FILE_DAV.getByName(subject.storageId).propfind({
80 subjectId: subject.id,
81 storageId: subject.storageId,
82 path,
83 depth: "0",
84 maxNodes: 1,
85 nowMs: Date.now(),
86 });
87 if (!result.ok) return fileApiError(result);
88 return c.json(fileMetadataResponse(result.nodes[0]!));
89 },
90 );
91 
92 api.get(
93 "/list",
94 describeJson("List files", "files", FileListResponseSchema, 200, fileErrorStatuses),
95 validator("query", FileListQuerySchema),
96 async (c) => {
97 const subject = await currentSubject(c);
98 if (!subject) return jsonError("not_found", "Authenticated subject not found.", 404);
99 const path = normalizeApiFilePath(c.req.valid("query").path);
100 if (path instanceof Response) return path;
101 
102 const result = await c.env.FILE_DAV.getByName(subject.storageId).propfind({
103 subjectId: subject.id,
104 storageId: subject.storageId,
105 path,
106 depth: "1",
107 maxNodes: maxDavDepthInfinityNodes(c.env),
108 nowMs: Date.now(),
109 });
110 if (!result.ok) return fileApiError(result);
111 const [directory, ...entries] = result.nodes;
112 if (!directory) return jsonError("not_found", "Resource not found.", 404);
113 if (directory.node.kind !== "collection") {
114 return jsonError("method_not_allowed", "Resource is not a collection.", 405);
115 }
116 return c.json({
117 directory: fileMetadataResponse(directory),
118 entries: entries.map(fileMetadataResponse),
119 });
120 },
121 );
122 
123 api.post(
124 "/mkdir",
125 describeJson("Create file collection", "files", FileMetadataResponseSchema, 201, fileErrorStatuses),
126 validator("json", FileMkdirRequestSchema),
127 async (c) => {
128 const subject = await currentSubject(c);
129 if (!subject) return jsonError("not_found", "Authenticated subject not found.", 404);
130 const path = normalizeApiFilePath(c.req.valid("json").path);
131 if (path instanceof Response) return path;
132 const nowMs = Date.now();
133 
134 const created = await c.env.FILE_DAV.getByName(subject.storageId).mkcol({
135 subjectId: subject.id,
136 storageId: subject.storageId,
137 path,
138 ifHeader: emptyIfHeader,
139 lockTokens: [],
140 nowMs,
141 });
142 if (!created.ok) return fileApiError(created);
143 
144 const result = await c.env.FILE_DAV.getByName(subject.storageId).propfind({
145 subjectId: subject.id,
146 storageId: subject.storageId,
147 path,
148 depth: "0",
149 maxNodes: 1,
150 nowMs,
151 });
152 if (!result.ok) return fileApiError(result);
153 return c.json(fileMetadataResponse(result.nodes[0]!), 201);
154 },
155 );
156 
157 api.post(
158 "/rename",
159 describeJson("Rename file path", "files", FileMetadataResponseSchema, 200, fileErrorStatuses),
160 validator("json", FileRenameRequestSchema),
161 async (c) => {
162 const subject = await currentSubject(c);
163 if (!subject) return jsonError("not_found", "Authenticated subject not found.", 404);
164 const body = c.req.valid("json");
165 const from = normalizeApiFilePath(body.from);
166 if (from instanceof Response) return from;
167 const to = normalizeApiFilePath(body.to);
168 if (to instanceof Response) return to;
169 const nowMs = Date.now();
170 
171 const moved = await c.env.FILE_DAV.getByName(subject.storageId).move({
172 subjectId: subject.id,
173 storageId: subject.storageId,
174 from,
175 to,
176 overwrite: false,
177 ifMatch: null,
178 ifNoneMatch: null,
179 ifHeader: emptyIfHeader,
180 lockTokens: [],
181 nowMs,
182 maxNodes: maxDavDepthInfinityNodes(c.env),
183 });
184 if (!moved.ok) return fileApiError(moved);
185 await enqueueGc(c, subject, moved.blobGc);
186 
187 const result = await c.env.FILE_DAV.getByName(subject.storageId).propfind({
188 subjectId: subject.id,
189 storageId: subject.storageId,
190 path: to,
191 depth: "0",
192 maxNodes: 1,
193 nowMs,
194 });
195 if (!result.ok) return fileApiError(result);
196 return c.json(fileMetadataResponse(result.nodes[0]!));
197 },
198 );
199 
200 api.delete(
201 "/delete",
202 describeRoute({
203 summary: "Delete file path",
204 tags: ["files"],
205 responses: {
206 204: { description: "File resource deleted" },
207 400: jsonErrorResponse,
208 401: jsonErrorResponse,
209 403: jsonErrorResponse,
210 404: jsonErrorResponse,
211 405: jsonErrorResponse,
212 423: jsonErrorResponse,
213 500: jsonErrorResponse,
214 },
215 }),
216 validator("json", FileDeleteRequestSchema),
217 async (c) => {
218 const subject = await currentSubject(c);
219 if (!subject) return jsonError("not_found", "Authenticated subject not found.", 404);
220 const path = normalizeApiFilePath(c.req.valid("json").path);
221 if (path instanceof Response) return path;
222 
223 const result = await c.env.FILE_DAV.getByName(subject.storageId).delete({
224 subjectId: subject.id,
225 storageId: subject.storageId,
226 path,
227 ifMatch: null,
228 ifNoneMatch: null,
229 ifHeader: emptyIfHeader,
230 lockTokens: [],
231 nowMs: Date.now(),
232 maxNodes: maxDavDepthInfinityNodes(c.env),
233 });
234 if (!result.ok) return fileApiError(result);
235 await enqueueGc(c, subject, result.blobGc);
236 return new Response(null, { status: 204 });
237 },
238 );
239 
240 return api;
241}