diff --git a/app/backend/src/server.ts b/app/backend/src/server.ts new file mode 100644 index 0000000..f8afc18 --- /dev/null +++ b/app/backend/src/server.ts @@ -0,0 +1,246 @@ +// 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 });