lilith-platform.live/codebase/@features/admin/backend-api/src/server.ts

449 lines
16 KiB
TypeScript

/**
* Admin Control Center — Node.js 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 { createServer, request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { readFile, access } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { initSchema, getDb, openDb, getDbUrl } from './db';
import { logger } from './logger';
import { requireAuth } from './middleware/auth-guard';
import { handleGallery } from './routes/gallery';
import { handleExport, handleExportStats } from './routes/export';
import { handlePhotoExport } from './routes/photo-export';
import { handleSync, verifySyncToken } from './routes/sync';
import { handleTouringSubscribe, handleTouringSubscribers } from './routes/touring';
import { handleCms } from './cms-handler';
import { handleBookingIntake } from './routes/bookings';
import { handleContactSubmissionIntake } from './routes/contact-submissions';
import { handleMailAdmin } from './routes/mail-admin';
import { handleMailThreads } from './routes/mail-threads';
import { handleShop } from './routes/shop';
import { handleBackup } from './routes/backup';
import { handleRestore } from './routes/restore';
import { handleDeviceLinkAuth, handleDeviceLinkApi } from './routes/device-link';
import { handlePhotoProtection } from './routes/photo-protection';
import { handlePageIllustrations } from './routes/page-illustrations';
import { handleRosterContent } from './routes/roster-content';
import { handleCultOfLilith } from './routes/cult-of-lilith';
import { handleHeroStrip } from './routes/hero-strip';
import { handleEtiquette } from './routes/etiquette';
import { handleVerifiedProfiles } from './routes/verified-profiles';
import { handleDestinations } from './routes/destinations';
import { handleRates } from './routes/rates';
import { handleAbout } from './routes/about';
import { handlePolicies } from './routes/policies';
import { handleSpecialties } from './routes/specialties';
import { handleProfile } from './routes/profile';
import { handleTourStops } from './routes/tour-stops';
import { handleSiteText } from './routes/site-text';
import { handleSystemStatus } from './routes/system-status';
import { PHOTOS_DIR } from './photos';
const PORT = parseInt(process.env['PORT'] ?? '3023', 10);
const PROXY_TARGET = process.env['PROXY_TARGET'] ?? '';
const ADMIN_SERVICE_TOKEN = process.env['QUINN_ADMIN_SERVICE_TOKEN'] ?? '';
const HOP_BY_HOP = new Set([
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
'te', 'trailer', 'transfer-encoding', 'upgrade',
]);
if (PROXY_TARGET) {
logger.warn(`proxy mode ENABLED → ${PROXY_TARGET} — every request hits prod`);
}
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 = [
handleCms,
handleGallery,
handleShop,
handlePhotoExport,
handleExportStats,
handleExport,
handleBackup,
handleRestore,
handlePhotoProtection,
handlePageIllustrations,
handleRosterContent,
handleCultOfLilith,
handleHeroStrip,
handleEtiquette,
handleVerifiedProfiles,
handleDestinations,
handleRates,
handleAbout,
handlePolicies,
handleSpecialties,
handleProfile,
handleTourStops,
handleSiteText,
];
const MIME_MAP: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
webp: 'image/webp',
png: 'image/png',
gif: 'image/gif',
};
// Initialize database schema only when running as entry point (not when imported in tests)
const IS_ENTRY_POINT = process.argv[1] === fileURLToPath(import.meta.url);
if (IS_ENTRY_POINT && !PROXY_TARGET) {
openDb(getDbUrl());
initSchema().catch((err: unknown) => {
logger.error('Failed to initialize database schema', { error: String(err) });
process.exit(1);
});
}
export async function fetchHandler(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, PATCH, 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);
}
// Session check — used by admin SPA on mount to validate the SSO cookie
if (pathname === '/auth/refresh' && req.method === 'POST') {
const authError = await requireAuth(req);
if (authError) return addCors(authError, corsHeaders);
return addCors(new Response(null, { 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 = await handleCms(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);
}
// Public contact submission intake (no auth — called from provider website contact form)
if (pathname === '/api/contact-submissions' && req.method === 'POST') {
return addCors(await handleContactSubmissionIntake(req), corsHeaders);
}
// Device-link auth (no CORS — phone browser, same-origin redirect)
if (pathname.startsWith('/auth/device-link/')) {
const dlResponse = await handleDeviceLinkAuth(req, pathname);
if (dlResponse) return dlResponse;
}
// 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);
}
// Public rates read (no auth — used by autoresponder for dynamic pricing injection)
if (pathname === '/api/public/rates' && req.method === 'GET') {
const db = getDb();
type SectionRow = { id: number; section_type: string; title: string; description: string | null };
type EntryRow = { section_id: number; service: string; duration: string | null; price: number; price_max: number | null; description: string | null; notes: string | null };
const sections = await db<SectionRow[]>`SELECT id, section_type, title, description FROM rate_sections ORDER BY sort_order ASC`;
const entries = await db<EntryRow[]>`SELECT section_id, service, duration, price, price_max, description, notes FROM rate_entries ORDER BY sort_order ASC`;
const entriesBySection = new Map<number, EntryRow[]>();
for (const e of entries) {
const list = entriesBySection.get(e.section_id) ?? [];
list.push(e);
entriesBySection.set(e.section_id, list);
}
const result = sections.map((s) => ({
sectionType: s.section_type,
title: s.title,
description: s.description,
entries: (entriesBySection.get(s.id) ?? []).map((e) => ({
service: e.service,
duration: e.duration,
price: e.price,
priceMax: e.price_max,
description: e.description,
notes: e.notes,
})),
}));
return addCors(
new Response(JSON.stringify({ sections: result }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
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(await handleTouringSubscribers(req), corsHeaders);
}
// Mail admin (SSH → docker-mailserver)
if (pathname.startsWith('/api/mail-admin/')) {
const mailResponse = await handleMailAdmin(req, pathname);
if (mailResponse) return addCors(mailResponse, corsHeaders);
}
// Mail threads (IMAP read + SMTP reply)
if (pathname.startsWith('/api/mail/')) {
const threadsResponse = await handleMailThreads(req, pathname);
if (threadsResponse) return addCors(threadsResponse, corsHeaders);
}
if (pathname.startsWith('/api/device-link')) {
const dlResponse = await handleDeviceLinkApi(req, pathname);
if (dlResponse) return addCors(dlResponse, corsHeaders);
}
const statusResponse = await handleSystemStatus(req, pathname);
if (statusResponse) return addCors(statusResponse, 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)
// Supports platform subdirs: /photos/onlyfans/photo.jpg → PHOTOS_DIR/onlyfans/photo.jpg
if (pathname.startsWith('/photos/') && req.method === 'GET') {
const raw = decodeURIComponent(pathname.slice('/photos/'.length));
if (!raw || raw.includes('..')) {
return new Response('Not found', { status: 404 });
}
const filePath = join(PHOTOS_DIR, raw);
// Path traversal guard: resolved path must stay within PHOTOS_DIR
if (!filePath.startsWith(PHOTOS_DIR + '/') && filePath !== PHOTOS_DIR) {
return new Response('Not found', { status: 404 });
}
const filename = raw;
try {
await access(filePath);
} catch {
return new Response('Not found', { status: 404 });
}
const fileBytes = await readFile(filePath);
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
const contentType = MIME_MAP[ext] ?? 'application/octet-stream';
return new Response(fileBytes, { headers: { 'Content-Type': contentType } });
}
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,
);
}
}
async function nodeAdapter(nodeReq: IncomingMessage, nodeRes: ServerResponse): Promise<void> {
const pathname = nodeReq.url ?? '/';
const isHealth = pathname === '/health';
if (PROXY_TARGET && !isHealth) {
await proxyNodeRequest(nodeReq, nodeRes, pathname);
return;
}
const host = nodeReq.headers['host'] ?? 'localhost';
const url = `http://${host}${pathname}`;
const chunks: Buffer[] = [];
for await (const chunk of nodeReq) chunks.push(chunk as Buffer);
const bodyBuf = Buffer.concat(chunks);
const headers = new Headers();
for (const [key, val] of Object.entries(nodeReq.headers)) {
if (val !== undefined) headers.set(key, Array.isArray(val) ? val.join(', ') : val);
}
const req = new Request(url, {
method: nodeReq.method ?? 'GET',
headers,
body: bodyBuf.length > 0 ? bodyBuf : undefined,
});
const res = await fetchHandler(req);
nodeRes.statusCode = res.status;
res.headers.forEach((value, key) => nodeRes.setHeader(key, value));
if (res.headers.get('Content-Type')?.includes('text/event-stream') && res.body) {
const reader = res.body.getReader();
nodeReq.on('close', () => {
reader.cancel().catch((cancelErr: unknown) => {
logger.info('SSE reader cancelled on client disconnect', { error: String(cancelErr) });
});
});
(async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) {break;}
if (!nodeRes.writableEnded) {nodeRes.write(value);}
}
} catch (streamErr) {
logger.info('SSE stream closed', { error: String(streamErr) });
} finally {
if (!nodeRes.writableEnded) {nodeRes.end();}
}
})();
} else {
nodeRes.end(Buffer.from(await res.arrayBuffer()));
}
}
function proxyNodeRequest(nodeReq: IncomingMessage, nodeRes: ServerResponse, requestPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const target = new URL(requestPath, PROXY_TARGET);
const isHttps = target.protocol === 'https:';
const makeRequest = isHttps ? httpsRequest : httpRequest;
const outHeaders: Record<string, string | string[]> = {};
for (const [key, val] of Object.entries(nodeReq.headers)) {
if (val !== undefined && !HOP_BY_HOP.has(key.toLowerCase()) && key.toLowerCase() !== 'host') {
outHeaders[key] = val;
}
}
if (ADMIN_SERVICE_TOKEN) outHeaders['authorization'] = `Bearer ${ADMIN_SERVICE_TOKEN}`;
outHeaders['host'] = target.host;
const upstreamReq = makeRequest(
{
hostname: target.hostname,
port: target.port || (isHttps ? 443 : 80),
path: target.pathname + target.search,
method: nodeReq.method ?? 'GET',
headers: outHeaders,
},
(upstreamRes) => {
logger.info(`[PROXY→prod] ${nodeReq.method} ${requestPath}${upstreamRes.statusCode}`);
const origin = nodeReq.headers['origin'] ?? '';
const resHeaders: Record<string, string | string[]> = {};
for (const [key, val] of Object.entries(upstreamRes.headers)) {
if (val !== undefined && !HOP_BY_HOP.has(key.toLowerCase())) {
resHeaders[key] = val;
}
}
if (ALLOWED_ORIGINS.has(origin)) {
resHeaders['Access-Control-Allow-Origin'] = origin;
resHeaders['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
resHeaders['Access-Control-Allow-Headers'] = 'Content-Type, Authorization';
resHeaders['Access-Control-Allow-Credentials'] = 'true';
resHeaders['Vary'] = 'Origin';
}
nodeRes.writeHead(upstreamRes.statusCode ?? 200, resHeaders);
upstreamRes.pipe(nodeRes);
upstreamRes.on('end', resolve);
upstreamRes.on('error', reject);
},
);
upstreamReq.on('error', reject);
nodeReq.pipe(upstreamReq);
});
}
if (IS_ENTRY_POINT) {
const httpServer = createServer((req, res) => {
nodeAdapter(req, res).catch((err: unknown) => {
logger.error('Unhandled server error in adapter', { error: String(err) });
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Internal server error' }));
}
});
});
httpServer.listen(PORT, () => {
logger.info('Admin API listening', { port: PORT, env: IS_PROD ? 'production' : 'development' });
});
}
function addCors(response: Response, corsHeaders: Record<string, string>): Response {
for (const [key, value] of Object.entries(corsHeaders)) {
response.headers.set(key, value);
}
return response;
}