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:
@@ -17,6 +17,8 @@ from app.models.admin_user import AdminUser
|
||||
from app.models.custom_page import CustomPage
|
||||
from app.models.upstream import Upstream
|
||||
from app.services.upstream_client import _find_user_id
|
||||
from app.services.auth_capture_service import extract_all
|
||||
from app.services.browser_session_service import browser_sessions
|
||||
from app.utils.auth import decode_token, get_current_user, get_user_from_token_param
|
||||
|
||||
router = APIRouter(prefix="/api/custom-pages", tags=["custom-pages"])
|
||||
@@ -48,6 +50,7 @@ class CustomPageCreate(BaseModel):
|
||||
login_password_selector: Optional[str] = None
|
||||
login_submit_selector: Optional[str] = None
|
||||
login_autofill_enabled: bool = False
|
||||
linked_upstream_id: Optional[int] = None
|
||||
|
||||
|
||||
class CustomPageUpdate(BaseModel):
|
||||
@@ -66,6 +69,7 @@ class CustomPageUpdate(BaseModel):
|
||||
login_submit_selector: Optional[str] = None
|
||||
login_autofill_enabled: Optional[bool] = None
|
||||
login_password_clear: Optional[bool] = None
|
||||
linked_upstream_id: Optional[int] = None
|
||||
|
||||
|
||||
class CustomPageResponse(BaseModel):
|
||||
@@ -84,6 +88,7 @@ class CustomPageResponse(BaseModel):
|
||||
login_submit_selector: Optional[str]
|
||||
login_autofill_enabled: bool
|
||||
login_password_configured: bool
|
||||
linked_upstream_id: Optional[int]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -118,6 +123,7 @@ def _page_response(page: CustomPage) -> CustomPageResponse:
|
||||
login_submit_selector=page.login_submit_selector,
|
||||
login_autofill_enabled=page.login_autofill_enabled,
|
||||
login_password_configured=bool(page.login_password),
|
||||
linked_upstream_id=page.linked_upstream_id,
|
||||
created_at=page.created_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()
|
||||
|
||||
|
||||
# ---- 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) ----
|
||||
|
||||
_STRIP_RESP = {
|
||||
|
||||
Reference in New Issue
Block a user