Spaces:
Running
Running
| """ | |
| 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): | |
| 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): | |
| 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 | |
| # --------------------------------------------------------------------------- | |
| 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"}) | |
| 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"}) | |
| def login_page(): | |
| return send_from_directory(".", "login.html") | |
| # --------------------------------------------------------------------------- | |
| # AI features | |
| # --------------------------------------------------------------------------- | |
| 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}) | |
| 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}) | |
| 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}) | |
| 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) | |
| # --------------------------------------------------------------------------- | |
| 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) | |
| 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) | |
| # --------------------------------------------------------------------------- | |
| 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) | |
| 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) | |
| 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 | |
| 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 | |
| # --------------------------------------------------------------------------- | |
| 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) | |
| 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 | |
| def health(): | |
| return jsonify({"status": "ok"}) | |
| if __name__ == "__main__": | |
| app.run(host="0.0.0.0", port=7860) | |