#!/usr/bin/env python3 """3GOD — the prop house. One .glb depot + inspection room shared by THRIFTGOD, 90sDJsim and the robotmonster 3D store. Stdlib only, no DB — a folder of .glb files plus a meta.json sidecar. Reads are public (the games load assets client-side in every visitor's browser); writes need the password. GOD3_PW=... python3 server.py [port] # default 8788 """ import hashlib, hmac, ipaddress, json, os, re, socket, sys, threading, time, urllib.error, urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import unquote, urlparse ROOT = os.path.dirname(os.path.abspath(__file__)) DIR = os.path.join(ROOT, 'assets') THUMBS = os.path.join(DIR, '.thumbs') # viewer-captured 256px jpegs os.makedirs(THUMBS, exist_ok=True) META = os.path.join(DIR, 'meta.json') OPEN = os.environ.get('GOD3_OPEN') == '1' # open-door testing: writes need no password if OPEN: print('🚪 GOD3_OPEN=1 — the prop house door is wide open (testing mode)') PW = os.environ.get('GOD3_PW') if not PW: # no shipped default — a locked depot on 'skeletonkey' is not locked if not OPEN: sys.exit('refusing to start: set GOD3_PW=, or GOD3_OPEN=1 for passwordless local testing.') PW = 'skeletonkey' # OPEN bypasses auth anyway; token value is moot TOKEN = hmac.new(PW.encode(), b'3god-ok', hashlib.sha256).hexdigest() TS_ALLOW = {'100.89.131.57', '100.91.239.7', '100.102.229.52', '100.69.21.128', # workstations/laptops '100.71.119.27', '100.94.195.115'} # VPS: botchat, dealgod # tailnet peers: direct connection over tailscale = auto-write. # ponytail: trusts the raw socket peer, NOT any X-Forwarded-For / CF-Connecting-IP header (spoofable). So it only grants # when the machine hits this port DIRECTLY over the tailnet — through the digalot.fyi Cloudflare front the peer is CF, never 100.x. MAX = 50 * 1024 * 1024 SAFE = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9._ -]*\.(glb|png|jpe?g|webp)$') EXTS = ('.glb', '.png', '.jpg', '.jpeg', '.webp') CT = {'.glb': 'model/gltf-binary', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp'} def looks_ok(data, name): """Magic bytes match the extension — no mystery files on the shelf.""" if name.endswith('.glb'): return data[:4] == b'glTF' if name.endswith('.png'): return data[:8] == b'\x89PNG\r\n\x1a\n' if name.endswith(('.jpg', '.jpeg')): return data[:2] == b'\xff\xd8' if name.endswith('.webp'): return data[:4] == b'RIFF' and data[8:12] == b'WEBP' return False _META_LOCK = threading.Lock() # ThreadingHTTPServer → guard the load-mutate-save def meta_load(): try: return json.load(open(META)) except Exception: return {} def meta_save(m): tmp = f'{META}.{os.getpid()}.tmp' # atomic: a torn write can never be read back as {} with open(tmp, 'w') as f: json.dump(m, f, indent=1) os.replace(tmp, META) def clean_name(n): n = re.sub(r'[^a-zA-Z0-9._ -]', '', os.path.basename(n or '')).strip() if n and not n.lower().endswith(EXTS): n += '.glb' return n if SAFE.match(n or '') else None def cloud_url(u): """Paste-friendly: turn Drive/Dropbox share links into direct downloads.""" m = re.search(r'drive\.google\.com/file/d/([\w-]+)', u) if m: return f'https://drive.google.com/uc?export=download&id={m.group(1)}' if 'dropbox.com' in u: return re.sub(r'[?&]dl=0', '', u) + ('&' if '?' in u else '?') + 'dl=1' return u def public_host(url): """SSRF guard: True only for http(s) URLs whose host resolves to public IPs. Blocks file://, ftp://, localhost, link-local (169.254.x metadata), and private ranges. ponytail: does NOT close DNS-rebinding (re-resolve to internal after this check) — pin the resolved IP and connect to it if 3GOD ever fetches on behalf of untrusted callers.""" p = urlparse(url) if p.scheme not in ('http', 'https') or not p.hostname: return False try: infos = socket.getaddrinfo(p.hostname, p.port or (443 if p.scheme == 'https' else 80), proto=socket.IPPROTO_TCP) except OSError: return False for *_, sockaddr in infos: ip = ipaddress.ip_address(sockaddr[0]) if (ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast or ip.is_unspecified): return False return bool(infos) class _GuardedRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): if not public_host(newurl): # re-validate every redirect hop raise urllib.error.HTTPError(newurl, code, 'redirect to non-public host blocked', headers, fp) return super().redirect_request(req, fp, code, msg, headers, newurl) _OPENER = urllib.request.build_opener(_GuardedRedirect) # http/https only (no file/ftp handlers), hops re-checked class H(BaseHTTPRequestHandler): def log_message(self, fmt, *a): pass def _send(self, code, body, ctype='application/json', extra=None): data = body if isinstance(body, bytes) else json.dumps(body).encode() self.send_response(code) self.send_header('Content-Type', ctype) self.send_header('Content-Length', len(data)) for k, v in (extra or {}).items(): self.send_header(k, v) self.end_headers() self.wfile.write(data) def authed(self): if OPEN: return True if self.client_address[0] in TS_ALLOW: # trusted tailnet peer (direct connection only) return True c = self.headers.get('Cookie', '') m = re.search(r'3g=([0-9a-f]{64})', c) return bool(m and hmac.compare_digest(m.group(1), TOKEN)) def do_GET(self): path = self.path.split('?')[0] if path == '/' or path == '/index.html': return self._send(200, open(os.path.join(ROOT, 'web', 'index.html'), 'rb').read(), 'text/html; charset=utf-8') if path == '/api/list': m = meta_load() out = [] for f in sorted(os.listdir(DIR)): if not f.endswith(EXTS): continue st = os.stat(os.path.join(DIR, f)) out.append(dict({'tags': [], 'note': ''}, **m.get(f, {}), file=f, size=st.st_size, added=int(st.st_mtime), thumb=os.path.isfile(os.path.join(THUMBS, f + '.jpg')))) return self._send(200, {'assets': out, 'authed': self.authed()}, extra={'Access-Control-Allow-Origin': '*'}) if path.startswith('/t/'): # thumbnail (viewer-captured) f = clean_name(unquote(path[3:])) fp = f and os.path.join(THUMBS, f + '.jpg') if not (fp and os.path.isfile(fp)): return self._send(404, {'error': 'no thumb yet'}) return self._send(200, open(fp, 'rb').read(), 'image/jpeg', {'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=3600'}) if path.startswith('/a/') or path.startswith('/dl/'): f = clean_name(unquote(path.split('/', 2)[2])) fp = f and os.path.join(DIR, f) if not (fp and os.path.isfile(fp)): return self._send(404, {'error': 'no such prop'}) extra = {'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=86400'} if path.startswith('/dl/'): extra['Content-Disposition'] = f'attachment; filename="{f}"' return self._send(200, open(fp, 'rb').read(), CT.get(os.path.splitext(f)[1], 'application/octet-stream'), extra) return self._send(404, {'error': 'lost in the prop house'}) def do_POST(self): path, _, q = self.path.partition('?') n = int(self.headers.get('Content-Length') or 0) if n > MAX: return self._send(413, {'error': 'over 50MB — decimate it, digger'}) raw = self.rfile.read(n) if n else b'' if path == '/api/login': body = json.loads(raw or '{}') if hmac.compare_digest(str(body.get('password', '')), PW): return self._send(200, {'ok': True}, extra={'Set-Cookie': f'3g={TOKEN}; Path=/; Max-Age=31536000; HttpOnly; SameSite=Lax'}) time.sleep(1) # gentle brake on guessing return self._send(403, {'error': 'not the magic word'}) if not self.authed(): return self._send(403, {'error': 'password first'}) if path == '/api/upload': # raw body, ?name=file.glb|png|jpg|webp name = clean_name(unquote(dict(p.split('=', 1) for p in q.split('&') if '=' in p).get('name', ''))) if not name: return self._send(400, {'error': 'name it something.glb (or .png/.jpg/.webp)'}) if not looks_ok(raw, name): return self._send(400, {'error': f'that is not really a {os.path.splitext(name)[1]} file'}) open(os.path.join(DIR, name), 'wb').write(raw) return self._send(200, {'ok': True, 'file': name}) if path == '/api/thumb': # viewer snapshots the loaded model name = clean_name(unquote(dict(p.split('=', 1) for p in q.split('&') if '=' in p).get('name', ''))) if not (name and os.path.isfile(os.path.join(DIR, name))): return self._send(404, {'error': 'no such prop'}) if raw[:2] != b'\xff\xd8': return self._send(400, {'error': 'thumbs are jpeg'}) open(os.path.join(THUMBS, name + '.jpg'), 'wb').write(raw) return self._send(200, {'ok': True}) body = json.loads(raw or '{}') if path == '/api/fetch': # paste a URL, server pulls it in name = clean_name(body.get('name', '')) if not name: return self._send(400, {'error': 'name it something.glb'}) target = cloud_url(body.get('url', '')) if not public_host(target): return self._send(400, {'error': 'only public http(s) URLs allowed (no file://, localhost or internal IPs)'}) try: req = urllib.request.Request(target, headers={'User-Agent': '3GOD/1.0'}) with _OPENER.open(req, timeout=60) as r: data = r.read(MAX + 1) except Exception as e: return self._send(400, {'error': f'fetch failed: {e}'}) if len(data) > MAX: return self._send(413, {'error': 'over 50MB'}) if not looks_ok(data, name): return self._send(400, {'error': 'that URL is not the file type its name claims (got HTML? make the link public/direct)'}) open(os.path.join(DIR, name), 'wb').write(data) return self._send(200, {'ok': True, 'file': name}) if path == '/api/del': f = clean_name(body.get('file', '')) fp = f and os.path.join(DIR, f) if fp and os.path.isfile(fp): os.remove(fp) tp = os.path.join(THUMBS, f + '.jpg') if os.path.isfile(tp): os.remove(tp) with _META_LOCK: m = meta_load() m.pop(f, None) meta_save(m) return self._send(200, {'ok': True}) if path == '/api/meta': # tags/notes + viewer-measured stats f = clean_name(body.get('file', '')) if not (f and os.path.isfile(os.path.join(DIR, f))): return self._send(404, {'error': 'no such prop'}) with _META_LOCK: m = meta_load() row = m.setdefault(f, {}) for k in ('tags', 'note', 'clips', 'tris', 'dims', 'rot', 'scale'): if k in body: row[k] = body[k] if isinstance(row.get('tags'), list): # cap + stringify (real XSS defense is escaping at render) row['tags'] = [str(t)[:40] for t in row['tags'][:20]] if 'note' in row: row['note'] = str(row['note'])[:500] meta_save(m) return self._send(200, {'ok': True}) return self._send(404, {'error': 'lost in the prop house'}) if __name__ == '__main__': port = int(sys.argv[1]) if len(sys.argv) > 1 else 8788 print(f'3GOD prop house on :{port} — {len([f for f in os.listdir(DIR) if f.endswith(EXTS)])} props on the shelf') ThreadingHTTPServer(('0.0.0.0', port), H).serve_forever()