feat: support real browser auth import

This commit is contained in:
liumangmang
2026-06-02 13:51:29 +08:00
parent f4d16a4c01
commit 84148f4a69
22 changed files with 1651 additions and 111 deletions
+2 -2
View File
@@ -33,7 +33,7 @@ RUN --mount=type=cache,target=/var/cache/apt \
libcairo2 libcups2 libdbus-1-3 libdrm2 libegl1 libfontconfig1 \
libfreetype6 libgbm1 libglib2.0-0t64 libgtk-3-0t64 libnspr4 libnss3 \
libpango-1.0-0 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 \
libxdamage1 libxext6 libxfixes3 libxrandr2 libxshmfence1 xvfb \
libxdamage1 libxext6 libxfixes3 libxrandr2 libxshmfence1 xvfb xauth \
curl \
&& rm -rf /var/lib/apt/lists/*
@@ -63,4 +63,4 @@ ENV DATABASE_URL=sqlite:////app/data/app.db
EXPOSE 8000
ENTRYPOINT ["tini", "--"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["xvfb-run", "-a", "--server-args=-screen 0 1920x1080x24", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+93
View File
@@ -10,6 +10,11 @@ from sqlalchemy.orm import Session
from app.database import get_db
from app.services.auth_capture_service import extract_all
from app.services.browser_import_service import (
ImportSessionError,
browser_imports,
build_import_result,
)
from app.services.browser_session_service import (
BrowserDependencyError,
BrowserSessionError,
@@ -43,6 +48,32 @@ class CaptureExtractResponse(BaseModel):
candidates: list[dict] = []
class ImportSessionCreate(BaseModel):
target_url: str = Field(..., description="Target page URL opened in the user's real browser")
class ImportSessionCreateResponse(BaseModel):
session_id: str
secret: str
expires_in_seconds: int
class ImportSessionStatusResponse(BaseModel):
session_id: str
ready: bool = False
expires_at: float
result: Optional[CaptureExtractResponse] = None
class ImportSessionSubmit(BaseModel):
secret: str
page_url: str = ""
cookies: list[dict] = []
local_storage: dict[str, Any] = {}
session_storage: dict[str, Any] = {}
auth_headers: list[dict] = []
def _sanitize_candidate(candidate: dict[str, Any]) -> dict[str, Any]:
return {
key: value
@@ -134,3 +165,65 @@ async def close_capture_session(
await browser_sessions.close(session_id)
except Exception as exc:
raise _browser_error(exc)
@router.post("/import-sessions", response_model=ImportSessionCreateResponse, status_code=201)
async def create_import_session(
body: ImportSessionCreate,
user=Depends(get_current_user),
):
"""Create a one-time real-browser import session.
The returned secret is intended for the local browser extension only.
"""
target_url = body.target_url.strip()
if not target_url.startswith(("http://", "https://")):
raise HTTPException(400, "Only http/https URLs are allowed")
session, secret = browser_imports.create(target_url=target_url, created_by=user.email)
return ImportSessionCreateResponse(
session_id=session.id,
secret=secret,
expires_in_seconds=600,
)
@router.get("/import-sessions/{session_id}", response_model=ImportSessionStatusResponse)
async def get_import_session(
session_id: str,
include_raw: bool = Query(default=False),
user=Depends(get_current_user),
):
try:
session = browser_imports.get(session_id, created_by=user.email)
except ImportSessionError as exc:
raise HTTPException(404, str(exc))
result = None
if session.payload is not None:
full_result = build_import_result(session.payload)
if not include_raw:
candidates = [_sanitize_candidate(candidate) for candidate in full_result.get("candidates", [])]
result = CaptureExtractResponse(candidates=candidates)
else:
result = CaptureExtractResponse(**full_result)
return ImportSessionStatusResponse(
session_id=session.id,
ready=session.payload is not None,
expires_at=session.expires_at,
result=result,
)
@router.post("/import-sessions/{session_id}/submit", status_code=204)
async def submit_import_session(
session_id: str,
body: ImportSessionSubmit,
):
try:
browser_imports.submit(
session_id=session_id,
secret=body.secret,
payload=body.model_dump(exclude={"secret"}),
)
except ImportSessionError as exc:
raise HTTPException(400, str(exc))
+44 -14
View File
@@ -221,25 +221,45 @@ class RefreshAuthResponse(BaseModel):
warning: Optional[str] = None
def _pick_best_candidate(candidates: list[dict], preferred_auth_type: str) -> Optional[dict]:
def _norm_path(value: Any) -> str:
return str(value or "").strip().rstrip("/")
def _detect_upstream_platform(upstream: Upstream, auth_config: dict) -> str:
api_prefix = _norm_path(upstream.api_prefix)
groups_endpoint = _norm_path(upstream.groups_endpoint)
rate_endpoint = _norm_path(upstream.rate_endpoint)
login_path = _norm_path(auth_config.get("login_path"))
if groups_endpoint == "/api/user/self/groups" or login_path == "/api/user/login":
return "new-api-user"
if api_prefix == "/api/v1" or groups_endpoint in {"/groups/available", "/groups/rates"} or login_path == "/auth/login":
return "sub2api"
return "unknown"
def _first_candidate(candidates: list[dict], *types: str) -> Optional[dict]:
for c in candidates:
if c.get("type") in types:
return c
return None
def _pick_best_candidate(candidates: list[dict], preferred_auth_type: str, platform: str = "unknown") -> Optional[dict]:
if not candidates:
return None
# cookie_bundle > cookie > bearer_token > api_key
# preferred_auth_type="cookie" 时优先匹配 bundle,其次单 cookie
if platform == "sub2api":
return _first_candidate(candidates, "bearer_token", "api_key")
if platform == "new-api-user":
return _first_candidate(candidates, "cookie_bundle", "cookie", "bearer_token", "api_key")
if preferred_auth_type == "cookie":
for c in candidates:
if c["type"] == "cookie_bundle":
return c
for c in candidates:
if c["type"] == "cookie":
return c
return _first_candidate(candidates, "cookie_bundle", "cookie")
elif preferred_auth_type in ("bearer", "api_key"):
type_map = {"bearer": "bearer_token", "api_key": "api_key"}
preferred = type_map.get(preferred_auth_type)
if preferred:
for c in candidates:
if c["type"] == preferred:
return c
return _first_candidate(candidates, preferred)
# fallback:排序后取第一个
return candidates[0]
@@ -268,11 +288,17 @@ async def refresh_auth(pid: int, db: Session = Depends(get_db), _=Depends(get_cu
return RefreshAuthResponse(success=False, message=f"提取失败: {exc}")
candidates = result.get("candidates", [])
candidate = _pick_best_candidate(candidates, upstream.auth_type)
existing_config = _json.loads(upstream.auth_config_json or "{}")
platform = _detect_upstream_platform(upstream, existing_config)
candidate = _pick_best_candidate(candidates, upstream.auth_type, platform)
if not candidate:
if platform == "sub2api" and _first_candidate(candidates, "cookie_bundle", "cookie"):
return RefreshAuthResponse(
success=False,
message="Sub2API 需要 Bearer Token;当前只提取到 Cookie。请在远程浏览器完成登录后刷新页面或触发一次接口请求,再重新提取。",
)
return RefreshAuthResponse(success=False, message="未提取到有效凭证,请确认已在远程浏览器中登录")
existing_config = _json.loads(upstream.auth_config_json or "{}")
ctype = candidate["type"]
if ctype in ("cookie_bundle", "cookie"):
@@ -281,6 +307,10 @@ async def refresh_auth(pid: int, db: Session = Depends(get_db), _=Depends(get_cu
existing_config["cookie_string"] = candidate.get("value", "")
if candidate.get("new_api_user"):
existing_config["new_api_user"] = candidate["new_api_user"]
if platform == "new-api-user":
upstream.api_prefix = ""
upstream.groups_endpoint = "/api/user/self/groups"
upstream.rate_endpoint = "/api/user/self/groups"
elif ctype == "bearer_token":
upstream.auth_type = "bearer"
raw = candidate.get("value", "")
+22 -2
View File
@@ -315,6 +315,26 @@ def list_generated_keys(uid: int, db: Session = Depends(get_db), _=Depends(get_c
_generate_key_lock = __import__("threading").Lock()
def _is_sub2api_upstream(upstream: Upstream) -> bool:
return upstream.api_prefix.strip("/") == "api/v1"
def _is_new_api_user_upstream(upstream: Upstream) -> bool:
auth_config = json.loads(upstream.auth_config_json or "{}")
return (
upstream.api_prefix.strip("/") == ""
and (
upstream.groups_endpoint == "/api/user/self/groups"
or auth_config.get("login_path") == "/api/user/login"
or bool(auth_config.get("new_api_user"))
)
)
def _supports_key_generation(upstream: Upstream) -> bool:
return _is_sub2api_upstream(upstream) or _is_new_api_user_upstream(upstream)
def _ensure_group_key(
db: Session,
client: UpstreamClient,
@@ -465,8 +485,8 @@ def generate_keys_by_groups(
u = db.query(Upstream).filter(Upstream.id == uid).first()
if not u:
raise HTTPException(404, "upstream not found")
if u.api_prefix.strip("/") != "api/v1":
raise HTTPException(400, "首版仅支持 Sub2API 上游(API Prefix 应为 /api/v1")
if not _supports_key_generation(u):
raise HTTPException(400, "仅支持 Sub2API 或 New-API 普通账号上游生成 Key")
# 生成前先对账,清理远端已删除的旧 Key
try:
+20 -9
View File
@@ -137,9 +137,12 @@ def _cookie_matches_hostname(cookie_domain: str, hostname: str) -> bool:
"""判断 cookie domain 是否适用于给定 hostname。
支持带点前缀的 domain(如 `.saki.lat` 匹配 `api.saki.lat`)。
注意:hostname 为空时,调用方应跳过 cookie 收集而不是调用此函数。
"""
if not cookie_domain or not hostname:
return True # 无 domain 限制时视为全域
if not cookie_domain:
return True # 无 domain 限制的 cookie 对当前域有效
if not hostname:
return False # 无法确定当前域,保守拒绝
# 去掉前缀点
domain = cookie_domain.lstrip(".")
return hostname == domain or hostname.endswith("." + domain)
@@ -153,14 +156,22 @@ def _build_cookie_bundle(
返回 (cookie_string, cookie_names_list)。
cookie_string 格式:name1=value1; name2=value2; ...
过滤掉空值 cookie。
过滤掉空值 cookie。若 page_url 为空或无法解析 hostname,返回空结果
(不收集全域 cookie 以防误写入无关域凭证)。
"""
if not page_url:
logger.debug("_build_cookie_bundle: no page_url, skipping cookie collection")
return "", []
hostname = ""
if page_url:
try:
hostname = urlparse(page_url).hostname or ""
except Exception:
pass
try:
hostname = urlparse(page_url).hostname or ""
except Exception:
pass
if not hostname:
logger.debug("_build_cookie_bundle: cannot parse hostname from %s, skipping", page_url[:80])
return "", []
parts: list[str] = []
names: list[str] = []
@@ -170,7 +181,7 @@ def _build_cookie_bundle(
domain = c.get("domain", "")
if not name or not value:
continue
if hostname and not _cookie_matches_hostname(domain, hostname):
if not _cookie_matches_hostname(domain, hostname):
continue
parts.append(f"{name}={value}")
names.append(name)
@@ -0,0 +1,159 @@
"""One-time credential import sessions for real-browser auth capture."""
from __future__ import annotations
import hashlib
import secrets
import time
from dataclasses import dataclass, field
from typing import Any
from app.services.auth_capture_service import _curate_candidates, _find_new_api_user
IMPORT_SESSION_TTL_SECONDS = 600
class ImportSessionError(RuntimeError):
pass
@dataclass
class BrowserImportSession:
id: str
secret_hash: str
target_url: str
created_by: str
expires_at: float
payload: dict[str, Any] | None = None
consumed: bool = False
created_at: float = field(default_factory=time.time)
def _hash_secret(secret: str) -> str:
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
def _normalize_storage(value: Any) -> dict[str, str]:
if not isinstance(value, dict):
return {}
result: dict[str, str] = {}
for key, item in value.items():
if item is None:
continue
result[str(key)] = item if isinstance(item, str) else str(item)
return result
def _normalize_cookies(value: Any) -> list[dict[str, Any]]:
if not isinstance(value, list):
return []
result: list[dict[str, Any]] = []
for item in value:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip()
cookie_value = str(item.get("value") or "")
if not name or not cookie_value:
continue
result.append({
"name": name,
"value": cookie_value,
"domain": str(item.get("domain") or ""),
"path": str(item.get("path") or "/"),
"httpOnly": bool(item.get("httpOnly", False)),
"secure": bool(item.get("secure", False)),
})
return result
def _normalize_headers(value: Any) -> list[dict[str, str]]:
if not isinstance(value, list):
return []
result: list[dict[str, str]] = []
for item in value:
if not isinstance(item, dict):
continue
header_value = str(item.get("value") or "").strip()
if not header_value:
continue
result.append({
"type": str(item.get("type") or "authorization"),
"value": header_value,
"url": str(item.get("url") or ""),
})
return result
def build_import_result(payload: dict[str, Any]) -> dict[str, Any]:
"""Convert extension-submitted payload into auth-capture result shape."""
page_url = str(payload.get("page_url") or payload.get("url") or "")
cookies = _normalize_cookies(payload.get("cookies"))
storage = _normalize_storage(payload.get("local_storage") or payload.get("storage"))
session_storage = _normalize_storage(payload.get("session_storage"))
auth_headers = _normalize_headers(payload.get("auth_headers"))
new_api_user = _find_new_api_user(storage, session_storage)
candidates = _curate_candidates(
cookies=cookies,
local_storage=storage,
session_storage=session_storage,
auth_headers=auth_headers,
new_api_user=new_api_user,
page_url=page_url,
)
return {
"cookies": cookies,
"storage": storage,
"session_storage": session_storage,
"auth_headers": auth_headers,
"candidates": candidates,
}
class BrowserImportService:
def __init__(self) -> None:
self._sessions: dict[str, BrowserImportSession] = {}
def create(self, target_url: str, created_by: str) -> tuple[BrowserImportSession, str]:
self.cleanup()
session_id = secrets.token_urlsafe(18)
secret = secrets.token_urlsafe(24)
session = BrowserImportSession(
id=session_id,
secret_hash=_hash_secret(secret),
target_url=target_url,
created_by=created_by,
expires_at=time.time() + IMPORT_SESSION_TTL_SECONDS,
)
self._sessions[session_id] = session
return session, secret
def get(self, session_id: str, created_by: str | None = None) -> BrowserImportSession:
self.cleanup()
session = self._sessions.get(session_id)
if not session:
raise ImportSessionError("import session not found")
if created_by is not None and session.created_by != created_by:
raise ImportSessionError("import session not found")
if session.expires_at <= time.time():
self._sessions.pop(session_id, None)
raise ImportSessionError("import session expired")
return session
def submit(self, session_id: str, secret: str, payload: dict[str, Any]) -> BrowserImportSession:
session = self.get(session_id)
if session.consumed:
raise ImportSessionError("import session already consumed")
if not secrets.compare_digest(session.secret_hash, _hash_secret(secret)):
raise ImportSessionError("invalid import secret")
session.payload = payload
session.consumed = True
return session
def cleanup(self) -> None:
now = time.time()
expired = [sid for sid, session in self._sessions.items() if session.expires_at <= now]
for sid in expired:
self._sessions.pop(sid, None)
browser_imports = BrowserImportService()
@@ -68,6 +68,34 @@ class BrowserSessionService:
self._last_event_at: dict[str, float] = {}
self._evict_task: Optional[asyncio.Task[None]] = None
def _browser_launch_kwargs(self, width: int, height: int) -> dict[str, Any]:
return {
"headless": get_settings().browser_headless,
"viewport": {"width": width, "height": height},
"color_scheme": "dark",
"locale": "zh-CN",
"timezone_id": get_settings().tz,
"ignore_default_args": ["--enable-automation"],
"args": [
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-blink-features=AutomationControlled",
"--window-size=%d,%d" % (width, height),
],
}
async def _install_browser_init_scripts(self, context: Any) -> None:
await context.add_init_script("""
(() => {
try {
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh', 'en-US', 'en'] });
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
window.chrome = window.chrome || { runtime: {} };
} catch (_) {}
})();
""")
async def create(
self,
custom_page_id: int,
@@ -113,11 +141,9 @@ class BrowserSessionService:
context = await self._playwright.chromium.launch_persistent_context(
str(self._profile_dir(profile_key)),
headless=get_settings().browser_headless,
viewport={"width": width, "height": height},
color_scheme="dark",
args=["--no-sandbox", "--disable-dev-shm-usage"],
**self._browser_launch_kwargs(width, height),
)
await self._install_browser_init_scripts(context)
await self._restore_session_state(context, profile_key)
# Grant clipboard access for the page origin
try:
@@ -773,11 +799,9 @@ class BrowserSessionService:
profile_key = f"auth-capture-{session_id[:12]}"
context = await self._playwright.chromium.launch_persistent_context(
str(self._profile_dir(profile_key)),
headless=get_settings().browser_headless,
viewport={"width": width, "height": height},
color_scheme="dark",
args=["--no-sandbox", "--disable-dev-shm-usage"],
**self._browser_launch_kwargs(width, height),
)
await self._install_browser_init_scripts(context)
# Grant clipboard access for the page origin
try:
parsed = urlparse(url)
+156 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
import time
from typing import Any, Optional
from urllib.parse import urljoin
import httpx
@@ -13,6 +14,9 @@ class UpstreamError(RuntimeError):
pass
NEW_API_DEFAULT_QUOTA_PER_UNIT = 500000
def _find_token(value: Any) -> str:
if isinstance(value, str) and value.count(".") >= 2:
return value
@@ -74,6 +78,8 @@ def mask_secret(value: Any) -> str:
def _unwrap_data(value: Any) -> Any:
if isinstance(value, dict) and "data" in value and ("code" in value or "message" in value):
return value.get("data")
if isinstance(value, dict) and "data" in value and "success" in value:
return value.get("data")
return value
@@ -105,6 +111,20 @@ def _extract_key_value(value: Any) -> str:
return ""
def _is_success_response(value: Any) -> bool:
if not isinstance(value, dict) or "success" not in value:
return True
return value.get("success") is True
def _response_message(value: Any, fallback: str = "") -> str:
if isinstance(value, dict):
msg = value.get("message") or value.get("detail")
if msg:
return str(msg)
return fallback
def _unwrap_list(value: Any) -> Optional[list[dict[str, Any]]]:
def _normalize(lst: list) -> list[dict[str, Any]]:
out = []
@@ -288,6 +308,17 @@ class UpstreamClient:
prefix = f"/{self.api_prefix}" if self.api_prefix else ""
return f"{self.base_url}{prefix}/{path.lstrip('/')}"
def _is_new_api_user_mode(self) -> bool:
login_path = str(self.auth_config.get("login_path") or "")
return (
self.api_prefix == ""
and (
bool(self.auth_config.get("new_api_user"))
or login_path == "/api/user/login"
or self.auth_type == "cookie"
)
)
def _headers(self, auth: bool = True) -> dict[str, str]:
headers: dict[str, str] = {
"Accept": "application/json",
@@ -348,6 +379,119 @@ class UpstreamClient:
raise UpstreamError(f"{method} {path} returned HTML, not JSON")
return resp.json()
def _ensure_api_success(self, payload: Any, action: str) -> None:
if not _is_success_response(payload):
raise UpstreamError(_response_message(payload, f"{action} failed"))
def _new_api_quota_per_unit(self) -> int:
try:
payload = self._request("GET", "/api/status", auth=False)
data = _unwrap_data(payload)
if isinstance(data, dict):
value = data.get("quota_per_unit")
quota_per_unit = int(float(value))
if quota_per_unit > 0:
return quota_per_unit
except Exception:
pass
return NEW_API_DEFAULT_QUOTA_PER_UNIT
@staticmethod
def _normalize_key_record(record: dict[str, Any]) -> dict[str, Any]:
out = dict(record)
if not out.get("name") and out.get("key_name"):
out["name"] = out.get("key_name")
if not out.get("group_id") and out.get("group"):
out["group_id"] = str(out.get("group"))
if not out.get("group_name") and out.get("group"):
out["group_name"] = str(out.get("group"))
return out
def _list_new_api_tokens(
self,
search: str = "",
group_id: str | int | None = None,
) -> list[dict[str, Any]]:
if search:
path = "/api/token/search"
params = {"keyword": search, "token": "", "p": 1, "size": 100}
else:
path = "/api/token/"
params = {"p": 1, "size": 100}
resp = self._client.request(
"GET",
self._url(path),
params=params,
headers=self._headers(),
cookies=self._cookies,
)
self._cookies.update(dict(resp.cookies))
resp.raise_for_status()
data = resp.json()
self._ensure_api_success(data, "list New-API tokens")
nested = _unwrap_data(data)
items: list[dict[str, Any]] | None = None
if isinstance(nested, list):
items = [i for i in nested if isinstance(i, dict)]
elif isinstance(nested, dict):
for key in ("items", "tokens", "list", "records"):
value = nested.get(key)
if isinstance(value, list):
items = [i for i in value if isinstance(i, dict)]
break
if items is None:
raise UpstreamError("unexpected New-API token list response")
normalized = [self._normalize_key_record(i) for i in items]
if group_id is not None:
gid = str(group_id)
normalized = [i for i in normalized if str(i.get("group_id") or i.get("group") or "") == gid]
return normalized
def _get_new_api_token_key(self, token_id: str | int) -> str:
payload = self._request("POST", f"/api/token/{token_id}/key")
self._ensure_api_success(payload, "get New-API token key")
key_value = _extract_key_value(_unwrap_data(payload))
if not key_value:
raise UpstreamError("New-API token key response did not include key")
return key_value
def _create_new_api_token(
self,
name: str,
group_id: str | int,
quota: float = 0,
expires_in_days: int | None = None,
) -> dict[str, Any]:
unlimited = quota <= 0
body: dict[str, Any] = {
"name": name,
"remain_quota": 0 if unlimited else int(round(quota * self._new_api_quota_per_unit())),
"unlimited_quota": unlimited,
"expired_time": int(time.time()) + expires_in_days * 86400 if expires_in_days else -1,
"model_limits_enabled": False,
"model_limits": "",
"allow_ips": "",
"group": str(group_id),
"cross_group_retry": False,
}
payload = self._request("POST", "/api/token/", body)
self._ensure_api_success(payload, "create New-API token")
matches = self._list_new_api_tokens(search=name, group_id=group_id)
token = next((i for i in matches if str(i.get("name") or "").strip() == name.strip()), None)
if not token:
raise UpstreamError("New-API token was created but could not be found by name")
token_id = token.get("id")
if token_id is None:
raise UpstreamError("New-API token list response did not include id")
key_value = self._get_new_api_token_key(token_id)
return {
"id": str(token_id),
"key": key_value,
"masked_key": mask_secret(key_value),
"raw": self._normalize_key_record(token),
}
def login(self) -> None:
if self.auth_type != "login_password":
return
@@ -412,6 +556,9 @@ class UpstreamClient:
endpoint: str = "/keys",
) -> list[dict[str, Any]]:
"""查询远端上游 Key 列表,支持按名称搜索、分组筛选、状态筛选。"""
if endpoint in {"/api/token", "/api/token/"} or (endpoint == "/keys" and self._is_new_api_user_mode()):
return self._list_new_api_tokens(search=search, group_id=group_id)
params: dict[str, Any] = {}
if search:
params["search"] = search
@@ -446,7 +593,7 @@ class UpstreamClient:
for key in ("items", "keys", "list", "records"):
val = data.get(key)
if isinstance(val, list):
return val
return [self._normalize_key_record(i) for i in val if isinstance(i, dict)]
raise UpstreamError(f"unexpected keys response type: {type(data).__name__}")
def delete_api_key(self, key_id: str, endpoint: str = "/keys") -> None:
@@ -486,6 +633,14 @@ class UpstreamClient:
rate_limit_7d: float = 0,
endpoint: str = "/keys",
) -> dict[str, Any]:
if endpoint in {"/api/token", "/api/token/"} or (endpoint == "/keys" and self._is_new_api_user_mode()):
return self._create_new_api_token(
name,
group_id,
quota=quota,
expires_in_days=expires_in_days,
)
body: dict[str, Any] = {
"name": name,
"group_id": int(group_id) if str(group_id).isdigit() else group_id,
+62 -2
View File
@@ -36,11 +36,17 @@ def test_dot_prefix_exact_match():
assert _cookie_matches_hostname(".saki.lat", "saki.lat")
def test_no_domain_matches_all():
"""domain 视为不限制"""
def test_no_domain_cookie_matches_any_hostname():
"""cookie domain(无限制)应对任意 hostname 返回 True"""
assert _cookie_matches_hostname("", "anything.example.com")
def test_empty_hostname_rejects_all():
"""hostname 为空时,所有有 domain 的 cookie 都应被保守拒绝。"""
assert not _cookie_matches_hostname(".saki.lat", "")
assert not _cookie_matches_hostname("saki.lat", "")
def test_different_domain_no_match():
assert not _cookie_matches_hostname(".example.com", "saki.lat")
@@ -210,3 +216,57 @@ def test_new_api_user_propagated_to_bundle():
)
bundle = next(c for c in candidates if c["type"] == "cookie_bundle")
assert bundle.get("new_api_user") == "42"
def test_browser_import_payload_builds_cookie_bundle_with_new_api_user():
from app.services.browser_import_service import build_import_result
result = build_import_result({
"page_url": "https://meow.example.com/panel",
"cookies": [
{"name": "cf_clearance", "value": "cf", "domain": ".example.com", "httpOnly": True},
{"name": "session", "value": "sess", "domain": ".example.com", "httpOnly": True},
],
"local_storage": {"uid": "7"},
"session_storage": {},
"auth_headers": [],
})
bundle = next(c for c in result["candidates"] if c["type"] == "cookie_bundle")
assert "cf_clearance=cf" in bundle["value"]
assert "session=sess" in bundle["value"]
assert bundle["new_api_user"] == "7"
def test_browser_import_payload_includes_auth_headers():
from app.services.browser_import_service import build_import_result
result = build_import_result({
"page_url": "https://sub2api.example.com/dashboard",
"cookies": [],
"local_storage": {},
"session_storage": {},
"auth_headers": [
{"type": "authorization", "value": "Bearer abc.def.ghi", "url": "https://sub2api.example.com/api/v1/groups"}
],
})
assert result["candidates"][0]["type"] == "bearer_token"
assert result["candidates"][0]["value"] == "Bearer abc.def.ghi"
def test_browser_import_session_secret_and_one_time_submit():
from app.services.browser_import_service import BrowserImportService, ImportSessionError
service = BrowserImportService()
session, secret = service.create("https://example.com/login", "admin@example.com")
with pytest.raises(ImportSessionError):
service.submit(session.id, "wrong", {"page_url": "https://example.com/"})
submitted = service.submit(session.id, secret, {"page_url": "https://example.com/"})
assert submitted.consumed is True
assert submitted.payload == {"page_url": "https://example.com/"}
with pytest.raises(ImportSessionError):
service.submit(session.id, secret, {"page_url": "https://example.com/again"})
+60 -59
View File
@@ -2,7 +2,6 @@ import sys
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
@@ -10,10 +9,9 @@ from sqlalchemy.pool import StaticPool
sys.path.insert(0, str(Path(__file__).resolve().parent))
from app import database as database_module
from app.database import Base, get_db
from app.main import app
from app.database import Base
from app.models.custom_page import CustomPage
from app.utils.auth import get_current_user
from app.routers import custom_pages
@pytest.fixture()
@@ -33,48 +31,41 @@ def db_session():
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def client(db_session):
def override_get_db():
yield db_session
def test_create_page_auto_enables_autofill_when_credentials_are_saved(db_session):
response = custom_pages.create_page(
custom_pages.CustomPageCreate(
name="Login page",
url="https://example.test/login",
access_mode="remote_browser",
login_username="alice",
login_password="secret",
),
db_session,
object(),
)
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_current_user] = lambda: object()
try:
yield TestClient(app)
finally:
app.dependency_overrides.clear()
assert response.login_autofill_enabled is True
assert response.login_password_configured is True
def test_create_page_auto_enables_autofill_when_credentials_are_saved(client):
response = client.post("/api/custom-pages", json={
"name": "Login page",
"url": "https://example.test/login",
"access_mode": "remote_browser",
"login_username": "alice",
"login_password": "secret",
})
def test_create_page_respects_explicit_autofill_disable(db_session):
response = custom_pages.create_page(
custom_pages.CustomPageCreate(
name="Login page",
url="https://example.test/login",
access_mode="remote_browser",
login_username="alice",
login_password="secret",
login_autofill_enabled=False,
),
db_session,
object(),
)
assert response.status_code == 201
assert response.json()["login_autofill_enabled"] is True
assert response.json()["login_password_configured"] is True
assert response.login_autofill_enabled is False
def test_create_page_respects_explicit_autofill_disable(client):
response = client.post("/api/custom-pages", json={
"name": "Login page",
"url": "https://example.test/login",
"access_mode": "remote_browser",
"login_username": "alice",
"login_password": "secret",
"login_autofill_enabled": False,
})
assert response.status_code == 201
assert response.json()["login_autofill_enabled"] is False
def test_update_page_auto_enables_autofill_when_new_password_is_saved(client, db_session):
def test_update_page_auto_enables_autofill_when_new_password_is_saved(db_session):
page = CustomPage(
name="Login page",
url="https://example.test/login",
@@ -87,16 +78,20 @@ def test_update_page_auto_enables_autofill_when_new_password_is_saved(client, db
db_session.commit()
db_session.refresh(page)
response = client.put(f"/api/custom-pages/{page.id}", json={
"login_username": "alice@example.test",
"login_password": "new-secret",
})
response = custom_pages.update_page(
page.id,
custom_pages.CustomPageUpdate(
login_username="alice@example.test",
login_password="new-secret",
),
db_session,
object(),
)
assert response.status_code == 200
assert response.json()["login_autofill_enabled"] is True
assert response.login_autofill_enabled is True
def test_update_page_keeps_autofill_disabled_when_existing_password_is_kept(client, db_session):
def test_update_page_keeps_autofill_disabled_when_existing_password_is_kept(db_session):
page = CustomPage(
name="Login page",
url="https://example.test/login",
@@ -109,15 +104,17 @@ def test_update_page_keeps_autofill_disabled_when_existing_password_is_kept(clie
db_session.commit()
db_session.refresh(page)
response = client.put(f"/api/custom-pages/{page.id}", json={
"login_username": "alice@example.test",
})
response = custom_pages.update_page(
page.id,
custom_pages.CustomPageUpdate(login_username="alice@example.test"),
db_session,
object(),
)
assert response.status_code == 200
assert response.json()["login_autofill_enabled"] is False
assert response.login_autofill_enabled is False
def test_update_page_respects_explicit_autofill_disable(client, db_session):
def test_update_page_respects_explicit_autofill_disable(db_session):
page = CustomPage(
name="Login page",
url="https://example.test/login",
@@ -130,13 +127,17 @@ def test_update_page_respects_explicit_autofill_disable(client, db_session):
db_session.commit()
db_session.refresh(page)
response = client.put(f"/api/custom-pages/{page.id}", json={
"login_username": "alice@example.test",
"login_autofill_enabled": False,
})
response = custom_pages.update_page(
page.id,
custom_pages.CustomPageUpdate(
login_username="alice@example.test",
login_autofill_enabled=False,
),
db_session,
object(),
)
assert response.status_code == 200
assert response.json()["login_autofill_enabled"] is False
assert response.login_autofill_enabled is False
def test_custom_page_migration_backfills_autofill_once(monkeypatch):
+177
View File
@@ -0,0 +1,177 @@
import asyncio
import json
import sys
from pathlib import Path
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
sys.path.insert(0, str(Path(__file__).resolve().parent))
from app.database import Base
from app.models.custom_page import CustomPage
from app.models.upstream import Upstream
from app.routers import custom_pages
@pytest.fixture()
def db_session():
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
Base.metadata.drop_all(bind=engine)
def _linked_page(db, upstream: Upstream) -> CustomPage:
db.add(upstream)
db.commit()
db.refresh(upstream)
page = CustomPage(
name="Login",
url="https://meow.example/login",
access_mode="remote_browser",
linked_upstream_id=upstream.id,
)
db.add(page)
db.commit()
db.refresh(page)
return page
def _install_refresh_fakes(monkeypatch, candidates: list[dict], calls: list[dict]):
monkeypatch.setattr(custom_pages.browser_sessions, "find_by_page_id", lambda _pid: object())
async def fake_extract_all(_session):
return {"candidates": candidates}
monkeypatch.setattr(custom_pages, "extract_all", fake_extract_all)
class FakeUpstreamClient:
def __init__(self, **kwargs):
self.kwargs = kwargs
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def get_available_groups(self, endpoint):
calls.append({"endpoint": endpoint, **self.kwargs})
return [{"id": "default", "name": "Default"}]
import app.services.upstream_client as upstream_client_module
monkeypatch.setattr(upstream_client_module, "UpstreamClient", FakeUpstreamClient)
def test_refresh_auth_sub2api_prefers_bearer_over_cookie_bundle(db_session, monkeypatch):
upstream = Upstream(
name="Meow",
base_url="https://api.saki.lat",
api_prefix="/api/v1",
auth_type="login_password",
auth_config_json=json.dumps({
"email": "alice@example.test",
"password": "secret",
"login_path": "/auth/login",
}),
groups_endpoint="/groups/available",
rate_endpoint="/groups/rates",
)
page = _linked_page(db_session, upstream)
calls: list[dict] = []
_install_refresh_fakes(monkeypatch, [
{"type": "cookie_bundle", "value": "cf_clearance=cf; session=s", "cookie_count": 2},
{"type": "bearer_token", "value": "jwt.header.payload", "source": "localStorage.auth_token"},
], calls)
response = asyncio.run(custom_pages.refresh_auth(page.id, db_session, object()))
db_session.refresh(upstream)
cfg = json.loads(upstream.auth_config_json)
assert response.success is True
assert upstream.auth_type == "bearer"
assert cfg["token"] == "jwt.header.payload"
assert "cookie_string" not in cfg
assert upstream.api_prefix == "/api/v1"
assert upstream.groups_endpoint == "/groups/available"
assert calls[0]["auth_type"] == "bearer"
assert calls[0]["endpoint"] == "/groups/available"
def test_refresh_auth_sub2api_rejects_cookie_only_capture(db_session, monkeypatch):
upstream = Upstream(
name="Meow",
base_url="https://api.saki.lat",
api_prefix="/api/v1",
auth_type="login_password",
auth_config_json=json.dumps({"login_path": "/auth/login"}),
groups_endpoint="/groups/available",
rate_endpoint="/groups/rates",
)
page = _linked_page(db_session, upstream)
_install_refresh_fakes(monkeypatch, [
{"type": "cookie_bundle", "value": "cf_clearance=cf; session=s", "cookie_count": 2},
], [])
response = asyncio.run(custom_pages.refresh_auth(page.id, db_session, object()))
db_session.refresh(upstream)
cfg = json.loads(upstream.auth_config_json)
assert response.success is False
assert "Sub2API 需要 Bearer Token" in response.message
assert upstream.auth_type == "login_password"
assert "cookie_string" not in cfg
def test_refresh_auth_new_api_user_uses_cookie_bundle_and_resets_user_endpoints(db_session, monkeypatch):
upstream = Upstream(
name="New API User",
base_url="https://newapi.example",
api_prefix="/api/v1",
auth_type="login_password",
auth_config_json=json.dumps({
"email": "alice",
"password": "secret",
"login_path": "/api/user/login",
}),
groups_endpoint="/groups/available",
rate_endpoint="/groups/rates",
)
page = _linked_page(db_session, upstream)
calls: list[dict] = []
_install_refresh_fakes(monkeypatch, [
{
"type": "cookie_bundle",
"value": "cf_clearance=cf; session=s",
"cookie_count": 2,
"new_api_user": "42",
},
{"type": "bearer_token", "value": "jwt.header.payload"},
], calls)
response = asyncio.run(custom_pages.refresh_auth(page.id, db_session, object()))
db_session.refresh(upstream)
cfg = json.loads(upstream.auth_config_json)
assert response.success is True
assert upstream.auth_type == "cookie"
assert cfg["cookie_string"] == "cf_clearance=cf; session=s"
assert cfg["new_api_user"] == "42"
assert upstream.api_prefix == ""
assert upstream.groups_endpoint == "/api/user/self/groups"
assert upstream.rate_endpoint == "/api/user/self/groups"
assert calls[0]["auth_type"] == "cookie"
assert calls[0]["endpoint"] == "/api/user/self/groups"
+103
View File
@@ -590,3 +590,106 @@ def test_sync_removes_deleted_remote_key(db_session):
remaining = db_session.query(UpstreamGeneratedKey).all()
assert len(remaining) == 0
def test_new_api_create_token_fetches_plaintext_key(monkeypatch):
"""New-API 创建 token 后需按 id 再取一次明文 key。"""
from app.services.upstream_client import UpstreamClient
client = UpstreamClient(
base_url="http://newapi.local",
api_prefix="",
auth_type="cookie",
auth_config={"cookie_string": "session=abc", "new_api_user": "7"},
)
created_bodies = []
def fake_request(method, path, body=None, auth=True):
if method == "GET" and path == "/api/status":
return {"success": True, "data": {"quota_per_unit": 500000}}
if method == "POST" and path == "/api/token/":
created_bodies.append(body)
return {"success": True, "message": ""}
if method == "POST" and path == "/api/token/123/key":
return {"success": True, "data": {"key": "new-api-plain-key"}}
raise AssertionError(f"unexpected request {method} {path}")
monkeypatch.setattr(client, "_request", fake_request)
monkeypatch.setattr(
client,
"_list_new_api_tokens",
lambda search="", group_id=None: [{"id": 123, "name": search, "group": group_id, "key": "new-****-key"}],
)
result = client.create_api_key(
"SmartUp-1-VIP-vip",
"vip",
quota=2,
expires_in_days=3,
endpoint="/api/token",
)
assert result["id"] == "123"
assert result["key"] == "new-api-plain-key"
assert created_bodies[0]["group"] == "vip"
assert created_bodies[0]["remain_quota"] == 1000000
assert created_bodies[0]["unlimited_quota"] is False
assert created_bodies[0]["expired_time"] > 0
def test_generate_keys_allows_new_api_user_upstream(db_session, monkeypatch):
"""New-API 普通账号上游应允许按分组生成 token。"""
from app.routers import upstreams as upstreams_router
from app.schemas.upstream import GenerateKeysByGroupsRequest
upstream = Upstream(
name="NewAPI",
base_url="http://newapi.local",
api_prefix="",
auth_type="cookie",
auth_config_json=json.dumps({"cookie_string": "session=abc", "new_api_user": "7"}),
groups_endpoint="/api/user/self/groups",
rate_endpoint="/api/user/self/groups",
)
db_session.add(upstream)
db_session.commit()
db_session.refresh(upstream)
monkeypatch.setattr(upstreams_router.website_sync, "reconcile_upstream_keys_full", lambda db, uid: True)
class FakeClient:
def __init__(self, **kwargs):
self.kwargs = kwargs
def __enter__(self):
return self
def __exit__(self, *args):
return None
def login(self):
return None
def get_available_groups(self, endpoint):
assert endpoint == "/api/user/self/groups"
return [{"id": "vip", "name": "VIP"}]
def find_smartup_group_key(self, gid, expected_name, prefix):
return None
def create_api_key(self, name, group_id, **kwargs):
assert kwargs["endpoint"] == "/api/token"
return {"id": "123", "key": "new-api-plain-key", "masked_key": "new-****-key", "raw": {"id": 123}}
monkeypatch.setattr(upstreams_router, "UpstreamClient", FakeClient)
response = upstreams_router.generate_keys_by_groups(
upstream.id,
GenerateKeysByGroupsRequest(group_ids=["vip"], endpoint="/api/token"),
db_session,
object(),
)
assert response.success is True
assert response.items[0].status == "created"
assert response.items[0].key_value == "new-api-plain-key"
+22
View File
@@ -0,0 +1,22 @@
# SmartUp Auth Importer
用于在本机 Chrome/Edge 真实浏览器中通过 Cloudflare 后,把目标站凭证导入 SmartUp。
## 安装
1. 打开 Chrome/Edge 扩展管理页。
2. 启用“开发者模式”。
3. 选择“加载已解压的扩展程序”。
4. 选择本目录:`browser-extension/`
## 使用
1. 在 SmartUp 上游认证提取窗口选择“真实浏览器导入”。
2. 点击“生成导入码”,复制 SmartUp 地址和导入码。
3. 在本机真实浏览器打开 Meow/New-API 页面并完成登录。
4. 点击 SmartUp Auth Importer 扩展图标。
5. 粘贴 SmartUp 地址和导入码。
6. 点击“采集当前页并回填”。
7. 回到 SmartUp,等待候选凭证出现后点击“填入当前表单”。
扩展会采集当前标签页所属域名的 cookie、localStorage、sessionStorage,以及扩展已捕获到的 Authorization / X-API-Key 请求头。
+46
View File
@@ -0,0 +1,46 @@
const authHeadersByTab = new Map()
const AUTH_HEADER_NAMES = new Set(['authorization', 'x-api-key', 'api-key'])
function rememberHeader(tabId, entry) {
if (tabId < 0) return
const rows = authHeadersByTab.get(tabId) || []
const dedupKey = `${entry.type}:${entry.value}:${entry.url}`
if (!rows.some((item) => `${item.type}:${item.value}:${item.url}` === dedupKey)) {
rows.unshift(entry)
}
authHeadersByTab.set(tabId, rows.slice(0, 50))
}
chrome.webRequest.onBeforeSendHeaders.addListener(
(details) => {
const headers = details.requestHeaders || []
for (const header of headers) {
const name = String(header.name || '').toLowerCase()
const value = String(header.value || '').trim()
if (!AUTH_HEADER_NAMES.has(name) || !value) continue
rememberHeader(details.tabId, {
type: name === 'authorization' ? 'authorization' : 'api_key',
value,
url: details.url || '',
})
}
},
{ urls: ['<all_urls>'] },
['requestHeaders', 'extraHeaders'],
)
chrome.tabs.onRemoved.addListener((tabId) => {
authHeadersByTab.delete(tabId)
})
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message?.type !== 'get-auth-headers') return false
const tabId = Number(message.tabId)
const origin = String(message.origin || '')
const rows = authHeadersByTab.get(tabId) || []
const filtered = origin
? rows.filter((item) => String(item.url || '').startsWith(origin))
: rows
sendResponse({ auth_headers: filtered })
return true
})
+23
View File
@@ -0,0 +1,23 @@
{
"manifest_version": 3,
"name": "SmartUp Auth Importer",
"version": "0.1.0",
"description": "Import cookies, storage, and auth headers from a real browser session into SmartUp.",
"permissions": [
"cookies",
"scripting",
"tabs",
"storage",
"webRequest"
],
"host_permissions": [
"<all_urls>"
],
"action": {
"default_popup": "popup.html",
"default_title": "SmartUp Auth Importer"
},
"background": {
"service_worker": "background.js"
}
}
+77
View File
@@ -0,0 +1,77 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>SmartUp Auth Importer</title>
<style>
body {
width: 360px;
margin: 0;
padding: 14px;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #1f2937;
}
h1 {
margin: 0 0 12px;
font-size: 16px;
}
label {
display: block;
margin: 10px 0 4px;
font-size: 12px;
color: #4b5563;
}
input {
width: 100%;
box-sizing: border-box;
padding: 8px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 13px;
}
button {
width: 100%;
margin-top: 12px;
padding: 9px 10px;
border: 0;
border-radius: 6px;
background: #2563eb;
color: white;
font-weight: 600;
cursor: pointer;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.hint {
margin-top: 8px;
font-size: 12px;
line-height: 1.5;
color: #6b7280;
}
.status {
margin-top: 10px;
min-height: 18px;
font-size: 12px;
color: #374151;
overflow-wrap: anywhere;
}
.ok { color: #15803d; }
.err { color: #b91c1c; }
</style>
</head>
<body>
<h1>SmartUp 凭证导入</h1>
<label for="smartup">SmartUp 地址</label>
<input id="smartup" placeholder="http://127.0.0.1:8899" />
<label for="code">导入码</label>
<input id="code" placeholder="session_id:secret" />
<div class="hint">
先在当前标签页完成目标站登录,再点击采集。扩展会读取当前域 cookie、storage 和已捕获的认证请求头。
</div>
<button id="submit">采集当前页并回填</button>
<div id="status" class="status"></div>
<script src="popup.js"></script>
</body>
</html>
+138
View File
@@ -0,0 +1,138 @@
const smartupInput = document.getElementById('smartup')
const codeInput = document.getElementById('code')
const submitButton = document.getElementById('submit')
const statusEl = document.getElementById('status')
function setStatus(text, cls = '') {
statusEl.textContent = text
statusEl.className = `status ${cls}`.trim()
}
function normalizeOrigin(value) {
const text = String(value || '').trim().replace(/\/+$/, '')
if (!/^https?:\/\//.test(text)) return ''
return text
}
function parseImportCode(value) {
const parts = String(value || '').trim().split(':')
if (parts.length < 2 || !parts[0] || !parts.slice(1).join(':')) return null
return {
sessionId: parts.shift(),
secret: parts.join(':'),
}
}
async function loadSavedConfig() {
const saved = await chrome.storage.local.get(['smartupOrigin', 'importCode'])
smartupInput.value = saved.smartupOrigin || 'http://127.0.0.1:8899'
codeInput.value = saved.importCode || ''
}
async function saveConfig(origin, code) {
await chrome.storage.local.set({ smartupOrigin: origin, importCode: code })
}
async function getActiveTab() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
if (!tab?.id || !tab.url || !/^https?:\/\//.test(tab.url)) {
throw new Error('请切换到已登录的 http/https 目标页面')
}
return tab
}
async function readPageStorage(tabId) {
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: () => {
const copyStorage = (storage) => {
const out = {}
for (let i = 0; i < storage.length; i += 1) {
const key = storage.key(i)
if (key) out[key] = storage.getItem(key) || ''
}
return out
}
return {
local_storage: copyStorage(window.localStorage),
session_storage: copyStorage(window.sessionStorage),
}
},
})
return result?.result || { local_storage: {}, session_storage: {} }
}
async function readCookies(url) {
const cookies = await chrome.cookies.getAll({ url })
return cookies.map((cookie) => ({
name: cookie.name,
value: cookie.value,
domain: cookie.domain,
path: cookie.path,
httpOnly: Boolean(cookie.httpOnly),
secure: Boolean(cookie.secure),
}))
}
function getAuthHeaders(tabId, origin) {
return new Promise((resolve) => {
chrome.runtime.sendMessage({ type: 'get-auth-headers', tabId, origin }, (response) => {
resolve(response?.auth_headers || [])
})
})
}
async function submitImport() {
const smartupOrigin = normalizeOrigin(smartupInput.value)
const importCode = codeInput.value.trim()
const parsed = parseImportCode(importCode)
if (!smartupOrigin) {
setStatus('SmartUp 地址必须以 http:// 或 https:// 开头', 'err')
return
}
if (!parsed) {
setStatus('导入码格式应为 session_id:secret', 'err')
return
}
submitButton.disabled = true
setStatus('正在采集当前页凭证…')
try {
await saveConfig(smartupOrigin, importCode)
const tab = await getActiveTab()
const pageUrl = tab.url
const pageOrigin = new URL(pageUrl).origin
const [cookies, storage, authHeaders] = await Promise.all([
readCookies(pageUrl),
readPageStorage(tab.id),
getAuthHeaders(tab.id, pageOrigin),
])
const response = await fetch(`${smartupOrigin}/api/auth-capture/import-sessions/${parsed.sessionId}/submit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
secret: parsed.secret,
page_url: pageUrl,
cookies,
local_storage: storage.local_storage || {},
session_storage: storage.session_storage || {},
auth_headers: authHeaders,
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || `提交失败:${response.status}`)
}
setStatus(`已提交:${cookies.length} 个 cookie${authHeaders.length} 个认证请求头`, 'ok')
} catch (error) {
setStatus(error?.message || '提交失败', 'err')
} finally {
submitButton.disabled = false
}
}
submitButton.addEventListener('click', () => {
void submitImport()
})
void loadSavedConfig()
+1
View File
@@ -19,6 +19,7 @@ services:
- DATABASE_URL=sqlite:////app/data/app.db
- TZ=${TZ:-Asia/Shanghai}
- UNHEALTHY_THRESHOLD=${UNHEALTHY_THRESHOLD:-3}
- BROWSER_HEADLESS=${BROWSER_HEADLESS:-false}
logging:
driver: json-file
options:
+19
View File
@@ -491,6 +491,12 @@ export interface AuthCaptureSession {
ws_url: string
}
export interface BrowserImportSession {
session_id: string
secret: string
expires_in_seconds: number
}
export interface AuthCaptureCandidate {
type: 'bearer_token' | 'cookie' | 'cookie_bundle' | 'credential' | 'api_key'
source: string
@@ -513,6 +519,13 @@ export interface AuthCaptureResult {
candidates: AuthCaptureCandidate[]
}
export interface BrowserImportStatus {
session_id: string
ready: boolean
expires_at: number
result: AuthCaptureResult | null
}
export const authCaptureApi = {
createSession: (url: string, width?: number, height?: number) =>
api.post<AuthCaptureSession>('/api/auth-capture/sessions', { url, width, height }),
@@ -522,6 +535,12 @@ export const authCaptureApi = {
}),
closeSession: (sessionId: string) =>
api.delete(`/api/auth-capture/sessions/${sessionId}`),
createImportSession: (targetUrl: string) =>
api.post<BrowserImportSession>('/api/auth-capture/import-sessions', { target_url: targetUrl }),
importSessionStatus: (sessionId: string, options?: { includeRaw?: boolean }) =>
api.get<BrowserImportStatus>(`/api/auth-capture/import-sessions/${sessionId}`, {
params: options?.includeRaw ? { include_raw: true } : undefined,
}),
wsUrl: (sessionId: string, token?: string) => {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
const params = new URLSearchParams()
+308 -1
View File
@@ -8,6 +8,14 @@
destroy-on-close
>
<div class="auth-capture-body">
<div class="capture-mode-row">
<el-radio-group v-model="captureMode" size="small">
<el-radio-button label="remote">远程浏览器</el-radio-button>
<el-radio-button label="import">真实浏览器导入</el-radio-button>
</el-radio-group>
</div>
<template v-if="captureMode === 'remote'">
<!-- Step 1: URL + Launch -->
<div v-if="!sessionId" class="capture-step">
<h4>步骤 1输入目标登录页面地址</h4>
@@ -134,12 +142,102 @@
</div>
</transition>
</div>
</template>
<template v-else>
<div class="capture-step">
<div class="capture-step-header">
<h4>真实浏览器导入</h4>
<div class="capture-actions">
<el-button size="small" :loading="creatingImportSession" type="primary" @click="createBrowserImportSession">
生成导入码
</el-button>
</div>
</div>
<p class="capture-hint">
在本机 Chrome/Edge 通过 Cloudflare 并登录后 SmartUp 凭证导入扩展采集并回填
</p>
<el-form @submit.prevent="createBrowserImportSession">
<el-form-item label="目标登录页 URL">
<el-input v-model="targetUrl" placeholder="https://example.com/login" />
</el-form-item>
</el-form>
<div v-if="importSessionId" class="import-session-panel">
<div class="import-row">
<span class="import-label">SmartUp 地址</span>
<code>{{ smartupOrigin }}</code>
<el-button size="small" text @click="copyText(smartupOrigin)">复制</el-button>
</div>
<div class="import-row">
<span class="import-label">导入码</span>
<code>{{ importCode }}</code>
<el-button size="small" text @click="copyText(importCode)">复制</el-button>
</div>
<div class="import-row">
<span class="import-label">状态</span>
<span :class="['import-status', importReady ? 'ready' : 'waiting']">
{{ importReady ? '已收到凭证' : '等待扩展提交…' }}
</span>
<el-button size="small" text :loading="importPolling" @click="pollImportSessionOnce">刷新</el-button>
</div>
<div class="import-row">
<span class="import-label">有效期</span>
<span :class="['import-status', importExpired ? 'expired' : 'waiting']">
{{ importExpiresLabel }}
</span>
</div>
</div>
<transition name="el-zoom-in-top">
<div v-if="importReady && importResult" class="candidate-panel">
<div class="candidate-panel-header">
<span>提取到 {{ importResult.candidates.length }} 个认证凭据</span>
<el-button size="small" text @click="resetImportResult">重新等待</el-button>
</div>
<div v-if="importResult.candidates.length === 0" class="candidate-empty">
未找到认证凭据请确认已在真实浏览器中成功登录后重试
</div>
<div v-else class="candidate-list">
<div
v-for="(c, i) in importResult.candidates"
:key="i"
class="candidate-card"
:class="{ selected: importSelectedIndex === i }"
@click="importSelectedIndex = i"
>
<div class="candidate-row">
<el-radio :model-value="importSelectedIndex === i" :label="i" @click.stop="importSelectedIndex = i">
<span class="candidate-badge" :class="c.type || 'credential'">
{{ badgeLabel(c.type) }}
</span>
<span class="candidate-label">{{ c.label }}</span>
</el-radio>
<span v-if="c.confidence" class="candidate-confidence" :class="confClass(c.confidence)">
{{ c.confidence }}%
</span>
</div>
<div class="candidate-preview">
<code>{{ candidatePreview(c) }}</code>
</div>
</div>
</div>
<div class="candidate-actions">
<el-button size="small" @click="handleClose">关闭</el-button>
<el-button size="small" type="primary" :disabled="importSelectedIndex < 0" :loading="applyingSelection" @click="confirmImportSelection">
填入当前表单
</el-button>
</div>
</div>
</transition>
</div>
</template>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch, onUnmounted, nextTick } from 'vue'
import { computed, ref, watch, onUnmounted, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { Pointer, Search, Back, Right, Refresh, Loading } from '@element-plus/icons-vue'
import { authCaptureApi, type AuthCaptureCandidate, type AuthCaptureResult } from '@/api'
@@ -148,6 +246,7 @@ import { useAuthStore } from '@/stores/auth'
const props = defineProps<{
modelValue: boolean
initialUrl?: string
preferredTypes?: AuthCaptureCandidate['type'][]
}>()
const emit = defineEmits<{
@@ -205,6 +304,10 @@ function resolveNewApiUser(rawResult: AuthCaptureResult, candidate: AuthCaptureC
function defaultCandidateIndex(candidates: AuthCaptureCandidate[]): number {
if (candidates.length === 0) return -1
for (const type of props.preferredTypes || []) {
const preferred = candidates.findIndex((c) => c.type === type)
if (preferred >= 0) return preferred
}
// 优先选完整 cookie bundle(包含 cf_clearance 等完整组合)
const bundle = candidates.findIndex((c) => c.type === 'cookie_bundle')
if (bundle >= 0) return bundle
@@ -222,6 +325,8 @@ watch(() => props.modelValue, (v) => { visible.value = v })
const targetUrl = ref(props.initialUrl || '')
watch(() => props.initialUrl, (v) => { if (v) targetUrl.value = v })
const captureMode = ref<'remote' | 'import'>('remote')
const smartupOrigin = computed(() => location.origin)
const AUTH_CAPTURE_STORAGE_KEY = 'smartup_auth_capture_fields'
@@ -264,8 +369,33 @@ const selectedIndex = ref(-1)
const wsConnected = ref(false)
const frameUrl = ref('')
const frameRef = ref<HTMLElement | null>(null)
const creatingImportSession = ref(false)
const importSessionId = ref('')
const importSecret = ref('')
const importPolling = ref(false)
const importReady = ref(false)
const importResult = ref<AuthCaptureResult | null>(null)
const importSelectedIndex = ref(-1)
const importExpiresAt = ref(0)
const nowSeconds = ref(Math.floor(Date.now() / 1000))
const importCode = computed(() => importSessionId.value && importSecret.value
? `${importSessionId.value}:${importSecret.value}`
: '',
)
const importSecondsLeft = computed(() => Math.max(0, Math.floor(importExpiresAt.value - nowSeconds.value)))
const importExpired = computed(() => Boolean(importExpiresAt.value) && importSecondsLeft.value <= 0 && !importReady.value)
const importExpiresLabel = computed(() => {
if (!importExpiresAt.value) return '未生成'
if (importReady.value) return '已完成'
if (importSecondsLeft.value <= 0) return '已过期,请重新生成'
const minutes = Math.floor(importSecondsLeft.value / 60)
const seconds = importSecondsLeft.value % 60
return minutes > 0 ? `${minutes}${seconds} 秒后过期` : `${seconds} 秒后过期`
})
let ws: WebSocket | null = null
let importPollTimer: number | null = null
let importClockTimer: number | null = null
let pointerDown = false
let frameW = 1; let frameH = 1 // natural dimensions of the frame
let prevFrameUrl = '' // previous blob URL pending cleanup
@@ -283,6 +413,39 @@ function clearFrameUrls() {
prevFrameUrl = ''
}
function stopImportPolling() {
if (importPollTimer !== null) {
window.clearInterval(importPollTimer)
importPollTimer = null
}
importPolling.value = false
}
function startImportClock() {
if (importClockTimer !== null) {
window.clearInterval(importClockTimer)
}
nowSeconds.value = Math.floor(Date.now() / 1000)
importClockTimer = window.setInterval(() => {
nowSeconds.value = Math.floor(Date.now() / 1000)
if (importExpired.value) {
stopImportPolling()
ElMessage.warning('导入码已过期,请重新生成')
if (importClockTimer !== null) {
window.clearInterval(importClockTimer)
importClockTimer = null
}
}
}, 1000)
}
function stopImportClock() {
if (importClockTimer !== null) {
window.clearInterval(importClockTimer)
importClockTimer = null
}
}
// ——— Launch ———
async function launchBrowser() {
@@ -301,6 +464,113 @@ async function launchBrowser() {
}
}
// ——— Real browser import ———
async function createBrowserImportSession() {
if (!targetUrl.value) return
saveFields()
creatingImportSession.value = true
try {
const res = await authCaptureApi.createImportSession(targetUrl.value)
importSessionId.value = res.data.session_id
importSecret.value = res.data.secret
importExpiresAt.value = Math.floor(Date.now() / 1000) + res.data.expires_in_seconds
importReady.value = false
importResult.value = null
importSelectedIndex.value = -1
ElMessage.success('导入码已生成,请在浏览器扩展中粘贴')
stopImportPolling()
startImportClock()
importPolling.value = true
importPollTimer = window.setInterval(() => {
void pollImportSessionOnce()
}, 2000)
void pollImportSessionOnce()
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '生成导入码失败')
} finally {
creatingImportSession.value = false
}
}
async function pollImportSessionOnce() {
if (!importSessionId.value) return
try {
const res = await authCaptureApi.importSessionStatus(importSessionId.value)
importExpiresAt.value = res.data.expires_at
if (res.data.ready && res.data.result) {
importReady.value = true
importResult.value = res.data.result
importSelectedIndex.value = defaultCandidateIndex(res.data.result.candidates)
stopImportPolling()
stopImportClock()
}
} catch (e: any) {
stopImportPolling()
ElMessage.error(e?.response?.data?.detail || '导入会话已失效')
}
}
function resetImportResult() {
importReady.value = false
importResult.value = null
importSelectedIndex.value = -1
if (importSessionId.value) {
importPolling.value = true
importPollTimer = window.setInterval(() => {
void pollImportSessionOnce()
}, 2000)
}
}
async function confirmImportSelection() {
if (importSelectedIndex.value < 0 || !importResult.value || !importSessionId.value) return
const selectedCandidate = importResult.value.candidates[importSelectedIndex.value]
applyingSelection.value = true
try {
const rawResult = await authCaptureApi.importSessionStatus(importSessionId.value, { includeRaw: true })
const candidates = rawResult.data.result?.candidates || []
const fullCandidate = candidates.find((candidate) => sameCandidate(candidate, selectedCandidate))
if (!rawResult.data.result || !fullCandidate) {
ElMessage.error('未找到完整认证信息,请重新导入后再试')
return
}
const resolvedValue = resolveCandidateValue(fullCandidate)
if (!resolvedValue) {
ElMessage.error('认证信息为空,请重新导入后再试')
return
}
emit('select', {
type: fullCandidate.type,
value: resolvedValue,
source: fullCandidate.source,
cookie_name: fullCandidate.cookie_name,
cookie_value: fullCandidate.cookie_value,
cookie_count: fullCandidate.cookie_count,
cookie_names: fullCandidate.cookie_names,
new_api_user: resolveNewApiUser(rawResult.data.result, fullCandidate),
})
closeDialog()
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '获取完整认证信息失败')
} finally {
applyingSelection.value = false
}
}
async function copyText(text: string) {
if (!text) return
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制')
} catch {
ElMessage.error('复制失败')
}
}
// ——— WebSocket frame stream ———
function connectWs() {
@@ -479,6 +749,8 @@ function resetExtract() {
}
async function handleClose() {
stopImportPolling()
stopImportClock()
disconnectWs()
if (sessionId.value) {
try { await authCaptureApi.closeSession(sessionId.value) } catch { /* ignore */ }
@@ -503,6 +775,8 @@ function disconnectWs() {
}
onUnmounted(() => {
stopImportPolling()
stopImportClock()
disconnectWs()
if (sessionId.value) {
authCaptureApi.closeSession(sessionId.value).catch(() => {})
@@ -532,6 +806,11 @@ function maskValue(v: string): string {
<style scoped>
.auth-capture-body { min-height: 350px; }
.capture-mode-row {
display: flex;
justify-content: flex-end;
margin-bottom: 10px;
}
.capture-step { padding: 4px 0; }
.capture-step-header {
display: flex; justify-content: space-between; align-items: center;
@@ -546,6 +825,34 @@ function maskValue(v: string): string {
.capture-launch-row {
display: flex; justify-content: space-between; align-items: center; margin-top: 4px;
}
.import-session-panel {
border: 1px solid var(--el-border-color);
border-radius: 8px;
padding: 10px;
margin: 10px 0;
background: var(--el-fill-color-light);
}
.import-row {
display: grid;
grid-template-columns: 90px minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
min-height: 32px;
}
.import-row code {
overflow-wrap: anywhere;
color: var(--el-text-color-primary);
}
.import-label {
color: var(--el-text-color-secondary);
font-size: 0.82rem;
}
.import-status {
font-size: 0.86rem;
}
.import-status.ready { color: #52c41a; }
.import-status.waiting { color: var(--el-text-color-secondary); }
.import-status.expired { color: #ff4d4f; }
.ws-status { display: flex; align-items: center; gap: 4px; font-size: 0.78rem; color: var(--el-text-color-secondary); }
.ws-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
.ws-dot.connected { background: #52c41a; }
+61 -1
View File
@@ -98,11 +98,18 @@
<el-switch v-model="form.enabled" />
</el-form-item>
<el-form-item v-if="form.access_mode === 'remote_browser'" label="关联上游">
<el-select v-model="form.linked_upstream_id" clearable placeholder="选择要一键刷新凭证的上游" style="width:100%">
<el-select v-model="form.linked_upstream_id" clearable placeholder="选择要一键刷新凭证的上游" style="width:100%" @change="handleLinkedUpstreamChange">
<el-option v-for="u in upstreamList" :key="u.id" :label="`${u.name} (${u.base_url})`" :value="u.id" />
</el-select>
<div class="form-hint">关联后可在页面查看器中一键刷新该上游的认证凭证</div>
</el-form-item>
<el-form-item v-if="form.access_mode === 'remote_browser' && form.linked_upstream_id" label="上游类型">
<el-select v-model="form.upstream_platform" style="width:100%">
<el-option label="Sub2API" value="sub2api" />
<el-option label="New-API" value="new-api" />
</el-select>
<div class="form-hint">保存后会同步该关联上游的接口路径刷新凭证时按此类型选择 Token Cookie</div>
</el-form-item>
<div class="login-section">
<div class="login-section-head">
<span>登录自动填充</span>
@@ -196,6 +203,8 @@ const editingId = ref<number | null>(null)
const loginAutofillTouched = ref(false)
const formRef = ref<FormInstance>()
type UpstreamPlatform = 'sub2api' | 'new-api'
type PageFormState = {
name: string
url: string
@@ -214,6 +223,7 @@ type PageFormState = {
login_password_configured: boolean
login_password_clear: boolean
linked_upstream_id: number | null
upstream_platform: UpstreamPlatform
}
const defaultForm = (): PageFormState => ({
@@ -234,6 +244,7 @@ const defaultForm = (): PageFormState => ({
login_password_configured: false,
login_password_clear: false,
linked_upstream_id: null,
upstream_platform: 'sub2api',
})
const form = ref(defaultForm())
const rules = {
@@ -280,10 +291,57 @@ function openEdit(page: CustomPageData) {
login_password_configured: page.login_password_configured,
login_password_clear: false,
linked_upstream_id: page.linked_upstream_id ?? null,
upstream_platform: detectUpstreamPlatform(page.linked_upstream_id ?? null),
}
dialogVisible.value = true
}
function selectedUpstream(id = form.value.linked_upstream_id): UpstreamData | undefined {
return upstreamList.value.find((u) => u.id === id)
}
function detectUpstreamPlatform(id: number | null): UpstreamPlatform {
const upstream = selectedUpstream(id)
if (!upstream) return 'sub2api'
const cfg = upstream.auth_config_masked || {}
if (
(upstream.groups_endpoint || '').replace(/\/+$/, '') === '/api/user/self/groups' ||
cfg.login_path === '/api/user/login'
) {
return 'new-api'
}
return 'sub2api'
}
function handleLinkedUpstreamChange(id: number | null) {
form.value.upstream_platform = detectUpstreamPlatform(id)
}
async function syncLinkedUpstreamPlatform() {
if (form.value.access_mode !== 'remote_browser' || !form.value.linked_upstream_id) return
if (form.value.upstream_platform === 'new-api') {
await upstreamsApi.update(form.value.linked_upstream_id, {
api_prefix: '',
groups_endpoint: '/api/user/self/groups',
rate_endpoint: '/api/user/self/groups',
balance_endpoint: '/api/user/self',
balance_response_path: 'data.quota',
balance_divisor: 500000,
auth_config: { login_path: '/api/user/login', username_field: 'username' },
} as any)
return
}
await upstreamsApi.update(form.value.linked_upstream_id, {
api_prefix: '/api/v1',
groups_endpoint: '/groups/available',
rate_endpoint: '/groups/rates',
balance_endpoint: '/auth/me',
balance_response_path: 'data.balance',
balance_divisor: 1,
auth_config: { login_path: '/auth/login', username_field: 'email' },
} as any)
}
async function handleSave() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
@@ -293,6 +351,7 @@ async function handleSave() {
...form.value,
use_proxy: form.value.access_mode === 'proxy',
}
delete (payload as any).upstream_platform
const hasNewLoginCredentials = Boolean(payload.login_username?.trim() && payload.login_password?.trim())
if (!loginAutofillTouched.value && hasNewLoginCredentials) {
payload.login_autofill_enabled = true
@@ -304,6 +363,7 @@ async function handleSave() {
} else {
await customPagesApi.create(savePayload)
}
await syncLinkedUpstreamPlatform()
ElMessage.success('保存成功')
dialogVisible.value = false
loadList()
+26 -12
View File
@@ -120,8 +120,7 @@
<el-form-item v-if="!editingId" label="系统类型(快捷配置)">
<el-select v-model="quickPlatform" @change="handlePlatformChange" style="width: 100%">
<el-option label="Sub2API" value="sub2api" />
<el-option label="New-API (管理员Key)" value="new-api" />
<el-option label="New-API (普通账号)" value="new-api-user" />
<el-option label="New-API" value="new-api-user" />
<el-option label="自定义" value="custom" />
</el-select>
</el-form-item>
@@ -405,6 +404,7 @@
<AuthCaptureDialog
v-model="authCaptureVisible"
:initial-url="authCaptureInitialUrl"
:preferred-types="authCapturePreferredTypes"
@select="handleAuthCaptureSelect"
/>
</div>
@@ -416,7 +416,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
import dayjs from 'dayjs'
import { Refresh, Plus, Edit, List, Delete, Warning, Clock, ArrowRight, Pointer, Key } from '@element-plus/icons-vue'
import { upstreamsApi, type GeneratedUpstreamKey, type UpstreamData, type UpstreamBatchActionResponse } from '@/api'
import { upstreamsApi, type AuthCaptureCandidate, type GeneratedUpstreamKey, type UpstreamData, type UpstreamBatchActionResponse } from '@/api'
import AuthCaptureDialog from '@/components/AuthCaptureDialog.vue'
const list = ref<(UpstreamData & { _testing?: boolean; _checking?: boolean })[]>([])
@@ -459,6 +459,12 @@ const authCaptureInitialUrl = computed(() => {
return base + '/login'
})
const authCapturePreferredTypes = computed<AuthCaptureCandidate['type'][]>(() => {
if (quickPlatform.value === 'sub2api') return ['bearer_token', 'api_key', 'cookie_bundle', 'cookie']
if (quickPlatform.value === 'new-api-user') return ['cookie_bundle', 'cookie', 'bearer_token', 'api_key']
return ['bearer_token', 'api_key', 'cookie_bundle', 'cookie']
})
function openAuthCapture() {
authCaptureVisible.value = true
}
@@ -480,6 +486,9 @@ function handleAuthCaptureSelect(candidate: {
// 完整 cookie 组:value 已是完整 "name1=v1; name2=v2" 字符串
form.value.auth_type = 'cookie'
form.value.auth_config.cookie_string = candidate.value
if (quickPlatform.value === 'sub2api') {
ElMessage.warning('Sub2API 通常需要 Bearer TokenCookie 只能在确认上游支持 Cookie 鉴权时使用')
}
if (candidate.new_api_user) {
form.value.auth_config.new_api_user = candidate.new_api_user
form.value.api_prefix = ''
@@ -498,6 +507,9 @@ function handleAuthCaptureSelect(candidate: {
form.value.auth_config.cookie_string = candidate.cookie_name && candidate.cookie_value
? `${candidate.cookie_name}=${candidate.cookie_value}`
: candidate.value
if (quickPlatform.value === 'sub2api') {
ElMessage.warning('Sub2API 通常需要 Bearer TokenCookie 只能在确认上游支持 Cookie 鉴权时使用')
}
if (candidate.new_api_user) {
form.value.auth_config.new_api_user = candidate.new_api_user
form.value.api_prefix = ''
@@ -542,14 +554,6 @@ function handlePlatformChange(val: string) {
form.value.balance_endpoint = '/auth/me'
form.value.balance_response_path = 'data.balance'
form.value.balance_divisor = 1.0
} else if (val === 'new-api') {
form.value.api_prefix = ''
form.value.groups_endpoint = '/api/group/'
form.value.rate_endpoint = '/api/option/?key=GroupRatio'
form.value.auth_type = 'bearer'
form.value.balance_endpoint = '/api/user/self'
form.value.balance_response_path = 'data.quota'
form.value.balance_divisor = 500000
} else if (val === 'new-api-user') {
form.value.api_prefix = ''
form.value.groups_endpoint = '/api/user/self/groups'
@@ -609,6 +613,16 @@ const healthyRate = computed(() => {
const pendingChecks = computed(() => list.value.filter((item) => !item.last_checked_at).length)
function isNewApiUserUpstream(row: UpstreamData | null) {
if (!row) return false
return row.api_prefix === ''
&& (
row.groups_endpoint === '/api/user/self/groups'
|| row.auth_config_masked?.login_path === '/api/user/login'
|| Boolean(row.auth_config_masked?.new_api_user)
)
}
const latestCheckedAt = computed(() => {
const times = list.value
.map((item) => item.last_checked_at)
@@ -799,7 +813,7 @@ async function openKeyGenerate(row: UpstreamData) {
rate_limit_5h: 0,
rate_limit_1d: 0,
rate_limit_7d: 0,
endpoint: '/keys',
endpoint: isNewApiUserUpstream(row) ? '/api/token' : '/keys',
}
useKeyExpiry.value = false
keyExpiresDays.value = 30