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 \ libcairo2 libcups2 libdbus-1-3 libdrm2 libegl1 libfontconfig1 \
libfreetype6 libgbm1 libglib2.0-0t64 libgtk-3-0t64 libnspr4 libnss3 \ libfreetype6 libgbm1 libglib2.0-0t64 libgtk-3-0t64 libnspr4 libnss3 \
libpango-1.0-0 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 \ libpango-1.0-0 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 \
libxdamage1 libxext6 libxfixes3 libxrandr2 libxshmfence1 xvfb \ libxdamage1 libxext6 libxfixes3 libxrandr2 libxshmfence1 xvfb xauth \
curl \ curl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
@@ -63,4 +63,4 @@ ENV DATABASE_URL=sqlite:////app/data/app.db
EXPOSE 8000 EXPOSE 8000
ENTRYPOINT ["tini", "--"] 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.database import get_db
from app.services.auth_capture_service import extract_all 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 ( from app.services.browser_session_service import (
BrowserDependencyError, BrowserDependencyError,
BrowserSessionError, BrowserSessionError,
@@ -43,6 +48,32 @@ class CaptureExtractResponse(BaseModel):
candidates: list[dict] = [] 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]: def _sanitize_candidate(candidate: dict[str, Any]) -> dict[str, Any]:
return { return {
key: value key: value
@@ -134,3 +165,65 @@ async def close_capture_session(
await browser_sessions.close(session_id) await browser_sessions.close(session_id)
except Exception as exc: except Exception as exc:
raise _browser_error(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 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: if not candidates:
return None 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": if preferred_auth_type == "cookie":
for c in candidates: return _first_candidate(candidates, "cookie_bundle", "cookie")
if c["type"] == "cookie_bundle":
return c
for c in candidates:
if c["type"] == "cookie":
return c
elif preferred_auth_type in ("bearer", "api_key"): elif preferred_auth_type in ("bearer", "api_key"):
type_map = {"bearer": "bearer_token", "api_key": "api_key"} type_map = {"bearer": "bearer_token", "api_key": "api_key"}
preferred = type_map.get(preferred_auth_type) preferred = type_map.get(preferred_auth_type)
if preferred: if preferred:
for c in candidates: return _first_candidate(candidates, preferred)
if c["type"] == preferred:
return c
# fallback:排序后取第一个 # fallback:排序后取第一个
return candidates[0] 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}") return RefreshAuthResponse(success=False, message=f"提取失败: {exc}")
candidates = result.get("candidates", []) 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 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="未提取到有效凭证,请确认已在远程浏览器中登录") return RefreshAuthResponse(success=False, message="未提取到有效凭证,请确认已在远程浏览器中登录")
existing_config = _json.loads(upstream.auth_config_json or "{}")
ctype = candidate["type"] ctype = candidate["type"]
if ctype in ("cookie_bundle", "cookie"): 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", "") existing_config["cookie_string"] = candidate.get("value", "")
if candidate.get("new_api_user"): if candidate.get("new_api_user"):
existing_config["new_api_user"] = candidate["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": elif ctype == "bearer_token":
upstream.auth_type = "bearer" upstream.auth_type = "bearer"
raw = candidate.get("value", "") 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() _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( def _ensure_group_key(
db: Session, db: Session,
client: UpstreamClient, client: UpstreamClient,
@@ -465,8 +485,8 @@ def generate_keys_by_groups(
u = db.query(Upstream).filter(Upstream.id == uid).first() u = db.query(Upstream).filter(Upstream.id == uid).first()
if not u: if not u:
raise HTTPException(404, "upstream not found") raise HTTPException(404, "upstream not found")
if u.api_prefix.strip("/") != "api/v1": if not _supports_key_generation(u):
raise HTTPException(400, "首版仅支持 Sub2API 上游(API Prefix 应为 /api/v1") raise HTTPException(400, "仅支持 Sub2API 或 New-API 普通账号上游生成 Key")
# 生成前先对账,清理远端已删除的旧 Key # 生成前先对账,清理远端已删除的旧 Key
try: try:
+20 -9
View File
@@ -137,9 +137,12 @@ def _cookie_matches_hostname(cookie_domain: str, hostname: str) -> bool:
"""判断 cookie domain 是否适用于给定 hostname。 """判断 cookie domain 是否适用于给定 hostname。
支持带点前缀的 domain(如 `.saki.lat` 匹配 `api.saki.lat`)。 支持带点前缀的 domain(如 `.saki.lat` 匹配 `api.saki.lat`)。
注意:hostname 为空时,调用方应跳过 cookie 收集而不是调用此函数。
""" """
if not cookie_domain or not hostname: if not cookie_domain:
return True # 无 domain 限制时视为全域 return True # 无 domain 限制的 cookie 对当前域有效
if not hostname:
return False # 无法确定当前域,保守拒绝
# 去掉前缀点 # 去掉前缀点
domain = cookie_domain.lstrip(".") domain = cookie_domain.lstrip(".")
return hostname == domain or hostname.endswith("." + domain) return hostname == domain or hostname.endswith("." + domain)
@@ -153,14 +156,22 @@ def _build_cookie_bundle(
返回 (cookie_string, cookie_names_list)。 返回 (cookie_string, cookie_names_list)。
cookie_string 格式:name1=value1; name2=value2; ... 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 = "" hostname = ""
if page_url: try:
try: hostname = urlparse(page_url).hostname or ""
hostname = urlparse(page_url).hostname or "" except Exception:
except Exception: pass
pass
if not hostname:
logger.debug("_build_cookie_bundle: cannot parse hostname from %s, skipping", page_url[:80])
return "", []
parts: list[str] = [] parts: list[str] = []
names: list[str] = [] names: list[str] = []
@@ -170,7 +181,7 @@ def _build_cookie_bundle(
domain = c.get("domain", "") domain = c.get("domain", "")
if not name or not value: if not name or not value:
continue continue
if hostname and not _cookie_matches_hostname(domain, hostname): if not _cookie_matches_hostname(domain, hostname):
continue continue
parts.append(f"{name}={value}") parts.append(f"{name}={value}")
names.append(name) 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._last_event_at: dict[str, float] = {}
self._evict_task: Optional[asyncio.Task[None]] = None 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( async def create(
self, self,
custom_page_id: int, custom_page_id: int,
@@ -113,11 +141,9 @@ class BrowserSessionService:
context = await self._playwright.chromium.launch_persistent_context( context = await self._playwright.chromium.launch_persistent_context(
str(self._profile_dir(profile_key)), str(self._profile_dir(profile_key)),
headless=get_settings().browser_headless, **self._browser_launch_kwargs(width, height),
viewport={"width": width, "height": height},
color_scheme="dark",
args=["--no-sandbox", "--disable-dev-shm-usage"],
) )
await self._install_browser_init_scripts(context)
await self._restore_session_state(context, profile_key) await self._restore_session_state(context, profile_key)
# Grant clipboard access for the page origin # Grant clipboard access for the page origin
try: try:
@@ -773,11 +799,9 @@ class BrowserSessionService:
profile_key = f"auth-capture-{session_id[:12]}" profile_key = f"auth-capture-{session_id[:12]}"
context = await self._playwright.chromium.launch_persistent_context( context = await self._playwright.chromium.launch_persistent_context(
str(self._profile_dir(profile_key)), str(self._profile_dir(profile_key)),
headless=get_settings().browser_headless, **self._browser_launch_kwargs(width, height),
viewport={"width": width, "height": height},
color_scheme="dark",
args=["--no-sandbox", "--disable-dev-shm-usage"],
) )
await self._install_browser_init_scripts(context)
# Grant clipboard access for the page origin # Grant clipboard access for the page origin
try: try:
parsed = urlparse(url) parsed = urlparse(url)
+156 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import time
from typing import Any, Optional from typing import Any, Optional
from urllib.parse import urljoin from urllib.parse import urljoin
import httpx import httpx
@@ -13,6 +14,9 @@ class UpstreamError(RuntimeError):
pass pass
NEW_API_DEFAULT_QUOTA_PER_UNIT = 500000
def _find_token(value: Any) -> str: def _find_token(value: Any) -> str:
if isinstance(value, str) and value.count(".") >= 2: if isinstance(value, str) and value.count(".") >= 2:
return value return value
@@ -74,6 +78,8 @@ def mask_secret(value: Any) -> str:
def _unwrap_data(value: Any) -> Any: def _unwrap_data(value: Any) -> Any:
if isinstance(value, dict) and "data" in value and ("code" in value or "message" in value): if isinstance(value, dict) and "data" in value and ("code" in value or "message" in value):
return value.get("data") return value.get("data")
if isinstance(value, dict) and "data" in value and "success" in value:
return value.get("data")
return value return value
@@ -105,6 +111,20 @@ def _extract_key_value(value: Any) -> str:
return "" 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 _unwrap_list(value: Any) -> Optional[list[dict[str, Any]]]:
def _normalize(lst: list) -> list[dict[str, Any]]: def _normalize(lst: list) -> list[dict[str, Any]]:
out = [] out = []
@@ -288,6 +308,17 @@ class UpstreamClient:
prefix = f"/{self.api_prefix}" if self.api_prefix else "" prefix = f"/{self.api_prefix}" if self.api_prefix else ""
return f"{self.base_url}{prefix}/{path.lstrip('/')}" 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]: def _headers(self, auth: bool = True) -> dict[str, str]:
headers: dict[str, str] = { headers: dict[str, str] = {
"Accept": "application/json", "Accept": "application/json",
@@ -348,6 +379,119 @@ class UpstreamClient:
raise UpstreamError(f"{method} {path} returned HTML, not JSON") raise UpstreamError(f"{method} {path} returned HTML, not JSON")
return resp.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: def login(self) -> None:
if self.auth_type != "login_password": if self.auth_type != "login_password":
return return
@@ -412,6 +556,9 @@ class UpstreamClient:
endpoint: str = "/keys", endpoint: str = "/keys",
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""查询远端上游 Key 列表,支持按名称搜索、分组筛选、状态筛选。""" """查询远端上游 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] = {} params: dict[str, Any] = {}
if search: if search:
params["search"] = search params["search"] = search
@@ -446,7 +593,7 @@ class UpstreamClient:
for key in ("items", "keys", "list", "records"): for key in ("items", "keys", "list", "records"):
val = data.get(key) val = data.get(key)
if isinstance(val, list): 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__}") raise UpstreamError(f"unexpected keys response type: {type(data).__name__}")
def delete_api_key(self, key_id: str, endpoint: str = "/keys") -> None: def delete_api_key(self, key_id: str, endpoint: str = "/keys") -> None:
@@ -486,6 +633,14 @@ class UpstreamClient:
rate_limit_7d: float = 0, rate_limit_7d: float = 0,
endpoint: str = "/keys", endpoint: str = "/keys",
) -> dict[str, Any]: ) -> 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] = { body: dict[str, Any] = {
"name": name, "name": name,
"group_id": int(group_id) if str(group_id).isdigit() else group_id, "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") assert _cookie_matches_hostname(".saki.lat", "saki.lat")
def test_no_domain_matches_all(): def test_no_domain_cookie_matches_any_hostname():
"""domain 视为不限制""" """cookie domain(无限制)应对任意 hostname 返回 True"""
assert _cookie_matches_hostname("", "anything.example.com") 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(): def test_different_domain_no_match():
assert not _cookie_matches_hostname(".example.com", "saki.lat") 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") bundle = next(c for c in candidates if c["type"] == "cookie_bundle")
assert bundle.get("new_api_user") == "42" 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 from pathlib import Path
import pytest import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, text from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
@@ -10,10 +9,9 @@ from sqlalchemy.pool import StaticPool
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
from app import database as database_module from app import database as database_module
from app.database import Base, get_db from app.database import Base
from app.main import app
from app.models.custom_page import CustomPage from app.models.custom_page import CustomPage
from app.utils.auth import get_current_user from app.routers import custom_pages
@pytest.fixture() @pytest.fixture()
@@ -33,48 +31,41 @@ def db_session():
Base.metadata.drop_all(bind=engine) Base.metadata.drop_all(bind=engine)
@pytest.fixture() def test_create_page_auto_enables_autofill_when_credentials_are_saved(db_session):
def client(db_session): response = custom_pages.create_page(
def override_get_db(): custom_pages.CustomPageCreate(
yield db_session 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 assert response.login_autofill_enabled is True
app.dependency_overrides[get_current_user] = lambda: object() assert response.login_password_configured is True
try:
yield TestClient(app)
finally:
app.dependency_overrides.clear()
def test_create_page_auto_enables_autofill_when_credentials_are_saved(client): def test_create_page_respects_explicit_autofill_disable(db_session):
response = client.post("/api/custom-pages", json={ response = custom_pages.create_page(
"name": "Login page", custom_pages.CustomPageCreate(
"url": "https://example.test/login", name="Login page",
"access_mode": "remote_browser", url="https://example.test/login",
"login_username": "alice", access_mode="remote_browser",
"login_password": "secret", login_username="alice",
}) login_password="secret",
login_autofill_enabled=False,
),
db_session,
object(),
)
assert response.status_code == 201 assert response.login_autofill_enabled is False
assert response.json()["login_autofill_enabled"] is True
assert response.json()["login_password_configured"] is True
def test_create_page_respects_explicit_autofill_disable(client): def test_update_page_auto_enables_autofill_when_new_password_is_saved(db_session):
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):
page = CustomPage( page = CustomPage(
name="Login page", name="Login page",
url="https://example.test/login", 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.commit()
db_session.refresh(page) db_session.refresh(page)
response = client.put(f"/api/custom-pages/{page.id}", json={ response = custom_pages.update_page(
"login_username": "alice@example.test", page.id,
"login_password": "new-secret", custom_pages.CustomPageUpdate(
}) login_username="alice@example.test",
login_password="new-secret",
),
db_session,
object(),
)
assert response.status_code == 200 assert response.login_autofill_enabled is True
assert response.json()["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( page = CustomPage(
name="Login page", name="Login page",
url="https://example.test/login", 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.commit()
db_session.refresh(page) db_session.refresh(page)
response = client.put(f"/api/custom-pages/{page.id}", json={ response = custom_pages.update_page(
"login_username": "alice@example.test", page.id,
}) custom_pages.CustomPageUpdate(login_username="alice@example.test"),
db_session,
object(),
)
assert response.status_code == 200 assert response.login_autofill_enabled is False
assert response.json()["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( page = CustomPage(
name="Login page", name="Login page",
url="https://example.test/login", 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.commit()
db_session.refresh(page) db_session.refresh(page)
response = client.put(f"/api/custom-pages/{page.id}", json={ response = custom_pages.update_page(
"login_username": "alice@example.test", page.id,
"login_autofill_enabled": False, custom_pages.CustomPageUpdate(
}) login_username="alice@example.test",
login_autofill_enabled=False,
),
db_session,
object(),
)
assert response.status_code == 200 assert response.login_autofill_enabled is False
assert response.json()["login_autofill_enabled"] is False
def test_custom_page_migration_backfills_autofill_once(monkeypatch): 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() remaining = db_session.query(UpstreamGeneratedKey).all()
assert len(remaining) == 0 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 - DATABASE_URL=sqlite:////app/data/app.db
- TZ=${TZ:-Asia/Shanghai} - TZ=${TZ:-Asia/Shanghai}
- UNHEALTHY_THRESHOLD=${UNHEALTHY_THRESHOLD:-3} - UNHEALTHY_THRESHOLD=${UNHEALTHY_THRESHOLD:-3}
- BROWSER_HEADLESS=${BROWSER_HEADLESS:-false}
logging: logging:
driver: json-file driver: json-file
options: options:
+19
View File
@@ -491,6 +491,12 @@ export interface AuthCaptureSession {
ws_url: string ws_url: string
} }
export interface BrowserImportSession {
session_id: string
secret: string
expires_in_seconds: number
}
export interface AuthCaptureCandidate { export interface AuthCaptureCandidate {
type: 'bearer_token' | 'cookie' | 'cookie_bundle' | 'credential' | 'api_key' type: 'bearer_token' | 'cookie' | 'cookie_bundle' | 'credential' | 'api_key'
source: string source: string
@@ -513,6 +519,13 @@ export interface AuthCaptureResult {
candidates: AuthCaptureCandidate[] candidates: AuthCaptureCandidate[]
} }
export interface BrowserImportStatus {
session_id: string
ready: boolean
expires_at: number
result: AuthCaptureResult | null
}
export const authCaptureApi = { export const authCaptureApi = {
createSession: (url: string, width?: number, height?: number) => createSession: (url: string, width?: number, height?: number) =>
api.post<AuthCaptureSession>('/api/auth-capture/sessions', { url, width, height }), api.post<AuthCaptureSession>('/api/auth-capture/sessions', { url, width, height }),
@@ -522,6 +535,12 @@ export const authCaptureApi = {
}), }),
closeSession: (sessionId: string) => closeSession: (sessionId: string) =>
api.delete(`/api/auth-capture/sessions/${sessionId}`), 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) => { wsUrl: (sessionId: string, token?: string) => {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:' const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
const params = new URLSearchParams() const params = new URLSearchParams()
+308 -1
View File
@@ -8,6 +8,14 @@
destroy-on-close destroy-on-close
> >
<div class="auth-capture-body"> <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 --> <!-- Step 1: URL + Launch -->
<div v-if="!sessionId" class="capture-step"> <div v-if="!sessionId" class="capture-step">
<h4>步骤 1输入目标登录页面地址</h4> <h4>步骤 1输入目标登录页面地址</h4>
@@ -134,12 +142,102 @@
</div> </div>
</transition> </transition>
</div> </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> </div>
</el-dialog> </el-dialog>
</template> </template>
<script setup lang="ts"> <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 { ElMessage } from 'element-plus'
import { Pointer, Search, Back, Right, Refresh, Loading } from '@element-plus/icons-vue' import { Pointer, Search, Back, Right, Refresh, Loading } from '@element-plus/icons-vue'
import { authCaptureApi, type AuthCaptureCandidate, type AuthCaptureResult } from '@/api' import { authCaptureApi, type AuthCaptureCandidate, type AuthCaptureResult } from '@/api'
@@ -148,6 +246,7 @@ import { useAuthStore } from '@/stores/auth'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
initialUrl?: string initialUrl?: string
preferredTypes?: AuthCaptureCandidate['type'][]
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -205,6 +304,10 @@ function resolveNewApiUser(rawResult: AuthCaptureResult, candidate: AuthCaptureC
function defaultCandidateIndex(candidates: AuthCaptureCandidate[]): number { function defaultCandidateIndex(candidates: AuthCaptureCandidate[]): number {
if (candidates.length === 0) return -1 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 等完整组合) // 优先选完整 cookie bundle(包含 cf_clearance 等完整组合)
const bundle = candidates.findIndex((c) => c.type === 'cookie_bundle') const bundle = candidates.findIndex((c) => c.type === 'cookie_bundle')
if (bundle >= 0) return bundle if (bundle >= 0) return bundle
@@ -222,6 +325,8 @@ watch(() => props.modelValue, (v) => { visible.value = v })
const targetUrl = ref(props.initialUrl || '') const targetUrl = ref(props.initialUrl || '')
watch(() => props.initialUrl, (v) => { if (v) targetUrl.value = v }) 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' const AUTH_CAPTURE_STORAGE_KEY = 'smartup_auth_capture_fields'
@@ -264,8 +369,33 @@ const selectedIndex = ref(-1)
const wsConnected = ref(false) const wsConnected = ref(false)
const frameUrl = ref('') const frameUrl = ref('')
const frameRef = ref<HTMLElement | null>(null) 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 ws: WebSocket | null = null
let importPollTimer: number | null = null
let importClockTimer: number | null = null
let pointerDown = false let pointerDown = false
let frameW = 1; let frameH = 1 // natural dimensions of the frame let frameW = 1; let frameH = 1 // natural dimensions of the frame
let prevFrameUrl = '' // previous blob URL pending cleanup let prevFrameUrl = '' // previous blob URL pending cleanup
@@ -283,6 +413,39 @@ function clearFrameUrls() {
prevFrameUrl = '' 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 ——— // ——— Launch ———
async function launchBrowser() { 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 ——— // ——— WebSocket frame stream ———
function connectWs() { function connectWs() {
@@ -479,6 +749,8 @@ function resetExtract() {
} }
async function handleClose() { async function handleClose() {
stopImportPolling()
stopImportClock()
disconnectWs() disconnectWs()
if (sessionId.value) { if (sessionId.value) {
try { await authCaptureApi.closeSession(sessionId.value) } catch { /* ignore */ } try { await authCaptureApi.closeSession(sessionId.value) } catch { /* ignore */ }
@@ -503,6 +775,8 @@ function disconnectWs() {
} }
onUnmounted(() => { onUnmounted(() => {
stopImportPolling()
stopImportClock()
disconnectWs() disconnectWs()
if (sessionId.value) { if (sessionId.value) {
authCaptureApi.closeSession(sessionId.value).catch(() => {}) authCaptureApi.closeSession(sessionId.value).catch(() => {})
@@ -532,6 +806,11 @@ function maskValue(v: string): string {
<style scoped> <style scoped>
.auth-capture-body { min-height: 350px; } .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 { padding: 4px 0; }
.capture-step-header { .capture-step-header {
display: flex; justify-content: space-between; align-items: center; display: flex; justify-content: space-between; align-items: center;
@@ -546,6 +825,34 @@ function maskValue(v: string): string {
.capture-launch-row { .capture-launch-row {
display: flex; justify-content: space-between; align-items: center; margin-top: 4px; 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-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 { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
.ws-dot.connected { background: #52c41a; } .ws-dot.connected { background: #52c41a; }
+61 -1
View File
@@ -98,11 +98,18 @@
<el-switch v-model="form.enabled" /> <el-switch v-model="form.enabled" />
</el-form-item> </el-form-item>
<el-form-item v-if="form.access_mode === 'remote_browser'" label="关联上游"> <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-option v-for="u in upstreamList" :key="u.id" :label="`${u.name} (${u.base_url})`" :value="u.id" />
</el-select> </el-select>
<div class="form-hint">关联后可在页面查看器中一键刷新该上游的认证凭证</div> <div class="form-hint">关联后可在页面查看器中一键刷新该上游的认证凭证</div>
</el-form-item> </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">
<div class="login-section-head"> <div class="login-section-head">
<span>登录自动填充</span> <span>登录自动填充</span>
@@ -196,6 +203,8 @@ const editingId = ref<number | null>(null)
const loginAutofillTouched = ref(false) const loginAutofillTouched = ref(false)
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()
type UpstreamPlatform = 'sub2api' | 'new-api'
type PageFormState = { type PageFormState = {
name: string name: string
url: string url: string
@@ -214,6 +223,7 @@ type PageFormState = {
login_password_configured: boolean login_password_configured: boolean
login_password_clear: boolean login_password_clear: boolean
linked_upstream_id: number | null linked_upstream_id: number | null
upstream_platform: UpstreamPlatform
} }
const defaultForm = (): PageFormState => ({ const defaultForm = (): PageFormState => ({
@@ -234,6 +244,7 @@ const defaultForm = (): PageFormState => ({
login_password_configured: false, login_password_configured: false,
login_password_clear: false, login_password_clear: false,
linked_upstream_id: null, linked_upstream_id: null,
upstream_platform: 'sub2api',
}) })
const form = ref(defaultForm()) const form = ref(defaultForm())
const rules = { const rules = {
@@ -280,10 +291,57 @@ function openEdit(page: CustomPageData) {
login_password_configured: page.login_password_configured, login_password_configured: page.login_password_configured,
login_password_clear: false, login_password_clear: false,
linked_upstream_id: page.linked_upstream_id ?? null, linked_upstream_id: page.linked_upstream_id ?? null,
upstream_platform: detectUpstreamPlatform(page.linked_upstream_id ?? null),
} }
dialogVisible.value = true 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() { async function handleSave() {
const valid = await formRef.value?.validate().catch(() => false) const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return if (!valid) return
@@ -293,6 +351,7 @@ async function handleSave() {
...form.value, ...form.value,
use_proxy: form.value.access_mode === 'proxy', use_proxy: form.value.access_mode === 'proxy',
} }
delete (payload as any).upstream_platform
const hasNewLoginCredentials = Boolean(payload.login_username?.trim() && payload.login_password?.trim()) const hasNewLoginCredentials = Boolean(payload.login_username?.trim() && payload.login_password?.trim())
if (!loginAutofillTouched.value && hasNewLoginCredentials) { if (!loginAutofillTouched.value && hasNewLoginCredentials) {
payload.login_autofill_enabled = true payload.login_autofill_enabled = true
@@ -304,6 +363,7 @@ async function handleSave() {
} else { } else {
await customPagesApi.create(savePayload) await customPagesApi.create(savePayload)
} }
await syncLinkedUpstreamPlatform()
ElMessage.success('保存成功') ElMessage.success('保存成功')
dialogVisible.value = false dialogVisible.value = false
loadList() loadList()
+26 -12
View File
@@ -120,8 +120,7 @@
<el-form-item v-if="!editingId" label="系统类型(快捷配置)"> <el-form-item v-if="!editingId" label="系统类型(快捷配置)">
<el-select v-model="quickPlatform" @change="handlePlatformChange" style="width: 100%"> <el-select v-model="quickPlatform" @change="handlePlatformChange" style="width: 100%">
<el-option label="Sub2API" value="sub2api" /> <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-option label="自定义" value="custom" />
</el-select> </el-select>
</el-form-item> </el-form-item>
@@ -405,6 +404,7 @@
<AuthCaptureDialog <AuthCaptureDialog
v-model="authCaptureVisible" v-model="authCaptureVisible"
:initial-url="authCaptureInitialUrl" :initial-url="authCaptureInitialUrl"
:preferred-types="authCapturePreferredTypes"
@select="handleAuthCaptureSelect" @select="handleAuthCaptureSelect"
/> />
</div> </div>
@@ -416,7 +416,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus' import type { FormInstance } from 'element-plus'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Refresh, Plus, Edit, List, Delete, Warning, Clock, ArrowRight, Pointer, Key } from '@element-plus/icons-vue' 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' import AuthCaptureDialog from '@/components/AuthCaptureDialog.vue'
const list = ref<(UpstreamData & { _testing?: boolean; _checking?: boolean })[]>([]) const list = ref<(UpstreamData & { _testing?: boolean; _checking?: boolean })[]>([])
@@ -459,6 +459,12 @@ const authCaptureInitialUrl = computed(() => {
return base + '/login' 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() { function openAuthCapture() {
authCaptureVisible.value = true authCaptureVisible.value = true
} }
@@ -480,6 +486,9 @@ function handleAuthCaptureSelect(candidate: {
// 完整 cookie 组:value 已是完整 "name1=v1; name2=v2" 字符串 // 完整 cookie 组:value 已是完整 "name1=v1; name2=v2" 字符串
form.value.auth_type = 'cookie' form.value.auth_type = 'cookie'
form.value.auth_config.cookie_string = candidate.value form.value.auth_config.cookie_string = candidate.value
if (quickPlatform.value === 'sub2api') {
ElMessage.warning('Sub2API 通常需要 Bearer TokenCookie 只能在确认上游支持 Cookie 鉴权时使用')
}
if (candidate.new_api_user) { if (candidate.new_api_user) {
form.value.auth_config.new_api_user = candidate.new_api_user form.value.auth_config.new_api_user = candidate.new_api_user
form.value.api_prefix = '' form.value.api_prefix = ''
@@ -498,6 +507,9 @@ function handleAuthCaptureSelect(candidate: {
form.value.auth_config.cookie_string = candidate.cookie_name && candidate.cookie_value form.value.auth_config.cookie_string = candidate.cookie_name && candidate.cookie_value
? `${candidate.cookie_name}=${candidate.cookie_value}` ? `${candidate.cookie_name}=${candidate.cookie_value}`
: candidate.value : candidate.value
if (quickPlatform.value === 'sub2api') {
ElMessage.warning('Sub2API 通常需要 Bearer TokenCookie 只能在确认上游支持 Cookie 鉴权时使用')
}
if (candidate.new_api_user) { if (candidate.new_api_user) {
form.value.auth_config.new_api_user = candidate.new_api_user form.value.auth_config.new_api_user = candidate.new_api_user
form.value.api_prefix = '' form.value.api_prefix = ''
@@ -542,14 +554,6 @@ function handlePlatformChange(val: string) {
form.value.balance_endpoint = '/auth/me' form.value.balance_endpoint = '/auth/me'
form.value.balance_response_path = 'data.balance' form.value.balance_response_path = 'data.balance'
form.value.balance_divisor = 1.0 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') { } else if (val === 'new-api-user') {
form.value.api_prefix = '' form.value.api_prefix = ''
form.value.groups_endpoint = '/api/user/self/groups' 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) 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 latestCheckedAt = computed(() => {
const times = list.value const times = list.value
.map((item) => item.last_checked_at) .map((item) => item.last_checked_at)
@@ -799,7 +813,7 @@ async function openKeyGenerate(row: UpstreamData) {
rate_limit_5h: 0, rate_limit_5h: 0,
rate_limit_1d: 0, rate_limit_1d: 0,
rate_limit_7d: 0, rate_limit_7d: 0,
endpoint: '/keys', endpoint: isNewApiUserUpstream(row) ? '/api/token' : '/keys',
} }
useKeyExpiry.value = false useKeyExpiry.value = false
keyExpiresDays.value = 30 keyExpiresDays.value = 30