Remove server remote browser support

This commit is contained in:
liumangmang
2026-06-02 19:25:20 +08:00
parent 3181a6f6cc
commit a42bcba483
22 changed files with 151 additions and 5029 deletions
-4
View File
@@ -18,7 +18,3 @@ BIND_HOST=0.0.0.0
TZ=Asia/Shanghai
# 连续失败多少次判定为 unhealthy(默认 3
UNHEALTHY_THRESHOLD=3
# 远程浏览器 profile 存储目录
BROWSER_PROFILES_DIR=/app/data/browser-profiles
# 生产环境通常保持 true;调试时可改为 false
BROWSER_HEADLESS=true
+5 -23
View File
@@ -1,12 +1,10 @@
# syntax=docker/dockerfile:1.4
# ---- Stage 1: Build frontend ----
FROM node:20-alpine AS frontend-build
WORKDIR /frontend
# 依赖层:package*.json 不变则复用 npm 缓存
COPY frontend/package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --registry=https://registry.npmmirror.com
RUN npm ci --registry=https://registry.npmmirror.com
# 源码层:业务代码变更不影响上层依赖
COPY frontend/ .
@@ -16,39 +14,23 @@ RUN npm run build
FROM python:3.12-slim
WORKDIR /app
ENV PLAYWRIGHT_DOWNLOAD_HOST=https://npmmirror.com/mirrors/playwright
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
RUN sed -i 's|http://deb.debian.org|https://mirrors.aliyun.com|g; s|http://security.debian.org|https://mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources
# Install tini as PID 1 to properly reap orphan Chromium zombie processes
# Install tini as PID 1 to properly reap orphan processes
RUN apt-get update && apt-get install -y --no-install-recommends tini && rm -rf /var/lib/apt/lists/*
# 系统依赖层:apt 包安装,缓存 deb 包避免重复下载
RUN --mount=type=cache,target=/var/cache/apt \
apt-get update \
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
fonts-liberation fonts-unifont fonts-wqy-zenhei \
libasound2t64 libatk-bridge2.0-0 libatk1.0-0 libatspi2.0-0 \
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 xauth \
curl \
&& rm -rf /var/lib/apt/lists/*
# Python 依赖层:requirements.txt 不变则复用 pip 缓存
COPY backend/requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --index-url https://pypi.tuna.tsinghua.edu.cn/simple \
RUN pip install --index-url https://pypi.tuna.tsinghua.edu.cn/simple \
--trusted-host pypi.tuna.tsinghua.edu.cn \
-r requirements.txt
# Playwright Chromium:安装在镜像层中,业务代码变更不会触发重下
# --mount=type=cache 利用 BuildKit 缓存避免每次构建重下 ~170 MB 浏览器
RUN --mount=type=cache,target=/root/.cache/ms-playwright,sharing=locked \
playwright install chromium
# 源码层:业务代码变更不影响上面所有依赖层
COPY backend/ .
@@ -63,4 +45,4 @@ ENV DATABASE_URL=sqlite:////app/data/app.db
EXPOSE 8000
ENTRYPOINT ["tini", "--"]
CMD ["xvfb-run", "-a", "--server-args=-screen 0 1920x1080x24", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+1 -4
View File
@@ -9,7 +9,7 @@ SmartUp 是一个独立的 Web 后台,用于管理多个 API 上游的分组
- **倍率快照**:检测变化后保存快照,diff 比对历史
- **Webhook 通知**:支持通用 JSON 和钉钉机器人(带签名)
- **通知日志**:记录每次发送结果,支持筛选查看
- **自定义页面**:支持直接嵌入、代理模式、远程浏览器模式
- **自定义页面**:支持外部控制台入口管理
## 技术栈
@@ -50,7 +50,6 @@ docker compose up -d --build
cd backend
python -m venv venv && source venv/bin/activate
pip install -r requirements.txt
playwright install chromium
# 创建 .env(可复制根目录的 .env.example
cat > .env << 'EOF'
@@ -95,8 +94,6 @@ cp ./data/app.db ./data/app.db.$(date +%Y%m%d)
| `SERVER_PORT` | 宿主机端口 | `8899` |
| `TZ` | 时区 | `Asia/Shanghai` |
| `UNHEALTHY_THRESHOLD` | 连续失败多少次标记为异常 | `3` |
| `BROWSER_PROFILES_DIR` | 远程浏览器登录态/profile 存储目录 | `/app/data/browser-profiles` |
| `BROWSER_HEADLESS` | 远程浏览器是否无头运行 | `true` |
## 目录结构
-2
View File
@@ -15,8 +15,6 @@ class Settings(BaseSettings):
tz: str = "Asia/Shanghai"
# consecutive failures before upstream goes unhealthy
unhealthy_threshold: int = 3
browser_profiles_dir: str = "/app/data/browser-profiles"
browser_headless: bool = True
@property
def cors_origin_list(self) -> list[str]:
+1 -4
View File
@@ -15,8 +15,7 @@ from app.models.admin_user import AdminUser
from app.database import SessionLocal
from app.utils.auth import hash_password, verify_password, validate_password_supported
from app.services.scheduler import start_scheduler, stop_scheduler
from app.routers import auth, upstreams, webhooks, logs, custom_pages, browser_sessions, websites, auth_capture
from app.services.browser_session_service import browser_sessions as browser_session_service
from app.routers import auth, upstreams, webhooks, logs, custom_pages, websites, auth_capture
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
logger = logging.getLogger(__name__)
@@ -67,7 +66,6 @@ async def lifespan(app: FastAPI):
_init_admin()
start_scheduler()
yield
await browser_session_service.shutdown()
stop_scheduler()
@@ -97,7 +95,6 @@ app.include_router(upstreams.router)
app.include_router(webhooks.router)
app.include_router(logs.router)
app.include_router(custom_pages.router)
app.include_router(browser_sessions.router)
app.include_router(websites.router)
app.include_router(auth_capture.router)
+2 -109
View File
@@ -1,45 +1,23 @@
"""Auth capture API — remote browser for manual login + credential extraction."""
"""Auth capture API for real-browser credential imports."""
from __future__ import annotations
import logging
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
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,
browser_sessions,
)
from app.utils.auth import get_current_user, get_user_from_token_param
logger = logging.getLogger(__name__)
from app.utils.auth import get_current_user
router = APIRouter(prefix="/api/auth-capture", tags=["auth-capture"])
SENSITIVE_CANDIDATE_FIELDS = frozenset({"value", "cookie_value"})
class CaptureSessionCreate(BaseModel):
url: str = Field(..., description="Target login page URL to open in browser")
width: int = Field(default=1280, ge=320, le=2560)
height: int = Field(default=720, ge=240, le=1600)
class CaptureSessionResponse(BaseModel):
session_id: str
ws_url: str
class CaptureExtractResponse(BaseModel):
cookies: list[dict] = []
storage: dict[str, str] = {}
@@ -82,91 +60,6 @@ def _sanitize_candidate(candidate: dict[str, Any]) -> dict[str, Any]:
}
def _browser_error(exc: Exception) -> HTTPException:
if isinstance(exc, BrowserDependencyError):
return HTTPException(503, str(exc))
if isinstance(exc, BrowserSessionError):
return HTTPException(409, str(exc))
if isinstance(exc, KeyError):
return HTTPException(404, "session not found")
if isinstance(exc, ValueError):
return HTTPException(400, str(exc))
logger.exception("auth-capture error")
return HTTPException(500, "internal error")
def _ws_url(session_id: str, token: str) -> str:
"""Build WebSocket URL for the remote browser viewer."""
return f"/api/browser-sessions/{session_id}/ws?token={token}"
@router.post("/sessions", response_model=CaptureSessionResponse, status_code=201)
async def create_capture_session(
body: CaptureSessionCreate,
_=Depends(get_current_user),
):
"""Create a temporary browser session pointing at the given URL.
Returns a session_id and ws_url for the frontend to view/interact.
The user should manually log in, then call GET /extract.
"""
try:
session = await browser_sessions.create_ephemeral(
url=body.url,
width=body.width,
height=body.height,
)
except Exception as exc:
raise _browser_error(exc)
# Build a short-lived token for WS auth (reuse current user's token logic)
# The frontend already has the user's Bearer token, pass it via query param
return CaptureSessionResponse(
session_id=session.id,
ws_url=f"/api/browser-sessions/{session.id}/ws",
)
@router.get("/sessions/{session_id}/extract", response_model=CaptureExtractResponse)
async def extract_credentials(
session_id: str,
include_raw: bool = Query(default=False, description="Include full cookies/storage/headers in response"),
_=Depends(get_current_user),
):
"""Extract auth credentials from the browser session.
By default only returns curated candidates (typed, scored, with masked preview).
Pass include_raw=true to also get full cookies, localStorage, and headers.
"""
try:
session = browser_sessions.get_session(session_id)
except KeyError:
raise HTTPException(404, "session not found")
try:
result = await extract_all(session)
except Exception as exc:
raise _browser_error(exc)
if not include_raw:
# Strip raw data — only keep curated candidates with masked previews
candidates = [_sanitize_candidate(candidate) for candidate in result.get("candidates", [])]
return CaptureExtractResponse(candidates=candidates)
return CaptureExtractResponse(**result)
@router.delete("/sessions/{session_id}", status_code=204)
async def close_capture_session(
session_id: str,
_=Depends(get_current_user),
):
"""Close and release the auth-capture browser session."""
try:
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,
-417
View File
@@ -1,417 +0,0 @@
"""Remote browser session API."""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
from typing import Any, Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi.responses import Response
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.custom_page import CustomPage
from app.services.browser_session_service import (
BrowserDependencyError,
BrowserSessionError,
browser_sessions,
)
from app.utils.auth import decode_token, get_current_user, get_user_from_token_param
from app.database import SessionLocal
from app.models.admin_user import AdminUser
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/browser-sessions", tags=["browser-sessions"])
class BrowserSessionCreate(BaseModel):
custom_page_id: int
width: int = Field(default=1280)
height: int = Field(default=720)
class BrowserTabResponse(BaseModel):
id: str
title: str
url: str
created_at: float
class BrowserSessionResponse(BaseModel):
id: str
custom_page_id: int
url: str
title: str
active_tab_id: Optional[str] = None
tabs: Optional[list[BrowserTabResponse]] = None
tab_revision: Optional[int] = 0
class BrowserSelectionResponse(BaseModel):
text: str
class BrowserEvent(BaseModel):
type: Literal["click", "dblclick", "mousemove", "mousedown", "mouseup", "type", "key", "scroll", "reload", "back", "forward", "resize"]
x: Optional[float] = None
y: Optional[float] = None
button: Optional[Literal["left", "right", "middle"]] = "left"
text: Optional[str] = None
key: Optional[str] = None
delta_x: Optional[float] = 0
delta_y: Optional[float] = 0
width: Optional[int] = None
height: Optional[int] = None
def _error_from_browser(exc: Exception) -> HTTPException:
if isinstance(exc, BrowserDependencyError):
return HTTPException(503, str(exc))
if isinstance(exc, BrowserSessionError):
return HTTPException(409, str(exc))
if isinstance(exc, KeyError):
return HTTPException(404, "browser session not found")
if isinstance(exc, ValueError):
return HTTPException(400, str(exc))
return HTTPException(502, f"Browser error: {exc}")
@router.post("", response_model=BrowserSessionResponse, status_code=201)
async def create_session(
body: BrowserSessionCreate,
db: Session = Depends(get_db),
_=Depends(get_current_user),
):
page = db.query(CustomPage).filter(CustomPage.id == body.custom_page_id).first()
if not page or not page.enabled:
raise HTTPException(404, "page not found")
if page.access_mode != "remote_browser":
raise HTTPException(400, "custom page is not configured for remote browser mode")
login_config = {
"enabled": page.login_autofill_enabled,
"username": page.login_username,
"password": page.login_password,
"username_selector": page.login_username_selector,
"password_selector": page.login_password_selector,
"submit_selector": page.login_submit_selector,
}
try:
session = await browser_sessions.create(page.id, page.url, body.width, body.height, login_config)
return await browser_sessions.state(session.id)
except Exception as exc:
raise _error_from_browser(exc)
@router.get("/{session_id}", response_model=BrowserSessionResponse)
async def get_session(session_id: str, _=Depends(get_current_user)):
try:
return await browser_sessions.state(session_id)
except Exception as exc:
raise _error_from_browser(exc)
@router.get("/{session_id}/screenshot")
async def session_screenshot(session_id: str, _=Depends(get_user_from_token_param)):
try:
image = await browser_sessions.screenshot(session_id)
except Exception as exc:
raise _error_from_browser(exc)
return Response(content=image, media_type="image/jpeg", headers={"Cache-Control": "no-store"})
@router.post("/{session_id}/events", response_model=BrowserSessionResponse)
async def send_event(session_id: str, body: BrowserEvent, _=Depends(get_current_user)):
try:
payload: dict[str, Any] = body.model_dump(exclude_none=True)
event_type = payload.pop("type")
return await browser_sessions.event(session_id, event_type, payload)
except Exception as exc:
raise _error_from_browser(exc)
@router.post("/{session_id}/tabs/{tab_id}/activate", response_model=BrowserSessionResponse)
async def activate_tab(session_id: str, tab_id: str, _=Depends(get_current_user)):
try:
return await browser_sessions.activate_tab(session_id, tab_id)
except Exception as exc:
raise _error_from_browser(exc)
@router.delete("/{session_id}/tabs/{tab_id}", response_model=BrowserSessionResponse)
async def close_tab(session_id: str, tab_id: str, _=Depends(get_current_user)):
try:
return await browser_sessions.close_tab(session_id, tab_id)
except Exception as exc:
raise _error_from_browser(exc)
@router.get("/{session_id}/selection", response_model=BrowserSelectionResponse)
async def get_selection(session_id: str, _=Depends(get_current_user)):
try:
return BrowserSelectionResponse(text=await browser_sessions.selected_text(session_id))
except Exception as exc:
raise _error_from_browser(exc)
class BrowserClipboardResponse(BaseModel):
text: Optional[str] = None
error: Optional[str] = None
@router.get("/{session_id}/clipboard", response_model=BrowserClipboardResponse)
async def session_clipboard(session_id: str, _=Depends(get_current_user)):
"""Read text from the remote browser's clipboard."""
from fastapi.responses import JSONResponse
try:
text, error = await browser_sessions.read_clipboard(session_id)
body: dict[str, Any] = {}
if text:
body["text"] = text
elif error == "denied":
body["error"] = "远程浏览器未授予剪贴板读取权限"
elif error == "read_failed":
body["error"] = "读取远程剪贴板时发生内部错误"
else:
if error:
logger.warning("clipboard read error for %s: %s", session_id[:12], error)
body["error"] = "远程剪贴板为空"
return JSONResponse(content=body, headers={"Cache-Control": "no-store"})
except Exception as exc:
raise _error_from_browser(exc)
class AutofillLoginResponse(BaseModel):
success: bool
message: str
@router.post("/{session_id}/autofill-login", response_model=AutofillLoginResponse)
async def autofill_login(session_id: str, _=Depends(get_current_user)):
"""Manually trigger login autofill for the remote browser session.
Uses the linked custom page's saved credentials. Never returns passwords.
"""
try:
session_state = await browser_sessions.state(session_id)
except Exception as exc:
raise _error_from_browser(exc)
from app.database import SessionLocal as _Db
from app.models.custom_page import CustomPage
db = _Db()
try:
page = db.query(CustomPage).filter(
CustomPage.id == session_state["custom_page_id"]
).first()
if not page or not page.enabled:
raise HTTPException(400, "linked custom page is not available")
if page.access_mode != "remote_browser":
raise HTTPException(400, "linked custom page is not in remote browser mode")
if not page.login_autofill_enabled:
return AutofillLoginResponse(success=False, message="该页面未启用自动填充登录")
if not page.login_username or not page.login_password:
return AutofillLoginResponse(success=False, message="该页面未保存账号密码")
login_config = {
"enabled": True,
"username": page.login_username,
"password": page.login_password,
"username_selector": page.login_username_selector,
"password_selector": page.login_password_selector,
"submit_selector": page.login_submit_selector,
}
filled = await browser_sessions.autofill_login(session_id, login_config)
if filled:
return AutofillLoginResponse(success=True, message="已填入账号密码")
return AutofillLoginResponse(
success=False,
message="未找到登录输入框,请先关闭弹窗或进入登录页后重试",
)
finally:
db.close()
@router.delete("/{session_id}", status_code=204)
async def close_session(session_id: str, _=Depends(get_current_user)):
await browser_sessions.close(session_id)
@router.delete("/profiles/{custom_page_id}", status_code=204)
async def clear_profile(custom_page_id: int, _=Depends(get_current_user)):
"""Close active session for the page and delete its profile directory.
On next open the browser starts fresh, losing login state.
"""
from app.models.custom_page import CustomPage
from app.database import SessionLocal as _Db
db = _Db()
try:
page = db.query(CustomPage).filter(CustomPage.id == custom_page_id).first()
if not page or not page.enabled:
raise HTTPException(404, "custom page not found")
if page.access_mode != "remote_browser":
raise HTTPException(400, "custom page is not in remote browser mode")
try:
await browser_sessions.clear_profile(custom_page_id, page.url)
except RuntimeError as exc:
raise HTTPException(500, str(exc))
finally:
db.close()
# ——— WebSocket stream ———
# Frame interval & diff detection (tuned for CPU efficiency)
_WS_MIN_INTERVAL = 0.15
_WS_IDLE_INTERVAL = 1.00
_WS_ACTIVE_INTERVAL = 0.20
_WS_BACKOFF_INTERVAL = 2.00
_WS_DEEP_IDLE_INTERVAL = 5.00
_WS_ACTIVE_WINDOW = 1.25
async def _ws_authenticate(token: Optional[str]) -> bool:
"""Validate JWT token for WebSocket connections."""
if not token:
return False
email = decode_token(token)
if not email:
return False
db = SessionLocal()
try:
user = db.query(AdminUser).filter(AdminUser.email == email).first()
return user is not None
finally:
db.close()
@router.websocket("/{session_id}/ws")
async def session_ws(
websocket: WebSocket,
session_id: str,
token: Optional[str] = Query(default=None),
):
"""WebSocket endpoint: pushes JPEG frames as binary, receives JSON event messages."""
# Authenticate before accepting
if not await _ws_authenticate(token):
await websocket.close(code=4401)
return
await websocket.accept()
# Track when a user event arrived so we can temporarily speed up
last_event_at: float = 0.0
last_frame_hash: str = ""
unchanged_count = 0
# Task: receive events from client
async def receive_loop():
nonlocal last_event_at, unchanged_count
try:
while True:
raw = await websocket.receive_text()
try:
msg = json.loads(raw)
except json.JSONDecodeError:
continue
msg_type = msg.get("type")
if not msg_type:
continue
payload: dict[str, Any] = {k: v for k, v in msg.items() if k != "type"}
try:
await browser_sessions.event(session_id, msg_type, payload, include_state=False)
last_event_at = asyncio.get_event_loop().time()
unchanged_count = 0
except Exception as exc:
logger.warning("ws event error: %s", exc)
try:
await websocket.send_json({"error": str(exc)})
except Exception:
pass
except (WebSocketDisconnect, asyncio.CancelledError):
pass
except Exception as exc:
logger.debug("ws receive_loop ended: %s", exc)
# Task: push screenshots
async def push_loop():
nonlocal last_frame_hash, unchanged_count
last_tab_revision = -1
try:
while True:
now = asyncio.get_event_loop().time()
if (now - last_event_at) < _WS_ACTIVE_WINDOW:
interval = _WS_ACTIVE_INTERVAL
elif unchanged_count >= 9:
interval = _WS_DEEP_IDLE_INTERVAL
elif unchanged_count >= 3:
interval = _WS_BACKOFF_INTERVAL
else:
interval = _WS_IDLE_INTERVAL
try:
# Check for tab state changes
session_obj = browser_sessions.get_session(session_id)
if session_obj.tab_revision != last_tab_revision:
last_tab_revision = session_obj.tab_revision
state = await browser_sessions.state(session_id)
await websocket.send_json({"type": "state", "session": state})
frame = await asyncio.wait_for(
browser_sessions.screenshot(session_id), timeout=5.0)
except asyncio.TimeoutError:
logger.warning("ws screenshot timeout for %s", session_id[:12])
await asyncio.sleep(interval)
continue
except KeyError:
await websocket.send_json({"error": "session_not_found"})
break
except Exception as exc:
logger.warning("ws screenshot error: %s", exc)
await asyncio.sleep(interval)
continue
frame_hash = hashlib.md5(frame).hexdigest()
if frame_hash != last_frame_hash:
last_frame_hash = frame_hash
unchanged_count = 0
try:
await websocket.send_bytes(frame)
except Exception:
break
else:
unchanged_count += 1
await asyncio.sleep(max(_WS_MIN_INTERVAL, interval))
except (WebSocketDisconnect, asyncio.CancelledError):
pass
except Exception as exc:
logger.debug("ws push_loop ended: %s", exc)
# Send initial metadata so client knows session info
try:
state = await browser_sessions.state(session_id)
await websocket.send_json({"type": "init", "session": state})
except Exception as exc:
await websocket.send_json({"error": f"session error: {exc}"})
await websocket.close()
return
recv_task = asyncio.create_task(receive_loop())
push_task = asyncio.create_task(push_loop())
# Run until one side closes
done, pending = await asyncio.wait(
[recv_task, push_task],
return_when=asyncio.FIRST_COMPLETED,
)
for t in pending:
t.cancel()
try:
await t
except asyncio.CancelledError:
pass
+5 -168
View File
@@ -1,7 +1,6 @@
"""Custom pages CRUD router + authenticated iframe proxy."""
from __future__ import annotations
import logging
import re
from datetime import datetime, timezone
from typing import Any, List, Literal, Optional
@@ -18,12 +17,8 @@ from app.models.admin_user import AdminUser
from app.models.custom_page import CustomPage
from app.models.upstream import Upstream
from app.services.upstream_client import _find_user_id
from app.services.auth_capture_service import extract_all
from app.services.browser_session_service import browser_sessions
from app.utils.auth import decode_token, get_current_user, get_user_from_token_param
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/custom-pages", tags=["custom-pages"])
# Headers that prevent iframe embedding — strip them from proxied responses
@@ -45,7 +40,7 @@ class CustomPageCreate(BaseModel):
sort_order: int = 0
enabled: bool = True
use_proxy: bool = False
access_mode: Literal["direct", "proxy", "remote_browser"] = "direct"
access_mode: Literal["direct", "proxy"] = "direct"
description: Optional[str] = None
login_username: Optional[str] = None
login_password: Optional[str] = None
@@ -63,7 +58,7 @@ class CustomPageUpdate(BaseModel):
sort_order: Optional[int] = None
enabled: Optional[bool] = None
use_proxy: Optional[bool] = None
access_mode: Optional[Literal["direct", "proxy", "remote_browser"]] = None
access_mode: Optional[Literal["direct", "proxy"]] = None
description: Optional[str] = None
login_username: Optional[str] = None
login_password: Optional[str] = None
@@ -118,7 +113,7 @@ def _page_response(page: CustomPage) -> CustomPageResponse:
sort_order=page.sort_order,
enabled=page.enabled,
use_proxy=page.use_proxy,
access_mode=page.access_mode,
access_mode="proxy" if page.use_proxy or page.access_mode == "proxy" else "direct",
description=page.description,
login_username=page.login_username,
login_username_selector=page.login_username_selector,
@@ -143,6 +138,8 @@ def list_pages(db: Session = Depends(get_db), _=Depends(get_current_user)):
@router.post("", response_model=CustomPageResponse, status_code=201)
def create_page(body: CustomPageCreate, db: Session = Depends(get_db), _=Depends(get_current_user)):
data = body.model_dump()
if "access_mode" not in body.model_fields_set and data.get("use_proxy"):
data["access_mode"] = "proxy"
data["use_proxy"] = data["access_mode"] == "proxy"
for key in (
"login_username",
@@ -210,166 +207,6 @@ def delete_page(pid: int, db: Session = Depends(get_db), _=Depends(get_current_u
db.commit()
# ---- One-click refresh auth ----
import json as _json
class RefreshAuthResponse(BaseModel):
success: bool
message: str
warning: Optional[str] = None
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
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":
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:
return _first_candidate(candidates, preferred)
# fallback:排序后取第一个
return candidates[0]
@router.post("/{pid}/refresh-auth", response_model=RefreshAuthResponse)
async def refresh_auth(pid: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
page = db.query(CustomPage).filter(CustomPage.id == pid).first()
if not page:
raise HTTPException(404, "page not found")
if page.access_mode != "remote_browser":
raise HTTPException(400, "page is not in remote_browser mode")
if not page.linked_upstream_id:
raise HTTPException(400, "page has no linked upstream")
upstream = db.query(Upstream).filter(Upstream.id == page.linked_upstream_id).first()
if not upstream:
raise HTTPException(404, "linked upstream not found")
try:
session = browser_sessions.find_by_page_id(page.id)
except KeyError:
return RefreshAuthResponse(success=False, message="请先打开远程浏览器并登录")
try:
result = await extract_all(session)
except Exception as exc:
return RefreshAuthResponse(success=False, message=f"提取失败: {exc}")
candidates = result.get("candidates", [])
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="未提取到有效凭证,请确认已在远程浏览器中登录")
ctype = candidate["type"]
if ctype in ("cookie_bundle", "cookie"):
upstream.auth_type = "cookie"
# cookie_bundle.value 已是完整 cookie_stringcookie.value 是 "name=value" 格式
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", "")
# Clean up: strip whitespace, remove "Bearer " prefix if present
token = raw.strip()
if token.startswith("Bearer "):
token = token[7:].strip()
# Validate token can be used as HTTP header value
try:
token.encode("latin-1")
except UnicodeEncodeError:
return RefreshAuthResponse(
success=False,
message="提取到的 Token 含有非 HTTP 标头字符,请确认已在远程浏览器中正确登录并重试",
)
existing_config["token"] = token
elif ctype == "api_key":
upstream.auth_type = "api_key"
existing_config["key"] = candidate.get("value", "")
existing_config.setdefault("header", "X-API-Key")
upstream.auth_config_json = _json.dumps(existing_config, ensure_ascii=False)
upstream.updated_at = datetime.now(timezone.utc)
# ── 宽松验证:写回后尝试调用 get_available_groups 验证凭证可用性 ──
# 失败时仍然 commit(凭证已写入),但在 message 里说明验证失败
# 这样用户仍能看到新凭证已写入,便于 debugcf_clearance 绑 IP 时验证必然失败)
warning_msg: Optional[str] = None
try:
from app.services.upstream_client import UpstreamClient
groups_endpoint = upstream.groups_endpoint or "/groups/available"
new_auth_config = _json.loads(upstream.auth_config_json)
with UpstreamClient(
base_url=upstream.base_url,
api_prefix=upstream.api_prefix or "",
auth_type=upstream.auth_type,
auth_config=new_auth_config,
timeout=float(upstream.timeout_seconds or 30),
) as uc:
uc.get_available_groups(groups_endpoint)
logger.info("refresh_auth: upstream %s credential verification passed", upstream.id)
except Exception as exc:
warning_msg = (
f"凭证已写入但 API 验证失败:{exc}"
"若 SmartUp 与远程浏览器不在同一 IPcf_clearance 可能无法复用,请手动测试连接。"
)
logger.warning(
"refresh_auth: upstream %s credential verification failed (written anyway): %s",
upstream.id, exc,
)
db.commit()
auth_type_label = upstream.auth_type
cookie_count = candidate.get("cookie_count", "")
count_str = f"{cookie_count} 个 cookie" if cookie_count else ""
success_msg = f"凭证已刷新 ({auth_type_label}{count_str})"
return RefreshAuthResponse(success=True, message=success_msg, warning=warning_msg)
# ---- Frame Proxy (simple: strip X-Frame-Options / CSP, pass through content) ----
_STRIP_RESP = {
+1 -111
View File
@@ -1,4 +1,4 @@
"""Auth credential extraction from remote browser sessions."""
"""Credential candidate curation for real-browser auth imports."""
from __future__ import annotations
import json
@@ -23,116 +23,6 @@ SESSION_COOKIE_NAMES = frozenset({
})
async def extract_cookies(session: Any) -> list[dict[str, Any]]:
"""Extract all cookies from the browser context."""
cookies = await session.context.cookies()
return [
{
"name": c.get("name", ""),
"value": c.get("value", ""),
"domain": c.get("domain", ""),
"httpOnly": c.get("httpOnly", False),
"secure": c.get("secure", False),
}
for c in cookies
]
async def extract_local_storage(page: Any) -> dict[str, str]:
try:
raw = await page.evaluate("() => JSON.stringify(window.localStorage)")
if isinstance(raw, str):
return json.loads(raw)
return raw or {}
except Exception as exc:
logger.debug("localStorage extraction failed: %s", exc)
return {}
async def extract_session_storage(page: Any) -> dict[str, str]:
try:
raw = await page.evaluate("() => JSON.stringify(window.sessionStorage)")
if isinstance(raw, str):
return json.loads(raw)
return raw or {}
except Exception as exc:
logger.debug("sessionStorage extraction failed: %s", exc)
return {}
async def extract_new_api_user_id(page: Any) -> str:
try:
value = await page.evaluate("""
async () => {
const uid = localStorage.getItem('uid')
if (uid) return uid
const userRaw = localStorage.getItem('user')
if (userRaw) {
try {
const user = JSON.parse(userRaw)
if (user?.id) return String(user.id)
} catch {}
}
const response = await fetch('/api/user/self', { credentials: 'include' })
if (!response.ok) return ''
const payload = await response.json()
const data = payload?.data || payload
return data?.id ? String(data.id) : ''
}
""")
return str(value or "").strip()
except Exception as exc:
logger.debug("New-API user id extraction failed: %s", exc)
return ""
async def extract_request_headers(session: Any) -> list[dict[str, str]]:
"""Return Authorization / API-Key headers captured continuously by CDP.
The CDP Network listener is started when the ephemeral session is created
(in BrowserSessionService.create_ephemeral), so headers from the login
flow are captured in real-time without needing a fresh CDP attach.
"""
if hasattr(session, "captured_headers") and session.captured_headers:
logger.debug("auth-capture: returning %d cached headers", len(session.captured_headers))
return list(session.captured_headers)
return []
async def extract_all(session: Any) -> dict[str, Any]:
"""Extract all auth credentials from a browser session.
Returns:
cookies, storage, session_storage, auth_headers, candidates
"""
page = session.page
cookies = await extract_cookies(session)
local_storage = await extract_local_storage(page)
session_storage = await extract_session_storage(page)
auth_headers = await extract_request_headers(session)
new_api_user = _find_new_api_user(local_storage, session_storage) or await extract_new_api_user_id(page)
# 获取当前浏览器页面的真实 URL(比 session.url 更准确)
page_url = ""
try:
page_url = page.url or ""
except Exception:
pass
candidates = _curate_candidates(
cookies, local_storage, session_storage, auth_headers, new_api_user,
page_url=page_url,
)
return {
"cookies": cookies,
"storage": local_storage,
"session_storage": session_storage,
"auth_headers": auth_headers,
"candidates": candidates,
}
def _cookie_matches_hostname(cookie_domain: str, hostname: str) -> bool:
"""判断 cookie domain 是否适用于给定 hostname。
@@ -1,951 +0,0 @@
"""Managed Playwright browser sessions for custom pages."""
from __future__ import annotations
import asyncio
import logging
import re
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
from urllib.parse import urlparse
from uuid import uuid4
from app.config import get_settings
logger = logging.getLogger(__name__)
class BrowserDependencyError(RuntimeError):
"""Raised when Playwright or its browser runtime is unavailable."""
class BrowserSessionError(RuntimeError):
"""Raised when an existing browser session can no longer be used."""
@dataclass
class BrowserTab:
id: str
page: Any
created_at: float
@dataclass
class BrowserSession:
id: str
custom_page_id: int
profile_key: str
context: Any
tabs: dict[str, BrowserTab]
active_tab_id: str
lock: asyncio.Lock
tab_revision: int = 0
cdp_session: Any = None
captured_headers: list[dict] = None # auth headers from CDP
last_saved_state_at: float = 0.0
@property
def active_tab(self) -> BrowserTab:
return self.tabs[self.active_tab_id]
@property
def page(self) -> Any:
return self.active_tab.page
class BrowserSessionService:
# Idle TTL: close sessions that haven't had activity for this long
IDLE_TTL_SECONDS = 1800 # 30 minutes
# Cap: max concurrent persistent sessions (excludes auth-capture)
MAX_SESSIONS = 10
def __init__(self) -> None:
self._playwright: Optional[Any] = None
self._sessions: dict[str, BrowserSession] = {}
self._profiles: dict[str, str] = {}
self._lock = asyncio.Lock()
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,
url: str,
width: int = 1280,
height: int = 720,
login_config: Optional[dict[str, Any]] = None,
) -> BrowserSession:
if not url.startswith(("http://", "https://")):
raise ValueError("Only http/https URLs are allowed")
width = max(320, min(width, 2560))
height = max(240, min(height, 1600))
async with self._lock:
await self._ensure_playwright()
profile_key = self._profile_key(custom_page_id, url)
existing_id = self._profiles.get(profile_key)
existing = self._sessions.get(existing_id or "")
if existing and not existing.page.is_closed():
# Health check: verify session can actually serve content
healthy = True
try:
async with existing.lock:
url_before = existing.page.url
await existing.page.evaluate("1") # ping
await existing.page.screenshot(type="jpeg", quality=10, timeout=5000)
await existing.page.set_viewport_size({"width": width, "height": height})
if url_before == "about:blank":
await existing.page.goto(url, wait_until="domcontentloaded", timeout=45000)
await self._autofill_login(existing.page, login_config)
await self._reset_page_zoom(existing)
self._touch(existing.id)
except Exception:
logger.info("existing session %s unhealthy, recreating", existing.id[:12])
healthy = False
if healthy:
return existing
# Close unhealthy session (profile stays on disk)
await self.close(existing.id)
if existing_id:
self._profiles.pop(profile_key, None)
# Idle cleanup: close stale sessions before spawning new ones
await self._evict_idle_sessions()
context = await self._playwright.chromium.launch_persistent_context(
str(self._profile_dir(profile_key)),
**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:
parsed = urlparse(url)
origin = f"{parsed.scheme}://{parsed.netloc}"
await context.grant_permissions(["clipboard-read", "clipboard-write"], origin=origin)
except Exception:
logger.debug("clipboard permission grant failed (non-fatal)")
page = context.pages[0] if context.pages else await context.new_page()
tab_id = uuid4().hex
tab = BrowserTab(id=tab_id, page=page, created_at=asyncio.get_event_loop().time())
session = BrowserSession(
id=uuid4().hex,
custom_page_id=custom_page_id,
profile_key=profile_key,
context=context,
tabs={tab_id: tab},
active_tab_id=tab_id,
lock=asyncio.Lock(),
)
self._sessions[session.id] = session
self._profiles[profile_key] = session.id
self._touch(session.id)
# Register listeners for the initial tab
self._setup_tab_listeners(session, page)
# Register page capture for multi-tab support
context.on("page", lambda p: self._handle_new_page(session, p))
# Evict again after adding the new session so cap is enforced immediately
await self._evict_idle_sessions()
try:
await page.goto(url, wait_until="domcontentloaded", timeout=45000)
await self._autofill_login(page, login_config)
await self._reset_page_zoom(session)
except Exception:
await self.close(session.id)
raise
logger.info("session created: %s (page=%s, profile=%s)", session.id[:12], custom_page_id, profile_key)
return session
def _touch(self, session_id: str) -> None:
"""Mark a session as recently active (reset idle timer)."""
self._last_event_at[session_id] = asyncio.get_event_loop().time()
def _handle_new_page(self, session: BrowserSession, page: Any) -> None:
"""Capture a new page opened by the remote browser (e.g. target="_blank")."""
asyncio.create_task(self._register_new_page(session, page))
def _setup_tab_listeners(self, session: BrowserSession, page: Any) -> None:
"""Register navigation and state listeners to bump tab_revision."""
def bump_revision(_=None):
session.tab_revision += 1
page.on("domcontentloaded", bump_revision)
page.on("load", bump_revision)
page.on("framenavigated", bump_revision)
page.on("close", bump_revision)
async def _register_new_page(self, session: BrowserSession, page: Any) -> None:
tab_id = uuid4().hex
tab = BrowserTab(id=tab_id, page=page, created_at=asyncio.get_event_loop().time())
async with session.lock:
session.tabs[tab_id] = tab
session.active_tab_id = tab_id
session.tab_revision += 1
logger.info("session %s: captured new tab %s (total: %d)", session.id[:12], tab_id[:8], len(session.tabs))
self._setup_tab_listeners(session, page)
# Best-effort: bring to front and reset zoom
await self._init_new_tab(session, tab)
async def _init_new_tab(self, session: BrowserSession, tab: BrowserTab) -> None:
try:
await tab.page.bring_to_front()
await self._reset_page_zoom(session)
# Grant clipboard permission for the new page's origin if possible
try:
url = tab.page.url
if url.startswith("http"):
parsed = urlparse(url)
origin = f"{parsed.scheme}://{parsed.netloc}"
await session.context.grant_permissions(["clipboard-read", "clipboard-write"], origin=origin)
except Exception:
pass
except Exception as exc:
logger.debug("new tab %s init failed: %s", tab.id[:8], exc)
async def screenshot(self, session_id: str) -> bytes:
session = self._get(session_id)
self._touch(session_id)
async with session.lock:
self._ensure_open(session)
if session.profile_key and not session.profile_key.startswith("auth-capture-"):
now = time.monotonic()
if now - session.last_saved_state_at > 10.0:
await self._save_session_state(session)
session.last_saved_state_at = now
return await session.page.screenshot(type="jpeg", quality=65, full_page=False)
async def event(
self,
session_id: str,
event_type: str,
payload: dict[str, Any],
*,
include_state: bool = True,
) -> dict[str, Any] | None:
session = self._get(session_id)
self._last_event_at[session_id] = asyncio.get_event_loop().time()
async with session.lock:
self._ensure_open(session)
page = session.page
if event_type == "click":
await page.mouse.click(float(payload["x"]), float(payload["y"]), button=payload.get("button", "left"))
elif event_type == "dblclick":
await page.mouse.dblclick(float(payload["x"]), float(payload["y"]), button=payload.get("button", "left"))
elif event_type == "mousemove":
await page.mouse.move(float(payload["x"]), float(payload["y"]))
elif event_type == "mousedown":
await page.mouse.move(float(payload["x"]), float(payload["y"]))
await page.mouse.down(button=payload.get("button", "left"))
elif event_type == "mouseup":
await page.mouse.move(float(payload["x"]), float(payload["y"]))
await page.mouse.up(button=payload.get("button", "left"))
elif event_type == "type":
text = str(payload.get("text", ""))
if text:
await page.keyboard.insert_text(text)
elif event_type == "key":
key = str(payload.get("key", ""))
if key:
await page.keyboard.press(key)
elif event_type == "scroll":
if payload.get("x") is not None and payload.get("y") is not None:
await page.mouse.move(float(payload["x"]), float(payload["y"]))
await page.mouse.wheel(float(payload.get("delta_x", 0)), float(payload.get("delta_y", 0)))
elif event_type == "reload":
await page.reload(wait_until="domcontentloaded", timeout=45000)
elif event_type == "back":
await page.go_back(wait_until="domcontentloaded", timeout=45000)
elif event_type == "forward":
await page.go_forward(wait_until="domcontentloaded", timeout=45000)
elif event_type == "resize":
width = max(320, min(int(payload.get("width", 1280)), 2560))
height = max(240, min(int(payload.get("height", 720)), 1600))
await page.set_viewport_size({"width": width, "height": height})
else:
raise ValueError("Unsupported browser event")
if session.profile_key and not session.profile_key.startswith("auth-capture-"):
now = time.monotonic()
if now - session.last_saved_state_at > 5.0:
await self._save_session_state(session)
session.last_saved_state_at = now
if not include_state:
return None
return await self._session_state(session)
async def selected_text(self, session_id: str) -> str:
session = self._get(session_id)
self._touch(session_id)
async with session.lock:
self._ensure_open(session)
value = await session.page.evaluate("() => window.getSelection()?.toString() || ''")
return str(value or "")
async def read_clipboard(self, session_id: str) -> tuple[Optional[str], Optional[str]]:
"""Read the remote browser's clipboard text.
Returns (text, error_reason).
text is None when the clipboard is empty or unreadable.
error_reason is None on success or "empty" — non-None indicates a genuine failure.
"""
session = self._get(session_id)
self._touch(session_id)
async with session.lock:
self._ensure_open(session)
try:
result = await session.page.evaluate("""
async () => {
try {
const text = await navigator.clipboard.readText();
return text || null;
} catch (e) {
if (e instanceof DOMException) {
if (e.name === 'NotAllowedError') return 'ERROR:denied';
if (e.name === 'NotFoundError') return null;
}
return 'ERROR:' + (e.message || String(e));
}
}
""")
if result is None:
return None, None # empty clipboard
if isinstance(result, str) and result.startswith("ERROR:"):
reason = result[6:]
logger.debug("clipboard read error for %s: %s", session_id[:12], reason)
return None, reason
return str(result), None
except Exception as exc:
logger.warning("clipboard read failed for %s: %s", session_id[:12], exc)
return None, "read_failed"
async def close(self, session_id: str) -> None:
self._last_event_at.pop(session_id, None)
session = self._discard_session(session_id)
if not session:
return
logger.info("session closing: %s (page=%s, profile=%s)", session_id[:12], session.custom_page_id, session.profile_key)
# 在完全关闭 context 前,强制将最新的状态落盘保存
if session.profile_key and not session.profile_key.startswith("auth-capture-"):
try:
if not session.page.is_closed():
await self._save_session_state(session)
except Exception as exc:
logger.debug("failed to save state during close: %s", exc)
# Detach CDP session if active
if session.cdp_session:
try:
await session.cdp_session.detach()
except Exception:
pass
close_ok = True
# 关闭 context 带超时,避免永远挂起
try:
await asyncio.wait_for(session.context.close(), timeout=10.0)
logger.info("session context closed: %s", session_id[:12])
except asyncio.TimeoutError:
close_ok = False
logger.warning("session close timeout: %s (falling back to browser.close)", session_id[:12])
try:
browser = getattr(session.context, "browser", None)
if browser is not None:
await asyncio.wait_for(browser.close(), timeout=5.0)
close_ok = True
logger.info("session browser fallback closed: %s", session_id[:12])
else:
logger.warning("session context.browser is None, cannot fallback: %s", session_id[:12])
except Exception as exc:
logger.warning("session browser fallback failed: %s: %s", session_id[:12], exc)
except Exception as exc:
close_ok = False
logger.warning("session close error: %s: %s", session_id[:12], exc)
# Clean up ephemeral (auth-capture) profile directories
if session.profile_key and session.profile_key.startswith("auth-capture-"):
profile_dir = self._profile_dir(session.profile_key)
import shutil
try:
shutil.rmtree(profile_dir, ignore_errors=True)
except Exception:
pass
if close_ok:
logger.info("session closed: %s", session_id[:12])
else:
logger.warning("session close_failed: %s", session_id[:12])
async def shutdown(self) -> None:
# Cancel the background eviction loop
if self._evict_task is not None and not self._evict_task.done():
self._evict_task.cancel()
try:
await self._evict_task
except asyncio.CancelledError:
pass
self._evict_task = None
sessions = list(self._sessions)
if sessions:
logger.info("shutdown: closing %d browser sessions", len(sessions))
for session_id in sessions:
try:
await asyncio.wait_for(self.close(session_id), timeout=15.0)
except Exception as exc:
logger.warning("shutdown close failed for %s: %s", session_id[:12], exc)
if self._playwright:
logger.info("shutdown: stopping playwright")
try:
await asyncio.wait_for(self._playwright.stop(), timeout=10.0)
except Exception as exc:
logger.warning("shutdown playwright stop failed: %s", exc)
self._playwright = None
async def state(self, session_id: str) -> dict[str, Any]:
session = self._get(session_id)
self._touch(session_id)
async with session.lock:
self._ensure_open(session)
return await self._session_state(session)
async def activate_tab(self, session_id: str, tab_id: str) -> dict[str, Any]:
session = self._get(session_id)
self._touch(session_id)
async with session.lock:
self._ensure_open(session)
if tab_id not in session.tabs:
raise KeyError("tab not found")
session.active_tab_id = tab_id
session.tab_revision += 1
await session.page.bring_to_front()
return await self._session_state(session)
async def close_tab(self, session_id: str, tab_id: str) -> dict[str, Any]:
session = self._get(session_id)
self._touch(session_id)
async with session.lock:
self._ensure_open(session)
if tab_id not in session.tabs:
raise KeyError("tab not found")
if len(session.tabs) <= 1:
raise ValueError("cannot close the last tab")
tab = session.tabs.pop(tab_id)
try:
await tab.page.close()
except Exception:
pass
if session.active_tab_id == tab_id:
# Pick the latest remaining tab
latest = max(session.tabs.values(), key=lambda t: t.created_at)
session.active_tab_id = latest.id
await session.page.bring_to_front()
session.tab_revision += 1
return await self._session_state(session)
async def _session_state(self, session: BrowserSession) -> dict[str, Any]:
tabs = []
# We might need to prune closed pages during state generation too
closed_ids = []
# Use list() to avoid RuntimeError if tabs dict changes during iteration
for tid, tab in list(session.tabs.items()):
if tab.page.is_closed():
closed_ids.append(tid)
continue
try:
title = await tab.page.title()
url = tab.page.url
except Exception:
title, url = "Loading...", "about:blank"
tabs.append({
"id": tid,
"title": title,
"url": url,
"created_at": tab.created_at,
})
if closed_ids:
for cid in closed_ids:
session.tabs.pop(cid, None)
if not session.tabs:
raise BrowserSessionError("all browser pages are closed")
if session.active_tab_id in closed_ids:
latest = max(session.tabs.values(), key=lambda t: t.created_at)
session.active_tab_id = latest.id
session.tab_revision += 1
tabs.sort(key=lambda x: x["created_at"])
return {
"id": session.id,
"custom_page_id": session.custom_page_id,
"url": session.page.url,
"title": await session.page.title(),
"active_tab_id": session.active_tab_id,
"tabs": tabs,
"tab_revision": session.tab_revision,
}
async def _ensure_playwright(self) -> None:
if self._playwright:
return
try:
from playwright.async_api import async_playwright
except ImportError as exc:
raise BrowserDependencyError("Playwright is not installed. Run `pip install -r requirements.txt`.") from exc
try:
self._playwright = await async_playwright().start()
except Exception as exc:
raise BrowserDependencyError(f"Unable to start Playwright: {exc}") from exc
# Start background eviction loop
if self._evict_task is None or self._evict_task.done():
self._evict_task = asyncio.create_task(self._evict_loop())
async def _reset_page_zoom(self, session: BrowserSession) -> None:
try:
cdp = await session.context.new_cdp_session(session.page)
try:
await cdp.send("Emulation.setPageScaleFactor", {"pageScaleFactor": 1})
finally:
await cdp.detach()
except Exception:
pass
async def autofill_login(
self,
session_id: str,
login_config: Optional[dict[str, Any]],
) -> bool:
"""Public: manually trigger login autofill for an active session.
Only fills username/password fields — never auto-submits.
Returns True if fields were found and filled, False otherwise.
Never returns password data to the caller.
"""
session = self._get(session_id)
self._touch(session_id)
async with session.lock:
self._ensure_open(session)
return await self._autofill_login(session.page, login_config, max_wait_seconds=3.0, skip_submit=True)
async def _autofill_login(
self,
page: Any,
config: Optional[dict[str, Any]],
*,
max_wait_seconds: float = 2.0,
poll_interval_seconds: float = 0.25,
skip_submit: bool = False,
) -> bool:
if not config or not config.get("enabled"):
return False
username = str(config.get("username") or "")
password = str(config.get("password") or "")
if not username or not password:
return False
try:
username_selectors = [
config.get("username_selector"),
"input[type='email']",
"input[name*='user' i]",
"input[id*='user' i]",
"input[name*='email' i]",
"input[id*='email' i]",
"input[name*='login' i]",
"input[id*='login' i]",
"input[autocomplete='username']",
"input:not([type]), input[type='text']",
]
password_selectors = [
config.get("password_selector"),
"input[type='password']",
"input[autocomplete='current-password']",
]
username_locator, password_locator = await self._wait_for_login_locators(
page,
username_selectors,
password_selectors,
max_wait_seconds=max_wait_seconds,
poll_interval_seconds=poll_interval_seconds,
)
if not username_locator or not password_locator:
logger.info("Login autofill skipped: login fields not found")
return False
await username_locator.fill(username, timeout=3000)
await password_locator.fill(password, timeout=3000)
if not skip_submit:
submit_selector = str(config.get("submit_selector") or "").strip()
if submit_selector:
submit = await self._first_visible_locator(page, [submit_selector], timeout=500)
if submit:
await submit.click(timeout=3000)
return True
except Exception as exc:
logger.info("Login autofill skipped: %s", exc)
return False
async def _wait_for_login_locators(
self,
page: Any,
username_selectors: list[Optional[str]],
password_selectors: list[Optional[str]],
*,
max_wait_seconds: float,
poll_interval_seconds: float,
) -> tuple[Optional[Any], Optional[Any]]:
deadline = time.monotonic() + max_wait_seconds
while True:
username_locator = await self._first_visible_locator(page, username_selectors, timeout=150)
password_locator = await self._first_visible_locator(page, password_selectors, timeout=150)
if username_locator and password_locator:
return username_locator, password_locator
if time.monotonic() >= deadline:
return None, None
await asyncio.sleep(poll_interval_seconds)
async def _first_visible_locator(
self,
page: Any,
selectors: list[Optional[str]],
*,
timeout: float = 1500,
) -> Optional[Any]:
for selector in selectors:
selector = str(selector or "").strip()
if not selector:
continue
try:
locator = page.locator(selector).first
if await locator.count() and await locator.is_visible(timeout=timeout):
return locator
except Exception:
continue
return None
def get_session(self, session_id: str) -> BrowserSession:
"""Retrieve a session by id — raises KeyError if missing."""
session = self._sessions.get(session_id)
if not session:
raise KeyError("browser session not found")
return session
def find_by_page_id(self, custom_page_id: int) -> BrowserSession:
"""Find the active session for a custom page. Raises KeyError if none."""
for session in self._sessions.values():
if session.custom_page_id == custom_page_id and not session.page.is_closed():
return session
raise KeyError(f"no active browser session for page {custom_page_id}")
_get = get_session # alias for internal use
def _ensure_open(self, session: BrowserSession) -> None:
if session.active_tab.page.is_closed():
# Current tab closed? Try to cleanup and find another one
session.tabs.pop(session.active_tab_id, None)
if session.tabs:
# Pick the latest created tab
latest = max(session.tabs.values(), key=lambda t: t.created_at)
session.active_tab_id = latest.id
session.tab_revision += 1
logger.info("active tab closed, switched to %s", latest.id[:8])
else:
self._discard_session(session.id)
raise BrowserSessionError("all browser pages are closed")
def _discard_session(self, session_id: str) -> BrowserSession | None:
session = self._sessions.pop(session_id, None)
if session and self._profiles.get(session.profile_key) == session_id:
self._profiles.pop(session.profile_key, None)
return session
async def _evict_loop(self) -> None:
"""Background loop that runs every 5 minutes to evict idle sessions."""
while True:
await asyncio.sleep(300) # 5 minutes
try:
await self._evict_idle_sessions()
except Exception:
logger.exception("idle eviction loop error")
async def _evict_idle_sessions(self) -> None:
"""Close oldest idle sessions when over cap, or any past TTL.
- Auth-capture sessions: max 10 minutes lifetime.
- Remote browser sessions: close after IDLE_TTL_SECONDS of no WebSocket activity.
"""
now = asyncio.get_event_loop().time()
to_remove: list[str] = []
for sid, session in self._sessions.items():
if session.profile_key and session.profile_key.startswith("auth-capture-"):
# auth-capture: max 10 minute TTL from creation
created = session.tabs.get(session.active_tab_id)
if created:
age = now - created.created_at
if age > 600:
to_remove.append(sid)
logger.info("evicting auth-capture session %s (age=%ds > 600s)", sid[:12], int(age))
else:
# remote browser sessions: idle TTL
last_active = self._last_event_at.get(sid, 0.0)
if last_active > 0 and (now - last_active) > self.IDLE_TTL_SECONDS:
to_remove.append(sid)
logger.info("evicting idle session %s (no activity for >%ds)", sid[:12], self.IDLE_TTL_SECONDS)
for sid in to_remove:
await self.close(sid)
# Second: if still over cap, evict oldest by last_event_at
persistent = [(sid, s) for sid, s in self._sessions.items()
if not (s.profile_key or "").startswith("auth-capture-")]
if len(persistent) > self.MAX_SESSIONS:
persistent.sort(key=lambda x: self._last_event_at.get(x[0], 0.0))
excess = len(persistent) - self.MAX_SESSIONS
for sid, _ in persistent[:excess]:
logger.info("evicting session %s (over cap of %d)", sid[:12], self.MAX_SESSIONS)
await self.close(sid)
async def clear_profile(self, custom_page_id: int, url: str) -> None:
"""Close session for the page if active, then delete profile directory.
Raises RuntimeError if the directory cannot be fully removed.
"""
import shutil
# Close active session and use its profile_key (precise match)
profile_key: Optional[str] = None
try:
session = self.find_by_page_id(custom_page_id)
profile_key = session.profile_key
await self.close(session.id)
except KeyError:
pass
# Fallback: compute from URL (may be wrong if URL changed since session was created)
if not profile_key:
profile_key = self._profile_key(custom_page_id, url)
profile_dir = self._profile_dir(profile_key)
if profile_dir.exists():
shutil.rmtree(profile_dir) # no ignore_errors — let failure surface
if profile_dir.exists():
raise RuntimeError(
f"Failed to fully remove browser profile directory: {profile_dir}"
)
logger.info("cleared browser profile for page %d: %s", custom_page_id, profile_dir)
def _profile_dir(self, profile_key: str) -> Path:
root = Path(get_settings().browser_profiles_dir)
root.mkdir(parents=True, exist_ok=True)
profile = root / profile_key
profile.mkdir(parents=True, exist_ok=True)
return profile
def _cookies_path(self, profile_key: str) -> Path:
return self._profile_dir(profile_key) / "session-cookies.json"
def _profile_key(self, custom_page_id: int, url: str) -> str:
parsed = urlparse(url)
origin = f"{parsed.scheme}-{parsed.netloc}".lower()
safe_origin = re.sub(r"[^a-z0-9_.-]+", "_", origin).strip("_") or "page"
return f"page-{custom_page_id}-{safe_origin[:80]}"
async def create_ephemeral(
self,
url: str,
width: int = 1280,
height: int = 720,
) -> BrowserSession:
"""Create a temporary browser session without a CustomPage record.
The session uses an isolated random-named profile so it never collides
with persistent custom-page profiles. Caller MUST close() when done.
"""
if not url.startswith(("http://", "https://")):
raise ValueError("Only http/https URLs are allowed")
width = max(320, min(width, 2560))
height = max(240, min(height, 1600))
async with self._lock:
await self._ensure_playwright()
session_id = uuid4().hex
profile_key = f"auth-capture-{session_id[:12]}"
context = await self._playwright.chromium.launch_persistent_context(
str(self._profile_dir(profile_key)),
**self._browser_launch_kwargs(width, height),
)
await self._install_browser_init_scripts(context)
# Grant clipboard access for the page origin
try:
parsed = urlparse(url)
origin = f"{parsed.scheme}://{parsed.netloc}"
await context.grant_permissions(["clipboard-read", "clipboard-write"], origin=origin)
except Exception:
logger.debug("clipboard permission grant failed (non-fatal)")
page = context.pages[0] if context.pages else await context.new_page()
tab_id = uuid4().hex
tab = BrowserTab(id=tab_id, page=page, created_at=asyncio.get_event_loop().time())
session = BrowserSession(
id=session_id,
custom_page_id=0,
profile_key=profile_key,
context=context,
tabs={tab_id: tab},
active_tab_id=tab_id,
lock=asyncio.Lock(),
captured_headers=[],
)
self._sessions[session.id] = session
self._touch(session.id)
# Register listeners for the initial tab
self._setup_tab_listeners(session, page)
# Register page capture
context.on("page", lambda p: self._handle_new_page(session, p))
# Start CDP network capture BEFORE the initial page load,
# so we capture login redirects and auth headers from the start.
await self._start_cdp_capture(session)
try:
await page.goto(url, wait_until="domcontentloaded", timeout=45000)
except Exception:
await self.close(session.id)
raise
return session
async def _start_cdp_capture(self, session: BrowserSession) -> None:
"""Enable CDP Network domain and capture Authorization headers."""
try:
cdp = await session.context.new_cdp_session(session.page)
await cdp.send("Network.enable")
def on_request(params: dict) -> None:
headers = params.get("request", {}).get("headers", {})
auth = headers.get("authorization") or headers.get("Authorization")
api_key = headers.get("x-api-key") or headers.get("X-API-Key")
url = params.get("request", {}).get("url", "")
if auth:
session.captured_headers.append({
"type": "authorization",
"value": auth,
"url": url,
})
if api_key:
session.captured_headers.append({
"type": "api_key",
"value": api_key,
"url": url,
})
cdp.on("Network.requestWillBeSent", on_request)
session.cdp_session = cdp
except Exception as exc:
logger.debug("CDP capture not available: %s", exc)
async def _save_session_state(self, session: BrowserSession) -> None:
if not session.profile_key or session.profile_key.startswith("auth-capture-"):
return
try:
state = await session.context.storage_state()
cookies_path = self._cookies_path(session.profile_key)
import json
import tempfile
import os
# Ensure parent directories exist
cookies_path.parent.mkdir(parents=True, exist_ok=True)
temp_fd, temp_path = tempfile.mkstemp(dir=str(cookies_path.parent))
try:
with os.fdopen(temp_fd, 'w', encoding='utf-8') as f:
json.dump(state, f, ensure_ascii=False, indent=2)
os.replace(temp_path, cookies_path)
except Exception:
try:
os.unlink(temp_path)
except Exception:
pass
raise
except Exception as exc:
logger.debug("failed to save session state for %s: %s", session.profile_key, exc)
async def _restore_session_state(self, context: Any, profile_key: str) -> None:
if profile_key.startswith("auth-capture-"):
return
cookies_path = self._cookies_path(profile_key)
if not cookies_path.exists() or cookies_path.stat().st_size == 0:
return
try:
import json
import time
with open(cookies_path, 'r', encoding='utf-8') as f:
state = json.load(f)
cookies = state.get("cookies", [])
if cookies:
now = time.time()
valid_cookies = []
for c in cookies:
expires = c.get("expires")
if expires is not None and expires > 0 and expires <= now:
continue
if expires is not None and expires <= 0:
c.pop("expires", None)
valid_cookies.append(c)
if valid_cookies:
await context.add_cookies(valid_cookies)
logger.info("restored %d cookies for profile %s", len(valid_cookies), profile_key)
# 还原 LocalStorage
origins = state.get("origins", [])
if origins:
origins_json = json.dumps(origins)
init_script = f"""
(() => {{
try {{
const origins = {origins_json};
const currentOrigin = window.location.origin;
const target = origins.find(o => o.origin === currentOrigin);
if (target && target.localStorage) {{
for (const item of target.localStorage) {{
try {{
window.localStorage.setItem(item.name, item.value);
}} catch (e) {{
console.error('Failed to restore localStorage key', item.name, e);
}}
}}
}}
}} catch (err) {{
console.error('LocalStorage restore initialization script failed', err);
}}
}})();
"""
await context.add_init_script(init_script)
logger.info("registered LocalStorage init script for profile %s (origins: %d)", profile_key, len(origins))
except Exception as exc:
logger.warning("failed to restore cookies/state for profile %s: %s", profile_key, exc)
browser_sessions = BrowserSessionService()
-1
View File
@@ -8,4 +8,3 @@ apscheduler==3.10.4
python-dotenv==1.0.1
pydantic-settings==2.6.1
python-multipart==0.0.20
playwright==1.52.0
-425
View File
@@ -1,425 +0,0 @@
import asyncio
from pathlib import Path
from app.config import get_settings
from app.routers.auth_capture import _sanitize_candidate
from app.services.browser_session_service import BrowserSessionService
class FakeLocator:
def __init__(self, *, visible=True, count=1):
self._visible = list(visible) if isinstance(visible, list) else [visible]
self._count = count
self.filled = []
self.clicked = 0
self.timeouts = []
@property
def first(self):
return self
async def count(self):
return self._count
async def is_visible(self, timeout=0):
self.timeouts.append(timeout)
if not self._visible:
return False
if len(self._visible) == 1:
return self._visible[0]
return self._visible.pop(0)
async def fill(self, value, timeout=0):
self.filled.append((value, timeout))
async def click(self, timeout=0):
self.clicked += 1
class FakePage:
url = "https://example.test/login"
def __init__(self, locators):
self.locators = locators
self.queries = []
def locator(self, selector):
self.queries.append(selector)
return self.locators.get(selector, FakeLocator(visible=False, count=0))
def run(coro):
return asyncio.run(coro)
def test_autofill_retries_until_delayed_fields_are_visible():
service = BrowserSessionService()
username = FakeLocator(visible=[False, True])
password = FakeLocator(visible=True)
submit = FakeLocator(visible=True)
page = FakePage({
"#user": username,
"#pass": password,
"#submit": submit,
})
run(service._autofill_login(
page,
{
"enabled": True,
"username": "alice",
"password": "secret",
"username_selector": "#user",
"password_selector": "#pass",
"submit_selector": "#submit",
},
max_wait_seconds=1,
poll_interval_seconds=0,
))
assert page.queries[0] == "#user"
assert "#pass" in page.queries
assert "input[type='password']" not in page.queries
assert username.filled == [("alice", 3000)]
assert password.filled == [("secret", 3000)]
assert submit.clicked == 1
def test_autofill_returns_without_selectors_when_disabled_or_missing_credentials():
service = BrowserSessionService()
disabled_page = FakePage({"#user": FakeLocator()})
run(service._autofill_login(
disabled_page,
{"enabled": False, "username": "alice", "password": "secret"},
max_wait_seconds=1,
poll_interval_seconds=0,
))
assert disabled_page.queries == []
missing_password_page = FakePage({"#user": FakeLocator()})
run(service._autofill_login(
missing_password_page,
{"enabled": True, "username": "alice", "password": ""},
max_wait_seconds=1,
poll_interval_seconds=0,
))
assert missing_password_page.queries == []
def test_sanitize_candidate_strips_secret_fields_but_keeps_metadata():
sanitized = _sanitize_candidate({
"type": "cookie",
"source": "cookie:session",
"value": "Bearer secret-token",
"preview": "Bearer s…token",
"label": "session cookie",
"confidence": 90,
"cookie_name": "session",
"cookie_value": "secret-cookie",
"domain": "example.test",
})
assert sanitized == {
"type": "cookie",
"source": "cookie:session",
"preview": "Bearer s…token",
"label": "session cookie",
"confidence": 90,
"cookie_name": "session",
"domain": "example.test",
}
def test_cookies_path_mapping():
import tempfile
import shutil
service = BrowserSessionService()
temp_dir = tempfile.mkdtemp()
original_dir = get_settings().browser_profiles_dir
get_settings().browser_profiles_dir = temp_dir
try:
profile_key = "test-profile-123"
expected_path = Path(service._cookies_path(profile_key))
assert expected_path.name == "session-cookies.json"
assert expected_path.parent == Path(service._profile_dir(profile_key))
finally:
get_settings().browser_profiles_dir = original_dir
shutil.rmtree(temp_dir, ignore_errors=True)
def test_screenshot_throttled_save():
import tempfile
import shutil
service = BrowserSessionService()
# 准备临时目录并 mock settings.browser_profiles_dir
temp_dir = tempfile.mkdtemp()
original_dir = get_settings().browser_profiles_dir
get_settings().browser_profiles_dir = temp_dir
try:
from unittest.mock import AsyncMock, MagicMock
from app.services.browser_session_service import BrowserSession, BrowserTab
# Mock Context & Page
fake_context = AsyncMock()
fake_page = MagicMock()
fake_page.is_closed = MagicMock(return_value=False)
fake_page.screenshot = AsyncMock(return_value=b"screenshot-bytes")
session = BrowserSession(
id="session123",
custom_page_id=1,
profile_key="page-1-test",
context=fake_context,
tabs={"main": BrowserTab(id="main", page=fake_page, created_at=0.0)},
active_tab_id="main",
lock=asyncio.Lock(),
last_saved_state_at=0.0
)
service._sessions[session.id] = session
# 第一次调用 screenshot: 触发存储
res1 = run(service.screenshot(session.id))
assert res1 == b"screenshot-bytes"
assert fake_context.storage_state.call_count == 1
# 记录第一次保存后的时间戳
first_save_time = session.last_saved_state_at
assert first_save_time > 0
# 第二次立即调用 screenshot: 应该因为限流 10s 被跳过,不增加 call_count
res2 = run(service.screenshot(session.id))
assert res2 == b"screenshot-bytes"
assert fake_context.storage_state.call_count == 1
# 模拟 11 秒后(防抖时间已过)再度截图
session.last_saved_state_at = first_save_time - 11.0
res3 = run(service.screenshot(session.id))
assert res3 == b"screenshot-bytes"
assert fake_context.storage_state.call_count == 2
# 测试临时 auth-capture 会话不触发任何 state 存储
ephemeral_session = BrowserSession(
id="session-eph",
custom_page_id=0,
profile_key="auth-capture-xyz",
context=fake_context,
tabs={"main": BrowserTab(id="main", page=fake_page, created_at=0.0)},
active_tab_id="main",
lock=asyncio.Lock(),
last_saved_state_at=0.0
)
service._sessions[ephemeral_session.id] = ephemeral_session
run(service.screenshot(ephemeral_session.id))
# 它的 call_count 依然是 2,没有增加
assert fake_context.storage_state.call_count == 2
finally:
get_settings().browser_profiles_dir = original_dir
shutil.rmtree(temp_dir, ignore_errors=True)
def test_close_saves_state_and_cleans_up():
import tempfile
import shutil
service = BrowserSessionService()
temp_dir = tempfile.mkdtemp()
original_dir = get_settings().browser_profiles_dir
get_settings().browser_profiles_dir = temp_dir
try:
from unittest.mock import AsyncMock, MagicMock
from app.services.browser_session_service import BrowserSession, BrowserTab
fake_context = AsyncMock()
fake_page = MagicMock()
fake_page.is_closed = MagicMock(return_value=False)
import time
session = BrowserSession(
id="session_close",
custom_page_id=2,
profile_key="page-2-test",
context=fake_context,
tabs={"main": BrowserTab(id="main", page=fake_page, created_at=0.0)},
active_tab_id="main",
lock=asyncio.Lock(),
last_saved_state_at=time.monotonic() # 此时在限流内
)
service._sessions[session.id] = session
# 即使在限流时间内,close 也必须强制保存
run(service.close(session.id))
assert fake_context.storage_state.call_count == 1
assert fake_context.close.call_count == 1
# 测试 ephemeral 会话在 close 时不应该保存 state,并且其 profile_dir 应当被清理,导致 cookies json 不复存在
eph_context = AsyncMock()
eph_page = MagicMock()
eph_page.is_closed = MagicMock(return_value=False)
eph_session = BrowserSession(
id="session_eph_close",
custom_page_id=0,
profile_key="auth-capture-abc",
context=eph_context,
tabs={"main": BrowserTab(id="main", page=eph_page, created_at=0.0)},
active_tab_id="main",
lock=asyncio.Lock(),
last_saved_state_at=0.0
)
service._sessions[eph_session.id] = eph_session
# 先手动创建一个假 session-cookies.json
eph_cookies_path = service._cookies_path(eph_session.profile_key)
eph_cookies_path.write_text("{}")
assert eph_cookies_path.exists()
run(service.close(eph_session.id))
# ephemeral close 时不保存,所以 call_count 依然是 0
assert eph_context.storage_state.call_count == 0
assert eph_context.close.call_count == 1
# 但对应的 profile 目录已被删除,文件自然不复存在
assert not eph_cookies_path.exists()
finally:
get_settings().browser_profiles_dir = original_dir
shutil.rmtree(temp_dir, ignore_errors=True)
def test_restore_session_state_decoding_and_inject():
import json
import tempfile
import shutil
import time
service = BrowserSessionService()
temp_dir = tempfile.mkdtemp()
original_dir = get_settings().browser_profiles_dir
get_settings().browser_profiles_dir = temp_dir
try:
from unittest.mock import AsyncMock
fake_context = AsyncMock()
profile_key = "test-restore-profile"
# 准备假 cookies.json,包含 cookies 和 origins/localStorage
cookies_path = service._cookies_path(profile_key)
now = time.time()
fake_state = {
"cookies": [
{
"name": "valid_persistent",
"value": "123",
"domain": "example.test",
"path": "/",
"expires": now + 3600 # 未过期
},
{
"name": "expired_cookie",
"value": "456",
"domain": "example.test",
"path": "/",
"expires": now - 3600 # 已过期
},
{
"name": "session_cookie",
"value": "789",
"domain": "example.test",
"path": "/",
"expires": -1 # session cookie,应被保留并剔除 expires 字段
}
],
"origins": [
{
"origin": "https://example.test",
"localStorage": [
{
"name": "theme",
"value": "dark"
}
]
}
]
}
with open(cookies_path, "w", encoding='utf-8') as f:
json.dump(fake_state, f)
# 运行还原方法
run(service._restore_session_state(fake_context, profile_key))
# 检查是否成功调用 add_cookies
assert fake_context.add_cookies.call_count == 1
# 检查过滤后的 cookies 内容
injected_cookies = fake_context.add_cookies.call_args[0][0]
assert len(injected_cookies) == 2
names = [c["name"] for c in injected_cookies]
assert "valid_persistent" in names
assert "session_cookie" in names
assert "expired_cookie" not in names
# 校验 session_cookie 是否成功移除了 expires
session_c = next(c for c in injected_cookies if c["name"] == "session_cookie")
assert "expires" not in session_c
# 检查是否成功调用了 add_init_script (用于还原 LocalStorage)
assert fake_context.add_init_script.call_count == 1
init_script = fake_context.add_init_script.call_args[0][0]
assert "window.localStorage.setItem" in init_script
assert "theme" in init_script
assert "dark" in init_script
finally:
get_settings().browser_profiles_dir = original_dir
shutil.rmtree(temp_dir, ignore_errors=True)
def test_websocket_event_saves_state():
import tempfile
import shutil
service = BrowserSessionService()
temp_dir = tempfile.mkdtemp()
original_dir = get_settings().browser_profiles_dir
get_settings().browser_profiles_dir = temp_dir
try:
from unittest.mock import AsyncMock, MagicMock
from app.services.browser_session_service import BrowserSession, BrowserTab
fake_context = AsyncMock()
fake_page = MagicMock()
fake_page.is_closed = MagicMock(return_value=False)
fake_page.mouse = MagicMock()
fake_page.mouse.click = AsyncMock()
session = BrowserSession(
id="session_ws",
custom_page_id=3,
profile_key="page-3-ws-test",
context=fake_context,
tabs={"main": BrowserTab(id="main", page=fake_page, created_at=0.0)},
active_tab_id="main",
lock=asyncio.Lock(),
last_saved_state_at=0.0
)
service._sessions[session.id] = session
# 即使 include_state=False,也应当在 5 秒节流到期后保存状态
run(service.event(
session_id=session.id,
event_type="click",
payload={"x": 10.0, "y": 20.0},
include_state=False
))
# storage_state 应该被调用,说明保存成功触发了
assert fake_context.storage_state.call_count == 1
assert session.last_saved_state_at > 0
finally:
get_settings().browser_profiles_dir = original_dir
shutil.rmtree(temp_dir, ignore_errors=True)
-192
View File
@@ -1,192 +0,0 @@
import asyncio
import json
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from app.services.browser_session_service import BrowserSessionService, BrowserSession, BrowserTab, BrowserSessionError
@pytest.fixture
def service():
return BrowserSessionService()
@pytest.fixture
def session(service):
fake_context = AsyncMock()
fake_page = AsyncMock()
# Playwright's page.on is synchronous
fake_page.on = MagicMock()
fake_page.is_closed = MagicMock(return_value=False)
fake_page.url = "https://initial.test"
fake_page.title = AsyncMock(return_value="Initial Tab")
tab_id = "tab1"
tab = BrowserTab(id=tab_id, page=fake_page, created_at=100.0)
sess = BrowserSession(
id="sess123",
custom_page_id=1,
profile_key="profile1",
context=fake_context,
tabs={tab_id: tab},
active_tab_id=tab_id,
lock=asyncio.Lock(),
)
service._sessions[sess.id] = sess
return sess
@pytest.mark.asyncio
async def test_tab_capture(service, session):
new_page = AsyncMock()
new_page.on = MagicMock()
new_page.is_closed = MagicMock(return_value=False)
new_page.url = "https://new.test"
new_page.title = AsyncMock(return_value="New Tab")
new_page.bring_to_front = AsyncMock()
# Simulate page capture
service._handle_new_page(session, new_page)
# Wait for the background registration task to finish
await asyncio.sleep(0.1)
assert len(session.tabs) == 2
assert session.active_tab_id != "tab1"
new_tab_id = session.active_tab_id
assert session.tabs[new_tab_id].page == new_page
assert session.tab_revision == 1
# Wait a bit for the background task _init_new_tab to finish if possible,
# though it's mocked anyway.
await asyncio.sleep(0.1)
@pytest.mark.asyncio
async def test_activate_tab(service, session):
new_page = AsyncMock()
new_page.is_closed = MagicMock(return_value=False)
new_page.bring_to_front = AsyncMock()
tab2_id = "tab2"
session.tabs[tab2_id] = BrowserTab(id=tab2_id, page=new_page, created_at=200.0)
await service.activate_tab(session.id, tab2_id)
assert session.active_tab_id == tab2_id
assert new_page.bring_to_front.call_count == 1
assert session.tab_revision == 1
@pytest.mark.asyncio
async def test_close_tab_safety(service, session):
# Cannot close last tab
with pytest.raises(ValueError, match="cannot close the last tab"):
await service.close_tab(session.id, "tab1")
@pytest.mark.asyncio
async def test_close_active_tab_fallback(service, session):
# Setup tab2
page2 = AsyncMock()
page2.is_closed = MagicMock(return_value=False)
page2.bring_to_front = AsyncMock()
tab2_id = "tab2"
session.tabs[tab2_id] = BrowserTab(id=tab2_id, page=page2, created_at=200.0)
# Active is tab2
session.active_tab_id = tab2_id
# Close active tab2
await service.close_tab(session.id, tab2_id)
assert len(session.tabs) == 1
assert session.active_tab_id == "tab1"
assert tab2_id not in session.tabs
assert session.tabs["tab1"].page.bring_to_front.call_count == 1
@pytest.mark.asyncio
async def test_session_state_includes_tabs(service, session):
# Setup tab2
page2 = AsyncMock()
page2.is_closed = MagicMock(return_value=False)
page2.url = "https://tab2.test"
page2.title = AsyncMock(return_value="Tab 2")
tab2_id = "tab2"
session.tabs[tab2_id] = BrowserTab(id=tab2_id, page=page2, created_at=200.0)
state = await service.state(session.id)
assert state["id"] == session.id
assert state["active_tab_id"] == "tab1"
assert len(state["tabs"]) == 2
# Tabs should be sorted by created_at
assert state["tabs"][0]["id"] == "tab1"
assert state["tabs"][1]["id"] == "tab2"
assert state["tabs"][0]["title"] == "Initial Tab"
assert state["tabs"][1]["url"] == "https://tab2.test"
@pytest.mark.asyncio
async def test_ensure_open_prunes_closed_tab(service, session):
# Setup tab2 and make it active
page2 = AsyncMock()
page2.is_closed = MagicMock(return_value=True) # Page 2 is closed
tab2_id = "tab2"
session.tabs[tab2_id] = BrowserTab(id=tab2_id, page=page2, created_at=200.0)
session.active_tab_id = tab2_id
# Calling any interaction method should trigger _ensure_open
# and fallback to tab1
await service.screenshot(session.id)
assert session.active_tab_id == "tab1"
assert tab2_id not in session.tabs
assert session.tab_revision == 1
@pytest.mark.asyncio
async def test_ensure_open_discards_session_if_all_tabs_closed(service, session):
session.tabs["tab1"].page.is_closed = MagicMock(return_value=True)
with pytest.raises(BrowserSessionError, match="all browser pages are closed"):
await service.screenshot(session.id)
assert session.id not in service._sessions
@pytest.mark.asyncio
async def test_tab_revision_bumps_on_events(service, session):
# Setup listeners for the initial tab
service._setup_tab_listeners(session, session.tabs["tab1"].page)
# Extract the "load" listener callback
calls = session.tabs["tab1"].page.on.call_args_list
load_callback = next(c[0][1] for c in calls if c[0][0] == "load")
initial_revision = session.tab_revision
load_callback()
assert session.tab_revision == initial_revision + 1
@pytest.mark.asyncio
async def test_session_state_concurrency_with_popup(service, session):
# Setup: page.title() will trigger a new page registration in background
triggered = False
async def mock_title():
nonlocal triggered
# Only trigger popup once (state() calls title twice: once for tabs list, once for top-level)
if not triggered:
triggered = True
# Simulate a popup arriving while title is being fetched
new_page = AsyncMock()
new_page.on = MagicMock()
new_page.is_closed = MagicMock(return_value=False)
new_page.url = "https://new.test"
new_page.bring_to_front = AsyncMock()
service._handle_new_page(session, new_page)
# Yield to let the registration task start (it will block on the lock)
await asyncio.sleep(0.01)
return "Initial Tab"
session.tabs["tab1"].page.title = mock_title
# Call state() which takes the lock and calls _session_state (which calls mock_title)
state = await service.state(session.id)
assert len(state["tabs"]) == 1 # Still 1 because popup registration is waiting for lock
# Yield to let registration task finish after state() released the lock
await asyncio.sleep(0.1)
assert len(session.tabs) == 2
assert session.tab_revision > 0
+6 -6
View File
@@ -36,7 +36,7 @@ def test_create_page_auto_enables_autofill_when_credentials_are_saved(db_session
custom_pages.CustomPageCreate(
name="Login page",
url="https://example.test/login",
access_mode="remote_browser",
access_mode="direct",
login_username="alice",
login_password="secret",
),
@@ -53,7 +53,7 @@ def test_create_page_respects_explicit_autofill_disable(db_session):
custom_pages.CustomPageCreate(
name="Login page",
url="https://example.test/login",
access_mode="remote_browser",
access_mode="direct",
login_username="alice",
login_password="secret",
login_autofill_enabled=False,
@@ -69,7 +69,7 @@ def test_update_page_auto_enables_autofill_when_new_password_is_saved(db_session
page = CustomPage(
name="Login page",
url="https://example.test/login",
access_mode="remote_browser",
access_mode="direct",
login_username="alice",
login_password="old-secret",
login_autofill_enabled=False,
@@ -95,7 +95,7 @@ def test_update_page_keeps_autofill_disabled_when_existing_password_is_kept(db_s
page = CustomPage(
name="Login page",
url="https://example.test/login",
access_mode="remote_browser",
access_mode="direct",
login_username="alice",
login_password="secret",
login_autofill_enabled=False,
@@ -118,7 +118,7 @@ def test_update_page_respects_explicit_autofill_disable(db_session):
page = CustomPage(
name="Login page",
url="https://example.test/login",
access_mode="remote_browser",
access_mode="direct",
login_username="alice",
login_password="secret",
login_autofill_enabled=False,
@@ -154,7 +154,7 @@ def test_custom_page_migration_backfills_autofill_once(monkeypatch):
conn.execute(text(
"INSERT INTO custom_pages "
"(name, url, icon, sort_order, enabled, use_proxy, access_mode, login_username, login_password, login_autofill_enabled, created_at, updated_at) "
"VALUES ('Login page', 'https://example.test/login', 'Link', 0, 1, 0, 'remote_browser', 'alice', 'secret', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
"VALUES ('Login page', 'https://example.test/login', 'Link', 0, 1, 0, 'direct', 'alice', 'secret', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
))
database_module._migrate_custom_pages()
-177
View File
@@ -1,177 +0,0 @@
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"
-1
View File
@@ -19,7 +19,6 @@ 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:
+3 -78
View File
@@ -379,7 +379,7 @@ export const logsApi = {
}
// ——— Custom Pages ———
export type CustomPageAccessMode = 'direct' | 'proxy' | 'remote_browser'
export type CustomPageAccessMode = 'direct' | 'proxy'
export interface CustomPageData {
id: number
@@ -408,8 +408,8 @@ export interface CustomPageForm {
icon: string
sort_order: number
enabled: boolean
use_proxy: boolean
access_mode: CustomPageAccessMode
use_proxy?: boolean
access_mode?: CustomPageAccessMode
description?: string
login_username?: string
login_password?: string
@@ -427,70 +427,9 @@ export const customPagesApi = {
create: (data: CustomPageForm) => api.post<CustomPageData>('/api/custom-pages', data),
update: (id: number, data: Partial<CustomPageForm>) => api.put<CustomPageData>(`/api/custom-pages/${id}`, data),
delete: (id: number) => api.delete(`/api/custom-pages/${id}`),
refreshAuth: (id: number) => api.post<{ success: boolean; message: string; warning?: string }>(`/api/custom-pages/${id}/refresh-auth`),
}
// ——— Remote browser sessions ———
export interface BrowserTabData {
id: string
title: string
url: string
created_at: number
}
export interface BrowserSessionData {
id: string
custom_page_id: number
url: string
title: string
active_tab_id?: string
tabs?: BrowserTabData[]
tab_revision?: number
}
export type BrowserEventPayload =
| { type: 'click' | 'dblclick' | 'mousemove' | 'mousedown' | 'mouseup'; x: number; y: number; button?: 'left' | 'right' | 'middle' }
| { type: 'type'; text: string }
| { type: 'key'; key: string }
| { type: 'scroll'; delta_x: number; delta_y: number; x?: number; y?: number }
| { type: 'reload' | 'back' | 'forward' }
| { type: 'resize'; width: number; height: number }
export const browserSessionsApi = {
create: (data: { custom_page_id: number; width: number; height: number }) =>
api.post<BrowserSessionData>('/api/browser-sessions', data),
get: (id: string) => api.get<BrowserSessionData>(`/api/browser-sessions/${id}`),
event: (id: string, data: BrowserEventPayload) =>
api.post<BrowserSessionData>(`/api/browser-sessions/${id}/events`, data),
activateTab: (id: string, tabId: string) =>
api.post<BrowserSessionData>(`/api/browser-sessions/${id}/tabs/${tabId}/activate`),
closeTab: (id: string, tabId: string) =>
api.delete<BrowserSessionData>(`/api/browser-sessions/${id}/tabs/${tabId}`),
selection: (id: string) => api.get<{ text: string }>(`/api/browser-sessions/${id}/selection`),
clipboard: (id: string) => api.get<{ text?: string; error?: string }>(`/api/browser-sessions/${id}/clipboard`),
close: (id: string) => api.delete(`/api/browser-sessions/${id}`),
autofillLogin: (id: string) => api.post<{ success: boolean; message: string }>(`/api/browser-sessions/${id}/autofill-login`),
clearProfile: (customPageId: number) => api.delete(`/api/browser-sessions/profiles/${customPageId}`),
screenshotUrl: (id: string, token?: string) => {
const params = new URLSearchParams({ t: String(Date.now()) })
if (token) params.set('token', token)
return `/api/browser-sessions/${id}/screenshot?${params.toString()}`
},
/** Build a WebSocket URL for the streaming endpoint. */
wsUrl: (id: string, token?: string) => {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
const params = new URLSearchParams()
if (token) params.set('token', token)
return `${proto}//${location.host}/api/browser-sessions/${id}/ws?${params.toString()}`
},
}
// ——— Auth Capture ———
export interface AuthCaptureSession {
session_id: string
ws_url: string
}
export interface BrowserImportSession {
session_id: string
secret: string
@@ -527,24 +466,10 @@ export interface BrowserImportStatus {
}
export const authCaptureApi = {
createSession: (url: string, width?: number, height?: number) =>
api.post<AuthCaptureSession>('/api/auth-capture/sessions', { url, width, height }),
extract: (sessionId: string, options?: { includeRaw?: boolean }) =>
api.get<AuthCaptureResult>(`/api/auth-capture/sessions/${sessionId}/extract`, {
params: options?.includeRaw ? { include_raw: true } : undefined,
}),
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()
if (token) params.set('token', token)
return `${proto}//${location.host}/api/browser-sessions/${sessionId}/ws?${params.toString()}`
},
}
+11 -173
View File
@@ -55,10 +55,10 @@
<div class="sidebar-section sidebar-section-pages">
<div class="sidebar-section-head">
<div>
<div class="sidebar-section-title">自定义页面</div>
<p class="sidebar-section-note">聚合外部控制台与嵌入页面</p>
<div class="sidebar-section-title">上游网址</div>
<p class="sidebar-section-note">外部控制台快捷入口</p>
</div>
<router-link to="/custom-pages" class="nav-manage-link" title="管理自定义页面" @click="closeMobileNav">
<router-link to="/custom-pages" class="nav-manage-link" title="管理上游网址" @click="closeMobileNav">
<el-icon><Setting /></el-icon>
</router-link>
</div>
@@ -69,7 +69,6 @@
v-for="page in customPages"
:key="page.id"
class="nav-item nav-item-custom"
:class="{ active: isCustomPageActive(page.id) }"
href="#"
@click.prevent="openCustomPage(page)"
>
@@ -82,14 +81,14 @@
</template>
<router-link v-else to="/custom-pages" class="add-page-link" @click="closeMobileNav">
<el-icon><Plus /></el-icon>
<span>添加页面</span>
<span>添加网址</span>
</router-link>
</div>
</div>
</aside>
<div class="main-wrap">
<header class="topbar" :class="{ compact: customPageTabs.length > 0 && isCustomPageRoute }">
<header class="topbar">
<div class="topbar-main">
<button type="button" class="mobile-menu" @click="mobileNavOpen = true" aria-label="打开导航">
<el-icon><Operation /></el-icon>
@@ -108,45 +107,18 @@
</div>
</header>
<main class="page-content" :class="{ 'has-custom-tabs': customPageTabs.length > 0 && isCustomPageRoute }">
<main class="page-content">
<router-view v-slot="{ Component }">
<component :is="Component" v-if="!isCustomPageRoute" />
<component :is="Component" />
</router-view>
<div v-show="isCustomPageRoute && customPageTabs.length > 0" class="custom-tabs-shell">
<div class="custom-tabs-bar">
<button
v-for="tab in customPageTabs"
:key="tab.id"
type="button"
class="custom-tab"
:class="{ active: tab.id === activeCustomPageId }"
@click="activateCustomPage(tab.id)"
>
<el-icon><component :is="iconMap[tab.icon] || LinkIcon" /></el-icon>
<span>{{ tab.name }}</span>
<el-icon class="custom-tab-close" @click.stop="closeCustomPageTab(tab.id)"><Close /></el-icon>
</button>
</div>
<div class="custom-tabs-content">
<PageViewer
v-for="tab in customPageTabs"
:key="tab.id"
:page-id="tab.id"
:active="isCustomPageRoute && tab.id === activeCustomPageId"
embedded
v-show="tab.id === activeCustomPageId"
/>
</div>
</div>
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, markRaw } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ref, onMounted, onUnmounted, markRaw } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import {
Link as LinkIcon,
@@ -174,10 +146,8 @@ import {
Operation,
} from '@element-plus/icons-vue'
import { customPagesApi, type CustomPageData } from '@/api'
import PageViewer from '@/views/PageViewer.vue'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const mobileNavOpen = ref(false)
@@ -198,20 +168,11 @@ const iconMap: Record<string, any> = {
}
const customPages = ref<CustomPageData[]>([])
const customPageTabs = ref<CustomPageData[]>([])
const activeCustomPageId = ref<number | null>(null)
const isCustomPageRoute = computed(() => route.path.startsWith('/page/'))
async function loadCustomPages() {
try {
const res = await customPagesApi.list()
customPages.value = res.data.filter((page) => page.enabled)
customPageTabs.value = customPageTabs.value
.map((tab) => customPages.value.find((page) => page.id === tab.id))
.filter((page): page is CustomPageData => Boolean(page))
if (activeCustomPageId.value && !customPageTabs.value.some((tab) => tab.id === activeCustomPageId.value)) {
activeCustomPageId.value = customPageTabs.value[0]?.id ?? null
}
} catch {
// sidebar data is non-blocking
}
@@ -226,34 +187,8 @@ function closeMobileNav() {
}
function openCustomPage(page: CustomPageData) {
if (!customPageTabs.value.some((tab) => tab.id === page.id)) {
customPageTabs.value.push(page)
}
closeMobileNav()
activateCustomPage(page.id)
}
function activateCustomPage(id: number) {
activeCustomPageId.value = id
if (route.path !== `/page/${id}`) router.push(`/page/${id}`)
}
function closeCustomPageTab(id: number) {
const index = customPageTabs.value.findIndex((tab) => tab.id === id)
if (index === -1) return
customPageTabs.value.splice(index, 1)
if (activeCustomPageId.value !== id) return
const next = customPageTabs.value[index] || customPageTabs.value[index - 1]
if (next) {
activateCustomPage(next.id)
} else {
activeCustomPageId.value = null
router.push('/custom-pages')
}
}
function isCustomPageActive(id: number) {
return isCustomPageRoute.value && activeCustomPageId.value === id
window.open(page.url, '_blank', 'noopener,noreferrer')
}
function handleLogout() {
@@ -270,14 +205,6 @@ onUnmounted(() => {
window.removeEventListener('custom-pages-updated', onPagesUpdated)
})
watch([() => route.path, customPages], () => {
mobileNavOpen.value = false
if (!isCustomPageRoute.value) return
const id = Number(route.params.id)
if (!id || activeCustomPageId.value === id) return
const page = customPages.value.find((item) => item.id === id)
if (page) openCustomPage(page)
})
</script>
<style scoped>
@@ -340,8 +267,7 @@ watch([() => route.path, customPages], () => {
.sidebar-brand,
.sidebar-section,
.topbar,
.custom-tabs-bar {
.topbar {
border: 1px solid var(--border-color);
box-shadow: var(--shadow-panel);
backdrop-filter: blur(14px);
@@ -548,12 +474,6 @@ watch([() => route.path, customPages], () => {
background: rgba(25, 19, 16, 0.7);
}
.topbar.compact {
min-height: 2.8rem;
padding: 0.4rem 0.75rem;
border-radius: calc(var(--radius-shell) - 0.35rem);
}
.topbar-main,
.topbar-right,
.topbar-status,
@@ -592,81 +512,6 @@ watch([() => route.path, customPages], () => {
padding: 0.2rem;
}
.page-content.has-custom-tabs {
overflow: hidden;
padding: 0;
}
.custom-tabs-shell {
height: 100%;
display: flex;
flex-direction: column;
min-width: 0;
gap: 0.45rem;
}
.custom-tabs-bar {
min-height: 2.4rem;
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.45rem;
border-radius: calc(var(--radius-shell) - 0.45rem);
background: rgba(25, 19, 16, 0.7);
overflow-x: auto;
}
.custom-tab {
min-height: 1.9rem;
min-width: 6rem;
max-width: 12rem;
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0 0.65rem;
border: 1px solid transparent;
border-radius: var(--radius-pill);
background: rgba(255, 244, 232, 0.02);
color: var(--text-secondary);
font-size: 0.82rem;
cursor: pointer;
}
.custom-tab span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.custom-tab.active {
background: linear-gradient(135deg, rgba(217, 139, 66, 0.16), rgba(217, 139, 66, 0.04));
border-color: rgba(239, 175, 99, 0.18);
color: var(--text-primary);
}
.custom-tab-close {
width: 1rem;
height: 1rem;
padding: 0.1rem;
border-radius: 0.35rem;
}
.custom-tab-close:hover {
background: rgba(221, 126, 114, 0.14);
color: var(--color-danger);
}
.custom-tabs-content {
position: relative;
flex: 1;
min-height: 0;
padding: 0.45rem;
border-radius: var(--radius-shell);
overflow: hidden;
background: rgba(10, 8, 7, 0.16);
}
@media (min-width: 1024px) {
.layout-shell {
gap: var(--shell-padding);
@@ -723,12 +568,5 @@ watch([() => route.path, customPages], () => {
justify-content: center;
}
.custom-tabs-bar {
min-height: 3.7rem;
}
.custom-tab {
min-height: 2.9rem;
}
}
</style>
+51 -499
View File
@@ -1,150 +1,13 @@
<template>
<el-dialog
v-model="visible"
title="🔐 远程浏览器认证提取"
width="960px"
title="真实浏览器认证导入"
width="720px"
:close-on-click-modal="false"
:before-close="handleClose"
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>
<el-form @submit.prevent="launchBrowser">
<el-form-item label="登录页 URL">
<el-input v-model="targetUrl" placeholder="https://example.com/auth/login" />
</el-form-item>
<div v-if="showExtraFields" class="capture-extra-fields">
<el-form-item label="登录账号">
<el-input v-model="loginUsername" placeholder="用于自动填充(可选)" />
</el-form-item>
<el-form-item label="登录密码">
<el-input v-model="loginPassword" type="password" placeholder="用于自动填充(可选)" />
</el-form-item>
</div>
<div class="capture-launch-row">
<el-button text size="small" @click="showExtraFields = !showExtraFields">
{{ showExtraFields ? '收起' : '自动填充选项' }}
</el-button>
<el-button type="primary" :loading="launching" @click="launchBrowser">
<el-icon><Pointer /></el-icon>
打开远程浏览器
</el-button>
</div>
</el-form>
</div>
<!-- Step 2: Interactive browser via WebSocket -->
<div v-else class="capture-step">
<div class="capture-step-header">
<h4>步骤 2在浏览器中手动登录</h4>
<div class="capture-actions">
<el-button size="small" @click="wsSend({type:'back'})" :disabled="!wsConnected">
<el-icon><Back /></el-icon>
</el-button>
<el-button size="small" @click="wsSend({type:'forward'})" :disabled="!wsConnected">
<el-icon><Right /></el-icon>
</el-button>
<el-button size="small" @click="wsSend({type:'reload'})" :disabled="!wsConnected">
<el-icon><Refresh /></el-icon>
</el-button>
<el-divider direction="vertical" />
<el-button size="small" :loading="extracting" type="primary" @click="extractCredentials">
<el-icon><Search /></el-icon>
提取认证信息
</el-button>
</div>
<div class="ws-status">
<span :class="['ws-dot', wsConnected ? 'connected' : 'disconnected']" />
{{ wsConnected ? '实时' : '连接中…' }}
</div>
</div>
<p class="capture-hint">在下方浏览器中完成登录后点击提取认证信息获取 token / cookie</p>
<!-- WS-based interactive frame -->
<div
ref="frameRef"
class="browser-viewport"
tabindex="0"
:class="{ 'ws-connected': wsConnected }"
@keydown.prevent="onKeydown"
@wheel.prevent="onWheel"
@pointerdown.stop.prevent="onPointerDown"
@pointermove.stop.prevent="onPointerMove"
@pointerup.stop.prevent="onPointerUp"
@pointercancel.stop.prevent="onPointerUp"
@dblclick.prevent="onDblClick"
@contextmenu.prevent
>
<img
v-if="frameUrl"
:src="frameUrl"
class="browser-frame"
alt="远程浏览器"
draggable="false"
@load="onFrameLoad"
/>
<div v-else class="browser-loading">
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
<p>正在连接远程浏览器</p>
</div>
</div>
<!-- Extraction results panel -->
<transition name="el-zoom-in-top">
<div v-if="extracted && result" class="candidate-panel">
<div class="candidate-panel-header">
<span>提取到 {{ result.candidates.length }} 个认证凭据</span>
<el-button size="small" text @click="resetExtract">重新提取</el-button>
</div>
<div v-if="result.candidates.length === 0" class="candidate-empty">
未找到认证凭据请确认已成功登录后重试
</div>
<div v-else class="candidate-list">
<div
v-for="(c, i) in result.candidates"
:key="i"
class="candidate-card"
:class="{ selected: selectedIndex === i }"
@click="selectedIndex = i"
>
<div class="candidate-row">
<el-radio :model-value="selectedIndex === i" :label="i" @click.stop="selectedIndex = 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="selectedIndex < 0" :loading="applyingSelection" @click="confirmSelection">
填入当前表单
</el-button>
</div>
</div>
</transition>
</div>
</template>
<template v-else>
<div class="capture-step">
<div class="capture-step-header">
<h4>真实浏览器导入</h4>
@@ -157,6 +20,7 @@
<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" />
@@ -231,17 +95,14 @@
</div>
</transition>
</div>
</template>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch, onUnmounted, nextTick } from 'vue'
import { computed, ref, watch, onUnmounted } 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'
import { useAuthStore } from '@/stores/auth'
const props = defineProps<{
modelValue: boolean
@@ -254,6 +115,40 @@ const emit = defineEmits<{
(e: 'select', candidate: { type: string; value: string; source: string; cookie_name?: string; cookie_value?: string; cookie_count?: number; cookie_names?: string[]; new_api_user?: string }): void
}>()
const visible = ref(props.modelValue)
const targetUrl = ref(props.initialUrl || '')
const smartupOrigin = computed(() => location.origin)
const creatingImportSession = ref(false)
const applyingSelection = 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 importPollTimer: number | null = null
let importClockTimer: number | null = null
watch(() => props.modelValue, (v) => { visible.value = v })
watch(() => props.initialUrl, (v) => { if (v) targetUrl.value = v })
function candidatePreview(candidate: AuthCaptureCandidate): string {
return candidate.preview || maskValue(candidate.value || '')
}
@@ -268,7 +163,6 @@ function sameCandidate(a: AuthCaptureCandidate, b: AuthCaptureCandidate): boolea
}
function resolveCandidateValue(candidate: AuthCaptureCandidate): string {
// cookie_bundle.value 已是完整 cookie 字符串;cookie.value 是 "name=value" 格式
if (candidate.type === 'cookie') {
return candidate.cookie_value || candidate.value || ''
}
@@ -308,10 +202,8 @@ function defaultCandidateIndex(candidates: AuthCaptureCandidate[]): number {
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
// 其次选 session cookie
const sessionCookie = candidates.findIndex((c) => c.type === 'cookie' && c.cookie_name === 'session')
if (sessionCookie >= 0) return sessionCookie
const anyCookie = candidates.findIndex((c) => c.type === 'cookie')
@@ -319,100 +211,6 @@ function defaultCandidateIndex(candidates: AuthCaptureCandidate[]): number {
return candidates.length === 1 ? 0 : -1
}
const auth = useAuthStore()
const visible = ref(props.modelValue)
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'
function loadSavedFields() {
try {
const raw = localStorage.getItem(AUTH_CAPTURE_STORAGE_KEY)
if (!raw) return
const saved = JSON.parse(raw)
if (saved.url) targetUrl.value = saved.url
if (saved.username) { loginUsername.value = saved.username; showExtraFields.value = true }
if (saved.password) { loginPassword.value = saved.password; showExtraFields.value = true }
} catch {}
}
function saveFields() {
try {
localStorage.setItem(AUTH_CAPTURE_STORAGE_KEY, JSON.stringify({
url: targetUrl.value,
username: loginUsername.value,
password: loginPassword.value,
}))
} catch {}
}
// Auto-fill
const showExtraFields = ref(false)
const loginUsername = ref('')
const loginPassword = ref('')
loadSavedFields()
// Session + WS
const sessionId = ref('')
const launching = ref(false)
const extracting = ref(false)
const applyingSelection = ref(false)
const extracted = ref(false)
const result = ref<AuthCaptureResult | null>(null)
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
function revokeFrameUrl(url: string) {
if (url) URL.revokeObjectURL(url)
}
function clearFrameUrls() {
revokeFrameUrl(frameUrl.value)
if (prevFrameUrl && prevFrameUrl !== frameUrl.value) {
revokeFrameUrl(prevFrameUrl)
}
frameUrl.value = ''
prevFrameUrl = ''
}
function stopImportPolling() {
if (importPollTimer !== null) {
window.clearInterval(importPollTimer)
@@ -431,10 +229,7 @@ function startImportClock() {
if (importExpired.value) {
stopImportPolling()
ElMessage.warning('导入码已过期,请重新生成')
if (importClockTimer !== null) {
window.clearInterval(importClockTimer)
importClockTimer = null
}
stopImportClock()
}
}, 1000)
}
@@ -446,29 +241,8 @@ function stopImportClock() {
}
}
// ——— Launch ———
async function launchBrowser() {
if (!targetUrl.value) return
saveFields()
launching.value = true
try {
const res = await authCaptureApi.createSession(targetUrl.value)
sessionId.value = res.data.session_id
await nextTick()
connectWs()
} catch (e: any) {
console.error('launch failed', e)
} finally {
launching.value = false
}
}
// ——— Real browser import ———
async function createBrowserImportSession() {
if (!targetUrl.value) return
saveFields()
creatingImportSession.value = true
try {
const res = await authCaptureApi.createImportSession(targetUrl.value)
@@ -516,10 +290,12 @@ function resetImportResult() {
importResult.value = null
importSelectedIndex.value = -1
if (importSessionId.value) {
stopImportPolling()
importPolling.value = true
importPollTimer = window.setInterval(() => {
void pollImportSessionOnce()
}, 2000)
startImportClock()
}
}
@@ -571,190 +347,9 @@ async function copyText(text: string) {
}
}
// ——— WebSocket frame stream ———
function connectWs() {
const token = auth.token
if (!token || !sessionId.value) return
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
const url = `${proto}//${location.host}/api/browser-sessions/${sessionId.value}/ws?token=${token}`
ws = new WebSocket(url)
ws.binaryType = 'arraybuffer'
ws.onopen = () => { wsConnected.value = true }
ws.onmessage = (evt) => {
if (evt.data instanceof ArrayBuffer) {
// Binary JPEG frame — swap in the new URL before cleaning up the old one
const blob = new Blob([evt.data], { type: 'image/jpeg' })
const nextFrameUrl = URL.createObjectURL(blob)
const previousFrameUrl = frameUrl.value
frameUrl.value = nextFrameUrl
prevFrameUrl = previousFrameUrl
if (previousFrameUrl) {
void nextTick(() => {
revokeFrameUrl(previousFrameUrl)
if (prevFrameUrl === previousFrameUrl) {
prevFrameUrl = ''
}
})
}
} else {
// JSON message (init, error, etc.)
try {
const msg = JSON.parse(evt.data)
if (msg.error) {
console.warn('WS error:', msg.error)
}
} catch { /* ignore */ }
}
}
ws.onclose = () => {
wsConnected.value = false
ws = null
clearFrameUrls()
}
ws.onerror = () => {
wsConnected.value = false
}
}
function wsSend(data: Record<string, any>) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data))
}
}
function onFrameLoad(e: Event) {
const img = e.target as HTMLImageElement
frameW = img.naturalWidth || 1
frameH = img.naturalHeight || 1
}
// ——— Event forwarding (via WS) ———
function scalePoint(e: PointerEvent): { x: number; y: number } {
const el = frameRef.value
if (!el) return { x: e.clientX, y: e.clientY }
const rect = el.getBoundingClientRect()
return {
x: ((e.clientX - rect.left) / rect.width) * frameW,
y: ((e.clientY - rect.top) / rect.height) * frameH,
}
}
function onPointerDown(e: PointerEvent) {
frameRef.value?.focus({ preventScroll: true })
pointerDown = true
const p = scalePoint(e)
wsSend({ type: 'mousedown', x: p.x, y: p.y, button: e.button === 2 ? 'right' : 'left' })
}
function onPointerMove(e: PointerEvent) {
if (!pointerDown) return
const p = scalePoint(e)
wsSend({ type: 'mousemove', x: p.x, y: p.y })
}
function onPointerUp(e: PointerEvent) {
if (!pointerDown) return
pointerDown = false
const p = scalePoint(e)
wsSend({ type: 'mouseup', x: p.x, y: p.y, button: e.button === 2 ? 'right' : 'left' })
}
function onDblClick(e: MouseEvent) {
const p = scalePoint(e as unknown as PointerEvent)
wsSend({ type: 'dblclick', x: p.x, y: p.y })
}
function onWheel(e: WheelEvent) {
const p = scalePoint(e as unknown as PointerEvent)
wsSend({ type: 'scroll', delta_x: e.deltaX, delta_y: e.deltaY, x: p.x, y: p.y })
}
function onKeydown(e: KeyboardEvent) {
const keyMap: Record<string, string> = {
Enter: 'Enter', Backspace: 'Backspace', Escape: 'Escape', Tab: 'Tab',
ArrowUp: 'ArrowUp', ArrowDown: 'ArrowDown', ArrowLeft: 'ArrowLeft', ArrowRight: 'ArrowRight',
}
if (keyMap[e.key]) {
wsSend({ type: 'key', key: keyMap[e.key] })
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
wsSend({ type: 'type', text: e.key })
}
}
// ——— Extraction ———
async function extractCredentials() {
if (!sessionId.value) return
extracting.value = true
try {
const res = await authCaptureApi.extract(sessionId.value)
result.value = res.data
extracted.value = true
selectedIndex.value = defaultCandidateIndex(res.data.candidates)
} catch (e: any) {
console.error('extract failed', e)
} finally {
extracting.value = false
}
}
async function confirmSelection() {
if (selectedIndex.value < 0 || !result.value || !sessionId.value) return
const selectedCandidate = result.value.candidates[selectedIndex.value]
applyingSelection.value = true
try {
const rawResult = await authCaptureApi.extract(sessionId.value, { includeRaw: true })
const fullCandidate = rawResult.data.candidates.find((candidate) => sameCandidate(candidate, selectedCandidate))
if (!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, fullCandidate),
})
closeDialog()
} catch (e: any) {
console.error('apply extract failed', e)
ElMessage.error(e?.response?.data?.detail || '获取完整认证信息失败')
} finally {
applyingSelection.value = false
}
}
function resetExtract() {
extracted.value = false
result.value = null
selectedIndex.value = -1
}
async function handleClose() {
function handleClose() {
stopImportPolling()
stopImportClock()
disconnectWs()
if (sessionId.value) {
try { await authCaptureApi.closeSession(sessionId.value) } catch { /* ignore */ }
}
closeDialog()
}
@@ -763,28 +358,6 @@ function closeDialog() {
emit('update:modelValue', false)
}
function disconnectWs() {
if (ws) {
ws.onclose = null
ws.onmessage = null
ws.close()
ws = null
}
wsConnected.value = false
clearFrameUrls()
}
onUnmounted(() => {
stopImportPolling()
stopImportClock()
disconnectWs()
if (sessionId.value) {
authCaptureApi.closeSession(sessionId.value).catch(() => {})
}
})
// ——— Helpers ———
function badgeLabel(type: string): string {
return {
bearer_token: 'Bearer',
@@ -794,23 +367,25 @@ function badgeLabel(type: string): string {
credential: 'Key',
}[type] || type
}
function confClass(s: number): string {
return s >= 80 ? 'conf-high' : s >= 50 ? 'conf-mid' : 'conf-low'
}
function maskValue(v: string): string {
if (!v || v.length <= 8) return '***'
if (v.length <= 16) return v.slice(0, 4) + '…' + v.slice(-4)
return v.slice(0, 8) + '…' + v.slice(-6)
}
onUnmounted(() => {
stopImportPolling()
stopImportClock()
})
</script>
<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;
@@ -819,12 +394,6 @@ function maskValue(v: string): string {
.capture-step-header h4 { margin: 0; }
.capture-actions { display: flex; gap: 6px; align-items: center; }
.capture-hint { color: var(--el-text-color-secondary); font-size: 0.85rem; margin: 0 0 8px; }
.capture-extra-fields {
margin-top: 8px; padding: 8px; background: transparent; border-radius: 6px;
}
.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;
@@ -853,23 +422,6 @@ function maskValue(v: string): string {
.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; }
.ws-dot.disconnected { background: #ff4d4f; }
.browser-viewport {
border: 1px solid var(--el-border-color); border-radius: 8px; overflow: hidden;
background: #000; cursor: crosshair; outline: none; position: relative; min-height: 300px;
}
.browser-frame {
display: block; width: 100%; height: auto;
user-select: none; -webkit-user-drag: none;
}
.browser-loading {
display: flex; flex-direction: column; align-items: center;
justify-content: center; min-height: 300px;
color: var(--el-text-color-secondary); gap: 12px;
}
.candidate-panel {
margin-top: 12px; border: 1px solid var(--el-border-color); border-radius: 8px; overflow: hidden;
}
@@ -885,7 +437,7 @@ function maskValue(v: string): string {
}
.candidate-card:last-child { border-bottom: none; }
.candidate-card:hover, .candidate-card.selected { background: var(--el-color-primary-light-9); }
.candidate-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 2px; }
.candidate-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 2px; gap: 8px; }
.candidate-badge {
display: inline-block; padding: 0 6px; border-radius: 4px;
font-size: 0.72rem; font-weight: 600; margin-right: 6px;
@@ -896,7 +448,7 @@ function maskValue(v: string): string {
.candidate-badge.api_key { background: #f0f5ff; color: #2f54eb; }
.candidate-badge.credential { background: #f6ffed; color: #52c41a; }
.candidate-label { font-size: 0.82rem; color: var(--el-text-color-secondary); }
.candidate-confidence { font-size: 0.75rem; font-weight: 600; padding: 1px 6px; border-radius: 8px; }
.candidate-confidence { font-size: 0.75rem; font-weight: 600; padding: 1px 6px; border-radius: 8px; flex-shrink: 0; }
.conf-high { background: #f6ffed; color: #52c41a; }
.conf-mid { background: #fff7e6; color: #d48806; }
.conf-low { background: #fff2f0; color: #ff4d4f; }
+1 -1
View File
@@ -20,7 +20,7 @@ const router = createRouter({
{ path: 'webhooks', component: () => import('@/views/Webhooks.vue') },
{ path: 'logs', component: () => import('@/views/NotificationLogs.vue') },
{ path: 'custom-pages', component: () => import('@/views/CustomPages.vue') },
{ path: 'page/:id', component: () => import('@/views/PageViewer.vue') },
{ path: 'page/:id', redirect: '/custom-pages' },
],
},
{ path: '/:pathMatch(.*)*', redirect: '/' },
+19 -213
View File
@@ -2,12 +2,12 @@
<div class="shell-page page-section custom-pages-shell">
<div class="page-header surface-card page-block">
<div class="page-heading">
<p class="page-kicker">Embedded Surfaces</p>
<h2 class="page-title">自定义页面</h2>
<p class="page-desc">嵌入外部网页到侧边栏统一管理上游平台</p>
<p class="page-kicker">External Consoles</p>
<h2 class="page-title">上游网址管理</h2>
<p class="page-desc">维护外部控制台入口点击后使用本机浏览器打开</p>
</div>
<el-button type="primary" @click="openCreate">
<el-icon><Plus /></el-icon> 添加页面
<el-icon><Plus /></el-icon> 添加网址
</el-button>
</div>
@@ -24,8 +24,6 @@
<div class="card-url" :title="page.url">{{ page.url }}</div>
</div>
<div class="tag-group">
<el-tag v-if="page.access_mode === 'proxy' || page.use_proxy" size="small" type="warning" class="proxy-tag">代理</el-tag>
<el-tag v-else-if="page.access_mode === 'remote_browser'" size="small" type="success" class="proxy-tag">远程浏览器</el-tag>
<el-tag v-if="!page.enabled" size="small" type="info" class="disabled-tag">已停用</el-tag>
</div>
</div>
@@ -34,7 +32,7 @@
<!-- Actions -->
<div class="card-actions">
<el-button size="small" text type="primary" @click="openViewer(page)">
<el-button size="small" text type="primary" @click="openExternalPage(page)">
<el-icon><Monitor /></el-icon> 打开
</el-button>
<el-button size="small" text @click="openEdit(page)">
@@ -49,8 +47,8 @@
<div v-if="!loading && list.length === 0" class="empty-state">
<el-icon :size="48" class="empty-icon"><Monitor /></el-icon>
<p>还没有自定义页面</p>
<p class="empty-sub">添加后可在侧边栏快速访问上游管理平</p>
<p>还没有上游网址</p>
<p class="empty-sub">添加后可在侧边栏快速打开外部控制</p>
<el-button type="primary" @click="openCreate">立即添加</el-button>
</div>
</div>
@@ -58,7 +56,7 @@
<!-- Create / Edit dialog -->
<el-dialog
v-model="dialogVisible"
:title="editingId ? '编辑页面' : '添加自定义页面'"
:title="editingId ? '编辑网址' : '添加上游网址'"
width="520px"
destroy-on-close
>
@@ -86,72 +84,9 @@
<el-input-number v-model="form.sort_order" :min="0" :max="999" style="width:140px" />
<span class="form-hint">数字越小越靠前</span>
</el-form-item>
<el-form-item label="访问模式">
<el-radio-group v-model="form.access_mode" class="access-mode-group">
<el-radio-button label="direct">直接嵌入</el-radio-button>
<el-radio-button label="proxy">代理</el-radio-button>
<el-radio-button label="remote_browser">远程浏览器</el-radio-button>
</el-radio-group>
<div class="form-hint mode-hint">远程浏览器适合 CookieCSP复杂 SPA 或拒绝 iframe 的站点</div>
</el-form-item>
<el-form-item label="启用">
<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%" @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>
<el-switch v-model="form.login_autofill_enabled" @change="loginAutofillTouched = true" />
</div>
<div class="form-hint login-hint">仅远程浏览器模式会执行不填写提交按钮 selector 时只填账号密码</div>
<el-form-item label="用户名">
<el-input v-model="form.login_username" autocomplete="username" placeholder="登录账号、邮箱或用户名" />
</el-form-item>
<el-form-item label="密码">
<div class="password-field">
<el-input
v-model="form.login_password"
type="password"
show-password
autocomplete="new-password"
:disabled="form.login_password_clear"
:placeholder="editingId && form.login_password_configured ? '留空保持原密码' : '登录密码'"
/>
<el-checkbox
v-if="editingId && form.login_password_configured"
v-model="form.login_password_clear"
@change="form.login_password = ''"
>
清空已保存密码
</el-checkbox>
</div>
</el-form-item>
<el-collapse class="selector-collapse">
<el-collapse-item title="高级 selector" name="selectors">
<el-form-item label="用户名">
<el-input v-model="form.login_username_selector" placeholder="例:input[name='email']" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.login_password_selector" placeholder="例:input[type='password']" />
</el-form-item>
<el-form-item label="提交按钮">
<el-input v-model="form.login_submit_selector" placeholder="可选,例:button[type='submit']" />
</el-form-item>
</el-collapse-item>
</el-collapse>
</div>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
@@ -163,7 +98,6 @@
<script setup lang="ts">
import { ref, onMounted, markRaw } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
import {
@@ -171,9 +105,7 @@ import {
SetUp, Reading, Cpu, DataLine, Grid, Connection,
Ticket, Wallet, Key, Tools, Star, House,
} from '@element-plus/icons-vue'
import { customPagesApi, upstreamsApi, type CustomPageAccessMode, type CustomPageData, type UpstreamData } from '@/api'
const router = useRouter()
import { customPagesApi, type CustomPageData, type CustomPageForm } from '@/api'
// ---- icon map ----
const iconMap: Record<string, any> = {
@@ -195,35 +127,19 @@ const iconMap: Record<string, any> = {
// ---- state ----
const list = ref<CustomPageData[]>([])
const upstreamList = ref<UpstreamData[]>([])
const loading = ref(false)
const dialogVisible = ref(false)
const saving = ref(false)
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
icon: string
sort_order: number
enabled: boolean
use_proxy: boolean
access_mode: CustomPageAccessMode
description: string
login_username: string
login_password: string
login_username_selector: string
login_password_selector: string
login_submit_selector: string
login_autofill_enabled: boolean
login_password_configured: boolean
login_password_clear: boolean
linked_upstream_id: number | null
upstream_platform: UpstreamPlatform
}
const defaultForm = (): PageFormState => ({
@@ -232,19 +148,7 @@ const defaultForm = (): PageFormState => ({
icon: 'Link',
sort_order: 0,
enabled: true,
use_proxy: false,
access_mode: 'direct',
description: '',
login_username: '',
login_password: '',
login_username_selector: '',
login_password_selector: '',
login_submit_selector: '',
login_autofill_enabled: false,
login_password_configured: false,
login_password_clear: false,
linked_upstream_id: null,
upstream_platform: 'sub2api',
})
const form = ref(defaultForm())
const rules = {
@@ -255,9 +159,8 @@ const rules = {
async function loadList() {
loading.value = true
try {
const [pagesRes, upstreamsRes] = await Promise.all([customPagesApi.list(), upstreamsApi.list()])
const pagesRes = await customPagesApi.list()
list.value = pagesRes.data
upstreamList.value = upstreamsRes.data
} finally {
loading.value = false
}
@@ -265,105 +168,41 @@ async function loadList() {
function openCreate() {
editingId.value = null
loginAutofillTouched.value = false
form.value = defaultForm()
dialogVisible.value = true
}
function openEdit(page: CustomPageData) {
editingId.value = page.id
loginAutofillTouched.value = false
form.value = {
name: page.name,
url: page.url,
icon: page.icon,
sort_order: page.sort_order,
enabled: page.enabled,
use_proxy: page.access_mode === 'proxy' || page.use_proxy,
access_mode: page.access_mode || (page.use_proxy ? 'proxy' : 'direct'),
description: page.description || '',
login_username: page.login_username || '',
login_password: '',
login_username_selector: page.login_username_selector || '',
login_password_selector: page.login_password_selector || '',
login_submit_selector: page.login_submit_selector || '',
login_autofill_enabled: page.login_autofill_enabled,
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
saving.value = true
try {
const { login_password_configured: _passwordConfigured, ...payload } = {
...form.value,
use_proxy: form.value.access_mode === 'proxy',
const savePayload: CustomPageForm = {
name: form.value.name,
url: form.value.url,
icon: form.value.icon,
sort_order: form.value.sort_order,
enabled: form.value.enabled,
description: form.value.description,
}
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
}
const { login_autofill_enabled: _autofillEnabled, ...payloadWithoutAutofill } = payload
const savePayload = loginAutofillTouched.value || hasNewLoginCredentials ? payload : payloadWithoutAutofill
if (editingId.value) {
await customPagesApi.update(editingId.value, savePayload)
} else {
await customPagesApi.create(savePayload)
}
await syncLinkedUpstreamPlatform()
ElMessage.success('保存成功')
dialogVisible.value = false
loadList()
@@ -386,8 +225,8 @@ async function confirmDelete(page: CustomPageData) {
} catch {}
}
function openViewer(page: CustomPageData) {
router.push(`/page/${page.id}`)
function openExternalPage(page: CustomPageData) {
window.open(page.url, '_blank', 'noopener,noreferrer')
}
onMounted(loadList)
@@ -459,7 +298,6 @@ onMounted(loadList)
}
.disabled-tag { flex-shrink: 0; }
.tag-group { display: flex; gap: 4px; flex-shrink: 0; }
.proxy-tag { flex-shrink: 0; }
.card-desc {
font-size: 12px;
@@ -482,38 +320,6 @@ onMounted(loadList)
}
.form-hint { font-size: 12px; color: var(--text-muted); margin-left: 8px; }
.mode-hint { margin-left: 0; margin-top: 6px; line-height: 1.4; }
.access-mode-group { max-width: 100%; }
.login-section {
border-top: 1px solid var(--border-color);
padding-top: 14px;
margin-top: 4px;
}
.login-section-head {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 6px;
padding-left: 90px;
}
.login-hint {
margin: 0 0 12px 90px;
}
.selector-collapse {
margin-left: 90px;
border-top: 0;
border-bottom: 0;
}
.password-field {
width: 100%;
display: flex;
flex-direction: column;
gap: 6px;
}
.empty-state {
grid-column: 1 / -1;
File diff suppressed because it is too large Load Diff