Files
i-want-to-heal-2/server/index.mjs
T

220 lines
7.6 KiB
JavaScript

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)
}