"""Local account-management service (``tripocli serve``). Serves a small web page plus a JSON API so cookies can be added, updated and inspected from a browser instead of the terminal: GET / the page GET /api/accounts list accounts (identity, session expiry, credits) POST /api/accounts add/update an account {name?, cookie} POST /api/accounts//activate make it the active account POST /api/accounts//refresh re-validate the cookie and re-mint the JWT DELETE /api/accounts/ remove an account The page never receives a full cookie — only a masked form. The server binds to loopback by default because whoever can reach it can act as the signed-in user. """ import json import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import unquote, urlparse from . import auth from .accounts import Accounts from .client import TripoClient from .config import Config from .errors import TripoAPIError PAGE = Path(__file__).parent / "web" / "accounts.html" CREDIT_CACHE_SECONDS = 30 def _mask(value, keep=8): if not value: return None return f"{value[:keep]}…{value[-4:]}" if len(value) > keep + 6 else "***" class AccountService: """Account store plus a short-lived credits cache for the list view.""" def __init__(self, accounts=None, config=None): self.accounts = accounts or Accounts() self.config = config or Config() self._lock = threading.Lock() self._cache = {} # name -> (timestamp, payload) # --- helpers --- def _client(self, name=None): return TripoClient(account=name, accounts=self.accounts, config=self.config) def _live_status(self, name): """Credits/membership for one account, cached briefly to keep the page snappy.""" cached = self._cache.get(name) if cached and time.time() - cached[0] < CREDIT_CACHE_SECONDS: return cached[1] status = {"total_credit": None, "expiring_credit": None, "expiring_date": None, "member_type": None, "member_valid_until": None, "token_expires_at": None, "error": None} try: client = self._client(name) client.ensure_credentials() profile = client.payment_profile() or {} wallet = profile.get("wallet") or {} member = profile.get("member") or {} status.update({ "total_credit": wallet.get("total_credit"), "expiring_credit": wallet.get("expiring_credit"), "expiring_date": wallet.get("expiring_date"), "member_type": member.get("type"), "member_valid_until": member.get("valid_until"), "token_expires_at": _iso(client.token), }) except Exception as error: status["error"] = str(error)[:200] self._cache[name] = (time.time(), status) return status def list_accounts(self): active = self.accounts.active_name() result = [] for name in self.accounts.names(): record = self.accounts.data["accounts"][name] item = { "name": name, "active": name == active, "email": record.get("email"), "user_id": record.get("user_id"), "session_expires_at": record.get("session_expires_at"), "cookie": _mask(record.get("cookie")), } item.update(self._live_status(name)) result.append(item) return {"active": active, "accounts": result} def add_account(self, name, cookie): if not cookie: raise ValueError("cookie 不能为空") token, session_expires = auth.mint_token(self.config.api_base, cookie, self.config.device_id) try: user_id, email = auth.fetch_identity(self.config.api_base, cookie, self.config.device_id) except Exception: user_id, email = None, None name = (name or email or user_id or "default").strip() with self._lock: self.accounts.add(name, auth.normalize_cookie(cookie), token=token, session_expires_at=session_expires, user_id=user_id, email=email) self._cache.pop(name, None) return {"name": name, "email": email, "user_id": user_id, "session_expires_at": session_expires} def activate(self, name): with self._lock: if not self.accounts.exists(name): raise KeyError(name) self.accounts.use(name) return {"active": name} def remove(self, name): with self._lock: if not self.accounts.exists(name): raise KeyError(name) self.accounts.remove(name) self._cache.pop(name, None) return {"removed": name, "active": self.accounts.active_name()} def refresh(self, name): with self._lock: _, record = self.accounts.get(name) if not record: raise KeyError(name) cookie = record.get("cookie") if not cookie: raise ValueError(f"账号 {name} 没有 cookie") token, session_expires = auth.mint_token(self.config.api_base, cookie, self.config.device_id) with self._lock: self.accounts.update_token(name, token, session_expires) self._cache.pop(name, None) return {"name": name, "token_expires_at": _iso(token), "session_expires_at": session_expires} def _iso(token): exp = auth.decode_jwt_payload(token or "").get("exp") if not exp: return None return time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(int(exp))) class Handler(BaseHTTPRequestHandler): server_version = "tripocli" service: AccountService = None # --- plumbing --- def log_message(self, fmt, *args): print(f"[serve] {self.address_string()} {fmt % args}") def _send(self, status, payload, content_type="application/json; charset=utf-8"): body = payload if isinstance(payload, bytes) else json.dumps(payload, ensure_ascii=False).encode() self.send_response(status) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") self.end_headers() if self.command != "HEAD": self.wfile.write(body) def _error(self, status, message): self._send(status, {"error": str(message)}) def _read_json(self): length = int(self.headers.get("Content-Length") or 0) if not length: return {} try: return json.loads(self.rfile.read(length).decode("utf-8")) except json.JSONDecodeError: raise ValueError("请求体不是合法 JSON") # --- routing --- def _segments(self): path = urlparse(self.path).path return [unquote(part) for part in path.strip("/").split("/") if part] def do_GET(self): parts = self._segments() if not parts: try: self._send(200, PAGE.read_bytes(), "text/html; charset=utf-8") except OSError as error: self._error(500, f"页面文件读取失败: {error}") return if parts == ["api", "accounts"]: self._send(200, self.service.list_accounts()) return if parts == ["api", "health"]: self._send(200, {"ok": True}) return self._error(404, "not found") def do_POST(self): parts = self._segments() try: if parts == ["api", "accounts"]: body = self._read_json() self._send(200, self.service.add_account(body.get("name", ""), body.get("cookie", ""))) return if len(parts) == 4 and parts[:2] == ["api", "accounts"]: name, action = parts[2], parts[3] if action == "activate": self._send(200, self.service.activate(name)) return if action == "refresh": self._send(200, self.service.refresh(name)) return except KeyError as error: self._error(404, f"账号不存在: {error.args[0]}") return except (ValueError, TripoAPIError) as error: self._error(400, error) return except Exception as error: self._error(500, error) return self._error(404, "not found") def do_DELETE(self): parts = self._segments() if len(parts) == 3 and parts[:2] == ["api", "accounts"]: try: self._send(200, self.service.remove(parts[2])) except KeyError as error: self._error(404, f"账号不存在: {error.args[0]}") except Exception as error: self._error(500, error) return self._error(404, "not found") def create_server(host="127.0.0.1", port=8765, service=None): handler = type("BoundHandler", (Handler,), {"service": service or AccountService()}) return ThreadingHTTPServer((host, port), handler) def serve(host="127.0.0.1", port=8765): httpd = create_server(host, port) actual_port = httpd.server_address[1] return httpd, f"http://{host}:{actual_port}/"