From b9ee546bc11b3cc6993b68269e7e481459a85727 Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 27 Jun 2026 10:59:59 +0800 Subject: [PATCH] Fix deploy.sh CRLF line endings for Linux compatibility Co-authored-by: Cursor --- .env.example | 18 +- .gitattributes | 10 + .gitignore | 14 +- DEPLOY.md | 467 ++++++++++++++++--------------- Dockerfile | 28 +- README.md | 130 ++++----- app/auth.py | 218 +++++++-------- app/browser_manager.py | 534 +++++++++++++++++------------------ app/input_handler.py | 140 +++++----- app/main.py | 612 ++++++++++++++++++++--------------------- app/security.py | 186 ++++++------- deploy.sh | 251 +++++++++-------- docker-compose.yml | 34 +-- requirements.txt | 14 +- static/auth.js | 70 ++--- static/index.html | 176 ++++++------ static/index.js | 326 +++++++++++----------- static/style.css | 538 ++++++++++++++++++------------------ static/viewer.html | 60 ++-- static/viewer.js | 434 ++++++++++++++--------------- 20 files changed, 2142 insertions(+), 2118 deletions(-) create mode 100644 .gitattributes diff --git a/.env.example b/.env.example index 49278e0..e20338a 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..41182fc --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore index 3e4590d..d1bf602 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ -.env -data/ -__pycache__/ -*.pyc -.pytest_cache/ -.venv/ -venv/ +.env +data/ +__pycache__/ +*.pyc +.pytest_cache/ +.venv/ +venv/ diff --git a/DEPLOY.md b/DEPLOY.md index 73fdd7c..bb7cc08 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -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 +``` diff --git a/Dockerfile b/Dockerfile index 933bc26..f2adae9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 0d04f16..9bc097b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/auth.py b/app/auth.py index 1948f7c..bcf0fac 100644 --- a/app/auth.py +++ b/app/auth.py @@ -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()) diff --git a/app/browser_manager.py b/app/browser_manager.py index 9d3a154..69cd78a 100644 --- a/app/browser_manager.py +++ b/app/browser_manager.py @@ -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() diff --git a/app/input_handler.py b/app/input_handler.py index 2146180..a9d0fe7 100644 --- a/app/input_handler.py +++ b/app/input_handler.py @@ -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}"} diff --git a/app/main.py b/app/main.py index b792efa..da14ef3 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/security.py b/app/security.py index e1dbb02..2553c27 100644 --- a/app/security.py +++ b/app/security.py @@ -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)) diff --git a/deploy.sh b/deploy.sh index 60f7305..d7099a3 100644 --- a/deploy.sh +++ b/deploy.sh @@ -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 "$@" diff --git a/docker-compose.yml b/docker-compose.yml index f239f2d..70dd453 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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" diff --git a/requirements.txt b/requirements.txt index 1f77e9a..537d94f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/static/auth.js b/static/auth.js index 572b85e..fb0f5cc 100644 --- a/static/auth.js +++ b/static/auth.js @@ -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; + }, +}; diff --git a/static/index.html b/static/index.html index 2a6ae52..cc80e42 100644 --- a/static/index.html +++ b/static/index.html @@ -1,88 +1,88 @@ - - - - - - 云端浏览器 - - - -
-
-

云端浏览器

-

输入网址,在境外服务器上打开并远程操作

-
- - - - - - - - - -
- - - - - + + + + + + 云端浏览器 + + + +
+
+

云端浏览器

+

输入网址,在境外服务器上打开并远程操作

+
+ + + + + + + + + +
+ + + + + diff --git a/static/index.js b/static/index.js index 45fdfc8..c4f7d78 100644 --- a/static/index.js +++ b/static/index.js @@ -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(); diff --git a/static/style.css b/static/style.css index a31199f..6c05f95 100644 --- a/static/style.css +++ b/static/style.css @@ -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; +} diff --git a/static/viewer.html b/static/viewer.html index 6b2961b..2dc962f 100644 --- a/static/viewer.html +++ b/static/viewer.html @@ -1,30 +1,30 @@ - - - - - - 浏览中 - 云端浏览器 - - - -
- - - - - - 连接中... - -
- -
- - -
- - - - + + + + + + 浏览中 - 云端浏览器 + + + +
+ + + + + + 连接中... + +
+ +
+ + +
+ + + + diff --git a/static/viewer.js b/static/viewer.js index 824123b..8dc0b08 100644 --- a/static/viewer.js +++ b/static/viewer.js @@ -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(); + } + }); +})();