feat(files): 文件管理接入 Alist 存储(浏览、增删改、移动复制、上传下载、回收站)
- 文件管理根多出 st:<存储 id>(对照 MP 存储切换),Alist 媒体库目录照常出现在媒体库组 - 列目录一次 list,refresh 只在用户刷新时带;连不上返回 503 不当成空目录 - 新建目录 / 改名(同名不覆盖)/ 删除进 Alist 上的 .umt-trash 并可还原 - 存储内移动复制走 Alist API,本地 ↔ Alist 走整理同一套执行与账本 - 新增 PUT /files/upload(同名不覆盖,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
c2bf8a071a
commit
9d95076a0e
24 files changed
+901
-72
No files matched your search
@@ -1,9 +1,9 @@
|
||||
import { lstat, mkdir } from "node:fs/promises";
|
||||
import { basename, dirname, extname, join } from "node:path";
|
||||
import { moveSafe } from "@umt/adapters";
|
||||
import type { FilesMkdirRequest, FilesMkdirResult, FilesRenameRequest, FilesRenameResult } from "@umt/contracts";
|
||||
import { sanitizeSegment } from "@umt/domain";
|
||||
import type { Principal } from "../../http/auth.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";
|
||||
import { downloadsFor, isWritableTarget, kindOfName, managedDownloads, sidecarsOf } from "./list.ts";
|
||||
@@ -31,7 +31,10 @@ export async function mkdirIn(
|
||||
await recheck(r.abs, r.rootReal);
|
||||
const abs = join(r.abs, body.name);
|
||||
try {
|
||||
await mkdir(abs);
|
||||
if (r.def.storageId) {
|
||||
if (await vexists(abs)) throw Object.assign(new Error("exists"), { code: "EEXIST" });
|
||||
await vmkdirp(abs);
|
||||
} else await mkdir(abs);
|
||||
} catch (e) {
|
||||
if (errnoOf(e) === "EEXIST") throw new FilesError("exists", `已存在:${body.name}`);
|
||||
throw new FilesError("bad_request", `创建目录失败:${errMessage(e)}`);
|
||||
@@ -88,7 +91,9 @@ export async function renameEntry(
|
||||
// 不覆盖:任一目标已存在 → 409,原文件不动
|
||||
const taken: string[] = [];
|
||||
for (const p of pairs) {
|
||||
const st = await lstat(p.toAbs).catch((e) => (errnoOf(e) === "ENOENT" ? null : e));
|
||||
const st = await (isRemotePath(p.toAbs) ? vlstat(p.toAbs) : lstat(p.toAbs)).catch((e) =>
|
||||
errnoOf(e) === "ENOENT" ? null : e,
|
||||
);
|
||||
if (st) taken.push(p.to);
|
||||
}
|
||||
if (taken.length > 0) throw new FilesError("exists", `目标已存在,不覆盖:${taken.join(", ")}`);
|
||||
@@ -116,17 +121,19 @@ export async function renameEntry(
|
||||
// 执行:逐个 no-clobber 改名;任一失败回滚已改的
|
||||
const done: Pair[] = [];
|
||||
const rollback = async () => {
|
||||
for (const p of done.reverse()) await moveSafe(p.toAbs, p.fromAbs).catch(() => undefined);
|
||||
for (const p of done.reverse()) await vrenameNoClobber(p.toAbs, p.fromAbs).catch(() => undefined);
|
||||
};
|
||||
for (const p of pairs) {
|
||||
try {
|
||||
await recheck(p.fromAbs, r.rootReal);
|
||||
const before = await lstat(p.fromAbs);
|
||||
const res = await moveSafe(p.fromAbs, p.toAbs);
|
||||
const remote = isRemotePath(p.fromAbs);
|
||||
const before = remote ? null : await lstat(p.fromAbs);
|
||||
const res = await vrenameNoClobber(p.fromAbs, p.toAbs);
|
||||
if (res.status === "exists") throw new FilesError("exists", `目标已存在,不覆盖:${p.to}`);
|
||||
if (res.status !== "moved") throw new FilesError("bad_request", `重命名失败(${res.status}): ${p.from}`);
|
||||
const after = await lstat(p.toAbs);
|
||||
if (after.ino !== before.ino || after.dev !== before.dev)
|
||||
// 远端没有 inode:Alist 改名接口成功即算成功(同 MP)
|
||||
const after = remote ? null : await lstat(p.toAbs);
|
||||
if (before && after && (after.ino !== before.ino || after.dev !== before.dev))
|
||||
throw new FilesError("bad_request", `重命名后 inode 不一致,已中止:${p.to}`);
|
||||
done.push(p);
|
||||
} catch (e) {
|
||||
|
||||
Reference in new issue
Block a user