449 lines
16 KiB
TypeScript
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;
|
|
}
|