import 'dotenv/config' import cors from 'cors' 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 { createLegacyLevelRepository } from './levelRepository.js' 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) } export const pool = new Pool({ connectionString: databaseUrl }) const editingEnabled = process.env.LEVEL_EDITING_ENABLED === 'true' const levels = createLegacyLevelRepository(pool, editingEnabled) function wantsEdit(req: express.Request) { return editingEnabled && req.query.edit === '1' } export const app = express() app.disable('x-powered-by') app.use(cors({ origin: process.env.CORS_ORIGIN || true })) 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' }) } }) app.get('/api/levels', async (_req, res, next) => { try { res.json(await levels.listLevels()) } catch (error) { next(error) } }) app.post('/api/levels', async (req, res, next) => { try { if (!editingEnabled) return res.status(403).json({ error: 'Level editing is disabled' }) const title = String(req.body?.title || 'Untitled Investigation').trim() const id = String(req.body?.id || `level-${Date.now()}`).trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-') res.status(201).json(await levels.createLevel({ id, title, subtitle: String(req.body?.subtitle || '') })) } catch (error) { next(error) } }) app.get('/api/assets/:id', async (req, res, next) => { try { const asset = await levels.getAsset(req.params.id) 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) } }) app.post('/api/levels/:id/documents', upload.single('file'), async (req, res, next) => { try { 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) } }) 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) } }) 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 { const authorMode = wantsEdit(req) await levels.saveLevel(state, authorMode) res.json({ ok: true, mode: authorMode ? 'author' : 'play' }) } catch (error) { next(error) } }) 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) } }) 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) export const server = app.listen(port, '0.0.0.0', () => console.log(`GUPI OSINT Board listening on http://localhost:${port}`)) async function shutdown() { server.close(); await pool.end(); process.exit(0) } if (!process.env.VITEST && process.env.OSINT_MANAGED_SERVER !== 'true') { process.on('SIGTERM', shutdown) process.on('SIGINT', shutdown) }