Fix deploy.sh CRLF line endings for Linux compatibility
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+109
-109
@@ -1,109 +1,109 @@
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
|
||||
DEFAULT_USERNAME = "admin"
|
||||
DEFAULT_PASSWORD = "admin"
|
||||
TOKEN_MAX_AGE = 60 * 60 * 24 * 7 # 7 days
|
||||
|
||||
|
||||
class AuthManager:
|
||||
def __init__(self, data_dir: Path) -> None:
|
||||
self.data_dir = data_dir
|
||||
self.auth_file = data_dir / "auth.json"
|
||||
self.secret_file = data_dir / "secret.key"
|
||||
self._serializer: Optional[URLSafeTimedSerializer] = None
|
||||
self._ensure_data_dir()
|
||||
self._ensure_secret()
|
||||
self._ensure_default_user()
|
||||
|
||||
def _ensure_data_dir(self) -> None:
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _ensure_secret(self) -> None:
|
||||
if not self.secret_file.exists():
|
||||
self.secret_file.write_text(secrets.token_hex(32), encoding="utf-8")
|
||||
secret = self.secret_file.read_text(encoding="utf-8").strip()
|
||||
self._serializer = URLSafeTimedSerializer(secret, salt="cloud-browser-auth")
|
||||
|
||||
def _ensure_default_user(self) -> None:
|
||||
if not self.auth_file.exists():
|
||||
self._save_credentials(DEFAULT_USERNAME, DEFAULT_PASSWORD)
|
||||
|
||||
def _hash_password(self, password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
def _verify_password(self, password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def _load_credentials(self) -> dict:
|
||||
return json.loads(self.auth_file.read_text(encoding="utf-8"))
|
||||
|
||||
def _save_credentials(self, username: str, password: str) -> None:
|
||||
payload = {
|
||||
"username": username,
|
||||
"password_hash": self._hash_password(password),
|
||||
}
|
||||
self.auth_file.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def get_username(self) -> str:
|
||||
return self._load_credentials()["username"]
|
||||
|
||||
def authenticate(self, username: str, password: str) -> bool:
|
||||
creds = self._load_credentials()
|
||||
if username != creds["username"]:
|
||||
return False
|
||||
return self._verify_password(password, creds["password_hash"])
|
||||
|
||||
def create_token(self, username: str) -> str:
|
||||
assert self._serializer is not None
|
||||
return self._serializer.dumps({"username": username, "ts": time.time()})
|
||||
|
||||
def verify_token(self, token: str) -> Optional[str]:
|
||||
if not token or self._serializer is None:
|
||||
return None
|
||||
try:
|
||||
data = self._serializer.loads(token, max_age=TOKEN_MAX_AGE)
|
||||
except (BadSignature, SignatureExpired):
|
||||
return None
|
||||
username = data.get("username")
|
||||
if username != self.get_username():
|
||||
return None
|
||||
return username
|
||||
|
||||
def change_credentials(
|
||||
self,
|
||||
current_username: str,
|
||||
current_password: str,
|
||||
new_username: str,
|
||||
new_password: str,
|
||||
) -> None:
|
||||
creds = self._load_credentials()
|
||||
if current_username != creds["username"]:
|
||||
raise ValueError("当前用户名不正确")
|
||||
if not self._verify_password(current_password, creds["password_hash"]):
|
||||
raise ValueError("当前密码不正确")
|
||||
if len(new_username.strip()) < 2:
|
||||
raise ValueError("新用户名至少 2 个字符")
|
||||
if len(new_password) < 4:
|
||||
raise ValueError("新密码至少 4 个字符")
|
||||
self._save_credentials(new_username.strip(), new_password)
|
||||
|
||||
|
||||
def get_data_dir() -> Path:
|
||||
return Path(os.getenv("DATA_DIR", "/app/data"))
|
||||
|
||||
|
||||
auth_manager = AuthManager(get_data_dir())
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
|
||||
DEFAULT_USERNAME = "admin"
|
||||
DEFAULT_PASSWORD = "admin"
|
||||
TOKEN_MAX_AGE = 60 * 60 * 24 * 7 # 7 days
|
||||
|
||||
|
||||
class AuthManager:
|
||||
def __init__(self, data_dir: Path) -> None:
|
||||
self.data_dir = data_dir
|
||||
self.auth_file = data_dir / "auth.json"
|
||||
self.secret_file = data_dir / "secret.key"
|
||||
self._serializer: Optional[URLSafeTimedSerializer] = None
|
||||
self._ensure_data_dir()
|
||||
self._ensure_secret()
|
||||
self._ensure_default_user()
|
||||
|
||||
def _ensure_data_dir(self) -> None:
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _ensure_secret(self) -> None:
|
||||
if not self.secret_file.exists():
|
||||
self.secret_file.write_text(secrets.token_hex(32), encoding="utf-8")
|
||||
secret = self.secret_file.read_text(encoding="utf-8").strip()
|
||||
self._serializer = URLSafeTimedSerializer(secret, salt="cloud-browser-auth")
|
||||
|
||||
def _ensure_default_user(self) -> None:
|
||||
if not self.auth_file.exists():
|
||||
self._save_credentials(DEFAULT_USERNAME, DEFAULT_PASSWORD)
|
||||
|
||||
def _hash_password(self, password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
def _verify_password(self, password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def _load_credentials(self) -> dict:
|
||||
return json.loads(self.auth_file.read_text(encoding="utf-8"))
|
||||
|
||||
def _save_credentials(self, username: str, password: str) -> None:
|
||||
payload = {
|
||||
"username": username,
|
||||
"password_hash": self._hash_password(password),
|
||||
}
|
||||
self.auth_file.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def get_username(self) -> str:
|
||||
return self._load_credentials()["username"]
|
||||
|
||||
def authenticate(self, username: str, password: str) -> bool:
|
||||
creds = self._load_credentials()
|
||||
if username != creds["username"]:
|
||||
return False
|
||||
return self._verify_password(password, creds["password_hash"])
|
||||
|
||||
def create_token(self, username: str) -> str:
|
||||
assert self._serializer is not None
|
||||
return self._serializer.dumps({"username": username, "ts": time.time()})
|
||||
|
||||
def verify_token(self, token: str) -> Optional[str]:
|
||||
if not token or self._serializer is None:
|
||||
return None
|
||||
try:
|
||||
data = self._serializer.loads(token, max_age=TOKEN_MAX_AGE)
|
||||
except (BadSignature, SignatureExpired):
|
||||
return None
|
||||
username = data.get("username")
|
||||
if username != self.get_username():
|
||||
return None
|
||||
return username
|
||||
|
||||
def change_credentials(
|
||||
self,
|
||||
current_username: str,
|
||||
current_password: str,
|
||||
new_username: str,
|
||||
new_password: str,
|
||||
) -> None:
|
||||
creds = self._load_credentials()
|
||||
if current_username != creds["username"]:
|
||||
raise ValueError("当前用户名不正确")
|
||||
if not self._verify_password(current_password, creds["password_hash"]):
|
||||
raise ValueError("当前密码不正确")
|
||||
if len(new_username.strip()) < 2:
|
||||
raise ValueError("新用户名至少 2 个字符")
|
||||
if len(new_password) < 4:
|
||||
raise ValueError("新密码至少 4 个字符")
|
||||
self._save_credentials(new_username.strip(), new_password)
|
||||
|
||||
|
||||
def get_data_dir() -> Path:
|
||||
return Path(os.getenv("DATA_DIR", "/app/data"))
|
||||
|
||||
|
||||
auth_manager = AuthManager(get_data_dir())
|
||||
|
||||
+267
-267
@@ -1,267 +1,267 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
from playwright.async_api import Browser, BrowserContext, Page, Playwright, async_playwright
|
||||
|
||||
from app.security import get_idle_timeout, get_screencast_quality, get_viewport_size
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrowserSession:
|
||||
session_id: str
|
||||
url: str
|
||||
playwright: Playwright
|
||||
browser: Browser
|
||||
context: BrowserContext
|
||||
page: Page
|
||||
cdp: Any
|
||||
created_at: float = field(default_factory=time.time)
|
||||
last_activity: float = field(default_factory=time.time)
|
||||
subscribers: set[asyncio.Queue] = field(default_factory=set)
|
||||
screencast_task: Optional[asyncio.Task] = None
|
||||
idle_task: Optional[asyncio.Task] = None
|
||||
closed: bool = False
|
||||
viewport_width: int = 1280
|
||||
viewport_height: int = 720
|
||||
|
||||
|
||||
class BrowserManager:
|
||||
def __init__(self, max_sessions: int = 1) -> None:
|
||||
self.max_sessions = max_sessions
|
||||
self._sessions: dict[str, BrowserSession] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def create_session(self, url: str) -> BrowserSession:
|
||||
async with self._lock:
|
||||
if len(self._sessions) >= self.max_sessions:
|
||||
raise RuntimeError(
|
||||
f"已达最大会话数 ({self.max_sessions}),请先关闭现有会话"
|
||||
)
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
width, height = get_viewport_size()
|
||||
quality = get_screencast_quality()
|
||||
|
||||
playwright = await async_playwright().start()
|
||||
browser = await playwright.chromium.launch(
|
||||
headless=True,
|
||||
args=[
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
],
|
||||
)
|
||||
context = await browser.new_context(
|
||||
viewport={"width": width, "height": height},
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/131.0.0.0 Safari/537.36"
|
||||
),
|
||||
)
|
||||
page = await context.new_page()
|
||||
cdp = await context.new_cdp_session(page)
|
||||
|
||||
session = BrowserSession(
|
||||
session_id=session_id,
|
||||
url=url,
|
||||
playwright=playwright,
|
||||
browser=browser,
|
||||
context=context,
|
||||
page=page,
|
||||
cdp=cdp,
|
||||
viewport_width=width,
|
||||
viewport_height=height,
|
||||
)
|
||||
|
||||
await page.goto(url, wait_until="domcontentloaded", timeout=60000)
|
||||
session.url = page.url
|
||||
|
||||
page.on(
|
||||
"framenavigated",
|
||||
lambda frame: asyncio.create_task(
|
||||
self._on_frame_navigated(session, frame)
|
||||
),
|
||||
)
|
||||
|
||||
session.screencast_task = asyncio.create_task(
|
||||
self._run_screencast(session, quality)
|
||||
)
|
||||
session.idle_task = asyncio.create_task(self._watch_idle(session))
|
||||
|
||||
self._sessions[session_id] = session
|
||||
return session
|
||||
|
||||
def session_count(self) -> int:
|
||||
return len(self._sessions)
|
||||
|
||||
async def get_session(self, session_id: str) -> Optional[BrowserSession]:
|
||||
return self._sessions.get(session_id)
|
||||
|
||||
async def close_session(self, session_id: str) -> None:
|
||||
async with self._lock:
|
||||
session = self._sessions.pop(session_id, None)
|
||||
if session:
|
||||
await self._cleanup_session(session)
|
||||
|
||||
async def close_all(self) -> None:
|
||||
async with self._lock:
|
||||
session_ids = list(self._sessions.keys())
|
||||
for session_id in session_ids:
|
||||
await self.close_session(session_id)
|
||||
|
||||
def touch(self, session: BrowserSession) -> None:
|
||||
session.last_activity = time.time()
|
||||
|
||||
async def navigate(self, session: BrowserSession, url: str) -> str:
|
||||
self.touch(session)
|
||||
await session.page.goto(url, wait_until="domcontentloaded", timeout=60000)
|
||||
session.url = session.page.url
|
||||
return session.url
|
||||
|
||||
async def go_back(self, session: BrowserSession) -> str:
|
||||
self.touch(session)
|
||||
await session.page.go_back(wait_until="domcontentloaded", timeout=30000)
|
||||
session.url = session.page.url
|
||||
return session.url
|
||||
|
||||
async def go_forward(self, session: BrowserSession) -> str:
|
||||
self.touch(session)
|
||||
await session.page.go_forward(wait_until="domcontentloaded", timeout=30000)
|
||||
session.url = session.page.url
|
||||
return session.url
|
||||
|
||||
async def reload(self, session: BrowserSession) -> str:
|
||||
self.touch(session)
|
||||
await session.page.reload(wait_until="domcontentloaded", timeout=60000)
|
||||
session.url = session.page.url
|
||||
return session.url
|
||||
|
||||
def subscribe(self, session: BrowserSession) -> asyncio.Queue:
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=8)
|
||||
session.subscribers.add(queue)
|
||||
return queue
|
||||
|
||||
def unsubscribe(self, session: BrowserSession, queue: asyncio.Queue) -> None:
|
||||
session.subscribers.discard(queue)
|
||||
|
||||
async def _broadcast(self, session: BrowserSession, message: dict) -> None:
|
||||
dead: list[asyncio.Queue] = []
|
||||
for queue in session.subscribers:
|
||||
try:
|
||||
queue.put_nowait(message)
|
||||
except asyncio.QueueFull:
|
||||
try:
|
||||
queue.get_nowait()
|
||||
queue.put_nowait(message)
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
except Exception:
|
||||
dead.append(queue)
|
||||
for queue in dead:
|
||||
session.subscribers.discard(queue)
|
||||
|
||||
async def _on_frame_navigated(self, session: BrowserSession, frame) -> None:
|
||||
if session.closed or frame != session.page.main_frame:
|
||||
return
|
||||
session.url = session.page.url
|
||||
await self._broadcast(session, {"type": "url_update", "url": session.url})
|
||||
|
||||
async def _run_screencast(self, session: BrowserSession, quality: int) -> None:
|
||||
async def on_screencast_frame(params: dict) -> None:
|
||||
if session.closed:
|
||||
return
|
||||
data = params.get("data", "")
|
||||
session_id = params.get("sessionId")
|
||||
try:
|
||||
await session.cdp.send(
|
||||
"Page.screencastFrameAck", {"sessionId": session_id}
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
frame_bytes = base64.b64decode(data)
|
||||
except Exception:
|
||||
return
|
||||
await self._broadcast(
|
||||
session,
|
||||
{
|
||||
"type": "frame",
|
||||
"data": frame_bytes,
|
||||
"url": session.url,
|
||||
"width": session.viewport_width,
|
||||
"height": session.viewport_height,
|
||||
},
|
||||
)
|
||||
|
||||
def schedule_frame(params: dict) -> None:
|
||||
asyncio.create_task(on_screencast_frame(params))
|
||||
|
||||
session.cdp.on("Page.screencastFrame", schedule_frame)
|
||||
|
||||
await session.cdp.send(
|
||||
"Page.startScreencast",
|
||||
{
|
||||
"format": "jpeg",
|
||||
"quality": quality,
|
||||
"maxWidth": session.viewport_width,
|
||||
"maxHeight": session.viewport_height,
|
||||
"everyNthFrame": 1,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
while not session.closed:
|
||||
await asyncio.sleep(1)
|
||||
finally:
|
||||
try:
|
||||
await session.cdp.send("Page.stopScreencast")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _watch_idle(self, session: BrowserSession) -> None:
|
||||
timeout = get_idle_timeout()
|
||||
try:
|
||||
while not session.closed:
|
||||
await asyncio.sleep(30)
|
||||
if time.time() - session.last_activity > timeout:
|
||||
await self.close_session(session.session_id)
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def _cleanup_session(self, session: BrowserSession) -> None:
|
||||
if session.closed:
|
||||
return
|
||||
session.closed = True
|
||||
|
||||
for task in (session.screencast_task, session.idle_task):
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
await self._broadcast(session, {"type": "closed", "reason": "session_ended"})
|
||||
|
||||
try:
|
||||
await session.context.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await session.browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await session.playwright.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
browser_manager = BrowserManager()
|
||||
import asyncio
|
||||
import base64
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
from playwright.async_api import Browser, BrowserContext, Page, Playwright, async_playwright
|
||||
|
||||
from app.security import get_idle_timeout, get_screencast_quality, get_viewport_size
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrowserSession:
|
||||
session_id: str
|
||||
url: str
|
||||
playwright: Playwright
|
||||
browser: Browser
|
||||
context: BrowserContext
|
||||
page: Page
|
||||
cdp: Any
|
||||
created_at: float = field(default_factory=time.time)
|
||||
last_activity: float = field(default_factory=time.time)
|
||||
subscribers: set[asyncio.Queue] = field(default_factory=set)
|
||||
screencast_task: Optional[asyncio.Task] = None
|
||||
idle_task: Optional[asyncio.Task] = None
|
||||
closed: bool = False
|
||||
viewport_width: int = 1280
|
||||
viewport_height: int = 720
|
||||
|
||||
|
||||
class BrowserManager:
|
||||
def __init__(self, max_sessions: int = 1) -> None:
|
||||
self.max_sessions = max_sessions
|
||||
self._sessions: dict[str, BrowserSession] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def create_session(self, url: str) -> BrowserSession:
|
||||
async with self._lock:
|
||||
if len(self._sessions) >= self.max_sessions:
|
||||
raise RuntimeError(
|
||||
f"已达最大会话数 ({self.max_sessions}),请先关闭现有会话"
|
||||
)
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
width, height = get_viewport_size()
|
||||
quality = get_screencast_quality()
|
||||
|
||||
playwright = await async_playwright().start()
|
||||
browser = await playwright.chromium.launch(
|
||||
headless=True,
|
||||
args=[
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
],
|
||||
)
|
||||
context = await browser.new_context(
|
||||
viewport={"width": width, "height": height},
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/131.0.0.0 Safari/537.36"
|
||||
),
|
||||
)
|
||||
page = await context.new_page()
|
||||
cdp = await context.new_cdp_session(page)
|
||||
|
||||
session = BrowserSession(
|
||||
session_id=session_id,
|
||||
url=url,
|
||||
playwright=playwright,
|
||||
browser=browser,
|
||||
context=context,
|
||||
page=page,
|
||||
cdp=cdp,
|
||||
viewport_width=width,
|
||||
viewport_height=height,
|
||||
)
|
||||
|
||||
await page.goto(url, wait_until="domcontentloaded", timeout=60000)
|
||||
session.url = page.url
|
||||
|
||||
page.on(
|
||||
"framenavigated",
|
||||
lambda frame: asyncio.create_task(
|
||||
self._on_frame_navigated(session, frame)
|
||||
),
|
||||
)
|
||||
|
||||
session.screencast_task = asyncio.create_task(
|
||||
self._run_screencast(session, quality)
|
||||
)
|
||||
session.idle_task = asyncio.create_task(self._watch_idle(session))
|
||||
|
||||
self._sessions[session_id] = session
|
||||
return session
|
||||
|
||||
def session_count(self) -> int:
|
||||
return len(self._sessions)
|
||||
|
||||
async def get_session(self, session_id: str) -> Optional[BrowserSession]:
|
||||
return self._sessions.get(session_id)
|
||||
|
||||
async def close_session(self, session_id: str) -> None:
|
||||
async with self._lock:
|
||||
session = self._sessions.pop(session_id, None)
|
||||
if session:
|
||||
await self._cleanup_session(session)
|
||||
|
||||
async def close_all(self) -> None:
|
||||
async with self._lock:
|
||||
session_ids = list(self._sessions.keys())
|
||||
for session_id in session_ids:
|
||||
await self.close_session(session_id)
|
||||
|
||||
def touch(self, session: BrowserSession) -> None:
|
||||
session.last_activity = time.time()
|
||||
|
||||
async def navigate(self, session: BrowserSession, url: str) -> str:
|
||||
self.touch(session)
|
||||
await session.page.goto(url, wait_until="domcontentloaded", timeout=60000)
|
||||
session.url = session.page.url
|
||||
return session.url
|
||||
|
||||
async def go_back(self, session: BrowserSession) -> str:
|
||||
self.touch(session)
|
||||
await session.page.go_back(wait_until="domcontentloaded", timeout=30000)
|
||||
session.url = session.page.url
|
||||
return session.url
|
||||
|
||||
async def go_forward(self, session: BrowserSession) -> str:
|
||||
self.touch(session)
|
||||
await session.page.go_forward(wait_until="domcontentloaded", timeout=30000)
|
||||
session.url = session.page.url
|
||||
return session.url
|
||||
|
||||
async def reload(self, session: BrowserSession) -> str:
|
||||
self.touch(session)
|
||||
await session.page.reload(wait_until="domcontentloaded", timeout=60000)
|
||||
session.url = session.page.url
|
||||
return session.url
|
||||
|
||||
def subscribe(self, session: BrowserSession) -> asyncio.Queue:
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=8)
|
||||
session.subscribers.add(queue)
|
||||
return queue
|
||||
|
||||
def unsubscribe(self, session: BrowserSession, queue: asyncio.Queue) -> None:
|
||||
session.subscribers.discard(queue)
|
||||
|
||||
async def _broadcast(self, session: BrowserSession, message: dict) -> None:
|
||||
dead: list[asyncio.Queue] = []
|
||||
for queue in session.subscribers:
|
||||
try:
|
||||
queue.put_nowait(message)
|
||||
except asyncio.QueueFull:
|
||||
try:
|
||||
queue.get_nowait()
|
||||
queue.put_nowait(message)
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
except Exception:
|
||||
dead.append(queue)
|
||||
for queue in dead:
|
||||
session.subscribers.discard(queue)
|
||||
|
||||
async def _on_frame_navigated(self, session: BrowserSession, frame) -> None:
|
||||
if session.closed or frame != session.page.main_frame:
|
||||
return
|
||||
session.url = session.page.url
|
||||
await self._broadcast(session, {"type": "url_update", "url": session.url})
|
||||
|
||||
async def _run_screencast(self, session: BrowserSession, quality: int) -> None:
|
||||
async def on_screencast_frame(params: dict) -> None:
|
||||
if session.closed:
|
||||
return
|
||||
data = params.get("data", "")
|
||||
session_id = params.get("sessionId")
|
||||
try:
|
||||
await session.cdp.send(
|
||||
"Page.screencastFrameAck", {"sessionId": session_id}
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
frame_bytes = base64.b64decode(data)
|
||||
except Exception:
|
||||
return
|
||||
await self._broadcast(
|
||||
session,
|
||||
{
|
||||
"type": "frame",
|
||||
"data": frame_bytes,
|
||||
"url": session.url,
|
||||
"width": session.viewport_width,
|
||||
"height": session.viewport_height,
|
||||
},
|
||||
)
|
||||
|
||||
def schedule_frame(params: dict) -> None:
|
||||
asyncio.create_task(on_screencast_frame(params))
|
||||
|
||||
session.cdp.on("Page.screencastFrame", schedule_frame)
|
||||
|
||||
await session.cdp.send(
|
||||
"Page.startScreencast",
|
||||
{
|
||||
"format": "jpeg",
|
||||
"quality": quality,
|
||||
"maxWidth": session.viewport_width,
|
||||
"maxHeight": session.viewport_height,
|
||||
"everyNthFrame": 1,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
while not session.closed:
|
||||
await asyncio.sleep(1)
|
||||
finally:
|
||||
try:
|
||||
await session.cdp.send("Page.stopScreencast")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _watch_idle(self, session: BrowserSession) -> None:
|
||||
timeout = get_idle_timeout()
|
||||
try:
|
||||
while not session.closed:
|
||||
await asyncio.sleep(30)
|
||||
if time.time() - session.last_activity > timeout:
|
||||
await self.close_session(session.session_id)
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def _cleanup_session(self, session: BrowserSession) -> None:
|
||||
if session.closed:
|
||||
return
|
||||
session.closed = True
|
||||
|
||||
for task in (session.screencast_task, session.idle_task):
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
await self._broadcast(session, {"type": "closed", "reason": "session_ended"})
|
||||
|
||||
try:
|
||||
await session.context.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await session.browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await session.playwright.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
browser_manager = BrowserManager()
|
||||
|
||||
+70
-70
@@ -1,70 +1,70 @@
|
||||
from typing import Any
|
||||
|
||||
from app.browser_manager import BrowserManager, BrowserSession
|
||||
|
||||
|
||||
async def handle_input(
|
||||
manager: BrowserManager,
|
||||
session: BrowserSession,
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
action = payload.get("action")
|
||||
if not action:
|
||||
return None
|
||||
|
||||
manager.touch(session)
|
||||
page = session.page
|
||||
|
||||
if action == "click":
|
||||
x = float(payload.get("x", 0))
|
||||
y = float(payload.get("y", 0))
|
||||
button = payload.get("button", "left")
|
||||
await page.mouse.click(x, y, button=button)
|
||||
return {"type": "ack", "action": action}
|
||||
|
||||
if action == "dblclick":
|
||||
x = float(payload.get("x", 0))
|
||||
y = float(payload.get("y", 0))
|
||||
await page.mouse.dblclick(x, y)
|
||||
return {"type": "ack", "action": action}
|
||||
|
||||
if action == "mousemove":
|
||||
x = float(payload.get("x", 0))
|
||||
y = float(payload.get("y", 0))
|
||||
await page.mouse.move(x, y)
|
||||
return None
|
||||
|
||||
if action == "wheel":
|
||||
delta_x = float(payload.get("deltaX", 0))
|
||||
delta_y = float(payload.get("deltaY", 0))
|
||||
await page.mouse.wheel(delta_x, delta_y)
|
||||
return {"type": "ack", "action": action}
|
||||
|
||||
if action == "keydown":
|
||||
key = payload.get("key")
|
||||
if not key:
|
||||
return None
|
||||
await page.keyboard.down(key)
|
||||
return {"type": "ack", "action": action}
|
||||
|
||||
if action == "keyup":
|
||||
key = payload.get("key")
|
||||
if not key:
|
||||
return None
|
||||
await page.keyboard.up(key)
|
||||
return {"type": "ack", "action": action}
|
||||
|
||||
if action == "type":
|
||||
text = payload.get("text", "")
|
||||
if text:
|
||||
await page.keyboard.type(text)
|
||||
return {"type": "ack", "action": action}
|
||||
|
||||
if action == "press":
|
||||
key = payload.get("key")
|
||||
if not key:
|
||||
return None
|
||||
await page.keyboard.press(key)
|
||||
return {"type": "ack", "action": action}
|
||||
|
||||
return {"type": "error", "message": f"未知操作: {action}"}
|
||||
from typing import Any
|
||||
|
||||
from app.browser_manager import BrowserManager, BrowserSession
|
||||
|
||||
|
||||
async def handle_input(
|
||||
manager: BrowserManager,
|
||||
session: BrowserSession,
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
action = payload.get("action")
|
||||
if not action:
|
||||
return None
|
||||
|
||||
manager.touch(session)
|
||||
page = session.page
|
||||
|
||||
if action == "click":
|
||||
x = float(payload.get("x", 0))
|
||||
y = float(payload.get("y", 0))
|
||||
button = payload.get("button", "left")
|
||||
await page.mouse.click(x, y, button=button)
|
||||
return {"type": "ack", "action": action}
|
||||
|
||||
if action == "dblclick":
|
||||
x = float(payload.get("x", 0))
|
||||
y = float(payload.get("y", 0))
|
||||
await page.mouse.dblclick(x, y)
|
||||
return {"type": "ack", "action": action}
|
||||
|
||||
if action == "mousemove":
|
||||
x = float(payload.get("x", 0))
|
||||
y = float(payload.get("y", 0))
|
||||
await page.mouse.move(x, y)
|
||||
return None
|
||||
|
||||
if action == "wheel":
|
||||
delta_x = float(payload.get("deltaX", 0))
|
||||
delta_y = float(payload.get("deltaY", 0))
|
||||
await page.mouse.wheel(delta_x, delta_y)
|
||||
return {"type": "ack", "action": action}
|
||||
|
||||
if action == "keydown":
|
||||
key = payload.get("key")
|
||||
if not key:
|
||||
return None
|
||||
await page.keyboard.down(key)
|
||||
return {"type": "ack", "action": action}
|
||||
|
||||
if action == "keyup":
|
||||
key = payload.get("key")
|
||||
if not key:
|
||||
return None
|
||||
await page.keyboard.up(key)
|
||||
return {"type": "ack", "action": action}
|
||||
|
||||
if action == "type":
|
||||
text = payload.get("text", "")
|
||||
if text:
|
||||
await page.keyboard.type(text)
|
||||
return {"type": "ack", "action": action}
|
||||
|
||||
if action == "press":
|
||||
key = payload.get("key")
|
||||
if not key:
|
||||
return None
|
||||
await page.keyboard.press(key)
|
||||
return {"type": "ack", "action": action}
|
||||
|
||||
return {"type": "error", "message": f"未知操作: {action}"}
|
||||
|
||||
+306
-306
@@ -1,306 +1,306 @@
|
||||
import asyncio
|
||||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Cookie, Depends, FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.auth import auth_manager
|
||||
from app.browser_manager import browser_manager
|
||||
from app.input_handler import handle_input
|
||||
from app.security import SecurityError, get_max_sessions, validate_url
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
|
||||
SESSION_COOKIE = "cloud_browser_token"
|
||||
|
||||
|
||||
class CreateSessionRequest(BaseModel):
|
||||
url: str = Field(..., min_length=1, max_length=2048)
|
||||
|
||||
|
||||
class CreateSessionResponse(BaseModel):
|
||||
session_id: str
|
||||
url: str
|
||||
|
||||
|
||||
class NavigateRequest(BaseModel):
|
||||
url: str = Field(..., min_length=1, max_length=2048)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str = Field(..., min_length=1, max_length=64)
|
||||
password: str = Field(..., min_length=1, max_length=128)
|
||||
|
||||
|
||||
class ChangeCredentialsRequest(BaseModel):
|
||||
current_username: str = Field(..., min_length=1, max_length=64)
|
||||
current_password: str = Field(..., min_length=1, max_length=128)
|
||||
new_username: str = Field(..., min_length=2, max_length=64)
|
||||
new_password: str = Field(..., min_length=4, max_length=128)
|
||||
|
||||
|
||||
def get_current_user(token: Optional[str] = Cookie(None, alias=SESSION_COOKIE)) -> str:
|
||||
username = auth_manager.verify_token(token or "")
|
||||
if not username:
|
||||
raise HTTPException(status_code=401, detail="未登录或登录已过期")
|
||||
return username
|
||||
|
||||
|
||||
def _set_auth_cookie(response: JSONResponse, token: str) -> JSONResponse:
|
||||
response.set_cookie(
|
||||
key=SESSION_COOKIE,
|
||||
value=token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
max_age=60 * 60 * 24 * 7,
|
||||
path="/",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
browser_manager.max_sessions = get_max_sessions()
|
||||
yield
|
||||
await browser_manager.close_all()
|
||||
|
||||
|
||||
app = FastAPI(title="Cloud Browser", lifespan=lifespan)
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index():
|
||||
return FileResponse(STATIC_DIR / "index.html")
|
||||
|
||||
|
||||
@app.get("/view/{session_id}", response_class=HTMLResponse)
|
||||
async def view_page(session_id: str):
|
||||
session = await browser_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在或已过期")
|
||||
return FileResponse(STATIC_DIR / "viewer.html")
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok", "sessions": browser_manager.session_count}
|
||||
|
||||
|
||||
@app.get("/api/auth/me")
|
||||
async def auth_me(user: str = Depends(get_current_user)):
|
||||
return {"username": user}
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
async def auth_login(body: LoginRequest):
|
||||
if not auth_manager.authenticate(body.username, body.password):
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
token = auth_manager.create_token(body.username)
|
||||
response = JSONResponse({"username": body.username, "message": "登录成功"})
|
||||
return _set_auth_cookie(response, token)
|
||||
|
||||
|
||||
@app.post("/api/auth/logout")
|
||||
async def auth_logout():
|
||||
response = JSONResponse({"message": "已退出登录"})
|
||||
response.delete_cookie(SESSION_COOKIE, path="/")
|
||||
return response
|
||||
|
||||
|
||||
@app.post("/api/auth/change-credentials")
|
||||
async def change_credentials(
|
||||
body: ChangeCredentialsRequest,
|
||||
user: str = Depends(get_current_user),
|
||||
):
|
||||
if body.current_username != user:
|
||||
raise HTTPException(status_code=403, detail="当前用户名与登录账号不一致")
|
||||
try:
|
||||
auth_manager.change_credentials(
|
||||
body.current_username,
|
||||
body.current_password,
|
||||
body.new_username,
|
||||
body.new_password,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
token = auth_manager.create_token(body.new_username)
|
||||
response = JSONResponse({"username": body.new_username, "message": "账号已更新"})
|
||||
return _set_auth_cookie(response, token)
|
||||
|
||||
|
||||
@app.post("/api/session", response_model=CreateSessionResponse)
|
||||
async def create_session(body: CreateSessionRequest, user: str = Depends(get_current_user)):
|
||||
try:
|
||||
url = validate_url(body.url)
|
||||
except SecurityError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
try:
|
||||
session = await browser_manager.create_session(url)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=429, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"创建会话失败: {exc}") from exc
|
||||
|
||||
return CreateSessionResponse(session_id=session.session_id, url=session.url)
|
||||
|
||||
|
||||
@app.delete("/api/session/{session_id}")
|
||||
async def delete_session(session_id: str, user: str = Depends(get_current_user)):
|
||||
session = await browser_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
await browser_manager.close_session(session_id)
|
||||
return {"status": "closed"}
|
||||
|
||||
|
||||
@app.post("/api/session/{session_id}/navigate")
|
||||
async def navigate_session(
|
||||
session_id: str,
|
||||
body: NavigateRequest,
|
||||
user: str = Depends(get_current_user),
|
||||
):
|
||||
session = await browser_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
|
||||
try:
|
||||
url = validate_url(body.url)
|
||||
except SecurityError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
try:
|
||||
current_url = await browser_manager.navigate(session, url)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"导航失败: {exc}") from exc
|
||||
|
||||
return {"url": current_url}
|
||||
|
||||
|
||||
@app.post("/api/session/{session_id}/back")
|
||||
async def go_back(session_id: str, user: str = Depends(get_current_user)):
|
||||
session = await browser_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
try:
|
||||
url = await browser_manager.go_back(session)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"无法后退: {exc}") from exc
|
||||
return {"url": url}
|
||||
|
||||
|
||||
@app.post("/api/session/{session_id}/forward")
|
||||
async def go_forward(session_id: str, user: str = Depends(get_current_user)):
|
||||
session = await browser_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
try:
|
||||
url = await browser_manager.go_forward(session)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"无法前进: {exc}") from exc
|
||||
return {"url": url}
|
||||
|
||||
|
||||
@app.post("/api/session/{session_id}/reload")
|
||||
async def reload_page(session_id: str, user: str = Depends(get_current_user)):
|
||||
session = await browser_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
try:
|
||||
url = await browser_manager.reload(session)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"刷新失败: {exc}") from exc
|
||||
return {"url": url}
|
||||
|
||||
|
||||
@app.websocket("/ws/{session_id}")
|
||||
async def websocket_stream(
|
||||
websocket: WebSocket,
|
||||
session_id: str,
|
||||
cloud_browser_token: Optional[str] = Cookie(None),
|
||||
):
|
||||
if not auth_manager.verify_token(cloud_browser_token or ""):
|
||||
await websocket.close(code=4401, reason="未登录")
|
||||
return
|
||||
|
||||
session = await browser_manager.get_session(session_id)
|
||||
if not session:
|
||||
await websocket.close(code=4404, reason="会话不存在")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
queue = browser_manager.subscribe(session)
|
||||
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "init",
|
||||
"url": session.url,
|
||||
"width": session.viewport_width,
|
||||
"height": session.viewport_height,
|
||||
}
|
||||
)
|
||||
|
||||
async def forward_frames():
|
||||
while not session.closed:
|
||||
try:
|
||||
message = await asyncio.wait_for(queue.get(), timeout=30)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
|
||||
if message.get("type") == "frame":
|
||||
await websocket.send_bytes(message["data"])
|
||||
if message.get("url") and message["url"] != session.url:
|
||||
await websocket.send_json({"type": "url", "url": message["url"]})
|
||||
elif message.get("type") == "closed":
|
||||
await websocket.send_json(message)
|
||||
break
|
||||
|
||||
forward_task = asyncio.create_task(forward_frames())
|
||||
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
await websocket.send_json({"type": "error", "message": "无效 JSON"})
|
||||
continue
|
||||
|
||||
action_type = payload.get("type", "input")
|
||||
if action_type == "ping":
|
||||
browser_manager.touch(session)
|
||||
await websocket.send_json({"type": "pong"})
|
||||
continue
|
||||
|
||||
if action_type == "navigate":
|
||||
try:
|
||||
url = validate_url(payload.get("url", ""))
|
||||
current_url = await browser_manager.navigate(session, url)
|
||||
await websocket.send_json({"type": "url", "url": current_url})
|
||||
except SecurityError as exc:
|
||||
await websocket.send_json({"type": "error", "message": str(exc)})
|
||||
except Exception as exc:
|
||||
await websocket.send_json(
|
||||
{"type": "error", "message": f"导航失败: {exc}"}
|
||||
)
|
||||
continue
|
||||
|
||||
result = await handle_input(browser_manager, session, payload)
|
||||
if result:
|
||||
await websocket.send_json(result)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
forward_task.cancel()
|
||||
try:
|
||||
await forward_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
browser_manager.unsubscribe(session, queue)
|
||||
import asyncio
|
||||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Cookie, Depends, FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.auth import auth_manager
|
||||
from app.browser_manager import browser_manager
|
||||
from app.input_handler import handle_input
|
||||
from app.security import SecurityError, get_max_sessions, validate_url
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
|
||||
SESSION_COOKIE = "cloud_browser_token"
|
||||
|
||||
|
||||
class CreateSessionRequest(BaseModel):
|
||||
url: str = Field(..., min_length=1, max_length=2048)
|
||||
|
||||
|
||||
class CreateSessionResponse(BaseModel):
|
||||
session_id: str
|
||||
url: str
|
||||
|
||||
|
||||
class NavigateRequest(BaseModel):
|
||||
url: str = Field(..., min_length=1, max_length=2048)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str = Field(..., min_length=1, max_length=64)
|
||||
password: str = Field(..., min_length=1, max_length=128)
|
||||
|
||||
|
||||
class ChangeCredentialsRequest(BaseModel):
|
||||
current_username: str = Field(..., min_length=1, max_length=64)
|
||||
current_password: str = Field(..., min_length=1, max_length=128)
|
||||
new_username: str = Field(..., min_length=2, max_length=64)
|
||||
new_password: str = Field(..., min_length=4, max_length=128)
|
||||
|
||||
|
||||
def get_current_user(token: Optional[str] = Cookie(None, alias=SESSION_COOKIE)) -> str:
|
||||
username = auth_manager.verify_token(token or "")
|
||||
if not username:
|
||||
raise HTTPException(status_code=401, detail="未登录或登录已过期")
|
||||
return username
|
||||
|
||||
|
||||
def _set_auth_cookie(response: JSONResponse, token: str) -> JSONResponse:
|
||||
response.set_cookie(
|
||||
key=SESSION_COOKIE,
|
||||
value=token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
max_age=60 * 60 * 24 * 7,
|
||||
path="/",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
browser_manager.max_sessions = get_max_sessions()
|
||||
yield
|
||||
await browser_manager.close_all()
|
||||
|
||||
|
||||
app = FastAPI(title="Cloud Browser", lifespan=lifespan)
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index():
|
||||
return FileResponse(STATIC_DIR / "index.html")
|
||||
|
||||
|
||||
@app.get("/view/{session_id}", response_class=HTMLResponse)
|
||||
async def view_page(session_id: str):
|
||||
session = await browser_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在或已过期")
|
||||
return FileResponse(STATIC_DIR / "viewer.html")
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok", "sessions": browser_manager.session_count}
|
||||
|
||||
|
||||
@app.get("/api/auth/me")
|
||||
async def auth_me(user: str = Depends(get_current_user)):
|
||||
return {"username": user}
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
async def auth_login(body: LoginRequest):
|
||||
if not auth_manager.authenticate(body.username, body.password):
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
token = auth_manager.create_token(body.username)
|
||||
response = JSONResponse({"username": body.username, "message": "登录成功"})
|
||||
return _set_auth_cookie(response, token)
|
||||
|
||||
|
||||
@app.post("/api/auth/logout")
|
||||
async def auth_logout():
|
||||
response = JSONResponse({"message": "已退出登录"})
|
||||
response.delete_cookie(SESSION_COOKIE, path="/")
|
||||
return response
|
||||
|
||||
|
||||
@app.post("/api/auth/change-credentials")
|
||||
async def change_credentials(
|
||||
body: ChangeCredentialsRequest,
|
||||
user: str = Depends(get_current_user),
|
||||
):
|
||||
if body.current_username != user:
|
||||
raise HTTPException(status_code=403, detail="当前用户名与登录账号不一致")
|
||||
try:
|
||||
auth_manager.change_credentials(
|
||||
body.current_username,
|
||||
body.current_password,
|
||||
body.new_username,
|
||||
body.new_password,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
token = auth_manager.create_token(body.new_username)
|
||||
response = JSONResponse({"username": body.new_username, "message": "账号已更新"})
|
||||
return _set_auth_cookie(response, token)
|
||||
|
||||
|
||||
@app.post("/api/session", response_model=CreateSessionResponse)
|
||||
async def create_session(body: CreateSessionRequest, user: str = Depends(get_current_user)):
|
||||
try:
|
||||
url = validate_url(body.url)
|
||||
except SecurityError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
try:
|
||||
session = await browser_manager.create_session(url)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=429, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"创建会话失败: {exc}") from exc
|
||||
|
||||
return CreateSessionResponse(session_id=session.session_id, url=session.url)
|
||||
|
||||
|
||||
@app.delete("/api/session/{session_id}")
|
||||
async def delete_session(session_id: str, user: str = Depends(get_current_user)):
|
||||
session = await browser_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
await browser_manager.close_session(session_id)
|
||||
return {"status": "closed"}
|
||||
|
||||
|
||||
@app.post("/api/session/{session_id}/navigate")
|
||||
async def navigate_session(
|
||||
session_id: str,
|
||||
body: NavigateRequest,
|
||||
user: str = Depends(get_current_user),
|
||||
):
|
||||
session = await browser_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
|
||||
try:
|
||||
url = validate_url(body.url)
|
||||
except SecurityError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
try:
|
||||
current_url = await browser_manager.navigate(session, url)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"导航失败: {exc}") from exc
|
||||
|
||||
return {"url": current_url}
|
||||
|
||||
|
||||
@app.post("/api/session/{session_id}/back")
|
||||
async def go_back(session_id: str, user: str = Depends(get_current_user)):
|
||||
session = await browser_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
try:
|
||||
url = await browser_manager.go_back(session)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"无法后退: {exc}") from exc
|
||||
return {"url": url}
|
||||
|
||||
|
||||
@app.post("/api/session/{session_id}/forward")
|
||||
async def go_forward(session_id: str, user: str = Depends(get_current_user)):
|
||||
session = await browser_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
try:
|
||||
url = await browser_manager.go_forward(session)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"无法前进: {exc}") from exc
|
||||
return {"url": url}
|
||||
|
||||
|
||||
@app.post("/api/session/{session_id}/reload")
|
||||
async def reload_page(session_id: str, user: str = Depends(get_current_user)):
|
||||
session = await browser_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
try:
|
||||
url = await browser_manager.reload(session)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"刷新失败: {exc}") from exc
|
||||
return {"url": url}
|
||||
|
||||
|
||||
@app.websocket("/ws/{session_id}")
|
||||
async def websocket_stream(
|
||||
websocket: WebSocket,
|
||||
session_id: str,
|
||||
cloud_browser_token: Optional[str] = Cookie(None),
|
||||
):
|
||||
if not auth_manager.verify_token(cloud_browser_token or ""):
|
||||
await websocket.close(code=4401, reason="未登录")
|
||||
return
|
||||
|
||||
session = await browser_manager.get_session(session_id)
|
||||
if not session:
|
||||
await websocket.close(code=4404, reason="会话不存在")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
queue = browser_manager.subscribe(session)
|
||||
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "init",
|
||||
"url": session.url,
|
||||
"width": session.viewport_width,
|
||||
"height": session.viewport_height,
|
||||
}
|
||||
)
|
||||
|
||||
async def forward_frames():
|
||||
while not session.closed:
|
||||
try:
|
||||
message = await asyncio.wait_for(queue.get(), timeout=30)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
|
||||
if message.get("type") == "frame":
|
||||
await websocket.send_bytes(message["data"])
|
||||
if message.get("url") and message["url"] != session.url:
|
||||
await websocket.send_json({"type": "url", "url": message["url"]})
|
||||
elif message.get("type") == "closed":
|
||||
await websocket.send_json(message)
|
||||
break
|
||||
|
||||
forward_task = asyncio.create_task(forward_frames())
|
||||
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
await websocket.send_json({"type": "error", "message": "无效 JSON"})
|
||||
continue
|
||||
|
||||
action_type = payload.get("type", "input")
|
||||
if action_type == "ping":
|
||||
browser_manager.touch(session)
|
||||
await websocket.send_json({"type": "pong"})
|
||||
continue
|
||||
|
||||
if action_type == "navigate":
|
||||
try:
|
||||
url = validate_url(payload.get("url", ""))
|
||||
current_url = await browser_manager.navigate(session, url)
|
||||
await websocket.send_json({"type": "url", "url": current_url})
|
||||
except SecurityError as exc:
|
||||
await websocket.send_json({"type": "error", "message": str(exc)})
|
||||
except Exception as exc:
|
||||
await websocket.send_json(
|
||||
{"type": "error", "message": f"导航失败: {exc}"}
|
||||
)
|
||||
continue
|
||||
|
||||
result = await handle_input(browser_manager, session, payload)
|
||||
if result:
|
||||
await websocket.send_json(result)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
forward_task.cancel()
|
||||
try:
|
||||
await forward_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
browser_manager.unsubscribe(session, queue)
|
||||
|
||||
+93
-93
@@ -1,93 +1,93 @@
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
BLOCKED_HOSTNAMES = {
|
||||
"localhost",
|
||||
"localhost.localdomain",
|
||||
"metadata.google.internal",
|
||||
}
|
||||
|
||||
PRIVATE_NETWORKS = [
|
||||
ipaddress.ip_network("0.0.0.0/8"),
|
||||
ipaddress.ip_network("10.0.0.0/8"),
|
||||
ipaddress.ip_network("127.0.0.0/8"),
|
||||
ipaddress.ip_network("169.254.0.0/16"),
|
||||
ipaddress.ip_network("172.16.0.0/12"),
|
||||
ipaddress.ip_network("192.168.0.0/16"),
|
||||
ipaddress.ip_network("::1/128"),
|
||||
ipaddress.ip_network("fc00::/7"),
|
||||
ipaddress.ip_network("fe80::/10"),
|
||||
]
|
||||
|
||||
ALLOWED_SCHEMES = {"http", "https"}
|
||||
|
||||
|
||||
class SecurityError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _normalize_url(raw_url: str) -> str:
|
||||
url = raw_url.strip()
|
||||
if not url:
|
||||
raise SecurityError("URL 不能为空")
|
||||
|
||||
if not re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", url):
|
||||
url = f"https://{url}"
|
||||
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ALLOWED_SCHEMES:
|
||||
raise SecurityError("仅允许 http/https 协议")
|
||||
|
||||
if not parsed.netloc:
|
||||
raise SecurityError("URL 格式无效")
|
||||
|
||||
if parsed.username or parsed.password:
|
||||
raise SecurityError("URL 中不允许包含用户名或密码")
|
||||
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
raise SecurityError("无法解析主机名")
|
||||
|
||||
hostname_lower = hostname.lower()
|
||||
if hostname_lower in BLOCKED_HOSTNAMES:
|
||||
raise SecurityError("不允许访问该主机")
|
||||
|
||||
if hostname_lower.endswith(".local") or hostname_lower.endswith(".internal"):
|
||||
raise SecurityError("不允许访问内网域名")
|
||||
|
||||
try:
|
||||
ip = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
return url
|
||||
|
||||
for network in PRIVATE_NETWORKS:
|
||||
if ip in network:
|
||||
raise SecurityError("不允许访问内网或本地地址")
|
||||
|
||||
return url
|
||||
|
||||
|
||||
def validate_url(raw_url: str) -> str:
|
||||
return _normalize_url(raw_url)
|
||||
|
||||
|
||||
def get_max_sessions() -> int:
|
||||
return max(1, int(os.getenv("MAX_SESSIONS", "1")))
|
||||
|
||||
|
||||
def get_idle_timeout() -> int:
|
||||
return max(60, int(os.getenv("SESSION_IDLE_TIMEOUT", "1800")))
|
||||
|
||||
|
||||
def get_viewport_size() -> tuple[int, int]:
|
||||
width = max(800, int(os.getenv("VIEWPORT_WIDTH", "1280")))
|
||||
height = max(600, int(os.getenv("VIEWPORT_HEIGHT", "720")))
|
||||
return width, height
|
||||
|
||||
|
||||
def get_screencast_quality() -> int:
|
||||
quality = int(os.getenv("SCREENCAST_QUALITY", "80"))
|
||||
return min(100, max(10, quality))
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
BLOCKED_HOSTNAMES = {
|
||||
"localhost",
|
||||
"localhost.localdomain",
|
||||
"metadata.google.internal",
|
||||
}
|
||||
|
||||
PRIVATE_NETWORKS = [
|
||||
ipaddress.ip_network("0.0.0.0/8"),
|
||||
ipaddress.ip_network("10.0.0.0/8"),
|
||||
ipaddress.ip_network("127.0.0.0/8"),
|
||||
ipaddress.ip_network("169.254.0.0/16"),
|
||||
ipaddress.ip_network("172.16.0.0/12"),
|
||||
ipaddress.ip_network("192.168.0.0/16"),
|
||||
ipaddress.ip_network("::1/128"),
|
||||
ipaddress.ip_network("fc00::/7"),
|
||||
ipaddress.ip_network("fe80::/10"),
|
||||
]
|
||||
|
||||
ALLOWED_SCHEMES = {"http", "https"}
|
||||
|
||||
|
||||
class SecurityError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _normalize_url(raw_url: str) -> str:
|
||||
url = raw_url.strip()
|
||||
if not url:
|
||||
raise SecurityError("URL 不能为空")
|
||||
|
||||
if not re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", url):
|
||||
url = f"https://{url}"
|
||||
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ALLOWED_SCHEMES:
|
||||
raise SecurityError("仅允许 http/https 协议")
|
||||
|
||||
if not parsed.netloc:
|
||||
raise SecurityError("URL 格式无效")
|
||||
|
||||
if parsed.username or parsed.password:
|
||||
raise SecurityError("URL 中不允许包含用户名或密码")
|
||||
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
raise SecurityError("无法解析主机名")
|
||||
|
||||
hostname_lower = hostname.lower()
|
||||
if hostname_lower in BLOCKED_HOSTNAMES:
|
||||
raise SecurityError("不允许访问该主机")
|
||||
|
||||
if hostname_lower.endswith(".local") or hostname_lower.endswith(".internal"):
|
||||
raise SecurityError("不允许访问内网域名")
|
||||
|
||||
try:
|
||||
ip = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
return url
|
||||
|
||||
for network in PRIVATE_NETWORKS:
|
||||
if ip in network:
|
||||
raise SecurityError("不允许访问内网或本地地址")
|
||||
|
||||
return url
|
||||
|
||||
|
||||
def validate_url(raw_url: str) -> str:
|
||||
return _normalize_url(raw_url)
|
||||
|
||||
|
||||
def get_max_sessions() -> int:
|
||||
return max(1, int(os.getenv("MAX_SESSIONS", "1")))
|
||||
|
||||
|
||||
def get_idle_timeout() -> int:
|
||||
return max(60, int(os.getenv("SESSION_IDLE_TIMEOUT", "1800")))
|
||||
|
||||
|
||||
def get_viewport_size() -> tuple[int, int]:
|
||||
width = max(800, int(os.getenv("VIEWPORT_WIDTH", "1280")))
|
||||
height = max(600, int(os.getenv("VIEWPORT_HEIGHT", "720")))
|
||||
return width, height
|
||||
|
||||
|
||||
def get_screencast_quality() -> int:
|
||||
quality = int(os.getenv("SCREENCAST_QUALITY", "80"))
|
||||
return min(100, max(10, quality))
|
||||
|
||||
Reference in New Issue
Block a user