fartcore / app.py
josephrw's picture
Upload app.py with huggingface_hub
d8de215 verified
Raw
History Blame Contribute Delete
24.3 kB
#!/usr/bin/env python3
"""
fartcore — LocalSpace App Bundler & Deployer
Upload .app, .dmg, or .zip bundles. They get extracted, inspected,
and auto-registered with your LocalSpace coordinator as artifacts + spaces.
"""
from __future__ import annotations
import hashlib
import json
import os
import plistlib
import re
import shutil
import subprocess
import time
import uuid
from pathlib import Path
from typing import Any
import httpx
from fastapi import FastAPI, File, Request, UploadFile
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
DATA_DIR = Path("data")
DATA_DIR.mkdir(exist_ok=True)
UPLOAD_DIR = DATA_DIR / "uploads"
UPLOAD_DIR.mkdir(exist_ok=True)
EXTRACT_DIR = DATA_DIR / "extracted"
EXTRACT_DIR.mkdir(exist_ok=True)
DB_PATH = DATA_DIR / "apps.json"
LOCALSPACE_API_URL = os.getenv("LOCALSPACE_API_URL", "http://localhost:8000").rstrip("/")
LOCALSPACE_API_KEY = os.getenv("LOCALSPACE_API_KEY", "dev-key-change-me")
app = FastAPI(title="fartcore — LocalSpace Deployer")
app.mount("/static", StaticFiles(directory="static"), name="static")
_store: dict[str, dict[str, Any]] = {}
def _load_db() -> None:
global _store
if DB_PATH.exists():
with open(DB_PATH, "r") as f:
_store = json.load(f)
def _save_db() -> None:
with open(DB_PATH, "w") as f:
json.dump(_store, f, indent=2, default=str)
def _hash_file(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
h.update(chunk)
return h.hexdigest()
def _slugify(name: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", name.lower().replace(".app", "").replace(".dmg", "")).strip("-")
def _read_info_plist(app_path: Path) -> dict[str, Any]:
plist_path = app_path / "Contents" / "Info.plist"
if not plist_path.exists():
return {}
try:
with open(plist_path, "rb") as f:
return plistlib.load(f)
except Exception:
return {}
def _find_app_in_dir(directory: Path) -> Path | None:
for item in directory.iterdir():
if item.suffix == ".app":
return item
for item in directory.iterdir():
if item.is_dir():
for sub in item.iterdir():
if sub.suffix == ".app":
return sub
return None
def _extract_zip(zip_path: Path, dest: Path) -> Path | None:
try:
subprocess.run(["unzip", "-q", str(zip_path), "-d", str(dest)], check=True, capture_output=True)
return _find_app_in_dir(dest)
except Exception:
return None
def _extract_dmg(dmg_path: Path, dest: Path) -> Path | None:
import platform
if platform.system() != "Darwin":
return None
mount_point = Path(f"/Volumes/fartcore-{uuid.uuid4().hex[:8]}")
try:
subprocess.run(
["hdiutil", "attach", str(dmg_path), "-mountpoint", str(mount_point), "-nobrowse", "-quiet"],
check=True,
capture_output=True,
)
app = _find_app_in_dir(mount_point)
if app:
copied = dest / app.name
shutil.copytree(app, copied, dirs_exist_ok=True)
return copied
return None
except Exception:
return None
finally:
if mount_point.exists():
subprocess.run(["hdiutil", "detach", str(mount_point), "-quiet"], capture_output=True)
def _extract_upload(upload_path: Path, dest: Path) -> Path | None:
suffix = upload_path.suffix.lower()
if suffix == ".zip":
return _extract_zip(upload_path, dest)
elif suffix == ".dmg":
return _extract_dmg(upload_path, dest)
elif suffix == ".app":
copied = dest / upload_path.name
if upload_path.is_dir():
shutil.copytree(upload_path, copied, dirs_exist_ok=True)
return copied
return None
def _scan_app_bundle(app_path: Path) -> dict[str, Any]:
info = _read_info_plist(app_path)
return {
"bundle_id": info.get("CFBundleIdentifier", "unknown"),
"display_name": info.get("CFBundleDisplayName") or info.get("CFBundleName", app_path.stem),
"version": info.get("CFBundleShortVersionString", "1.0.0"),
"bundle_version": info.get("CFBundleVersion", "1"),
"executable": info.get("CFBundleExecutable", ""),
"minimum_os": info.get("LSMinimumSystemVersion", "10.15"),
"icon_file": info.get("CFBundleIconFile", ""),
}
def _find_preview_file(app_path: Path) -> Path | None:
resources = app_path / "Contents" / "Resources"
if not resources.exists():
return None
for pattern in ("index.html", "ui.html", "main.html"):
candidate = resources / pattern
if candidate.exists():
return candidate
matches = list(resources.glob("*.html"))
return matches[0] if matches else None
async def _register_with_localspace(
app_name: str,
app_path: Path,
artifact_type: str,
runtime_command: str = "",
) -> dict[str, Any] | None:
headers = {"Authorization": f"Bearer {LOCALSPACE_API_KEY}", "Content-Type": "application/json"}
slug = _slugify(app_name)
try:
r = await httpx.AsyncClient().get(f"{LOCALSPACE_API_URL}/workers", headers=headers, timeout=5)
workers = r.json() if r.status_code == 200 else []
if not workers:
return None
worker_id = workers[0].get("worker_id", "auto")
except Exception:
return None
artifact_payload = {
"artifact_type": artifact_type,
"name": app_name,
"local_path": str(app_path),
"runtime_command": runtime_command,
}
try:
r = await httpx.AsyncClient().post(
f"{LOCALSPACE_API_URL}/artifacts", headers=headers, json=artifact_payload, timeout=10
)
if r.status_code in (200, 201):
artifact_id = r.json()["artifact_id"]
else:
return None
except Exception:
return None
space_payload = {
"slug": slug,
"worker_id": worker_id,
"artifact_id": artifact_id,
"compute_lease": {
"cpu_cores": 2,
"memory_mb": 512,
"disk_mb": 1024,
"max_job_seconds": 30,
"allowed_runtimes": [artifact_type],
},
"description": f"Deployed via fartcore: {app_name}",
}
try:
r = await httpx.AsyncClient().post(
f"{LOCALSPACE_API_URL}/spaces", headers=headers, json=space_payload, timeout=10
)
if r.status_code in (200, 201):
return r.json()
except Exception:
pass
return None
@app.on_event("startup")
async def startup():
_load_db()
@app.get("/", response_class=HTMLResponse)
async def index():
return open(Path(__file__).parent / "static" / "index.html").read() if (Path(__file__).parent / "static" / "index.html").exists() else _default_html()
def _default_html() -> str:
return """<!DOCTYPE html><html><head><meta charset="UTF-8"><title>fartcore</title>
<style>:root{--bg:#0f0f0f;--card:#1a1a1a;--text:#e0e0e0;--muted:#888;--accent:#3b82f6;}
body{font-family:system-ui;background:var(--bg);color:var(--text);margin:0;padding:2rem;}
.container{max-width:900px;margin:0 auto;}h1{font-size:2rem;margin-bottom:.5rem;}
.sub{color:var(--muted);margin-bottom:2rem;}
.drop-zone{border:2px dashed #444;border-radius:12px;padding:3rem;text-align:center;cursor:pointer;}
.drop-zone:hover{border-color:var(--accent);}.drop-zone input{display:none;}
.apps{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:1rem;margin-top:2rem;}
.app-card{background:var(--card);border-radius:10px;padding:1rem;border:1px solid #2a2a2a;}
.app-card h3{margin:0 0 .5rem;font-size:1rem;}.app-card .meta{font-size:.8rem;color:var(--muted);}
.app-card a{color:var(--accent);text-decoration:none;font-size:.85rem;}
.status{margin-top:1rem;font-size:.9rem;color:var(--muted);}</style></head><body>
<div class="container"><h1>fartcore</h1><p class="sub">Drop a .app, .dmg, or .zip &mdash; get a LocalSpace public URL.</p>
<div class="drop-zone" id="dz" onclick="document.getElementById('fi').click()">
<p>Drop file here or click to browse</p><input type="file" id="fi" accept=".dmg,.zip" onchange="up(this.files)"></div>
<div class="status" id="st"></div><div class="apps" id="apps"></div></div>
<script>
const dz=document.getElementById('dz');
dz.addEventListener('dragover',e=>{e.preventDefault();dz.style.borderColor='var(--accent)';});
dz.addEventListener('dragleave',()=>{dz.style.borderColor='#444';});
dz.addEventListener('drop',e=>{e.preventDefault();dz.style.borderColor='#444';up(e.dataTransfer.files);});
async function up(files){if(!files.length)return;st.textContent='Uploading...';const fd=new FormData();fd.append('file',files[0]);const r=await fetch('/api/upload',{method:'POST',body:fd});const j=await r.json();st.textContent=j.message||j.error||'Done';loadApps();}
async function loadApps(){const r=await fetch('/api/apps');const j=await r.json();const el=document.getElementById('apps');el.innerHTML='';(j.apps||[]).forEach(a=>{const d=document.createElement('div');d.className='app-card';d.innerHTML=`<h3>${a.display_name||a.name}</h3><div class="meta">${a.version||''} &middot; ${a.bundle_id||''}</div><div class="meta">${(a.size||0).toLocaleString()} bytes</div><a href="/app/${a.id}">Preview</a> | <a href="/api/download/${a.id}">Download</a>${a.localspace_url?` | <a href="${a.localspace_url}" target="_blank">LocalSpace</a>`:''}`;el.appendChild(d);});}
loadApps();
</script></body></html>"""
@app.post("/api/upload")
async def upload(file: UploadFile = File(...)):
app_id = str(uuid.uuid4())
upload_path = UPLOAD_DIR / f"{app_id}_{file.filename}"
with open(upload_path, "wb") as f:
shutil.copyfileobj(file.file, f)
extract_dest = EXTRACT_DIR / app_id
extract_dest.mkdir(exist_ok=True)
app_bundle = _extract_upload(upload_path, extract_dest)
if not app_bundle:
return JSONResponse(
status_code=422,
content={"error": "Could not extract app bundle from upload. Supported: .app folder, .zip containing .app, .dmg (macOS only)"},
)
meta = _scan_app_bundle(app_bundle)
preview_file = _find_preview_file(app_bundle)
record = {
"id": app_id,
"name": file.filename,
"display_name": meta.get("display_name", file.filename),
"bundle_id": meta.get("bundle_id"),
"version": meta.get("version"),
"bundle_version": meta.get("bundle_version"),
"executable": meta.get("executable"),
"minimum_os": meta.get("minimum_os"),
"uploaded_at": time.time(),
"size": upload_path.stat().st_size,
"upload_path": str(upload_path),
"extracted_path": str(app_bundle),
"preview_path": str(preview_file) if preview_file else None,
}
# Try to auto-register with LocalSpace
artifact_type = "dmg" if upload_path.suffix.lower() == ".dmg" else "app"
space_info = await _register_with_localspace(
record["display_name"],
app_bundle,
artifact_type,
)
if space_info:
record["localspace_url"] = space_info.get("public_url")
record["localspace_space_id"] = space_info.get("space_id")
record["localspace_slug"] = space_info.get("slug")
_store[app_id] = record
_save_db()
return {
"id": app_id,
"message": f"Extracted {meta.get('display_name', file.filename)} v{meta.get('version', '?')}",
"localspace_url": record.get("localspace_url"),
**record,
}
@app.get("/api/apps")
async def list_apps():
apps = sorted(_store.values(), key=lambda a: a["uploaded_at"], reverse=True)
return {"apps": apps}
@app.get("/app/{app_id}", response_class=HTMLResponse)
async def app_page(app_id: str):
rec = _store.get(app_id)
if not rec:
return HTMLResponse("<h1>Not found</h1>", status_code=404)
preview = rec.get("preview_path")
if preview and Path(preview).exists():
content = Path(preview).read_text(errors="replace")
return HTMLResponse(content)
return HTMLResponse(f"<h1>{rec.get('display_name', rec['name'])}</h1><p>No preview available.</p>")
@app.get("/api/preview/{app_id}/{path:path}")
async def preview_file(app_id: str, path: str):
rec = _store.get(app_id)
if not rec:
return JSONResponse({"error": "not found"}, status_code=404)
base = Path(rec["extracted_path"])
target = base / path
try:
target.resolve().relative_to(base.resolve())
except ValueError:
return JSONResponse({"error": "path traversal"}, status_code=403)
if not target.exists():
return JSONResponse({"error": "file not found"}, status_code=404)
return FileResponse(target)
@app.get("/api/download/{app_id}")
async def download(app_id: str):
rec = _store.get(app_id)
if not rec:
return JSONResponse({"error": "not found"}, status_code=404)
upload_path = Path(rec["upload_path"])
if not upload_path.exists():
return JSONResponse({"error": "file missing"}, status_code=404)
return FileResponse(upload_path, filename=rec["name"])
@app.delete("/api/apps/{app_id}")
async def delete_app(app_id: str):
rec = _store.pop(app_id, None)
if rec:
try:
shutil.rmtree(EXTRACT_DIR / app_id, ignore_errors=True)
Path(rec["upload_path"]).unlink(missing_ok=True)
except Exception:
pass
_save_db()
return {"deleted": app_id}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=7860)
UPLOADS.mkdir(exist_ok=True)
B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
def b58(v):
n = int.from_bytes(v, "big")
if n == 0: return B58[0]
s = ""
while n:
n, r = divmod(n, 58)
s = B58[r] + s
return s
def solana_kp(audio_hash):
seed = hashlib.sha512(audio_hash).digest()[:32]
try:
from nacl.signing import SigningKey
sk = SigningKey(seed)
vk = sk.verify_key
pub = bytes(vk)
priv = bytes(sk) + pub
return b58(priv), b58(pub)
except ImportError:
pub = hashlib.sha256(seed).digest()
priv = hashlib.sha256(pub).digest() + pub
return b58(priv), b58(pub)
def init_db():
with sqlite3.connect(DB) as c:
c.execute("""CREATE TABLE IF NOT EXISTS farts (
id TEXT PRIMARY KEY, created TEXT, audio_hash TEXT,
duration REAL, note_count INTEGER, midi_path TEXT,
solana_priv TEXT, solana_pub TEXT, fartscore INTEGER, report TEXT
)""")
c.commit()
init_db()
class Wav:
@staticmethod
def read(path):
with open(path, "rb") as f:
assert f.read(4) == b"RIFF"; f.read(4)
assert f.read(4) == b"WAVE"
fmt = data = b""
while True:
cid = f.read(4)
if not cid: break
sz = struct.unpack("<I", f.read(4))[0]
chunk = f.read(sz)
if cid == b"fmt ": fmt = chunk
elif cid == b"data": data = chunk
af, ch, sr, _, _, bits = struct.unpack("<HHIIHH", fmt[:16])
assert af == 1 and bits == 16
samples = struct.unpack(f"<{len(data)//2}h", data)
if ch == 2:
samples = [(samples[i]+samples[i+1])/2 for i in range(0,len(samples),2)]
return np.array(samples, np.float32)/32768.0, sr
@staticmethod
def write(path, samples, sr=16000):
data = (np.clip(samples, -1.0, 1.0) * 32767).astype(np.int16).tobytes()
fmt = struct.pack("<HHIIHH", 1, 1, sr, sr * 2, 2, 16)
with open(path, "wb") as f:
f.write(b"RIFF")
f.write(struct.pack("<I", 36 + len(data)))
f.write(b"WAVE")
f.write(b"fmt " + struct.pack("<I", 16) + fmt)
f.write(b"data" + struct.pack("<I", len(data)) + data)
class YIN:
def __init__(self, sr=16000):
self.sr = sr
self.fs = int(sr * 0.046)
self.hop = self.fs // 4
self.th = 0.15
def _diff(self, x):
n = len(x); mt = n // 2; d = np.zeros(mt)
for t in range(1, mt): d[t] = np.sum((x[:n-t] - x[t:n])**2)
return d
def _cmdf(self, d):
c = np.ones(len(d)); rs = 0.0
for t in range(1, len(d)):
rs += d[t]
c[t] = d[t] / (rs/t) if rs > 0 else 1.0
return c
def pitch(self, frame):
if len(frame) < self.fs: frame = np.pad(frame, (0, self.fs - len(frame)))
else: frame = frame[:self.fs]
frame = frame * np.hanning(len(frame))
d = self._diff(frame); c = self._cmdf(d)
est = None
for t in range(2, len(c)):
if c[t] < self.th:
while t+1 < len(c) and c[t+1] < c[t]: t += 1
est = t; break
if est is None: est = int(np.argmin(c[2:])) + 2
if 1 <= est < len(c) - 1:
a, b, g = c[est-1], c[est], c[est+1]
est += 0.5 * (a - g) / (a - 2*b + g)
return self.sr / est if est > 0 else None
def detect(self, y):
return [(i/self.sr, self.pitch(y[i:i+self.fs])) for i in range(0, len(y)-self.fs, self.hop)]
class SMF:
def __init__(self, tpq=480):
self.tpq = tpq; self.tracks = []
def add(self): self.tracks.append([]); return len(self.tracks)-1
def _vlq(self, v):
b = [v & 0x7F]; v >>= 7
while v: b.append((v & 0x7F) | 0x80); v >>= 7
return bytes(reversed(b))
def meta(self, t, d=b""): return bytes([0xFF, t, len(d)]) + d
def tempo(self, tr, us=500000): self.tracks[tr].append((0, self.meta(0x51, struct.pack(">I", us)[1:])))
def pc(self, tr, ch, p): self.tracks[tr].append((0, bytes([0xC0 | (ch & 0x0F), p & 0x7F])))
def on(self, tr, ch, n, v, d=0): self.tracks[tr].append((d, bytes([0x90 | (ch & 0x0F), n & 0x7F, v & 0x7F])))
def off(self, tr, ch, n, d=0): self.tracks[tr].append((d, bytes([0x80 | (ch & 0x0F), n & 0x7F, 0])))
def eot(self, tr, d=0): self.tracks[tr].append((d, self.meta(0x2F)))
def save(self, path):
with open(path, "wb") as f:
f.write(b"MThd" + struct.pack(">IHHH", 6, 1, len(self.tracks), self.tpq))
for ev in self.tracks:
td = b""; at = 0
for d, m in ev:
at += d
td += self._vlq(d) + m
f.write(b"MTrk" + struct.pack(">I", len(td)) + td)
@classmethod
def from_notes(cls, notes, path):
if not notes: return None
w = cls(); t = w.add()
w.tempo(t); w.pc(t, 0, 58)
bps = 120 / 60.0; tps = 480 * bps; lt = 0
for st, du, n, v in sorted(notes, key=lambda x: x[0]):
stt = int(st * tps); dut = max(1, int(du * tps))
w.on(t, 0, n, v, stt - lt); w.off(t, 0, n, dut)
lt = stt + dut
w.eot(t, 0); w.save(path); return path
def freq2midi(f): return int(np.clip(69 + 12 * np.log2(f / 440), 20, 108))
def notes_from_pitch(pitches):
notes = []; cur_note = None; cur_start = 0.0
for i, (t, f) in enumerate(pitches):
if f is None or f < 40:
if cur_note is not None:
notes.append((cur_start, t - cur_start, cur_note, 80)); cur_note = None
continue
note = freq2midi(f)
if cur_note is None or abs(note - cur_note) >= 2:
if cur_note is not None:
notes.append((cur_start, t - cur_start, cur_note, 80))
cur_note = note; cur_start = t
if cur_note is not None and len(pitches) > 0:
notes.append((cur_start, pitches[-1][0] - cur_start, cur_note, 80))
merged = []
for n in notes:
if n[1] < 0.05: continue
if merged and abs(n[0] - (merged[-1][0] + merged[-1][1])) < 0.03 and n[2] == merged[-1][2]:
merged[-1] = (merged[-1][0], merged[-1][1] + n[1], n[2], max(merged[-1][3], n[3]))
else: merged.append(n)
return merged
def analyze_audio(path):
y, sr = Wav.read(path)
duration = len(y) / sr
audio_hash = hashlib.sha256(open(path, "rb").read()).digest()
ah_hex = audio_hash.hex()
yin = YIN(sr)
pitches = yin.detect(y)
notes = notes_from_pitch(pitches)
midi_path = UPLOADS / f"{ah_hex[:16]}.mid"
SMF.from_notes(notes, midi_path)
fs = int(hashlib.sha256(ah_hex.encode()).hexdigest(), 16) % 101
priv, pub = solana_kp(audio_hash)
report = {
"audio_hash": ah_hex, "duration_sec": round(duration, 3), "sample_rate": sr,
"frame_count": len(pitches), "note_count": len(notes),
"notes": [{"start": round(s,3), "dur": round(d,3), "note": n, "vel": v} for s,d,n,v in notes[:20]],
"midi_file": str(midi_path.name), "fartscore": fs,
"solana_private": priv, "solana_public": pub,
}
with sqlite3.connect(DB) as c:
c.execute("INSERT OR REPLACE INTO farts VALUES (?,?,?,?,?,?,?,?,?,?)", (
ah_hex[:16], datetime.now(timezone.utc).isoformat(), ah_hex,
duration, len(notes), str(midi_path), priv, pub, fs, json.dumps(report)
))
c.commit()
return report
def generate_mock_wav(path, seed=42):
np.random.seed(seed)
sr = 16000
notes_hz = [261.63, 329.63, 392.00, 523.25, 440.00, 349.23, 293.66]
np.random.shuffle(notes_hz)
segments = []
for hz in notes_hz[:4]:
dur = np.random.uniform(0.3, 0.7)
t = np.linspace(0, dur, int(sr * dur), endpoint=False)
seg = np.sin(2 * np.pi * hz * t) * 0.5
seg += np.sin(2 * np.pi * hz * 2 * t) * 0.15
seg += np.sin(2 * np.pi * hz * 3 * t) * 0.08
seg += np.random.normal(0, 0.03, len(t))
env = np.ones_like(t)
fade = int(sr * 0.02)
env[:fade] = np.linspace(0, 1, fade)
env[-fade:] = np.linspace(1, 0, fade)
segments.append(seg * env)
gap = np.zeros(int(sr * 0.1))
full = []
for seg in segments:
full.extend(seg); full.extend(gap)
samples = np.array(full[:sr * 5], dtype=np.float32)
Wav.write(path, samples, sr)
return path
app = FastAPI(title="AFIP", version="3.0")
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/")
def root(): return FileResponse("static/index.html")
@app.post("/api/analyze")
async def analyze(file: UploadFile = File(...)):
if not file.filename.endswith(".wav"): raise HTTPException(400, "Only .wav")
tmp = UPLOADS / f"tmp_{int(time.time()*1000)}.wav"
with open(tmp, "wb") as f: f.write(await file.read())
try: return JSONResponse(analyze_audio(tmp))
finally: tmp.unlink(missing_ok=True)
@app.post("/api/mock")
async def mock():
seed = int(time.time() * 1000) % 0xFFFFFFFF
tmp = UPLOADS / f"mock_{seed}.wav"
generate_mock_wav(tmp, seed)
try: return JSONResponse(analyze_audio(tmp))
finally: tmp.unlink(missing_ok=True)
@app.get("/api/midi/{file_id}")
def get_midi(file_id: str):
p = UPLOADS / f"{file_id}.mid"
if not p.exists(): raise HTTPException(404, "MIDI not found")
return FileResponse(p, media_type="audio/midi", filename=f"{file_id}.mid")
@app.get("/api/history")
def history(limit: int = 20):
with sqlite3.connect(DB) as c:
c.row_factory = sqlite3.Row
rows = c.execute("SELECT * FROM farts ORDER BY created DESC LIMIT ?", (limit,)).fetchall()
return JSONResponse([dict(r) for r in rows])
@app.get("/api/leaderboard")
def leaderboard():
with sqlite3.connect(DB) as c:
c.row_factory = sqlite3.Row
rows = c.execute("SELECT id, created, fartscore, solana_pub FROM farts ORDER BY fartscore DESC LIMIT 10").fetchall()
return JSONResponse([dict(r) for r in rows])
@app.get("/api/wallet/{pubkey}")
def wallet_info(pubkey: str):
with sqlite3.connect(DB) as c:
c.row_factory = sqlite3.Row
row = c.execute("SELECT * FROM farts WHERE solana_pub = ?", (pubkey,)).fetchone()
if not row: raise HTTPException(404, "Wallet not found")
return JSONResponse(dict(row))
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", "7860")))