I Want To Heal 2 build v1.0.2
This commit is contained in:
+235
-5
@@ -1,5 +1,11 @@
|
||||
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'
|
||||
@@ -11,6 +17,8 @@ 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 ?? '')
|
||||
@@ -49,8 +57,15 @@ const server = createServer(async (request, response) => {
|
||||
|
||||
await serveStatic(response, url.pathname)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
sendJson(response, 500, { error: 'internal_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.',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -70,6 +85,8 @@ function applyCors(request, response) {
|
||||
}
|
||||
|
||||
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 })
|
||||
@@ -154,7 +171,49 @@ async function handleApi(request, response, url) {
|
||||
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
|
||||
@@ -188,15 +247,186 @@ async function requireUser(request, response) {
|
||||
return result.rows[0]
|
||||
}
|
||||
|
||||
async function readJson(request) {
|
||||
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) {
|
||||
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
|
||||
function sendJson(response, status, payload, headers = {}) {
|
||||
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...headers })
|
||||
response.end(JSON.stringify(payload))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user