Browser-back / app.py
Nrighton233j
Fix merge conflict resolution: remove literal conflict-marker text
9cb3523
Raw
History Blame Contribute Delete
11 kB
"""
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)