220 lines
7.6 KiB
JavaScript
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)
|
|
}
|