Files
gupi-osint-board/server/index.ts
T

457 lines
24 KiB
TypeScript
Raw Normal View History

2026-08-14 12:43:11 +02:00
import 'dotenv/config'
import cors from 'cors'
import cookieParser from 'cookie-parser'
2026-08-14 12:43:11 +02:00
import express from 'express'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import multer from 'multer'
import pg from 'pg'
import type { CaseState } from '../src/types.js'
import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin, resolveUserId } from './auth.js'
import { createLevelRepository } from './levelRepository.js'
import { createNarrativeRepository } from './narrativeRepository.js'
import { createTextExtractorFromEnv } from './ocr.js'
import { createStoryGraphRepository, type StoryNodeType } from './storyGraphRepository.js'
2026-08-17 09:24:53 +02:00
import { createObjectStorageFromEnv } from './objectStorage.js'
2026-08-14 12:43:11 +02:00
const { Pool } = pg
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) {
console.error('DATABASE_URL is required. Run PostgreSQL and execute npm run migrate:up first.')
process.exit(1)
}
2026-08-14 12:57:06 +02:00
export const pool = new Pool({ connectionString: databaseUrl })
2026-08-14 12:43:11 +02:00
const editingEnabled = process.env.LEVEL_EDITING_ENABLED === 'true'
2026-08-17 09:24:53 +02:00
const objectStorage = createObjectStorageFromEnv()
await objectStorage.initialize()
const textExtractor = createTextExtractorFromEnv()
2026-08-17 09:24:53 +02:00
const levels = createLevelRepository(pool, editingEnabled, objectStorage)
const narrative = createNarrativeRepository(pool, objectStorage)
const storyGraph = createStoryGraphRepository(pool)
const STORY_NODE_TYPES: StoryNodeType[] = ['cutscene', 'dialogue', 'level', 'det_gate', 'llm_gate']
2026-08-14 12:43:11 +02:00
function wantsEdit(req: express.Request) {
return editingEnabled && req.query.edit === '1' && hasAdminClaim(req)
2026-08-14 12:43:11 +02:00
}
function slug(value: unknown, fallback: string) {
return String(value || fallback).trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '')
}
2026-08-14 12:43:11 +02:00
2026-08-14 12:57:06 +02:00
export const app = express()
2026-08-14 12:43:11 +02:00
app.disable('x-powered-by')
app.use(cors({ origin: process.env.CORS_ORIGIN || true, credentials: true }))
app.use(cookieParser())
app.use(authenticateJwt)
2026-08-14 12:43:11 +02:00
app.use(express.json({ limit: '2mb' }))
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: Number(process.env.MAX_DOCUMENT_BYTES || 25 * 1024 * 1024), files: 1 },
})
app.get('/api/health', async (_req, res) => {
try { await pool.query('SELECT 1'); res.json({ ok: true, database: 'connected', objectStorage: objectStorage.provider, textExtraction: textExtractor.provider, schema: 'osint', editingEnabled }) }
2026-08-14 12:43:11 +02:00
catch { res.status(503).json({ ok: false, database: 'unavailable' }) }
})
app.get('/api/session', (req, res) => res.json({ authenticated: Boolean(req.authClaims), isAdmin: hasAdminClaim(req) }))
if (process.env.NODE_ENV !== 'production') app.get('/api/dev/admin-session', (req, res) => {
const requestedReturn = String(req.query.returnTo || '/')
const returnTo = requestedReturn.startsWith('/') && !requestedReturn.startsWith('//') ? requestedReturn : '/'
res.cookie('auth_token', createDevelopmentAdminToken(), { httpOnly: true, sameSite: 'lax', path: '/', maxAge: 7 * 24 * 60 * 60 * 1000 })
res.redirect(returnTo)
})
2026-08-14 12:43:11 +02:00
app.get('/api/levels', async (_req, res, next) => {
try { res.json(await levels.listLevels()) }
2026-08-14 12:43:11 +02:00
catch (error) { next(error) }
})
app.get('/api/templates', async (_req, res, next) => {
try { res.json(await levels.listTemplates()) }
catch (error) { next(error) }
})
app.post('/api/templates/:slug/levels', requireAdmin, async (req, res, next) => {
try {
if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
const title = String(req.body?.title || '').trim() || undefined
const levelSlug = slug(req.body?.id, `${req.params.slug}-${Date.now()}`)
const level = await levels.instantiateTemplate(String(req.params.slug), { id: levelSlug, title, version: req.body?.version })
level ? res.status(201).json(level) : res.status(404).json({ error: 'Template version not found' })
} catch (error) { next(error) }
})
app.post('/api/levels', requireAdmin, async (req, res, next) => {
2026-08-14 12:43:11 +02:00
try {
if (!editingEnabled) return res.status(403).json({ error: 'Level editing is disabled' })
const title = String(req.body?.title || 'Untitled Investigation').trim()
const id = slug(req.body?.id, `level-${Date.now()}`)
res.status(201).json(await levels.createLevel({ id, title, subtitle: String(req.body?.subtitle || '') }))
2026-08-14 12:43:11 +02:00
} catch (error) { next(error) }
})
app.post('/api/levels/:id/templates', requireAdmin, async (req, res, next) => {
try {
if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
const name = String(req.body?.name || 'Untitled Template').trim()
const templateSlug = slug(req.body?.slug, name)
const template = await levels.saveLevelAsTemplate(String(req.params.id), { slug: templateSlug, name })
template ? res.status(201).json(template) : res.status(404).json({ error: 'Level not found' })
} catch (error) { next(error) }
})
2026-08-14 12:43:11 +02:00
app.get('/api/assets/:id', async (req, res, next) => {
try {
const asset = await levels.getAsset(req.params.id)
2026-08-14 12:43:11 +02:00
if (!asset) return res.status(404).json({ error: 'Asset not found' })
2026-08-17 09:24:53 +02:00
const inline = asset.mimeType === 'application/pdf' || asset.mimeType.startsWith('image/') || asset.mimeType.startsWith('text/')
res.setHeader('Content-Type', asset.mimeType || 'application/octet-stream')
res.setHeader('Content-Length', asset.byteSize)
res.setHeader('Content-Disposition', `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(asset.originalName)}`)
2026-08-14 12:43:11 +02:00
res.setHeader('X-Content-Type-Options', 'nosniff')
2026-08-17 09:24:53 +02:00
asset.stream.on('error', next)
asset.stream.pipe(res)
2026-08-14 12:43:11 +02:00
} catch (error) { next(error) }
})
app.post('/api/levels/:id/documents', upload.single('file'), async (req, res, next) => {
2026-08-14 12:43:11 +02:00
try {
if (!req.file) return res.status(400).json({ error: 'A file is required' })
const extraction = await textExtractor.extract(req.file)
const x = Number(req.body?.x); const y = Number(req.body?.y)
const placement = Number.isFinite(x) && Number.isFinite(y) ? { x, y } : undefined
const document = await levels.uploadDocument(String(req.params.id), req.file, extraction, placement)
document ? res.status(201).json(document) : res.status(404).json({ error: 'Level not found' })
} catch (error) { next(error) }
2026-08-14 12:43:11 +02:00
})
app.post('/api/levels/:id/reveals/seen', async (req, res, next) => {
try {
const ids = Array.isArray(req.body?.documentIds) ? req.body.documentIds.map(String) : []
const acknowledged = await levels.acknowledgeRevealedDocuments(String(req.params.id), ids)
acknowledged === null ? res.status(404).json({ error: 'Level not found' }) : res.json({ acknowledged })
} catch (error) { next(error) }
})
app.get('/api/levels/:id/flags', requireAdmin, async (req, res, next) => {
try {
const flags = await levels.listFlags(String(req.params.id))
flags ? res.json(flags) : res.status(404).json({ error: 'Level not found' })
} catch (error) { next(error) }
})
app.put('/api/levels/:id/flags/:key', requireAdmin, async (req, res, next) => {
try {
const updated = await levels.setFlag(String(req.params.id), String(req.params.key), true)
updated ? res.json({ ok: true }) : res.status(404).json({ error: 'Level not found' })
} catch (error) { next(error) }
})
app.delete('/api/levels/:id/flags/:key', requireAdmin, async (req, res, next) => {
try {
const updated = await levels.setFlag(String(req.params.id), String(req.params.key), false)
updated ? res.json({ ok: true }) : res.status(404).json({ error: 'Level not found' })
} catch (error) { next(error) }
})
2026-08-14 12:43:11 +02:00
app.get('/api/levels/:id', async (req, res, next) => {
try {
const level = await levels.getLevel(req.params.id, wantsEdit(req))
level ? res.json(level) : res.status(404).json({ error: 'Level not found' })
} catch (error) { next(error) }
2026-08-14 12:43:11 +02:00
})
app.put('/api/levels/:id', async (req, res, next) => {
const state = req.body as CaseState
2026-08-17 09:24:53 +02:00
if (!state || state.id !== req.params.id || !Array.isArray(state.exhibits) || !Array.isArray(state.views) || !Array.isArray(state.connections)) return res.status(400).json({ error: 'Invalid level state' })
2026-08-14 12:43:11 +02:00
try {
const authorMode = wantsEdit(req)
await levels.saveLevel(state, authorMode)
res.json({ ok: true, mode: authorMode ? 'author' : 'play' })
} catch (error) { next(error) }
2026-08-14 12:43:11 +02:00
})
app.post('/api/levels/:id/reset', async (req, res, next) => {
try {
const level = await levels.resetLevel(req.params.id)
level ? res.json(level) : res.status(404).json({ error: 'Level not found' })
} catch (error) { next(error) }
2026-08-14 12:43:11 +02:00
})
// Admin authoring panel: NPC template library and mystery listing. Reads require an
// admin claim; writes additionally require editing to be enabled on this deployment.
function requireEditing(res: express.Response) {
if (!editingEnabled) { res.status(403).json({ error: 'Level editing is disabled' }); return false }
return true
}
app.get('/api/levels/:id/evidence-match-rules', requireAdmin, async (req, res, next) => {
try {
const rules = await levels.listEvidenceMatchRules(String(req.params.id))
rules ? res.json(rules) : res.status(404).json({ error: 'Level not found' })
} catch (error) { next(error) }
})
app.post('/api/levels/:id/evidence-match-rules', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const rule = await levels.createEvidenceMatchRule(String(req.params.id), req.body)
rule ? res.status(201).json(rule) : res.status(404).json({ error: 'Level not found' })
} catch (error) { next(error) }
})
app.put('/api/levels/:id/evidence-match-rules/:ruleId', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const rule = await levels.updateEvidenceMatchRule(String(req.params.id), String(req.params.ruleId), req.body)
rule ? res.json(rule) : res.status(404).json({ error: 'Level or evidence match rule not found' })
} catch (error) { next(error) }
})
app.delete('/api/levels/:id/evidence-match-rules/:ruleId', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const removed = await levels.deleteEvidenceMatchRule(String(req.params.id), String(req.params.ruleId))
if (removed === null) return res.status(404).json({ error: 'Level not found' })
removed ? res.json({ ok: true }) : res.status(404).json({ error: 'Evidence match rule not found' })
} catch (error) { next(error) }
})
app.get('/api/admin/mysteries', requireAdmin, async (_req, res, next) => {
try { res.json(await narrative.listMysteries()) } catch (error) { next(error) }
})
app.delete('/api/admin/mysteries/:id', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const ok = await narrative.deleteMystery(String(req.params.id))
ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Mystery not found' })
} catch (error) { next(error) }
})
// Shared asset library (images, audio, PDFs) — reuses the immutable, deduplicated
// osint.assets store; bytes served via GET /api/assets/:id.
app.get('/api/admin/assets', requireAdmin, async (_req, res, next) => {
try { res.json(await narrative.listAssets()) } catch (error) { next(error) }
})
app.post('/api/admin/assets', requireAdmin, upload.single('file'), async (req, res, next) => {
try {
if (!requireEditing(res)) return
if (!req.file) return res.status(400).json({ error: 'A file is required' })
res.status(201).json(await narrative.uploadAsset(req.file))
} catch (error) { next(error) }
})
app.delete('/api/admin/assets/:id', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const outcome = await narrative.deleteAsset(String(req.params.id))
if (outcome === 'deleted') return res.json({ ok: true })
res.status(outcome === 'in_use' ? 409 : 404).json({ error: outcome === 'in_use' ? 'Asset is in use' : 'Asset not found' })
} catch (error) { next(error) }
})
app.get('/api/admin/npcs', requireAdmin, async (_req, res, next) => {
try { res.json(await narrative.listNpcs()) } catch (error) { next(error) }
})
app.post('/api/admin/npcs', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
if (!req.body?.key) return res.status(400).json({ error: 'An NPC key is required' })
res.status(201).json(await narrative.createNpc({ key: String(req.body.key), name: String(req.body.name || ''), role: String(req.body.role || ''), defaultPose: req.body.defaultPose || null }))
} catch (error) { next(error) }
})
app.patch('/api/admin/npcs/:id', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const npc = await narrative.updateNpc(String(req.params.id), { name: req.body?.name, role: req.body?.role, defaultPose: req.body?.defaultPose })
npc ? res.json(npc) : res.status(404).json({ error: 'NPC not found' })
} catch (error) { next(error) }
})
app.delete('/api/admin/npcs/:id', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const outcome = await narrative.deleteNpc(String(req.params.id))
if (outcome === 'deleted') return res.json({ ok: true })
res.status(outcome === 'in_use' ? 409 : 404).json({ error: outcome === 'in_use' ? 'NPC is used by a cutscene' : 'NPC not found' })
} catch (error) { next(error) }
})
app.post('/api/admin/npcs/:id/poses', requireAdmin, upload.single('file'), async (req, res, next) => {
try {
if (!requireEditing(res)) return
if (!req.file) return res.status(400).json({ error: 'An image file is required' })
if (!req.body?.poseKey) return res.status(400).json({ error: 'A pose key is required' })
const npc = await narrative.addPose(String(req.params.id), String(req.body.poseKey), req.file)
npc ? res.status(201).json(npc) : res.status(404).json({ error: 'NPC not found' })
} catch (error) { next(error) }
})
app.delete('/api/admin/npcs/:id/poses/:poseKey', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const npc = await narrative.deletePose(String(req.params.id), String(req.params.poseKey))
npc ? res.json(npc) : res.status(404).json({ error: 'NPC not found' })
} catch (error) { next(error) }
})
// Story flow graph authoring (Phase 1): nodes, terminals, wiring, entrypoint.
app.get('/api/admin/level-templates', requireAdmin, async (_req, res, next) => {
try { res.json(await storyGraph.listLevelTemplates()) } catch (error) { next(error) }
})
app.get('/api/admin/mysteries/:id/graph', requireAdmin, async (req, res, next) => {
try {
const graph = await storyGraph.getGraph(String(req.params.id))
graph ? res.json(graph) : res.status(404).json({ error: 'Mystery not found' })
} catch (error) { next(error) }
})
app.post('/api/admin/mysteries/:id/nodes', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const nodeType = String(req.body?.nodeType) as StoryNodeType
if (!STORY_NODE_TYPES.includes(nodeType)) return res.status(400).json({ error: 'Unknown node type' })
const node = await storyGraph.createNode(String(req.params.id), { nodeType, xpos: Number(req.body?.xpos) || 0, ypos: Number(req.body?.ypos) || 0, label: req.body?.label })
node ? res.status(201).json(node) : res.status(404).json({ error: 'Mystery not found' })
} catch (error) { next(error) }
})
app.patch('/api/admin/story-nodes/:id', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const node = await storyGraph.updateNode(String(req.params.id), req.body || {})
node ? res.json(node) : res.status(404).json({ error: 'Node not found' })
} catch (error) { next(error) }
})
app.delete('/api/admin/story-nodes/:id', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const ok = await storyGraph.deleteNode(String(req.params.id))
ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Node not found' })
} catch (error) { next(error) }
})
app.post('/api/admin/story-nodes/:id/terminals', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
if (!req.body?.terminalKey) return res.status(400).json({ error: 'A terminal key is required' })
const node = await storyGraph.addTerminal(String(req.params.id), { terminalKey: String(req.body.terminalKey), label: req.body?.label })
node ? res.status(201).json(node) : res.status(404).json({ error: 'Node not found' })
} catch (error) { next(error) }
})
app.patch('/api/admin/story-terminals/:id', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const result = await storyGraph.updateTerminal(String(req.params.id), req.body || {})
result.ok ? res.json({ ok: true }) : res.status(result.error === 'Terminal not found' ? 404 : 400).json({ error: result.error })
} catch (error) { next(error) }
})
app.delete('/api/admin/story-terminals/:id', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const ok = await storyGraph.deleteTerminal(String(req.params.id))
ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Terminal not found' })
} catch (error) { next(error) }
})
app.put('/api/admin/mysteries/:id/entry', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const result = await storyGraph.setEntryNode(String(req.params.id), req.body?.nodeId ?? null)
result.ok ? res.json({ ok: true }) : res.status(400).json({ error: result.error })
} catch (error) { next(error) }
})
app.post('/api/admin/mysteries/:id/graph', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
if (!req.body?.entry || !Array.isArray(req.body?.nodes)) return res.status(400).json({ error: 'A graph spec needs entry and nodes' })
const result = await storyGraph.authorGraph(String(req.params.id), req.body)
result ? res.status(201).json(result) : res.status(404).json({ error: 'Mystery not found' })
} catch (error) { next(error) }
})
// Utterance sub-graph (dialogue crafter).
app.get('/api/admin/story-nodes/:id/utterances', requireAdmin, async (req, res, next) => {
try { res.json(await storyGraph.listUtterances(String(req.params.id))) } catch (error) { next(error) }
})
// Resolved runtime dialogue tree for the editor's live preview (same resolver as play).
app.get('/api/admin/story-nodes/:id/dialogue', requireAdmin, async (req, res, next) => {
try { res.json(await narrative.resolveDialogue(String(req.params.id))) } catch (error) { next(error) }
})
app.post('/api/admin/story-nodes/:id/utterances', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const utterer = req.body?.utterer === 'player' ? 'player' : 'npc'
const utterance = await storyGraph.createUtterance(String(req.params.id), { utterer, xpos: Number(req.body?.xpos) || 0, ypos: Number(req.body?.ypos) || 0, text: req.body?.text })
utterance ? res.status(201).json(utterance) : res.status(404).json({ error: 'Node not found' })
} catch (error) { next(error) }
})
app.patch('/api/admin/utterances/:id', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const result = await storyGraph.updateUtterance(String(req.params.id), req.body || {})
result.ok ? res.json({ ok: true }) : res.status(result.error === 'Utterance not found' ? 404 : 400).json({ error: result.error })
} catch (error) { next(error) }
})
app.delete('/api/admin/utterances/:id', requireAdmin, async (req, res, next) => {
try {
if (!requireEditing(res)) return
const ok = await storyGraph.deleteUtterance(String(req.params.id))
ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Utterance not found' })
} catch (error) { next(error) }
})
2026-08-22 15:51:32 +02:00
// Play mode: the launchable case list for the splash picker (entrypoint required).
app.get('/api/mysteries', async (_req, res, next) => {
try { res.json(await narrative.listPlayableMysteries()) }
catch (error) { next(error) }
})
// Narrative authoring: create a mystery and its NPC cast. The flow (cutscenes,
// dialogue, levels) lives in the story graph, seeded separately.
app.post('/api/mysteries', requireAdmin, async (req, res, next) => {
try {
if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
const body = req.body || {}
if (!body.slug || !body.title) return res.status(400).json({ error: 'A mystery requires slug and title' })
const created = await narrative.authorMystery({ slug: slug(body.slug, body.slug), title: String(body.title), cast: Array.isArray(body.cast) ? body.cast : [] })
res.status(201).json(created)
} catch (error) { next(error) }
})
// New Game creates a playthrough bound to the caller's identity.
app.post('/api/playthroughs', async (req, res, next) => {
try {
const result = await narrative.createPlaythrough(resolveUserId(req), req.body?.mystery ? slug(req.body.mystery, req.body.mystery) : undefined)
result ? res.status(201).json(result) : res.status(404).json({ error: 'No mystery available' })
} catch (error) { next(error) }
})
app.get('/api/playthroughs/current', async (req, res, next) => {
try {
const result = await narrative.getCurrentPlaythrough(resolveUserId(req))
result ? res.json(result) : res.status(204).end()
} catch (error) { next(error) }
})
// Advance the playthrough through the story graph (follows a terminal; auto-skips
// gates; instantiates the board when entering a level node).
app.post('/api/playthroughs/:id/advance', async (req, res, next) => {
try {
const result = await narrative.advancePlaythrough(resolveUserId(req), String(req.params.id), req.body?.terminalKey)
result.ok ? res.json(result.state ?? null) : res.status(result.error === 'Playthrough not found' ? 404 : 400).json({ error: result.error })
} catch (error) { next(error) }
})
// The playthrough case-state (achievements). Read is open; granting is a dev-only
// stand-in until the server-side achievement rule engine drives awards from play.
app.get('/api/playthroughs/:id/achievements', async (req, res, next) => {
try {
const flags = await narrative.listAchievements(String(req.params.id))
flags ? res.json(flags) : res.status(404).json({ error: 'Playthrough not found' })
} catch (error) { next(error) }
})
app.post('/api/playthroughs/:id/achievements', async (req, res, next) => {
try {
if (process.env.NODE_ENV === 'production') return res.status(403).json({ error: 'Manual grants are disabled' })
if (!req.body?.flagKey) return res.status(400).json({ error: 'A flagKey is required' })
const result = await narrative.awardAchievement(String(req.params.id), String(req.body.flagKey), req.body.nodeId ? String(req.body.nodeId) : null)
result.ok ? res.json({ earned: result.earned }) : res.status(result.error === 'Playthrough not found' ? 404 : 400).json({ error: result.error })
} catch (error) { next(error) }
})
2026-08-22 15:12:56 +02:00
// Dev teleport: jump the playthrough to an explicit story node. Powers /node/:id.
app.post('/api/playthroughs/:id/goto', async (req, res, next) => {
try {
if (process.env.NODE_ENV === 'production') return res.status(403).json({ error: 'Node teleport is disabled' })
if (!req.body?.nodeId) return res.status(400).json({ error: 'A nodeId is required' })
const result = await narrative.gotoNode(resolveUserId(req), String(req.params.id), String(req.body.nodeId))
result.ok ? res.json(result.state ?? null) : res.status(result.error === 'Playthrough not found' || result.error === 'Node not found' ? 404 : 400).json({ error: result.error })
} catch (error) { next(error) }
})
2026-08-14 12:43:11 +02:00
app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
if (error instanceof multer.MulterError) {
return res.status(error.code === 'LIMIT_FILE_SIZE' ? 413 : 400).json({ error: error.code === 'LIMIT_FILE_SIZE' ? 'Document exceeds the upload limit' : error.message })
}
console.error(error); res.status(500).json({ error: 'Internal server error' })
})
const here = path.dirname(fileURLToPath(import.meta.url)); const dist = path.resolve(here, '..', 'dist')
if (fs.existsSync(dist)) { app.use(express.static(dist)); app.get('*splat', (_req, res) => res.sendFile(path.join(dist, 'index.html'))) }
const port = Number(process.env.PORT || 8787)
2026-08-14 12:57:06 +02:00
export const server = app.listen(port, '0.0.0.0', () => console.log(`GUPI OSINT Board listening on http://localhost:${port}`))
2026-08-14 12:43:11 +02:00
async function shutdown() { server.close(); await pool.end(); process.exit(0) }
if (!process.env.VITEST && process.env.OSINT_MANAGED_SERVER !== 'true') {
2026-08-14 12:57:06 +02:00
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)
}