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 `
${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');