import { Hono } from "hono"; import { describeRoute, validator } from "hono-openapi"; import type { ParsedIfHeader } from "@/worker/dav/if-header"; import { maxDavDepthInfinityNodes } from "@/worker/dav/limits"; import { normalizeFilesPath, type NormalizedDavPath } from "@/worker/dav/paths"; import { FileDeleteRequestSchema, FileListQuerySchema, FileListResponseSchema, FileMetadataQuerySchema, FileMetadataResponseSchema, FileMkdirRequestSchema, FileRenameRequestSchema, } from "@/worker/openapi/schemas"; import type { FileDavError, FileDavNodeView } from "@/worker/objects/file-dav-object"; import { describeJson, jsonErrorResponse } from "@/worker/routes/api/openapi"; import { subjectForSession } from "@/worker/routes/api/session"; import type { AppContext, AppEnv } from "@/worker/types"; import { enqueueGc } from "@/worker/webdav/gc"; import { jsonError } from "@/worker/util/response"; const fileErrorStatuses = [400, 401, 403, 404, 405, 409, 423, 500] as const; const emptyIfHeader: ParsedIfHeader = { lists: [] }; function fileApiError(error: FileDavError): Response { return jsonError(error.code, error.message, error.status); } function normalizeApiFilePath(input: string | null | undefined): NormalizedDavPath | Response { const value = input?.trim(); const pathname = !value || value === "/" ? "/files/" : value === "/files" || value.startsWith("/files/") ? value : value.startsWith("/") ? `/files${value}` : `/files/${value}`; const normalized = normalizeFilesPath(pathname); if (!normalized.ok) return jsonError("invalid_file_path", normalized.message, normalized.status); return normalized.path; } function fileMetadataResponse(view: FileDavNodeView) { return { href: view.href, path: view.href, name: view.node.name, kind: view.node.kind, size: view.node.size, content_type: view.node.contentType, content_language: view.node.contentLanguage, etag: view.node.etag, created_at: new Date(view.node.createdAtMs).toISOString(), modified_at: new Date(view.node.modifiedAtMs).toISOString(), }; } async function currentSubject(c: AppContext) { const subject = await subjectForSession(c); if (!subject) return null; return subject; } export function createFileApiRoutes(): Hono { const api = new Hono(); api.get( "/metadata", describeJson("Get file metadata", "files", FileMetadataResponseSchema, 200, fileErrorStatuses), validator("query", FileMetadataQuerySchema), async (c) => { const subject = await currentSubject(c); if (!subject) return jsonError("not_found", "Authenticated subject not found.", 404); const path = normalizeApiFilePath(c.req.valid("query").path); if (path instanceof Response) return path; const result = await c.env.FILE_DAV.getByName(subject.storageId).propfind({ subjectId: subject.id, storageId: subject.storageId, path, depth: "0", maxNodes: 1, nowMs: Date.now(), }); if (!result.ok) return fileApiError(result); return c.json(fileMetadataResponse(result.nodes[0]!)); }, ); api.get( "/list", describeJson("List files", "files", FileListResponseSchema, 200, fileErrorStatuses), validator("query", FileListQuerySchema), async (c) => { const subject = await currentSubject(c); if (!subject) return jsonError("not_found", "Authenticated subject not found.", 404); const path = normalizeApiFilePath(c.req.valid("query").path); if (path instanceof Response) return path; const result = await c.env.FILE_DAV.getByName(subject.storageId).propfind({ subjectId: subject.id, storageId: subject.storageId, path, depth: "1", maxNodes: maxDavDepthInfinityNodes(c.env), nowMs: Date.now(), }); if (!result.ok) return fileApiError(result); const [directory, ...entries] = result.nodes; if (!directory) return jsonError("not_found", "Resource not found.", 404); if (directory.node.kind !== "collection") { return jsonError("method_not_allowed", "Resource is not a collection.", 405); } return c.json({ directory: fileMetadataResponse(directory), entries: entries.map(fileMetadataResponse), }); }, ); api.post( "/mkdir", describeJson("Create file collection", "files", FileMetadataResponseSchema, 201, fileErrorStatuses), validator("json", FileMkdirRequestSchema), async (c) => { const subject = await currentSubject(c); if (!subject) return jsonError("not_found", "Authenticated subject not found.", 404); const path = normalizeApiFilePath(c.req.valid("json").path); if (path instanceof Response) return path; const nowMs = Date.now(); const created = await c.env.FILE_DAV.getByName(subject.storageId).mkcol({ subjectId: subject.id, storageId: subject.storageId, path, ifHeader: emptyIfHeader, lockTokens: [], nowMs, }); if (!created.ok) return fileApiError(created); const result = await c.env.FILE_DAV.getByName(subject.storageId).propfind({ subjectId: subject.id, storageId: subject.storageId, path, depth: "0", maxNodes: 1, nowMs, }); if (!result.ok) return fileApiError(result); return c.json(fileMetadataResponse(result.nodes[0]!), 201); }, ); api.post( "/rename", describeJson("Rename file path", "files", FileMetadataResponseSchema, 200, fileErrorStatuses), validator("json", FileRenameRequestSchema), async (c) => { const subject = await currentSubject(c); if (!subject) return jsonError("not_found", "Authenticated subject not found.", 404); const body = c.req.valid("json"); const from = normalizeApiFilePath(body.from); if (from instanceof Response) return from; const to = normalizeApiFilePath(body.to); if (to instanceof Response) return to; const nowMs = Date.now(); const moved = await c.env.FILE_DAV.getByName(subject.storageId).move({ subjectId: subject.id, storageId: subject.storageId, from, to, overwrite: false, ifMatch: null, ifNoneMatch: null, ifHeader: emptyIfHeader, lockTokens: [], nowMs, maxNodes: maxDavDepthInfinityNodes(c.env), }); if (!moved.ok) return fileApiError(moved); await enqueueGc(c, subject, moved.blobGc); const result = await c.env.FILE_DAV.getByName(subject.storageId).propfind({ subjectId: subject.id, storageId: subject.storageId, path: to, depth: "0", maxNodes: 1, nowMs, }); if (!result.ok) return fileApiError(result); return c.json(fileMetadataResponse(result.nodes[0]!)); }, ); api.delete( "/delete", describeRoute({ summary: "Delete file path", tags: ["files"], responses: { 204: { description: "File resource deleted" }, 400: jsonErrorResponse, 401: jsonErrorResponse, 403: jsonErrorResponse, 404: jsonErrorResponse, 405: jsonErrorResponse, 423: jsonErrorResponse, 500: jsonErrorResponse, }, }), validator("json", FileDeleteRequestSchema), async (c) => { const subject = await currentSubject(c); if (!subject) return jsonError("not_found", "Authenticated subject not found.", 404); const path = normalizeApiFilePath(c.req.valid("json").path); if (path instanceof Response) return path; const result = await c.env.FILE_DAV.getByName(subject.storageId).delete({ subjectId: subject.id, storageId: subject.storageId, path, ifMatch: null, ifNoneMatch: null, ifHeader: emptyIfHeader, lockTokens: [], nowMs: Date.now(), maxNodes: maxDavDepthInfinityNodes(c.env), }); if (!result.ok) return fileApiError(result); await enqueueGc(c, subject, result.blobGc); return new Response(null, { status: 204 }); }, ); return api; }