feat: add local access control and managed point clouds
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { COOKIE_NAME } from "@shared/const";
|
||||
import { COOKIE_NAME, SESSION_TTL_MS } from "@shared/const";
|
||||
import { getSessionCookieOptions } from "./_core/cookies";
|
||||
import { systemRouter } from "./_core/systemRouter";
|
||||
import { publicProcedure, protectedProcedure, router } from "./_core/trpc";
|
||||
import { adminProcedure, publicProcedure, protectedProcedure, router, sessionProcedure, superAdminProcedure } from "./_core/trpc";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import * as fs from "fs";
|
||||
@@ -15,10 +15,37 @@ import { stepParseCacheDir } from "./stepParse.js";
|
||||
import { listPlugins, getPlugin } from "./plugins/registry.js";
|
||||
import { enqueueAlgorithmJob, algorithmJobStats, cancelAlgorithmJob, cancelAlgorithmJobsByTask } from "./algorithmJob.js";
|
||||
import { cancelLegacyAnalysisJob, cancelLegacyAnalysisJobsByTask } from "./analysisJobControl.js";
|
||||
import { deleteAnalysisResult } from "./analysisResultDeletion.js";
|
||||
import {
|
||||
clearLoginFailures,
|
||||
createSessionToken,
|
||||
hashPassword,
|
||||
isLoginRateLimited,
|
||||
publicUser,
|
||||
recordLoginFailure,
|
||||
validatePassword,
|
||||
verifyPassword,
|
||||
} from "./_core/auth";
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || "/data";
|
||||
const TASKS_DIR = path.join(DATA_DIR, "tasks");
|
||||
|
||||
function pointcloudDisplayName(baseNameInput: string, filePath: string): string {
|
||||
const baseName = baseNameInput.trim();
|
||||
if (!baseName) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "点云名称不能为空" });
|
||||
}
|
||||
if (/[\\/\u0000-\u001f\u007f]/.test(baseName)) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "点云名称不能包含斜杠、反斜杠或控制字符" });
|
||||
}
|
||||
|
||||
const name = `${baseName}${path.extname(filePath)}`;
|
||||
if (Array.from(name).length > 255) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "点云名称不能超过 255 个字符(含扩展名)" });
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
const DEMO_MEASUREMENT_PLUGIN_IDS = [
|
||||
"train-measurement",
|
||||
"coupler-panel",
|
||||
@@ -306,12 +333,164 @@ export const appRouter = router({
|
||||
system: systemRouter,
|
||||
|
||||
auth: router({
|
||||
me: publicProcedure.query(opts => opts.ctx.user),
|
||||
login: publicProcedure
|
||||
.input(z.object({ username: z.string().min(1).max(64), password: z.string().min(1).max(256) }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const username = input.username.trim();
|
||||
const ip = ctx.req.ip || ctx.req.socket.remoteAddress || "unknown";
|
||||
if (isLoginRateLimited(ip, username)) {
|
||||
throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "登录尝试过于频繁,请稍后再试" });
|
||||
}
|
||||
const user = await db.getUserByUsername(username);
|
||||
const valid = Boolean(user?.isActive === 1 && await verifyPassword(input.password, user?.passwordHash ?? null));
|
||||
if (!user || !valid) {
|
||||
recordLoginFailure(ip, username);
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "用户名或密码错误" });
|
||||
}
|
||||
clearLoginFailures(ip, username);
|
||||
await db.updateUser(user.id, { lastLoginAt: new Date() });
|
||||
const token = await createSessionToken(user);
|
||||
ctx.res.cookie(COOKIE_NAME, token, {
|
||||
...getSessionCookieOptions(ctx.req),
|
||||
maxAge: SESSION_TTL_MS,
|
||||
});
|
||||
return publicUser({ ...user, lastLoginAt: new Date() });
|
||||
}),
|
||||
me: sessionProcedure.query(opts => publicUser(opts.ctx.user)),
|
||||
logout: publicProcedure.mutation(({ ctx }) => {
|
||||
const cookieOptions = getSessionCookieOptions(ctx.req);
|
||||
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
|
||||
return { success: true } as const;
|
||||
}),
|
||||
changePassword: sessionProcedure
|
||||
.input(z.object({ currentPassword: z.string().min(1).max(256), newPassword: z.string().min(8).max(256) }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!await verifyPassword(input.currentPassword, ctx.user.passwordHash)) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "当前密码错误" });
|
||||
}
|
||||
if (input.currentPassword === input.newPassword) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "新密码不能与当前密码相同" });
|
||||
}
|
||||
validatePassword(input.newPassword);
|
||||
const passwordHash = await hashPassword(input.newPassword);
|
||||
const nextVersion = ctx.user.sessionVersion + 1;
|
||||
await db.updateUser(ctx.user.id, {
|
||||
passwordHash,
|
||||
mustChangePassword: 0,
|
||||
sessionVersion: nextVersion,
|
||||
});
|
||||
const token = await createSessionToken({ id: ctx.user.id, sessionVersion: nextVersion });
|
||||
ctx.res.cookie(COOKIE_NAME, token, {
|
||||
...getSessionCookieOptions(ctx.req),
|
||||
maxAge: SESSION_TTL_MS,
|
||||
});
|
||||
return { success: true } as const;
|
||||
}),
|
||||
}),
|
||||
|
||||
users: router({
|
||||
list: superAdminProcedure
|
||||
.input(z.object({ search: z.string().max(100).optional() }).optional())
|
||||
.query(async ({ input }) => (await db.listUsers(input?.search)).map(publicUser)),
|
||||
create: superAdminProcedure
|
||||
.input(z.object({
|
||||
username: z.string().trim().min(3).max(64).regex(/^[A-Za-z0-9._-]+$/, "用户名只能包含字母、数字、点、下划线和横线"),
|
||||
temporaryPassword: z.string().min(8).max(256),
|
||||
name: z.string().trim().min(1).max(100),
|
||||
email: z.string().email().max(320).optional().or(z.literal("")),
|
||||
role: z.enum(["user", "admin", "super_admin"]),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const row = await db.createUser({
|
||||
username: input.username,
|
||||
passwordHash: await hashPassword(input.temporaryPassword),
|
||||
name: input.name,
|
||||
email: input.email || null,
|
||||
role: input.role,
|
||||
isActive: 1,
|
||||
mustChangePassword: 1,
|
||||
sessionVersion: 0,
|
||||
});
|
||||
if (!row) throw new Error("创建用户失败");
|
||||
return publicUser(row);
|
||||
} catch (error) {
|
||||
if (isDuplicateKeyError(error)) {
|
||||
throw new TRPCError({ code: "CONFLICT", message: "用户名已存在" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
update: superAdminProcedure
|
||||
.input(z.object({
|
||||
id: z.number().int().positive(),
|
||||
username: z.string().trim().min(3).max(64).regex(/^[A-Za-z0-9._-]+$/).optional(),
|
||||
name: z.string().trim().min(1).max(100).optional(),
|
||||
email: z.string().email().max(320).optional().or(z.literal("")),
|
||||
role: z.enum(["user", "admin", "super_admin"]).optional(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const target = await db.getUserById(input.id);
|
||||
if (!target) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" });
|
||||
if (target.id === ctx.user.id && input.role && input.role !== target.role) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "不能修改自己的角色" });
|
||||
}
|
||||
if (target.role === "super_admin" && target.isActive === 1 && input.role && input.role !== "super_admin" && await db.countActiveSuperAdmins() <= 1) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "不能移除最后一个有效超级管理员" });
|
||||
}
|
||||
const { id, email, ...rest } = input;
|
||||
try {
|
||||
const row = await db.updateUser(id, { ...rest, ...(email !== undefined ? { email: email || null } : {}) });
|
||||
if (!row) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" });
|
||||
return publicUser(row);
|
||||
} catch (error) {
|
||||
if (isDuplicateKeyError(error)) throw new TRPCError({ code: "CONFLICT", message: "用户名已存在" });
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
setActive: superAdminProcedure
|
||||
.input(z.object({ id: z.number().int().positive(), isActive: z.boolean() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const target = await db.getUserById(input.id);
|
||||
if (!target) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" });
|
||||
if (target.id === ctx.user.id) throw new TRPCError({ code: "FORBIDDEN", message: "不能启用或禁用自己" });
|
||||
if (input.isActive && (!target.passwordHash || target.username === `legacy_user_${target.id}`)) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "遗留账号必须先修改用户名并重置临时密码" });
|
||||
}
|
||||
if (!input.isActive && target.role === "super_admin" && target.isActive === 1 && await db.countActiveSuperAdmins() <= 1) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "不能禁用最后一个有效超级管理员" });
|
||||
}
|
||||
const row = await db.updateUser(target.id, {
|
||||
isActive: input.isActive ? 1 : 0,
|
||||
sessionVersion: target.sessionVersion + 1,
|
||||
});
|
||||
return publicUser(row!);
|
||||
}),
|
||||
resetPassword: superAdminProcedure
|
||||
.input(z.object({ id: z.number().int().positive(), temporaryPassword: z.string().min(8).max(256) }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (input.id === ctx.user.id) throw new TRPCError({ code: "FORBIDDEN", message: "请通过修改密码功能修改自己的密码" });
|
||||
const target = await db.getUserById(input.id);
|
||||
if (!target) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" });
|
||||
await db.updateUser(target.id, {
|
||||
passwordHash: await hashPassword(input.temporaryPassword),
|
||||
mustChangePassword: 1,
|
||||
sessionVersion: target.sessionVersion + 1,
|
||||
});
|
||||
return { success: true } as const;
|
||||
}),
|
||||
delete: superAdminProcedure
|
||||
.input(z.object({ id: z.number().int().positive() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (input.id === ctx.user.id) throw new TRPCError({ code: "FORBIDDEN", message: "不能删除自己" });
|
||||
const target = await db.getUserById(input.id);
|
||||
if (!target) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" });
|
||||
if (target.role === "super_admin" && target.isActive === 1 && await db.countActiveSuperAdmins() <= 1) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "不能删除最后一个有效超级管理员" });
|
||||
}
|
||||
await db.deleteUser(target.id);
|
||||
return { success: true } as const;
|
||||
}),
|
||||
}),
|
||||
|
||||
// ─── 车系 ──────────────────────────────────────────────────────────────────
|
||||
@@ -444,6 +623,23 @@ export const appRouter = router({
|
||||
return db.listModelPointcloudFiles(input.modelId);
|
||||
}),
|
||||
|
||||
rename: adminProcedure
|
||||
.input(z.object({ id: z.number(), baseName: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const row = await db.getModelPointcloudFileById(input.id);
|
||||
if (!row) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "车型库点云不存在" });
|
||||
}
|
||||
const name = pointcloudDisplayName(input.baseName, row.filePath);
|
||||
if (name === row.name) return row;
|
||||
|
||||
const duplicate = await db.findModelPointcloudFileByModelAndName(row.modelId, name, row.id);
|
||||
if (duplicate) {
|
||||
throw new TRPCError({ code: "CONFLICT", message: "当前车型下已存在同名点云" });
|
||||
}
|
||||
return db.updateModelPointcloudFileName(row.id, name);
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
@@ -596,6 +792,23 @@ export const appRouter = router({
|
||||
return db.listTaskPointcloudFiles(input.taskId);
|
||||
}),
|
||||
|
||||
rename: adminProcedure
|
||||
.input(z.object({ id: z.number(), baseName: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const row = await db.getTaskPointcloudFileById(input.id);
|
||||
if (!row) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "任务点云不存在" });
|
||||
}
|
||||
const name = pointcloudDisplayName(input.baseName, row.filePath);
|
||||
if (name === row.name) return row;
|
||||
|
||||
const duplicate = await db.findTaskPointcloudFileByTaskAndName(row.taskId, name, row.id);
|
||||
if (duplicate) {
|
||||
throw new TRPCError({ code: "CONFLICT", message: "当前任务下已存在同名点云" });
|
||||
}
|
||||
return db.updateTaskPointcloudFileName(row.id, name);
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
@@ -801,11 +1014,7 @@ export const appRouter = router({
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
// 先取消正在运行或排队的 job,再删数据库记录
|
||||
cancelAlgorithmJob(input.id);
|
||||
cancelLegacyAnalysisJob(input.id);
|
||||
await db.deleteTaskAnalysisResult(input.id);
|
||||
return { success: true };
|
||||
return deleteAnalysisResult(input.id);
|
||||
}),
|
||||
}),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user