emresar's picture
Deploy showcase
f034b57 verified
Raw
History Blame Contribute Delete
16.3 kB
"""Minimal access-gated static server for the Sync Pilot showcase (HF Docker Space).
Serves the pre-built Vite ``dist/`` behind an ``ACCESS_KEY`` login page, mirroring
the Streamlit dashboard's gate:
* ACCESS_KEY unset → served directly (rely on the Space being private).
* ACCESS_KEY set → a login page is shown until the correct key is entered;
a signed cookie then keeps the session for 30 days.
All routes (incl. assets + catalog.json) are gated, so nothing leaks pre-auth.
"""
from __future__ import annotations
import hashlib
import io
import json
import logging
import os
import threading
import time
from datetime import datetime, timezone
import requests
from flask import Flask, Response, g, jsonify, make_response, redirect, request, send_from_directory
# Timestamped usage logging lives here (not the model-heavy backend) so it can be
# shipped without a slow backend restart. The proxy sees every /api/live/* call,
# so it's the full feature-usage trail. UTC ISO timestamps.
logging.Formatter.converter = time.gmtime
logging.basicConfig(level=logging.INFO,
format="%(asctime)sZ | %(levelname)s | %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S")
log = logging.getLogger("showcase")
ACCESS_KEY = os.getenv("ACCESS_KEY", "")
PORT = int(os.getenv("PORT", "7860"))
DIST = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dist")
# Live-tagging proxy: the showcase server forwards /api/live/* to the *private*
# backend Space, holding both the HF token (passes the platform's private-Space
# gate) and the backend's ACCESS_KEY (passes the app's compute gate). The browser
# thus needs no keys and makes only same-origin calls. Backend stays private, so
# its source + model artifacts are never exposed.
LIVE_BACKEND_URL = os.getenv("LIVE_BACKEND_URL", "").rstrip("/")
LIVE_HF_TOKEN = os.getenv("HF_TOKEN", "")
LIVE_ACCESS_KEY = os.getenv("LIVE_ACCESS_KEY", "") # the backend's ACCESS_KEY
app = Flask(__name__)
# Owner-only admin key for the usage map (set ADMIN_KEY as a Space secret to keep
# it private; if unset it falls back to the normal login gate).
ADMIN_KEY = os.getenv("ADMIN_KEY", "")
# Visitor usage, accumulated from requests (the proxy sees every /api/live/* +
# page load). Geo is looked up lazily + cached. Persisted to a HF *dataset* so it
# survives Space restarts/rebuilds (the container filesystem and memory both reset
# on restart, so a plain file wouldn't last). Loaded on startup, flushed on a
# timer. Reuses HF_TOKEN (the same secret the proxy already holds).
_USAGE: dict[str, dict] = {}
_USAGE_LOCK = threading.Lock()
_USAGE_DIRTY = {"v": False} # set on each new hit; the flush thread clears it
USAGE_DATASET = os.getenv("USAGE_DATASET", "emresar/sync-pilot-usage")
USAGE_FILE = "usage_store.json"
USAGE_FLUSH_SEC = int(os.getenv("USAGE_FLUSH_SEC", "60"))
def _now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _load_usage() -> None:
"""Load the persisted usage store from the HF dataset. Best-effort: a missing
file, missing token, or read-only token just starts the map empty."""
if not LIVE_HF_TOKEN:
log.info("usage persistence off: no HF_TOKEN")
return
try:
from huggingface_hub import hf_hub_download
path = hf_hub_download(USAGE_DATASET, USAGE_FILE, repo_type="dataset",
token=LIVE_HF_TOKEN)
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
ips = data.get("ips", {})
with _USAGE_LOCK:
for ip, rec in ips.items():
_USAGE[ip] = rec
log.info("usage store loaded from %s: %d IPs", USAGE_DATASET, len(ips))
except Exception as exc: # noqa: BLE001
log.info("usage store not loaded (%s) — starting empty", str(exc)[:120])
def _save_usage() -> None:
"""Flush the in-memory store to the HF dataset. Non-fatal on failure."""
if not LIVE_HF_TOKEN:
return
with _USAGE_LOCK:
payload = json.dumps({"ips": _USAGE, "updated": _now()},
ensure_ascii=False).encode("utf-8")
try:
from huggingface_hub import HfApi
HfApi(token=LIVE_HF_TOKEN).upload_file(
path_or_fileobj=io.BytesIO(payload), path_in_repo=USAGE_FILE,
repo_id=USAGE_DATASET, repo_type="dataset",
commit_message="usage update")
except Exception as exc: # noqa: BLE001
log.warning("usage store save failed: %s", str(exc)[:120])
def _usage_flush_loop() -> None:
while True:
time.sleep(USAGE_FLUSH_SEC)
if _USAGE_DIRTY["v"]:
_USAGE_DIRTY["v"] = False # clear before save; a hit mid-save re-dirties
_save_usage()
_USAGE_STARTED = {"v": False}
def _start_usage_persistence() -> None:
if _USAGE_STARTED["v"]:
return
_USAGE_STARTED["v"] = True
_load_usage()
threading.Thread(target=_usage_flush_loop, daemon=True).start()
def _client_ip() -> str:
return request.headers.get("X-Forwarded-For", request.remote_addr or "?").split(",")[0].strip()
def _geo(ip: str) -> dict:
"""IP → {lat, lon, city, country} via ip-api.com (free, no key, HTTP/port 80
which HF egress allows). Returns {} on failure/private IP."""
try:
r = requests.get(f"http://ip-api.com/json/{ip}",
params={"fields": "status,lat,lon,city,country"}, timeout=4)
d = r.json()
if d.get("status") == "success":
return {"lat": d["lat"], "lon": d["lon"], "city": d.get("city"), "country": d.get("country")}
except Exception: # noqa: BLE001
pass
return {}
def _admin_token() -> str:
return hashlib.sha256(f"sync_pilot_admin_{ADMIN_KEY}".encode()).hexdigest()[:24]
def _admin_ok() -> bool:
"""Owner-only gate for /admin/*. Prefers a signed cookie set via /admin/login
(no key in the URL); still accepts ?key= as a fallback for scripts/curl. With
ADMIN_KEY unset it degrades to the normal showcase login cookie."""
if ADMIN_KEY:
return (request.cookies.get("sp_admin") == _admin_token()
or request.args.get("key") == ADMIN_KEY)
return _authed()
@app.before_request
def _usage_start() -> None:
g._t0 = time.perf_counter()
@app.after_request
def _usage_log(resp: Response) -> Response:
"""One timestamped line per meaningful request + record the visitor IP for the
usage map. Static-asset chatter (.js/.css/.woff) and admin polls are skipped."""
try:
p = request.path
leaf = p.rsplit("/", 1)[-1]
if (p.startswith("/api/live/") or p == "/" or "." not in leaf) and not p.startswith("/admin"):
dt = time.perf_counter() - getattr(g, "_t0", time.perf_counter())
ip = _client_ip()
log.info("%s %s -> %s %.2fs ip=%s", request.method, p, resp.status_code, dt, ip)
if ip and ip != "?" and not ip.startswith(("10.", "192.168.", "127.")):
with _USAGE_LOCK:
rec = _USAGE.setdefault(ip, {"ip": ip, "count": 0, "first": _now(), "geo": None})
rec["count"] += 1
rec["last"] = _now()
_USAGE_DIRTY["v"] = True
except Exception: # noqa: BLE001 - logging must never break a response
pass
return resp
def _live_headers() -> dict[str, str]:
h = {"X-Access-Key": LIVE_ACCESS_KEY}
if LIVE_HF_TOKEN:
h["Authorization"] = f"Bearer {LIVE_HF_TOKEN}"
return h
def _proxy(method: str, path: str, **kw) -> Response:
if not _authed():
return jsonify({"detail": "unauthorized"}), 401 # type: ignore[return-value]
if not LIVE_BACKEND_URL:
return jsonify({"detail": "live backend not configured"}), 503 # type: ignore[return-value]
try:
r = requests.request(method, f"{LIVE_BACKEND_URL}{path}",
headers=_live_headers(), timeout=35, **kw)
except requests.RequestException as exc:
return jsonify({"detail": f"backend unreachable: {exc}"}), 502 # type: ignore[return-value]
return Response(r.content, status=r.status_code,
content_type=r.headers.get("content-type", "application/json"))
@app.post("/api/live/tag")
def live_tag() -> Response:
return _proxy("POST", "/tag", json=request.get_json(force=True, silent=True) or {})
@app.get("/api/live/status/<job_id>")
def live_status(job_id: str) -> Response:
return _proxy("GET", f"/status/{job_id}")
@app.post("/api/live/sync")
def live_sync() -> Response:
return _proxy("POST", "/sync", json=request.get_json(force=True, silent=True) or {})
@app.post("/api/live/tag-file")
def live_tag_file() -> Response:
"""Proxy a multipart audio upload to the private backend's /tag-file."""
if not _authed():
return jsonify({"detail": "unauthorized"}), 401 # type: ignore[return-value]
if not LIVE_BACKEND_URL:
return jsonify({"detail": "live backend not configured"}), 503 # type: ignore[return-value]
f = request.files.get("file")
if f is None:
return jsonify({"detail": "no file uploaded"}), 422 # type: ignore[return-value]
try:
r = requests.post(f"{LIVE_BACKEND_URL}/tag-file", headers=_live_headers(),
files={"file": (f.filename, f.stream, f.mimetype)}, timeout=120)
except requests.RequestException as exc:
return jsonify({"detail": f"backend unreachable: {exc}"}), 502 # type: ignore[return-value]
return Response(r.content, status=r.status_code,
content_type=r.headers.get("content-type", "application/json"))
def _token() -> str:
return hashlib.sha256(f"sync_pilot_showcase_{ACCESS_KEY}".encode()).hexdigest()[:24]
def _authed() -> bool:
return not ACCESS_KEY or request.cookies.get("sp_auth") == _token()
_LOGIN = """<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sync Pilot — access</title>
<style>
body{{margin:0;height:100vh;display:grid;place-items:center;background:#0b0a0f;
font-family:ui-sans-serif,system-ui,sans-serif;color:#ece9f1}}
.card{{width:340px;padding:36px 32px;border:1px solid #2a2533;border-radius:16px;
background:#15131c;text-align:center}}
.mark{{width:44px;height:44px;margin:0 auto 14px;border-radius:12px;
background:linear-gradient(135deg,#a78bfa,#f0abfc 48%,#fbbf24)}}
h1{{font-size:1.25rem;margin:0 0 2px}} p{{color:#9a93a8;font-size:.85rem;margin:0 0 20px}}
input{{width:100%;box-sizing:border-box;padding:10px 12px;border-radius:9px;
border:1px solid #2a2533;background:#1e1a29;color:#ece9f1;font-size:.95rem}}
button{{width:100%;margin-top:10px;padding:10px;border:0;border-radius:9px;cursor:pointer;
background:linear-gradient(135deg,#a78bfa,#f0abfc);color:#1a1320;font-weight:600}}
.err{{color:#f87171;font-size:.8rem;margin-top:10px;min-height:1em}}
</style></head><body>
<form class="card" method="post" action="/login">
<div class="mark"></div>
<h1>Sync Pilot</h1><p>Catalog Intelligence · access key required</p>
<input type="password" name="key" placeholder="Access key" autofocus autocomplete="current-password">
<button type="submit">Enter</button>
<div class="err">{err}</div>
</form>
</body></html>"""
@app.post("/login")
def login():
if ACCESS_KEY and request.form.get("key") == ACCESS_KEY:
resp = make_response(redirect("/"))
# SameSite=None; Secure so the cookie survives the cross-origin iframe
# that huggingface.co embeds the *.hf.space app in (Lax is dropped there).
resp.set_cookie("sp_auth", _token(), max_age=60 * 60 * 24 * 30,
httponly=True, samesite="None", secure=True)
return resp
return redirect("/?error=1")
_MAP_HTML = """<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sync Pilot — kullanım haritası</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
html,body{margin:0;height:100%;background:#0b0a0f;font-family:ui-sans-serif,system-ui,sans-serif;color:#ece9f1}
#bar{position:fixed;z-index:1000;top:10px;left:10px;background:#15131cdd;border:1px solid #2a2533;
border-radius:10px;padding:8px 12px;font-size:.85rem}
#bar b{color:#a78bfa}
#map{height:100%;width:100%}
</style></head><body>
<div id="bar">Kullanım haritası · <b id="ips">0</b> IP · <b id="hits">0</b> istek · <span id="upd">—</span></div>
<div id="map"></div>
<script>
const key=new URLSearchParams(location.search).get("key")||"";
const map=L.map("map",{worldCopyJump:true}).setView([39,35],3);
L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
{attribution:"&copy; OpenStreetMap &copy; CARTO",subdomains:"abcd",maxZoom:19}).addTo(map);
const layer=L.layerGroup().addTo(map);
async function tick(){
try{
const r=await fetch("/admin/usage"+(key?("?key="+encodeURIComponent(key)):""));
if(!r.ok)return;
const d=await r.json();
ips.textContent=d.total_ips; hits.textContent=d.total_hits; upd.textContent="güncellendi "+(d.updated||"");
layer.clearLayers();
for(const p of d.points){
const g=p.geo; if(!g||g.lat==null)continue;
L.circleMarker([g.lat,g.lon],{radius:6+Math.min(20,Math.log2(p.count+1)*4),
color:"#a78bfa",fillColor:"#a78bfa",fillOpacity:.5,weight:1})
.bindPopup("<b>"+(g.city||"?")+", "+(g.country||"?")+"</b><br>"+p.ip+"<br>"+p.count+" istek<br>son: "+(p.last||""))
.addTo(layer);
}
}catch(e){}
}
tick(); setInterval(tick,30000);
</script></body></html>"""
@app.route("/admin/usage")
def admin_usage():
if not _admin_ok():
return jsonify({"detail": "unauthorized"}), 401 # type: ignore[return-value]
with _USAGE_LOCK:
items = [dict(r) for r in _USAGE.values()]
for r in items: # lazily geolocate new IPs, cached back into _USAGE
if r.get("geo") is None:
geo = _geo(r["ip"])
with _USAGE_LOCK:
if r["ip"] in _USAGE:
_USAGE[r["ip"]]["geo"] = geo
r["geo"] = geo
_USAGE_DIRTY["v"] = True # persist the resolved geo on next flush
return jsonify({"updated": _now(), "total_ips": len(items),
"total_hits": sum(r["count"] for r in items),
"points": [r for r in items if r.get("geo")]})
_ADMIN_LOGIN = _LOGIN.replace('action="/login"', 'action="/admin/login"').replace(
"Catalog Intelligence · access key required", "Usage map · admin key required")
@app.get("/admin/login")
def admin_login_form():
if not ADMIN_KEY:
return Response("ADMIN_KEY not set — the admin map uses the normal login gate.",
mimetype="text/plain")
return _ADMIN_LOGIN.format(err="Invalid key" if request.args.get("error") else ""), 200
@app.post("/admin/login")
def admin_login():
if ADMIN_KEY and request.form.get("key") == ADMIN_KEY:
resp = make_response(redirect("/admin/map"))
resp.set_cookie("sp_admin", _admin_token(), max_age=60 * 60 * 24 * 30,
httponly=True, samesite="None", secure=True)
return resp
return redirect("/admin/login?error=1")
@app.route("/admin/map")
def admin_map():
if not _admin_ok():
return redirect("/admin/login") # one-time key entry → cookie, no key in URL
return Response(_MAP_HTML, mimetype="text/html")
@app.route("/", defaults={"path": ""})
@app.route("/<path:path>")
def serve(path: str):
if not _authed():
err = "Invalid key" if request.args.get("error") else ""
return _LOGIN.format(err=err), 200
full = os.path.join(DIST, path)
if path and os.path.isfile(full):
return send_from_directory(DIST, path)
return send_from_directory(DIST, "index.html") # SPA fallback
# Start dataset-backed usage persistence at import (runs under gunicorn's worker
# and under `python app.py`). Single worker (Dockerfile -w 1) → one consistent store.
_start_usage_persistence()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=PORT)