Files
UHD_Media_Tool/apps/server/src/modules/file-manager/upload.ts
T
rootandClaude Opus 5.5 521c86d730 fix(storage): 本地写入口拒绝 Alist 虚拟路径,补字幕在 Alist 库根上明确失败
虚拟前缀 /@alist/… 读到本地是 ENOENT,但 mkdir -p、复制 / 移动目标、写文件、回收站落点会在本机真建出
/@alist 并写入文件。统一守卫:任何本地写类操作参数是虚拟路径即抛 REMOTE_PATH_ON_LOCAL_FS
(remote_path_on_local_fs),不落盘。

- adapters:fs/remote-guard.ts,接在 runFsOp、FsProxy.run(父进程拒绝不起子进程)、copyWithVerify、
  crossDeviceMove、moveSafe、commitNoClobber、sweepStalePartials、writeRootSentinel
- server:storage/local-fs.ts 带守卫的写函数,整理 / 替换 / 刮削 / 回收站 / 上传 / 字幕 / vfs 本地分支改用它
- 补字幕目标在 Alist 媒体库上返回失败原因,撤销同样拒绝

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-07 00:35:41 +08:00

84 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 };
}