584 lines
19 KiB
JavaScript
584 lines
19 KiB
JavaScript
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 arenaQueue = new Map()
|
|
const arenaMatches = new Map()
|
|
const arenaQueueTtlMs = 15 * 1000
|
|
const arenaMatchTtlMs = 60 * 60 * 1000
|
|
|
|
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')
|
|
if (mode === 'arenas') {
|
|
sendJson(response, 200, joinArenaQueue(user))
|
|
return
|
|
}
|
|
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
|
|
}
|
|
|
|
const arenaQueueTicket = url.pathname.match(/^\/api\/pvp\/queue\/([A-Za-z0-9_-]+)$/)
|
|
if (request.method === 'GET' && arenaQueueTicket) {
|
|
const user = await requireUser(request, response)
|
|
if (!user) return
|
|
sendJson(response, 200, checkArenaQueue(user, arenaQueueTicket[1]))
|
|
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
|
|
}
|
|
|
|
if (request.method === 'DELETE' && arenaQueueTicket) {
|
|
const user = await requireUser(request, response)
|
|
if (!user) return
|
|
cancelArenaQueue(user, arenaQueueTicket[1])
|
|
sendJson(response, 200, { ok: true })
|
|
return
|
|
}
|
|
|
|
sendJson(response, 404, { error: 'not_found' })
|
|
}
|
|
|
|
function cleanupArenaMemory(now = Date.now()) {
|
|
for (const [ticketId, ticket] of arenaQueue.entries()) {
|
|
if (now - ticket.updatedAt > arenaQueueTtlMs) arenaQueue.delete(ticketId)
|
|
}
|
|
for (const [matchId, match] of arenaMatches.entries()) {
|
|
if (now - match.updatedAt > arenaMatchTtlMs) arenaMatches.delete(matchId)
|
|
}
|
|
}
|
|
|
|
function arenaPlayerInfo(user) {
|
|
return {
|
|
accountId: user.id,
|
|
displayName: user.display_name ?? user.username ?? 'Arena Player',
|
|
username: user.username ?? user.auth_subject,
|
|
}
|
|
}
|
|
|
|
function arenaSnapshot(match) {
|
|
return {
|
|
id: match.id,
|
|
mode: 'arenas',
|
|
createdAt: match.createdAt,
|
|
players: match.players,
|
|
updatedAt: match.updatedAt,
|
|
}
|
|
}
|
|
|
|
function createArenaMatch(players, now = Date.now()) {
|
|
const match = {
|
|
id: randomBytes(12).toString('base64url'),
|
|
players,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
arenaMatches.set(match.id, match)
|
|
return match
|
|
}
|
|
|
|
function joinArenaQueue(user) {
|
|
const now = Date.now()
|
|
cleanupArenaMemory(now)
|
|
const existingTicket = [...arenaQueue.values()].find((ticket) => ticket.accountId === user.id)
|
|
if (existingTicket?.matchId) {
|
|
const match = arenaMatches.get(existingTicket.matchId)
|
|
if (match) {
|
|
const side = match.players.a.accountId === user.id ? 'a' : 'b'
|
|
return { ticketId: existingTicket.id, status: 'matched', side, match: arenaSnapshot(match) }
|
|
}
|
|
}
|
|
|
|
const opponent = [...arenaQueue.values()]
|
|
.filter((ticket) => !ticket.matchId && ticket.accountId !== user.id)
|
|
.sort((left, right) => left.createdAt - right.createdAt)[0]
|
|
const player = arenaPlayerInfo(user)
|
|
if (opponent) {
|
|
const match = createArenaMatch({
|
|
a: { side: 'a', ...opponent.player },
|
|
b: { side: 'b', ...player },
|
|
}, now)
|
|
opponent.matchId = match.id
|
|
opponent.updatedAt = now
|
|
const ticketId = randomBytes(12).toString('base64url')
|
|
arenaQueue.set(ticketId, {
|
|
id: ticketId,
|
|
accountId: user.id,
|
|
player,
|
|
matchId: match.id,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
})
|
|
return { ticketId, status: 'matched', side: 'b', match: arenaSnapshot(match) }
|
|
}
|
|
|
|
if (existingTicket) {
|
|
existingTicket.updatedAt = now
|
|
return { ticketId: existingTicket.id, status: 'waiting' }
|
|
}
|
|
|
|
const ticketId = randomBytes(12).toString('base64url')
|
|
arenaQueue.set(ticketId, {
|
|
id: ticketId,
|
|
accountId: user.id,
|
|
player,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
})
|
|
return { ticketId, status: 'waiting' }
|
|
}
|
|
|
|
function checkArenaQueue(user, ticketId) {
|
|
cleanupArenaMemory()
|
|
const ticket = arenaQueue.get(ticketId)
|
|
if (!ticket || ticket.accountId !== user.id) {
|
|
const error = new Error('Arena queue ticket not found.')
|
|
error.status = 404
|
|
throw error
|
|
}
|
|
ticket.updatedAt = Date.now()
|
|
if (!ticket.matchId) return { ticketId, status: 'waiting' }
|
|
const match = arenaMatches.get(ticket.matchId)
|
|
if (!match) return { ticketId, status: 'waiting' }
|
|
const side = match.players.a.accountId === user.id ? 'a' : 'b'
|
|
return { ticketId, status: 'matched', side, match: arenaSnapshot(match) }
|
|
}
|
|
|
|
function cancelArenaQueue(user, ticketId) {
|
|
const ticket = arenaQueue.get(ticketId)
|
|
if (ticket && ticket.accountId === user.id && !ticket.matchId) arenaQueue.delete(ticketId)
|
|
}
|
|
|
|
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)
|
|
}
|