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:
parent
e35b613600
commit
0467c53727
@ -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
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user