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:
54
server/src/common/api-exception.filter.ts
Normal file
54
server/src/common/api-exception.filter.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
68
server/src/common/html-sanitizer.ts
Normal file
68
server/src/common/html-sanitizer.ts
Normal 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' }),
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user