import { createReadStream, existsSync, statSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { createHash, randomBytes, scryptSync, timingSafeEqual, } from 'node:crypto' 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 sessionCookieName = 'iwanttoheal2_session' const sessionLifetimeSeconds = 60 * 60 * 24 * 30 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) { const status = Number(error?.status) || (request.url?.startsWith('/api/') ? 400 : 500) if (status >= 500) console.error(error) sendJson(response, status, { error: status >= 500 ? 'internal_error' : error instanceof Error ? error.message : 'Unable to process request.', }) } }) 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 (await handleAuthApi(request, response, url)) return 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 handleAuthApi(request, response, url) { if (!url.pathname.startsWith('/api/auth/')) return false if (request.method === 'POST' && url.pathname === '/api/auth/register') { const body = await readJson(request) const result = await registerAccount(request, body) sendJson(response, 201, result, { 'Set-Cookie': sessionCookie(result.token, request) }) return true } if (request.method === 'POST' && url.pathname === '/api/auth/login') { const body = await readJson(request) const result = await loginAccount(request, body) sendJson(response, 200, result, { 'Set-Cookie': sessionCookie(result.token, request) }) return true } if (request.method === 'GET' && url.pathname === '/api/auth/session') { const session = await currentSession(request) sendJson(response, 200, { account: session ? accountPayload(session) : null, profile: null, }) return true } if (request.method === 'POST' && url.pathname === '/api/auth/logout') { const token = requestSessionToken(request) if (token) { await pool.query('delete from sessions where token_hash = $1', [tokenHash(token)]) } sendJson(response, 200, { ok: true }, { 'Set-Cookie': sessionCookie('', request, 0) }) return true } sendJson(response, 404, { error: 'not_found' }) return true } async function requireUser(request, response) { const session = await currentSession(request) if (session) return session 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] } function normalizeUsername(value) { const username = String(value ?? '').trim() if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) { throw new Error('Username must be 3-20 letters, numbers, or underscores.') } return username } function normalizeDisplayName(value, fallback) { const name = String(value ?? fallback).trim() if (!/^[A-Za-z][A-Za-z0-9 '-]{1,19}$/.test(name)) { throw new Error('Character name must be 2-20 characters and start with a letter.') } return name } function validatePassword(value) { const password = String(value ?? '') if (password.length < 10 || password.length > 128) { throw new Error('Password must be 10-128 characters.') } return password } function passwordDigest(password, salt) { return scryptSync(password, salt, 64).toString('hex') } function verifyPassword(password, user) { const actual = Buffer.from(passwordDigest(password, user.password_salt), 'hex') const expected = Buffer.from(user.password_hash, 'hex') return actual.length === expected.length && timingSafeEqual(actual, expected) } function tokenHash(token) { return createHash('sha256').update(token).digest('hex') } function parseCookies(request) { return Object.fromEntries( String(request.headers.cookie ?? '') .split(';') .map((part) => part.trim()) .filter(Boolean) .map((part) => { const separator = part.indexOf('=') return separator < 0 ? [part, ''] : [part.slice(0, separator), decodeURIComponent(part.slice(separator + 1))] }), ) } function bearerToken(request) { const authorization = request.headers.authorization if (typeof authorization !== 'string') return '' const match = authorization.match(/^Bearer\s+(.+)$/i) return match ? match[1].trim() : '' } function requestSessionToken(request) { return bearerToken(request) || parseCookies(request)[sessionCookieName] || '' } function sessionCookie(token, request, maxAge = sessionLifetimeSeconds) { const secure = request.headers['x-forwarded-proto'] === 'https' || Boolean(request.socket.encrypted) || process.env.COOKIE_SECURE === '1' return [ `${sessionCookieName}=${encodeURIComponent(token)}`, 'HttpOnly', 'Path=/', 'SameSite=Lax', `Max-Age=${maxAge}`, secure ? 'Secure' : '', ].filter(Boolean).join('; ') } function requestIp(request) { if (process.env.TRUST_PROXY === '1') { const forwarded = request.headers['x-forwarded-for'] if (typeof forwarded === 'string') return forwarded.split(',')[0].trim() } return request.socket.remoteAddress ?? 'unknown' } async function createSession(userId, ip) { const token = randomBytes(32).toString('base64url') await pool.query( ` insert into sessions (user_id, token_hash, expires_at, created_ip) values ($1, $2, now() + interval '30 days', $3) `, [userId, tokenHash(token), ip], ) return token } async function currentSession(request) { const token = requestSessionToken(request) if (!token) return null await pool.query('delete from sessions where expires_at <= now()') const result = await pool.query( ` select app_users.id, app_users.username, app_users.display_name, app_users.auth_issuer, app_users.auth_subject from sessions join app_users on app_users.id = sessions.user_id where sessions.token_hash = $1 and sessions.expires_at > now() `, [tokenHash(token)], ) return result.rows[0] ?? null } function accountPayload(user) { return { id: user.id, username: user.username ?? user.auth_subject, displayName: user.display_name, } } async function registerAccount(request, payload) { const username = normalizeUsername(payload?.username) const normalizedSubject = username.toLowerCase() const password = validatePassword(payload?.password) const displayName = normalizeDisplayName(payload?.characterName, username) const existing = await pool.query( 'select id from app_users where username is not null and lower(username) = lower($1)', [username], ) if (existing.rows[0]) throw new Error('That username is already taken.') const salt = randomBytes(16).toString('hex') const result = await pool.query( ` insert into app_users (auth_issuer, auth_subject, username, password_hash, password_salt, display_name) values ('local', $1, $2, $3, $4, $5) returning id, username, display_name, auth_issuer, auth_subject `, [normalizedSubject, username, passwordDigest(password, salt), salt, displayName], ) const user = result.rows[0] const token = await createSession(user.id, requestIp(request)) return { account: accountPayload(user), profile: null, token } } async function loginAccount(request, payload) { const username = normalizeUsername(payload?.username) const password = String(payload?.password ?? '') const result = await pool.query( ` select id, username, password_hash, password_salt, display_name, auth_issuer, auth_subject from app_users where username is not null and lower(username) = lower($1) `, [username], ) const user = result.rows[0] if (!user || !user.password_hash || !user.password_salt || !verifyPassword(password, user)) { throw new Error('Invalid username or password.') } const token = await createSession(user.id, requestIp(request)) return { account: accountPayload(user), profile: null, token } } async function readJson(request, maxSize = 512 * 1024) { const chunks = [] let size = 0 for await (const chunk of request) chunks.push(chunk) for (const chunk of chunks) size += chunk.length if (size > maxSize) throw new Error('Request body is too large.') if (chunks.length === 0) return null return JSON.parse(Buffer.concat(chunks).toString('utf8')) } function sendJson(response, status, payload, headers = {}) { response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...headers }) 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) }