""" app.py — B24 Browser AI backend. Accounts are NOT owned here. Signup/login are proxied to the messenger backend (Messenger_back_database) so B24 Meet and B24 Browser share one account system. Every other route verifies the JWT locally using the shared JWT_SECRET (see auth.py) — no network round-trip needed. """ import os import time from functools import wraps from concurrent.futures import ThreadPoolExecutor, as_completed import requests from flask import Flask, request, jsonify, Response, send_from_directory from flask_cors import CORS import auth import search import ai import scraper import video import keepalive import ota import ota_db app = Flask(__name__) CORS(app) ota_db.init_db() ADMIN_PASSKEY = os.environ.get("ADMIN_PASSKEY") MESSENGER_BACKEND_URL = os.environ.get( "MESSENGER_BACKEND_URL", "https://brighton233j-messenger-back-database.hf.space" ) GNEWS_API_KEY = os.environ.get("GNEWS_API_KEY") _news_cache = {"data": None, "fetched_at": 0} NEWS_CACHE_SECONDS = 1200 # 20 minutes def get_auth_user(): header = request.headers.get("Authorization", "") token = header.replace("Bearer ", "").strip() if not token: return None return auth.verify_token(token) def require_auth(fn): @wraps(fn) def wrapper(*args, **kwargs): payload = get_auth_user() if not payload: return jsonify({"error": "Unauthorized"}), 401 request.user = payload return fn(*args, **kwargs) return wrapper def require_admin(fn): @wraps(fn) def wrapper(*args, **kwargs): key = request.headers.get("X-Admin-Key") if not ADMIN_PASSKEY or key != ADMIN_PASSKEY: return jsonify({"error": "Forbidden"}), 403 return fn(*args, **kwargs) return wrapper def get_identity(): """Auth'd users are identified by their account id; guests are scoped by IP so per-identity limits (e.g. keep-alive session caps) still apply without requiring login.""" payload = get_auth_user() if payload: return payload["sub"] return f"guest:{request.remote_addr}" # --------------------------------------------------------------------------- # Auth — proxied to messenger backend, accounts live there only # --------------------------------------------------------------------------- @app.route("/auth/signup", methods=["POST"]) def signup(): try: resp = requests.post( f"{MESSENGER_BACKEND_URL}/auth/signup", json=request.get_json(silent=True) or {}, timeout=10, ) except requests.RequestException: return jsonify({"error": "Could not reach account server"}), 502 return (resp.text, resp.status_code, {"Content-Type": "application/json"}) @app.route("/auth/login", methods=["POST"]) def login(): try: resp = requests.post( f"{MESSENGER_BACKEND_URL}/auth/login", json=request.get_json(silent=True) or {}, timeout=10, ) except requests.RequestException: return jsonify({"error": "Could not reach account server"}), 502 return (resp.text, resp.status_code, {"Content-Type": "application/json"}) @app.route("/login") def login_page(): return send_from_directory(".", "login.html") # --------------------------------------------------------------------------- # AI features # --------------------------------------------------------------------------- @app.route("/summarize", methods=["POST"]) @require_auth def summarize(): data = request.get_json(silent=True) or {} url = data.get("url", "") text = data.get("text", "") if not text: return jsonify({"error": "text is required"}), 400 summary = ai.summarize_page(url, text) return jsonify({"summary": summary}) @app.route("/smart-search", methods=["POST"]) @require_auth def smart_search(): data = request.get_json(silent=True) or {} query = data.get("query", "").strip() if not query: return jsonify({"error": "query is required"}), 400 results = search.ddg_search(query) if isinstance(results, dict) and "error" in results: return jsonify(results), 502 answer = ai.synthesize_search(query, results) return jsonify({"answer": answer, "results": results}) @app.route("/related", methods=["POST"]) @require_auth def related(): data = request.get_json(silent=True) or {} url = data.get("url", "") text = data.get("text", "") if not text: return jsonify({"error": "text is required"}), 400 queries = ai.suggest_related(url, text) def _search_one(q): results = search.ddg_search(q, max_results=1) if isinstance(results, list) and results: return {"query": q, **results[0]} return {"query": q, "title": q, "url": "", "snippet": ""} links = [] with ThreadPoolExecutor(max_workers=5) as pool: futures = {pool.submit(_search_one, q): q for q in queries} for fut in as_completed(futures): links.append(fut.result()) return jsonify({"related": links}) @app.route("/scrape", methods=["POST"]) @require_auth def scrape(): data = request.get_json(silent=True) or {} url = data.get("url", "") if not url: return jsonify({"error": "url is required"}), 400 result = scraper.fetch_page(url) if "error" in result: return jsonify(result), 502 return jsonify(result) # --------------------------------------------------------------------------- # Video (yt-dlp) # --------------------------------------------------------------------------- @app.route("/video/info", methods=["POST"]) @require_auth def video_info(): data = request.get_json(silent=True) or {} url = data.get("url", "") if not url: return jsonify({"error": "url is required"}), 400 result = video.get_video_info(url) if "error" in result: return jsonify(result), 502 return jsonify(result) @app.route("/video/download", methods=["GET"]) @require_auth def video_download(): url = request.args.get("url", "") format_id = request.args.get("format_id") if not url: return jsonify({"error": "url is required"}), 400 return Response( video.stream_download(url, format_id), mimetype="video/x-matroska", headers={"Content-Disposition": "attachment; filename=video.mkv"}, ) # --------------------------------------------------------------------------- # Keep Alive — open to guests (scoped by IP instead of account when signed out) # --------------------------------------------------------------------------- @app.route("/keepalive/start", methods=["POST"]) def keepalive_start(): data = request.get_json(silent=True) or {} url = data.get("url", "") interval = data.get("interval", 60) if not url: return jsonify({"error": "url is required"}), 400 result = keepalive.start(get_identity(), url, interval) if "error" in result: return jsonify(result), 429 return jsonify(result) @app.route("/keepalive/stop", methods=["POST"]) def keepalive_stop(): data = request.get_json(silent=True) or {} session_id = data.get("session_id", "") result = keepalive.stop(get_identity(), session_id) if "error" in result: return jsonify(result), 404 return jsonify(result) @app.route("/keepalive/status", methods=["GET"]) def keepalive_status(): return jsonify({"sessions": keepalive.status(get_identity())}) # --------------------------------------------------------------------------- # News (GNews.io, cached) # --------------------------------------------------------------------------- def fetch_news(query="Uganda technology"): if not GNEWS_API_KEY: return [] try: resp = requests.get( "https://gnews.io/api/v4/search", params={"q": query, "lang": "en", "max": 10, "token": GNEWS_API_KEY}, timeout=10, ) data = resp.json() except Exception: return [] items = [] for a in data.get("articles", []): items.append({ "title": a.get("title", ""), "link": a.get("url", ""), "source": (a.get("source") or {}).get("name", ""), "published": a.get("publishedAt", ""), "image": a.get("image"), }) return items @app.route("/news", methods=["GET"]) def get_news(): query = request.args.get("q", "Uganda technology") now = time.time() if _news_cache["data"] and (now - _news_cache["fetched_at"] < NEWS_CACHE_SECONDS): return jsonify({"cached": True, "articles": _news_cache["data"]}) articles = fetch_news(query) _news_cache["data"] = articles _news_cache["fetched_at"] = now return jsonify({"cached": False, "articles": articles}) # --------------------------------------------------------------------------- # Admin / OTA # --------------------------------------------------------------------------- @app.route("/admin/upload-ota", methods=["POST"]) @require_admin def admin_upload_ota(): """Admin uploads a zip of the dist/ folder from `npx expo export --platform android --output-dir dist` (multipart 'file' + form fields 'runtime_version', optional 'notes').""" if "file" not in request.files: return jsonify({"error": "No file provided"}), 400 file = request.files["file"] runtime_version = (request.form.get("runtime_version") or "").strip() notes = request.form.get("notes") if not runtime_version: return jsonify({"error": "runtime_version is required"}), 400 result, error = ota.process_upload(file, runtime_version, notes) if error: body, status = error return jsonify(body), status return jsonify(result) @app.route("/api/manifest", methods=["GET"]) def api_manifest(): """The endpoint expo-updates itself calls (set as `updates.url` in app.json). Implements the Expo Updates protocol's plain-JSON response path (no code signing; protocol version 1's 'no update' case is a 406, per spec).""" protocol_version = request.headers.get("expo-protocol-version", "0") platform = request.headers.get("expo-platform") or request.args.get("platform") runtime_version = request.headers.get("expo-runtime-version") or request.args.get("runtime-version") current_update_id = request.headers.get("expo-current-update-id") manifest, error = ota.build_manifest(protocol_version, platform, runtime_version, current_update_id) if error: body, status = error return jsonify(body), status resp = jsonify(manifest) resp.headers["expo-protocol-version"] = protocol_version resp.headers["expo-sfv-version"] = "0" resp.headers["cache-control"] = "private, max-age=0" return resp @app.route("/health", methods=["GET"]) def health(): return jsonify({"status": "ok"}) if __name__ == "__main__": app.run(host="0.0.0.0", port=7860)