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
This commit is contained in:
1 parent
a54905e571
commit
521c86d730
23 files changed
+371
-13
No files matched your search
@@ -1,5 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, realpath, rmdir } from "node:fs/promises";
|
||||
import { basename, dirname, join, relative } from "node:path";
|
||||
import {
|
||||
type CopyReceipt,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
import type { OrganizationRecord } from "@umt/contracts";
|
||||
import { sql } from "@umt/db";
|
||||
import { withAttemptToken } from "@umt/tasks";
|
||||
import { mkdir, realpath, rmdir } from "../../../storage/local-fs.ts";
|
||||
import { involvesRemote, remoteTransfer } from "../../../storage/transfer.ts";
|
||||
import { isRemotePath, vcheckRoot, vmkdirp, vrenameNoClobber, vstatSafe } from "../../../storage/vfs.ts";
|
||||
import { closeDecisions } from "../../sources-identity/service/common.ts";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readdir, rmdir } from "node:fs/promises";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import { DEFAULT_ROOT_SENTINEL } from "@umt/adapters";
|
||||
import { fileKindOf, type OrganizeFileKind, type OrganizePlan, type PlannedFile } from "@umt/domain";
|
||||
import { organizationStatusFromFiles, organizePlanToFileResults } from "@umt/mapping";
|
||||
import { readdir, rmdir } from "../../../storage/local-fs.ts";
|
||||
import {
|
||||
isRemotePath,
|
||||
vstatSafe as statSafe,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, readdir, realpath, rm } from "node:fs/promises";
|
||||
import { basename, dirname, join, relative } from "node:path";
|
||||
import { DEFAULT_ROOT_SENTINEL, type FileFacts } from "@umt/adapters";
|
||||
import { sql } from "@umt/db";
|
||||
import { type ReplacementScope, replacementCovers, replacementScope } from "@umt/domain";
|
||||
import { mkdir, readdir, realpath, rm } from "../../../storage/local-fs.ts";
|
||||
import {
|
||||
isRemotePath,
|
||||
vcheckRoot,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { DEFAULT_ROOT_SENTINEL } from "@umt/adapters";
|
||||
import {
|
||||
@@ -24,6 +23,7 @@ import {
|
||||
seasonOfDirName,
|
||||
workNfo,
|
||||
} from "@umt/domain";
|
||||
import { mkdir, rename, rm, writeFile } from "../../../storage/local-fs.ts";
|
||||
import {
|
||||
isRemotePath,
|
||||
vlstat as lstat,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { lstat, mkdir } from "node:fs/promises";
|
||||
import { basename, dirname, extname, join } from "node:path";
|
||||
import type { FilesMkdirRequest, FilesMkdirResult, FilesRenameRequest, FilesRenameResult } from "@umt/contracts";
|
||||
import { sanitizeSegment } from "@umt/domain";
|
||||
import type { Principal } from "../../http/auth.ts";
|
||||
import { lstat, mkdir } from "../../storage/local-fs.ts";
|
||||
import { isRemotePath, vexists, vlstat, vmkdirp, vrenameNoClobber } from "../../storage/vfs.ts";
|
||||
import { actorOfP, errMessage, errnoOf, FilesError, type FmDeps, recheck, relOf, resolveIn } from "./common.ts";
|
||||
import { audit, filesChanged } from "./ledger.ts";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import type { ProbeResult } from "@umt/adapters";
|
||||
import type {
|
||||
@@ -11,6 +10,7 @@ import type {
|
||||
} from "@umt/contracts";
|
||||
import { sql } from "@umt/db";
|
||||
import type { Principal } from "../../http/auth.ts";
|
||||
import { mkdir, readFile, writeFile } from "../../storage/local-fs.ts";
|
||||
import { vlstat } from "../../storage/vfs.ts";
|
||||
import { FilesError, type FmDeps, jsonb, resolveIn } from "./common.ts";
|
||||
import { audit } from "./ledger.ts";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { lstat, mkdir, readdir, realpath, rm } from "node:fs/promises";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { moveSafe } from "@umt/adapters";
|
||||
import type {
|
||||
@@ -16,6 +15,7 @@ import type {
|
||||
} from "@umt/contracts";
|
||||
import { sql, type TrashItemRow } from "@umt/db";
|
||||
import type { Principal } from "../../http/auth.ts";
|
||||
import { lstat, mkdir, readdir, realpath, rm } from "../../storage/local-fs.ts";
|
||||
import { isRemotePath, vlstat, vmkdirp, vreaddirStats, vremove, vrenameNoClobber } from "../../storage/vfs.ts";
|
||||
import { parseVPath, toVPath } from "../../storage/vpath.ts";
|
||||
import { upsertDecision } from "../acquisition-organize/services/common.ts";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, open, rm } from "node:fs/promises";
|
||||
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";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { copyFile, mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
lifecycleKey,
|
||||
@@ -26,6 +25,8 @@ import type { Selectable } from "kysely";
|
||||
import { dataPaths } from "../../config.ts";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import { runSandbox, SandboxError } from "../../sandbox/client.ts";
|
||||
import { copyFile, mkdir, readdir, rename, rm, stat, writeFile } from "../../storage/local-fs.ts";
|
||||
import { isRemotePath } from "../../storage/vpath.ts";
|
||||
import type { ServerContext } from "../types.ts";
|
||||
|
||||
// 补字幕(docs/plan/subtitles.md;对照 MP 插件「海拉鲁字幕大师」upload / matching / subtitle_writer):
|
||||
@@ -199,6 +200,11 @@ export async function applySubtitles(
|
||||
failed.push({ name: it.name, reason: "目标视频不在这部作品的媒体库里" });
|
||||
continue;
|
||||
}
|
||||
// Alist 上的媒体库:补字幕暂不支持(MP 的 Alist 能力里也没有),明确失败,不往本机写
|
||||
if (isRemotePath(t.rootPath)) {
|
||||
failed.push({ name: it.name, reason: "目标视频在 Alist 存储上,暂不支持补字幕" });
|
||||
continue;
|
||||
}
|
||||
const name = subtitleFileName(t.relativePath, it.language, it.ext as SubtitleExt, defaultLang);
|
||||
const rel = join(dirname(t.relativePath), name);
|
||||
if (planned.has(rel)) {
|
||||
@@ -322,6 +328,8 @@ export async function undoSubtitleWrite(ctx: ServerContext, id: string): Promise
|
||||
.select("local_path")
|
||||
.where("id", "=", row.library_root_id)
|
||||
.executeTakeFirstOrThrow();
|
||||
if (isRemotePath(root.local_path))
|
||||
throw new ApiException("precondition_failed", "字幕在 Alist 存储上,不能在这里撤销");
|
||||
const abs = join(root.local_path, row.relative_path);
|
||||
if (row.backup_path && (await exists(row.backup_path))) {
|
||||
await copyFile(row.backup_path, `${abs}.umt-restore.tmp`);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as fsp from "node:fs/promises";
|
||||
import { assertLocalPaths } from "@umt/adapters";
|
||||
|
||||
// 本地写入口的远端路径守卫(server 侧):整理 / 刮削 / 字幕 / 回收站 / 上传里直接调 node:fs 写文件的地方都从这里取函数。
|
||||
// 参数是 Alist 虚拟路径(/@alist/…)就抛 FsError("REMOTE_PATH_ON_LOCAL_FS"),不在本机建出 /@alist 目录或写进文件。
|
||||
// 读类函数(stat / readdir / readFile …)原样转出:对虚拟路径只会 ENOENT。
|
||||
|
||||
export * from "node:fs/promises";
|
||||
|
||||
type P = Parameters<typeof fsp.mkdir>[0];
|
||||
const s = (p: unknown) => (typeof p === "string" ? p : p instanceof URL ? p.pathname : undefined);
|
||||
|
||||
export const mkdir: typeof fsp.mkdir = (async (p: P, o?: Parameters<typeof fsp.mkdir>[1]) => {
|
||||
assertLocalPaths("mkdir", s(p));
|
||||
return fsp.mkdir(p, o);
|
||||
}) as typeof fsp.mkdir;
|
||||
|
||||
export const writeFile: typeof fsp.writeFile = async (p, data, o) => {
|
||||
assertLocalPaths("writeFile", s(p));
|
||||
return fsp.writeFile(p, data, o);
|
||||
};
|
||||
|
||||
export const rename: typeof fsp.rename = async (a, b) => {
|
||||
assertLocalPaths("rename", s(a), s(b));
|
||||
return fsp.rename(a, b);
|
||||
};
|
||||
|
||||
export const copyFile: typeof fsp.copyFile = async (a, b, mode) => {
|
||||
assertLocalPaths("copyFile", s(a), s(b));
|
||||
return fsp.copyFile(a, b, mode);
|
||||
};
|
||||
|
||||
export const rm: typeof fsp.rm = async (p, o) => {
|
||||
assertLocalPaths("rm", s(p));
|
||||
return fsp.rm(p, o);
|
||||
};
|
||||
|
||||
export const rmdir: typeof fsp.rmdir = async (p, o) => {
|
||||
assertLocalPaths("rmdir", s(p));
|
||||
return fsp.rmdir(p, o);
|
||||
};
|
||||
|
||||
export const unlink: typeof fsp.unlink = async (p) => {
|
||||
assertLocalPaths("unlink", s(p));
|
||||
return fsp.unlink(p);
|
||||
};
|
||||
|
||||
export const link: typeof fsp.link = async (a, b) => {
|
||||
assertLocalPaths("link", s(a), s(b));
|
||||
return fsp.link(a, b);
|
||||
};
|
||||
|
||||
/** open:写 / 追加 / 新建模式才拦;只读打开放行(虚拟路径只会 ENOENT) */
|
||||
export const open: typeof fsp.open = (async (
|
||||
p: Parameters<typeof fsp.open>[0],
|
||||
flags?: string | number,
|
||||
mode?: number,
|
||||
) => {
|
||||
if (flags !== undefined && flags !== "r") assertLocalPaths("open", s(p));
|
||||
return fsp.open(p, flags, mode);
|
||||
}) as typeof fsp.open;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, rename, rm, stat, unlink } from "node:fs/promises";
|
||||
import { basename, dirname, join, posix } from "node:path";
|
||||
import { AlistError, type ProgressCallback } from "@umt/adapters";
|
||||
import { mkdir, rename, rm, stat, unlink } from "./local-fs.ts";
|
||||
import { invalidateRemote, relayDir, remote, remoteError } from "./vfs.ts";
|
||||
import { parseVPath, sameStorage } from "./vpath.ts";
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { lstat, mkdir, readdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, join, posix } from "node:path";
|
||||
import {
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
type StatResult,
|
||||
statSafe,
|
||||
} from "@umt/adapters";
|
||||
import { lstat, mkdir, readdir, readFile, realpath, rename, rm, writeFile } from "./local-fs.ts";
|
||||
import { storageDataDir, storageEntry } from "./registry.ts";
|
||||
import { parseVPath, toVPath, type VPath } from "./vpath.ts";
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createSubtitlesModule } from "../../src/modules/subtitles/index.ts";
|
||||
import * as lfs from "../../src/storage/local-fs.ts";
|
||||
import { createHarness, type Harness, pgAvailable } from "../auth-settings/harness.ts";
|
||||
import { DEV_KEY } from "../helpers.ts";
|
||||
|
||||
// server 侧本地写入口(整理 / 刮削 / 字幕 / 回收站 / 上传共用的 storage/local-fs.ts)对 Alist 虚拟路径一律拒绝,
|
||||
// 本机不出现 /@alist;补字幕的目标在 Alist 媒体库上时明确失败(不支持),不往本机写。
|
||||
|
||||
const V = "/@alist/00000000-0000-4000-8000-0000000000ee/s1/lib";
|
||||
const code = async (p: Promise<unknown>) => {
|
||||
try {
|
||||
await p;
|
||||
} catch (e) {
|
||||
return (e as { code?: string }).code;
|
||||
}
|
||||
return "no-error";
|
||||
};
|
||||
|
||||
describe("local-fs 写入口守卫", () => {
|
||||
let dir: string;
|
||||
beforeAll(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "umt-lfs-"));
|
||||
await lfs.writeFile(join(dir, "a.txt"), "x");
|
||||
});
|
||||
afterAll(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("mkdir / writeFile / rename / copyFile / rm / rmdir / unlink / link / open(写)都拒绝虚拟路径", async () => {
|
||||
const R = "REMOTE_PATH_ON_LOCAL_FS";
|
||||
expect(await code(lfs.mkdir(`${V}/d`, { recursive: true }))).toBe(R);
|
||||
expect(await code(lfs.writeFile(`${V}/a.nfo`, "<movie/>"))).toBe(R);
|
||||
expect(await code(lfs.rename(join(dir, "a.txt"), `${V}/a.txt`))).toBe(R);
|
||||
expect(await code(lfs.copyFile(join(dir, "a.txt"), `${V}/a.txt`))).toBe(R);
|
||||
expect(await code(lfs.rm(`${V}/a.txt`, { force: true }))).toBe(R);
|
||||
expect(await code(lfs.rmdir(V))).toBe(R);
|
||||
expect(await code(lfs.unlink(`${V}/a.txt`))).toBe(R);
|
||||
expect(await code(lfs.link(join(dir, "a.txt"), `${V}/a.txt`))).toBe(R);
|
||||
expect(await code(lfs.open(`${V}/a.txt`, "w"))).toBe(R);
|
||||
expect(existsSync("/@alist")).toBe(false);
|
||||
expect(existsSync(join(dir, "a.txt"))).toBe(true);
|
||||
});
|
||||
|
||||
it("本地路径与只读打开照常", async () => {
|
||||
await lfs.mkdir(join(dir, "sub"), { recursive: true });
|
||||
await lfs.rename(join(dir, "a.txt"), join(dir, "sub", "a.txt"));
|
||||
expect(await lfs.readFile(join(dir, "sub", "a.txt"), "utf8")).toBe("x");
|
||||
expect(await code(lfs.open(`${V}/a.txt`, "r"))).toBe("ENOENT");
|
||||
});
|
||||
});
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
describe.skipIf(!hasPg)("补字幕:目标在 Alist 媒体库上明确失败", () => {
|
||||
let h: Harness;
|
||||
let entityId: string;
|
||||
let locationId: string;
|
||||
beforeAll(async () => {
|
||||
h = await createHarness({ extraModules: [createSubtitlesModule()] });
|
||||
const root = await h.db
|
||||
.insertInto("library_root")
|
||||
.values({
|
||||
name: "Alist 剧集",
|
||||
media_kind: "mixed",
|
||||
local_path: V,
|
||||
management_mode: "managed",
|
||||
sentinel_file: null,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
entityId = (
|
||||
await h.db
|
||||
.insertInto("media_entity")
|
||||
.values({ kind: "series", title: "远端剧", year: 2024 } as never)
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
const a = await h.db
|
||||
.insertInto("media_asset")
|
||||
.values({ media_entity_id: entityId, kind: "video", status: "active" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
locationId = (
|
||||
await h.db
|
||||
.insertInto("asset_location")
|
||||
.values({
|
||||
media_asset_id: a.id,
|
||||
location_kind: "library",
|
||||
library_root_id: root.id,
|
||||
relative_path: "远端剧 (2024)/Season 1/远端剧 - S01E01.mkv",
|
||||
status: "available",
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
}, 120_000);
|
||||
afterAll(async () => h?.close());
|
||||
|
||||
it("写入返回失败原因,不写任何文件、不建资产", async () => {
|
||||
const res = await h.app.request("/api/v1/subtitles/apply", {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${DEV_KEY}`, "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
entityId,
|
||||
items: [
|
||||
{
|
||||
name: "a.srt",
|
||||
ext: "srt",
|
||||
language: "zh-Hans",
|
||||
content: btoa("1\n00:00:01,000 --> 00:00:02,000\nhi\n"),
|
||||
targetLocationId: locationId,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status, JSON.stringify(body)).toBe(200);
|
||||
expect(body.data.written).toEqual([]);
|
||||
expect(body.data.failed).toEqual([{ name: "a.srt", reason: "目标视频在 Alist 存储上,暂不支持补字幕" }]);
|
||||
expect(existsSync("/@alist")).toBe(false);
|
||||
const subs = await h.db.selectFrom("media_asset").select("id").where("kind", "=", "subtitle").execute();
|
||||
expect(subs).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user