feat: secure analysis PLY sampling
This commit is contained in:
@@ -3,7 +3,7 @@ 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 { samplePointcloudFiles } from './pointcloudSample.js';
|
||||
import { forceReload as reloadPluginRegistry } from './plugins/registry.js';
|
||||
import { authenticateRequest } from './_core/auth.js';
|
||||
import { inspectPointcloudMetadata } from './pointcloudMetadata.js';
|
||||
@@ -88,7 +88,19 @@ function formatFileSize(bytes: number): string {
|
||||
|
||||
function safeResolve(base: string, filename: string): string | null {
|
||||
const resolved = path.resolve(base, decodeURIComponent(filename));
|
||||
return resolved.startsWith(path.resolve(base)) ? resolved : null;
|
||||
const relative = path.relative(path.resolve(base), resolved);
|
||||
return relative !== '' && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)
|
||||
? resolved
|
||||
: null;
|
||||
}
|
||||
|
||||
function sendSampledPointcloudBatch(res: Parameters<RequestHandler>[1], batch: Awaited<ReturnType<typeof samplePointcloudFiles>>) {
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.setHeader('X-Point-Count', batch.pointCount);
|
||||
res.setHeader('X-Has-Color', batch.hasColor ? '1' : '0');
|
||||
res.setHeader('X-Has-Error', batch.hasError ? '1' : '0');
|
||||
res.setHeader('X-File-Stats', JSON.stringify(batch.fileStats));
|
||||
res.send(batch.buffer);
|
||||
}
|
||||
|
||||
// ─── 车型 CAD 文件上传 ────────────────────────────────────────────────────────
|
||||
@@ -333,7 +345,6 @@ router.post('/sample/batch', requireFileRead, async (req, res) => {
|
||||
return res.send(empty);
|
||||
}
|
||||
|
||||
const budget = Math.min(totalPoints, 2000000);
|
||||
const supportedExts = ['.ply', '.txt', '.xyz', '.pts'];
|
||||
|
||||
const fileInfos = paths.map((fp) => ({
|
||||
@@ -348,66 +359,100 @@ router.post('/sample/batch', requireFileRead, async (req, res) => {
|
||||
|
||||
console.log(
|
||||
`[FileRoutes] Batch sample: ${fileInfos.length} files ` +
|
||||
`(${(totalBytes / 1e9).toFixed(2)} GB), budget=${budget}`
|
||||
`(${(totalBytes / 1e9).toFixed(2)} GB), budget=${totalPoints}`
|
||||
);
|
||||
|
||||
try {
|
||||
const quotaMap = new Map<string, number>();
|
||||
for (const f of supportedFiles) {
|
||||
const quota = totalBytes > 0
|
||||
? Math.max(100, Math.round((f.size / totalBytes) * budget))
|
||||
: Math.floor(budget / supportedFiles.length);
|
||||
quotaMap.set(f.filePath, quota);
|
||||
}
|
||||
|
||||
const sampledPerFile = await Promise.all(
|
||||
fileInfos.map((f) => {
|
||||
if (!supportedExts.includes(f.ext) || f.size === 0) return Promise.resolve([] as SampledPoint[]);
|
||||
return samplePointcloudFile(f.filePath, f.size, quotaMap.get(f.filePath) ?? 0);
|
||||
})
|
||||
);
|
||||
|
||||
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);
|
||||
let offset = 0;
|
||||
for (const pts of sampledPerFile) {
|
||||
for (const p of pts) {
|
||||
binBuf.writeFloatLE(p.x, offset);
|
||||
binBuf.writeFloatLE(p.y, offset + 4);
|
||||
binBuf.writeFloatLE(p.z, offset + 8);
|
||||
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);
|
||||
binBuf.writeFloatLE((p.b ?? 128) / 255, offset + 24);
|
||||
}
|
||||
offset += bytesPerPoint;
|
||||
}
|
||||
}
|
||||
|
||||
const fileStats = fileInfos.map((f, i) => ({
|
||||
name: f.name,
|
||||
sizeGB: +(f.size / 1e9).toFixed(3),
|
||||
sampled: sampledPerFile[i].length,
|
||||
}));
|
||||
|
||||
console.log(`[FileRoutes] Batch done: ${totalSampled} pts, ${(binBuf.length / 1e6).toFixed(1)} MB`);
|
||||
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);
|
||||
const batch = await samplePointcloudFiles(supportedFiles.map((file) => file.filePath), totalPoints);
|
||||
console.log(`[FileRoutes] Batch done: ${batch.pointCount} pts, ${(batch.buffer.length / 1e6).toFixed(1)} MB`);
|
||||
sendSampledPointcloudBatch(res, batch);
|
||||
} catch (err) {
|
||||
console.error('[FileRoutes] Batch sampling error:', err);
|
||||
res.status(500).json({ error: 'Failed to sample files' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 分析结果 PLY 批量采样接口 ───────────────────────────────────────────────
|
||||
// POST /api/files/sample/analysis
|
||||
// Body: { taskId: number, resultId: number, files: string[], totalPoints?: number }
|
||||
|
||||
router.post('/sample/analysis', requireFileRead, async (req, res) => {
|
||||
const { taskId, resultId, files, totalPoints = 300_000 } = req.body as {
|
||||
taskId?: number;
|
||||
resultId?: number;
|
||||
files?: unknown;
|
||||
totalPoints?: number;
|
||||
};
|
||||
if (!Number.isSafeInteger(taskId) || Number(taskId) <= 0 ||
|
||||
!Number.isSafeInteger(resultId) || Number(resultId) <= 0 ||
|
||||
!Number.isSafeInteger(totalPoints) || totalPoints <= 0) {
|
||||
return res.status(400).json({ error: '分析结果采样参数无效' });
|
||||
}
|
||||
if (!Array.isArray(files) || files.length === 0 ||
|
||||
files.some((file) => typeof file !== 'string' || file.length === 0)) {
|
||||
return res.status(400).json({ error: '请提供 PLY 文件列表' });
|
||||
}
|
||||
|
||||
const validTaskId = taskId as number;
|
||||
const validResultId = resultId as number;
|
||||
const validTotalPoints = totalPoints as number;
|
||||
|
||||
const row = await db.getTaskAnalysisResultById(validResultId);
|
||||
if (!row || row.taskId !== validTaskId) {
|
||||
return res.status(404).json({ error: '分析结果不存在或不属于当前任务' });
|
||||
}
|
||||
|
||||
const baseDir = path.join(TASKS_DIR, String(validTaskId), 'analysis', String(validResultId));
|
||||
let realBaseDir: string;
|
||||
try {
|
||||
realBaseDir = fs.realpathSync(baseDir);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return res.status(404).json({ error: '分析结果目录不存在' });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const resolvedFiles: string[] = [];
|
||||
for (const file of files as string[]) {
|
||||
if (path.isAbsolute(file) || path.extname(file).toLowerCase() !== '.ply') {
|
||||
return res.status(400).json({ error: '只允许读取分析结果目录中的相对 PLY 路径' });
|
||||
}
|
||||
let filePath: string | null;
|
||||
try {
|
||||
filePath = safeResolve(baseDir, file);
|
||||
} catch {
|
||||
return res.status(400).json({ error: 'PLY 文件路径编码无效' });
|
||||
}
|
||||
if (!filePath) return res.status(403).json({ error: 'PLY 文件路径越界' });
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(filePath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return res.status(404).json({ error: `PLY 文件不存在: ${file}` });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) {
|
||||
return res.status(400).json({ error: `PLY 路径不是普通文件: ${file}` });
|
||||
}
|
||||
const realFilePath = fs.realpathSync(filePath);
|
||||
const realRelative = path.relative(realBaseDir, realFilePath);
|
||||
if (realRelative === '' || realRelative === '..' || realRelative.startsWith(`..${path.sep}`) || path.isAbsolute(realRelative)) {
|
||||
return res.status(403).json({ error: 'PLY 文件路径越界' });
|
||||
}
|
||||
resolvedFiles.push(realFilePath);
|
||||
}
|
||||
|
||||
try {
|
||||
const batch = await samplePointcloudFiles(resolvedFiles, validTotalPoints, 300_000);
|
||||
sendSampledPointcloudBatch(res, batch);
|
||||
} catch (error) {
|
||||
console.error('[FileRoutes] Analysis PLY sampling error:', error);
|
||||
return res.status(500).json({ error: '分析结果 PLY 采样失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 平滑热力分析结果(异步任务产物,仅读取已落盘的顶点色)──────────────────────
|
||||
// GET /api/files/read/analysis/heatmap/:taskId/:resultId
|
||||
|
||||
|
||||
Reference in New Issue
Block a user