Remove server remote browser support
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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_string;cookie.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 里说明验证失败
|
||||
# 这样用户仍能看到新凭证已写入,便于 debug(cf_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 与远程浏览器不在同一 IP,cf_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 = {
|
||||
|
||||
Reference in New Issue
Block a user