完善模型库权限与工艺数据解析
This commit is contained in:
@@ -13,26 +13,32 @@ function fixedLoginRole(): UserRole {
|
||||
return process.env.FIXED_LOGIN_ROLE === "admin" ? "admin" : "user";
|
||||
}
|
||||
|
||||
function envValue(name: string) {
|
||||
const value = process.env[name]?.trim();
|
||||
return value || undefined;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
host: process.env.HOST ?? "0.0.0.0",
|
||||
port: Number(process.env.PORT ?? 3001),
|
||||
jwtSecret: process.env.JWT_SECRET ?? "dev-only-change-me",
|
||||
host: envValue("HOST") ?? "0.0.0.0",
|
||||
port: Number(envValue("PORT") ?? 3001),
|
||||
jwtSecret: envValue("JWT_SECRET") ?? "dev-only-change-me",
|
||||
authMode: process.env.APP_AUTH_MODE === "fixed" ? "fixed" : "login",
|
||||
fixedLogin: {
|
||||
username: process.env.FIXED_LOGIN_USERNAME ?? "viewer",
|
||||
username: envValue("FIXED_LOGIN_USERNAME") ?? "viewer",
|
||||
role: fixedLoginRole()
|
||||
},
|
||||
allowUserEditSystemLibrary: envValue("ALLOW_USER_EDIT_SYSTEM_LIBRARY") === "true",
|
||||
dataDir: path.resolve(serverRoot, "data"),
|
||||
storageDir: path.resolve(serverRoot, "storage"),
|
||||
databasePath: process.env.DB_PATH ?? path.resolve(serverRoot, "data", "model-library.db"),
|
||||
storageDriver: (process.env.STORAGE_DRIVER ?? "local").toLowerCase(),
|
||||
publicBaseUrl: process.env.PUBLIC_BASE_URL ?? "",
|
||||
cdnBaseUrl: process.env.CDN_BASE_URL ?? "",
|
||||
databasePath: envValue("DB_PATH") ?? path.resolve(serverRoot, "data", "model-library.db"),
|
||||
storageDriver: (envValue("STORAGE_DRIVER") ?? "local").toLowerCase(),
|
||||
publicBaseUrl: envValue("PUBLIC_BASE_URL") ?? "",
|
||||
cdnBaseUrl: envValue("CDN_BASE_URL") ?? "",
|
||||
cos: {
|
||||
secretId: process.env.COS_SECRET_ID ?? "",
|
||||
secretKey: process.env.COS_SECRET_KEY ?? "",
|
||||
bucket: process.env.COS_BUCKET ?? "",
|
||||
region: process.env.COS_REGION ?? "",
|
||||
prefix: (process.env.COS_PREFIX ?? "").replace(/^\/+|\/+$/g, "")
|
||||
secretId: envValue("COS_SECRET_ID") ?? "",
|
||||
secretKey: envValue("COS_SECRET_KEY") ?? "",
|
||||
bucket: envValue("COS_BUCKET") ?? "",
|
||||
region: envValue("COS_REGION") ?? "",
|
||||
prefix: (envValue("COS_PREFIX") ?? "").replace(/^\/+|\/+$/g, "")
|
||||
}
|
||||
};
|
||||
|
||||
@@ -10,11 +10,6 @@ export const db = new DatabaseSync(config.databasePath);
|
||||
db.exec("PRAGMA journal_mode = WAL");
|
||||
db.exec("PRAGMA foreign_keys = ON");
|
||||
|
||||
function hasColumn(table: string, column: string) {
|
||||
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as unknown as { name: string }[];
|
||||
return rows.some((row) => row.name === column);
|
||||
}
|
||||
|
||||
export function migrate() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
@@ -33,6 +28,9 @@ export function migrate() {
|
||||
parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
library_type TEXT NOT NULL DEFAULT 'system',
|
||||
owner_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(parent_id, name)
|
||||
@@ -60,11 +58,13 @@ export function migrate() {
|
||||
name TEXT NOT NULL,
|
||||
original_filename TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
library_type TEXT NOT NULL DEFAULT 'system',
|
||||
owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
storage_provider TEXT NOT NULL DEFAULT 'local',
|
||||
file_size INTEGER NOT NULL,
|
||||
thumbnail TEXT,
|
||||
thumbnail_path TEXT,
|
||||
thumbnail_provider TEXT,
|
||||
operation_tree TEXT NOT NULL DEFAULT '{"OperationTree":"[]"}',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(folder_id, name)
|
||||
@@ -83,32 +83,13 @@ export function migrate() {
|
||||
CREATE INDEX IF NOT EXISTS idx_model_properties_model_id ON model_properties(model_id);
|
||||
`);
|
||||
|
||||
if (!hasColumn("models", "brand_id")) {
|
||||
db.exec("ALTER TABLE models ADD COLUMN brand_id INTEGER REFERENCES brands(id) ON DELETE SET NULL");
|
||||
}
|
||||
if (!hasColumn("models", "type_id")) {
|
||||
db.exec("ALTER TABLE models ADD COLUMN type_id INTEGER REFERENCES model_types(id) ON DELETE SET NULL");
|
||||
}
|
||||
if (!hasColumn("models", "storage_provider")) {
|
||||
db.exec("ALTER TABLE models ADD COLUMN storage_provider TEXT NOT NULL DEFAULT 'local'");
|
||||
}
|
||||
if (!hasColumn("models", "thumbnail_path")) {
|
||||
db.exec("ALTER TABLE models ADD COLUMN thumbnail_path TEXT");
|
||||
}
|
||||
if (!hasColumn("models", "thumbnail_provider")) {
|
||||
db.exec("ALTER TABLE models ADD COLUMN thumbnail_provider TEXT");
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_models_brand_id ON models(brand_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_models_type_id ON models(type_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_folders_library_owner ON folders(library_type, owner_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_models_library_owner ON models(library_type, owner_user_id);
|
||||
`);
|
||||
|
||||
const rootFolder = db.prepare("SELECT id FROM folders WHERE parent_id IS NULL AND name = ?").get("模型库");
|
||||
if (!rootFolder) {
|
||||
db.prepare("INSERT INTO folders (parent_id, name, path) VALUES (NULL, ?, ?)").run("模型库", "模型库");
|
||||
}
|
||||
|
||||
const admin = db.prepare("SELECT id FROM users WHERE username = ?").get("admin");
|
||||
if (!admin) {
|
||||
db.prepare(`
|
||||
@@ -116,4 +97,12 @@ export function migrate() {
|
||||
VALUES (?, ?, 'admin', 1)
|
||||
`).run("admin", bcrypt.hashSync("admin123", 10));
|
||||
}
|
||||
|
||||
const systemRoot = db.prepare("SELECT id FROM folders WHERE path = ?").get("system") as { id: number } | undefined;
|
||||
if (!systemRoot) {
|
||||
db.prepare(`
|
||||
INSERT INTO folders (parent_id, name, path, library_type, owner_user_id, is_system)
|
||||
VALUES (NULL, ?, ?, 'system', NULL, 1)
|
||||
`).run("系统模型库", "system");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import path from "node:path";
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "./db.js";
|
||||
import { requireAdmin, requireAuth } from "./auth.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";
|
||||
@@ -17,65 +18,125 @@ const updateSchema = z.object({
|
||||
name: z.string()
|
||||
});
|
||||
|
||||
function buildTree(rows: FolderRow[]) {
|
||||
const nodes = rows.map((row) => ({
|
||||
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: `${row.name}(${row.model_count ?? 0})`,
|
||||
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)
|
||||
},
|
||||
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
|
||||
},
|
||||
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 getFolder(id: number) {
|
||||
return db.prepare("SELECT * FROM folders WHERE id = ?").get(id) as FolderRow | undefined;
|
||||
}
|
||||
|
||||
function childPath(parent: FolderRow | undefined, name: string) {
|
||||
return parent ? path.posix.join(parent.path, name) : name;
|
||||
}
|
||||
|
||||
function assertCanRenameRoot(folder: FolderRow) {
|
||||
if (folder.parent_id === null) {
|
||||
throw new Error("根目录不能重命名");
|
||||
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 () => {
|
||||
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 f.parent_id, f.name
|
||||
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, tree: buildTree(rows) };
|
||||
return { folders: rows.map((row) => folderPayload(request, row)), tree: buildTree(rows, request.userInfo) };
|
||||
});
|
||||
|
||||
app.post("/api/folders", { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
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) VALUES (?, ?, ?)")
|
||||
.run(body.parentId ?? null, name, folderPath);
|
||||
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 {
|
||||
@@ -83,7 +144,7 @@ export async function folderRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/api/folders/:id", { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
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);
|
||||
@@ -91,6 +152,7 @@ export async function folderRoutes(app: FastifyInstance) {
|
||||
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;
|
||||
@@ -146,14 +208,15 @@ export async function folderRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/folders/:id", { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
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 (folder.parent_id === null) {
|
||||
return reply.code(400).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)));
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { db } from "./db.js";
|
||||
import { requireAdmin, requireAuth } from "./auth.js";
|
||||
import { getFolder, modelPermissions, requireFolderWrite, requireModelWrite } from "./permissions.js";
|
||||
import { deleteStoredObject, publicObjectUrl, storage } from "./storage.js";
|
||||
import { DictionaryRow, FolderRow, ModelRow } from "./types.js";
|
||||
import {
|
||||
@@ -16,7 +17,7 @@ const updateSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
brandName: z.string().optional(),
|
||||
typeName: z.string().optional(),
|
||||
thumbnail: z.string().nullable().optional(),
|
||||
operationTree: z.string().optional(),
|
||||
properties: z.record(z.string()).optional()
|
||||
});
|
||||
|
||||
@@ -32,9 +33,7 @@ const dictionaryBodySchema = z.object({
|
||||
name: z.string().min(1).max(80)
|
||||
});
|
||||
|
||||
function getFolder(id: number) {
|
||||
return db.prepare("SELECT * FROM folders WHERE id = ?").get(id) as FolderRow | undefined;
|
||||
}
|
||||
const defaultOperationTree = JSON.stringify({ OperationTree: "[]" });
|
||||
|
||||
function getProperties(modelId: number) {
|
||||
const rows = db.prepare("SELECT property_key, property_value FROM model_properties WHERE model_id = ?")
|
||||
@@ -56,13 +55,14 @@ function saveProperties(modelId: number, properties: Record<string, string>) {
|
||||
}
|
||||
}
|
||||
|
||||
function modelPayload(row: ModelRow) {
|
||||
function modelPayload(row: ModelRow, userInfo?: { id: number; username: string; role: "admin" | "user" }) {
|
||||
return {
|
||||
...row,
|
||||
file_url: publicObjectUrl(row.file_path, row.storage_provider),
|
||||
thumbnail_url: row.thumbnail_path && row.thumbnail_provider
|
||||
? publicObjectUrl(row.thumbnail_path, row.thumbnail_provider)
|
||||
: row.thumbnail,
|
||||
: null,
|
||||
permissions: userInfo ? modelPermissions(userInfo, row) : { read: true, write: false },
|
||||
properties: getProperties(row.id)
|
||||
};
|
||||
}
|
||||
@@ -78,6 +78,26 @@ function dataImageToBuffer(dataUrl: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOperationTree(value: string | undefined) {
|
||||
const clean = value?.trim();
|
||||
if (!clean) return defaultOperationTree;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(clean);
|
||||
} catch {
|
||||
throw new Error("工艺数据必须是合法 JSON 字符串");
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || typeof (parsed as { OperationTree?: unknown }).OperationTree !== "string") {
|
||||
throw new Error("工艺数据格式必须符合 OperationTreeDB");
|
||||
}
|
||||
try {
|
||||
JSON.parse((parsed as { OperationTree: string }).OperationTree);
|
||||
} catch {
|
||||
throw new Error("OperationTree 字段必须是合法 JSON 字符串");
|
||||
}
|
||||
return JSON.stringify(parsed);
|
||||
}
|
||||
|
||||
function normalizeDictionaryName(name: string | undefined) {
|
||||
const clean = name?.trim();
|
||||
return clean || null;
|
||||
@@ -205,19 +225,19 @@ export async function modelRoutes(app: FastifyInstance) {
|
||||
total: total.count,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
items: rows.map(modelPayload)
|
||||
items: rows.map((row) => modelPayload(row, request.userInfo))
|
||||
};
|
||||
});
|
||||
|
||||
app.post("/api/models/upload", { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
app.post("/api/models/upload", { preHandler: [requireAuth] }, async (request, reply) => {
|
||||
const parts = request.parts();
|
||||
let folderId: number | undefined;
|
||||
let thumbnail: string | null = null;
|
||||
const properties: Record<string, string> = {};
|
||||
let uploaded: { filename: string; buffer: Buffer } | undefined;
|
||||
let modelName: string | undefined;
|
||||
let brandName: string | undefined;
|
||||
let typeName: string | undefined;
|
||||
let operationTree: string | undefined;
|
||||
|
||||
for await (const part of parts) {
|
||||
if (part.type === "file") {
|
||||
@@ -234,7 +254,7 @@ export async function modelRoutes(app: FastifyInstance) {
|
||||
if (part.fieldname === "name") modelName = String(part.value ?? "");
|
||||
if (part.fieldname === "brandName") brandName = String(part.value ?? "");
|
||||
if (part.fieldname === "typeName") typeName = String(part.value ?? "");
|
||||
if (part.fieldname === "thumbnail") thumbnail = String(part.value || "") || null;
|
||||
if (part.fieldname === "operationTree") operationTree = String(part.value ?? "");
|
||||
if (part.fieldname.startsWith("prop.")) {
|
||||
properties[part.fieldname.slice(5)] = String(part.value ?? "");
|
||||
}
|
||||
@@ -248,6 +268,7 @@ export async function modelRoutes(app: FastifyInstance) {
|
||||
if (!folder) {
|
||||
return reply.code(404).send({ message: "目录不存在" });
|
||||
}
|
||||
if (!requireFolderWrite(request, reply, folder)) return;
|
||||
|
||||
const originalModelName = assertValidFolderName(modelName || normalizeModelName(uploaded.filename));
|
||||
const storageFilename = `${normalizeModelName(uploaded.filename)}-${nanoid(8)}.glb`;
|
||||
@@ -262,10 +283,11 @@ export async function modelRoutes(app: FastifyInstance) {
|
||||
try {
|
||||
const brandId = findOrCreateDictionary("brands", brandName);
|
||||
const typeId = findOrCreateDictionary("model_types", typeName);
|
||||
const normalizedOperationTree = normalizeOperationTree(operationTree);
|
||||
const result = db.prepare(`
|
||||
INSERT INTO models (folder_id, brand_id, type_id, name, original_filename, file_path, storage_provider, file_size, thumbnail)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(folderId, brandId, typeId, originalModelName, uploaded.filename, stored.key, stored.provider, uploaded.buffer.length, thumbnail);
|
||||
INSERT INTO models (folder_id, brand_id, type_id, name, original_filename, file_path, library_type, owner_user_id, storage_provider, file_size, operation_tree)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(folderId, brandId, typeId, originalModelName, uploaded.filename, stored.key, folder.library_type, folder.owner_user_id, stored.provider, uploaded.buffer.length, normalizedOperationTree);
|
||||
saveProperties(Number(result.lastInsertRowid), properties);
|
||||
return reply.code(201).send({ id: result.lastInsertRowid });
|
||||
} catch (error) {
|
||||
@@ -274,22 +296,24 @@ export async function modelRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/api/models/:id", { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
app.put("/api/models/: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 model = db.prepare("SELECT * FROM models WHERE id = ?").get(params.id) as ModelRow | undefined;
|
||||
if (!model) {
|
||||
return reply.code(404).send({ message: "模型不存在" });
|
||||
}
|
||||
if (!requireModelWrite(request, reply, model)) return;
|
||||
const name = assertValidFolderName(body.name);
|
||||
try {
|
||||
const brandId = findOrCreateDictionary("brands", body.brandName);
|
||||
const typeId = findOrCreateDictionary("model_types", body.typeName);
|
||||
const operationTree = body.operationTree === undefined ? model.operation_tree : normalizeOperationTree(body.operationTree);
|
||||
db.prepare(`
|
||||
UPDATE models
|
||||
SET name = ?, brand_id = ?, type_id = ?, thumbnail = ?, updated_at = CURRENT_TIMESTAMP
|
||||
SET name = ?, brand_id = ?, type_id = ?, operation_tree = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(name, brandId, typeId, body.thumbnail ?? model.thumbnail, model.id);
|
||||
`).run(name, brandId, typeId, operationTree, model.id);
|
||||
if (body.properties) {
|
||||
saveProperties(model.id, body.properties);
|
||||
}
|
||||
@@ -299,19 +323,20 @@ export async function modelRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/api/models/:id/thumbnail", { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
app.put("/api/models/:id/thumbnail", { preHandler: [requireAuth] }, async (request, reply) => {
|
||||
const params = z.object({ id: z.coerce.number().int() }).parse(request.params);
|
||||
const body = thumbnailSchema.parse(request.body);
|
||||
const model = db.prepare("SELECT * FROM models WHERE id = ?").get(params.id) as ModelRow | undefined;
|
||||
if (!model) {
|
||||
return reply.code(404).send({ message: "模型不存在" });
|
||||
}
|
||||
if (!requireModelWrite(request, reply, model)) return;
|
||||
const image = dataImageToBuffer(body.thumbnail);
|
||||
const stored = await storage.putObject(`thumbnails/model-${model.id}-${nanoid(8)}.${image.ext}`, image.buffer);
|
||||
try {
|
||||
db.prepare(`
|
||||
UPDATE models
|
||||
SET thumbnail = NULL, thumbnail_path = ?, thumbnail_provider = ?, updated_at = CURRENT_TIMESTAMP
|
||||
SET thumbnail_path = ?, thumbnail_provider = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(stored.key, stored.provider, model.id);
|
||||
if (model.thumbnail_path && model.thumbnail_provider) {
|
||||
@@ -324,12 +349,13 @@ export async function modelRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/models/:id", { preHandler: [requireAdmin] }, async (request, reply) => {
|
||||
app.delete("/api/models/:id", { preHandler: [requireAuth] }, async (request, reply) => {
|
||||
const params = z.object({ id: z.coerce.number().int() }).parse(request.params);
|
||||
const model = db.prepare("SELECT * FROM models WHERE id = ?").get(params.id) as ModelRow | undefined;
|
||||
if (!model) {
|
||||
return reply.code(404).send({ message: "模型不存在" });
|
||||
}
|
||||
if (!requireModelWrite(request, reply, model)) return;
|
||||
db.prepare("DELETE FROM models WHERE id = ?").run(model.id);
|
||||
await deleteStoredObject(model.file_path, model.storage_provider);
|
||||
if (model.thumbnail_path && model.thumbnail_provider) {
|
||||
|
||||
55
server/src/permissions.ts
Normal file
55
server/src/permissions.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { config } from "./config.js";
|
||||
import { db } from "./db.js";
|
||||
import { FolderRow, ModelRow } from "./types.js";
|
||||
|
||||
export type PermissionPayload = {
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
};
|
||||
|
||||
export function canWriteLibrary(user: FastifyRequest["userInfo"], libraryType: "system" | "user", ownerUserId: number | null) {
|
||||
if (user.role === "admin") return true;
|
||||
if (libraryType === "user" && ownerUserId === user.id) return true;
|
||||
if (libraryType === "system" && config.allowUserEditSystemLibrary) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function folderPermissions(user: FastifyRequest["userInfo"], folder: Pick<FolderRow, "library_type" | "owner_user_id">): PermissionPayload {
|
||||
return {
|
||||
read: true,
|
||||
write: canWriteLibrary(user, folder.library_type, folder.owner_user_id)
|
||||
};
|
||||
}
|
||||
|
||||
export function modelPermissions(user: FastifyRequest["userInfo"], model: Pick<ModelRow, "library_type" | "owner_user_id">): PermissionPayload {
|
||||
return {
|
||||
read: true,
|
||||
write: canWriteLibrary(user, model.library_type, model.owner_user_id)
|
||||
};
|
||||
}
|
||||
|
||||
export function getFolder(id: number) {
|
||||
return db.prepare(`
|
||||
SELECT f.*, u.username AS owner_username
|
||||
FROM folders f
|
||||
LEFT JOIN users u ON u.id = f.owner_user_id
|
||||
WHERE f.id = ?
|
||||
`).get(id) as FolderRow | undefined;
|
||||
}
|
||||
|
||||
export function requireFolderWrite(request: FastifyRequest, reply: FastifyReply, folder: FolderRow) {
|
||||
if (!folderPermissions(request.userInfo, folder).write) {
|
||||
reply.code(403).send({ message: "没有该模型库的操作权限" });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function requireModelWrite(request: FastifyRequest, reply: FastifyReply, model: ModelRow) {
|
||||
if (!modelPermissions(request.userInfo, model).write) {
|
||||
reply.code(403).send({ message: "没有该模型的操作权限" });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -22,6 +22,10 @@ export interface FolderRow {
|
||||
parent_id: number | null;
|
||||
name: string;
|
||||
path: string;
|
||||
library_type: "system" | "user";
|
||||
owner_user_id: number | null;
|
||||
is_system: number;
|
||||
owner_username?: string | null;
|
||||
model_count?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -35,11 +39,13 @@ export interface ModelRow {
|
||||
name: string;
|
||||
original_filename: string;
|
||||
file_path: string;
|
||||
library_type: "system" | "user";
|
||||
owner_user_id: number | null;
|
||||
storage_provider: "local" | "cos";
|
||||
file_size: number;
|
||||
thumbnail: string | null;
|
||||
thumbnail_path: string | null;
|
||||
thumbnail_provider: "local" | "cos" | null;
|
||||
operation_tree: string;
|
||||
brand_name?: string | null;
|
||||
type_name?: string | null;
|
||||
created_at: string;
|
||||
|
||||
Reference in New Issue
Block a user