Gate admin menu and authoring with shared JWT

This commit is contained in:
2026-08-15 09:29:08 +02:00
parent eeaa4138fa
commit 8f1f5a8743
16 changed files with 366 additions and 52 deletions
+19 -8
View File
@@ -1,5 +1,6 @@
import 'dotenv/config'
import cors from 'cors'
import cookieParser from 'cookie-parser'
import express from 'express'
import fs from 'node:fs'
import path from 'node:path'
@@ -7,6 +8,7 @@ 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 } from './auth.js'
import { createLevelRepository } from './levelRepository.js'
const { Pool } = pg
@@ -21,7 +23,7 @@ const editingEnabled = process.env.LEVEL_EDITING_ENABLED === 'true'
const levels = createLevelRepository(pool, editingEnabled)
function wantsEdit(req: express.Request) {
return editingEnabled && req.query.edit === '1'
return editingEnabled && req.query.edit === '1' && hasAdminClaim(req)
}
function slug(value: unknown, fallback: string) {
return String(value || fallback).trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '')
@@ -29,7 +31,9 @@ function slug(value: unknown, fallback: string) {
export const app = express()
app.disable('x-powered-by')
app.use(cors({ origin: process.env.CORS_ORIGIN || true }))
app.use(cors({ origin: process.env.CORS_ORIGIN || true, credentials: true }))
app.use(cookieParser())
app.use(authenticateJwt)
app.use(express.json({ limit: '2mb' }))
const upload = multer({
storage: multer.memoryStorage(),
@@ -40,6 +44,13 @@ 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/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)
})
app.get('/api/levels', async (_req, res, next) => {
try { res.json(await levels.listLevels()) }
catch (error) { next(error) }
@@ -48,16 +59,16 @@ app.get('/api/templates', async (_req, res, next) => {
try { res.json(await levels.listTemplates()) }
catch (error) { next(error) }
})
app.post('/api/templates/:slug/levels', async (req, res, next) => {
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(req.params.slug, { id: levelSlug, title, version: req.body?.version })
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', async (req, res, next) => {
app.post('/api/levels', requireAdmin, 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()
@@ -65,12 +76,12 @@ app.post('/api/levels', async (req, res, next) => {
res.status(201).json(await levels.createLevel({ id, title, subtitle: String(req.body?.subtitle || '') }))
} catch (error) { next(error) }
})
app.post('/api/levels/:id/templates', async (req, res, next) => {
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(req.params.id, { slug: templateSlug, 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) }
})
@@ -86,7 +97,7 @@ app.get('/api/assets/:id', async (req, res, next) => {
res.send(asset.content)
} catch (error) { next(error) }
})
app.post('/api/levels/:id/documents', upload.single('file'), async (req, res, next) => {
app.post('/api/levels/:id/documents', requireAdmin, 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' })