Spaces:
Running
Running
| """Admin routes — login, accounts, device auth, settings, keys.""" | |
| from __future__ import annotations | |
| import json | |
| import httpx | |
| from fastapi import APIRouter, Depends, Request | |
| from fastapi.responses import JSONResponse | |
| from pydantic import BaseModel | |
| from ..core.auth import verify_api_key, verify_login, create_login_token | |
| from ..core import config | |
| from ..core.config import update_setting | |
| from ..core.logger import get_logger, set_level | |
| from ..services.token_manager import OB1TokenManager, DEVICE_AUTH_URL, OB1_WORKOS_AUTH_URL | |
| from ..services.api_key_manager import ApiKeyManager | |
| log = get_logger("admin") | |
| router = APIRouter(prefix="/admin") | |
| # Public login route (no auth) | |
| login_router = APIRouter() | |
| _tm: OB1TokenManager = None | |
| _km: ApiKeyManager = None | |
| def init(token_manager: OB1TokenManager, key_manager: ApiKeyManager): | |
| global _tm, _km | |
| _tm = token_manager | |
| _km = key_manager | |
| # ===== Login (public) ===== | |
| class LoginRequest(BaseModel): | |
| username: str | |
| password: str | |
| async def login(req: LoginRequest): | |
| if verify_login(req.username, req.password): | |
| token = create_login_token(req.username) | |
| return {"success": True, "token": token} | |
| return JSONResponse(status_code=401, content={"success": False, "message": "用户名或密码错误"}) | |
| # ===== Protected routes ===== | |
| _auth = [Depends(verify_api_key)] | |
| async def status(): | |
| return {"loaded": _tm.is_loaded, "user": _tm.user_email, "org": _tm.org_id, "current_idx": _tm.current_idx, **_tm.stats} | |
| # ===== Accounts ===== | |
| async def list_accounts(): | |
| return {"accounts": _tm.list_accounts(), "stats": _tm.stats} | |
| async def refresh_account(idx: int): | |
| ok = await _tm.refresh_account(idx) | |
| return {"ok": ok, "error": "" if ok else "refresh failed"} | |
| async def remove_account(idx: int): | |
| ok = _tm.remove_account(idx) | |
| return {"ok": ok} | |
| async def force_refresh(): | |
| ok = await _tm.refresh() | |
| return {"ok": ok} | |
| async def export_accounts(): | |
| return {"accounts": [a.to_dict() for a in _tm._accounts]} | |
| class ImportRequest(BaseModel): | |
| accounts: list[dict] | |
| async def import_accounts(req: ImportRequest): | |
| count = _tm.import_accounts(req.accounts) | |
| return {"ok": True, "imported": count} | |
| class PushRequest(BaseModel): | |
| refresh_tokens: list[str] | None = None | |
| accounts: list[dict] | None = None | |
| def _verify_api_key_from_request(request: Request) -> bool: | |
| from ..core.auth import _extract_token | |
| token = _extract_token(request) | |
| return bool(token and _km and _km.validate(token)) | |
| async def push_accounts(req: PushRequest, request: Request): | |
| """外部推送账号接口(API Key 鉴权),支持两种模式: | |
| 1. refresh_tokens: 只传 RT,服务端自动刷新补全 | |
| 2. accounts: 传完整账号数据,直接导入 | |
| """ | |
| if not _verify_api_key_from_request(request): | |
| return JSONResponse(status_code=401, content={"ok": False, "message": "Invalid API Key"}) | |
| imported = 0 | |
| errors = [] | |
| # 模式1:完整账号数据直接导入 | |
| if req.accounts: | |
| imported = _tm.import_accounts(req.accounts) | |
| # 模式2:只传 refresh_token,自动刷新 | |
| if req.refresh_tokens: | |
| from ..services.token_manager import Account | |
| for rt in req.refresh_tokens: | |
| rt = rt.strip() | |
| if not rt: | |
| continue | |
| existing_rts = {a.refresh_token for a in _tm._accounts} | |
| if rt in existing_rts: | |
| errors.append({"refresh_token": rt[:20] + "...", "error": "已存在"}) | |
| continue | |
| acct = Account({"refresh_token": rt, "expires_at": 0}) | |
| _tm._accounts.append(acct) | |
| idx = len(_tm._accounts) - 1 | |
| ok = await _tm.refresh_account(idx, force=True) | |
| if ok: | |
| imported += 1 | |
| else: | |
| _tm._accounts.pop(idx) | |
| errors.append({"refresh_token": rt[:20] + "...", "error": "刷新失败"}) | |
| if imported: | |
| _tm._save() | |
| return {"ok": True, "imported": imported, "errors": errors} | |
| class BatchDeleteRequest(BaseModel): | |
| indices: list[int] | |
| async def batch_delete_accounts(req: BatchDeleteRequest): | |
| removed = _tm.batch_remove(req.indices) | |
| return {"ok": True, "removed": removed} | |
| # ===== Device Auth ===== | |
| async def start_device_auth(): | |
| try: | |
| proxy = config.PROXY_URL or None | |
| async with httpx.AsyncClient(proxy=proxy, timeout=15) as client: | |
| resp = await client.post( | |
| DEVICE_AUTH_URL, | |
| data={"client_id": config.OB1_WORKOS_CLIENT_ID}, | |
| headers={"Content-Type": "application/x-www-form-urlencoded"}, | |
| ) | |
| if resp.status_code != 200: | |
| return {"error": f"WorkOS returned {resp.status_code}"} | |
| return resp.json() | |
| except Exception as e: | |
| return {"error": str(e)} | |
| class PollRequest(BaseModel): | |
| device_code: str | |
| async def poll_device_auth(req: PollRequest): | |
| try: | |
| proxy = config.PROXY_URL or None | |
| async with httpx.AsyncClient(proxy=proxy, timeout=15) as client: | |
| resp = await client.post( | |
| OB1_WORKOS_AUTH_URL, | |
| data={ | |
| "grant_type": "urn:ietf:params:oauth:grant-type:device_code", | |
| "device_code": req.device_code, | |
| "client_id": config.OB1_WORKOS_CLIENT_ID, | |
| }, | |
| headers={"Content-Type": "application/x-www-form-urlencoded"}, | |
| timeout=15, | |
| ) | |
| if resp.status_code == 200: | |
| result = resp.json() | |
| email = await _tm.add_account_from_device(result) | |
| return {"status": "complete", "email": email} | |
| body = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {} | |
| error = body.get("error", "") | |
| if error == "authorization_pending": | |
| return {"status": "pending", "message": "等待用户授权..."} | |
| if error == "slow_down": | |
| return {"status": "pending", "message": "请稍候..."} | |
| if error == "expired_token": | |
| return {"status": "expired", "message": "授权已过期"} | |
| return {"status": "error", "message": body.get("error_description", error or f"HTTP {resp.status_code}")} | |
| except Exception as e: | |
| return {"status": "error", "message": str(e)} | |
| # ===== API Keys ===== | |
| class CreateKeyRequest(BaseModel): | |
| name: str = "" | |
| async def list_keys(): | |
| return {"keys": _km.list_keys()} | |
| async def create_key(req: CreateKeyRequest): | |
| key = _km.create_key(req.name) | |
| return {"ok": True, "key": key} | |
| async def delete_key(key: str): | |
| ok = _km.delete_key(key) | |
| return {"ok": ok} | |
| async def toggle_key(key: str): | |
| ok = _km.toggle_key(key) | |
| return {"ok": ok} | |
| # ===== Settings ===== | |
| async def get_settings(): | |
| return { | |
| "username": config.ADMIN_USERNAME, | |
| "api_key": _km._keys[0].key if _km and _km._keys else config.API_KEY, | |
| "proxy_url": config.PROXY_URL, | |
| "max_retries": config.MAX_RETRIES, | |
| "retry_delay": config.RETRY_DELAY, | |
| "rotation_mode": config.OB1_ROTATION_MODE, | |
| "refresh_interval": config.OB1_REFRESH_INTERVAL, | |
| "log_level": config.LOG_LEVEL, | |
| } | |
| class PasswordUpdate(BaseModel): | |
| old_password: str | |
| new_password: str | |
| async def update_password(req: PasswordUpdate): | |
| if req.old_password != config.ADMIN_PASSWORD: | |
| return JSONResponse(status_code=400, content={"ok": False, "message": "旧密码错误"}) | |
| update_setting("admin", "password", req.new_password) | |
| return {"ok": True} | |
| class UsernameUpdate(BaseModel): | |
| username: str | |
| async def update_username(req: UsernameUpdate): | |
| update_setting("admin", "username", req.username) | |
| return {"ok": True} | |
| class ApiKeyUpdate(BaseModel): | |
| api_key: str | |
| async def update_api_key_setting(req: ApiKeyUpdate): | |
| old_key = config.API_KEY | |
| update_setting("global", "api_key", req.api_key) | |
| # Sync to ApiKeyManager so the new key is immediately usable | |
| if _km: | |
| _km.delete_key(old_key) | |
| _km.create_key_with_value(req.api_key, "默认密钥") | |
| return {"ok": True} | |
| class ProxyUpdate(BaseModel): | |
| url: str = "" | |
| async def update_proxy(req: ProxyUpdate): | |
| update_setting("proxy", "url", req.url) | |
| return {"ok": True} | |
| class ProxyTestRequest(BaseModel): | |
| url: str = "" | |
| async def test_proxy(req: ProxyTestRequest): | |
| proxy_url = req.url.strip() | |
| if not proxy_url: | |
| return {"ok": False, "error": "代理地址为空"} | |
| try: | |
| async with httpx.AsyncClient(proxy=proxy_url, timeout=10) as client: | |
| resp = await client.get("https://httpbin.org/ip") | |
| if resp.status_code == 200: | |
| ip = resp.json().get("origin", "unknown") | |
| return {"ok": True, "ip": ip} | |
| return {"ok": False, "error": f"HTTP {resp.status_code}"} | |
| except Exception as e: | |
| return {"ok": False, "error": str(e)} | |
| class RetryUpdate(BaseModel): | |
| max_retries: int = 3 | |
| retry_delay: int = 1 | |
| async def update_retry(req: RetryUpdate): | |
| update_setting("retry", "max_retries", req.max_retries) | |
| update_setting("retry", "retry_delay", req.retry_delay) | |
| return {"ok": True} | |
| class RotationModeUpdate(BaseModel): | |
| mode: str | |
| async def update_rotation_mode(req: RotationModeUpdate): | |
| if req.mode not in ("cache-first", "balanced", "performance"): | |
| return JSONResponse(status_code=400, content={"ok": False, "message": "无效的调度模式"}) | |
| update_setting("ob1", "rotation_mode", req.mode) | |
| return {"ok": True} | |
| class LogLevelUpdate(BaseModel): | |
| level: str | |
| async def update_log_level(req: LogLevelUpdate): | |
| lvl = req.level.upper() | |
| if lvl not in ("DEBUG", "INFO", "WARNING", "ERROR"): | |
| return JSONResponse(status_code=400, content={"ok": False, "message": "无效的日志级别"}) | |
| update_setting("logging", "level", lvl) | |
| set_level(lvl) | |
| return {"ok": True} | |
| class RefreshIntervalUpdate(BaseModel): | |
| interval: int = 0 | |
| async def update_refresh_interval(req: RefreshIntervalUpdate): | |
| if req.interval < 0: | |
| return JSONResponse(status_code=400, content={"ok": False, "message": "刷新间隔不能为负数"}) | |
| update_setting("ob1", "refresh_interval", req.interval) | |
| # Restart the periodic refresh task | |
| from ..main import restart_auto_refresh | |
| restart_auto_refresh() | |
| return {"ok": True} | |