import type { FastifyError, FastifyInstance } from 'fastify' import Fastify from 'fastify' import cors from '@fastify/cors' import helmet from '@fastify/helmet' import rateLimit from '@fastify/rate-limit' import formbody from '@fastify/formbody' import fjwt from '@fastify/jwt' import fastifyStatic from '@fastify/static' import { existsSync, mkdirSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { APP_IDS, allPermissionKeys, } from '@authportal/shared' import { closeDb, createDb, getAppSwitcherConfig, getUserApps, hasAnyUser, listAdmins, healthCheck, migrateSchema, setUserAccess, users, type AppDb, type Sqlite, } from '@authportal/db' import { hashPassword, setPasswordConcurrency } from './lib/password.js' import { randomUUID } from 'node:crypto' import { assertBootstrapPassword, parseTrustProxy, type AppConfig, } from './config.js' import { authRoutes } from './routes/auth.js' import { adminRoutes } from './routes/admin.js' import { auditAdminRoutes } from './routes/audit.js' import { auditIngestRoutes } from './routes/ingest-audit.js' import { oidcRoutes } from './routes/oidc.js' import { webauthnRoutes } from './routes/webauthn.js' import { startAuditRetentionJob } from './services/audit-retention.js' import { startHousekeepingJob } from './services/housekeeping.js' import { startBackupJob } from './services/backup.js' import { ensureOidcSigningKey, isOidcKeyReady, resetOidcKeyCache, } from './lib/oidc/keys.js' import { createOriginChecker } from './lib/cors.js' /** Пути сервера: для них SPA-fallback не применяется (404 JSON вместо index.html). */ const API_PREFIXES = ['/api', '/oauth', '/.well-known'] const HASHED_ASSET_RE = /[\\/]assets[\\/]/ const INDEX_HTML_RE = /[\\/]index\.html$/ declare module 'fastify' { interface FastifyInstance { config: AppConfig db: AppDb sqlite: Sqlite } } export async function ensureBootstrapAdmin(app: FastifyInstance): Promise { if (!hasAnyUser(app.db)) { assertBootstrapPassword(app.config) const now = new Date().toISOString() const passwordHash = await hashPassword(app.config.adminPassword) const id = randomUUID() app.db .insert(users) .values({ id, email: app.config.adminEmail.toLowerCase(), name: app.config.adminName, passwordHash, isAdmin: true, disabled: false, createdAt: now, updatedAt: now, }) .run() setUserAccess(app.db, id, [...APP_IDS], allPermissionKeys()) app.log.info(`bootstrap admin created: ${app.config.adminEmail}`) return } // Ensure existing admins have app tiles if access was never set for (const user of listAdmins(app.db)) { const apps = getUserApps(app.db, user.id) if (apps.length === 0) { setUserAccess(app.db, user.id, [...APP_IDS], allPermissionKeys()) app.log.info(`granted full catalog access to admin ${user.email}`) } } } export async function buildApp(opts: { config: AppConfig databaseUrl?: string }): Promise { const config = opts.config const dbUrl = opts.databaseUrl ?? config.databaseUrl const path = dbUrl.replace(/^sqlite:/, '') if (path !== ':memory:') { mkdirSync(dirname(resolve(path)), { recursive: true }) } setPasswordConcurrency(config.argon2Concurrency) const { db, sqlite } = createDb(dbUrl) migrateSchema(sqlite) const app = Fastify({ logger: { level: config.logLevel, // Секреты не должны попадать в логи, даже если сериализатор начнёт отдавать заголовки. redact: { paths: [ 'req.headers.authorization', 'req.headers.cookie', 'res.headers["set-cookie"]', ], censor: '[redacted]', }, }, trustProxy: parseTrustProxy(config.trustProxy), // keepAlive выше idle-таймаута Traefik (60 с), иначе 502 на переиспользованном соединении keepAliveTimeout: 65_000, connectionTimeout: 0, requestTimeout: 30_000, bodyLimit: 1_048_576, }) app.decorate('config', config) app.decorate('db', db) app.decorate('sqlite', sqlite) app.addHook('onClose', async () => { closeDb(sqlite) }) app.setErrorHandler((err: FastifyError | Error, req, reply) => { const status = err.name === 'ZodError' ? 400 : (err as FastifyError).statusCode && (err as FastifyError).statusCode! >= 400 ? (err as FastifyError).statusCode! : 500 if (status >= 500) { req.log.error(err) return reply.status(500).send({ error: { code: 'INTERNAL', message: 'Internal server error' }, }) } const code = status === 429 ? 'RATE_LIMITED' : status === 400 ? 'BAD_REQUEST' : ((err as FastifyError).code ?? 'ERROR') return reply.status(status).send({ error: { code, message: err.message } }) }) const originAllowed = createOriginChecker(config, () => getAppSwitcherConfig(db).apps.map((a) => a.url), ) 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(rateLimit, { max: 200, timeWindow: '1 minute', // пробы Docker/Traefik не должны упираться в лимит allowList: (req) => req.url === '/health' || req.url === '/ready', }) await app.register(fjwt, { secret: config.jwtSecret }) // liveness: процесс жив и отвечает; БД не трогаем, чтобы пробы не рестартили // контейнер из-за временной блокировки. app.get('/health', async () => ({ ok: true })) // readiness: БД отвечает и ключ подписи OIDC загружен. app.get('/ready', async (_request, reply) => { try { healthCheck(sqlite) } catch { return reply.status(503).send({ ok: false, reason: 'db' }) } if (!isOidcKeyReady()) { return reply.status(503).send({ ok: false, reason: 'oidc_key' }) } return { ok: true } }) app.get('/api/v1/health', async () => ({ ok: true, service: 'auth-portal' })) resetOidcKeyCache() await ensureOidcSigningKey(app) await ensureBootstrapAdmin(app) await app.register(authRoutes) await app.register(adminRoutes) await app.register(auditAdminRoutes) await app.register(auditIngestRoutes) await app.register(oidcRoutes) await app.register(webauthnRoutes) if (process.env.NODE_ENV !== 'test') { const stopRetention = startAuditRetentionJob(app) const stopHousekeeping = startHousekeepingJob(app) const stopBackup = startBackupJob(app) app.addHook('onClose', async () => { stopRetention() stopHousekeeping() stopBackup() }) if (!config.oidcRsaPrivateKey && config.backupIntervalHours <= 0) { app.log.warn( 'OIDC-ключ подписи хранится только в SQLite, а встроенный бэкап выключен: ' + 'задайте OIDC_RSA_PRIVATE_KEY или BACKUP_INTERVAL_HOURS / запускайте `backup` по cron', ) } } if (config.staticDir && existsSync(config.staticDir)) { await app.register(fastifyStatic, { root: resolve(config.staticDir), wildcard: false, // .gz/.br для статики готовит Dockerfile на этапе сборки, здесь только отдаём. preCompressed: true, // Cache-Control выставляем сами (иначе плагин перезаписывает заголовок). cacheControl: false, setHeaders(res, filePath) { if (HASHED_ASSET_RE.test(filePath)) { // Vite кладёт хеш в имя файла → кэш на год без ревалидации. res.setHeader('Cache-Control', 'public, max-age=31536000, immutable') } else if (INDEX_HTML_RE.test(filePath)) { // Точка входа должна ревалидироваться, иначе клиент застрянет на старых чанках. res.setHeader('Cache-Control', 'no-cache') } else { res.setHeader('Cache-Control', 'public, max-age=0') } }, }) } const spa = Boolean(config.staticDir && existsSync(config.staticDir)) app.setNotFoundHandler((req, reply) => { if ( spa && req.method === 'GET' && !API_PREFIXES.some((p) => req.url.startsWith(p)) ) { return reply.sendFile('index.html') } return reply .status(404) .send({ error: { code: 'NOT_FOUND', message: 'Not found' } }) }) return app }