feat(storage): 存储实例与 Alist 虚拟路径(迁移 0090、设置接口、注册表、vfs、健康检查)

- storage_instance 表 + secret 用途 storage_credential;令牌 / 密码 / 路径密码只写不读
- /storage-instances CRUD 与测试连接(版本、用户、根路径、容量);被库根引用不能删
- 远端路径 /@alist/<id>/<路径>,vfs 统一本地 / Alist 的 stat、列目录、建目录、读写、移动、删除
- 整理矩阵中涉及 Alist 的传输(storage/transfer.ts)与远端 ffprobe(直链只读头)
- 媒体库目录可登记在 Alist 上(不写哨兵,目录可访问即在线);健康检查列出存储实例

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 23:52:20 +08:00
1 parent 5ff24110f8
commit c2bf8a071a
20 files changed
+1625 -8

No files matched your search

+2
View File
@@ -17,6 +17,7 @@ import type { EventBusPort } from "./http/sse.ts";
import { assertCapabilityCount, createCapabilityRegistry } from "./modules/agent/capability-registry.ts";
import { createSecretStore, createSettingsReader } from "./modules/auth-settings/provided.ts";
import type { OutboxPort, ServerContext, ServerModule, ServerRole } from "./modules/types.ts";
import { configureStorage } from "./storage/registry.ts";
// 进程装配:ServerContext、任务注册表、outbox 路由。api / worker / all 三种角色共用。
@@ -200,6 +201,7 @@ export async function bootstrap(opts: BootstrapOptions): Promise<Bootstrapped> {
// 授权(automation_grant / approval + 自动化总开关)、审计(audit_event)、幂等键与操作账本在这里注入
capabilities: createCapabilityRegistry({ logger, db, settings }),
};
configureStorage(ctx);
const tasks = buildTaskRegistry(opts.modules, ctx);
for (const m of opts.modules) m.registerCapabilities?.(ctx.capabilities, ctx);
ctx.hooks = buildHookRegistry(opts.modules, ctx);
@@ -7,6 +7,7 @@ import { registerEngineRoutes } from "./engine-routes.ts";
import { registerInstanceRoutes } from "./instances-routes.ts";
import { registerKeyRoutes } from "./keys-routes.ts";
import { registerSettingsRoutes } from "./settings-routes.ts";
import { registerStorageRoutes } from "./storage-routes.ts";
/**
* W2-A 认证/设置/实例/引擎面板:管理员登录门禁、settings、api_key、secret 加密服务、
@@ -38,6 +39,7 @@ export function createAuthSettingsModule(opts: AuthSettingsModuleOptions = {}):
registerSettingsRoutes(app, ctx);
registerKeyRoutes(app, ctx);
registerInstanceRoutes(app, ctx);
registerStorageRoutes(app, ctx);
registerEngineRoutes(app, ctx, opts.engineSchema ? { schema: opts.engineSchema } : {});
if (opts.setupTokenOnStart !== false && ctx.db) {
ensureSetupToken(ctx).catch((err) => ctx.logger.error({ err }, "生成 setup token 失败"));
@@ -25,6 +25,8 @@ import {
import { z } from "zod";
import { ApiException } from "../../http/errors.ts";
import { redact, redactString } from "../../logger.ts";
import { isRemotePath, vcheckRoot } from "../../storage/vfs.ts";
import { parseVPath } from "../../storage/vpath.ts";
import type { ModuleApp, ServerContext } from "../types.ts";
import {
afterCursor,
@@ -184,6 +186,17 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
// ── library_root ──
const sentinel = async (localPath: string, file: string | null | undefined) => {
// Alist 库根(/@alist/<存储 id>/<路径>):不写哨兵(同 MP),只确认目录存在且存储可连
if (isRemotePath(localPath)) {
if (!parseVPath(localPath)) throw new ApiException("validation_failed", "Alist 路径格式不对");
const h = await vcheckRoot(localPath);
if (h.kind !== "online")
throw new ApiException(
"precondition_failed",
`Alist 目录不可用(${h.reason}),先在 Alist 里建好目录并测试存储连接`,
);
return null;
}
const name = file || DEFAULT_ROOT_SENTINEL;
try {
const st = await stat(localPath);
@@ -229,7 +242,9 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
test("/library-roots/{id}/test", "检查媒体库目录在线(目录 + 哨兵文件)", "library-roots"),
async ({ params, actor }) => {
const row = await getRow("library_root", params.id);
const h = await checkRoot(String(row.local_path), (row.sentinel_file as string | null) ?? DEFAULT_ROOT_SENTINEL);
const h = isRemotePath(String(row.local_path))
? await vcheckRoot(String(row.local_path))
: await checkRoot(String(row.local_path), (row.sentinel_file as string | null) ?? DEFAULT_ROOT_SENTINEL);
const ok = h.kind === "online";
await update("library_root", row.id, {
health_status: ok ? "healthy" : "offline",
@@ -0,0 +1,264 @@
import { AlistError, normAlistPath, redactAlistText } from "@umt/adapters";
import {
Api,
StorageInstance,
StorageInstanceCreate,
StorageInstanceUpdate,
type StorageTestResult,
secretInput,
} from "@umt/contracts";
import { ApiException } from "../../http/errors.ts";
import { buildClient, clearStorageCache, configureStorage, resolvePlain } from "../../storage/registry.ts";
import type { ModuleApp, ServerContext } from "../types.ts";
import {
afterCursor,
audit,
crudDefs,
cursorCol,
decodeCursor,
endpoint,
pageOf,
pgConflict,
toApi,
toDb,
} from "./endpoint.ts";
// 存储实例(Alist / OpenList)CRUD + 测试连接(docs/design/storage-alist.md §3、§4)。
// 凭据(令牌 / 密码)与路径密码只写不读:省略或空串 = 不变,null = 清除;明文只在 secret 表里加密保存。
const JSON_COLS = ["options", "health_detail", "version_info"] as const;
const tags = ["storage-instances"];
type Row = Record<string, unknown> & {
id: string;
secret_ref: string | null;
path_password_ref: string | null;
auth_kind: "token" | "password" | "guest";
username: string | null;
};
export function registerStorageRoutes(app: ModuleApp, ctx: ServerContext) {
configureStorage(ctx);
const S = {
...crudDefs("/storage-instances", "存储实例", {
row: StorageInstance,
create: StorageInstanceCreate,
update: StorageInstanceUpdate,
}),
test: Api.storageInstances.test,
remove: Api.storageInstances.remove,
};
const db = () => ctx.db;
const getRow = async (id: string): Promise<Row> => {
const r = await db().selectFrom("storage_instance").selectAll().where("id", "=", id).executeTakeFirst();
if (!r) throw new ApiException("not_found", "存储实例不存在");
return r as unknown as Row;
};
const normalize = (b: Partial<StorageInstanceCreate>) => {
const out: Record<string, unknown> = { ...b };
if (b.baseUrl !== undefined) out.baseUrl = b.baseUrl.trim().replace(/\/+$/, "");
if (b.rootPath !== undefined) {
try {
out.rootPath = normAlistPath(b.rootPath);
} catch {
throw new ApiException("validation_failed", "根路径不合法");
}
}
return out;
};
const checkAuth = (authKind: string, username: string | null | undefined, hasCredential: boolean) => {
if (authKind === "token" && !hasCredential) throw new ApiException("validation_failed", "令牌认证需要填写令牌");
if (authKind === "password" && (!username || !hasCredential))
throw new ApiException("validation_failed", "账号密码认证需要填写用户名和密码");
};
const putSecret = async (oldRef: string | null, plaintext: string) => {
if (oldRef) {
await ctx.secrets.replace(oldRef, plaintext);
return oldRef;
}
return ctx.secrets.create({ purpose: "storage_credential", plaintext });
};
const view = (r: object) => toApi(r);
endpoint(app, { ...S.list, tags }, async ({ query }) => {
const q0 = query as { cursor?: string; limit: number };
const cur = decodeCursor(q0.cursor);
let q = db().selectFrom("storage_instance").selectAll().select(cursorCol("created_at"));
if (cur) q = q.where(afterCursor("created_at", cur));
const rows = await q
.orderBy("created_at", "desc")
.orderBy("id", "desc")
.limit(q0.limit + 1)
.execute();
const page = pageOf(rows as unknown as { cursor_us: number; id: string }[], q0.limit);
return { items: page.items.map(view), nextCursor: page.nextCursor } as never;
});
endpoint(app, { ...S.get, tags }, async ({ params }) => view(await getRow(params.id)) as never);
endpoint(app, { ...S.create, tags }, async ({ body, actor }) => {
const { credential: rawCredential, pathPassword: rawPw, ...rest } = body;
const credential = secretInput(rawCredential) ?? undefined;
const pathPassword = secretInput(rawPw) ?? undefined;
checkAuth(rest.authKind ?? "token", rest.username, !!credential);
const refs: string[] = [];
try {
const secretRef =
credential && (rest.authKind ?? "token") !== "guest"
? await ctx.secrets.create({ purpose: "storage_credential", plaintext: credential })
: null;
if (secretRef) refs.push(secretRef);
const pwRef = pathPassword
? await ctx.secrets.create({ purpose: "storage_credential", plaintext: pathPassword })
: null;
if (pwRef) refs.push(pwRef);
const row = await db()
.insertInto("storage_instance")
.values({
...(toDb(normalize(rest), JSON_COLS) as { name: string; base_url: string }),
secret_ref: secretRef,
path_password_ref: pwRef,
})
.returningAll()
.executeTakeFirstOrThrow()
.catch((e: unknown) => pgConflict(e, "存储实例名称重复"));
await audit(
ctx,
actor,
"storage_instance.create",
{ type: "storage_instance", id: row.id },
{ name: row.name, authKind: row.auth_kind },
);
return view(row) as never;
} catch (e) {
for (const r of refs) await ctx.secrets.remove(r).catch(() => {});
throw e;
}
});
endpoint(app, { ...S.update, tags }, async ({ params, body, actor }) => {
const { credential: rawCredential, pathPassword: rawPw, ...rest } = body;
const credential = secretInput(rawCredential);
const pathPassword = secretInput(rawPw);
const cur = await getRow(params.id);
const authKind = rest.authKind ?? cur.auth_kind;
const username = rest.username !== undefined ? rest.username : cur.username;
const hasCred = credential === null ? false : credential ? true : !!cur.secret_ref;
checkAuth(authKind, username, hasCred);
const values = toDb(normalize(rest), JSON_COLS);
const drop: string[] = [];
if (authKind === "guest" || credential === null) {
if (cur.secret_ref) {
values.secret_ref = null;
drop.push(cur.secret_ref);
}
} else if (credential) values.secret_ref = await putSecret(cur.secret_ref, credential);
if (pathPassword === null) {
if (cur.path_password_ref) {
values.path_password_ref = null;
drop.push(cur.path_password_ref);
}
} else if (pathPassword) values.path_password_ref = await putSecret(cur.path_password_ref, pathPassword);
// 改了凭据也要让注册表重建客户端:updated_at 由触发器刷新;无字段变化时补一次 touch
const row = await db()
.updateTable("storage_instance")
.set({ ...(values as Record<string, never>), updated_at: new Date() })
.where("id", "=", params.id)
.returningAll()
.executeTakeFirst()
.catch((e: unknown) => pgConflict(e, "存储实例名称重复"));
if (!row) throw new ApiException("not_found", "存储实例不存在");
for (const r of drop) await ctx.secrets.remove(r).catch(() => {});
clearStorageCache(row.id);
await audit(
ctx,
actor,
"storage_instance.update",
{ type: "storage_instance", id: row.id },
{
fields: Object.keys(body).map((k) =>
k === "credential" || k === "pathPassword" ? `${k}(${body[k] === null ? "cleared" : "set"})` : k,
),
},
);
return view(row) as never;
});
endpoint(app, { ...S.remove, tags }, async ({ params, actor }) => {
const row = await getRow(params.id);
const prefix = `/@alist/${row.id}`;
const used = await db()
.selectFrom("library_root")
.select("name")
.where((eb) => eb.or([eb("local_path", "=", prefix), eb("local_path", "like", `${prefix}/%`)]))
.execute();
if (used.length)
throw new ApiException(
"conflict",
`存储实例仍被媒体库目录使用:${used.map((u) => u.name).join("、")},先删除或改用其它存储`,
);
await db().deleteFrom("storage_instance").where("id", "=", row.id).execute();
for (const r of [row.secret_ref, row.path_password_ref]) if (r) await ctx.secrets.remove(r).catch(() => {});
clearStorageCache(row.id);
await audit(ctx, actor, "storage_instance.delete", { type: "storage_instance", id: row.id });
return { ok: true as const };
});
endpoint(app, { ...S.test, tags }, async ({ params, actor }) => {
const row = await getRow(params.id);
const result = await testStorage(ctx, row);
await db()
.updateTable("storage_instance")
.set({
health_status: result.healthStatus,
last_health_at: new Date(),
health_detail: JSON.stringify({ message: result.message, rootOk: result.rootOk, usage: result.usage }),
...(result.version ? { version_info: JSON.stringify({ version: result.version, user: result.user }) } : {}),
})
.where("id", "=", row.id)
.execute();
clearStorageCache(row.id);
await audit(ctx, actor, "storage_instance.test", { type: "storage_instance", id: row.id }, { ok: result.ok });
return result;
});
}
/** 测试连接(设置页「测试连接」与健康检查共用) */
export async function testStorage(
ctx: Pick<ServerContext, "secrets" | "logger">,
row: Row & { base_url?: unknown; root_path?: unknown; options?: unknown },
): Promise<StorageTestResult> {
let plain: { credential: string | null; pathPassword: string | null } = { credential: null, pathPassword: null };
const fail = (healthStatus: StorageTestResult["healthStatus"], message: string): StorageTestResult => ({
ok: false,
healthStatus,
version: null,
user: null,
rootOk: false,
usage: null,
message: redactAlistText(message, [plain.credential, plain.pathPassword]).slice(0, 500),
});
try {
plain = await resolvePlain(ctx.secrets, row);
const client = buildClient(row as never, plain);
const version = await client.version();
const user = row.auth_kind === "guest" ? "guest" : (await client.me()).username;
const root = await client.stat(String(row.root_path ?? "/"));
const usage = await client.usage(String(row.root_path ?? "/"));
if (!root?.isDir) return { ...fail("degraded", `根路径不存在或不是目录:${row.root_path}`), version, user, usage };
return { ok: true, healthStatus: "healthy", version, user, rootOk: true, usage, message: null };
} catch (e) {
if (e instanceof AlistError) {
const label =
e.kind === "auth"
? row.auth_kind === "token"
? "令牌失效或无权限"
: "登录失败(用户名或密码不对)"
: e.kind === "unavailable" || e.kind === "timeout"
? "连不上"
: "接口出错";
return fail("offline", `${label}:${e.message}`);
}
return fail("offline", String((e as Error)?.message ?? e));
}
}
+43 -4
View File
@@ -10,6 +10,9 @@ import {
SystemWorkerLane,
} from "@umt/contracts";
import { type DB, type Kysely, type Selectable, sql } from "@umt/db";
import { storageEntry } from "../../storage/registry.ts";
import { isRemotePath, vcheckRoot } from "../../storage/vfs.ts";
import { toVPath } from "../../storage/vpath.ts";
import { CURRENT_HOST_WINDOW_MS, HEARTBEAT_ROW_RETENTION_MS, HEARTBEAT_STALE_MS } from "./heartbeat.ts";
// GET /system/health 各段的取数(W9-I)。每段独立 try/catch:一段失败只把该段标 unknown,不让整页 500。
@@ -214,7 +217,14 @@ export async function loadPathTargets(db: Kysely<DB>) {
.where("path_mapping.view_kind", "=", "downloader")
.orderBy("path_mapping.local_prefix")
.execute();
return { roots, maps };
const storages = await db
.selectFrom("storage_instance")
.select(["id", "name", "root_path"])
.where("enabled", "=", true)
.orderBy("name")
.execute()
.catch(() => [] as { id: string; name: string; root_path: string }[]);
return { roots, maps, storages };
}
export function diskLevel(ratio: number, t: HealthDeps["thresholds"]): HealthLevel {
@@ -226,11 +236,14 @@ export async function loadDisksAndMounts(d: HealthDeps): Promise<{ disks: System
const usage = d.diskUsage ?? diskUsage;
const check = d.checkRoot ?? checkRoot;
const ms = d.ioTimeoutMs ?? 3000;
const { roots, maps } = await loadPathTargets(d.db);
const { roots, maps, storages } = await loadPathTargets(d.db);
const targets: PathTarget[] = [
{ scope: "data_dir", id: null, name: "DATA_DIR", path: d.dataDir },
...roots.map((r) => ({ scope: "library_root" as const, id: r.id, name: r.name, path: r.local_path })),
// Alist 库根没有本机磁盘可统计(容量在文件管理按 mount_details 显示)
...roots
.filter((r) => !isRemotePath(r.local_path))
.map((r) => ({ scope: "library_root" as const, id: r.id, name: r.name, path: r.local_path })),
...maps.map((m) => ({
scope: "download_dir" as const,
id: m.id,
@@ -280,7 +293,12 @@ export async function loadDisksAndMounts(d: HealthDeps): Promise<{ disks: System
...roots.map(async (r): Promise<SystemMount> => {
const base = { kind: "library_root" as const, id: r.id, name: r.name, path: r.local_path, sentinelChecked: true };
try {
const h = await withTimeout(check(r.local_path, r.sentinel_file ?? DEFAULT_ROOT_SENTINEL), ms);
const h = await withTimeout(
isRemotePath(r.local_path)
? vcheckRoot(r.local_path)
: check(r.local_path, r.sentinel_file ?? DEFAULT_ROOT_SENTINEL),
isRemotePath(r.local_path) ? Math.max(ms, 15_000) : ms,
);
return h.kind === "online"
? { ...base, status: "online", reason: null }
: { ...base, status: "offline", reason: h.reason };
@@ -316,6 +334,27 @@ export async function loadDisksAndMounts(d: HealthDeps): Promise<{ disks: System
return { ...base, status: "offline", reason };
}
}),
// Alist / OpenList 实例:连通 + 根路径可访问(令牌失效、连不上都在这里显示)
...storages.map(async (s): Promise<SystemMount> => {
const path = toVPath(s.id, s.root_path);
const base = {
kind: "storage_instance" as const,
id: s.id,
name: s.name,
path: s.root_path,
sentinelChecked: false,
};
try {
await storageEntry(s.id);
const h = await withTimeout(vcheckRoot(path), Math.max(ms, 15_000));
if (h.kind === "online") return { ...base, status: "online", reason: null };
return { ...base, status: h.reason === "timeout" ? "timeout" : "offline", reason: h.reason };
} catch (err) {
return err instanceof Timeout
? { ...base, status: "timeout", reason: "timeout" }
: { ...base, status: "offline", reason: "error" };
}
}),
]);
return { disks, mounts };
}
+4 -1
View File
@@ -85,7 +85,10 @@ export async function loadMountedPaths(db: Kysely<DB>): Promise<MountedPath[]> {
.where("view_kind", "=", "downloader")
.execute();
return [
...roots.map((r) => ({ kind: "library_root" as const, id: r.id, name: r.name, path: r.local_path })),
// Alist 库根(/@alist/…)不是本机挂载,由健康检查按实例连通性判断
...roots
.filter((r) => !r.local_path.startsWith("/@alist/"))
.map((r) => ({ kind: "library_root" as const, id: r.id, name: r.name, path: r.local_path })),
...maps.map((m) => ({ kind: "path_mapping" as const, id: m.id, name: m.remote_prefix, path: m.local_prefix })),
];
}
+23
View File
@@ -0,0 +1,23 @@
import { ffprobe, type ProbeResult, redactAlistText } from "@umt/adapters";
import { vrawUrl } from "./vfs.ts";
import { parseVPath } from "./vpath.ts";
// 远端文件的 ffprobe(docs/design/storage-alist.md §7):不下载,直接对签名直链探测,ffprobe 只读容器头与少量包(HTTP Range)。
// 拿不到直链或探测失败返回 unknown(不阻塞,同本地 ffprobe 失败的口径)。直链进错误信息前脱敏。
export async function probeAny(
path: string,
local: (p: string) => Promise<ProbeResult> = (p) => ffprobe(p),
): Promise<ProbeResult> {
if (!parseVPath(path)) return local(path);
let url: string;
try {
url = await vrawUrl(path);
} catch (e) {
return { status: "unknown", reason: "probe_failed", message: redactAlistText(String((e as Error)?.message ?? e)) };
}
const r = await ffprobe(url, { inputArgs: ["-rw_timeout", "30000000"], timeoutMs: 60_000 });
if (r.status === "unknown" && r.message)
return { ...r, message: redactAlistText(r.message.split(url).join("<直链>")) };
return r;
}
+170
View File
@@ -0,0 +1,170 @@
import { type AlistAuth, AlistClient, AlistError } from "@umt/adapters";
import { STORAGE_OPTION_DEFAULTS, type StorageOptions } from "@umt/contracts";
import type { DB, Kysely, StorageInstanceRow } from "@umt/db";
import type { ServerContext } from "../modules/types.ts";
// 存储实例注册表(docs/design/storage-alist.md §3):按 id 取实例 + 解密凭据 → AlistClient。
// 进程级:模块装配时 configureStorage(ctx);实例行按 updated_at 缓存,配置一改客户端重建(令牌缓存随之作废,同 MP init_storage)。
export interface StorageEntry {
id: string;
name: string;
enabled: boolean;
/** 本系统可用的 Alist 根路径 */
rootPath: string;
options: Required<StorageOptions>;
client: AlistClient;
/** 日志脱敏要遮的秘密值 */
secrets: string[];
}
interface Source {
dataDir?: string | undefined;
db: Kysely<DB>;
secrets: ServerContext["secrets"];
logger?: ServerContext["logger"] | undefined;
}
let source: Source | null = null;
const cache = new Map<string, { stamp: string; entry: StorageEntry; checkedAt: number }>();
const overrides = new Map<string, StorageEntry>();
const RECHECK_MS = 5_000;
/** 模块装配时调用(幂等;最后一次为准) */
export function configureStorage(
ctx: Pick<ServerContext, "db" | "secrets"> & {
logger?: ServerContext["logger"];
config?: Partial<Pick<ServerContext["config"], "DATA_DIR">>;
},
) {
if (source?.db !== ctx.db) cache.clear();
source = { db: ctx.db, secrets: ctx.secrets, logger: ctx.logger, dataDir: ctx.config?.DATA_DIR };
}
/** 数据目录(跨存储中转、上传暂存放在 <DATA_DIR>/cache/storage-relay) */
export const storageDataDir = (): string | undefined => source?.dataDir;
/** 测试用:直接给某个存储 id 指定客户端(mock Alist) */
export function overrideStorage(
id: string,
e: { client: AlistClient; name?: string; rootPath?: string; options?: StorageOptions } | null,
): void {
if (!e) {
overrides.delete(id);
return;
}
overrides.set(id, {
id,
name: e.name ?? "Alist",
enabled: true,
rootPath: e.rootPath ?? "/",
options: { ...STORAGE_OPTION_DEFAULTS, ...(e.options ?? {}) },
client: e.client,
secrets: [],
});
}
export function clearStorageCache(id?: string): void {
if (id) cache.delete(id);
else cache.clear();
}
export const storageOptions = (o: unknown): Required<StorageOptions> => ({
...STORAGE_OPTION_DEFAULTS,
...((o && typeof o === "object" ? o : {}) as StorageOptions),
});
/** 由实例行 + 明文凭据建客户端(测试连接、注册表共用) */
export function buildClient(
row: Pick<StorageInstanceRow, "base_url" | "auth_kind" | "username" | "options">,
plain: { credential: string | null; pathPassword: string | null },
logger?: ServerContext["logger"],
): AlistClient {
const o = storageOptions(row.options);
let auth: AlistAuth;
if (row.auth_kind === "token") {
if (!plain.credential) throw new AlistError("auth", "没有配置令牌");
auth = { kind: "token", token: plain.credential };
} else if (row.auth_kind === "password") {
if (!row.username || !plain.credential) throw new AlistError("auth", "账号密码认证需要用户名和密码");
auth = { kind: "password", username: row.username, password: plain.credential };
} else auth = { kind: "guest" };
return new AlistClient({
baseUrl: row.base_url,
auth,
pathPassword: plain.pathPassword,
qps: o.qps,
concurrency: o.concurrency,
timeoutMs: o.timeoutSec * 1000,
idleTimeoutMs: o.idleTimeoutSec * 1000,
taskTimeoutMs: o.taskTimeoutMin * 60_000,
retries: o.retries,
uploadAsTask: o.uploadAsTask,
...(logger
? {
logger: {
debug: (obj, msg) => logger.debug(obj, msg),
warn: (obj, msg) => logger.warn(obj, msg),
},
}
: {}),
});
}
export async function resolvePlain(
secrets: Source["secrets"],
row: Pick<StorageInstanceRow, "secret_ref" | "path_password_ref">,
) {
return {
credential: row.secret_ref ? await secrets.resolve(row.secret_ref) : null,
pathPassword: row.path_password_ref ? await secrets.resolve(row.path_password_ref) : null,
};
}
/** 取存储实例;不存在 / 停用 / 未装配抛 AlistError(unavailable,按「存储离线」处理,不当成文件不存在) */
export async function storageEntry(id: string, opts: { allowDisabled?: boolean } = {}): Promise<StorageEntry> {
const o = overrides.get(id);
if (o) return o;
if (!source) throw new AlistError("unavailable", "存储注册表未初始化");
const hit = cache.get(id);
if (hit && Date.now() - hit.checkedAt < RECHECK_MS) return check(hit.entry, opts);
const row = await source.db.selectFrom("storage_instance").selectAll().where("id", "=", id).executeTakeFirst();
if (!row) throw new AlistError("unavailable", `存储实例不存在:${id}`);
const stamp = new Date(row.updated_at).toISOString();
if (hit && hit.stamp === stamp) {
hit.checkedAt = Date.now();
return check(hit.entry, opts);
}
const plain = await resolvePlain(source.secrets, row);
const entry: StorageEntry = {
id: row.id,
name: row.name,
enabled: row.enabled,
rootPath: row.root_path,
options: storageOptions(row.options),
client: buildClient(row, plain, source.logger),
secrets: [plain.credential, plain.pathPassword].filter((s): s is string => !!s),
};
cache.set(id, { stamp, entry, checkedAt: Date.now() });
return check(entry, opts);
}
function check(e: StorageEntry, opts: { allowDisabled?: boolean }): StorageEntry {
if (!e.enabled && !opts.allowDisabled) throw new AlistError("unavailable", `存储实例已停用:${e.name}`);
return e;
}
/** 启用的实例(文件管理根、健康检查) */
export async function listStorages(db?: Kysely<DB>): Promise<StorageInstanceRow[]> {
const d = db ?? source?.db;
if (!d) return [];
return d.selectFrom("storage_instance").selectAll().orderBy("name").execute();
}
/** 存储名表(展示「存储名:/路径」) */
export async function storageNames(db?: Kysely<DB>): Promise<Map<string, string>> {
const m = new Map<string, string>();
for (const [id, e] of overrides) m.set(id, e.name);
for (const r of await listStorages(db)) m.set(r.id, r.name);
return m;
}
+278
View File
@@ -0,0 +1,278 @@
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 { invalidateRemote, relayDir, remote, remoteError } from "./vfs.ts";
import { parseVPath, sameStorage } from "./vpath.ts";
// 整理矩阵中涉及 Alist 的方向(docs/design/storage-alist.md §5):
// 本地→Alist 上传、Alist→本地 下载、同实例 Alist→Alist API copy / move、跨实例 下载→上传。
// 成功判定与 MP 一致(用户决定):接口成功 + 目标存在,不比对大小 / 哈希;移动在判定通过后删源,删源失败 = attention。
// 本地 ↔ 本地不经这里(仍走 fsproxy 与现有核验)。
export interface RemoteTransferInput {
kind: "copy" | "move";
src: string;
dst: string;
/** 目标已存在:conflict 失败;replace 先把旧文件移到 replaceTrashDir(同存储),成功后旧文件留在那里,失败移回 */
onExisting: "conflict" | "replace";
replaceTrashDir?: string;
/** 本次是重试(上次可能已完成外部动作) */
resumed?: boolean;
signal?: AbortSignal;
onProgress?: ProgressCallback;
}
export interface RemoteTransferResult {
status: "succeeded" | "failed" | "attention";
bytes?: number;
/** 顺带得到的本地文件 sha256(上传的源 / 下载的结果),只是事实记录 */
checksum?: string;
resumed?: boolean;
error?: { code: string; message: string };
replaced?: { disposition: "trash"; path: string; size: number; mtimeMs: number };
steps?: { step: string; status: string; at: string; detail?: string; error?: { code: string; message: string } }[];
via: "upload" | "download" | "alist_copy" | "alist_move" | "relay";
}
/** Alist / 本地错误 → 整理账本错误码(可重试的 IO/TIMEOUT 走任务重试) */
export function transferErrorCode(e: unknown, role: "源" | "目标" | null = null): { code: string; message: string } {
if (e instanceof AlistError) {
const code =
e.kind === "not_found"
? role === "源"
? "SOURCE_MISSING"
: "IO"
: e.kind === "conflict"
? "EEXIST"
: e.kind === "auth"
? "EACCES"
: e.kind === "timeout" || e.kind === "rate_limited"
? "TIMEOUT"
: e.kind === "aborted"
? "ABORTED"
: "IO";
return { code, message: `Alist:${e.message}` };
}
const c = (e as { code?: string } | null)?.code;
const message = (e as Error)?.message ?? String(e);
if (c === "ENOENT") return { code: role === "源" ? "SOURCE_MISSING" : "IO", message };
if (c === "EEXIST") return { code: "EEXIST", message };
if (c === "EACCES" || c === "EPERM") return { code: "EACCES", message };
if (c === "ENOSPC") return { code: "ENOSPC", message };
if (c === "ETIMEDOUT") return { code: "TIMEOUT", message };
if (c === "ABORT_ERR") return { code: "ABORTED", message };
return { code: "IO", message };
}
const now = () => new Date().toISOString();
async function localExists(p: string): Promise<{ size: number; mtimeMs: number } | null> {
try {
const s = await stat(p);
return { size: s.size, mtimeMs: s.mtimeMs };
} catch (e) {
if ((e as { code?: string }).code === "ENOENT") return null;
throw e;
}
}
/** 任一端是远端路径? */
export const involvesRemote = (src: string, dst: string) => !!parseVPath(src) || !!parseVPath(dst);
/** 执行一次涉及远端的复制 / 移动 */
export async function remoteTransfer(x: RemoteTransferInput): Promise<RemoteTransferResult> {
const rs = parseVPath(x.src);
const rd = parseVPath(x.dst);
if (rs) invalidateRemote(x.src);
if (rd) invalidateRemote(x.dst);
const via: RemoteTransferResult["via"] =
rs && rd
? sameStorage(x.src, x.dst)
? x.kind === "copy"
? "alist_copy"
: "alist_move"
: "relay"
: rd
? "upload"
: "download";
const fail = (e: unknown, role: "源" | "目标" | null = null): RemoteTransferResult => ({
status: "failed",
error: transferErrorCode(e, role),
via,
});
// ── 源 / 目标状态 ──
let srcSize = 0;
let srcExists = true;
try {
if (rs) {
const { client } = await remote(x.src);
const o = await client.stat(rs.remote);
srcExists = !!o;
srcSize = o?.size ?? 0;
if (o?.isDir) return fail(new AlistError("error", `源是目录:${x.src}`));
} else {
const s = await localExists(x.src);
srcExists = !!s;
srcSize = s?.size ?? 0;
}
} catch (e) {
return fail(e, "源");
}
let dstFacts: { size: number; mtimeMs: number } | null;
try {
if (rd) {
const { client } = await remote(x.dst);
const o = await client.stat(rd.remote);
dstFacts = o ? { size: o.size, mtimeMs: o.modified } : null;
} else dstFacts = await localExists(x.dst);
} catch (e) {
return fail(e, "目标");
}
// 对账:上次尝试已完成外部动作但没留下结果(目标都是「临时名 → 改名」落地的,目标在 = 上次已写完)
if (dstFacts && x.resumed && x.onExisting !== "replace") {
if (x.kind === "move" && srcExists) {
const del = await deleteSource(x.src).then(
() => null,
(e) => transferErrorCode(e),
);
if (del)
return {
status: "attention",
bytes: dstFacts.size,
resumed: true,
via,
steps: [{ step: "delete_source", status: "failed", at: now(), error: del }],
};
}
return { status: "succeeded", bytes: dstFacts.size, resumed: true, via };
}
if (!srcExists) {
if (x.kind === "move" && dstFacts) return { status: "succeeded", bytes: dstFacts.size, resumed: true, via };
return { status: "failed", error: { code: "SOURCE_MISSING", message: `源文件不存在:${x.src}` }, via };
}
if (dstFacts && x.onExisting !== "replace")
return { status: "failed", error: { code: "EEXIST", message: `目标已存在:${x.dst}` }, via };
// ── 替换:旧目标先移走 ──
let parked: { path: string; size: number; mtimeMs: number } | null = null;
if (dstFacts && x.onExisting === "replace") {
const trashDir = x.replaceTrashDir ?? join(dirname(x.dst), ".umt-trash");
const to = join(trashDir, `replaced-${Date.now()}-${randomUUID().slice(0, 6)}`, basename(x.dst));
try {
await parkOld(x.dst, to);
parked = { path: to, size: dstFacts.size, mtimeMs: dstFacts.mtimeMs };
} catch (e) {
return fail(e, "目标");
}
}
const restoreParked = async () => {
if (parked) await parkOld(parked.path, x.dst).catch(() => undefined);
};
// ── 传输 ──
let bytes = srcSize;
let checksum: string | undefined;
try {
if (rd && !rs) {
const { client } = await remote(x.dst);
const r = await client.upload(x.src, rd.remote, {
...(x.signal ? { signal: x.signal } : {}),
...(x.onProgress ? { onProgress: x.onProgress } : {}),
});
bytes = r.bytes;
checksum = `sha256:${r.hashes.sha256}`;
} else if (rs && !rd) {
await mkdir(dirname(x.dst), { recursive: true });
const { client } = await remote(x.src);
const r = await client.download(rs.remote, x.dst, {
...(x.signal ? { signal: x.signal } : {}),
...(x.onProgress ? { onProgress: x.onProgress } : {}),
});
bytes = r.bytes;
checksum = `sha256:${r.sha256}`;
} else if (rs && rd && sameStorage(x.src, x.dst)) {
const { client } = await remote(x.src);
const opts = x.signal ? { signal: x.signal } : {};
const o =
x.kind === "copy"
? await client.copyFile(rs.remote, rd.remote, opts)
: await client.moveFile(rs.remote, rd.remote, opts);
bytes = o.size || srcSize;
} else if (rs && rd) {
// 跨实例:下载到本机中转 → 上传(MP 不支持,我们补上)
const tmpDir = join(relayDir(), randomUUID());
await mkdir(tmpDir, { recursive: true });
const tmp = join(tmpDir, posix.basename(rs.remote));
try {
const a = await remote(x.src);
await a.client.download(rs.remote, tmp, {
...(x.signal ? { signal: x.signal } : {}),
...(x.onProgress ? { onProgress: (d, t) => x.onProgress?.(d / 2, t) } : {}),
});
const b = await remote(x.dst);
const r = await b.client.upload(tmp, rd.remote, {
...(x.signal ? { signal: x.signal } : {}),
...(x.onProgress ? { onProgress: (d, t) => x.onProgress?.(t / 2 + d / 2, t) } : {}),
});
bytes = r.bytes;
checksum = `sha256:${r.hashes.sha256}`;
} finally {
await rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
}
}
} catch (e) {
await restoreParked();
return fail(e);
}
const out: RemoteTransferResult = {
status: "succeeded",
bytes,
...(checksum ? { checksum } : {}),
via,
...(parked ? { replaced: { disposition: "trash" as const, ...parked } } : {}),
};
// 同实例 API move 自己删了源;其它移动在成功后删源
if (x.kind === "move" && via !== "alist_move") {
const del = await deleteSource(x.src).then(
() => null,
(e) => transferErrorCode(e),
);
if (del)
return {
...out,
status: "attention",
steps: [{ step: "delete_source", status: "failed", at: now(), error: del }],
};
}
return out;
}
async function deleteSource(src: string): Promise<void> {
const v = parseVPath(src);
if (!v) {
await unlink(src);
return;
}
const { client } = await remote(src);
try {
await client.removePath(v.remote);
} catch (e) {
throw remoteError(e, src);
}
}
/** 同存储内把 from 移到 to(替换时暂放旧文件 / 失败移回) */
async function parkOld(from: string, to: string): Promise<void> {
const a = parseVPath(from);
const b = parseVPath(to);
if (!a && !b) {
await mkdir(dirname(to), { recursive: true });
await rename(from, to);
return;
}
if (!a || !b || a.storageId !== b.storageId) throw new AlistError("error", "替换暂存必须与目标在同一存储");
const { client } = await remote(from);
await client.moveFile(a.remote, b.remote);
}
+356
View File
@@ -0,0 +1,356 @@
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 {
type AlistClient,
AlistError,
checkRoot,
DEFAULT_ROOT_SENTINEL,
type FileFacts,
type RootHealth,
type StatResult,
statSafe,
} from "@umt/adapters";
import { storageDataDir, storageEntry } from "./registry.ts";
import { parseVPath, toVPath, type VPath } from "./vpath.ts";
// 本地 / Alist 统一的文件操作(docs/design/storage-alist.md §3)。本地分支原样调用 node:fs / adapters,
// 远端分支经注册表取 AlistClient。远端错误映射成 errno 风格的 code:不存在 ENOENT、冲突 EEXIST、鉴权 EACCES,
// 连接 / 服务端失败 EHOSTDOWN / ETIMEDOUT(文件管理按「根不可用」503 处理,整理按可重试 IO 处理,绝不当成不存在)。
export { isRemotePath, parseVPath, toVPath } from "./vpath.ts";
export interface VStats {
size: number;
mtimeMs: number;
ino: number;
dev: number;
nlink: number;
isFile(): boolean;
isDirectory(): boolean;
isSymbolicLink(): boolean;
}
/** Alist 错误 → 带 errno code 的 Error */
export function remoteError(e: unknown, path?: string): Error & { code: string } {
if (e instanceof AlistError) {
const code =
e.kind === "not_found"
? "ENOENT"
: e.kind === "conflict"
? "EEXIST"
: e.kind === "auth"
? "EACCES"
: e.kind === "timeout" || e.kind === "rate_limited"
? "ETIMEDOUT"
: e.kind === "aborted"
? "ABORT_ERR"
: "EHOSTDOWN";
return Object.assign(new Error(`Alist:${e.message}${path ? `(${path})` : ""}`), { code, alist: e.kind });
}
if (e && typeof e === "object" && "code" in e) return e as Error & { code: string };
return Object.assign(new Error(String((e as Error)?.message ?? e)), { code: "EIO" });
}
/** 远端对象的伪设备号:同一存储实例相同,与本机设备号不冲突 */
export function remoteDev(storageId: string): number {
return 2 ** 40 + Number.parseInt(createHash("sha1").update(storageId).digest("hex").slice(0, 8), 16);
}
export async function remote(p: string): Promise<{ v: VPath; client: AlistClient }> {
const v = parseVPath(p);
if (!v) throw Object.assign(new Error(`不是远端路径:${p}`), { code: "EINVAL" });
try {
const e = await storageEntry(v.storageId);
return { v, client: e.client };
} catch (err) {
throw remoteError(err, p);
}
}
const statsOf = (storageId: string, o: { isDir: boolean; size: number; modified: number }): VStats => ({
size: o.isDir ? 0 : o.size,
mtimeMs: o.modified,
ino: 0,
dev: remoteDev(storageId),
nlink: 1,
isFile: () => !o.isDir,
isDirectory: () => o.isDir,
isSymbolicLink: () => false,
});
export const factsOf = (s: VStats): FileFacts => ({
size: Number(s.size),
mtimeMs: Number(s.mtimeMs),
ino: Number(s.ino),
dev: Number(s.dev),
nlink: Number(s.nlink),
isFile: s.isFile(),
isDirectory: s.isDirectory(),
isSymbolicLink: s.isSymbolicLink(),
});
// 远端 stat 短缓存:列目录拿到的子项 stat 在几秒内复用,避免遍历时逐个 get(§9-8 限制请求量);写操作按目录失效
const STAT_TTL_MS = 3000;
const statCache = new Map<string, { at: number; stats: VStats }>();
function cacheStat(p: string, stats: VStats) {
statCache.set(p, { at: Date.now(), stats });
if (statCache.size > 20_000) statCache.delete(statCache.keys().next().value!);
}
/** 写后失效:该路径、其子树与父目录 */
export function invalidateRemote(p: string): void {
const parent = posix.dirname(p);
for (const k of statCache.keys()) if (k === p || k.startsWith(`${p}/`) || k === parent) statCache.delete(k);
}
export async function vlstat(p: string, opts: { refresh?: boolean } = {}): Promise<VStats> {
if (!parseVPath(p)) return (await lstat(p)) as unknown as VStats;
if (!opts.refresh) {
const hit = statCache.get(p);
if (hit && Date.now() - hit.at < STAT_TTL_MS) return hit.stats;
}
const { v, client } = await remote(p);
try {
const st = statsOf(v.storageId, await client.get(v.remote, opts));
cacheStat(p, st);
return st;
} catch (e) {
throw remoteError(e, p);
}
}
/** 远端没有符号链接:realpath = 规范化后的路径(存在才返回) */
export async function vrealpath(p: string): Promise<string> {
const v = parseVPath(p);
if (!v) return realpath(p);
await vlstat(p);
return toVPath(v.storageId, v.remote);
}
export interface VDirent {
name: string;
stats: VStats;
}
/** 列目录(带每项的 stat;远端一次 list 拿全) */
export async function vreaddirStats(p: string, opts: { refresh?: boolean } = {}): Promise<VDirent[]> {
const v = parseVPath(p);
if (!v) {
const names = await readdir(p);
const out: VDirent[] = [];
for (const name of names) {
const st = await lstat(join(p, name)).catch(() => null);
if (st) out.push({ name, stats: st as unknown as VStats });
}
return out;
}
const { client } = await remote(p);
try {
return (await client.list(v.remote, opts)).map((o) => {
const stats = statsOf(v.storageId, o);
cacheStat(join(p, o.name), stats);
return { name: o.name, stats };
});
} catch (e) {
throw remoteError(e, p);
}
}
export interface VDirentLite {
name: string;
isDirectory(): boolean;
isFile(): boolean;
isSymbolicLink(): boolean;
}
/** readdir(withFileTypes) 的统一版本;本地照旧 */
export async function vreaddirents(p: string): Promise<VDirentLite[]> {
if (!parseVPath(p)) return readdir(p, { withFileTypes: true });
return (await vreaddirStats(p)).map((d) => ({
name: d.name,
isDirectory: () => d.stats.isDirectory(),
isFile: () => d.stats.isFile(),
isSymbolicLink: () => false,
}));
}
export async function vreaddir(p: string): Promise<string[]> {
if (!parseVPath(p)) return readdir(p);
return (await vreaddirStats(p)).map((d) => d.name).sort();
}
/** 同 adapters statSafe 的口径;远端失败(连不上 / 超时 / 鉴权)返回 error,只有明确不存在才是 missing */
export async function vstatSafe(p: string, opts: { root?: string; sentinel?: string } = {}): Promise<StatResult> {
if (!parseVPath(p)) return statSafe(p, opts);
if (opts.root) {
const h = await vcheckRoot(opts.root, opts.sentinel);
if (h.kind === "offline") return { kind: "root_offline", root: opts.root, reason: h.reason };
}
try {
return { kind: "ok", facts: factsOf(await vlstat(p)) };
} catch (e) {
const err = remoteError(e, p);
if (err.code === "ENOENT") return { kind: "missing" };
if (err.code === "EACCES") return { kind: "permission_denied" };
return { kind: "error", code: err.code === "ETIMEDOUT" ? "TIMEOUT" : "IO", message: err.message };
}
}
/** 根在线:本地按哨兵;远端 = 根路径存在且是目录(Alist 不用哨兵,同 MP) */
export async function vcheckRoot(root: string, sentinel: string = DEFAULT_ROOT_SENTINEL): Promise<RootHealth> {
const v = parseVPath(root);
if (!v) return checkRoot(root, sentinel);
try {
const st = await vlstat(root);
if (!st.isDirectory()) return { kind: "offline", reason: "root_missing" };
return { kind: "online", dev: remoteDev(v.storageId) };
} catch (e) {
const err = remoteError(e, root);
if (err.code === "ENOENT") return { kind: "offline", reason: "root_missing" };
if (err.code === "EACCES") return { kind: "offline", reason: "permission_denied", code: "EACCES" };
if (err.code === "ETIMEDOUT") return { kind: "offline", reason: "timeout" };
return { kind: "offline", reason: "error", code: err.code };
}
}
export async function vmkdirp(p: string): Promise<void> {
const v = parseVPath(p);
if (!v) {
await mkdir(p, { recursive: true });
return;
}
const { client } = await remote(p);
invalidateRemote(p);
await client.mkdir(v.remote).catch((e) => {
throw remoteError(e, p);
});
}
export async function vexists(p: string): Promise<boolean> {
try {
await vlstat(p);
return true;
} catch (e) {
if ((e as { code?: string }).code === "ENOENT") return false;
throw e;
}
}
export async function vreadFile(p: string, maxBytes = 4 * 1024 * 1024): Promise<Uint8Array> {
const v = parseVPath(p);
if (!v) return new Uint8Array(await readFile(p));
const { client } = await remote(p);
try {
return await client.readFile(v.remote, maxBytes);
} catch (e) {
throw remoteError(e, p);
}
}
/** 本机临时目录(远端写入的本地中转) */
export function relayDir(): string {
return join(storageDataDir() ?? Deno.env.get("DATA_DIR") ?? tmpdir(), "cache", "storage-relay");
}
/**
* 原子写:本地同目录临时文件 → rename;远端本地生成后上传(同 MP 刮削 `_save_file`):
* 上传到旁边的临时名,成功后删旧文件(若有)再改名。
*/
export async function vwriteFileAtomic(p: string, content: Uint8Array | string): Promise<void> {
const v = parseVPath(p);
if (!v) {
await mkdir(dirname(p), { recursive: true });
const tmp = `${p}.${randomUUID().slice(0, 8)}.tmp`;
try {
await writeFile(tmp, content);
await rename(tmp, p);
} catch (e) {
await rm(tmp, { force: true }).catch(() => {});
throw e;
}
return;
}
const { client } = await remote(p);
invalidateRemote(p);
const dir = relayDir();
await mkdir(dir, { recursive: true });
const local = join(dir, `${randomUUID()}-${basename(p)}`);
const stage = `${v.remote}.umt-new`;
try {
await writeFile(local, content);
if (await client.stat(stage)) await client.removePath(stage);
await client.upload(local, stage);
if (await client.stat(v.remote)) await client.removePath(v.remote);
await client.rename(stage, posix.basename(v.remote));
} catch (e) {
await client.removePath(stage).catch(() => undefined);
throw remoteError(e, p);
} finally {
await rm(local, { force: true }).catch(() => {});
}
}
/** 删除文件或目录(远端 remove;本地 rm -r) */
export async function vremove(p: string): Promise<void> {
const v = parseVPath(p);
if (!v) {
await rm(p, { recursive: true, force: true });
return;
}
const { client } = await remote(p);
invalidateRemote(p);
await client.removePath(v.remote).catch((e) => {
throw remoteError(e, p);
});
}
/** 同存储内移动 / 改名(远端用 API move,目标已存在 EEXIST;本地 rename 不覆盖由调用方保证) */
export async function vmove(src: string, dst: string): Promise<void> {
const a = parseVPath(src);
const b = parseVPath(dst);
if (!a && !b) {
await mkdir(dirname(dst), { recursive: true });
await rename(src, dst);
return;
}
if (!a || !b || a.storageId !== b.storageId)
throw Object.assign(new Error(`跨存储不能直接移动:${src} → ${dst}`), { code: "EXDEV" });
const { client } = await remote(src);
invalidateRemote(src);
invalidateRemote(dst);
await client.moveFile(a.remote, b.remote).catch((e) => {
throw remoteError(e, src);
});
}
/** 远端直链(ffprobe 用;日志前要脱敏) */
export async function vrawUrl(p: string): Promise<string> {
const { v, client } = await remote(p);
return client.rawUrl(v.remote).catch((e) => {
throw remoteError(e, p);
});
}
/** 同目录不覆盖改名(远端 Alist rename 本身拒绝同名;本地走 adapters moveSafe) */
export async function vrenameNoClobber(from: string, to: string): Promise<{ status: "moved" | "exists" | "exdev" }> {
const a = parseVPath(from);
if (!a) {
const { moveSafe } = await import("@umt/adapters");
return moveSafe(from, to);
}
const b = parseVPath(to);
if (!b || b.storageId !== a.storageId) return { status: "exdev" };
const { client } = await remote(from);
invalidateRemote(from);
invalidateRemote(to);
try {
if (await client.stat(b.remote)) return { status: "exists" };
if (posix.dirname(a.remote) === posix.dirname(b.remote)) await client.rename(a.remote, posix.basename(b.remote));
else await client.moveFile(a.remote, b.remote);
return { status: "moved" };
} catch (e) {
if (e instanceof AlistError && e.kind === "conflict") return { status: "exists" };
throw remoteError(e, from);
}
}
+45
View File
@@ -0,0 +1,45 @@
import { posix } from "node:path";
// 远端路径的虚拟前缀(docs/design/storage-alist.md §0、§3):/@alist/<存储 id>/<Alist 内路径>。
// 它是合法的 POSIX 绝对路径,join / dirname / relative 照常可用;本机没有 /@alist,
// 不认识前缀的旧代码对它做本地 IO 只会得到 ENOENT(失败安全)。
export const VROOT = "/@alist";
const RE = /^\/@alist\/([0-9a-f-]{36})(\/.*)?$/i;
export interface VPath {
storageId: string;
/** Alist 内绝对路径(以 / 开头,无尾斜杠) */
remote: string;
}
export const isRemotePath = (p: string | null | undefined): boolean =>
!!p && (p === VROOT || p.startsWith(`${VROOT}/`));
export function parseVPath(p: string): VPath | null {
if (!isRemotePath(p)) return null;
const m = RE.exec(p.replace(/\/+$/, ""));
if (!m) return null;
const remote = posix.normalize(m[2] ?? "/");
if (remote.split("/").includes("..")) return null;
return { storageId: m[1]!.toLowerCase(), remote: remote === "/" ? "/" : remote.replace(/\/+$/, "") };
}
export function toVPath(storageId: string, remote: string): string {
const r = posix.normalize(`/${remote}`).replace(/\/+$/, "");
return `${VROOT}/${storageId}${r === "" || r === "/" ? "" : r}`;
}
/** 同一存储实例? */
export function sameStorage(a: string, b: string): boolean {
const pa = parseVPath(a);
const pb = parseVPath(b);
return !!pa && !!pb && pa.storageId === pb.storageId;
}
/** 界面 / 日志展示:「存储名:/路径」;本地原样 */
export function displayPath(p: string, names: ReadonlyMap<string, string> = new Map()): string {
const v = parseVPath(p);
if (!v) return p;
return `${names.get(v.storageId) ?? "Alist"}:${v.remote}`;
}
@@ -0,0 +1,176 @@
import { createMockAlist, serveMockAlist } from "@umt/adapters/alist-mock";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildHealth } from "../../src/modules/system/health.ts";
import { clearStorageCache, storageEntry } from "../../src/storage/registry.ts";
import { DEV_KEY, json } from "../helpers.ts";
import { bearer, createHarness, type Harness, pgAvailable } from "./harness.ts";
// 存储实例(Alist / OpenList)设置接口:只写凭据、测试连接(版本 / 用户 / 根路径 / 容量)、
// 令牌失效 → offline、被库根引用不能删、健康检查里的存储实例(docs/design/storage-alist.md §3、§4)。
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("存储实例(真实 PG + 模拟 Alist HTTP)", () => {
let h: Harness;
let admin: ReturnType<typeof bearer>;
const mock = createMockAlist({
token: "perm-token-xyz",
username: "alice",
password: "pw-alice",
mountDetails: { "/s1": { total_space: 1000, free_space: 250, used_space: 750 } },
});
mock.mkdirp("/s1/media");
let srv: { base: string; close: () => Promise<void> };
beforeAll(async () => {
srv = serveMockAlist(mock);
h = await createHarness({ engine: false });
admin = bearer(h.app, DEV_KEY);
}, 60_000);
afterAll(async () => {
await h?.close();
await srv?.close();
});
it("新建(令牌):凭据只写不读;测试连接拿到版本、用户、根路径、容量", async () => {
const c = await admin("POST", "/storage-instances", {
name: "openlist-a",
baseUrl: `${srv.base}/`,
rootPath: "/s1/media/",
authKind: "token",
credential: "perm-token-xyz",
pathPassword: "folder-pw",
});
expect(c.status).toBe(200);
const row = (await json(c)).data;
expect(row.baseUrl).toBe(srv.base);
expect(row.rootPath).toBe("/s1/media");
expect(row.secretRef).toBeTruthy();
expect(row.pathPasswordRef).toBeTruthy();
expect(JSON.stringify(row)).not.toContain("perm-token-xyz");
expect(JSON.stringify(row)).not.toContain("folder-pw");
expect(await h.ctx.secrets.resolve(row.secretRef)).toBe("perm-token-xyz");
const t = (await json(await admin("POST", `/storage-instances/${row.id}/test`))).data;
expect(t).toMatchObject({ ok: true, healthStatus: "healthy", user: "alice", rootOk: true });
expect(t.version).toContain("v4");
expect(t.usage).toEqual({ total: 1000, free: 250, used: 750 });
const after = (await json(await admin("GET", `/storage-instances/${row.id}`))).data;
expect(after.healthStatus).toBe("healthy");
});
it("账号密码:缺密码 422;改成错密码后测试 = offline 且信息里没有密码", async () => {
const bad = await admin("POST", "/storage-instances", {
name: "pw-missing",
baseUrl: srv.base,
authKind: "password",
username: "alice",
});
expect(bad.status).toBe(422);
const c = (
await json(
await admin("POST", "/storage-instances", {
name: "openlist-pw",
baseUrl: srv.base,
authKind: "password",
username: "alice",
credential: "pw-alice",
}),
)
).data;
expect((await json(await admin("POST", `/storage-instances/${c.id}/test`))).data.ok).toBe(true);
await admin("PATCH", `/storage-instances/${c.id}`, { credential: "wrong-secret-pw" });
const t = (await json(await admin("POST", `/storage-instances/${c.id}/test`))).data;
expect(t).toMatchObject({ ok: false, healthStatus: "offline" });
expect(t.message).toContain("登录失败");
expect(t.message).not.toContain("wrong-secret-pw");
});
it("永久令牌失效:测试与健康检查都报不可用", async () => {
const c = (
await json(
await admin("POST", "/storage-instances", {
name: "openlist-badtoken",
baseUrl: srv.base,
rootPath: "/s1",
credential: "revoked-token",
}),
)
).data;
const t = (await json(await admin("POST", `/storage-instances/${c.id}/test`))).data;
expect(t.ok).toBe(false);
expect(t.message).toContain("令牌失效");
clearStorageCache();
const health = await buildHealth({
db: h.ctx.db,
engineSchema: "openworkflow",
dataDir: "/tmp",
self: { version: "t", startedAt: new Date(), sseConnections: 0 },
thresholds: { warnPercent: 85, criticalPercent: 95 },
});
const m = health.mounts.find((x) => x.kind === "storage_instance" && x.id === c.id);
expect(m).toMatchObject({ status: "offline", reason: "permission_denied" });
const ok = health.mounts.find((x) => x.kind === "storage_instance" && x.name === "openlist-a");
expect(ok?.status).toBe("online");
});
it("改凭据后注册表重建客户端;清除路径密码", async () => {
const row = (await json(await admin("GET", "/storage-instances?limit=50"))).data.items.find(
(x: { name: string }) => x.name === "openlist-a",
);
const e1 = await storageEntry(row.id);
expect(e1.secrets).toContain("folder-pw");
const r = (await json(await admin("PATCH", `/storage-instances/${row.id}`, { pathPassword: null }))).data;
expect(r.pathPasswordRef).toBeNull();
const e2 = await storageEntry(row.id);
expect(e2.client).not.toBe(e1.client);
expect(e2.secrets).not.toContain("folder-pw");
});
it("被媒体库目录引用时不能删;解除后可删,凭据一并删除", async () => {
const c = (
await json(
await admin("POST", "/storage-instances", {
name: "to-delete",
baseUrl: srv.base,
rootPath: "/s1",
credential: "perm-token-xyz",
}),
)
).data;
const lib = await admin("POST", "/library-roots", {
name: "Alist 电影",
localPath: `/@alist/${c.id}/s1/media`,
mediaKind: "movie",
});
expect(lib.status).toBe(200);
const libRow = (await json(lib)).data;
expect(libRow.sentinelFile).toBeNull();
const missing = await admin("POST", "/library-roots", {
name: "不存在的目录",
localPath: `/@alist/${c.id}/s1/nope`,
});
expect(missing.status).toBe(412);
const blocked = await admin("DELETE", `/storage-instances/${c.id}`);
expect(blocked.status).toBe(409);
const test = (await json(await admin("POST", `/library-roots/${libRow.id}/test`))).data;
expect(test.ok).toBe(true);
await admin("DELETE", `/library-roots/${libRow.id}`);
const del = await admin("DELETE", `/storage-instances/${c.id}`);
expect(del.status).toBe(200);
await expect(h.ctx.secrets.resolve(c.secretRef)).rejects.toThrow();
const audits = await h.ctx.db
.selectFrom("audit_event")
.select("action")
.where("action", "like", "storage_instance.%")
.execute();
expect(new Set(audits.map((a) => a.action))).toEqual(
new Set([
"storage_instance.create",
"storage_instance.update",
"storage_instance.test",
"storage_instance.delete",
]),
);
});
});
+3 -1
View File
@@ -49,6 +49,8 @@ export interface ProbeOptions {
/** ffprobe 可执行文件,默认 "ffprobe"(PATH 查找) */
bin?: string;
timeoutMs?: number;
/** 输入前的附加参数(远端直链用 -rw_timeout 等) */
inputArgs?: string[];
}
const optNum = (v: unknown): number | undefined => {
@@ -78,7 +80,7 @@ export function ffprobe(path: string, opts: ProbeOptions = {}): Promise<ProbeRes
return new Promise((resolve) => {
execFile(
opts.bin ?? "ffprobe",
["-v", "error", "-print_format", "json", "-show_format", "-show_streams", "--", path],
["-v", "error", ...(opts.inputArgs ?? []), "-print_format", "json", "-show_format", "-show_streams", "--", path],
{ timeout: opts.timeoutMs ?? 30_000, maxBuffer: 16 * 1024 * 1024 },
(err, stdout, stderr) => {
if (err) {
+22
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import { IdParam } from "../common.ts";
import * as instances from "../instances.ts";
import * as storage from "../storage.ts";
import { crud, del, ep, instanceTest, R } from "./shared.ts";
export const instancesApi = {
@@ -54,6 +55,27 @@ export const instancesApi = {
test: instanceTest("/media-server-instances/{id}/test", "测试媒体服务器连接"),
remove: del("/media-server-instances/{id}", "删除媒体服务器实例"),
},
storageInstances: {
...crud(
"/storage-instances",
"存储实例",
{
row: storage.StorageInstance,
create: storage.StorageInstanceCreate,
update: storage.StorageInstanceUpdate,
},
R,
),
test: ep({
method: "post",
path: "/storage-instances/{id}/test",
scope: "admin",
summary: "测试 Alist / OpenList 连接(版本、登录用户、根路径、容量)",
params: IdParam,
response: storage.StorageTestResult,
}),
remove: del("/storage-instances/{id}", "删除存储实例"),
},
pathMappings: {
...crud(
"/path-mappings",
+4
View File
@@ -225,6 +225,10 @@ export const AuditAction = z.enum([
"media_server_instance.update",
"media_server_instance.delete",
"media_server_instance.test",
"storage_instance.create",
"storage_instance.update",
"storage_instance.delete",
"storage_instance.test",
"path_mapping.create",
"path_mapping.update",
"path_mapping.delete",
+1
View File
@@ -31,5 +31,6 @@ export * from "./redact.ts";
export * from "./scrape.ts";
export * from "./settings.ts";
export * from "./sources.ts";
export * from "./storage.ts";
export * from "./subtitles.ts";
export * from "./system-resources.ts";
+3 -1
View File
@@ -168,6 +168,7 @@ export const SecretPurpose = z.enum([
"media_server_api_key",
"metadata_api_key",
"llm_api_key",
"storage_credential",
"other",
]);
export type SecretPurpose = z.infer<typeof SecretPurpose>;
@@ -448,7 +449,8 @@ export const SystemDisk = z.object({
export type SystemDisk = z.infer<typeof SystemDisk>;
export const SystemMount = z.object({
kind: z.enum(["library_root", "path_mapping"]),
/** storage_instance = Alist / OpenList 实例(连通 + 根路径可访问;令牌失效为 offline/auth) */
kind: z.enum(["library_root", "path_mapping", "storage_instance"]),
id: z.string(),
name: z.string(),
path: z.string(),
+121
View File
@@ -0,0 +1,121 @@
import { z } from "zod";
import { EntityBase, Id, JsonObject, Timestamp, WriteOnlySecret } from "./common.ts";
import { InstanceHealth } from "./instances.ts";
// Alist / OpenList 存储实例(docs/design/storage-alist.md §3)。凭据(令牌 / 密码)与路径密码只写不读。
export const StorageKind = z.enum(["alist"]);
export type StorageKind = z.infer<typeof StorageKind>;
/** 只用于展示与版本差异;接口相同(MP 的 AlistGo 是同实现的子类) */
export const StorageFlavor = z.enum(["alist", "openlist"]);
export type StorageFlavor = z.infer<typeof StorageFlavor>;
/** token 永久令牌(Alist 后台「设置 → 其他 → 令牌」);password 账号密码换临时令牌;guest 访客 */
export const StorageAuthKind = z.enum(["token", "password", "guest"]);
export type StorageAuthKind = z.infer<typeof StorageAuthKind>;
/** 实例高级选项;不填用缺省(括号内) */
export const StorageOptions = z
.object({
/** 每秒请求数(5);0 不限 */
qps: z.number().int().min(0).max(100),
/** 并发(4);0 不限 */
concurrency: z.number().int().min(0).max(32),
/** 普通请求超时秒(30) */
timeoutSec: z.number().int().min(5).max(600),
/** 上传 / 下载无进度空闲超时秒(120) */
idleTimeoutSec: z.number().int().min(10).max(3600),
/** 等待 Alist 复制 / 移动 / 上传任务的上限分钟(360) */
taskTimeoutMin: z.number().int().min(1).max(10080),
/** 可重试错误的重试次数(3) */
retries: z.number().int().min(0).max(10),
/** 上传用 As-Task(false,同 MP) */
uploadAsTask: z.boolean(),
/** 目录监控最大深度(5,同 MP) */
snapshotMaxDepth: z.number().int().min(1).max(20),
/** 目录监控按目录修改时间跳过未变子树(true) */
snapshotCheckFolderModtime: z.boolean(),
})
.partial();
export type StorageOptions = z.infer<typeof StorageOptions>;
export const STORAGE_OPTION_DEFAULTS: Required<StorageOptions> = {
qps: 5,
concurrency: 4,
timeoutSec: 30,
idleTimeoutSec: 120,
taskTimeoutMin: 360,
retries: 3,
uploadAsTask: false,
snapshotMaxDepth: 5,
snapshotCheckFolderModtime: true,
};
/** Alist 内路径:以 / 开头,不含 .. 段 */
export const AlistPath = z
.string()
.max(4096)
.refine((p) => p.startsWith("/") && !p.includes("\0") && !p.split("/").some((s) => s === ".." || s === "."), {
message: "Alist 路径必须以 / 开头,不能含 . 或 ..",
});
export const StorageInstance = EntityBase.extend({
name: z.string().min(1).max(100),
kind: StorageKind,
flavor: StorageFlavor,
baseUrl: z.url(),
/** 本系统可浏览 / 使用的 Alist 根路径(缺省 /) */
rootPath: AlistPath,
authKind: StorageAuthKind,
username: z.string().nullable(),
secretRef: Id.nullable(),
pathPasswordRef: Id.nullable(),
enabled: z.boolean(),
options: StorageOptions,
healthStatus: InstanceHealth,
lastHealthAt: Timestamp.nullable(),
healthDetail: JsonObject,
versionInfo: JsonObject,
});
export type StorageInstance = z.infer<typeof StorageInstance>;
export const StorageInstanceCreate = StorageInstance.pick({
name: true,
flavor: true,
baseUrl: true,
rootPath: true,
authKind: true,
username: true,
enabled: true,
options: true,
})
.partial({ flavor: true, rootPath: true, authKind: true, username: true, enabled: true, options: true })
.extend({
/** 令牌(authKind=token)或密码(authKind=password);只写 */
credential: WriteOnlySecret(4096).optional(),
/** 路径密码;只写 */
pathPassword: WriteOnlySecret(1024).optional(),
});
export type StorageInstanceCreate = z.infer<typeof StorageInstanceCreate>;
/** 更新:credential / pathPassword 省略或空串 = 不变,null = 清除 */
export const StorageInstanceUpdate = StorageInstanceCreate.partial().extend({
credential: WriteOnlySecret(4096).nullable().optional(),
pathPassword: WriteOnlySecret(1024).nullable().optional(),
});
export type StorageInstanceUpdate = z.infer<typeof StorageInstanceUpdate>;
export const StorageUsage = z.object({ total: z.number(), free: z.number(), used: z.number() });
export type StorageUsage = z.infer<typeof StorageUsage>;
export const StorageTestResult = z.object({
ok: z.boolean(),
healthStatus: InstanceHealth,
version: z.string().nullable(),
/** 登录用户(访客为 guest) */
user: z.string().nullable(),
/** 根路径存在且是目录 */
rootOk: z.boolean(),
/** 容量;驱动不给或令牌不是管理员时为 null(显示「未知」) */
usage: StorageUsage.nullable(),
message: z.string().nullable(),
});
export type StorageTestResult = z.infer<typeof StorageTestResult>;
@@ -0,0 +1,62 @@
import type { Kysely } from "kysely";
import { entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
// Alist / OpenList 存储实例(docs/design/storage-alist.md §3):远端路径用虚拟前缀 /@alist/<id>/<路径> 表示,
// 库根 local_path、整理记录、资产位置里都直接存这个路径,不需要新列。凭据走 secret 表(purpose storage_credential)。
const PURPOSE_OLD = [
"site_cookie",
"site_passkey",
"site_api_key",
"site_password",
"feed_url",
"downloader_password",
"media_server_api_key",
"metadata_api_key",
"llm_api_key",
"other",
];
const PURPOSE_NEW = [...PURPOSE_OLD.slice(0, -1), "storage_credential", "other"];
const HEALTH = ["unknown", "healthy", "degraded", "offline"];
const swapPurpose = (values: readonly string[]) => `
alter table secret drop constraint if exists secret_purpose_check;
alter table secret add constraint secret_purpose_check ${oneOf("purpose", values)};
`;
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(db, swapPurpose(PURPOSE_NEW));
await execDdl(
db,
entityTable(
"storage_instance",
`
name text not null unique,
kind text not null default 'alist' ${oneOf("kind", ["alist"])},
flavor text not null default 'openlist' ${oneOf("flavor", ["alist", "openlist"])},
base_url text not null,
root_path text not null default '/' check (root_path like '/%'),
auth_kind text not null default 'token' ${oneOf("auth_kind", ["token", "password", "guest"])},
username text,
secret_ref uuid references secret(id) on delete set null,
path_password_ref uuid references secret(id) on delete set null,
enabled boolean not null default true,
options jsonb not null default '{}'::jsonb,
health_status text not null default 'unknown' ${oneOf("health_status", HEALTH)},
last_health_at timestamptz,
health_detail jsonb not null default '{}'::jsonb,
version_info jsonb not null default '{}'::jsonb`,
),
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`drop table if exists storage_instance cascade;
delete from secret where purpose = 'storage_credential';
${swapPurpose(PURPOSE_OLD)}`,
);
}
+30
View File
@@ -246,6 +246,7 @@ export type SecretPurpose =
| "media_server_api_key"
| "metadata_api_key"
| "llm_api_key"
| "storage_credential"
| "other";
export type ServerMappingItemType = "movie" | "series" | "season" | "episode" | "other";
export type ServerMappingMatchStatus = "matched" | "ambiguous" | "unmatched" | "stale";
@@ -260,6 +261,10 @@ export type SiteSnapshotKind = "periodic" | "backfill" | "live_search" | "manual
export type SiteSnapshotStatus = "running" | "ok" | "partial" | "failed";
export type SourceReleaseResolutionStatus = "unresolved" | "resolved" | "ambiguous" | "unidentifiable" | "rejected";
export type SourceReleaseAvailability = "active" | "site_disabled" | "expired" | "unknown";
export type StorageInstanceKind = "alist";
export type StorageInstanceFlavor = "alist" | "openlist";
export type StorageInstanceAuthKind = "token" | "password" | "guest";
export type StorageInstanceHealthStatus = "unknown" | "healthy" | "degraded" | "offline";
export type SubscriptionMediaKind = "movie" | "series" | "variety";
export type SubscriptionMode = "once" | "continuous";
export type SubscriptionSeasonScope = "current_season" | "following_seasons";
@@ -1635,6 +1640,27 @@ export interface SseEventTable {
payload: Generated<Json>;
}
export interface StorageInstanceTable {
id: Generated<string>;
name: string;
kind: Generated<StorageInstanceKind>;
flavor: Generated<StorageInstanceFlavor>;
base_url: string;
root_path: Generated<string>;
auth_kind: Generated<StorageInstanceAuthKind>;
username: string | null;
secret_ref: string | null;
path_password_ref: string | null;
enabled: Generated<boolean>;
options: Generated<Json>;
health_status: Generated<StorageInstanceHealthStatus>;
last_health_at: Timestamptz | null;
health_detail: Generated<Json>;
version_info: Generated<Json>;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
}
export interface SubscriptionTable {
id: Generated<string>;
media_entity_id: string | null;
@@ -1869,6 +1895,7 @@ export interface DB {
slot_unit: SlotUnitTable;
source_release: SourceReleaseTable;
sse_event: SseEventTable;
storage_instance: StorageInstanceTable;
subscription: SubscriptionTable;
subscription_history: SubscriptionHistoryTable;
subtitle_write: SubtitleWriteTable;
@@ -2115,6 +2142,9 @@ export type SourceReleaseUpdate = Updateable<SourceReleaseTable>;
export type SseEventRow = Selectable<SseEventTable>;
export type NewSseEvent = Insertable<SseEventTable>;
export type SseEventUpdate = Updateable<SseEventTable>;
export type StorageInstanceRow = Selectable<StorageInstanceTable>;
export type NewStorageInstance = Insertable<StorageInstanceTable>;
export type StorageInstanceUpdate = Updateable<StorageInstanceTable>;
export type SubscriptionRow = Selectable<SubscriptionTable>;
export type NewSubscription = Insertable<SubscriptionTable>;
export type SubscriptionUpdate = Updateable<SubscriptionTable>;