ship-check for digalot.fyi/wardrobe: relative UI paths (subpath-proxy safe) + POST body cap

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-25 15:43:29 +10:00
parent e35b613600
commit 0467c53727
2 changed files with 40 additions and 36 deletions

View File

@ -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

View File

@ -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 ? `<span class="slot">${g.slot}${g.slot_inferred ? '?' : ''}</span>` : '';
return `<div class="card ${on ? 'on' : ''}" data-p="${g.path}" title="${g.name}">
<img loading="lazy" src="/thumb?p=${encodeURIComponent(g.path)}" alt="">
<img loading="lazy" src="thumb?p=${encodeURIComponent(g.path)}" alt="">
${slot}${g.nsfw ? '<span class="warn">NSFW</span>' : ''}
<span class="cap">${(g.title || g.name).replace(/\.(glb|gltf|fbx|obj|png|jpg)$/i, '').replace(/-nsfw/i, '')}</span>
</div>`;
@ -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 = `<b>not a file drop.</b> ${r.why}<br>edit: <code>${r.registry || '?'}</code>`;
@ -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 = '<option value="">—</option>' +
$('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]) =>
`<button class="ghost" data-lod="${t}" style="flex:1;font-size:11px">${n}<br><span class="sub">${(t / 1000)}k</span></button>`).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');