Files
SmartUp/backend/app/routers/browser_sessions.py
T
SmartUp Developer ad16618406 fix: address multiple code audit findings
- CORS: replace wildcard with explicit origin list from CORS_ORIGINS env
- Auth: enforce strong defaults, JWT blacklist (RevokedToken model), login rate limiting
- Auth: validate password length before bcrypt (72-byte limit)
- Scheduler: single-threaded worker to mitigate SQLite write contention
- Scheduler: graceful shutdown (wait=True)
- Snapshots: add prune_snapshots() with configurable retention count
- Storage: isolate localStorage keys via VITE_APP_KEY prefix
- Config: add cors_origins, login_rate_limit, snapshot_retention_count settings
2026-05-17 10:52:18 +08:00

277 lines
9.3 KiB
Python

"""Remote browser session API."""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
from typing import Any, Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi.responses import Response
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.custom_page import CustomPage
from app.services.browser_session_service import (
BrowserDependencyError,
BrowserSessionError,
browser_sessions,
)
from app.utils.auth import decode_token, get_current_user, get_user_from_token_param
from app.database import SessionLocal
from app.models.admin_user import AdminUser
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/browser-sessions", tags=["browser-sessions"])
class BrowserSessionCreate(BaseModel):
custom_page_id: int
width: int = Field(default=1280)
height: int = Field(default=720)
class BrowserSessionResponse(BaseModel):
id: str
custom_page_id: int
url: str
title: str
class BrowserSelectionResponse(BaseModel):
text: str
class BrowserEvent(BaseModel):
type: Literal["click", "dblclick", "mousemove", "mousedown", "mouseup", "type", "key", "scroll", "reload", "back", "forward", "resize"]
x: Optional[float] = None
y: Optional[float] = None
button: Optional[Literal["left", "right", "middle"]] = "left"
text: Optional[str] = None
key: Optional[str] = None
delta_x: Optional[float] = 0
delta_y: Optional[float] = 0
width: Optional[int] = None
height: Optional[int] = None
def _error_from_browser(exc: Exception) -> HTTPException:
if isinstance(exc, BrowserDependencyError):
return HTTPException(503, str(exc))
if isinstance(exc, BrowserSessionError):
return HTTPException(409, str(exc))
if isinstance(exc, KeyError):
return HTTPException(404, "browser session not found")
if isinstance(exc, ValueError):
return HTTPException(400, str(exc))
return HTTPException(502, f"Browser error: {exc}")
@router.post("", response_model=BrowserSessionResponse, status_code=201)
async def create_session(
body: BrowserSessionCreate,
db: Session = Depends(get_db),
_=Depends(get_current_user),
):
page = db.query(CustomPage).filter(CustomPage.id == body.custom_page_id).first()
if not page or not page.enabled:
raise HTTPException(404, "page not found")
if page.access_mode != "remote_browser":
raise HTTPException(400, "custom page is not configured for remote browser mode")
login_config = {
"enabled": page.login_autofill_enabled,
"username": page.login_username,
"password": page.login_password,
"username_selector": page.login_username_selector,
"password_selector": page.login_password_selector,
"submit_selector": page.login_submit_selector,
}
try:
session = await browser_sessions.create(page.id, page.url, body.width, body.height, login_config)
return await browser_sessions.state(session.id)
except Exception as exc:
raise _error_from_browser(exc)
@router.get("/{session_id}", response_model=BrowserSessionResponse)
async def get_session(session_id: str, _=Depends(get_current_user)):
try:
return await browser_sessions.state(session_id)
except Exception as exc:
raise _error_from_browser(exc)
@router.get("/{session_id}/screenshot")
async def session_screenshot(session_id: str, _=Depends(get_user_from_token_param)):
try:
image = await browser_sessions.screenshot(session_id)
except Exception as exc:
raise _error_from_browser(exc)
return Response(content=image, media_type="image/jpeg", headers={"Cache-Control": "no-store"})
@router.post("/{session_id}/events", response_model=BrowserSessionResponse)
async def send_event(session_id: str, body: BrowserEvent, _=Depends(get_current_user)):
try:
payload: dict[str, Any] = body.model_dump(exclude_none=True)
event_type = payload.pop("type")
return await browser_sessions.event(session_id, event_type, payload)
except Exception as exc:
raise _error_from_browser(exc)
@router.get("/{session_id}/selection", response_model=BrowserSelectionResponse)
async def get_selection(session_id: str, _=Depends(get_current_user)):
try:
return BrowserSelectionResponse(text=await browser_sessions.selected_text(session_id))
except Exception as exc:
raise _error_from_browser(exc)
@router.delete("/{session_id}", status_code=204)
async def close_session(session_id: str, _=Depends(get_current_user)):
await browser_sessions.close(session_id)
# ——— WebSocket stream ———
# Frame interval & diff detection
_WS_MIN_INTERVAL = 0.10
_WS_IDLE_INTERVAL = 0.35
_WS_ACTIVE_INTERVAL = 0.12
_WS_BACKOFF_INTERVAL = 0.60
_WS_DEEP_IDLE_INTERVAL = 1.00
_WS_ACTIVE_WINDOW = 1.25
async def _ws_authenticate(token: Optional[str]) -> bool:
"""Validate JWT token for WebSocket connections."""
if not token:
return False
email = decode_token(token)
if not email:
return False
db = SessionLocal()
try:
user = db.query(AdminUser).filter(AdminUser.email == email).first()
return user is not None
finally:
db.close()
@router.websocket("/{session_id}/ws")
async def session_ws(
websocket: WebSocket,
session_id: str,
token: Optional[str] = Query(default=None),
):
"""WebSocket endpoint: pushes JPEG frames as binary, receives JSON event messages."""
# Authenticate before accepting
if not await _ws_authenticate(token):
await websocket.close(code=4401)
return
await websocket.accept()
# Track when a user event arrived so we can temporarily speed up
last_event_at: float = 0.0
last_frame_hash: str = ""
unchanged_count = 0
# Task: receive events from client
async def receive_loop():
nonlocal last_event_at, unchanged_count
try:
while True:
raw = await websocket.receive_text()
try:
msg = json.loads(raw)
except json.JSONDecodeError:
continue
msg_type = msg.get("type")
if not msg_type:
continue
payload: dict[str, Any] = {k: v for k, v in msg.items() if k != "type"}
try:
await browser_sessions.event(session_id, msg_type, payload, include_state=False)
last_event_at = asyncio.get_event_loop().time()
unchanged_count = 0
except Exception as exc:
logger.warning("ws event error: %s", exc)
try:
await websocket.send_json({"error": str(exc)})
except Exception:
pass
except (WebSocketDisconnect, asyncio.CancelledError):
pass
except Exception as exc:
logger.debug("ws receive_loop ended: %s", exc)
# Task: push screenshots
async def push_loop():
nonlocal last_frame_hash, unchanged_count
try:
while True:
now = asyncio.get_event_loop().time()
if (now - last_event_at) < _WS_ACTIVE_WINDOW:
interval = _WS_ACTIVE_INTERVAL
elif unchanged_count >= 9:
interval = _WS_DEEP_IDLE_INTERVAL
elif unchanged_count >= 3:
interval = _WS_BACKOFF_INTERVAL
else:
interval = _WS_IDLE_INTERVAL
try:
frame = await browser_sessions.screenshot(session_id)
except KeyError:
await websocket.send_json({"error": "session_not_found"})
break
except Exception as exc:
logger.warning("ws screenshot error: %s", exc)
await asyncio.sleep(interval)
continue
frame_hash = hashlib.md5(frame).hexdigest()
if frame_hash != last_frame_hash:
last_frame_hash = frame_hash
unchanged_count = 0
try:
await websocket.send_bytes(frame)
except Exception:
break
else:
unchanged_count += 1
await asyncio.sleep(max(_WS_MIN_INTERVAL, interval))
except (WebSocketDisconnect, asyncio.CancelledError):
pass
except Exception as exc:
logger.debug("ws push_loop ended: %s", exc)
# Send initial metadata so client knows session info
try:
state = await browser_sessions.state(session_id)
await websocket.send_json({"type": "init", "session": state})
except Exception as exc:
await websocket.send_json({"error": f"session error: {exc}"})
await websocket.close()
return
recv_task = asyncio.create_task(receive_loop())
push_task = asyncio.create_task(push_loop())
# Run until one side closes
done, pending = await asyncio.wait(
[recv_task, push_task],
return_when=asyncio.FIRST_COMPLETED,
)
for t in pending:
t.cancel()
try:
await t
except asyncio.CancelledError:
pass