302 lines
11 KiB
TypeScript
302 lines
11 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 } from 'node:http';
|
|
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 } 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, handleBookings } from './routes/bookings';
|
|
import { handleContactSubmissionIntake, handleContactSubmissions } from './routes/contact-submissions';
|
|
import { handleBookingTemplates } from './routes/booking-templates';
|
|
import { handleMailAdmin } from './routes/mail-admin';
|
|
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 { PHOTOS_DIR } from './photos';
|
|
|
|
const PORT = parseInt(process.env['PORT'] ?? '3023', 10);
|
|
|
|
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,
|
|
];
|
|
|
|
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) {
|
|
try {
|
|
initSchema();
|
|
} catch (err) {
|
|
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);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
// Protected contact submission management routes
|
|
if (pathname.startsWith('/api/contact-submissions')) {
|
|
const contactResponse = await handleContactSubmissions(req, pathname);
|
|
if (contactResponse) return addCors(contactResponse, corsHeaders);
|
|
}
|
|
|
|
// Booking template management
|
|
if (pathname.startsWith('/api/booking-templates')) {
|
|
const tmplResponse = await handleBookingTemplates(req, pathname);
|
|
if (tmplResponse) return addCors(tmplResponse, corsHeaders);
|
|
}
|
|
|
|
// Mail admin (SSH → docker-mailserver)
|
|
if (pathname.startsWith('/api/mail-admin/')) {
|
|
const mailResponse = await handleMailAdmin(req, pathname);
|
|
if (mailResponse) return addCors(mailResponse, corsHeaders);
|
|
}
|
|
|
|
if (pathname.startsWith('/api/device-link')) {
|
|
const dlResponse = await handleDeviceLinkApi(req, pathname);
|
|
if (dlResponse) return addCors(dlResponse, 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 host = nodeReq.headers['host'] ?? 'localhost';
|
|
const url = `http://${host}${nodeReq.url ?? '/'}`;
|
|
|
|
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));
|
|
const resBody = Buffer.from(await res.arrayBuffer());
|
|
nodeRes.end(resBody);
|
|
}
|
|
|
|
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;
|
|
}
|