151 lines
5.2 KiB
TypeScript
151 lines
5.2 KiB
TypeScript
import path from 'node:path';
|
|||
|
|
import { fileURLToPath } from 'node:url';
|
||
|
|
import fs from 'node:fs';
|
||
|
|
import Fastify from 'fastify';
|
||
|
|
import { ZodError } from 'zod';
|
||
|
|
import cookie from '@fastify/cookie';
|
||
|
|
import helmet from '@fastify/helmet';
|
||
|
|
import rateLimit from '@fastify/rate-limit';
|
||
|
|
import staticPlugin from '@fastify/static';
|
||
|
|
import { env, isProd } from './env';
|
||
|
|
import { initSentry, captureError } from './lib/sentry';
|
||
|
|
import { authPlugin } from './auth/plugin';
|
||
|
|
import { csrfPlugin } from './auth/csrf';
|
||
|
|
import { authRoutes } from './routes/auth';
|
||
|
|
import { healthRoutes } from './routes/health';
|
||
|
|
import { contactRoutes } from './routes/contact';
|
||
|
|
import { clientsRoutes } from './routes/clients';
|
||
|
|
import { casesRoutes } from './routes/cases';
|
||
|
|
import { timeEntriesRoutes } from './routes/time-entries';
|
||
|
|
import { invoicesRoutes } from './routes/invoices';
|
||
|
|
import { adminRoutes } from './routes/admin';
|
||
|
|
import { accountRoutes } from './routes/account';
|
||
|
|
import { toolUsageRoutes } from './routes/tool-usage';
|
||
|
|
import { billingRoutes } from './routes/billing';
|
||
|
|
import { stripeWebhookRoute } from './routes/webhooks-stripe';
|
||
|
|
|
||
|
|
const __filename = fileURLToPath(import.meta.url);
|
||
|
|
const __dirname = path.dirname(__filename);
|
||
|
|
|
||
|
|
initSentry();
|
||
|
|
|
||
|
|
export async function buildServer() {
|
||
|
|
const app = Fastify({
|
||
|
|
logger: isProd
|
||
|
|
? { level: 'info' }
|
||
|
|
: { level: 'debug', transport: { target: 'pino-pretty', options: { colorize: true } } },
|
||
|
|
trustProxy: true,
|
||
|
|
bodyLimit: 5 * 1024 * 1024,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Register the global error handler EARLY so it wins over plugin-default handlers and
|
||
|
|
// catches ZodErrors thrown by .parse() inside route handlers.
|
||
|
|
app.setErrorHandler((err, req, reply) => {
|
||
|
|
if (err instanceof ZodError || (err as { validation?: unknown }).validation || err.name === 'ZodError') {
|
||
|
|
req.log.info({ err }, 'validation error');
|
||
|
|
return reply
|
||
|
|
.code(400)
|
||
|
|
.send({ error: 'validation', details: err instanceof ZodError ? err.errors : (err as Error).message });
|
||
|
|
}
|
||
|
|
if ((err as { statusCode?: number }).statusCode === 429) {
|
||
|
|
// Let @fastify/rate-limit handle its own response shape.
|
||
|
|
return reply.send(err);
|
||
|
|
}
|
||
|
|
req.log.error({ err }, 'unhandled error');
|
||
|
|
captureError(err, { url: req.url, method: req.method, userId: req.user?.id });
|
||
|
|
return reply.code(500).send({ error: 'internal_error' });
|
||
|
|
});
|
||
|
|
|
||
|
|
// CSP: tight in production, off in dev (Vite HMR injects inline scripts/styles + uses eval)
|
||
|
|
await app.register(helmet, {
|
||
|
|
contentSecurityPolicy: isProd
|
||
|
|
? {
|
||
|
|
directives: {
|
||
|
|
defaultSrc: ["'self'"],
|
||
|
|
scriptSrc: ["'self'"],
|
||
|
|
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
|
||
|
|
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'],
|
||
|
|
imgSrc: ["'self'", 'data:', 'blob:'],
|
||
|
|
connectSrc: ["'self'"],
|
||
|
|
frameAncestors: ["'none'"],
|
||
|
|
formAction: ["'self'"],
|
||
|
|
baseUri: ["'self'"],
|
||
|
|
objectSrc: ["'none'"],
|
||
|
|
upgradeInsecureRequests: [],
|
||
|
|
},
|
||
|
|
}
|
||
|
|
: false,
|
||
|
|
crossOriginEmbedderPolicy: false,
|
||
|
|
});
|
||
|
|
|
||
|
|
await app.register(cookie, {
|
||
|
|
secret: env.SESSION_SECRET,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Global rate limit floor — per-route limits override below.
|
||
|
|
await app.register(rateLimit, {
|
||
|
|
global: true,
|
||
|
|
max: 600,
|
||
|
|
timeWindow: '1 minute',
|
||
|
|
keyGenerator: (req) => `${req.ip}`,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Stripe webhook BEFORE auth/CSRF — registered as its own subapp with a buffer-only parser
|
||
|
|
// so signature verification works against the raw body.
|
||
|
|
await app.register(stripeWebhookRoute);
|
||
|
|
|
||
|
|
await app.register(authPlugin);
|
||
|
|
await app.register(csrfPlugin);
|
||
|
|
|
||
|
|
await app.register(authRoutes);
|
||
|
|
await app.register(healthRoutes);
|
||
|
|
await app.register(contactRoutes);
|
||
|
|
await app.register(clientsRoutes);
|
||
|
|
await app.register(casesRoutes);
|
||
|
|
await app.register(timeEntriesRoutes);
|
||
|
|
await app.register(invoicesRoutes);
|
||
|
|
await app.register(adminRoutes);
|
||
|
|
await app.register(accountRoutes);
|
||
|
|
await app.register(toolUsageRoutes);
|
||
|
|
await app.register(billingRoutes);
|
||
|
|
|
||
|
|
// Serve the built SPA in production. In dev, the Vite dev server runs separately.
|
||
|
|
const webDist = env.WEB_DIST_PATH ?? path.resolve(__dirname, '../../web/dist');
|
||
|
|
if (fs.existsSync(webDist)) {
|
||
|
|
await app.register(staticPlugin, {
|
||
|
|
root: webDist,
|
||
|
|
prefix: '/',
|
||
|
|
cacheControl: true,
|
||
|
|
maxAge: '1y',
|
||
|
|
immutable: true,
|
||
|
|
decorateReply: false,
|
||
|
|
});
|
||
|
|
|
||
|
|
// SPA fallback: any non-/api path returns index.html
|
||
|
|
app.setNotFoundHandler((req, reply) => {
|
||
|
|
if (req.raw.url?.startsWith('/api/')) {
|
||
|
|
return reply.code(404).send({ error: 'not_found' });
|
||
|
|
}
|
||
|
|
return reply.type('text/html').sendFile('index.html', webDist);
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
app.log.warn({ webDist }, 'web/dist not found — SPA assets will not be served');
|
||
|
|
}
|
||
|
|
|
||
|
|
return app;
|
||
|
|
}
|
||
|
|
|
||
|
|
const isEntrypoint = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
||
|
|
|
||
|
|
if (isEntrypoint) {
|
||
|
|
const app = await buildServer();
|
||
|
|
try {
|
||
|
|
await app.listen({ host: '0.0.0.0', port: env.PORT });
|
||
|
|
app.log.info(`eLegal Software API listening on :${env.PORT}`);
|
||
|
|
} catch (err) {
|
||
|
|
app.log.error(err);
|
||
|
|
captureError(err);
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
}
|