Fix deploy.sh CRLF line endings for Linux compatibility

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-27 10:59:59 +08:00
parent 65f5caf4d9
commit b9ee546bc1
20 changed files with 2142 additions and 2118 deletions
+9 -9
View File
@@ -1,9 +1,9 @@
# 服务端口(映射到宿主机)
APP_PORT=32450
# 应用配置
MAX_SESSIONS=1
SESSION_IDLE_TIMEOUT=1800
VIEWPORT_WIDTH=1280
VIEWPORT_HEIGHT=720
SCREENCAST_QUALITY=80
# 服务端口(映射到宿主机)
APP_PORT=32450
# 应用配置
MAX_SESSIONS=1
SESSION_IDLE_TIMEOUT=1800
VIEWPORT_WIDTH=1280
VIEWPORT_HEIGHT=720
SCREENCAST_QUALITY=80
+10
View File
@@ -0,0 +1,10 @@
* text=auto
*.sh text eol=lf
Dockerfile text eol=lf
*.yml text eol=lf
*.py text eol=lf
*.md text eol=lf
*.html text eol=lf
*.js text eol=lf
*.css text eol=lf
.env.example text eol=lf
+7 -7
View File
@@ -1,7 +1,7 @@
.env
data/
__pycache__/
*.pyc
.pytest_cache/
.venv/
venv/
.env
data/
__pycache__/
*.pyc
.pytest_cache/
.venv/
venv/
+241 -226
View File
@@ -1,226 +1,241 @@
# 云端浏览器部署文档
本文档说明如何在 Linux 服务器(含宝塔面板环境)上部署云端浏览器。
- 安装目录:`/opt/cloud-browser`
- 运行用户:`root`
- 默认端口:`32450`
- 默认账号:`admin` / `admin`
- 代码仓库:[https://git.bz121.com/dekun/cloud-browser.git](https://git.bz121.com/dekun/cloud-browser.git)
---
## 一、环境要求
| 项目 | 要求 |
|------|------|
| 系统 | Ubuntu 20.04+ / Debian 11+ / CentOS 7+ |
| 内存 | 最低 1GB,推荐 2GB |
| Docker | 20.10+ |
| Docker Compose | v2+ |
| 端口 | 32450(或自定义,需在 `.env` 中修改) |
> 服务器已安装宝塔面板、Docker、Nginx 均可,**无需额外配置 Nginx**,服务直接监听端口,反向代理请自行操作。
---
## 二、一键部署(推荐)
**root** 用户 SSH 登录服务器,执行:
```bash
curl -fsSL https://git.bz121.com/dekun/cloud-browser/raw/branch/main/deploy.sh -o /tmp/deploy.sh
bash /tmp/deploy.sh
```
若仓库尚未推送或 curl 不可用,也可手动克隆后执行:
```bash
git clone https://git.bz121.com/dekun/cloud-browser.git /opt/cloud-browser
bash /opt/cloud-browser/deploy.sh
```
### 一键脚本会自动完成
1. 检测并安装 Docker(如未安装)
2. 克隆/更新代码到 `/opt/cloud-browser`
3. 创建 `.env``data/` 数据目录
4. 构建并启动 Docker 容器
5. 等待健康检查通过
6. 输出访问地址和默认账号
### 部署成功示例输出
```
==========================================
云端浏览器部署完成
==========================================
访问地址: http://1.2.3.4:32450
默认账号: admin
默认密码: admin
安装目录: /opt/cloud-browser
==========================================
```
---
## 三、手动部署
### 1. 克隆代码
```bash
git clone https://git.bz121.com/dekun/cloud-browser.git /opt/cloud-browser
cd /opt/cloud-browser
```
### 2. 配置环境变量(可选)
```bash
cp .env.example .env
nano .env
```
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `APP_PORT` | 宿主机端口 | `32450` |
| `MAX_SESSIONS` | 最大并发会话 | `1` |
| `SESSION_IDLE_TIMEOUT` | 空闲超时(秒) | `1800` |
| `VIEWPORT_WIDTH` | 浏览器宽度 | `1280` |
| `VIEWPORT_HEIGHT` | 浏览器高度 | `720` |
| `SCREENCAST_QUALITY` | 画面质量 10-100 | `80` |
### 3. 启动服务
```bash
mkdir -p data
docker compose up -d --build
```
### 4. 验证
```bash
curl http://127.0.0.1:32450/api/health
# {"status":"ok","sessions":0}
```
浏览器访问 `http://服务器IP:32450`,使用 `admin` / `admin` 登录。
---
## 四、登录与修改密码
1. 打开首页,输入默认账号 `admin`、密码 `admin` 登录
2. 登录后点击右上角 **「账号设置」**
3. 填写当前用户名、当前密码、新用户名、新密码
4. 保存后自动退出,使用新凭据重新登录
账号信息保存在 `/opt/cloud-browser/data/auth.json`,容器重启后不会丢失。
---
## 五、反向代理(自行配置)
服务默认监听 `32450` 端口。若需通过域名 + HTTPS 访问,请在宝塔/Nginx 中自行配置反向代理。
目标地址:
```
http://127.0.0.1:32450
```
**注意**:浏览页面使用 WebSocket(路径 `/ws/`),反代时需开启 WebSocket 支持(Upgrade 头)。
---
## 六、运维命令
```bash
cd /opt/cloud-browser
# 查看运行状态
docker compose ps
# 查看日志
docker compose logs -f app
# 重启
docker compose restart
# 停止
docker compose down
# 更新代码并重新部署
bash deploy.sh
```
---
## 七、防火墙
确保服务器防火墙 / 安全组放行 **32450** 端口(若直接通过 IP 访问):
```bash
# ufw 示例
ufw allow 32450/tcp
# firewalld 示例
firewall-cmd --permanent --add-port=32450/tcp
firewall-cmd --reload
```
若仅通过 Nginx 反代访问,可不对公网开放 32450,仅本机 `127.0.0.1` 访问即可。
---
## 八、常见问题
### 部署脚本提示非 root 用户
```bash
sudo su -
bash deploy.sh
```
### 端口被占用
修改 `/opt/cloud-browser/.env` 中的 `APP_PORT`,然后
```bash
cd /opt/cloud-browser
docker compose down
docker compose up -d
```
### 页面黑屏
```bash
docker compose logs -f app
```
常见原因:内存不足(建议 ≥ 2GB)、Chromium 启动失败。
### WebSocket 连接失败
若使用了 Nginx 反代,检查是否配置了 WebSocket Upgrade 支持。
### 忘记密码
```bash
cd /opt/cloud-browser
docker compose down
rm -f data/auth.json data/secret.key
docker compose up -d
```
将恢复为默认账号 `admin` / `admin`**会清除已修改的密码**
---
## 九、卸载
```bash
cd /opt/cloud-browser
docker compose down
cd /
rm -rf /opt/cloud-browser
```
# 云端浏览器部署文档
本文档说明如何在 Linux 服务器(含宝塔面板环境)上部署云端浏览器。
- 安装目录:`/opt/cloud-browser`
- 运行用户:`root`
- 默认端口:`32450`
- 默认账号:`admin` / `admin`
- 代码仓库:[https://git.bz121.com/dekun/cloud-browser.git](https://git.bz121.com/dekun/cloud-browser.git)
---
## 一、环境要求
| 项目 | 要求 |
|------|------|
| 系统 | Ubuntu 20.04+ / Debian 11+ / CentOS 7+ |
| 内存 | 最低 1GB,推荐 2GB |
| Docker | 20.10+ |
| Docker Compose | v2+ |
| 端口 | 32450(或自定义,需在 `.env` 中修改) |
> 服务器已安装宝塔面板、Docker、Nginx 均可,**无需额外配置 Nginx**,服务直接监听端口,反向代理请自行操作。
---
## 二、一键部署(推荐)
**root** 用户 SSH 登录服务器,执行:
```bash
curl -fsSL https://git.bz121.com/dekun/cloud-browser/raw/branch/main/deploy.sh -o /tmp/deploy.sh
bash /tmp/deploy.sh
```
若仓库尚未推送或 curl 不可用,也可手动克隆后执行:
```bash
git clone https://git.bz121.com/dekun/cloud-browser.git /opt/cloud-browser
bash /opt/cloud-browser/deploy.sh
```
### 一键脚本会自动完成
1. 检测并安装 Docker(如未安装)
2. 克隆/更新代码到 `/opt/cloud-browser`
3. 创建 `.env``data/` 数据目录
4. 构建并启动 Docker 容器
5. 等待健康检查通过
6. 输出访问地址和默认账号
### 部署成功示例输出
```
==========================================
云端浏览器部署完成
==========================================
访问地址: http://1.2.3.4:32450
默认账号: admin
默认密码: admin
安装目录: /opt/cloud-browser
==========================================
```
---
## 三、手动部署
### 1. 克隆代码
```bash
git clone https://git.bz121.com/dekun/cloud-browser.git /opt/cloud-browser
cd /opt/cloud-browser
```
### 2. 配置环境变量(可选)
```bash
cp .env.example .env
nano .env
```
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `APP_PORT` | 宿主机端口 | `32450` |
| `MAX_SESSIONS` | 最大并发会话 | `1` |
| `SESSION_IDLE_TIMEOUT` | 空闲超时(秒) | `1800` |
| `VIEWPORT_WIDTH` | 浏览器宽度 | `1280` |
| `VIEWPORT_HEIGHT` | 浏览器高度 | `720` |
| `SCREENCAST_QUALITY` | 画面质量 10-100 | `80` |
### 3. 启动服务
```bash
mkdir -p data
docker compose up -d --build
```
### 4. 验证
```bash
curl http://127.0.0.1:32450/api/health
# {"status":"ok","sessions":0}
```
浏览器访问 `http://服务器IP:32450`,使用 `admin` / `admin` 登录。
---
## 四、登录与修改密码
1. 打开首页,输入默认账号 `admin`、密码 `admin` 登录
2. 登录后点击右上角 **「账号设置」**
3. 填写当前用户名、当前密码、新用户名、新密码
4. 保存后自动退出,使用新凭据重新登录
账号信息保存在 `/opt/cloud-browser/data/auth.json`,容器重启后不会丢失。
---
## 五、反向代理(自行配置)
服务默认监听 `32450` 端口。若需通过域名 + HTTPS 访问,请在宝塔/Nginx 中自行配置反向代理。
目标地址:
```
http://127.0.0.1:32450
```
**注意**:浏览页面使用 WebSocket(路径 `/ws/`),反代时需开启 WebSocket 支持(Upgrade 头)。
---
## 六、运维命令
```bash
cd /opt/cloud-browser
# 查看运行状态
docker compose ps
# 查看日志
docker compose logs -f app
# 重启
docker compose restart
# 停止
docker compose down
# 更新代码并重新部署
bash deploy.sh
```
---
## 七、防火墙
确保服务器防火墙 / 安全组放行 **32450** 端口(若直接通过 IP 访问):
```bash
# ufw 示例
ufw allow 32450/tcp
# firewalld 示例
firewall-cmd --permanent --add-port=32450/tcp
firewall-cmd --reload
```
若仅通过 Nginx 反代访问,可不对公网开放 32450,仅本机 `127.0.0.1` 访问即可。
---
## 八、常见问题
### 部署脚本报错 `set: pipefail: invalid option`
脚本在 Windows 编辑后可能带 CRLF 换行符,在 Linux 上会报错。修复:
```bash
sed -i 's/\r$//' /opt/cloud-browser/deploy.sh
bash /opt/cloud-browser/deploy.sh
```
或重新拉取最新代码
```bash
cd /opt/cloud-browser && git pull && bash deploy.sh
```
---
```bash
sudo su -
bash deploy.sh
```
### 端口被占用
修改 `/opt/cloud-browser/.env` 中的 `APP_PORT`,然后:
```bash
cd /opt/cloud-browser
docker compose down
docker compose up -d
```
### 页面黑屏
```bash
docker compose logs -f app
```
常见原因:内存不足(建议 ≥ 2GB)、Chromium 启动失败
### WebSocket 连接失败
若使用了 Nginx 反代,检查是否配置了 WebSocket Upgrade 支持。
### 忘记密码
```bash
cd /opt/cloud-browser
docker compose down
rm -f data/auth.json data/secret.key
docker compose up -d
```
将恢复为默认账号 `admin` / `admin`**会清除已修改的密码**)。
---
## 九、卸载
```bash
cd /opt/cloud-browser
docker compose down
cd /
rm -rf /opt/cloud-browser
```
+14 -14
View File
@@ -1,14 +1,14 @@
FROM mcr.microsoft.com/playwright/python:v1.49.1-jammy
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
COPY static/ ./static/
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
FROM mcr.microsoft.com/playwright/python:v1.49.1-jammy
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
COPY static/ ./static/
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+65 -65
View File
@@ -1,65 +1,65 @@
# 云端浏览器
在境外 VPS 上自部署的轻量远程浏览器:登录后输入网址,由云端 Chromium 打开页面,画面通过 WebSocket 实时回传,支持鼠标键盘操作。
## 功能特性
- 输入网址即可在云端打开并远程操作
- 内置登录鉴权,默认账号 `admin` / `admin`
- 前端可修改用户名和密码
- Docker 部署,默认端口 **32450**
- 反向代理(HTTPS/域名)请自行在宝塔/Nginx 配置
## 快速体验
部署完成后访问:
```
http://服务器IP:32450
```
默认账号密码均为 `admin`**登录后请立即修改**。
## 文档
- [部署文档 DEPLOY.md](DEPLOY.md) — 含一键部署说明
## 技术架构
```
浏览器 → :32450 → FastAPI → Playwright Chromium
↑ WebSocket 画面流 / 输入事件
```
## 目录结构
```
cloud-browser/
├── app/ # 后端(FastAPI + Playwright
├── static/ # 前端页面
├── deploy.sh # 一键部署脚本
├── docker-compose.yml
├── Dockerfile
└── DEPLOY.md # 部署文档
```
## 常用命令
```bash
cd /opt/cloud-browser
docker compose logs -f app # 查看日志
docker compose restart # 重启
docker compose down # 停止
bash deploy.sh # 更新并重新部署
```
## 安全说明
- 首次部署后务必修改默认密码
- 内置 SSRF 防护,禁止访问内网地址
- 账号数据保存在 `data/auth.json`Docker 卷持久化)
- 建议通过 Nginx/宝塔配置 HTTPS 后再对外使用
## 许可证
MIT
# 云端浏览器
在境外 VPS 上自部署的轻量远程浏览器:登录后输入网址,由云端 Chromium 打开页面,画面通过 WebSocket 实时回传,支持鼠标键盘操作。
## 功能特性
- 输入网址即可在云端打开并远程操作
- 内置登录鉴权,默认账号 `admin` / `admin`
- 前端可修改用户名和密码
- Docker 部署,默认端口 **32450**
- 反向代理(HTTPS/域名)请自行在宝塔/Nginx 配置
## 快速体验
部署完成后访问:
```
http://服务器IP:32450
```
默认账号密码均为 `admin`**登录后请立即修改**。
## 文档
- [部署文档 DEPLOY.md](DEPLOY.md) — 含一键部署说明
## 技术架构
```
浏览器 → :32450 → FastAPI → Playwright Chromium
↑ WebSocket 画面流 / 输入事件
```
## 目录结构
```
cloud-browser/
├── app/ # 后端(FastAPI + Playwright
├── static/ # 前端页面
├── deploy.sh # 一键部署脚本
├── docker-compose.yml
├── Dockerfile
└── DEPLOY.md # 部署文档
```
## 常用命令
```bash
cd /opt/cloud-browser
docker compose logs -f app # 查看日志
docker compose restart # 重启
docker compose down # 停止
bash deploy.sh # 更新并重新部署
```
## 安全说明
- 首次部署后务必修改默认密码
- 内置 SSRF 防护,禁止访问内网地址
- 账号数据保存在 `data/auth.json`Docker 卷持久化)
- 建议通过 Nginx/宝塔配置 HTTPS 后再对外使用
## 许可证
MIT
+109 -109
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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))
+125 -126
View File
@@ -1,126 +1,125 @@
#!/usr/bin/env bash
#
# 云端浏览器一键部署脚本
# 安装路径: /opt/cloud-browser
# 运行用户: root
# 默认端口: 32450
# 默认账号: admin / admin
#
set -euo pipefail
INSTALL_DIR="/opt/cloud-browser"
REPO_URL="https://git.bz121.com/dekun/cloud-browser.git"
APP_PORT="32450"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
err() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
if [[ "$(id -u)" -ne 0 ]]; then
err "请使用 root 用户运行此脚本"
exit 1
fi
install_docker() {
if command -v docker &>/dev/null; then
log "Docker 已安装: $(docker --version)"
return
fi
warn "未检测到 Docker,正在安装..."
curl -fsSL https://get.docker.com | sh
systemctl enable docker
systemctl start docker
log "Docker 安装完成"
}
install_compose() {
if docker compose version &>/dev/null; then
log "Docker Compose 已就绪"
return
fi
err "Docker Compose 不可用,请手动安装后重试"
exit 1
}
clone_or_update() {
if [[ -d "${INSTALL_DIR}/.git" ]]; then
log "更新代码: ${INSTALL_DIR}"
git -C "${INSTALL_DIR}" pull --rebase
else
log "克隆代码到 ${INSTALL_DIR}"
mkdir -p "$(dirname "${INSTALL_DIR}")"
git clone "${REPO_URL}" "${INSTALL_DIR}"
fi
}
setup_env() {
cd "${INSTALL_DIR}"
if [[ ! -f .env ]]; then
cp .env.example .env
log "已创建 .env 配置文件"
fi
mkdir -p data
chmod 700 data
}
start_service() {
cd "${INSTALL_DIR}"
log "构建并启动容器..."
docker compose up -d --build
}
wait_health() {
local retries=30
log "等待服务启动..."
while [[ $retries -gt 0 ]]; do
if curl -sf "http://127.0.0.1:${APP_PORT}/api/health" &>/dev/null; then
log "服务已就绪"
return 0
fi
retries=$((retries - 1))
sleep 2
done
err "服务启动超时,请检查日志: docker compose -f ${INSTALL_DIR}/docker-compose.yml logs -f app"
exit 1
}
print_summary() {
local ip
ip=$(curl -s --max-time 3 ifconfig.me 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}' || echo "YOUR_SERVER_IP")
echo ""
echo "=========================================="
echo " 云端浏览器部署完成"
echo "=========================================="
echo " 访问地址: http://${ip}:${APP_PORT}"
echo " 默认账号: admin"
echo " 默认密码: admin"
echo " 安装目录: ${INSTALL_DIR}"
echo ""
echo " 登录后请立即修改用户名和密码"
echo " 反向代理请自行在宝塔/Nginx 中配置"
echo ""
echo " 常用命令:"
echo " 查看日志: cd ${INSTALL_DIR} && docker compose logs -f app"
echo " 重启服务: cd ${INSTALL_DIR} && docker compose restart"
echo " 停止服务: cd ${INSTALL_DIR} && docker compose down"
echo " 更新部署: bash ${INSTALL_DIR}/deploy.sh"
echo "=========================================="
}
main() {
log "开始部署云端浏览器..."
install_docker
install_compose
clone_or_update
setup_env
start_service
wait_health
print_summary
}
main "$@"
#!/usr/bin/env bash
#
# 云端浏览器一键部署脚本
# 安装路径: /opt/cloud-browser
# 运行用户: root
# 默认端口: 32450
# 默认账号: admin / admin
#
set -eu
INSTALL_DIR="/opt/cloud-browser"
REPO_URL="https://git.bz121.com/dekun/cloud-browser.git"
APP_PORT="32450"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
err() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
if [ "$(id -u)" -ne 0 ]; then
err "请使用 root 用户运行此脚本"
exit 1
fi
install_docker() {
if command -v docker >/dev/null 2>&1; then
log "Docker 已安装: $(docker --version)"
return
fi
warn "未检测到 Docker,正在安装..."
curl -fsSL https://get.docker.com | sh
systemctl enable docker
systemctl start docker
log "Docker 安装完成"
}
install_compose() {
if docker compose version >/dev/null 2>&1; then
log "Docker Compose 已就绪"
return
fi
err "Docker Compose 不可用,请手动安装后重试"
exit 1
}
clone_or_update() {
if [ -d "${INSTALL_DIR}/.git" ]; then
log "更新代码: ${INSTALL_DIR}"
git -C "${INSTALL_DIR}" pull --rebase
else
log "克隆代码到 ${INSTALL_DIR}"
mkdir -p "$(dirname "${INSTALL_DIR}")"
git clone "${REPO_URL}" "${INSTALL_DIR}"
fi
}
setup_env() {
cd "${INSTALL_DIR}"
if [ ! -f .env ]; then
cp .env.example .env
log "已创建 .env 配置文件"
fi
mkdir -p data
chmod 700 data
}
start_service() {
cd "${INSTALL_DIR}"
log "构建并启动容器..."
docker compose up -d --build
}
wait_health() {
retries=30
log "等待服务启动..."
while [ "$retries" -gt 0 ]; do
if curl -sf "http://127.0.0.1:${APP_PORT}/api/health" >/dev/null 2>&1; then
log "服务已就绪"
return 0
fi
retries=$((retries - 1))
sleep 2
done
err "服务启动超时,请检查日志: docker compose -f ${INSTALL_DIR}/docker-compose.yml logs -f app"
exit 1
}
print_summary() {
ip=$(curl -s --max-time 3 ifconfig.me 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}' || echo "YOUR_SERVER_IP")
echo ""
echo "=========================================="
echo " 云端浏览器部署完成"
echo "=========================================="
echo " 访问地址: http://${ip}:${APP_PORT}"
echo " 默认账号: admin"
echo " 默认密码: admin"
echo " 安装目录: ${INSTALL_DIR}"
echo ""
echo " 登录后请立即修改用户名和密码"
echo " 反向代理请自行在宝塔/Nginx 中配置"
echo ""
echo " 常用命令:"
echo " 查看日志: cd ${INSTALL_DIR} && docker compose logs -f app"
echo " 重启服务: cd ${INSTALL_DIR} && docker compose restart"
echo " 停止服务: cd ${INSTALL_DIR} && docker compose down"
echo " 更新部署: bash ${INSTALL_DIR}/deploy.sh"
echo "=========================================="
}
main() {
log "开始部署云端浏览器..."
install_docker
install_compose
clone_or_update
setup_env
start_service
wait_health
print_summary
}
main "$@"
+17 -17
View File
@@ -1,17 +1,17 @@
services:
app:
build: .
restart: unless-stopped
ports:
- "${APP_PORT:-32450}:8000"
env_file: .env
environment:
- DATA_DIR=/app/data
- MAX_SESSIONS=${MAX_SESSIONS:-1}
- SESSION_IDLE_TIMEOUT=${SESSION_IDLE_TIMEOUT:-1800}
- VIEWPORT_WIDTH=${VIEWPORT_WIDTH:-1280}
- VIEWPORT_HEIGHT=${VIEWPORT_HEIGHT:-720}
- SCREENCAST_QUALITY=${SCREENCAST_QUALITY:-80}
volumes:
- ./data:/app/data
shm_size: "1gb"
services:
app:
build: .
restart: unless-stopped
ports:
- "${APP_PORT:-32450}:8000"
env_file: .env
environment:
- DATA_DIR=/app/data
- MAX_SESSIONS=${MAX_SESSIONS:-1}
- SESSION_IDLE_TIMEOUT=${SESSION_IDLE_TIMEOUT:-1800}
- VIEWPORT_WIDTH=${VIEWPORT_WIDTH:-1280}
- VIEWPORT_HEIGHT=${VIEWPORT_HEIGHT:-720}
- SCREENCAST_QUALITY=${SCREENCAST_QUALITY:-80}
volumes:
- ./data:/app/data
shm_size: "1gb"
+7 -7
View File
@@ -1,7 +1,7 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
playwright==1.49.1
python-dotenv==1.0.1
pydantic==2.10.4
bcrypt==4.2.1
itsdangerous==2.2.0
fastapi==0.115.6
uvicorn[standard]==0.34.0
playwright==1.49.1
python-dotenv==1.0.1
pydantic==2.10.4
bcrypt==4.2.1
itsdangerous==2.2.0
+35 -35
View File
@@ -1,35 +1,35 @@
const AUTH = {
async me() {
const res = await fetch("/api/auth/me", { credentials: "include" });
if (!res.ok) return null;
return res.json();
},
async login(username, password) {
const res = await fetch("/api/auth/login", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || "登录失败");
return data;
},
async logout() {
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
},
async changeCredentials(payload) {
const res = await fetch("/api/auth/change-credentials", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || "修改失败");
return data;
},
};
const AUTH = {
async me() {
const res = await fetch("/api/auth/me", { credentials: "include" });
if (!res.ok) return null;
return res.json();
},
async login(username, password) {
const res = await fetch("/api/auth/login", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || "登录失败");
return data;
},
async logout() {
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
},
async changeCredentials(payload) {
const res = await fetch("/api/auth/change-credentials", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || "修改失败");
return data;
},
};
+88 -88
View File
@@ -1,88 +1,88 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>云端浏览器</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main class="container">
<header class="header">
<h1>云端浏览器</h1>
<p class="subtitle">输入网址,在境外服务器上打开并远程操作</p>
</header>
<!-- 登录 -->
<section id="login-section" class="panel hidden">
<h2>登录</h2>
<form id="login-form" class="stack-form">
<label for="login-user">用户名</label>
<input id="login-user" type="text" autocomplete="username" required>
<label for="login-pass">密码</label>
<input id="login-pass" type="password" autocomplete="current-password" required>
<button type="submit">登录</button>
<p id="login-error" class="error hidden"></p>
</form>
<p class="hint">默认账号:admin / admin,登录后请尽快修改</p>
</section>
<!-- 主界面 -->
<section id="main-section" class="hidden">
<div class="top-bar">
<span id="welcome-user" class="welcome"></span>
<button id="btn-settings" type="button" class="btn-secondary">账号设置</button>
<button id="btn-logout" type="button" class="btn-secondary">退出</button>
</div>
<form id="start-form" class="start-form">
<label for="url-input">目标网址</label>
<div class="input-row">
<input
id="url-input"
type="text"
placeholder="https://example.com"
autocomplete="off"
required
>
<button type="submit" id="start-btn">进入</button>
</div>
<p id="error-msg" class="error hidden"></p>
</form>
<section class="tips">
<h2>使用说明</h2>
<ul>
<li>页面将在云服务器 Chromium 中加载,画面实时回传</li>
<li>可同时登录账号、搜索和浏览你的数据</li>
<li>会话空闲 30 分钟后自动关闭</li>
</ul>
</section>
</section>
<!-- 修改账号 -->
<section id="settings-section" class="panel hidden">
<h2>修改用户名和密码</h2>
<form id="settings-form" class="stack-form">
<label for="cur-user">当前用户名</label>
<input id="cur-user" type="text" required>
<label for="cur-pass">当前密码</label>
<input id="cur-pass" type="password" required>
<label for="new-user">新用户名</label>
<input id="new-user" type="text" required>
<label for="new-pass">新密码</label>
<input id="new-pass" type="password" required>
<div class="btn-row">
<button type="submit">保存</button>
<button id="btn-settings-cancel" type="button" class="btn-secondary">取消</button>
</div>
<p id="settings-error" class="error hidden"></p>
<p id="settings-success" class="success hidden"></p>
</form>
</section>
</main>
<script src="/static/auth.js"></script>
<script src="/static/index.js"></script>
</body>
</html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>云端浏览器</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main class="container">
<header class="header">
<h1>云端浏览器</h1>
<p class="subtitle">输入网址,在境外服务器上打开并远程操作</p>
</header>
<!-- 登录 -->
<section id="login-section" class="panel hidden">
<h2>登录</h2>
<form id="login-form" class="stack-form">
<label for="login-user">用户名</label>
<input id="login-user" type="text" autocomplete="username" required>
<label for="login-pass">密码</label>
<input id="login-pass" type="password" autocomplete="current-password" required>
<button type="submit">登录</button>
<p id="login-error" class="error hidden"></p>
</form>
<p class="hint">默认账号:admin / admin,登录后请尽快修改</p>
</section>
<!-- 主界面 -->
<section id="main-section" class="hidden">
<div class="top-bar">
<span id="welcome-user" class="welcome"></span>
<button id="btn-settings" type="button" class="btn-secondary">账号设置</button>
<button id="btn-logout" type="button" class="btn-secondary">退出</button>
</div>
<form id="start-form" class="start-form">
<label for="url-input">目标网址</label>
<div class="input-row">
<input
id="url-input"
type="text"
placeholder="https://example.com"
autocomplete="off"
required
>
<button type="submit" id="start-btn">进入</button>
</div>
<p id="error-msg" class="error hidden"></p>
</form>
<section class="tips">
<h2>使用说明</h2>
<ul>
<li>页面将在云服务器 Chromium 中加载,画面实时回传</li>
<li>可同时登录账号、搜索和浏览你的数据</li>
<li>会话空闲 30 分钟后自动关闭</li>
</ul>
</section>
</section>
<!-- 修改账号 -->
<section id="settings-section" class="panel hidden">
<h2>修改用户名和密码</h2>
<form id="settings-form" class="stack-form">
<label for="cur-user">当前用户名</label>
<input id="cur-user" type="text" required>
<label for="cur-pass">当前密码</label>
<input id="cur-pass" type="password" required>
<label for="new-user">新用户名</label>
<input id="new-user" type="text" required>
<label for="new-pass">新密码</label>
<input id="new-pass" type="password" required>
<div class="btn-row">
<button type="submit">保存</button>
<button id="btn-settings-cancel" type="button" class="btn-secondary">取消</button>
</div>
<p id="settings-error" class="error hidden"></p>
<p id="settings-success" class="success hidden"></p>
</form>
</section>
</main>
<script src="/static/auth.js"></script>
<script src="/static/index.js"></script>
</body>
</html>
+163 -163
View File
@@ -1,163 +1,163 @@
const loginSection = document.getElementById("login-section");
const mainSection = document.getElementById("main-section");
const settingsSection = document.getElementById("settings-section");
const loginForm = document.getElementById("login-form");
const loginError = document.getElementById("login-error");
const welcomeUser = document.getElementById("welcome-user");
const settingsForm = document.getElementById("settings-form");
const settingsError = document.getElementById("settings-error");
const settingsSuccess = document.getElementById("settings-success");
const form = document.getElementById("start-form");
const urlInput = document.getElementById("url-input");
const startBtn = document.getElementById("start-btn");
const errorMsg = document.getElementById("error-msg");
function show(el) {
el.classList.remove("hidden");
}
function hide(el) {
el.classList.add("hidden");
}
function showPanelError(el, message) {
el.textContent = message;
show(el);
}
function hidePanelError(el) {
hide(el);
}
function showMain(username) {
hide(loginSection);
hide(settingsSection);
show(mainSection);
welcomeUser.textContent = `当前用户:${username}`;
document.getElementById("cur-user").value = username;
urlInput.focus();
}
function showLogin() {
hide(mainSection);
hide(settingsSection);
show(loginSection);
}
async function init() {
const user = await AUTH.me();
if (user) {
showMain(user.username);
} else {
showLogin();
}
}
loginForm.addEventListener("submit", async (e) => {
e.preventDefault();
hidePanelError(loginError);
const username = document.getElementById("login-user").value.trim();
const password = document.getElementById("login-pass").value;
try {
const data = await AUTH.login(username, password);
showMain(data.username);
} catch (err) {
showPanelError(loginError, err.message);
}
});
document.getElementById("btn-logout").addEventListener("click", async () => {
await AUTH.logout();
showLogin();
});
document.getElementById("btn-settings").addEventListener("click", () => {
hide(mainSection);
hidePanelError(settingsError);
hide(settingsSuccess);
show(settingsSection);
});
document.getElementById("btn-settings-cancel").addEventListener("click", () => {
const user = welcomeUser.textContent.replace("当前用户:", "");
showMain(user);
});
settingsForm.addEventListener("submit", async (e) => {
e.preventDefault();
hidePanelError(settingsError);
hide(settingsSuccess);
try {
const data = await AUTH.changeCredentials({
current_username: document.getElementById("cur-user").value.trim(),
current_password: document.getElementById("cur-pass").value,
new_username: document.getElementById("new-user").value.trim(),
new_password: document.getElementById("new-pass").value,
});
document.getElementById("cur-pass").value = "";
document.getElementById("new-pass").value = "";
settingsSuccess.textContent = "账号已更新,请使用新凭据登录";
show(settingsSuccess);
setTimeout(async () => {
await AUTH.logout();
showLogin();
}, 1500);
welcomeUser.textContent = `当前用户:${data.username}`;
} catch (err) {
showPanelError(settingsError, err.message);
}
});
function showError(message) {
errorMsg.textContent = message;
show(errorMsg);
}
function hideError() {
hide(errorMsg);
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
hideError();
const url = urlInput.value.trim();
if (!url) {
showError("请输入网址");
return;
}
startBtn.disabled = true;
startBtn.textContent = "启动中...";
try {
const response = await fetch("/api/session", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
});
const data = await response.json().catch(() => ({}));
if (response.status === 401) {
showLogin();
showPanelError(loginError, "登录已过期,请重新登录");
return;
}
if (!response.ok) {
showError(data.detail || "创建会话失败");
return;
}
window.location.href = `/view/${data.session_id}`;
} catch (err) {
showError("网络错误,请稍后重试");
} finally {
startBtn.disabled = false;
startBtn.textContent = "进入";
}
});
init();
const loginSection = document.getElementById("login-section");
const mainSection = document.getElementById("main-section");
const settingsSection = document.getElementById("settings-section");
const loginForm = document.getElementById("login-form");
const loginError = document.getElementById("login-error");
const welcomeUser = document.getElementById("welcome-user");
const settingsForm = document.getElementById("settings-form");
const settingsError = document.getElementById("settings-error");
const settingsSuccess = document.getElementById("settings-success");
const form = document.getElementById("start-form");
const urlInput = document.getElementById("url-input");
const startBtn = document.getElementById("start-btn");
const errorMsg = document.getElementById("error-msg");
function show(el) {
el.classList.remove("hidden");
}
function hide(el) {
el.classList.add("hidden");
}
function showPanelError(el, message) {
el.textContent = message;
show(el);
}
function hidePanelError(el) {
hide(el);
}
function showMain(username) {
hide(loginSection);
hide(settingsSection);
show(mainSection);
welcomeUser.textContent = `当前用户:${username}`;
document.getElementById("cur-user").value = username;
urlInput.focus();
}
function showLogin() {
hide(mainSection);
hide(settingsSection);
show(loginSection);
}
async function init() {
const user = await AUTH.me();
if (user) {
showMain(user.username);
} else {
showLogin();
}
}
loginForm.addEventListener("submit", async (e) => {
e.preventDefault();
hidePanelError(loginError);
const username = document.getElementById("login-user").value.trim();
const password = document.getElementById("login-pass").value;
try {
const data = await AUTH.login(username, password);
showMain(data.username);
} catch (err) {
showPanelError(loginError, err.message);
}
});
document.getElementById("btn-logout").addEventListener("click", async () => {
await AUTH.logout();
showLogin();
});
document.getElementById("btn-settings").addEventListener("click", () => {
hide(mainSection);
hidePanelError(settingsError);
hide(settingsSuccess);
show(settingsSection);
});
document.getElementById("btn-settings-cancel").addEventListener("click", () => {
const user = welcomeUser.textContent.replace("当前用户:", "");
showMain(user);
});
settingsForm.addEventListener("submit", async (e) => {
e.preventDefault();
hidePanelError(settingsError);
hide(settingsSuccess);
try {
const data = await AUTH.changeCredentials({
current_username: document.getElementById("cur-user").value.trim(),
current_password: document.getElementById("cur-pass").value,
new_username: document.getElementById("new-user").value.trim(),
new_password: document.getElementById("new-pass").value,
});
document.getElementById("cur-pass").value = "";
document.getElementById("new-pass").value = "";
settingsSuccess.textContent = "账号已更新,请使用新凭据登录";
show(settingsSuccess);
setTimeout(async () => {
await AUTH.logout();
showLogin();
}, 1500);
welcomeUser.textContent = `当前用户:${data.username}`;
} catch (err) {
showPanelError(settingsError, err.message);
}
});
function showError(message) {
errorMsg.textContent = message;
show(errorMsg);
}
function hideError() {
hide(errorMsg);
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
hideError();
const url = urlInput.value.trim();
if (!url) {
showError("请输入网址");
return;
}
startBtn.disabled = true;
startBtn.textContent = "启动中...";
try {
const response = await fetch("/api/session", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
});
const data = await response.json().catch(() => ({}));
if (response.status === 401) {
showLogin();
showPanelError(loginError, "登录已过期,请重新登录");
return;
}
if (!response.ok) {
showError(data.detail || "创建会话失败");
return;
}
window.location.href = `/view/${data.session_id}`;
} catch (err) {
showError("网络错误,请稍后重试");
} finally {
startBtn.disabled = false;
startBtn.textContent = "进入";
}
});
init();
+269 -269
View File
@@ -1,269 +1,269 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #0f1419;
color: #e7e9ea;
min-height: 100vh;
}
.container {
max-width: 640px;
margin: 0 auto;
padding: 48px 24px;
}
.header h1 {
font-size: 2rem;
margin-bottom: 8px;
}
.subtitle {
color: #8b98a5;
margin-bottom: 32px;
}
.start-form label {
display: block;
margin-bottom: 8px;
font-weight: 500;
}
.input-row {
display: flex;
gap: 8px;
}
.input-row input {
flex: 1;
padding: 12px 16px;
border: 1px solid #38444d;
border-radius: 8px;
background: #192734;
color: #e7e9ea;
font-size: 1rem;
}
.input-row input:focus {
outline: none;
border-color: #1d9bf0;
}
button {
padding: 12px 20px;
border: none;
border-radius: 8px;
background: #1d9bf0;
color: #fff;
font-size: 1rem;
cursor: pointer;
font-weight: 500;
}
button:hover {
background: #1a8cd8;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error {
color: #f4212e;
margin-top: 12px;
font-size: 0.9rem;
}
.hidden {
display: none !important;
}
.tips {
margin-top: 48px;
padding: 24px;
background: #192734;
border-radius: 12px;
border: 1px solid #38444d;
}
.tips h2 {
font-size: 1rem;
margin-bottom: 12px;
}
.tips ul {
padding-left: 20px;
color: #8b98a5;
line-height: 1.8;
}
/* Viewer */
.viewer-body {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #192734;
border-bottom: 1px solid #38444d;
flex-shrink: 0;
}
.toolbar button {
padding: 8px 12px;
min-width: 36px;
}
.toolbar #address-bar {
flex: 1;
padding: 8px 12px;
border: 1px solid #38444d;
border-radius: 6px;
background: #0f1419;
color: #e7e9ea;
font-size: 0.9rem;
}
.toolbar #address-bar:focus {
outline: none;
border-color: #1d9bf0;
}
.status {
font-size: 0.8rem;
color: #8b98a5;
white-space: nowrap;
}
.btn-danger {
background: #f4212e;
}
.btn-danger:hover {
background: #dc1d28;
}
.viewport-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #000;
position: relative;
overflow: hidden;
}
#screen {
max-width: 100%;
max-height: 100%;
cursor: default;
outline: none;
}
.overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.85);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
}
.overlay a {
color: #1d9bf0;
text-decoration: none;
}
.overlay a:hover {
text-decoration: underline;
}
.panel {
margin-bottom: 24px;
padding: 24px;
background: #192734;
border-radius: 12px;
border: 1px solid #38444d;
}
.panel h2 {
font-size: 1.1rem;
margin-bottom: 16px;
}
.stack-form label {
display: block;
margin: 12px 0 6px;
font-weight: 500;
}
.stack-form input {
width: 100%;
padding: 10px 14px;
border: 1px solid #38444d;
border-radius: 8px;
background: #0f1419;
color: #e7e9ea;
font-size: 1rem;
}
.stack-form input:focus {
outline: none;
border-color: #1d9bf0;
}
.stack-form button {
margin-top: 16px;
}
.btn-row {
display: flex;
gap: 8px;
margin-top: 16px;
}
.btn-secondary {
background: #38444d;
}
.btn-secondary:hover {
background: #4a5560;
}
.top-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 24px;
}
.welcome {
flex: 1;
color: #8b98a5;
font-size: 0.9rem;
}
.hint {
margin-top: 12px;
color: #8b98a5;
font-size: 0.85rem;
}
.success {
color: #00ba7c;
margin-top: 12px;
font-size: 0.9rem;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #0f1419;
color: #e7e9ea;
min-height: 100vh;
}
.container {
max-width: 640px;
margin: 0 auto;
padding: 48px 24px;
}
.header h1 {
font-size: 2rem;
margin-bottom: 8px;
}
.subtitle {
color: #8b98a5;
margin-bottom: 32px;
}
.start-form label {
display: block;
margin-bottom: 8px;
font-weight: 500;
}
.input-row {
display: flex;
gap: 8px;
}
.input-row input {
flex: 1;
padding: 12px 16px;
border: 1px solid #38444d;
border-radius: 8px;
background: #192734;
color: #e7e9ea;
font-size: 1rem;
}
.input-row input:focus {
outline: none;
border-color: #1d9bf0;
}
button {
padding: 12px 20px;
border: none;
border-radius: 8px;
background: #1d9bf0;
color: #fff;
font-size: 1rem;
cursor: pointer;
font-weight: 500;
}
button:hover {
background: #1a8cd8;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error {
color: #f4212e;
margin-top: 12px;
font-size: 0.9rem;
}
.hidden {
display: none !important;
}
.tips {
margin-top: 48px;
padding: 24px;
background: #192734;
border-radius: 12px;
border: 1px solid #38444d;
}
.tips h2 {
font-size: 1rem;
margin-bottom: 12px;
}
.tips ul {
padding-left: 20px;
color: #8b98a5;
line-height: 1.8;
}
/* Viewer */
.viewer-body {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #192734;
border-bottom: 1px solid #38444d;
flex-shrink: 0;
}
.toolbar button {
padding: 8px 12px;
min-width: 36px;
}
.toolbar #address-bar {
flex: 1;
padding: 8px 12px;
border: 1px solid #38444d;
border-radius: 6px;
background: #0f1419;
color: #e7e9ea;
font-size: 0.9rem;
}
.toolbar #address-bar:focus {
outline: none;
border-color: #1d9bf0;
}
.status {
font-size: 0.8rem;
color: #8b98a5;
white-space: nowrap;
}
.btn-danger {
background: #f4212e;
}
.btn-danger:hover {
background: #dc1d28;
}
.viewport-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #000;
position: relative;
overflow: hidden;
}
#screen {
max-width: 100%;
max-height: 100%;
cursor: default;
outline: none;
}
.overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.85);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
}
.overlay a {
color: #1d9bf0;
text-decoration: none;
}
.overlay a:hover {
text-decoration: underline;
}
.panel {
margin-bottom: 24px;
padding: 24px;
background: #192734;
border-radius: 12px;
border: 1px solid #38444d;
}
.panel h2 {
font-size: 1.1rem;
margin-bottom: 16px;
}
.stack-form label {
display: block;
margin: 12px 0 6px;
font-weight: 500;
}
.stack-form input {
width: 100%;
padding: 10px 14px;
border: 1px solid #38444d;
border-radius: 8px;
background: #0f1419;
color: #e7e9ea;
font-size: 1rem;
}
.stack-form input:focus {
outline: none;
border-color: #1d9bf0;
}
.stack-form button {
margin-top: 16px;
}
.btn-row {
display: flex;
gap: 8px;
margin-top: 16px;
}
.btn-secondary {
background: #38444d;
}
.btn-secondary:hover {
background: #4a5560;
}
.top-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 24px;
}
.welcome {
flex: 1;
color: #8b98a5;
font-size: 0.9rem;
}
.hint {
margin-top: 12px;
color: #8b98a5;
font-size: 0.85rem;
}
.success {
color: #00ba7c;
margin-top: 12px;
font-size: 0.9rem;
}
+30 -30
View File
@@ -1,30 +1,30 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>浏览中 - 云端浏览器</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="viewer-body">
<div class="toolbar">
<button id="btn-back" title="后退" type="button"></button>
<button id="btn-forward" title="前进" type="button"></button>
<button id="btn-reload" title="刷新" type="button"></button>
<input id="address-bar" type="text" placeholder="输入网址..." autocomplete="off">
<button id="btn-go" type="button">前往</button>
<span id="status" class="status">连接中...</span>
<button id="btn-close" class="btn-danger" type="button">关闭会话</button>
</div>
<div id="viewport-wrap" class="viewport-wrap">
<canvas id="screen" tabindex="0"></canvas>
<div id="overlay" class="overlay hidden">
<p id="overlay-msg">会话已结束</p>
<a href="/">返回首页</a>
</div>
</div>
<script src="/static/viewer.js"></script>
</body>
</html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>浏览中 - 云端浏览器</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="viewer-body">
<div class="toolbar">
<button id="btn-back" title="后退" type="button"></button>
<button id="btn-forward" title="前进" type="button"></button>
<button id="btn-reload" title="刷新" type="button"></button>
<input id="address-bar" type="text" placeholder="输入网址..." autocomplete="off">
<button id="btn-go" type="button">前往</button>
<span id="status" class="status">连接中...</span>
<button id="btn-close" class="btn-danger" type="button">关闭会话</button>
</div>
<div id="viewport-wrap" class="viewport-wrap">
<canvas id="screen" tabindex="0"></canvas>
<div id="overlay" class="overlay hidden">
<p id="overlay-msg">会话已结束</p>
<a href="/">返回首页</a>
</div>
</div>
<script src="/static/viewer.js"></script>
</body>
</html>
+217 -217
View File
@@ -1,217 +1,217 @@
(function () {
const sessionId = window.location.pathname.split("/").pop();
const canvas = document.getElementById("screen");
const ctx = canvas.getContext("2d");
const addressBar = document.getElementById("address-bar");
const statusEl = document.getElementById("status");
const overlay = document.getElementById("overlay");
const overlayMsg = document.getElementById("overlay-msg");
let ws = null;
let viewportWidth = 1280;
let viewportHeight = 720;
let scaleX = 1;
let scaleY = 1;
let pingTimer = null;
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${wsProtocol}//${window.location.host}/ws/${sessionId}`;
function setStatus(text) {
statusEl.textContent = text;
}
function showOverlay(message) {
overlayMsg.textContent = message;
overlay.classList.remove("hidden");
}
function mapCoords(clientX, clientY) {
const rect = canvas.getBoundingClientRect();
const x = (clientX - rect.left) / scaleX;
const y = (clientY - rect.top) / scaleY;
return { x, y };
}
function send(payload) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
}
function drawFrame(blob) {
const img = new Image();
const url = URL.createObjectURL(blob);
img.onload = () => {
if (canvas.width !== img.width || canvas.height !== img.height) {
canvas.width = img.width;
canvas.height = img.height;
viewportWidth = img.width;
viewportHeight = img.height;
updateScale();
}
ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(url);
};
img.src = url;
}
function updateScale() {
const rect = canvas.getBoundingClientRect();
scaleX = rect.width / viewportWidth;
scaleY = rect.height / viewportHeight;
}
function connect() {
ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
ws.onopen = () => {
setStatus("已连接");
pingTimer = setInterval(() => send({ type: "ping" }), 60000);
};
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
drawFrame(new Blob([event.data], { type: "image/jpeg" }));
return;
}
try {
const msg = JSON.parse(event.data);
if (msg.type === "init") {
viewportWidth = msg.width;
viewportHeight = msg.height;
addressBar.value = msg.url || "";
updateScale();
} else if (msg.type === "url" || msg.type === "url_update") {
addressBar.value = msg.url || "";
} else if (msg.type === "closed") {
showOverlay("会话已结束");
ws.close();
} else if (msg.type === "error") {
setStatus(msg.message);
}
} catch (_) {
/* ignore */
}
};
ws.onclose = () => {
setStatus("已断开");
clearInterval(pingTimer);
};
ws.onerror = () => {
setStatus("连接错误");
};
}
canvas.addEventListener("click", (e) => {
canvas.focus();
const { x, y } = mapCoords(e.clientX, e.clientY);
send({ action: "click", x, y, button: "left" });
});
canvas.addEventListener("dblclick", (e) => {
e.preventDefault();
const { x, y } = mapCoords(e.clientX, e.clientY);
send({ action: "dblclick", x, y });
});
canvas.addEventListener("wheel", (e) => {
e.preventDefault();
send({ action: "wheel", deltaX: e.deltaX, deltaY: e.deltaY });
}, { passive: false });
canvas.addEventListener("mousemove", (e) => {
const { x, y } = mapCoords(e.clientX, e.clientY);
send({ action: "mousemove", x, y });
});
const specialKeys = new Set([
"Enter", "Backspace", "Delete", "Tab", "Escape",
"ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
"Home", "End", "PageUp", "PageDown",
]);
canvas.addEventListener("keydown", (e) => {
e.preventDefault();
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
send({ action: "type", text: e.key });
} else if (specialKeys.has(e.key)) {
send({ action: "press", key: e.key });
} else {
send({ action: "keydown", key: e.key });
}
});
canvas.addEventListener("keyup", (e) => {
e.preventDefault();
if (e.key.length > 1 || e.ctrlKey || e.metaKey || e.altKey) {
send({ action: "keyup", key: e.key });
}
});
window.addEventListener("resize", updateScale);
async function apiPost(path) {
const res = await fetch(path, { method: "POST", credentials: "include" });
if (res.status === 401) {
window.location.href = "/";
return null;
}
return res.json();
}
async function ensureAuth() {
const res = await fetch("/api/auth/me", { credentials: "include" });
if (!res.ok) {
window.location.href = "/";
return false;
}
return true;
}
document.getElementById("btn-back").addEventListener("click", async () => {
const data = await apiPost(`/api/session/${sessionId}/back`);
if (data) addressBar.value = data.url;
});
document.getElementById("btn-forward").addEventListener("click", async () => {
const data = await apiPost(`/api/session/${sessionId}/forward`);
if (data) addressBar.value = data.url;
});
document.getElementById("btn-reload").addEventListener("click", async () => {
const data = await apiPost(`/api/session/${sessionId}/reload`);
if (data) addressBar.value = data.url;
});
function navigateTo(url) {
send({ type: "navigate", url });
}
document.getElementById("btn-go").addEventListener("click", () => {
navigateTo(addressBar.value.trim());
});
addressBar.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
navigateTo(addressBar.value.trim());
}
});
document.getElementById("btn-close").addEventListener("click", async () => {
await fetch(`/api/session/${sessionId}`, { method: "DELETE", credentials: "include" });
showOverlay("会话已关闭");
if (ws) ws.close();
});
ensureAuth().then((ok) => {
if (ok) {
connect();
canvas.focus();
}
});
})();
(function () {
const sessionId = window.location.pathname.split("/").pop();
const canvas = document.getElementById("screen");
const ctx = canvas.getContext("2d");
const addressBar = document.getElementById("address-bar");
const statusEl = document.getElementById("status");
const overlay = document.getElementById("overlay");
const overlayMsg = document.getElementById("overlay-msg");
let ws = null;
let viewportWidth = 1280;
let viewportHeight = 720;
let scaleX = 1;
let scaleY = 1;
let pingTimer = null;
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${wsProtocol}//${window.location.host}/ws/${sessionId}`;
function setStatus(text) {
statusEl.textContent = text;
}
function showOverlay(message) {
overlayMsg.textContent = message;
overlay.classList.remove("hidden");
}
function mapCoords(clientX, clientY) {
const rect = canvas.getBoundingClientRect();
const x = (clientX - rect.left) / scaleX;
const y = (clientY - rect.top) / scaleY;
return { x, y };
}
function send(payload) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
}
function drawFrame(blob) {
const img = new Image();
const url = URL.createObjectURL(blob);
img.onload = () => {
if (canvas.width !== img.width || canvas.height !== img.height) {
canvas.width = img.width;
canvas.height = img.height;
viewportWidth = img.width;
viewportHeight = img.height;
updateScale();
}
ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(url);
};
img.src = url;
}
function updateScale() {
const rect = canvas.getBoundingClientRect();
scaleX = rect.width / viewportWidth;
scaleY = rect.height / viewportHeight;
}
function connect() {
ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
ws.onopen = () => {
setStatus("已连接");
pingTimer = setInterval(() => send({ type: "ping" }), 60000);
};
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
drawFrame(new Blob([event.data], { type: "image/jpeg" }));
return;
}
try {
const msg = JSON.parse(event.data);
if (msg.type === "init") {
viewportWidth = msg.width;
viewportHeight = msg.height;
addressBar.value = msg.url || "";
updateScale();
} else if (msg.type === "url" || msg.type === "url_update") {
addressBar.value = msg.url || "";
} else if (msg.type === "closed") {
showOverlay("会话已结束");
ws.close();
} else if (msg.type === "error") {
setStatus(msg.message);
}
} catch (_) {
/* ignore */
}
};
ws.onclose = () => {
setStatus("已断开");
clearInterval(pingTimer);
};
ws.onerror = () => {
setStatus("连接错误");
};
}
canvas.addEventListener("click", (e) => {
canvas.focus();
const { x, y } = mapCoords(e.clientX, e.clientY);
send({ action: "click", x, y, button: "left" });
});
canvas.addEventListener("dblclick", (e) => {
e.preventDefault();
const { x, y } = mapCoords(e.clientX, e.clientY);
send({ action: "dblclick", x, y });
});
canvas.addEventListener("wheel", (e) => {
e.preventDefault();
send({ action: "wheel", deltaX: e.deltaX, deltaY: e.deltaY });
}, { passive: false });
canvas.addEventListener("mousemove", (e) => {
const { x, y } = mapCoords(e.clientX, e.clientY);
send({ action: "mousemove", x, y });
});
const specialKeys = new Set([
"Enter", "Backspace", "Delete", "Tab", "Escape",
"ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
"Home", "End", "PageUp", "PageDown",
]);
canvas.addEventListener("keydown", (e) => {
e.preventDefault();
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
send({ action: "type", text: e.key });
} else if (specialKeys.has(e.key)) {
send({ action: "press", key: e.key });
} else {
send({ action: "keydown", key: e.key });
}
});
canvas.addEventListener("keyup", (e) => {
e.preventDefault();
if (e.key.length > 1 || e.ctrlKey || e.metaKey || e.altKey) {
send({ action: "keyup", key: e.key });
}
});
window.addEventListener("resize", updateScale);
async function apiPost(path) {
const res = await fetch(path, { method: "POST", credentials: "include" });
if (res.status === 401) {
window.location.href = "/";
return null;
}
return res.json();
}
async function ensureAuth() {
const res = await fetch("/api/auth/me", { credentials: "include" });
if (!res.ok) {
window.location.href = "/";
return false;
}
return true;
}
document.getElementById("btn-back").addEventListener("click", async () => {
const data = await apiPost(`/api/session/${sessionId}/back`);
if (data) addressBar.value = data.url;
});
document.getElementById("btn-forward").addEventListener("click", async () => {
const data = await apiPost(`/api/session/${sessionId}/forward`);
if (data) addressBar.value = data.url;
});
document.getElementById("btn-reload").addEventListener("click", async () => {
const data = await apiPost(`/api/session/${sessionId}/reload`);
if (data) addressBar.value = data.url;
});
function navigateTo(url) {
send({ type: "navigate", url });
}
document.getElementById("btn-go").addEventListener("click", () => {
navigateTo(addressBar.value.trim());
});
addressBar.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
navigateTo(addressBar.value.trim());
}
});
document.getElementById("btn-close").addEventListener("click", async () => {
await fetch(`/api/session/${sessionId}`, { method: "DELETE", credentials: "include" });
showOverlay("会话已关闭");
if (ws) ws.close();
});
ensureAuth().then((ok) => {
if (ok) {
connect();
canvas.focus();
}
});
})();