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