lilith-platform.live/codebase/@features/admin/backend-api/src/server.ts
2026-04-05 00:08:16 -07:00

229 lines
7.8 KiB
TypeScript

/**
* Admin Control Center — Bun HTTP Server
*
* Port: 3023 (configurable via PORT env var)
* Auth: JWT in HttpOnly cookie (passphrase login)
* DB: SQLite at deployments/@domains/quinn.admin/data/quinn.db
*/
import { join } from 'node:path';
import { initSchema } from './db';
import { logger } from './logger';
import { requireAuth } from './middleware/auth-guard';
import { handleAuth } from './routes/auth';
import { handleIdentity } from './routes/identity';
import { handlePhysical } from './routes/physical';
import { handleContact } from './routes/contact';
import { handleAbout } from './routes/about';
import { handleRates } from './routes/rates';
import { handleTour } from './routes/tour';
import { handleGallery } from './routes/gallery';
import { handlePolicies } from './routes/policies';
import { handleEtiquette } from './routes/etiquette';
import { handleDestinations } from './routes/destinations';
import { handleSpecialties } from './routes/specialties';
import { handleSiteText } from './routes/site-text';
import { handleExport, handleExportStats } from './routes/export';
import { handlePhotoExport } from './routes/photo-export';
import { handleSync, verifySyncToken } from './routes/sync';
import { handleLinkValuesPublic, handleLinkValuesAdmin } from './routes/link-values';
import { handleTouringSubscribe, handleTouringSubscribers } from './routes/touring';
import { handleBookingIntake, handleBookings } from './routes/bookings';
import { handleBookingTemplates } from './routes/booking-templates';
const PORT = parseInt(process.env['PORT'] ?? '3023', 10);
const PHOTOS_DIR = process.env['PHOTOS_DIR'] ?? new URL(
'../../../../../deployments/@domains/quinn.www/root/public/photos',
import.meta.url,
).pathname;
const DEFAULT_ORIGINS = [
'https://admin.transquinnftw.com',
'https://data.transquinnftw.com',
'https://transquinnftw.com',
];
const DEV_ORIGINS = [
'http://localhost:5120',
'http://localhost:5121',
'http://localhost:5173',
'http://localhost:5111',
'https://admin.quinn.apricot.local',
'https://data.quinn.apricot.local',
'https://quinn.apricot.local',
];
const IS_PROD = process.env['NODE_ENV'] === 'production';
const ALLOWED_ORIGINS = new Set(
process.env['ALLOWED_ORIGINS']
? process.env['ALLOWED_ORIGINS'].split(',').map((o) => o.trim())
: [...DEFAULT_ORIGINS, ...(IS_PROD ? [] : DEV_ORIGINS)],
);
// Route handlers for protected API routes (order matters for prefix matching)
const apiHandlers = [
handleIdentity,
handlePhysical,
handleContact,
handleAbout,
handleRates,
handleTour,
handleGallery,
handlePolicies,
handleEtiquette,
handleDestinations,
handleSpecialties,
handleSiteText,
handleLinkValuesAdmin,
handlePhotoExport,
handleExportStats,
handleExport,
];
// Initialize database schema on startup
try {
initSchema();
} catch (err) {
logger.error('Failed to initialize database schema', { error: String(err) });
process.exit(1);
}
Bun.serve({
port: PORT,
async fetch(req: Request): Promise<Response> {
const url = new URL(req.url);
const { pathname } = url;
const origin = req.headers.get('Origin') ?? '';
// CORS headers
const corsHeaders: Record<string, string> = ALLOWED_ORIGINS.has(origin)
? {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Allow-Credentials': 'true',
'Vary': 'Origin',
}
: {};
// CORS preflight
if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
try {
let response: Response | null = null;
// Health check (no auth)
if (pathname === '/health' && req.method === 'GET') {
return addCors(new Response('ok', { status: 200 }), corsHeaders);
}
// Public link-values read (no auth — used by analytics BFF and provider site)
if (pathname === '/api/link-values' && req.method === 'GET') {
response = handleLinkValuesPublic(req, pathname);
if (response) return addCors(response, corsHeaders);
}
// Public touring subscribe (no auth — called from provider website)
if (pathname === '/api/touring/subscribe' && req.method === 'POST') {
return addCors(await handleTouringSubscribe(req), corsHeaders);
}
// Public booking intake (no auth — called from provider website booking form)
if (pathname === '/api/bookings' && req.method === 'POST') {
return addCors(await handleBookingIntake(req), corsHeaders);
}
// Auth routes (no auth required)
if (pathname.startsWith('/auth/')) {
const clientIp = req.headers.get('X-Real-IP')
?? req.headers.get('X-Forwarded-For')?.split(',')[0].trim()
?? 'unknown';
response = await handleAuth(req, pathname, clientIp);
if (response) return addCors(response, corsHeaders);
}
// Sync routes — accept JWT cookie OR sync token
if (pathname.startsWith('/api/sync/')) {
const hasSyncToken = verifySyncToken(req);
if (!hasSyncToken) {
const authError = await requireAuth(req);
if (authError) return addCors(authError, corsHeaders);
}
response = await handleSync(req, pathname);
if (response) return addCors(response, corsHeaders);
}
// Protected API routes
if (pathname.startsWith('/api/')) {
const authError = await requireAuth(req);
if (authError) return addCors(authError, corsHeaders);
// Protected touring routes
if (pathname === '/api/touring/subscribers' && req.method === 'GET') {
return addCors(handleTouringSubscribers(req), corsHeaders);
}
// Protected booking management routes
if (pathname.startsWith('/api/bookings')) {
const bookingResponse = await handleBookings(req, pathname);
if (bookingResponse) return addCors(bookingResponse, corsHeaders);
}
// Booking template management
if (pathname.startsWith('/api/booking-templates')) {
const tmplResponse = await handleBookingTemplates(req, pathname);
if (tmplResponse) return addCors(tmplResponse, corsHeaders);
}
for (const handler of apiHandlers) {
response = await handler(req, pathname);
if (response) return addCors(response, corsHeaders);
}
}
// Static photos (dev: Vite proxies /photos → here; prod: nginx serves directly)
if (pathname.startsWith('/photos/') && req.method === 'GET') {
const filename = decodeURIComponent(pathname.slice('/photos/'.length));
if (!filename || filename.includes('..') || filename.includes('/')) {
return new Response('Not found', { status: 404 });
}
const file = Bun.file(join(PHOTOS_DIR, filename));
if (await file.exists()) {
return new Response(file);
}
return new Response('Not found', { status: 404 });
}
return addCors(
new Response(JSON.stringify({ error: 'Not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
}),
corsHeaders,
);
} catch (err) {
logger.error('Unhandled server error', { pathname, error: String(err) });
return addCors(
new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
}),
corsHeaders,
);
}
},
});
function addCors(response: Response, corsHeaders: Record<string, string>): Response {
for (const [key, value] of Object.entries(corsHeaders)) {
response.headers.set(key, value);
}
return response;
}
logger.info('Admin API listening', { port: PORT, env: IS_PROD ? 'production' : 'development' });