From 128e24a7cb364249115068dee85564ece77be0ca Mon Sep 17 00:00:00 2001 From: type-two Date: Tue, 21 Jul 2026 02:43:26 +1000 Subject: [PATCH] [deploy] digalot.fyi hosting: prefix-agnostic frontend, ship-check hardening, docker+nginx kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - web: all URLs relative (imports ./web/..., fetches assets/...) so the app serves at / locally and under /scenegod/ behind nginx; esc() on dock innerHTML interpolations (ship-check XSS class) - server: capped_body on every POST (scenes 5MB, frames 25MB, meta/director 256KB-1MB), render begin sanity (fps<=60, dims<=4096, frame idx<=20000) - deploy/: Dockerfile (slim+ffmpeg), compose (project 'scenegod' — avoids the shared-'deploy'-project orphan trap with meshgod), nginx location (suite basic-auth), deploy.sh (rsync model, meshgod-style) - live: digalot.fyi/scenegod/ -> 401 unauthed, app healthy on dealgod_default Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 19 +++++++++++++++++++ deploy/Dockerfile | 11 +++++++++++ deploy/deploy.sh | 9 +++++++++ deploy/docker-compose.yml | 27 +++++++++++++++++++++++++++ deploy/nginx-scenegod.conf | 15 +++++++++++++++ scenegod/server.py | 36 ++++++++++++++++++++++++++++++------ scenegod/web/dock.js | 15 +++++++++------ scenegod/web/index.html | 14 +++++++------- scenegod/web/presets.js | 4 ++-- scenegod/web/render.js | 10 +++++----- scenegod/web/stage.js | 4 ++-- scenegod/web/tlui.js | 12 ++++++------ 12 files changed, 142 insertions(+), 34 deletions(-) create mode 100644 deploy/Dockerfile create mode 100755 deploy/deploy.sh create mode 100644 deploy/docker-compose.yml create mode 100644 deploy/nginx-scenegod.conf diff --git a/CLAUDE.md b/CLAUDE.md index 3f7f1ae..11a3923 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,25 @@ Drop characters + animation clips + image backdrops on a 3D stage, keyframe transforms/cameras/lights on a timeline, render mp4. Web app: FastAPI + three.js, same conventions as MESHGOD (`~/Documents/MESHGOD`). +## Deploy model (digalot.fyi suite) +- Prod: dealgod VPS `root@100.94.195.115`, `/opt/scenegod` — NOT a git + checkout: `./deploy/deploy.sh` rsyncs from a dev Mac and rebuilds + (`deploy/docker-compose.yml`, project name `scenegod` — do NOT drop the + `name:` key; meshgod+scenegod both compose from dirs called "deploy" and + would otherwise share a project and orphan each other). Container joins the + external `dealgod_default` net; data volumes under `/opt/scenegod-data/`. +- nginx: `deploy/nginx-scenegod.conf` block lives in the digalot.fyi server + in `/opt/dealgod/nginx/dealgod-servers.conf` (dealgod-nginx container) — + suite basic-auth (`meshgod.htpasswd`). Live: digalot.fyi/scenegod/. +- Frontend is PREFIX-AGNOSTIC: all web URLs are relative (module imports + `./web/...`, fetches `assets/...`) so the app works at `/` locally AND + under `/scenegod/`. Never reintroduce a leading `/` in a web-side URL. +- VPS env keeps `SCENEGOD_LLM_URL`/`SCENEGOD_MB` UNSET (features 503/hide; + they shell to tailnet boxes — ultra-only). rhubarb not in the image. +- Server has ship-check hardening: `capped_body` on every POST (frames 25MB, + scenes 5MB), render dims ≤4096/fps ≤60/frame index ≤20000, `esc()` on all + dock innerHTML interpolations. Keep new endpoints behind the same rules. + ## You are one of THREE parallel lane agents 1. **Read, in order:** this file → `PLAN.md` → your lane file in `lanes/` → your log in `logs/`. Your lane file is your source of truth — the diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..a445039 --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,11 @@ +# scenegod — godverse-standard slim Python service + ffmpeg (render encode). +# rhubarb NOT installed (lip-sync 503s cleanly; use the ultra instance for that). +FROM python:3.12-slim +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +EXPOSE 8020 +CMD ["uvicorn", "scenegod.server:app", "--host", "0.0.0.0", "--port", "8020"] diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..af465f9 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# rsync-deploy scenegod to the dealgod VPS (NOT a git checkout there — meshgod model). +set -e +VPS=root@100.94.195.115 +rsync -az --delete \ + --exclude .git --exclude .venv --exclude assets --exclude scenes \ + --exclude renders --exclude '*.pyc' --exclude __pycache__ --exclude .env \ + "$(cd "$(dirname "$0")/.." && pwd)/" "$VPS":/opt/scenegod/ +ssh "$VPS" 'mkdir -p /opt/scenegod-data/{assets,scenes,renders} && cd /opt/scenegod/deploy && docker compose up -d --build' diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..ffe826e --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,27 @@ +# Add scenegod to the dealgod stack (same pattern as meshgod). Deploy from a dev Mac: +# ./deploy/deploy.sh (rsync → VPS /opt/scenegod, compose build on the box) +# nginx proxies digalot.fyi/scenegod/ → scenegod:8020 behind the suite basic-auth. +name: scenegod # explicit — both meshgod+scenegod compose from dirs named "deploy"; + # without this they share one project and see each other as orphans +services: + scenegod: + build: + context: .. + dockerfile: deploy/Dockerfile + container_name: scenegod + restart: unless-stopped + environment: + - SCENEGOD_ASSETS=/app/assets # mounted volume — synced from the Macs + - SCENEGOD_SCENES=/app/scenes + - SCENEGOD_RENDERS=/app/renders + # deliberately UNSET on the VPS (each 503s cleanly / hides its UI): + # SCENEGOD_LLM_URL / SCENEGOD_LLM_MODEL (tailnet Ollama — ultra-side feature) + # SCENEGOD_MB (MODELBEAST proxy — NEVER on the VPS) + volumes: + - /opt/scenegod-data/assets:/app/assets + - /opt/scenegod-data/scenes:/app/scenes + - /opt/scenegod-data/renders:/app/renders + networks: [dealgod_default] +networks: + dealgod_default: + external: true diff --git a/deploy/nginx-scenegod.conf b/deploy/nginx-scenegod.conf new file mode 100644 index 0000000..6644eed --- /dev/null +++ b/deploy/nginx-scenegod.conf @@ -0,0 +1,15 @@ +# Lives in /opt/dealgod/nginx/dealgod-servers.conf inside the digalot.fyi server +# block (dealgod-nginx container). Same suite login as meshgod. +location = /scenegod { return 301 /scenegod/; } +location /scenegod/ { + auth_basic "meshgod - mates only"; + auth_basic_user_file /opt/dealgod/le/meshgod.htpasswd; + proxy_pass http://scenegod:8020/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Remote-User $remote_user; + client_max_body_size 30m; # render frames <=25MB + slack + proxy_read_timeout 300s; # encode status polling +} diff --git a/scenegod/server.py b/scenegod/server.py index dd96165..f6b0ae0 100644 --- a/scenegod/server.py +++ b/scenegod/server.py @@ -228,9 +228,21 @@ def scene_get(name: str): return json.loads(p.read_text()) + +# ship-check: body caps — this server now fronts digalot.fyi behind nginx +# basic-auth; never trust content-length alone, check the read bytes too. +async def capped_body(request: Request, limit: int) -> bytes: + cl = request.headers.get("content-length") + if cl and cl.isdigit() and int(cl) > limit: + raise HTTPException(413, f"body over {limit} bytes") + body = await request.body() + if len(body) > limit: + raise HTTPException(413, f"body over {limit} bytes") + return body + @app.post("/scenes/{name}") async def scene_save(name: str, request: Request): - body = await request.body() + body = await capped_body(request, 5 * 1024 * 1024) try: scene = json.loads(body) except json.JSONDecodeError as e: @@ -286,9 +298,13 @@ async def render_begin(request: Request): rid = uuid.uuid4().hex (RENDERS / rid / "frames").mkdir(parents=True) try: - meta = json.loads(await request.body() or b"{}") + meta = json.loads(await capped_body(request, 64 * 1024) or b"{}") except json.JSONDecodeError: meta = {} + if not (1 <= int(meta.get("fps", 30)) <= 60) or \ + not (16 <= int(meta.get("width", 1280)) <= 4096) or \ + not (16 <= int(meta.get("height", 720)) <= 4096): + raise HTTPException(400, "fps/width/height out of range") (RENDERS / rid / "meta.json").write_text(json.dumps(meta)) _renders[rid] = {"state": "queued", "log": ""} return {"renderId": rid} @@ -299,7 +315,9 @@ async def render_frame(rid: str, n: int, request: Request): frames = _render_dir(rid) / "frames" if not frames.is_dir(): raise HTTPException(404, "unknown renderId") - body = await request.body() + if not (0 <= n <= 20000): + raise HTTPException(400, "frame index out of range") + body = await capped_body(request, 25 * 1024 * 1024) if not body: raise HTTPException(400, "empty frame body") (frames / f"{n:06d}.png").write_bytes(body) @@ -318,7 +336,7 @@ async def render_end(rid: str, request: Request): except (OSError, json.JSONDecodeError): fps = 30 try: - body = json.loads(await request.body() or b"{}") + body = json.loads(await capped_body(request, 1024 * 1024) or b"{}") except json.JSONDecodeError: body = {} audio = [] @@ -450,7 +468,10 @@ async def director(request: Request): if not url: raise HTTPException(503, "no LLM configured (set SCENEGOD_LLM_URL, e.g. " "http://100.69.21.128:11434, and SCENEGOD_LLM_MODEL)") - body = await request.json() + try: + body = json.loads(await capped_body(request, 256 * 1024)) + except json.JSONDecodeError: + raise HTTPException(400, "invalid JSON") text = (body.get("text") or "").strip() if not text: raise HTTPException(400, "text required") @@ -506,7 +527,10 @@ async def mb_submit(request: Request): /api/assets first, then /api/jobs — the meshgod/ops.py contract.""" if os.environ.get("SCENEGOD_MB") != "1": raise HTTPException(503, "MODELBEAST proxy disabled (set SCENEGOD_MB=1)") - body = await request.json() + try: + body = json.loads(await capped_body(request, 256 * 1024)) + except json.JSONDecodeError: + raise HTTPException(400, "invalid JSON") op = (body.get("operator") or "").strip() if not op: raise HTTPException(400, "operator required") diff --git a/scenegod/web/dock.js b/scenegod/web/dock.js index 0c08ff1..a419d4b 100644 --- a/scenegod/web/dock.js +++ b/scenegod/web/dock.js @@ -5,6 +5,9 @@ import { stats } from './room3d.js'; const TABS = ['characters', 'props', 'backdrops', 'animations']; +const esc = s => String(s).replace(/[&<>"']/g, + c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); // ship-check: XSS + export class Dock { constructor({ stage, dockEl, inspectorEl, viewEl }){ this.stage = stage; this.dock = dockEl; this.insp = inspectorEl; this.view = viewEl; @@ -21,7 +24,7 @@ export class Dock { async load(){ try { - const r = await fetch('/assets/tree'); + const r = await fetch('assets/tree'); if(!r.ok) throw new Error(r.status); this.tree = { characters:[], props:[], backdrops:[], animations:[], ...(await r.json()) }; } catch(e){ @@ -101,10 +104,10 @@ export class Dock { for(const it of items){ const c = document.createElement('div'); c.className = 'card'; c.draggable = true; const th = it.thumb - ? `` + ? `` : `
${icon}
`; const vp = this.tab === 'backdrops' ? this._videoPath(it) : null; - c.innerHTML = `${th}
${vp ? '🎞 ' : ''}${this._name(it)}
`; + c.innerHTML = `${th}
${vp ? '🎞 ' : ''}${esc(this._name(it))}
`; c.onclick = () => this._addFromLibrary(it); c.ondragstart = ev => ev.dataTransfer.setData('application/scenegod', JSON.stringify({ tab: this.tab, path: this._path(it), name: this._name(it), video: vp })); @@ -182,7 +185,7 @@ export class Dock { if(!en){ el.innerHTML = '
nothing selected
'; return; } const t = this.stage.entityTransform(en.id); const h = document.createElement('div'); h.className = 'ihd'; - h.innerHTML = `${en.label}${en.kind}${ + h.innerHTML = `${esc(en.label)}${esc(en.kind)}${ en.source && en.source.type === 'upload' ? ' · upload' : ''}`; el.appendChild(h); @@ -230,7 +233,7 @@ export class Dock { } _field(label, inputEl){ const w = document.createElement('label'); w.className = 'fld'; - w.innerHTML = `${label}`; w.appendChild(inputEl); return w; } + w.innerHTML = `${esc(label)}`; w.appendChild(inputEl); return w; } _num(label, val, cb){ const i = document.createElement('input'); i.type='number'; i.step='0.1'; i.value=val; i.oninput = () => cb(parseFloat(i.value) || 0); return this._field(label, i); } _color(label, val, cb){ const i = document.createElement('input'); i.type='color'; i.value=val; @@ -239,7 +242,7 @@ export class Dock { for(const o of opts){ const op = document.createElement('option'); op.value=op.textContent=o; if(o===val) op.selected=true; s.appendChild(op); } s.onchange = () => cb(s.value); return this._field(label, s); } _vec3(label, arr, cb){ const w = document.createElement('div'); w.className = 'fld vec'; - w.innerHTML = `${label}`; + w.innerHTML = `${esc(label)}`; const cur = [...arr]; ['x','y','z'].forEach((_, k) => { const i = document.createElement('input'); i.type='number'; i.step='0.1'; i.value=+arr[k].toFixed(3); i.oninput = () => { cur[k] = parseFloat(i.value) || 0; cb([...cur]); }; w.appendChild(i); }); diff --git a/scenegod/web/index.html b/scenegod/web/index.html index e5567aa..ca92283 100644 --- a/scenegod/web/index.html +++ b/scenegod/web/index.html @@ -4,7 +4,7 @@ SCENEGOD — machinima stage - +
@@ -25,12 +25,12 @@ }}