feat: add local access control and managed point clouds
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
import { Router, type RequestHandler } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import multer from 'multer';
|
||||
import * as db from './db';
|
||||
import { samplePointcloudFile, type SampledPoint } from './pointcloudSample.js';
|
||||
import { forceReload as reloadPluginRegistry } from './plugins/registry.js';
|
||||
import { authenticateRequest } from './_core/auth.js';
|
||||
import { inspectPointcloudMetadata } from './pointcloudMetadata.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -48,6 +50,33 @@ const diskUpload = multer({
|
||||
limits: { fileSize: 50 * 1024 * 1024 * 1024 }, // 50 GB
|
||||
});
|
||||
|
||||
const requireFileRead: RequestHandler = async (req, res, next) => {
|
||||
try {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return res.status(401).json({ error: '请先登录' });
|
||||
if (user.mustChangePassword === 1) return res.status(403).json({ error: '请先修改初始密码' });
|
||||
res.locals.user = user;
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const requireFileWrite: RequestHandler = async (req, res, next) => {
|
||||
try {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return res.status(401).json({ error: '请先登录' });
|
||||
if (user.mustChangePassword === 1) return res.status(403).json({ error: '请先修改初始密码' });
|
||||
if (user.role !== 'admin' && user.role !== 'super_admin') {
|
||||
return res.status(403).json({ error: '无权执行此操作' });
|
||||
}
|
||||
res.locals.user = user;
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 工具函数 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
@@ -66,7 +95,7 @@ function safeResolve(base: string, filename: string): string | null {
|
||||
// POST /api/files/upload/cad/:modelId
|
||||
// multipart/form-data field: file
|
||||
|
||||
router.post('/upload/cad/:modelId', diskUpload.single('file'), async (req, res) => {
|
||||
router.post('/upload/cad/:modelId', requireFileWrite, diskUpload.single('file'), async (req, res) => {
|
||||
const modelId = parseInt(req.params.modelId, 10);
|
||||
if (!req.file || isNaN(modelId)) {
|
||||
return res.status(400).json({ success: false, error: 'Missing file or modelId' });
|
||||
@@ -98,18 +127,20 @@ router.post('/upload/cad/:modelId', diskUpload.single('file'), async (req, res)
|
||||
// POST /api/files/upload/model-pointcloud/:modelId
|
||||
// multipart/form-data field: file
|
||||
|
||||
router.post('/upload/model-pointcloud/:modelId', diskUpload.single('file'), async (req, res) => {
|
||||
router.post('/upload/model-pointcloud/:modelId', requireFileWrite, diskUpload.single('file'), async (req, res) => {
|
||||
const modelId = parseInt(req.params.modelId, 10);
|
||||
if (!req.file || isNaN(modelId)) {
|
||||
return res.status(400).json({ success: false, error: 'Missing file or modelId' });
|
||||
}
|
||||
|
||||
const destPath = req.file.path;
|
||||
const metadata = inspectPointcloudMetadata(destPath, req.file.originalname);
|
||||
const record = await db.createModelPointcloudFile({
|
||||
modelId,
|
||||
name: req.file.originalname,
|
||||
fileSize: req.file.size,
|
||||
filePath: destPath,
|
||||
...metadata,
|
||||
});
|
||||
|
||||
res.json({
|
||||
@@ -126,7 +157,7 @@ router.post('/upload/model-pointcloud/:modelId', diskUpload.single('file'), asyn
|
||||
// ─── 任务 CAD 文件上传 ────────────────────────────────────────────────────────
|
||||
// POST /api/files/upload/task-cad/:taskId
|
||||
|
||||
router.post('/upload/task-cad/:taskId', diskUpload.single('file'), async (req, res) => {
|
||||
router.post('/upload/task-cad/:taskId', requireFileWrite, diskUpload.single('file'), async (req, res) => {
|
||||
const taskId = parseInt(req.params.taskId, 10);
|
||||
if (!req.file || isNaN(taskId)) {
|
||||
return res.status(400).json({ success: false, error: 'Missing file or taskId' });
|
||||
@@ -157,7 +188,7 @@ router.post('/upload/task-cad/:taskId', diskUpload.single('file'), async (req, r
|
||||
// ─── 读取任务 CAD 文件 ────────────────────────────────────────────────────────
|
||||
// GET /api/files/read/task-cad/:taskId/:filename
|
||||
|
||||
router.get('/read/task-cad/:taskId/:filename', (req, res) => {
|
||||
router.get('/read/task-cad/:taskId/:filename', requireFileRead, (req, res) => {
|
||||
const cadDir = path.join(TASKS_DIR, req.params.taskId, 'cad');
|
||||
const filePath = safeResolve(cadDir, req.params.filename);
|
||||
if (!filePath) return res.status(403).json({ error: 'Access denied' });
|
||||
@@ -168,18 +199,20 @@ router.get('/read/task-cad/:taskId/:filename', (req, res) => {
|
||||
// ─── 任务点云文件上传 ─────────────────────────────────────────────────────────
|
||||
// POST /api/files/upload/pointcloud/:taskId
|
||||
|
||||
router.post('/upload/pointcloud/:taskId', diskUpload.single('file'), async (req, res) => {
|
||||
router.post('/upload/pointcloud/:taskId', requireFileWrite, diskUpload.single('file'), async (req, res) => {
|
||||
const taskId = parseInt(req.params.taskId, 10);
|
||||
if (!req.file || isNaN(taskId)) {
|
||||
return res.status(400).json({ success: false, error: 'Missing file or taskId' });
|
||||
}
|
||||
|
||||
const destPath = req.file.path;
|
||||
const metadata = inspectPointcloudMetadata(destPath, req.file.originalname);
|
||||
const record = await db.createTaskPointcloudFile({
|
||||
taskId,
|
||||
name: req.file.originalname,
|
||||
fileSize: req.file.size,
|
||||
filePath: destPath,
|
||||
...metadata,
|
||||
});
|
||||
|
||||
res.json({
|
||||
@@ -196,7 +229,7 @@ router.post('/upload/pointcloud/:taskId', diskUpload.single('file'), async (req,
|
||||
// ─── 读取 CAD 文件 ─────────────────────────────────────────────────────────────
|
||||
// GET /api/files/read/cad/:modelId/:filename
|
||||
|
||||
router.get('/read/cad/:modelId/:filename', (req, res) => {
|
||||
router.get('/read/cad/:modelId/:filename', requireFileRead, (req, res) => {
|
||||
const cadDir = path.join(MODELS_DIR, req.params.modelId, 'cad');
|
||||
const filePath = safeResolve(cadDir, req.params.filename);
|
||||
if (!filePath) return res.status(403).json({ error: 'Access denied' });
|
||||
@@ -207,7 +240,7 @@ router.get('/read/cad/:modelId/:filename', (req, res) => {
|
||||
// ─── 读取车型点云文件 ─────────────────────────────────────────────────────────
|
||||
// GET /api/files/read/model-pointcloud/:modelId/:filename
|
||||
|
||||
router.get('/read/model-pointcloud/:modelId/:filename', (req, res) => {
|
||||
router.get('/read/model-pointcloud/:modelId/:filename', requireFileRead, (req, res) => {
|
||||
const pcDir = path.join(MODELS_DIR, req.params.modelId, 'pointcloud');
|
||||
const filePath = safeResolve(pcDir, req.params.filename);
|
||||
if (!filePath) return res.status(403).json({ error: 'Access denied' });
|
||||
@@ -218,7 +251,7 @@ router.get('/read/model-pointcloud/:modelId/:filename', (req, res) => {
|
||||
// ─── 读取点云文件 ─────────────────────────────────────────────────────────────
|
||||
// GET /api/files/read/pointcloud/:taskId/:filename
|
||||
|
||||
router.get('/read/pointcloud/:taskId/:filename', (req, res) => {
|
||||
router.get('/read/pointcloud/:taskId/:filename', requireFileRead, (req, res) => {
|
||||
const pcDir = path.join(TASKS_DIR, req.params.taskId, 'pointcloud');
|
||||
const filePath = safeResolve(pcDir, req.params.filename);
|
||||
if (!filePath) return res.status(403).json({ error: 'Access denied' });
|
||||
@@ -229,7 +262,7 @@ router.get('/read/pointcloud/:taskId/:filename', (req, res) => {
|
||||
// ─── 删除 CAD 文件 ─────────────────────────────────────────────────────────────
|
||||
// DELETE /api/files/delete/cad/:id
|
||||
|
||||
router.delete('/delete/cad/:id', async (req, res) => {
|
||||
router.delete('/delete/cad/:id', requireFileWrite, async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (isNaN(id)) return res.status(400).json({ success: false, error: 'Invalid id' });
|
||||
|
||||
@@ -245,7 +278,7 @@ router.delete('/delete/cad/:id', async (req, res) => {
|
||||
// ─── 删除点云文件 ─────────────────────────────────────────────────────────────
|
||||
// DELETE /api/files/delete/pointcloud/:id
|
||||
|
||||
router.delete('/delete/pointcloud/:id', async (req, res) => {
|
||||
router.delete('/delete/pointcloud/:id', requireFileWrite, async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (isNaN(id)) return res.status(400).json({ success: false, error: 'Invalid id' });
|
||||
await db.deleteTaskPointcloudFile(id);
|
||||
@@ -254,13 +287,11 @@ router.delete('/delete/pointcloud/:id', async (req, res) => {
|
||||
|
||||
// ─── 批量采样接口 ─────────────────────────────────────────────────────────────
|
||||
// POST /api/files/sample/batch
|
||||
// Body: { taskId: number, totalPoints?: number }
|
||||
// or { filePaths: string[], totalPoints?: number } (内部绝对路径,需认证)
|
||||
// Body: { taskId: number, fileIds?: number[], totalPoints?: number }
|
||||
|
||||
router.post('/sample/batch', async (req, res) => {
|
||||
const { taskId, filePaths: rawPaths, totalPoints = 1000000, fileIds } = req.body as {
|
||||
router.post('/sample/batch', requireFileRead, async (req, res) => {
|
||||
const { taskId, totalPoints = 1000000, fileIds } = req.body as {
|
||||
taskId?: number;
|
||||
filePaths?: string[];
|
||||
totalPoints?: number;
|
||||
/** 可选:只采样指定 ID 的文件(按 task_pointcloud_files.id);空/缺省 = 全部 */
|
||||
fileIds?: number[];
|
||||
@@ -268,7 +299,7 @@ router.post('/sample/batch', async (req, res) => {
|
||||
|
||||
let paths: string[] = [];
|
||||
|
||||
if (taskId != null) {
|
||||
if (taskId != null && Number.isInteger(taskId) && taskId > 0) {
|
||||
let records = await db.listTaskPointcloudFiles(taskId);
|
||||
// 当前端传入 fileIds 时只采样选中的文件
|
||||
if (Array.isArray(fileIds) && fileIds.length > 0) {
|
||||
@@ -276,10 +307,8 @@ router.post('/sample/batch', async (req, res) => {
|
||||
records = records.filter((r) => idSet.has(r.id));
|
||||
}
|
||||
paths = records.map((r) => r.filePath);
|
||||
} else if (Array.isArray(rawPaths) && rawPaths.length > 0) {
|
||||
paths = rawPaths;
|
||||
} else {
|
||||
return res.status(400).json({ error: 'Provide taskId or filePaths' });
|
||||
return res.status(400).json({ error: '请提供有效的 taskId' });
|
||||
}
|
||||
|
||||
if (paths.length === 0) {
|
||||
@@ -326,6 +355,7 @@ router.post('/sample/batch', async (req, res) => {
|
||||
|
||||
const totalSampled = sampledPerFile.reduce((s, pts) => s + pts.length, 0);
|
||||
// 如果任意点有颜色,则输出 28 字节/点(x,y,z,error,r,g,b 各 float32);否则 16 字节
|
||||
const hasError = sampledPerFile.some((pts) => pts.some((p) => Number.isFinite(p.error)));
|
||||
const hasColor = sampledPerFile.some((pts) => pts.some((p) => p.r !== undefined));
|
||||
const bytesPerPoint = hasColor ? 28 : 16;
|
||||
const binBuf = Buffer.allocUnsafe(totalSampled * bytesPerPoint);
|
||||
@@ -335,7 +365,7 @@ router.post('/sample/batch', async (req, res) => {
|
||||
binBuf.writeFloatLE(p.x, offset);
|
||||
binBuf.writeFloatLE(p.y, offset + 4);
|
||||
binBuf.writeFloatLE(p.z, offset + 8);
|
||||
binBuf.writeFloatLE(p.error, offset + 12);
|
||||
binBuf.writeFloatLE(p.error ?? Number.NaN, offset + 12);
|
||||
if (hasColor) {
|
||||
binBuf.writeFloatLE((p.r ?? 128) / 255, offset + 16);
|
||||
binBuf.writeFloatLE((p.g ?? 128) / 255, offset + 20);
|
||||
@@ -355,6 +385,7 @@ router.post('/sample/batch', async (req, res) => {
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.setHeader('X-Point-Count', totalSampled);
|
||||
res.setHeader('X-Has-Color', hasColor ? '1' : '0');
|
||||
res.setHeader('X-Has-Error', hasError ? '1' : '0');
|
||||
res.setHeader('X-File-Stats', JSON.stringify(fileStats));
|
||||
res.send(binBuf);
|
||||
} catch (err) {
|
||||
@@ -366,7 +397,7 @@ router.post('/sample/batch', async (req, res) => {
|
||||
// ─── 平滑热力分析结果(异步任务产物,仅读取已落盘的顶点色)──────────────────────
|
||||
// GET /api/files/read/analysis/heatmap/:taskId/:resultId
|
||||
|
||||
router.get('/read/analysis/heatmap/:taskId/:resultId', async (req, res) => {
|
||||
router.get('/read/analysis/heatmap/:taskId/:resultId', requireFileRead, async (req, res) => {
|
||||
const taskId = parseInt(req.params.taskId, 10);
|
||||
const resultId = parseInt(req.params.resultId, 10);
|
||||
if (Number.isNaN(taskId) || Number.isNaN(resultId)) {
|
||||
@@ -402,7 +433,7 @@ router.get('/read/analysis/heatmap/:taskId/:resultId', async (req, res) => {
|
||||
// ─── 装配检测:每零件顶点热力缓存(可选)──────────────────────────────────────
|
||||
// GET /api/files/read/analysis/assembly-vertex/:taskId/:resultId/:filename
|
||||
|
||||
router.get('/read/analysis/assembly-vertex/:taskId/:resultId/:filename', async (req, res) => {
|
||||
router.get('/read/analysis/assembly-vertex/:taskId/:resultId/:filename', requireFileRead, async (req, res) => {
|
||||
const taskId = parseInt(req.params.taskId, 10);
|
||||
const resultId = parseInt(req.params.resultId, 10);
|
||||
const filename = req.params.filename;
|
||||
@@ -432,7 +463,7 @@ router.get('/read/analysis/assembly-vertex/:taskId/:resultId/:filename', async (
|
||||
// 注意:此路由必须放在 heatmap / assembly-vertex 等具体路由之后,否则
|
||||
// 会用 taskId="heatmap" 这种字符串把请求误吞掉。
|
||||
|
||||
router.get('/read/analysis/:taskId/:resultId/*', async (req, res) => {
|
||||
router.get('/read/analysis/:taskId/:resultId/*', requireFileRead, async (req, res) => {
|
||||
const taskId = parseInt(req.params.taskId, 10);
|
||||
const resultId = parseInt(req.params.resultId, 10);
|
||||
const filename = (req.params as Record<string, string>)[0] || (req.params as Record<string, string>)['0'];
|
||||
@@ -455,7 +486,7 @@ router.get('/read/analysis/:taskId/:resultId/*', async (req, res) => {
|
||||
// ─── 分析结果重新同步 ────────────────────────────────────────────────────────
|
||||
// POST /api/files/resync/analysis/:taskId/:resultId
|
||||
// 把磁盘上的 result.json 重新同步到数据库,用于手动修复 overlay3d 等字段。
|
||||
router.post('/resync/analysis/:taskId/:resultId', async (req, res) => {
|
||||
router.post('/resync/analysis/:taskId/:resultId', requireFileWrite, async (req, res) => {
|
||||
const taskId = parseInt(req.params.taskId, 10);
|
||||
const resultId = parseInt(req.params.resultId, 10);
|
||||
if (Number.isNaN(taskId) || Number.isNaN(resultId)) {
|
||||
@@ -494,7 +525,7 @@ router.post('/resync/analysis/:taskId/:resultId', async (req, res) => {
|
||||
// POST /api/files/plugins/reload
|
||||
// 重新从磁盘扫描 manifests/*.json,无需重启服务器。
|
||||
// 用于更新算法版本后立即生效。
|
||||
router.post('/plugins/reload', (_req, res) => {
|
||||
router.post('/plugins/reload', requireFileWrite, (_req, res) => {
|
||||
try {
|
||||
const plugins = reloadPluginRegistry();
|
||||
res.json({
|
||||
|
||||
Reference in New Issue
Block a user