diff --git a/server.py b/server.py index 38e8f9b..6f7fcff 100644 --- a/server.py +++ b/server.py @@ -75,6 +75,29 @@ def slug(s): return re.sub(r'[^a-z0-9]+', '-', (s or '').lower()).strip('-')[:60] or 'x' +# Asset classification lives in the FILENAME, matching the convention already in use across the +# fleet (~/Documents/FBX: a '-nsfw' suffix before the extension, 106 of 124 files tagged, plus an +# `nsfw` column in _INVENTORY.csv). Filenames beat a sidecar database here because assets get +# rsynced between six machines constantly — a tag in the name travels with the file and can't +# drift out of sync, and it's visible in Finder. +NSFW_RE = re.compile(r'-nsfw(?=[.\s(]|$)', re.I) + + +def is_nsfw(path): + return bool(NSFW_RE.search(os.path.basename(path))) + + +def tagged_name(name, nsfw): + """Add or remove the -nsfw suffix, preserving the extension.""" + stem, dot, ext = name.rpartition('.') + if not dot: + stem, ext = name, '' + stem = NSFW_RE.sub('', stem).rstrip('-') + if nsfw: + stem += '-nsfw' + return stem + (dot + ext if dot else '') + + def allowed(path): p = os.path.realpath(path) roots = list(DIRS.values()) + EXTRA_BODIES + [CACHE, OUTFITS, os.path.join(LIB, 'doll')] @@ -98,6 +121,7 @@ def scan(): p = os.path.join(dd, f) rows.append({'name': f, 'path': p, 'kb': os.path.getsize(p) // 1024, 'home': os.path.basename(dd), + 'nsfw': is_nsfw(f), 'kind': 'img' if f.lower().endswith(IMG_EXT) else 'model'}) out[key] = rows out['outfits'] = sorted(f[:-5] for f in os.listdir(OUTFITS) if f.endswith('.json')) @@ -355,6 +379,21 @@ class H(BaseHTTPRequestHandler): body = json.loads(raw or b'{}') + if u.path == '/api/tag': # add/remove the -nsfw suffix on an asset + p, nsfw = body.get('path', ''), bool(body.get('nsfw')) + if not allowed(p) or not os.path.isfile(p): + return self.j({'error': 'path outside library'}, 403) + d, name = os.path.split(p) + new = tagged_name(name, nsfw) + if new == name: + return self.j({'ok': True, 'path': p, 'note': 'already tagged that way'}) + tgt = os.path.join(d, new) + if os.path.exists(tgt): + return self.j({'error': 'a file with that name already exists'}, 409) + os.rename(p, tgt) + # the convert cache is keyed on the source path, so a rename orphans its entry + return self.j({'ok': True, 'path': tgt, 'nsfw': nsfw}) + if u.path == '/api/outfit': # save a dress-up: body + attachments + clip + tints # The rigid-attach sliders used to live only in browser memory ("preview only") — every # hat placement died on reload. This is the persistence half of that tier. @@ -505,6 +544,12 @@ class H(BaseHTTPRequestHandler): bad = [s for s in stems if '_i_' not in s] if bad: return self.j({'error': 'refusing to overwrite generic slot art: ' + bad[0]}, 400) + # The house rule "bases stay in the library, only dressed outfits ship into games" + # has lived as one unenforced line in the README. This is the enforcement. + nsfw = [s for s in stems if is_nsfw(s)] + if nsfw: + return self.j({'error': f'refusing to export nsfw-tagged asset into a game ' + f'directory: {nsfw[0]}'}, 403) files = [(s, os.path.join(DOLL.DOLL_DIR, s + '.png')) for s in stems] missing = [s for s, p in files if not os.path.isfile(p)] if missing: @@ -558,7 +603,13 @@ class H(BaseHTTPRequestHandler): elif op == 'assemble': b = glb_of(a['body'], jid) gs = [glb_of(g, jid) for g in a['garments']] - out = os.path.join(DIRS['out'], slug(a.get('name') or 'outfit') + '.glb') + # An assembled result INHERITS the body's nsfw tag. Clearing it automatically + # because "a garment was added" would be a silent wrong call — a hat does not + # clothe a nude base — and the failure mode is shipping a nude model into a + # game. Untagging stays a deliberate act once you've looked at the result. + out = os.path.join(DIRS['out'], + tagged_name(slug(a.get('name') or 'outfit') + '.glb', + is_nsfw(a['body']))) run_blender(['assemble', b, out] + gs, jid) elif op == 'convert': src = a['path'] diff --git a/web/index.html b/web/index.html index 7d72677..cb85fed 100644 --- a/web/index.html +++ b/web/index.html @@ -49,6 +49,8 @@ .sw { width:22px; height:22px; border-radius:6px; border:1px solid var(--line); cursor:pointer; padding:0 } .sw.on { border-color:var(--gold); box-shadow:0 0 0 2px #e8c25744 } .fitchip { margin:3px 4px 0 0; font-weight:400 } + .nsfw { background:#7a2233; color:#ffd9e0; border-radius:4px; padding:0 4px; margin-right:5px; + font-size:10px; letter-spacing:.5px; font-weight:700 } /* 2D paper-doll stage — takes over the viewport while the doll tab is open */ #dollStage { position:absolute; inset:0; display:none; align-items:center; justify-content:center; background:#14120e; z-index:3 } @@ -70,6 +72,10 @@