// Fastify entry. Phase 3 wires session + CSRF + auth routes. // Public/admin API land in Phase 4-5. import Fastify from 'fastify'; import cors from '@fastify/cors'; import cookie from '@fastify/cookie'; import rateLimit from '@fastify/rate-limit'; import compress from '@fastify/compress'; import { statfs } from 'node:fs/promises'; import { config } from './config.js'; import { prisma } from './db.js'; import { redis, connectRedis } from './redis.js'; import sessionPlugin, { requireAuth } from './plugins/session.js'; import csrfPlugin from './plugins/csrf.js'; import requestIdPlugin from './plugins/request-id.js'; import securityHeadersPlugin from './plugins/security-headers.js'; import authRoutes from './routes/auth.js'; import signupRoutes from './routes/signup.js'; import oauthRoutes from './routes/oauth.js'; import passwordResetRoutes from './routes/password-reset.js'; import profileRoutes from './routes/profile.js'; import publicRoutes from './routes/public.js'; import adminRoutes from './routes/admin.js'; import captchaRoutes from './routes/captcha.js'; import commentRoutes from './routes/comments.js'; import historyRoutes from './routes/history.js'; import notificationRoutes from './routes/notifications.js'; import followRoutes from './routes/follows.js'; import listRoutes from './routes/lists.js'; import analyticsRoutes from './routes/analytics.js'; import adminStreamRoutes from './routes/admin-stream.js'; import qaRoutes from './routes/qa.js'; import monetizationRoutes from './routes/monetization.js'; import errorRoutes from './routes/errors.js'; import revisionRoutes from './routes/revisions.js'; import { wikiProposalRoutes } from './routes/wiki-proposals.js'; import { ragRoutes } from './routes/rag-gateway.js'; import { lmsRoutes } from './routes/lms.js'; import { directoryRoutes } from './routes/directory.js'; import metricsRoutes from './routes/metrics.js'; import { publishVisitorCount, recordActiveVisitor } from './lib/realtime.js'; import backendPackage from '../package.json' with { type: 'json' }; const isDev = config.NODE_ENV !== 'production'; const app = Fastify({ logger: { level: config.LOG_LEVEL, ...(isDev ? { transport: { target: 'pino-pretty', options: { colorize: true, translateTime: 'SYS:standard', ignore: 'pid,hostname' } } } : {}), serializers: { req(req) { return { method: req.method, url: req.url, remoteAddress: req.ip }; }, res(res) { return { statusCode: res.statusCode }; }, }, }, trustProxy: ['127.0.0.1', '::1'], bodyLimit: 1 * 1024 * 1024, }); app.setErrorHandler((err: Error & { code?: string; statusCode?: number; validation?: unknown }, req, reply) => { req.log.error({ err }, 'unhandled_request_error'); const isValidationError = err.code === 'FST_ERR_VALIDATION' || err.validation !== undefined; const status = err.statusCode && err.statusCode >= 400 ? err.statusCode : 500; reply.code(status).send({ error: isValidationError ? 'invalid_input' : err.statusCode && err.statusCode < 500 ? err.code ?? 'request_error' : 'internal_error', }); }); await connectRedis(); await app.register(cors, { origin: config.corsOrigins, credentials: true, }); await app.register(cookie, { secret: config.SESSION_SECRET, hook: 'onRequest', }); await app.register(sessionPlugin); await app.register(csrfPlugin); await app.register(requestIdPlugin); await app.register(securityHeadersPlugin); await app.register(compress, { global: true, encodings: ['br', 'gzip', 'deflate'] }); await app.register(rateLimit, { global: true, max: 600, timeWindow: '1 minute', redis, nameSpace: 'teknav-rate-limit:', allowList: ['127.0.0.1', '::1'], skipOnError: true, keyGenerator: (req) => req.ip, errorResponseBuilder: (_req, context) => ({ error: 'rate_limited', message: `Too many requests, retry in ${context.after}`, retryAfter: context.ttl, }), }); app.addHook('onRequest', async (req) => { await recordActiveVisitor(req).catch((err) => req.log.warn({ err }, 'failed to record active visitor')); }); await app.register(authRoutes); await app.register(signupRoutes); await app.register(oauthRoutes); await app.register(passwordResetRoutes); await app.register(profileRoutes); await app.register(publicRoutes); await app.register(commentRoutes); await app.register(historyRoutes); await app.register(notificationRoutes); await app.register(followRoutes); await app.register(listRoutes); await app.register(analyticsRoutes); await app.register(adminRoutes); await app.register(adminStreamRoutes); await app.register(captchaRoutes); await app.register(qaRoutes); await app.register(monetizationRoutes); await app.register(errorRoutes); await app.register(revisionRoutes); await app.register(wikiProposalRoutes); await app.register(ragRoutes); await app.register(lmsRoutes); await app.register(directoryRoutes); await app.register(metricsRoutes); const visitorInterval = setInterval(() => { publishVisitorCount().catch((err) => app.log.warn({ err }, 'failed to publish visitor count')); }, 10_000); app.addHook('onClose', async () => clearInterval(visitorInterval)); app.addHook('onResponse', (req, reply, done) => { app.log.info({ requestId: (req as any).requestId, userId: req.session?.userId ?? null, method: req.method, route: req.url, statusCode: reply.statusCode, durationMs: Math.round(reply.elapsedTime), }, 'request_completed'); done(); }); async function runHealthChecks() { const [pgOk, redisOk] = await Promise.all([ prisma.$queryRaw`SELECT 1 AS ok`.then(() => true).catch(() => false), redis.ping().then((r: string) => r === 'PONG').catch(() => false), ]); let postgresConnections: number | null = null; try { const rows = await prisma.$queryRaw>` SELECT COUNT(*) AS count FROM pg_stat_activity WHERE datname = current_database() `; postgresConnections = Number(rows[0]?.count ?? 0); } catch {} let redisMemoryMb: number | null = null; let redisConnectedClients: number | null = null; try { const [memInfo, clientInfo] = await Promise.all([redis.info('memory'), redis.info('clients')]); const memMatch = memInfo.match(/used_memory:(\d+)/); if (memMatch) redisMemoryMb = Math.round(Number(memMatch[1]) / 1024 / 1024 * 10) / 10; const clientMatch = clientInfo.match(/connected_clients:(\d+)/); if (clientMatch) redisConnectedClients = Number(clientMatch[1]); } catch {} let lastPublishedAt: Date | null = null; try { const last = await prisma.article.findFirst({ where: { publishedAt: { not: null } }, orderBy: { publishedAt: 'desc' }, select: { publishedAt: true }, }); lastPublishedAt = last?.publishedAt ?? null; } catch {} const uploadDir = process.env.UPLOAD_DIR ?? './uploads'; let uploadsDir: { path: string; exists: boolean; freeMb: number | null } = { path: uploadDir, exists: false, freeMb: null }; try { const stats = await statfs(uploadDir); uploadsDir = { path: uploadDir, exists: true, freeMb: Math.round((stats.bfree * stats.bsize) / 1024 / 1024) }; } catch {} return { ok: pgOk && redisOk, postgres: pgOk, redis: redisOk, postgresConnections, redisMemoryMb, redisConnectedClients, lastPublishedAt, uploadsDir, version: (backendPackage as { version?: string }).version ?? '0.0.0', environment: config.NODE_ENV, uptimeSeconds: Math.round(process.uptime()), features: { smtpConfigured: Boolean(config.SMTP_HOST), smsConfigured: Boolean(config.SMS_PROVIDER && config.SMS_API_KEY), oauthGoogleConfigured: Boolean(config.GOOGLE_CLIENT_ID && config.GOOGLE_CLIENT_SECRET), oauthGithubConfigured: Boolean(config.GITHUB_CLIENT_ID && config.GITHUB_CLIENT_SECRET), webPushConfigured: Boolean(process.env.VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY), zarinpalConfigured: Boolean(process.env.ZARINPAL_MERCHANT_ID), storageBackend: process.env.STORAGE_BACKEND ?? 'local', }, }; } app.get('/api/v1/health', async () => { const pgOk = await prisma.$queryRaw`SELECT 1 AS ok`.then(() => true).catch(() => false); const redisOk = await redis.ping().then((r: string) => r === 'PONG').catch(() => false); return { ok: pgOk && redisOk }; }); app.get('/api/v1/ready', async (_req, reply) => { const pgOk = await prisma.$queryRaw`SELECT 1 AS ok`.then(() => true).catch(() => false); const redisOk = await redis.ping().then((r: string) => r === 'PONG').catch(() => false); if (!pgOk || !redisOk) return reply.code(503).send({ ok: false }); return { ok: true }; }); app.get('/api/v1/admin/health', { preHandler: requireAuth(['admin']) }, async () => { return runHealthChecks(); }); const close = async () => { app.log.info('shutting down'); await app.close(); await redis.quit(); await prisma.$disconnect(); process.exit(0); }; process.on('SIGINT', close); process.on('SIGTERM', close); await app.listen({ host: '0.0.0.0', port: config.PORT });