import { randomUUID } from "node:crypto"; import { join } from "node:path"; import { commitNoClobber, partialPathFor } from "@umt/adapters"; import type { FilesUploadQuery, FilesUploadResult } from "@umt/contracts"; import { sanitizeSegment } from "@umt/domain"; import type { Principal } from "../../http/auth.ts"; import { mkdir, open, rm } from "../../storage/local-fs.ts"; import { relayDir, remote, remoteError, vexists } from "../../storage/vfs.ts"; import { errnoOf, FilesError, type FmDeps, recheck, relOf, resolveIn } from "./common.ts"; import { audit, filesChanged } from "./ledger.ts"; import { isWritableTarget } from "./list.ts"; // PUT /files/upload(docs/design/storage-alist.md §4):请求体是文件字节,放进 {root, path} 目录,同名不覆盖。 // Alist 根:先流式写本机临时文件,再按 §2.6 上传(临时名 → 改名,成功判定同 MP);本地根:同目录 partial → 不覆盖改名。 async function spool(body: ReadableStream, file: string, signal?: AbortSignal): Promise { const fh = await open(file, "w"); let n = 0; try { const reader = body.getReader(); for (;;) { signal?.throwIfAborted(); const { value, done } = await reader.read(); if (done) break; await fh.write(value); n += value.length; } } finally { await fh.close(); } return n; } export async function uploadFile( d: FmDeps, principal: Principal | undefined, q: FilesUploadQuery, body: ReadableStream | null, signal?: AbortSignal, ): Promise { if (!body) throw new FilesError("bad_request", "请求体为空"); const r = await resolveIn(d, q.root, q.path); if (!r.lst?.isDirectory() || r.isSymlink) throw new FilesError("bad_request", `不是目录:${r.rel || "/"}`); if (!(await isWritableTarget(r.def, r.rootReal))) throw new FilesError("not_writable", `根 ${r.def.name} 不可写`); if (sanitizeSegment(q.name) !== q.name) throw new FilesError("bad_request", `文件名含非法字符或首尾有空白/点,可改为:${sanitizeSegment(q.name)}`); await recheck(r.abs, r.rootReal); const dst = join(r.abs, q.name); const rel = relOf(r.rootReal, dst); let size: number; if (r.def.storageId) { if (await vexists(dst).catch((e) => Promise.reject(remoteError(e, dst)))) throw new FilesError("exists", `已存在,不覆盖:${q.name}`); const dir = relayDir(); await mkdir(dir, { recursive: true }); const tmp = join(dir, `${randomUUID()}-upload`); try { size = await spool(body, tmp, signal); const { v, client } = await remote(dst); await client.upload(tmp, v.remote, signal ? { signal } : {}); } catch (e) { if (e instanceof FilesError) throw e; const err = remoteError(e, dst); if (errnoOf(err) === "EEXIST") throw new FilesError("exists", `已存在,不覆盖:${q.name}`); throw new FilesError("upstream", `上传到 Alist 失败:${err.message}`); } finally { await rm(tmp, { force: true }).catch(() => undefined); } } else { const partial = partialPathFor(dst); try { size = await spool(body, partial, signal); await commitNoClobber(partial, dst); } catch (e) { await rm(partial, { force: true }).catch(() => undefined); if (errnoOf(e) === "EEXIST") throw new FilesError("exists", `已存在,不覆盖:${q.name}`); throw e; } } await audit(d, principal, "upload", { root: q.root, path: rel, size }); filesChanged(d, q.root, [r.rel]); return { path: rel, size }; }