diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 3275186..6fb48aa 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -85,29 +85,6 @@ services: networks: - kaboot-network - kaboot-sandbox: - build: - context: ./server/sandbox - dockerfile: Dockerfile - restart: unless-stopped - read_only: true - security_opt: - - no-new-privileges:true - cap_drop: - - ALL - environment: - MAX_CONCURRENT: "2" - tmpfs: - - /tmp:size=200M,mode=1777 - deploy: - replicas: ${SANDBOX_REPLICAS:-4} - resources: - limits: - cpus: '1' - memory: 512M - networks: - - kaboot-network - kaboot-backend: build: context: ./server @@ -128,14 +105,10 @@ services: STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} STRIPE_PRICE_ID_MONTHLY: ${STRIPE_PRICE_ID_MONTHLY:-} STRIPE_PRICE_ID_YEARLY: ${STRIPE_PRICE_ID_YEARLY:-} - SANDBOX_URL: http://kaboot-sandbox:3002 - USE_SANDBOX: "true" volumes: - kaboot-data:/data tmpfs: - /tmp:size=100M - depends_on: - - kaboot-sandbox networks: - kaboot-network diff --git a/docker-compose.yml b/docker-compose.yml index 43f7abb..1a9e809 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -97,33 +97,6 @@ services: networks: - kaboot-network - # ═══════════════════════════════════════════════════════════════════════════ - # KABOOT - Document Conversion Sandbox (isolated LibreOffice) - # ═══════════════════════════════════════════════════════════════════════════ - - kaboot-sandbox: - build: - context: ./server/sandbox - dockerfile: Dockerfile - restart: unless-stopped - read_only: true - security_opt: - - no-new-privileges:true - cap_drop: - - ALL - environment: - MAX_CONCURRENT: "2" - tmpfs: - - /tmp:size=200M,mode=1777 - deploy: - replicas: ${SANDBOX_REPLICAS:-2} - resources: - limits: - cpus: '1' - memory: 512M - networks: - - kaboot-network - # ═══════════════════════════════════════════════════════════════════════════ # KABOOT - Application Backend # ═══════════════════════════════════════════════════════════════════════════ @@ -145,8 +118,6 @@ services: CORS_ORIGIN: http://localhost:${KABOOT_FRONTEND_PORT:-5173},http://${KABOOT_HOST:-localhost}:${KABOOT_FRONTEND_PORT:-5173} LOG_REQUESTS: ${LOG_REQUESTS:-true} GEMINI_API_KEY: ${GEMINI_API_KEY:-} - SANDBOX_URL: http://kaboot-sandbox:3002 - USE_SANDBOX: "true" volumes: - ./data:/data tmpfs: @@ -155,7 +126,6 @@ services: - "${KABOOT_BACKEND_PORT:-3001}:3001" depends_on: - authentik-server - - kaboot-sandbox networks: - kaboot-network diff --git a/docs/AUTHENTIK_SETUP.md b/docs/AUTHENTIK_SETUP.md index 685fc03..94f4c34 100644 --- a/docs/AUTHENTIK_SETUP.md +++ b/docs/AUTHENTIK_SETUP.md @@ -408,68 +408,6 @@ Then run the dev server with `npm run dev -- --host` to bind to all interfaces. - Verify `OIDC_ISSUER` and `OIDC_JWKS_URI` are correct - The backend must be able to reach Authentik at `http://authentik-server:9000` (Docker network) -## Required OIDC Claims - -The Kaboot backend validates the following JWT claims: - -| Claim | Required | Description | -|-------|----------|-------------| -| `sub` | Yes | User identifier (subject) | -| `iss` | Yes | Must match `OIDC_ISSUER` env var | -| `aud` | Recommended | Must match `OIDC_AUDIENCE` env var if set | -| `preferred_username` | No | Display name, falls back to `sub` | -| `email` | No | User's email address | -| `groups` | No | Array of group names for access control | - -### Audience Claim Configuration - -The `aud` (audience) claim prevents tokens issued for other applications from being accepted. The blueprint configures this automatically via the `kaboot` scope. - -**Backend Configuration:** -```bash -# Set to your OIDC client ID -OIDC_AUDIENCE=kaboot-spa -``` - -**Frontend Configuration:** -The frontend must request the `kaboot` scope to include the audience claim: -```typescript -// src/config/oidc.ts -scope: 'openid profile email offline_access groups kaboot' -``` - -**Manual Authentik Setup (if not using blueprints):** - -1. Go to **Customisation** > **Property Mappings** -2. Click **Create** > **Scope Mapping** -3. Configure: - | Field | Value | - |-------|-------| - | Name | `Kaboot Audience Scope` | - | Scope name | `kaboot` | - | Expression | `return {"aud": "kaboot-spa"}` | - -4. Go to **Applications** > **Providers** > **Kaboot OAuth2** -5. Edit and add the new scope mapping to **Scopes** - -### Groups Claim for Access Control - -The `groups` claim controls access to premium features: - -| Group | Access Level | -|-------|--------------| -| `kaboot-users` | Basic access (required to use app) | -| `kaboot-ai-access` | Unlimited AI generations, OCR access | -| `kaboot-admin` | Admin features | - -The groups scope mapping is configured in the blueprint. For manual setup: - -1. Create a scope mapping with expression: - ```python - return {"groups": [group.name for group in request.user.ak_groups.all()]} - ``` -2. Add it to the provider's scopes - ## Production Notes For production deployment, see [PRODUCTION.md](./PRODUCTION.md) for full instructions. diff --git a/docs/SECURITY_PLAN.md b/docs/SECURITY_PLAN.md deleted file mode 100644 index b7169c1..0000000 --- a/docs/SECURITY_PLAN.md +++ /dev/null @@ -1,114 +0,0 @@ -# AUDIT - -High / Critical Findings -- Client-exposed Gemini system key risk. vite.config.ts injects process.env.GEMINI_API_KEY into the frontend bundle and services/geminiService.ts falls back to process.env.API_KEY; if a server/system key is set, it becomes visible to all clients and bypasses backend access controls. Recommend removing env injection for secrets and using only POST /api/generate for system AI. Evidence: vite.config.ts, services/geminiService.ts:6-12. -- Untrusted document parsing runs native tooling. server/src/services/documentParser.ts uses LibreOffice + multiple parsers on attacker-supplied files; even without command injection, crafted docs are a common RCE vector. Treat as high risk unless sandboxed. Recommend running conversions in a container/worker with seccomp/AppArmor, low privileges, CPU/memory limits, and timeouts. Evidence: server/src/services/documentParser.ts:223-226. -- Memory-heavy file uploads without per-endpoint rate limits. multer.memoryStorage() accepts up to 50MB and parsing happens synchronously; repeated requests can exhaust RAM/CPU. Recommend per-route rate limits + streaming to disk or queueing with concurrency caps. Evidence: server/src/routes/upload.ts:11-18. -Medium Findings -- JWT audience not validated. requireAuth verifies issuer/alg but not aud. A token minted for another client with the same issuer could be accepted. Recommend adding audience validation (client_id) and optionally azp. Evidence: server/src/middleware/auth.ts:58-64. -- Tokens and API keys stored in localStorage. OIDC user store and API keys are persisted client-side, which is vulnerable to XSS. Mitigation: prefer in-memory storage or httpOnly cookies; avoid persisting API keys. Evidence: src/config/oidc.ts:17, hooks/useUserPreferences.ts:17-48, hooks/useGame.ts:62-74. -- Public game lookup is enumerable if PINs are short. GET /api/games/:pin is unauthenticated (rate-limited) and returns game metadata. If PIN length is small, enumeration is plausible. Consider longer pins, additional throttling, or auth for non-host data. Evidence: server/src/routes/games.ts:85-107. -Low Findings / Hardening -- Auth error details echoed to client. requireAuth returns details: err.message which can leak verifier details. Recommend generic responses and server-only logs. Evidence: server/src/middleware/auth.ts:66-69. -- Logging includes share tokens and pins. When LOG_REQUESTS=true, paths like /api/shared/:token are logged. Consider redacting or omitting sensitive paths. Evidence: server/src/index.ts:52-58, server/src/routes/shared.ts. -- CORS uses credentials: true with configurable origin. If cookies are ever used in future, misconfiguration could enable CSRF. Recommend disabling credentials unless needed, and enforce HTTPS origins in prod. Evidence: server/src/index.ts:42-46. -Positive Controls Observed -- Parametrized SQL across routes (no obvious SQLi). server/src/routes/*.ts. -- JWT verification uses RS256 and JWKS with caching. server/src/middleware/auth.ts. -- Stripe webhook signature verification present. server/src/routes/payments.ts:337. -- Helmet with CSP enabled on backend responses. server/src/index.ts:17-28. -- No dangerouslySetInnerHTML found in frontend. -Key Recommendation Order -1. Remove client-side exposure of GEMINI_API_KEY and force system AI through backend. (vite.config.ts, services/geminiService.ts) -2. Sandbox document parsing / LibreOffice conversions. (server/src/services/documentParser.ts) -3. Add audience validation for JWTs. (server/src/middleware/auth.ts) -4. Add upload-specific throttling and reduce memory pressure. (server/src/routes/upload.ts) -5. Reduce client-side secret persistence (move to in-memory / httpOnly). (src/config/oidc.ts, hooks/useUserPreferences.ts) - -# Security Plan (Kaboot) - -This plan turns the audit findings into a tracked, testable remediation roadmap. - -## Goals -- [x] Eliminate client-side exposure of system AI secrets and enforce server-side access control only. -- [x] Reduce risk of RCE/DoS in document parsing and uploads. *(sandboxing, rate limits, circuit breaker all done)* -- [ ] Harden token validation and client storage practices. *(JWT aud validation done; client storage pending)* -- [ ] Improve auditability, logging hygiene, and safe rollout. - -## Scope -- [ ] Backend entrypoints and middleware: `server/src/index.ts`, `server/src/routes/*.ts`, `server/src/middleware/auth.ts`. -- [ ] Document processing: `server/src/services/documentParser.ts`, `server/src/routes/upload.ts`. -- [ ] Frontend auth/storage and AI clients: `src/config/oidc.ts`, `hooks/useUserPreferences.ts`, `hooks/useAuthenticatedFetch.ts`, `services/geminiService.ts`. -- [ ] Build/config surfaces: `vite.config.ts`, `.env.example`, `docker-compose*.yml`, `docs/*`. - -## Assumptions -- [ ] Backend is the only trusted system for system AI generation. -- [ ] Auth provider is Authentik with OIDC JWTs. -- [ ] Uploads may be attacker-controlled and should be treated as hostile. - -## Phase 0 - Baseline and Inventory -- [ ] Inventory all secrets and confirm none are present in repo or frontend bundles. -- [ ] Verify current auth boundary: which routes require `requireAuth` vs public. -- [ ] Record current rate limits and error handling for uploads, generation, games, and shared endpoints. -- [ ] Add a basic secret scan in CI (gitleaks/trufflehog) and document false-positive exclusions. - -## Phase 1 - Critical Remediations - -### 1) Remove Client Exposure of System AI Key -- [x] Remove `process.env.GEMINI_API_KEY` exposure from `vite.config.ts`. -- [x] Remove `process.env.API_KEY` fallback in `services/geminiService.ts`. -- [x] Ensure all system AI usage flows through `POST /api/generate` only. -- [ ] Validate that built frontend bundles do not contain Gemini keys. - -### 2) Sandbox Document Processing -- [x] Move LibreOffice conversion to a sandboxed worker/container (no network, low privileges). -- [x] Enforce per-job CPU/memory limits and execution timeouts. -- [x] Store temp files in a private directory (0700) and clean up consistently. -- [x] Add a circuit breaker for repeated failures or timeouts. - -### 3) Upload Abuse Protection -- [x] Add route-specific rate limiting for `POST /api/upload`. -- [x] Cap concurrent uploads per user and per IP. -- [ ] Consider streaming to disk or queueing (avoid large in-memory buffers when possible). - -### 4) JWT Audience Validation -- [x] Validate `aud` (and `azp` if present) in `server/src/middleware/auth.ts`. -- [x] Document required OIDC claims in `docs/AUTHENTIK_SETUP.md`. - -## Phase 2 - Hardening - -### 1) Games Endpoint Risk Reduction -- [x] Increase PIN entropy or move to a join token. -- [ ] Require host proof (secret or auth) for state mutation endpoints. -- [ ] Add per-endpoint throttling for public lookups. - -### 2) Logging and Error Hygiene -- [ ] Redact sensitive paths/tokens in request logs (`server/src/index.ts`). -- [x] Return generic auth errors to clients; keep details server-only (`server/src/middleware/auth.ts`). - -### 3) CORS and CSRF Posture -- [ ] Review use of `credentials: true` and disable if not required. -- [ ] Enforce HTTPS origins in production configs. - -### 4) Privacy and Disclosure -- [ ] Add a user-facing notice that documents may be sent to external AI providers. -- [ ] Document data handling and retention for uploads and AI generation. - -## Phase 3 - Verification -- [ ] Add tests for JWT `aud` mismatches and invalid tokens. -- [ ] Add upload tests for size limits, malformed files, and parser errors. -- [ ] Add regression tests for shared and game endpoints (rate limiting and access). -- [ ] Run dependency vulnerability checks in CI (`npm audit` or Snyk). -- [ ] Verify no secrets appear in logs or built assets. - -## Phase 4 - Rollout -- [ ] Deploy to staging and validate auth, upload, generation, and payments end-to-end. -- [ ] Rotate exposed keys after client bundle no longer includes secrets. -- [ ] Use feature flags for upload pipeline changes. -- [ ] Monitor latency, 429s, error rates, and upload failures for 48h post-deploy. - -## Deliverables -- [ ] Code changes with references and rationale per file. -- [ ] Updated docs for system AI, uploads, and privacy. -- [ ] Test report and monitoring checklist. -- [ ] Post-remediation security summary with residual risks. diff --git a/server/Dockerfile b/server/Dockerfile index 9439812..1b1dfc1 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -2,7 +2,8 @@ FROM node:22-alpine WORKDIR /app -RUN apk add --no-cache python3 make g++ curl +# Build dependencies + LibreOffice for legacy Office format conversion +RUN apk add --no-cache python3 make g++ libreoffice curl COPY package*.json ./ RUN npm install diff --git a/server/sandbox/Dockerfile b/server/sandbox/Dockerfile deleted file mode 100644 index 1b28fc9..0000000 --- a/server/sandbox/Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -FROM node:22-alpine - -RUN apk add --no-cache libreoffice curl \ - && addgroup -g 1000 sandbox \ - && adduser -u 1000 -G sandbox -s /bin/sh -D sandbox \ - && mkdir -p /app /tmp/convert \ - && chown -R sandbox:sandbox /app /tmp/convert - -WORKDIR /app - -COPY --chown=sandbox:sandbox package.json ./ -RUN npm install --omit=dev - -COPY --chown=sandbox:sandbox convert.js ./ - -USER sandbox - -EXPOSE 3002 - -HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:3002/health || exit 1 - -CMD ["node", "convert.js"] diff --git a/server/sandbox/convert.js b/server/sandbox/convert.js deleted file mode 100644 index 10eaf09..0000000 --- a/server/sandbox/convert.js +++ /dev/null @@ -1,168 +0,0 @@ -import { createServer } from 'http'; -import { exec } from 'child_process'; -import { writeFile, readFile, unlink, mkdir, readdir } from 'fs/promises'; -import { existsSync } from 'fs'; -import { join } from 'path'; -import { randomUUID } from 'crypto'; - -const PORT = process.env.PORT || 3002; -const TEMP_DIR = '/tmp/convert'; -const TIMEOUT_MS = 30000; -const MAX_FILE_SIZE = 50 * 1024 * 1024; -const MAX_CONCURRENT = parseInt(process.env.MAX_CONCURRENT || '3', 10); - -const EXTENSION_TO_OUTPUT = { - '.ppt': 'pptx', - '.doc': 'docx', - '.xls': 'xlsx', -}; - -let activeJobs = 0; -let queuedJobs = 0; - -async function cleanup(dir) { - if (!existsSync(dir)) return; - try { - for (const file of await readdir(dir)) { - await unlink(join(dir, file)).catch(() => {}); - } - await unlink(dir).catch(() => {}); - } catch {} -} - -function runLibreOffice(inputPath, outputDir, outputExt) { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error('timeout')); - }, TIMEOUT_MS); - - exec( - `libreoffice --headless --convert-to ${outputExt} --outdir "${outputDir}" "${inputPath}"`, - { maxBuffer: 10 * 1024 * 1024 }, - (error) => { - clearTimeout(timer); - if (error) reject(error); - else resolve(); - } - ); - }); -} - -async function processConversion(buffer, ext) { - const jobId = randomUUID(); - const jobDir = join(TEMP_DIR, jobId); - const inputPath = join(jobDir, `input${ext}`); - const outputExt = EXTENSION_TO_OUTPUT[ext]; - const outputPath = join(jobDir, `input.${outputExt}`); - - try { - await mkdir(jobDir, { mode: 0o700 }); - await writeFile(inputPath, buffer, { mode: 0o600 }); - - await runLibreOffice(inputPath, jobDir, outputExt); - - if (!existsSync(outputPath)) { - throw new Error('no_output'); - } - - return await readFile(outputPath); - } finally { - await cleanup(jobDir); - } -} - -async function handleConvert(req, res) { - if (activeJobs >= MAX_CONCURRENT) { - queuedJobs++; - if (queuedJobs > MAX_CONCURRENT * 2) { - queuedJobs--; - res.writeHead(503, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Server busy, try again later' })); - return; - } - - await new Promise((resolve) => { - const check = setInterval(() => { - if (activeJobs < MAX_CONCURRENT) { - clearInterval(check); - queuedJobs--; - resolve(); - } - }, 100); - }); - } - - activeJobs++; - - const chunks = []; - let size = 0; - - req.on('data', (chunk) => { - size += chunk.length; - if (size <= MAX_FILE_SIZE) { - chunks.push(chunk); - } - }); - - req.on('end', async () => { - if (size > MAX_FILE_SIZE) { - activeJobs--; - res.writeHead(413, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'File too large' })); - return; - } - - const url = new URL(req.url, `http://localhost:${PORT}`); - const ext = url.searchParams.get('ext'); - - if (!ext || !EXTENSION_TO_OUTPUT[ext]) { - activeJobs--; - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid or missing extension parameter' })); - return; - } - - try { - const converted = await processConversion(Buffer.concat(chunks), ext); - res.writeHead(200, { - 'Content-Type': 'application/octet-stream', - 'Content-Length': converted.length, - }); - res.end(converted); - } catch (err) { - const message = err.message === 'timeout' - ? 'Conversion timed out' - : err.message === 'no_output' - ? 'Conversion produced no output' - : 'Conversion failed'; - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: message })); - } finally { - activeJobs--; - } - }); -} - -const server = createServer((req, res) => { - if (req.method === 'GET' && req.url === '/health') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ status: 'ok', active: activeJobs, queued: queuedJobs })); - return; - } - - if (req.method === 'POST' && req.url?.startsWith('/convert')) { - handleConvert(req, res); - return; - } - - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Not found' })); -}); - -if (!existsSync(TEMP_DIR)) { - await mkdir(TEMP_DIR, { recursive: true, mode: 0o700 }); -} - -server.listen(PORT, '0.0.0.0', () => { - console.log(`Sandbox running on port ${PORT} (max ${MAX_CONCURRENT} concurrent)`); -}); diff --git a/server/sandbox/package.json b/server/sandbox/package.json deleted file mode 100644 index 685582d..0000000 --- a/server/sandbox/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "kaboot-sandbox", - "version": "1.0.0", - "type": "module", - "private": true, - "scripts": { - "start": "node convert.js" - } -} diff --git a/server/src/db/connection.ts b/server/src/db/connection.ts index 5987914..b820a79 100644 --- a/server/src/db/connection.ts +++ b/server/src/db/connection.ts @@ -164,7 +164,6 @@ const runMigrations = () => { pin TEXT PRIMARY KEY, host_peer_id TEXT NOT NULL, host_secret TEXT NOT NULL, - host_user_id TEXT, quiz_data TEXT NOT NULL, game_config TEXT NOT NULL, game_state TEXT NOT NULL DEFAULT 'LOBBY', @@ -186,12 +185,6 @@ const runMigrations = () => { console.log("Migration: Added first_correct_player_id to game_sessions"); } - const hasHostUserId = sessionTableInfo.some(col => col.name === "host_user_id"); - if (!hasHostUserId) { - db.exec("ALTER TABLE game_sessions ADD COLUMN host_user_id TEXT"); - console.log("Migration: Added host_user_id to game_sessions"); - } - const hasColorScheme = userTableInfo2.some(col => col.name === "color_scheme"); if (!hasColorScheme) { db.exec("ALTER TABLE users ADD COLUMN color_scheme TEXT DEFAULT 'blue'"); diff --git a/server/src/db/schema.sql b/server/src/db/schema.sql index 9d3e3c0..070cdf9 100644 --- a/server/src/db/schema.sql +++ b/server/src/db/schema.sql @@ -58,7 +58,6 @@ CREATE TABLE IF NOT EXISTS game_sessions ( pin TEXT PRIMARY KEY, host_peer_id TEXT NOT NULL, host_secret TEXT NOT NULL, - host_user_id TEXT, quiz_data TEXT NOT NULL, game_config TEXT NOT NULL, game_state TEXT NOT NULL DEFAULT 'LOBBY', diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index c0a2d8f..da2fc5b 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -42,53 +42,6 @@ export interface AuthenticatedRequest extends Request { user?: AuthenticatedUser; } -export function optionalAuth( - req: AuthenticatedRequest, - res: Response, - next: NextFunction -): void { - const authHeader = req.headers.authorization; - - if (!authHeader?.startsWith('Bearer ')) { - next(); - return; - } - - const token = authHeader.slice(7); - - const verifyOptions: jwt.VerifyOptions = { - issuer: OIDC_ISSUER, - algorithms: ['RS256'], - }; - if (OIDC_AUDIENCE) { - verifyOptions.audience = OIDC_AUDIENCE; - } - - jwt.verify( - token, - getSigningKey, - verifyOptions, - (err, decoded) => { - if (err) { - console.error('Optional auth - token verification failed:', err.message); - next(); - return; - } - - const payload = decoded as jwt.JwtPayload; - req.user = { - sub: payload.sub!, - preferred_username: payload.preferred_username || payload.sub!, - email: payload.email, - name: payload.name, - groups: payload.groups || [], - }; - - next(); - } - ); -} - export function requireAuth( req: AuthenticatedRequest, res: Response, diff --git a/server/src/routes/games.ts b/server/src/routes/games.ts index 7ef2c45..1978529 100644 --- a/server/src/routes/games.ts +++ b/server/src/routes/games.ts @@ -2,7 +2,6 @@ import { Router, Request, Response } from 'express'; import rateLimit from 'express-rate-limit'; import { db } from '../db/connection.js'; import { randomBytes } from 'crypto'; -import { optionalAuth, AuthenticatedRequest } from '../middleware/auth.js'; const router = Router(); @@ -32,7 +31,6 @@ interface GameSession { pin: string; host_peer_id: string; host_secret: string; - host_user_id: string | null; quiz_data: string; game_config: string; game_state: string; @@ -57,7 +55,7 @@ const cleanupExpiredSessions = () => { setInterval(cleanupExpiredSessions, 60 * 1000); -router.post('/', gameCreationLimiter, optionalAuth, (req: AuthenticatedRequest, res: Response) => { +router.post('/', gameCreationLimiter, (req: Request, res: Response) => { try { const { pin, hostPeerId, quiz, gameConfig } = req.body; @@ -67,12 +65,11 @@ router.post('/', gameCreationLimiter, optionalAuth, (req: AuthenticatedRequest, } const hostSecret = randomBytes(32).toString('hex'); - const hostUserId = req.user?.sub || null; db.prepare(` - INSERT INTO game_sessions (pin, host_peer_id, host_secret, host_user_id, quiz_data, game_config, game_state, players_data) - VALUES (?, ?, ?, ?, ?, ?, 'LOBBY', '[]') - `).run(pin, hostPeerId, hostSecret, hostUserId, JSON.stringify(quiz), JSON.stringify(gameConfig)); + INSERT INTO game_sessions (pin, host_peer_id, host_secret, quiz_data, game_config, game_state, players_data) + VALUES (?, ?, ?, ?, ?, 'LOBBY', '[]') + `).run(pin, hostPeerId, hostSecret, JSON.stringify(quiz), JSON.stringify(gameConfig)); res.status(201).json({ success: true, hostSecret }); } catch (err: any) { @@ -114,29 +111,20 @@ router.get('/:pin', gameLookupLimiter, (req: Request, res: Response) => { } }); -router.get('/:pin/host', optionalAuth, (req: AuthenticatedRequest, res: Response) => { +router.get('/:pin/host', (req: Request, res: Response) => { try { const { pin } = req.params; const hostSecret = req.headers['x-host-secret'] as string; - const hostUserId = req.user?.sub; - if (!hostSecret && !hostUserId) { - res.status(401).json({ error: 'Host authentication required (X-Host-Secret header or Authorization token)' }); + if (!hostSecret) { + res.status(401).json({ error: 'Host secret required' }); return; } - const session = db.prepare('SELECT * FROM game_sessions WHERE pin = ?').get(pin) as GameSession | undefined; + const session = db.prepare('SELECT * FROM game_sessions WHERE pin = ? AND host_secret = ?').get(pin, hostSecret) as GameSession | undefined; if (!session) { - res.status(404).json({ error: 'Game not found' }); - return; - } - - const isValidSecret = hostSecret && session.host_secret === hostSecret; - const isValidUser = hostUserId && session.host_user_id === hostUserId; - - if (!isValidSecret && !isValidUser) { - res.status(404).json({ error: 'Game not found or invalid credentials' }); + res.status(404).json({ error: 'Game not found or invalid secret' }); return; } @@ -156,30 +144,21 @@ router.get('/:pin/host', optionalAuth, (req: AuthenticatedRequest, res: Response } }); -router.patch('/:pin', optionalAuth, (req: AuthenticatedRequest, res: Response) => { +router.patch('/:pin', (req: Request, res: Response) => { try { const { pin } = req.params; const hostSecret = req.headers['x-host-secret'] as string; - const hostUserId = req.user?.sub; const { hostPeerId, gameState, currentQuestionIndex, players, firstCorrectPlayerId } = req.body; - if (!hostSecret && !hostUserId) { - res.status(401).json({ error: 'Host authentication required (X-Host-Secret header or Authorization token)' }); + if (!hostSecret) { + res.status(401).json({ error: 'Host secret required' }); return; } - const session = db.prepare('SELECT pin, host_secret, host_user_id FROM game_sessions WHERE pin = ?').get(pin) as GameSession | undefined; + const session = db.prepare('SELECT pin FROM game_sessions WHERE pin = ? AND host_secret = ?').get(pin, hostSecret); if (!session) { - res.status(404).json({ error: 'Game not found' }); - return; - } - - const isValidSecret = hostSecret && session.host_secret === hostSecret; - const isValidUser = hostUserId && session.host_user_id === hostUserId; - - if (!isValidSecret && !isValidUser) { - res.status(404).json({ error: 'Game not found or invalid credentials' }); + res.status(404).json({ error: 'Game not found or invalid secret' }); return; } @@ -213,12 +192,12 @@ router.patch('/:pin', optionalAuth, (req: AuthenticatedRequest, res: Response) = } updates.push('updated_at = CURRENT_TIMESTAMP'); - values.push(pin); + values.push(pin, hostSecret); db.prepare(` UPDATE game_sessions SET ${updates.join(', ')} - WHERE pin = ? + WHERE pin = ? AND host_secret = ? `).run(...values); res.json({ success: true }); @@ -228,34 +207,23 @@ router.patch('/:pin', optionalAuth, (req: AuthenticatedRequest, res: Response) = } }); -router.delete('/:pin', optionalAuth, (req: AuthenticatedRequest, res: Response) => { +router.delete('/:pin', (req: Request, res: Response) => { try { const { pin } = req.params; const hostSecret = req.headers['x-host-secret'] as string; - const hostUserId = req.user?.sub; - if (!hostSecret && !hostUserId) { - res.status(401).json({ error: 'Host authentication required (X-Host-Secret header or Authorization token)' }); + if (!hostSecret) { + res.status(401).json({ error: 'Host secret required' }); return; } - const session = db.prepare('SELECT pin, host_secret, host_user_id FROM game_sessions WHERE pin = ?').get(pin) as GameSession | undefined; + const result = db.prepare('DELETE FROM game_sessions WHERE pin = ? AND host_secret = ?').run(pin, hostSecret); - if (!session) { - res.status(404).json({ error: 'Game not found' }); + if (result.changes === 0) { + res.status(404).json({ error: 'Game not found or invalid secret' }); return; } - const isValidSecret = hostSecret && session.host_secret === hostSecret; - const isValidUser = hostUserId && session.host_user_id === hostUserId; - - if (!isValidSecret && !isValidUser) { - res.status(404).json({ error: 'Game not found or invalid credentials' }); - return; - } - - db.prepare('DELETE FROM game_sessions WHERE pin = ?').run(pin); - res.json({ success: true }); } catch (err) { console.error('Error deleting game session:', err); diff --git a/server/src/services/documentParser.ts b/server/src/services/documentParser.ts index a37a704..bf7f88c 100644 --- a/server/src/services/documentParser.ts +++ b/server/src/services/documentParser.ts @@ -8,42 +8,6 @@ import { join } from 'path'; import { randomUUID } from 'crypto'; const PROCESSING_TIMEOUT_MS = 30000; -const SANDBOX_URL = process.env.SANDBOX_URL || 'http://localhost:3002'; -const USE_SANDBOX = process.env.USE_SANDBOX !== 'false'; - -const CIRCUIT_BREAKER_THRESHOLD = 5; -const CIRCUIT_BREAKER_RESET_MS = 60000; - -class CircuitBreaker { - private failures = 0; - private lastFailure = 0; - private open = false; - - recordSuccess(): void { - this.failures = 0; - this.open = false; - } - - recordFailure(): void { - this.failures++; - this.lastFailure = Date.now(); - if (this.failures >= CIRCUIT_BREAKER_THRESHOLD) { - this.open = true; - } - } - - isOpen(): boolean { - if (!this.open) return false; - if (Date.now() - this.lastFailure > CIRCUIT_BREAKER_RESET_MS) { - this.open = false; - this.failures = 0; - return false; - } - return true; - } -} - -const sandboxCircuit = new CircuitBreaker(); export const GEMINI_NATIVE_TYPES = [ 'application/pdf', @@ -237,51 +201,14 @@ function extractWithSheetJS(buffer: Buffer): string { return textParts.join('\n\n'); } +// Map legacy extensions to their modern equivalents for LibreOffice conversion const LEGACY_TO_MODERN: Record = { '.ppt': 'pptx', '.doc': 'docx', '.xls': 'xlsx', }; -async function convertViaSandbox(buffer: Buffer, extension: string): Promise { - if (sandboxCircuit.isOpen()) { - throw new Error('CIRCUIT_OPEN'); - } - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), PROCESSING_TIMEOUT_MS); - - try { - const response = await fetch(`${SANDBOX_URL}/convert?ext=${extension}`, { - method: 'POST', - body: buffer, - signal: controller.signal, - headers: { 'Content-Type': 'application/octet-stream' }, - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Conversion failed' })); - sandboxCircuit.recordFailure(); - throw new Error(error.error || 'Document conversion failed'); - } - - sandboxCircuit.recordSuccess(); - return Buffer.from(await response.arrayBuffer()); - } catch (err) { - if (err instanceof Error && err.name === 'AbortError') { - sandboxCircuit.recordFailure(); - throw new Error('Document conversion timed out. Try a smaller file.'); - } - if (err instanceof Error && err.message !== 'CIRCUIT_OPEN') { - sandboxCircuit.recordFailure(); - } - throw err; - } finally { - clearTimeout(timeout); - } -} - -async function convertLocally(buffer: Buffer, extension: string): Promise { +async function extractWithLibreOffice(buffer: Buffer, extension: string, useOcr: boolean = false): Promise { const tempId = randomUUID(); const privateTempDir = join(tmpdir(), `kaboot-${tempId}`); const inputPath = join(privateTempDir, `input${extension}`); @@ -298,63 +225,47 @@ async function convertLocally(buffer: Buffer, extension: string): Promise { - let convertedBuffer: Buffer; - - if (USE_SANDBOX) { - try { - convertedBuffer = await convertViaSandbox(buffer, extension); - } catch (err) { - if (err instanceof Error && err.message === 'CIRCUIT_OPEN') { - throw new Error('Document conversion service temporarily unavailable. Please try again in a minute.'); - } - throw err; - } - } else { - convertedBuffer = await convertLocally(buffer, extension); - } - - const config = useOcr ? { extractAttachments: true, ocr: true, ocrLanguage: 'eng' } : {}; - const ast = await officeParser.parseOffice(convertedBuffer, config); - let text = ast.toText(); - - if (useOcr && ast.attachments) { - for (const attachment of ast.attachments) { - if (attachment.ocrText) { - text += '\n' + attachment.ocrText; - } - } - } - return text; -} - export async function processDocument( buffer: Buffer, mimeType: string, diff --git a/server/tests/api.test.ts b/server/tests/api.test.ts index 2df6fb9..a1de080 100644 --- a/server/tests/api.test.ts +++ b/server/tests/api.test.ts @@ -1994,118 +1994,6 @@ console.log('\n=== Game Session Tests ==='); }); }); - console.log('\nHost Proof Validation Tests:'); - - let authGamePin: string | null = null; - - await test('POST /api/games with Authorization token stores host_user_id', async () => { - const gameData = { - pin: '777777', - hostPeerId: 'kaboot-777777', - quiz: { title: 'Auth Test Quiz', questions: [] }, - gameConfig: {}, - }; - - const { data } = await gameRequest('POST', '/api/games', gameData, { - 'Authorization': `Bearer ${TOKEN}`, - }, 201); - - if (!(data as { success: boolean }).success) throw new Error('Expected success: true'); - authGamePin = '777777'; - }); - - await test('GET /api/games/:pin/host with valid Authorization token succeeds', async () => { - if (!authGamePin) throw new Error('No auth game created'); - - const { data } = await gameRequest('GET', `/api/games/${authGamePin}/host`, undefined, { - 'Authorization': `Bearer ${TOKEN}`, - }); - - const game = data as Record; - if (game.pin !== authGamePin) throw new Error('Wrong PIN'); - if (!game.quiz) throw new Error('Missing quiz'); - }); - - await test('PATCH /api/games/:pin with valid Authorization token succeeds', async () => { - if (!authGamePin) throw new Error('No auth game created'); - - await gameRequest('PATCH', `/api/games/${authGamePin}`, { - gameState: 'QUESTION', - }, { - 'Authorization': `Bearer ${TOKEN}`, - }); - - const { data } = await gameRequest('GET', `/api/games/${authGamePin}/host`, undefined, { - 'Authorization': `Bearer ${TOKEN}`, - }); - - const game = data as Record; - if (game.gameState !== 'QUESTION') throw new Error('gameState not updated'); - }); - - await test('GET /api/games/:pin/host with neither secret nor token returns 401', async () => { - if (!authGamePin) throw new Error('No auth game created'); - await gameRequest('GET', `/api/games/${authGamePin}/host`, undefined, {}, 401); - }); - - await test('PATCH /api/games/:pin with neither secret nor token returns 401', async () => { - if (!authGamePin) throw new Error('No auth game created'); - await gameRequest('PATCH', `/api/games/${authGamePin}`, { gameState: 'LOBBY' }, {}, 401); - }); - - await test('DELETE /api/games/:pin with neither secret nor token returns 401', async () => { - if (!authGamePin) throw new Error('No auth game created'); - await gameRequest('DELETE', `/api/games/${authGamePin}`, undefined, {}, 401); - }); - - await test('GET /api/games/:pin/host with invalid token returns 401', async () => { - if (!authGamePin) throw new Error('No auth game created'); - await gameRequest('GET', `/api/games/${authGamePin}/host`, undefined, { - 'Authorization': 'Bearer invalid-token-12345', - }, 401); - }); - - await test('PATCH /api/games/:pin with invalid token returns 401', async () => { - if (!authGamePin) throw new Error('No auth game created'); - await gameRequest('PATCH', `/api/games/${authGamePin}`, { gameState: 'LOBBY' }, { - 'Authorization': 'Bearer invalid-token-12345', - }, 401); - }); - - await test('DELETE /api/games/:pin with valid Authorization token succeeds', async () => { - if (!authGamePin) throw new Error('No auth game created'); - - await gameRequest('DELETE', `/api/games/${authGamePin}`, undefined, { - 'Authorization': `Bearer ${TOKEN}`, - }); - - await gameRequest('GET', `/api/games/${authGamePin}`, undefined, {}, 404); - }); - - await test('POST /api/games without auth allows access via secret only', async () => { - const gameData = { - pin: '888888', - hostPeerId: 'kaboot-888888', - quiz: { title: 'No Auth Quiz', questions: [] }, - gameConfig: {}, - }; - - const { data } = await gameRequest('POST', '/api/games', gameData, {}, 201); - const secret = (data as { hostSecret: string }).hostSecret; - - await gameRequest('GET', `/api/games/888888/host`, undefined, { - 'X-Host-Secret': secret, - }); - - await gameRequest('GET', `/api/games/888888/host`, undefined, { - 'Authorization': `Bearer ${TOKEN}`, - }, 404); - - await gameRequest('DELETE', '/api/games/888888', undefined, { - 'X-Host-Secret': secret, - }); - }); - console.log('\n=== AI Generate Endpoint Tests ==='); console.log('\nGenerate Status Tests:');