「根」统一叫登记目录;去掉「请…」;H&R 拦截不再露待处理事项 ID,改给下一步动作。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
85 lines
3.5 KiB
TypeScript
85 lines
3.5 KiB
TypeScript
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<Uint8Array>, file: string, signal?: AbortSignal): Promise<number> {
|
|
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<Uint8Array> | null,
|
|
signal?: AbortSignal,
|
|
): Promise<FilesUploadResult> {
|
|
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 };
|
|
}
|