Spaces:
Sleeping
Sleeping
File size: 15,688 Bytes
9b67768 a32e5a5 dba6bd0 a32e5a5 3babdb9 dba6bd0 a32e5a5 dba6bd0 a32e5a5 dba6bd0 a32e5a5 dba6bd0 a32e5a5 dba6bd0 a32e5a5 dba6bd0 a32e5a5 dba6bd0 a32e5a5 dba6bd0 a32e5a5 dba6bd0 a32e5a5 dba6bd0 a32e5a5 dba6bd0 a32e5a5 dba6bd0 a32e5a5 79a6914 a32e5a5 5c0d872 dba6bd0 5c0d872 dba6bd0 5c0d872 79a6914 dba6bd0 a32e5a5 79a6914 dba6bd0 5c0d872 dba6bd0 a32e5a5 dba6bd0 a32e5a5 dba6bd0 a32e5a5 dba6bd0 a32e5a5 dba6bd0 a32e5a5 dba6bd0 a32e5a5 dba6bd0 a32e5a5 9b67768 dba6bd0 3babdb9 9b67768 a32e5a5 dba6bd0 9b67768 a32e5a5 dba6bd0 9b67768 dba6bd0 9b67768 a32e5a5 9b67768 a32e5a5 dba6bd0 a32e5a5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 | import os
import sys
import json
import sqlite3
import time
import asyncio
import shutil
import uuid
import threading
from fastapi import FastAPI, UploadFile, File, Form, Header, HTTPException, Depends, Query, Request
from fastapi.responses import JSONResponse, FileResponse, StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from typing import Optional
from contextlib import asynccontextmanager
STORAGE_DIR = "/data" if os.path.exists("/data") else "."
LOCAL_DB_PATH = os.path.join(STORAGE_DIR, "metadata.db")
WEBHOOK_TOKEN = os.getenv("WEBHOOK_TOKEN", "YOUR_SECURE_PASSWORD_HERE")
DATABASE_URL = os.getenv("DATABASE_URL")
USE_POSTGRES = DATABASE_URL is not None
DB_WRITE_LOCK = threading.Lock()
DB_POOL = None
_thread_local = threading.local()
# βββ DATABASE HELPERS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def get_sqlite_conn():
if not hasattr(_thread_local, "conn"):
_thread_local.conn = sqlite3.connect(LOCAL_DB_PATH, timeout=60.0, check_same_thread=False)
_thread_local.conn.execute("PRAGMA journal_mode = WAL;")
_thread_local.conn.execute("PRAGMA synchronous = NORMAL;")
return _thread_local.conn
def init_db_pool():
global DB_POOL
if USE_POSTGRES:
from psycopg2.pool import ThreadedConnectionPool
DB_POOL = ThreadedConnectionPool(5, 20, DATABASE_URL)
conn = DB_POOL.getconn()
try:
with conn.cursor() as cursor:
cursor.execute("""
CREATE TABLE IF NOT EXISTS images (
id SERIAL PRIMARY KEY,
room_id TEXT,
session_id TEXT,
batch_index INTEGER,
prompt TEXT,
filename TEXT,
timestamp REAL
);
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_room ON images(room_id);")
conn.commit()
except Exception:
conn.rollback()
raise
finally:
DB_POOL.putconn(conn)
else:
conn = sqlite3.connect(LOCAL_DB_PATH)
cursor = conn.cursor()
try:
conn.execute("PRAGMA journal_mode = WAL;")
conn.execute("BEGIN IMMEDIATE;")
cursor.execute("""
CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id TEXT,
session_id TEXT,
batch_index INTEGER,
prompt TEXT,
filename TEXT,
timestamp REAL
)
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_room ON images(room_id);")
conn.commit()
except Exception:
conn.rollback()
raise
finally:
cursor.close()
conn.close()
def execute_thread_query(query_func, *args, is_write=False):
if USE_POSTGRES:
conn = DB_POOL.getconn()
try:
with conn.cursor() as cursor:
result = query_func(cursor, *args)
conn.commit()
return result
except Exception:
try: conn.rollback()
except Exception: pass
raise
finally:
DB_POOL.putconn(conn)
else:
conn = get_sqlite_conn()
if is_write:
with DB_WRITE_LOCK:
conn.execute("BEGIN IMMEDIATE;")
cursor = conn.cursor()
try:
result = query_func(cursor, *args)
conn.commit()
return result
except Exception:
try: conn.rollback()
except Exception: pass
raise
finally:
cursor.close()
else:
cursor = conn.cursor()
try:
return query_func(cursor, *args)
finally:
cursor.close()
def _fetch_expired_worker(cursor, cutoff_time: float, limit_val: int):
placeholder = "%s" if USE_POSTGRES else "?"
query = f"SELECT filename FROM images WHERE timestamp < {placeholder} LIMIT {placeholder};"
cursor.execute(query, (cutoff_time, limit_val))
return [r[0] for r in cursor.fetchall()]
def _drop_expired_worker(cursor, filenames: list):
if not filenames: return
placeholder = "%s" if USE_POSTGRES else "?"
placeholders = ",".join([placeholder] * len(filenames))
query = f"DELETE FROM images WHERE filename IN ({placeholders});"
cursor.execute(query, tuple(filenames))
def _read_history_worker(cursor, room_id: str):
placeholder = "%s" if USE_POSTGRES else "?"
cursor.execute(
f"SELECT filename FROM images WHERE room_id = {placeholder} ORDER BY timestamp DESC;",
(room_id,)
)
return cursor.fetchall()
def _insert_image_worker(cursor, room_id: str, session_id: str, batch_index: int, prompt: str, filename: str):
placeholder = "%s" if USE_POSTGRES else "?"
query = (
f"INSERT INTO images (room_id, session_id, batch_index, prompt, filename, timestamp) "
f"VALUES ({placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder});"
)
cursor.execute(query, (room_id, session_id, batch_index, prompt, filename, time.time()))
def disk_clear_files(expired_files: list):
for old_file in expired_files:
file_path = os.path.join(STORAGE_DIR, old_file)
try:
if os.path.exists(file_path): os.remove(file_path)
except Exception as e:
print(f"[Cleanup File Error] Storage unlink failure {old_file}: {e}", file=sys.stderr)
# βββ CLEANUP LOOP βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
async def decoupled_cleanup_loop():
error_backoff = 1.0
while True:
try:
await asyncio.sleep(900)
cutoff_time = time.time() - (2 * 3600)
while True:
expired_files = await asyncio.to_thread(
execute_thread_query, _fetch_expired_worker, cutoff_time, 500, is_write=False
)
if not expired_files: break
await asyncio.to_thread(disk_clear_files, expired_files)
await asyncio.to_thread(execute_thread_query, _drop_expired_worker, expired_files, is_write=True)
await asyncio.sleep(0.1)
error_backoff = 1.0
except Exception as e:
print(f"[Cleanup Master Loop Error]: {e}. Backing off for {error_backoff}s...", file=sys.stderr)
await asyncio.sleep(error_backoff)
error_backoff = min(error_backoff * 2, 300.0)
# βββ EVENT BROKER (SSE) βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Manages in-memory SSE queues, one per room. Each connected browser tab holds
# one asyncio.Queue. Single-tab enforcement: when a new client subscribes to a
# room that already has a listener, the old queue receives a "superseded" event
# before being replaced, causing the old EventSource to close itself.
class EventBroker:
def __init__(self):
# room_id β single active Queue (one subscriber per room enforced)
self._queues: dict[str, asyncio.Queue] = {}
async def subscribe(self, room_id: str) -> asyncio.Queue:
"""Register a new subscriber. Evicts any existing subscriber for this room."""
if room_id in self._queues:
old_q = self._queues[room_id]
try:
# Tell the old tab to close its EventSource and stop retrying.
await old_q.put({"event": "superseded", "data": {}})
except Exception:
pass # old queue may already be garbage-collected
q = asyncio.Queue()
self._queues[room_id] = q
return q
def unsubscribe(self, room_id: str, q: asyncio.Queue):
"""Remove a subscriber only if it is still the current one for this room."""
if self._queues.get(room_id) is q:
del self._queues[room_id]
async def broadcast(self, room_id: str, event: str, data: dict):
"""Push an event to the active subscriber for this room, if any."""
q = self._queues.get(room_id)
if q:
try:
await q.put({"event": event, "data": data})
except Exception:
pass
broker = EventBroker()
# βββ APP LIFESPAN βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db_pool()
cleanup_task = asyncio.create_task(decoupled_cleanup_loop())
yield
cleanup_task.cancel()
if USE_POSTGRES and DB_POOL: DB_POOL.closeall()
app = FastAPI(lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://jiyuudayo-image.prankmoneyku.workers.dev",
"https://api-gateway.prankmoneyku.workers.dev",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# βββ AUTH βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def verify_webhook_token(x_webhook_token: Optional[str] = Header(None)):
if not x_webhook_token or x_webhook_token != WEBHOOK_TOKEN:
raise HTTPException(status_code=403, detail="Unauthorized upload source")
# βββ ROUTES βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@app.get("/")
async def root_status():
return {"status": "online", "service": "Webhook Receiver Backend"}
@app.get("/stream/{room_id}")
async def stream_events(room_id: str):
"""
Server-Sent Events endpoint. Each room_id supports one active subscriber;
connecting a second client (new tab) evicts the first via a 'superseded' event.
A ': ping' heartbeat is emitted every 15 seconds to keep both the Cloudflare
Worker proxy connection and the HF infrastructure socket alive.
"""
async def generator():
q = await broker.subscribe(room_id)
try:
while True:
try:
msg = await asyncio.wait_for(q.get(), timeout=15.0)
event_name = msg["event"]
event_data = json.dumps(msg["data"])
yield f"event: {event_name}\ndata: {event_data}\n\n"
# After delivering superseded, stop this generator β the client
# will close its EventSource on receipt and won't reconnect.
if event_name == "superseded":
return
except asyncio.TimeoutError:
# Heartbeat β keeps the Cloudflare Worker's idle-subrequest
# timeout from closing the proxied connection. SSE comment lines
# (starting with ':') are valid per spec and ignored by clients.
yield ": ping\n\n"
finally:
broker.unsubscribe(room_id, q)
return StreamingResponse(
generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # disable nginx response buffering on HF side
}
)
@app.post("/webhook", dependencies=[Depends(verify_webhook_token)])
async def receive_webhook(
file: UploadFile = File(...),
x_room_id: str = Header(...),
x_session_id: str = Form(...),
x_batch_index: int = Form(...),
x_image_prompt: str = Form(...),
):
await file.seek(0)
secure_uuid = uuid.uuid4().hex
unique_filename = f"room_{x_room_id}_{secure_uuid}_{x_batch_index}.png"
full_path = os.path.join(STORAGE_DIR, unique_filename)
try:
def save_to_nas():
with open(full_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
await asyncio.to_thread(save_to_nas)
await asyncio.to_thread(
execute_thread_query,
_insert_image_worker,
x_room_id, x_session_id, x_batch_index, x_image_prompt, unique_filename,
is_write=True
)
except Exception as e:
if os.path.exists(full_path): os.remove(full_path)
raise HTTPException(status_code=500, detail=f"Pipeline ingestion failure: {str(e)}")
# Broadcast to any connected SSE client for this room.
# Fire-and-forget via create_task β the GPU script gets its 200 OK immediately
# regardless of whether a browser tab is currently subscribed.
asyncio.create_task(broker.broadcast(x_room_id, "new_image", {
"filename": unique_filename,
"room_id": x_room_id,
"session_id": x_session_id,
"batch_index": x_batch_index,
"prompt": x_image_prompt,
"task_id": f"task-{secure_uuid}",
}))
return {"status": "success", "saved_as": unique_filename}
@app.post("/registry-webhook", dependencies=[Depends(verify_webhook_token)])
async def registry_webhook(request: Request):
"""
Receives registry updates from the Cloudflare Worker (forwarded from
GPU scripts) and broadcasts them via SSE to any connected browser tab
subscribed to this room. Fire-and-forget β the GPU script's 200 OK
is not delayed by SSE delivery.
"""
body = await request.json()
room_id = body.get("room_id", "")
if not room_id:
return JSONResponse(content={"error": "Missing room_id"}, status_code=400)
asyncio.create_task(broker.broadcast(room_id, "registry_update", {
"provider": body.get("provider", ""),
"cached_models": body.get("cached_models", []),
"total_space": body.get("total_space", 0),
"used_space": body.get("used_space", 0),
}))
return {"ok": True}
@app.get("/history")
async def get_history(x_room_id: str = Header(...)):
rows = await asyncio.to_thread(execute_thread_query, _read_history_worker, x_room_id, is_write=False)
history = {}
for idx, (filename,) in enumerate(rows):
fake_id = f"task-{idx}"
history[fake_id] = {
"outputs": {
"9": {
"images": [{"filename": filename, "type": "output", "subfolder": ""}]
}
}
}
return JSONResponse(content=history)
@app.get("/view")
async def view_image(filename: str = Query(...), room_id: str = Query(...)):
safe_path = os.path.basename(filename)
if not safe_path.startswith(f"room_{room_id}_") or not safe_path.endswith(".png"):
raise HTTPException(status_code=403, detail="Access denied")
full_path = os.path.join(STORAGE_DIR, safe_path)
if os.path.exists(full_path):
return FileResponse(full_path, media_type="image/png")
return JSONResponse(content={"error": "Not Found"}, status_code=404) |