229 lines
7.8 KiB
TypeScript
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' });
|