Compare commits
3 commits
cd04d34b23
...
c162c4bdde
| Author | SHA1 | Date | |
|---|---|---|---|
| c162c4bdde | |||
| b7126b0d07 | |||
| 70df689701 |
14 changed files with 776 additions and 56 deletions
|
|
@ -85,6 +85,29 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- kaboot-network
|
- 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:
|
kaboot-backend:
|
||||||
build:
|
build:
|
||||||
context: ./server
|
context: ./server
|
||||||
|
|
@ -105,10 +128,14 @@ services:
|
||||||
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
|
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
|
||||||
STRIPE_PRICE_ID_MONTHLY: ${STRIPE_PRICE_ID_MONTHLY:-}
|
STRIPE_PRICE_ID_MONTHLY: ${STRIPE_PRICE_ID_MONTHLY:-}
|
||||||
STRIPE_PRICE_ID_YEARLY: ${STRIPE_PRICE_ID_YEARLY:-}
|
STRIPE_PRICE_ID_YEARLY: ${STRIPE_PRICE_ID_YEARLY:-}
|
||||||
|
SANDBOX_URL: http://kaboot-sandbox:3002
|
||||||
|
USE_SANDBOX: "true"
|
||||||
volumes:
|
volumes:
|
||||||
- kaboot-data:/data
|
- kaboot-data:/data
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /tmp:size=100M
|
- /tmp:size=100M
|
||||||
|
depends_on:
|
||||||
|
- kaboot-sandbox
|
||||||
networks:
|
networks:
|
||||||
- kaboot-network
|
- kaboot-network
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,33 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- kaboot-network
|
- 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
|
# KABOOT - Application Backend
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
@ -118,6 +145,8 @@ services:
|
||||||
CORS_ORIGIN: http://localhost:${KABOOT_FRONTEND_PORT:-5173},http://${KABOOT_HOST:-localhost}:${KABOOT_FRONTEND_PORT:-5173}
|
CORS_ORIGIN: http://localhost:${KABOOT_FRONTEND_PORT:-5173},http://${KABOOT_HOST:-localhost}:${KABOOT_FRONTEND_PORT:-5173}
|
||||||
LOG_REQUESTS: ${LOG_REQUESTS:-true}
|
LOG_REQUESTS: ${LOG_REQUESTS:-true}
|
||||||
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
|
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
|
||||||
|
SANDBOX_URL: http://kaboot-sandbox:3002
|
||||||
|
USE_SANDBOX: "true"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
tmpfs:
|
tmpfs:
|
||||||
|
|
@ -126,6 +155,7 @@ services:
|
||||||
- "${KABOOT_BACKEND_PORT:-3001}:3001"
|
- "${KABOOT_BACKEND_PORT:-3001}:3001"
|
||||||
depends_on:
|
depends_on:
|
||||||
- authentik-server
|
- authentik-server
|
||||||
|
- kaboot-sandbox
|
||||||
networks:
|
networks:
|
||||||
- kaboot-network
|
- kaboot-network
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -408,6 +408,68 @@ Then run the dev server with `npm run dev -- --host` to bind to all interfaces.
|
||||||
- Verify `OIDC_ISSUER` and `OIDC_JWKS_URI` are correct
|
- Verify `OIDC_ISSUER` and `OIDC_JWKS_URI` are correct
|
||||||
- The backend must be able to reach Authentik at `http://authentik-server:9000` (Docker network)
|
- 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
|
## Production Notes
|
||||||
|
|
||||||
For production deployment, see [PRODUCTION.md](./PRODUCTION.md) for full instructions.
|
For production deployment, see [PRODUCTION.md](./PRODUCTION.md) for full instructions.
|
||||||
|
|
|
||||||
114
docs/SECURITY_PLAN.md
Normal file
114
docs/SECURITY_PLAN.md
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
# 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.
|
||||||
|
|
@ -2,8 +2,7 @@ FROM node:22-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Build dependencies + LibreOffice for legacy Office format conversion
|
RUN apk add --no-cache python3 make g++ curl
|
||||||
RUN apk add --no-cache python3 make g++ libreoffice curl
|
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
|
||||||
23
server/sandbox/Dockerfile
Normal file
23
server/sandbox/Dockerfile
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
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"]
|
||||||
168
server/sandbox/convert.js
Normal file
168
server/sandbox/convert.js
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
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)`);
|
||||||
|
});
|
||||||
9
server/sandbox/package.json
Normal file
9
server/sandbox/package.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"name": "kaboot-sandbox",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"start": "node convert.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -164,6 +164,7 @@ const runMigrations = () => {
|
||||||
pin TEXT PRIMARY KEY,
|
pin TEXT PRIMARY KEY,
|
||||||
host_peer_id TEXT NOT NULL,
|
host_peer_id TEXT NOT NULL,
|
||||||
host_secret TEXT NOT NULL,
|
host_secret TEXT NOT NULL,
|
||||||
|
host_user_id TEXT,
|
||||||
quiz_data TEXT NOT NULL,
|
quiz_data TEXT NOT NULL,
|
||||||
game_config TEXT NOT NULL,
|
game_config TEXT NOT NULL,
|
||||||
game_state TEXT NOT NULL DEFAULT 'LOBBY',
|
game_state TEXT NOT NULL DEFAULT 'LOBBY',
|
||||||
|
|
@ -185,6 +186,12 @@ const runMigrations = () => {
|
||||||
console.log("Migration: Added first_correct_player_id to game_sessions");
|
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");
|
const hasColorScheme = userTableInfo2.some(col => col.name === "color_scheme");
|
||||||
if (!hasColorScheme) {
|
if (!hasColorScheme) {
|
||||||
db.exec("ALTER TABLE users ADD COLUMN color_scheme TEXT DEFAULT 'blue'");
|
db.exec("ALTER TABLE users ADD COLUMN color_scheme TEXT DEFAULT 'blue'");
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ CREATE TABLE IF NOT EXISTS game_sessions (
|
||||||
pin TEXT PRIMARY KEY,
|
pin TEXT PRIMARY KEY,
|
||||||
host_peer_id TEXT NOT NULL,
|
host_peer_id TEXT NOT NULL,
|
||||||
host_secret TEXT NOT NULL,
|
host_secret TEXT NOT NULL,
|
||||||
|
host_user_id TEXT,
|
||||||
quiz_data TEXT NOT NULL,
|
quiz_data TEXT NOT NULL,
|
||||||
game_config TEXT NOT NULL,
|
game_config TEXT NOT NULL,
|
||||||
game_state TEXT NOT NULL DEFAULT 'LOBBY',
|
game_state TEXT NOT NULL DEFAULT 'LOBBY',
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,53 @@ export interface AuthenticatedRequest extends Request {
|
||||||
user?: AuthenticatedUser;
|
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(
|
export function requireAuth(
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
res: Response,
|
res: Response,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Router, Request, Response } from 'express';
|
||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
import { db } from '../db/connection.js';
|
import { db } from '../db/connection.js';
|
||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
|
import { optionalAuth, AuthenticatedRequest } from '../middleware/auth.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
|
@ -31,6 +32,7 @@ interface GameSession {
|
||||||
pin: string;
|
pin: string;
|
||||||
host_peer_id: string;
|
host_peer_id: string;
|
||||||
host_secret: string;
|
host_secret: string;
|
||||||
|
host_user_id: string | null;
|
||||||
quiz_data: string;
|
quiz_data: string;
|
||||||
game_config: string;
|
game_config: string;
|
||||||
game_state: string;
|
game_state: string;
|
||||||
|
|
@ -55,7 +57,7 @@ const cleanupExpiredSessions = () => {
|
||||||
|
|
||||||
setInterval(cleanupExpiredSessions, 60 * 1000);
|
setInterval(cleanupExpiredSessions, 60 * 1000);
|
||||||
|
|
||||||
router.post('/', gameCreationLimiter, (req: Request, res: Response) => {
|
router.post('/', gameCreationLimiter, optionalAuth, (req: AuthenticatedRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { pin, hostPeerId, quiz, gameConfig } = req.body;
|
const { pin, hostPeerId, quiz, gameConfig } = req.body;
|
||||||
|
|
||||||
|
|
@ -65,11 +67,12 @@ router.post('/', gameCreationLimiter, (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const hostSecret = randomBytes(32).toString('hex');
|
const hostSecret = randomBytes(32).toString('hex');
|
||||||
|
const hostUserId = req.user?.sub || null;
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO game_sessions (pin, host_peer_id, host_secret, quiz_data, game_config, game_state, players_data)
|
INSERT INTO game_sessions (pin, host_peer_id, host_secret, host_user_id, quiz_data, game_config, game_state, players_data)
|
||||||
VALUES (?, ?, ?, ?, ?, 'LOBBY', '[]')
|
VALUES (?, ?, ?, ?, ?, ?, 'LOBBY', '[]')
|
||||||
`).run(pin, hostPeerId, hostSecret, JSON.stringify(quiz), JSON.stringify(gameConfig));
|
`).run(pin, hostPeerId, hostSecret, hostUserId, JSON.stringify(quiz), JSON.stringify(gameConfig));
|
||||||
|
|
||||||
res.status(201).json({ success: true, hostSecret });
|
res.status(201).json({ success: true, hostSecret });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|
@ -111,20 +114,29 @@ router.get('/:pin', gameLookupLimiter, (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/:pin/host', (req: Request, res: Response) => {
|
router.get('/:pin/host', optionalAuth, (req: AuthenticatedRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { pin } = req.params;
|
const { pin } = req.params;
|
||||||
const hostSecret = req.headers['x-host-secret'] as string;
|
const hostSecret = req.headers['x-host-secret'] as string;
|
||||||
|
const hostUserId = req.user?.sub;
|
||||||
|
|
||||||
if (!hostSecret) {
|
if (!hostSecret && !hostUserId) {
|
||||||
res.status(401).json({ error: 'Host secret required' });
|
res.status(401).json({ error: 'Host authentication required (X-Host-Secret header or Authorization token)' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = db.prepare('SELECT * FROM game_sessions WHERE pin = ? AND host_secret = ?').get(pin, hostSecret) as GameSession | undefined;
|
const session = db.prepare('SELECT * FROM game_sessions WHERE pin = ?').get(pin) as GameSession | undefined;
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
res.status(404).json({ error: 'Game not found or invalid secret' });
|
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' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,21 +156,30 @@ router.get('/:pin/host', (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.patch('/:pin', (req: Request, res: Response) => {
|
router.patch('/:pin', optionalAuth, (req: AuthenticatedRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { pin } = req.params;
|
const { pin } = req.params;
|
||||||
const hostSecret = req.headers['x-host-secret'] as string;
|
const hostSecret = req.headers['x-host-secret'] as string;
|
||||||
|
const hostUserId = req.user?.sub;
|
||||||
const { hostPeerId, gameState, currentQuestionIndex, players, firstCorrectPlayerId } = req.body;
|
const { hostPeerId, gameState, currentQuestionIndex, players, firstCorrectPlayerId } = req.body;
|
||||||
|
|
||||||
if (!hostSecret) {
|
if (!hostSecret && !hostUserId) {
|
||||||
res.status(401).json({ error: 'Host secret required' });
|
res.status(401).json({ error: 'Host authentication required (X-Host-Secret header or Authorization token)' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = db.prepare('SELECT pin FROM game_sessions WHERE pin = ? AND host_secret = ?').get(pin, hostSecret);
|
const session = db.prepare('SELECT pin, host_secret, host_user_id FROM game_sessions WHERE pin = ?').get(pin) as GameSession | undefined;
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
res.status(404).json({ error: 'Game not found or invalid secret' });
|
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' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,12 +213,12 @@ router.patch('/:pin', (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
updates.push('updated_at = CURRENT_TIMESTAMP');
|
updates.push('updated_at = CURRENT_TIMESTAMP');
|
||||||
values.push(pin, hostSecret);
|
values.push(pin);
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE game_sessions
|
UPDATE game_sessions
|
||||||
SET ${updates.join(', ')}
|
SET ${updates.join(', ')}
|
||||||
WHERE pin = ? AND host_secret = ?
|
WHERE pin = ?
|
||||||
`).run(...values);
|
`).run(...values);
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
|
@ -207,23 +228,34 @@ router.patch('/:pin', (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/:pin', (req: Request, res: Response) => {
|
router.delete('/:pin', optionalAuth, (req: AuthenticatedRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { pin } = req.params;
|
const { pin } = req.params;
|
||||||
const hostSecret = req.headers['x-host-secret'] as string;
|
const hostSecret = req.headers['x-host-secret'] as string;
|
||||||
|
const hostUserId = req.user?.sub;
|
||||||
|
|
||||||
if (!hostSecret) {
|
if (!hostSecret && !hostUserId) {
|
||||||
res.status(401).json({ error: 'Host secret required' });
|
res.status(401).json({ error: 'Host authentication required (X-Host-Secret header or Authorization token)' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = db.prepare('DELETE FROM game_sessions WHERE pin = ? AND host_secret = ?').run(pin, hostSecret);
|
const session = db.prepare('SELECT pin, host_secret, host_user_id FROM game_sessions WHERE pin = ?').get(pin) as GameSession | undefined;
|
||||||
|
|
||||||
if (result.changes === 0) {
|
if (!session) {
|
||||||
res.status(404).json({ error: 'Game not found or invalid secret' });
|
res.status(404).json({ error: 'Game not found' });
|
||||||
return;
|
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 });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error deleting game session:', err);
|
console.error('Error deleting game session:', err);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,42 @@ import { join } from 'path';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
const PROCESSING_TIMEOUT_MS = 30000;
|
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 = [
|
export const GEMINI_NATIVE_TYPES = [
|
||||||
'application/pdf',
|
'application/pdf',
|
||||||
|
|
@ -201,14 +237,51 @@ function extractWithSheetJS(buffer: Buffer): string {
|
||||||
return textParts.join('\n\n');
|
return textParts.join('\n\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map legacy extensions to their modern equivalents for LibreOffice conversion
|
|
||||||
const LEGACY_TO_MODERN: Record<string, string> = {
|
const LEGACY_TO_MODERN: Record<string, string> = {
|
||||||
'.ppt': 'pptx',
|
'.ppt': 'pptx',
|
||||||
'.doc': 'docx',
|
'.doc': 'docx',
|
||||||
'.xls': 'xlsx',
|
'.xls': 'xlsx',
|
||||||
};
|
};
|
||||||
|
|
||||||
async function extractWithLibreOffice(buffer: Buffer, extension: string, useOcr: boolean = false): Promise<string> {
|
async function convertViaSandbox(buffer: Buffer, extension: string): Promise<Buffer> {
|
||||||
|
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<Buffer> {
|
||||||
const tempId = randomUUID();
|
const tempId = randomUUID();
|
||||||
const privateTempDir = join(tmpdir(), `kaboot-${tempId}`);
|
const privateTempDir = join(tmpdir(), `kaboot-${tempId}`);
|
||||||
const inputPath = join(privateTempDir, `input${extension}`);
|
const inputPath = join(privateTempDir, `input${extension}`);
|
||||||
|
|
@ -225,47 +298,63 @@ async function extractWithLibreOffice(buffer: Buffer, extension: string, useOcr:
|
||||||
mkdirSync(privateTempDir, { mode: 0o700 });
|
mkdirSync(privateTempDir, { mode: 0o700 });
|
||||||
writeFileSync(inputPath, buffer, { mode: 0o600 });
|
writeFileSync(inputPath, buffer, { mode: 0o600 });
|
||||||
|
|
||||||
try {
|
execSync(
|
||||||
execSync(
|
`libreoffice --headless --convert-to ${modernExt} --outdir "${privateTempDir}" "${inputPath}"`,
|
||||||
`libreoffice --headless --convert-to ${modernExt} --outdir "${privateTempDir}" "${inputPath}"`,
|
{ timeout: PROCESSING_TIMEOUT_MS, stdio: 'pipe', maxBuffer: 10 * 1024 * 1024 }
|
||||||
{ timeout: PROCESSING_TIMEOUT_MS, stdio: 'pipe', maxBuffer: 10 * 1024 * 1024 }
|
);
|
||||||
);
|
|
||||||
} catch (execError) {
|
|
||||||
const error = execError as Error & { code?: string; killed?: boolean };
|
|
||||||
if (error.killed) {
|
|
||||||
throw new Error('Document conversion timed out. Try a smaller file.');
|
|
||||||
}
|
|
||||||
if (error.code === 'ENOENT' || error.message?.includes('not found')) {
|
|
||||||
throw new Error(
|
|
||||||
`Legacy ${extension} files require LibreOffice for text extraction. ` +
|
|
||||||
`Please convert to .${modernExt} format or ensure LibreOffice is installed.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new Error('Document conversion failed. The file may be corrupted.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existsSync(outputPath)) {
|
if (!existsSync(outputPath)) {
|
||||||
throw new Error('Document conversion produced no output.');
|
throw new Error('Document conversion produced no output.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const convertedBuffer = readFileSync(outputPath);
|
return readFileSync(outputPath);
|
||||||
const config = useOcr ? { extractAttachments: true, ocr: true, ocrLanguage: 'eng' } : {};
|
} catch (execError) {
|
||||||
const ast = await officeParser.parseOffice(convertedBuffer, config);
|
const error = execError as Error & { code?: string; killed?: boolean };
|
||||||
let text = ast.toText();
|
if (error.killed) {
|
||||||
|
throw new Error('Document conversion timed out. Try a smaller file.');
|
||||||
if (useOcr && ast.attachments) {
|
|
||||||
for (const attachment of ast.attachments) {
|
|
||||||
if (attachment.ocrText) {
|
|
||||||
text += '\n' + attachment.ocrText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return text;
|
if (error.code === 'ENOENT' || error.message?.includes('not found')) {
|
||||||
|
throw new Error(
|
||||||
|
`Legacy ${extension} files require LibreOffice for text extraction. ` +
|
||||||
|
`Please convert to .${LEGACY_TO_MODERN[extension]} format or ensure LibreOffice is installed.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error('Document conversion failed. The file may be corrupted.');
|
||||||
} finally {
|
} finally {
|
||||||
cleanup();
|
cleanup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function extractWithLibreOffice(buffer: Buffer, extension: string, useOcr: boolean = false): Promise<string> {
|
||||||
|
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(
|
export async function processDocument(
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
mimeType: string,
|
mimeType: string,
|
||||||
|
|
|
||||||
|
|
@ -1994,6 +1994,118 @@ 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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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('\n=== AI Generate Endpoint Tests ===');
|
||||||
|
|
||||||
console.log('\nGenerate Status Tests:');
|
console.log('\nGenerate Status Tests:');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue