Files
2026-06-17 20:04:36 -04:00

334 lines
14 KiB
JavaScript

import { createReadStream, existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
import { createServer } from 'node:http'
import { randomBytes } from 'node:crypto'
import { extname, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { DatabaseSync } from 'node:sqlite'
const host = '127.0.0.1'
const port = Number(process.env.ADMIN_PORT ?? 4174)
const databasePath = fileURLToPath(new URL('../data/game.db', import.meta.url))
const distPath = fileURLToPath(new URL('../dist', import.meta.url))
const bossImageDirectory = fileURLToPath(new URL('../data/uploads/bosses/', import.meta.url))
const itemImageDirectory = fileURLToPath(new URL('../data/uploads/items/', import.meta.url))
const bossImageContentTypes = {
'.gif': 'image/gif',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
}
function sendJson(response, status, body) {
response.statusCode = status
response.setHeader('Content-Type', 'application/json')
response.end(JSON.stringify(body))
}
async function readJson(request, maxSize = 16 * 1024) {
const chunks = []
let size = 0
for await (const chunk of request) {
size += chunk.length
if (size > maxSize) {
const error = new Error('Request body is too large.')
error.status = 413
throw error
}
chunks.push(chunk)
}
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
}
function sendBossImage(request, response) {
const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname)
const filename = pathname.replace('/api/boss-images/', '')
if (!/^[A-Za-z0-9._-]+$/.test(filename)) {
sendJson(response, 404, { error: 'Image not found.' })
return
}
const imagePath = resolve(bossImageDirectory, filename)
const insideDirectory = imagePath.startsWith(resolve(bossImageDirectory) + sep)
const extension = extname(imagePath).toLowerCase()
if (!insideDirectory || !bossImageContentTypes[extension] || !existsSync(imagePath) || !statSync(imagePath).isFile()) {
sendJson(response, 404, { error: 'Image not found.' })
return
}
response.statusCode = 200
response.setHeader('Content-Type', bossImageContentTypes[extension])
response.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
response.setHeader('X-Content-Type-Options', 'nosniff')
createReadStream(imagePath).pipe(response)
}
function sendItemImage(request, response) {
const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname)
const filename = pathname.replace('/api/item-images/', '')
if (!/^[A-Za-z0-9._-]+$/.test(filename)) {
sendJson(response, 404, { error: 'Image not found.' })
return
}
const imagePath = resolve(itemImageDirectory, filename)
const insideDirectory = imagePath.startsWith(resolve(itemImageDirectory) + sep)
const extension = extname(imagePath).toLowerCase()
if (!insideDirectory || !bossImageContentTypes[extension] || !existsSync(imagePath) || !statSync(imagePath).isFile()) {
sendJson(response, 404, { error: 'Image not found.' })
return
}
response.statusCode = 200
response.setHeader('Content-Type', bossImageContentTypes[extension])
response.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
response.setHeader('X-Content-Type-Options', 'nosniff')
createReadStream(imagePath).pipe(response)
}
function saveBossImage(database, encounterId, payload) {
const encounter = database.prepare(`
SELECT id, slug, encounter_type AS encounterType
FROM encounters WHERE id = ?
`).get(encounterId)
if (!encounter || encounter.encounterType !== 'boss') {
throw new Error('Boss encounter not found.')
}
const dataUrl = String(payload.imageData ?? '')
const match = dataUrl.match(/^data:(image\/(?:png|jpeg|webp|gif));base64,([A-Za-z0-9+/=]+)$/)
if (!match) throw new Error('Upload a PNG, JPG, WebP, or GIF image.')
const extensionByType = { 'image/gif': 'gif', 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' }
const bytes = Buffer.from(match[2], 'base64')
if (bytes.length === 0 || bytes.length > 4 * 1024 * 1024) {
throw new Error('Boss image must be 1 byte to 4 MB.')
}
mkdirSync(bossImageDirectory, { recursive: true })
const filename = `${encounter.slug}-${Date.now()}-${randomBytes(4).toString('hex')}.${extensionByType[match[1]]}`
writeFileSync(resolve(bossImageDirectory, filename), bytes, { mode: 0o644 })
const imageUrl = `/api/boss-images/${filename}`
database.prepare(`UPDATE encounters SET image_url = ? WHERE id = ?`).run(imageUrl, encounterId)
return imageUrl
}
function saveItemImage(database, itemId, payload) {
const item = database.prepare(`SELECT id, slug, slot FROM items WHERE id = ?`).get(itemId)
if (!item || item.slot === 'component') throw new Error('Equipment item not found.')
const dataUrl = String(payload.imageData ?? '')
const match = dataUrl.match(/^data:(image\/(?:png|jpeg|webp|gif));base64,([A-Za-z0-9+/=]+)$/)
if (!match) throw new Error('Upload a PNG, JPG, WebP, or GIF image.')
const extensionByType = { 'image/gif': 'gif', 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' }
const bytes = Buffer.from(match[2], 'base64')
if (bytes.length === 0 || bytes.length > 4 * 1024 * 1024) {
throw new Error('Equipment image must be 1 byte to 4 MB.')
}
mkdirSync(itemImageDirectory, { recursive: true })
const filename = `${item.slug}-${Date.now()}-${randomBytes(4).toString('hex')}.${extensionByType[match[1]]}`
writeFileSync(resolve(itemImageDirectory, filename), bytes, { mode: 0o644 })
const imageUrl = `/api/item-images/${filename}`
database.prepare(`UPDATE items SET image_url = ? WHERE id = ?`).run(imageUrl, itemId)
return imageUrl
}
function sendFile(response, filePath) {
const contentTypes = {
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.ico': 'image/x-icon',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
}
response.statusCode = 200
response.setHeader('Content-Type', contentTypes[extname(filePath).toLowerCase()] ?? 'application/octet-stream')
response.setHeader('X-Content-Type-Options', 'nosniff')
response.setHeader('Referrer-Policy', 'same-origin')
response.setHeader('X-Frame-Options', 'DENY')
createReadStream(filePath).pipe(response)
}
function serveStatic(request, response) {
const requestPath = decodeURIComponent(new URL(request.url, 'http://localhost').pathname)
const candidate = resolve(distPath, `.${requestPath}`)
const insideDist = candidate === distPath || candidate.startsWith(`${distPath}${sep}`)
if (insideDist && existsSync(candidate) && statSync(candidate).isFile()) {
sendFile(response, candidate)
return
}
const adminIndexPath = resolve(distPath, 'admin.html')
if (!existsSync(adminIndexPath)) {
response.statusCode = 503
response.end('Admin build missing. Run npm run build.')
return
}
sendFile(response, adminIndexPath)
}
const server = createServer(async (request, response) => {
try {
if (!request.url?.startsWith('/api/')) {
serveStatic(request, response)
return
}
if (request.url.startsWith('/api/boss-images/') && request.method === 'GET') {
sendBossImage(request, response)
return
}
if (request.url.startsWith('/api/item-images/') && request.method === 'GET') {
sendItemImage(request, response)
return
}
if (!existsSync(databasePath)) {
sendJson(response, 503, { error: 'Database missing. Run npm run db:init.' })
return
}
const database = new DatabaseSync(databasePath)
database.exec('PRAGMA foreign_keys = ON')
database.exec('PRAGMA journal_mode = WAL')
database.exec('PRAGMA busy_timeout = 5000')
try {
if (request.url === '/api/admin/data' && request.method === 'GET') {
const items = database.prepare(`
SELECT id, slug, name, slot, rarity, item_level AS itemLevel,
healing_power AS healingPower, max_resource_bonus AS maxResourceBonus,
glyph, image_url AS imageUrl, description
FROM items ORDER BY slot, item_level, id
`).all()
const encounters = database.prepare(`
SELECT id, dungeon_id AS dungeonId, sequence, slug, name AS enemyName,
encounter_type AS encounterType, image_url AS imageUrl
FROM encounters ORDER BY dungeon_id, sequence
`).all()
const difficulties = database.prepare(`
SELECT id, slug, name, dropped_item_level AS droppedItemLevel
FROM difficulties ORDER BY id
`).all()
const encounterLoot = database.prepare(`
SELECT encounter_id AS encounterId, item_id AS itemId,
difficulty_id AS difficultyId, drop_weight AS dropWeight, drop_chance AS dropChance
FROM encounter_loot ORDER BY encounter_id, difficulty_id, item_id
`).all()
const craftingRecipes = database.prepare(`
SELECT cr.id, cr.item_id AS itemId, cr.difficulty_id AS difficultyId,
cr.source_dungeon_id AS sourceDungeonId,
cr.source_encounter_id AS sourceEncounterId,
crc.item_id AS componentId, crc.quantity
FROM crafting_recipes cr
LEFT JOIN crafting_recipe_components crc ON crc.recipe_id = cr.id
ORDER BY cr.id, crc.item_id
`).all()
const recipes = new Map()
for (const row of craftingRecipes) {
if (!recipes.has(row.id)) {
recipes.set(row.id, {
id: row.id, itemId: row.itemId, difficultyId: row.difficultyId,
sourceDungeonId: row.sourceDungeonId, sourceEncounterId: row.sourceEncounterId,
components: [],
})
}
if (row.componentId) {
recipes.get(row.id).components.push({ itemId: row.componentId, quantity: row.quantity })
}
}
const dungeons = database.prepare(`SELECT id, slug, name FROM dungeons ORDER BY id`).all()
sendJson(response, 200, { items, encounters, difficulties, encounterLoot, craftingRecipes: [...recipes.values()], dungeons })
return
}
const bossImageMatch = request.url.match(/^\/api\/admin\/encounters\/(\d+)\/image$/)
if (bossImageMatch && request.method === 'PUT') {
const payload = await readJson(request, 6 * 1024 * 1024)
const imageUrl = saveBossImage(database, Number(bossImageMatch[1]), payload)
sendJson(response, 200, { ok: true, imageUrl })
return
}
const itemImageMatch = request.url.match(/^\/api\/admin\/items\/(\d+)\/image$/)
if (itemImageMatch && request.method === 'PUT') {
const payload = await readJson(request, 6 * 1024 * 1024)
const imageUrl = saveItemImage(database, Number(itemImageMatch[1]), payload)
sendJson(response, 200, { ok: true, imageUrl })
return
}
const itemMatch = request.url.match(/^\/api\/admin\/items\/(\d+)$/)
if (itemMatch && request.method === 'PUT') {
const payload = await readJson(request)
const itemId = Number(itemMatch[1])
const fields = []
const values = []
for (const key of ['name', 'slug', 'slot', 'rarity', 'glyph', 'description']) {
if (payload[key] !== undefined) { fields.push(`${key} = ?`); values.push(payload[key]) }
}
for (const key of ['item_level', 'healing_power', 'max_resource_bonus']) {
if (payload[key] !== undefined) { fields.push(`${key} = ?`); values.push(Number(payload[key])) }
}
if (fields.length === 0) { sendJson(response, 400, { error: 'No fields to update.' }); return }
values.push(itemId)
database.prepare(`UPDATE items SET ${fields.join(', ')} WHERE id = ?`).run(...values)
sendJson(response, 200, { ok: true })
return
}
if (request.url === '/api/admin/encounter-loot' && request.method === 'POST') {
const payload = await readJson(request)
database.prepare(`
INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(encounter_id, difficulty_id, item_id)
DO UPDATE SET drop_weight = excluded.drop_weight, drop_chance = excluded.drop_chance
`).run(payload.encounterId, payload.itemId, payload.difficultyId, payload.dropWeight ?? 100, payload.dropChance ?? 0.65)
sendJson(response, 200, { ok: true })
return
}
const lootDelete = request.url.match(/^\/api\/admin\/encounter-loot\/(\d+)\/(\d+)\/(\d+)$/)
if (lootDelete && request.method === 'DELETE') {
database.prepare(`DELETE FROM encounter_loot WHERE encounter_id = ? AND difficulty_id = ? AND item_id = ?`)
.run(Number(lootDelete[1]), Number(lootDelete[2]), Number(lootDelete[3]))
sendJson(response, 200, { ok: true })
return
}
const recipeComponents = request.url.match(/^\/api\/admin\/crafting-recipes\/(\d+)\/components$/)
if (recipeComponents && request.method === 'POST') {
const payload = await readJson(request)
database.prepare(`
INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity)
VALUES (?, ?, ?)
ON CONFLICT(recipe_id, item_id)
DO UPDATE SET quantity = excluded.quantity
`).run(Number(recipeComponents[1]), payload.itemId, payload.quantity)
sendJson(response, 200, { ok: true })
return
}
const recipeComponentDelete = request.url.match(/^\/api\/admin\/crafting-recipes\/(\d+)\/components\/(\d+)$/)
if (recipeComponentDelete && request.method === 'DELETE') {
database.prepare(`DELETE FROM crafting_recipe_components WHERE recipe_id = ? AND item_id = ?`)
.run(Number(recipeComponentDelete[1]), Number(recipeComponentDelete[2]))
sendJson(response, 200, { ok: true })
return
}
sendJson(response, 404, { error: 'Admin API route not found.' })
} catch (error) {
const status = Number(error?.status) || 400
sendJson(response, status, { error: error instanceof Error ? error.message : 'Unable to process request.' })
} finally {
database.close()
}
} catch (error) {
sendJson(response, 500, { error: error instanceof Error ? error.message : 'Server error.' })
}
})
server.listen(port, host, () => {
console.log(`Admin panel listening on http://${host}:${port}`)
})