232 lines
8.3 KiB
TypeScript
232 lines
8.3 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { FastifyInstance } from "fastify";
|
|
import { z } from "zod";
|
|
import { db } from "./db.js";
|
|
import { requireAuth } from "./auth.js";
|
|
import { folderPermissions, getFolder, requireFolderWrite } from "./permissions.js";
|
|
import { storage } from "./storage.js";
|
|
import { FolderRow } from "./types.js";
|
|
import { assertValidFolderName, ensureDir, removeIfExists, safeStoragePath, toFolderStorageRelative } from "./utils.js";
|
|
|
|
const createSchema = z.object({
|
|
parentId: z.number().int().nullable().optional(),
|
|
name: z.string()
|
|
});
|
|
|
|
const updateSchema = z.object({
|
|
name: z.string()
|
|
});
|
|
|
|
function folderPayload(request: { userInfo: { id: number; username: string; role: "admin" | "user" } }, row: FolderRow) {
|
|
return {
|
|
...row,
|
|
permissions: folderPermissions(request.userInfo, row)
|
|
};
|
|
}
|
|
|
|
function buildFolderNodes(rows: FolderRow[], userInfo: { id: number; username: string; role: "admin" | "user" }) {
|
|
return rows.map((row) => ({
|
|
id: String(row.id),
|
|
text: `${displayFolderName(row)}(${row.model_count ?? 0})`,
|
|
parentId: row.parent_id,
|
|
path: row.path,
|
|
data: {
|
|
permissions: folderPermissions(userInfo, row),
|
|
libraryType: row.library_type,
|
|
ownerUserId: row.owner_user_id,
|
|
isSystem: Boolean(row.is_system)
|
|
},
|
|
state: {
|
|
opened: row.library_type === "system" && row.parent_id === null
|
|
},
|
|
children: [] as unknown[]
|
|
}));
|
|
}
|
|
|
|
function buildTree(rows: FolderRow[], userInfo: { id: number; username: string; role: "admin" | "user" }) {
|
|
const nodes = buildFolderNodes(rows, userInfo);
|
|
const userGroupNode = {
|
|
id: "virtual:user-libraries",
|
|
text: "用户模型库",
|
|
parentId: null as number | null,
|
|
path: "users",
|
|
data: {
|
|
permissions: { read: true, write: false },
|
|
virtual: true
|
|
},
|
|
state: {
|
|
opened: true
|
|
},
|
|
children: [] as unknown[]
|
|
};
|
|
const byId = new Map(nodes.map((node) => [Number(node.id), node]));
|
|
const roots = [];
|
|
for (const node of nodes) {
|
|
if (node.parentId && byId.has(node.parentId)) {
|
|
byId.get(node.parentId)!.children.push(node);
|
|
} else if (node.data.libraryType === "user") {
|
|
userGroupNode.children.push(node);
|
|
} else {
|
|
roots.push(node);
|
|
}
|
|
}
|
|
if (userGroupNode.children.length > 0) {
|
|
roots.push(userGroupNode);
|
|
}
|
|
return roots;
|
|
}
|
|
|
|
function childPath(parent: FolderRow | undefined, name: string) {
|
|
return parent ? path.posix.join(parent.path, name) : name;
|
|
}
|
|
|
|
function assertCanRenameRoot(folder: FolderRow) {
|
|
if (folder.is_system) {
|
|
throw new Error("系统保留目录不能重命名");
|
|
}
|
|
}
|
|
|
|
function displayFolderName(row: FolderRow) {
|
|
if (row.library_type === "user" && row.is_system && row.owner_username) {
|
|
return row.owner_username;
|
|
}
|
|
return row.name;
|
|
}
|
|
|
|
function ensureUserLibrary(user: { id: number; username: string }) {
|
|
const pathValue = `users/${user.id}`;
|
|
const existing = db.prepare("SELECT id FROM folders WHERE path = ?").get(pathValue) as { id: number } | undefined;
|
|
if (existing) return existing.id;
|
|
const result = db.prepare(`
|
|
INSERT INTO folders (parent_id, name, path, library_type, owner_user_id, is_system)
|
|
VALUES (NULL, ?, ?, 'user', ?, 1)
|
|
`).run("用户模型库", pathValue, user.id);
|
|
return Number(result.lastInsertRowid);
|
|
}
|
|
|
|
export async function folderRoutes(app: FastifyInstance) {
|
|
app.get("/api/folders", { preHandler: [requireAuth] }, async (request) => {
|
|
ensureUserLibrary(request.userInfo);
|
|
const rows = db.prepare(`
|
|
SELECT
|
|
f.*,
|
|
u.username AS owner_username,
|
|
COUNT(m.id) AS model_count
|
|
FROM folders f
|
|
LEFT JOIN users u ON u.id = f.owner_user_id
|
|
LEFT JOIN models m ON m.folder_id = f.id
|
|
GROUP BY f.id
|
|
ORDER BY
|
|
CASE f.library_type WHEN 'system' THEN 0 ELSE 1 END,
|
|
f.owner_user_id,
|
|
f.parent_id,
|
|
f.name
|
|
`).all() as unknown as FolderRow[];
|
|
return { folders: rows.map((row) => folderPayload(request, row)), tree: buildTree(rows, request.userInfo) };
|
|
});
|
|
|
|
app.post("/api/folders", { preHandler: [requireAuth] }, async (request, reply) => {
|
|
const body = createSchema.parse(request.body);
|
|
const name = assertValidFolderName(body.name);
|
|
const parent = body.parentId ? getFolder(body.parentId) : undefined;
|
|
if (body.parentId && !parent) {
|
|
return reply.code(404).send({ message: "父目录不存在" });
|
|
}
|
|
if (!parent) {
|
|
return reply.code(400).send({ message: "请选择父目录" });
|
|
}
|
|
if (!requireFolderWrite(request, reply, parent)) return;
|
|
const folderPath = childPath(parent, name);
|
|
try {
|
|
const result = db.prepare(`
|
|
INSERT INTO folders (parent_id, name, path, library_type, owner_user_id, is_system)
|
|
VALUES (?, ?, ?, ?, ?, 0)
|
|
`).run(parent.id, name, folderPath, parent.library_type, parent.owner_user_id);
|
|
await ensureDir(safeStoragePath(toFolderStorageRelative(folderPath)));
|
|
return reply.code(201).send({ id: result.lastInsertRowid, name, path: folderPath });
|
|
} catch {
|
|
return reply.code(409).send({ message: "同级目录已存在" });
|
|
}
|
|
});
|
|
|
|
app.put("/api/folders/:id", { preHandler: [requireAuth] }, async (request, reply) => {
|
|
const params = z.object({ id: z.coerce.number().int() }).parse(request.params);
|
|
const body = updateSchema.parse(request.body);
|
|
const name = assertValidFolderName(body.name);
|
|
const folder = getFolder(params.id);
|
|
if (!folder) {
|
|
return reply.code(404).send({ message: "目录不存在" });
|
|
}
|
|
if (!requireFolderWrite(request, reply, folder)) return;
|
|
assertCanRenameRoot(folder);
|
|
const parent = folder.parent_id ? getFolder(folder.parent_id) : undefined;
|
|
const oldPath = folder.path;
|
|
const newPath = childPath(parent, name);
|
|
if (storage.provider !== "local") {
|
|
const modelCount = db.prepare("SELECT COUNT(*) AS count FROM models WHERE file_path LIKE ?")
|
|
.get(`models/${oldPath}/%`) as { count: number };
|
|
if (modelCount.count > 0) {
|
|
return reply.code(400).send({ message: "当前存储模式下,包含模型的目录不能重命名" });
|
|
}
|
|
}
|
|
|
|
const tx = () => {
|
|
db.exec("BEGIN");
|
|
try {
|
|
db.prepare("UPDATE folders SET name = ?, path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
|
.run(name, newPath, folder.id);
|
|
|
|
const descendants = db.prepare("SELECT * FROM folders WHERE path LIKE ? ORDER BY LENGTH(path)")
|
|
.all(`${oldPath}/%`) as unknown as FolderRow[];
|
|
for (const item of descendants) {
|
|
const replacedPath = item.path.replace(oldPath + "/", newPath + "/");
|
|
db.prepare("UPDATE folders SET path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
|
.run(replacedPath, item.id);
|
|
}
|
|
|
|
if (storage.provider === "local") {
|
|
const models = db.prepare("SELECT id, file_path FROM models WHERE file_path LIKE ?")
|
|
.all(`models/${oldPath}/%`) as { id: number; file_path: string }[];
|
|
for (const model of models) {
|
|
db.prepare("UPDATE models SET file_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
|
.run(model.file_path.replace(`models/${oldPath}/`, `models/${newPath}/`), model.id);
|
|
}
|
|
}
|
|
db.exec("COMMIT");
|
|
} catch (error) {
|
|
db.exec("ROLLBACK");
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
try {
|
|
tx();
|
|
if (storage.provider === "local") {
|
|
await fs.rename(
|
|
safeStoragePath(toFolderStorageRelative(oldPath)),
|
|
safeStoragePath(toFolderStorageRelative(newPath))
|
|
);
|
|
}
|
|
return { ok: true };
|
|
} catch {
|
|
return reply.code(409).send({ message: "目录重命名失败,可能存在重名目录" });
|
|
}
|
|
});
|
|
|
|
app.delete("/api/folders/:id", { preHandler: [requireAuth] }, async (request, reply) => {
|
|
const params = z.object({ id: z.coerce.number().int() }).parse(request.params);
|
|
const folder = getFolder(params.id);
|
|
if (!folder) {
|
|
return reply.code(404).send({ message: "目录不存在" });
|
|
}
|
|
if (!requireFolderWrite(request, reply, folder)) return;
|
|
if (folder.is_system) {
|
|
return reply.code(400).send({ message: "系统保留目录不能删除" });
|
|
}
|
|
db.prepare("DELETE FROM folders WHERE id = ?").run(folder.id);
|
|
await removeIfExists(safeStoragePath(toFolderStorageRelative(folder.path)));
|
|
return { ok: true };
|
|
});
|
|
}
|