Fix deploy.sh CRLF line endings for Linux compatibility
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+9
-9
@@ -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
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
* text=auto
|
||||
*.sh text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.py text eol=lf
|
||||
*.md text eol=lf
|
||||
*.html text eol=lf
|
||||
*.js text eol=lf
|
||||
*.css text eol=lf
|
||||
.env.example text eol=lf
|
||||
+7
-7
@@ -1,7 +1,7 @@
|
||||
.env
|
||||
data/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
data/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
@@ -1,226 +1,241 @@
|
||||
# 云端浏览器部署文档
|
||||
|
||||
本文档说明如何在 Linux 服务器(含宝塔面板环境)上部署云端浏览器。
|
||||
|
||||
- 安装目录:`/opt/cloud-browser`
|
||||
- 运行用户:`root`
|
||||
- 默认端口:`32450`
|
||||
- 默认账号:`admin` / `admin`
|
||||
- 代码仓库:[https://git.bz121.com/dekun/cloud-browser.git](https://git.bz121.com/dekun/cloud-browser.git)
|
||||
|
||||
---
|
||||
|
||||
## 一、环境要求
|
||||
|
||||
| 项目 | 要求 |
|
||||
|------|------|
|
||||
| 系统 | Ubuntu 20.04+ / Debian 11+ / CentOS 7+ |
|
||||
| 内存 | 最低 1GB,推荐 2GB |
|
||||
| Docker | 20.10+ |
|
||||
| Docker Compose | v2+ |
|
||||
| 端口 | 32450(或自定义,需在 `.env` 中修改) |
|
||||
|
||||
> 服务器已安装宝塔面板、Docker、Nginx 均可,**无需额外配置 Nginx**,服务直接监听端口,反向代理请自行操作。
|
||||
|
||||
---
|
||||
|
||||
## 二、一键部署(推荐)
|
||||
|
||||
以 **root** 用户 SSH 登录服务器,执行:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.bz121.com/dekun/cloud-browser/raw/branch/main/deploy.sh -o /tmp/deploy.sh
|
||||
bash /tmp/deploy.sh
|
||||
```
|
||||
|
||||
若仓库尚未推送或 curl 不可用,也可手动克隆后执行:
|
||||
|
||||
```bash
|
||||
git clone https://git.bz121.com/dekun/cloud-browser.git /opt/cloud-browser
|
||||
bash /opt/cloud-browser/deploy.sh
|
||||
```
|
||||
|
||||
### 一键脚本会自动完成
|
||||
|
||||
1. 检测并安装 Docker(如未安装)
|
||||
2. 克隆/更新代码到 `/opt/cloud-browser`
|
||||
3. 创建 `.env` 和 `data/` 数据目录
|
||||
4. 构建并启动 Docker 容器
|
||||
5. 等待健康检查通过
|
||||
6. 输出访问地址和默认账号
|
||||
|
||||
### 部署成功示例输出
|
||||
|
||||
```
|
||||
==========================================
|
||||
云端浏览器部署完成
|
||||
==========================================
|
||||
访问地址: http://1.2.3.4:32450
|
||||
默认账号: admin
|
||||
默认密码: admin
|
||||
安装目录: /opt/cloud-browser
|
||||
==========================================
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、手动部署
|
||||
|
||||
### 1. 克隆代码
|
||||
|
||||
```bash
|
||||
git clone https://git.bz121.com/dekun/cloud-browser.git /opt/cloud-browser
|
||||
cd /opt/cloud-browser
|
||||
```
|
||||
|
||||
### 2. 配置环境变量(可选)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `APP_PORT` | 宿主机端口 | `32450` |
|
||||
| `MAX_SESSIONS` | 最大并发会话 | `1` |
|
||||
| `SESSION_IDLE_TIMEOUT` | 空闲超时(秒) | `1800` |
|
||||
| `VIEWPORT_WIDTH` | 浏览器宽度 | `1280` |
|
||||
| `VIEWPORT_HEIGHT` | 浏览器高度 | `720` |
|
||||
| `SCREENCAST_QUALITY` | 画面质量 10-100 | `80` |
|
||||
|
||||
### 3. 启动服务
|
||||
|
||||
```bash
|
||||
mkdir -p data
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### 4. 验证
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:32450/api/health
|
||||
# {"status":"ok","sessions":0}
|
||||
```
|
||||
|
||||
浏览器访问 `http://服务器IP:32450`,使用 `admin` / `admin` 登录。
|
||||
|
||||
---
|
||||
|
||||
## 四、登录与修改密码
|
||||
|
||||
1. 打开首页,输入默认账号 `admin`、密码 `admin` 登录
|
||||
2. 登录后点击右上角 **「账号设置」**
|
||||
3. 填写当前用户名、当前密码、新用户名、新密码
|
||||
4. 保存后自动退出,使用新凭据重新登录
|
||||
|
||||
账号信息保存在 `/opt/cloud-browser/data/auth.json`,容器重启后不会丢失。
|
||||
|
||||
---
|
||||
|
||||
## 五、反向代理(自行配置)
|
||||
|
||||
服务默认监听 `32450` 端口。若需通过域名 + HTTPS 访问,请在宝塔/Nginx 中自行配置反向代理。
|
||||
|
||||
目标地址:
|
||||
|
||||
```
|
||||
http://127.0.0.1:32450
|
||||
```
|
||||
|
||||
**注意**:浏览页面使用 WebSocket(路径 `/ws/`),反代时需开启 WebSocket 支持(Upgrade 头)。
|
||||
|
||||
---
|
||||
|
||||
## 六、运维命令
|
||||
|
||||
```bash
|
||||
cd /opt/cloud-browser
|
||||
|
||||
# 查看运行状态
|
||||
docker compose ps
|
||||
|
||||
# 查看日志
|
||||
docker compose logs -f app
|
||||
|
||||
# 重启
|
||||
docker compose restart
|
||||
|
||||
# 停止
|
||||
docker compose down
|
||||
|
||||
# 更新代码并重新部署
|
||||
bash deploy.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、防火墙
|
||||
|
||||
确保服务器防火墙 / 安全组放行 **32450** 端口(若直接通过 IP 访问):
|
||||
|
||||
```bash
|
||||
# ufw 示例
|
||||
ufw allow 32450/tcp
|
||||
|
||||
# firewalld 示例
|
||||
firewall-cmd --permanent --add-port=32450/tcp
|
||||
firewall-cmd --reload
|
||||
```
|
||||
|
||||
若仅通过 Nginx 反代访问,可不对公网开放 32450,仅本机 `127.0.0.1` 访问即可。
|
||||
|
||||
---
|
||||
|
||||
## 八、常见问题
|
||||
|
||||
### 部署脚本提示非 root 用户
|
||||
|
||||
```bash
|
||||
sudo su -
|
||||
bash deploy.sh
|
||||
```
|
||||
|
||||
### 端口被占用
|
||||
|
||||
修改 `/opt/cloud-browser/.env` 中的 `APP_PORT`,然后:
|
||||
|
||||
```bash
|
||||
cd /opt/cloud-browser
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 页面黑屏
|
||||
|
||||
```bash
|
||||
docker compose logs -f app
|
||||
```
|
||||
|
||||
常见原因:内存不足(建议 ≥ 2GB)、Chromium 启动失败。
|
||||
|
||||
### WebSocket 连接失败
|
||||
|
||||
若使用了 Nginx 反代,检查是否配置了 WebSocket Upgrade 支持。
|
||||
|
||||
### 忘记密码
|
||||
|
||||
```bash
|
||||
cd /opt/cloud-browser
|
||||
docker compose down
|
||||
rm -f data/auth.json data/secret.key
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
将恢复为默认账号 `admin` / `admin`(**会清除已修改的密码**)。
|
||||
|
||||
---
|
||||
|
||||
## 九、卸载
|
||||
|
||||
```bash
|
||||
cd /opt/cloud-browser
|
||||
docker compose down
|
||||
cd /
|
||||
rm -rf /opt/cloud-browser
|
||||
```
|
||||
# 云端浏览器部署文档
|
||||
|
||||
本文档说明如何在 Linux 服务器(含宝塔面板环境)上部署云端浏览器。
|
||||
|
||||
- 安装目录:`/opt/cloud-browser`
|
||||
- 运行用户:`root`
|
||||
- 默认端口:`32450`
|
||||
- 默认账号:`admin` / `admin`
|
||||
- 代码仓库:[https://git.bz121.com/dekun/cloud-browser.git](https://git.bz121.com/dekun/cloud-browser.git)
|
||||
|
||||
---
|
||||
|
||||
## 一、环境要求
|
||||
|
||||
| 项目 | 要求 |
|
||||
|------|------|
|
||||
| 系统 | Ubuntu 20.04+ / Debian 11+ / CentOS 7+ |
|
||||
| 内存 | 最低 1GB,推荐 2GB |
|
||||
| Docker | 20.10+ |
|
||||
| Docker Compose | v2+ |
|
||||
| 端口 | 32450(或自定义,需在 `.env` 中修改) |
|
||||
|
||||
> 服务器已安装宝塔面板、Docker、Nginx 均可,**无需额外配置 Nginx**,服务直接监听端口,反向代理请自行操作。
|
||||
|
||||
---
|
||||
|
||||
## 二、一键部署(推荐)
|
||||
|
||||
以 **root** 用户 SSH 登录服务器,执行:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.bz121.com/dekun/cloud-browser/raw/branch/main/deploy.sh -o /tmp/deploy.sh
|
||||
bash /tmp/deploy.sh
|
||||
```
|
||||
|
||||
若仓库尚未推送或 curl 不可用,也可手动克隆后执行:
|
||||
|
||||
```bash
|
||||
git clone https://git.bz121.com/dekun/cloud-browser.git /opt/cloud-browser
|
||||
bash /opt/cloud-browser/deploy.sh
|
||||
```
|
||||
|
||||
### 一键脚本会自动完成
|
||||
|
||||
1. 检测并安装 Docker(如未安装)
|
||||
2. 克隆/更新代码到 `/opt/cloud-browser`
|
||||
3. 创建 `.env` 和 `data/` 数据目录
|
||||
4. 构建并启动 Docker 容器
|
||||
5. 等待健康检查通过
|
||||
6. 输出访问地址和默认账号
|
||||
|
||||
### 部署成功示例输出
|
||||
|
||||
```
|
||||
==========================================
|
||||
云端浏览器部署完成
|
||||
==========================================
|
||||
访问地址: http://1.2.3.4:32450
|
||||
默认账号: admin
|
||||
默认密码: admin
|
||||
安装目录: /opt/cloud-browser
|
||||
==========================================
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、手动部署
|
||||
|
||||
### 1. 克隆代码
|
||||
|
||||
```bash
|
||||
git clone https://git.bz121.com/dekun/cloud-browser.git /opt/cloud-browser
|
||||
cd /opt/cloud-browser
|
||||
```
|
||||
|
||||
### 2. 配置环境变量(可选)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `APP_PORT` | 宿主机端口 | `32450` |
|
||||
| `MAX_SESSIONS` | 最大并发会话 | `1` |
|
||||
| `SESSION_IDLE_TIMEOUT` | 空闲超时(秒) | `1800` |
|
||||
| `VIEWPORT_WIDTH` | 浏览器宽度 | `1280` |
|
||||
| `VIEWPORT_HEIGHT` | 浏览器高度 | `720` |
|
||||
| `SCREENCAST_QUALITY` | 画面质量 10-100 | `80` |
|
||||
|
||||
### 3. 启动服务
|
||||
|
||||
```bash
|
||||
mkdir -p data
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### 4. 验证
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:32450/api/health
|
||||
# {"status":"ok","sessions":0}
|
||||
```
|
||||
|
||||
浏览器访问 `http://服务器IP:32450`,使用 `admin` / `admin` 登录。
|
||||
|
||||
---
|
||||
|
||||
## 四、登录与修改密码
|
||||
|
||||
1. 打开首页,输入默认账号 `admin`、密码 `admin` 登录
|
||||
2. 登录后点击右上角 **「账号设置」**
|
||||
3. 填写当前用户名、当前密码、新用户名、新密码
|
||||
4. 保存后自动退出,使用新凭据重新登录
|
||||
|
||||
账号信息保存在 `/opt/cloud-browser/data/auth.json`,容器重启后不会丢失。
|
||||
|
||||
---
|
||||
|
||||
## 五、反向代理(自行配置)
|
||||
|
||||
服务默认监听 `32450` 端口。若需通过域名 + HTTPS 访问,请在宝塔/Nginx 中自行配置反向代理。
|
||||
|
||||
目标地址:
|
||||
|
||||
```
|
||||
http://127.0.0.1:32450
|
||||
```
|
||||
|
||||
**注意**:浏览页面使用 WebSocket(路径 `/ws/`),反代时需开启 WebSocket 支持(Upgrade 头)。
|
||||
|
||||
---
|
||||
|
||||
## 六、运维命令
|
||||
|
||||
```bash
|
||||
cd /opt/cloud-browser
|
||||
|
||||
# 查看运行状态
|
||||
docker compose ps
|
||||
|
||||
# 查看日志
|
||||
docker compose logs -f app
|
||||
|
||||
# 重启
|
||||
docker compose restart
|
||||
|
||||
# 停止
|
||||
docker compose down
|
||||
|
||||
# 更新代码并重新部署
|
||||
bash deploy.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、防火墙
|
||||
|
||||
确保服务器防火墙 / 安全组放行 **32450** 端口(若直接通过 IP 访问):
|
||||
|
||||
```bash
|
||||
# ufw 示例
|
||||
ufw allow 32450/tcp
|
||||
|
||||
# firewalld 示例
|
||||
firewall-cmd --permanent --add-port=32450/tcp
|
||||
firewall-cmd --reload
|
||||
```
|
||||
|
||||
若仅通过 Nginx 反代访问,可不对公网开放 32450,仅本机 `127.0.0.1` 访问即可。
|
||||
|
||||
---
|
||||
|
||||
## 八、常见问题
|
||||
|
||||
### 部署脚本报错 `set: pipefail: invalid option`
|
||||
|
||||
脚本在 Windows 编辑后可能带 CRLF 换行符,在 Linux 上会报错。修复:
|
||||
|
||||
```bash
|
||||
sed -i 's/\r$//' /opt/cloud-browser/deploy.sh
|
||||
bash /opt/cloud-browser/deploy.sh
|
||||
```
|
||||
|
||||
或重新拉取最新代码:
|
||||
|
||||
```bash
|
||||
cd /opt/cloud-browser && git pull && bash deploy.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```bash
|
||||
sudo su -
|
||||
bash deploy.sh
|
||||
```
|
||||
|
||||
### 端口被占用
|
||||
|
||||
修改 `/opt/cloud-browser/.env` 中的 `APP_PORT`,然后:
|
||||
|
||||
```bash
|
||||
cd /opt/cloud-browser
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 页面黑屏
|
||||
|
||||
```bash
|
||||
docker compose logs -f app
|
||||
```
|
||||
|
||||
常见原因:内存不足(建议 ≥ 2GB)、Chromium 启动失败。
|
||||
|
||||
### WebSocket 连接失败
|
||||
|
||||
若使用了 Nginx 反代,检查是否配置了 WebSocket Upgrade 支持。
|
||||
|
||||
### 忘记密码
|
||||
|
||||
```bash
|
||||
cd /opt/cloud-browser
|
||||
docker compose down
|
||||
rm -f data/auth.json data/secret.key
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
将恢复为默认账号 `admin` / `admin`(**会清除已修改的密码**)。
|
||||
|
||||
---
|
||||
|
||||
## 九、卸载
|
||||
|
||||
```bash
|
||||
cd /opt/cloud-browser
|
||||
docker compose down
|
||||
cd /
|
||||
rm -rf /opt/cloud-browser
|
||||
```
|
||||
|
||||
+14
-14
@@ -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"]
|
||||
|
||||
@@ -1,65 +1,65 @@
|
||||
# 云端浏览器
|
||||
|
||||
在境外 VPS 上自部署的轻量远程浏览器:登录后输入网址,由云端 Chromium 打开页面,画面通过 WebSocket 实时回传,支持鼠标键盘操作。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 输入网址即可在云端打开并远程操作
|
||||
- 内置登录鉴权,默认账号 `admin` / `admin`
|
||||
- 前端可修改用户名和密码
|
||||
- Docker 部署,默认端口 **32450**
|
||||
- 反向代理(HTTPS/域名)请自行在宝塔/Nginx 配置
|
||||
|
||||
## 快速体验
|
||||
|
||||
部署完成后访问:
|
||||
|
||||
```
|
||||
http://服务器IP:32450
|
||||
```
|
||||
|
||||
默认账号密码均为 `admin`,**登录后请立即修改**。
|
||||
|
||||
## 文档
|
||||
|
||||
- [部署文档 DEPLOY.md](DEPLOY.md) — 含一键部署说明
|
||||
|
||||
## 技术架构
|
||||
|
||||
```
|
||||
浏览器 → :32450 → FastAPI → Playwright Chromium
|
||||
↑ WebSocket 画面流 / 输入事件
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
cloud-browser/
|
||||
├── app/ # 后端(FastAPI + Playwright)
|
||||
├── static/ # 前端页面
|
||||
├── deploy.sh # 一键部署脚本
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
└── DEPLOY.md # 部署文档
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
cd /opt/cloud-browser
|
||||
docker compose logs -f app # 查看日志
|
||||
docker compose restart # 重启
|
||||
docker compose down # 停止
|
||||
bash deploy.sh # 更新并重新部署
|
||||
```
|
||||
|
||||
## 安全说明
|
||||
|
||||
- 首次部署后务必修改默认密码
|
||||
- 内置 SSRF 防护,禁止访问内网地址
|
||||
- 账号数据保存在 `data/auth.json`(Docker 卷持久化)
|
||||
- 建议通过 Nginx/宝塔配置 HTTPS 后再对外使用
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
# 云端浏览器
|
||||
|
||||
在境外 VPS 上自部署的轻量远程浏览器:登录后输入网址,由云端 Chromium 打开页面,画面通过 WebSocket 实时回传,支持鼠标键盘操作。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 输入网址即可在云端打开并远程操作
|
||||
- 内置登录鉴权,默认账号 `admin` / `admin`
|
||||
- 前端可修改用户名和密码
|
||||
- Docker 部署,默认端口 **32450**
|
||||
- 反向代理(HTTPS/域名)请自行在宝塔/Nginx 配置
|
||||
|
||||
## 快速体验
|
||||
|
||||
部署完成后访问:
|
||||
|
||||
```
|
||||
http://服务器IP:32450
|
||||
```
|
||||
|
||||
默认账号密码均为 `admin`,**登录后请立即修改**。
|
||||
|
||||
## 文档
|
||||
|
||||
- [部署文档 DEPLOY.md](DEPLOY.md) — 含一键部署说明
|
||||
|
||||
## 技术架构
|
||||
|
||||
```
|
||||
浏览器 → :32450 → FastAPI → Playwright Chromium
|
||||
↑ WebSocket 画面流 / 输入事件
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
cloud-browser/
|
||||
├── app/ # 后端(FastAPI + Playwright)
|
||||
├── static/ # 前端页面
|
||||
├── deploy.sh # 一键部署脚本
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
└── DEPLOY.md # 部署文档
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
cd /opt/cloud-browser
|
||||
docker compose logs -f app # 查看日志
|
||||
docker compose restart # 重启
|
||||
docker compose down # 停止
|
||||
bash deploy.sh # 更新并重新部署
|
||||
```
|
||||
|
||||
## 安全说明
|
||||
|
||||
- 首次部署后务必修改默认密码
|
||||
- 内置 SSRF 防护,禁止访问内网地址
|
||||
- 账号数据保存在 `data/auth.json`(Docker 卷持久化)
|
||||
- 建议通过 Nginx/宝塔配置 HTTPS 后再对外使用
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
|
||||
+109
-109
@@ -1,109 +1,109 @@
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
|
||||
DEFAULT_USERNAME = "admin"
|
||||
DEFAULT_PASSWORD = "admin"
|
||||
TOKEN_MAX_AGE = 60 * 60 * 24 * 7 # 7 days
|
||||
|
||||
|
||||
class AuthManager:
|
||||
def __init__(self, data_dir: Path) -> None:
|
||||
self.data_dir = data_dir
|
||||
self.auth_file = data_dir / "auth.json"
|
||||
self.secret_file = data_dir / "secret.key"
|
||||
self._serializer: Optional[URLSafeTimedSerializer] = None
|
||||
self._ensure_data_dir()
|
||||
self._ensure_secret()
|
||||
self._ensure_default_user()
|
||||
|
||||
def _ensure_data_dir(self) -> None:
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _ensure_secret(self) -> None:
|
||||
if not self.secret_file.exists():
|
||||
self.secret_file.write_text(secrets.token_hex(32), encoding="utf-8")
|
||||
secret = self.secret_file.read_text(encoding="utf-8").strip()
|
||||
self._serializer = URLSafeTimedSerializer(secret, salt="cloud-browser-auth")
|
||||
|
||||
def _ensure_default_user(self) -> None:
|
||||
if not self.auth_file.exists():
|
||||
self._save_credentials(DEFAULT_USERNAME, DEFAULT_PASSWORD)
|
||||
|
||||
def _hash_password(self, password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
def _verify_password(self, password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def _load_credentials(self) -> dict:
|
||||
return json.loads(self.auth_file.read_text(encoding="utf-8"))
|
||||
|
||||
def _save_credentials(self, username: str, password: str) -> None:
|
||||
payload = {
|
||||
"username": username,
|
||||
"password_hash": self._hash_password(password),
|
||||
}
|
||||
self.auth_file.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def get_username(self) -> str:
|
||||
return self._load_credentials()["username"]
|
||||
|
||||
def authenticate(self, username: str, password: str) -> bool:
|
||||
creds = self._load_credentials()
|
||||
if username != creds["username"]:
|
||||
return False
|
||||
return self._verify_password(password, creds["password_hash"])
|
||||
|
||||
def create_token(self, username: str) -> str:
|
||||
assert self._serializer is not None
|
||||
return self._serializer.dumps({"username": username, "ts": time.time()})
|
||||
|
||||
def verify_token(self, token: str) -> Optional[str]:
|
||||
if not token or self._serializer is None:
|
||||
return None
|
||||
try:
|
||||
data = self._serializer.loads(token, max_age=TOKEN_MAX_AGE)
|
||||
except (BadSignature, SignatureExpired):
|
||||
return None
|
||||
username = data.get("username")
|
||||
if username != self.get_username():
|
||||
return None
|
||||
return username
|
||||
|
||||
def change_credentials(
|
||||
self,
|
||||
current_username: str,
|
||||
current_password: str,
|
||||
new_username: str,
|
||||
new_password: str,
|
||||
) -> None:
|
||||
creds = self._load_credentials()
|
||||
if current_username != creds["username"]:
|
||||
raise ValueError("当前用户名不正确")
|
||||
if not self._verify_password(current_password, creds["password_hash"]):
|
||||
raise ValueError("当前密码不正确")
|
||||
if len(new_username.strip()) < 2:
|
||||
raise ValueError("新用户名至少 2 个字符")
|
||||
if len(new_password) < 4:
|
||||
raise ValueError("新密码至少 4 个字符")
|
||||
self._save_credentials(new_username.strip(), new_password)
|
||||
|
||||
|
||||
def get_data_dir() -> Path:
|
||||
return Path(os.getenv("DATA_DIR", "/app/data"))
|
||||
|
||||
|
||||
auth_manager = AuthManager(get_data_dir())
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
|
||||
DEFAULT_USERNAME = "admin"
|
||||
DEFAULT_PASSWORD = "admin"
|
||||
TOKEN_MAX_AGE = 60 * 60 * 24 * 7 # 7 days
|
||||
|
||||
|
||||
class AuthManager:
|
||||
def __init__(self, data_dir: Path) -> None:
|
||||
self.data_dir = data_dir
|
||||
self.auth_file = data_dir / "auth.json"
|
||||
self.secret_file = data_dir / "secret.key"
|
||||
self._serializer: Optional[URLSafeTimedSerializer] = None
|
||||
self._ensure_data_dir()
|
||||
self._ensure_secret()
|
||||
self._ensure_default_user()
|
||||
|
||||
def _ensure_data_dir(self) -> None:
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _ensure_secret(self) -> None:
|
||||
if not self.secret_file.exists():
|
||||
self.secret_file.write_text(secrets.token_hex(32), encoding="utf-8")
|
||||
secret = self.secret_file.read_text(encoding="utf-8").strip()
|
||||
self._serializer = URLSafeTimedSerializer(secret, salt="cloud-browser-auth")
|
||||
|
||||
def _ensure_default_user(self) -> None:
|
||||
if not self.auth_file.exists():
|
||||
self._save_credentials(DEFAULT_USERNAME, DEFAULT_PASSWORD)
|
||||
|
||||
def _hash_password(self, password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
def _verify_password(self, password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def _load_credentials(self) -> dict:
|
||||
return json.loads(self.auth_file.read_text(encoding="utf-8"))
|
||||
|
||||
def _save_credentials(self, username: str, password: str) -> None:
|
||||
payload = {
|
||||
"username": username,
|
||||
"password_hash": self._hash_password(password),
|
||||
}
|
||||
self.auth_file.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def get_username(self) -> str:
|
||||
return self._load_credentials()["username"]
|
||||
|
||||
def authenticate(self, username: str, password: str) -> bool:
|
||||
creds = self._load_credentials()
|
||||
if username != creds["username"]:
|
||||
return False
|
||||
return self._verify_password(password, creds["password_hash"])
|
||||
|
||||
def create_token(self, username: str) -> str:
|
||||
assert self._serializer is not None
|
||||
return self._serializer.dumps({"username": username, "ts": time.time()})
|
||||
|
||||
def verify_token(self, token: str) -> Optional[str]:
|
||||
if not token or self._serializer is None:
|
||||
return None
|
||||
try:
|
||||
data = self._serializer.loads(token, max_age=TOKEN_MAX_AGE)
|
||||
except (BadSignature, SignatureExpired):
|
||||
return None
|
||||
username = data.get("username")
|
||||
if username != self.get_username():
|
||||
return None
|
||||
return username
|
||||
|
||||
def change_credentials(
|
||||
self,
|
||||
current_username: str,
|
||||
current_password: str,
|
||||
new_username: str,
|
||||
new_password: str,
|
||||
) -> None:
|
||||
creds = self._load_credentials()
|
||||
if current_username != creds["username"]:
|
||||
raise ValueError("当前用户名不正确")
|
||||
if not self._verify_password(current_password, creds["password_hash"]):
|
||||
raise ValueError("当前密码不正确")
|
||||
if len(new_username.strip()) < 2:
|
||||
raise ValueError("新用户名至少 2 个字符")
|
||||
if len(new_password) < 4:
|
||||
raise ValueError("新密码至少 4 个字符")
|
||||
self._save_credentials(new_username.strip(), new_password)
|
||||
|
||||
|
||||
def get_data_dir() -> Path:
|
||||
return Path(os.getenv("DATA_DIR", "/app/data"))
|
||||
|
||||
|
||||
auth_manager = AuthManager(get_data_dir())
|
||||
|
||||
+267
-267
@@ -1,267 +1,267 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
from playwright.async_api import Browser, BrowserContext, Page, Playwright, async_playwright
|
||||
|
||||
from app.security import get_idle_timeout, get_screencast_quality, get_viewport_size
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrowserSession:
|
||||
session_id: str
|
||||
url: str
|
||||
playwright: Playwright
|
||||
browser: Browser
|
||||
context: BrowserContext
|
||||
page: Page
|
||||
cdp: Any
|
||||
created_at: float = field(default_factory=time.time)
|
||||
last_activity: float = field(default_factory=time.time)
|
||||
subscribers: set[asyncio.Queue] = field(default_factory=set)
|
||||
screencast_task: Optional[asyncio.Task] = None
|
||||
idle_task: Optional[asyncio.Task] = None
|
||||
closed: bool = False
|
||||
viewport_width: int = 1280
|
||||
viewport_height: int = 720
|
||||
|
||||
|
||||
class BrowserManager:
|
||||
def __init__(self, max_sessions: int = 1) -> None:
|
||||
self.max_sessions = max_sessions
|
||||
self._sessions: dict[str, BrowserSession] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def create_session(self, url: str) -> BrowserSession:
|
||||
async with self._lock:
|
||||
if len(self._sessions) >= self.max_sessions:
|
||||
raise RuntimeError(
|
||||
f"已达最大会话数 ({self.max_sessions}),请先关闭现有会话"
|
||||
)
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
width, height = get_viewport_size()
|
||||
quality = get_screencast_quality()
|
||||
|
||||
playwright = await async_playwright().start()
|
||||
browser = await playwright.chromium.launch(
|
||||
headless=True,
|
||||
args=[
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
],
|
||||
)
|
||||
context = await browser.new_context(
|
||||
viewport={"width": width, "height": height},
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/131.0.0.0 Safari/537.36"
|
||||
),
|
||||
)
|
||||
page = await context.new_page()
|
||||
cdp = await context.new_cdp_session(page)
|
||||
|
||||
session = BrowserSession(
|
||||
session_id=session_id,
|
||||
url=url,
|
||||
playwright=playwright,
|
||||
browser=browser,
|
||||
context=context,
|
||||
page=page,
|
||||
cdp=cdp,
|
||||
viewport_width=width,
|
||||
viewport_height=height,
|
||||
)
|
||||
|
||||
await page.goto(url, wait_until="domcontentloaded", timeout=60000)
|
||||
session.url = page.url
|
||||
|
||||
page.on(
|
||||
"framenavigated",
|
||||
lambda frame: asyncio.create_task(
|
||||
self._on_frame_navigated(session, frame)
|
||||
),
|
||||
)
|
||||
|
||||
session.screencast_task = asyncio.create_task(
|
||||
self._run_screencast(session, quality)
|
||||
)
|
||||
session.idle_task = asyncio.create_task(self._watch_idle(session))
|
||||
|
||||
self._sessions[session_id] = session
|
||||
return session
|
||||
|
||||
def session_count(self) -> int:
|
||||
return len(self._sessions)
|
||||
|
||||
async def get_session(self, session_id: str) -> Optional[BrowserSession]:
|
||||
return self._sessions.get(session_id)
|
||||
|
||||
async def close_session(self, session_id: str) -> None:
|
||||
async with self._lock:
|
||||
session = self._sessions.pop(session_id, None)
|
||||
if session:
|
||||
await self._cleanup_session(session)
|
||||
|
||||
async def close_all(self) -> None:
|
||||
async with self._lock:
|
||||
session_ids = list(self._sessions.keys())
|
||||
for session_id in session_ids:
|
||||
await self.close_session(session_id)
|
||||
|
||||
def touch(self, session: BrowserSession) -> None:
|
||||
session.last_activity = time.time()
|
||||
|
||||
async def navigate(self, session: BrowserSession, url: str) -> str:
|
||||
self.touch(session)
|
||||
await session.page.goto(url, wait_until="domcontentloaded", timeout=60000)
|
||||
session.url = session.page.url
|
||||
return session.url
|
||||
|
||||
async def go_back(self, session: BrowserSession) -> str:
|
||||
self.touch(session)
|
||||
await session.page.go_back(wait_until="domcontentloaded", timeout=30000)
|
||||
session.url = session.page.url
|
||||
return session.url
|
||||
|
||||
async def go_forward(self, session: BrowserSession) -> str:
|
||||
self.touch(session)
|
||||
await session.page.go_forward(wait_until="domcontentloaded", timeout=30000)
|
||||
session.url = session.page.url
|
||||
return session.url
|
||||
|
||||
async def reload(self, session: BrowserSession) -> str:
|
||||
self.touch(session)
|
||||
await session.page.reload(wait_until="domcontentloaded", timeout=60000)
|
||||
session.url = session.page.url
|
||||
return session.url
|
||||
|
||||
def subscribe(self, session: BrowserSession) -> asyncio.Queue:
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=8)
|
||||
session.subscribers.add(queue)
|
||||
return queue
|
||||
|
||||
def unsubscribe(self, session: BrowserSession, queue: asyncio.Queue) -> None:
|
||||
session.subscribers.discard(queue)
|
||||
|
||||
async def _broadcast(self, session: BrowserSession, message: dict) -> None:
|
||||
dead: list[asyncio.Queue] = []
|
||||
for queue in session.subscribers:
|
||||
try:
|
||||
queue.put_nowait(message)
|
||||
except asyncio.QueueFull:
|
||||
try:
|
||||
queue.get_nowait()
|
||||
queue.put_nowait(message)
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
except Exception:
|
||||
dead.append(queue)
|
||||
for queue in dead:
|
||||
session.subscribers.discard(queue)
|
||||
|
||||
async def _on_frame_navigated(self, session: BrowserSession, frame) -> None:
|
||||
if session.closed or frame != session.page.main_frame:
|
||||
return
|
||||
session.url = session.page.url
|
||||
await self._broadcast(session, {"type": "url_update", "url": session.url})
|
||||
|
||||
async def _run_screencast(self, session: BrowserSession, quality: int) -> None:
|
||||
async def on_screencast_frame(params: dict) -> None:
|
||||
if session.closed:
|
||||
return
|
||||
data = params.get("data", "")
|
||||
session_id = params.get("sessionId")
|
||||
try:
|
||||
await session.cdp.send(
|
||||
"Page.screencastFrameAck", {"sessionId": session_id}
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
frame_bytes = base64.b64decode(data)
|
||||
except Exception:
|
||||
return
|
||||
await self._broadcast(
|
||||
session,
|
||||
{
|
||||
"type": "frame",
|
||||
"data": frame_bytes,
|
||||
"url": session.url,
|
||||
"width": session.viewport_width,
|
||||
"height": session.viewport_height,
|
||||
},
|
||||
)
|
||||
|
||||
def schedule_frame(params: dict) -> None:
|
||||
asyncio.create_task(on_screencast_frame(params))
|
||||
|
||||
session.cdp.on("Page.screencastFrame", schedule_frame)
|
||||
|
||||
await session.cdp.send(
|
||||
"Page.startScreencast",
|
||||
{
|
||||
"format": "jpeg",
|
||||
"quality": quality,
|
||||
"maxWidth": session.viewport_width,
|
||||
"maxHeight": session.viewport_height,
|
||||
"everyNthFrame": 1,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
while not session.closed:
|
||||
await asyncio.sleep(1)
|
||||
finally:
|
||||
try:
|
||||
await session.cdp.send("Page.stopScreencast")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _watch_idle(self, session: BrowserSession) -> None:
|
||||
timeout = get_idle_timeout()
|
||||
try:
|
||||
while not session.closed:
|
||||
await asyncio.sleep(30)
|
||||
if time.time() - session.last_activity > timeout:
|
||||
await self.close_session(session.session_id)
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def _cleanup_session(self, session: BrowserSession) -> None:
|
||||
if session.closed:
|
||||
return
|
||||
session.closed = True
|
||||
|
||||
for task in (session.screencast_task, session.idle_task):
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
await self._broadcast(session, {"type": "closed", "reason": "session_ended"})
|
||||
|
||||
try:
|
||||
await session.context.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await session.browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await session.playwright.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
browser_manager = BrowserManager()
|
||||
import asyncio
|
||||
import base64
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
from playwright.async_api import Browser, BrowserContext, Page, Playwright, async_playwright
|
||||
|
||||
from app.security import get_idle_timeout, get_screencast_quality, get_viewport_size
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrowserSession:
|
||||
session_id: str
|
||||
url: str
|
||||
playwright: Playwright
|
||||
browser: Browser
|
||||
context: BrowserContext
|
||||
page: Page
|
||||
cdp: Any
|
||||
created_at: float = field(default_factory=time.time)
|
||||
last_activity: float = field(default_factory=time.time)
|
||||
subscribers: set[asyncio.Queue] = field(default_factory=set)
|
||||
screencast_task: Optional[asyncio.Task] = None
|
||||
idle_task: Optional[asyncio.Task] = None
|
||||
closed: bool = False
|
||||
viewport_width: int = 1280
|
||||
viewport_height: int = 720
|
||||
|
||||
|
||||
class BrowserManager:
|
||||
def __init__(self, max_sessions: int = 1) -> None:
|
||||
self.max_sessions = max_sessions
|
||||
self._sessions: dict[str, BrowserSession] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def create_session(self, url: str) -> BrowserSession:
|
||||
async with self._lock:
|
||||
if len(self._sessions) >= self.max_sessions:
|
||||
raise RuntimeError(
|
||||
f"已达最大会话数 ({self.max_sessions}),请先关闭现有会话"
|
||||
)
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
width, height = get_viewport_size()
|
||||
quality = get_screencast_quality()
|
||||
|
||||
playwright = await async_playwright().start()
|
||||
browser = await playwright.chromium.launch(
|
||||
headless=True,
|
||||
args=[
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
],
|
||||
)
|
||||
context = await browser.new_context(
|
||||
viewport={"width": width, "height": height},
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/131.0.0.0 Safari/537.36"
|
||||
),
|
||||
)
|
||||
page = await context.new_page()
|
||||
cdp = await context.new_cdp_session(page)
|
||||
|
||||
session = BrowserSession(
|
||||
session_id=session_id,
|
||||
url=url,
|
||||
playwright=playwright,
|
||||
browser=browser,
|
||||
context=context,
|
||||
page=page,
|
||||
cdp=cdp,
|
||||
viewport_width=width,
|
||||
viewport_height=height,
|
||||
)
|
||||
|
||||
await page.goto(url, wait_until="domcontentloaded", timeout=60000)
|
||||
session.url = page.url
|
||||
|
||||
page.on(
|
||||
"framenavigated",
|
||||
lambda frame: asyncio.create_task(
|
||||
self._on_frame_navigated(session, frame)
|
||||
),
|
||||
)
|
||||
|
||||
session.screencast_task = asyncio.create_task(
|
||||
self._run_screencast(session, quality)
|
||||
)
|
||||
session.idle_task = asyncio.create_task(self._watch_idle(session))
|
||||
|
||||
self._sessions[session_id] = session
|
||||
return session
|
||||
|
||||
def session_count(self) -> int:
|
||||
return len(self._sessions)
|
||||
|
||||
async def get_session(self, session_id: str) -> Optional[BrowserSession]:
|
||||
return self._sessions.get(session_id)
|
||||
|
||||
async def close_session(self, session_id: str) -> None:
|
||||
async with self._lock:
|
||||
session = self._sessions.pop(session_id, None)
|
||||
if session:
|
||||
await self._cleanup_session(session)
|
||||
|
||||
async def close_all(self) -> None:
|
||||
async with self._lock:
|
||||
session_ids = list(self._sessions.keys())
|
||||
for session_id in session_ids:
|
||||
await self.close_session(session_id)
|
||||
|
||||
def touch(self, session: BrowserSession) -> None:
|
||||
session.last_activity = time.time()
|
||||
|
||||
async def navigate(self, session: BrowserSession, url: str) -> str:
|
||||
self.touch(session)
|
||||
await session.page.goto(url, wait_until="domcontentloaded", timeout=60000)
|
||||
session.url = session.page.url
|
||||
return session.url
|
||||
|
||||
async def go_back(self, session: BrowserSession) -> str:
|
||||
self.touch(session)
|
||||
await session.page.go_back(wait_until="domcontentloaded", timeout=30000)
|
||||
session.url = session.page.url
|
||||
return session.url
|
||||
|
||||
async def go_forward(self, session: BrowserSession) -> str:
|
||||
self.touch(session)
|
||||
await session.page.go_forward(wait_until="domcontentloaded", timeout=30000)
|
||||
session.url = session.page.url
|
||||
return session.url
|
||||
|
||||
async def reload(self, session: BrowserSession) -> str:
|
||||
self.touch(session)
|
||||
await session.page.reload(wait_until="domcontentloaded", timeout=60000)
|
||||
session.url = session.page.url
|
||||
return session.url
|
||||
|
||||
def subscribe(self, session: BrowserSession) -> asyncio.Queue:
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=8)
|
||||
session.subscribers.add(queue)
|
||||
return queue
|
||||
|
||||
def unsubscribe(self, session: BrowserSession, queue: asyncio.Queue) -> None:
|
||||
session.subscribers.discard(queue)
|
||||
|
||||
async def _broadcast(self, session: BrowserSession, message: dict) -> None:
|
||||
dead: list[asyncio.Queue] = []
|
||||
for queue in session.subscribers:
|
||||
try:
|
||||
queue.put_nowait(message)
|
||||
except asyncio.QueueFull:
|
||||
try:
|
||||
queue.get_nowait()
|
||||
queue.put_nowait(message)
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
except Exception:
|
||||
dead.append(queue)
|
||||
for queue in dead:
|
||||
session.subscribers.discard(queue)
|
||||
|
||||
async def _on_frame_navigated(self, session: BrowserSession, frame) -> None:
|
||||
if session.closed or frame != session.page.main_frame:
|
||||
return
|
||||
session.url = session.page.url
|
||||
await self._broadcast(session, {"type": "url_update", "url": session.url})
|
||||
|
||||
async def _run_screencast(self, session: BrowserSession, quality: int) -> None:
|
||||
async def on_screencast_frame(params: dict) -> None:
|
||||
if session.closed:
|
||||
return
|
||||
data = params.get("data", "")
|
||||
session_id = params.get("sessionId")
|
||||
try:
|
||||
await session.cdp.send(
|
||||
"Page.screencastFrameAck", {"sessionId": session_id}
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
frame_bytes = base64.b64decode(data)
|
||||
except Exception:
|
||||
return
|
||||
await self._broadcast(
|
||||
session,
|
||||
{
|
||||
"type": "frame",
|
||||
"data": frame_bytes,
|
||||
"url": session.url,
|
||||
"width": session.viewport_width,
|
||||
"height": session.viewport_height,
|
||||
},
|
||||
)
|
||||
|
||||
def schedule_frame(params: dict) -> None:
|
||||
asyncio.create_task(on_screencast_frame(params))
|
||||
|
||||
session.cdp.on("Page.screencastFrame", schedule_frame)
|
||||
|
||||
await session.cdp.send(
|
||||
"Page.startScreencast",
|
||||
{
|
||||
"format": "jpeg",
|
||||
"quality": quality,
|
||||
"maxWidth": session.viewport_width,
|
||||
"maxHeight": session.viewport_height,
|
||||
"everyNthFrame": 1,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
while not session.closed:
|
||||
await asyncio.sleep(1)
|
||||
finally:
|
||||
try:
|
||||
await session.cdp.send("Page.stopScreencast")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _watch_idle(self, session: BrowserSession) -> None:
|
||||
timeout = get_idle_timeout()
|
||||
try:
|
||||
while not session.closed:
|
||||
await asyncio.sleep(30)
|
||||
if time.time() - session.last_activity > timeout:
|
||||
await self.close_session(session.session_id)
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def _cleanup_session(self, session: BrowserSession) -> None:
|
||||
if session.closed:
|
||||
return
|
||||
session.closed = True
|
||||
|
||||
for task in (session.screencast_task, session.idle_task):
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
await self._broadcast(session, {"type": "closed", "reason": "session_ended"})
|
||||
|
||||
try:
|
||||
await session.context.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await session.browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await session.playwright.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
browser_manager = BrowserManager()
|
||||
|
||||
+70
-70
@@ -1,70 +1,70 @@
|
||||
from typing import Any
|
||||
|
||||
from app.browser_manager import BrowserManager, BrowserSession
|
||||
|
||||
|
||||
async def handle_input(
|
||||
manager: BrowserManager,
|
||||
session: BrowserSession,
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
action = payload.get("action")
|
||||
if not action:
|
||||
return None
|
||||
|
||||
manager.touch(session)
|
||||
page = session.page
|
||||
|
||||
if action == "click":
|
||||
x = float(payload.get("x", 0))
|
||||
y = float(payload.get("y", 0))
|
||||
button = payload.get("button", "left")
|
||||
await page.mouse.click(x, y, button=button)
|
||||
return {"type": "ack", "action": action}
|
||||
|
||||
if action == "dblclick":
|
||||
x = float(payload.get("x", 0))
|
||||
y = float(payload.get("y", 0))
|
||||
await page.mouse.dblclick(x, y)
|
||||
return {"type": "ack", "action": action}
|
||||
|
||||
if action == "mousemove":
|
||||
x = float(payload.get("x", 0))
|
||||
y = float(payload.get("y", 0))
|
||||
await page.mouse.move(x, y)
|
||||
return None
|
||||
|
||||
if action == "wheel":
|
||||
delta_x = float(payload.get("deltaX", 0))
|
||||
delta_y = float(payload.get("deltaY", 0))
|
||||
await page.mouse.wheel(delta_x, delta_y)
|
||||
return {"type": "ack", "action": action}
|
||||
|
||||
if action == "keydown":
|
||||
key = payload.get("key")
|
||||
if not key:
|
||||
return None
|
||||
await page.keyboard.down(key)
|
||||
return {"type": "ack", "action": action}
|
||||
|
||||
if action == "keyup":
|
||||
key = payload.get("key")
|
||||
if not key:
|
||||
return None
|
||||
await page.keyboard.up(key)
|
||||
return {"type": "ack", "action": action}
|
||||
|
||||
if action == "type":
|
||||
text = payload.get("text", "")
|
||||
if text:
|
||||
await page.keyboard.type(text)
|
||||
return {"type": "ack", "action": action}
|
||||
|
||||
if action == "press":
|
||||
key = payload.get("key")
|
||||
if not key:
|
||||
return None
|
||||
await page.keyboard.press(key)
|
||||
return {"type": "ack", "action": action}
|
||||
|
||||
return {"type": "error", "message": f"未知操作: {action}"}
|
||||
from typing import Any
|
||||
|
||||
from app.browser_manager import BrowserManager, BrowserSession
|
||||
|
||||
|
||||
async def handle_input(
|
||||
manager: BrowserManager,
|
||||
session: BrowserSession,
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
action = payload.get("action")
|
||||
if not action:
|
||||
return None
|
||||
|
||||
manager.touch(session)
|
||||
page = session.page
|
||||
|
||||
if action == "click":
|
||||
x = float(payload.get("x", 0))
|
||||
y = float(payload.get("y", 0))
|
||||
button = payload.get("button", "left")
|
||||
await page.mouse.click(x, y, button=button)
|
||||
return {"type": "ack", "action": action}
|
||||
|
||||
if action == "dblclick":
|
||||
x = float(payload.get("x", 0))
|
||||
y = float(payload.get("y", 0))
|
||||
await page.mouse.dblclick(x, y)
|
||||
return {"type": "ack", "action": action}
|
||||
|
||||
if action == "mousemove":
|
||||
x = float(payload.get("x", 0))
|
||||
y = float(payload.get("y", 0))
|
||||
await page.mouse.move(x, y)
|
||||
return None
|
||||
|
||||
if action == "wheel":
|
||||
delta_x = float(payload.get("deltaX", 0))
|
||||
delta_y = float(payload.get("deltaY", 0))
|
||||
await page.mouse.wheel(delta_x, delta_y)
|
||||
return {"type": "ack", "action": action}
|
||||
|
||||
if action == "keydown":
|
||||
key = payload.get("key")
|
||||
if not key:
|
||||
return None
|
||||
await page.keyboard.down(key)
|
||||
return {"type": "ack", "action": action}
|
||||
|
||||
if action == "keyup":
|
||||
key = payload.get("key")
|
||||
if not key:
|
||||
return None
|
||||
await page.keyboard.up(key)
|
||||
return {"type": "ack", "action": action}
|
||||
|
||||
if action == "type":
|
||||
text = payload.get("text", "")
|
||||
if text:
|
||||
await page.keyboard.type(text)
|
||||
return {"type": "ack", "action": action}
|
||||
|
||||
if action == "press":
|
||||
key = payload.get("key")
|
||||
if not key:
|
||||
return None
|
||||
await page.keyboard.press(key)
|
||||
return {"type": "ack", "action": action}
|
||||
|
||||
return {"type": "error", "message": f"未知操作: {action}"}
|
||||
|
||||
+306
-306
@@ -1,306 +1,306 @@
|
||||
import asyncio
|
||||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Cookie, Depends, FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.auth import auth_manager
|
||||
from app.browser_manager import browser_manager
|
||||
from app.input_handler import handle_input
|
||||
from app.security import SecurityError, get_max_sessions, validate_url
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
|
||||
SESSION_COOKIE = "cloud_browser_token"
|
||||
|
||||
|
||||
class CreateSessionRequest(BaseModel):
|
||||
url: str = Field(..., min_length=1, max_length=2048)
|
||||
|
||||
|
||||
class CreateSessionResponse(BaseModel):
|
||||
session_id: str
|
||||
url: str
|
||||
|
||||
|
||||
class NavigateRequest(BaseModel):
|
||||
url: str = Field(..., min_length=1, max_length=2048)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str = Field(..., min_length=1, max_length=64)
|
||||
password: str = Field(..., min_length=1, max_length=128)
|
||||
|
||||
|
||||
class ChangeCredentialsRequest(BaseModel):
|
||||
current_username: str = Field(..., min_length=1, max_length=64)
|
||||
current_password: str = Field(..., min_length=1, max_length=128)
|
||||
new_username: str = Field(..., min_length=2, max_length=64)
|
||||
new_password: str = Field(..., min_length=4, max_length=128)
|
||||
|
||||
|
||||
def get_current_user(token: Optional[str] = Cookie(None, alias=SESSION_COOKIE)) -> str:
|
||||
username = auth_manager.verify_token(token or "")
|
||||
if not username:
|
||||
raise HTTPException(status_code=401, detail="未登录或登录已过期")
|
||||
return username
|
||||
|
||||
|
||||
def _set_auth_cookie(response: JSONResponse, token: str) -> JSONResponse:
|
||||
response.set_cookie(
|
||||
key=SESSION_COOKIE,
|
||||
value=token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
max_age=60 * 60 * 24 * 7,
|
||||
path="/",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
browser_manager.max_sessions = get_max_sessions()
|
||||
yield
|
||||
await browser_manager.close_all()
|
||||
|
||||
|
||||
app = FastAPI(title="Cloud Browser", lifespan=lifespan)
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index():
|
||||
return FileResponse(STATIC_DIR / "index.html")
|
||||
|
||||
|
||||
@app.get("/view/{session_id}", response_class=HTMLResponse)
|
||||
async def view_page(session_id: str):
|
||||
session = await browser_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在或已过期")
|
||||
return FileResponse(STATIC_DIR / "viewer.html")
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok", "sessions": browser_manager.session_count}
|
||||
|
||||
|
||||
@app.get("/api/auth/me")
|
||||
async def auth_me(user: str = Depends(get_current_user)):
|
||||
return {"username": user}
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
async def auth_login(body: LoginRequest):
|
||||
if not auth_manager.authenticate(body.username, body.password):
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
token = auth_manager.create_token(body.username)
|
||||
response = JSONResponse({"username": body.username, "message": "登录成功"})
|
||||
return _set_auth_cookie(response, token)
|
||||
|
||||
|
||||
@app.post("/api/auth/logout")
|
||||
async def auth_logout():
|
||||
response = JSONResponse({"message": "已退出登录"})
|
||||
response.delete_cookie(SESSION_COOKIE, path="/")
|
||||
return response
|
||||
|
||||
|
||||
@app.post("/api/auth/change-credentials")
|
||||
async def change_credentials(
|
||||
body: ChangeCredentialsRequest,
|
||||
user: str = Depends(get_current_user),
|
||||
):
|
||||
if body.current_username != user:
|
||||
raise HTTPException(status_code=403, detail="当前用户名与登录账号不一致")
|
||||
try:
|
||||
auth_manager.change_credentials(
|
||||
body.current_username,
|
||||
body.current_password,
|
||||
body.new_username,
|
||||
body.new_password,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
token = auth_manager.create_token(body.new_username)
|
||||
response = JSONResponse({"username": body.new_username, "message": "账号已更新"})
|
||||
return _set_auth_cookie(response, token)
|
||||
|
||||
|
||||
@app.post("/api/session", response_model=CreateSessionResponse)
|
||||
async def create_session(body: CreateSessionRequest, user: str = Depends(get_current_user)):
|
||||
try:
|
||||
url = validate_url(body.url)
|
||||
except SecurityError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
try:
|
||||
session = await browser_manager.create_session(url)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=429, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"创建会话失败: {exc}") from exc
|
||||
|
||||
return CreateSessionResponse(session_id=session.session_id, url=session.url)
|
||||
|
||||
|
||||
@app.delete("/api/session/{session_id}")
|
||||
async def delete_session(session_id: str, user: str = Depends(get_current_user)):
|
||||
session = await browser_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
await browser_manager.close_session(session_id)
|
||||
return {"status": "closed"}
|
||||
|
||||
|
||||
@app.post("/api/session/{session_id}/navigate")
|
||||
async def navigate_session(
|
||||
session_id: str,
|
||||
body: NavigateRequest,
|
||||
user: str = Depends(get_current_user),
|
||||
):
|
||||
session = await browser_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
|
||||
try:
|
||||
url = validate_url(body.url)
|
||||
except SecurityError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
try:
|
||||
current_url = await browser_manager.navigate(session, url)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"导航失败: {exc}") from exc
|
||||
|
||||
return {"url": current_url}
|
||||
|
||||
|
||||
@app.post("/api/session/{session_id}/back")
|
||||
async def go_back(session_id: str, user: str = Depends(get_current_user)):
|
||||
session = await browser_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
try:
|
||||
url = await browser_manager.go_back(session)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"无法后退: {exc}") from exc
|
||||
return {"url": url}
|
||||
|
||||
|
||||
@app.post("/api/session/{session_id}/forward")
|
||||
async def go_forward(session_id: str, user: str = Depends(get_current_user)):
|
||||
session = await browser_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
try:
|
||||
url = await browser_manager.go_forward(session)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"无法前进: {exc}") from exc
|
||||
return {"url": url}
|
||||
|
||||
|
||||
@app.post("/api/session/{session_id}/reload")
|
||||
async def reload_page(session_id: str, user: str = Depends(get_current_user)):
|
||||
session = await browser_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
try:
|
||||
url = await browser_manager.reload(session)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"刷新失败: {exc}") from exc
|
||||
return {"url": url}
|
||||
|
||||
|
||||
@app.websocket("/ws/{session_id}")
|
||||
async def websocket_stream(
|
||||
websocket: WebSocket,
|
||||
session_id: str,
|
||||
cloud_browser_token: Optional[str] = Cookie(None),
|
||||
):
|
||||
if not auth_manager.verify_token(cloud_browser_token or ""):
|
||||
await websocket.close(code=4401, reason="未登录")
|
||||
return
|
||||
|
||||
session = await browser_manager.get_session(session_id)
|
||||
if not session:
|
||||
await websocket.close(code=4404, reason="会话不存在")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
queue = browser_manager.subscribe(session)
|
||||
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "init",
|
||||
"url": session.url,
|
||||
"width": session.viewport_width,
|
||||
"height": session.viewport_height,
|
||||
}
|
||||
)
|
||||
|
||||
async def forward_frames():
|
||||
while not session.closed:
|
||||
try:
|
||||
message = await asyncio.wait_for(queue.get(), timeout=30)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
|
||||
if message.get("type") == "frame":
|
||||
await websocket.send_bytes(message["data"])
|
||||
if message.get("url") and message["url"] != session.url:
|
||||
await websocket.send_json({"type": "url", "url": message["url"]})
|
||||
elif message.get("type") == "closed":
|
||||
await websocket.send_json(message)
|
||||
break
|
||||
|
||||
forward_task = asyncio.create_task(forward_frames())
|
||||
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
await websocket.send_json({"type": "error", "message": "无效 JSON"})
|
||||
continue
|
||||
|
||||
action_type = payload.get("type", "input")
|
||||
if action_type == "ping":
|
||||
browser_manager.touch(session)
|
||||
await websocket.send_json({"type": "pong"})
|
||||
continue
|
||||
|
||||
if action_type == "navigate":
|
||||
try:
|
||||
url = validate_url(payload.get("url", ""))
|
||||
current_url = await browser_manager.navigate(session, url)
|
||||
await websocket.send_json({"type": "url", "url": current_url})
|
||||
except SecurityError as exc:
|
||||
await websocket.send_json({"type": "error", "message": str(exc)})
|
||||
except Exception as exc:
|
||||
await websocket.send_json(
|
||||
{"type": "error", "message": f"导航失败: {exc}"}
|
||||
)
|
||||
continue
|
||||
|
||||
result = await handle_input(browser_manager, session, payload)
|
||||
if result:
|
||||
await websocket.send_json(result)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
forward_task.cancel()
|
||||
try:
|
||||
await forward_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
browser_manager.unsubscribe(session, queue)
|
||||
import asyncio
|
||||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Cookie, Depends, FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.auth import auth_manager
|
||||
from app.browser_manager import browser_manager
|
||||
from app.input_handler import handle_input
|
||||
from app.security import SecurityError, get_max_sessions, validate_url
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
|
||||
SESSION_COOKIE = "cloud_browser_token"
|
||||
|
||||
|
||||
class CreateSessionRequest(BaseModel):
|
||||
url: str = Field(..., min_length=1, max_length=2048)
|
||||
|
||||
|
||||
class CreateSessionResponse(BaseModel):
|
||||
session_id: str
|
||||
url: str
|
||||
|
||||
|
||||
class NavigateRequest(BaseModel):
|
||||
url: str = Field(..., min_length=1, max_length=2048)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str = Field(..., min_length=1, max_length=64)
|
||||
password: str = Field(..., min_length=1, max_length=128)
|
||||
|
||||
|
||||
class ChangeCredentialsRequest(BaseModel):
|
||||
current_username: str = Field(..., min_length=1, max_length=64)
|
||||
current_password: str = Field(..., min_length=1, max_length=128)
|
||||
new_username: str = Field(..., min_length=2, max_length=64)
|
||||
new_password: str = Field(..., min_length=4, max_length=128)
|
||||
|
||||
|
||||
def get_current_user(token: Optional[str] = Cookie(None, alias=SESSION_COOKIE)) -> str:
|
||||
username = auth_manager.verify_token(token or "")
|
||||
if not username:
|
||||
raise HTTPException(status_code=401, detail="未登录或登录已过期")
|
||||
return username
|
||||
|
||||
|
||||
def _set_auth_cookie(response: JSONResponse, token: str) -> JSONResponse:
|
||||
response.set_cookie(
|
||||
key=SESSION_COOKIE,
|
||||
value=token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
max_age=60 * 60 * 24 * 7,
|
||||
path="/",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
browser_manager.max_sessions = get_max_sessions()
|
||||
yield
|
||||
await browser_manager.close_all()
|
||||
|
||||
|
||||
app = FastAPI(title="Cloud Browser", lifespan=lifespan)
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index():
|
||||
return FileResponse(STATIC_DIR / "index.html")
|
||||
|
||||
|
||||
@app.get("/view/{session_id}", response_class=HTMLResponse)
|
||||
async def view_page(session_id: str):
|
||||
session = await browser_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在或已过期")
|
||||
return FileResponse(STATIC_DIR / "viewer.html")
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok", "sessions": browser_manager.session_count}
|
||||
|
||||
|
||||
@app.get("/api/auth/me")
|
||||
async def auth_me(user: str = Depends(get_current_user)):
|
||||
return {"username": user}
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
async def auth_login(body: LoginRequest):
|
||||
if not auth_manager.authenticate(body.username, body.password):
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
token = auth_manager.create_token(body.username)
|
||||
response = JSONResponse({"username": body.username, "message": "登录成功"})
|
||||
return _set_auth_cookie(response, token)
|
||||
|
||||
|
||||
@app.post("/api/auth/logout")
|
||||
async def auth_logout():
|
||||
response = JSONResponse({"message": "已退出登录"})
|
||||
response.delete_cookie(SESSION_COOKIE, path="/")
|
||||
return response
|
||||
|
||||
|
||||
@app.post("/api/auth/change-credentials")
|
||||
async def change_credentials(
|
||||
body: ChangeCredentialsRequest,
|
||||
user: str = Depends(get_current_user),
|
||||
):
|
||||
if body.current_username != user:
|
||||
raise HTTPException(status_code=403, detail="当前用户名与登录账号不一致")
|
||||
try:
|
||||
auth_manager.change_credentials(
|
||||
body.current_username,
|
||||
body.current_password,
|
||||
body.new_username,
|
||||
body.new_password,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
token = auth_manager.create_token(body.new_username)
|
||||
response = JSONResponse({"username": body.new_username, "message": "账号已更新"})
|
||||
return _set_auth_cookie(response, token)
|
||||
|
||||
|
||||
@app.post("/api/session", response_model=CreateSessionResponse)
|
||||
async def create_session(body: CreateSessionRequest, user: str = Depends(get_current_user)):
|
||||
try:
|
||||
url = validate_url(body.url)
|
||||
except SecurityError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
try:
|
||||
session = await browser_manager.create_session(url)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=429, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"创建会话失败: {exc}") from exc
|
||||
|
||||
return CreateSessionResponse(session_id=session.session_id, url=session.url)
|
||||
|
||||
|
||||
@app.delete("/api/session/{session_id}")
|
||||
async def delete_session(session_id: str, user: str = Depends(get_current_user)):
|
||||
session = await browser_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
await browser_manager.close_session(session_id)
|
||||
return {"status": "closed"}
|
||||
|
||||
|
||||
@app.post("/api/session/{session_id}/navigate")
|
||||
async def navigate_session(
|
||||
session_id: str,
|
||||
body: NavigateRequest,
|
||||
user: str = Depends(get_current_user),
|
||||
):
|
||||
session = await browser_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
|
||||
try:
|
||||
url = validate_url(body.url)
|
||||
except SecurityError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
try:
|
||||
current_url = await browser_manager.navigate(session, url)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"导航失败: {exc}") from exc
|
||||
|
||||
return {"url": current_url}
|
||||
|
||||
|
||||
@app.post("/api/session/{session_id}/back")
|
||||
async def go_back(session_id: str, user: str = Depends(get_current_user)):
|
||||
session = await browser_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
try:
|
||||
url = await browser_manager.go_back(session)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"无法后退: {exc}") from exc
|
||||
return {"url": url}
|
||||
|
||||
|
||||
@app.post("/api/session/{session_id}/forward")
|
||||
async def go_forward(session_id: str, user: str = Depends(get_current_user)):
|
||||
session = await browser_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
try:
|
||||
url = await browser_manager.go_forward(session)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"无法前进: {exc}") from exc
|
||||
return {"url": url}
|
||||
|
||||
|
||||
@app.post("/api/session/{session_id}/reload")
|
||||
async def reload_page(session_id: str, user: str = Depends(get_current_user)):
|
||||
session = await browser_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="会话不存在")
|
||||
try:
|
||||
url = await browser_manager.reload(session)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"刷新失败: {exc}") from exc
|
||||
return {"url": url}
|
||||
|
||||
|
||||
@app.websocket("/ws/{session_id}")
|
||||
async def websocket_stream(
|
||||
websocket: WebSocket,
|
||||
session_id: str,
|
||||
cloud_browser_token: Optional[str] = Cookie(None),
|
||||
):
|
||||
if not auth_manager.verify_token(cloud_browser_token or ""):
|
||||
await websocket.close(code=4401, reason="未登录")
|
||||
return
|
||||
|
||||
session = await browser_manager.get_session(session_id)
|
||||
if not session:
|
||||
await websocket.close(code=4404, reason="会话不存在")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
queue = browser_manager.subscribe(session)
|
||||
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "init",
|
||||
"url": session.url,
|
||||
"width": session.viewport_width,
|
||||
"height": session.viewport_height,
|
||||
}
|
||||
)
|
||||
|
||||
async def forward_frames():
|
||||
while not session.closed:
|
||||
try:
|
||||
message = await asyncio.wait_for(queue.get(), timeout=30)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
|
||||
if message.get("type") == "frame":
|
||||
await websocket.send_bytes(message["data"])
|
||||
if message.get("url") and message["url"] != session.url:
|
||||
await websocket.send_json({"type": "url", "url": message["url"]})
|
||||
elif message.get("type") == "closed":
|
||||
await websocket.send_json(message)
|
||||
break
|
||||
|
||||
forward_task = asyncio.create_task(forward_frames())
|
||||
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
await websocket.send_json({"type": "error", "message": "无效 JSON"})
|
||||
continue
|
||||
|
||||
action_type = payload.get("type", "input")
|
||||
if action_type == "ping":
|
||||
browser_manager.touch(session)
|
||||
await websocket.send_json({"type": "pong"})
|
||||
continue
|
||||
|
||||
if action_type == "navigate":
|
||||
try:
|
||||
url = validate_url(payload.get("url", ""))
|
||||
current_url = await browser_manager.navigate(session, url)
|
||||
await websocket.send_json({"type": "url", "url": current_url})
|
||||
except SecurityError as exc:
|
||||
await websocket.send_json({"type": "error", "message": str(exc)})
|
||||
except Exception as exc:
|
||||
await websocket.send_json(
|
||||
{"type": "error", "message": f"导航失败: {exc}"}
|
||||
)
|
||||
continue
|
||||
|
||||
result = await handle_input(browser_manager, session, payload)
|
||||
if result:
|
||||
await websocket.send_json(result)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
forward_task.cancel()
|
||||
try:
|
||||
await forward_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
browser_manager.unsubscribe(session, queue)
|
||||
|
||||
+93
-93
@@ -1,93 +1,93 @@
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
BLOCKED_HOSTNAMES = {
|
||||
"localhost",
|
||||
"localhost.localdomain",
|
||||
"metadata.google.internal",
|
||||
}
|
||||
|
||||
PRIVATE_NETWORKS = [
|
||||
ipaddress.ip_network("0.0.0.0/8"),
|
||||
ipaddress.ip_network("10.0.0.0/8"),
|
||||
ipaddress.ip_network("127.0.0.0/8"),
|
||||
ipaddress.ip_network("169.254.0.0/16"),
|
||||
ipaddress.ip_network("172.16.0.0/12"),
|
||||
ipaddress.ip_network("192.168.0.0/16"),
|
||||
ipaddress.ip_network("::1/128"),
|
||||
ipaddress.ip_network("fc00::/7"),
|
||||
ipaddress.ip_network("fe80::/10"),
|
||||
]
|
||||
|
||||
ALLOWED_SCHEMES = {"http", "https"}
|
||||
|
||||
|
||||
class SecurityError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _normalize_url(raw_url: str) -> str:
|
||||
url = raw_url.strip()
|
||||
if not url:
|
||||
raise SecurityError("URL 不能为空")
|
||||
|
||||
if not re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", url):
|
||||
url = f"https://{url}"
|
||||
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ALLOWED_SCHEMES:
|
||||
raise SecurityError("仅允许 http/https 协议")
|
||||
|
||||
if not parsed.netloc:
|
||||
raise SecurityError("URL 格式无效")
|
||||
|
||||
if parsed.username or parsed.password:
|
||||
raise SecurityError("URL 中不允许包含用户名或密码")
|
||||
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
raise SecurityError("无法解析主机名")
|
||||
|
||||
hostname_lower = hostname.lower()
|
||||
if hostname_lower in BLOCKED_HOSTNAMES:
|
||||
raise SecurityError("不允许访问该主机")
|
||||
|
||||
if hostname_lower.endswith(".local") or hostname_lower.endswith(".internal"):
|
||||
raise SecurityError("不允许访问内网域名")
|
||||
|
||||
try:
|
||||
ip = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
return url
|
||||
|
||||
for network in PRIVATE_NETWORKS:
|
||||
if ip in network:
|
||||
raise SecurityError("不允许访问内网或本地地址")
|
||||
|
||||
return url
|
||||
|
||||
|
||||
def validate_url(raw_url: str) -> str:
|
||||
return _normalize_url(raw_url)
|
||||
|
||||
|
||||
def get_max_sessions() -> int:
|
||||
return max(1, int(os.getenv("MAX_SESSIONS", "1")))
|
||||
|
||||
|
||||
def get_idle_timeout() -> int:
|
||||
return max(60, int(os.getenv("SESSION_IDLE_TIMEOUT", "1800")))
|
||||
|
||||
|
||||
def get_viewport_size() -> tuple[int, int]:
|
||||
width = max(800, int(os.getenv("VIEWPORT_WIDTH", "1280")))
|
||||
height = max(600, int(os.getenv("VIEWPORT_HEIGHT", "720")))
|
||||
return width, height
|
||||
|
||||
|
||||
def get_screencast_quality() -> int:
|
||||
quality = int(os.getenv("SCREENCAST_QUALITY", "80"))
|
||||
return min(100, max(10, quality))
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
BLOCKED_HOSTNAMES = {
|
||||
"localhost",
|
||||
"localhost.localdomain",
|
||||
"metadata.google.internal",
|
||||
}
|
||||
|
||||
PRIVATE_NETWORKS = [
|
||||
ipaddress.ip_network("0.0.0.0/8"),
|
||||
ipaddress.ip_network("10.0.0.0/8"),
|
||||
ipaddress.ip_network("127.0.0.0/8"),
|
||||
ipaddress.ip_network("169.254.0.0/16"),
|
||||
ipaddress.ip_network("172.16.0.0/12"),
|
||||
ipaddress.ip_network("192.168.0.0/16"),
|
||||
ipaddress.ip_network("::1/128"),
|
||||
ipaddress.ip_network("fc00::/7"),
|
||||
ipaddress.ip_network("fe80::/10"),
|
||||
]
|
||||
|
||||
ALLOWED_SCHEMES = {"http", "https"}
|
||||
|
||||
|
||||
class SecurityError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _normalize_url(raw_url: str) -> str:
|
||||
url = raw_url.strip()
|
||||
if not url:
|
||||
raise SecurityError("URL 不能为空")
|
||||
|
||||
if not re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", url):
|
||||
url = f"https://{url}"
|
||||
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ALLOWED_SCHEMES:
|
||||
raise SecurityError("仅允许 http/https 协议")
|
||||
|
||||
if not parsed.netloc:
|
||||
raise SecurityError("URL 格式无效")
|
||||
|
||||
if parsed.username or parsed.password:
|
||||
raise SecurityError("URL 中不允许包含用户名或密码")
|
||||
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
raise SecurityError("无法解析主机名")
|
||||
|
||||
hostname_lower = hostname.lower()
|
||||
if hostname_lower in BLOCKED_HOSTNAMES:
|
||||
raise SecurityError("不允许访问该主机")
|
||||
|
||||
if hostname_lower.endswith(".local") or hostname_lower.endswith(".internal"):
|
||||
raise SecurityError("不允许访问内网域名")
|
||||
|
||||
try:
|
||||
ip = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
return url
|
||||
|
||||
for network in PRIVATE_NETWORKS:
|
||||
if ip in network:
|
||||
raise SecurityError("不允许访问内网或本地地址")
|
||||
|
||||
return url
|
||||
|
||||
|
||||
def validate_url(raw_url: str) -> str:
|
||||
return _normalize_url(raw_url)
|
||||
|
||||
|
||||
def get_max_sessions() -> int:
|
||||
return max(1, int(os.getenv("MAX_SESSIONS", "1")))
|
||||
|
||||
|
||||
def get_idle_timeout() -> int:
|
||||
return max(60, int(os.getenv("SESSION_IDLE_TIMEOUT", "1800")))
|
||||
|
||||
|
||||
def get_viewport_size() -> tuple[int, int]:
|
||||
width = max(800, int(os.getenv("VIEWPORT_WIDTH", "1280")))
|
||||
height = max(600, int(os.getenv("VIEWPORT_HEIGHT", "720")))
|
||||
return width, height
|
||||
|
||||
|
||||
def get_screencast_quality() -> int:
|
||||
quality = int(os.getenv("SCREENCAST_QUALITY", "80"))
|
||||
return min(100, max(10, quality))
|
||||
|
||||
@@ -1,126 +1,125 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# 云端浏览器一键部署脚本
|
||||
# 安装路径: /opt/cloud-browser
|
||||
# 运行用户: root
|
||||
# 默认端口: 32450
|
||||
# 默认账号: admin / admin
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_DIR="/opt/cloud-browser"
|
||||
REPO_URL="https://git.bz121.com/dekun/cloud-browser.git"
|
||||
APP_PORT="32450"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
err() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
||||
|
||||
if [[ "$(id -u)" -ne 0 ]]; then
|
||||
err "请使用 root 用户运行此脚本"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install_docker() {
|
||||
if command -v docker &>/dev/null; then
|
||||
log "Docker 已安装: $(docker --version)"
|
||||
return
|
||||
fi
|
||||
warn "未检测到 Docker,正在安装..."
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
systemctl enable docker
|
||||
systemctl start docker
|
||||
log "Docker 安装完成"
|
||||
}
|
||||
|
||||
install_compose() {
|
||||
if docker compose version &>/dev/null; then
|
||||
log "Docker Compose 已就绪"
|
||||
return
|
||||
fi
|
||||
err "Docker Compose 不可用,请手动安装后重试"
|
||||
exit 1
|
||||
}
|
||||
|
||||
clone_or_update() {
|
||||
if [[ -d "${INSTALL_DIR}/.git" ]]; then
|
||||
log "更新代码: ${INSTALL_DIR}"
|
||||
git -C "${INSTALL_DIR}" pull --rebase
|
||||
else
|
||||
log "克隆代码到 ${INSTALL_DIR}"
|
||||
mkdir -p "$(dirname "${INSTALL_DIR}")"
|
||||
git clone "${REPO_URL}" "${INSTALL_DIR}"
|
||||
fi
|
||||
}
|
||||
|
||||
setup_env() {
|
||||
cd "${INSTALL_DIR}"
|
||||
if [[ ! -f .env ]]; then
|
||||
cp .env.example .env
|
||||
log "已创建 .env 配置文件"
|
||||
fi
|
||||
mkdir -p data
|
||||
chmod 700 data
|
||||
}
|
||||
|
||||
start_service() {
|
||||
cd "${INSTALL_DIR}"
|
||||
log "构建并启动容器..."
|
||||
docker compose up -d --build
|
||||
}
|
||||
|
||||
wait_health() {
|
||||
local retries=30
|
||||
log "等待服务启动..."
|
||||
while [[ $retries -gt 0 ]]; do
|
||||
if curl -sf "http://127.0.0.1:${APP_PORT}/api/health" &>/dev/null; then
|
||||
log "服务已就绪"
|
||||
return 0
|
||||
fi
|
||||
retries=$((retries - 1))
|
||||
sleep 2
|
||||
done
|
||||
err "服务启动超时,请检查日志: docker compose -f ${INSTALL_DIR}/docker-compose.yml logs -f app"
|
||||
exit 1
|
||||
}
|
||||
|
||||
print_summary() {
|
||||
local ip
|
||||
ip=$(curl -s --max-time 3 ifconfig.me 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}' || echo "YOUR_SERVER_IP")
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " 云端浏览器部署完成"
|
||||
echo "=========================================="
|
||||
echo " 访问地址: http://${ip}:${APP_PORT}"
|
||||
echo " 默认账号: admin"
|
||||
echo " 默认密码: admin"
|
||||
echo " 安装目录: ${INSTALL_DIR}"
|
||||
echo ""
|
||||
echo " 登录后请立即修改用户名和密码"
|
||||
echo " 反向代理请自行在宝塔/Nginx 中配置"
|
||||
echo ""
|
||||
echo " 常用命令:"
|
||||
echo " 查看日志: cd ${INSTALL_DIR} && docker compose logs -f app"
|
||||
echo " 重启服务: cd ${INSTALL_DIR} && docker compose restart"
|
||||
echo " 停止服务: cd ${INSTALL_DIR} && docker compose down"
|
||||
echo " 更新部署: bash ${INSTALL_DIR}/deploy.sh"
|
||||
echo "=========================================="
|
||||
}
|
||||
|
||||
main() {
|
||||
log "开始部署云端浏览器..."
|
||||
install_docker
|
||||
install_compose
|
||||
clone_or_update
|
||||
setup_env
|
||||
start_service
|
||||
wait_health
|
||||
print_summary
|
||||
}
|
||||
|
||||
main "$@"
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# 云端浏览器一键部署脚本
|
||||
# 安装路径: /opt/cloud-browser
|
||||
# 运行用户: root
|
||||
# 默认端口: 32450
|
||||
# 默认账号: admin / admin
|
||||
#
|
||||
set -eu
|
||||
|
||||
INSTALL_DIR="/opt/cloud-browser"
|
||||
REPO_URL="https://git.bz121.com/dekun/cloud-browser.git"
|
||||
APP_PORT="32450"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
err() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
err "请使用 root 用户运行此脚本"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install_docker() {
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
log "Docker 已安装: $(docker --version)"
|
||||
return
|
||||
fi
|
||||
warn "未检测到 Docker,正在安装..."
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
systemctl enable docker
|
||||
systemctl start docker
|
||||
log "Docker 安装完成"
|
||||
}
|
||||
|
||||
install_compose() {
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
log "Docker Compose 已就绪"
|
||||
return
|
||||
fi
|
||||
err "Docker Compose 不可用,请手动安装后重试"
|
||||
exit 1
|
||||
}
|
||||
|
||||
clone_or_update() {
|
||||
if [ -d "${INSTALL_DIR}/.git" ]; then
|
||||
log "更新代码: ${INSTALL_DIR}"
|
||||
git -C "${INSTALL_DIR}" pull --rebase
|
||||
else
|
||||
log "克隆代码到 ${INSTALL_DIR}"
|
||||
mkdir -p "$(dirname "${INSTALL_DIR}")"
|
||||
git clone "${REPO_URL}" "${INSTALL_DIR}"
|
||||
fi
|
||||
}
|
||||
|
||||
setup_env() {
|
||||
cd "${INSTALL_DIR}"
|
||||
if [ ! -f .env ]; then
|
||||
cp .env.example .env
|
||||
log "已创建 .env 配置文件"
|
||||
fi
|
||||
mkdir -p data
|
||||
chmod 700 data
|
||||
}
|
||||
|
||||
start_service() {
|
||||
cd "${INSTALL_DIR}"
|
||||
log "构建并启动容器..."
|
||||
docker compose up -d --build
|
||||
}
|
||||
|
||||
wait_health() {
|
||||
retries=30
|
||||
log "等待服务启动..."
|
||||
while [ "$retries" -gt 0 ]; do
|
||||
if curl -sf "http://127.0.0.1:${APP_PORT}/api/health" >/dev/null 2>&1; then
|
||||
log "服务已就绪"
|
||||
return 0
|
||||
fi
|
||||
retries=$((retries - 1))
|
||||
sleep 2
|
||||
done
|
||||
err "服务启动超时,请检查日志: docker compose -f ${INSTALL_DIR}/docker-compose.yml logs -f app"
|
||||
exit 1
|
||||
}
|
||||
|
||||
print_summary() {
|
||||
ip=$(curl -s --max-time 3 ifconfig.me 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}' || echo "YOUR_SERVER_IP")
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " 云端浏览器部署完成"
|
||||
echo "=========================================="
|
||||
echo " 访问地址: http://${ip}:${APP_PORT}"
|
||||
echo " 默认账号: admin"
|
||||
echo " 默认密码: admin"
|
||||
echo " 安装目录: ${INSTALL_DIR}"
|
||||
echo ""
|
||||
echo " 登录后请立即修改用户名和密码"
|
||||
echo " 反向代理请自行在宝塔/Nginx 中配置"
|
||||
echo ""
|
||||
echo " 常用命令:"
|
||||
echo " 查看日志: cd ${INSTALL_DIR} && docker compose logs -f app"
|
||||
echo " 重启服务: cd ${INSTALL_DIR} && docker compose restart"
|
||||
echo " 停止服务: cd ${INSTALL_DIR} && docker compose down"
|
||||
echo " 更新部署: bash ${INSTALL_DIR}/deploy.sh"
|
||||
echo "=========================================="
|
||||
}
|
||||
|
||||
main() {
|
||||
log "开始部署云端浏览器..."
|
||||
install_docker
|
||||
install_compose
|
||||
clone_or_update
|
||||
setup_env
|
||||
start_service
|
||||
wait_health
|
||||
print_summary
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
+17
-17
@@ -1,17 +1,17 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${APP_PORT:-32450}:8000"
|
||||
env_file: .env
|
||||
environment:
|
||||
- DATA_DIR=/app/data
|
||||
- MAX_SESSIONS=${MAX_SESSIONS:-1}
|
||||
- SESSION_IDLE_TIMEOUT=${SESSION_IDLE_TIMEOUT:-1800}
|
||||
- VIEWPORT_WIDTH=${VIEWPORT_WIDTH:-1280}
|
||||
- VIEWPORT_HEIGHT=${VIEWPORT_HEIGHT:-720}
|
||||
- SCREENCAST_QUALITY=${SCREENCAST_QUALITY:-80}
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
shm_size: "1gb"
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${APP_PORT:-32450}:8000"
|
||||
env_file: .env
|
||||
environment:
|
||||
- DATA_DIR=/app/data
|
||||
- MAX_SESSIONS=${MAX_SESSIONS:-1}
|
||||
- SESSION_IDLE_TIMEOUT=${SESSION_IDLE_TIMEOUT:-1800}
|
||||
- VIEWPORT_WIDTH=${VIEWPORT_WIDTH:-1280}
|
||||
- VIEWPORT_HEIGHT=${VIEWPORT_HEIGHT:-720}
|
||||
- SCREENCAST_QUALITY=${SCREENCAST_QUALITY:-80}
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
shm_size: "1gb"
|
||||
|
||||
+7
-7
@@ -1,7 +1,7 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
playwright==1.49.1
|
||||
python-dotenv==1.0.1
|
||||
pydantic==2.10.4
|
||||
bcrypt==4.2.1
|
||||
itsdangerous==2.2.0
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
playwright==1.49.1
|
||||
python-dotenv==1.0.1
|
||||
pydantic==2.10.4
|
||||
bcrypt==4.2.1
|
||||
itsdangerous==2.2.0
|
||||
|
||||
+35
-35
@@ -1,35 +1,35 @@
|
||||
const AUTH = {
|
||||
async me() {
|
||||
const res = await fetch("/api/auth/me", { credentials: "include" });
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async login(username, password) {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.detail || "登录失败");
|
||||
return data;
|
||||
},
|
||||
|
||||
async logout() {
|
||||
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
|
||||
},
|
||||
|
||||
async changeCredentials(payload) {
|
||||
const res = await fetch("/api/auth/change-credentials", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.detail || "修改失败");
|
||||
return data;
|
||||
},
|
||||
};
|
||||
const AUTH = {
|
||||
async me() {
|
||||
const res = await fetch("/api/auth/me", { credentials: "include" });
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async login(username, password) {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.detail || "登录失败");
|
||||
return data;
|
||||
},
|
||||
|
||||
async logout() {
|
||||
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
|
||||
},
|
||||
|
||||
async changeCredentials(payload) {
|
||||
const res = await fetch("/api/auth/change-credentials", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.detail || "修改失败");
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
+88
-88
@@ -1,88 +1,88 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>云端浏览器</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="container">
|
||||
<header class="header">
|
||||
<h1>云端浏览器</h1>
|
||||
<p class="subtitle">输入网址,在境外服务器上打开并远程操作</p>
|
||||
</header>
|
||||
|
||||
<!-- 登录 -->
|
||||
<section id="login-section" class="panel hidden">
|
||||
<h2>登录</h2>
|
||||
<form id="login-form" class="stack-form">
|
||||
<label for="login-user">用户名</label>
|
||||
<input id="login-user" type="text" autocomplete="username" required>
|
||||
<label for="login-pass">密码</label>
|
||||
<input id="login-pass" type="password" autocomplete="current-password" required>
|
||||
<button type="submit">登录</button>
|
||||
<p id="login-error" class="error hidden"></p>
|
||||
</form>
|
||||
<p class="hint">默认账号:admin / admin,登录后请尽快修改</p>
|
||||
</section>
|
||||
|
||||
<!-- 主界面 -->
|
||||
<section id="main-section" class="hidden">
|
||||
<div class="top-bar">
|
||||
<span id="welcome-user" class="welcome"></span>
|
||||
<button id="btn-settings" type="button" class="btn-secondary">账号设置</button>
|
||||
<button id="btn-logout" type="button" class="btn-secondary">退出</button>
|
||||
</div>
|
||||
|
||||
<form id="start-form" class="start-form">
|
||||
<label for="url-input">目标网址</label>
|
||||
<div class="input-row">
|
||||
<input
|
||||
id="url-input"
|
||||
type="text"
|
||||
placeholder="https://example.com"
|
||||
autocomplete="off"
|
||||
required
|
||||
>
|
||||
<button type="submit" id="start-btn">进入</button>
|
||||
</div>
|
||||
<p id="error-msg" class="error hidden"></p>
|
||||
</form>
|
||||
|
||||
<section class="tips">
|
||||
<h2>使用说明</h2>
|
||||
<ul>
|
||||
<li>页面将在云服务器 Chromium 中加载,画面实时回传</li>
|
||||
<li>可同时登录账号、搜索和浏览你的数据</li>
|
||||
<li>会话空闲 30 分钟后自动关闭</li>
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<!-- 修改账号 -->
|
||||
<section id="settings-section" class="panel hidden">
|
||||
<h2>修改用户名和密码</h2>
|
||||
<form id="settings-form" class="stack-form">
|
||||
<label for="cur-user">当前用户名</label>
|
||||
<input id="cur-user" type="text" required>
|
||||
<label for="cur-pass">当前密码</label>
|
||||
<input id="cur-pass" type="password" required>
|
||||
<label for="new-user">新用户名</label>
|
||||
<input id="new-user" type="text" required>
|
||||
<label for="new-pass">新密码</label>
|
||||
<input id="new-pass" type="password" required>
|
||||
<div class="btn-row">
|
||||
<button type="submit">保存</button>
|
||||
<button id="btn-settings-cancel" type="button" class="btn-secondary">取消</button>
|
||||
</div>
|
||||
<p id="settings-error" class="error hidden"></p>
|
||||
<p id="settings-success" class="success hidden"></p>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/static/auth.js"></script>
|
||||
<script src="/static/index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>云端浏览器</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="container">
|
||||
<header class="header">
|
||||
<h1>云端浏览器</h1>
|
||||
<p class="subtitle">输入网址,在境外服务器上打开并远程操作</p>
|
||||
</header>
|
||||
|
||||
<!-- 登录 -->
|
||||
<section id="login-section" class="panel hidden">
|
||||
<h2>登录</h2>
|
||||
<form id="login-form" class="stack-form">
|
||||
<label for="login-user">用户名</label>
|
||||
<input id="login-user" type="text" autocomplete="username" required>
|
||||
<label for="login-pass">密码</label>
|
||||
<input id="login-pass" type="password" autocomplete="current-password" required>
|
||||
<button type="submit">登录</button>
|
||||
<p id="login-error" class="error hidden"></p>
|
||||
</form>
|
||||
<p class="hint">默认账号:admin / admin,登录后请尽快修改</p>
|
||||
</section>
|
||||
|
||||
<!-- 主界面 -->
|
||||
<section id="main-section" class="hidden">
|
||||
<div class="top-bar">
|
||||
<span id="welcome-user" class="welcome"></span>
|
||||
<button id="btn-settings" type="button" class="btn-secondary">账号设置</button>
|
||||
<button id="btn-logout" type="button" class="btn-secondary">退出</button>
|
||||
</div>
|
||||
|
||||
<form id="start-form" class="start-form">
|
||||
<label for="url-input">目标网址</label>
|
||||
<div class="input-row">
|
||||
<input
|
||||
id="url-input"
|
||||
type="text"
|
||||
placeholder="https://example.com"
|
||||
autocomplete="off"
|
||||
required
|
||||
>
|
||||
<button type="submit" id="start-btn">进入</button>
|
||||
</div>
|
||||
<p id="error-msg" class="error hidden"></p>
|
||||
</form>
|
||||
|
||||
<section class="tips">
|
||||
<h2>使用说明</h2>
|
||||
<ul>
|
||||
<li>页面将在云服务器 Chromium 中加载,画面实时回传</li>
|
||||
<li>可同时登录账号、搜索和浏览你的数据</li>
|
||||
<li>会话空闲 30 分钟后自动关闭</li>
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<!-- 修改账号 -->
|
||||
<section id="settings-section" class="panel hidden">
|
||||
<h2>修改用户名和密码</h2>
|
||||
<form id="settings-form" class="stack-form">
|
||||
<label for="cur-user">当前用户名</label>
|
||||
<input id="cur-user" type="text" required>
|
||||
<label for="cur-pass">当前密码</label>
|
||||
<input id="cur-pass" type="password" required>
|
||||
<label for="new-user">新用户名</label>
|
||||
<input id="new-user" type="text" required>
|
||||
<label for="new-pass">新密码</label>
|
||||
<input id="new-pass" type="password" required>
|
||||
<div class="btn-row">
|
||||
<button type="submit">保存</button>
|
||||
<button id="btn-settings-cancel" type="button" class="btn-secondary">取消</button>
|
||||
</div>
|
||||
<p id="settings-error" class="error hidden"></p>
|
||||
<p id="settings-success" class="success hidden"></p>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/static/auth.js"></script>
|
||||
<script src="/static/index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+163
-163
@@ -1,163 +1,163 @@
|
||||
const loginSection = document.getElementById("login-section");
|
||||
const mainSection = document.getElementById("main-section");
|
||||
const settingsSection = document.getElementById("settings-section");
|
||||
const loginForm = document.getElementById("login-form");
|
||||
const loginError = document.getElementById("login-error");
|
||||
const welcomeUser = document.getElementById("welcome-user");
|
||||
const settingsForm = document.getElementById("settings-form");
|
||||
const settingsError = document.getElementById("settings-error");
|
||||
const settingsSuccess = document.getElementById("settings-success");
|
||||
const form = document.getElementById("start-form");
|
||||
const urlInput = document.getElementById("url-input");
|
||||
const startBtn = document.getElementById("start-btn");
|
||||
const errorMsg = document.getElementById("error-msg");
|
||||
|
||||
function show(el) {
|
||||
el.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function hide(el) {
|
||||
el.classList.add("hidden");
|
||||
}
|
||||
|
||||
function showPanelError(el, message) {
|
||||
el.textContent = message;
|
||||
show(el);
|
||||
}
|
||||
|
||||
function hidePanelError(el) {
|
||||
hide(el);
|
||||
}
|
||||
|
||||
function showMain(username) {
|
||||
hide(loginSection);
|
||||
hide(settingsSection);
|
||||
show(mainSection);
|
||||
welcomeUser.textContent = `当前用户:${username}`;
|
||||
document.getElementById("cur-user").value = username;
|
||||
urlInput.focus();
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
hide(mainSection);
|
||||
hide(settingsSection);
|
||||
show(loginSection);
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const user = await AUTH.me();
|
||||
if (user) {
|
||||
showMain(user.username);
|
||||
} else {
|
||||
showLogin();
|
||||
}
|
||||
}
|
||||
|
||||
loginForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
hidePanelError(loginError);
|
||||
const username = document.getElementById("login-user").value.trim();
|
||||
const password = document.getElementById("login-pass").value;
|
||||
try {
|
||||
const data = await AUTH.login(username, password);
|
||||
showMain(data.username);
|
||||
} catch (err) {
|
||||
showPanelError(loginError, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("btn-logout").addEventListener("click", async () => {
|
||||
await AUTH.logout();
|
||||
showLogin();
|
||||
});
|
||||
|
||||
document.getElementById("btn-settings").addEventListener("click", () => {
|
||||
hide(mainSection);
|
||||
hidePanelError(settingsError);
|
||||
hide(settingsSuccess);
|
||||
show(settingsSection);
|
||||
});
|
||||
|
||||
document.getElementById("btn-settings-cancel").addEventListener("click", () => {
|
||||
const user = welcomeUser.textContent.replace("当前用户:", "");
|
||||
showMain(user);
|
||||
});
|
||||
|
||||
settingsForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
hidePanelError(settingsError);
|
||||
hide(settingsSuccess);
|
||||
try {
|
||||
const data = await AUTH.changeCredentials({
|
||||
current_username: document.getElementById("cur-user").value.trim(),
|
||||
current_password: document.getElementById("cur-pass").value,
|
||||
new_username: document.getElementById("new-user").value.trim(),
|
||||
new_password: document.getElementById("new-pass").value,
|
||||
});
|
||||
document.getElementById("cur-pass").value = "";
|
||||
document.getElementById("new-pass").value = "";
|
||||
settingsSuccess.textContent = "账号已更新,请使用新凭据登录";
|
||||
show(settingsSuccess);
|
||||
setTimeout(async () => {
|
||||
await AUTH.logout();
|
||||
showLogin();
|
||||
}, 1500);
|
||||
welcomeUser.textContent = `当前用户:${data.username}`;
|
||||
} catch (err) {
|
||||
showPanelError(settingsError, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
function showError(message) {
|
||||
errorMsg.textContent = message;
|
||||
show(errorMsg);
|
||||
}
|
||||
|
||||
function hideError() {
|
||||
hide(errorMsg);
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
hideError();
|
||||
|
||||
const url = urlInput.value.trim();
|
||||
if (!url) {
|
||||
showError("请输入网址");
|
||||
return;
|
||||
}
|
||||
|
||||
startBtn.disabled = true;
|
||||
startBtn.textContent = "启动中...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/session", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (response.status === 401) {
|
||||
showLogin();
|
||||
showPanelError(loginError, "登录已过期,请重新登录");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
showError(data.detail || "创建会话失败");
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/view/${data.session_id}`;
|
||||
} catch (err) {
|
||||
showError("网络错误,请稍后重试");
|
||||
} finally {
|
||||
startBtn.disabled = false;
|
||||
startBtn.textContent = "进入";
|
||||
}
|
||||
});
|
||||
|
||||
init();
|
||||
const loginSection = document.getElementById("login-section");
|
||||
const mainSection = document.getElementById("main-section");
|
||||
const settingsSection = document.getElementById("settings-section");
|
||||
const loginForm = document.getElementById("login-form");
|
||||
const loginError = document.getElementById("login-error");
|
||||
const welcomeUser = document.getElementById("welcome-user");
|
||||
const settingsForm = document.getElementById("settings-form");
|
||||
const settingsError = document.getElementById("settings-error");
|
||||
const settingsSuccess = document.getElementById("settings-success");
|
||||
const form = document.getElementById("start-form");
|
||||
const urlInput = document.getElementById("url-input");
|
||||
const startBtn = document.getElementById("start-btn");
|
||||
const errorMsg = document.getElementById("error-msg");
|
||||
|
||||
function show(el) {
|
||||
el.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function hide(el) {
|
||||
el.classList.add("hidden");
|
||||
}
|
||||
|
||||
function showPanelError(el, message) {
|
||||
el.textContent = message;
|
||||
show(el);
|
||||
}
|
||||
|
||||
function hidePanelError(el) {
|
||||
hide(el);
|
||||
}
|
||||
|
||||
function showMain(username) {
|
||||
hide(loginSection);
|
||||
hide(settingsSection);
|
||||
show(mainSection);
|
||||
welcomeUser.textContent = `当前用户:${username}`;
|
||||
document.getElementById("cur-user").value = username;
|
||||
urlInput.focus();
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
hide(mainSection);
|
||||
hide(settingsSection);
|
||||
show(loginSection);
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const user = await AUTH.me();
|
||||
if (user) {
|
||||
showMain(user.username);
|
||||
} else {
|
||||
showLogin();
|
||||
}
|
||||
}
|
||||
|
||||
loginForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
hidePanelError(loginError);
|
||||
const username = document.getElementById("login-user").value.trim();
|
||||
const password = document.getElementById("login-pass").value;
|
||||
try {
|
||||
const data = await AUTH.login(username, password);
|
||||
showMain(data.username);
|
||||
} catch (err) {
|
||||
showPanelError(loginError, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("btn-logout").addEventListener("click", async () => {
|
||||
await AUTH.logout();
|
||||
showLogin();
|
||||
});
|
||||
|
||||
document.getElementById("btn-settings").addEventListener("click", () => {
|
||||
hide(mainSection);
|
||||
hidePanelError(settingsError);
|
||||
hide(settingsSuccess);
|
||||
show(settingsSection);
|
||||
});
|
||||
|
||||
document.getElementById("btn-settings-cancel").addEventListener("click", () => {
|
||||
const user = welcomeUser.textContent.replace("当前用户:", "");
|
||||
showMain(user);
|
||||
});
|
||||
|
||||
settingsForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
hidePanelError(settingsError);
|
||||
hide(settingsSuccess);
|
||||
try {
|
||||
const data = await AUTH.changeCredentials({
|
||||
current_username: document.getElementById("cur-user").value.trim(),
|
||||
current_password: document.getElementById("cur-pass").value,
|
||||
new_username: document.getElementById("new-user").value.trim(),
|
||||
new_password: document.getElementById("new-pass").value,
|
||||
});
|
||||
document.getElementById("cur-pass").value = "";
|
||||
document.getElementById("new-pass").value = "";
|
||||
settingsSuccess.textContent = "账号已更新,请使用新凭据登录";
|
||||
show(settingsSuccess);
|
||||
setTimeout(async () => {
|
||||
await AUTH.logout();
|
||||
showLogin();
|
||||
}, 1500);
|
||||
welcomeUser.textContent = `当前用户:${data.username}`;
|
||||
} catch (err) {
|
||||
showPanelError(settingsError, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
function showError(message) {
|
||||
errorMsg.textContent = message;
|
||||
show(errorMsg);
|
||||
}
|
||||
|
||||
function hideError() {
|
||||
hide(errorMsg);
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
hideError();
|
||||
|
||||
const url = urlInput.value.trim();
|
||||
if (!url) {
|
||||
showError("请输入网址");
|
||||
return;
|
||||
}
|
||||
|
||||
startBtn.disabled = true;
|
||||
startBtn.textContent = "启动中...";
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/session", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (response.status === 401) {
|
||||
showLogin();
|
||||
showPanelError(loginError, "登录已过期,请重新登录");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
showError(data.detail || "创建会话失败");
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/view/${data.session_id}`;
|
||||
} catch (err) {
|
||||
showError("网络错误,请稍后重试");
|
||||
} finally {
|
||||
startBtn.disabled = false;
|
||||
startBtn.textContent = "进入";
|
||||
}
|
||||
});
|
||||
|
||||
init();
|
||||
|
||||
+269
-269
@@ -1,269 +1,269 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: #0f1419;
|
||||
color: #e7e9ea;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #8b98a5;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.start-form label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-row input {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #38444d;
|
||||
border-radius: 8px;
|
||||
background: #192734;
|
||||
color: #e7e9ea;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.input-row input:focus {
|
||||
outline: none;
|
||||
border-color: #1d9bf0;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 12px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #1d9bf0;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #1a8cd8;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #f4212e;
|
||||
margin-top: 12px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.tips {
|
||||
margin-top: 48px;
|
||||
padding: 24px;
|
||||
background: #192734;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #38444d;
|
||||
}
|
||||
|
||||
.tips h2 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tips ul {
|
||||
padding-left: 20px;
|
||||
color: #8b98a5;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
/* Viewer */
|
||||
.viewer-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #192734;
|
||||
border-bottom: 1px solid #38444d;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar button {
|
||||
padding: 8px 12px;
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
.toolbar #address-bar {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #38444d;
|
||||
border-radius: 6px;
|
||||
background: #0f1419;
|
||||
color: #e7e9ea;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.toolbar #address-bar:focus {
|
||||
outline: none;
|
||||
border-color: #1d9bf0;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 0.8rem;
|
||||
color: #8b98a5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #f4212e;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #dc1d28;
|
||||
}
|
||||
|
||||
.viewport-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #000;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#screen {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
cursor: default;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.overlay a {
|
||||
color: #1d9bf0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.overlay a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin-bottom: 24px;
|
||||
padding: 24px;
|
||||
background: #192734;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #38444d;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stack-form label {
|
||||
display: block;
|
||||
margin: 12px 0 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stack-form input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #38444d;
|
||||
border-radius: 8px;
|
||||
background: #0f1419;
|
||||
color: #e7e9ea;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.stack-form input:focus {
|
||||
outline: none;
|
||||
border-color: #1d9bf0;
|
||||
}
|
||||
|
||||
.stack-form button {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #38444d;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #4a5560;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
flex: 1;
|
||||
color: #8b98a5;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 12px;
|
||||
color: #8b98a5;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #00ba7c;
|
||||
margin-top: 12px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: #0f1419;
|
||||
color: #e7e9ea;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #8b98a5;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.start-form label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-row input {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #38444d;
|
||||
border-radius: 8px;
|
||||
background: #192734;
|
||||
color: #e7e9ea;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.input-row input:focus {
|
||||
outline: none;
|
||||
border-color: #1d9bf0;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 12px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #1d9bf0;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #1a8cd8;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #f4212e;
|
||||
margin-top: 12px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.tips {
|
||||
margin-top: 48px;
|
||||
padding: 24px;
|
||||
background: #192734;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #38444d;
|
||||
}
|
||||
|
||||
.tips h2 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tips ul {
|
||||
padding-left: 20px;
|
||||
color: #8b98a5;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
/* Viewer */
|
||||
.viewer-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #192734;
|
||||
border-bottom: 1px solid #38444d;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar button {
|
||||
padding: 8px 12px;
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
.toolbar #address-bar {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #38444d;
|
||||
border-radius: 6px;
|
||||
background: #0f1419;
|
||||
color: #e7e9ea;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.toolbar #address-bar:focus {
|
||||
outline: none;
|
||||
border-color: #1d9bf0;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 0.8rem;
|
||||
color: #8b98a5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #f4212e;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #dc1d28;
|
||||
}
|
||||
|
||||
.viewport-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #000;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#screen {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
cursor: default;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.overlay a {
|
||||
color: #1d9bf0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.overlay a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin-bottom: 24px;
|
||||
padding: 24px;
|
||||
background: #192734;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #38444d;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stack-form label {
|
||||
display: block;
|
||||
margin: 12px 0 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stack-form input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #38444d;
|
||||
border-radius: 8px;
|
||||
background: #0f1419;
|
||||
color: #e7e9ea;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.stack-form input:focus {
|
||||
outline: none;
|
||||
border-color: #1d9bf0;
|
||||
}
|
||||
|
||||
.stack-form button {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #38444d;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #4a5560;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
flex: 1;
|
||||
color: #8b98a5;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 12px;
|
||||
color: #8b98a5;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #00ba7c;
|
||||
margin-top: 12px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
+30
-30
@@ -1,30 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>浏览中 - 云端浏览器</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="viewer-body">
|
||||
<div class="toolbar">
|
||||
<button id="btn-back" title="后退" type="button">←</button>
|
||||
<button id="btn-forward" title="前进" type="button">→</button>
|
||||
<button id="btn-reload" title="刷新" type="button">↻</button>
|
||||
<input id="address-bar" type="text" placeholder="输入网址..." autocomplete="off">
|
||||
<button id="btn-go" type="button">前往</button>
|
||||
<span id="status" class="status">连接中...</span>
|
||||
<button id="btn-close" class="btn-danger" type="button">关闭会话</button>
|
||||
</div>
|
||||
|
||||
<div id="viewport-wrap" class="viewport-wrap">
|
||||
<canvas id="screen" tabindex="0"></canvas>
|
||||
<div id="overlay" class="overlay hidden">
|
||||
<p id="overlay-msg">会话已结束</p>
|
||||
<a href="/">返回首页</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/viewer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>浏览中 - 云端浏览器</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="viewer-body">
|
||||
<div class="toolbar">
|
||||
<button id="btn-back" title="后退" type="button">←</button>
|
||||
<button id="btn-forward" title="前进" type="button">→</button>
|
||||
<button id="btn-reload" title="刷新" type="button">↻</button>
|
||||
<input id="address-bar" type="text" placeholder="输入网址..." autocomplete="off">
|
||||
<button id="btn-go" type="button">前往</button>
|
||||
<span id="status" class="status">连接中...</span>
|
||||
<button id="btn-close" class="btn-danger" type="button">关闭会话</button>
|
||||
</div>
|
||||
|
||||
<div id="viewport-wrap" class="viewport-wrap">
|
||||
<canvas id="screen" tabindex="0"></canvas>
|
||||
<div id="overlay" class="overlay hidden">
|
||||
<p id="overlay-msg">会话已结束</p>
|
||||
<a href="/">返回首页</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/viewer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+217
-217
@@ -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();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user