Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
247 lines
8.8 KiB
TypeScript
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 });
|