334 lines
14 KiB
JavaScript
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}`)
|
|
})
|