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 adminOverridesPath = fileURLToPath(new URL('../db/admin-overrides.sql', 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 dungeonImageDirectory = fileURLToPath(new URL('../data/uploads/dungeons/', 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)) } function sqlValue(value) { if (value === null || value === undefined) return 'NULL' if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'NULL' return `'${String(value).replaceAll("'", "''")}'` } function writeAdminOverrides(database) { const lines = [ '-- Generated by local admin panel. Commit this file with uploaded art changes.', 'PRAGMA foreign_keys = ON;', 'BEGIN TRANSACTION;', '', ] for (const dungeon of database.prepare(` SELECT id, slug, name, recommended_level AS recommendedLevel, content_type AS contentType, party_size AS partySize, experience_reward AS experienceReward, image_url AS imageUrl, description FROM dungeons ORDER BY id `).all()) { lines.push(`UPDATE dungeons SET slug = ${sqlValue(dungeon.slug)}, name = ${sqlValue(dungeon.name)}, recommended_level = ${sqlValue(dungeon.recommendedLevel)}, content_type = ${sqlValue(dungeon.contentType)}, party_size = ${sqlValue(dungeon.partySize)}, experience_reward = ${sqlValue(dungeon.experienceReward)}, image_url = ${sqlValue(dungeon.imageUrl)}, description = ${sqlValue(dungeon.description)} WHERE id = ${sqlValue(dungeon.id)};`) } lines.push('') for (const encounter of database.prepare(` SELECT id, slug, name, encounter_type AS encounterType, max_health AS maxHealth, base_damage AS baseDamage, tank_damage AS tankDamage, party_damage AS partyDamage, description, image_url AS imageUrl FROM encounters ORDER BY id `).all()) { lines.push(`UPDATE encounters SET slug = ${sqlValue(encounter.slug)}, name = ${sqlValue(encounter.name)}, encounter_type = ${sqlValue(encounter.encounterType)}, max_health = ${sqlValue(encounter.maxHealth)}, base_damage = ${sqlValue(encounter.baseDamage)}, tank_damage = ${sqlValue(encounter.tankDamage)}, party_damage = ${sqlValue(encounter.partyDamage)}, description = ${sqlValue(encounter.description)}, image_url = ${sqlValue(encounter.imageUrl)} WHERE id = ${sqlValue(encounter.id)};`) } lines.push('') for (const item of 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 id `).all()) { lines.push(`UPDATE items SET slug = ${sqlValue(item.slug)}, name = ${sqlValue(item.name)}, slot = ${sqlValue(item.slot)}, rarity = ${sqlValue(item.rarity)}, item_level = ${sqlValue(item.itemLevel)}, healing_power = ${sqlValue(item.healingPower)}, max_resource_bonus = ${sqlValue(item.maxResourceBonus)}, glyph = ${sqlValue(item.glyph)}, image_url = ${sqlValue(item.imageUrl)}, description = ${sqlValue(item.description)} WHERE id = ${sqlValue(item.id)};`) } lines.push('', 'DELETE FROM encounter_loot;') for (const loot of 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()) { lines.push(`INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) VALUES (${sqlValue(loot.encounterId)}, ${sqlValue(loot.itemId)}, ${sqlValue(loot.difficultyId)}, ${sqlValue(loot.dropWeight)}, ${sqlValue(loot.dropChance)});`) } lines.push('') for (const recipe of database.prepare(` SELECT id, difficulty_id AS difficultyId, source_dungeon_id AS sourceDungeonId, source_encounter_id AS sourceEncounterId FROM crafting_recipes ORDER BY id `).all()) { lines.push(`UPDATE crafting_recipes SET difficulty_id = ${sqlValue(recipe.difficultyId)}, source_dungeon_id = ${sqlValue(recipe.sourceDungeonId)}, source_encounter_id = ${sqlValue(recipe.sourceEncounterId)} WHERE id = ${sqlValue(recipe.id)};`) } lines.push('', 'DELETE FROM crafting_recipe_components;') for (const component of database.prepare(` SELECT recipe_id AS recipeId, item_id AS itemId, quantity FROM crafting_recipe_components ORDER BY recipe_id, item_id `).all()) { lines.push(`INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES (${sqlValue(component.recipeId)}, ${sqlValue(component.itemId)}, ${sqlValue(component.quantity)});`) } lines.push('', 'DELETE FROM gear_upgrade_paths;') for (const path of database.prepare(` SELECT from_item_id AS fromItemId, to_item_id AS toItemId FROM gear_upgrade_paths ORDER BY from_item_id `).all()) { lines.push(`INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (${sqlValue(path.fromItemId)}, ${sqlValue(path.toItemId)});`) } lines.push('', 'COMMIT;', '') writeFileSync(adminOverridesPath, lines.join('\n'), { mode: 0o644 }) } 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 sendDungeonImage(request, response) { const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname) const filename = pathname.replace('/api/dungeon-images/', '') if (!/^[A-Za-z0-9._-]+$/.test(filename)) { sendJson(response, 404, { error: 'Image not found.' }) return } const imagePath = resolve(dungeonImageDirectory, filename) const insideDirectory = imagePath.startsWith(resolve(dungeonImageDirectory) + 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) throw new Error('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 saveDungeonImage(database, dungeonId, payload) { const dungeon = database.prepare(`SELECT id, slug FROM dungeons WHERE id = ?`).get(dungeonId) if (!dungeon) throw new Error('Dungeon 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('Dungeon image must be 1 byte to 4 MB.') } mkdirSync(dungeonImageDirectory, { recursive: true }) const filename = `${dungeon.slug}-${Date.now()}-${randomBytes(4).toString('hex')}.${extensionByType[match[1]]}` writeFileSync(resolve(dungeonImageDirectory, filename), bytes, { mode: 0o644 }) const imageUrl = `/api/dungeon-images/${filename}` database.prepare(`UPDATE dungeons SET image_url = ? WHERE id = ?`).run(imageUrl, dungeonId) 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 (request.url.startsWith('/api/dungeon-images/') && request.method === 'GET') { sendDungeonImage(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, max_health AS maxHealth, base_damage AS baseDamage, tank_damage AS tankDamage, party_damage AS partyDamage, description, 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, recommended_level AS recommendedLevel, content_type AS contentType, party_size AS partySize, experience_reward AS experienceReward, image_url AS imageUrl, description FROM dungeons ORDER BY id `).all() const gearUpgradePaths = database.prepare(` SELECT from_item_id AS fromItemId, to_item_id AS toItemId FROM gear_upgrade_paths ORDER BY from_item_id `).all() sendJson(response, 200, { items, encounters, difficulties, encounterLoot, craftingRecipes: [...recipes.values()], dungeons, gearUpgradePaths, }) 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) writeAdminOverrides(database) 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) writeAdminOverrides(database) sendJson(response, 200, { ok: true, imageUrl }) return } const dungeonImageMatch = request.url.match(/^\/api\/admin\/dungeons\/(\d+)\/image$/) if (dungeonImageMatch && request.method === 'PUT') { const payload = await readJson(request, 6 * 1024 * 1024) const imageUrl = saveDungeonImage(database, Number(dungeonImageMatch[1]), payload) writeAdminOverrides(database) 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) writeAdminOverrides(database) sendJson(response, 200, { ok: true }) return } const dungeonMatch = request.url.match(/^\/api\/admin\/dungeons\/(\d+)$/) if (dungeonMatch && request.method === 'PUT') { const payload = await readJson(request) const dungeonId = Number(dungeonMatch[1]) const fields = [] const values = [] for (const key of ['name', 'slug', 'content_type', 'description']) { if (payload[key] !== undefined) { fields.push(`${key} = ?`); values.push(payload[key]) } } for (const key of ['recommended_level', 'party_size', 'experience_reward']) { 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(dungeonId) database.prepare(`UPDATE dungeons SET ${fields.join(', ')} WHERE id = ?`).run(...values) writeAdminOverrides(database) sendJson(response, 200, { ok: true }) return } const encounterMatch = request.url.match(/^\/api\/admin\/encounters\/(\d+)$/) if (encounterMatch && request.method === 'PUT') { const payload = await readJson(request) const encounterId = Number(encounterMatch[1]) const fields = [] const values = [] for (const key of ['name', 'slug', 'encounter_type', 'description']) { if (payload[key] !== undefined) { fields.push(`${key} = ?`); values.push(payload[key]) } } for (const key of ['max_health', 'base_damage', 'tank_damage', 'party_damage']) { 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(encounterId) database.prepare(`UPDATE encounters SET ${fields.join(', ')} WHERE id = ?`).run(...values) writeAdminOverrides(database) 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) writeAdminOverrides(database) 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])) writeAdminOverrides(database) 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) writeAdminOverrides(database) sendJson(response, 200, { ok: true }) return } const recipeMatch = request.url.match(/^\/api\/admin\/crafting-recipes\/(\d+)$/) if (recipeMatch && request.method === 'PUT') { const payload = await readJson(request) database.prepare(` UPDATE crafting_recipes SET difficulty_id = ?, source_dungeon_id = ?, source_encounter_id = ? WHERE id = ? `).run( payload.difficultyId ? Number(payload.difficultyId) : null, payload.sourceDungeonId ? Number(payload.sourceDungeonId) : null, payload.sourceEncounterId ? Number(payload.sourceEncounterId) : null, Number(recipeMatch[1]), ) writeAdminOverrides(database) 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])) writeAdminOverrides(database) sendJson(response, 200, { ok: true }) return } if (request.url === '/api/admin/gear-upgrade-paths' && request.method === 'POST') { const payload = await readJson(request) const fromItemId = Number(payload.fromItemId) const toItemId = Number(payload.toItemId) if (!fromItemId || !toItemId || fromItemId === toItemId) throw new Error('Choose two different equipment items.') database.prepare(` INSERT INTO gear_upgrade_paths (from_item_id, to_item_id) VALUES (?, ?) ON CONFLICT(from_item_id) DO UPDATE SET to_item_id = excluded.to_item_id `).run(fromItemId, toItemId) writeAdminOverrides(database) sendJson(response, 200, { ok: true }) return } const upgradePathDelete = request.url.match(/^\/api\/admin\/gear-upgrade-paths\/(\d+)$/) if (upgradePathDelete && request.method === 'DELETE') { database.prepare(`DELETE FROM gear_upgrade_paths WHERE from_item_id = ?`).run(Number(upgradePathDelete[1])) writeAdminOverrides(database) 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}`) })