from fastapi import APIRouter, Depends, Form, HTTPException, Request from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from sqlalchemy import func from sqlalchemy.orm import Session from app import config, discovery, security from app.admin.deps import current_user from app.database import get_db from app.models import ApiKey, Endpoint, RequestLog, Service, User router = APIRouter(prefix="/admin") templates = Jinja2Templates(directory=str(config.BASE_DIR / "app" / "templates")) # Slugs the proxy catch-all must never claim RESERVED_SLUGS = {"admin", "static", "health", "docs", "redoc", "openapi.json"} def build_tree(endpoints: list[Endpoint]) -> dict: """Nest endpoints by path segment for the hierarchical access picker. Node: {name, children: {segment: node}, endpoints: [Endpoint]}. Chains of empty single-child nodes are compressed ('api' + 'v1' -> 'api/v1').""" root = {"name": "", "children": {}, "endpoints": []} for e in endpoints: node = root for part in (p for p in e.path.split("/") if p): node = node["children"].setdefault( part, {"name": part, "children": {}, "endpoints": []}) node["endpoints"].append(e) def compress(node: dict) -> None: for key in list(node["children"]): child = node["children"][key] while not child["endpoints"] and len(child["children"]) == 1: (grandchild,) = child["children"].values() child["name"] = child["name"] + "/" + grandchild["name"] child["endpoints"] = grandchild["endpoints"] child["children"] = grandchild["children"] compress(child) if child["name"] != key: node["children"][child["name"]] = node["children"].pop(key) compress(root) return root def render(request: Request, name: str, user: User | None = None, **ctx): return templates.TemplateResponse( request, name, {"user": user, "active": name.split(".")[0], **ctx} ) def _redirect(url: str) -> RedirectResponse: return RedirectResponse(url, status_code=303) # ---------- auth ---------- @router.get("/login", response_class=HTMLResponse) def login_page(request: Request): return render(request, "login.html") @router.post("/login") def login(request: Request, username: str = Form(...), password: str = Form(...), db: Session = Depends(get_db)): user = db.query(User).filter(User.username == username).one_or_none() if not user or not user.is_active or not security.verify_password(password, user.password_hash): return render(request, "login.html", error="Invalid username or password.") response = _redirect("/admin") response.set_cookie( config.SESSION_COOKIE, security.create_session_token(user.id), max_age=config.SESSION_MAX_AGE, httponly=True, samesite="lax", ) return response @router.get("/logout") def logout(): response = _redirect("/admin/login") response.delete_cookie(config.SESSION_COOKIE) return response # ---------- dashboard ---------- @router.get("", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse) def dashboard(request: Request, user: User = Depends(current_user), db: Session = Depends(get_db)): recent = ( db.query(RequestLog).order_by(RequestLog.timestamp.desc()).limit(15).all() ) return render(request, "dashboard.html", user, recent=recent) # ---------- services & endpoints ---------- @router.get("/services", response_class=HTMLResponse) def services_page(request: Request, user: User = Depends(current_user), db: Session = Depends(get_db)): services = db.query(Service).order_by(Service.name).all() counts = dict( db.query(RequestLog.service_id, func.count(RequestLog.id)) .group_by(RequestLog.service_id).all() ) return render(request, "services.html", user, services=services, counts=counts) @router.post("/services") def create_service(name: str = Form(...), slug: str = Form(...), base_url: str = Form(...), description: str = Form(""), timeout_seconds: float = Form(30.0), verify_tls: bool = Form(False), user: User = Depends(current_user), db: Session = Depends(get_db)): slug = slug.strip().lower() if slug in RESERVED_SLUGS: raise HTTPException(400, f"Slug '{slug}' is reserved by the gateway itself.") if db.query(Service).filter(Service.slug == slug).count(): raise HTTPException(400, f"Slug '{slug}' is already taken.") service = Service(name=name.strip(), slug=slug, base_url=base_url.strip().rstrip("/"), description=description.strip(), timeout_seconds=timeout_seconds, verify_tls=verify_tls) db.add(service) db.commit() # The page auto-validates the new service (which also caches its endpoints). return _redirect(f"/admin/services?validate={service.id}") @router.post("/services/{service_id}/update") def update_service(service_id: int, name: str = Form(...), base_url: str = Form(...), description: str = Form(""), timeout_seconds: float = Form(30.0), verify_tls: bool = Form(False), user: User = Depends(current_user), db: Session = Depends(get_db)): service = db.get(Service, service_id) if not service: raise HTTPException(404) service.name, service.base_url = name.strip(), base_url.strip().rstrip("/") service.description, service.timeout_seconds = description.strip(), timeout_seconds service.verify_tls = verify_tls db.commit() return _redirect(f"/admin/services?validate={service.id}") @router.post("/services/{service_id}/validate") def validate_service(service_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)): """Probe the upstream; if it publishes an OpenAPI document this also refreshes the endpoint cache.""" service = db.get(Service, service_id) if not service: raise HTTPException(404) return discovery.validate_service(db, service) @router.post("/services/{service_id}/toggle") def toggle_service(service_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)): service = db.get(Service, service_id) if not service: raise HTTPException(404) service.is_active = not service.is_active db.commit() return _redirect("/admin/services") @router.post("/services/{service_id}/delete") def delete_service(service_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)): service = db.get(Service, service_id) if service: db.delete(service) db.commit() return _redirect("/admin/services") # ---------- users ---------- @router.get("/users", response_class=HTMLResponse) def users_page(request: Request, user: User = Depends(current_user), db: Session = Depends(get_db)): users = db.query(User).order_by(User.username).all() return render(request, "users.html", user, users=users) @router.post("/users") def create_user(username: str = Form(...), password: str = Form(...), user: User = Depends(current_user), db: Session = Depends(get_db)): username = username.strip() if db.query(User).filter(User.username == username).count(): raise HTTPException(400, f"Username '{username}' is already taken.") db.add(User(username=username, password_hash=security.hash_password(password))) db.commit() return _redirect("/admin/users") @router.post("/users/{user_id}/toggle") def toggle_user(user_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)): target = db.get(User, user_id) if not target: raise HTTPException(404) if target.id == user.id: raise HTTPException(400, "You cannot deactivate your own account.") target.is_active = not target.is_active db.commit() return _redirect("/admin/users") @router.post("/users/{user_id}/password") def reset_password(user_id: int, password: str = Form(...), user: User = Depends(current_user), db: Session = Depends(get_db)): target = db.get(User, user_id) if not target: raise HTTPException(404) target.password_hash = security.hash_password(password) db.commit() return _redirect("/admin/users") @router.post("/users/{user_id}/delete") def delete_user(user_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)): if user_id == user.id: raise HTTPException(400, "You cannot delete your own account.") target = db.get(User, user_id) if target: db.delete(target) db.commit() return _redirect("/admin/users") # ---------- API keys / endpoint access ---------- @router.get("/keys", response_class=HTMLResponse) def keys_page(request: Request, user: User = Depends(current_user), db: Session = Depends(get_db)): services = db.query(Service).order_by(Service.name).all() # Rendered straight from the database — the OpenAPI sync runs in the # background via /admin/api/endpoints/sync, triggered by the page's JS. trees = {s.id: build_tree(s.endpoints) for s in services} synced = {s.id: discovery.spec_status(s.id) for s in services} keys = db.query(ApiKey).order_by(ApiKey.created_at.desc()).all() users = db.query(User).filter(User.is_active).order_by(User.username).all() new_key = request.query_params.get("new_key") return render(request, "keys.html", user, keys=keys, users=users, services=services, trees=trees, synced=synced, new_key=new_key) @router.get("/api/endpoints/sync") def sync_endpoints(force: bool = False, user: User = Depends(current_user), db: Session = Depends(get_db)): """Refresh all endpoint catalogs from their OpenAPI documents (TTL-cached). The keys page calls this in the background and reloads if anything changed.""" changed_any = False specs = {} for service in db.query(Service).order_by(Service.name).all(): found, changed = discovery.sync_service(db, service, force=force) specs[service.id] = found changed_any = changed_any or changed return {"changed": changed_any, "specs": specs} @router.post("/keys") def create_key(request: Request, name: str = Form(...), user_id: int = Form(...), rate_limit_per_minute: int = Form(60), endpoint_ids: list[int] = Form([]), user: User = Depends(current_user), db: Session = Depends(get_db)): owner = db.get(User, user_id) if not owner: raise HTTPException(400, "Unknown user.") plain, prefix, key_hash = security.generate_api_key() key = ApiKey(user_id=owner.id, name=name.strip(), prefix=prefix, key_hash=key_hash, rate_limit_per_minute=max(0, rate_limit_per_minute)) key.endpoints = db.query(Endpoint).filter( Endpoint.id.in_(endpoint_ids)).all() if endpoint_ids else [] db.add(key) db.commit() # Shown once on the next page load; never stored in plain text. return _redirect(f"/admin/keys?new_key={plain}") @router.post("/keys/{key_id}/access") def update_key_access(key_id: int, endpoint_ids: list[int] = Form([]), rate_limit_per_minute: int = Form(60), user: User = Depends(current_user), db: Session = Depends(get_db)): key = db.get(ApiKey, key_id) if not key: raise HTTPException(404) key.endpoints = db.query(Endpoint).filter( Endpoint.id.in_(endpoint_ids)).all() if endpoint_ids else [] key.rate_limit_per_minute = max(0, rate_limit_per_minute) db.commit() return _redirect("/admin/keys") @router.post("/keys/{key_id}/toggle") def toggle_key(key_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)): key = db.get(ApiKey, key_id) if not key: raise HTTPException(404) key.is_active = not key.is_active db.commit() return _redirect("/admin/keys") @router.post("/keys/{key_id}/delete") def delete_key(key_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)): key = db.get(ApiKey, key_id) if key: db.delete(key) db.commit() return _redirect("/admin/keys") # ---------- request browser ---------- PAGE_SIZE = 50 def _int_or_none(value: str | None) -> int | None: """HTML GET forms submit empty strings for untouched fields — treat anything non-numeric as 'no filter' instead of a validation error.""" try: return int(value) if value else None except ValueError: return None @router.get("/requests", response_class=HTMLResponse) def requests_page(request: Request, user: User = Depends(current_user), db: Session = Depends(get_db), service_id: str | None = None, user_id: str | None = None, key_id: str | None = None, status_class: str | None = None, q: str | None = None, page: str | None = None): service_id = _int_or_none(service_id) user_id = _int_or_none(user_id) key_id = _int_or_none(key_id) page = _int_or_none(page) or 1 query = db.query(RequestLog) if service_id: query = query.filter(RequestLog.service_id == service_id) if key_id: query = query.filter(RequestLog.api_key_id == key_id) if user_id: query = query.join(ApiKey, RequestLog.api_key_id == ApiKey.id).filter( ApiKey.user_id == user_id) if status_class in ("2", "3", "4", "5"): low = int(status_class) * 100 query = query.filter(RequestLog.status_code >= low, RequestLog.status_code < low + 100) if q: query = query.filter(RequestLog.path.contains(q)) total = query.count() page = max(1, page) logs = (query.order_by(RequestLog.timestamp.desc()) .offset((page - 1) * PAGE_SIZE).limit(PAGE_SIZE).all()) services = db.query(Service).order_by(Service.name).all() users = db.query(User).order_by(User.username).all() keys = db.query(ApiKey).order_by(ApiKey.name).all() return render(request, "requests.html", user, logs=logs, total=total, page=page, pages=max(1, -(-total // PAGE_SIZE)), services=services, users=users, keys=keys, f={"service_id": service_id, "user_id": user_id, "key_id": key_id, "status_class": status_class or "", "q": q or ""}) def _pretty_json(text: str) -> str: import json try: return json.dumps(json.loads(text), indent=2, ensure_ascii=False) except (ValueError, TypeError): return text @router.get("/requests/{log_id}/data") def request_data(log_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)): """Everything the inline request inspector needs, as JSON.""" log = db.get(RequestLog, log_id) if not log: raise HTTPException(404) return { "id": log.id, "time": log.timestamp.strftime("%Y-%m-%d %H:%M:%S"), "status": log.status_code, "latency_ms": round(log.latency_ms, 1), "method": log.method, "path": log.path, "query_string": log.query_string, "service": log.service.name if log.service else None, "slug": log.service.slug if log.service else None, "forwarded_to": (log.service.base_url + log.path + ("?" + log.query_string if log.query_string else "")) if log.service else None, "endpoint": f"{log.endpoint.method} {log.endpoint.path}" if log.endpoint else None, "endpoint_description": log.endpoint.description if log.endpoint else "", "key": log.api_key.name if log.api_key else None, "key_prefix": log.api_key.prefix if log.api_key else "", "user": log.api_key.user.username if log.api_key else None, "client_ip": log.client_ip, "request_body": _pretty_json(log.request_body), "response_body": _pretty_json(log.response_body), } # ---------- monitoring ---------- @router.get("/monitoring", response_class=HTMLResponse) def monitoring_page(request: Request, user: User = Depends(current_user), db: Session = Depends(get_db)): logs = db.query(RequestLog).order_by(RequestLog.timestamp.desc()).limit(100).all() services = db.query(Service).order_by(Service.name).all() users = db.query(User).order_by(User.username).all() keys = db.query(ApiKey).order_by(ApiKey.name).all() return render(request, "monitoring.html", user, logs=logs, services=services, users=users, keys=keys)