Initialize backendized SurClaw report system

- Add React/Vite frontend for login, dashboard, reports, templates, users, settings, AI, speech, and media workflows.

- Add NestJS/Prisma/PostgreSQL backend with auth, dashboard stats, reports, templates, users, departments, settings, files, AI, speech, audit logs, and HTML sanitization.

- Add Prisma schema, migrations, seed data, persistent app sessions, Docker/Nginx deployment files, and upload volume configuration.

- Add Vitest, Playwright, backend integration tests, and project documentation for requirements, design, permissions, API contracts, testing, deployment, security, and progress.

- Configure production local fallback switch and remove unused Gemini direct dependency/env wiring.
This commit is contained in:
2026-05-02 01:37:20 +08:00
commit 014aca8619
162 changed files with 27116 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
} from '@nestjs/common';
import type { Response } from 'express';
@Catch()
export class ApiExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const response = host.switchToHttp().getResponse<Response>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const payload = exception instanceof HttpException ? exception.getResponse() : undefined;
const message =
typeof payload === 'object' && payload !== null && 'message' in payload
? String((payload as { message: unknown }).message)
: exception instanceof Error
? exception.message
: '服务器内部错误';
response.status(status).json({
error: {
code: this.resolveCode(status),
message,
},
requestId: response.getHeader('x-request-id') ?? undefined,
});
}
private resolveCode(status: number) {
switch (status) {
case HttpStatus.BAD_REQUEST:
return 'BAD_REQUEST';
case HttpStatus.UNAUTHORIZED:
return 'UNAUTHORIZED';
case HttpStatus.FORBIDDEN:
return 'FORBIDDEN';
case HttpStatus.NOT_FOUND:
return 'NOT_FOUND';
case HttpStatus.CONFLICT:
return 'CONFLICT';
case HttpStatus.UNPROCESSABLE_ENTITY:
return 'VALIDATION_ERROR';
default:
return 'INTERNAL_SERVER_ERROR';
}
}
}

View File

@@ -0,0 +1,68 @@
import sanitizeHtml from 'sanitize-html';
const allowedTags = [
...sanitizeHtml.defaults.allowedTags,
'img',
'span',
'div',
'section',
'article',
'header',
'footer',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
'colgroup',
'col',
];
const allowedAttributes: sanitizeHtml.IOptions['allowedAttributes'] = {
...sanitizeHtml.defaults.allowedAttributes,
'*': [
'class',
'style',
'title',
'data-bind',
'data-ai-id',
'data-mode',
'data-placeholder-id',
'contenteditable',
'colspan',
'rowspan',
'align',
],
img: ['src', 'alt', 'width', 'height', 'class', 'style', 'data-*'],
a: ['href', 'name', 'target', 'rel'],
};
export const sanitizeReportHtml = (html: string) =>
sanitizeHtml(html || '', {
allowedTags,
allowedAttributes,
allowedSchemes: ['http', 'https', 'data'],
allowedSchemesByTag: {
img: ['http', 'https', 'data'],
},
allowedStyles: {
'*': {
color: [/^#[0-9a-f]{3,8}$/iu, /^rgb\(/iu, /^rgba\(/iu, /^[a-z]+$/iu],
'background-color': [/^#[0-9a-f]{3,8}$/iu, /^rgb\(/iu, /^rgba\(/iu, /^[a-z]+$/iu],
'font-size': [/^\d+(\.\d+)?(px|pt|em|rem|%)$/u],
'font-weight': [/^\d+$/u, /^(normal|bold|bolder|lighter)$/u],
'text-align': [/^(left|right|center|justify)$/u],
width: [/^\d+(\.\d+)?(px|%|em|rem)$/u],
height: [/^\d+(\.\d+)?(px|%|em|rem)$/u],
margin: [/^[\d.\spxemrem%auto-]+$/u],
padding: [/^[\d.\spxemrem%-]+$/u],
border: [/^[\w\s#().,%/-]+$/u],
'border-collapse': [/^collapse$/u],
display: [/^(block|inline|inline-block|flex|table|table-row|table-cell)$/u],
},
},
transformTags: {
a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer' }),
},
});