perf: prepared statements, CSP, digest pin, вырезание design-preview, нагрузочные бюджеты, jsdom-тесты web, composite CI, SBOM/Trivy
This commit is contained in:
1 parent
08be310e6b
commit
1a68597d48
31 files changed
+1313
-158
No files matched your search
+26
-2
@@ -1,5 +1,6 @@
|
||||
import type { FastifyError, FastifyInstance } from 'fastify'
|
||||
import Fastify from 'fastify'
|
||||
import compress from '@fastify/compress'
|
||||
import cors from '@fastify/cors'
|
||||
import helmet from '@fastify/helmet'
|
||||
import rateLimit from '@fastify/rate-limit'
|
||||
@@ -51,6 +52,9 @@ import { createOriginChecker } from './lib/cors.js'
|
||||
|
||||
/** Пути сервера: для них SPA-fallback не применяется (404 JSON вместо index.html). */
|
||||
const API_PREFIXES = ['/api', '/oauth', '/.well-known', '/assets/']
|
||||
function boolFlag(v: string): boolean {
|
||||
return v === '1' || v.toLowerCase() === 'true'
|
||||
}
|
||||
const HASHED_ASSET_RE = /[\\/]assets[\\/]/
|
||||
const INDEX_HTML_RE = /[\\/]index\.html$/
|
||||
|
||||
@@ -167,14 +171,34 @@ export async function buildApp(opts: {
|
||||
const originAllowed = createOriginChecker(config, () =>
|
||||
getAppSwitcherConfig(db).apps.map((a) => a.url),
|
||||
)
|
||||
if (boolFlag(config.enableCompress)) {
|
||||
// Предсжатая статика уже с Content-Encoding — плагин её не трогает.
|
||||
await app.register(compress, { threshold: 1024, encodings: ['br', 'gzip'] })
|
||||
}
|
||||
await app.register(formbody)
|
||||
await app.register(cors, {
|
||||
origin: (origin, cb) => cb(null, !origin || originAllowed(origin)),
|
||||
credentials: true,
|
||||
})
|
||||
await app.register(helmet, { contentSecurityPolicy: false })
|
||||
await app.register(helmet, {
|
||||
// SPA целиком на своём origin; inline-стили нужны React/Tailwind (style-атрибуты).
|
||||
contentSecurityPolicy: {
|
||||
useDefaults: false,
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
||||
fontSrc: ["'self'", 'data:'],
|
||||
connectSrc: ["'self'"],
|
||||
objectSrc: ["'none'"],
|
||||
baseUri: ["'self'"],
|
||||
frameAncestors: ["'none'"],
|
||||
},
|
||||
},
|
||||
})
|
||||
await app.register(rateLimit, {
|
||||
max: 200,
|
||||
max: config.rateLimitMax,
|
||||
timeWindow: '1 minute',
|
||||
// пробы Docker/Traefik не должны упираться в лимит
|
||||
allowList: (req) => req.url === '/health' || req.url === '/ready',
|
||||
|
||||
@@ -39,6 +39,10 @@ export const configSchema = z.object({
|
||||
backupKeep: z.coerce.number().int().positive().default(7),
|
||||
/** Период встроенного бэкапа в часах; 0 — выключен (используйте cron/CLI). */
|
||||
backupIntervalHours: z.coerce.number().min(0).default(0),
|
||||
/** Сжатие ответов в Node (без Traefik compress). Включайте, только если перед приложением нет сжимающего прокси. */
|
||||
enableCompress: z.string().default('false'),
|
||||
/** Глобальный лимит запросов на IP в минуту (per-route лимиты задаются отдельно). */
|
||||
rateLimitMax: z.coerce.number().int().positive().default(200),
|
||||
/** Доп. разрешённые CORS-origins через запятую ('*' — все). */
|
||||
corsOrigins: z.string().default(''),
|
||||
/** Одновременных argon2-хешей (каждый ~19 МиБ). */
|
||||
@@ -121,6 +125,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
backupDir: env.BACKUP_DIR || undefined,
|
||||
backupKeep: env.BACKUP_KEEP ?? 7,
|
||||
backupIntervalHours: env.BACKUP_INTERVAL_HOURS ?? 0,
|
||||
enableCompress: env.ENABLE_COMPRESS ?? 'false',
|
||||
rateLimitMax: env.RATE_LIMIT_MAX ?? 200,
|
||||
corsOrigins: env.CORS_ORIGINS ?? '',
|
||||
argon2Concurrency: env.ARGON2_CONCURRENCY ?? 2,
|
||||
trustProxy: env.TRUST_PROXY ?? DEFAULT_TRUST_PROXY,
|
||||
|
||||
Reference in new issue
Block a user