feat: support real browser auth import
This commit is contained in:
+2
-2
@@ -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"]
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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 请求头。
|
||||
@@ -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
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 Token;Cookie 只能在确认上游支持 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 Token;Cookie 只能在确认上游支持 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
|
||||
|
||||
Reference in New Issue
Block a user