feat: one-click upstream auth refresh from custom page viewer
- Add linked_upstream_id to CustomPage model with DB migration
- New POST /api/custom-pages/{pid}/refresh-auth endpoint extracts
credentials from active remote browser and updates linked upstream
- PageViewer toolbar shows key icon button when page has linked upstream
- CustomPages form adds upstream dropdown for remote_browser pages
- Auth capture extracts New-Api-User from localStorage uid/user/self API
- Upstream client sends New-Api-User header in cookie auth mode
- Fix auth capture dialog: transparent background, field persistence,
login URL defaults to base_url/login, focus on click for keyboard input
- Fix upstream test ASCII encoding with non-header characters validation
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -64,4 +64,6 @@ def _migrate_custom_pages():
|
|||||||
"AND NULLIF(TRIM(login_password), '') IS NOT NULL"
|
"AND NULLIF(TRIM(login_password), '') IS NOT NULL"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if "linked_upstream_id" not in columns:
|
||||||
|
conn.execute(text("ALTER TABLE custom_pages ADD COLUMN linked_upstream_id INTEGER"))
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class CustomPage(Base):
|
|||||||
login_submit_selector: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
|
login_submit_selector: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
|
||||||
login_autofill_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
login_autofill_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
login_autofill_backfilled_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
login_autofill_backfilled_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
linked_upstream_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime,
|
DateTime,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -21,6 +21,8 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/auth-capture", tags=["auth-capture"])
|
router = APIRouter(prefix="/api/auth-capture", tags=["auth-capture"])
|
||||||
|
|
||||||
|
SENSITIVE_CANDIDATE_FIELDS = frozenset({"value", "cookie_value"})
|
||||||
|
|
||||||
|
|
||||||
class CaptureSessionCreate(BaseModel):
|
class CaptureSessionCreate(BaseModel):
|
||||||
url: str = Field(..., description="Target login page URL to open in browser")
|
url: str = Field(..., description="Target login page URL to open in browser")
|
||||||
@@ -41,6 +43,14 @@ class CaptureExtractResponse(BaseModel):
|
|||||||
candidates: list[dict] = []
|
candidates: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_candidate(candidate: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
key: value
|
||||||
|
for key, value in candidate.items()
|
||||||
|
if key not in SENSITIVE_CANDIDATE_FIELDS
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _browser_error(exc: Exception) -> HTTPException:
|
def _browser_error(exc: Exception) -> HTTPException:
|
||||||
if isinstance(exc, BrowserDependencyError):
|
if isinstance(exc, BrowserDependencyError):
|
||||||
return HTTPException(503, str(exc))
|
return HTTPException(503, str(exc))
|
||||||
@@ -108,8 +118,9 @@ async def extract_credentials(
|
|||||||
raise _browser_error(exc)
|
raise _browser_error(exc)
|
||||||
|
|
||||||
if not include_raw:
|
if not include_raw:
|
||||||
# Strip raw data — only keep curated candidates
|
# Strip raw data — only keep curated candidates with masked previews
|
||||||
return CaptureExtractResponse(candidates=result.get("candidates", []))
|
candidates = [_sanitize_candidate(candidate) for candidate in result.get("candidates", [])]
|
||||||
|
return CaptureExtractResponse(candidates=candidates)
|
||||||
return CaptureExtractResponse(**result)
|
return CaptureExtractResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ from app.models.admin_user import AdminUser
|
|||||||
from app.models.custom_page import CustomPage
|
from app.models.custom_page import CustomPage
|
||||||
from app.models.upstream import Upstream
|
from app.models.upstream import Upstream
|
||||||
from app.services.upstream_client import _find_user_id
|
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
|
from app.utils.auth import decode_token, get_current_user, get_user_from_token_param
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/custom-pages", tags=["custom-pages"])
|
router = APIRouter(prefix="/api/custom-pages", tags=["custom-pages"])
|
||||||
@@ -48,6 +50,7 @@ class CustomPageCreate(BaseModel):
|
|||||||
login_password_selector: Optional[str] = None
|
login_password_selector: Optional[str] = None
|
||||||
login_submit_selector: Optional[str] = None
|
login_submit_selector: Optional[str] = None
|
||||||
login_autofill_enabled: bool = False
|
login_autofill_enabled: bool = False
|
||||||
|
linked_upstream_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class CustomPageUpdate(BaseModel):
|
class CustomPageUpdate(BaseModel):
|
||||||
@@ -66,6 +69,7 @@ class CustomPageUpdate(BaseModel):
|
|||||||
login_submit_selector: Optional[str] = None
|
login_submit_selector: Optional[str] = None
|
||||||
login_autofill_enabled: Optional[bool] = None
|
login_autofill_enabled: Optional[bool] = None
|
||||||
login_password_clear: Optional[bool] = None
|
login_password_clear: Optional[bool] = None
|
||||||
|
linked_upstream_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class CustomPageResponse(BaseModel):
|
class CustomPageResponse(BaseModel):
|
||||||
@@ -84,6 +88,7 @@ class CustomPageResponse(BaseModel):
|
|||||||
login_submit_selector: Optional[str]
|
login_submit_selector: Optional[str]
|
||||||
login_autofill_enabled: bool
|
login_autofill_enabled: bool
|
||||||
login_password_configured: bool
|
login_password_configured: bool
|
||||||
|
linked_upstream_id: Optional[int]
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
@@ -118,6 +123,7 @@ def _page_response(page: CustomPage) -> CustomPageResponse:
|
|||||||
login_submit_selector=page.login_submit_selector,
|
login_submit_selector=page.login_submit_selector,
|
||||||
login_autofill_enabled=page.login_autofill_enabled,
|
login_autofill_enabled=page.login_autofill_enabled,
|
||||||
login_password_configured=bool(page.login_password),
|
login_password_configured=bool(page.login_password),
|
||||||
|
linked_upstream_id=page.linked_upstream_id,
|
||||||
created_at=page.created_at,
|
created_at=page.created_at,
|
||||||
updated_at=page.updated_at,
|
updated_at=page.updated_at,
|
||||||
)
|
)
|
||||||
@@ -201,6 +207,82 @@ def delete_page(pid: int, db: Session = Depends(get_db), _=Depends(get_current_u
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---- One-click refresh auth ----
|
||||||
|
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshAuthResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_best_candidate(candidates: list[dict], preferred_auth_type: str) -> Optional[dict]:
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
type_map = {"cookie": "cookie", "bearer": "bearer_token", "api_key": "api_key"}
|
||||||
|
preferred = type_map.get(preferred_auth_type)
|
||||||
|
if preferred:
|
||||||
|
for c in candidates:
|
||||||
|
if c["type"] == preferred:
|
||||||
|
return c
|
||||||
|
return 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", [])
|
||||||
|
candidate = _pick_best_candidate(candidates, upstream.auth_type)
|
||||||
|
if not candidate:
|
||||||
|
return RefreshAuthResponse(success=False, message="未提取到有效凭证,请确认已在远程浏览器中登录")
|
||||||
|
|
||||||
|
existing_config = _json.loads(upstream.auth_config_json or "{}")
|
||||||
|
ctype = candidate["type"]
|
||||||
|
|
||||||
|
if ctype == "cookie":
|
||||||
|
upstream.auth_type = "cookie"
|
||||||
|
if candidate.get("cookie_name") and candidate.get("cookie_value"):
|
||||||
|
existing_config["cookie_string"] = f"{candidate['cookie_name']}={candidate['cookie_value']}"
|
||||||
|
else:
|
||||||
|
existing_config["cookie_string"] = candidate.get("value", "")
|
||||||
|
if candidate.get("new_api_user"):
|
||||||
|
existing_config["new_api_user"] = candidate["new_api_user"]
|
||||||
|
elif ctype == "bearer_token":
|
||||||
|
upstream.auth_type = "bearer"
|
||||||
|
existing_config["token"] = candidate.get("value", "")
|
||||||
|
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)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return RefreshAuthResponse(success=True, message=f"凭证已刷新 ({upstream.auth_type})")
|
||||||
|
|
||||||
|
|
||||||
# ---- Frame Proxy (simple: strip X-Frame-Options / CSP, pass through content) ----
|
# ---- Frame Proxy (simple: strip X-Frame-Options / CSP, pass through content) ----
|
||||||
|
|
||||||
_STRIP_RESP = {
|
_STRIP_RESP = {
|
||||||
|
|||||||
@@ -59,6 +59,32 @@ async def extract_session_storage(page: Any) -> dict[str, str]:
|
|||||||
return {}
|
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]]:
|
async def extract_request_headers(session: Any) -> list[dict[str, str]]:
|
||||||
"""Return Authorization / API-Key headers captured continuously by CDP.
|
"""Return Authorization / API-Key headers captured continuously by CDP.
|
||||||
|
|
||||||
@@ -83,7 +109,8 @@ async def extract_all(session: Any) -> dict[str, Any]:
|
|||||||
local_storage = await extract_local_storage(page)
|
local_storage = await extract_local_storage(page)
|
||||||
session_storage = await extract_session_storage(page)
|
session_storage = await extract_session_storage(page)
|
||||||
auth_headers = await extract_request_headers(session)
|
auth_headers = await extract_request_headers(session)
|
||||||
candidates = _curate_candidates(cookies, local_storage, session_storage, auth_headers)
|
new_api_user = _find_new_api_user(local_storage, session_storage) or await extract_new_api_user_id(page)
|
||||||
|
candidates = _curate_candidates(cookies, local_storage, session_storage, auth_headers, new_api_user)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"cookies": cookies,
|
"cookies": cookies,
|
||||||
@@ -99,6 +126,7 @@ def _curate_candidates(
|
|||||||
local_storage: dict[str, str],
|
local_storage: dict[str, str],
|
||||||
session_storage: dict[str, str],
|
session_storage: dict[str, str],
|
||||||
auth_headers: list[dict[str, str]],
|
auth_headers: list[dict[str, str]],
|
||||||
|
new_api_user: str = "",
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Scan extracted data for likely credentials with confidence scoring."""
|
"""Scan extracted data for likely credentials with confidence scoring."""
|
||||||
candidates: list[dict[str, Any]] = []
|
candidates: list[dict[str, Any]] = []
|
||||||
@@ -148,19 +176,59 @@ def _curate_candidates(
|
|||||||
_add(candidates, "bearer_token", f"{store_name}.{key}", val, _preview(val),
|
_add(candidates, "bearer_token", f"{store_name}.{key}", val, _preview(val),
|
||||||
f"{store_name}.{key} (API Key)", 90)
|
f"{store_name}.{key} (API Key)", 90)
|
||||||
|
|
||||||
|
if not new_api_user:
|
||||||
|
new_api_user = _find_new_api_user(local_storage, session_storage)
|
||||||
|
|
||||||
# 3. Session cookies
|
# 3. Session cookies
|
||||||
for c in cookies:
|
for c in cookies:
|
||||||
cname = c["name"].lower()
|
cname = c["name"].lower()
|
||||||
if any(k in cname for k in SESSION_COOKIE_NAMES):
|
if any(k in cname for k in SESSION_COOKIE_NAMES):
|
||||||
preview = _preview(c["value"])
|
preview = _preview(c["value"])
|
||||||
cookie_val = f"{c['name']}={c['value']}"
|
cookie_val = f"{c['name']}={c['value']}"
|
||||||
|
confidence = 99 if cname == "session" else 85
|
||||||
|
extra = {"cookie_name": c["name"], "cookie_value": c["value"]}
|
||||||
|
if cname == "session" and new_api_user:
|
||||||
|
extra["new_api_user"] = new_api_user
|
||||||
_add(candidates, "cookie", f"cookie:{c['name']}", cookie_val, preview,
|
_add(candidates, "cookie", f"cookie:{c['name']}", cookie_val, preview,
|
||||||
f"🍪 {c['name']} ({c['domain']})", 75,
|
f"Cookie {c['name']} ({c['domain']})", confidence,
|
||||||
extra={"cookie_name": c["name"], "cookie_value": c["value"]})
|
extra=extra)
|
||||||
|
|
||||||
|
candidates.sort(key=lambda item: (
|
||||||
|
0 if item.get("type") == "cookie" and item.get("cookie_name") == "session" else
|
||||||
|
1 if item.get("type") == "cookie" else
|
||||||
|
2,
|
||||||
|
-int(item.get("confidence") or 0),
|
||||||
|
))
|
||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _find_storage_value(*stores: dict[str, str], key: str) -> str:
|
||||||
|
for store in stores:
|
||||||
|
value = store.get(key)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _find_new_api_user(*stores: dict[str, str]) -> str:
|
||||||
|
uid = _find_storage_value(*stores, key="uid")
|
||||||
|
if uid:
|
||||||
|
return uid
|
||||||
|
user_raw = _find_storage_value(*stores, key="user")
|
||||||
|
if not user_raw:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
user = json.loads(user_raw)
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
if isinstance(user, dict):
|
||||||
|
for key in ("id", "user_id", "userId"):
|
||||||
|
value = user.get(key)
|
||||||
|
if value is not None:
|
||||||
|
return str(value).strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _add(
|
def _add(
|
||||||
candidates: list[dict[str, Any]],
|
candidates: list[dict[str, Any]],
|
||||||
ctype: str,
|
ctype: str,
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ class BrowserSessionService:
|
|||||||
elif event_type == "type":
|
elif event_type == "type":
|
||||||
text = str(payload.get("text", ""))
|
text = str(payload.get("text", ""))
|
||||||
if text:
|
if text:
|
||||||
await page.keyboard.type(text)
|
await page.keyboard.insert_text(text)
|
||||||
elif event_type == "key":
|
elif event_type == "key":
|
||||||
key = str(payload.get("key", ""))
|
key = str(payload.get("key", ""))
|
||||||
if key:
|
if key:
|
||||||
@@ -325,6 +325,13 @@ class BrowserSessionService:
|
|||||||
raise KeyError("browser session not found")
|
raise KeyError("browser session not found")
|
||||||
return session
|
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
|
_get = get_session # alias for internal use
|
||||||
|
|
||||||
def _ensure_open(self, session: BrowserSession) -> None:
|
def _ensure_open(self, session: BrowserSession) -> None:
|
||||||
|
|||||||
@@ -28,6 +28,19 @@ def _find_token(value: Any) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_auth_header_value(value: Any, field_name: str) -> str:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
if text.startswith("Bearer "):
|
||||||
|
text = text[7:].strip()
|
||||||
|
try:
|
||||||
|
text.encode("latin-1")
|
||||||
|
except UnicodeEncodeError as exc:
|
||||||
|
raise UpstreamError(f"{field_name} contains non-HTTP-header characters; please re-extract and apply the full credential") from exc
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def _find_user_id(value: Any) -> str:
|
def _find_user_id(value: Any) -> str:
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
for key in ("id", "user_id", "userId"):
|
for key in ("id", "user_id", "userId"):
|
||||||
@@ -232,25 +245,32 @@ class UpstreamClient:
|
|||||||
if not auth:
|
if not auth:
|
||||||
return headers
|
return headers
|
||||||
if self.auth_type == "bearer":
|
if self.auth_type == "bearer":
|
||||||
token = self.auth_config.get("token", "")
|
token = _clean_auth_header_value(self.auth_config.get("token", ""), "Bearer token")
|
||||||
if token:
|
if token:
|
||||||
headers["Authorization"] = f"Bearer {token}"
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
elif self.auth_type == "api_key":
|
elif self.auth_type == "api_key":
|
||||||
key = self.auth_config.get("key", "")
|
key = _clean_auth_header_value(self.auth_config.get("key", ""), "API key")
|
||||||
header = self.auth_config.get("header", "Authorization")
|
header = self.auth_config.get("header", "Authorization")
|
||||||
if key:
|
if key:
|
||||||
headers[header] = key
|
headers[header] = key
|
||||||
elif self.auth_type == "cookie":
|
elif self.auth_type == "cookie":
|
||||||
cookie_str = self.auth_config.get("cookie_string", "")
|
cookie_str = _clean_auth_header_value(self.auth_config.get("cookie_string", ""), "Cookie")
|
||||||
if cookie_str:
|
if cookie_str:
|
||||||
headers["Cookie"] = cookie_str
|
headers["Cookie"] = cookie_str
|
||||||
|
new_api_user = _clean_auth_header_value(self.auth_config.get("new_api_user", ""), "New-Api-User")
|
||||||
|
if new_api_user:
|
||||||
|
headers["New-Api-User"] = new_api_user
|
||||||
elif self.auth_type == "login_password" and self._token:
|
elif self.auth_type == "login_password" and self._token:
|
||||||
headers["Authorization"] = f"Bearer {self._token}"
|
token = _clean_auth_header_value(self._token, "Login token")
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
if self.auth_type == "login_password" and self._new_api_user:
|
if self.auth_type == "login_password" and self._new_api_user:
|
||||||
headers["New-Api-User"] = self._new_api_user
|
headers["New-Api-User"] = self._new_api_user
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
def _request(self, method: str, path: str, body: Any = None, auth: bool = True) -> Any:
|
def _request(self, method: str, path: str, body: Any = None, auth: bool = True) -> Any:
|
||||||
|
if auth and self.auth_type == "cookie" and "user/self" in path and not self.auth_config.get("new_api_user"):
|
||||||
|
raise UpstreamError("New-API user endpoint requires New-Api-User; re-extract the session cookie after login and save the upstream")
|
||||||
url = self._url(path)
|
url = self._url(path)
|
||||||
if body is not None:
|
if body is not None:
|
||||||
resp = self._client.request(
|
resp = self._client.request(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
from app.routers.auth_capture import _sanitize_candidate
|
||||||
from app.services.browser_session_service import BrowserSessionService
|
from app.services.browser_session_service import BrowserSessionService
|
||||||
|
|
||||||
|
|
||||||
@@ -102,3 +103,27 @@ def test_autofill_returns_without_selectors_when_disabled_or_missing_credentials
|
|||||||
poll_interval_seconds=0,
|
poll_interval_seconds=0,
|
||||||
))
|
))
|
||||||
assert missing_password_page.queries == []
|
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",
|
||||||
|
}
|
||||||
|
|||||||
+20
-12
@@ -259,6 +259,7 @@ export interface CustomPageData {
|
|||||||
login_submit_selector: string | null
|
login_submit_selector: string | null
|
||||||
login_autofill_enabled: boolean
|
login_autofill_enabled: boolean
|
||||||
login_password_configured: boolean
|
login_password_configured: boolean
|
||||||
|
linked_upstream_id: number | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@@ -279,6 +280,7 @@ export interface CustomPageForm {
|
|||||||
login_submit_selector?: string
|
login_submit_selector?: string
|
||||||
login_autofill_enabled?: boolean
|
login_autofill_enabled?: boolean
|
||||||
login_password_clear?: boolean
|
login_password_clear?: boolean
|
||||||
|
linked_upstream_id?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const customPagesApi = {
|
export const customPagesApi = {
|
||||||
@@ -287,6 +289,7 @@ export const customPagesApi = {
|
|||||||
create: (data: CustomPageForm) => api.post<CustomPageData>('/api/custom-pages', data),
|
create: (data: CustomPageForm) => api.post<CustomPageData>('/api/custom-pages', data),
|
||||||
update: (id: number, data: Partial<CustomPageForm>) => api.put<CustomPageData>(`/api/custom-pages/${id}`, data),
|
update: (id: number, data: Partial<CustomPageForm>) => api.put<CustomPageData>(`/api/custom-pages/${id}`, data),
|
||||||
delete: (id: number) => api.delete(`/api/custom-pages/${id}`),
|
delete: (id: number) => api.delete(`/api/custom-pages/${id}`),
|
||||||
|
refreshAuth: (id: number) => api.post<{ success: boolean; message: string }>(`/api/custom-pages/${id}/refresh-auth`),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ——— Remote browser sessions ———
|
// ——— Remote browser sessions ———
|
||||||
@@ -333,28 +336,33 @@ export interface AuthCaptureSession {
|
|||||||
ws_url: string
|
ws_url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuthCaptureCandidate {
|
||||||
|
type: 'bearer_token' | 'cookie' | 'credential' | 'api_key'
|
||||||
|
source: string
|
||||||
|
preview: string
|
||||||
|
label: string
|
||||||
|
confidence: number
|
||||||
|
value?: string
|
||||||
|
cookie_name?: string
|
||||||
|
cookie_value?: string
|
||||||
|
new_api_user?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthCaptureResult {
|
export interface AuthCaptureResult {
|
||||||
cookies: Record<string, any>[]
|
cookies: Record<string, any>[]
|
||||||
storage: Record<string, string>
|
storage: Record<string, string>
|
||||||
session_storage: Record<string, string>
|
session_storage: Record<string, string>
|
||||||
auth_headers: Record<string, string>[]
|
auth_headers: Record<string, string>[]
|
||||||
candidates: {
|
candidates: AuthCaptureCandidate[]
|
||||||
type: 'bearer_token' | 'cookie' | 'credential' | 'api_key'
|
|
||||||
source: string
|
|
||||||
value: string
|
|
||||||
preview: string
|
|
||||||
label: string
|
|
||||||
confidence: number
|
|
||||||
cookie_name?: string
|
|
||||||
cookie_value?: string
|
|
||||||
}[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authCaptureApi = {
|
export const authCaptureApi = {
|
||||||
createSession: (url: string, width?: number, height?: number) =>
|
createSession: (url: string, width?: number, height?: number) =>
|
||||||
api.post<AuthCaptureSession>('/api/auth-capture/sessions', { url, width, height }),
|
api.post<AuthCaptureSession>('/api/auth-capture/sessions', { url, width, height }),
|
||||||
extract: (sessionId: string) =>
|
extract: (sessionId: string, options?: { includeRaw?: boolean }) =>
|
||||||
api.get<AuthCaptureResult>(`/api/auth-capture/sessions/${sessionId}/extract`),
|
api.get<AuthCaptureResult>(`/api/auth-capture/sessions/${sessionId}/extract`, {
|
||||||
|
params: options?.includeRaw ? { include_raw: true } : undefined,
|
||||||
|
}),
|
||||||
closeSession: (sessionId: string) =>
|
closeSession: (sessionId: string) =>
|
||||||
api.delete(`/api/auth-capture/sessions/${sessionId}`),
|
api.delete(`/api/auth-capture/sessions/${sessionId}`),
|
||||||
wsUrl: (sessionId: string, token?: string) => {
|
wsUrl: (sessionId: string, token?: string) => {
|
||||||
|
|||||||
@@ -121,13 +121,13 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="candidate-preview">
|
<div class="candidate-preview">
|
||||||
<code>{{ c.preview || maskValue(c.value) }}</code>
|
<code>{{ candidatePreview(c) }}</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="candidate-actions">
|
<div class="candidate-actions">
|
||||||
<el-button size="small" @click="handleClose">关闭</el-button>
|
<el-button size="small" @click="handleClose">关闭</el-button>
|
||||||
<el-button size="small" type="primary" :disabled="selectedIndex < 0" @click="confirmSelection">
|
<el-button size="small" type="primary" :disabled="selectedIndex < 0" :loading="applyingSelection" @click="confirmSelection">
|
||||||
填入当前表单
|
填入当前表单
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,8 +140,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onUnmounted, nextTick } from 'vue'
|
import { ref, watch, onUnmounted, nextTick } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
import { Pointer, Search, Back, Right, Refresh, Loading } from '@element-plus/icons-vue'
|
import { Pointer, Search, Back, Right, Refresh, Loading } from '@element-plus/icons-vue'
|
||||||
import { authCaptureApi, browserSessionsApi, type AuthCaptureResult } from '@/api'
|
import { authCaptureApi, type AuthCaptureCandidate, type AuthCaptureResult } from '@/api'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -151,9 +152,64 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:modelValue', v: boolean): void
|
(e: 'update:modelValue', v: boolean): void
|
||||||
(e: 'select', candidate: { type: string; value: string; source: string; cookie_name?: string; cookie_value?: string }): void
|
(e: 'select', candidate: { type: string; value: string; source: string; cookie_name?: string; cookie_value?: string; new_api_user?: string }): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
function candidatePreview(candidate: AuthCaptureCandidate): string {
|
||||||
|
return candidate.preview || maskValue(candidate.value || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameCandidate(a: AuthCaptureCandidate, b: AuthCaptureCandidate): boolean {
|
||||||
|
return a.type === b.type
|
||||||
|
&& a.source === b.source
|
||||||
|
&& a.label === b.label
|
||||||
|
&& a.preview === b.preview
|
||||||
|
&& a.confidence === b.confidence
|
||||||
|
&& a.cookie_name === b.cookie_name
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCandidateValue(candidate: AuthCaptureCandidate): string {
|
||||||
|
return candidate.type === 'cookie'
|
||||||
|
? (candidate.cookie_value || candidate.value || '')
|
||||||
|
: (candidate.value || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveNewApiUser(rawResult: AuthCaptureResult, candidate: AuthCaptureCandidate): string | undefined {
|
||||||
|
if (candidate.new_api_user) return candidate.new_api_user
|
||||||
|
const stores = [rawResult.storage, rawResult.session_storage]
|
||||||
|
for (const store of stores) {
|
||||||
|
const uid = store?.uid
|
||||||
|
if (uid) return String(uid)
|
||||||
|
const userRaw = store?.user
|
||||||
|
if (userRaw) {
|
||||||
|
try {
|
||||||
|
const user = JSON.parse(userRaw)
|
||||||
|
if (user?.id) return String(user.id)
|
||||||
|
if (user?.user_id) return String(user.user_id)
|
||||||
|
if (user?.userId) return String(user.userId)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
const statusRaw = store?.status
|
||||||
|
if (statusRaw) {
|
||||||
|
try {
|
||||||
|
const status = JSON.parse(statusRaw)
|
||||||
|
const id = status?.user?.id || status?.id || status?.data?.id
|
||||||
|
if (id) return String(id)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultCandidateIndex(candidates: AuthCaptureCandidate[]): number {
|
||||||
|
if (candidates.length === 0) return -1
|
||||||
|
const sessionCookie = candidates.findIndex((candidate) => candidate.type === 'cookie' && candidate.cookie_name === 'session')
|
||||||
|
if (sessionCookie >= 0) return sessionCookie
|
||||||
|
const anyCookie = candidates.findIndex((candidate) => candidate.type === 'cookie')
|
||||||
|
if (anyCookie >= 0) return anyCookie
|
||||||
|
return candidates.length === 1 ? 0 : -1
|
||||||
|
}
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const visible = ref(props.modelValue)
|
const visible = ref(props.modelValue)
|
||||||
watch(() => props.modelValue, (v) => { visible.value = v })
|
watch(() => props.modelValue, (v) => { visible.value = v })
|
||||||
@@ -161,15 +217,41 @@ watch(() => props.modelValue, (v) => { visible.value = v })
|
|||||||
const targetUrl = ref(props.initialUrl || '')
|
const targetUrl = ref(props.initialUrl || '')
|
||||||
watch(() => props.initialUrl, (v) => { if (v) targetUrl.value = v })
|
watch(() => props.initialUrl, (v) => { if (v) targetUrl.value = v })
|
||||||
|
|
||||||
|
const 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
|
// Auto-fill
|
||||||
const showExtraFields = ref(false)
|
const showExtraFields = ref(false)
|
||||||
const loginUsername = ref('')
|
const loginUsername = ref('')
|
||||||
const loginPassword = ref('')
|
const loginPassword = ref('')
|
||||||
|
|
||||||
|
loadSavedFields()
|
||||||
|
|
||||||
// Session + WS
|
// Session + WS
|
||||||
const sessionId = ref('')
|
const sessionId = ref('')
|
||||||
const launching = ref(false)
|
const launching = ref(false)
|
||||||
const extracting = ref(false)
|
const extracting = ref(false)
|
||||||
|
const applyingSelection = ref(false)
|
||||||
const extracted = ref(false)
|
const extracted = ref(false)
|
||||||
const result = ref<AuthCaptureResult | null>(null)
|
const result = ref<AuthCaptureResult | null>(null)
|
||||||
const selectedIndex = ref(-1)
|
const selectedIndex = ref(-1)
|
||||||
@@ -180,12 +262,26 @@ const frameRef = ref<HTMLElement | null>(null)
|
|||||||
let ws: WebSocket | null = null
|
let ws: WebSocket | null = null
|
||||||
let pointerDown = false
|
let pointerDown = false
|
||||||
let frameW = 1; let frameH = 1 // natural dimensions of the frame
|
let frameW = 1; let frameH = 1 // natural dimensions of the frame
|
||||||
let prevFrameUrl = '' // previous blob URL to revoke
|
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 = ''
|
||||||
|
}
|
||||||
|
|
||||||
// ——— Launch ———
|
// ——— Launch ———
|
||||||
|
|
||||||
async function launchBrowser() {
|
async function launchBrowser() {
|
||||||
if (!targetUrl.value) return
|
if (!targetUrl.value) return
|
||||||
|
saveFields()
|
||||||
launching.value = true
|
launching.value = true
|
||||||
try {
|
try {
|
||||||
const res = await authCaptureApi.createSession(targetUrl.value)
|
const res = await authCaptureApi.createSession(targetUrl.value)
|
||||||
@@ -213,11 +309,20 @@ function connectWs() {
|
|||||||
|
|
||||||
ws.onmessage = (evt) => {
|
ws.onmessage = (evt) => {
|
||||||
if (evt.data instanceof ArrayBuffer) {
|
if (evt.data instanceof ArrayBuffer) {
|
||||||
// Binary JPEG frame — revoke previous to avoid memory leak
|
// Binary JPEG frame — swap in the new URL before cleaning up the old one
|
||||||
if (prevFrameUrl) URL.revokeObjectURL(prevFrameUrl)
|
|
||||||
const blob = new Blob([evt.data], { type: 'image/jpeg' })
|
const blob = new Blob([evt.data], { type: 'image/jpeg' })
|
||||||
prevFrameUrl = URL.createObjectURL(blob)
|
const nextFrameUrl = URL.createObjectURL(blob)
|
||||||
frameUrl.value = prevFrameUrl
|
const previousFrameUrl = frameUrl.value
|
||||||
|
frameUrl.value = nextFrameUrl
|
||||||
|
prevFrameUrl = previousFrameUrl
|
||||||
|
if (previousFrameUrl) {
|
||||||
|
void nextTick(() => {
|
||||||
|
revokeFrameUrl(previousFrameUrl)
|
||||||
|
if (prevFrameUrl === previousFrameUrl) {
|
||||||
|
prevFrameUrl = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// JSON message (init, error, etc.)
|
// JSON message (init, error, etc.)
|
||||||
try {
|
try {
|
||||||
@@ -232,6 +337,7 @@ function connectWs() {
|
|||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
wsConnected.value = false
|
wsConnected.value = false
|
||||||
ws = null
|
ws = null
|
||||||
|
clearFrameUrls()
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
@@ -264,9 +370,10 @@ function scalePoint(e: PointerEvent): { x: number; y: number } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onPointerDown(e: PointerEvent) {
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
frameRef.value?.focus({ preventScroll: true })
|
||||||
pointerDown = true
|
pointerDown = true
|
||||||
const p = scalePoint(e)
|
const p = scalePoint(e)
|
||||||
wsSend({ type: e.buttons === 2 ? 'mousedown' : 'mousedown', x: p.x, y: p.y, button: e.button === 2 ? 'right' : 'left' })
|
wsSend({ type: 'mousedown', x: p.x, y: p.y, button: e.button === 2 ? 'right' : 'left' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointerMove(e: PointerEvent) {
|
function onPointerMove(e: PointerEvent) {
|
||||||
@@ -313,7 +420,7 @@ async function extractCredentials() {
|
|||||||
const res = await authCaptureApi.extract(sessionId.value)
|
const res = await authCaptureApi.extract(sessionId.value)
|
||||||
result.value = res.data
|
result.value = res.data
|
||||||
extracted.value = true
|
extracted.value = true
|
||||||
selectedIndex.value = res.data.candidates.length === 1 ? 0 : -1
|
selectedIndex.value = defaultCandidateIndex(res.data.candidates)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('extract failed', e)
|
console.error('extract failed', e)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -321,17 +428,40 @@ async function extractCredentials() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmSelection() {
|
async function confirmSelection() {
|
||||||
if (selectedIndex.value < 0 || !result.value) return
|
if (selectedIndex.value < 0 || !result.value || !sessionId.value) return
|
||||||
const c = result.value.candidates[selectedIndex.value]
|
const selectedCandidate = result.value.candidates[selectedIndex.value]
|
||||||
emit('select', {
|
applyingSelection.value = true
|
||||||
type: c.type,
|
try {
|
||||||
value: c.type === 'cookie' ? (c.cookie_value || c.value) : c.value,
|
const rawResult = await authCaptureApi.extract(sessionId.value, { includeRaw: true })
|
||||||
source: c.source,
|
const fullCandidate = rawResult.data.candidates.find((candidate) => sameCandidate(candidate, selectedCandidate))
|
||||||
cookie_name: c.cookie_name,
|
|
||||||
cookie_value: c.cookie_value,
|
if (!fullCandidate) {
|
||||||
})
|
ElMessage.error('未找到完整认证信息,请重新提取后再试')
|
||||||
closeDialog()
|
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,
|
||||||
|
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() {
|
function resetExtract() {
|
||||||
@@ -361,6 +491,7 @@ function disconnectWs() {
|
|||||||
ws = null
|
ws = null
|
||||||
}
|
}
|
||||||
wsConnected.value = false
|
wsConnected.value = false
|
||||||
|
clearFrameUrls()
|
||||||
}
|
}
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -396,7 +527,7 @@ function maskValue(v: string): string {
|
|||||||
.capture-actions { display: flex; gap: 6px; align-items: center; }
|
.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-hint { color: var(--el-text-color-secondary); font-size: 0.85rem; margin: 0 0 8px; }
|
||||||
.capture-extra-fields {
|
.capture-extra-fields {
|
||||||
margin-top: 8px; padding: 8px; background: var(--el-fill-color-lighter); border-radius: 6px;
|
margin-top: 8px; padding: 8px; background: transparent; border-radius: 6px;
|
||||||
}
|
}
|
||||||
.capture-launch-row {
|
.capture-launch-row {
|
||||||
display: flex; justify-content: space-between; align-items: center; margin-top: 4px;
|
display: flex; justify-content: space-between; align-items: center; margin-top: 4px;
|
||||||
|
|||||||
@@ -97,6 +97,12 @@
|
|||||||
<el-form-item label="启用">
|
<el-form-item label="启用">
|
||||||
<el-switch v-model="form.enabled" />
|
<el-switch v-model="form.enabled" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item v-if="form.access_mode === 'remote_browser'" label="关联上游">
|
||||||
|
<el-select v-model="form.linked_upstream_id" clearable placeholder="选择要一键刷新凭证的上游" style="width:100%">
|
||||||
|
<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>
|
||||||
<div class="login-section">
|
<div class="login-section">
|
||||||
<div class="login-section-head">
|
<div class="login-section-head">
|
||||||
<span>登录自动填充</span>
|
<span>登录自动填充</span>
|
||||||
@@ -158,7 +164,7 @@ import {
|
|||||||
SetUp, Reading, Cpu, DataLine, Grid, Connection,
|
SetUp, Reading, Cpu, DataLine, Grid, Connection,
|
||||||
Ticket, Wallet, Key, Tools, Star, House,
|
Ticket, Wallet, Key, Tools, Star, House,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import { customPagesApi, type CustomPageAccessMode, type CustomPageData } from '@/api'
|
import { customPagesApi, upstreamsApi, type CustomPageAccessMode, type CustomPageData, type UpstreamData } from '@/api'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -182,6 +188,7 @@ const iconMap: Record<string, any> = {
|
|||||||
|
|
||||||
// ---- state ----
|
// ---- state ----
|
||||||
const list = ref<CustomPageData[]>([])
|
const list = ref<CustomPageData[]>([])
|
||||||
|
const upstreamList = ref<UpstreamData[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
@@ -206,6 +213,7 @@ type PageFormState = {
|
|||||||
login_autofill_enabled: boolean
|
login_autofill_enabled: boolean
|
||||||
login_password_configured: boolean
|
login_password_configured: boolean
|
||||||
login_password_clear: boolean
|
login_password_clear: boolean
|
||||||
|
linked_upstream_id: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultForm = (): PageFormState => ({
|
const defaultForm = (): PageFormState => ({
|
||||||
@@ -225,6 +233,7 @@ const defaultForm = (): PageFormState => ({
|
|||||||
login_autofill_enabled: false,
|
login_autofill_enabled: false,
|
||||||
login_password_configured: false,
|
login_password_configured: false,
|
||||||
login_password_clear: false,
|
login_password_clear: false,
|
||||||
|
linked_upstream_id: null,
|
||||||
})
|
})
|
||||||
const form = ref(defaultForm())
|
const form = ref(defaultForm())
|
||||||
const rules = {
|
const rules = {
|
||||||
@@ -235,8 +244,9 @@ const rules = {
|
|||||||
async function loadList() {
|
async function loadList() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await customPagesApi.list()
|
const [pagesRes, upstreamsRes] = await Promise.all([customPagesApi.list(), upstreamsApi.list()])
|
||||||
list.value = res.data
|
list.value = pagesRes.data
|
||||||
|
upstreamList.value = upstreamsRes.data
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -269,6 +279,7 @@ function openEdit(page: CustomPageData) {
|
|||||||
login_autofill_enabled: page.login_autofill_enabled,
|
login_autofill_enabled: page.login_autofill_enabled,
|
||||||
login_password_configured: page.login_password_configured,
|
login_password_configured: page.login_password_configured,
|
||||||
login_password_clear: false,
|
login_password_clear: false,
|
||||||
|
linked_upstream_id: page.linked_upstream_id ?? null,
|
||||||
}
|
}
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,11 @@
|
|||||||
<el-icon><Right /></el-icon>
|
<el-icon><Right /></el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
|
<el-tooltip v-if="canRefreshAuth" content="一键刷新上游凭证">
|
||||||
|
<el-button size="small" text type="warning" :loading="refreshingAuth" @click="refreshAuth">
|
||||||
|
<el-icon><Key /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</el-tooltip>
|
||||||
<el-tooltip content="在新标签页打开">
|
<el-tooltip content="在新标签页打开">
|
||||||
<el-button size="small" text @click="openExternal">
|
<el-button size="small" text @click="openExternal">
|
||||||
<el-icon><TopRight /></el-icon>
|
<el-icon><TopRight /></el-icon>
|
||||||
@@ -209,6 +214,8 @@ type RemoteBrowserErrorState = {
|
|||||||
|
|
||||||
const pageIcon = computed(() => iconMap[page.value?.icon || 'Link'] || LinkIcon)
|
const pageIcon = computed(() => iconMap[page.value?.icon || 'Link'] || LinkIcon)
|
||||||
const isRemoteBrowser = computed(() => page.value?.access_mode === 'remote_browser')
|
const isRemoteBrowser = computed(() => page.value?.access_mode === 'remote_browser')
|
||||||
|
const canRefreshAuth = computed(() => isRemoteBrowser.value && page.value?.linked_upstream_id && remoteSession.value)
|
||||||
|
const refreshingAuth = ref(false)
|
||||||
const effectivePageId = computed(() => props.pageId ?? Number(route.params.id))
|
const effectivePageId = computed(() => props.pageId ?? Number(route.params.id))
|
||||||
const embedded = computed(() => props.embedded)
|
const embedded = computed(() => props.embedded)
|
||||||
const showRemoteError = computed(() => Boolean(remoteErrorState.value) && !isStartingRemoteBrowser.value)
|
const showRemoteError = computed(() => Boolean(remoteErrorState.value) && !isStartingRemoteBrowser.value)
|
||||||
@@ -430,6 +437,23 @@ async function copyRemoteSelection() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshAuth() {
|
||||||
|
if (!page.value) return
|
||||||
|
refreshingAuth.value = true
|
||||||
|
try {
|
||||||
|
const res = await customPagesApi.refreshAuth(page.value.id)
|
||||||
|
if (res.data.success) {
|
||||||
|
ElMessage.success(res.data.message || '凭证已刷新')
|
||||||
|
} else {
|
||||||
|
ElMessage.warning(res.data.message || '刷新失败')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e.response?.data?.detail || '刷新凭证失败')
|
||||||
|
} finally {
|
||||||
|
refreshingAuth.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function remoteViewport() {
|
function remoteViewport() {
|
||||||
const rect = remoteFrameRef.value?.getBoundingClientRect()
|
const rect = remoteFrameRef.value?.getBoundingClientRect()
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -270,8 +270,8 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="form.auth_type === 'login_password'">
|
<template v-else-if="form.auth_type === 'login_password'">
|
||||||
<el-form-item label="登录邮箱">
|
<el-form-item :label="form.auth_config.username_field === 'username' ? '登录账号' : '登录邮箱'">
|
||||||
<el-input v-model="form.auth_config.email" placeholder="admin@example.com" />
|
<el-input v-model="form.auth_config.email" :placeholder="form.auth_config.username_field === 'username' ? 'admin' : 'admin@example.com'" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="登录密码">
|
<el-form-item label="登录密码">
|
||||||
<el-input v-model="form.auth_config.password" type="password" show-password placeholder="***" />
|
<el-input v-model="form.auth_config.password" type="password" show-password placeholder="***" />
|
||||||
@@ -409,7 +409,7 @@
|
|||||||
|
|
||||||
<AuthCaptureDialog
|
<AuthCaptureDialog
|
||||||
v-model="authCaptureVisible"
|
v-model="authCaptureVisible"
|
||||||
:initial-url="form.base_url"
|
:initial-url="authCaptureInitialUrl"
|
||||||
@select="handleAuthCaptureSelect"
|
@select="handleAuthCaptureSelect"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -452,11 +452,17 @@ const rules = {
|
|||||||
|
|
||||||
const authCaptureVisible = ref(false)
|
const authCaptureVisible = ref(false)
|
||||||
|
|
||||||
|
const authCaptureInitialUrl = computed(() => {
|
||||||
|
const base = (form.value.base_url || '').replace(/\/+$/, '')
|
||||||
|
if (!base) return ''
|
||||||
|
return base + '/login'
|
||||||
|
})
|
||||||
|
|
||||||
function openAuthCapture() {
|
function openAuthCapture() {
|
||||||
authCaptureVisible.value = true
|
authCaptureVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAuthCaptureSelect(candidate: { type: string; value: string; cookie_name?: string; cookie_value?: string }) {
|
function handleAuthCaptureSelect(candidate: { type: string; value: string; cookie_name?: string; cookie_value?: string; new_api_user?: string }) {
|
||||||
if (candidate.type === 'bearer_token') {
|
if (candidate.type === 'bearer_token') {
|
||||||
form.value.auth_type = 'bearer'
|
form.value.auth_type = 'bearer'
|
||||||
form.value.auth_config.token = candidate.value
|
form.value.auth_config.token = candidate.value
|
||||||
@@ -466,6 +472,17 @@ function handleAuthCaptureSelect(candidate: { type: string; value: string; cooki
|
|||||||
form.value.auth_config.cookie_string = candidate.cookie_name && candidate.cookie_value
|
form.value.auth_config.cookie_string = candidate.cookie_name && candidate.cookie_value
|
||||||
? `${candidate.cookie_name}=${candidate.cookie_value}`
|
? `${candidate.cookie_name}=${candidate.cookie_value}`
|
||||||
: candidate.value
|
: candidate.value
|
||||||
|
if (candidate.new_api_user) {
|
||||||
|
form.value.auth_config.new_api_user = candidate.new_api_user
|
||||||
|
form.value.api_prefix = ''
|
||||||
|
form.value.groups_endpoint = '/api/user/self/groups'
|
||||||
|
form.value.rate_endpoint = '/api/user/self/groups'
|
||||||
|
} else if (quickPlatform.value === 'new-api-user') {
|
||||||
|
form.value.api_prefix = ''
|
||||||
|
form.value.groups_endpoint = '/api/user/self/groups'
|
||||||
|
form.value.rate_endpoint = '/api/user/self/groups'
|
||||||
|
ElMessage.warning('已填入 Cookie,但未提取到 New-Api-User,请重新登录后再提取')
|
||||||
|
}
|
||||||
ElMessage.success('已填入 Cookie')
|
ElMessage.success('已填入 Cookie')
|
||||||
} else if (candidate.type === 'api_key') {
|
} else if (candidate.type === 'api_key') {
|
||||||
form.value.auth_type = 'api_key'
|
form.value.auth_type = 'api_key'
|
||||||
@@ -495,6 +512,7 @@ function handlePlatformChange(val: string) {
|
|||||||
form.value.rate_endpoint = '/groups/rates'
|
form.value.rate_endpoint = '/groups/rates'
|
||||||
form.value.auth_type = 'login_password'
|
form.value.auth_type = 'login_password'
|
||||||
form.value.auth_config.login_path = '/auth/login'
|
form.value.auth_config.login_path = '/auth/login'
|
||||||
|
form.value.auth_config.username_field = 'email'
|
||||||
} else if (val === 'new-api') {
|
} else if (val === 'new-api') {
|
||||||
form.value.api_prefix = ''
|
form.value.api_prefix = ''
|
||||||
form.value.groups_endpoint = '/api/group/'
|
form.value.groups_endpoint = '/api/group/'
|
||||||
@@ -506,6 +524,7 @@ function handlePlatformChange(val: string) {
|
|||||||
form.value.rate_endpoint = '/api/user/self/groups'
|
form.value.rate_endpoint = '/api/user/self/groups'
|
||||||
form.value.auth_type = 'login_password'
|
form.value.auth_type = 'login_password'
|
||||||
form.value.auth_config.login_path = '/api/user/login'
|
form.value.auth_config.login_path = '/api/user/login'
|
||||||
|
form.value.auth_config.username_field = 'username'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user