456 lines
18 KiB
JavaScript
456 lines
18 KiB
JavaScript
const express = require('express')
|
|
const bcrypt = require('bcryptjs')
|
|
const crypto = require('crypto')
|
|
const fs = require('fs')
|
|
const path = require('path')
|
|
|
|
const app = express()
|
|
const PORT = process.env.PORT || 3333
|
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || ''
|
|
|
|
// Stateless admin token: hmac(timestamp, secret). Valid 24h.
|
|
function makeAdminToken() {
|
|
const ts = Date.now().toString()
|
|
const sig = crypto.createHmac('sha256', 'colony-admin-' + ADMIN_PASSWORD).update(ts).digest('hex')
|
|
return ts + '.' + sig
|
|
}
|
|
function validAdminToken(token) {
|
|
if (!ADMIN_PASSWORD || !token) return !ADMIN_PASSWORD // if no password set, allow all
|
|
const [ts, sig] = (token || '').split('.')
|
|
if (!ts || !sig) return false
|
|
if (Date.now() - Number(ts) > 86_400_000) return false // expired
|
|
const expected = crypto.createHmac('sha256', 'colony-admin-' + ADMIN_PASSWORD).update(ts).digest('hex')
|
|
return sig === expected
|
|
}
|
|
function requireAdmin(req, res, next) {
|
|
if (validAdminToken(req.headers['x-admin-token'])) return next()
|
|
res.status(401).json({ error: 'Admin authentication required' })
|
|
}
|
|
|
|
// SSE clients: Map<slug, Set<res>>
|
|
const sseClients = new Map()
|
|
|
|
function broadcast(slug) {
|
|
const bucket = sseClients.get(slug)
|
|
if (!bucket) return
|
|
const msg = `data: update\n\n`
|
|
for (const res of bucket) {
|
|
try { res.write(msg) } catch {}
|
|
}
|
|
}
|
|
const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'colony.json')
|
|
const DECKS_PATH = process.env.DECKS_PATH || path.join(__dirname, 'decks.json')
|
|
const DECK_IMAGES_PATH = process.env.DECK_IMAGES_PATH || path.join(__dirname, 'assets', 'deck-images')
|
|
|
|
function loadDB() {
|
|
try { return JSON.parse(fs.readFileSync(DB_PATH, 'utf8')) }
|
|
catch { return { games: [] } }
|
|
}
|
|
|
|
function saveDB(data) {
|
|
const tmp = DB_PATH + '.tmp'
|
|
fs.writeFileSync(tmp, JSON.stringify(data, null, 2))
|
|
fs.renameSync(tmp, DB_PATH)
|
|
}
|
|
|
|
function toSlug(title) {
|
|
return title.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
|
}
|
|
|
|
function newId() {
|
|
return crypto.randomUUID()
|
|
}
|
|
|
|
function now() {
|
|
return new Date().toISOString()
|
|
}
|
|
|
|
app.use(express.json())
|
|
app.use(express.static(path.join(__dirname)))
|
|
|
|
function requirePassword(req, res, next) {
|
|
const password = req.headers['x-game-password']
|
|
if (!password) return res.status(401).json({ error: 'Password required' })
|
|
const db = loadDB()
|
|
const game = db.games.find(g => g.slug === req.params.slug)
|
|
if (!game) return res.status(404).json({ error: 'Game not found' })
|
|
if (!bcrypt.compareSync(password, game.password_hash)) {
|
|
return res.status(401).json({ error: 'Invalid password' })
|
|
}
|
|
req.game = game
|
|
req.db = db
|
|
next()
|
|
}
|
|
|
|
function publicGame(g) {
|
|
return {
|
|
id: g.id, slug: g.slug, title: g.title, description: g.description || '',
|
|
mood: g.mood || 'light',
|
|
timeline_start: g.timeline_start, timeline_end: g.timeline_end,
|
|
start_title: g.start_title || '', start_description: g.start_description || '', start_mood: g.start_mood || '',
|
|
end_title: g.end_title || '', end_description: g.end_description || '', end_mood: g.end_mood || '',
|
|
deck_id: g.deck_id || '',
|
|
created_at: g.created_at
|
|
}
|
|
}
|
|
|
|
// --- Admin auth ---
|
|
app.post('/api/admin/login', (req, res) => {
|
|
if (!ADMIN_PASSWORD) return res.json({ token: 'no-auth-required' })
|
|
if (req.body.password !== ADMIN_PASSWORD) return res.status(401).json({ error: 'Wrong password' })
|
|
res.json({ token: makeAdminToken() })
|
|
})
|
|
|
|
// --- Live updates (SSE) ---
|
|
app.get('/api/games/:slug/live', (req, res) => {
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive',
|
|
'X-Accel-Buffering': 'no',
|
|
})
|
|
res.write('data: connected\n\n')
|
|
const slug = req.params.slug
|
|
if (!sseClients.has(slug)) sseClients.set(slug, new Set())
|
|
sseClients.get(slug).add(res)
|
|
req.on('close', () => {
|
|
sseClients.get(slug)?.delete(res)
|
|
if (sseClients.get(slug)?.size === 0) sseClients.delete(slug)
|
|
})
|
|
})
|
|
|
|
// --- Games ---
|
|
app.get('/api/games', (req, res) => {
|
|
const db = loadDB()
|
|
res.json(db.games.map(g => ({
|
|
id: g.id, slug: g.slug, title: g.title, description: g.description,
|
|
mood: g.mood, timeline_start: g.timeline_start, timeline_end: g.timeline_end,
|
|
created_at: g.created_at
|
|
})))
|
|
})
|
|
|
|
app.post('/api/games', (req, res) => {
|
|
const { title, description, mood, timeline_start, timeline_end, password,
|
|
start_title, start_description, start_mood,
|
|
end_title, end_description, end_mood } = req.body
|
|
if (!title || !timeline_start || !timeline_end || !password) {
|
|
return res.status(400).json({ error: 'title, timeline_start, timeline_end, password required' })
|
|
}
|
|
const base = toSlug(title)
|
|
if (!base) return res.status(400).json({ error: 'Title must contain letters or numbers' })
|
|
const db = loadDB()
|
|
let slug = base, n = 1
|
|
while (db.games.find(g => g.slug === slug)) slug = `${base}-${n++}`
|
|
const game = {
|
|
id: newId(), slug, title, description: description || '',
|
|
mood: mood || 'light', timeline_start, timeline_end,
|
|
start_title: start_title || '', start_description: start_description || '', start_mood: start_mood || '',
|
|
end_title: end_title || '', end_description: end_description || '', end_mood: end_mood || '',
|
|
password_hash: bcrypt.hashSync(password, 10),
|
|
created_at: now(), items: []
|
|
}
|
|
db.games.push(game)
|
|
saveDB(db)
|
|
res.status(201).json(publicGame(game))
|
|
})
|
|
|
|
app.get('/api/games/:slug', (req, res) => {
|
|
const db = loadDB()
|
|
const game = db.games.find(g => g.slug === req.params.slug)
|
|
if (!game) return res.status(404).json({ error: 'Game not found' })
|
|
res.json(publicGame(game))
|
|
})
|
|
|
|
app.put('/api/games/:slug', requirePassword, (req, res) => {
|
|
const { title, description, mood, timeline_start, timeline_end,
|
|
start_title, start_description, start_mood,
|
|
end_title, end_description, end_mood } = req.body
|
|
if (!title || !timeline_start || !timeline_end) {
|
|
return res.status(400).json({ error: 'title, timeline_start, timeline_end required' })
|
|
}
|
|
const game = req.game
|
|
game.title = title
|
|
game.description = description || ''
|
|
game.mood = mood || 'light'
|
|
game.timeline_start = timeline_start
|
|
game.timeline_end = timeline_end
|
|
game.start_title = start_title || ''
|
|
game.start_description = start_description || ''
|
|
game.start_mood = start_mood || ''
|
|
game.end_title = end_title || ''
|
|
game.end_description = end_description || ''
|
|
game.end_mood = end_mood || ''
|
|
game.deck_id = req.body.deck_id || ''
|
|
saveDB(req.db); broadcast(req.params.slug)
|
|
res.json(publicGame(game))
|
|
})
|
|
|
|
app.post('/api/games/:slug/verify', (req, res) => {
|
|
const { password } = req.body
|
|
if (!password) return res.status(400).json({ error: 'Password required' })
|
|
const db = loadDB()
|
|
const game = db.games.find(g => g.slug === req.params.slug)
|
|
if (!game) return res.status(404).json({ error: 'Game not found' })
|
|
if (!bcrypt.compareSync(password, game.password_hash)) {
|
|
return res.status(401).json({ error: 'Invalid password' })
|
|
}
|
|
res.json({ ok: true })
|
|
})
|
|
|
|
// --- Items ---
|
|
app.get('/api/games/:slug/items', (req, res) => {
|
|
const db = loadDB()
|
|
const game = db.games.find(g => g.slug === req.params.slug)
|
|
if (!game) return res.status(404).json({ error: 'Game not found' })
|
|
const sorted = [...(game.items || [])].sort((a, b) => a.date.localeCompare(b.date) || a.created_at.localeCompare(b.created_at))
|
|
res.json(sorted)
|
|
})
|
|
|
|
app.post('/api/games/:slug/items', requirePassword, (req, res) => {
|
|
const { type, title, description, mood, date, end_date, status, discipline, env } = req.body
|
|
if (!type || !title || !date) return res.status(400).json({ error: 'type, title, date required' })
|
|
if (!['event', 'project', 'person'].includes(type)) {
|
|
return res.status(400).json({ error: 'type must be event, project, or person' })
|
|
}
|
|
const item = {
|
|
id: newId(), type, title, description: description || '',
|
|
mood: mood || null, date, end_date: end_date || null,
|
|
status: status || null, discipline: discipline || null,
|
|
env: env || null, env_layout: req.body.env_layout || null, created_at: now()
|
|
}
|
|
req.game.items = req.game.items || []
|
|
req.game.items.push(item)
|
|
saveDB(req.db); broadcast(req.params.slug)
|
|
res.status(201).json(item)
|
|
})
|
|
|
|
app.put('/api/games/:slug/items/:id', requirePassword, (req, res) => {
|
|
const items = req.game.items || []
|
|
const idx = items.findIndex(i => i.id === req.params.id)
|
|
if (idx === -1) return res.status(404).json({ error: 'Item not found' })
|
|
const { title, description, mood, date, end_date, status, discipline } = req.body
|
|
if (!title || !date) return res.status(400).json({ error: 'title, date required' })
|
|
const { env, env_layout } = req.body
|
|
items[idx] = { ...items[idx], title, description: description || '', mood: mood || null, date, end_date: end_date || null, status: status || null, discipline: discipline || null, env: env || null, env_layout: env_layout || null }
|
|
saveDB(req.db); broadcast(req.params.slug)
|
|
res.json(items[idx])
|
|
})
|
|
|
|
app.delete('/api/games/:slug/items/:id', requirePassword, (req, res) => {
|
|
const items = req.game.items || []
|
|
const idx = items.findIndex(i => i.id === req.params.id)
|
|
if (idx === -1) return res.status(404).json({ error: 'Item not found' })
|
|
items.splice(idx, 1)
|
|
saveDB(req.db); broadcast(req.params.slug)
|
|
res.json({ ok: true })
|
|
})
|
|
|
|
|
|
// --- Keywords ---
|
|
app.get('/api/games/:slug/keywords', (req, res) => {
|
|
const db = loadDB()
|
|
const game = db.games.find(g => g.slug === req.params.slug)
|
|
if (!game) return res.status(404).json({ error: 'Game not found' })
|
|
res.json([...(game.keywords || [])].sort((a, b) => a.term.localeCompare(b.term)))
|
|
})
|
|
|
|
app.post('/api/games/:slug/keywords', requirePassword, (req, res) => {
|
|
const { term, definition } = req.body
|
|
if (!term) return res.status(400).json({ error: 'term required' })
|
|
req.game.keywords = req.game.keywords || []
|
|
const existing = req.game.keywords.find(k => k.term.toLowerCase() === term.toLowerCase())
|
|
if (existing) {
|
|
existing.definition = definition || ''
|
|
saveDB(req.db); broadcast(req.params.slug)
|
|
return res.json(existing)
|
|
}
|
|
const kw = { id: newId(), term, definition: definition || '', created_at: now() }
|
|
req.game.keywords.push(kw)
|
|
saveDB(req.db); broadcast(req.params.slug)
|
|
res.status(201).json(kw)
|
|
})
|
|
|
|
app.put('/api/games/:slug/keywords/:id', requirePassword, (req, res) => {
|
|
const kws = req.game.keywords || []
|
|
const idx = kws.findIndex(k => k.id === req.params.id)
|
|
if (idx === -1) return res.status(404).json({ error: 'Keyword not found' })
|
|
const { term, definition } = req.body
|
|
kws[idx] = { ...kws[idx], term: term || kws[idx].term, definition: definition || '' }
|
|
saveDB(req.db); broadcast(req.params.slug)
|
|
res.json(kws[idx])
|
|
})
|
|
|
|
app.delete('/api/games/:slug/keywords/:id', requirePassword, (req, res) => {
|
|
const kws = req.game.keywords || []
|
|
const idx = kws.findIndex(k => k.id === req.params.id)
|
|
if (idx === -1) return res.status(404).json({ error: 'Keyword not found' })
|
|
kws.splice(idx, 1)
|
|
saveDB(req.db); broadcast(req.params.slug)
|
|
res.json({ ok: true })
|
|
})
|
|
|
|
// --- Decks (image libraries) ---
|
|
function loadDecks() {
|
|
try { return JSON.parse(fs.readFileSync(DECKS_PATH, 'utf8')) }
|
|
catch { return { decks: [] } }
|
|
}
|
|
function saveDecks(data) {
|
|
const tmp = DECKS_PATH + '.tmp'
|
|
fs.writeFileSync(tmp, JSON.stringify(data, null, 2))
|
|
fs.renameSync(tmp, DECKS_PATH)
|
|
}
|
|
|
|
app.get('/api/decks', (req, res) => {
|
|
res.json(loadDecks().decks)
|
|
})
|
|
|
|
app.post('/api/decks', requireAdmin, (req, res) => {
|
|
const { name, description } = req.body
|
|
if (!name) return res.status(400).json({ error: 'name required' })
|
|
const db = loadDecks()
|
|
const deck = { id: newId(), name, description: description || '', images: [], created_at: now() }
|
|
db.decks.push(deck)
|
|
saveDecks(db)
|
|
res.status(201).json(deck)
|
|
})
|
|
|
|
app.put('/api/decks/:id', requireAdmin, (req, res) => {
|
|
const db = loadDecks()
|
|
const deck = db.decks.find(d => d.id === req.params.id)
|
|
if (!deck) return res.status(404).json({ error: 'Deck not found' })
|
|
deck.name = req.body.name || deck.name
|
|
deck.description = req.body.description ?? deck.description
|
|
saveDecks(db)
|
|
res.json(deck)
|
|
})
|
|
|
|
app.delete('/api/decks/:id', requireAdmin, (req, res) => {
|
|
const db = loadDecks()
|
|
const idx = db.decks.findIndex(d => d.id === req.params.id)
|
|
if (idx === -1) return res.status(404).json({ error: 'Deck not found' })
|
|
// Delete image files
|
|
for (const img of db.decks[idx].images || []) {
|
|
try { fs.unlinkSync(path.join(DECK_IMAGES_PATH, img.filename)) } catch {}
|
|
}
|
|
db.decks.splice(idx, 1)
|
|
saveDecks(db)
|
|
res.json({ ok: true })
|
|
})
|
|
|
|
// --- Cards within a deck ---
|
|
app.post('/api/decks/:id/cards', requireAdmin, (req, res) => {
|
|
const db = loadDecks()
|
|
const deck = db.decks.find(d => d.id === req.params.id)
|
|
if (!deck) return res.status(404).json({ error: 'Deck not found' })
|
|
const { title, type, question, a, b, requires } = req.body
|
|
if (!title || !a || !b) return res.status(400).json({ error: 'title, a, b required' })
|
|
const card = { id: newId(), title, type: type || 'action', question: question || '', a, b, requires: requires || [], images: [], created_at: now() }
|
|
deck.cards = deck.cards || []
|
|
deck.cards.push(card)
|
|
saveDecks(db)
|
|
res.status(201).json(card)
|
|
})
|
|
|
|
app.put('/api/decks/:deckId/cards/:cardId', requireAdmin, (req, res) => {
|
|
const db = loadDecks()
|
|
const deck = db.decks.find(d => d.id === req.params.deckId)
|
|
if (!deck) return res.status(404).json({ error: 'Deck not found' })
|
|
const card = (deck.cards || []).find(c => c.id === req.params.cardId)
|
|
if (!card) return res.status(404).json({ error: 'Card not found' })
|
|
const { title, type, question, a, b, requires } = req.body
|
|
Object.assign(card, { title: title ?? card.title, type: type ?? card.type, question: question ?? card.question, a: a ?? card.a, b: b ?? card.b, requires: requires ?? card.requires })
|
|
saveDecks(db)
|
|
res.json(card)
|
|
})
|
|
|
|
app.delete('/api/decks/:deckId/cards/:cardId', requireAdmin, (req, res) => {
|
|
const db = loadDecks()
|
|
const deck = db.decks.find(d => d.id === req.params.deckId)
|
|
if (!deck) return res.status(404).json({ error: 'Deck not found' })
|
|
const idx = (deck.cards || []).findIndex(c => c.id === req.params.cardId)
|
|
if (idx === -1) return res.status(404).json({ error: 'Card not found' })
|
|
for (const img of deck.cards[idx].images || []) {
|
|
try { fs.unlinkSync(path.join(DECK_IMAGES_PATH, img.filename)) } catch {}
|
|
}
|
|
deck.cards.splice(idx, 1)
|
|
saveDecks(db)
|
|
res.json({ ok: true })
|
|
})
|
|
|
|
app.post('/api/decks/:deckId/cards/:cardId/images', requireAdmin, (req, res) => {
|
|
const db = loadDecks()
|
|
const deck = db.decks.find(d => d.id === req.params.deckId)
|
|
if (!deck) return res.status(404).json({ error: 'Deck not found' })
|
|
const card = (deck.cards || []).find(c => c.id === req.params.cardId)
|
|
if (!card) return res.status(404).json({ error: 'Card not found' })
|
|
const { filename, data, keywords } = req.body
|
|
if (!data) return res.status(400).json({ error: 'data required' })
|
|
const ext = path.extname(filename || '.jpg') || '.jpg'
|
|
const savedName = `deck-${newId()}${ext}`
|
|
fs.mkdirSync(DECK_IMAGES_PATH, { recursive: true })
|
|
fs.writeFileSync(path.join(DECK_IMAGES_PATH, savedName), Buffer.from(data, 'base64'))
|
|
const img = { id: newId(), filename: savedName, keywords: keywords || [], created_at: now() }
|
|
card.images = card.images || []
|
|
card.images.push(img)
|
|
saveDecks(db)
|
|
res.status(201).json(img)
|
|
})
|
|
|
|
app.delete('/api/decks/:deckId/cards/:cardId/images/:imgId', requireAdmin, (req, res) => {
|
|
const db = loadDecks()
|
|
const deck = db.decks.find(d => d.id === req.params.deckId)
|
|
if (!deck) return res.status(404).json({ error: 'Deck not found' })
|
|
const card = (deck.cards || []).find(c => c.id === req.params.cardId)
|
|
if (!card) return res.status(404).json({ error: 'Card not found' })
|
|
const idx = (card.images || []).findIndex(i => i.id === req.params.imgId)
|
|
if (idx === -1) return res.status(404).json({ error: 'Image not found' })
|
|
try { fs.unlinkSync(path.join(DECK_IMAGES_PATH, card.images[idx].filename)) } catch {}
|
|
card.images.splice(idx, 1)
|
|
saveDecks(db)
|
|
res.json({ ok: true })
|
|
})
|
|
|
|
// --- Pool images (for event auto-matching) ---
|
|
app.post('/api/decks/:id/images', requireAdmin, (req, res) => {
|
|
const db = loadDecks()
|
|
const deck = db.decks.find(d => d.id === req.params.id)
|
|
if (!deck) return res.status(404).json({ error: 'Deck not found' })
|
|
const { filename, data, keywords } = req.body
|
|
if (!data) return res.status(400).json({ error: 'data required' })
|
|
const ext = path.extname(filename || '.jpg') || '.jpg'
|
|
const savedName = `deck-${newId()}${ext}`
|
|
const imgPath = path.join(DECK_IMAGES_PATH, savedName)
|
|
fs.mkdirSync(path.dirname(imgPath), { recursive: true })
|
|
fs.writeFileSync(imgPath, Buffer.from(data, 'base64'))
|
|
const img = { id: newId(), filename: savedName, keywords: keywords || [], created_at: now() }
|
|
deck.images = deck.images || []
|
|
deck.images.push(img)
|
|
saveDecks(db)
|
|
res.status(201).json(img)
|
|
})
|
|
|
|
app.put('/api/decks/:deckId/images/:imgId', requireAdmin, (req, res) => {
|
|
const db = loadDecks()
|
|
const deck = db.decks.find(d => d.id === req.params.deckId)
|
|
if (!deck) return res.status(404).json({ error: 'Deck not found' })
|
|
const img = deck.images.find(i => i.id === req.params.imgId)
|
|
if (!img) return res.status(404).json({ error: 'Image not found' })
|
|
img.keywords = req.body.keywords ?? img.keywords
|
|
saveDecks(db)
|
|
res.json(img)
|
|
})
|
|
|
|
app.delete('/api/decks/:deckId/images/:imgId', requireAdmin, (req, res) => {
|
|
const db = loadDecks()
|
|
const deck = db.decks.find(d => d.id === req.params.deckId)
|
|
if (!deck) return res.status(404).json({ error: 'Deck not found' })
|
|
const idx = deck.images.findIndex(i => i.id === req.params.imgId)
|
|
if (idx === -1) return res.status(404).json({ error: 'Image not found' })
|
|
try { fs.unlinkSync(path.join(DECK_IMAGES_PATH, deck.images[idx].filename)) } catch {}
|
|
deck.images.splice(idx, 1)
|
|
saveDecks(db)
|
|
res.json({ ok: true })
|
|
})
|
|
|
|
app.listen(PORT, () => console.log(`Colony Archive running on http://localhost:${PORT}`))
|