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:
SmartUp Developer
2026-05-19 09:27:14 +08:00
parent 7cb0ff1608
commit 4c71148ff9
13 changed files with 462 additions and 53 deletions
+2
View File
@@ -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"))
+1
View File
@@ -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,
+14 -3
View File
@@ -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)
+82
View File
@@ -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 = {
+71 -3
View File
@@ -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:
+24 -4
View File
@@ -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(
+25
View File
@@ -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
View File
@@ -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) => {
+151 -20
View File
@@ -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]
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', { emit('select', {
type: c.type, type: fullCandidate.type,
value: c.type === 'cookie' ? (c.cookie_value || c.value) : c.value, value: resolvedValue,
source: c.source, source: fullCandidate.source,
cookie_name: c.cookie_name, cookie_name: fullCandidate.cookie_name,
cookie_value: c.cookie_value, cookie_value: fullCandidate.cookie_value,
new_api_user: resolveNewApiUser(rawResult.data, fullCandidate),
}) })
closeDialog() 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;
+14 -3
View File
@@ -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
} }
+24
View File
@@ -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 {
+23 -4
View File
@@ -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'
} }
} }