#!/usr/bin/env python3 """SKATEMAKER PRO dev server. Static files + three jobs the browser can't do alone: /api/parks GET list | /api/parks/ GET load, POST save (JSON body) /api/upload?name= POST raw bytes -> assets/uploads/, returns {"path": ...} /mb/... MODELBEAST proxy (queue on the m3ultra, token from ~/Documents/backnforth/.env — stays server-side, never in JS) /mb/assets GET -> asset list /mb/assets//file GET -> file bytes (same-origin for GLTFLoader) /mb/jobs POST -> submit {operator, asset_id?, params} /mb/jobs/ GET -> poll (control-char-safe) /mb/import?asset=&name= GET -> download MB asset into assets/uploads/, return path Run: python3 serve.py [port] (default 8093) """ import json, os, re, sys, urllib.parse, urllib.request from http.server import HTTPServer, SimpleHTTPRequestHandler ROOT = os.path.dirname(os.path.abspath(__file__)) PARKS = os.path.join(ROOT, 'parks') UPLOADS = os.path.join(ROOT, 'assets', 'uploads') KIT = os.environ.get('PARK_KIT', os.path.join(os.path.dirname(ROOT), 'park_kit')) MB_HOST = os.environ.get('MB_HOST', 'http://100.89.131.57:8777') SAFE = re.compile(r'^[\w.\- ]+$') def mb_token(): t = os.environ.get('MB_TOKEN') if t: return t try: for line in open(os.path.expanduser('~/Documents/backnforth/.env')): if line.startswith('MB_TOKEN='): return line.split('=', 1)[1].strip() except OSError: pass return None def mb_req(path, data=None, ctype=None, timeout=180): tok = mb_token() if not tok: raise RuntimeError('MB_TOKEN not found') h = {'Authorization': 'Bearer ' + tok} if ctype: h['Content-Type'] = ctype r = urllib.request.Request(MB_HOST + path, data=data, headers=h) return urllib.request.urlopen(r, timeout=timeout).read() def clean_json(b): # MB job logs carry raw control chars s = b.decode('utf-8', 'replace') return ''.join(c if c >= ' ' or c in '\t\n' else ' ' for c in s).encode() class H(SimpleHTTPRequestHandler): def _send(self, code, body, ctype='application/json'): self.send_response(code) self.send_header('Content-Type', ctype) self.send_header('Content-Length', str(len(body))) self.send_header('Cache-Control', 'no-store') self.end_headers() self.wfile.write(body) def _json(self, obj, code=200): self._send(code, json.dumps(obj).encode()) def _err(self, msg, code=500): self._json({'error': str(msg)}, code) def do_GET(self): path, _, query = self.path.partition('?') q = dict(p.split('=', 1) for p in query.split('&') if '=' in p) try: if path == '/api/parks': names = sorted(f[:-5] for f in os.listdir(PARKS) if f.endswith('.json')) return self._json(names) if path.startswith('/api/parks/'): name = urllib.parse.unquote(path.split('/', 3)[3]) if not SAFE.match(name): return self._err('bad name', 400) f = os.path.join(PARKS, name + '.json') if not os.path.exists(f): return self._err('not found', 404) return self._send(200, open(f, 'rb').read()) if path.startswith('/kit/'): # park_kit mount (props+textures+manifest) rel = os.path.normpath(path[5:]).lstrip('/') f = os.path.join(KIT, rel) if not f.startswith(KIT + os.sep) or not os.path.isfile(f): return self._err('not found', 404) import mimetypes ctype = mimetypes.guess_type(f)[0] or 'application/octet-stream' return self._send(200, open(f, 'rb').read(), ctype) if path == '/mb/assets': return self._send(200, clean_json(mb_req('/api/assets?limit=100'))) m = re.match(r'^/mb/assets/([\w-]+)/file$', path) if m: return self._send(200, mb_req(f'/api/assets/{m.group(1)}/file'), 'application/octet-stream') m = re.match(r'^/mb/jobs/([\w-]+)$', path) if m: return self._send(200, clean_json(mb_req(f'/api/jobs/{m.group(1)}'))) if path == '/mb/import': aid, name = q.get('asset', ''), urllib.parse.unquote(q.get('name', 'asset.bin')) if not re.match(r'^[\w-]+$', aid) or not SAFE.match(name): return self._err('bad args', 400) os.makedirs(UPLOADS, exist_ok=True) data = mb_req(f'/api/assets/{aid}/file') out = os.path.join(UPLOADS, name) open(out, 'wb').write(data) return self._json({'path': 'assets/uploads/' + name, 'bytes': len(data)}) except Exception as e: return self._err(e) return super().do_GET() def do_POST(self): path, _, query = self.path.partition('?') q = dict(p.split('=', 1) for p in query.split('&') if '=' in p) n = int(self.headers.get('Content-Length') or 0) body = self.rfile.read(n) if n else b'' try: if path.startswith('/api/parks/'): name = urllib.parse.unquote(path.split('/', 3)[3]) if not SAFE.match(name): return self._err('bad name', 400) json.loads(body) # must be valid JSON os.makedirs(PARKS, exist_ok=True) open(os.path.join(PARKS, name + '.json'), 'wb').write(body) return self._json({'saved': name}) if path == '/api/kit-thumb': # rendered-once kit thumbnails (committed) name = urllib.parse.unquote(q.get('name', '')) if not re.match(r'^[\w\-]+\.png$', name): return self._err('bad name', 400) d = os.path.join(ROOT, 'assets', 'kit_thumbs') os.makedirs(d, exist_ok=True) open(os.path.join(d, name), 'wb').write(body) return self._json({'path': 'assets/kit_thumbs/' + name}) if path == '/api/upload': name = urllib.parse.unquote(q.get('name', '')) if not SAFE.match(name): return self._err('bad name', 400) os.makedirs(UPLOADS, exist_ok=True) open(os.path.join(UPLOADS, name), 'wb').write(body) return self._json({'path': 'assets/uploads/' + name, 'bytes': len(body)}) if path == '/mb/jobs': return self._send(200, clean_json( mb_req('/api/jobs', data=body, ctype='application/json'))) if path == '/mb/upload': # raw bytes -> MB multipart asset name = urllib.parse.unquote(q.get('name', 'upload.png')) if not SAFE.match(name): return self._err('bad name', 400) bound = 'smpb0undary' ctype = q.get('type', 'application/octet-stream') mp = (f'--{bound}\r\nContent-Disposition: form-data; name="file"; ' f'filename="{name}"\r\nContent-Type: {ctype}\r\n\r\n').encode() \ + body + f'\r\n--{bound}--\r\n'.encode() return self._send(200, clean_json(mb_req( '/api/assets', data=mp, ctype=f'multipart/form-data; boundary={bound}'))) except Exception as e: return self._err(e) return self._err('unknown endpoint', 404) def log_message(self, fmt, *a): if '/mb/jobs/' not in str(a[0] if a else ''): # quiet the poll spam super().log_message(fmt, *a) if __name__ == '__main__': port = int(sys.argv[1]) if len(sys.argv) > 1 else 8093 os.chdir(ROOT) print(f'SKATEMAKER PRO on http://localhost:{port} (MB proxy -> {MB_HOST})') HTTPServer(('0.0.0.0', port), H).serve_forever()