import { createReadStream, existsSync, statSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { createServer } from 'node:http' import { extname, join, normalize } from 'node:path' import { fileURLToPath } from 'node:url' import { createPool, waitForDatabase } from './db.mjs' const __dirname = fileURLToPath(new URL('.', import.meta.url)) const rootDir = normalize(join(__dirname, '..')) const distDir = join(rootDir, 'dist') const host = process.env.HOST ?? '0.0.0.0' const port = Number(process.env.PORT ?? 4173) const pool = createPool() const corsOrigins = new Set( (process.env.CORS_ORIGINS ?? '') .split(',') .map((origin) => origin.trim()) .filter(Boolean), ) const mimeTypes = { '.css': 'text/css; charset=utf-8', '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.map': 'application/json; charset=utf-8', '.png': 'image/png', '.svg': 'image/svg+xml', '.webp': 'image/webp', } await waitForDatabase(pool) const server = createServer(async (request, response) => { try { applyCors(request, response) if (request.method === 'OPTIONS') { response.writeHead(204) response.end() return } const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`) if (url.pathname.startsWith('/api/')) { await handleApi(request, response, url) return } await serveStatic(response, url.pathname) } catch (error) { console.error(error) sendJson(response, 500, { error: 'internal_error' }) } }) server.listen(port, host, () => { console.log(`Action Mode server listening on ${host}:${port}`) }) function applyCors(request, response) { const origin = request.headers.origin if (origin && corsOrigins.has(origin)) { response.setHeader('Access-Control-Allow-Origin', origin) response.setHeader('Vary', 'Origin') } response.setHeader('Access-Control-Allow-Credentials', 'true') response.setHeader('Access-Control-Allow-Headers', 'authorization,content-type,x-auth-issuer,x-auth-subject,x-display-name') response.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS') } async function handleApi(request, response, url) { if (request.method === 'GET' && url.pathname === '/api/health') { const db = await pool.query('select now() as now') sendJson(response, 200, { ok: true, database: 'ok', now: db.rows[0].now }) return } if (request.method === 'GET' && url.pathname === '/api/me') { const user = await requireUser(request, response) if (!user) return sendJson(response, 200, { user }) return } if (request.method === 'GET' && url.pathname.startsWith('/api/save/')) { const user = await requireUser(request, response) if (!user) return const slotKey = decodeURIComponent(url.pathname.replace('/api/save/', '')) || 'default' const save = await pool.query( 'select slot_key, save_json, save_version, client_updated_at, updated_at from save_slots where user_id = $1 and slot_key = $2', [user.id, slotKey], ) sendJson(response, 200, { save: save.rows[0] ?? null }) return } if (request.method === 'PUT' && url.pathname.startsWith('/api/save/')) { const user = await requireUser(request, response) if (!user) return const slotKey = decodeURIComponent(url.pathname.replace('/api/save/', '')) || 'default' const body = await readJson(request) if (!body || typeof body.save !== 'object') { sendJson(response, 400, { error: 'invalid_save_payload' }) return } const saved = await pool.query( ` insert into save_slots (user_id, slot_key, save_json, save_version, client_updated_at, updated_at) values ($1, $2, $3, $4, $5, now()) on conflict (user_id, slot_key) do update set save_json = excluded.save_json, save_version = excluded.save_version, client_updated_at = excluded.client_updated_at, updated_at = now() returning slot_key, save_json, save_version, client_updated_at, updated_at `, [user.id, slotKey, body.save, Number(body.saveVersion ?? 1), body.clientUpdatedAt ?? null], ) sendJson(response, 200, { save: saved.rows[0] }) return } if (request.method === 'POST' && url.pathname === '/api/pvp/queue') { const user = await requireUser(request, response) if (!user) return const body = await readJson(request) const mode = String(body?.mode ?? 'action-healer') const rating = Number(body?.rating ?? 1000) const queued = await pool.query( ` insert into pvp_queue (user_id, mode, rating, status, updated_at) values ($1, $2, $3, 'queued', now()) on conflict (user_id, mode, status) do update set rating = excluded.rating, updated_at = now() returning id, mode, rating, status, queued_at, updated_at `, [user.id, mode, rating], ) sendJson(response, 200, { queue: queued.rows[0] }) return } if (request.method === 'DELETE' && url.pathname === '/api/pvp/queue') { const user = await requireUser(request, response) if (!user) return const mode = url.searchParams.get('mode') ?? 'action-healer' await pool.query('delete from pvp_queue where user_id = $1 and mode = $2 and status = $3', [user.id, mode, 'queued']) sendJson(response, 200, { ok: true }) return } sendJson(response, 404, { error: 'not_found' }) } async function requireUser(request, response) { if (process.env.TRUST_AUTH_HEADERS !== '1') { sendJson(response, 401, { error: 'auth_not_configured' }) return null } const authSubject = request.headers['x-auth-subject'] if (!authSubject || Array.isArray(authSubject)) { sendJson(response, 401, { error: 'missing_auth_subject' }) return null } const authIssuerHeader = request.headers['x-auth-issuer'] const displayNameHeader = request.headers['x-display-name'] const authIssuer = Array.isArray(authIssuerHeader) ? authIssuerHeader[0] : authIssuerHeader || process.env.AUTH_ISSUER || 'https://auth.phenomrom.com' const displayName = Array.isArray(displayNameHeader) ? displayNameHeader[0] : displayNameHeader || 'Healer' const result = await pool.query( ` insert into app_users (auth_issuer, auth_subject, display_name, updated_at) values ($1, $2, $3, now()) on conflict (auth_issuer, auth_subject) do update set display_name = excluded.display_name, updated_at = now() returning id, auth_issuer, auth_subject, display_name, created_at, updated_at `, [authIssuer, authSubject, displayName], ) return result.rows[0] } async function readJson(request) { const chunks = [] for await (const chunk of request) chunks.push(chunk) if (chunks.length === 0) return null return JSON.parse(Buffer.concat(chunks).toString('utf8')) } function sendJson(response, status, payload) { response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }) response.end(JSON.stringify(payload)) } async function serveStatic(response, pathname) { const safePath = normalize(pathname).replace(/^(\.\.[/\\])+/, '') const candidatePath = join(distDir, safePath === '/' ? 'index.html' : safePath) const filePath = candidatePath.startsWith(distDir) && existsSync(candidatePath) && statSync(candidatePath).isFile() ? candidatePath : join(distDir, 'index.html') const contentType = mimeTypes[extname(filePath)] ?? 'application/octet-stream' response.writeHead(200, { 'Content-Type': contentType }) if (filePath.endsWith('index.html')) { response.end(await readFile(filePath)) return } createReadStream(filePath).pipe(response) }