From 0467c53727e2dc817970decba346b1c8b7311e2c Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 25 Jul 2026 15:43:29 +1000 Subject: [PATCH] ship-check for digalot.fyi/wardrobe: relative UI paths (subpath-proxy safe) + POST body cap Co-Authored-By: Claude Fable 5 --- server.py | 4 +++ web/index.html | 72 +++++++++++++++++++++++++------------------------- 2 files changed, 40 insertions(+), 36 deletions(-) diff --git a/server.py b/server.py index 221db90..d7bf6eb 100644 --- a/server.py +++ b/server.py @@ -101,6 +101,8 @@ DJSIM_HOST = os.environ.get('WG_DJSIM_HOST', 'johnking@100.91.239.7') DJSIM_DOLL = os.environ.get('WG_DJSIM_DOLL', 'Documents/90sDJsim/web/world/media/doll') PORT = int(os.environ.get('WG_PORT', 8150)) HOST = os.environ.get('WG_HOST', '127.0.0.1') +# POST body cap - tiny stdlib server, do not let a peer stream unbounded bytes at it +MAX_BODY = int(os.environ.get('WG_MAX_BODY', 512 * 1024 * 1024)) for d in list(DIRS.values()) + [CACHE, OUTFITS]: os.makedirs(d, exist_ok=True) @@ -548,6 +550,8 @@ class H(BaseHTTPRequestHandler): u = urllib.parse.urlparse(self.path) q = dict(urllib.parse.parse_qsl(u.query)) n = int(self.headers.get('Content-Length') or 0) + if n > MAX_BODY: + return self.j({'error': 'body too large'}, 413) raw = self.rfile.read(n) if n else b'' if u.path == '/api/upload': # file input → library dir diff --git a/web/index.html b/web/index.html index 44f87ce..7398535 100644 --- a/web/index.html +++ b/web/index.html @@ -300,7 +300,7 @@ new ResizeObserver(resize).observe(stage); })(); const loader = new GLTFLoader(); -const loadGlb = p => new Promise((res, rej) => loader.load('/glb?p=' + encodeURIComponent(p), res, undefined, rej)); +const loadGlb = p => new Promise((res, rej) => loader.load('glb?p=' + encodeURIComponent(p), res, undefined, rej)); function frameObject(root) { const box = new THREE.Box3().setFromObject(root); @@ -365,7 +365,7 @@ async function previewGarment(item) { GARM = item; renderLists(); if (garmRoot) { scene.remove(garmRoot); garmRoot = null; } if (!item.path.match(/\.(glb|gltf|fbx|obj)$/i)) { // flat cut-out: show the 2D tier - $('flatPrev').src = '/file?p=' + encodeURIComponent(item.path); + $('flatPrev').src = 'file?p=' + encodeURIComponent(item.path); $('flatPrev').style.display = 'block'; return; } @@ -412,14 +412,14 @@ $('saveFitBtn').onclick = async () => { if (!BODY) return toast('pick a body first'); const attach = {}; attached.forEach((a, i) => { const w = a.userData.wg || {}; attach[(w.name || 'item') + '#' + i] = w; }); - const r = await fetch('/api/outfit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + const r = await fetch('api/outfit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: $('fitName').value || 'dressup', body: BODY.path, anim: clips[+$('clipSel').value]?.name, attach }) }).then(r => r.json()); toast(r.error ? '✗ ' + r.error : `✓ saved dress-up "${r.name}" (${Object.keys(attach).length} item/s)`); refresh(); }; async function loadFit(name) { - const f = await fetch('/api/outfit/' + encodeURIComponent(name)).then(r => r.json()); + const f = await fetch('api/outfit/' + encodeURIComponent(name)).then(r => r.json()); if (f.error) return toast('✗ ' + f.error); const bodyItem = (LIB.bodies || []).find(b => b.path === f.body) || { name: f.body.split('/').pop(), path: f.body }; await setBody(bodyItem); @@ -440,7 +440,7 @@ async function loadFit(name) { // ---------- library ---------- async function refresh() { - const r = await fetch('/api/lib').then(r => r.json()); + const r = await fetch('api/lib').then(r => r.json()); LIB = r.lib; LIB.doll = r.doll || {}; // doll catalogue rides alongside lib, not inside it $('farm').textContent = (r.blender ? 'blender ✓' : 'blender ✗') + @@ -506,7 +506,7 @@ function renderGarmentGrid() { // '?' marks a slot we guessed from the filename rather than one that was actually set const slot = g.slot ? `${g.slot}${g.slot_inferred ? '?' : ''}` : ''; return `
- + ${slot}${g.nsfw ? 'NSFW' : ''} ${(g.title || g.name).replace(/\.(glb|gltf|fbx|obj|png|jpg)$/i, '').replace(/-nsfw/i, '')}
`; @@ -547,7 +547,7 @@ $('expBtn').onclick = async () => { if (!it) return toast('select a garment or body first'); const target = $('expTarget').value; if (target === 'djsim-doll') return $('dollExport').click(); - const r = await fetch('/api/export', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + const r = await fetch('api/export', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target, path: it.path, overwrite: $('expOver').checked }) }).then(r => r.json()); if (r.ready === false) { $('expNote').innerHTML = `not a file drop. ${r.why}
edit: ${r.registry || '?'}`; @@ -561,19 +561,19 @@ $('expBtn').onclick = async () => { async function pollJob(job, label) { while (true) { await new Promise(r => setTimeout(r, 1500)); - const j = await fetch('/api/job/' + job).then(r => r.json()); + const j = await fetch('api/job/' + job).then(r => r.json()); toast(`${label}: ${j.status}${j.note ? ' · ' + j.note : ''}`); if (j.status === 'done') { refresh(); return j.out; } if (j.status === 'error') { toast('✗ ' + (j.log || '').split('\n').pop()); return null; } } } $('warmBtn').onclick = async () => { - const out = await runJob('/api/thumbs/warm', {}, 'thumbs'); + const out = await runJob('api/thumbs/warm', {}, 'thumbs'); if (out !== null) renderGarmentGrid(); }; $('metaSave').onclick = async () => { if (!GARM) return toast('pick a garment first'); - const r = await fetch('/api/meta', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + const r = await fetch('api/meta', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: GARM.path, slot: $('metaSlot').value, tags: $('metaTags').value.split(',').map(s => s.trim()).filter(Boolean) }) }).then(r => r.json()); @@ -594,7 +594,7 @@ function renderLists() { document.querySelectorAll('.item').forEach(el => el.onclick = () => { const it = { name: el.querySelector('.nm').textContent, path: el.dataset.p }; if (el.dataset.k === 'bodies') setBody(it); - else if (el.dataset.k === 'gen') { GENPICK = it.path; $('genPrev').src = '/file?p=' + encodeURIComponent(it.path); $('genPrev').style.display = 'block'; $('rmbgBtn').disabled = false; $('to3dBtn').disabled = $('hangBtn').disabled = !it.path.endsWith('-cut.png'); } + else if (el.dataset.k === 'gen') { GENPICK = it.path; $('genPrev').src = 'file?p=' + encodeURIComponent(it.path); $('genPrev').style.display = 'block'; $('rmbgBtn').disabled = false; $('to3dBtn').disabled = $('hangBtn').disabled = !it.path.endsWith('-cut.png'); } else previewGarment(it); }); document.querySelectorAll('.fitchip').forEach(el => el.onclick = () => loadFit(el.dataset.fit)); @@ -609,7 +609,7 @@ function renderLists() { function wireUpload(inputId, to) { $(inputId).onchange = async e => { const f = e.target.files[0]; if (!f) return; - await fetch(`/api/upload?to=${to}&name=${encodeURIComponent(f.name)}`, { method: 'POST', body: await f.arrayBuffer() }); + await fetch(`api/upload?to=${to}&name=${encodeURIComponent(f.name)}`, { method: 'POST', body: await f.arrayBuffer() }); toast('uploaded ' + f.name); refresh(); }; } @@ -624,7 +624,7 @@ $('bodyType').innerHTML = '' + $('bodyTypeSave').onclick = async () => { if (!BODY) return toast('pick a body first'); const t = $('bodyType').value; if (!t) return toast('choose a type'); - const r = await fetch('/api/meta', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + const r = await fetch('api/meta', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: BODY.path, body_type: t }) }).then(r => r.json()); toast(r.error ? '✗ ' + r.error : `✓ ${BODY.name} is ${t} (${BODY_TYPES[t]}m on export)`); refresh(); @@ -633,7 +633,7 @@ $('tagBtn').onclick = async () => { const it = BODY || GARM; if (!it) return toast('select a body or garment first'); const now = /-nsfw/i.test(it.name); - const r = await fetch('/api/tag', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + const r = await fetch('api/tag', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: it.path, nsfw: !now }) }).then(r => r.json()); if (r.error) return toast('✗ ' + r.error); toast(now ? 'untagged — this can now be exported to games' : 'tagged nsfw — blocked from game export'); @@ -648,7 +648,7 @@ async function runJob(url, payload, label) { if (r.error) { toast('✗ ' + r.error); return null; } while (true) { await new Promise(res => setTimeout(res, 1500)); - const j = await fetch('/api/job/' + r.job).then(r => r.json()); + const j = await fetch('api/job/' + r.job).then(r => r.json()); toast(`${label}: ${j.status}${j.note ? ' · ' + j.note : ''}`); if (j.status === 'done') { toast(`✓ ${label} → ${(j.out || '').split('/').pop()}`); refresh(); return j.out; } if (j.status === 'error') { toast('✗ ' + label + ': ' + (j.log || '').split('\n').pop()); return null; } @@ -660,20 +660,20 @@ $('lodPresets').innerHTML = LODS.map(([n, t]) => ``).join(''); document.querySelectorAll('[data-lod]').forEach(b => b.onclick = () => { $('decT').value = b.dataset.lod; - if (BODY) runJob('/api/blender', { op: 'decimate', args: { path: BODY.path, tris: +b.dataset.lod } }, 'decimate'); + if (BODY) runJob('api/blender', { op: 'decimate', args: { path: BODY.path, tris: +b.dataset.lod } }, 'decimate'); else toast('pick a body first'); }); $('unitBtn').onclick = () => BODY - ? runJob('/api/blender', { op: 'unitfix', args: { path: BODY.path } }, 'unitfix') + ? runJob('api/blender', { op: 'unitfix', args: { path: BODY.path } }, 'unitfix') : toast('pick a body first'); $('rigBtn').onclick = () => BODY - ? runJob('/api/rig', { path: BODY.path, tris: +$('decT').value || 0 }, 'rig (minutes)') + ? runJob('api/rig', { path: BODY.path, tris: +$('decT').value || 0 }, 'rig (minutes)') : toast('pick a body first'); -$('scaleBtn').onclick = () => BODY && runJob('/api/blender', { op: 'scale', args: { path: BODY.path, height: +$('scaleH').value } }, 'scale'); -$('decBtn').onclick = () => BODY && runJob('/api/blender', { op: 'decimate', args: { path: BODY.path, tris: +$('decT').value } }, 'decimate'); -$('fitBtn').onclick = () => (BODY && GARM) ? runJob('/api/blender', { op: 'fit', args: { body: BODY.path, garment: GARM.path, mode: 'garment', inflate: +$('fitInf').value } }, 'fit') : toast('pick a body AND a garment'); -$('fitMergeBtn').onclick = () => (BODY && GARM) ? runJob('/api/blender', { op: 'fit', args: { body: BODY.path, garment: GARM.path, mode: 'merge', inflate: +$('fitInf').value } }, 'fit+merge') : toast('pick a body AND a garment'); -$('asmBtn').onclick = () => (BODY && picked.size) ? runJob('/api/blender', { op: 'assemble', args: { body: BODY.path, garments: [...picked], name: $('outfitName').value } }, 'assemble') : toast('pick a body + tick fitted garments'); +$('scaleBtn').onclick = () => BODY && runJob('api/blender', { op: 'scale', args: { path: BODY.path, height: +$('scaleH').value } }, 'scale'); +$('decBtn').onclick = () => BODY && runJob('api/blender', { op: 'decimate', args: { path: BODY.path, tris: +$('decT').value } }, 'decimate'); +$('fitBtn').onclick = () => (BODY && GARM) ? runJob('api/blender', { op: 'fit', args: { body: BODY.path, garment: GARM.path, mode: 'garment', inflate: +$('fitInf').value } }, 'fit') : toast('pick a body AND a garment'); +$('fitMergeBtn').onclick = () => (BODY && GARM) ? runJob('api/blender', { op: 'fit', args: { body: BODY.path, garment: GARM.path, mode: 'merge', inflate: +$('fitInf').value } }, 'fit+merge') : toast('pick a body AND a garment'); +$('asmBtn').onclick = () => (BODY && picked.size) ? runJob('api/blender', { op: 'assemble', args: { body: BODY.path, garments: [...picked], name: $('outfitName').value } }, 'assemble') : toast('pick a body + tick fitted garments'); // generator chain let GENPICK = null; @@ -683,19 +683,19 @@ async function doGen() { variant: +$('genVariant').value || 0, view: 'top' }; // show the resolved prompt + seed: the cheapest debugging affordance there is, and without it // a bad generation gives you nothing to reason about - const pre = await fetch('/api/gen', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + const pre = await fetch('api/gen', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }).then(r => r.json()); if (pre.error) return toast('✗ ' + pre.error); $('genMeta').textContent = `seed ${pre.seed} · ${pre.prompt}`; toast('flux…'); while (true) { await new Promise(r => setTimeout(r, 1500)); - const j = await fetch('/api/job/' + pre.job).then(r => r.json()); + const j = await fetch('api/job/' + pre.job).then(r => r.json()); toast(`flux: ${j.status}${j.note ? ' · ' + j.note : ''}`); if (j.status === 'error') return toast('✗ flux: ' + (j.log || '').split('\n').pop()); if (j.status === 'done') { GENPICK = j.out; - $('genPrev').src = '/file?p=' + encodeURIComponent(j.out) + '&t=' + Date.now(); + $('genPrev').src = 'file?p=' + encodeURIComponent(j.out) + '&t=' + Date.now(); $('genPrev').style.display = 'block'; $('rmbgBtn').disabled = false; refresh(); return toast('✓ ' + (j.out || '').split('/').pop()); } @@ -705,13 +705,13 @@ $('genBtn').onclick = doGen; $('genReroll').onclick = () => { $('genVariant').value = (+$('genVariant').value || 0) + 1; doGen(); }; $('rmbgBtn').onclick = async () => { if (!GENPICK) return; - const out = await runJob('/api/rmbg', { path: GENPICK }, 'cutout'); - if (out) { GENPICK = out; $('genPrev').src = '/file?p=' + encodeURIComponent(out) + '&t=' + Date.now(); $('to3dBtn').disabled = $('hangBtn').disabled = false; } + const out = await runJob('api/rmbg', { path: GENPICK }, 'cutout'); + if (out) { GENPICK = out; $('genPrev').src = 'file?p=' + encodeURIComponent(out) + '&t=' + Date.now(); $('to3dBtn').disabled = $('hangBtn').disabled = false; } }; -$('to3dBtn').onclick = () => GENPICK && runJob('/api/to3d', { path: GENPICK }, 'trellis'); +$('to3dBtn').onclick = () => GENPICK && runJob('api/to3d', { path: GENPICK }, 'trellis'); $('hangBtn').onclick = async () => { if (!GENPICK) return; - const r = await fetch('/api/hangable', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: GENPICK }) }).then(r => r.json()); + const r = await fetch('api/hangable', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: GENPICK }) }).then(r => r.json()); toast(r.note || r.error); refresh(); }; @@ -720,7 +720,7 @@ $('hangBtn').onclick = async () => { // so pre-baked outfits toggle instantly. Keeps the original map so "bare" is reversible. const texLoader = new THREE.TextureLoader(); $('reskinBtn').onclick = () => (BODY && GARM) - ? runJob('/api/reskin', { body: BODY.path, garment: GARM.path, desc: 'the ' + GARM.name.replace(/[-_]/g, ' ').replace(/\.\w+$/, '') }, 'reskin') + ? runJob('api/reskin', { body: BODY.path, garment: GARM.path, desc: 'the ' + GARM.name.replace(/[-_]/g, ' ').replace(/\.\w+$/, '') }, 'reskin') : toast('pick a body AND a garment first'); function renderCostumes() { @@ -735,7 +735,7 @@ $('costumeSel').onchange = e => { if (!o.isMesh || !o.material) return; if (o.userData.bareMap === undefined) o.userData.bareMap = o.material.map || null; if (!p) { o.material.map = o.userData.bareMap; o.material.needsUpdate = true; return; } - texLoader.load('/file?p=' + encodeURIComponent(p) + '&t=' + Date.now(), t => { + texLoader.load('file?p=' + encodeURIComponent(p) + '&t=' + Date.now(), t => { t.flipY = false; t.colorSpace = THREE.SRGBColorSpace; // glTF UV convention o.material.map = t; o.material.needsUpdate = true; }); @@ -771,10 +771,10 @@ function renderDoll() { async function composeDoll() { const stems = Object.values(DOLLPICK); - const r = await fetch('/api/doll/compose', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + const r = await fetch('api/doll/compose', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ stems, name: 'preview' }) }).then(r => r.json()); if (r.error) return toast('✗ ' + r.error); - $('dollPrev').src = '/file?p=' + encodeURIComponent(r.path) + '&t=' + Date.now(); + $('dollPrev').src = 'file?p=' + encodeURIComponent(r.path) + '&t=' + Date.now(); toast(`doll: ${stems.length} layer/s`); } @@ -791,13 +791,13 @@ $('dollStrip').onclick = () => { DOLLPICK = {}; renderDoll(); composeDoll(); }; $('dollGenBtn').onclick = async () => { const phrase = $('dollGenPhrase').value.trim(); if (!phrase) return toast('describe the garment'); const slot = $('dollGenSlot').value; - const out = await runJob('/api/doll/gen', { slot, phrase, key: phrase }, `doll ${slot}`); + const out = await runJob('api/doll/gen', { slot, phrase, key: phrase }, `doll ${slot}`); if (out) { await refresh(); renderDoll(); } }; $('dollExport').onclick = async () => { const stems = Object.values(DOLLPICK); if (!stems.length) return toast('dress the doll first'); - const r = await fetch('/api/doll/export', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + const r = await fetch('api/doll/export', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ stems, dry: true }) }).then(r => r.json()); if (r.error) return toast('✗ ' + r.error); toast(r.note || 'exported');