2026-08-14 12:43:11 +02:00
|
|
|
import 'dotenv/config'
|
|
|
|
|
import cors from 'cors'
|
2026-08-15 09:29:08 +02:00
|
|
|
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'
|
2026-08-14 13:57:51 +02:00
|
|
|
import pg from 'pg'
|
|
|
|
|
import type { CaseState } from '../src/types.js'
|
2026-08-15 09:29:08 +02:00
|
|
|
import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin } from './auth.js'
|
2026-08-14 14:09:24 +02:00
|
|
|
import { createLevelRepository } from './levelRepository.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-14 14:09:24 +02:00
|
|
|
const levels = createLevelRepository(pool, editingEnabled)
|
2026-08-14 12:43:11 +02:00
|
|
|
|
|
|
|
|
function wantsEdit(req: express.Request) {
|
2026-08-15 09:29:08 +02:00
|
|
|
return editingEnabled && req.query.edit === '1' && hasAdminClaim(req)
|
2026-08-14 12:43:11 +02:00
|
|
|
}
|
2026-08-14 14:17:54 +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')
|
2026-08-15 09:29:08 +02:00
|
|
|
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', schema: 'osint', editingEnabled }) }
|
|
|
|
|
catch { res.status(503).json({ ok: false, database: 'unavailable' }) }
|
|
|
|
|
})
|
2026-08-15 09:29:08 +02:00
|
|
|
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) => {
|
2026-08-14 13:57:51 +02:00
|
|
|
try { res.json(await levels.listLevels()) }
|
2026-08-14 12:43:11 +02:00
|
|
|
catch (error) { next(error) }
|
|
|
|
|
})
|
2026-08-14 14:17:54 +02:00
|
|
|
app.get('/api/templates', async (_req, res, next) => {
|
|
|
|
|
try { res.json(await levels.listTemplates()) }
|
|
|
|
|
catch (error) { next(error) }
|
|
|
|
|
})
|
2026-08-15 09:29:08 +02:00
|
|
|
app.post('/api/templates/:slug/levels', requireAdmin, async (req, res, next) => {
|
2026-08-14 14:17:54 +02:00
|
|
|
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()}`)
|
2026-08-15 09:29:08 +02:00
|
|
|
const level = await levels.instantiateTemplate(String(req.params.slug), { id: levelSlug, title, version: req.body?.version })
|
2026-08-14 14:17:54 +02:00
|
|
|
level ? res.status(201).json(level) : res.status(404).json({ error: 'Template version not found' })
|
|
|
|
|
} catch (error) { next(error) }
|
|
|
|
|
})
|
2026-08-15 09:29:08 +02:00
|
|
|
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()
|
2026-08-14 14:17:54 +02:00
|
|
|
const id = slug(req.body?.id, `level-${Date.now()}`)
|
2026-08-14 13:57:51 +02:00
|
|
|
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) }
|
|
|
|
|
})
|
2026-08-15 09:29:08 +02:00
|
|
|
app.post('/api/levels/:id/templates', requireAdmin, async (req, res, next) => {
|
2026-08-14 14:17:54 +02:00
|
|
|
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)
|
2026-08-15 09:29:08 +02:00
|
|
|
const template = await levels.saveLevelAsTemplate(String(req.params.id), { slug: templateSlug, name })
|
2026-08-14 14:17:54 +02:00
|
|
|
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 {
|
2026-08-14 13:57:51 +02:00
|
|
|
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' })
|
|
|
|
|
const inline = asset.mime_type === 'application/pdf' || asset.mime_type.startsWith('image/') || asset.mime_type.startsWith('text/')
|
|
|
|
|
res.setHeader('Content-Type', asset.mime_type || 'application/octet-stream')
|
|
|
|
|
res.setHeader('Content-Length', asset.byte_size)
|
|
|
|
|
res.setHeader('Content-Disposition', `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(asset.original_name)}`)
|
|
|
|
|
res.setHeader('X-Content-Type-Options', 'nosniff')
|
|
|
|
|
res.send(asset.content)
|
|
|
|
|
} catch (error) { next(error) }
|
|
|
|
|
})
|
2026-08-15 09:29:08 +02:00
|
|
|
app.post('/api/levels/:id/documents', requireAdmin, upload.single('file'), async (req, res, next) => {
|
2026-08-14 12:43:11 +02:00
|
|
|
try {
|
2026-08-14 13:57:51 +02:00
|
|
|
if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
|
|
|
|
|
if (!req.file) return res.status(400).json({ error: 'A file is required' })
|
|
|
|
|
const document = await levels.uploadDocument(String(req.params.id), req.file)
|
|
|
|
|
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.get('/api/levels/:id', async (req, res, next) => {
|
2026-08-14 13:57:51 +02:00
|
|
|
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
|
|
|
|
|
if (!state || state.id !== req.params.id || !Array.isArray(state.evidence) || !Array.isArray(state.connections)) return res.status(400).json({ error: 'Invalid level state' })
|
|
|
|
|
try {
|
2026-08-14 13:57:51 +02:00
|
|
|
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 {
|
2026-08-14 13:57:51 +02:00
|
|
|
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
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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) }
|
2026-08-14 13:43:37 +02:00
|
|
|
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)
|
|
|
|
|
}
|