Files
teknav-bak/app/backend/src/server.ts
T
arsam ea897c34c4 Import source: app/backend/src/server
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
2026-09-18 23:20:45 +03:30

247 lines
8.8 KiB
TypeScript

// 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<Array<{ count: bigint }>>`
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 });