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}`) })