feat: secure analysis PLY sampling

This commit is contained in:
2026-08-13 18:16:57 +08:00
parent 29a69793db
commit 9f4482bea1
5 changed files with 350 additions and 61 deletions

View File

@@ -1890,21 +1890,32 @@ export default function PointCloudViewer({
setPlyOverlayStatus({ status: 'loading', message: '正在加载 PLY 叠加层' });
(async () => {
try {
// 用 /sample/batch 接口按 filePath 采样,最多 30 万点
// 支持多文件(各面板 PLY),合并采样
// 通过受限的分析结果接口加载相对 PLY 路径,支持多文件合并采样
const plyFiles: string[] = Array.isArray(overlay.files) && overlay.files.length > 0
? overlay.files
: [overlay.file];
const resp = await fetch('/api/files/sample/batch', {
const resp = await fetch('/api/files/sample/analysis', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: ac.signal,
body: JSON.stringify({
filePaths: plyFiles.map(f => `/data/tasks/${taskId}/analysis/${resultId}/${f}`),
taskId,
resultId,
files: plyFiles,
totalPoints: 300_000,
}),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
if (!resp.ok) {
const payload = await resp.json().catch(() => null) as { error?: string } | null;
const detail = payload?.error;
if (resp.status === 404) {
throw new Error(detail ?? '分析结果或 PLY 文件不存在');
}
if (resp.status === 400 || resp.status === 403) {
throw new Error(detail ?? 'PLY 文件参数或访问权限无效');
}
throw new Error(detail ?? `服务端采样失败(HTTP ${resp.status})`);
}
const hasColor = resp.headers.get('X-Has-Color') === '1';
const stride = hasColor ? 7 : 4;
const buf = await resp.arrayBuffer();
@@ -1950,7 +1961,10 @@ export default function PointCloudViewer({
if (e instanceof DOMException && e.name === 'AbortError') return;
console.warn('[PlyOverlay] load failed:', e);
setPlyOverlayData(null);
setPlyOverlayStatus({ status: 'error', message: '加载 PLY 叠加层失败' });
setPlyOverlayStatus({
status: 'error',
message: e instanceof Error ? e.message : '服务端采样失败,请稍后重试',
});
}
})();
return () => ac.abort();

View File

@@ -0,0 +1,126 @@
import express from 'express';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { AddressInfo } from 'node:net';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
authenticated: true,
resultTaskId: 7,
}));
vi.mock('./_core/auth.js', () => ({
authenticateRequest: vi.fn(async () => mocks.authenticated
? { id: 1, role: 'user', mustChangePassword: 0 }
: null),
}));
vi.mock('./db', () => ({
getTaskAnalysisResultById: vi.fn(async (id: number) => id === 11
? { id, taskId: mocks.resultTaskId }
: undefined),
listTaskPointcloudFiles: vi.fn(async () => []),
}));
const dataDir = mkdtempSync(join(tmpdir(), 'analysis-ply-route-'));
process.env.DATA_DIR = dataDir;
function writePly(relativePath: string, withColor = false): void {
const resultDir = join(dataDir, 'tasks', '7', 'analysis', '11');
const filePath = join(resultDir, relativePath);
mkdirSync(join(filePath, '..'), { recursive: true });
const properties = withColor
? ['x', 'y', 'z', 'red', 'green', 'blue']
: ['x', 'y', 'z'];
const header = Buffer.from([
'ply',
'format binary_little_endian 1.0',
'element vertex 2',
...properties.map((name) => `property float ${name}`),
'end_header',
'',
].join('\n'), 'ascii');
const values = withColor
? [[1, 2, 3, 255, 0, 128], [4, 5, 6, 0, 255, 64]]
: [[7, 8, 9], [10, 11, 12]];
const body = Buffer.alloc(values.length * properties.length * 4);
values.flat().forEach((value, index) => body.writeFloatLE(value, index * 4));
writeFileSync(filePath, Buffer.concat([header, body]));
}
describe('POST /api/files/sample/analysis', () => {
let origin: string;
let closeServer: () => Promise<void>;
beforeAll(async () => {
writePly('plain.ply');
writePly('nested/color.ply', true);
const { fileRouter } = await import('./fileRoutes');
const app = express();
app.use(express.json());
app.use('/api/files', fileRouter);
const server = app.listen(0);
await new Promise<void>((resolve) => server.once('listening', resolve));
origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
closeServer = () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
});
afterAll(async () => {
await closeServer();
rmSync(dataDir, { recursive: true, force: true });
});
beforeEach(() => {
mocks.authenticated = true;
mocks.resultTaskId = 7;
});
async function post(body: unknown) {
return fetch(`${origin}/api/files/sample/analysis`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
it('采样单个无色 PLY 并返回 16 字节步长', async () => {
const response = await post({ taskId: 7, resultId: 11, files: ['plain.ply'], totalPoints: 2 });
expect(response.status).toBe(200);
expect(response.headers.get('X-Point-Count')).toBe('2');
expect(response.headers.get('X-Has-Color')).toBe('0');
expect((await response.arrayBuffer()).byteLength).toBe(2 * 16);
});
it('采样多个 PLY,彩色响应使用 28 字节步长且不超预算', async () => {
const response = await post({
taskId: 7,
resultId: 11,
files: ['plain.ply', 'nested/color.ply'],
totalPoints: 3,
});
expect(response.status).toBe(200);
expect(response.headers.get('X-Point-Count')).toBe('3');
expect(response.headers.get('X-Has-Color')).toBe('1');
expect((await response.arrayBuffer()).byteLength).toBe(3 * 28);
});
it('拒绝未登录访问', async () => {
mocks.authenticated = false;
expect((await post({ taskId: 7, resultId: 11, files: ['plain.ply'] })).status).toBe(401);
});
it('拒绝错误的任务与结果组合', async () => {
mocks.resultTaskId = 8;
expect((await post({ taskId: 7, resultId: 11, files: ['plain.ply'] })).status).toBe(404);
});
it.each([
['路径穿越', '../plain.ply', 403],
['绝对路径', '/tmp/plain.ply', 400],
['非 PLY 文件', 'result.json', 400],
['缺失文件', 'missing.ply', 404],
])('拒绝%s', async (_label, file, status) => {
expect((await post({ taskId: 7, resultId: 11, files: [file] })).status).toBe(status);
});
});

View File

@@ -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

View File

@@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { samplePointcloudFile } from './pointcloudSample';
import { samplePointcloudFile, samplePointcloudFiles } from './pointcloudSample';
const temporaryDirectories: string[] = [];
@@ -58,3 +58,32 @@ describe('samplePointcloudFile', () => {
expect(points.map((point) => point.error)).toEqual([0.25, -0.5]);
});
});
describe('samplePointcloudFiles', () => {
it('合并彩色与无色 PLY,并使用 28 字节彩色步长', async () => {
const plain = writeBinaryPly(['x', 'y', 'z'], [[1, 2, 3], [4, 5, 6]]);
const colored = writeBinaryPly(
['x', 'y', 'z', 'red', 'green', 'blue'],
[[7, 8, 9, 255, 128, 0], [10, 11, 12, 0, 64, 255]],
);
const result = await samplePointcloudFiles([plain, colored], 4, 10);
expect(result.pointCount).toBe(4);
expect(result.hasColor).toBe(true);
expect(result.hasError).toBe(false);
expect(result.buffer.byteLength).toBe(4 * 28);
expect(result.fileStats.map((file) => file.sampled)).toEqual([2, 2]);
});
it('限制总采样数量,无色 PLY 使用 16 字节步长', async () => {
const first = writeBinaryPly(['x', 'y', 'z'], Array.from({ length: 10 }, (_, i) => [i, i, i]));
const second = writeBinaryPly(['x', 'y', 'z'], Array.from({ length: 10 }, (_, i) => [i + 10, i, i]));
const result = await samplePointcloudFiles([first, second], 100, 6);
expect(result.pointCount).toBe(6);
expect(result.hasColor).toBe(false);
expect(result.buffer.byteLength).toBe(6 * 16);
});
});

View File

@@ -3,6 +3,14 @@ import * as path from 'path';
export type SampledPoint = { x: number; y: number; z: number; error?: number; r?: number; g?: number; b?: number };
export type SampledPointcloudBatch = {
buffer: Buffer;
pointCount: number;
hasColor: boolean;
hasError: boolean;
fileStats: Array<{ name: string; sizeGB: number; sampled: number }>;
};
/** 与 fileRoutes 批量采样一致:随机 seek 文本点云行 */
export async function randomSeekSample(
filePath: string,
@@ -356,3 +364,70 @@ export async function samplePointcloudFile(
// .txt / .xyz / .pts / etc. — existing text line sampler
return randomSeekSample(filePath, fileSize, k);
}
/**
* 对一组已校验的点云文件按文件大小分配总采样配额,并编码为浏览器消费的二进制协议。
* 每点固定包含 x/y/z/error;任一文件带颜色时,所有点再包含归一化后的 r/g/b。
*/
export async function samplePointcloudFiles(
filePaths: string[],
requestedTotalPoints: number,
maxTotalPoints = 2_000_000,
): Promise<SampledPointcloudBatch> {
const totalPoints = Number.isFinite(requestedTotalPoints)
? Math.max(0, Math.min(Math.floor(requestedTotalPoints), maxTotalPoints))
: 0;
const fileInfos = filePaths.map((filePath) => ({
filePath,
name: path.basename(filePath),
size: fs.statSync(filePath).size,
}));
const totalBytes = fileInfos.reduce((sum, file) => sum + file.size, 0);
// floor 后把剩余名额依次补给文件,保证所有配额之和严格不超过总预算。
const quotas = fileInfos.map((file) => totalBytes > 0
? Math.floor((file.size / totalBytes) * totalPoints)
: 0);
let remainder = totalPoints - quotas.reduce((sum, quota) => sum + quota, 0);
for (let index = 0; remainder > 0 && fileInfos.length > 0; index = (index + 1) % fileInfos.length) {
quotas[index] += 1;
remainder -= 1;
}
const sampledPerFile = await Promise.all(
fileInfos.map((file, index) => samplePointcloudFile(file.filePath, file.size, quotas[index])),
);
const pointCount = sampledPerFile.reduce((sum, points) => sum + points.length, 0);
const hasError = sampledPerFile.some((points) => points.some((point) => Number.isFinite(point.error)));
const hasColor = sampledPerFile.some((points) => points.some((point) => point.r !== undefined));
const bytesPerPoint = hasColor ? 28 : 16;
const buffer = Buffer.allocUnsafe(pointCount * bytesPerPoint);
let offset = 0;
for (const points of sampledPerFile) {
for (const point of points) {
buffer.writeFloatLE(point.x, offset);
buffer.writeFloatLE(point.y, offset + 4);
buffer.writeFloatLE(point.z, offset + 8);
buffer.writeFloatLE(point.error ?? Number.NaN, offset + 12);
if (hasColor) {
buffer.writeFloatLE((point.r ?? 128) / 255, offset + 16);
buffer.writeFloatLE((point.g ?? 128) / 255, offset + 20);
buffer.writeFloatLE((point.b ?? 128) / 255, offset + 24);
}
offset += bytesPerPoint;
}
}
return {
buffer,
pointCount,
hasColor,
hasError,
fileStats: fileInfos.map((file, index) => ({
name: file.name,
sizeGB: +(file.size / 1e9).toFixed(3),
sampled: sampledPerFile[index].length,
})),
};
}