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
@@ -16,6 +16,8 @@ import type {
|
||||
} from "@umt/contracts";
|
||||
import { sql, type TrashItemRow } from "@umt/db";
|
||||
import type { Principal } from "../../http/auth.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";
|
||||
import { fsRuntime } from "../acquisition-organize/services/organize.ts";
|
||||
import {
|
||||
@@ -47,6 +49,7 @@ const likePrefix = (rel: string) => `${rel.replace(/[\\%_]/g, (m) => `\\${m}`)}/
|
||||
const day = (t: Date) => t.toISOString().slice(0, 10).replace(/-/g, "");
|
||||
|
||||
async function sizeOf(abs: string, cap = 200_000): Promise<{ size: number; files: string[] }> {
|
||||
if (isRemotePath(abs)) return remoteSizeOf(abs, cap);
|
||||
const st = await lstat(abs);
|
||||
if (!st.isDirectory()) return { size: Number(st.size), files: [""] };
|
||||
let size = 0;
|
||||
@@ -68,6 +71,39 @@ async function sizeOf(abs: string, cap = 200_000): Promise<{ size: number; files
|
||||
return { size, files };
|
||||
}
|
||||
|
||||
/** 远端:按目录 list(每个子目录一次请求) */
|
||||
async function remoteSizeOf(abs: string, cap: number): Promise<{ size: number; files: string[] }> {
|
||||
const st = await vlstat(abs);
|
||||
if (!st.isDirectory()) return { size: Number(st.size), files: [""] };
|
||||
let size = 0;
|
||||
const files: string[] = [];
|
||||
const walk = async (dir: string, prefix: string) => {
|
||||
for (const it of await vreaddirStats(dir)) {
|
||||
if (files.length >= cap) return;
|
||||
const rel = prefix ? `${prefix}/${it.name}` : it.name;
|
||||
if (it.stats.isDirectory()) await walk(join(dir, it.name), rel);
|
||||
else {
|
||||
size += Number(it.stats.size);
|
||||
files.push(rel);
|
||||
}
|
||||
}
|
||||
};
|
||||
await walk(abs, "");
|
||||
return { size, files };
|
||||
}
|
||||
|
||||
/**
|
||||
* 回收站所在目录:本地 = <根>/.umt-trash;远端同布局,用 Alist 同驱动 move(立即完成)。
|
||||
* 远端根是 Alist 的 "/"(各挂载的上一级,不能建目录)时放进该项所在挂载的 .umt-trash。
|
||||
*/
|
||||
export function trashBase(rootReal: string, abs: string): string {
|
||||
const v = parseVPath(rootReal);
|
||||
if (v?.remote !== "/") return rootReal;
|
||||
const a = parseVPath(abs);
|
||||
const mount = a?.remote.split("/").filter(Boolean)[0];
|
||||
return mount ? toVPath(v.storageId, `/${mount}`) : rootReal;
|
||||
}
|
||||
|
||||
interface CheckCtx {
|
||||
abs: string | null;
|
||||
rootReal: string | null;
|
||||
@@ -234,7 +270,8 @@ export async function deleteFiles(
|
||||
await recheck(abs, ctx.rootReal!);
|
||||
let where: string | null = null;
|
||||
if (body.mode === "permanent") {
|
||||
await rm(abs, { recursive: true, force: false });
|
||||
if (isRemotePath(abs)) await vremove(abs);
|
||||
else await rm(abs, { recursive: true, force: false });
|
||||
deleted.push(check.path);
|
||||
} else {
|
||||
const row = await moveToTrash(d, principal, check, ctx, expiresAt);
|
||||
@@ -379,6 +416,16 @@ async function moveToTrash(
|
||||
const abs = ctx.abs!;
|
||||
const id = randomUUID();
|
||||
const stamp = day(d.ao.ports.now());
|
||||
if (isRemotePath(abs)) {
|
||||
// Alist:同存储 move 进 <根>/.umt-trash(MP 无回收站;Alist 驱动自己的回收站不依赖)
|
||||
const base = trashBase(ctx.rootReal!, abs);
|
||||
const rel = base === ctx.rootReal ? check.path : relOfRoot(base, abs);
|
||||
const trashPath = join(base, TRASH_DIR, stamp, id, rel);
|
||||
await vmkdirp(dirname(trashPath));
|
||||
const r = await vrenameNoClobber(abs, trashPath);
|
||||
if (r.status !== "moved") throw new FilesError("exists", `移入回收站失败(${r.status}):${trashPath}`);
|
||||
return insertTrashRow(d, principal, check, ctx, { id, rootHost: base, trashPath, expiresAt });
|
||||
}
|
||||
let trashPath = join(ctx.rootReal!, TRASH_DIR, stamp, id, check.path);
|
||||
let moved = false;
|
||||
try {
|
||||
@@ -404,13 +451,24 @@ async function moveToTrash(
|
||||
await copyTree(d, abs, trashPath, files);
|
||||
await rm(abs, { recursive: true });
|
||||
}
|
||||
return insertTrashRow(d, principal, check, ctx, { id, rootHost: ctx.rootReal!, trashPath, expiresAt });
|
||||
}
|
||||
|
||||
function insertTrashRow(
|
||||
d: FmDeps,
|
||||
principal: Principal | undefined,
|
||||
check: FileDeleteCheck,
|
||||
ctx: CheckCtx,
|
||||
x: { id: string; rootHost: string; trashPath: string; expiresAt: Date },
|
||||
): Promise<TrashItemRow> {
|
||||
const { id, trashPath, expiresAt } = x;
|
||||
return d.ctx.db
|
||||
.insertInto("trash_item")
|
||||
.values({
|
||||
id,
|
||||
root_id: check.root,
|
||||
library_root_id: ctx.libraryRootId,
|
||||
root_host_path: ctx.rootReal!,
|
||||
root_host_path: x.rootHost,
|
||||
original_path: check.path,
|
||||
trash_path: trashPath,
|
||||
is_dir: check.isDir,
|
||||
@@ -477,6 +535,11 @@ function trashPathSafe(d: FmDeps, row: TrashItemRow): boolean {
|
||||
*/
|
||||
export async function mkdirWithin(rootReal: string, rel: string): Promise<void> {
|
||||
if (rel === "" || rel === ".") return;
|
||||
if (isRemotePath(rootReal)) {
|
||||
if (rel.split("/").includes("..")) throw new FilesError("path_escape", `路径含 ..: ${rel}`);
|
||||
await vmkdirp(join(rootReal, rel));
|
||||
return;
|
||||
}
|
||||
let cur = rootReal;
|
||||
for (const seg of rel.split("/")) {
|
||||
if (seg === "" || seg === ".") continue;
|
||||
@@ -494,6 +557,13 @@ export async function mkdirWithin(rootReal: string, rel: string): Promise<void>
|
||||
}
|
||||
}
|
||||
const relOfRoot = (root: string, p: string) => (p === root ? "" : p.slice(root.length + 1));
|
||||
const sameStorageOf = (a: string, b: string) => parseVPath(a)?.storageId === parseVPath(b)?.storageId;
|
||||
/** <…>/.umt-trash/<yyyymmdd>/<uuid>:回收站路径里 uuid 那一级 */
|
||||
function trashUuidDir(row: TrashItemRow): string | null {
|
||||
const segs = row.trash_path.split("/");
|
||||
const i = segs.indexOf(row.id);
|
||||
return i > 0 ? segs.slice(0, i + 1).join("/") : null;
|
||||
}
|
||||
|
||||
export async function restoreTrash(
|
||||
d: FmDeps,
|
||||
@@ -537,8 +607,14 @@ export async function restoreTrash(
|
||||
);
|
||||
dst = parent.abs;
|
||||
}
|
||||
if (await lstat(dst).catch(() => null)) throw new FilesError("exists", "原位置已被占用,可选择「还原到…」");
|
||||
const r = await moveSafe(row.trash_path, dst);
|
||||
if (await (isRemotePath(dst) ? vlstat(dst) : lstat(dst)).catch(() => null))
|
||||
throw new FilesError("exists", "原位置已被占用,可选择「还原到…」");
|
||||
if (
|
||||
isRemotePath(dst) !== isRemotePath(row.trash_path) ||
|
||||
(isRemotePath(dst) && !sameStorageOf(dst, row.trash_path))
|
||||
)
|
||||
throw new FilesError("bad_request", "回收站里的项只能还原到它原来所在的存储");
|
||||
const r = await vrenameNoClobber(row.trash_path, dst);
|
||||
if (r.status === "exists") throw new FilesError("exists", "原位置已被占用");
|
||||
if (r.status === "exdev") {
|
||||
// 跨卷还原:与跨卷进回收站同一上限(FILES_TRASH_CROSS_MAX_BYTES),复制经 fsproxy,核验后删回收站副本
|
||||
@@ -601,10 +677,17 @@ export async function purgeTrash(
|
||||
for (const row of rows) {
|
||||
try {
|
||||
if (!trashPathSafe(d, row)) throw new FilesError("path_escape", "回收站路径异常,拒绝清除");
|
||||
await rm(row.trash_path, { recursive: true, force: true });
|
||||
// 顺带清掉空的 <uuid>/<yyyymmdd> 目录
|
||||
const uuidDir = row.trash_path.slice(0, row.trash_path.length - row.original_path.length - 1);
|
||||
await rm(uuidDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
const uuidDir = trashUuidDir(row);
|
||||
if (isRemotePath(row.trash_path)) {
|
||||
await vremove(row.trash_path).catch((e) => {
|
||||
if (errnoOf(e) !== "ENOENT") throw e;
|
||||
});
|
||||
if (uuidDir) await vremove(uuidDir).catch(() => undefined);
|
||||
} else {
|
||||
await rm(row.trash_path, { recursive: true, force: true });
|
||||
if (uuidDir) await rm(uuidDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
const gone = await d.ctx.db.deleteFrom("trash_item").where("id", "=", row.id).returning("id").executeTakeFirst();
|
||||
if (gone) purged.push(row.id);
|
||||
} catch (e) {
|
||||
|
||||
Reference in new issue
Block a user