I Want To Heal 2 web/server v1.0.0
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { createPool, waitForDatabase } from './db.mjs'
|
||||
|
||||
const pool = createPool()
|
||||
|
||||
try {
|
||||
await waitForDatabase(pool)
|
||||
await pool.query('begin')
|
||||
await pool.query(`
|
||||
create extension if not exists pgcrypto;
|
||||
|
||||
create table if not exists app_users (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
auth_issuer text not null,
|
||||
auth_subject text not null,
|
||||
display_name text not null default 'Healer',
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (auth_issuer, auth_subject)
|
||||
);
|
||||
|
||||
create table if not exists save_slots (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references app_users(id) on delete cascade,
|
||||
slot_key text not null default 'default',
|
||||
save_json jsonb not null,
|
||||
save_version integer not null default 1,
|
||||
client_updated_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (user_id, slot_key)
|
||||
);
|
||||
|
||||
create table if not exists action_mechanic_configs (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid references app_users(id) on delete cascade,
|
||||
config_key text not null default 'default',
|
||||
config_json jsonb not null,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (user_id, config_key)
|
||||
);
|
||||
|
||||
create table if not exists pvp_queue (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references app_users(id) on delete cascade,
|
||||
mode text not null default 'action-healer',
|
||||
rating integer not null default 1000,
|
||||
status text not null default 'queued',
|
||||
queued_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (user_id, mode, status)
|
||||
);
|
||||
|
||||
create table if not exists pvp_matches (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
mode text not null default 'action-healer',
|
||||
status text not null default 'pending',
|
||||
player_one_id uuid not null references app_users(id) on delete cascade,
|
||||
player_two_id uuid references app_users(id) on delete cascade,
|
||||
match_state jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
completed_at timestamptz
|
||||
);
|
||||
|
||||
create index if not exists save_slots_user_id_idx on save_slots(user_id);
|
||||
create index if not exists pvp_queue_mode_status_idx on pvp_queue(mode, status, queued_at);
|
||||
create index if not exists pvp_matches_player_one_idx on pvp_matches(player_one_id);
|
||||
create index if not exists pvp_matches_player_two_idx on pvp_matches(player_two_id);
|
||||
`)
|
||||
await pool.query('commit')
|
||||
console.log('Database initialized.')
|
||||
} catch (error) {
|
||||
await pool.query('rollback').catch(() => {})
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
await pool.end()
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import pg from 'pg'
|
||||
|
||||
const { Pool } = pg
|
||||
|
||||
export function getDatabaseUrl() {
|
||||
const databaseUrl = process.env.DATABASE_URL
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL is required for server database access.')
|
||||
}
|
||||
return databaseUrl
|
||||
}
|
||||
|
||||
export function createPool() {
|
||||
return new Pool({
|
||||
connectionString: getDatabaseUrl(),
|
||||
max: Number(process.env.DB_POOL_MAX ?? 10),
|
||||
})
|
||||
}
|
||||
|
||||
export async function waitForDatabase(pool, attempts = 30) {
|
||||
let lastError
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
await pool.query('select 1')
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.min(5000, attempt * 500)))
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user