diff --git a/llm_team_ui.py b/llm_team_ui.py index 958b44b..ca3a42d 100644 --- a/llm_team_ui.py +++ b/llm_team_ui.py @@ -19,15 +19,33 @@ from functools import wraps app = Flask(__name__) app.secret_key = os.environ.get("FLASK_SECRET", secrets.token_hex(32)) -# ─── AUTH ───────────────────────────────────────────────────── +# ─── AUTH + DEMO MODE ───────────────────────────────────────── _rate_limit = {} # ip -> (count, window_start) -RATE_LIMIT_WINDOW = 60 # seconds -RATE_LIMIT_MAX = 60 # requests per window -LOGIN_RATE_MAX = 5 # login attempts per window +RATE_LIMIT_WINDOW = 60 +RATE_LIMIT_MAX = 60 +LOGIN_RATE_MAX = 5 + +# IPs that never get rate-limited (your LAN, localhost) +ALLOWLIST_IPS = {"127.0.0.1", "::1", "192.168.1.1"} +# Demo mode state — toggled by admin at runtime +_demo_mode = {"active": False, "started_by": None} + +# Admin-only write routes — blocked in demo for non-admin users +ADMIN_WRITE_ROUTES = { + "/api/admin/config": ["POST"], + "/api/admin/test-provider": ["POST"], + "/api/auth/login": ["POST"], +} + + +def is_allowlisted(ip): + return ip in ALLOWLIST_IPS or ip.startswith("192.168.1.") def rate_limited(ip, max_req=RATE_LIMIT_MAX): + if is_allowlisted(ip): + return False now = time.time() if ip not in _rate_limit or now - _rate_limit[ip][1] > RATE_LIMIT_WINDOW: _rate_limit[ip] = (1, now) @@ -39,10 +57,21 @@ def rate_limited(ip, max_req=RATE_LIMIT_MAX): return False +def is_admin(): + return session.get("role") == "admin" + + +def is_demo(): + return _demo_mode["active"] + + def login_required(f): @wraps(f) def decorated(*args, **kwargs): - if not session.get("user_id"): + # Demo mode: everyone gets in + if is_demo() and not session.get("user_id"): + session["demo_user"] = True + if not session.get("user_id") and not is_demo(): if request.path.startswith("/api/"): return jsonify({"error": "unauthorized"}), 401 return redirect("/login") @@ -53,7 +82,15 @@ def login_required(f): def admin_required(f): @wraps(f) def decorated(*args, **kwargs): + # Demo mode: allow read access (GET), block writes unless admin + if is_demo(): + if request.method == "GET": + return f(*args, **kwargs) + if not is_admin(): + return jsonify({"error": "demo mode: read-only", "demo": True}), 403 if not session.get("user_id"): + if request.path.startswith("/api/"): + return jsonify({"error": "unauthorized"}), 401 return redirect("/login") if session.get("role") != "admin": return "Forbidden", 403 @@ -64,14 +101,19 @@ def admin_required(f): @app.before_request def security_checks(): ip = request.remote_addr - # Rate limit + # Rate limit (allowlisted IPs skip) if rate_limited(ip): return jsonify({"error": "rate limited"}), 429 - # Allow login/static without auth - if request.path in ("/login", "/api/auth/login", "/api/auth/setup"): + # Always allow these + if request.path in ("/login", "/api/auth/login", "/api/auth/setup", "/api/demo/status"): return if request.path.startswith("/static"): return + # In demo mode, block admin write routes for non-admins + if is_demo() and not is_admin(): + for route, methods in ADMIN_WRITE_ROUTES.items(): + if request.path == route and request.method in methods: + return jsonify({"error": "demo mode: admin settings are read-only", "demo": True}), 403 @app.after_request @@ -238,6 +280,44 @@ def logout_page(): session.clear() return redirect("/login") + +@app.route("/api/demo/status") +def demo_status(): + return jsonify({"active": is_demo(), "started_by": _demo_mode.get("started_by")}) + + +@app.route("/api/demo/toggle", methods=["POST"]) +def demo_toggle(): + if not is_admin(): + return jsonify({"error": "admin only"}), 403 + _demo_mode["active"] = not _demo_mode["active"] + _demo_mode["started_by"] = session.get("username") if _demo_mode["active"] else None + return jsonify({"active": _demo_mode["active"]}) + + +@app.route("/api/demo/allowlist", methods=["GET"]) +def demo_get_allowlist(): + if not is_admin(): + return jsonify({"error": "admin only"}), 403 + return jsonify({"ips": sorted(ALLOWLIST_IPS)}) + + +@app.route("/api/demo/allowlist", methods=["POST"]) +def demo_set_allowlist(): + if not is_admin(): + return jsonify({"error": "admin only"}), 403 + data = request.json or {} + ip = data.get("ip", "").strip() + action = data.get("action", "add") + if not ip: + return jsonify({"error": "ip required"}), 400 + if action == "add": + ALLOWLIST_IPS.add(ip) + elif action == "remove" and ip in ALLOWLIST_IPS: + ALLOWLIST_IPS.discard(ip) + return jsonify({"ips": sorted(ALLOWLIST_IPS)}) + + CONFIG_PATH = "/root/llm_team_config.json" DEFAULT_CONFIG = { "providers": { @@ -461,6 +541,7 @@ HTML = r""" Lab Admin + Logout @@ -1050,7 +1131,46 @@ async function deleteRun(id) { renderHistoryList(); } +// ─── DEMO MODE ─────────────────────────────── +async function checkDemo() { + try { + const r = await fetch('/api/demo/status'); + const d = await r.json(); + updateDemoUI(d.active); + } catch(e) {} +} + +function updateDemoUI(active) { + const btn = document.getElementById('demo-toggle'); + const banner = document.getElementById('demo-banner'); + if (btn) { + btn.style.display = ''; + btn.textContent = active ? 'Demo ON' : 'Demo'; + btn.style.color = active ? '#22c55e' : 'var(--orange)'; + btn.style.borderColor = active ? 'rgba(34,197,94,0.4)' : 'rgba(245,158,11,0.3)'; + } + if (active) { + if (!banner) { + const b = document.createElement('div'); + b.id = 'demo-banner'; + b.style.cssText = 'position:fixed;top:0;left:0;right:0;background:rgba(34,197,94,0.1);border-bottom:1px solid rgba(34,197,94,0.3);color:#22c55e;text-align:center;font-size:11px;padding:4px;z-index:50;font-weight:500'; + b.textContent = 'DEMO MODE — public access enabled'; + document.body.prepend(b); + } + } else if (banner) { + banner.remove(); + } +} + +async function toggleDemo() { + const r = await fetch('/api/demo/toggle', {method:'POST'}); + const d = await r.json(); + if (d.error) return; + updateDemoUI(d.active); +} + loadModels(); +checkDemo();
+ + +When active, the public can view and use the Team UI, Lab, and all modes without logging in. Admin settings (API keys, config saves) are read-only for non-admins.
+These IPs are never rate-limited. Your local network (192.168.1.*) is always allowed.
+ +