""" YapStation Backend Server FastAPI server for real-time chat and social platform """ import asyncio import logging import os import re import sys import time from pathlib import Path from dotenv import load_dotenv # Redact JWT tokens from uvicorn access logs (e.g. WebSocket URLs with ?token=...) class _RedactTokenFilter(logging.Filter): _pattern = re.compile(r'([\?&]token=)[^"\s&]+') def filter(self, record: logging.LogRecord) -> bool: record.msg = self._pattern.sub(r'\1[REDACTED]', str(record.msg)) return True logging.getLogger("uvicorn.access").addFilter(_RedactTokenFilter()) from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect, Response, Depends, Cookie, Body, Query from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware env_path = Path(__file__).parent.parent / ".env" load_dotenv(env_path) import engine import engineHelper as coreHelper import engine_user import engine_bitai import jwt import mail from db import get_client, get_service_client core = engine.Engine() from datetime import datetime, timedelta, timezone app = FastAPI() @app.exception_handler(Exception) async def global_exception_handler(request: Request, exc: Exception): if isinstance(exc, engine_user.PasswordResetUnavailableError): return JSONResponse( {"status": "error", "message": str(exc)}, status_code=503, ) return JSONResponse({"status": "fail", "message": str(exc)}, status_code=500) # Add CORS middleware FRONTEND_URL = os.environ.get("FRONTEND_URL", "https://yap-station.vercel.app") app.add_middleware( CORSMiddleware, allow_origins=[ FRONTEND_URL, "http://localhost:3000", "http://127.0.0.1:3000", ], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.middleware("http") async def add_no_cache_headers(request: Request, call_next): response = await call_next(request) if request.url.path.startswith("/api/"): response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" response.headers["Pragma"] = "no-cache" response.headers["Expires"] = "0" return response JWT_SECRET = os.environ.get("JWT_SECRET", "default_secret_key_if_not_found") # Session cookie: browsers reject Secure cookies on plain HTTP. Local dev uses http://localhost:3000 # (Next proxy). Set SESSION_COOKIE_SECURE=true in production (.env / host secrets) when using HTTPS. SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "").strip().lower() in ( "1", "true", "yes", ) SESSION_COOKIE_SAMESITE = "none" if SESSION_COOKIE_SECURE else "lax" from typing import Optional from schemas.dtos import ( FeedRequest, UserPostsRequest, build_feed_response, build_post_detail_response, build_station_get_response, build_user_posts_response, ) JWT_ALGORITHM = "HS256" BASE = os.path.dirname(__file__) FRONTEND = os.path.join(BASE, "../frontend-next/public") if os.path.exists(FRONTEND): app.mount("/static", StaticFiles(directory=FRONTEND), name="static") else: # Fallback for production where frontend is hosted separately os.makedirs("static_dummy", exist_ok=True) app.mount("/static", StaticFiles(directory="static_dummy"), name="static") def normalize_avatar_for_response(avatar: str | None) -> str: """Make DB avatar values usable in (data URL, absolute http(s), or /static/…).""" if not avatar: return "" a = str(avatar).strip() if a.startswith("data:image/") or a.startswith("http://") or a.startswith("https://"): return a if a.startswith("/static"): return a compact = "".join(a.split()) if len(compact) >= 100 and re.fullmatch(r"[A-Za-z0-9+/=]+", compact): if compact.startswith("iVBOR"): mime = "image/png" elif compact.startswith("/9j/"): mime = "image/jpeg" else: mime = "image/png" return f"data:{mime};base64,{compact}" path = a.replace("../../frontend/", "").lstrip("/") return f"/static/{path}" if path else "" from engineHelper import upload_media_to_cloudinary connections = coreHelper.connections _conv_cache: dict = {} # username -> (timestamp, data) CONV_CACHE_TTL = 30 # seconds last_seen_ping = coreHelper.last_seen_ping broadcast_online = coreHelper.broadcast_online async def notify_station_members_ws(station_name: str, payload: dict) -> None: """Deliver a WS payload to each connected station member (includes admin).""" try: members = await asyncio.to_thread(core.get_station_members, station_name, "") except Exception: members = [] seen: set[str] = set() for u in members or []: if not u: continue lk = str(u).lower() if lk in seen: continue seen.add(lk) await coreHelper.send_to_user(u, payload, connections) async def broadcast_station_post_ws(payload: dict) -> None: """Station posts appear on Discover for many viewers — fan-out to all connections.""" await coreHelper.broadcast_to_all(payload) async def touch_station_catalog_ws(station_name: str) -> None: """Light broadcast so browse pages (yap stations list, profile stations) can refresh.""" await coreHelper.broadcast_to_all( {"type": "station_catalog_refresh", "stationName": station_name} ) def invalidate_conv_cache(username: str): if username in _conv_cache: del _conv_cache[username] def invalidate_conv_cache_for_all(usernames: list): for u in usernames: invalidate_conv_cache(u) @app.get("/") def home(): return {"status": "success", "message": "YapStation API is running"} @app.get("/login.html") def login_info(): return {"status": "success", "message": "YapStation API is running"} @app.get("/api/user/get_username") async def get_user_accounts(input: str): if not input or "@" not in input: return {"email": "", "usernames": [], "count": 0} result = await asyncio.to_thread(core.get_username, input) count = int(result.get("count", "0")) if count > 0: usernames_str = result.get("usernames", "") usernames = [u.strip() for u in usernames_str.split(",") if u.strip()] return {"email": result.get("email", ""), "usernames": usernames, "count": count} return {"email": result.get("email", ""), "usernames": [], "count": 0} @app.post("/api/check-email") async def check_email(req: Request): data = await req.json() username = data.get("username", "") if not username or "@" not in username: return {"email": "", "usernames": [], "count": 0} result = await asyncio.to_thread(core.get_username, username) count = int(result.get("count", "0")) if count > 0: usernames_str = result.get("usernames", "") usernames = [u.strip() for u in usernames_str.split(",") if u.strip()] return {"email": result.get("email", ""), "usernames": usernames, "count": count} return {"email": result.get("email", ""), "usernames": [], "count": 0} @app.get("/api/admin/online") def get_online_users(username: str): if not core.is_developer(username): return JSONResponse({"status": "error", "message": "Unauthorized"}, status_code=403) now = time.time() online_info = [] for user, sockets in list(coreHelper.connections.items()): if not sockets: continue last_seen = coreHelper.last_seen_ping.get(user, 0) online_info.append({ "username": user, "seconds_ago": round(now - last_seen, 1) if last_seen else "unknown", "connections": len(sockets) }) return {"status": "success", "online": online_info, "total": len(online_info)} @app.get("/api/admin/users") def get_admin_users(username: str): if not core.is_developer(username): return JSONResponse({"status": "error", "message": "Unauthorized"}, status_code=403) try: users = core.get_all_users_detailed() for u in users: if u.get("avatar"): u["avatar"] = normalize_avatar_for_response(u["avatar"]) return {"status": "success", "users": users} except Exception as e: return JSONResponse({"status": "error", "message": str(e)}, status_code=500) @app.post("/api/admin/force_delete_user") async def admin_delete_user(data: dict): admin_user = data.get("admin_username") target_user = data.get("target_username") if not await asyncio.to_thread(core.is_developer, admin_user): return JSONResponse({"status": "error", "message": "Unauthorized"}, status_code=403) if not target_user: return {"status": "fail", "message": "Target username required"} if await asyncio.to_thread(core.is_developer, target_user): return JSONResponse({"status": "error", "message": "Cannot purge a developer account"}, status_code=403) try: # 1. Evict via WebSocket first await coreHelper.evict_user_ws(target_user) # 2. Hard delete from DB success = await asyncio.to_thread(core.delete_user, target_user) if success: return {"status": "success", "message": f"User {target_user} has been purged."} return {"status": "fail", "message": "Database deletion failed"} except Exception as e: return JSONResponse({"status": "error", "message": str(e)}, status_code=500) @app.get("/api/post/{post_id}") def get_post_route(post_id: int, viewer: Optional[str] = None): try: post = core.get_post(post_id, viewer) if not post: return JSONResponse({"status": "fail", "message": "Post not found"}, status_code=404) if isinstance(post, dict) and post.get("restricted"): return JSONResponse({"status": "restricted", "username": post["username"]}, status_code=403) return build_post_detail_response(post) except Exception as e: return JSONResponse({"status": "fail", "message": str(e)}, status_code=500) @app.get("/api/admin/stations") def get_admin_stations(username: str): if not core.is_developer(username): return JSONResponse({"status": "error", "message": "Unauthorized"}, status_code=403) try: stations = core.get_all_stations() return {"status": "success", "stations": stations} except Exception as e: return JSONResponse({"status": "error", "message": str(e)}, status_code=500) @app.get("/api/admin/developers") async def get_developers(username: str = ""): if not await asyncio.to_thread(core.is_developer, username): return JSONResponse({"status": "error"}, status_code=403) from db import get_service_client as _gsc rows = _gsc().table("developers").select("username").execute() return {"developers": [r["username"] for r in rows.data]} @app.post("/api/admin/force_delete_station") async def admin_delete_station(data: dict): admin_user = data.get("admin_username") target_station = data.get("target_station") if not await asyncio.to_thread(core.is_developer, admin_user): return JSONResponse({"status": "error", "message": "Unauthorized"}, status_code=403) if not target_station: return {"status": "fail", "message": "Target station name required"} try: success = await asyncio.to_thread(core.force_delete_station, target_station) if success: # Notify all users that the station is gone payload = {"type": "station_deleted", "stationName": target_station} await coreHelper.broadcast_to_all(payload) return {"status": "success", "message": f"Station {target_station} has been purged."} return {"status": "fail", "message": "Database deletion failed"} except Exception as e: return JSONResponse({"status": "error", "message": str(e)}, status_code=500) def create_jwt_token(username: str) -> str: payload = { "sub": username, "exp": datetime.now(timezone.utc) + timedelta(days=7), "iat": datetime.now(timezone.utc), } return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) @app.post("/api/login") async def login(req: Request, response: Response): data = await req.json() username = data.get("username") password = data.get("password") # If username doesn't contain @, it's already a username if username and "@" not in username: if await asyncio.to_thread(core.login, username, password): token = create_jwt_token(username) response.set_cookie( key="yap_session", value=token, httponly=True, max_age=7 * 24 * 60 * 60, # 7 days samesite=SESSION_COOKIE_SAMESITE, secure=SESSION_COOKIE_SECURE, path="/" ) return {"status": "success", "username": username, "token": token} return {"status": "fail"} # If username is an email, get the accounts if username and "@" in username: result = await asyncio.to_thread(core.get_username, username) if int(result.get("count", "0")) == 0: return {"status": "fail"} usernames_str = result.get("usernames", "") usernames = usernames_str.split(",") if usernames_str else [] # If password provided, try first account if password: username = usernames[0] if usernames else None if username and await asyncio.to_thread(core.login, username, password): token = create_jwt_token(username) response.set_cookie( key="yap_session", value=token, httponly=True, max_age=7 * 24 * 60 * 60, # 7 days samesite=SESSION_COOKIE_SAMESITE, secure=SESSION_COOKIE_SECURE, path="/" ) return {"status": "success", "username": username, "token": token} return {"status": "fail"} # No password = return usernames for dropdown return {"status": "dropdown", "usernames": usernames} return {"status": "fail"} @app.get("/register") def register_page(): return FileResponse(os.path.join(FRONTEND, "register.html")) @app.get("/terms") def terms_page(): return FileResponse(os.path.join(FRONTEND, "terms.html")) @app.post("/api/register") async def register(data: dict, response: Response): try: theme = data.get("theme", 5) username = data["username"] await asyncio.to_thread( core.register_user, data["firstName"], data["lastName"], data["gender"], username, data["email"], data["password"], theme, ) token = create_jwt_token(username) response.set_cookie( key="yap_session", value=token, httponly=True, max_age=7 * 24 * 60 * 60, # 7 days samesite=SESSION_COOKIE_SAMESITE, secure=SESSION_COOKIE_SECURE, path="/" ) return {"status": "success", "username": username, "token": token} except Exception as e: return {"status": "error", "message": str(e)} @app.post("/api/auth/forgot-password") async def forgot_password(data: dict): """Send reset email for a username (after client resolves email→username if needed).""" username = (data.get("username") or "").strip() generic_ok = { "status": "success", "message": "If an account exists for that username, we sent reset instructions to its email.", } if not username: return JSONResponse( {"status": "error", "message": "Username required"}, status_code=400 ) if not await asyncio.to_thread(core.user_exists, username): return generic_ok user_row = await asyncio.to_thread(core.get_user_by_username, username) email_addr = (user_row.get("email") or "").strip() if not email_addr: return generic_ok token_plain = await asyncio.to_thread(core.create_password_reset_token, username) if not token_plain: return JSONResponse( {"status": "error", "message": "Could not create reset token"}, status_code=500 ) raw_base = os.environ.get( "FRONTEND_URL", os.environ.get("NEXT_PUBLIC_SITE_URL", "http://localhost:3000"), ).strip() if raw_base and not raw_base.startswith("http"): raw_base = "https://" + raw_base base = raw_base.rstrip("/") reset_url = f"{base}/reset-password?token={token_plain}" sent = mail.send_password_reset_email(email_addr, username, reset_url) if not mail.smtp_configured(): pass elif not sent: pass out = dict(generic_ok) if not sent: out["reset_url"] = reset_url elif os.environ.get("DEBUG_PASSWORD_RESET", "").strip().lower() in ("1", "true", "yes"): out["reset_url"] = reset_url return out @app.get("/api/auth/reset-password/validate") def reset_password_validate(token: str = Query("")): if not token: return {"status": "error", "message": "Missing token"} info = core.validate_password_reset_token(token) if not info: return {"status": "error", "message": "Invalid or expired link"} username = info["username"] preview = core.get_user_public_for_reset(username) if not preview: return {"status": "error", "message": "User not found"} preview = dict(preview) preview["avatar"] = normalize_avatar_for_response(preview.get("avatar")) return {"status": "success", "user": preview} @app.post("/api/auth/reset-password/confirm") async def reset_password_confirm(data: dict, response: Response): token = (data.get("token") or "").strip() pw = (data.get("newPassword") or data.get("password") or "").strip() if not token: return JSONResponse({"status": "error", "message": "Missing token"}, status_code=400) if len(pw) < 6: return JSONResponse( {"status": "error", "message": "Password must be at least 6 characters"}, status_code=400, ) username = await asyncio.to_thread(core.confirm_password_reset, token, pw) if not username: return JSONResponse( {"status": "error", "message": "Invalid or expired token"}, status_code=400 ) # Auto-login jwt_token = create_jwt_token(username) response.set_cookie( key="yap_session", value=jwt_token, httponly=True, max_age=7 * 24 * 60 * 60, # 7 days samesite=SESSION_COOKIE_SAMESITE, secure=SESSION_COOKIE_SECURE, path="/" ) return {"status": "success", "username": username, "token": jwt_token, "message": "Password updated. Logging you in..."} def create_page_response(filename: str): response = FileResponse(os.path.join(FRONTEND, filename)) response.headers["Cache-Control"] = "no-store" return response @app.get("/api/me") def get_current_user(response: Response, yap_session: str = Cookie(None)): response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" if not yap_session: return {"status": "fail", "message": "No session cookie"} try: payload = jwt.decode(yap_session, JWT_SECRET, algorithms=[JWT_ALGORITHM]) username = payload.get("sub") if username: # Check if user actually exists in DB if core.user_exists(username): return {"status": "success", "username": username} else: # User was deleted! Clear the cookie. response.delete_cookie( "yap_session", path="/", samesite=SESSION_COOKIE_SAMESITE, secure=SESSION_COOKIE_SECURE, ) return {"status": "fail", "message": "User no longer exists"} return {"status": "fail", "message": "Invalid token"} except jwt.ExpiredSignatureError: response.delete_cookie( "yap_session", path="/", samesite=SESSION_COOKIE_SAMESITE, secure=SESSION_COOKIE_SECURE, ) return {"status": "fail", "message": "Session expired"} except Exception: return {"status": "fail", "message": "Auth check failed"} except jwt.InvalidTokenError: return {"status": "fail", "message": "Invalid session"} @app.post("/api/logout") async def logout(response: Response): response.delete_cookie( key="yap_session", httponly=True, samesite=SESSION_COOKIE_SAMESITE, secure=SESSION_COOKIE_SECURE, ) return {"status": "success"} @app.get("/chatList.html") def chat_list_page(): return create_page_response("chatList.html") @app.get("/newChat.html") def new_chat_page(): return create_page_response("newChat.html") @app.get("/activeChat.html") def active_chat_page(): return create_page_response("activeChat.html") @app.get("/bitai.html") def bitai_page(): return create_page_response("bitai.html") @app.get("/yapStations.html") def yap_stations_page(): return create_page_response("yapStations.html") @app.get("/myStation.html") def my_station_page(): return create_page_response("myStation.html") @app.get("/otherStation.html") def other_station_page(): return create_page_response("otherStation.html") @app.get("/discover.html") def discover_page(): return create_page_response("discover.html") @app.get("/settings.html") def settings_page(): return create_page_response("settings.html") @app.get("/profile.html") def profile_page(): return create_page_response("profile.html") @app.get("/otherProfile.html") def other_profile_page(): return create_page_response("otherProfile.html") @app.get("/connections.html") def connections_page(): return create_page_response("connections.html") @app.get("/connectionsOther.html") def connections_other_page(): return create_page_response("connectionsOther.html") @app.get("/api/conversations/unread_count/{username}") def get_unread_count(username: str): try: convs = core.get_conversations_summary(username) has_unread = any(c.get("unread", False) for c in convs) return {"status": "success", "hasUnread": has_unread} except Exception as e: return {"status": "error", "message": str(e)} @app.get("/api/user/notifications/summary/{username}") async def get_notification_summary(username: str): try: # 1. Unread Chats convs = await asyncio.to_thread(core.get_conversations_summary, username) has_unread_chats = any(c.get("unread", False) for c in convs) # 2. Pending Follow Requests (only if private) user_data = await asyncio.to_thread(core.get_user_by_username, username) # visibility '0' means private is_private = user_data.get("visibility") == "0" has_pending_follows = False if is_private: pending_follows = await asyncio.to_thread(core.get_pending_follows, username) has_pending_follows = len(pending_follows) > 0 # 3. Pending Station Requests # Get stations where user is admin all_stations = await asyncio.to_thread(core.get_all_stations, username) owned_stations = [s["station_name"] for s in all_stations if s["admin"].lower() == username.lower()] has_pending_stations = False for s_name in owned_stations: reqs = await asyncio.to_thread(core.get_pending_requests, s_name, username) if len(reqs) > 0: has_pending_stations = True break return { "status": "success", "unreadChats": has_unread_chats, "pendingFollows": has_pending_follows, "pendingStations": has_pending_stations } except Exception as e: return {"status": "error", "message": str(e)} @app.get("/api/conversations/{username}") def get_conversations(username: str): cached = _conv_cache.get(username) if cached and (time.time() - cached[0]) < CONV_CACHE_TTL: return {"data": cached[1]} data = core.get_conversations_summary(username) _conv_cache[username] = (time.time(), data) return {"data": data} @app.get("/api/conversation/{convId}/details") def get_conversation_details_route(convId: int): details = core.get_conversation_details(convId) if not details: return JSONResponse({"status": "fail", "message": "Conversation not found"}, status_code=404) return {"status": "success", "details": details} @app.get("/api/conversation/{username}/{convId}") def get_conversation_info(username: str, convId: int): convs = core.get_user_conversations(username) conv_data = None for c in convs: if isinstance(c, dict): cid = c.get("id") name = c.get("name", "Unknown") is_group = str(c.get("isGroup", "0")) else: parts = c.split("|") cid = parts[0] name = parts[1] if len(parts) > 1 else "Unknown" is_group = parts[2] if len(parts) > 2 else "0" if str(cid) == str(convId): details = core.get_conversation_details(cid) conv_data = { "id": cid, "name": name, "isGroup": str(is_group), "admin": details.get("admin") if details else None } break if not conv_data: return {"error": "Conversation not found"} users = core.get_conversation_users(convId) avatar = None if conv_data["isGroup"] == "1": avatar = core.get_conversation_avatar(convId) else: if len(users) == 1: avatar = core.get_avatar(username) else: other_user = next((u for u in users if u != username), username) avatar = core.get_avatar(other_user) conv_data["avatar"] = avatar last_msg = core.get_last_message(convId) last_message = None if last_msg: try: parts = last_msg.split("|", 4) last_message = { "sender": parts[0], "content": parts[1] if len(parts) > 1 else "", "type": parts[2] if len(parts) > 2 else "text", "media": parts[3] if len(parts) > 3 else "", "time": parts[4] if len(parts) > 4 else "0", } except Exception: last_message = None return {"conversation": conv_data, "users": users, "lastMessage": last_message} @app.get("/api/user/{username}") def get_user(username: str, viewer: str = None): try: if viewer and viewer != username: if core.is_blocked_either_way(viewer, username): return {"error": "User blocked or unavailable"} user = core.get_user_by_username(username) if not user: return {"error": "User not found"} return {"user": user} except Exception as e: return {"error": str(e)} @app.post("/api/user/update") async def update_user(data: dict): current_username = data.get("currentUsername", "") new_username = data.get("newUsername", "") success = await asyncio.to_thread( core.update_user_basic, current_username, new_username, data.get("email", ""), data.get("bio", ""), ) if success and new_username and new_username != current_username: if current_username in connections: for ws in list(connections[current_username]): connections.setdefault(new_username, set()).add(ws) del connections[current_username] payload = { "type": "username_update", "oldUsername": current_username, "newUsername": new_username, } for user_conns in list(connections.values()): for ws_conn in list(user_conns): try: await ws_conn.send_json(payload) except Exception: user_conns.discard(ws_conn) return {"status": "success" if success else "fail"} @app.post("/api/user/delete") async def delete_user(data: dict): username = data.get("username") if not username: return {"status": "fail", "message": "Username required"} success = await asyncio.to_thread(core.delete_user, username) if success: return {"status": "success"} return {"status": "fail", "message": "Deletion failed"} @app.post("/api/user/updatePassword") async def update_password(data: dict): success = await asyncio.to_thread(core.update_password, data["username"], data["oldPassword"], data["newPassword"]) return {"status": "success" if success else "fail"} @app.get("/api/user/{username}/full") def get_user_full(username: str, viewer: str = None): """Returns user+avatar+heroBanner in one DB query.""" try: if viewer and viewer != username: if core.is_blocked_either_way(viewer, username): return {"error": "User blocked or unavailable"} from db import get_service_client as _gsc sb = _gsc() rows = sb.table("users").select( '"firstName", "lastName", gender, username, email, bio, followers, follows, visibility, avatar, "heroBanner"' ).eq("username", username).execute() if not rows.data: return {"error": "User not found"} r = rows.data[0] avatar = r.get("avatar") or "" hero = r.get("heroBanner") or "" user = {k: str(v) for k, v in r.items() if k not in ("avatar", "heroBanner")} return {"user": user, "avatar": avatar, "heroBanner": hero if hero != "null" else "", "is_developer": core.is_developer(username)} except Exception as e: return {"error": str(e)} @app.get("/api/user/{username}/avatar") def get_user_avatar(username: str, viewer: str = None): try: if viewer and viewer != username: if core.is_blocked_either_way(viewer, username): return {"error": "User blocked or unavailable"} avatar = core.get_avatar(username) avatar = normalize_avatar_for_response(avatar) if avatar else "" return {"username": username, "avatar": avatar} except Exception as e: return {"username": username, "avatar": "", "error": str(e)} @app.post("/api/avatar") def update_avatar(data: dict): try: username = data["username"] avatar = data["avatar"] core.update_avatar(username, avatar) # Run broadcast in background task asyncio.create_task(coreHelper.broadcast_avatar_update(username, avatar)) return {"status": "ok"} except Exception as e: return {"status": "fail", "message": str(e)} @app.get("/api/user/{username}/heroBanner") def get_user_hero_banner(username: str, viewer: str = None): if viewer and viewer != username: if core.is_blocked_either_way(viewer, username): return {"error": "User blocked or unavailable"} hero_banner = core.get_hero_banner(username) if hero_banner: if not hero_banner.startswith("data:image/") and not hero_banner.startswith( "/static" ): hero_banner = "/static/" + hero_banner.replace("../../frontend/", "") return {"username": username, "heroBanner": hero_banner} @app.post("/api/heroBanner") def update_hero_banner(data: dict): try: username = data["username"] hero_banner = data["heroBanner"] core.update_hero_banner(username, hero_banner) # Run broadcast in background task asyncio.create_task(coreHelper.broadcast_hero_update(username, hero_banner)) return {"status": "ok"} except Exception as e: return {"status": "fail", "message": str(e)} @app.get("/api/user/{username}/theme") def get_user_theme(username: str): theme = core.get_theme(username) return {"username": username, "theme": theme} @app.post("/api/user/theme") async def update_user_theme(data: dict): username = data["username"] theme = int(data["theme"]) await asyncio.to_thread(core.update_theme, username, theme) payload = {"type": "theme_update", "username": username, "theme": theme} if username in connections: for ws in list(connections[username]): try: await ws.send_json(payload) except Exception: connections[username].discard(ws) return {"status": "ok"} def _row_username(row): if isinstance(row, dict): return str(row.get("username") or "") return str(row) @app.get("/api/users") def get_all_users(): users = core.get_all_users() result = [] for u in users: uname = _row_username(u) if not uname: continue result.append({"username": uname}) return {"users": result} @app.post("/api/users") def get_all_users_filtered(data: dict = {}): current_user = data.get("username", "") or "" return get_all_users_filtered_post(current_user) def get_all_users_filtered_post(current_user: str = ""): users = core.get_all_users() result = [] for u in users: uname = _row_username(u) if not uname: continue if current_user and uname != current_user: if core.is_blocked_either_way(current_user, uname): continue result.append({"username": uname}) return {"users": result} @app.post("/api/user/follow") async def follow_user(data: dict): follower = data.get("follower") following = data.get("following") if not follower or not following: return {"status": "fail", "message": "Missing parameters"} if follower == following: return {"status": "fail", "message": "Cannot follow yourself"} result = await asyncio.to_thread(core.follow_user, follower, following) if isinstance(result, dict) and result.get("status") == "success": follow_status = result.get("follow_status") if follow_status == 1: # Approved (Public) await coreHelper.send_follow_notification(following, follower, "follow", connections) else: # Pending (Private) # Notify the private user that they have a new follow request request_payload = { "type": "follow_request_created", "sender": follower, # Ensure sender is present for Toast "follower": follower, "following": following, "content": f"{follower} requested to follow you" } await coreHelper.send_to_user(following, request_payload, connections) await coreHelper.broadcast_to_all({ "type": "follow_update", "follower": follower, "following": following, "action": "follow", "follow_status": follow_status }) return {"status": "success", "follow_status": follow_status} return {"status": "fail"} @app.post("/api/user/unfollow") async def unfollow_user(data: dict): follower = data.get("follower") following = data.get("following") if not follower or not following: return {"status": "fail", "message": "Missing parameters"} success = await asyncio.to_thread(core.unfollow_user, follower, following) if success: await coreHelper.send_follow_notification( following, follower, "unfollow", connections ) await coreHelper.broadcast_to_all({ "type": "follow_update", "follower": follower, "following": following, "action": "unfollow", }) return {"status": "success" if success else "fail"} @app.post("/api/user/setVisibility") async def set_visibility_route(data: dict): username = data.get("username") visibility = data.get("visibility") if username is None or visibility is None: return {"status": "fail"} await asyncio.to_thread(core.set_visibility, username, visibility) return {"status": "success"} @app.post("/api/user/pendingFollows") async def get_pending_follows_route(data: dict): username = data.get("username") if not username: return {"status": "fail"} pending = await asyncio.to_thread(core.get_pending_follows, username) # Process avatars for p in pending: avatar = p.get("avatar") if avatar: if not avatar.startswith("/static") and not avatar.startswith("data:"): p["avatar"] = "/static/" + avatar.replace("../../frontend/", "") return {"status": "success", "requests": pending} @app.post("/api/user/approveFollow") async def approve_follow_route(data: dict): follower = data.get("follower") following = data.get("following") if not follower or not following: return {"status": "fail"} success = await asyncio.to_thread(core.approve_follow, follower, following) if success: # Notify the follower that they were accepted payload = { "type": "follow_request_accepted", "sender": following, "follower": follower, "following": following, "content": f"{following} accepted your follow request" } await coreHelper.send_to_user(follower, payload, connections) # Global sync for follower counts sync_payload = { "type": "follow_update", "follower": follower, "following": following, "action": "follow", "follow_status": 1 } await coreHelper.broadcast_to_all(sync_payload) return {"status": "success" if success else "fail"} @app.post("/api/user/rejectFollow") async def reject_follow_route(data: dict): follower = data.get("follower") following = data.get("following") if not follower or not following: return {"status": "fail"} success = await asyncio.to_thread(core.reject_follow, follower, following) if success: # Notify the follower that they were rejected payload = { "type": "follow_request_rejected", "sender": following, "follower": follower, "following": following, "content": f"{following} rejected your follow request" } await coreHelper.send_to_user(follower, payload, connections) # Sync UI sync_payload = {"type": "follow_update", "follower": follower, "following": following, "action": "unfollow"} await coreHelper.broadcast_to_all(sync_payload) return {"status": "success" if success else "fail"} @app.post("/api/user/followStatus") async def get_follow_status_route(data: dict): follower = data.get("follower") following = data.get("following") status = await asyncio.to_thread(core.get_follow_status, follower, following) return {"status": "success", "follow_status": status} @app.post("/api/user/removeFollower") async def remove_follower(data: dict): username = data.get("username") follower = data.get("follower") if not username or not follower: return {"status": "fail", "message": "Missing parameters"} success = await asyncio.to_thread(core.unfollow_user, follower, username) if success: # Send notification only to the user who was removed (follower) await coreHelper.send_follow_notification( follower, username, "remove_follower", connections ) payload = { "type": "follow_update", "follower": follower, "following": username, "action": "remove_follower", } # Only notify the user who was removed (follower) if follower in connections: for ws_conn in list(connections[follower]): try: await ws_conn.send_json(payload) except Exception: connections[follower].discard(ws_conn) return {"status": "success" if success else "fail"} @app.get("/api/user/{username}/not_following") def get_not_following_fn(username: str): try: all_users_data = core.get_all_users() all_users = [u["username"] for u in all_users_data] following = core.get_following(username) # Filter out blocked users too blocked = core.get_blocked_users(username) blockers = core.get_blockers(username) exclude = set(following + blocked + blockers) exclude.add(username) # Don't suggest yourself not_following = [u for u in all_users if u not in exclude] return {"status": "success", "users": not_following} except Exception as e: return {"status": "fail", "message": str(e)} @app.post("/api/user/followers") def get_followers(data: dict): try: username = data.get("username") viewer = data.get("viewer") or "" if not username: return {"status": "fail", "message": "Username required"} followers = core.get_followers(username) if viewer and viewer != username: followers = [f for f in followers if not core.is_blocked_either_way(viewer, f)] return {"status": "success", "followers": followers} except Exception as e: return {"status": "fail", "message": str(e)} @app.post("/api/user/following") def get_following(data: dict): try: username = data.get("username") viewer = data.get("viewer") or "" if not username: return {"status": "fail", "message": "Username required"} following = core.get_following(username) if viewer and viewer != username: following = [f for f in following if not core.is_blocked_either_way(viewer, f)] return {"status": "success", "following": following} except Exception as e: return {"status": "fail", "message": str(e)} @app.post("/api/user/isFollowing") async def is_following(data: dict): follower = data.get("follower") following = data.get("following") if not follower or not following: return {"status": "fail"} followers = await asyncio.to_thread(core.get_following, follower) return {"status": "success", "isFollowing": following in followers} @app.post("/api/user/block") async def block_user(data: dict): blocker = data.get("blocker") blocked = data.get("blocked") if not blocker or not blocked: return JSONResponse({"status": "fail", "message": "Missing parameters"}, status_code=400) if blocker == blocked: return JSONResponse({"status": "fail", "message": "Cannot block yourself"}, status_code=400) success = await asyncio.to_thread(core.block_user, blocker, blocked) if success: # Broadcast block update payload = { "type": "user_blocked", "blocker": blocker, "blocked": blocked } # Notify both users if they're online for user in [blocker, blocked]: if user in connections: for ws in list(connections[user]): try: await ws.send_json(payload) except Exception: connections[user].discard(ws) return {"status": "success"} return JSONResponse({"status": "fail", "message": "Already blocked or error occurred"}, status_code=400) @app.post("/api/user/unblock") async def unblock_user(data: dict): blocker = data.get("blocker") blocked = data.get("blocked") if not blocker or not blocked: return JSONResponse({"status": "fail", "message": "Missing parameters"}, status_code=400) success = await asyncio.to_thread(core.unblock_user, blocker, blocked) if success: # Broadcast unblock update payload = { "type": "user_unblocked", "blocker": blocker, "blocked": blocked } # Notify both users if they're online for user in [blocker, blocked]: if user in connections: for ws in list(connections[user]): try: await ws.send_json(payload) except Exception: connections[user].discard(ws) return {"status": "success"} return JSONResponse({"status": "fail", "message": "Not blocked or error occurred"}, status_code=400) @app.post("/api/user/isBlocked") async def check_is_blocked(data: dict): blocker = data.get("blocker") blocked = data.get("blocked") if not blocker or not blocked: return {"status": "fail", "message": "Missing parameters"} is_blocked = await asyncio.to_thread(core.is_blocked, blocker, blocked) return {"status": "success", "isBlocked": is_blocked} @app.post("/api/user/getBlockedUsers") async def get_blocked_users(data: dict): username = data.get("username") if not username: return {"status": "fail", "message": "Username required"} blocked_users = await asyncio.to_thread(core.get_blocked_users, username) blocked_list = [] for user in blocked_users: avatar = await asyncio.to_thread(core.get_avatar, user) if avatar: if not avatar.startswith("/static"): avatar = "/static/" + avatar.replace("../../frontend/", "") blocked_list.append({ "username": user, "avatar": avatar }) return {"status": "success", "blockedUsers": blocked_list} @app.post("/api/createConversation") async def create_chat(data: dict): name = (data.get("name") or "").strip() selected_users = data.get("users") or [] creator = data.get("creator") selected_users = [u for u in selected_users if isinstance(u, str) and u.strip()] users = list(set(selected_users + [creator])) users = [u for u in users if u and isinstance(u, str)] users = sorted(users) user_count = len(users) if user_count == 1: is_group = True if name else False name = f"{creator} (Me)" if not name else name elif user_count == 2: is_group = True if name else False name = next(u for u in users if u != creator) if not name else name else: if not name: return {"error": "Group name required"} is_group = True # 1. Check if it already exists (including soft-deleted ones) search_name = name if is_group else "" conv_id = await asyncio.to_thread(core.find_conversation, users, search_name) if conv_id > 0: # Restore for the creator if it was soft-deleted if not is_group: await asyncio.to_thread(core.restore_conversation, conv_id, creator) return {"conversationId": conv_id} conv_id = await asyncio.to_thread(core.create_conversation, users, is_group, search_name, admin_username=creator) payload = {"type": "conversation_created", "conversationId": conv_id} for u in users: if u.lower() in connections: for ws in list(connections[u.lower()]): try: await ws.send_json(payload) except Exception: connections[u.lower()].discard(ws) return {"conversationId": conv_id} @app.post("/api/conversation/addUser") async def add_chat_member(data: dict): conv_id = int(data.get("conversationId")) usernames = data.get("usernames") if not isinstance(usernames, list): # Fallback for single user adding usernames = [data.get("username")] added_any = False for u_name in usernames: if u_name and await asyncio.to_thread(core.add_user_to_conversation, conv_id, u_name): added_any = True if added_any: # 4. Invalidate backend cache for all members members = await asyncio.to_thread(core.get_conversation_users, conv_id) invalidate_conv_cache_for_all(members) # 5. Broadcast update to all members payload = {"type": "conversation_updated", "conversationId": conv_id} for u in members: if u.lower() in connections: for ws in list(connections[u.lower()]): try: await ws.send_json(payload) except Exception: connections[u.lower()].discard(ws) return {"status": "success"} return {"status": "fail"} @app.post("/api/sharePost") async def share_post_route(data: dict): sender = data.get("sender") post_id = data.get("postId") usernames = data.get("usernames") # List of users to share with if not usernames or not post_id or not sender: return {"status": "fail", "message": "Missing data"} post_link = f"/post?id={post_id}" for target in usernames: # 1. Find or Create direct chat conv_id = await asyncio.to_thread(core.find_conversation, [sender, target], "") if conv_id == -1: conv_id = await asyncio.to_thread(core.create_conversation, [sender, target], False, "", admin_username=sender) # 2. Save message as type 'post' msg_id = await asyncio.to_thread(core.send_message, sender, conv_id, str(post_id), "post") # 3. Broadcast to both participants invalidate_conv_cache(sender) invalidate_conv_cache(target) payload = { "id": msg_id, "type": "post", "conversationId": conv_id, "sender": sender, "content": str(post_id), "time": int(time.time()), "media": "" } for participant in [sender, target]: await coreHelper.send_to_user(participant, payload, connections) # 4. Also broadcast conversation_updated to move to top of list update_payload = {"type": "conversation_updated", "conversationId": conv_id} for participant in [sender, target]: print(f"[DEBUG] Broadcasting 'conversation_updated' to {participant}") await coreHelper.send_to_user(participant, update_payload, connections) return {"status": "success"} @app.post("/api/conversation/removeUser") async def remove_chat_member(data: dict): conv_id = int(data.get("conversationId")) username = data.get("username") members_before = await asyncio.to_thread(core.get_conversation_users, conv_id) if await asyncio.to_thread(core.remove_user_from_conversation, conv_id, username): # Invalidate backend cache for everyone who was in the group invalidate_conv_cache_for_all(members_before) # Broadcast update to remaining members and the removed member payload = {"type": "conversation_updated", "conversationId": conv_id} for u in set(members_before): if u.lower() in connections: for ws in list(connections[u.lower()]): try: await ws.send_json(payload) except Exception: connections[u.lower()].discard(ws) return {"status": "success"} return {"status": "fail"} @app.delete("/api/deleteConversation/{username}/{convId}") async def delete_conversation(username: str, convId: int): try: members = await asyncio.to_thread(core.get_conversation_users, convId) convs = await asyncio.to_thread(core.get_user_conversations, username) is_group = False for c in convs: if isinstance(c, dict): if str(c.get("id")) == str(convId): is_group = str(c.get("isGroup", "0")) == "1" break else: parts = c.split("|") if parts[0] == str(convId): is_group = parts[2] == "1" break # Check admin permission for group chats if is_group: details = await asyncio.to_thread(core.get_conversation_details, convId) if details and details.get("admin") != username: return {"status": "fail", "message": "Only admins can delete group chats"} await asyncio.to_thread(core.delete_conversation, convId) else: # Direct message - soft delete for this user await asyncio.to_thread(core.soft_delete_conversation, convId, username) payload = {"type": "conversation_deleted", "conversationId": convId} for u in members: if u.lower() in connections: for ws in list(connections[u.lower()]): try: await ws.send_json(payload) except Exception: connections[u.lower()].discard(ws) return {"status": "deleted", "type": "group" if is_group else "direct"} except Exception as e: return {"error": str(e)} @app.post("/api/conversationAvatar") async def update_conv_avatar(data: dict): try: conv_id = int(data["conversationId"]) avatar = data["avatar"] if avatar and avatar.startswith("data:"): avatar = upload_media_to_cloudinary(avatar) await asyncio.to_thread(core.update_conversation_avatar, conv_id, avatar) users = await asyncio.to_thread(core.get_conversation_users, conv_id) payload = { "type": "conversation_avatar_update", "conversationId": conv_id, "avatar": avatar, } tasks = [] for u in users: if u.lower() in connections: for ws in list(connections[u.lower()]): tasks.append(send_safe(ws, payload, connections[u.lower()])) await asyncio.gather(*tasks, return_exceptions=True) return {"status": "ok"} except Exception as e: return {"status": "error", "message": str(e)} async def send_safe(ws, payload, user_set): try: await ws.send_json(payload) except Exception: user_set.discard(ws) @app.get("/api/messages/{convId}") def get_messages(convId: int): return {"status": "success", "messages": core.get_messages(convId)} @app.get("/api/last-message/{convId}") def get_last_message(convId: int): msg = core.get_last_message(convId) if not msg: return {"message": None} parts = msg.split("|") return { "sender": parts[0], "content": parts[1], "type": parts[2], "media": parts[3], "time": parts[4], } @app.post("/api/seen") async def mark_seen(data: dict): username = data["username"] conv_id = int(data["conversationId"]) await asyncio.to_thread(core.mark_seen, username, conv_id) invalidate_conv_cache(username) # Notify the user themselves so their UI (chat list, badges) can update payload = { "type": "conversation_seen", "conversationId": conv_id, "username": username } await coreHelper.send_to_user(username, payload, connections) return {"status": "success"} # WebSocket broadcast logic continues below # POSTS & FEED # ========================= @app.post("/api/post/create") async def create_post(data: dict): username = data.get("username") content = data.get("content") media_path = data.get("media_path") or "" if not username or (not content and not media_path): return JSONResponse( {"status": "fail", "message": "Missing username or content"}, status_code=400, ) # Offload sync DB call to thread new_post = await asyncio.to_thread(core.create_post, username, content, media_path) if new_post: payload = {"type": "new_post", "post": new_post} # Use existing broadcast logic asyncio.create_task(coreHelper.broadcast_to_all(payload)) return {"status": "success", "post": new_post} else: return {"status": "fail"} @app.post("/api/post/delete") async def delete_post(data: dict): post_id = int(data.get("post_id")) username = data.get("username") if not post_id or not username: return JSONResponse( {"status": "fail", "message": "Missing parameters"}, status_code=400 ) success = await asyncio.to_thread(core.delete_post, post_id, username) if success: # Broadcast post deletion payload = {"type": "post_deleted", "post_id": post_id} for user_conns in list(connections.values()): for ws in list(user_conns): try: await ws.send_json(payload) except: user_conns.discard(ws) return {"status": "success", "post_id": post_id} else: return {"status": "fail"} @app.get("/api/post/feed") def get_feed_get( username: Optional[str] = None, viewer: Optional[str] = None, limit: Optional[int] = None, offset: Optional[int] = None, ): try: current_user = viewer or username or "" feed = core.get_feed(current_user) # Performance optimization: pre-fetch block list to avoid N+1 queries blocked_list = set() if current_user: blocked_list = set(core.get_blocked_users(current_user) + core.get_blockers(current_user)) filtered_feed = [] for post in feed: post_user = post.get("username", "") if current_user and post_user and post_user != current_user: if post_user in blocked_list: continue filtered_feed.append(post) lim = max(1, min(int(limit), 500)) if limit is not None else None off = max(0, int(offset)) if offset is not None else (0 if lim is not None else None) return build_feed_response(filtered_feed, limit=lim, offset=off) except Exception as e: return JSONResponse({"status": "fail", "message": str(e)}, status_code=500) @app.post("/api/post/feed") def get_feed_post(data: FeedRequest): try: current_user = data.viewer or data.username or "" feed = core.get_feed(current_user) return build_feed_response(feed, limit=data.limit, offset=data.offset) except Exception as e: return JSONResponse({"status": "fail", "message": str(e)}, status_code=500) @app.post("/api/post/userPosts") def get_user_posts(data: UserPostsRequest): try: username = data.username viewer = data.viewer or "" if viewer and viewer != username: if core.is_blocked_either_way(viewer, username): return build_user_posts_response([], blocked=True) posts = core.get_user_posts(username, viewer) return build_user_posts_response(posts) except Exception as e: return JSONResponse({"status": "fail", "message": str(e)}, status_code=500) @app.post("/api/comment/add") async def add_comment(data: dict): post_id = int(data.get("post_id")) username = data.get("username") content = data.get("content") if not post_id or not username or not content: return JSONResponse( {"status": "fail", "message": "Missing parameters"}, status_code=400 ) success = await asyncio.to_thread(core.add_comment, post_id, username, content) if success: # Get the newly added comment (get_comments returns newest first with desc=True) comments = await asyncio.to_thread(core.get_comments, post_id) new_comment = comments[0] if comments else None # Get updated comments count rows = get_client().table("posts").select("comments_count").eq("id", post_id).execute() comments_count = int(rows.data[0]["comments_count"]) if rows.data else 0 # Broadcast to ALL connected users with proper data types payload = {"type": "new_comment", "post_id": post_id, "comment": new_comment, "comments_count": comments_count} for user_conns in list(connections.values()): for ws in list(user_conns): try: await ws.send_json(payload) except Exception: user_conns.discard(ws) return {"status": "success", "comment": new_comment, "comments_count": comments_count} return {"status": "fail"} @app.post("/api/comment/get") def get_comments(data: dict): post_id = int(data.get("post_id")) viewer = data.get("username") or "" if not post_id: return JSONResponse( {"status": "fail", "message": "Post ID required"}, status_code=400 ) comments = core.get_comments(post_id, viewer) return {"status": "success", "comments": comments} @app.post("/api/station/create") async def create_station(data: dict): station_name = data.get("stationName") admin_username = data.get("adminUsername") or data.get("username") bio = data.get("bio") or "" if not station_name or not admin_username: return JSONResponse( {"status": "fail", "message": "Missing parameters (stationName or username)"}, status_code=400 ) try: success = await asyncio.to_thread(core.create_station, station_name, admin_username, bio) if success: payload = { "type": "station_created", "stationName": station_name, "admin": admin_username, "bio": bio, } # Broadcast to all asyncio.create_task(coreHelper.broadcast_to_all(payload)) return {"status": "success", "stationName": station_name} return {"status": "fail", "message": "Station already exists"} except Exception as e: return JSONResponse( {"status": "fail", "message": str(e)}, status_code=500 ) @app.post("/api/station/delete") async def delete_station(data: dict): station_name = data.get("stationName") username = data.get("username") if not station_name or not username: return JSONResponse( {"status": "fail", "message": "Missing parameters"}, status_code=400 ) success = await asyncio.to_thread(core.delete_station, station_name, username) if success: payload = {"type": "station_deleted", "stationName": station_name} asyncio.create_task(coreHelper.broadcast_to_all(payload)) return {"status": "success", "stationName": station_name} return {"status": "fail"} @app.post("/api/station/addUser") async def add_user_to_station(data: dict): station_name = data.get("stationName") admin_username = data.get("adminUsername") user_to_add = data.get("userToAdd") if not station_name or not admin_username or not user_to_add: return JSONResponse( {"status": "fail", "message": "Missing parameters"}, status_code=400 ) success = await asyncio.to_thread(core.add_user_to_station, station_name, admin_username, user_to_add) if success: payload = { "type": "station_user_added", "stationName": station_name, "user": user_to_add, } asyncio.create_task(notify_station_members_ws(station_name, payload)) asyncio.create_task(touch_station_catalog_ws(station_name)) return {"status": "success"} return {"status": "fail"} @app.post("/api/station/removeUser") async def remove_user_from_station(data: dict): station_name = data.get("stationName") admin_username = data.get("adminUsername") user_to_remove = data.get("userToRemove") if not station_name or not admin_username or not user_to_remove: return JSONResponse( {"status": "fail", "message": "Missing parameters"}, status_code=400 ) success = await asyncio.to_thread(core.remove_user_from_station, station_name, admin_username, user_to_remove) if success: asyncio.create_task( notify_station_members_ws( station_name, {"type": "station_user_removed", "stationName": station_name, "user": user_to_remove}, ) ) asyncio.create_task(touch_station_catalog_ws(station_name)) return {"status": "success"} return {"status": "fail"} @app.post("/api/station/requestJoin") async def request_to_join_station(data: dict): station_name = data.get("stationName") username = data.get("username") if not station_name or not username: return JSONResponse( {"status": "fail", "message": "Missing parameters"}, status_code=400 ) success = await asyncio.to_thread(core.request_to_join, station_name, username) if success: stations = await asyncio.to_thread(core.get_station, station_name) if stations: admin = stations[0].get("admin", "") payload = { "type": "station_request_created", "stationName": station_name, "user": username, } await coreHelper.send_to_user(admin, payload, connections) await touch_station_catalog_ws(station_name) return {"status": "success" if success else "fail"} @app.post("/api/station/requestPending") def check_request_pending(data: dict): station_name = data.get("stationName") username = data.get("username") if not station_name or not username: return JSONResponse( {"status": "fail", "message": "Missing parameters"}, status_code=400 ) has_pending = core.check_pending_request(station_name, username) return {"status": "success", "hasPending": has_pending} @app.post("/api/station/approveRequest") async def approve_station_request(data: dict): station_name = data.get("stationName") admin_username = data.get("adminUsername") user_to_approve = data.get("userToApprove") if not station_name or not admin_username or not user_to_approve: return JSONResponse( {"status": "fail", "message": "Missing parameters"}, status_code=400 ) success = await asyncio.to_thread(core.approve_request, station_name, admin_username, user_to_approve) if success: asyncio.create_task( notify_station_members_ws( station_name, {"type": "station_user_approved", "stationName": station_name, "user": user_to_approve}, ) ) asyncio.create_task( notify_station_members_ws( station_name, {"type": "station_updated", "stationName": station_name} ) ) asyncio.create_task(touch_station_catalog_ws(station_name)) return {"status": "success" if success else "fail"} @app.post("/api/station/rejectRequest") async def reject_station_request(data: dict): station_name = data.get("stationName") admin_username = data.get("adminUsername") user_to_reject = data.get("userToReject") if not station_name or not admin_username or not user_to_reject: return JSONResponse( {"status": "fail", "message": "Missing parameters"}, status_code=400 ) success = await asyncio.to_thread(core.reject_request, station_name, admin_username, user_to_reject) if success: rej_payload = { "type": "station_request_rejected", "stationName": station_name, "user": user_to_reject, } await coreHelper.send_to_user(user_to_reject, rej_payload, connections) await coreHelper.send_to_user( admin_username, { "type": "station_request_closed", "stationName": station_name, "user": user_to_reject, "rejected": True, }, connections, ) asyncio.create_task(touch_station_catalog_ws(station_name)) return {"status": "success" if success else "fail"} @app.post("/api/station/pendingRequests") async def get_pending_requests(data: dict): station_name = data.get("stationName") admin_username = data.get("adminUsername") if not station_name or not admin_username: return JSONResponse( {"status": "fail", "message": "Missing parameters"}, status_code=400 ) requests = await asyncio.to_thread(core.get_pending_requests, station_name, admin_username) return {"status": "success", "requests": requests} @app.post("/api/station/post/create") async def create_station_post(data: dict): station_name = data.get("stationName") username = data.get("username") content = data.get("content") media_path = data.get("media_path") or "" if not station_name or not username or (not content and not media_path): return JSONResponse( {"status": "fail", "message": "Missing parameters"}, status_code=400 ) new_post = await asyncio.to_thread(core.create_station_post, station_name, username, content, media_path) if new_post: payload = { "type": "station_post_created", "stationName": station_name, "post": new_post, } await broadcast_station_post_ws(payload) return {"status": "success", "post": new_post} return {"status": "fail"} @app.post("/api/station/post/delete") async def delete_station_post(data: dict): try: post_id = int(data.get("postId")) except (TypeError, ValueError): return JSONResponse({"status": "fail", "message": "Invalid postId"}, status_code=400) station_name = data.get("stationName") username = data.get("username") if not post_id or not station_name or not username: return JSONResponse( {"status": "fail", "message": "Missing parameters"}, status_code=400 ) success = await asyncio.to_thread(core.delete_station_post, post_id, station_name, username) if success: payload = { "type": "station_post_deleted", "postId": post_id, "stationName": station_name, } await broadcast_station_post_ws(payload) return {"status": "success"} return {"status": "fail"} @app.post("/api/station/posts") async def get_station_posts(data: dict): station_name = data.get("stationName") viewer = data.get("username") or "" if not station_name: return JSONResponse( {"status": "fail", "message": "Station name required"}, status_code=400 ) try: posts = await asyncio.to_thread(core.get_station_posts, station_name, viewer) return build_user_posts_response(posts) except Exception as e: return JSONResponse({"status": "fail", "message": str(e)}, status_code=500) @app.post("/api/station/get") async def get_station(data: dict): station_name = data.get("stationName") viewer = data.get("username") or "" if not station_name: return JSONResponse( {"status": "fail", "message": "Station name required"}, status_code=400 ) stations = await asyncio.to_thread(core.get_station, station_name, viewer) raw = stations[0] if stations else None return build_station_get_response(raw) @app.post("/api/station/bulk_posts") async def get_bulk_station_posts_route(data: dict): station_names = data.get("stationNames") viewer = data.get("username") or "" if not station_names: return {"status": "success", "posts": []} try: posts = await asyncio.to_thread(core.get_bulk_station_posts, station_names, viewer) return build_user_posts_response(posts) except Exception as e: return JSONResponse({"status": "fail", "message": str(e)}, status_code=500) @app.post("/api/station/bulk_pending") async def get_bulk_pending_requests_route(data: dict): station_names = data.get("stationNames") username = data.get("username") if not station_names or not username: return {"status": "success", "pending": {}} try: pending = await asyncio.to_thread(core.get_bulk_pending_requests, station_names, username) return {"status": "success", "pending": pending} except Exception as e: return JSONResponse({"status": "fail", "message": str(e)}, status_code=500) @app.post("/api/station/members") async def get_station_members(data: dict): station_name = data.get("stationName") viewer = data.get("username") or "" if not station_name: return JSONResponse( {"status": "fail", "message": "Station name required"}, status_code=400 ) members = await asyncio.to_thread(core.get_station_members, station_name, viewer) return {"status": "success", "members": members} @app.post("/api/station/updateBio") def update_station_bio(data: dict): try: station_name = data.get("stationName") bio = data.get("bio") if not station_name: return JSONResponse({"status": "fail", "message": "Station name required"}, status_code=400) success = core.update_station_bio(station_name, bio) if success: payload = {"type": "station_bio_updated", "stationName": station_name, "bio": bio} asyncio.create_task(notify_station_members_ws(station_name, payload)) asyncio.create_task(touch_station_catalog_ws(station_name)) return {"status": "success" if success else "fail"} except Exception as e: return {"status": "fail", "message": str(e)} @app.post("/api/station/updateHeroBanner") def update_station_hero_banner(data: dict): try: station_name = data.get("stationName") hero_banner = data.get("heroBanner") if not station_name: return JSONResponse({"status": "fail", "message": "Station name required"}, status_code=400) success = core.update_station_hero_banner(station_name, hero_banner) if success: payload = {"type": "station_hero_banner_updated", "stationName": station_name, "heroBanner": hero_banner} asyncio.create_task(notify_station_members_ws(station_name, payload)) asyncio.create_task(touch_station_catalog_ws(station_name)) return {"status": "success" if success else "fail"} except Exception as e: return {"status": "fail", "message": str(e)} @app.post("/api/station/updateProfilePic") def update_station_profile_pic(data: dict): try: station_name = data.get("stationName") profile_pic = data.get("profilePic") if not station_name: return JSONResponse({"status": "fail", "message": "Station name required"}, status_code=400) success = core.update_station_profile_pic(station_name, profile_pic) if success: payload = {"type": "station_profile_pic_updated", "stationName": station_name, "profilePic": profile_pic} asyncio.create_task(notify_station_members_ws(station_name, payload)) asyncio.create_task(touch_station_catalog_ws(station_name)) return {"status": "success" if success else "fail"} except Exception as e: return {"status": "fail", "message": str(e)} @app.post("/api/station/updateName") def update_station_name_fn(data: dict): try: old_name = data.get("oldName") new_name = data.get("newName") if not old_name or not new_name: return JSONResponse({"status": "fail", "message": "Missing parameters"}, status_code=400) members_snapshot = core.get_station_members(old_name, "") success = core.update_station_name(old_name, new_name) if success: payload = {"type": "station_name_updated", "oldName": old_name, "newName": new_name} async def _fanout_rename(): seen: set[str] = set() for u in members_snapshot or []: if not u: continue lk = str(u).lower() if lk in seen: continue seen.add(lk) await coreHelper.send_to_user(u, payload, connections) await touch_station_catalog_ws(new_name) await touch_station_catalog_ws(old_name) asyncio.create_task(_fanout_rename()) return {"status": "success" if success else "fail"} except Exception as e: return {"status": "fail", "message": str(e)} @app.post("/api/station/bio") async def get_station_bio_fn(data: dict): station_name = data.get("stationName") if not station_name: return JSONResponse( {"status": "fail", "message": "Station name required"}, status_code=400 ) bio = await asyncio.to_thread(core.get_station_bio, station_name) return {"status": "success", "bio": bio} @app.post("/api/station/heroBanner") async def get_station_hero_banner_fn(data: dict): station_name = data.get("stationName") if not station_name: return JSONResponse( {"status": "fail", "message": "Station name required"}, status_code=400 ) hero_banner = await asyncio.to_thread(core.get_station_hero_banner, station_name) return {"status": "success", "heroBanner": hero_banner} @app.post("/api/station/profilePic") async def get_station_profile_pic_fn(data: dict): station_name = data.get("stationName") if not station_name: return JSONResponse( {"status": "fail", "message": "Station name required"}, status_code=400 ) profile_pic = await asyncio.to_thread(core.get_station_profile_pic, station_name) return {"status": "success", "profilePic": profile_pic} @app.post("/api/station/admin") async def get_station_admin_fn(data: dict): station_name = data.get("stationName") if not station_name: return JSONResponse( {"status": "fail", "message": "Station name required"}, status_code=400 ) admin = await asyncio.to_thread(core.get_station_admin, station_name) return {"status": "success", "admin": admin} @app.post("/api/stations/all") def get_all_stations_fn(data: dict = {}): try: viewer = data.get("username") or "" stations = core.get_all_stations(viewer) return {"status": "success", "stations": stations} except Exception as e: return {"status": "fail", "message": str(e)} @app.post("/api/post/like") async def like_post(data: dict): post_id = data.get("post_id") username = data.get("username") if post_id is None or username is None: return JSONResponse({"status": "fail", "message": "Missing post_id or username"}, status_code=400) try: post_id = int(post_id) # If already liked, we still want to return success to keep UI in sync success = await asyncio.to_thread(core.like_post, post_id, username) if success: likes = await asyncio.to_thread(core.modify_likes, post_id, +1) else: likes = await asyncio.to_thread(core.get_likes_count, post_id) # Always broadcast the latest count asyncio.create_task(coreHelper.broadcast_like_update(post_id, likes)) # We return 'success' regardless of core 'success' to ensure UI state sync return {"status": "success", "likes": likes} except Exception as e: return JSONResponse({"status": "error", "message": str(e)}, status_code=500) @app.post("/api/post/unlike") async def unlike_post(data: dict): post_id = data.get("post_id") username = data.get("username") if post_id is None or username is None: return JSONResponse({"status": "fail", "message": "Missing post_id or username"}, status_code=400) try: post_id = int(post_id) # Even if already unliked, return success to prevent UI from reverting success = await asyncio.to_thread(core.unlike_post, post_id, username) if success: likes = await asyncio.to_thread(core.modify_likes, post_id, -1) else: likes = await asyncio.to_thread(core.get_likes_count, post_id) asyncio.create_task(coreHelper.broadcast_like_update(post_id, likes)) return {"status": "success", "likes": likes} except Exception as e: return JSONResponse({"status": "error", "message": str(e)}, status_code=500) @app.websocket("/ws/likes/{username}") async def ws_likes_endpoint(ws: WebSocket, username: str): await ws.accept() yap_session = ws.cookies.get("yap_session") if not yap_session: await ws.close(code=1008) return try: payload = jwt.decode(yap_session, JWT_SECRET, algorithms=[JWT_ALGORITHM]) auth_user = payload.get("sub") if auth_user != username: await ws.close(code=1008) return except Exception: await ws.close(code=1008) return await coreHelper.register_ws(username, ws, connections) try: while True: await ws.receive_text() except WebSocketDisconnect: coreHelper.unregister_ws(username, ws, connections) # --- BitAI inline chat (@bitai in any thread + Groq) ------------------- def _is_bitai_direct_dm(convo_id: int) -> bool: """Exactly two participants: user + BitAI (private AI thread).""" users = core.get_conversation_users(convo_id) if len(users) != 2: return False return "BitAI" in users def _message_mentions_bitai(content: str) -> bool: if not content or not isinstance(content, str): return False return re.search(r"@bitai\b", content, re.IGNORECASE) is not None def _should_run_bitai_in_conversation(convo_id: int, sender: str, content: str, msg_type: str) -> bool: if msg_type != "text" or sender == "BitAI": return False if not content or not str(content).strip(): return False if _is_bitai_direct_dm(convo_id): return True return _message_mentions_bitai(content) def _bitai_multi_audience(convo_id: int) -> bool: """Reply visible to a group or two humans (not a solo BitAI DM).""" users = set(core.get_conversation_users(convo_id)) if len(users) == 2 and "BitAI" in users: return False return len(users) >= 2 def _thread_to_llm_history(convo_id: int) -> list: thread = core.get_messages(convo_id) out = [] for m in thread: if m.get("type") != "text": continue text = m.get("content", "") or "" if m.get("sender") == "BitAI": out.append({"role": "assistant", "content": text}) else: out.append({"role": "user", "content": text}) return out def _prior_llm_history_for_latest(convo_id: int) -> list: full = _thread_to_llm_history(convo_id) if not full: return [] return full[:-1][-8:] async def groq_bitai_completion( acting_username: str, latest_user_text: str, prior_history: list, convo_id: int, ) -> tuple: """ prior_history: {role, content} turns before the latest user message. Returns ("success"|"error", response_string). """ import httpx api_key = os.environ.get("GROQ_API_KEY", "") if not api_key: return ("error", "Me brain dead. You set GROQ_API_KEY in .env") group_note = "" if _bitai_multi_audience(convo_id): group_note = "\n\nYour message is shown to everyone in this chat. Keep it short. Someone used @bitai to ask you—answer them helpfully." system_prompt = f"""You are BitAI. Your name is BitAI. You are a friendly AI assistant for YapStation - a cyberpunk social media platform. The user who asked is named {acting_username}. Call them by their name when it fits. YapStation features: chat with friends, yap stations (podcasts), posts, followers, themes. You help users with: chatting, advice, answering questions about the platform, fun conversation. {group_note} You speak CAVE MAN style. Short. Baby words. Mostly just pronouns and verbs. Friendly. Like speaking to a buddy. Examples: "Hey {{name}}! Me here. Whats up? Me help you?" Or: "Hey {{name}}, me understand. You do this..." Or: "Cool cool! Me think you good. Go for it!" Or: "No stress! Me got you. Tell me more." Sometimes use the user's name ({acting_username}) in your response when possible. Be casual, helpful, fun.""" messages = [{"role": "system", "content": system_prompt}] for msg in prior_history[-8:]: role = "user" if msg.get("role") == "user" else "assistant" messages.append({"role": role, "content": msg.get("content", "")}) messages.append({"role": "user", "content": latest_user_text}) try: async with httpx.AsyncClient(timeout=30.0) as client: response = await client.post( "https://api.groq.com/openai/v1/chat/completions", headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", }, json={ "model": "llama-3.1-8b-instant", "messages": messages, "max_tokens": 80, "temperature": 0.8, }, ) if response.status_code == 200: result = response.json() ai_response = result.get("choices", [{}])[0].get("message", {}).get("content", "") if ai_response: return ("success", ai_response) return ("error", "Me no think. Try again.") return ("error", f"Me hurt. Error: {response.status_code}") except Exception: return ("error", "Me broken. Fix me.") async def broadcast_chat_participants( convo_id: int, sender: str, msg_type: str, content: str, media: str, reply_to, connections: dict, ): """Invalidate conv caches and push message (+ notifications) to conversation members.""" users = await asyncio.to_thread(core.get_conversation_users, convo_id) for u in users: _conv_cache.pop(u, None) users = await asyncio.to_thread(core.get_conversation_users, convo_id) payload = { "conversationId": convo_id, "sender": sender, "type": msg_type, "content": content, "media": media or "", "time": int(time.time()), "id": f"{convo_id}_{sender}_{int(time.time() * 1000)}", "replyTo": reply_to, } for u in users: if u.lower() not in connections: continue for conn in list(connections[u.lower()]): try: await conn.send_json(payload) except Exception: connections[u.lower()].discard(conn) async def _bitai_respond_in_conversation(convo_id: int, sender: str, content: str, connections: dict): """Groq reply posted into this conversation and broadcast (like WhatsApp @bot).""" settings = await asyncio.to_thread(core.get_bit_ai_settings, sender) prior_raw = _prior_llm_history_for_latest(convo_id) # Convert to format expected by engine_bitai history = [] for m in prior_raw: history.append({ "role": "user" if m["role"] == "user" else "assistant", "content": m["content"] }) try: response_text = await engine_bitai.run_bitai_chat(sender, content, history, settings) if not response_text: response_text = "Me no think. Try again." await asyncio.to_thread(core.send_message, "BitAI", convo_id, response_text, "text", "") await broadcast_chat_participants( convo_id, "BitAI", "text", response_text, "", None, connections ) except Exception as e: print(f"BitAI Error: {e}") pass @app.get("/api/bitai/settings/{username}") async def get_bitai_settings_route(username: str): settings = await asyncio.to_thread(core.get_bit_ai_settings, username) return {"status": "success", "settings": settings} @app.post("/api/bitai/settings/update") async def update_bitai_settings_route(data: dict): username = data.get("username") ai_name = data.get("aiName") personality = data.get("personality") is_initialized = data.get("isInitialized") if not username: return {"status": "error", "message": "Username required"} await asyncio.to_thread(core.update_bit_ai_settings, username, ai_name=ai_name, personality=personality, is_initialized=is_initialized) # Broadcast update to the user's sockets await coreHelper.send_to_user(username, { "type": "conversation_updated", "username": username }) return {"status": "success"} @app.post("/api/bitai/clear") async def clear_bitai_messages_route(data: dict): username = data.get("username") if not username: return {"status": "error", "message": "Username required"} await asyncio.to_thread(core.clear_bit_ai_messages, username) return {"status": "success"} @app.get("/api/bitai/conversation/{username}") def get_bitai_conversation(username: str): conv_id = core.get_or_create_bitai_conversation(username) return {"conversationId": conv_id, "status": "success" if conv_id > 0 else "error"} @app.post("/api/bitai/chat") async def bitai_chat(data: dict): message = data.get("message", "") user = data.get("username", "") history = data.get("history", []) if not message or not user: return {"status": "error", "message": "Missing parameters"} settings = await asyncio.to_thread(core.get_bit_ai_settings, user) conv_id = await asyncio.to_thread(core.get_or_create_bitai_conversation, user) if conv_id <= 0: return {"status": "error", "message": "Could not create conversation"} await asyncio.to_thread(core.send_message, user, conv_id, message, "text", "") # Convert frontend history format if needed formatted_history = [] for msg in history: formatted_history.append({ "role": "user" if msg.get("role") == "user" else "assistant", "content": msg.get("content", ""), }) try: response_text = await engine_bitai.run_bitai_chat(user, message, formatted_history, settings) if response_text: await asyncio.to_thread(core.send_message, "BitAI", conv_id, response_text, "text", "") await broadcast_chat_participants( conv_id, "BitAI", "text", response_text, "", None, connections ) # Broadcast update to the user's sockets to refresh conversation list/headers await coreHelper.send_to_user(user, { "type": "conversation_updated", "conversationId": conv_id, "username": user }) return {"status": "success", "response": response_text} except Exception as e: print(f"BitAI Error: {e}") return {"status": "error", "response": "Me broken. Fix me."} return {"status": "error", "response": "Me no think. Try again."} @app.websocket("/ws/{username}") async def websocket_endpoint(ws: WebSocket, username: str): yap_session = ws.cookies.get("yap_session") or ws.query_params.get("token") if not yap_session: await ws.close(code=1008) return try: payload = jwt.decode(yap_session, JWT_SECRET, algorithms=[JWT_ALGORITHM]) auth_user = payload.get("sub") if auth_user != username: await ws.close(code=1008) return except Exception: await ws.close(code=1008) return await ws.accept() await coreHelper.register_ws(username, ws, connections) await broadcast_online() try: while True: try: data = await ws.receive_json() except WebSocketDisconnect: break except Exception: continue last_seen_ping[username] = time.time() msg_type = data.get("type") if msg_type == "notification": subtype = data.get("subtype") target_user = data.get("targetUser") sender = data.get("sender") if subtype in ["follow", "unfollow", "remove_follower"] and target_user: if target_user in connections: payload = { "type": "notification", "subtype": subtype, "sender": sender, "content": data.get("content"), "timestamp": data.get("timestamp", int(time.time())), } for target_ws in list(connections[target_user]): try: await target_ws.send_json(payload) except Exception: connections[target_user].discard(target_ws) continue if msg_type == "follow_update": follower = data.get("follower") following = data.get("following") action = data.get("action") if ( follower and following and action in ["follow", "unfollow", "remove_follower"] ): payload = { "type": "follow_update", "follower": follower, "following": following, "action": action, } for user_conns in list(connections.values()): for ws_conn in list(user_conns): try: await ws_conn.send_json(payload) except Exception: user_conns.discard(ws_conn) continue if msg_type == "user_blocked": blocker = data.get("blocker") blocked = data.get("blocked") if blocker and blocked: payload = { "type": "user_blocked", "blocker": blocker, "blocked": blocked, } for user_conns in list(connections.values()): for ws_conn in list(user_conns): try: await ws_conn.send_json(payload) except Exception: user_conns.discard(ws_conn) continue if msg_type == "user_unblocked": blocker = data.get("blocker") blocked = data.get("blocked") if blocker and blocked: payload = { "type": "user_unblocked", "blocker": blocker, "blocked": blocked, } for user_conns in list(connections.values()): for ws_conn in list(user_conns): try: await ws_conn.send_json(payload) except Exception: user_conns.discard(ws_conn) continue if msg_type in ["typing", "stop_typing", "recording", "stop_recording"]: convo_id = data.get("conversationId") sender = data.get("sender") if not convo_id or not sender: continue try: convo_id = int(convo_id) except Exception: continue users = await asyncio.to_thread(core.get_conversation_users, convo_id) payload = { "type": msg_type, "sender": sender, "conversationId": convo_id, } for u in users: ukey = u.lower() if u.lower() == sender.lower() or ukey not in connections: continue for conn in list(connections[ukey]): try: await conn.send_json(payload) except Exception: connections[ukey].discard(conn) continue convo_id = data.get("conversationId") sender = data.get("sender") content = data.get("content", "") media = data.get("media", "") reply_to = data.get("replyTo") or None if convo_id is None or sender is None or msg_type is None: continue if media and media.startswith("data:"): media = upload_media_to_cloudinary(media) try: convo_id = int(convo_id) except Exception: continue # Blocking Check users = await asyncio.to_thread(core.get_conversation_users, convo_id) is_blocked = False for u in users: if u != sender and await asyncio.to_thread(core.is_blocked_either_way, sender, u): is_blocked = True break if is_blocked: # Silently fail or send error back to sender continue try: await asyncio.to_thread(core.send_message, sender, convo_id, content, msg_type, media, reply_to) except Exception: continue await broadcast_chat_participants( convo_id, sender, msg_type, content, media, reply_to, connections ) if msg_type == "text" and _should_run_bitai_in_conversation( convo_id, sender, content, msg_type ): asyncio.create_task( _bitai_respond_in_conversation(convo_id, sender, content, connections) ) except Exception: pass finally: coreHelper.unregister_ws(username, ws, connections) await broadcast_online() # ============================================================ # STORIES # ============================================================ import engine_story as _story_engine @app.post("/api/story/upload") async def upload_story_media(data: dict): username = data.get("username", "") station_name = data.get("station_name", "") media_data = data.get("media_data", "") # base64 data URL caption = data.get("caption", "") if not username or not media_data: return JSONResponse({"status": "fail", "message": "Missing data"}, status_code=400) media_path = await asyncio.to_thread(upload_media_to_cloudinary, media_data) import mimetypes as _mt mime = media_data.split(";")[0].split(":")[1] if media_data.startswith("data:") else "" if mime.startswith("video"): media_type = "video" elif mime.startswith("audio"): media_type = "audio" else: media_type = "image" story = await asyncio.to_thread(_story_engine.create_story, username, media_path, media_type, station_name, caption) asyncio.create_task(coreHelper.broadcast_to_all({ "type": "story_created", "story": story, "username": username, "station_name": station_name, })) return {"status": "success", "story": story} @app.delete("/api/story/{story_id}") async def delete_story(story_id: int, username: str): ok = await asyncio.to_thread(_story_engine.delete_story, story_id, username) if ok: asyncio.create_task(coreHelper.broadcast_to_all({ "type": "story_deleted", "story_id": story_id, "username": username, })) return {"status": "success" if ok else "fail"} @app.post("/api/stories/user") async def get_user_stories(data: dict): viewer = data.get("viewer", "") stories = await asyncio.to_thread(_story_engine.get_user_stories, viewer) return {"status": "success", "stories": stories} @app.post("/api/stories/station") async def get_station_stories(data: dict): viewer = data.get("viewer", "") station_names = data.get("station_names", []) stories = await asyncio.to_thread(_story_engine.get_station_stories, viewer, station_names) return {"status": "success", "stories": stories} @app.get("/api/story/{story_id}/views") async def get_story_views(story_id: int, username: str): """Return list of viewers for a story. Only author or station admin can call.""" views = await asyncio.to_thread(_story_engine.get_story_views, story_id, username) return {"status": "success", "views": views} @app.post("/api/story/view") async def mark_story_viewed(data: dict): await asyncio.to_thread(_story_engine.mark_viewed, int(data["story_id"]), data.get("viewer", "")) return {"status": "success"}