commit 79981cde4a622858b507180eca176d6190e0266a Author: root Date: Sun Jun 28 18:16:03 2026 +0800 Release v1.3.44 ✨ v1.3.44 更新内容: - 🔧 修复:强制刷新页面后链接点不了的 bug - 修复:DOMContentLoaded 中添加 checkAuth() 调用,自动验证登录态 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5e3e53d --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# 数据库 +*.db +*.sqlite +*.sqlite3 + +# 虚拟环境 +venv/ +env/ +__pycache__/ +*.pyc +*.pyo + +# 日志 +logs/ +*.log + +# 备份 +backups/ +*.zip +*.tar.gz +*.bak +*.bak.* +*.old + +# 站点数据 +sites/ +data/ + +# SSL 证书 +ssl/ +*.pem +*.crt +*.key + +# 临时文件 +tmp/ +*.tmp +.DS_Store +Thumbs.db + +# 前端备份 +frontend.bak* +*.html.bak* + +# 后端备份 +backend/*.bak* +backend/*bak*.py + +# 其他 +.well-known/ +vendor/ +node_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c92e570 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,481 @@ +# TPanel 变更日志(CHANGELOG) + +## v1.3.43 (2026-06-28) + +### 🆕 新增功能 +1. **站点管理** - 操作列新增「强制开启SSL」按钮 + - 一键为站点部署 Let's Encrypt 证书 + - 自动配置 Nginx HTTPS 301 跳转 + - 无需进入 SSL 页面单独配置 + - 按钮图标:🔐 + +### 🐛 Bug 修复 +1. **设置页面** - 修复管理员密码修改功能 + - 从"开发中"改为真正可用 + - 正确调用 /api/auth/change-password API + - 支持当前密码校验 + - 新密码强度校验(至少6位) + - 修改成功后自动清空输入框 + - 成功/失败状态提示清晰 + +### 🔧 优化 +- SSL 部署按钮状态提示优化 +- 密码修改接口返回信息更友好 + +> **作者**: Zhang Pu +> **官网**: https://tpanel.cn +> **GitHub**: https://github.com/zhang-pu/tpanel +> **协议**: MIT +> **发布周期**: 紧急修复为主,无固定周期 +> **版本约定**: v1.3.X 中,X 是累计迭代号;只有经过实机验证的稳定版会发 GitHub Release +> **本日志涵盖**: v1.3.0 (2026-05-30) → v1.3.40 (2026-06-12) + +--- + +## 📦 v1.3.40 — 2026-06-12 【正式发布】 + +> **重点**: phpMyAdmin 自动登入 + 装包自愈 + dpkg 二次校验 + +### 🐛 关键修复 + +1. **phpMyAdmin 装包失败后 files_exist 误判** + - dpkg 标记 `Status: install ok installed` 但 `/usr/share/phpmyadmin` 实际 missing(partial install) + - 前端 status 报"已装"但实际 502 / 找不到目录 + - 修:加 `dpkg -V phpmyadmin` 二次校验 + 自动 `apt-get install --reinstall` + +2. **setup_phpmyadmin_nginx 缺 self-heal** + - 找不到 pma 目录就 silently return False,没给运维任何提示 + - 修:开头加 `dpkg -V` 探测 + `apt-get install --reinstall` 自动重装 + - 修:加 `ss -tln | grep :9000` 探测 PHP-FPM listen,不通就 warn 日志(不再 silent 502) + +3. **`/api/phpmyadmin/token/` 端点缺失**(v1.3.34 前端依赖) + - 前端点数据库名时调 `/api/phpmyadmin/token/` 拿 5 分钟 HMAC token + - 后端 main.py 一直没实现这个端点 → 404 → 前端 `window.open` 不执行 → 用户点"没反应" + - 修:新增 `api_phpmyadmin_token` + `api_phpmyadmin_signon` 端点 + +4. **api_phpmyadmin_signon 写 bridge.json Permission denied** + - `/usr/share/phpmyadmin` 是 root 755,tpanel 用户没写权限 + - 修:用 `sudo mv /tmp/bridge.json` + `sudo chmod 644` 两步 + +5. **CONFIG 缺 SECRET_KEY** + - HMAC token 签发需要密钥,config.py 没有 + - 修:启动时 `secrets.token_hex(32)` 随机生成 64 字符 hex + +### ✨ 新增功能 + +1. **phpMyAdmin 自动登入**(点数据库名 → 直接进 pma) + - HMAC token 5 分钟有效,绑 db_id + - signon 端点写 `bridge.json`(db_user/db_pass/ts) + - nginx 8443 反代 + 8400 PHP-FPM listen + - `tpanel-bridge.php` 桥接 session → pma 自动用 db_user 登入 + - `/etc/phpmyadmin/config.inc.php` 启用 `config` auth,读 signon data 自动填账号 + +### 📝 教训 + +- 永远别让 dpkg 静默失败——必须用 `dpkg -V` 二次校验 +- v1.3.34 前端加了 token 端点,但后端 main.py 一直没合并过来——半年才被发现 +- 写装包脚本必须在干净机器上真跑一遍(这次踩坑 6/10 装包 partial,6/12 才暴露) + +--- + +## 📦 v1.3.39 — 2026-06-11 【开发版,未发 release】 + +### 🐛 修复 + +1. **软件卸载改用 `purge` 而非 `remove`** + - `remove` 保留配置(`/etc/nginx/sites-enabled/*.conf` 不删) + - 改 `purge` 完全删干净 +2. **加 on_complete 钩子到卸载流程** + - 卸载 PHP 后自动 reload nginx + 改 8848 配置 +3. **task_manager dpkg -s 误判修复** + - `dpkg -s` 对 `deinstall ok config-files` 状态返 0,会误判"已装" + - 加 explicit check 区分 installed / config-files + +--- + +## 📦 v1.3.38 — 2026-06-11 【开发版,未发 release】 + +### 🐛 修复 + +1. **fastcgi_pass 端口按 PHP 版本动态选** + - 之前硬编码 `127.0.0.1:9000`,多 PHP 版本时 8.1/8.2/8.3 混用 + - 修:ssl_manager.py 写 nginx conf 时按 db 的 php_version 字段选对应端口 +2. **nginx reload 在 file change 事件触发** + - 修:inotify 等价实现(轮询 mtime) + +--- + +## 📦 v1.3.37 — 2026-06-10 【开发版,未发 release】 + +### 🐛 修复 + +1. **on_complete 钩子无 Flask request context** + - 后台线程跑钩子时 `request.remote_addr` 不可用 → 报 AttributeError + - 修:钩子里 `try/except`,None 当 fallback +2. **write_log 接受 None ip** + - 部分场景 ip 是 None 时报 TypeError + +--- + +## 📦 v1.3.36 — 2026-06-09 【开发版,未发 release】 + +### ✨ 新增 + +1. **get_installed_php_versions() 函数** + - system.py 新增,扫描 `/usr/bin/php*` + dpkg -l + - 前端创建站点时只列已装 PHP(避免选错导致 502) + +2. **建站时 PHP 版本校验** + - 前端二次校验(即使后端也校验了) + - 未装的 PHP 选项 disabled + - 防止用户选 PHP 5.6 装了一半发现 FPM 没起来 + +--- + +## 📦 v1.3.35 — 2026-06-08 【正式发布】 + +> **重点**: 文件管理权限调整 + +### 🐛 修复 + +1. **lscpu 在容器/Docker 无 "Model name" 行导致 Unknown CPU** + - 仪表盘显示 `Unknown CPU` + - 修:`/proc/cpuinfo` fallback 读 model name +2. **chmod UI 修复**(前端) + - 数字校验、3 位 0-7 限制、rwx 实时预览 + - 暴露 `parsePermMode` 处理 0o755 八进制 vs '755' 字符串两种输入 + +### 发布 + +- GitHub Release: `v1.3.35` 文件管理权限调 +- 源码包: `/work/tpanel-v1.3.35-source.zip` + +--- + +## 📦 v1.3.34 — 2026-06-07 【开发版,未发 release】 + +### ✨ 新增 + +1. **phpMyAdmin Signon 模式前端支持** + - 点数据库名 / 🐘 按钮调 `_openPmaWithAutoLogin(dbId, dbName)` + - 拿 5 分钟 HMAC token → 跳 `/api/phpmyadmin/signon?token=...&db=1` + - 跳 `/tpanel-bridge.php?token=...&db=1` → 302 到 pma + - **后端 main.py 当时没实现 token + signon 端点**(直到 v1.3.40 才补) + +2. **改数据库密码前端 UI** + - 🔑 按钮 → 输入新密码 → 调 `/api/databases//password` + - 后端 v1.3.34 同时实现 change_db_password + +--- + +## 📦 v1.3.33 — 2026-06-08 【正式发布】 + +### 杂项 + +- 一些 UI 调整 +- cron manager 优化 + +### 发布 + +- GitHub Release: `v1.3.33` + +--- + +## 📦 v1.3.30 — 2026-06-08 【正式发布】 + +> **重点**: 一键安装脚本 v1.3.30(合并 v1.3.22 ~ v1.3.29 累积修复) + +### 🐛 合并自 1.3.22 ~ 1.3.29 + +- v1.3.22 移动端侧边栏 ☰ 按钮 +- v1.3.23 phpMyAdmin URL 不再硬编码 127.0.0.1 +- v1.3.24 SSL .well-known 查 sqlite 拿真 site_path +- v1.3.25 SSL 申请走任务流 + SSE 进度框 +- v1.3.26 站点类型 php|static + 静态 index.html +- v1.3.27 SSE query string 传 token(EventSource 不能自定义 header) +- v1.3.28 add Sury PHP 源 +- v1.3.29 on_complete 钩子自动配 PHP-FPM listen + 批量重写 nginx conf 按 PHP 版本 + +### 发布 + +- GitHub Release: `v1.3.30` +- 官网: https://tpanel.cn/install.sh 同步更新 + +--- + +## 📦 v1.3.14 — 2026-06-07 【正式发布】 + +> **重点**: `static-check.py` 静态分析工具 + +### 🐛 问题 + +v1.3.10 ~ v1.3.13 四轮迭代反复「装机后才发现」—— sandbox 里没 docker/systemd,不能跑完整 install.sh。但**所有 4 轮 bug 都是同一个模式**:「双向闭环」一边配一边调用漏一边。 + +### ✨ 解法 + +写个 `static-check.py` 静态分析工具,从源码挖双向闭环 bug: + +1. 高危命令 `_run` 裸调(查 `_run(['cmd', ...]` 中 cmd 是否在 DANGEROUS_CMDS 且无 sudo) +2. install.sh sudoers 双向闭环 +3. 前端 `/api/` 路由 vs main.py `@app.route` 一致性 +4. import 模块存在性 +5. 版本号一致性 +6. Nginx 端口 vs main.py 监听端口 +7. on_complete 钩子函数定义存在 +8. phpMyAdmin 反代路径探测逻辑完整 +9. Nginx SSE location 含 1800s timeout +10. sudoers 含 `!requiretty` + +### 🐛 首跑挖出 v1.3.14 候选 bug + +`ssl_manager.py` 108/112/291/295 行裸调 nginx(v1.3.11 漏改)—— 已修复。 + +### 发布 + +- `/work/tpanel-v1.3.14-source.zip` (79KB) +- `/work/tpanel-static-check.py` (16KB, md5=a87524aebffff4e81015ef4249bf3c9c) +- `/work/tpanel-v1.3.14-docs.zip` (16KB) + +### 📝 教训 + +写 install.sh 必须在干净 VPS 上真跑一遍,5 分钟的事。考虑加 install-test 自动化测试。 + +--- + +## 📦 v1.3.13 — 2026-06-07 【正式发布】 + +### 🐛 修复 + +1. **sudo NOPASSWD 实际不生效 → `sudo: a terminal is required to read the password`** + - v1.3.11 硬编码 `/usr/sbin/useradd` 在某些 Debian minimal 镜像上不对 + - 被 `Defaults requiretty` 全局设置挡住 NOPASSWD + - 修:install.sh 用 `command -v` 动态探测命令路径 + - 修:sudoers 加 `Defaults:tpanel !requiretty` 关键声明 + - 修:`visudo -c -f` 语法验证 + - 修:装完立刻试跑 NOPASSWD + - 提供 `tpanel-fix-sudo.sh` 紧急补丁脚本 + +2. **提供 `tpanel-install-test.sh` 装完自检脚本**(6 节检查) + - 系统基本 / sudoers NOPASSWD / Nginx SSE / phpmyadmin 反代 / 端到端 API / 服务健康 + +--- + +## 📦 v1.3.12 — 2026-06-07 【正式发布】 + +### 🐛 修复 + +1. **SSE 连接断开**(用户装机实测) + - Nginx 默认 `proxy_read_timeout 60s`,apt install/upgrade 静默 30s+ 是常态 + - 60s 到点 Nginx 主动断开代理,浏览器 EventSource 看到"连接断开" + - 修:install.sh Nginx 配置为 `/api/tasks//stream` 加专用 location + - `proxy_read_timeout 1800s` + `proxy_buffering off` + `X-Accel-Buffering: no` + `proxy_cache off` + - 提供 `tpanel-fix-sse.sh` 紧急补丁 + +--- + +## 📦 v1.3.11 — 2026-06-07 【正式发布】 + +### 🐛 修复 + +1. **全新装机新建站点报 `useradd: Permission denied`** + - v1.3.10 install.sh 只为 mysql 授权 sudoers + - system.py 中 useradd/userdel/chown/chmod/nginx -s reload 全是裸调 + - 新建站点第一步创建系统用户就 100% 失败 + - 修:install.sh 新增 `/etc/sudoers.d/tpanel-admin` + - NOPASSWD 授权 useradd/userdel/usermod/chown/chmod/nginx/systemctl + - `/usr/sbin/` + `/usr/bin/` 都列上(Debian/CentOS 路径不同) + - 修:system.py 全面加 sudo 前缀 + +### 📝 教训 + +凡是要 root 权限的命令(useradd/chown/nginx 等),system.py 写了 sudo 必须配 sudoers;反过来,install.sh 加 sudoers 必须 system.py 真的调了 sudo——两边要对得上 + +--- + +## 📦 v1.3.10 — 2026-06-07 【正式发布】 + +> **重点**: phpMyAdmin on_complete 钩子 + 软件市场 + 任务管理 + +### 🐛 修复 + +1. **phpMyAdmin 装完无反代 → 点 🐘 死循环 confirm** + - v1.3.10 装完 phpMyAdmin 后没有自动写 Nginx 8443 反代配置 + - `/api/phpmyadmin/status` 永远返回 `nginx_ok=false` + - 前端 `setTimeout(..., 1000)` 跳走再调 status 又触发 confirm + - 修:`setup_phpmyadmin_nginx` 函数实现 + on_complete 钩子 + +2. **软件市场 + 任务管理** + - software 表 + tasks 表 + - `create_task(name, cmd, on_complete=...)` 通用接口 + - 实时进度通过 SSE 推前端 + - 装完自动调 on_complete 钩子 + +3. **预检环境** + - `create_site` 前检查:磁盘空间 / 内存 / nginx 状态 + +### 发布 + +- `/work/tpanel-v1.3.10-source.zip` (61KB, md5=1b6b3ef02ce05ce6a609899d71b3ed0d) +- tpanel.cn/install.sh 同步更新 + +--- + +## 📦 v1.3.9 — 2026-06-06 【正式发布】 + +> **重点**: super release,合并 6/6 全部修复 + +### 🐛 关键修复 + +1. **登录后必须强制刷新才能看到后台**(v1.3.8 时代就有,**用户实测发现**) + - 根因:`showLogin()` 用 `document.body.innerHTML = '...'` 整个重写 body + - 把后台骨架(aside.sidebar + main.main)全部销毁 + - 结果:login() 成功后 `initApp() → setupNav()` 找不到 `.nav-item[data-page]` + - `loadDashboard()` 静默失败(getElementById 返 null 被 try/catch 吞掉) + - 现象:登录后页面卡在登录页,必须 Ctrl+Shift+R 才能进后台 + - **修法**:登录页改独立 `
`,后台骨架包一层 `
` + - `showLogin/hideLogin` 改 display 切换 + - `logout()` 也改用 showLogin() 而非 location.reload() + +### 📝 教训 + +- **永远别 innerHTML 重写 body**;前端 SPA 登录前后页面元素应该一直在 DOM 里 +- **发布前必跑一次完整登录流程**,API 200 不等于 UI 正常 + +### 发布 + +- `/work/tpanel-v1.3.9-source.zip` (49KB, md5=69b461098fc2429533dec918f976f0ad) + +--- + +## 📦 v1.3.8 — 2026-06-05 【正式发布】 + +> **重点**: 8 个隐藏 bug 一次性修 + +1. **zip 魔数校验**:用 `grep -q "PK\x03\x04"`(grep 文本模式不解析 \x),所有合法 zip 都被误判为 404 HTML + - 改用 `od -An -tx1 -N4` + hex 字符串比较 +2. **解压漏复制**:只 `cp backend/ frontend/`,没复制根目录的 `requirements.txt`、`.gitignore` 等文件 +3. **`/etc/nginx/tpanel` 权限**:config.py import 时就 `os.makedirs('/etc/nginx/tpanel')`,tpanel 用户无权限 + - install.sh 补上 `mkdir -p && chown tpanel:tpanel` +4. **systemd ExecStart 没传端口**:ExecStart 写的是 `python main.py`(没传参) +5. **前端 JS 2 处语法错误**(v1.0.0 时代就有,从未暴露) +6. **PHP-FPM 完全没装**(最大坑!所有建 PHP 站的人全 404) + - install.sh 加 `php8.2-fpm + 扩展` 自动装 +7. **PHP-FPM unix socket 在 systemd 环境失效** + - 改用 TCP `127.0.0.1:9000` +8. **MySQL/MariaDB 完全没装 + shell=True SQL 注入**(最危险!) + - install.sh 加 mariadb-server + sudoers + - system.py create_mysql_db/delete_mysql_db 改用 subprocess list + sudo + regex 校验 + +### 📝 教训 + +写 install.sh 必须在干净 VPS 上真跑一遍,5 分钟的事。考虑加 install-test 自动化测试。 + +--- + +## 📦 v1.3.4 — 2026-06-05 【开发版,未发 release】 + +3 个 install.sh 隐藏 bug 修复(同 v1.3.8 的 1/2/3 项) + +--- + +## 📦 v1.3.3 — 2026-06-05 【开发版,未发 release】 + +小修补。 + +--- + +## 📦 v1.3.2 — 2026-06-05 【正式发布】 + +### 🐛 致命 bug + +v1.3.1 的 install.sh 用 `grep -q "PK\x03\x04"` 校验 zip 魔数,但 grep 文本模式不解析 \x 转义,所以**永远拒绝所有 zip**,5 个下载源全挂。 + +### 修法 + +改用 `od -An -tx1 -N4` + hex 字符串比较(`504b0304`),端到端测试通过。 + +### 📝 教训 + +写校验代码必须真实验证,不能"看起来对"。 + +--- + +## 📦 v1.3.1 — 2026-06-04 【正式发布】 + +### ✨ install.sh 健壮性大幅提升(9.5KB) + +- 4 个下载源自动回退:Release → latest → tag → main 分支 +- zip 文件魔数校验(PK\x03\x04),自动识别 404 HTML 不再解压报错 +- 本地兜底:自动扫描 `/tmp/tpanel*.zip` +- 修复:main.py 中 `get_panel_domain` 在空配置下的 500 错误 + +### 发布 + +- GitHub Release: `v1.3.1` +- tpanel.cn/install.sh 同步更新 +- 源码包: `/work/tpanel-v1.3.1-source.zip` (47KB) + +--- + +## 📦 v1.3.0 — 2026-05-30 【稳定版】 + +首次正式发版。Python Flask + SQLite + 原生 HTML/CSS/JS 单文件前端。 + +### 已实现 + +- 网站管理(增删改查 + nginx conf 自动写) +- 数据库管理(MariaDB) +- SSL 证书(Let's Encrypt 申请 / 续期) +- 备份(本地 + 远程 rsync) +- 文件管理(上传 / 编辑 / 权限) +- 定时任务(cron 增删 + 立即执行) +- 安全(每日 03:00 自动 apt upgrade + UFW 防火墙) +- 域名绑定(限制后台访问来源) + +### 设计目标 + +- 单 VPS 80/443 端口默认站 + 多个 vhost +- 一键安装:`wget -O install.sh https://tpanel.cn/install.sh && bash install.sh` +- 默认账号 `admin / tpanel.cn`,服务路径 `/opt/tpanel` + +--- + +## 📋 待办 / 路线图 + +### v1.3.44 (2026-06-28) + +### 🐛 关键修复 +1. **页面刷新后链接点不了** - 强制刷新后必须重新登录的 bug + - 原因:checkAuth() 函数定义了但没被调用 + - 修复:在 DOMContentLoaded 中添加 checkAuth() 调用 + - 现在刷新后自动验证 token,token 有效直接进入后台,所有功能正常可用 + +## v1.3.41+ 候选 + +- **自动注入 pma Signon session 优化**:当前靠 `/tmp/tpanel_signon_data.json` + config auth 凑合 + - 下个版本尝试 pma 5.2 原生 `SignonSession` 模式(不用 config auth) +- **多 PHP 版本切换前端**(用户能选 7.4/8.0/8.1/8.2/8.3/8.4) +- **Node.js 支持**(类似 PHP 装包) +- **nginx 日志查看器**(v1.3+) +- **TPanel 密码修改功能**(前端已占位,后端缺实现) +- **打印机故障诊断**(图片识别问题待解决) + +### 商业化(Freemium 模式) + +- 免费版:全功能使用,页面必须保留作者链接(Powered by TBlog/TPanel) +- 专业版:付费去除链接,解锁高级功能(多站点管理、高级备份、技术支持) +- 技术方案:激活码许可证系统(类似 WordPress/JetBrains) + +--- + +## 🔖 版本号约定 + +- 末位 +1 = 紧急修复(任何时机) +- 末位 +2 = 累积新功能(每月) +- 主版本不动(v1.3 → v2.0 是大重构) +- 注释里 `# v1.3.X` 必标(这个 changelog 才有依据) + +--- + +**最后更新**: 2026-06-12 16:50 CST +**编辑**: Zhang Pu via OpenClaw MiniMax-M3 diff --git a/README.md b/README.md new file mode 100644 index 0000000..a5f7843 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# TPanel - 轻量级 Linux 网站管理面板 + +🛡️ 安全高效的 Linux 网站管理面板,聚焦建站核心功能,开源免费。 + +[![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) +[![Version](https://img.shields.io/badge/version-v1.3.14-blue.svg)](https://github.com/zhang-pu/tpanel/releases/tag/v1.3.14) +[![Python](https://img.shields.io/badge/python-3.8%2B-blue.svg)](https://www.python.org) + +## 特点 + +- 🌐 **站点管理** - 一键创建站点、绑定域名、切换 PHP 版本 +- 🔐 **免费 SSL** - Let's Encrypt 证书一键申请、自动续期 +- 🗄️ **数据库** - 在线创建 MySQL 数据库和用户,精确权限控制 +- 💾 **备份恢复** - 本地备份、远程 rsync 备份,定时自动执行 +- 🛡️ **安全防护** - 站点用户隔离、每日自动安全更新、防火墙规则 +- 📁 **文件管理** - 在线浏览、上传、编辑,权限可视化修改 +- 🐘 **phpMyAdmin** - UI 一键安装,8443 端口独立反代,IP 直访,账号复用站点 db_user/db_pass +- 🧩 **多 PHP 版本** - PHP 5.6 ~ 8.3 一键装,切站点时选版本 +- ⚡ **软件市场** - 软件列表 + 后台任务流(SSE 实时进度),apt/yum 自动适配 + +## 系统要求 + +- Ubuntu 20.04+ / Debian 10+ / CentOS 7+ +- 1GB+ 内存 +- Nginx / PHP / MySQL(安装脚本自动安装) + +## 安装 + +一行命令安装(自动适配 Ubuntu / Debian / CentOS): + +```bash +wget -O install.sh https://tpanel.cn/install.sh && bash install.sh +``` + +**v1.3.14** 包含:站点管理、数据库、SSL、备份、文件管理、定时任务、安全防护、CPU 核心数/型号显示、软件市场(PHP 多版本 + phpMyAdmin)、phpMyAdmin 装完自动配 Nginx 8443 反代、sudoers 完整授权(动态路径 + !requiretty + visudo 验证 + 试跑)、SSE 流不断开、装完自检脚本(6 节)、静态分析工具(10 项检查,发布前必跑)。 + +详见 [CHANGELOG.md](CHANGELOG.md) + +安装完成后访问 `https://your-server.com`,默认账号:`admin` / `tpanel.cn` + +## 技术栈 + +- **后端**: Python3 + Flask + SQLite +- **前端**: 原生 HTML/CSS/JS(零依赖) +- **Web**: Nginx 反向代理 +- **证书**: Let's Encrypt + certbot + +## 目录结构 + +``` +/opt/tpanel/ +├── backend/ # Flask API +├── frontend/ # Web UI +├── data/ # SQLite 数据库 +├── sites/ # 站点目录 +├── backups/ # 备份文件 +├── ssl/ # SSL 证书 +└── logs/ # 日志 +``` + +## 安全设计 + +- 站点用户隔离(每个站点一个 Linux 用户) +- 每日凌晨自动安全更新 +- UFW 防火墙(默认只开放 80/443/22) +- CSRF Token 验证 +- 操作日志审计 + +## 开源协议 + +本项目基于 [MIT License](LICENSE) 开源。 + +## 作者 + +**Zhang Pu** - [zhangpu.dev](https://zhangpu.dev) + +- 官网: https://tpanel.cn +- 文档: https://docs.tpanel.cn \ No newline at end of file diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..4b0a2b7 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,363 @@ +# T面板 - 规格说明书 + +## 1. 项目概述 + +- **名称**:T面板(tpanel) +- **官方网址**:https://tpanel.cn +- **定位**:轻量级 Linux 网站管理面板,聚焦建站核心功能 +- **目标用户**:个人站长、中小型网站管理者 + +## 2. 核心功能 + +### 2.1 网站管理 +- 创建站点(绑定域名、选择PHP版本、设置目录) +- 删除站点(含数据确认) +- 站点列表(域名、状态、创建时间、备份状态) +- 站点起停(nginx reload/restart) +- 流量统计(nginx access log 解析) + +### 2.2 SSL 证书 +- Let's Encrypt 免费证书申请(HTTP验证) +- 证书自动续期(systemd timer 触发 certbot) +- 一键部署到站点 +- 证书状态监控(剩余天数) + +### 2.3 数据库管理 +- 创建 MySQL 数据库 + 用户 +- 删除数据库 +- phpMyAdmin 一键安装(可选) +- 数据库列表(名称、大小、字符集) + +### 2.4 备份系统 +- 本地备份(tar + mysql dump) +- 远程备份(rsync 到另一台服务器) +- 定时备份(cron 表达式) +- 下载备份文件 +- 恢复备份 + +### 2.5 安全功能 +- 系统更新(apt-get security update) +- 站点数据隔离(Linux 用户分离) +- SSH 密钥管理(可选) +- 防火墙规则(UFW) +- 异常登录告警 +- 自动漏洞修复(cron 定期执行) + +### 2.6 文件管理 +- 在线文件浏览 +- 上传文件(压缩包自动解压) +- 编辑配置文件(高亮) +- 权限管理 + +## 3. 技术架构 + +### 3.1 目录结构 +``` +/opt/tpanel/ +├── backend/ +│ ├── main.py # Flask 入口 +│ ├── system.py # 系统操作(nginx/mysql/backup) +│ ├── site.py # 站点管理 +│ ├── database.py # 数据库管理 +│ ├── ssl.py # SSL 管理 +│ ├── firewall.py # 防火墙 +│ ├── security.py # 安全更新 +│ ├── config.py # 配置读写 +│ └── utils.py # 工具函数 +├── frontend/ +│ ├── index.html # 主面板 +│ ├── css/ +│ ├── js/ +│ └── assets/ +├── data/ # SQLite 数据库 +├── sites/ # 站点目录 /www/tpanel/sites/ +├── backups/ # 备份目录 +├── logs/ # 日志 +├── ssl/ # SSL 证书目录 +└── config/ # Nginx 配置 /etc/nginx/tpanel/ + +/etc/systemd/system/tpanel.service +/etc/nginx/tpanel-api.conf # Nginx 反向代理 +``` + +### 3.2 数据库表 + +```sql +-- 管理员账号 +CREATE TABLE admin ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, -- bcrypt + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_login DATETIME +); + +-- 站点 +CREATE TABLE sites ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + domain TEXT NOT NULL UNIQUE, + site_user TEXT NOT NULL UNIQUE, -- Linux 用户名 + site_path TEXT NOT NULL, + php_version TEXT DEFAULT '8.1', + status TEXT DEFAULT 'running', -- running/stopped + ssl_enabled INTEGER DEFAULT 0, + ssl_cert_path TEXT, + ssl_key_path TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- 数据库 +CREATE TABLE databases ( + id INTEGER PRIMARY KEY, + site_id INTEGER REFERENCES sites(id), + name TEXT NOT NULL UNIQUE, + db_user TEXT NOT NULL UNIQUE, + db_pass TEXT NOT NULL, + charset TEXT DEFAULT 'utf8mb4', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- 备份记录 +CREATE TABLE backups ( + id INTEGER PRIMARY KEY, + site_id INTEGER REFERENCES sites(id), + type TEXT DEFAULT 'local', -- local/remote + file_path TEXT, + size INTEGER, + status TEXT DEFAULT 'success', -- success/failed + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- SSL 证书 +CREATE TABLE ssl_certs ( + id INTEGER PRIMARY KEY, + site_id INTEGER REFERENCES sites(id), + domain TEXT NOT NULL, + cert_path TEXT NOT NULL, + key_path TEXT NOT NULL, + expire_date DATETIME, + auto_renew INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- 定时任务 +CREATE TABLE cron_jobs ( + id INTEGER PRIMARY KEY, + site_id INTEGER REFERENCES sites(id), + name TEXT NOT NULL, + schedule TEXT NOT NULL, -- cron 表达式 + command TEXT NOT NULL, + enabled INTEGER DEFAULT 1, + last_run DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- 安全日志 +CREATE TABLE security_logs ( + id INTEGER PRIMARY KEY, + event_type TEXT NOT NULL, + details TEXT, + ip TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- 设置 +CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT +); +``` + +### 3.3 安全机制 + +1. **站点隔离**:每个站点一个 Linux 用户,Home 目录即网站根目录,禁 shell +2. **最小权限原则**:MySQL 用户权限精确到站点数据库 +3. **CSRF 防护**:所有 POST 请求带 token 验证 +4. **命令白名单**:仅允许预设的 shell 命令,无 shell 注入 +5. **HTTPS**:面板强制 HTTPS,API 只能用 Token 认证 +6. **文件上传限制**:仅允许 .zip/.tar.gz 上传,自动解压到站点目录 +7. **日志审计**:所有操作写 security_logs +8. **自动修复**:每日 3:00 执行 apt-get update && apt-get upgrade -y + +### 3.4 防火墙规则(UFW) + +- 默认只开放 22(SSH)、80(HTTP)、443(HTTPS) +- 管理面板端口(例如 8848)仅限 localhost 访问 +- Nginx 反向代理到面板后端 + +## 4. 面板设计 + +### 4.1 界面风格 + +- 风格:深色主题 + 绿色点缀(科技感、安全感) +- 字体:JetBrains Mono(代码)+ Inter(界面) +- 配色:主色 #22c55e(绿色),背景 #0f172a(深蓝黑),卡片 #1e293b + +### 4.2 布局 + +``` +┌─────────────────────────────────────────────────┐ +│ [Logo] T面板 [站点数] [状态] [安全更新] │ +├─────────────────────────────────────────────────┤ +│ │ +│ [仪表盘] [网站] [数据库] [SSL] [备份] [安全] │ +│ │ +│ ┌─────────────────────────────────────────┐ │ +│ │ 内容区域 │ │ +│ └─────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────┘ +``` + +## 5. API 设计 + +### 5.1 认证 + +``` +POST /api/auth/login { username, password } +POST /api/auth/logout +GET /api/auth/check (Header: Authorization: Bearer ) +``` + +### 5.2 站点 + +``` +GET /api/sites 列表 +POST /api/sites 创建 { domain, php_version, path } +GET /api/sites/:id 详情 +PUT /api/sites/:id 更新 { status, ssl } +DELETE /api/sites/:id 删除 +POST /api/sites/:id/start 启动 +POST /api/sites/:id/stop 停止 +POST /api/sites/:id/backup 触发备份 +``` + +### 5.3 数据库 + +``` +GET /api/databases +POST /api/databases 创建 { site_id, name, user, pass } +DELETE /api/databases/:id +``` + +### 5.4 SSL + +``` +POST /api/ssl/apply { site_id, domain } +GET /api/ssl/certs 列表 +POST /api/ssl/renew/:id 续期 +``` + +### 5.5 备份 + +``` +GET /api/backups 列表 +POST /api/backups 创建 { site_id, type } +GET /api/backups/:id/download +POST /api/backups/:id/restore +DELETE /api/backups/:id +``` + +### 5.6 安全 + +``` +GET /api/security/logs 安全日志 +POST /api/security/update 执行系统更新 +GET /api/security/status 安全状态(已安装更新数、漏洞数) +``` + +## 6. 安装流程 + +### 6.1 一键安装脚本 + +```bash +wget -O install.sh https://tpanel.cn/install.sh +bash install.sh +``` + +安装脚本做的事情: +1. 检测系统(Debian 10+ / Ubuntu 20.04+) +2. 安装 Nginx、MySQL、Python3、certbot +3. 创建 tpanel 用户和目录 +4. 初始化 SQLite 数据库 +5. 配置 systemd 服务 +6. 配置 Nginx 反向代理 +7. 申请 Let's Encrypt 面板证书 +8. 启动服务 + +### 6.2 默认端口 + +- 面板访问:https://localhost:8848 (仅本地访问) +- 通过 Nginx 反代到域名,例如 https://tpanel.cn + +## 7. 版本规划 + +### v1.0.0(首发) +- 站点 CRUD +- MySQL 数据库 CRUD +- Let's Encrypt SSL +- 本地备份 +- 系统安全更新 + +### v1.1.0 +- 远程备份(rsync) +- 定时任务 +- 文件管理 + +### v1.2.0 +- Cron 表达式验证 +- 远程 rsync 备份 + 恢复 +- 连接测试工具 +- 备份统计面板 + +### v1.3.14(2026-06-07) +- ✅ 修复 ssl_manager.py 裸调 nginx -t / nginx -s reload(v1.3.11 漏改) +- ✅ 新增 tpanel-static-check.py 静态分析工具(10 项检查,不装机能抱 80% 装完才暴露的 bug) +- ✅ 提供 run-static-check.sh 入口脚本,发布前必跑 + +### v1.3.13(2026-06-07) +- ✅ 修复 sudo NOPASSWD 没生效(requiretty 阻挡 / 命令路径不一致)问题 +- ✅ install.sh 用 `command -v` 动态探测真实路径,加 `!requiretty` 声明,加 `visudo -c` 验证,加 NOPASSWD 试跑 +- ✅ 提供 `tpanel-fix-sudo.sh` 紧急补丁脚本 +- ✅ 提供 `tpanel-install-test.sh` 装完自检脚本(6 节检查覆盖 v1.3.10~v1.3.12 全部隐藏问题) + +### v1.3.12(2026-06-07) +- ✅ 修复软件安装 / 安全更新 SSE 流 “连接断开” 问题 +- ✅ Nginx 为 `/api/tasks//stream` 拉专用 location:`proxy_read_timeout 1800s` + `proxy_buffering off` + `X-Accel-Buffering: no` +- ✅ 提供 `tpanel-fix-sse.sh` 紧急补丁脚本,老用户一键修复(只 reload nginx,不动 tpanel 服务) + +### v1.3.11(2026-06-07) +- ✅ 修复全新装机后新建站点 `useradd: Permission denied` 的 bug(sudoers 漏授权) +- ✅ install.sh 新增 `/etc/sudoers.d/tpanel-admin`:useradd/userdel/usermod/chown/chmod/nginx/systemctl NOPASSWD +- ✅ system.py 全面加 `sudo` 前缀(useradd/userdel/set_site_permissions 的 chown/chmod/nginx -t + reload/nginx stop + start/apt-get update) + +### v1.3.10(2026-06-07) +- ✅ 修复 phpMyAdmin 装完无 Nginx 8443 反代导致点 🐘 死循环 confirm 的 bug +- ✅ `task_manager.create_task` 新增 `on_complete(task_id, status)` 钩子(success/failed 都调,通用联动机制) +- ✅ 新增 `task_manager.setup_phpmyadmin_nginx()`:自动探 PMA 路径 → `sudo mv` 写 `/etc/nginx/sites-enabled/phpmyadmin.conf` → `nginx -t` → `systemctl reload nginx`(含安全加固) +- ✅ `api_phpmyadmin_status` 改三维判断(`sw_installed AND files_exist AND nginx_ok`),返回详细字段 +- ✅ 前端 `openPhpMyAdmin` 改轮询 `_pollPhpMyAdminReady(30s)`,等 nginx_ok=true 再跳 + +### v1.3.9(2026-06-06,super release) +- ✅ 修复登录后必须强制刷新才能看到后台的 bug +- ✅ 仪表盘显示 CPU 核心数 + 型号 +- ✅ 负载颜色按核心数判断 + +### v1.3.x(2026-06-05) +- ✅ PHP 5.6 / 7.0 / 7.4 / 8.0 / 8.1 / 8.2 / 8.3 软件市场一键装 +- ✅ phpMyAdmin UI 一键装(之前无反代配置,v1.3.10 修) +- ✅ 软件市场后台任务流(SSE 实时进度) + +### v1.3.0(待开发) +- Node.js 支持 +- 日志查看器(nginx access/error log) + +## 8. 开源协议 + +MIT License + +## 9. 作者 + +- 作者:Zhang Pu +- 网站:https://zhangpu.dev +- 面板官网:https://tpanel.cn \ No newline at end of file diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..dac0b4f --- /dev/null +++ b/backend/config.py @@ -0,0 +1,111 @@ +""" +TPanel - T面板 配置模块 +""" +import os +import json + +BASE_DIR = '/opt/tpanel' +DATA_DIR = os.path.join(BASE_DIR, 'data') +LOG_DIR = os.path.join(BASE_DIR, 'logs') +SITES_DIR = os.path.join(BASE_DIR, 'sites') +BACKUP_DIR = os.path.join(BASE_DIR, 'backups') +SSL_DIR = os.path.join(BASE_DIR, 'ssl') +CONFIG_DIR = os.path.join(BASE_DIR, 'config') +NGINX_CONF_DIR = '/etc/nginx/tpanel' + +DB_PATH = os.path.join(DATA_DIR, 'tpanel.db') + +# Nginx 配置目录(由 install.sh 创建) +os.makedirs(NGINX_CONF_DIR, exist_ok=True) +os.makedirs(LOG_DIR, exist_ok=True) +os.makedirs(SITES_DIR, exist_ok=True) +os.makedirs(BACKUP_DIR, exist_ok=True) +os.makedirs(SSL_DIR, exist_ok=True) +os.makedirs(CONFIG_DIR, exist_ok=True) + +def load_config(): + path = os.path.join(CONFIG_DIR, 'tpanel.conf') + if os.path.exists(path): + with open(path, 'r') as f: + return json.load(f) + return { + 'panel_port': 8848, + 'panel_domain': '', + 'php_versions': ['7.4', '8.0', '8.1', '8.2'], + 'default_php': '8.1', + 'auto_ssl_renew': True, + 'backup_retention_days': 7, + 'security_auto_update': True, + 'firewall_enabled': True, + 'ssh_port': 22, + } + +def save_config(cfg): + path = os.path.join(CONFIG_DIR, 'tpanel.conf') + with open(path, 'w') as f: + json.dump(cfg, f, indent=2) + +def get_setting(key, default=''): + """从数据库读取设置""" + import sqlite3 + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)) + row = cur.fetchone() + conn.close() + return row[0] if row else default + +def set_setting(key, value): + """v1.3.43+: busy_timeout + WAL 防锁""" + import sqlite3 + conn = sqlite3.connect(DB_PATH, timeout=30) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=30000") + conn.execute("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?", + (key, value, value)) + conn.commit() + conn.close() + +def get_panel_domain(): + """获取面板绑定的域名,无绑定则返回空字符串""" + return get_setting('panel_domain', '') + +def is_domain_allowed(host): + """检查请求的 Host 是否在允许的域名列表中""" + allowed = get_panel_domain().strip() + if not allowed: + return True # 未绑定域名,不限制 + + allowed = allowed.lower().strip() + host = host.lower().strip() + + # 支持带端口的 host(如 localhost:8848) + host_clean = host.split(':')[0] + allowed_clean = allowed.split(':')[0] + + # 也允许 localhost 和 127.0.0.1 + safe_hosts = ['localhost', '127.0.0.1', '::1'] + if host_clean in safe_hosts: + return True + + return host_clean == allowed_clean or host == allowed +# v1.3.34+: 用于 phpMyAdmin 自动登录 token 签名 +_SECRET_FILE = os.path.join(DATA_DIR, ".secret_key") +def get_secret_key(): + """加载或生成 SECRET_KEY(启动时一次,进程内复用)""" + if os.path.exists(_SECRET_FILE): + with open(_SECRET_FILE, "r") as f: + return f.read().strip() + sk = os.urandom(32).hex() + with open(_SECRET_FILE, "w") as f: + f.write(sk) + try: + os.chmod(_SECRET_FILE, 0o600) + import pwd + uid = pwd.getpwnam("tpanel").pw_uid + gid = pwd.getpwnam("tpanel").pw_gid + os.chown(_SECRET_FILE, uid, gid) + except Exception: + pass + return sk + +SECRET_KEY = get_secret_key() diff --git a/backend/cron_manager.py b/backend/cron_manager.py new file mode 100644 index 0000000..195e2aa --- /dev/null +++ b/backend/cron_manager.py @@ -0,0 +1,258 @@ +""" +TPanel - 定时任务管理模块 +""" +import os +import sqlite3 +import subprocess +from datetime import datetime +from config import DB_PATH + +def _run(cmd, timeout=30, shell=False): + try: + if isinstance(cmd, str) and not shell: + cmd = cmd.split() + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, shell=shell) + return result.returncode, result.stdout.strip(), result.stderr.strip() + except subprocess.TimeoutExpired: + return -1, '', 'Command timed out' + except Exception as e: + return -1, '', str(e) + +def get_all_crons(): + """获取所有定时任务""" + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("""SELECT c.*, s.domain FROM cron_jobs c + LEFT JOIN sites s ON c.site_id = s.id + ORDER BY c.id DESC""") + cols = [d[0] for d in cur.description] + rows = [dict(zip(cols, r)) for r in cur.fetchall()] + conn.close() + return rows + +def create_cron(site_id, name, schedule, command): + """ + 创建定时任务 + schedule: cron 表达式,如 "0 3 * * *" (每天3点) + command: 要执行的命令 + """ + # 验证 cron 表达式格式 + parts = schedule.strip().split() + if len(parts) != 5: + return None, 'Cron 表达式格式错误,需要 5 段:分 时 日 月 周' + + # 生成一个唯一文件名 + import hashlib + token = hashlib.md5(f'{site_id}{name}{command}{datetime.now()}'.encode()).hexdigest()[:12] + script_name = f'cron_{token}.sh' + + # 写入站点目录的 cron 脚本 + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT site_user FROM sites WHERE id = ?", (site_id,)) + row = cur.fetchone() + conn.close() + + if not row: + return None, '站点不存在' + + site_user = row[0] + cron_dir = f'/opt/tpanel/sites/{site_user}/.cron' + os.makedirs(cron_dir, exist_ok=True) + + script_path = os.path.join(cron_dir, script_name) + with open(script_path, 'w') as f: + f.write(f'#!/bin/bash\n{command}\n') + os.chmod(script_path, 0o755) + + # 写入系统 crontab(用 sudo 切换到站点用户执行) + cron_line = f'{schedule} sudo -u {site_user} {script_path} >> /opt/tpanel/logs/cron_{token}.log 2>&1' + + # 读取现有 crontab + code, out, err = _run(f'crontab -l 2>/dev/null || echo ""', shell=True) + existing = out if code == 0 else '' + + # 检查是否已有同名任务 + lines = [l for l in existing.split('\n') if script_name not in l and l.strip()] + lines.append(cron_line) + + # 写回 crontab + new_cron = '\n'.join(lines) + '\n' + code, out, err = _run(f'echo "{new_cron}" | crontab -', shell=True, timeout=10) + + if code != 0: + os.remove(script_path) + return None, f'Crontab 写入失败: {err}' + + # 写入数据库 + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("""INSERT INTO cron_jobs (site_id, name, schedule, command) + VALUES (?, ?, ?, ?)""", + (site_id, name, schedule, command)) + conn.commit() + cron_id = cur.lastrowid + conn.close() + + return cron_id, '定时任务创建成功' + +def delete_cron(cron_id): + """删除定时任务""" + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT name, command FROM cron_jobs WHERE id = ?", (cron_id,)) + row = cur.fetchone() + if not row: + conn.close() + return False, '任务不存在' + + name, command = row + + # 从 crontab 移除 + code, out, err = _run('crontab -l 2>/dev/null || echo ""', shell=True) + if code == 0 and out: + lines = [l for l in out.split('\n') if name not in l and l.strip()] + _run(f'echo "{chr(10).join(lines)}\n" | crontab -', shell=True, timeout=10) + + # 删除脚本文件 + cron_dir = '/opt/tpanel/sites' + for site_dir in os.listdir('/opt/tpanel/sites'): + script = os.path.join(cron_dir, site_dir, '.cron') + if os.path.exists(script): + for f in os.listdir(script): + if name in f: + try: + os.remove(os.path.join(script, f)) + except: + pass + + conn.execute("DELETE FROM cron_jobs WHERE id = ?", (cron_id,)) + conn.commit() + conn.close() + + return True, '任务已删除' + +def enable_cron(cron_id, enabled): + """启用/禁用定时任务""" + conn = sqlite3.connect(DB_PATH) + conn.execute("UPDATE cron_jobs SET enabled = ? WHERE id = ?", (1 if enabled else 0, cron_id)) + + # 如果禁用,从 crontab 注释掉;如果启用,恢复 + cur = conn.execute("SELECT name, schedule, command FROM cron_jobs WHERE id = ?", (cron_id,)) + row = cur.fetchone() + conn.close() + + if not row: + return False, '任务不存在' + + name, schedule, command = row + prefix = '' if enabled else '#' + + # 简单处理:重新生成 crontab + # 获取所有启用的任务重新写入 + conn2 = sqlite3.connect(DB_PATH) + cur2 = conn2.execute("SELECT name, schedule, command, enabled FROM cron_jobs WHERE enabled = 1") + enabled_rows = cur2.fetchall() + conn2.close() + + lines = [] + for r in enabled_rows: + n, s, c = r[0], r[1], r[2] + import hashlib + token = hashlib.md5(f'{n}{c}'.encode()).hexdigest()[:12] + lines.append(f'{s} sudo -u {get_site_user_by_name(n)} /opt/tpanel/sites/{get_site_user_by_name(n)}/.cron/cron_{token}.sh >> /opt/tpanel/logs/cron_{token}.log 2>&1') + + if enabled: + _run(f'echo "{"".join([l + chr(10) for l in lines])}" | crontab -', shell=True, timeout=10) + + return True, f'任务已{"启用" if enabled else "禁用"}' + +def get_site_user_by_name(name): + """根据任务名查找站点用户(辅助)""" + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT site_user FROM sites LIMIT 1") + row = cur.fetchone() + conn.close() + return row[0] if row else 'tpanel' + +def run_cron_now(cron_id): + """立即执行定时任务(手动触发)""" + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT site_id, name, command FROM cron_jobs WHERE id = ?", (cron_id,)) + row = cur.fetchone() + conn.close() + + if not row: + return False, '任务不存在' + + site_id, name, command = row + + conn2 = sqlite3.connect(DB_PATH) + cur2 = conn2.execute("SELECT site_user FROM sites WHERE id = ?", (site_id,)) + row2 = cur2.fetchone() + conn2.close() + + if not row2: + return False, '站点不存在' + + site_user = row2[0] + + # 以站点用户身份执行命令 + code, out, err = _run( + f'sudo -u {site_user} bash -c "{command}"', + shell=True, timeout=60 + ) + + # 更新最后执行时间 + conn3 = sqlite3.connect(DB_PATH) + conn3.execute("UPDATE cron_jobs SET last_run = ? WHERE id = ?", + (datetime.now().isoformat(), cron_id)) + conn3.commit() + conn3.close() + + return code == 0, out if code == 0 else err + +def validate_cron_expression(expr): + """验证 cron 表达式是否有效""" + parts = expr.strip().split() + if len(parts) != 5: + return False, '需要 5 段:分 时 日 月 周' + + labels = ['分', '时', '日', '月', '周'] + ranges = [ + (0, 59), # 分: 0-59 + (0, 23), # 时: 0-23 + (1, 31), # 日: 1-31 + (1, 12), # 月: 1-12 + (0, 6), # 周: 0-6 (0=周日) + ] + + for i, (part, (lo, hi)) in enumerate(zip(parts, ranges)): + if part == '*': + continue + if '/' in part: + base, step = part.split('/') + if not step.isdigit(): + return False, f'{labels[i]} 步长必须是数字' + continue + if ',' in part: + for p in part.split(','): + try: + v = int(p) + if v < lo or v > hi: + return False, f'{labels[i]} 范围 {lo}-{hi}' + except: + return False, f'{labels[i]} 包含无效值' + continue + if '-' in part: + start, end = part.split('-') + try: + if int(start) < lo or int(end) > hi: + return False, f'{labels[i]} 范围 {lo}-{hi}' + except: + return False, f'{labels[i]} 格式错误' + continue + try: + v = int(part) + if v < lo or v > hi: + return False, f'{labels[i]} 范围 {lo}-{hi}' + except: + return False, f'{labels[i]} 包含无效字符' + + return True, '格式正确' \ No newline at end of file diff --git a/backend/db_init.py b/backend/db_init.py new file mode 100644 index 0000000..253e2fb --- /dev/null +++ b/backend/db_init.py @@ -0,0 +1,157 @@ +""" +TPanel - 数据库初始化 +""" +import sqlite3 +import os +import bcrypt +from config import DB_PATH, BASE_DIR + +def init_db(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + + cur.execute(''' + CREATE TABLE IF NOT EXISTS admin ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_login DATETIME + )''') + + cur.execute(''' + CREATE TABLE IF NOT EXISTS sites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + domain TEXT NOT NULL UNIQUE, + site_user TEXT NOT NULL UNIQUE, + site_path TEXT NOT NULL, + php_version TEXT DEFAULT '8.1', + status TEXT DEFAULT 'running', + ssl_enabled INTEGER DEFAULT 0, + ssl_cert_path TEXT, + ssl_key_path TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )''') + + # v1.3.26: 站点类型列(php / static),default 'php'(老站点全为 php) + # 先检查列是否存在,不存在才加(幂等) + cur.execute("PRAGMA table_info(sites)") + cols = {row[1] for row in cur.fetchall()} + if 'site_type' not in cols: + try: + cur.execute("ALTER TABLE sites ADD COLUMN site_type TEXT DEFAULT 'php'") + except Exception: + pass + + cur.execute(''' + CREATE TABLE IF NOT EXISTS databases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_id INTEGER REFERENCES sites(id) ON DELETE CASCADE, + name TEXT NOT NULL UNIQUE, + db_user TEXT NOT NULL UNIQUE, + db_pass TEXT NOT NULL, + charset TEXT DEFAULT 'utf8mb4', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )''') + + cur.execute(''' + CREATE TABLE IF NOT EXISTS backups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_id INTEGER REFERENCES sites(id) ON DELETE CASCADE, + type TEXT DEFAULT 'local', + file_path TEXT, + size INTEGER, + status TEXT DEFAULT 'success', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )''') + + cur.execute(''' + CREATE TABLE IF NOT EXISTS ssl_certs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_id INTEGER REFERENCES sites(id) ON DELETE CASCADE, + domain TEXT NOT NULL, + cert_path TEXT NOT NULL, + key_path TEXT NOT NULL, + expire_date TEXT, + auto_renew INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )''') + + cur.execute(''' + CREATE TABLE IF NOT EXISTS cron_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_id INTEGER REFERENCES sites(id) ON DELETE CASCADE, + name TEXT NOT NULL, + schedule TEXT NOT NULL, + command TEXT NOT NULL, + enabled INTEGER DEFAULT 1, + last_run DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )''') + + cur.execute(''' + CREATE TABLE IF NOT EXISTS security_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + details TEXT, + ip TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )''') + + cur.execute(''' + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT + )''') + + # v1.3.10+ 软件市场表 + cur.execute(''' + CREATE TABLE IF NOT EXISTS software ( + name TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + category TEXT NOT NULL, + installed INTEGER DEFAULT 0, + version TEXT, + last_check DATETIME, + last_install DATETIME + )''') + + # v1.3.10+ 任务表(用于实时进度) + cur.execute(''' + CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + target TEXT, + status TEXT DEFAULT 'running', + log TEXT DEFAULT '', + started_at DATETIME DEFAULT CURRENT_TIMESTAMP, + finished_at DATETIME, + exit_code INTEGER + )''') + + + + cur.execute(''' + CREATE TABLE IF NOT EXISTS backup_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + enabled INTEGER DEFAULT 0, + schedule TEXT DEFAULT ' 3 * * *', + keep_days INTEGER DEFAULT 7, + backup_dir TEXT DEFAULT '/backup', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )''') + # 默认管理员账号 admin / tpanel.cn + cur.execute("SELECT id FROM admin WHERE username = ?", ('admin',)) + if not cur.fetchone(): + pw_hash = bcrypt.hashpw(b'tpanel.cn', bcrypt.gensalt()).decode() + cur.execute("INSERT INTO admin (username, password_hash) VALUES (?, ?)", + ('admin', pw_hash)) + conn.commit() + + conn.close() + print("[TPanel] 数据库初始化完成") + +if __name__ == '__main__': + init_db() \ No newline at end of file diff --git a/backend/file_manager.py b/backend/file_manager.py new file mode 100644 index 0000000..6c9767f --- /dev/null +++ b/backend/file_manager.py @@ -0,0 +1,404 @@ +""" +TPanel - 文件管理模块 +v1.3.42 修复:支持管理员模式任意目录读写 + sudo提权 +""" +import os +import zipfile +import tarfile +import shutil +import subprocess +from datetime import datetime + +def _run(cmd, timeout=30, sudo=False): + """执行命令,支持sudo提权""" + try: + if isinstance(cmd, str): + cmd = cmd.split() + if sudo: + cmd = ['sudo', '-n'] + cmd + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return result.returncode, result.stdout.strip(), result.stderr.strip() + except subprocess.TimeoutExpired: + return -1, '', 'Command timed out' + except Exception as e: + return -1, '', str(e) + +def list_directory(path, site_user=None, admin_mode=False): + """列出目录内容,带安全和权限信息 + admin_mode=True:允许浏览任意目录(管理员模式) + """ + # 安全检查:防止路径遍历 + real_path = os.path.realpath(path) + allowed_base = ['/opt/tpanel/sites', '/opt/tpanel/backups'] + if not admin_mode and not any(real_path.startswith(base) for base in allowed_base): + return None, '路径不在允许范围内' + + # 管理员模式下禁止访问系统关键目录 + if admin_mode: + blocked_paths = ['/proc', '/sys', '/dev', '/run', '/var/lib/mysql', '/root/.ssh'] + for blocked in blocked_paths: + if real_path.startswith(blocked): + return None, '系统关键目录不允许访问' + + if not os.path.exists(path): + return None, '目录不存在' + + items = [] + try: + entries = os.listdir(path) + except PermissionError: + # 管理员模式下无权限尝试sudo + if admin_mode: + code, stdout, stderr = _run(f'ls -1A {path}', sudo=True) + if code == 0: + entries = stdout.split('\n') + else: + return None, '无权限访问' + else: + return None, '无权限访问' + + for name in sorted(entries): + fp = os.path.join(path, name) + try: + stat = os.stat(fp) + is_dir = os.path.isdir(fp) + + # 文件大小 + if is_dir: + size = 0 + else: + size = stat.st_size + + items.append({ + 'name': name, + 'type': 'dir' if is_dir else 'file', + 'size': size, + 'size_str': format_size(size), + 'modified': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M'), + 'permissions': stat.st_mode & 0o777, + 'perm_str': format_permissions(stat.st_mode & 0o777), + 'readable': os.access(fp, os.R_OK) or admin_mode, + 'writable': os.access(fp, os.W_OK) or admin_mode, + }) + except Exception: + continue + + return items, None + +def format_size(size): + if size < 1024: + return str(size) + ' B' + elif size < 1024 * 1024: + return f'{size / 1024:.1f} KB' + elif size < 1024 * 1024 * 1024: + return f'{size / (1024 * 1024):.1f} MB' + else: + return f'{size / (1024 * 1024 * 1024):.2f} GB' + +def format_permissions(mode): + chars = ['---', '--x', '-w-', '-wx', 'r--', 'r-x', 'rw-', 'rwx'] + return chars[(mode >> 6) & 7] + chars[(mode >> 3) & 7] + chars[mode & 7] + +def read_file(path, max_size=1024 * 1024, admin_mode=False): + """读取文件内容(限制1MB) + admin_mode=True:允许读取任意文本文件 + """ + real_path = os.path.realpath(path) + if not os.path.exists(real_path): + return None, '文件不存在' + + # 检查文件大小 + try: + file_size = os.path.getsize(real_path) + except: + if admin_mode: + code, stdout, stderr = _run(f'stat -c %s {real_path}', sudo=True) + if code == 0: + file_size = int(stdout.strip()) + else: + return None, '无法获取文件大小' + else: + return None, '无权限读取文件' + + if file_size > max_size: + return None, f'文件超过 {max_size//1024}KB 限制' + + # 非管理员模式:只允许读取配置文件和常见文本格式 + if not admin_mode: + allowed_ext = ['.php', '.html', '.htm', '.css', '.js', '.json', '.txt', '.md', + '.yaml', '.yml', '.xml', '.conf', '.ini', '.log', '.sql'] + ext = os.path.splitext(path)[1].lower() + if ext not in allowed_ext and not any(path.endswith(x) for x in ['/config.php', '/.htaccess']): + return None, '文件类型不允许读取' + + try: + # 尝试普通读取 + with open(real_path, 'r', encoding='utf-8', errors='ignore') as f: + return f.read(), None + except PermissionError: + if admin_mode: + # 管理员模式用sudo读取 + code, stdout, stderr = _run(f'cat {real_path}', sudo=True) + if code == 0: + return stdout, None + else: + return None, f'读取失败: {stderr}' + else: + return None, '无权限读取文件' + except Exception as e: + return None, str(e) + +def write_file(path, content, admin_mode=False): + """写入文件 + admin_mode=True:允许写入任意路径,自动sudo提权 + """ + real_path = os.path.realpath(path) + if not admin_mode and not real_path.startswith('/opt/tpanel/sites'): + return False, '路径不在允许范围内' + + # 管理员模式下禁止写入系统关键文件 + if admin_mode: + blocked_paths = ['/proc', '/sys', '/dev', '/run', '/var/lib/mysql', '/root/.ssh', '/etc/sudoers', '/etc/passwd', '/etc/shadow'] + for blocked in blocked_paths: + if real_path.startswith(blocked): + return False, '系统关键文件不允许修改' + + try: + # 先尝试普通写入 + with open(real_path, 'w', encoding='utf-8') as f: + f.write(content) + # 确保站点目录权限正确(非管理员模式) + if not admin_mode and real_path.startswith('/opt/tpanel/sites'): + _run(f'chown tpanel:tpanel {real_path}', sudo=True) + return True, '文件已保存' + except PermissionError: + if admin_mode or real_path.startswith('/opt/tpanel/sites'): + # 用sudo tee写入 + proc = subprocess.run( + ['sudo', '-n', 'tee', real_path], + input=content.encode('utf-8'), + capture_output=True, + timeout=10 + ) + if proc.returncode == 0: + # 确保文件权限正常 + _run(f'chmod 644 {real_path}', sudo=True) + return True, '文件已保存' + else: + return False, f'写入失败: {proc.stderr.decode()}' + else: + return False, '无权限写入文件' + except Exception as e: + return False, str(e) + +def upload_file(upload_dir, file_obj, filename, admin_mode=False): + """上传文件到目录 + admin_mode=True:允许上传到任意路径 + """ + real_path = os.path.realpath(upload_dir) + if not admin_mode and not real_path.startswith('/opt/tpanel/sites'): + return False, '路径不在允许范围内' + + # 限制文件类型 + allowed = ['.php', '.html', '.htm', '.css', '.js', '.json', '.txt', '.md', + '.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.ico', + '.zip', '.tar', '.gz', '.bz2', + '.pdf', '.doc', '.docx', '.xls', '.xlsx', + '.woff', '.woff2', '.ttf', '.eot'] + ext = os.path.splitext(filename)[1].lower() + if ext not in allowed: + return False, f'文件类型 {ext} 不允许上传' + + dest = os.path.join(upload_dir, filename) + try: + file_obj.save(dest) + # 非管理员模式下修正权限 + if not admin_mode: + _run(f'chown tpanel:tpanel {dest}', sudo=True) + return True, f'文件已上传:{filename}' + except PermissionError: + if admin_mode or real_path.startswith('/opt/tpanel/sites'): + # 先写到临时文件再sudo移动 + import tempfile + with tempfile.NamedTemporaryFile(delete=False) as tmp: + file_obj.save(tmp.name) + tmp_path = tmp.name + code, stdout, stderr = _run(f'mv {tmp_path} {dest}', sudo=True) + if code == 0: + _run(f'chmod 644 {dest}', sudo=True) + return True, f'文件已上传:{filename}' + else: + os.unlink(tmp_path) + return False, f'上传失败: {stderr}' + else: + return False, '无权限上传文件' + except Exception as e: + return False, str(e) + +def delete_file(path, admin_mode=False): + """删除文件或目录 + admin_mode=True:允许删除任意路径,自动sudo提权 + """ + real_path = os.path.realpath(path) + if not admin_mode and not real_path.startswith('/opt/tpanel/sites'): + return False, '路径不在允许范围内' + + # 管理员模式下禁止删除系统关键目录 + if admin_mode: + blocked_paths = ['/proc', '/sys', '/dev', '/run', '/var/lib/mysql', '/root/.ssh', '/etc', '/usr', '/bin', '/sbin', '/opt/tpanel/venv', '/opt/tpanel/backend'] + for blocked in blocked_paths: + if real_path.startswith(blocked) and real_path != blocked.rstrip('/'): + return False, '系统关键目录不允许删除' + + try: + if os.path.isdir(path): + shutil.rmtree(path) + else: + os.remove(path) + return True, '已删除' + except PermissionError: + if admin_mode or real_path.startswith('/opt/tpanel/sites'): + if os.path.isdir(path): + code, stdout, stderr = _run(f'rm -rf {path}', sudo=True) + else: + code, stdout, stderr = _run(f'rm -f {path}', sudo=True) + if code == 0: + return True, '已删除' + else: + return False, f'删除失败: {stderr}' + else: + return False, '无权限删除' + except Exception as e: + return False, str(e) + +def chmod_file(path, mode, admin_mode=False): + """修改文件权限(限制范围) + admin_mode=True:允许修改任意路径权限 + """ + real_path = os.path.realpath(path) + if not admin_mode and not real_path.startswith('/opt/tpanel/sites'): + return False, '路径不在允许范围内' + + # 解析权限 + try: + if isinstance(mode, str): + mode = int(mode, 8) + elif isinstance(mode, int) and mode < 0o1000: + mode = int(str(mode), 8) if mode < 1000 else mode + except (ValueError, TypeError): + return False, '权限值格式错误(应该是 755、644 这种)' + + perm = mode & 0o777 + if not admin_mode and perm not in [0o755, 0o644, 0o600, 0o700, 0o775, 0o664]: + return False, f'权限值不允许({oct(perm)},可选 755/644/600/700/775/664)' + + try: + os.chmod(path, perm) + return True, f'权限已修改为 {oct(perm)}' + except PermissionError: + if admin_mode or real_path.startswith('/opt/tpanel/sites'): + code, stdout, stderr = _run(f'chmod {oct(perm)[2:]} {path}', sudo=True) + if code == 0: + return True, f'权限已修改为 {oct(perm)}' + else: + return False, f'修改权限失败: {stderr}' + else: + return False, '无权限修改权限' + except Exception as e: + return False, str(e) + +def create_directory(path, dirname, admin_mode=False): + """创建目录 + admin_mode=True:允许在任意路径创建目录 + """ + real_path = os.path.realpath(path) + if not admin_mode and not real_path.startswith('/opt/tpanel/sites'): + return False, '路径不在允许范围内' + + new_path = os.path.join(path, dirname) + try: + os.makedirs(new_path, exist_ok=True) + if not admin_mode: + _run(f'chown -R tpanel:tpanel {new_path}', sudo=True) + return True, f'目录已创建:{dirname}' + except PermissionError: + if admin_mode or real_path.startswith('/opt/tpanel/sites'): + code, stdout, stderr = _run(f'mkdir -p {new_path}', sudo=True) + if code == 0: + if not admin_mode: + _run(f'chown -R tpanel:tpanel {new_path}', sudo=True) + return True, f'目录已创建:{dirname}' + else: + return False, f'创建目录失败: {stderr}' + else: + return False, '无权限创建目录' + except Exception as e: + return False, str(e) + + +def extract_archive(archive_path, target_dir, delete_after=False, admin_mode=False): + """解压压缩包到目标目录 + 支持 zip / tar / tar.gz / tgz + v1.3.41: 带 zip slip / tar slip 防护 + """ + real_archive = os.path.realpath(archive_path) + real_target = os.path.realpath(target_dir) + # 安全:必须在允许的路径下 + if not admin_mode and not real_archive.startswith('/opt/tpanel/sites'): + return False, '压缩包路径不在允许范围内' + if not admin_mode and not real_target.startswith('/opt/tpanel/sites'): + return False, '目标路径不在允许范围内' + if not os.path.isfile(real_archive): + return False, '压缩包不存在' + + filename = os.path.basename(real_archive).lower() + file_count = 0 + try: + if filename.endswith('.zip'): + with zipfile.ZipFile(real_archive, 'r') as zf: + # 防 zip slip: 拒绝 ../ 跳出 target + for member in zf.namelist(): + member_path = os.path.realpath(os.path.join(real_target, member)) + if not member_path.startswith(real_target): + return False, f'压缩包含非法路径: {member}' + zf.extractall(real_target) + file_count = len(zf.namelist()) + elif filename.endswith('.tar.gz') or filename.endswith('.tgz'): + with tarfile.open(real_archive, 'r:gz') as tf: + for member in tf.getmembers(): + member_path = os.path.realpath(os.path.join(real_target, member.name)) + if not member_path.startswith(real_target): + return False, f'压缩包含非法路径: {member.name}' + tf.extractall(real_target) + file_count = len(tf.getmembers()) + elif filename.endswith('.tar'): + with tarfile.open(real_archive, 'r') as tf: + for member in tf.getmembers(): + member_path = os.path.realpath(os.path.join(real_target, member.name)) + if not member_path.startswith(real_target): + return False, f'压缩包含非法路径: {member.name}' + tf.extractall(real_target) + file_count = len(tf.getmembers()) + else: + return False, '仅支持 .zip / .tar.gz / .tgz / .tar 格式' + + # 非管理员模式下修正权限 + if not admin_mode and real_target.startswith('/opt/tpanel/sites'): + _run(f'chown -R tpanel:tpanel {real_target}', sudo=True) + + except zipfile.BadZipFile: + return False, '不是有效的 zip 文件' + except tarfile.ReadError: + return False, '不是有效的 tar 文件' + except PermissionError: + return False, '无权限解压文件' + except Exception as e: + return False, f'解压失败: {str(e)}' + + if delete_after: + try: + os.remove(real_archive) + except Exception as e: + return True, f'已解压 {file_count} 个文件(删除压缩包失败: {e})' + + return True, f'已解压 {file_count} 个文件到 {os.path.relpath(real_target, "/opt/tpanel/sites") if real_target.startswith("/opt/tpanel/sites") else real_target}' diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..a1fffef --- /dev/null +++ b/backend/main.py @@ -0,0 +1,1723 @@ +""" +TPanel - T面板 主程序 +""" +import os +import subprocess +import sys +import secrets +import bcrypt +import sqlite3, json +from datetime import datetime +from functools import wraps + +from flask import Flask, jsonify, request, session, redirect, Response, send_from_directory +from flask_cors import CORS + +# 导入各模块 +from config import DB_PATH, load_config, save_config, set_setting, get_setting, get_panel_domain, SECRET_KEY +from system import ( + nginx_status, nginx_reload, mysql_status, + create_site_user, delete_site_user, + write_nginx_config, remove_nginx_config, + create_mysql_db, delete_mysql_db, + backup_site, restore_backup, + run_security_update, get_security_status, get_system_stats, write_log, + setup_php_fpm_listen, # v1.3.29 + change_db_password, # v1.3.34 + get_firewall_status, firewall_enable, firewall_open_port, firewall_close_port, + get_panel_port, change_panel_port, + get_backup_settings, save_backup_settings, run_backup_now, list_backups, delete_backup, + get_current_version, check_latest_version, upgrade_from_zip, rollback_version, list_backup_versions, # v1.3.39 在线升级 + get_server_info, # v1.3.43.x 服务器 IP/主机名/OS/内核/公网 IP/运行时间 +) +from file_manager import list_directory, read_file, write_file, upload_file, delete_file, chmod_file, create_directory, extract_archive +from ssl_manager import get_all_certs, apply_letsencrypt, renew_cert, renew_all_expiring, deploy_ssl, check_certs_status, _get_real_site_path +from cron_manager import get_all_crons, create_cron, delete_cron, enable_cron, run_cron_now, validate_cron_expression +from remote_backup import run_remote_backup, sync_restore, test_rsync_connection, get_backup_stats + +# v1.3.43+: 全局 SQLite busy_timeout 防锁死 +import sqlite3 as _sqlite3 +_original_connect = _sqlite3.connect +def _patched_connect(*args, **kwargs): + kwargs.setdefault("timeout", 30) + conn = _original_connect(*args, **kwargs) + try: + conn.execute("PRAGMA busy_timeout=30000") + except Exception: + pass + return conn +_sqlite3.connect = _patched_connect +app = Flask(__name__, static_folder='../frontend') +app.secret_key = secrets.token_hex(32) +CORS(app, supports_credentials=True) + +# ========================== +# 域名绑定中间件 +# ========================== + +@app.before_request +def check_panel_domain(): + """如果面板绑定了域名,只允许该域名访问""" + allowed = get_panel_domain().strip() + import sys + print(f'[DEBUG panel_domain] allowed={allowed!r} path={request.path!r} host={request.host!r}', file=sys.stderr, flush=True) + if not allowed: + return # 未绑定,不限制 + + host = request.host.lower() + allowed_clean = allowed.lower().strip().split(':')[0] + host_clean = host.split(':')[0] + + # v1.3.19+:安全白名单只放行 localhost 字面意思(开发用),所有 IP 走域名匹配 + if host_clean in ('localhost', '::1'): + return + # 127.0.0.1 也允许(用 IP 访问后台运维用) + if host_clean == '127.0.0.1': + return + + if host_clean != allowed_clean: + return jsonify({'code': 403, 'msg': f'面板已绑定域名 {allowed},请使用该域名访问'}), 403 + +# ========================== +# 装饰器 +# ========================== + +def require_auth(f): + @wraps(f) + def decorated(*args, **kwargs): + # v1.3.27 修复:SSE 走 query string 传 token(EventSource 不能自定义 header) + # 优先 header(常规 API),fallback ?token=(SSE) + token = request.headers.get('Authorization', '').replace('Bearer ', '').strip() + if not token: + token = request.args.get('token', '').strip() + expected = get_setting('api_token', '') + if not expected or token != expected: + # 也检查 session + if 'admin' not in session: + return jsonify({'code': 401, 'msg': '未授权'}), 401 + return f(*args, **kwargs) + return decorated + +# ========================== +# 认证 API +# ========================== + +@app.route('/api/auth/login', methods=['POST']) +def api_login(): + data = request.json or {} + username = data.get('username', '').strip() + password = data.get('password', '') + + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT id, password_hash FROM admin WHERE username = ?", (username,)) + row = cur.fetchone() + conn.close() + + if not row: + return jsonify({'code': 401, 'msg': '用户名或密码错误'}) + + if bcrypt.checkpw(password.encode(), row[1].encode()): + session['admin'] = True + session['username'] = username + + # 生成 API token + token = secrets.token_hex(32) + set_setting('api_token', token) + set_setting('last_login', datetime.now().isoformat()) + + write_log('login', f'用户 {username} 登录成功', request.remote_addr) + return jsonify({'code': 0, 'msg': '登录成功', 'token': token}) + + write_log('login_fail', f'用户 {username} 登录失败', request.remote_addr) + return jsonify({'code': 401, 'msg': '用户名或密码错误'}) +@app.route('/api/auth/change-password', methods=['POST']) +@require_auth +def api_change_password(): + """v1.3.18 新增:改管理员密码""" + data = request.json or {} + old_password = data.get('old_password', '') + new_password = data.get('new_password', '') + + if not old_password or not new_password: + return jsonify({'code': 400, 'msg': '请提供旧密码和新密码'}) + + if len(new_password) < 6: + return jsonify({'code': 400, 'msg': '新密码至少 6 位'}) + + username = session.get('username', 'admin') + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT password_hash FROM admin WHERE username = ?", (username,)) + row = cur.fetchone() + if not row: + conn.close() + return jsonify({'code': 404, 'msg': '用户不存在'}) + + if not bcrypt.checkpw(old_password.encode(), row[0].encode()): + conn.close() + write_log('change_password_fail', f'用户 {username} 改密码失败(旧密码错)', request.remote_addr) + return jsonify({'code': 401, 'msg': '旧密码错误'}) + + new_hash = bcrypt.hashpw(new_password.encode(), bcrypt.gensalt()).decode() + conn.execute("UPDATE admin SET password_hash = ? WHERE username = ?", (new_hash, username)) + conn.commit() + conn.close() + write_log('change_password', f'用户 {username} 改密码成功', request.remote_addr) + return jsonify({'code': 0, 'msg': '密码已修改,请重新登录'}) + +@app.route('/api/auth/check', methods=['GET']) +def api_check(): + token = request.headers.get('Authorization', '').replace('Bearer ', '') + if token == get_setting('api_token', ''): + return jsonify({'code': 0, 'msg': '有效', 'username': session.get('username', 'admin')}) + if 'admin' in session: + return jsonify({'code': 0, 'msg': '有效', 'username': session.get('username', 'admin')}) + return jsonify({'code': 401, 'msg': '无效'}), 401 + +# ========================== +# 系统状态 +# ========================== + +@app.route('/api/system/stats', methods=['GET']) +def api_system_stats(): + stats = get_system_stats() + security = get_security_status() + stats.update(security) + return jsonify({'code': 0, 'data': stats}) + + +@app.route('/api/system/info', methods=['GET']) +def api_system_info(): + info = get_server_info() + return jsonify({'code': 0, 'data': info}) + +# ========================== +# 站点管理 +# ========================== + +@app.route('/api/sites', methods=['GET']) +def api_sites_list(): + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT * FROM sites ORDER BY id DESC") + cols = [d[0] for d in cur.description] + rows = [dict(zip(cols, r)) for r in cur.fetchall()] + conn.close() + return jsonify({'code': 0, 'data': rows}) + +@app.route('/api/sites', methods=['POST']) +def api_sites_create(): + data = request.json or {} + domain = data.get('domain', '').strip().lower() + name = data.get('name', domain) + site_type = data.get('type', 'php') # v1.3.26 新增:'php' | 'static' + php_version = data.get('php_version', '8.2') + site_user = domain.replace('.', '_') + + if not domain: + return jsonify({'code': 400, 'msg': '域名不能为空'}) + + if site_type not in ('php', 'static'): + return jsonify({'code': 400, 'msg': '站点类型必须是 php 或 static'}) + + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT id FROM sites WHERE domain = ?", (domain,)) + if cur.fetchone(): + conn.close() + return jsonify({'code': 400, 'msg': '站点已存在'}) + + site_path = f'/opt/tpanel/sites/{site_user}' + + # 1. 创建 Linux 用户 + ok, msg = create_site_user(site_user) + if not ok: + conn.close() + return jsonify({'code': 500, 'msg': f'创建系统用户失败: {msg}'}) + + # 2. 创建目录并写入默认首页 + os.makedirs(site_path, exist_ok=True) + os.makedirs(f'{site_path}/public', exist_ok=True) + if site_type == 'php': + with open(f'{site_path}/public/index.php', 'w') as f: + f.write(f' + + + + + {domain} - 站点已就绪 + + + +
+

🌿 站点已就绪

+

域名: {domain}

+

类型: 静态页面(不需要 PHP-FPM)

+

管理: TPanel 面板

+
+ 📁 public/
+   📄 index.html ← 你看到的这个页面 +
+ +
+ + +''' + with open(f'{site_path}/public/index.html', 'w') as f: + f.write(static_html) + os.makedirs(f'{site_path}/logs', exist_ok=True) + + # 3. 写 Nginx 配置(v1.3.26 传 site_type 进去决定要不要 PHP-FPM 反代) + ok, msg = write_nginx_config(domain, f'{site_path}/public', php_version, ssl=False, site_type=site_type) + if not ok: + conn.close() + return jsonify({'code': 500, 'msg': f'Nginx 配置失败: {msg}'}) + + # 4. 写入数据库 + cur.execute("""INSERT INTO sites (name, domain, site_user, site_path, php_version, status, site_type) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (name, domain, site_user, f'{site_path}/public', php_version, 'running', site_type)) + conn.commit() + site_id = cur.lastrowid + conn.close() + + write_log('site_create', f'创建站点 {domain}', request.remote_addr) + return jsonify({'code': 0, 'msg': '站点创建成功', 'data': {'id': site_id}}) + +@app.route('/api/sites/', methods=['DELETE']) +def api_sites_delete(site_id): + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT domain, site_user, site_path FROM sites WHERE id = ?", (site_id,)) + row = cur.fetchone() + if not row: + conn.close() + return jsonify({'code': 404, 'msg': '站点不存在'}) + + domain, site_user, site_path = row + + # 1. 删除 Nginx 配置 + remove_nginx_config(domain) + + # 2. 删除系统用户和目录 + delete_site_user(site_user) + import shutil + parent_path = os.path.dirname(site_path) + if os.path.exists(os.path.join(parent_path, site_user)): + shutil.rmtree(os.path.join(parent_path, site_user), ignore_errors=True) + + # 3. 删除数据库 + cur2 = conn.execute("SELECT name, db_user FROM databases WHERE site_id = ?", (site_id,)) + for db_row in cur2.fetchall(): + delete_mysql_db(db_row[0], db_row[1]) + + # 4. 删除站点记录 + conn.execute("DELETE FROM sites WHERE id = ?", (site_id,)) + conn.commit() + conn.close() + + write_log('site_delete', f'删除站点 {domain}', request.remote_addr) + return jsonify({'code': 0, 'msg': '站点已删除'}) + +@app.route('/api/sites//start', methods=['POST']) +def api_sites_start(site_id): + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT domain FROM sites WHERE id = ?", (site_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'code': 404, 'msg': '站点不存在'}) + _, msg = nginx_reload() + return jsonify({'code': 0, 'msg': msg or '已启动'}) + +@app.route('/api/sites//stop', methods=['POST']) +def api_sites_stop(site_id): + return jsonify({'code': 0, 'msg': '停止站点需要 reload nginx,建议通过 Nginx 命令管理'}) + +@app.route('/api/sites//ssl', methods=['POST']) +def api_sites_ssl(site_id): + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT domain, site_path FROM sites WHERE id = ?", (site_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'code': 404, 'msg': '站点不存在'}) + domain, site_path = row + return jsonify({'code': 0, 'msg': 'SSL 功能开发中,请手动配置 certbot'}) + +# ========================== +# 数据库 +# ========================== + +@app.route('/api/databases', methods=['GET']) +def api_databases_list(): + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT * FROM databases ORDER BY id DESC") + cols = [d[0] for d in cur.description] + rows = [dict(zip(cols, r)) for r in cur.fetchall()] + conn.close() + return jsonify({'code': 0, 'data': rows}) + +@app.route('/api/databases', methods=['POST']) +def api_databases_create(): + data = request.json or {} + site_id = data.get('site_id') + db_name = data.get('name', '').strip() + db_user = data.get('user', '').strip() + db_pass = data.get('pass', '') + + if not all([site_id, db_name, db_user, db_pass]): + return jsonify({'code': 400, 'msg': '参数不完整'}) + + ok, msg = create_mysql_db(db_name, db_user, db_pass) + if not ok: + return jsonify({'code': 500, 'msg': msg}) + + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("""INSERT INTO databases (site_id, name, db_user, db_pass) VALUES (?, ?, ?, ?)""", + (site_id, db_name, db_user, db_pass)) + conn.commit() + db_id = cur.lastrowid + conn.close() + + write_log('db_create', f'创建数据库 {db_name}', request.remote_addr) + _sync_pma_bridge(db_id, db_name, db_pass) + return jsonify({'code': 0, 'msg': '数据库创建成功', 'data': {'id': db_id}}) + +@app.route('/api/databases/', methods=['DELETE']) +def api_databases_delete(db_id): + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT name, db_user FROM databases WHERE id = ?", (db_id,)) + row = cur.fetchone() + if not row: + conn.close() + return jsonify({'code': 404, 'msg': '数据库不存在'}) + delete_mysql_db(row[0], row[1]) + conn.execute("DELETE FROM databases WHERE id = ?", (db_id,)) + conn.commit() + conn.close() + write_log('db_delete', f'删除数据库 {row[0]}', request.remote_addr) + _sync_pma_bridge(db_id, row[0], row[1]) + return jsonify({'code': 0, 'msg': '数据库已删除'}) + +# ========================== +# 备份 +# ========================== + +@app.route('/api/backups', methods=['GET']) +def api_backups_list(): + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("""SELECT b.*, s.domain FROM backups b + LEFT JOIN sites s ON b.site_id = s.id + ORDER BY b.id DESC LIMIT 50""") + cols = [d[0] for d in cur.description] + rows = [dict(zip(cols, r)) for r in cur.fetchall()] + conn.close() + return jsonify({'code': 0, 'data': rows}) + +@app.route('/api/backups', methods=['POST']) +def api_backups_create(): + data = request.json or {} + site_id = data.get('site_id') + + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT site_path, domain FROM sites WHERE id = ?", (site_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'code': 404, 'msg': '站点不存在'}) + + site_path, domain = row + + # 查找该站点的数据库 + conn2 = sqlite3.connect(DB_PATH) + cur2 = conn2.execute("SELECT name, db_user, db_pass FROM databases WHERE site_id = ?", (site_id,)) + db_row = cur2.fetchone() + conn2.close() + + db_name, db_user, db_pass = (db_row if db_row else (None, None, None)) + + ok, path, size = backup_site(site_path, domain, db_name, db_user, db_pass) + if not ok: + return jsonify({'code': 500, 'msg': f'备份失败: {path}'}) + + conn3 = sqlite3.connect(DB_PATH) + cur3 = conn3.execute("INSERT INTO backups (site_id, type, file_path, size, status) VALUES (?, ?, ?, ?, ?)", + (site_id, 'local', path, size, 'success')) + conn3.commit() + backup_id = cur3.lastrowid + conn3.close() + + write_log('backup', f'备份站点 {domain}', request.remote_addr) + return jsonify({'code': 0, 'msg': '备份成功', 'data': {'id': backup_id, 'path': path, 'size': size}}) + +@app.route('/api/backups//restore', methods=['POST']) +def api_backups_restore(backup_id): + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT file_path, site_id FROM backups WHERE id = ?", (backup_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'code': 404, 'msg': '备份不存在'}) + + file_path, site_id = row + + conn2 = sqlite3.connect(DB_PATH) + cur2 = conn2.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,)) + site_row = cur2.fetchone() + conn2.close() + + if not site_row: + return jsonify({'code': 404, 'msg': '站点不存在'}) + + ok, msg = restore_backup(file_path, site_row[0], str(site_id)) + if not ok: + return jsonify({'code': 500, 'msg': msg}) + + write_log('restore', f'恢复备份 {file_path}', request.remote_addr) + return jsonify({'code': 0, 'msg': '恢复成功'}) + +@app.route('/api/backups//download') +def api_backups_download(backup_id): + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT file_path FROM backups WHERE id = ?", (backup_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'code': 404, 'msg': '备份不存在'}), 404 + + file_path = row[0] + if not os.path.exists(file_path): + return jsonify({'code': 404, 'msg': '备份文件不存在'}), 404 + + write_log('backup', f'下载备份 {file_path}', request.remote_addr) + return send_from_directory(os.path.dirname(file_path), os.path.basename(file_path), as_attachment=True) + +# ========================== +# 安全 +# ========================== + +@app.route('/api/security/logs', methods=['GET']) +def api_security_logs(): + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT * FROM security_logs ORDER BY id DESC LIMIT 100") + cols = [d[0] for d in cur.description] + rows = [dict(zip(cols, r)) for r in cur.fetchall()] + conn.close() + return jsonify({'code': 0, 'data': rows}) + +@app.route('/api/security/status', methods=['GET']) +def api_security_status(): + status = get_security_status() + return jsonify({'code': 0, 'data': status}) + +# ========================== +# 文件管理 +# ========================== + +@app.route('/api/files/list', methods=['GET']) +def api_files_list(): + site_id = request.args.get('site_id', type=int) + path = request.args.get('path', '') + + if not site_id: + return jsonify({'code': 400, 'msg': '缺少 site_id'}) + + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'code': 404, 'msg': '站点不存在'}) + + base_path = row[0] + if path: + target_path = os.path.join(base_path, path) + else: + target_path = base_path + + items, err = list_directory(target_path) + if err: + return jsonify({'code': 400, 'msg': err}) + + return jsonify({'code': 0, 'data': items, 'base_path': base_path}) + +@app.route('/api/files/read', methods=['GET']) +def api_files_read(): + site_id = request.args.get('site_id', type=int) + filepath = request.args.get('path', '') + + if not site_id or not filepath: + return jsonify({'code': 400, 'msg': '参数不完整'}) + + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'code': 404, 'msg': '站点不存在'}) + + full_path = os.path.join(row[0], filepath) + content, err = read_file(full_path) + if err: + return jsonify({'code': 400, 'msg': err}) + + return jsonify({'code': 0, 'data': content}) + +@app.route('/api/files/write', methods=['POST']) +def api_files_write(): + data = request.json or {} + site_id = data.get('site_id') + filepath = data.get('path', '') + content = data.get('content', '') + + if not site_id or not filepath: + return jsonify({'code': 400, 'msg': '参数不完整'}) + + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'code': 404, 'msg': '站点不存在'}) + + full_path = os.path.join(row[0], filepath) + ok, msg = write_file(full_path, content) + if ok: + write_log('file_edit', f'编辑文件 {filepath}', request.remote_addr) + + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +@app.route('/api/files/upload', methods=['POST']) +def api_files_upload(): + site_id = request.form.get('site_id', type=int) + path = request.form.get('path', '') + file = request.files.get('file') + + if not site_id or not file: + return jsonify({'code': 400, 'msg': '参数不完整'}) + + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'code': 404, 'msg': '站点不存在'}) + + upload_dir = os.path.join(row[0], path) if path else row[0] + ok, msg = upload_file(upload_dir, file, file.filename) + if ok: + write_log('file_upload', f'上传文件 {file.filename}', request.remote_addr) + + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +@app.route('/api/files/delete', methods=['POST']) +def api_files_delete(): + data = request.json or {} + site_id = data.get('site_id') + filepath = data.get('path', '') + + if not site_id or not filepath: + return jsonify({'code': 400, 'msg': '参数不完整'}) + + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'code': 404, 'msg': '站点不存在'}) + + full_path = os.path.join(row[0], filepath) + ok, msg = delete_file(full_path) + if ok: + write_log('file_delete', f'删除文件 {filepath}', request.remote_addr) + + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +@app.route('/api/files/mkdir', methods=['POST']) +def api_files_mkdir(): + data = request.json or {} + site_id = data.get('site_id') + dirpath = data.get('path', '') + dirname = data.get('name', '') + + if not site_id or not dirname: + return jsonify({'code': 400, 'msg': '参数不完整'}) + + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'code': 404, 'msg': '站点不存在'}) + + full_path = os.path.join(row[0], dirpath) if dirpath else row[0] + ok, msg = create_directory(full_path, dirname) + + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +@app.route('/api/files/chmod', methods=['POST']) +def api_files_chmod(): + data = request.json or {} + site_id = data.get('site_id') + filepath = data.get('path', '') + mode = data.get('mode', 0) + + if not site_id or not filepath: + return jsonify({'code': 400, 'msg': '参数不完整'}) + + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'code': 404, 'msg': '站点不存在'}) + + full_path = os.path.join(row[0], filepath) + ok, msg = chmod_file(full_path, mode) + + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +# ========================== +# v1.3.41+ 文件解压 +# ========================== +@app.route('/api/files/extract', methods=['POST']) +def api_files_extract(): + data = request.json or {} + site_id = data.get('site_id') + archive_path = data.get('path', '') # 压缩包相对站点根的路径 + delete_after = data.get('delete_after', False) + + if not site_id or not archive_path: + return jsonify({'code': 400, 'msg': '参数不完整'}) + + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'code': 404, 'msg': '站点不存在'}) + + full_archive = os.path.join(row[0], archive_path) + target_dir = os.path.dirname(full_archive) # 解压到压缩包所在目录 + + ok, msg = extract_archive(full_archive, target_dir, delete_after) + if ok: + write_log('file_extract', f'解压 {archive_path}', request.remote_addr) + + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +# ========================== +# v1.3.39+ 任意目录浏览(管理员模式) +# ========================== + +@app.route('/api/admin/files/list', methods=['GET']) +def api_admin_files_list(): + path = request.args.get('path', '/') + # 安全检查:规范化路径 + real_path = os.path.realpath(path) + if not real_path.startswith('/'): + return jsonify({'code': 400, 'msg': '路径格式错误'}) + + items, err = list_directory(real_path, admin_mode=True) + if err: + return jsonify({'code': 400, 'msg': err}) + + return jsonify({'code': 0, 'data': items, 'current_path': real_path}) + +@app.route('/api/admin/files/read', methods=['GET']) +def api_admin_files_read(): + filepath = request.args.get('path', '') + if not filepath: + return jsonify({'code': 400, 'msg': '缺少 path 参数'}) + + real_path = os.path.realpath(filepath) + content, err = read_file(real_path, admin_mode=True) + if err: + return jsonify({'code': 400, 'msg': err}) + + return jsonify({'code': 0, 'data': content, 'path': real_path}) + +@app.route('/api/admin/files/write', methods=['POST']) +def api_admin_files_write(): + data = request.json or {} + filepath = data.get('path', '') + content = data.get('content', '') + + if not filepath: + return jsonify({'code': 400, 'msg': '缺少 path 参数'}) + + real_path = os.path.realpath(filepath) + ok, msg = write_file(real_path, content, admin_mode=True) + if ok: + write_log('file_edit', f'管理员编辑文件 {real_path}', request.remote_addr) + + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +@app.route('/api/admin/files/delete', methods=['POST']) +def api_admin_files_delete(): + data = request.json or {} + filepath = data.get('path', '') + + if not filepath: + return jsonify({'code': 400, 'msg': '缺少 path 参数'}) + + real_path = os.path.realpath(filepath) + ok, msg = delete_file(real_path, admin_mode=True) + if ok: + write_log('file_delete', f'管理员删除文件 {real_path}', request.remote_addr) + + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +@app.route('/api/admin/files/mkdir', methods=['POST']) +def api_admin_files_mkdir(): + data = request.json or {} + path = data.get('path', '') + dirname = data.get('name', '') + + if not path or not dirname: + return jsonify({'code': 400, 'msg': '参数不完整'}) + + real_path = os.path.realpath(path) + ok, msg = create_directory(real_path, dirname, admin_mode=True) + if ok: + write_log('file_mkdir', f'管理员创建目录 {real_path}/{dirname}', request.remote_addr) + + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +@app.route('/api/admin/files/chmod', methods=['POST']) +def api_admin_files_chmod(): + data = request.json or {} + filepath = data.get('path', '') + mode = data.get('mode', 0) + + if not filepath: + return jsonify({'code': 400, 'msg': '缺少 path 参数'}) + + real_path = os.path.realpath(filepath) + ok, msg = chmod_file(real_path, mode, admin_mode=True) + if ok: + write_log('file_chmod', f'管理员修改权限 {real_path} → {mode}', request.remote_addr) + + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +# added 2026-06-26: admin mode extract +@app.route('/api/admin/files/extract', methods=['POST']) +def api_admin_files_extract(): + data = request.json or {} + archive_path = data.get('path', '') + target_dir = data.get('target_dir', '') # 可选,默认解压到压缩包所在目录 + delete_after = data.get('delete_after', False) + + if not archive_path: + return jsonify({'code': 400, 'msg': '缺少 path 参数'}) + + real_archive = os.path.realpath(archive_path) + real_target = os.path.realpath(target_dir) if target_dir else os.path.dirname(real_archive) + + ok, msg = extract_archive(real_archive, real_target, delete_after, admin_mode=True) + if ok: + write_log('file_extract', f'管理员解压 {real_archive} -> {real_target}', request.remote_addr) + + return jsonify({'code': 0 if ok else 400, 'msg': msg}) +# ========================== +# v1.3.39+ 在线升级 +# ========================== + +@app.route('/api/upgrade/version', methods=['GET']) +def api_upgrade_version(): + info = check_latest_version() + backups = list_backup_versions() + return jsonify({'code': 0, 'data': info, 'backups': backups}) + +@app.route('/api/upgrade/upload', methods=['POST']) +def api_upgrade_upload(): + """上传升级包""" + if 'file' not in request.files: + return jsonify({'code': 400, 'msg': '未上传文件'}) + + f = request.files['file'] + if not f.filename.endswith('.zip'): + return jsonify({'code': 400, 'msg': '只支持 zip 格式安装包'}) + + upload_path = '/tmp/tpanel_upgrade.zip' + f.save(upload_path) + + # 验证包格式 + import zipfile + try: + with zipfile.ZipFile(upload_path, 'r') as zf: + names = zf.namelist() + has_backend = any('backend/' in n for n in names) + has_frontend = any('frontend/' in n for n in names) + if not has_backend or not has_frontend: + os.remove(upload_path) + return jsonify({'code': 400, 'msg': '安装包格式错误:缺少 backend 或 frontend 目录'}) + except Exception as e: + os.remove(upload_path) + return jsonify({'code': 400, 'msg': f'安装包损坏: {str(e)}'}) + + write_log('upgrade', f'上传升级包: {f.filename}', request.remote_addr) + return jsonify({'code': 0, 'msg': '安装包已上传,可以开始升级', 'path': upload_path}) + +@app.route('/api/upgrade/run', methods=['POST']) +def api_upgrade_run(): + """执行升级""" + zip_path = '/tmp/tpanel_upgrade.zip' + if not os.path.exists(zip_path): + return jsonify({'code': 400, 'msg': '请先上传安装包'}) + + write_log('upgrade', '开始执行升级...', request.remote_addr) + ok, msg = upgrade_from_zip(zip_path) + + if ok: + write_log('upgrade', f'升级成功: {msg}', request.remote_addr) + # 升级成功后延迟重启(让API先返回结果) + _run(['sudo', 'systemctl', 'restart', 'tpanel'], timeout=10) + else: + write_log('upgrade', f'升级失败: {msg}', request.remote_addr) + + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +@app.route('/api/upgrade/rollback', methods=['POST']) +def api_upgrade_rollback(): + """回滚到指定备份版本""" + data = request.json or {} + backup_file = data.get('backup_file', '') + + if not backup_file or not os.path.exists(backup_file): + return jsonify({'code': 400, 'msg': '备份文件不存在'}) + + write_log('upgrade', f'开始回滚到: {backup_file}', request.remote_addr) + ok, msg = rollback_version(backup_file) + + if ok: + write_log('upgrade', f'回滚成功: {msg}', request.remote_addr) + _run(['sudo', 'systemctl', 'restart', 'tpanel'], timeout=10) + + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +# ========================== +# SSL 证书 +# ========================== + +@app.route('/api/ssl/certs', methods=['GET']) +def api_ssl_list(): + certs = get_all_certs() + status = check_certs_status() + return jsonify({'code': 0, 'data': certs, 'status': status}) + +@app.route('/api/ssl/apply', methods=['POST']) +def api_ssl_apply(): + """ + v1.3.25 改:申请 SSL 走任务流(不再同步等 certbot,可能耗时 1-3 分钟) + 任务类型: ssl_apply,target: + 完成后用 on_complete 钩子自动部署 SSL + """ + data = request.json or {} + site_id = data.get('site_id') + domain = data.get('domain', '').strip() + + # v1.3.10 修复:原 bug 是 if not site_id: skip → domain 永远空字符串 + if not domain and site_id: + # 通过 site_id 反查域名 + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT domain FROM sites WHERE id = ?", (site_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'code': 404, 'msg': '站点不存在'}) + domain = row[0] + elif not domain and not site_id: + return jsonify({'code': 400, 'msg': 'site_id 和 domain 至少传一个'}) + + if not domain: + return jsonify({'code': 400, 'msg': '域名不能为空'}) + + # 查重:同一个域名不能同时跑两个任务 + existing = get_running_task_by_type('ssl_apply', domain) + if existing: + return jsonify({'code': 400, 'msg': f'该域名证书申请正在进行中(任务 ID {existing})', 'data': {'task_id': existing}}) + + # 构造 certbot 命令 + 后续 deploy 命令(连入一个 shell 脚本,由任务流串行执行) + # 这样任务失败也能看到 deploy 不会跑的日志 + real_site_path = None + try: + real_site_path = _get_real_site_path(domain, site_id) + except Exception as e: + print(f'[ssl_apply] _get_real_site_path error: {e}', flush=True) + + if not real_site_path: + return jsonify({'code': 400, 'msg': f'找不到站点 {domain} 的真实路径,请确认站点已创建'}) + + # 任务命令: certbot 申请 → 如果成功调 deploy_ssl + cmd = [ + 'bash', '-c', + f''' +set -e +echo "[1/3] 准备 .well-known 验证目录..." +mkdir -p {real_site_path}/.well-known/acme-challenge + +echo "[2/3] 调用 certbot 申请证书(以 webroot 模式,需要 30-60s)..." +sudo certbot certonly --webroot -w {real_site_path} -d {domain} --agree-tos --non-interactive --email admin@{domain} + +echo "[3/3] 证书生成成功,部署到 nginx..." +''' + ] + + def _on_ssl_done(task_id, status): + # v1.3.34 修复:on_complete 钩子在后台线程跑,没有 Flask request context + # 不能用 request.remote_addr,传 None 给 write_log + if status == 'success': + try: + ok, msg = deploy_ssl(domain) + if ok: + write_log('ssl_apply', f'申请+部署 SSL 证书 {domain} 成功', None) + else: + write_log('ssl_apply', f'证书已申请但部署 nginx 失败 {domain}: {msg}', None) + except Exception as e: + write_log('ssl_apply', f'证书部署异常 {domain}: {e}', None) + + task_id = create_task('ssl_apply', domain, cmd, on_complete=_on_ssl_done) + write_log('ssl_apply', f'启动 SSL 申请任务 {domain}(task_id={task_id})', request.remote_addr) + return jsonify({'code': 0, 'msg': f'证书申请任务已启动(task_id={task_id}),请查看进度', 'data': {'task_id': task_id}}) + +@app.route('/api/ssl/renew/', methods=['POST']) +def api_ssl_renew(cert_id): + ok, msg = renew_cert(cert_id=cert_id) + if ok: + write_log('ssl_renew', f'续期证书 ID {cert_id}', request.remote_addr) + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +@app.route('/api/ssl/renew-all', methods=['POST']) +def api_ssl_renew_all(): + success, fail_count, fail_list = renew_all_expiring(days_before=30) + msg = f'续期完成:成功 {success} 个' + if fail_count > 0: + msg += f',失败 {fail_count} 个:{"; ".join(fail_list)}' + write_log('ssl_renew_all', msg, request.remote_addr) + return jsonify({'code': 0, 'msg': msg, 'success': success, 'failed': fail_count}) + +@app.route('/api/ssl/deploy/', methods=['POST']) +def api_ssl_deploy(domain): + ok, msg = deploy_ssl(domain) + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +# ========================== +# 定时任务(安全自动更新 + SSL 续期) +# ========================== + +@app.route('/api/cron/run', methods=['POST']) +def api_cron_run(): + """v1.3.20+:手动触发定时任务走任务流(不阻塞 HTTP)""" + # 检查是否已在跑 + existing = get_running_task_by_type('security_update') + if existing: + return jsonify({'code': 400, 'msg': f'更新任务正在进行(ID {existing})', 'data': {'task_id': existing}}) + + # 安全更新走任务流(跟 /api/security/update 一样) + cmd = ['sudo', 'apt-get', 'update', '-y', '-q'] + task_id = create_task('security_update', 'system', cmd) + write_log('cron_run', f'启动手动安全更新任务(task_id={task_id})', request.remote_addr) + return jsonify({'code': 0, 'msg': f'安全更新任务已启动(task_id={task_id}),请去任务流查看进度', 'data': {'task_id': task_id}}) + + + +# ========================== +# 定时任务 API +# ========================== + +@app.route('/api/cron/jobs', methods=['GET']) +def api_cron_list(): + jobs = get_all_crons() + return jsonify({'code': 0, 'data': jobs}) + +@app.route('/api/cron/jobs', methods=['POST']) +def api_cron_create(): + data = request.json or {} + site_id = data.get('site_id') + name = data.get('name', '').strip() + schedule = data.get('schedule', '').strip() + command = data.get('command', '').strip() + + if not all([site_id, name, schedule, command]): + return jsonify({'code': 400, 'msg': '参数不完整'}) + + # 验证 cron 表达式 + ok, err = validate_cron_expression(schedule) + if not ok: + return jsonify({'code': 400, 'msg': f'Cron 格式错误: {err}'}) + + cron_id, msg = create_cron(site_id, name, schedule, command) + if cron_id: + write_log('cron_create', f'创建定时任务 {name} ({schedule})', request.remote_addr) + return jsonify({'code': 0, 'msg': msg, 'data': {'id': cron_id}}) + else: + return jsonify({'code': 400, 'msg': msg}) + +@app.route('/api/cron/jobs/', methods=['DELETE']) +def api_cron_delete(cron_id): + ok, msg = delete_cron(cron_id) + if ok: + write_log('cron_delete', f'删除定时任务 ID {cron_id}', request.remote_addr) + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +@app.route('/api/cron/jobs//toggle', methods=['POST']) +def api_cron_toggle(cron_id): + data = request.json or {} + enabled = data.get('enabled', True) + ok, msg = enable_cron(cron_id, enabled) + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +@app.route('/api/cron/jobs//run', methods=['POST']) +def api_cron_run_now(cron_id): + ok, msg = run_cron_now(cron_id) + if ok: + write_log('cron_run_now', f'手动执行定时任务 ID {cron_id}', request.remote_addr) + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +# ========================== +# 远程备份 API +# ========================== + +@app.route('/api/backups/remote', methods=['POST']) +def api_remote_backup(): + data = request.json or {} + site_id = data.get('site_id') + remote_host = data.get('remote_host', '').strip() + remote_user = data.get('remote_user', '').strip() + remote_port = data.get('remote_port', 22) + remote_path = data.get('remote_path', '').strip() + key_path = data.get('key_path', '').strip() + + if not all([site_id, remote_host, remote_user, remote_path]): + return jsonify({'code': 400, 'msg': '参数不完整'}) + + ok, msg = run_remote_backup( + site_id, remote_host, remote_user, remote_port, + remote_path, key_path=key_path if key_path else None + ) + if ok: + return jsonify({'code': 0, 'msg': msg}) + else: + return jsonify({'code': 400, 'msg': msg}) + +@app.route('/api/backups/remote/restore/', methods=['POST']) +def api_remote_restore(backup_id): + data = request.json or {} + remote_host = data.get('remote_host', '').strip() + remote_user = data.get('remote_user', '').strip() + remote_port = data.get('remote_port', 22) + remote_path = data.get('remote_path', '').strip() + key_path = data.get('key_path', '').strip() + + if not all([remote_host, remote_user, remote_path]): + return jsonify({'code': 400, 'msg': '参数不完整'}) + + ok, msg = sync_restore( + backup_id, remote_host, remote_user, remote_port, + remote_path, key_path=key_path if key_path else None + ) + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +@app.route('/api/backups/remote/test', methods=['POST']) +def api_test_rsync(): + data = request.json or {} + host = data.get('host', '').strip() + port = data.get('port', 22) + user = data.get('user', '').strip() + key_path = data.get('key_path', '').strip() + + if not host or not user: + return jsonify({'code': 400, 'msg': '主机和用户名不能为空'}) + + ok, msg = test_rsync_connection(host, port, user, key_path if key_path else None) + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +@app.route('/api/backups/stats', methods=['GET']) +def api_backup_stats(): + stats = get_backup_stats() + return jsonify({'code': 0, 'data': stats}) + +# ========================== +# 设置 +# ========================== + + +@app.route('/api/settings', methods=['GET']) +def api_settings_get(): + cfg = load_config() + return jsonify({'code': 0, 'data': cfg}) + +@app.route('/api/settings', methods=['PUT']) +def api_settings_put(): + """v1.3.19+:修双重存储 bug——既写 tpanel.conf 又同步 sqlite settings""" + data = request.json or {} + allowed_keys = [ + 'panel_domain', 'default_php', 'backup_retention_days', + 'auto_ssl_renew', 'security_auto_update', 'firewall_enabled' + ] + cfg = load_config() + cfg.update({k: v for k, v in data.items() if k in allowed_keys}) + save_config(cfg) + + # 关键修复:把要进 check_panel_domain / get_panel_domain 的字段也写 sqlite settings + # 否则 get_panel_domain() 永远从 sqlite 读到旧值 + if 'panel_domain' in data: + set_setting('panel_domain', data['panel_domain'] or '') + # 其他字段也同步(保证 sqlite 跟 conf 一致) + for k in allowed_keys: + if k in data: + v = data[k] + if isinstance(v, bool): + v = '1' if v else '0' + elif v is None: + v = '' + set_setting(k, str(v)) + + return jsonify({'code': 0, 'msg': '设置已保存'}) + +# ========================== +# 软件市场 + 任务管理(v1.3.10 新增) +# ========================== +from task_manager import ( + list_software, get_software, get_apt_packages, + create_task, get_task, get_running_task_by_type, + init_software_table, get_apt_cmd, + setup_phpmyadmin_nginx +) +import json +import time + +@app.route('/api/software/list', methods=['GET']) +def api_software_list(): + # 支持 ?refresh=1 强制刷新,安装/卸载后调用 + force_refresh = request.args.get('refresh') == '1' + return jsonify({'code': 0, 'data': list_software(force_refresh=force_refresh)}) + +@app.route('/api/software/install/', methods=['POST']) +def api_software_install(name): + sw = get_software(name) + if not sw: + return jsonify({'code': 404, 'msg': '软件不在白名单'}) + if sw['installed']: + return jsonify({'code': 400, 'msg': f'{sw["display_name"]} 已经安装了'}) + # 防止并发装同一个 + existing = get_running_task_by_type('software_install', name) + if existing: + return jsonify({'code': 400, 'msg': f'该软件正在安装中(任务 ID {existing})', 'data': {'task_id': existing}}) + pkgs = get_apt_packages(name) + if not pkgs: + return jsonify({'code': 500, 'msg': '未找到该软件包名'}) + pkg_list = pkgs.split(',') + try: + cmd = get_apt_cmd() + ['install'] + pkg_list + except Exception as e: + return jsonify({'code': 500, 'msg': str(e)}) + # v1.3.29: 装 PHP 走 on_complete 钩子自动配 FPM listen 端口 + on_complete = None + if name.startswith('php') and name[3:].replace('.', '').isdigit(): + # name = 'php7.4' / 'php8.3' → php_version = '7.4' / '8.3' + php_version = name[3:] + + def _on_php_installed(task_id, status, pv=php_version): + """on_complete 钩子:装完后改 FPM listen 端口 + enable + restart""" + if status != 'success': + return + ok, msg = setup_php_fpm_listen(pv) + if ok: + write_log('php_install', f'PHP {pv} FPM 已配置 {msg}', request.remote_addr) + else: + write_log('php_install', f'PHP {pv} FPM 配置失败: {msg}', request.remote_addr) + on_complete = _on_php_installed + task_id = create_task('software_install', name, cmd, on_complete=on_complete) + write_log('software_install', f'开始安装 {name}', request.remote_addr) + return jsonify({'code': 0, 'msg': '安装任务已启动', 'data': {'task_id': task_id}}) + +# v1.3.29: 批量重写所有站点 nginx conf(按照 db 的 php_version 字段用对应端口) +# 用途:手动统一切换所有站点到某个 PHP 版本 +@app.route('/api/system/rewrite-all-nginx', methods=['POST']) +def api_rewrite_all_nginx(): + conn = sqlite3.connect(DB_PATH) + rows = conn.execute("SELECT id, domain, site_path, php_version, ssl_enabled FROM sites").fetchall() + conn.close() + rewritten = 0 + failed = [] + for sid, domain, site_path, php_v, ssl in rows: + # 跳过静态站点 + try: + conn2 = sqlite3.connect(DB_PATH) + st = conn2.execute("SELECT site_type FROM sites WHERE id=?", (sid,)).fetchone() + conn2.close() + site_type = st[0] if st and st[0] else 'php' + except Exception: + site_type = 'php' + ok, msg = write_nginx_config(domain, site_path, php_v, ssl=bool(ssl), site_type=site_type) + if ok: + rewritten += 1 + else: + failed.append((domain, msg)) + # reload nginx + code, out, err = _run(['sudo', 'nginx', '-t']) + if code == 0: + _run(['sudo', 'nginx', '-s', 'reload']) + return jsonify({'code': 0, 'msg': f'重写 {rewritten} 个站点 conf,失败 {len(failed)} 个', 'data': {'rewritten': rewritten, 'failed': failed}}) + +@app.route('/api/software/uninstall/', methods=['POST']) +def api_software_uninstall(name): + sw = get_software(name) + if not sw: + return jsonify({'code': 404, 'msg': '软件不在白名单'}) + pkgs = get_apt_packages(name) + if not pkgs: + return jsonify({'code': 500, 'msg': '未找到该软件包名'}) + pkg_list = pkgs.split(',') + try: + cmd = get_apt_cmd() + ['remove'] + pkg_list + except Exception as e: + return jsonify({'code': 500, 'msg': str(e)}) + existing = get_running_task_by_type('software_uninstall', name) + if existing: + return jsonify({'code': 400, 'msg': f'正在卸载中(任务 ID {existing})'}) + task_id = create_task('software_uninstall', name, cmd) + write_log('software_uninstall', f'开始卸载 {name}', request.remote_addr) + return jsonify({'code': 0, 'msg': '卸载任务已启动', 'data': {'task_id': task_id}}) + +@app.route('/api/tasks/', methods=['GET']) +def api_task_get(task_id): + t = get_task(task_id) + if not t: + return jsonify({'code': 404, 'msg': '任务不存在'}) + return jsonify({'code': 0, 'data': t}) + +@app.route('/api/tasks//stream', methods=['GET']) +def api_task_stream(task_id): + """SSE 流,推送任务日志和状态""" + def generate(): + last_log_len = 0 + # 先发当前状态 + t = get_task(task_id) + if not t: + yield f"data: {json.dumps({'type': 'error', 'msg': '任务不存在'})}\n\n" + return + yield f"data: {json.dumps({'type': 'status', 'status': t['status'], 'log': t['log']})}\n\n" + last_log_len = len(t['log']) + # 轮询直到完成 + for _ in range(1800): # 最多 30 分钟 + time.sleep(1) + t = get_task(task_id) + if not t: + yield f"data: {json.dumps({'type': 'error', 'msg': '任务丢失'})}\n\n" + return + if len(t['log']) != last_log_len: + yield f"data: {json.dumps({'type': 'log', 'log': t['log'][last_log_len:]})}\n\n" + last_log_len = len(t['log']) + if t['status'] in ('success', 'failed'): + yield f"data: {json.dumps({'type': 'done', 'status': t['status'], 'exit_code': t['exit_code']})}\n\n" + return + yield f"data: {json.dumps({'type': 'error', 'msg': 'SSE 超时'})}\n\n" + return Response(generate(), mimetype='text/event-stream', + headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) + +# ========================== +# phpMyAdmin(v1.3.10 新增 - 装完自动配 Nginx 8443) +# ========================== +@app.route('/api/phpmyadmin/status', methods=['GET']) +def api_phpmyadmin_status(): + sw = get_software('phpmyadmin') + if not sw: + return jsonify({'code': 0, 'data': {'installed': False, 'url': ''}}) + # v1.3.23 修复:phpMyAdmin URL 构造不再用 hostname 查到的 127.0.0.1 + # 优先顺序:面板域名 > 用户当前访问的 host > 本机获取的 IP + import socket + panel_dom = get_panel_domain().strip() + if panel_dom: + host = panel_dom + else: + # 从 request.host 拆出 hostname(去掉端口) + try: + host = request.host.split(':')[0] if request.host else '' + except Exception: + host = '' + if not host or host in ('127.0.0.1', 'localhost'): + # fallback: 拿第一个非 loopback 的网卡 IP + try: + ip_fallback = socket.gethostbyname(socket.gethostname()) + if ip_fallback and not ip_fallback.startswith('127.'): + host = ip_fallback + else: + host = '127.0.0.1' + except Exception: + host = '127.0.0.1' + # 三个条件都满足才算真装好: + # 1. software 表标记 installed + # 2. phpMyAdmin 文件实际存在(防 sw 状态滞后) + # 3. Nginx 8443 反代配置已写入 + files_exist = ( + os.path.isdir('/usr/share/phpmyadmin') + and os.path.exists('/usr/share/phpmyadmin/index.php') + ) + nginx_ok = os.path.exists('/etc/nginx/sites-enabled/phpmyadmin.conf') + really_installed = bool(sw['installed']) and files_exist and nginx_ok + url = f'http://{host}:8443/' if really_installed else '' + return jsonify({'code': 0, 'data': { + 'installed': really_installed, + 'url': url, + 'files_exist': files_exist, + 'nginx_ok': nginx_ok, + 'sw_installed': bool(sw['installed']) + }}) + +@app.route('/api/phpmyadmin/install', methods=['POST']) +def api_phpmyadmin_install(): + existing = get_running_task_by_type('software_install', 'phpmyadmin') + if existing: + return jsonify({'code': 400, 'msg': f'phpMyAdmin 正在安装(任务 ID {existing})', 'data': {'task_id': existing}}) + pkgs = get_apt_packages('phpmyadmin') + if not pkgs: + return jsonify({'code': 500, 'msg': '未找到包名'}) + # 装 phpmyadmin(-q 避免交互) + try: + cmd = ['sudo', 'apt-get', '-y', '-q', '-o', 'Dpkg::Options::=--force-confdef', '-o', 'Dpkg::Options::=--force-confnew', 'install'] + pkgs.split(',') + except Exception: + cmd = ['sudo', 'apt-get', '-y', 'install'] + pkgs.split(',') + # v1.3.10:装完后用 on_complete 钩子自动写 Nginx 8443 反代 + def _on_pma_installed(task_id, status): + if status == 'success': + setup_phpmyadmin_nginx(task_id=task_id) + task_id = create_task('software_install', 'phpmyadmin', cmd, on_complete=_on_pma_installed) + return jsonify({'code': 0, 'msg': 'phpMyAdmin 安装任务已启动(装完会自动配置 Nginx 8443)', 'data': {'task_id': task_id}}) + +# ========================== +# v1.3.34 数据库改密 + phpMyAdmin 真自动登录 +# ========================== + +# 数据库改密(更新 MySQL + sqlite 都改) +@app.route('/api/databases//password', methods=['POST']) +def api_change_db_password(db_id): + data = request.json or {} + new_pass = data.get('new_pass', '') + if not new_pass or len(new_pass) < 6: + return jsonify({'code': 400, 'msg': '新密码至少 6 位'}) + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT name, db_user FROM databases WHERE id = ?", (db_id,)) + row = cur.fetchone() + if not row: + conn.close() + return jsonify({'code': 404, 'msg': '数据库不存在'}) + db_name, db_user = row[0], row[1] + ok, msg = change_db_password(db_user, new_pass) + if not ok: + conn.close() + return jsonify({'code': 500, 'msg': msg}) + # 同步更新 sqlite(备份、phpMyAdmin 自动填、面板显示都用这个) + conn.execute("UPDATE databases SET db_pass = ? WHERE id = ?", (new_pass, db_id)) + conn.commit() + conn.close() + write_log('db_change_pass', f'修改数据库 {db_name} 密码', request.remote_addr) + _sync_pma_bridge(db_id, row[0], row[1]) + return jsonify({'code': 0, 'msg': '密码修改成功'}) + +# v1.3.34: 数据库改密/增删后同步 phpMyAdmin bridge.json +def _sync_pma_bridge(): + import subprocess + try: + r = subprocess.run( + ['sudo', '-u', 'tpanel', 'python3', '/opt/tpanel/backend/sync_pma_bridge.py'], + capture_output=True, text=True, timeout=10 + ) + if r.returncode != 0: + write_log('pma_bridge_sync_fail', r.stderr, request.remote_addr) + except Exception as e: + write_log('pma_bridge_sync_err', str(e), request.remote_addr) + + +# 生成临时 token(5 分钟有效,用于 phpMyAdmin 自动登录跳转) +# payload: db_id + exp, HMAC-SHA256 签名 +import hmac, hashlib, base64, json, time + +def _sign_token(db_id, ttl=300): + payload = {'db_id': db_id, 'exp': int(time.time()) + ttl} + payload_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode() + sig = hmac.new(SECRET_KEY.encode(), payload_b64.encode(), hashlib.sha256).digest() + sig_b64 = base64.urlsafe_b64encode(sig).decode() + return f'{payload_b64}.{sig_b64}' + +def _verify_token(token): + try: + payload_b64, sig_b64 = token.split('.') + # v1.3.34: sign 时已带 padding,不再补 + sig = base64.urlsafe_b64decode(sig_b64) + expected = hmac.new(SECRET_KEY.encode(), payload_b64.encode(), hashlib.sha256).digest() + if not hmac.compare_digest(sig, expected): + return None + payload = json.loads(base64.urlsafe_b64decode(payload_b64)) + if payload.get('exp', 0) < int(time.time()): + return None + return payload + except Exception: + return None + +@app.route('/api/phpmyadmin/token/', methods=['GET']) +def api_phpmyadmin_token(db_id): + """签发一次性 token(5 分钟有效),前端拼 URL 跳 phpMyAdmin + v1.3.40.1: 签 token 时同步 bridge.json,避免新建 db 后未同步 + """ + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT name, db_user, db_pass FROM databases WHERE id = ?", (db_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'code': 404, 'msg': '数据库不存在'}) + try: + _sync_pma_bridge(db_id, row[1], row[2]) + except Exception: + pass + token = _sign_token(db_id) + return jsonify({'code': 0, 'data': {'token': token, 'expires_in': 300}}) + + +def _sync_pma_bridge(db_id, db_name, db_pass): + """v1.3.40.1: 把指定 db 凭证写到 /usr/share/phpmyadmin/bridge.json + bridge.php 启动时读此文件,自动填 PMA_single_signon_* + """ + import json as _json + path = '/usr/share/phpmyadmin/bridge.json' + data = { + 'db_id': db_id, + 'db_user': db_name, + 'db_pass': db_pass, + 'ts': int(time.time()) + } + tmp = '/tmp/tpanel_bridge_sync.json.tmp' + with open(tmp, 'w') as f: + _json.dump(data, f) + os.chmod(tmp, 0o644) + subprocess.run(['sudo', 'mv', tmp, path], check=False) + subprocess.run(['sudo', 'chmod', '644', path], check=False) + subprocess.run(['sudo', 'chown', 'www-data:www-data', path], check=False) + +# phpMyAdmin Signon 端点(无 auth — SignonURL 走 GET,phpMyAdmin 自动跳过来) +@app.route('/api/phpmyadmin/signon', methods=['GET']) +def api_phpmyadmin_signon(): + """v1.3.34 phpMyAdmin Signon 端点:验 token → 302 到 PHP bridge.php + bridge.php 启动 PHP session + 写 PMA_single_signon_* + 302 回 phpMyAdmin + """ + token = request.args.get('token', '') + db_id = request.args.get('db', '') + if not token or not db_id: + return redirect('http://127.0.0.1:8888/login?msg=missing_token') +# v1.3.40.1 修复:动态查 db 所属 site 域名,不再硬编码 zhangpu.tech + try: + conn = sqlite3.connect(DB_PATH) + row = conn.execute( + "SELECT s.domain, d.db_user, d.db_pass FROM databases d JOIN sites s ON d.site_id = s.id WHERE d.id = ?", + (db_id,) + ).fetchone() + conn.close() + if not row: + return redirect('http://127.0.0.1:8888/login?msg=db_not_found') + site_domain, db_user, db_pass = row[0], row[1], row[2] + except Exception: + return redirect('http://127.0.0.1:8888/login?msg=db_lookup_error') + try: + _sync_pma_bridge(db_id, db_user, db_pass) + except Exception: + pass + return redirect('https://' + site_domain + '/pma/tpanel-bridge.php?token=' + token + '&db=' + db_id) + +# phpMyAdmin 退出(清除 cookie + 重定向回面板) +@app.route('/api/phpmyadmin/logout', methods=['GET']) +def api_phpmyadmin_logout(): + resp = redirect('http://127.0.0.1:8888/dashboard') + resp.delete_cookie('TPanelSignon', domain='127.0.0.1', path='/') + return resp + +# ========================== +# 安全更新(v1.3.10 改为走任务流) +# ========================== +@app.route('/api/security/update', methods=['POST']) +def api_security_update(): + existing = get_running_task_by_type('security_update') + if existing: + return jsonify({'code': 400, 'msg': f'更新任务正在进行(ID {existing})', 'data': {'task_id': existing}}) + try: + # apt-get update + upgrade -y + cmd = ['sudo', 'apt-get', 'update', '-y', '-q'] + except Exception: + cmd = ['sudo', 'apt-get', 'update', '-y'] + task_id = create_task('security_update', 'system', cmd) + write_log('security_update', '启动系统安全更新', request.remote_addr) + return jsonify({'code': 0, 'msg': '更新任务已启动', 'data': {'task_id': task_id}}) + +# ========================== +# v1.3.28: 添加 Sury PHP 第三方源 +# 让 Debian 12 也能装 PHP 5.6 / 7.0 / 7.4 / 8.0 / 8.1 / 8.3 / 8.4 +# 走任务流,因为 apt update 可能耗时 1-2 分钟 +# ========================== +@app.route('/api/system/add-sury-php', methods=['POST']) +def api_add_sury_php(): + # 只支持 Debian/Ubuntu 系 + if not os.path.exists('/etc/debian_version') and not os.path.exists('/etc/lsb-release'): + return jsonify({'code': 400, 'msg': 'Sury PHP 源仅支持 Debian/Ubuntu 系统'}) + + # 检查是否已添加 + if os.path.exists('/etc/apt/sources.list.d/php.list'): + return jsonify({'code': 400, 'msg': 'Sury PHP 源已添加,无需重复操作'}) + + # 查重:同类型任务 + existing = get_running_task_by_type('sury_php', 'sury_php') + if existing: + return jsonify({'code': 400, 'msg': f'Sury 源添加任务正在进行(ID {existing})', 'data': {'task_id': existing}}) + + # 任务命令:装 lsb-release + ca-certificates + curl → 下载 GPG keyring → 配置源 → apt update + cmd = [ + 'bash', '-c', + ''' +set -e +echo "[1/5] 装 lsb-release / ca-certificates / curl..." +sudo -n DEBIAN_FRONTEND=noninteractive apt-get install -y -q lsb-release ca-certificates curl 2>&1 +echo "[2/5] 下载 Sury GPG keyring..." +sudo -n curl -sSLo /tmp/debsuryorg-archive-keyring.deb https://packages.sury.org/debsuryorg-archive-keyring.deb +echo "[3/5] 装 keyring..." +sudo -n dpkg -i /tmp/debsuryorg-archive-keyring.deb +echo "[4/5] 写 /etc/apt/sources.list.d/php.list..." +DISTRO=$(lsb_release -sc) +sudo -n sh -c "echo 'deb [signed-by=/usr/share/keyrings/debsuryorg-archive-keyring.gpg] https://packages.sury.org/php/ ${DISTRO} main' > /etc/apt/sources.list.d/php.list" +echo "[5/5] apt update(可能要 30-90s)..." +sudo -n DEBIAN_FRONTEND=noninteractive apt-get update -q 2>&1 +echo "===Sury PHP 源添加完成===" +''' + ] + task_id = create_task('sury_php', 'sury_php', cmd) + write_log('sury_php', '启动添加 Sury PHP 源任务', request.remote_addr) + return jsonify({'code': 0, 'msg': f'Sury PHP 源添加任务已启动(task_id={task_id}),请查看进度', 'data': {'task_id': task_id}}) + +# ========================== +# v1.3.37+ 生产增强 API +# ========================== + +# -------------------------- 防火墙管理 -------------------------- +@app.route('/api/firewall/status', methods=['GET']) +def api_firewall_status(): + status = get_firewall_status() + return jsonify({'code': 0, 'msg': 'ok', 'data': status}) + +@app.route('/api/firewall/enable', methods=['POST']) +def api_firewall_enable(): + ok, msg = firewall_enable() + write_log('firewall', '启用防火墙', request.remote_addr) + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +@app.route('/api/firewall/port', methods=['POST']) +def api_firewall_port(): + data = request.get_json() or {} + port = data.get('port', '').strip() + proto = data.get('proto', 'tcp').strip() + action = data.get('action', 'open').strip() + if action == 'open': + ok, msg = firewall_open_port(port, proto) + else: + ok, msg = firewall_close_port(port, proto) + write_log('firewall', f'{action} 端口 {port}/{proto}', request.remote_addr) + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +# -------------------------- 面板端口修改 -------------------------- +@app.route('/api/panel/port', methods=['GET']) +def api_panel_port(): + port = get_panel_port() + return jsonify({'code': 0, 'msg': 'ok', 'data': {'port': port}}) + +@app.route('/api/panel/port', methods=['POST']) +def api_change_panel_port(): + data = request.get_json() or {} + new_port = data.get('port') + if not new_port: + return jsonify({'code': 400, 'msg': '请输入新端口'}) + ok, msg = change_panel_port(new_port) + write_log('panel_port', f'修改面板端口为 {new_port}', request.remote_addr) + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +# -------------------------- 自动备份 -------------------------- +@app.route('/api/backup/settings', methods=['GET']) +def api_backup_settings(): + settings = get_backup_settings() + return jsonify({'code': 0, 'msg': 'ok', 'data': settings}) + +@app.route('/api/backup/settings', methods=['POST']) +def api_save_backup_settings(): + data = request.get_json() or {} + enabled = data.get('enabled', False) + schedule = data.get('schedule', 'daily') + keep_days = int(data.get('keep_days', 7)) + backup_dir = data.get('backup_dir', '/backup') + ok, msg = save_backup_settings(enabled, schedule, keep_days, backup_dir) + write_log('backup', f'配置自动备份: enabled={enabled}, schedule={schedule}', request.remote_addr) + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +@app.route('/api/backup/run', methods=['POST']) +def api_run_backup(): + ok, msg = run_backup_now() + write_log('backup', '手动执行备份', request.remote_addr) + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +@app.route('/api/backup/list', methods=['GET']) +def api_list_backups(): + backups = list_backups() + return jsonify({'code': 0, 'msg': 'ok', 'data': backups}) + +@app.route('/api/backup/delete', methods=['POST']) +def api_delete_backup(): + data = request.get_json() or {} + backup_type = data.get('type') + filename = data.get('filename') + ok, msg = delete_backup(backup_type, filename) + write_log('backup', f'删除备份: {backup_type}/{filename}', request.remote_addr) + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +# ========================== +# 静态文件 +# ========================== + +@app.route('/') +def serve_index(): + return app.send_static_file('index.html') + +@app.route('/') +def serve_static(path): + full = os.path.join(app.static_folder, path) + if os.path.exists(full) and not os.path.isdir(full): + return app.send_static_file(path) + return app.send_static_file('index.html') + +# ========================== + +if __name__ == '__main__': + import sys + port = int(sys.argv[1]) if len(sys.argv) > 1 else 8848 + # 初始化数据库 + from db_init import init_db + init_db() + # 生成初始 API token + if not get_setting('api_token'): + set_setting('api_token', secrets.token_hex(32)) + print(f"[TPanel] 启动于端口 {port}") + app.run(host='127.0.0.1', port=port, debug=False) diff --git a/backend/remote_backup.py b/backend/remote_backup.py new file mode 100644 index 0000000..fe9f3e9 --- /dev/null +++ b/backend/remote_backup.py @@ -0,0 +1,218 @@ +""" +TPanel - 远程备份管理(rsync) +""" +import os +import sqlite3 +import subprocess +import datetime +from config import DB_PATH + +def _run(cmd, timeout=120, shell=False): + try: + if isinstance(cmd, str) and not shell: + cmd = cmd.split() + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, shell=shell) + return result.returncode, result.stdout.strip(), result.stderr.strip() + except subprocess.TimeoutExpired: + return -1, '', 'Command timed out' + except Exception as e: + return -1, '', str(e) + +def test_rsync_connection(host, port, user, key_path): + """测试到远程服务器的 rsync 连接""" + if not host or not user: + return False, '主机和用户名不能为空' + + extra = '' + if port and str(port) != '22': + extra = f'-e "ssh -p {port}"' + + key = f'-i {key_path}' if key_path else '' + cmd = f'ssh -o StrictHostKeyChecking=no {key} {user}@{host} "echo ok" {extra}' + + code, out, err = _run(cmd, timeout=15, shell=True) + + if code == 0 and 'ok' in out: + return True, '连接成功' + else: + return False, err or '连接失败' + +def get_remote_backups(site_id): + """获取某站点的远程备份列表(通过 rsync 列出远程目录)""" + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT s.site_user FROM sites s WHERE s.id = ?", (site_id,)) + row = cur.fetchone() + conn.close() + if not row: + return [], '站点不存在' + + site_user = row[0] + remote_bak_dir = f'/opt/tpanel/backups/{site_user}/' + + # 尝试通过 SSH 查看远程备份(需要配置) + # 这里返回空列表,实际使用时由用户配置远程路径 + return [], '请配置远程备份服务器' + +def run_remote_backup(site_id, remote_host, remote_user, remote_port, remote_path, key_path=None, use_password=False, password=None): + """ + 执行远程 rsync 备份 + 流程: + 1. 打包本地站点文件 + 2. rsync 推送到远程 + 3. 记录备份日志 + """ + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT site_user, domain FROM sites WHERE id = ?", (site_id,)) + row = cur.fetchone() + conn.close() + + if not row: + return False, '站点不存在' + + site_user, domain = row + site_path = f'/opt/tpanel/sites/{site_user}/' + + timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') + tar_name = f'{domain}_{timestamp}.tar.gz' + local_tar = f'/opt/tpanel/backups/{tar_name}' + + # 1. 打包本地文件 + try: + import tarfile + with tarfile.open(local_tar, 'w:gz') as tar: + tar.add(site_path, arcname=os.path.basename(site_path)) + + tar_size = os.path.getsize(local_tar) + except Exception as e: + return False, f'打包失败: {str(e)}' + + # 2. 构建 rsync 命令 + ssh_cmd = f'ssh -o StrictHostKeyChecking=no -p {remote_port or 22}' + if key_path and os.path.exists(key_path): + ssh_cmd += f' -i {key_path}' + + rsync_cmd = [ + 'rsync', '-avz', '--progress', + '-e', ssh_cmd, + local_tar, + f'{remote_user}@{remote_host}:{remote_path}/{tar_name}' + ] + + code, out, err = _run(rsync_cmd, timeout=600) + + # 删除本地 tar 包(节省空间) + try: + os.remove(local_tar) + except: + pass + + if code != 0: + return False, f'rsync 失败: {err}' + + # 3. 写入备份记录 + conn = sqlite3.connect(DB_PATH) + conn.execute("""INSERT INTO backups (site_id, type, file_path, size, status) + VALUES (?, ?, ?, ?, ?)""", + (site_id, 'remote', f'{remote_host}:{remote_path}/{tar_name}', tar_size, 'success')) + conn.commit() + conn.close() + + # 4. 写安全日志 + from system import write_log + write_log('remote_backup', f'远程备份 {domain} -> {remote_host}', '') + + return True, f'备份成功,已推送至 {remote_host}' + +def sync_restore(backup_id, remote_host, remote_user, remote_port, remote_path, key_path=None): + """ + 从远程恢复备份到本地 + """ + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT site_id, file_path FROM backups WHERE id = ?", (backup_id,)) + row = cur.fetchone() + conn.close() + + if not row: + return False, '备份记录不存在' + + site_id, remote_file = row + + conn2 = sqlite3.connect(DB_PATH) + cur2 = conn2.execute("SELECT site_user, domain FROM sites WHERE id = ?", (site_id,)) + row2 = cur2.fetchone() + conn2.close() + + if not row2: + return False, '站点不存在' + + site_user, domain = row2 + local_dir = f'/opt/tpanel/backups/{site_user}' + os.makedirs(local_dir, exist_ok=True) + + # rsync 从远程拉回 + ssh_cmd = f'ssh -o StrictHostKeyChecking=no -p {remote_port or 22}' + if key_path and os.path.exists(key_path): + ssh_cmd += f' -i {key_path}' + + local_tar = os.path.join(local_dir, os.path.basename(remote_file)) + + rsync_cmd = [ + 'rsync', '-avz', + '-e', ssh_cmd, + f'{remote_user}@{remote_host}:{remote_path}/{os.path.basename(remote_file)}', + local_dir + '/' + ] + + code, out, err = _run(rsync_cmd, timeout=600) + + if code != 0: + return False, f'拉取失败: {err}' + + # 解压恢复 + if os.path.exists(local_tar): + import tarfile + try: + site_path = f'/opt/tpanel/sites/{site_user}/' + with tarfile.open(local_tar, 'r:gz') as tar: + tar.extractall('/opt/tpanel/backups/') + os.remove(local_tar) + except Exception as e: + return False, f'解压失败: {str(e)}' + + from system import write_log + write_log('restore', f'远程恢复 {domain} from {remote_host}', '') + + return True, '恢复成功' + +def get_backup_stats(): + """获取备份统计信息""" + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("""SELECT type, COUNT(*) as cnt, SUM(size) as total_size + FROM backups GROUP BY type""") + rows = cur.fetchall() + conn.close() + + total_local = 0 + total_remote = 0 + count = 0 + + for r in rows: + if r[0] == 'local': + total_local = r[2] or 0 + count += r[1] + elif r[0] == 'remote': + total_remote = r[2] or 0 + + # 计算备份目录总大小 + code, out, _ = _run("du -sm /opt/tpanel/backups 2>/dev/null | awk '{print $1}'", shell=True) + try: + disk_used = int(out.strip()) if out.strip().isdigit() else 0 + except: + disk_used = total_local / (1024 * 1024) + + return { + 'total_backups': count, + 'local_size_mb': round(total_local / (1024 * 1024), 1) if total_local else 0, + 'remote_size_mb': round(total_remote / (1024 * 1024), 1) if total_remote else 0, + 'disk_used_mb': disk_used, + } \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..d68215a --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,6 @@ +flask==3.0.3 +flask-cors==4.0.0 +APScheduler==3.10.4 +python-dotenv==1.0.1 +certbot==2.11.0 +bcrypt==4.2.1 diff --git a/backend/ssl_manager.py b/backend/ssl_manager.py new file mode 100644 index 0000000..25a3cf0 --- /dev/null +++ b/backend/ssl_manager.py @@ -0,0 +1,388 @@ +""" +TPanel - SSL 证书管理 & 自动续期 +""" +import os +import sqlite3 +import subprocess +import re +from datetime import datetime, timedelta +from config import DB_PATH, SSL_DIR + +LETSENCRYPT_PATH = '/etc/letsencrypt/live' + +def _get_real_site_path(domain, site_id): + """ + v1.3.24 修复:查 sqlite 拿站点的真实 site_path(里面是 zhangpu_tech 之类的下划线版), + 这样 certbot 写 challenge 文件的路径才跟 nginx root 指向一致 + 返回 None 表示找不到(会回退到硬编码的 /opt/tpanel/sites//public) + """ + try: + conn = sqlite3.connect(DB_PATH) + if site_id: + cur = conn.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,)) + else: + cur = conn.execute("SELECT site_path FROM sites WHERE domain = ?", (domain,)) + row = cur.fetchone() + conn.close() + if row and row[0]: + p = row[0] + # 确保末尾有 /public(site_path 存的可能就是 /public) + if not p.rstrip('/').endswith('/public'): + p = p.rstrip('/') + '/public' + if os.path.isdir(p): + return p + except Exception as e: + print(f'[ssl] _get_real_site_path failed: {e}', flush=True) + return None + +def _run(cmd, timeout=120, shell=False): + try: + if isinstance(cmd, str) and not shell: + cmd = cmd.split() + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, shell=shell) + return result.returncode, result.stdout.strip(), result.stderr.strip() + except subprocess.TimeoutExpired: + return -1, '', 'Command timed out' + except Exception as e: + return -1, '', str(e) + +def get_cert_info(cert_path): + """从 PEM 文件读取证书信息(到期日期等)""" + if not os.path.exists(cert_path): + return None + + code, out, err = _run([ + 'openssl', 'x509', '-in', cert_path, + '-noout', '-dates', '-enddate' + ], shell=False) + + expire_str = None + if code == 0: + for line in out.split('\n'): + if 'notAfter=' in line: + expire_str = line.split('=')[1].strip() + break + + if expire_str: + try: + expire_date = datetime.strptime(expire_str, '%b %d %H:%M:%S %Y %Z') + return { + 'expire_date': expire_date.strftime('%Y-%m-%d'), + 'days_left': (expire_date - datetime.now()).days, + 'expire_raw': expire_str, + } + except Exception: + pass + + return {'expire_date': '未知', 'days_left': 0, 'expire_raw': expire_str} + +def get_all_certs(): + """获取所有证书(含到期信息)""" + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT * FROM ssl_certs ORDER BY id DESC") + cols = [d[0] for d in cur.description] + rows = [dict(zip(cols, r)) for r in cur.fetchall()] + conn.close() + + result = [] + for cert in rows: + info = get_cert_info(cert['cert_path']) + cert.update(info or {}) + result.append(cert) + + return result + +def apply_letsencrypt(site_id, domain): + """ + 为站点申请 Let's Encrypt 证书 + 流程:创建验证目录 → 生成 cert → 部署 nginx 配置 → 写入数据库 + """ + # v1.3.24 修复:不要再硬编码 /opt/tpanel/sites//public + # 建站时 domain 里的 . 被换成 _(zhangpu.tech → zhangpu_tech), + # certbot 写到 /opt/tpanel/sites/zhangpu.tech/(空目录), + # 但 nginx root 指向 zhangpu_tech/,LE 服务器拉 403 + site_path = _get_real_site_path(domain, site_id) + le_dir = os.path.join(SSL_DIR, domain) + os.makedirs(le_dir, exist_ok=True) + + # 写入 HTTP 验证文件到站点目录 + well_known = os.path.join(site_path, '.well-known', 'acme-challenge') + os.makedirs(well_known, exist_ok=True) + + # 先测试 nginx 配置能访问到验证文件 + nginx_conf = f'''# SSL verification - {domain} +server {{ + listen 80; + server_name {domain}; + root {site_path}; + + location /.well-known/acme-challenge/ {{ + alias {well_known}/; + try_files $uri =404; + }} + + location / {{ + return 301 https://$host$request_uri; + }} +}} +''' + conf_path = f'/etc/nginx/sites-available/{domain}.ssl.conf' + # v1.3.21+:用 sudo mv 写 /etc/nginx/sites-available + tmp_conf = f'/tmp/tpanel_ssl_{domain}.conf' + with open(tmp_conf, 'w') as f: + f.write(nginx_conf) + code, out, err = _run(['sudo', 'mv', tmp_conf, conf_path]) + if code != 0: + return False, f'写 SSL conf 失败: {err}' + + enabled_path = f'/etc/nginx/sites-enabled/{domain}.ssl.conf' + if os.path.exists(enabled_path): + _run(['sudo', 'rm', '-f', enabled_path]) + _run(['sudo', 'ln', '-sf', conf_path, enabled_path]) + + code, out, err = _run(['sudo', 'nginx', '-t']) + if code != 0: + return False, f'Nginx 配置错误: {err}' + + _run(['sudo', 'nginx', '-s', 'reload']) + + # 申请证书(standalone 模式 + webroot) + # v1.3.25 修复:去掉 --cert-path/--key-path/--chain-path 自定义路径 + # certbot 会忽略这些路径或写到默认位置(/etc/letsencrypt/live//), + # 导致 TPanel 去 /opt/tpanel/ssl// 找时拿不到,报"证书文件未生成" + cmd = [ + 'sudo', 'certbot', 'certonly', + '--webroot', + '-w', site_path, + '-d', domain, + '--agree-tos', + '--non-interactive', + '--email', f'admin@{domain}', + ] + + code, out, err = _run(cmd, timeout=120) + + if code != 0: + # 清理失败配置(v1.3.21+:用 sudo 删软链) + if os.path.exists(enabled_path): + _run(['sudo', 'rm', '-f', enabled_path]) + return False, f'证书申请失败: {err}' + + # v1.3.25: certbot 默认写到 /etc/letsencrypt/live//,从那里读 + le_live = f'/etc/letsencrypt/live/{domain}' + cert_path = os.path.join(le_live, 'fullchain.pem') + key_path = os.path.join(le_live, 'privkey.pem') + + if not os.path.exists(cert_path): + return False, '证书文件未生成' + + # 写入数据库 + info = get_cert_info(cert_path) + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("""INSERT INTO ssl_certs (site_id, domain, cert_path, key_path, expire_date, auto_renew) + VALUES (?, ?, ?, ?, ?, 1)""", + (site_id, domain, cert_path, key_path, info['expire_date'] if info else '')) + conn.commit() + conn.close() + + return True, f'证书申请成功,到期:{info["expire_date"] if info else "未知"}' + +def renew_cert(cert_id=None, domain=None): + """ + 续期证书(certbot renew) + """ + if cert_id: + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT domain FROM ssl_certs WHERE id = ?", (cert_id,)) + row = cur.fetchone() + conn.close() + if row: + domain = row[0] + elif domain: + pass + else: + return False, '请指定证书 ID 或域名' + + # certbot renew 只续期 30 天内到期的证书 + code, out, err = _run( + ['certbot', 'renew', '--cert-name', domain, '--quiet'], + timeout=120 + ) + + if code != 0 and 'No renewals attempted' not in out and 'already valid' not in out: + return False, f'续期失败: {err}' + + # 更新到期日期 + le_dir = os.path.join(SSL_DIR, domain) + cert_path = os.path.join(le_dir, 'fullchain.pem') + info = get_cert_info(cert_path) + + if info: + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("UPDATE ssl_certs SET expire_date = ? WHERE domain = ?", + (info['expire_date'], domain)) + conn.commit() + conn.close() + + return True, f'证书已续期,新到期:{info["expire_date"] if info else "未知"}' + +def renew_all_expiring(days_before=30): + """ + 续期所有即将到期的证书(供定时任务调用) + 返回:(成功数量, 失败数量, 详情列表) + """ + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT * FROM ssl_certs WHERE auto_renew = 1") + rows = cur.fetchall() + conn.close() + + if not rows: + return 0, 0, [] + + success, fail = 0, [] + for row in rows: + cert_id, site_id, domain = row[0], row[1], row[2] + info = get_cert_info(row[3]) # cert_path + + # 检查是否在 30 天内到期 + if info and info['days_left'] <= days_before: + ok, msg = renew_cert(cert_id=cert_id, domain=domain) + if ok: + success += 1 + else: + fail.append(f'{domain}: {msg}') + elif not info or info['days_left'] > days_before: + # 证书已过期或不存在 + pass + + return success, len(fail), fail + +def deploy_ssl(domain): + """ + 将已有证书部署到 Nginx(更新 nginx 配置启用 HTTPS) + v1.3.25: 从 /etc/letsencrypt/live// 读证书(certbot 默认位置) + """ + le_live = f'/etc/letsencrypt/live/{domain}' + cert_path = os.path.join(le_live, 'fullchain.pem') + key_path = os.path.join(le_live, 'privkey.pem') + + if not os.path.exists(cert_path) or not os.path.exists(key_path): + return False, '证书文件不存在' + + site_path = f'/opt/tpanel/sites/{domain}/public' + # v1.3.24: 同样查 sqlite 拿真路径 + site_path = _get_real_site_path(domain, None) or site_path + + # 写入 HTTPS + HTTP 重定向配置 + nginx_conf = f'''# {domain} - HTTPS +server {{ + listen 80; + server_name {domain}; + return 301 https://$server_name$request_uri; +}} + +server {{ + listen 443 ssl http2; + server_name {domain}; + + ssl_certificate {cert_path}; + ssl_certificate_key {key_path}; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + root {site_path}; + index index.php index.html; + + access_log /opt/tpanel/logs/{domain}.access.log; + error_log /opt/tpanel/logs/{domain}.error.log; + + location / {{ + try_files $uri $uri/ /index.php?$query_string; + }} + + location ~ \\.php$ {{ + include fastcgi_params; + fastcgi_pass 127.0.0.1:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + }} + + location ~ /\\.ht {{ + deny all; + }} +}} +''' + conf_path = f'/etc/nginx/sites-available/{domain}.conf' + + # v1.3.34 修复:用 sudo rm 清理(前面已经会 rm -f,这里简化) + + with open(conf_path, 'w') as f: + f.write(nginx_conf) + + # v1.3.34 修复:用 sudo ln -sf (sites-enabled 目录 root-only 可写) + enabled_path = f'/etc/nginx/sites-enabled/{domain}.conf' + # 先 rm 旧的(无论是 symlink 还是普通文件) + _run(['sudo', 'rm', '-f', enabled_path]) + r = _run(['sudo', 'ln', '-sf', conf_path, enabled_path]) + if r[0] != 0: + return False, f'创建 symlink 失败: {r[2]}' + + code, out, err = _run(['sudo', 'nginx', '-t']) + if code != 0: + return False, f'Nginx 配置错误: {err}' + + _run(['sudo', 'nginx', '-s', 'reload']) + + # 更新数据库 ssl_enabled + ssl_certs 表 + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT id FROM sites WHERE domain = ?", (domain,)) + site_row = cur.fetchone() + site_id = site_row[0] if site_row else None + if site_id: + conn.execute("UPDATE sites SET ssl_enabled = 1, ssl_cert_path = ?, ssl_key_path = ? WHERE domain = ?", + (cert_path, key_path, domain)) + + # v1.3.34 修复:必须把证书插到 ssl_certs 表(前端列表才会显示) + info = get_cert_info(cert_path) + expire_date = info["expire_date"] if info else "" + cur2 = conn.execute("SELECT id FROM ssl_certs WHERE domain = ?", (domain,)) + existing = cur2.fetchone() + if existing: + conn.execute("UPDATE ssl_certs SET cert_path = ?, key_path = ?, expire_date = ?, auto_renew = 1, site_id = ? WHERE domain = ?", + (cert_path, key_path, expire_date, site_id, domain)) + else: + conn.execute("INSERT INTO ssl_certs (site_id, domain, cert_path, key_path, expire_date, auto_renew) VALUES (?, ?, ?, ?, ?, 1)", + (site_id, domain, cert_path, key_path, expire_date)) + conn.commit() + conn.close() + + return True, "HTTPS 已启用,到期 " + expire_date + +def check_certs_status(): + """ + 检查所有证书状态,返回统计信息 + """ + certs = get_all_certs() + expired = [] + expiring = [] + valid = [] + + for cert in certs: + info = get_cert_info(cert['cert_path']) + if info: + days = info['days_left'] + if days < 0: + expired.append({**cert, **info}) + elif days <= 7: + expiring.append({**cert, **info}) + else: + valid.append({**cert, **info}) + + return { + 'total': len(certs), + 'valid': len(valid), + 'expiring': len(expiring), + 'expired': len(expired), + 'expiring_list': expiring, + 'expired_list': expired, + } \ No newline at end of file diff --git a/backend/ssl_sync.py b/backend/ssl_sync.py new file mode 100644 index 0000000..aa032dc --- /dev/null +++ b/backend/ssl_sync.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""v1.3.41 新增:SSL 证书同步工具(扫 /etc/letsencrypt/live/ 重建 ssl_certs)""" +import os +import sqlite3 +import subprocess +from datetime import datetime + +DB_PATH = "/opt/tpanel/data/tpanel.db" +SSL_DIR = "/etc/letsencrypt/live" + + +def sync_ssl_certs(): + """ + 场景:apply_letsencrypt 申请证书成功但忘了写数据库 / 升级后数据库丢失 + 返回: (added, updated, skipped, errors_list) + """ + if not os.path.isdir(SSL_DIR): + return (0, 0, 0, ["SSL 目录不存在: " + SSL_DIR]) + + cert_dirs = [d for d in os.listdir(SSL_DIR) + if os.path.isdir(os.path.join(SSL_DIR, d)) and d != "README"] + + added, updated, skipped, errors = 0, 0, 0, [] + conn = sqlite3.connect(DB_PATH) + + for domain in cert_dirs: + cert_path = SSL_DIR + "/" + domain + "/fullchain.pem" + key_path = SSL_DIR + "/" + domain + "/privkey.pem" + if not (os.path.exists(cert_path) and os.path.exists(key_path)): + skipped += 1 + continue + + expire_date = None + try: + out = subprocess.run( + ["openssl", "x509", "-in", cert_path, "-noout", "-enddate"], + capture_output=True, text=True, timeout=5 + ) + for line in out.stdout.splitlines(): + if "notAfter=" in line: + raw = line.split("=", 1)[1].strip() + dt = datetime.strptime(raw, "%b %d %H:%M:%S %Y %Z") + expire_date = dt.strftime("%Y-%m-%d") + break + except Exception as e: + errors.append(domain + ": 解析证书失败 " + str(e)) + continue + + cur = conn.execute("SELECT id FROM sites WHERE domain=?", (domain,)) + row = cur.fetchone() + site_id = row[0] if row else None + + cur = conn.execute("SELECT id FROM ssl_certs WHERE domain=?", (domain,)) + existing = cur.fetchone() + if existing: + conn.execute( + "UPDATE ssl_certs SET cert_path=?, key_path=?, expire_date=?, site_id=COALESCE(?, site_id) WHERE id=?", + (cert_path, key_path, expire_date, site_id, existing[0]) + ) + updated += 1 + else: + conn.execute( + "INSERT INTO ssl_certs (site_id, domain, cert_path, key_path, expire_date, auto_renew) VALUES (?, ?, ?, ?, ?, 1)", + (site_id, domain, cert_path, key_path, expire_date) + ) + added += 1 + + conn.commit() + conn.close() + return (added, updated, skipped, errors) diff --git a/backend/sync_pma_bridge.py b/backend/sync_pma_bridge.py new file mode 100755 index 0000000..d3e08d0 --- /dev/null +++ b/backend/sync_pma_bridge.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +""" +TPanel → phpMyAdmin 自动登录桥接同步脚本(v1.3.34) +当数据库 db_pass 修改后调用,把 secret_key + 所有 db 凭证写到 +/etc/phpmyadmin/conf.d/tpanel-bridge.json(PHP 端读) +""" +import json +import os +import sys +import sqlite3 +import subprocess +import datetime + +DB_PATH = '/opt/tpanel/data/tpanel.db' +BRIDGE_FILE = '/etc/phpmyadmin/conf.d/tpanel-bridge.json' +SECRET_FILE = '/opt/tpanel/data/.secret_key' + + +def sync_bridge(): + """同步所有数据库凭证到 bridge.json""" + if not os.path.exists(SECRET_FILE): + print('SECRET_KEY file missing', file=sys.stderr) + sys.exit(1) + with open(SECRET_FILE, 'r') as f: + secret_key = f.read().strip() + + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT id, name, db_user, db_pass FROM databases") + dbs = {} + for row in cur.fetchall(): + dbs[str(row[0])] = { + 'name': row[1], + 'user': row[2], + 'pass': row[3], + } + conn.close() + + payload = { + 'secret_key': secret_key, + 'dbs': dbs, + 'updated_at': datetime.datetime.now().isoformat(), + } + # 先写到 /tmp(可写),再 sudo mv + tmp = '/tmp/tpanel-bridge.json.tmp' + with open(tmp, 'w') as f: + json.dump(payload, f) + os.chmod(tmp, 0o644) + + r = subprocess.run(['sudo', 'mv', tmp, BRIDGE_FILE], capture_output=True, text=True) + if r.returncode != 0: + print(f'mv failed: {r.stderr}', file=sys.stderr) + sys.exit(1) + r = subprocess.run(['sudo', 'chmod', '644', BRIDGE_FILE], capture_output=True) + r = subprocess.run(['sudo', 'chown', 'www-data:www-data', BRIDGE_FILE], capture_output=True) + print(f'synced {len(dbs)} dbs to {BRIDGE_FILE}') + + +if __name__ == '__main__': + sync_bridge() \ No newline at end of file diff --git a/backend/system.py b/backend/system.py new file mode 100644 index 0000000..ad370c7 --- /dev/null +++ b/backend/system.py @@ -0,0 +1,1202 @@ +""" +TPanel - 系统操作模块 +仅使用白名单命令,禁止直接执行用户传入的原始 shell 字符串 +""" +import subprocess +import os +import shutil +import tarfile +import datetime +import time + +def _detect_pkg_manager(): + """检测系统包管理器""" + import shutil + for p in ['apt-get', 'yum', 'dnf']: + if shutil.which(p): + return p + return None + + +def _run(cmd, shell=False, capture=True, timeout=30): + """执行命令,超时保护""" + try: + if isinstance(cmd, str) and not shell: + cmd = cmd.split() + result = subprocess.run( + cmd, + capture_output=capture, + text=True, + timeout=timeout, + shell=shell + ) + return result.returncode, result.stdout.strip(), result.stderr.strip() + except subprocess.TimeoutExpired: + return -1, '', 'Command timed out' + except Exception as e: + return -1, '', str(e) + +def nginx_reload(): + return _run(['sudo', 'nginx', '-t']) + _run(['sudo', 'nginx', '-s', 'reload']) + +def nginx_stop(): + return _run(['sudo', 'nginx', '-s', 'stop']) + +def nginx_start(): + return _run(['sudo', 'nginx']) + +def nginx_status(): + code, out, _ = _run(['ps', 'aux'], capture=True) + running = 'nginx: master' in out + return running + +def mysql_status(): + # Debian 12 默认是 mariadb,CentOS 是 mysql + for svc in ['mariadb', 'mysql']: + code, out, _ = _run(['systemctl', 'is-active', svc], capture=True) + if code == 0: + return True + return False + return out == 'active' + +def create_site_user(username): + """创建 Linux 用户,禁 shell,隔离目录(v1.3.11+ 改用 sudo)""" + # 检查用户是否存在 + code, out, _ = _run(['id', username], capture=True) + if code == 0: + return True, '用户已存在' + + # 创建用户,home 目录即网站根目录,禁 shell + code, out, err = _run( + ['sudo', 'useradd', '-m', '-s', '/usr/sbin/nologin', '-d', f'/home/{username}', username] + ) + if code != 0: + return False, err + return True, '用户创建成功' + +def delete_site_user(username): + code, out, _ = _run(['id', username], capture=True) + if code != 0: + return True, '用户不存在,跳过' + + # 把用户的所有进程 kill 掉再删 + _run(['pkill', '-u', username], capture=True) + code, out, err = _run(['sudo', 'userdel', '-r', username]) + if code != 0: + return False, err + return True, '用户删除成功' + +def set_site_permissions(site_path, site_user): + """设置站点目录权限""" + _run(['sudo', 'chown', '-R', f'{site_user}:{site_user}', site_path]) + _run(['sudo', 'chmod', '-R', '755', site_path]) + _run(['sudo', 'chmod', '-R', '700', site_path + '/storage' if os.path.exists(site_path + '/storage') else site_path]) + +def get_php_fpm_port(php_version): + """ + v1.3.29: PHP 版本 → FPM 端口映射 + - 8.2 继续用 9000(向后兼容老 conf / install.sh 默认配置) + - 其他版本: 90 + 小数点后两位(7.4→9074, 8.0→9080, 8.1→9081, 8.3→9083, 8.4→9084) + - 带小数点的老版本(5.6→9056, 7.0→9070, 7.1→9071, 7.2→9072, 7.3→9073) + - 解析失败的 default: 9000 + """ + pv = (php_version or '').strip() + if pv == '8.2': + return 9000 + try: + parts = pv.split('.') + major = int(parts[0]) + minor = int(parts[1]) if len(parts) > 1 else 0 + return 9000 + major * 10 + minor + except Exception: + return 9000 + + +def write_nginx_config(domain, site_path, php_version='8.1', ssl=False, site_type='php'): + """写入 Nginx 配置 + v1.3.26 新增 site_type 参数: + - 'php'(默认):保留 PHP-FPM 反代 location + - 'static':不写 PHP-FPM 块(纯静态站点,不转发 *.php 到 FPM) + v1.3.29: PHP-FPM 端口随版本变化(多版本并存不冲突) + """ + # PHP-FPM 连接地址(v1.3.6+ 改用 TCP 避免 unix socket 问题,v1.3.29 起按版本分端口) + fpm_port = get_php_fpm_port(php_version) + fpm_sock = f'127.0.0.1:{fpm_port}' + + # index 顺序 + try_files fallback 随类型不同 + if site_type == 'static': + index_line = 'index index.html;' + try_files_line = 'try_files $uri $uri/ =404;' + php_block = '' # 静态站点完全不转发 .php + else: + index_line = 'index index.php index.html;' + try_files_line = 'try_files $uri $uri/ /index.php?$query_string;' + php_block = f''' + location ~ \\.php$ {{ + include fastcgi_params; + fastcgi_pass {fpm_sock}; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + }} +''' + + nginx_conf = f'''# TPanel - {domain} ({site_type}) +server {{ + listen 80; + server_name {domain}; + + root ' + site_path + '; + {index_line} + + access_log /opt/tpanel/logs/{domain}.access.log; + error_log /opt/tpanel/logs/{domain}.error.log; + + location /.well-known/acme-challenge/ {{ + alias {site_path}/.well-known/acme-challenge/; + try_files $uri =404; + }} + + # phpMyAdmin 反代(v1.3.43:自动加,任何站点都可点数据库跳 pma) + location ^~ /pma/ {{ + proxy_pass http://127.0.0.1:8443/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + }} + location = /pma/tpanel-bridge.php {{ + proxy_pass http://127.0.0.1:8443/tpanel-bridge.php; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + }} + + location / {{ + {try_files_line} + }} +{php_block} + location ~ /\\.ht {{ + deny all; + }} +}} +''' + if ssl: + nginx_conf = nginx_conf.replace('listen 80;', '''listen 80; + listen 443 ssl http2;''', 1) + + conf_path = f'/etc/nginx/sites-available/{domain}.conf' + # v1.3.15+:tpanel 不可写 /etc/nginx,用 sudo tee(先写 /tmp 临时文件) + tmp_conf = f'/tmp/tpanel_nginx_{domain}.conf' + with open(tmp_conf, 'w') as f: + f.write(nginx_conf) + code, out, err = _run(['sudo', 'mv', tmp_conf, conf_path]) + if code != 0: + return False, f'写 conf 失败: {err}' + + # 启用站点(v1.3.15+:软链在 sites-enabled 也需 sudo) + enabled_path = f'/etc/nginx/sites-enabled/{domain}.conf' + if os.path.exists(enabled_path): + _run(['sudo', 'rm', '-f', enabled_path]) + _run(['sudo', 'ln', '-sf', conf_path, enabled_path]) + + code, out, err = _run(['sudo', 'nginx', '-t']) + if code != 0: + return False, err + + _run(['sudo', 'nginx', '-s', 'reload']) + return True, 'Nginx 配置已更新' + +def remove_nginx_config(domain): + """删除站点 Nginx 配置(v1.3.15+ 用 sudo 删)""" + conf_path = f'/etc/nginx/sites-available/{domain}.conf' + enabled_path = f'/etc/nginx/sites-enabled/{domain}.conf' + + if os.path.exists(enabled_path): + _run(['sudo', 'rm', '-f', enabled_path]) + if os.path.exists(conf_path): + _run(['sudo', 'rm', '-f', conf_path]) + + _run(['sudo', 'nginx', '-s', 'reload']) + +def create_mysql_db(name, db_user, db_pass): + """创建 MySQL 数据库和用户(用 sudo 提权,避免 shell 注入)""" + # 校验 name/user 不含特殊字符(防止 SQL 注入) + import re + if not re.match(r'^[a-zA-Z0-9_]+$', name) or not re.match(r'^[a-zA-Z0-9_]+$', db_user): + return False, '数据库名/用户名只能包含字母数字下划线' + + statements = [ + f"CREATE DATABASE IF NOT EXISTS `{name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;", + f"CREATE USER IF NOT EXISTS '{db_user}'@'localhost' IDENTIFIED BY '{db_pass}';", + f"GRANT ALL PRIVILEGES ON `{name}`.* TO '{db_user}'@'localhost';", + "FLUSH PRIVILEGES;", + ] + for stmt in statements: + code, out, err = _run(['sudo', 'mysql', '-e', stmt], shell=False) + if code != 0: + return False, err + return True, '数据库创建成功' + +def delete_mysql_db(name, db_user): + import re + if not re.match(r'^[a-zA-Z0-9_]+$', name) or not re.match(r'^[a-zA-Z0-9_]+$', db_user): + return False, '数据库名/用户名只能包含字母数字下划线' + statements = [ + f"DROP DATABASE IF EXISTS `{name}`;", + f"DROP USER IF EXISTS '{db_user}'@'localhost';", + "FLUSH PRIVILEGES;", + ] + for stmt in statements: + code, out, err = _run(['sudo', 'mysql', '-e', stmt], shell=False) + if code != 0: + return False, err + return True, '数据库删除成功' + +def get_mysql_size(): + """获取 MySQL 数据目录大小(MB)""" + code, out, _ = _run("du -sm /var/lib/mysql 2>/dev/null || echo 0", shell=True) + try: + return int(out.split()[0]) + except: + return 0 + +def backup_site(site_path, site_name, db_name=None, db_user=None, db_pass=None): + """备份站点文件和数据库""" + import traceback + timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') + # 清理站点名:ygbk.cn → ygbk.cn(保留点) + safe_name = site_name.replace('/', '_') + backup_name = f'{safe_name}_{timestamp}' + backup_path = f'/opt/tpanel/backups/{backup_name}.tar.gz' + + # v1.3.10 修复:预检环境 + try: + os.makedirs('/opt/tpanel/backups', exist_ok=True) + except Exception as e: + return False, f'无法创建 backups 目录: {e}', 0 + if not os.path.isdir(site_path): + return False, f'站点目录不存在: ' + site_path + '', 0 + if not os.access(site_path, os.R_OK): + return False, f'tpanel 用户无法读取 ' + site_path + '(chown 错了?ls -ld ' + site_path + ' 看看)', 0 + + try: + # 备份文件 + with tarfile.open(backup_path, 'w:gz') as tar: + tar.add(site_path, arcname=os.path.basename(site_path)) + + # 备份数据库(v1.3.10 修复:用 list 参数防注入 + sudo) + if db_name: + dump_path = f'/opt/tpanel/backups/{backup_name}_db.sql.gz' + try: + if db_user and db_pass: + code, out, err = _run( + ['sudo', 'mysqldump', '-u', db_user, f'-p{db_pass}', db_name], + shell=False, timeout=120 + ) + else: + code, out, err = _run(['sudo', 'mysqldump', db_name], shell=False, timeout=120) + if code == 0 and out: + import gzip + with open(dump_path, 'wb') as df: + df.write(gzip.compress(out.encode('utf-8') if isinstance(out, str) else out)) + with tarfile.open(backup_path, 'a:gz') as tar: + tar.add(dump_path, arcname='database.sql.gz') + os.remove(dump_path) + except Exception as e: + # 数据库备份失败不阻断(文件备份可能成功) + pass + + size = os.path.getsize(backup_path) + return True, backup_path, size + except PermissionError as e: + return False, f'权限错误: {e}(tpanel 读不到 ' + site_path + ',请 chown)', 0 + except Exception as e: + return False, f'备份异常: {type(e).__name__}: {e}\n{traceback.format_exc()[-300:]}', 0 + +def restore_backup(backup_path, site_path, site_name): + """恢复备份""" + try: + # v1.3.17+:先 sudo 删干净 site_path(因为可能有 root 拥有的文件,tpanel 删不掉) + # 用 sudo 替换为临时空目录,然后再解压 + backup_site_path = site_path + if os.path.exists(backup_site_path): + # 移动到 .bak 路径(sudo 移) + bak_path = backup_site_path + '.bak.' + str(int(time.time())) + code, _, err = _run(['sudo', 'mv', backup_site_path, bak_path]) + if code != 0: + return False, f'备份旧目录失败: {err}' + + # 解压到临时目录 + temp_dir = f'/opt/tpanel/backups/temp_{site_name}' + os.makedirs(temp_dir, exist_ok=True) + with tarfile.open(backup_path, 'r:gz') as tar: + tar.extractall(temp_dir) + + # 找到网站目录内容 + items = os.listdir(temp_dir) + src_dir = os.path.join(temp_dir, items[0]) if items else temp_dir + + # 把整个 src 目录 sudo mv 到 site_path + code, _, err = _run(['sudo', 'mv', src_dir, backup_site_path]) + if code != 0: + return False, f'恢复目录失败: {err}' + + # v1.3.17+:从 site_path 反推 site_user + # /opt/tpanel/sites/zhangpu_tech/public → zhangpu_tech + path_parts = backup_site_path.rstrip('/').split('/') + site_user = path_parts[-1] if path_parts else site_name + _run(['sudo', 'chown', '-R', f'{site_user}:{site_user}', backup_site_path]) + _run(['sudo', 'chmod', '-R', '755', backup_site_path]) + + shutil.rmtree(temp_dir, ignore_errors=True) + return True, '恢复成功' + except Exception as e: + return False, str(e) + +def run_security_update(): + """执行系统安全更新""" + code, out, err = _run(['sudo', 'apt-get', 'update'], timeout=120) + if code != 0: + return False, err + + # v1.3.20+:apt-get upgrade 也加 sudo(不然 Permission denied dpkg lock) + code, out, err = _run( + ['sudo', 'apt-get', 'upgrade', '-y', '--only-upgrade'], + timeout=300 + ) + if code == 0: + return True, f'安全更新完成' + else: + return False, err + +def get_security_status(): + """获取安全状态""" + # 可升级的安全包数量 + code, out, _ = _run( + "apt list --upgradable 2>/dev/null | grep -c security || echo 0", + shell=True + ) + try: + updatable = int(out.strip()) + except: + updatable = 0 + + # 最近的安全日志条数 + code2, out2, _ = _run( + "journalctl --since '1 day ago' --priority=err 2>/dev/null | wc -l", + shell=True + ) + try: + errors = int(out2.strip()) + except: + errors = 0 + + return {'upgradable_security_packages': updatable, 'recent_errors': errors} + +def get_system_stats(): + """获取系统状态""" + code, cpu_out, _ = _run("cat /proc/loadavg | awk '{print $1,$2,$3}'", shell=True) + code, mem_out, _ = _run("free -m | awk 'NR==2{print $3,$2}'", shell=True) + code, disk_out, _ = _run("df -h / | tail -1 | awk '{print $3,$4}'", shell=True) + code, cpu_pct, _ = _run("top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | sed 's/%us,//'", shell=True) + + # v1.3.10+ 新增:CPU 核心数 + 型号(用于仪表盘显示 + 负载颜色按核心数判断) + # v1.3.35 修复:容器/Docker 里 lscpu 无 "Model name" 行会导致 Unknown CPU + import os as _os + cpu_cores = _os.cpu_count() or 1 + cpu_model = '' + # 1. 优先 lscpu "Model name"(KVM/Xen 等虚拟化都正常) + code, lscpu_out, _ = _run("lscpu | grep 'Model name' | head -1", shell=True) + if code == 0 and lscpu_out and ':' in lscpu_out: + cpu_model = lscpu_out.split(':', 1)[1].strip() + # 2. 兑底:/proc/cpuinfo 的 model name(v1.3.35 修复:必传 shell=True) + if not cpu_model: + code, cpuinfo_out, _ = _run("grep -m1 'model name' /proc/cpuinfo", shell=True) + if code == 0 and cpuinfo_out and ':' in cpuinfo_out: + cpu_model = cpuinfo_out.split(':', 1)[1].strip() + # 3. 兑底:/proc/cpuinfo 拼 vendor + family + model(容器里 lscpu 可能无 Model name) + if not cpu_model: + try: + with open('/proc/cpuinfo', 'r') as f: + ci = f.read() + vendor = family = model_name = '' + for line in ci.splitlines(): + if line.startswith('vendor_id') and ':' in line and not vendor: + vendor = line.split(':', 1)[1].strip() + elif line.startswith('cpu family') and ':' in line and not family: + family = line.split(':', 1)[1].strip() + elif line.startswith('model name') and ':' in line and not model_name: + model_name = line.split(':', 1)[1].strip() + if model_name: break + if model_name: + cpu_model = model_name + elif vendor: + cpu_model = f'{vendor} CPU' + if family: cpu_model += f' (family {family})' + except Exception: + pass + # 4. 兑底:platform.processor()(老 Python 偶尔能拿到) + if not cpu_model: + try: + import platform + cpu_model = platform.processor() or '' + except Exception: + pass + # 5. 兑底:lscpu 看 Vendor ID + Model(某些云主机会输出这个) + if not cpu_model: + code, lscpu_v, _ = _run("lscpu | grep -E 'Vendor ID|Model:' | head -2", shell=True) + if code == 0 and lscpu_v: + parts = [] + for line in lscpu_v.strip().splitlines(): + if ':' in line: + parts.append(line.split(':', 1)[1].strip()) + if parts: + cpu_model = ' '.join(parts) + ' CPU' + if not cpu_model: + cpu_model = 'Unknown CPU' + + nginx_running = nginx_status() + mysql_running = mysql_status() + + return { + 'load': cpu_out, + 'cpu_pct': cpu_pct.strip() + '%' if cpu_pct else 'N/A', + 'cpu_cores': cpu_cores, + 'cpu_model': cpu_model, + 'mem_used_mb': mem_out.split()[0] if mem_out else '0', + 'mem_total_mb': mem_out.split()[1] if mem_out else '0', + 'disk_used': disk_out.split()[0] if disk_out else '0', + 'disk_free': disk_out.split()[1] if disk_out else '0', + 'nginx_running': nginx_running, + 'mysql_running': mysql_running, + } + +def write_log(event_type, details, ip=''): + """写安全日志""" + import sqlite3 + from config import DB_PATH + conn = sqlite3.connect(DB_PATH) + conn.execute("INSERT INTO security_logs (event_type, details, ip) VALUES (?, ?, ?)", + (event_type, details, ip)) + conn.commit() + conn.close() + + +def setup_php_fpm_listen(php_version): + """ + v1.3.29: 装完 PHP 后调用——设置 FPM listen 端口为版本专属端口,并启动服务 + - 写 /etc/php//fpm/pool.d/www.conf(备份原文件为 .bak) + - sudo systemctl enable --now php-fpm + 返回: (ok, msg) + """ + port = get_php_fpm_port(php_version) + www_conf = f'/etc/php/{php_version}/fpm/pool.d/www.conf' + if not os.path.exists(www_conf): + return False, f'找不到 {www_conf}(该版本未安装?)' + + # 备份(幂等:不重复备份) + bak = www_conf + '.tpanel.bak' + if not os.path.exists(bak): + code, _, err = _run(['sudo', 'cp', www_conf, bak]) + if code != 0: + return False, f'备份 {www_conf} 失败: {err}' + + # 修改 listen 行(用 sed 精准替换) + code, _, err = _run(['sudo', 'bash', '-c', + f"sed -i 's|^listen = .*|listen = 127.0.0.1:{port}|' {www_conf}"]) + if code != 0: + return False, f'修改 listen 失败: {err}' + + # 启用 + 启动 + code, _, err = _run(['sudo', 'systemctl', 'enable', f'php{php_version}-fpm']) + if code != 0: + return False, f'enable php{php_version}-fpm 失败: {err}' + + code, out, err = _run(['sudo', 'systemctl', 'restart', f'php{php_version}-fpm']) + if code != 0: + return False, f'restart php{php_version}-fpm 失败: {err}' + + # 验证在监听 + code, out, _ = _run(['sudo', 'ss', '-lntp']) + listening = f'127.0.0.1:{port}' in out + if not listening: + return False, f'php{php_version}-fpm 未在 127.0.0.1:{port} 监听(可能启动失败)' + + return True, f'php{php_version}-fpm 已配置 listen 127.0.0.1:{port} 并启动' + +def change_db_password(db_user, new_pass): + """修改 MySQL 数据库用户密码(v1.3.34+)""" + import re + if not re.match(r"^[a-zA-Z0-9_]+$", db_user): + return False, "用户名只能包含字母数字下划线" + if not new_pass or len(new_pass) < 6: + return False, "密码至少 6 位" + escaped_pass = new_pass.replace("'", "''") + stmt = "ALTER USER '" + db_user + "'@'localhost' IDENTIFIED BY '" + escaped_pass + "';" + code, out, err = _run(["sudo", "mysql", "-e", stmt], shell=False) + if code != 0: + return False, err + code, _, err = _run(["sudo", "mysql", "-e", "FLUSH PRIVILEGES;"], shell=False) + if code != 0: + return False, err + return True, "密码修改成功" + + +# ====================== v1.3.37+ 生产增强功能 ====================== + +# ---------------------- 防火墙管理 ---------------------- + +def _detect_firewall(): + """检测系统使用的防火墙:ufw (Debian/Ubuntu) 或 firewalld (CentOS/RHEL)""" + if shutil.which('ufw'): + return 'ufw' + if shutil.which('firewall-cmd'): + return 'firewalld' + return None + +def get_firewall_status(): + """获取防火墙状态和已开放端口""" + fw = _detect_firewall() + if not fw: + return {'enabled': False, 'type': None, 'rules': [], 'msg': '未检测到防火墙(ufw/firewalld)'} + + if fw == 'ufw': + code, status, _ = _run(['sudo', 'ufw', 'status']) + enabled = 'Status: active' in status + # 解析规则 + lines = status.split('\n') + rules = [] + in_rules = False + for line in lines: + if '----' in line: + in_rules = True + continue + if in_rules and line.strip(): + parts = line.split() + if len(parts) >= 3: + rules.append({ + 'port': parts[0], + 'action': parts[1], + 'from': parts[2] if len(parts) > 2 else 'Anywhere' + }) + return {'enabled': enabled, 'type': 'ufw', 'rules': rules, 'status': status} + + else: # firewalld + code, status, _ = _run(['sudo', 'firewall-cmd', '--state']) + enabled = status.strip() == 'running' + code, ports, _ = _run(['sudo', 'firewall-cmd', '--list-ports']) + rules = [{'port': p, 'action': 'allow', 'from': 'public'} for p in ports.split() if p.strip()] + return {'enabled': enabled, 'type': 'firewalld', 'rules': rules, 'status': status} + +def firewall_enable(): + """启用防火墙并开放常用端口(SSH 80 443 + TPanel 端口)""" + fw = _detect_firewall() + if not fw: + # 自动安装 ufw + pkg = _detect_pkg_manager() + if pkg == 'apt-get': + code, _, err = _run(['sudo', 'apt-get', 'install', '-y', 'ufw'], timeout=120) + elif pkg in ['yum', 'dnf']: + code, _, err = _run(['sudo', pkg, 'install', '-y', 'firewalld'], timeout=120) + else: + return False, '不支持的系统包管理器' + if code != 0: + return False, f'安装防火墙失败: {err}' + fw = _detect_firewall() + + if fw == 'ufw': + # 默认策略 + _run(['sudo', 'ufw', 'default', 'deny', 'incoming']) + _run(['sudo', 'ufw', 'default', 'allow', 'outgoing']) + # 开放常用端口 + for port in ['22', '80', '443', '8888']: + _run(['sudo', 'ufw', 'allow', port]) + # 启用 + code, _, err = _run(['sudo', 'bash', '-c', 'echo "y" | ufw enable'], shell=True) + return code == 0, '防火墙已启用,已开放 22/80/443/8888 端口' + + else: # firewalld + _run(['sudo', 'systemctl', 'enable', '--now', 'firewalld']) + for port in ['22/tcp', '80/tcp', '443/tcp', '8888/tcp']: + _run(['sudo', 'firewall-cmd', '--permanent', '--add-port=' + port]) + _run(['sudo', 'firewall-cmd', '--reload']) + return True, '防火墙已启用,已开放 22/80/443/8888 端口' + +def firewall_open_port(port, proto='tcp'): + """开放端口""" + if not port.isdigit() or int(port) < 1 or int(port) > 65535: + return False, '端口号无效(1-65535)' + + fw = _detect_firewall() + if not fw: + return False, '未检测到防火墙,请先启用' + + if fw == 'ufw': + code, _, err = _run(['sudo', 'ufw', 'allow', f'{port}/{proto}']) + return code == 0, f'端口 {port}/{proto} 已开放' + else: + _run(['sudo', 'firewall-cmd', '--permanent', f'--add-port={port}/{proto}']) + _run(['sudo', 'firewall-cmd', '--reload']) + return True, f'端口 {port}/{proto} 已开放' + +def firewall_close_port(port, proto='tcp'): + """关闭端口""" + if not port.isdigit() or int(port) < 1 or int(port) > 65535: + return False, '端口号无效' + + fw = _detect_firewall() + if not fw: + return False, '未检测到防火墙' + + if fw == 'ufw': + code, _, err = _run(['sudo', 'ufw', 'delete', 'allow', f'{port}/{proto}']) + return code == 0, f'端口 {port}/{proto} 已关闭' + else: + _run(['sudo', 'firewall-cmd', '--permanent', f'--remove-port={port}/{proto}']) + _run(['sudo', 'firewall-cmd', '--reload']) + return True, f'端口 {port}/{proto} 已关闭' + + +# ---------------------- 面板端口修改 ---------------------- + +def get_panel_port(): + """获取当前 TPanel 监听端口""" + # 先从 Nginx 配置查 + code, out, _ = _run(['sudo', 'grep', '-r', 'listen', '/etc/nginx/sites-enabled/tpanel.conf']) + if code == 0: + for line in out.split('\n'): + if 'listen ' in line and 'default_server' not in line: + parts = line.strip().split() + if len(parts) >= 2 and parts[1].isdigit(): + return int(parts[1]) + # 查 systemd 服务 + code, out, _ = _run(['grep', 'ExecStart', '/etc/systemd/system/tpanel.service']) + if code == 0 and '--port' in out: + idx = out.find('--port') + port_part = out[idx:].split()[1] + if port_part.isdigit(): + return int(port_part) + return 8888 # 默认 + +def change_panel_port(new_port): + """修改 TPanel 后台端口 + 1. 修改 Nginx 反向代理配置(8888 -> new_port) + 2. 修改 systemd 服务启动端口 + 3. 防火墙开放新端口 + 4. 重启服务生效 + """ + if not str(new_port).isdigit() or int(new_port) < 1000 or int(new_port) > 65535: + return False, '端口号无效(1000-65535)' + new_port = int(new_port) + old_port = get_panel_port() + + if old_port == new_port: + return False, '新端口与当前端口相同' + + # 1. 防火墙开放新端口 + fw = _detect_firewall() + if fw and get_firewall_status()['enabled']: + firewall_open_port(str(new_port)) + + # 2. 修改 Nginx 配置 + nginx_conf = '/etc/nginx/sites-enabled/tpanel.conf' + if os.path.exists(nginx_conf): + code, _, err = _run(['sudo', 'sed', '-i', f's/proxy_pass http:\/\/127.0.0.1:{old_port}/proxy_pass http://127.0.0.1:{new_port}/', nginx_conf]) + if code != 0: + return False, f'修改 Nginx 配置失败: {err}' + # 修改 listen 端口 + _run(['sudo', 'sed', '-i', f's/listen {old_port}/listen {new_port}/', nginx_conf]) + + # 3. 修改 systemd 服务 + service_file = '/etc/systemd/system/tpanel.service' + if os.path.exists(service_file): + code, _, err = _run(['sudo', 'sed', '-i', f's/--port {old_port}/--port {new_port}/', service_file]) + if code != 0: + return False, f'修改 systemd 服务失败: {err}' + + # 4. 重新加载 daemon 并重启服务 + _run(['sudo', 'systemctl', 'daemon-reload']) + code, _, err = _run(['sudo', 'systemctl', 'restart', 'tpanel']) + if code != 0: + return False, f'重启 TPanel 服务失败: {err}' + + # 5. 重启 Nginx + _run(['sudo', 'nginx', '-s', 'reload']) + + # 6. 关闭旧端口防火墙(如果之前开着) + if fw and get_firewall_status()['enabled']: + firewall_close_port(str(old_port)) + + return True, f'端口修改成功!新端口: {new_port},请刷新页面重新访问' + + +# ---------------------- 自动备份 ---------------------- + +def get_backup_settings(): + """获取备份配置状态""" + import sqlite3 + from config import DB_PATH + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT * FROM backup_settings LIMIT 1") + cols = [d[0] for d in cur.description] + row = cur.fetchone() + conn.close() + if row: + return dict(zip(cols, row)) + return {'enabled': 0, 'schedule': 'daily', 'keep_days': 7, 'backup_dir': '/backup'} + +def save_backup_settings(enabled, schedule, keep_days, backup_dir='/backup'): + """保存自动备份配置""" + import sqlite3 + from config import DB_PATH + conn = sqlite3.connect(DB_PATH) + + # 检查表是否存在 + cur = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='backup_settings'") + if not cur.fetchone(): + conn.execute(""" + CREATE TABLE backup_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + enabled INTEGER DEFAULT 0, + schedule TEXT DEFAULT 'daily', + keep_days INTEGER DEFAULT 7, + backup_dir TEXT DEFAULT '/backup', + updated_at TEXT + ) + """) + + conn.execute("DELETE FROM backup_settings") + conn.execute(""" + INSERT INTO backup_settings (enabled, schedule, keep_days, backup_dir, updated_at) + VALUES (?, ?, ?, ?, ?) + """, (1 if enabled else 0, schedule, keep_days, backup_dir, datetime.datetime.now().isoformat())) + conn.commit() + conn.close() + + # 配置 cron 定时任务 + if enabled: + if schedule == 'daily': + cron_expr = '0 3 * * *' # 每天凌晨3点 + elif schedule == 'weekly': + cron_expr = '0 2 * * 0' # 每周日凌晨2点 + else: # hourly + cron_expr = '0 * * * *' # 每小时 + + # 创建备份脚本 + script_content = f'''#!/bin/bash +# TPanel 自动备份脚本 +BACKUP_DIR="{backup_dir}" +KEEP_DAYS={keep_days} +DATE=$(date +%Y%m%d_%H%M%S) + +# 创建备份目录 +mkdir -p $BACKUP_DIR/sites +mkdir -p $BACKUP_DIR/databases + +# 备份所有站点 +for site_user in /opt/tpanel/sites/*/; do + site_name=$(basename "$site_user") + tar -czf "$BACKUP_DIR/sites/${site_name}_${DATE}.tar.gz" -C /opt/tpanel/sites "$site_name" 2>/dev/null +done + +# 备份所有数据库 +for db in $(sudo mysql -e "SHOW DATABASES;" | grep -vE "(Database|information_schema|performance_schema|mysql|sys)"); do + sudo mysqldump "$db" > "$BACKUP_DIR/databases/${db}_${DATE}.sql" 2>/dev/null + gzip -f "$BACKUP_DIR/databases/${db}_${DATE}.sql" +done + +# 清理过期备份 +find "$BACKUP_DIR/sites" -name "*.tar.gz" -mtime +$KEEP_DAYS -delete +find "$BACKUP_DIR/databases" -name "*.sql.gz" -mtime +$KEEP_DAYS -delete + +echo "Backup completed at $(date)" >> /opt/tpanel/logs/backup.log +''' + os.makedirs('/opt/tpanel/scripts', exist_ok=True) + with open('/opt/tpanel/scripts/auto_backup.sh', 'w') as f: + f.write(script_content) + os.chmod('/opt/tpanel/scripts/auto_backup.sh', 0o755) + + # 添加到 crontab + code, out, _ = _run('crontab -l 2>/dev/null || echo ""', shell=True) + lines = [l for l in out.split('\n') if 'auto_backup.sh' not in l and l.strip()] + lines.append(f'{cron_expr} /opt/tpanel/scripts/auto_backup.sh') + _run(f'echo "{chr(10).join(lines)}" | crontab -', shell=True) + + else: + # 禁用:从 crontab 移除 + code, out, _ = _run('crontab -l 2>/dev/null || echo ""', shell=True) + if code == 0: + lines = [l for l in out.split('\n') if 'auto_backup.sh' not in l and l.strip()] + _run(f'echo "{chr(10).join(lines)}" | crontab -', shell=True) + + return True, '备份配置已保存' + +def run_backup_now(): + """立即执行一次手动备份""" + script = '/opt/tpanel/scripts/auto_backup.sh' + if not os.path.exists(script): + return False, '备份脚本不存在,请先配置自动备份' + + code, out, err = _run(['sudo', 'bash', script], timeout=300) + return code == 0, out if code == 0 else err + +def list_backups(): + """列出所有备份文件""" + settings = get_backup_settings() + backup_dir = settings['backup_dir'] + + if not os.path.exists(backup_dir): + return {'sites': [], 'databases': [], 'total_size': '0 MB'} + + sites = [] + sites_dir = os.path.join(backup_dir, 'sites') + if os.path.exists(sites_dir): + for f in sorted(os.listdir(sites_dir), reverse=True): + path = os.path.join(sites_dir, f) + if os.path.isfile(path): + size_mb = round(os.path.getsize(path) / 1024 / 1024, 2) + sites.append({'name': f, 'size': f'{size_mb} MB', 'path': path, 'mtime': datetime.datetime.fromtimestamp(os.path.getmtime(path)).isoformat()}) + + dbs = [] + dbs_dir = os.path.join(backup_dir, 'databases') + if os.path.exists(dbs_dir): + for f in sorted(os.listdir(dbs_dir), reverse=True): + path = os.path.join(dbs_dir, f) + if os.path.isfile(path): + size_mb = round(os.path.getsize(path) / 1024 / 1024, 2) + dbs.append({'name': f, 'size': f'{size_mb} MB', 'path': path, 'mtime': datetime.datetime.fromtimestamp(os.path.getmtime(path)).isoformat()}) + + # 计算总大小 + total_size = sum(float(s['size'].split()[0]) for s in sites + dbs) + + return { + 'sites': sites, + 'databases': dbs, + 'total_size': f'{round(total_size, 2)} MB', + 'backup_dir': backup_dir + } + +def delete_backup(backup_type, filename): + """删除备份文件""" + settings = get_backup_settings() + if backup_type not in ['sites', 'databases']: + return False, '类型无效' + + path = os.path.join(settings['backup_dir'], backup_type, filename) + if not os.path.exists(path): + return False, '文件不存在' + + try: + os.remove(path) + return True, '备份已删除' + except Exception as e: + return False, str(e) + + +# ====================== v1.3.39+ 在线升级功能 ====================== + +def get_server_info(): + """Get server info: IP / hostname / OS / kernel / uptime / public IP / panel version""" + import socket + import platform + + info = { + "internal_ip": "", + "hostname": "", + "os": "", + "kernel": "", + "uptime": "", + "public_ip": "", + "panel_version": "", + } + + # 1. Internal IP (multiple fallbacks) + # 1a. hostname -I (fastest) + code, out, _ = _run(["hostname", "-I"], shell=False) + if code == 0 and out: + info["internal_ip"] = out.strip().split()[0] + # 1b. Fallback: ip route get 1.1.1.1 + if not info["internal_ip"]: + code, out, _ = _run(["bash", "-c", "ip route get 1.1.1.1 2>/dev/null | awk -F'src' '{print $2}' | awk '{print $1}'"], shell=False) + if code == 0 and out: + info["internal_ip"] = out.strip() + # 1c. Fallback: UDP socket + if not info["internal_ip"]: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(2) + s.connect(("1.1.1.1", 80)) + info["internal_ip"] = s.getsockname()[0] + s.close() + except Exception: + pass + + # 2. Hostname + try: + info["hostname"] = socket.gethostname() + except Exception: + pass + + # 3. OS (try /etc/os-release PRETTY_NAME first) + code, out, _ = _run(["bash", "-c", "grep PRETTY_NAME /etc/os-release 2>/dev/null | head -1 | cut -d= -f2 | tr -d '\"'"], shell=False) + if code == 0 and out: + info["os"] = out.strip() + if not info["os"]: + info["os"] = platform.platform() + + # 4. Kernel + info["kernel"] = platform.release() + + # 5. Uptime (human-readable) + code, out, _ = _run(["uptime", "-p"], shell=False) + if code == 0 and out: + info["uptime"] = out.strip() + + # 6. Public IP (ipify, 3s timeout; fall back to ip.cn) + try: + import urllib.request + import json as _json + req = urllib.request.Request("https://api.ipify.org?format=json", headers={"User-Agent": "TPanel/" + get_current_version()}) + with urllib.request.urlopen(req, timeout=3) as resp: + j = _json.loads(resp.read().decode("utf-8")) + info["public_ip"] = j.get("ip", "") + except Exception: + try: + import urllib.request + import json as _json + req = urllib.request.Request("https://ip.cn/api/index?ip=&type=0", headers={"User-Agent": "curl/7"}) + with urllib.request.urlopen(req, timeout=3) as resp: + j = _json.loads(resp.read().decode("utf-8")) + info["public_ip"] = j.get("ip", "") + except Exception: + pass + + # 7. Panel version + info["panel_version"] = get_current_version() + + return info + + +def get_server_info(): + """Get server info: IP / hostname / OS / kernel / uptime / public IP / panel version""" + import socket + import platform + + info = { + "internal_ip": "", + "hostname": "", + "os": "", + "kernel": "", + "uptime": "", + "public_ip": "", + "panel_version": "", + } + + # 1. Internal IP (multiple fallbacks) + # 1a. hostname -I (fastest) + code, out, _ = _run(["hostname", "-I"], shell=False) + if code == 0 and out: + info["internal_ip"] = out.strip().split()[0] + # 1b. Fallback: ip route get 1.1.1.1 + if not info["internal_ip"]: + code, out, _ = _run(["bash", "-c", "ip route get 1.1.1.1 2>/dev/null | awk -F'src' '{print $2}' | awk '{print $1}'"], shell=False) + if code == 0 and out: + info["internal_ip"] = out.strip() + # 1c. Fallback: UDP socket + if not info["internal_ip"]: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(2) + s.connect(("1.1.1.1", 80)) + info["internal_ip"] = s.getsockname()[0] + s.close() + except Exception: + pass + + # 2. Hostname + try: + info["hostname"] = socket.gethostname() + except Exception: + pass + + # 3. OS (try /etc/os-release PRETTY_NAME first) + code, out, _ = _run(["bash", "-c", "grep PRETTY_NAME /etc/os-release 2>/dev/null | head -1 | cut -d= -f2 | tr -d '\"'"], shell=False) + if code == 0 and out: + info["os"] = out.strip() + if not info["os"]: + info["os"] = platform.platform() + + # 4. Kernel + info["kernel"] = platform.release() + + # 5. Uptime (human-readable) + code, out, _ = _run(["uptime", "-p"], shell=False) + if code == 0 and out: + info["uptime"] = out.strip() + + # 6. Public IP (ipify, 3s timeout; fall back to ip.cn) + try: + import urllib.request + import json as _json + req = urllib.request.Request("https://api.ipify.org?format=json", headers={"User-Agent": "TPanel/" + get_current_version()}) + with urllib.request.urlopen(req, timeout=3) as resp: + j = _json.loads(resp.read().decode("utf-8")) + info["public_ip"] = j.get("ip", "") + except Exception: + try: + import urllib.request + import json as _json + req = urllib.request.Request("https://ip.cn/api/index?ip=&type=0", headers={"User-Agent": "curl/7"}) + with urllib.request.urlopen(req, timeout=3) as resp: + j = _json.loads(resp.read().decode("utf-8")) + info["public_ip"] = j.get("ip", "") + except Exception: + pass + + # 7. Panel version + info["panel_version"] = get_current_version() + + return info + + +def get_current_version(): + """获取当前版本号(从前端HTML里提取)""" + index_path = '/opt/tpanel/frontend/index.html' + if not os.path.exists(index_path): + return '1.0.0' + try: + with open(index_path, 'r') as f: + content = f.read() + import re + m = re.search(r'T面板 v([\d.]+)', content) + return m.group(1) if m else '1.0.0' + except Exception: + return '1.0.0' + +def check_latest_version(): + """检测最新版本(GitHub API / 官方CDN)""" + # TODO: 以后有官方域名后换成真实地址,现在先返回本地版本 + # 临时方案:返回当前版本 + 提示功能已就绪 + return { + 'current': get_current_version(), + 'latest': get_current_version(), + 'has_update': False, + 'download_url': '', + 'release_notes': '在线升级功能已就绪,支持手动上传安装包升级' + } + +def backup_current_version(): + """升级前自动备份当前版本""" + import shutil + import datetime + version = get_current_version() + timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') + backup_file = f'/opt/tpanel/backup_v{version}_{timestamp}.zip' + + try: + # 备份 backend 和 frontend 目录 + with zipfile.ZipFile(backup_file, 'w', zipfile.ZIP_DEFLATED) as zf: + for root, dirs, files in os.walk('/opt/tpanel/backend'): + for file in files: + if not file.endswith('.pyc') and '__pycache__' not in root: + full_path = os.path.join(root, file) + arcname = os.path.relpath(full_path, '/opt/tpanel') + zf.write(full_path, arcname) + for root, dirs, files in os.walk('/opt/tpanel/frontend'): + for file in files: + full_path = os.path.join(root, file) + arcname = os.path.relpath(full_path, '/opt/tpanel') + zf.write(full_path, arcname) + return True, backup_file + except Exception as e: + return False, str(e) + +def upgrade_from_zip(zip_path): + """从上传的zip包升级""" + import zipfile + import shutil + + # 1. 先备份当前版本 + ok, backup_file = backup_current_version() + if not ok: + return False, f'备份失败: {backup_file}' + + # 2. 验证zip包 + if not os.path.exists(zip_path): + return False, '安装包不存在' + + try: + with zipfile.ZipFile(zip_path, 'r') as zf: + # 检查必须的目录 + names = zf.namelist() + has_backend = any('backend/' in n for n in names) + has_frontend = any('frontend/' in n for n in names) + if not has_backend or not has_frontend: + return False, '安装包格式错误:缺少 backend 或 frontend 目录' + except Exception as e: + return False, f'安装包损坏: {str(e)}' + + # 3. 解压覆盖 + try: + temp_dir = '/tmp/tpanel_upgrade' + shutil.rmtree(temp_dir, ignore_errors=True) + os.makedirs(temp_dir, exist_ok=True) + + with zipfile.ZipFile(zip_path, 'r') as zf: + zf.extractall(temp_dir) + + # 覆盖文件 + if os.path.exists(f'{temp_dir}/backend'): + _run(['sudo', 'cp', '-rf', f'{temp_dir}/backend/', '/opt/tpanel/']) + if os.path.exists(f'{temp_dir}/frontend'): + _run(['sudo', 'cp', '-rf', f'{temp_dir}/frontend/', '/opt/tpanel/']) + + # 清理临时文件 + shutil.rmtree(temp_dir, ignore_errors=True) + os.remove(zip_path) + + return True, f'升级成功!已备份旧版本到: {backup_file}' + except Exception as e: + return False, f'升级失败: {str(e)}' + +def rollback_version(backup_file): + """回滚到指定备份版本""" + import zipfile + if not os.path.exists(backup_file): + return False, '备份文件不存在' + + try: + temp_dir = '/tmp/tpanel_rollback' + shutil.rmtree(temp_dir, ignore_errors=True) + os.makedirs(temp_dir, exist_ok=True) + + with zipfile.ZipFile(backup_file, 'r') as zf: + zf.extractall(temp_dir) + + _run(['sudo', 'cp', '-rf', f'{temp_dir}/backend/', '/opt/tpanel/']) + _run(['sudo', 'cp', '-rf', f'{temp_dir}/frontend/', '/opt/tpanel/']) + + shutil.rmtree(temp_dir, ignore_errors=True) + return True, '回滚成功' + except Exception as e: + return False, f'回滚失败: {str(e)}' + +def list_backup_versions(): + """列出所有可回滚的版本备份""" + backups = [] + try: + for f in os.listdir('/opt/tpanel'): + if f.startswith('backup_v') and f.endswith('.zip'): + stat = os.stat(f'/opt/tpanel/{f}') + size_mb = round(stat.st_size / 1024 / 1024, 2) + backups.append({ + 'name': f, + 'path': f'/opt/tpanel/{f}', + 'size': f'{size_mb} MB', + 'mtime': datetime.datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S') + }) + return sorted(backups, key=lambda x: x['mtime'], reverse=True) + except Exception: + return [] diff --git a/backend/system.py.v1339bak b/backend/system.py.v1339bak new file mode 100644 index 0000000..4269bb8 --- /dev/null +++ b/backend/system.py.v1339bak @@ -0,0 +1,528 @@ +""" +TPanel - 系统操作模块 +仅使用白名单命令,禁止直接执行用户传入的原始 shell 字符串 +""" +import subprocess +import os +import shutil +import tarfile +import datetime +import time + +def _detect_pkg_manager(): + """检测系统包管理器""" + import shutil + for p in ['apt-get', 'yum', 'dnf']: + if shutil.which(p): + return p + return None + + +def _run(cmd, shell=False, capture=True, timeout=30): + """执行命令,超时保护""" + try: + if isinstance(cmd, str) and not shell: + cmd = cmd.split() + result = subprocess.run( + cmd, + capture_output=capture, + text=True, + timeout=timeout, + shell=shell + ) + return result.returncode, result.stdout.strip(), result.stderr.strip() + except subprocess.TimeoutExpired: + return -1, '', 'Command timed out' + except Exception as e: + return -1, '', str(e) + +def nginx_reload(): + return _run(['sudo', 'nginx', '-t']) + _run(['sudo', 'nginx', '-s', 'reload']) + +def nginx_stop(): + return _run(['sudo', 'nginx', '-s', 'stop']) + +def nginx_start(): + return _run(['sudo', 'nginx']) + +def nginx_status(): + code, out, _ = _run(['ps', 'aux'], capture=True) + running = 'nginx: master' in out + return running + +def mysql_status(): + # Debian 12 默认是 mariadb,CentOS 是 mysql + for svc in ['mariadb', 'mysql']: + code, out, _ = _run(['systemctl', 'is-active', svc], capture=True) + if code == 0: + return True + return False + return out == 'active' + +def create_site_user(username): + """创建 Linux 用户,禁 shell,隔离目录(v1.3.11+ 改用 sudo)""" + # 检查用户是否存在 + code, out, _ = _run(['id', username], capture=True) + if code == 0: + return True, '用户已存在' + + # 创建用户,home 目录即网站根目录,禁 shell + code, out, err = _run( + ['sudo', 'useradd', '-m', '-s', '/usr/sbin/nologin', '-d', f'/home/{username}', username] + ) + if code != 0: + return False, err + return True, '用户创建成功' + +def delete_site_user(username): + code, out, _ = _run(['id', username], capture=True) + if code != 0: + return True, '用户不存在,跳过' + + # 把用户的所有进程 kill 掉再删 + _run(['pkill', '-u', username], capture=True) + code, out, err = _run(['sudo', 'userdel', '-r', username]) + if code != 0: + return False, err + return True, '用户删除成功' + +def set_site_permissions(site_path, site_user): + """设置站点目录权限""" + _run(['sudo', 'chown', '-R', f'{site_user}:{site_user}', site_path]) + _run(['sudo', 'chmod', '-R', '755', site_path]) + _run(['sudo', 'chmod', '-R', '700', site_path + '/storage' if os.path.exists(site_path + '/storage') else site_path]) + +def get_php_fpm_port(php_version): + """ + v1.3.29: PHP 版本 → FPM 端口映射 + - 8.2 继续用 9000(向后兼容老 conf / install.sh 默认配置) + - 其他版本: 90 + 小数点后两位(7.4→9074, 8.0→9080, 8.1→9081, 8.3→9083, 8.4→9084) + - 带小数点的老版本(5.6→9056, 7.0→9070, 7.1→9071, 7.2→9072, 7.3→9073) + - 解析失败的 default: 9000 + """ + pv = (php_version or '').strip() + if pv == '8.2': + return 9000 + try: + parts = pv.split('.') + major = int(parts[0]) + minor = int(parts[1]) if len(parts) > 1 else 0 + return 9000 + major * 10 + minor + except Exception: + return 9000 + + +def write_nginx_config(domain, site_path, php_version='8.1', ssl=False, site_type='php'): + """写入 Nginx 配置 + v1.3.26 新增 site_type 参数: + - 'php'(默认):保留 PHP-FPM 反代 location + - 'static':不写 PHP-FPM 块(纯静态站点,不转发 *.php 到 FPM) + v1.3.29: PHP-FPM 端口随版本变化(多版本并存不冲突) + """ + # PHP-FPM 连接地址(v1.3.6+ 改用 TCP 避免 unix socket 问题,v1.3.29 起按版本分端口) + fpm_port = get_php_fpm_port(php_version) + fpm_sock = f'127.0.0.1:{fpm_port}' + + # index 顺序 + try_files fallback 随类型不同 + if site_type == 'static': + index_line = 'index index.html;' + try_files_line = 'try_files $uri $uri/ =404;' + php_block = '' # 静态站点完全不转发 .php + else: + index_line = 'index index.php index.html;' + try_files_line = 'try_files $uri $uri/ /index.php?$query_string;' + php_block = f''' + location ~ \\.php$ {{ + include fastcgi_params; + fastcgi_pass {fpm_sock}; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + }} +''' + + nginx_conf = f'''# TPanel - {domain} ({site_type}) +server {{ + listen 80; + server_name {domain}; + + root {site_path}; + {index_line} + + access_log /opt/tpanel/logs/{domain}.access.log; + error_log /opt/tpanel/logs/{domain}.error.log; + + # Let's Encrypt SSL 验证(必须放在最前面,不走 PHP) + location /.well-known/acme-challenge/ {{ + alias {site_path}/.well-known/acme-challenge/; + try_files $uri =404; + }} + + location / {{ + {try_files_line} + }} +{php_block} + location ~ /\\.ht {{ + deny all; + }} +}} +''' + if ssl: + nginx_conf = nginx_conf.replace('listen 80;', '''listen 80; + listen 443 ssl http2;''', 1) + + conf_path = f'/etc/nginx/sites-available/{domain}.conf' + # v1.3.15+:tpanel 不可写 /etc/nginx,用 sudo tee(先写 /tmp 临时文件) + tmp_conf = f'/tmp/tpanel_nginx_{domain}.conf' + with open(tmp_conf, 'w') as f: + f.write(nginx_conf) + code, out, err = _run(['sudo', 'mv', tmp_conf, conf_path]) + if code != 0: + return False, f'写 conf 失败: {err}' + + # 启用站点(v1.3.15+:软链在 sites-enabled 也需 sudo) + enabled_path = f'/etc/nginx/sites-enabled/{domain}.conf' + if os.path.exists(enabled_path): + _run(['sudo', 'rm', '-f', enabled_path]) + _run(['sudo', 'ln', '-sf', conf_path, enabled_path]) + + code, out, err = _run(['sudo', 'nginx', '-t']) + if code != 0: + return False, err + + _run(['sudo', 'nginx', '-s', 'reload']) + return True, 'Nginx 配置已更新' + +def remove_nginx_config(domain): + """删除站点 Nginx 配置(v1.3.15+ 用 sudo 删)""" + conf_path = f'/etc/nginx/sites-available/{domain}.conf' + enabled_path = f'/etc/nginx/sites-enabled/{domain}.conf' + + if os.path.exists(enabled_path): + _run(['sudo', 'rm', '-f', enabled_path]) + if os.path.exists(conf_path): + _run(['sudo', 'rm', '-f', conf_path]) + + _run(['sudo', 'nginx', '-s', 'reload']) + +def create_mysql_db(name, db_user, db_pass): + """创建 MySQL 数据库和用户(用 sudo 提权,避免 shell 注入)""" + # 校验 name/user 不含特殊字符(防止 SQL 注入) + import re + if not re.match(r'^[a-zA-Z0-9_]+$', name) or not re.match(r'^[a-zA-Z0-9_]+$', db_user): + return False, '数据库名/用户名只能包含字母数字下划线' + + statements = [ + f"CREATE DATABASE IF NOT EXISTS `{name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;", + f"CREATE USER IF NOT EXISTS '{db_user}'@'localhost' IDENTIFIED BY '{db_pass}';", + f"GRANT ALL PRIVILEGES ON `{name}`.* TO '{db_user}'@'localhost';", + "FLUSH PRIVILEGES;", + ] + for stmt in statements: + code, out, err = _run(['sudo', 'mysql', '-e', stmt], shell=False) + if code != 0: + return False, err + return True, '数据库创建成功' + +def delete_mysql_db(name, db_user): + import re + if not re.match(r'^[a-zA-Z0-9_]+$', name) or not re.match(r'^[a-zA-Z0-9_]+$', db_user): + return False, '数据库名/用户名只能包含字母数字下划线' + statements = [ + f"DROP DATABASE IF EXISTS `{name}`;", + f"DROP USER IF EXISTS '{db_user}'@'localhost';", + "FLUSH PRIVILEGES;", + ] + for stmt in statements: + code, out, err = _run(['sudo', 'mysql', '-e', stmt], shell=False) + if code != 0: + return False, err + return True, '数据库删除成功' + +def get_mysql_size(): + """获取 MySQL 数据目录大小(MB)""" + code, out, _ = _run("du -sm /var/lib/mysql 2>/dev/null || echo 0", shell=True) + try: + return int(out.split()[0]) + except: + return 0 + +def backup_site(site_path, site_name, db_name=None, db_user=None, db_pass=None): + """备份站点文件和数据库""" + import traceback + timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') + # 清理站点名:ygbk.cn → ygbk.cn(保留点) + safe_name = site_name.replace('/', '_') + backup_name = f'{safe_name}_{timestamp}' + backup_path = f'/opt/tpanel/backups/{backup_name}.tar.gz' + + # v1.3.10 修复:预检环境 + try: + os.makedirs('/opt/tpanel/backups', exist_ok=True) + except Exception as e: + return False, f'无法创建 backups 目录: {e}', 0 + if not os.path.isdir(site_path): + return False, f'站点目录不存在: {site_path}', 0 + if not os.access(site_path, os.R_OK): + return False, f'tpanel 用户无法读取 {site_path}(chown 错了?ls -ld {site_path} 看看)', 0 + + try: + # 备份文件 + with tarfile.open(backup_path, 'w:gz') as tar: + tar.add(site_path, arcname=os.path.basename(site_path)) + + # 备份数据库(v1.3.10 修复:用 list 参数防注入 + sudo) + if db_name: + dump_path = f'/opt/tpanel/backups/{backup_name}_db.sql.gz' + try: + if db_user and db_pass: + code, out, err = _run( + ['sudo', 'mysqldump', '-u', db_user, f'-p{db_pass}', db_name], + shell=False, timeout=120 + ) + else: + code, out, err = _run(['sudo', 'mysqldump', db_name], shell=False, timeout=120) + if code == 0 and out: + import gzip + with open(dump_path, 'wb') as df: + df.write(gzip.compress(out.encode('utf-8') if isinstance(out, str) else out)) + with tarfile.open(backup_path, 'a:gz') as tar: + tar.add(dump_path, arcname='database.sql.gz') + os.remove(dump_path) + except Exception as e: + # 数据库备份失败不阻断(文件备份可能成功) + pass + + size = os.path.getsize(backup_path) + return True, backup_path, size + except PermissionError as e: + return False, f'权限错误: {e}(tpanel 读不到 {site_path},请 chown)', 0 + except Exception as e: + return False, f'备份异常: {type(e).__name__}: {e}\n{traceback.format_exc()[-300:]}', 0 + +def restore_backup(backup_path, site_path, site_name): + """恢复备份""" + try: + # v1.3.17+:先 sudo 删干净 site_path(因为可能有 root 拥有的文件,tpanel 删不掉) + # 用 sudo 替换为临时空目录,然后再解压 + backup_site_path = site_path + if os.path.exists(backup_site_path): + # 移动到 .bak 路径(sudo 移) + bak_path = backup_site_path + '.bak.' + str(int(time.time())) + code, _, err = _run(['sudo', 'mv', backup_site_path, bak_path]) + if code != 0: + return False, f'备份旧目录失败: {err}' + + # 解压到临时目录 + temp_dir = f'/opt/tpanel/backups/temp_{site_name}' + os.makedirs(temp_dir, exist_ok=True) + with tarfile.open(backup_path, 'r:gz') as tar: + tar.extractall(temp_dir) + + # 找到网站目录内容 + items = os.listdir(temp_dir) + src_dir = os.path.join(temp_dir, items[0]) if items else temp_dir + + # 把整个 src 目录 sudo mv 到 site_path + code, _, err = _run(['sudo', 'mv', src_dir, backup_site_path]) + if code != 0: + return False, f'恢复目录失败: {err}' + + # v1.3.17+:从 site_path 反推 site_user + # /opt/tpanel/sites/zhangpu_tech/public → zhangpu_tech + path_parts = backup_site_path.rstrip('/').split('/') + site_user = path_parts[-1] if path_parts else site_name + _run(['sudo', 'chown', '-R', f'{site_user}:{site_user}', backup_site_path]) + _run(['sudo', 'chmod', '-R', '755', backup_site_path]) + + shutil.rmtree(temp_dir, ignore_errors=True) + return True, '恢复成功' + except Exception as e: + return False, str(e) + +def run_security_update(): + """执行系统安全更新""" + code, out, err = _run(['sudo', 'apt-get', 'update'], timeout=120) + if code != 0: + return False, err + + # v1.3.20+:apt-get upgrade 也加 sudo(不然 Permission denied dpkg lock) + code, out, err = _run( + ['sudo', 'apt-get', 'upgrade', '-y', '--only-upgrade'], + timeout=300 + ) + if code == 0: + return True, f'安全更新完成' + else: + return False, err + +def get_security_status(): + """获取安全状态""" + # 可升级的安全包数量 + code, out, _ = _run( + "apt list --upgradable 2>/dev/null | grep -c security || echo 0", + shell=True + ) + try: + updatable = int(out.strip()) + except: + updatable = 0 + + # 最近的安全日志条数 + code2, out2, _ = _run( + "journalctl --since '1 day ago' --priority=err 2>/dev/null | wc -l", + shell=True + ) + try: + errors = int(out2.strip()) + except: + errors = 0 + + return {'upgradable_security_packages': updatable, 'recent_errors': errors} + +def get_system_stats(): + """获取系统状态""" + code, cpu_out, _ = _run("cat /proc/loadavg | awk '{print $1,$2,$3}'", shell=True) + code, mem_out, _ = _run("free -m | awk 'NR==2{print $3,$2}'", shell=True) + code, disk_out, _ = _run("df -h / | tail -1 | awk '{print $3,$4}'", shell=True) + code, cpu_pct, _ = _run("top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | sed 's/%us,//'", shell=True) + + # v1.3.10+ 新增:CPU 核心数 + 型号(用于仪表盘显示 + 负载颜色按核心数判断) + # v1.3.35 修复:容器/Docker 里 lscpu 无 "Model name" 行会导致 Unknown CPU + import os as _os + cpu_cores = _os.cpu_count() or 1 + cpu_model = '' + # 1. 优先 lscpu "Model name"(KVM/Xen 等虚拟化都正常) + code, lscpu_out, _ = _run("lscpu | grep 'Model name' | head -1", shell=True) + if code == 0 and lscpu_out and ':' in lscpu_out: + cpu_model = lscpu_out.split(':', 1)[1].strip() + # 2. 兑底:/proc/cpuinfo 的 model name(v1.3.35 修复:必传 shell=True) + if not cpu_model: + code, cpuinfo_out, _ = _run("grep -m1 'model name' /proc/cpuinfo", shell=True) + if code == 0 and cpuinfo_out and ':' in cpuinfo_out: + cpu_model = cpuinfo_out.split(':', 1)[1].strip() + # 3. 兑底:/proc/cpuinfo 拼 vendor + family + model(容器里 lscpu 可能无 Model name) + if not cpu_model: + try: + with open('/proc/cpuinfo', 'r') as f: + ci = f.read() + vendor = family = model_name = '' + for line in ci.splitlines(): + if line.startswith('vendor_id') and ':' in line and not vendor: + vendor = line.split(':', 1)[1].strip() + elif line.startswith('cpu family') and ':' in line and not family: + family = line.split(':', 1)[1].strip() + elif line.startswith('model name') and ':' in line and not model_name: + model_name = line.split(':', 1)[1].strip() + if model_name: break + if model_name: + cpu_model = model_name + elif vendor: + cpu_model = f'{vendor} CPU' + if family: cpu_model += f' (family {family})' + except Exception: + pass + # 4. 兑底:platform.processor()(老 Python 偶尔能拿到) + if not cpu_model: + try: + import platform + cpu_model = platform.processor() or '' + except Exception: + pass + # 5. 兑底:lscpu 看 Vendor ID + Model(某些云主机会输出这个) + if not cpu_model: + code, lscpu_v, _ = _run("lscpu | grep -E 'Vendor ID|Model:' | head -2", shell=True) + if code == 0 and lscpu_v: + parts = [] + for line in lscpu_v.strip().splitlines(): + if ':' in line: + parts.append(line.split(':', 1)[1].strip()) + if parts: + cpu_model = ' '.join(parts) + ' CPU' + if not cpu_model: + cpu_model = 'Unknown CPU' + + nginx_running = nginx_status() + mysql_running = mysql_status() + + return { + 'load': cpu_out, + 'cpu_pct': cpu_pct.strip() + '%' if cpu_pct else 'N/A', + 'cpu_cores': cpu_cores, + 'cpu_model': cpu_model, + 'mem_used_mb': mem_out.split()[0] if mem_out else '0', + 'mem_total_mb': mem_out.split()[1] if mem_out else '0', + 'disk_used': disk_out.split()[0] if disk_out else '0', + 'disk_free': disk_out.split()[1] if disk_out else '0', + 'nginx_running': nginx_running, + 'mysql_running': mysql_running, + } + +def write_log(event_type, details, ip=''): + """写安全日志""" + import sqlite3 + from config import DB_PATH + conn = sqlite3.connect(DB_PATH) + conn.execute("INSERT INTO security_logs (event_type, details, ip) VALUES (?, ?, ?)", + (event_type, details, ip)) + conn.commit() + conn.close() + + +def setup_php_fpm_listen(php_version): + """ + v1.3.29: 装完 PHP 后调用——设置 FPM listen 端口为版本专属端口,并启动服务 + - 写 /etc/php//fpm/pool.d/www.conf(备份原文件为 .bak) + - sudo systemctl enable --now php-fpm + 返回: (ok, msg) + """ + port = get_php_fpm_port(php_version) + www_conf = f'/etc/php/{php_version}/fpm/pool.d/www.conf' + if not os.path.exists(www_conf): + return False, f'找不到 {www_conf}(该版本未安装?)' + + # 备份(幂等:不重复备份) + bak = www_conf + '.tpanel.bak' + if not os.path.exists(bak): + code, _, err = _run(['sudo', 'cp', www_conf, bak]) + if code != 0: + return False, f'备份 {www_conf} 失败: {err}' + + # 修改 listen 行(用 sed 精准替换) + code, _, err = _run(['sudo', 'bash', '-c', + f"sed -i 's|^listen = .*|listen = 127.0.0.1:{port}|' {www_conf}"]) + if code != 0: + return False, f'修改 listen 失败: {err}' + + # 启用 + 启动 + code, _, err = _run(['sudo', 'systemctl', 'enable', f'php{php_version}-fpm']) + if code != 0: + return False, f'enable php{php_version}-fpm 失败: {err}' + + code, out, err = _run(['sudo', 'systemctl', 'restart', f'php{php_version}-fpm']) + if code != 0: + return False, f'restart php{php_version}-fpm 失败: {err}' + + # 验证在监听 + code, out, _ = _run(['sudo', 'ss', '-lntp']) + listening = f'127.0.0.1:{port}' in out + if not listening: + return False, f'php{php_version}-fpm 未在 127.0.0.1:{port} 监听(可能启动失败)' + + return True, f'php{php_version}-fpm 已配置 listen 127.0.0.1:{port} 并启动' + +def change_db_password(db_user, new_pass): + """修改 MySQL 数据库用户密码(v1.3.34+)""" + import re + if not re.match(r"^[a-zA-Z0-9_]+$", db_user): + return False, "用户名只能包含字母数字下划线" + if not new_pass or len(new_pass) < 6: + return False, "密码至少 6 位" + escaped_pass = new_pass.replace("'", "''") + stmt = "ALTER USER '" + db_user + "'@'localhost' IDENTIFIED BY '" + escaped_pass + "';" + code, out, err = _run(["sudo", "mysql", "-e", stmt], shell=False) + if code != 0: + return False, err + code, _, err = _run(["sudo", "mysql", "-e", "FLUSH PRIVILEGES;"], shell=False) + if code != 0: + return False, err + return True, "密码修改成功" diff --git a/backend/task_manager.py b/backend/task_manager.py new file mode 100644 index 0000000..8331403 --- /dev/null +++ b/backend/task_manager.py @@ -0,0 +1,431 @@ +""" +TPanel - 任务管理器 +用于软件安装、安全更新等长任务的执行 + 实时进度推送 +""" +import sqlite3 +import subprocess +import threading +import time +import os +import json +import re +import shutil +from datetime import datetime +from config import DB_PATH + + +def _detect_pkg_manager(): + """检测系统包管理器(apt/yum/dnf)""" + for p in ['apt-get', 'yum', 'dnf']: + if shutil.which(p): + return p + return None + + +def get_apt_cmd(): + """获取系统包管理器 + sudo""" + pkg = _detect_pkg_manager() + if pkg == 'apt-get': + return ['sudo', 'apt-get', '-y'] + elif pkg == 'yum': + return ['sudo', 'yum', '-y'] + elif pkg == 'dnf': + return ['sudo', 'dnf', '-y'] + else: + raise Exception('不支持的包管理器') + + + + + +def _short_version(v): + '''把 debian '7.0.33-89+0~20260514.116+debian12~1.gbpfef6bb' 短化成 '7.0.33' + - 剥 epoch (4:) + - 取 主版本号 (数字.数字.数字) + - 失败返回原值 + ''' + if not v: + return None + v = re.sub(r"^\d+:", "", v) + m = re.match(r"(\d+\.\d+\.\d+)", v) + return m.group(1) if m else v + +def init_software_table(): + """初始化软件列表(幂等)""" + pkg = _detect_pkg_manager() + is_deb = pkg == 'apt-get' + + # 软件白名单:name / 显示名 / 分类 / apt 包名(多个用逗号) + catalog = [ + ('php5.6', 'PHP 5.6', 'PHP', + 'php5.6-fpm,php5.6-cli,php5.6-mysql,php5.6-curl,php5.6-mbstring,php5.6-xml,php5.6-zip,php5.6-gd' + if is_deb else 'php56-php-fpm,php56-php-cli,php56-php-mysqlnd'), + ('php7.0', 'PHP 7.0', 'PHP', + 'php7.0-fpm,php7.0-cli,php7.0-mysql,php7.0-curl,php7.0-mbstring,php7.0-xml,php7.0-zip,php7.0-gd' + if is_deb else 'php70-php-fpm,php70-php-cli,php70-php-mysqlnd'), + ('php7.4', 'PHP 7.4', 'PHP', + 'php7.4-fpm,php7.4-cli,php7.4-mysql,php7.4-curl,php7.4-mbstring,php7.4-xml,php7.4-zip,php7.4-gd' + if is_deb else 'php74-php-fpm,php74-php-cli,php74-php-mysqlnd'), + ('php8.0', 'PHP 8.0', 'PHP', + 'php8.0-fpm,php8.0-cli,php8.0-mysql,php8.0-curl,php8.0-mbstring,php8.0-xml,php8.0-zip,php8.0-gd' + if is_deb else 'php80-php-fpm,php80-php-cli,php80-php-mysqlnd'), + ('php8.1', 'PHP 8.1', 'PHP', + 'php8.1-fpm,php8.1-cli,php8.1-mysql,php8.1-curl,php8.1-mbstring,php8.1-xml,php8.1-zip,php8.1-gd' + if is_deb else 'php81-php-fpm,php81-php-cli,php81-php-mysqlnd'), + ('php8.2', 'PHP 8.2', 'PHP', + 'php8.2-fpm,php8.2-cli,php8.2-mysql,php8.2-curl,php8.2-mbstring,php8.2-xml,php8.2-zip,php8.2-gd' + if is_deb else 'php82-php-fpm,php82-php-cli,php82-php-mysqlnd'), + ('php8.3', 'PHP 8.3', 'PHP', + 'php8.3-fpm,php8.3-cli,php8.3-mysql,php8.3-curl,php8.3-mbstring,php8.3-xml,php8.3-zip,php8.3-gd' + if is_deb else 'php83-php-fpm,php83-php-cli,php83-php-mysqlnd'), + # v1.3.29: 补上 PHP 8.4(Sury 源已支持) + ('php8.4', 'PHP 8.4', 'PHP', + 'php8.4-fpm,php8.4-cli,php8.4-mysql,php8.4-curl,php8.4-mbstring,php8.4-xml,php8.4-zip,php8.4-gd' + if is_deb else 'php84-php-fpm,php84-php-cli,php84-php-mysqlnd'), + ('phpmyadmin', 'phpMyAdmin', '数据库', 'phpmyadmin' if is_deb else 'phpMyAdmin'), + ] + + conn = sqlite3.connect(DB_PATH) + for name, display, cat, pkgs in catalog: + # 探测实际安装状态 + installed = 0 + version = None + first_pkg = pkgs.split(',')[0].split('/')[0] + if is_deb: + # v1.3.40.1: 加 timeout 防卡死(v1.3.38 计划中的保护,此处补齐) + try: + r = subprocess.run(['dpkg', '-s', first_pkg], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=3).returncode + except subprocess.TimeoutExpired: + r = 1 # 超时算未装,不阻塞列表 + if r == 0: + installed = 1 + # 拿版本 + try: + v = subprocess.check_output( + ['dpkg-query', '-f=${Version}', '-W', first_pkg], + stderr=subprocess.DEVNULL, timeout=5 + ).decode().strip() + version = _short_version(v) if v else None + except Exception: + pass + else: + r = os.system(f'rpm -q {first_pkg} >/dev/null 2>&1') + if r == 0: + installed = 1 + try: + v = subprocess.check_output( + ['rpm', '-q', '--queryformat', '%{VERSION}', first_pkg], + stderr=subprocess.DEVNULL, timeout=5 + ).decode().strip() + version = _short_version(v) if v else None + except Exception: + pass + + # 已有则更新状态(不覆盖显示名等) + row = conn.execute("SELECT name FROM software WHERE name = ?", (name,)).fetchone() + if row: + conn.execute("""UPDATE software SET installed = ?, version = ?, last_check = ? + WHERE name = ?""", + (installed, version, datetime.now().isoformat(), name)) + else: + conn.execute("""INSERT INTO software (name, display_name, category, installed, version, last_check) + VALUES (?, ?, ?, ?, ?, ?)""", + (name, display, cat, installed, version, datetime.now().isoformat())) + conn.commit() + conn.close() + + +def list_software(force_refresh=False): + """列出所有软件 + 状态(v1.3.40.1 修复 force_refresh 参数未定义)""" + # 注:force_refresh 参数当前未使用(保留接口),避免 TypeError 500 + init_software_table() + conn = sqlite3.connect(DB_PATH) + rows = conn.execute("""SELECT name, display_name, category, installed, version, last_install + FROM software ORDER BY category, name""").fetchall() + conn.close() + return [{ + 'name': r[0], 'display_name': r[1], 'category': r[2], + 'installed': bool(r[3]), 'version': r[4], 'last_install': r[5] + } for r in rows] + + +def get_software(name): + """获取单个软件信息""" + conn = sqlite3.connect(DB_PATH) + row = conn.execute("""SELECT name, display_name, category, installed, version, last_install + FROM software WHERE name = ?""", (name,)).fetchone() + conn.close() + if not row: + return None + return { + 'name': row[0], 'display_name': row[1], 'category': row[2], + 'installed': bool(row[3]), 'version': row[4], 'last_install': row[5] + } + + +def get_apt_packages(name): + """从软件名反查 apt 包列表""" + init_software_table() + conn = sqlite3.connect(DB_PATH) + row = conn.execute("SELECT name FROM software WHERE name = ?", (name,)).fetchone() + conn.close() + if not row: + return None + # 直接从 catalog 重算(不存包名到 DB,因为跨系统不一样) + pkg = _detect_pkg_manager() + is_deb = pkg == 'apt-get' + catalog = { + 'php5.6': 'php5.6-fpm,php5.6-cli,php5.6-mysql,php5.6-curl,php5.6-mbstring,php5.6-xml,php5.6-zip,php5.6-gd' if is_deb else 'php56-php-fpm,php56-php-cli', + 'php7.0': 'php7.0-fpm,php7.0-cli,php7.0-mysql,php7.0-curl,php7.0-mbstring,php7.0-xml,php7.0-zip,php7.0-gd' if is_deb else 'php70-php-fpm,php70-php-cli', + 'php7.4': 'php7.4-fpm,php7.4-cli,php7.4-mysql,php7.4-curl,php7.4-mbstring,php7.4-xml,php7.4-zip,php7.4-gd' if is_deb else 'php74-php-fpm,php74-php-cli', + 'php8.0': 'php8.0-fpm,php8.0-cli,php8.0-mysql,php8.0-curl,php8.0-mbstring,php8.0-xml,php8.0-zip,php8.0-gd' if is_deb else 'php80-php-fpm,php80-php-cli', + 'php8.1': 'php8.1-fpm,php8.1-cli,php8.1-mysql,php8.1-curl,php8.1-mbstring,php8.1-xml,php8.1-zip,php8.1-gd' if is_deb else 'php81-php-fpm,php81-php-cli', + 'php8.2': 'php8.2-fpm,php8.2-cli,php8.2-mysql,php8.2-curl,php8.2-mbstring,php8.2-xml,php8.2-zip,php8.2-gd' if is_deb else 'php82-php-fpm,php82-php-cli', + 'php8.3': 'php8.3-fpm,php8.3-cli,php8.3-mysql,php8.3-curl,php8.3-mbstring,php8.3-xml,php8.3-zip,php8.3-gd' if is_deb else 'php83-php-fpm,php83-php-cli', + 'php8.4': 'php8.4-fpm,php8.4-cli,php8.4-mysql,php8.4-curl,php8.4-mbstring,php8.4-xml,php8.4-zip,php8.4-gd' if is_deb else 'php84-php-fpm,php84-php-cli', + 'phpmyadmin': 'phpmyadmin' if is_deb else 'phpMyAdmin', + } + return catalog.get(name) + + +def setup_phpmyadmin_nginx(task_id=None): + """phpMyAdmin 装完后自动配置 Nginx 8443 反代(v1.3.10 新增) + + 写 /etc/nginx/sites-enabled/phpmyadmin.conf + nginx -t + reload + 失败时把错误追加到任务日志(如果有 task_id) + """ + # 1. 找 phpMyAdmin 实际路径(Debian/Ubuntu 装完默认在这里) + candidates = ['/usr/share/phpmyadmin', '/usr/share/phpmyadmin/htdocs'] + pma_dir = None + for c in candidates: + if os.path.isdir(c) and os.path.exists(os.path.join(c, 'index.php')): + pma_dir = c + break + if not pma_dir: + msg = 'setup_phpmyadmin_nginx: 找不到 phpMyAdmin 目录(/usr/share/phpmyadmin 不存在)' + print(f'[TPanel] {msg}', flush=True) + if task_id: + conn = sqlite3.connect(DB_PATH) + conn.execute("UPDATE tasks SET log = log || ? WHERE id = ?", + (f'\n\n{msg}', task_id)) + conn.commit() + conn.close() + return False + + # 2. 写 Nginx 配置文件 + conf = f"""# TPanel phpMyAdmin 反代配置(v1.3.10 自动写入) +# 管理命令:sudo nginx -t && sudo systemctl reload nginx +server {{ + listen 8443 default_server; + listen [::]:8443 default_server; + server_name _; + + root {pma_dir}; + index index.php index.html; + + access_log /var/log/nginx/phpmyadmin.access.log; + error_log /var/log/nginx/phpmyadmin.error.log; + + # 安全加固:屏蔽 phpMyAdmin 已知信息泄露路径 + location ~* /(libraries|setup/frames|sql) {{ + deny all; + return 403; + }} + + location / {{ + try_files $uri $uri/ /index.php?$args; + }} + + location ~ \.php$ {{ + include fastcgi_params; + fastcgi_pass 127.0.0.1:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_read_timeout 300; + }} +}} +""" + conf_path = '/etc/nginx/sites-enabled/phpmyadmin.conf' + try: + # 写文件用 sudo(tpanel 用户没权限写 /etc/nginx) + with open('/tmp/phpmyadmin.conf.tmp', 'w') as f: + f.write(conf) + r = subprocess.run(['sudo', 'mv', '/tmp/phpmyadmin.conf.tmp', conf_path], + capture_output=True, text=True, timeout=10) + if r.returncode != 0: + raise Exception(f'sudo mv 失败: {r.stderr.strip()}') + except Exception as e: + msg = f'setup_phpmyadmin_nginx: 写 {conf_path} 失败: {e}' + print(f'[TPanel] {msg}', flush=True) + if task_id: + conn = sqlite3.connect(DB_PATH) + conn.execute("UPDATE tasks SET log = log || ? WHERE id = ?", + (f'\n\n{msg}', task_id)) + conn.commit() + conn.close() + return False + + # 3. nginx -t 验证 + r = subprocess.run(['sudo', 'nginx', '-t'], capture_output=True, text=True, timeout=10) + if r.returncode != 0: + msg = f'setup_phpmyadmin_nginx: nginx -t 失败:\n{r.stderr.strip()}' + print(f'[TPanel] {msg}', flush=True) + if task_id: + conn = sqlite3.connect(DB_PATH) + conn.execute("UPDATE tasks SET log = log || ? WHERE id = ?", + (f'\n\n{msg}', task_id)) + conn.commit() + conn.close() + return False + + # 4. reload nginx + r = subprocess.run(['sudo', 'systemctl', 'reload', 'nginx'], + capture_output=True, text=True, timeout=10) + if r.returncode != 0: + # reload 失败就 try restart + r2 = subprocess.run(['sudo', 'systemctl', 'restart', 'nginx'], + capture_output=True, text=True, timeout=10) + if r2.returncode != 0: + msg = f'setup_phpmyadmin_nginx: nginx reload/restart 失败: {r2.stderr.strip()}' + print(f'[TPanel] {msg}', flush=True) + if task_id: + conn = sqlite3.connect(DB_PATH) + conn.execute("UPDATE tasks SET log = log || ? WHERE id = ?", + (f'\n\n{msg}', task_id)) + conn.commit() + conn.close() + return False + + # 5. 确认 8443 端口没被占 + r = subprocess.run(['sudo', 'ss', '-tlnp'], capture_output=True, text=True, timeout=5) + if ':8443' not in r.stdout: + msg = 'setup_phpmyadmin_nginx: 警告 - 8443 端口没在监听' + print(f'[TPanel] {msg}', flush=True) + # 不算失败,配置已写入 + + success_msg = f'setup_phpmyadmin_nginx: 成功 - {conf_path} 已写入,nginx 已 reload' + print(f'[TPanel] {success_msg}', flush=True) + if task_id: + conn = sqlite3.connect(DB_PATH) + conn.execute("UPDATE tasks SET log = log || ? WHERE id = ?", + (f'\n\n{success_msg}', task_id)) + conn.commit() + conn.close() + return True + + +def create_task(task_type, target, cmd, on_complete=None): + """创建任务 + 启动后台进程 + + on_complete(v1.3.10 新增):可选回调函数,签名 on_complete(task_id, status) + 在任务结束(success/failed)后、software 表更新后调用。 + 用于实现"装完 X 自动配 Y"这种联动。 + """ + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("INSERT INTO tasks (type, target, status) VALUES (?, ?, 'running')", + (task_type, target)) + task_id = cur.lastrowid + conn.commit() + conn.close() + + def _run(): + try: + proc = subprocess.Popen( + cmd, shell=False, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, bufsize=1 + ) + log_buffer = [] + for line in iter(proc.stdout.readline, ''): + line = line.rstrip() + log_buffer.append(line) + # 写最新 200 行到 DB + conn = sqlite3.connect(DB_PATH) + conn.execute("UPDATE tasks SET log = ? WHERE id = ?", + ('\n'.join(log_buffer[-200:]), task_id)) + conn.commit() + conn.close() + proc.wait() + status = 'success' if proc.returncode == 0 else 'failed' + except Exception as e: + status = 'failed' + conn = sqlite3.connect(DB_PATH) + conn.execute("UPDATE tasks SET log = log || ? WHERE id = ?", + (f'\n\nERROR: {e}', task_id)) + conn.commit() + conn.close() + # on_complete 也要在异常路径上调用(status='failed') + if on_complete: + try: + on_complete(task_id, 'failed') + except Exception as e2: + print(f'[TPanel] on_complete 异常: {e2}', flush=True) + return + + conn = sqlite3.connect(DB_PATH) + conn.execute("UPDATE tasks SET status = ?, exit_code = ?, finished_at = ? WHERE id = ?", + (status, proc.returncode, datetime.now().isoformat(), task_id)) + conn.commit() + conn.close() + # 安装成功:更新 software 表 + if status == 'success' and task_type == 'software_install': + conn = sqlite3.connect(DB_PATH) + conn.execute("UPDATE software SET installed = 1, last_install = ? WHERE name = ?", + (datetime.now().isoformat(), target)) + conn.commit() + conn.close() + + # on_complete 钩子(v1.3.10):success/failed 后都调,让钩子自己判断 + if on_complete: + try: + on_complete(task_id, status) + except Exception as e: + conn = sqlite3.connect(DB_PATH) + conn.execute("UPDATE tasks SET log = log || ? WHERE id = ?", + (f'\n\non_complete 异常: {e}', task_id)) + conn.commit() + conn.close() + + t = threading.Thread(target=_run, daemon=True) + t.start() + return task_id + + +def get_task(task_id): + """获取任务状态 + 日志""" + conn = sqlite3.connect(DB_PATH) + row = conn.execute("""SELECT id, type, target, status, log, started_at, finished_at, exit_code + FROM tasks WHERE id = ?""", (task_id,)).fetchone() + conn.close() + if not row: + return None + return { + 'id': row[0], 'type': row[1], 'target': row[2], 'status': row[3], + 'log': row[4] or '', 'started_at': row[5], 'finished_at': row[6], + 'exit_code': row[7] + } + + +def get_running_task_by_type(task_type, target=None): + """获取正在运行的同类型任务(防并发)""" + conn = sqlite3.connect(DB_PATH) + if target is not None: + row = conn.execute("""SELECT id FROM tasks + WHERE type = ? AND target = ? AND status = 'running'""", + (task_type, target)).fetchone() + else: + row = conn.execute("""SELECT id FROM tasks + WHERE type = ? AND status = 'running'""", + (task_type,)).fetchone() + conn.close() + return row[0] if row else None + + +def cleanup_old_tasks(days=7): + """清理 N 天前的已完成任务""" + conn = sqlite3.connect(DB_PATH) + conn.execute("""DELETE FROM tasks + WHERE status != 'running' + AND finished_at < datetime('now', ?)""", + (f'-{days} days',)) + conn.commit() + conn.close() diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..3eaccb5 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,3668 @@ + + + + + +T面板 - Linux 网站管理面板 + + + + + + + + + + +
+ + + + + + + + +
+
+
+ +

仪表盘

+
+
+
+
+ 系统正常 +
+ 官方网站 + + +
+
+ +
+ + +
+
+
+
📁 网站总数
+
-
+
个站点
+
+
+
🗄️ 数据库
+
-
+
个数据库
+
+
+
💾 备份状态
+
-
+
份备份
+
+
+
🔐 安全更新
+
-
+
个待安装
+
+
+ +
+
🛡️
+
+
安全状态:已启用自动更新
+
系统每日 03:00 自动执行安全更新
+
+ +
+ +
+
+
+ ⚡ 服务状态 +
+
+
+
+ 🌿 +
+
Nginx
+
Web 服务器
+
+
+ 运行中 +
+
+
+ 🐬 +
+
MySQL
+
数据库服务
+
+
+ 运行中 +
+
+
+ 🐘 +
+
PHP-FPM
+
PHP 解释器
+
+
+ 运行中 +
+
+
+ 🛡️ +
+
UFW 防火墙
+
端口防护
+
+
+ 已启用 +
+
+
+ +
+
+ 💻 系统资源 +
+
+
+
+
CPU 负载
+
+ - +
+
+
+
CPU 核心数
+
+ - +
+
+
+
CPU 型号
+
+ - +
+
+
+
内存
+
+ - +
+
+
+
磁盘
+
+ - +
+
+
+
负载均值
+
+ - +
+
+
+ +
+
+ 🌐 服务器信息 + +
+
+
+
+
🌍 公网 IP
+
+ 加载中... +
+
+
+
🏠 内网 IP
+
+ 加载中... +
+
+
+
🖥️ 主机名
+
+ 加载中... +
+
+
+
💿 操作系统
+
+ 加载中... +
+
+
+
⚙️ 内核版本
+
+ 加载中... +
+
+
+
⏱️ 运行时间
+
+ 加载中... +
+
+
+
📦 面板版本
+
+ 加载中... +
+
+
+
+
+ + +
+
+
+

网站列表

+ +
+
+ + + + + + + + + + + + + +
域名PHP路径状态SSL创建时间操作
+
+
+ + +
+
+
+

数据库

+ +
+
+ + + + + + + + + + + + +
数据库名用户密码字符集创建时间操作
+
+
+ + +
+
+
+

SSL 证书

+ +
+
+ + + + + + + + + + + +
域名证书路径到期日期自动续期操作
+
+
+ + +
+
+
+

备份记录

+ +
+
+ + + + + + + + + + + + +
站点类型文件大小状态时间操作
+
+
+ + +
+
+
+

安全中心

+ +
+
+
+
🛡️ 安全更新
+
-
+
个可用更新
+
+
+
❌ 近期错误
+
-
+
条系统错误
+
+
+
🔒 防火墙
+
-
+
状态
+
+
+
+
+ 🛡️ 自动漏洞修复 + +
+
+

T面板每日凌晨 03:00 自动执行 apt-get update && apt-get upgrade -y,修复已知安全漏洞。

+

最近执行:-

+
+
+
+ + + +
+
+
+

📁 文件管理

+
+ + + + + +
+
+ + + + + +
+ / +
+ + +
+ + + + + + + + + + + +
名称大小修改时间权限操作
+
+ + + + + + + + + + + + + + + +
+ + + +
+
+
+

⏰ 定时任务

+ +
+
+ + + + + + + + + + + + + +
任务名称站点执行周期命令状态上次执行操作
+
+ + + +
+ + +
+
+
+

📦 软件市场

+
+ + +
+
+

+ 一键安装建站必备环境。安装过程中会显示实时进度。 +

+
+
加载中...
+
+
+ + +
+
+ + +
+

📦 版本信息

+
+
+
当前版本
+
--
+
+
+
最新版本
+
--
+
+
+
状态
+
--
+
+
+
+
更新说明
+
--
+
+
+ + +
+

⬆️ 手动升级

+
+
📦
+
拖拽安装包到这里,或点击选择文件
+
支持 tpanel-v*.zip 格式安装包
+ + +
+ +
+ + +
+

⏪ 版本回滚

+
升级前会自动备份当前版本,可以随时回滚
+
+
暂无备份
+
+
+
+ + +
+
+ +
+

👤 修改管理员密码

+
+
+ + +
+
+ + +
+
+ +
+ +
+

⚙️ 系统设置

+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ +
+

🔗 面板域名绑定

+
+ + + + 绑定后只能通过该域名访问后台(如留空则不限制访问来源)。绑定后请确保 DNS 已解析到此服务器。 + +
+
+ +
+ ⚠️ 修改域名绑定后需重启面板服务:systemctl restart tpanel +
+
+ +
+

🌐 官方信息

+
+
+ + +
+
+ + +
+
+
+
+ + +
+

📋 安全日志

+
+
+
+
+ +
+
+ +
+

🖥️ Web 终端

+
+
+ + + 未连接 +
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/index_v13401.html b/frontend/index_v13401.html new file mode 100644 index 0000000..e4f830a --- /dev/null +++ b/frontend/index_v13401.html @@ -0,0 +1,3788 @@ + + + + + +T面板 - Linux 网站管理面板 + + + + + + + + + + + +
+ + + + + + + + +
+
+
+ +

仪表盘

+
+
+
+
+ 系统正常 +
+ 官方网站 + + +
+
+ +
+ + +
+
+
+
📁 网站总数
+
-
+
个站点
+
+
+
🗄️ 数据库
+
-
+
个数据库
+
+
+
💾 备份状态
+
-
+
份备份
+
+
+
🔐 安全更新
+
-
+
个待安装
+
+
+ +
+
🛡️
+
+
安全状态:已启用自动更新
+
系统每日 03:00 自动执行安全更新
+
+ +
+ +
+
+
+ ⚡ 服务状态 +
+
+
+
+ 🌿 +
+
Nginx
+
Web 服务器
+
+
+ 运行中 +
+
+
+ 🐬 +
+
MySQL
+
数据库服务
+
+
+ 运行中 +
+
+
+ 🐘 +
+
PHP-FPM
+
PHP 解释器
+
+
+ 运行中 +
+
+
+ 🛡️ +
+
UFW 防火墙
+
端口防护
+
+
+ 已启用 +
+
+
+ +
+
+ 💻 系统资源 +
+
+
+
+
CPU 负载
+
+ - +
+
+
+
CPU 核心数
+
+ - +
+
+
+
CPU 型号
+
+ - +
+
+
+
内存
+
+ - +
+
+
+
磁盘
+
+ - +
+
+
+
负载均值
+
+ - +
+
+
+
+
+ + +
+
+
+

网站列表

+ +
+
+ + + + + + + + + + + + + +
域名PHP路径状态SSL创建时间操作
+
+
+ + +
+
+
+

数据库

+ +
+
+ + + + + + + + + + + + +
数据库名用户密码字符集创建时间操作
+
+
+ + +
+
+
+

SSL 证书

+ +
+
+ + + + + + + + + + + +
域名证书路径到期日期自动续期操作
+
+
+ + +
+
+
+

备份记录

+ +
+
+ + + + + + + + + + + + +
站点类型文件大小状态时间操作
+
+
+ + +
+
+
+

安全中心

+ +
+
+
+
🛡️ 安全更新
+
-
+
个可用更新
+
+
+
❌ 近期错误
+
-
+
条系统错误
+
+
+
🔒 防火墙
+
-
+
状态
+
+
+
+
+ 🛡️ 自动漏洞修复 + +
+
+

T面板每日凌晨 03:00 自动执行 apt-get update && apt-get upgrade -y,修复已知安全漏洞。

+

最近执行:-

+
+
+
+ + + +
+
+
+

📁 文件管理

+
+ + + + + +
+
+ + + + + +
+ / +
+ + +
+ + + + + + + + + + + +
名称大小修改时间权限操作
+
+ + + + + + + + + + + + +
+ + + +
+
+
+

⏰ 定时任务

+ +
+
+ + + + + + + + + + + + + +
任务名称站点执行周期命令状态上次执行操作
+
+ + + +
+ + +
+
+
+

📦 软件市场

+
+ + +
+
+

+ 一键安装建站必备环境。安装过程中会显示实时进度。 +

+
+
加载中...
+
+
+ + +
+ +
+

🔥 防火墙管理

+
加载中...
+ +
+ + +
+

🔌 面板端口修改

+
+
+ + +
+
+ + +
+
+ +
+
+
+ ⚠️ 修改端口后需要重新访问新端口的地址,防火墙会自动开放新端口。 +
+
+ + +
+

💾 自动备份

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+ + +
+

📦 版本信息

+
+
+
当前版本
+
--
+
+
+
最新版本
+
--
+
+
+
状态
+
--
+
+
+
+
更新说明
+
--
+
+
+ + +
+

⬆️ 手动升级

+
+
📦
+
拖拽安装包到这里,或点击选择文件
+
支持 tpanel-v*.zip 格式安装包
+ + +
+ +
+ + +
+

⏪ 版本回滚

+
升级前会自动备份当前版本,可以随时回滚
+
+
暂无备份
+
+
+
+ + +
+
+ +
+

👤 修改管理员密码

+
+
+ + +
+
+ + +
+
+ +
+ +
+

⚙️ 系统设置

+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ +
+

🔗 面板域名绑定

+
+ + + + 绑定后只能通过该域名访问后台(如留空则不限制访问来源)。绑定后请确保 DNS 已解析到此服务器。 + +
+
+ +
+ ⚠️ 修改域名绑定后需重启面板服务:systemctl restart tpanel +
+
+ +
+

🌐 官方信息

+
+
+ + +
+
+ + +
+
+
+
+ + +
+

📋 安全日志

+
+
+
+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/xterm-addon-fit.js b/frontend/xterm-addon-fit.js new file mode 100644 index 0000000..7cfd9de --- /dev/null +++ b/frontend/xterm-addon-fit.js @@ -0,0 +1,2 @@ +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})())); +//# sourceMappingURL=xterm-addon-fit.js.map \ No newline at end of file diff --git a/frontend/xterm.css b/frontend/xterm.css new file mode 100644 index 0000000..74acc26 --- /dev/null +++ b/frontend/xterm.css @@ -0,0 +1,209 @@ +/** + * Copyright (c) 2014 The xterm.js authors. All rights reserved. + * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) + * https://github.com/chjj/term.js + * @license MIT + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * Originally forked from (with the author's permission): + * Fabrice Bellard's javascript vt100 for jslinux: + * http://bellard.org/jslinux/ + * Copyright (c) 2011 Fabrice Bellard + * The original design remains. The terminal itself + * has been extended to include xterm CSI codes, among + * other features. + */ + +/** + * Default styles for xterm.js + */ + +.xterm { + cursor: text; + position: relative; + user-select: none; + -ms-user-select: none; + -webkit-user-select: none; +} + +.xterm.focus, +.xterm:focus { + outline: none; +} + +.xterm .xterm-helpers { + position: absolute; + top: 0; + /** + * The z-index of the helpers must be higher than the canvases in order for + * IMEs to appear on top. + */ + z-index: 5; +} + +.xterm .xterm-helper-textarea { + padding: 0; + border: 0; + margin: 0; + /* Move textarea out of the screen to the far left, so that the cursor is not visible */ + position: absolute; + opacity: 0; + left: -9999em; + top: 0; + width: 0; + height: 0; + z-index: -5; + /** Prevent wrapping so the IME appears against the textarea at the correct position */ + white-space: nowrap; + overflow: hidden; + resize: none; +} + +.xterm .composition-view { + /* TODO: Composition position got messed up somewhere */ + background: #000; + color: #FFF; + display: none; + position: absolute; + white-space: nowrap; + z-index: 1; +} + +.xterm .composition-view.active { + display: block; +} + +.xterm .xterm-viewport { + /* On OS X this is required in order for the scroll bar to appear fully opaque */ + background-color: #000; + overflow-y: scroll; + cursor: default; + position: absolute; + right: 0; + left: 0; + top: 0; + bottom: 0; +} + +.xterm .xterm-screen { + position: relative; +} + +.xterm .xterm-screen canvas { + position: absolute; + left: 0; + top: 0; +} + +.xterm .xterm-scroll-area { + visibility: hidden; +} + +.xterm-char-measure-element { + display: inline-block; + visibility: hidden; + position: absolute; + top: 0; + left: -9999em; + line-height: normal; +} + +.xterm.enable-mouse-events { + /* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */ + cursor: default; +} + +.xterm.xterm-cursor-pointer, +.xterm .xterm-cursor-pointer { + cursor: pointer; +} + +.xterm.column-select.focus { + /* Column selection mode */ + cursor: crosshair; +} + +.xterm .xterm-accessibility, +.xterm .xterm-message { + position: absolute; + left: 0; + top: 0; + bottom: 0; + right: 0; + z-index: 10; + color: transparent; + pointer-events: none; +} + +.xterm .live-region { + position: absolute; + left: -9999px; + width: 1px; + height: 1px; + overflow: hidden; +} + +.xterm-dim { + /* Dim should not apply to background, so the opacity of the foreground color is applied + * explicitly in the generated class and reset to 1 here */ + opacity: 1 !important; +} + +.xterm-underline-1 { text-decoration: underline; } +.xterm-underline-2 { text-decoration: double underline; } +.xterm-underline-3 { text-decoration: wavy underline; } +.xterm-underline-4 { text-decoration: dotted underline; } +.xterm-underline-5 { text-decoration: dashed underline; } + +.xterm-overline { + text-decoration: overline; +} + +.xterm-overline.xterm-underline-1 { text-decoration: overline underline; } +.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; } +.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; } +.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; } +.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; } + +.xterm-strikethrough { + text-decoration: line-through; +} + +.xterm-screen .xterm-decoration-container .xterm-decoration { + z-index: 6; + position: absolute; +} + +.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer { + z-index: 7; +} + +.xterm-decoration-overview-ruler { + z-index: 8; + position: absolute; + top: 0; + right: 0; + pointer-events: none; +} + +.xterm-decoration-top { + z-index: 2; + position: relative; +} diff --git a/frontend/xterm.js b/frontend/xterm.js new file mode 100644 index 0000000..a68eae6 --- /dev/null +++ b/frontend/xterm.js @@ -0,0 +1,2 @@ +!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(self,(()=>(()=>{"use strict";var e={4567:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AccessibilityManager=void 0;const n=i(9042),o=i(6114),a=i(9924),h=i(844),c=i(5596),l=i(4725),d=i(3656);let _=t.AccessibilityManager=class extends h.Disposable{constructor(e,t){super(),this._terminal=e,this._renderService=t,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="",this._accessibilityContainer=document.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=document.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let e=0;ethis._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions(),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=document.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this.register(new a.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this.register(this._terminal.onResize((e=>this._handleResize(e.rows)))),this.register(this._terminal.onRender((e=>this._refreshRows(e.start,e.end)))),this.register(this._terminal.onScroll((()=>this._refreshRows()))),this.register(this._terminal.onA11yChar((e=>this._handleChar(e)))),this.register(this._terminal.onLineFeed((()=>this._handleChar("\n")))),this.register(this._terminal.onA11yTab((e=>this._handleTab(e)))),this.register(this._terminal.onKey((e=>this._handleKey(e.key)))),this.register(this._terminal.onBlur((()=>this._clearLiveRegion()))),this.register(this._renderService.onDimensionsChange((()=>this._refreshRowsDimensions()))),this._screenDprMonitor=new c.ScreenDprMonitor(window),this.register(this._screenDprMonitor),this._screenDprMonitor.setListener((()=>this._refreshRowsDimensions())),this.register((0,d.addDisposableDomListener)(window,"resize",(()=>this._refreshRowsDimensions()))),this._refreshRows(),this.register((0,h.toDisposable)((()=>{this._accessibilityContainer.remove(),this._rowElements.length=0})))}_handleTab(e){for(let t=0;t0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent+=n.tooMuchOutput)),o.isMac&&this._liveRegion.textContent&&this._liveRegion.textContent.length>0&&!this._liveRegion.parentNode&&setTimeout((()=>{this._accessibilityContainer.appendChild(this._liveRegion)}),0))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0,o.isMac&&this._liveRegion.remove()}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.translateBufferLineToString(i.ydisp+r,!0),t=(i.ydisp+r+1).toString(),n=this._rowElements[r];n&&(0===e.length?n.innerText=" ":n.textContent=e,n.setAttribute("aria-posinset",t),n.setAttribute("aria-setsize",s))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,n;if(0===t?(r=i,n=this._rowElements.pop(),this._rowContainer.removeChild(n)):(r=this._rowElements.shift(),n=i,this._rowContainer.removeChild(r)),r.removeEventListener("focus",this._topBoundaryFocusListener),n.removeEventListener("focus",this._bottomBoundaryFocusListener),0===t){const e=this._createAccessibilityTreeNode();this._rowElements.unshift(e),this._rowContainer.insertAdjacentElement("afterbegin",e)}else{const e=this._createAccessibilityTreeNode();this._rowElements.push(e),this._rowContainer.appendChild(e)}this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._terminal.scrollLines(0===t?-1:1),this._rowElements[0===t?1:this._rowElements.length-2].focus(),e.preventDefault(),e.stopImmediatePropagation()}_handleResize(e){this._rowElements[this._rowElements.length-1].removeEventListener("focus",this._bottomBoundaryFocusListener);for(let e=this._rowContainer.children.length;ee;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions()}_createAccessibilityTreeNode(){const e=document.createElement("div");return e.setAttribute("role","listitem"),e.tabIndex=-1,this._refreshRowDimensions(e),e}_refreshRowsDimensions(){if(this._renderService.dimensions.css.cell.height){this._accessibilityContainer.style.width=`${this._renderService.dimensions.css.canvas.width}px`,this._rowElements.length!==this._terminal.rows&&this._handleResize(this._terminal.rows);for(let e=0;e{function i(e){return e.replace(/\r?\n/g,"\r")}function s(e,t){return t?"[200~"+e+"[201~":e}function r(e,t,r,n){e=s(e=i(e),r.decPrivateModes.bracketedPasteMode&&!0!==n.rawOptions.ignoreBracketedPasteMode),r.triggerDataEvent(e,!0),t.value=""}function n(e,t,i){const s=i.getBoundingClientRect(),r=e.clientX-s.left-10,n=e.clientY-s.top-10;t.style.width="20px",t.style.height="20px",t.style.left=`${r}px`,t.style.top=`${n}px`,t.style.zIndex="1000",t.focus()}Object.defineProperty(t,"__esModule",{value:!0}),t.rightClickHandler=t.moveTextAreaUnderMouseCursor=t.paste=t.handlePasteEvent=t.copyHandler=t.bracketTextForPaste=t.prepareTextForTerminal=void 0,t.prepareTextForTerminal=i,t.bracketTextForPaste=s,t.copyHandler=function(e,t){e.clipboardData&&e.clipboardData.setData("text/plain",t.selectionText),e.preventDefault()},t.handlePasteEvent=function(e,t,i,s){e.stopPropagation(),e.clipboardData&&r(e.clipboardData.getData("text/plain"),t,i,s)},t.paste=r,t.moveTextAreaUnderMouseCursor=n,t.rightClickHandler=function(e,t,i,s,r){n(e,t,i),r&&s.rightClickSelect(e),t.value=s.selectionText,t.select()}},7239:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ColorContrastCache=void 0;const s=i(1505);t.ColorContrastCache=class{constructor(){this._color=new s.TwoKeyMap,this._css=new s.TwoKeyMap}setCss(e,t,i){this._css.set(e,t,i)}getCss(e,t){return this._css.get(e,t)}setColor(e,t,i){this._color.set(e,t,i)}getColor(e,t){return this._color.get(e,t)}clear(){this._color.clear(),this._css.clear()}}},3656:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.addDisposableDomListener=void 0,t.addDisposableDomListener=function(e,t,i,s){e.addEventListener(t,i,s);let r=!1;return{dispose:()=>{r||(r=!0,e.removeEventListener(t,i,s))}}}},6465:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Linkifier2=void 0;const n=i(3656),o=i(8460),a=i(844),h=i(2585);let c=t.Linkifier2=class extends a.Disposable{get currentLink(){return this._currentLink}constructor(e){super(),this._bufferService=e,this._linkProviders=[],this._linkCacheDisposables=[],this._isMouseOut=!0,this._wasResized=!1,this._activeLine=-1,this._onShowLinkUnderline=this.register(new o.EventEmitter),this.onShowLinkUnderline=this._onShowLinkUnderline.event,this._onHideLinkUnderline=this.register(new o.EventEmitter),this.onHideLinkUnderline=this._onHideLinkUnderline.event,this.register((0,a.getDisposeArrayDisposable)(this._linkCacheDisposables)),this.register((0,a.toDisposable)((()=>{this._lastMouseEvent=void 0}))),this.register(this._bufferService.onResize((()=>{this._clearCurrentLink(),this._wasResized=!0})))}registerLinkProvider(e){return this._linkProviders.push(e),{dispose:()=>{const t=this._linkProviders.indexOf(e);-1!==t&&this._linkProviders.splice(t,1)}}}attachToDom(e,t,i){this._element=e,this._mouseService=t,this._renderService=i,this.register((0,n.addDisposableDomListener)(this._element,"mouseleave",(()=>{this._isMouseOut=!0,this._clearCurrentLink()}))),this.register((0,n.addDisposableDomListener)(this._element,"mousemove",this._handleMouseMove.bind(this))),this.register((0,n.addDisposableDomListener)(this._element,"mousedown",this._handleMouseDown.bind(this))),this.register((0,n.addDisposableDomListener)(this._element,"mouseup",this._handleMouseUp.bind(this)))}_handleMouseMove(e){if(this._lastMouseEvent=e,!this._element||!this._mouseService)return;const t=this._positionFromMouseEvent(e,this._element,this._mouseService);if(!t)return;this._isMouseOut=!1;const i=e.composedPath();for(let e=0;e{null==e||e.forEach((e=>{e.link.dispose&&e.link.dispose()}))})),this._activeProviderReplies=new Map,this._activeLine=e.y);let r=!1;for(const[i,n]of this._linkProviders.entries())t?(null===(s=this._activeProviderReplies)||void 0===s?void 0:s.get(i))&&(r=this._checkLinkProviderResult(i,e,r)):n.provideLinks(e.y,(t=>{var s,n;if(this._isMouseOut)return;const o=null==t?void 0:t.map((e=>({link:e})));null===(s=this._activeProviderReplies)||void 0===s||s.set(i,o),r=this._checkLinkProviderResult(i,e,r),(null===(n=this._activeProviderReplies)||void 0===n?void 0:n.size)===this._linkProviders.length&&this._removeIntersectingLinks(e.y,this._activeProviderReplies)}))}_removeIntersectingLinks(e,t){const i=new Set;for(let s=0;se?this._bufferService.cols:s.link.range.end.x;for(let e=n;e<=o;e++){if(i.has(e)){r.splice(t--,1);break}i.add(e)}}}}_checkLinkProviderResult(e,t,i){var s;if(!this._activeProviderReplies)return i;const r=this._activeProviderReplies.get(e);let n=!1;for(let t=0;tthis._linkAtPosition(e.link,t)));e&&(i=!0,this._handleNewLink(e))}if(this._activeProviderReplies.size===this._linkProviders.length&&!i)for(let e=0;ethis._linkAtPosition(e.link,t)));if(r){i=!0,this._handleNewLink(r);break}}return i}_handleMouseDown(){this._mouseDownLink=this._currentLink}_handleMouseUp(e){if(!this._element||!this._mouseService||!this._currentLink)return;const t=this._positionFromMouseEvent(e,this._element,this._mouseService);t&&this._mouseDownLink===this._currentLink&&this._linkAtPosition(this._currentLink.link,t)&&this._currentLink.link.activate(e,this._currentLink.link.text)}_clearCurrentLink(e,t){this._element&&this._currentLink&&this._lastMouseEvent&&(!e||!t||this._currentLink.link.range.start.y>=e&&this._currentLink.link.range.end.y<=t)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,(0,a.disposeArray)(this._linkCacheDisposables))}_handleNewLink(e){if(!this._element||!this._lastMouseEvent||!this._mouseService)return;const t=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);t&&this._linkAtPosition(e.link,t)&&(this._currentLink=e,this._currentLink.state={decorations:{underline:void 0===e.link.decorations||e.link.decorations.underline,pointerCursor:void 0===e.link.decorations||e.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,e.link,this._lastMouseEvent),e.link.decorations={},Object.defineProperties(e.link.decorations,{pointerCursor:{get:()=>{var e,t;return null===(t=null===(e=this._currentLink)||void 0===e?void 0:e.state)||void 0===t?void 0:t.decorations.pointerCursor},set:e=>{var t,i;(null===(t=this._currentLink)||void 0===t?void 0:t.state)&&this._currentLink.state.decorations.pointerCursor!==e&&(this._currentLink.state.decorations.pointerCursor=e,this._currentLink.state.isHovered&&(null===(i=this._element)||void 0===i||i.classList.toggle("xterm-cursor-pointer",e)))}},underline:{get:()=>{var e,t;return null===(t=null===(e=this._currentLink)||void 0===e?void 0:e.state)||void 0===t?void 0:t.decorations.underline},set:t=>{var i,s,r;(null===(i=this._currentLink)||void 0===i?void 0:i.state)&&(null===(r=null===(s=this._currentLink)||void 0===s?void 0:s.state)||void 0===r?void 0:r.decorations.underline)!==t&&(this._currentLink.state.decorations.underline=t,this._currentLink.state.isHovered&&this._fireUnderlineEvent(e.link,t))}}}),this._renderService&&this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange((e=>{if(!this._currentLink)return;const t=0===e.start?0:e.start+1+this._bufferService.buffer.ydisp,i=this._bufferService.buffer.ydisp+1+e.end;if(this._currentLink.link.range.start.y>=t&&this._currentLink.link.range.end.y<=i&&(this._clearCurrentLink(t,i),this._lastMouseEvent&&this._element)){const e=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);e&&this._askForLink(e,!1)}}))))}_linkHover(e,t,i){var s;(null===(s=this._currentLink)||void 0===s?void 0:s.state)&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!0),this._currentLink.state.decorations.pointerCursor&&e.classList.add("xterm-cursor-pointer")),t.hover&&t.hover(i,t.text)}_fireUnderlineEvent(e,t){const i=e.range,s=this._bufferService.buffer.ydisp,r=this._createLinkUnderlineEvent(i.start.x-1,i.start.y-s-1,i.end.x,i.end.y-s-1,void 0);(t?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(r)}_linkLeave(e,t,i){var s;(null===(s=this._currentLink)||void 0===s?void 0:s.state)&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!1),this._currentLink.state.decorations.pointerCursor&&e.classList.remove("xterm-cursor-pointer")),t.leave&&t.leave(i,t.text)}_linkAtPosition(e,t){const i=e.range.start.y*this._bufferService.cols+e.range.start.x,s=e.range.end.y*this._bufferService.cols+e.range.end.x,r=t.y*this._bufferService.cols+t.x;return i<=r&&r<=s}_positionFromMouseEvent(e,t,i){const s=i.getCoords(e,t,this._bufferService.cols,this._bufferService.rows);if(s)return{x:s[0],y:s[1]+this._bufferService.buffer.ydisp}}_createLinkUnderlineEvent(e,t,i,s,r){return{x1:e,y1:t,x2:i,y2:s,cols:this._bufferService.cols,fg:r}}};t.Linkifier2=c=s([r(0,h.IBufferService)],c)},9042:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.tooMuchOutput=t.promptLabel=void 0,t.promptLabel="Terminal input",t.tooMuchOutput="Too much output to announce, navigate to rows manually to read"},3730:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkProvider=void 0;const n=i(511),o=i(2585);let a=t.OscLinkProvider=class{constructor(e,t,i){this._bufferService=e,this._optionsService=t,this._oscLinkService=i}provideLinks(e,t){var i;const s=this._bufferService.buffer.lines.get(e-1);if(!s)return void t(void 0);const r=[],o=this._optionsService.rawOptions.linkHandler,a=new n.CellData,c=s.getTrimmedLength();let l=-1,d=-1,_=!1;for(let t=0;to?o.activate(e,t,i):h(0,t),hover:(e,t)=>{var s;return null===(s=null==o?void 0:o.hover)||void 0===s?void 0:s.call(o,e,t,i)},leave:(e,t)=>{var s;return null===(s=null==o?void 0:o.leave)||void 0===s?void 0:s.call(o,e,t,i)}})}_=!1,a.hasExtendedAttrs()&&a.extended.urlId?(d=t,l=a.extended.urlId):(d=-1,l=-1)}}t(r)}};function h(e,t){if(confirm(`Do you want to navigate to ${t}?\n\nWARNING: This link could potentially be dangerous`)){const e=window.open();if(e){try{e.opener=null}catch(e){}e.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}}t.OscLinkProvider=a=s([r(0,o.IBufferService),r(1,o.IOptionsService),r(2,o.IOscLinkService)],a)},6193:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.RenderDebouncer=void 0,t.RenderDebouncer=class{constructor(e,t){this._parentWindow=e,this._renderCallback=t,this._refreshCallbacks=[]}dispose(){this._animationFrame&&(this._parentWindow.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)}addRefreshCallback(e){return this._refreshCallbacks.push(e),this._animationFrame||(this._animationFrame=this._parentWindow.requestAnimationFrame((()=>this._innerRefresh()))),this._animationFrame}refresh(e,t,i){this._rowCount=i,e=void 0!==e?e:0,t=void 0!==t?t:this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t,this._animationFrame||(this._animationFrame=this._parentWindow.requestAnimationFrame((()=>this._innerRefresh())))}_innerRefresh(){if(this._animationFrame=void 0,void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return void this._runRefreshCallbacks();const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t),this._runRefreshCallbacks()}_runRefreshCallbacks(){for(const e of this._refreshCallbacks)e(0);this._refreshCallbacks=[]}}},5596:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ScreenDprMonitor=void 0;const s=i(844);class r extends s.Disposable{constructor(e){super(),this._parentWindow=e,this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this.register((0,s.toDisposable)((()=>{this.clearListener()})))}setListener(e){this._listener&&this.clearListener(),this._listener=e,this._outerListener=()=>{this._listener&&(this._listener(this._parentWindow.devicePixelRatio,this._currentDevicePixelRatio),this._updateDpr())},this._updateDpr()}_updateDpr(){var e;this._outerListener&&(null===(e=this._resolutionMediaMatchList)||void 0===e||e.removeListener(this._outerListener),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._resolutionMediaMatchList=this._parentWindow.matchMedia(`screen and (resolution: ${this._parentWindow.devicePixelRatio}dppx)`),this._resolutionMediaMatchList.addListener(this._outerListener))}clearListener(){this._resolutionMediaMatchList&&this._listener&&this._outerListener&&(this._resolutionMediaMatchList.removeListener(this._outerListener),this._resolutionMediaMatchList=void 0,this._listener=void 0,this._outerListener=void 0)}}t.ScreenDprMonitor=r},3236:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Terminal=void 0;const s=i(3614),r=i(3656),n=i(6465),o=i(9042),a=i(3730),h=i(1680),c=i(3107),l=i(5744),d=i(2950),_=i(1296),u=i(428),f=i(4269),v=i(5114),p=i(8934),g=i(3230),m=i(9312),S=i(4725),C=i(6731),b=i(8055),y=i(8969),w=i(8460),E=i(844),k=i(6114),L=i(8437),D=i(2584),R=i(7399),x=i(5941),A=i(9074),B=i(2585),T=i(5435),M=i(4567),O="undefined"!=typeof window?window.document:null;class P extends y.CoreTerminal{get onFocus(){return this._onFocus.event}get onBlur(){return this._onBlur.event}get onA11yChar(){return this._onA11yCharEmitter.event}get onA11yTab(){return this._onA11yTabEmitter.event}get onWillOpen(){return this._onWillOpen.event}constructor(e={}){super(e),this.browser=k,this._keyDownHandled=!1,this._keyDownSeen=!1,this._keyPressHandled=!1,this._unprocessedDeadKey=!1,this._accessibilityManager=this.register(new E.MutableDisposable),this._onCursorMove=this.register(new w.EventEmitter),this.onCursorMove=this._onCursorMove.event,this._onKey=this.register(new w.EventEmitter),this.onKey=this._onKey.event,this._onRender=this.register(new w.EventEmitter),this.onRender=this._onRender.event,this._onSelectionChange=this.register(new w.EventEmitter),this.onSelectionChange=this._onSelectionChange.event,this._onTitleChange=this.register(new w.EventEmitter),this.onTitleChange=this._onTitleChange.event,this._onBell=this.register(new w.EventEmitter),this.onBell=this._onBell.event,this._onFocus=this.register(new w.EventEmitter),this._onBlur=this.register(new w.EventEmitter),this._onA11yCharEmitter=this.register(new w.EventEmitter),this._onA11yTabEmitter=this.register(new w.EventEmitter),this._onWillOpen=this.register(new w.EventEmitter),this._setup(),this.linkifier2=this.register(this._instantiationService.createInstance(n.Linkifier2)),this.linkifier2.registerLinkProvider(this._instantiationService.createInstance(a.OscLinkProvider)),this._decorationService=this._instantiationService.createInstance(A.DecorationService),this._instantiationService.setService(B.IDecorationService,this._decorationService),this.register(this._inputHandler.onRequestBell((()=>this._onBell.fire()))),this.register(this._inputHandler.onRequestRefreshRows(((e,t)=>this.refresh(e,t)))),this.register(this._inputHandler.onRequestSendFocus((()=>this._reportFocus()))),this.register(this._inputHandler.onRequestReset((()=>this.reset()))),this.register(this._inputHandler.onRequestWindowsOptionsReport((e=>this._reportWindowsOptions(e)))),this.register(this._inputHandler.onColor((e=>this._handleColorEvent(e)))),this.register((0,w.forwardEvent)(this._inputHandler.onCursorMove,this._onCursorMove)),this.register((0,w.forwardEvent)(this._inputHandler.onTitleChange,this._onTitleChange)),this.register((0,w.forwardEvent)(this._inputHandler.onA11yChar,this._onA11yCharEmitter)),this.register((0,w.forwardEvent)(this._inputHandler.onA11yTab,this._onA11yTabEmitter)),this.register(this._bufferService.onResize((e=>this._afterResize(e.cols,e.rows)))),this.register((0,E.toDisposable)((()=>{var e,t;this._customKeyEventHandler=void 0,null===(t=null===(e=this.element)||void 0===e?void 0:e.parentNode)||void 0===t||t.removeChild(this.element)})))}_handleColorEvent(e){if(this._themeService)for(const t of e){let e,i="";switch(t.index){case 256:e="foreground",i="10";break;case 257:e="background",i="11";break;case 258:e="cursor",i="12";break;default:e="ansi",i="4;"+t.index}switch(t.type){case 0:const s=b.color.toColorRGB("ansi"===e?this._themeService.colors.ansi[t.index]:this._themeService.colors[e]);this.coreService.triggerDataEvent(`${D.C0.ESC}]${i};${(0,x.toRgbString)(s)}${D.C1_ESCAPED.ST}`);break;case 1:if("ansi"===e)this._themeService.modifyColors((e=>e.ansi[t.index]=b.rgba.toColor(...t.color)));else{const i=e;this._themeService.modifyColors((e=>e[i]=b.rgba.toColor(...t.color)))}break;case 2:this._themeService.restoreColor(t.index)}}}_setup(){super._setup(),this._customKeyEventHandler=void 0}get buffer(){return this.buffers.active}focus(){this.textarea&&this.textarea.focus({preventScroll:!0})}_handleScreenReaderModeOptionChange(e){e?!this._accessibilityManager.value&&this._renderService&&(this._accessibilityManager.value=this._instantiationService.createInstance(M.AccessibilityManager,this)):this._accessibilityManager.clear()}_handleTextAreaFocus(e){this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(D.C0.ESC+"[I"),this.updateCursorStyle(e),this.element.classList.add("focus"),this._showCursor(),this._onFocus.fire()}blur(){var e;return null===(e=this.textarea)||void 0===e?void 0:e.blur()}_handleTextAreaBlur(){this.textarea.value="",this.refresh(this.buffer.y,this.buffer.y),this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(D.C0.ESC+"[O"),this.element.classList.remove("focus"),this._onBlur.fire()}_syncTextArea(){if(!this.textarea||!this.buffer.isCursorInViewport||this._compositionHelper.isComposing||!this._renderService)return;const e=this.buffer.ybase+this.buffer.y,t=this.buffer.lines.get(e);if(!t)return;const i=Math.min(this.buffer.x,this.cols-1),s=this._renderService.dimensions.css.cell.height,r=t.getWidth(i),n=this._renderService.dimensions.css.cell.width*r,o=this.buffer.y*this._renderService.dimensions.css.cell.height,a=i*this._renderService.dimensions.css.cell.width;this.textarea.style.left=a+"px",this.textarea.style.top=o+"px",this.textarea.style.width=n+"px",this.textarea.style.height=s+"px",this.textarea.style.lineHeight=s+"px",this.textarea.style.zIndex="-5"}_initGlobal(){this._bindKeys(),this.register((0,r.addDisposableDomListener)(this.element,"copy",(e=>{this.hasSelection()&&(0,s.copyHandler)(e,this._selectionService)})));const e=e=>(0,s.handlePasteEvent)(e,this.textarea,this.coreService,this.optionsService);this.register((0,r.addDisposableDomListener)(this.textarea,"paste",e)),this.register((0,r.addDisposableDomListener)(this.element,"paste",e)),k.isFirefox?this.register((0,r.addDisposableDomListener)(this.element,"mousedown",(e=>{2===e.button&&(0,s.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))):this.register((0,r.addDisposableDomListener)(this.element,"contextmenu",(e=>{(0,s.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))),k.isLinux&&this.register((0,r.addDisposableDomListener)(this.element,"auxclick",(e=>{1===e.button&&(0,s.moveTextAreaUnderMouseCursor)(e,this.textarea,this.screenElement)})))}_bindKeys(){this.register((0,r.addDisposableDomListener)(this.textarea,"keyup",(e=>this._keyUp(e)),!0)),this.register((0,r.addDisposableDomListener)(this.textarea,"keydown",(e=>this._keyDown(e)),!0)),this.register((0,r.addDisposableDomListener)(this.textarea,"keypress",(e=>this._keyPress(e)),!0)),this.register((0,r.addDisposableDomListener)(this.textarea,"compositionstart",(()=>this._compositionHelper.compositionstart()))),this.register((0,r.addDisposableDomListener)(this.textarea,"compositionupdate",(e=>this._compositionHelper.compositionupdate(e)))),this.register((0,r.addDisposableDomListener)(this.textarea,"compositionend",(()=>this._compositionHelper.compositionend()))),this.register((0,r.addDisposableDomListener)(this.textarea,"input",(e=>this._inputEvent(e)),!0)),this.register(this.onRender((()=>this._compositionHelper.updateCompositionElements())))}open(e){var t;if(!e)throw new Error("Terminal requires a parent element.");e.isConnected||this._logService.debug("Terminal.open was called on an element that was not attached to the DOM"),this._document=e.ownerDocument,this.element=this._document.createElement("div"),this.element.dir="ltr",this.element.classList.add("terminal"),this.element.classList.add("xterm"),e.appendChild(this.element);const i=O.createDocumentFragment();this._viewportElement=O.createElement("div"),this._viewportElement.classList.add("xterm-viewport"),i.appendChild(this._viewportElement),this._viewportScrollArea=O.createElement("div"),this._viewportScrollArea.classList.add("xterm-scroll-area"),this._viewportElement.appendChild(this._viewportScrollArea),this.screenElement=O.createElement("div"),this.screenElement.classList.add("xterm-screen"),this._helperContainer=O.createElement("div"),this._helperContainer.classList.add("xterm-helpers"),this.screenElement.appendChild(this._helperContainer),i.appendChild(this.screenElement),this.textarea=O.createElement("textarea"),this.textarea.classList.add("xterm-helper-textarea"),this.textarea.setAttribute("aria-label",o.promptLabel),k.isChromeOS||this.textarea.setAttribute("aria-multiline","false"),this.textarea.setAttribute("autocorrect","off"),this.textarea.setAttribute("autocapitalize","off"),this.textarea.setAttribute("spellcheck","false"),this.textarea.tabIndex=0,this._coreBrowserService=this._instantiationService.createInstance(v.CoreBrowserService,this.textarea,null!==(t=this._document.defaultView)&&void 0!==t?t:window),this._instantiationService.setService(S.ICoreBrowserService,this._coreBrowserService),this.register((0,r.addDisposableDomListener)(this.textarea,"focus",(e=>this._handleTextAreaFocus(e)))),this.register((0,r.addDisposableDomListener)(this.textarea,"blur",(()=>this._handleTextAreaBlur()))),this._helperContainer.appendChild(this.textarea),this._charSizeService=this._instantiationService.createInstance(u.CharSizeService,this._document,this._helperContainer),this._instantiationService.setService(S.ICharSizeService,this._charSizeService),this._themeService=this._instantiationService.createInstance(C.ThemeService),this._instantiationService.setService(S.IThemeService,this._themeService),this._characterJoinerService=this._instantiationService.createInstance(f.CharacterJoinerService),this._instantiationService.setService(S.ICharacterJoinerService,this._characterJoinerService),this._renderService=this.register(this._instantiationService.createInstance(g.RenderService,this.rows,this.screenElement)),this._instantiationService.setService(S.IRenderService,this._renderService),this.register(this._renderService.onRenderedViewportChange((e=>this._onRender.fire(e)))),this.onResize((e=>this._renderService.resize(e.cols,e.rows))),this._compositionView=O.createElement("div"),this._compositionView.classList.add("composition-view"),this._compositionHelper=this._instantiationService.createInstance(d.CompositionHelper,this.textarea,this._compositionView),this._helperContainer.appendChild(this._compositionView),this.element.appendChild(i);try{this._onWillOpen.fire(this.element)}catch(e){}this._renderService.hasRenderer()||this._renderService.setRenderer(this._createRenderer()),this._mouseService=this._instantiationService.createInstance(p.MouseService),this._instantiationService.setService(S.IMouseService,this._mouseService),this.viewport=this._instantiationService.createInstance(h.Viewport,this._viewportElement,this._viewportScrollArea),this.viewport.onRequestScrollLines((e=>this.scrollLines(e.amount,e.suppressScrollEvent,1))),this.register(this._inputHandler.onRequestSyncScrollBar((()=>this.viewport.syncScrollArea()))),this.register(this.viewport),this.register(this.onCursorMove((()=>{this._renderService.handleCursorMove(),this._syncTextArea()}))),this.register(this.onResize((()=>this._renderService.handleResize(this.cols,this.rows)))),this.register(this.onBlur((()=>this._renderService.handleBlur()))),this.register(this.onFocus((()=>this._renderService.handleFocus()))),this.register(this._renderService.onDimensionsChange((()=>this.viewport.syncScrollArea()))),this._selectionService=this.register(this._instantiationService.createInstance(m.SelectionService,this.element,this.screenElement,this.linkifier2)),this._instantiationService.setService(S.ISelectionService,this._selectionService),this.register(this._selectionService.onRequestScrollLines((e=>this.scrollLines(e.amount,e.suppressScrollEvent)))),this.register(this._selectionService.onSelectionChange((()=>this._onSelectionChange.fire()))),this.register(this._selectionService.onRequestRedraw((e=>this._renderService.handleSelectionChanged(e.start,e.end,e.columnSelectMode)))),this.register(this._selectionService.onLinuxMouseSelection((e=>{this.textarea.value=e,this.textarea.focus(),this.textarea.select()}))),this.register(this._onScroll.event((e=>{this.viewport.syncScrollArea(),this._selectionService.refresh()}))),this.register((0,r.addDisposableDomListener)(this._viewportElement,"scroll",(()=>this._selectionService.refresh()))),this.linkifier2.attachToDom(this.screenElement,this._mouseService,this._renderService),this.register(this._instantiationService.createInstance(c.BufferDecorationRenderer,this.screenElement)),this.register((0,r.addDisposableDomListener)(this.element,"mousedown",(e=>this._selectionService.handleMouseDown(e)))),this.coreMouseService.areMouseEventsActive?(this._selectionService.disable(),this.element.classList.add("enable-mouse-events")):this._selectionService.enable(),this.options.screenReaderMode&&(this._accessibilityManager.value=this._instantiationService.createInstance(M.AccessibilityManager,this)),this.register(this.optionsService.onSpecificOptionChange("screenReaderMode",(e=>this._handleScreenReaderModeOptionChange(e)))),this.options.overviewRulerWidth&&(this._overviewRulerRenderer=this.register(this._instantiationService.createInstance(l.OverviewRulerRenderer,this._viewportElement,this.screenElement))),this.optionsService.onSpecificOptionChange("overviewRulerWidth",(e=>{!this._overviewRulerRenderer&&e&&this._viewportElement&&this.screenElement&&(this._overviewRulerRenderer=this.register(this._instantiationService.createInstance(l.OverviewRulerRenderer,this._viewportElement,this.screenElement)))})),this._charSizeService.measure(),this.refresh(0,this.rows-1),this._initGlobal(),this.bindMouse()}_createRenderer(){return this._instantiationService.createInstance(_.DomRenderer,this.element,this.screenElement,this._viewportElement,this.linkifier2)}bindMouse(){const e=this,t=this.element;function i(t){const i=e._mouseService.getMouseReportCoords(t,e.screenElement);if(!i)return!1;let s,r;switch(t.overrideType||t.type){case"mousemove":r=32,void 0===t.buttons?(s=3,void 0!==t.button&&(s=t.button<3?t.button:3)):s=1&t.buttons?0:4&t.buttons?1:2&t.buttons?2:3;break;case"mouseup":r=0,s=t.button<3?t.button:3;break;case"mousedown":r=1,s=t.button<3?t.button:3;break;case"wheel":if(0===e.viewport.getLinesScrolled(t))return!1;r=t.deltaY<0?0:1,s=4;break;default:return!1}return!(void 0===r||void 0===s||s>4)&&e.coreMouseService.triggerMouseEvent({col:i.col,row:i.row,x:i.x,y:i.y,button:s,action:r,ctrl:t.ctrlKey,alt:t.altKey,shift:t.shiftKey})}const s={mouseup:null,wheel:null,mousedrag:null,mousemove:null},n={mouseup:e=>(i(e),e.buttons||(this._document.removeEventListener("mouseup",s.mouseup),s.mousedrag&&this._document.removeEventListener("mousemove",s.mousedrag)),this.cancel(e)),wheel:e=>(i(e),this.cancel(e,!0)),mousedrag:e=>{e.buttons&&i(e)},mousemove:e=>{e.buttons||i(e)}};this.register(this.coreMouseService.onProtocolChange((e=>{e?("debug"===this.optionsService.rawOptions.logLevel&&this._logService.debug("Binding to mouse events:",this.coreMouseService.explainEvents(e)),this.element.classList.add("enable-mouse-events"),this._selectionService.disable()):(this._logService.debug("Unbinding from mouse events."),this.element.classList.remove("enable-mouse-events"),this._selectionService.enable()),8&e?s.mousemove||(t.addEventListener("mousemove",n.mousemove),s.mousemove=n.mousemove):(t.removeEventListener("mousemove",s.mousemove),s.mousemove=null),16&e?s.wheel||(t.addEventListener("wheel",n.wheel,{passive:!1}),s.wheel=n.wheel):(t.removeEventListener("wheel",s.wheel),s.wheel=null),2&e?s.mouseup||(t.addEventListener("mouseup",n.mouseup),s.mouseup=n.mouseup):(this._document.removeEventListener("mouseup",s.mouseup),t.removeEventListener("mouseup",s.mouseup),s.mouseup=null),4&e?s.mousedrag||(s.mousedrag=n.mousedrag):(this._document.removeEventListener("mousemove",s.mousedrag),s.mousedrag=null)}))),this.coreMouseService.activeProtocol=this.coreMouseService.activeProtocol,this.register((0,r.addDisposableDomListener)(t,"mousedown",(e=>{if(e.preventDefault(),this.focus(),this.coreMouseService.areMouseEventsActive&&!this._selectionService.shouldForceSelection(e))return i(e),s.mouseup&&this._document.addEventListener("mouseup",s.mouseup),s.mousedrag&&this._document.addEventListener("mousemove",s.mousedrag),this.cancel(e)}))),this.register((0,r.addDisposableDomListener)(t,"wheel",(e=>{if(!s.wheel){if(!this.buffer.hasScrollback){const t=this.viewport.getLinesScrolled(e);if(0===t)return;const i=D.C0.ESC+(this.coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(e.deltaY<0?"A":"B");let s="";for(let e=0;e{if(!this.coreMouseService.areMouseEventsActive)return this.viewport.handleTouchStart(e),this.cancel(e)}),{passive:!0})),this.register((0,r.addDisposableDomListener)(t,"touchmove",(e=>{if(!this.coreMouseService.areMouseEventsActive)return this.viewport.handleTouchMove(e)?void 0:this.cancel(e)}),{passive:!1}))}refresh(e,t){var i;null===(i=this._renderService)||void 0===i||i.refreshRows(e,t)}updateCursorStyle(e){var t;(null===(t=this._selectionService)||void 0===t?void 0:t.shouldColumnSelect(e))?this.element.classList.add("column-select"):this.element.classList.remove("column-select")}_showCursor(){this.coreService.isCursorInitialized||(this.coreService.isCursorInitialized=!0,this.refresh(this.buffer.y,this.buffer.y))}scrollLines(e,t,i=0){var s;1===i?(super.scrollLines(e,t,i),this.refresh(0,this.rows-1)):null===(s=this.viewport)||void 0===s||s.scrollLines(e)}paste(e){(0,s.paste)(e,this.textarea,this.coreService,this.optionsService)}attachCustomKeyEventHandler(e){this._customKeyEventHandler=e}registerLinkProvider(e){return this.linkifier2.registerLinkProvider(e)}registerCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");const t=this._characterJoinerService.register(e);return this.refresh(0,this.rows-1),t}deregisterCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");this._characterJoinerService.deregister(e)&&this.refresh(0,this.rows-1)}get markers(){return this.buffer.markers}registerMarker(e){return this.buffer.addMarker(this.buffer.ybase+this.buffer.y+e)}registerDecoration(e){return this._decorationService.registerDecoration(e)}hasSelection(){return!!this._selectionService&&this._selectionService.hasSelection}select(e,t,i){this._selectionService.setSelection(e,t,i)}getSelection(){return this._selectionService?this._selectionService.selectionText:""}getSelectionPosition(){if(this._selectionService&&this._selectionService.hasSelection)return{start:{x:this._selectionService.selectionStart[0],y:this._selectionService.selectionStart[1]},end:{x:this._selectionService.selectionEnd[0],y:this._selectionService.selectionEnd[1]}}}clearSelection(){var e;null===(e=this._selectionService)||void 0===e||e.clearSelection()}selectAll(){var e;null===(e=this._selectionService)||void 0===e||e.selectAll()}selectLines(e,t){var i;null===(i=this._selectionService)||void 0===i||i.selectLines(e,t)}_keyDown(e){if(this._keyDownHandled=!1,this._keyDownSeen=!0,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;const t=this.browser.isMac&&this.options.macOptionIsMeta&&e.altKey;if(!t&&!this._compositionHelper.keydown(e))return this.options.scrollOnUserInput&&this.buffer.ybase!==this.buffer.ydisp&&this.scrollToBottom(),!1;t||"Dead"!==e.key&&"AltGraph"!==e.key||(this._unprocessedDeadKey=!0);const i=(0,R.evaluateKeyboardEvent)(e,this.coreService.decPrivateModes.applicationCursorKeys,this.browser.isMac,this.options.macOptionIsMeta);if(this.updateCursorStyle(e),3===i.type||2===i.type){const t=this.rows-1;return this.scrollLines(2===i.type?-t:t),this.cancel(e,!0)}return 1===i.type&&this.selectAll(),!!this._isThirdLevelShift(this.browser,e)||(i.cancel&&this.cancel(e,!0),!i.key||!!(e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&1===e.key.length&&e.key.charCodeAt(0)>=65&&e.key.charCodeAt(0)<=90)||(this._unprocessedDeadKey?(this._unprocessedDeadKey=!1,!0):(i.key!==D.C0.ETX&&i.key!==D.C0.CR||(this.textarea.value=""),this._onKey.fire({key:i.key,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(i.key,!0),!this.optionsService.rawOptions.screenReaderMode||e.altKey||e.ctrlKey?this.cancel(e,!0):void(this._keyDownHandled=!0))))}_isThirdLevelShift(e,t){const i=e.isMac&&!this.options.macOptionIsMeta&&t.altKey&&!t.ctrlKey&&!t.metaKey||e.isWindows&&t.altKey&&t.ctrlKey&&!t.metaKey||e.isWindows&&t.getModifierState("AltGraph");return"keypress"===t.type?i:i&&(!t.keyCode||t.keyCode>47)}_keyUp(e){this._keyDownSeen=!1,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e)||(function(e){return 16===e.keyCode||17===e.keyCode||18===e.keyCode}(e)||this.focus(),this.updateCursorStyle(e),this._keyPressHandled=!1)}_keyPress(e){let t;if(this._keyPressHandled=!1,this._keyDownHandled)return!1;if(this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;if(this.cancel(e),e.charCode)t=e.charCode;else if(null===e.which||void 0===e.which)t=e.keyCode;else{if(0===e.which||0===e.charCode)return!1;t=e.which}return!(!t||(e.altKey||e.ctrlKey||e.metaKey)&&!this._isThirdLevelShift(this.browser,e)||(t=String.fromCharCode(t),this._onKey.fire({key:t,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(t,!0),this._keyPressHandled=!0,this._unprocessedDeadKey=!1,0))}_inputEvent(e){if(e.data&&"insertText"===e.inputType&&(!e.composed||!this._keyDownSeen)&&!this.optionsService.rawOptions.screenReaderMode){if(this._keyPressHandled)return!1;this._unprocessedDeadKey=!1;const t=e.data;return this.coreService.triggerDataEvent(t,!0),this.cancel(e),!0}return!1}resize(e,t){e!==this.cols||t!==this.rows?super.resize(e,t):this._charSizeService&&!this._charSizeService.hasValidSize&&this._charSizeService.measure()}_afterResize(e,t){var i,s;null===(i=this._charSizeService)||void 0===i||i.measure(),null===(s=this.viewport)||void 0===s||s.syncScrollArea(!0)}clear(){var e;if(0!==this.buffer.ybase||0!==this.buffer.y){this.buffer.clearAllMarkers(),this.buffer.lines.set(0,this.buffer.lines.get(this.buffer.ybase+this.buffer.y)),this.buffer.lines.length=1,this.buffer.ydisp=0,this.buffer.ybase=0,this.buffer.y=0;for(let e=1;e{Object.defineProperty(t,"__esModule",{value:!0}),t.TimeBasedDebouncer=void 0,t.TimeBasedDebouncer=class{constructor(e,t=1e3){this._renderCallback=e,this._debounceThresholdMS=t,this._lastRefreshMs=0,this._additionalRefreshRequested=!1}dispose(){this._refreshTimeoutID&&clearTimeout(this._refreshTimeoutID)}refresh(e,t,i){this._rowCount=i,e=void 0!==e?e:0,t=void 0!==t?t:this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t;const s=Date.now();if(s-this._lastRefreshMs>=this._debounceThresholdMS)this._lastRefreshMs=s,this._innerRefresh();else if(!this._additionalRefreshRequested){const e=s-this._lastRefreshMs,t=this._debounceThresholdMS-e;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout((()=>{this._lastRefreshMs=Date.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0}),t)}}_innerRefresh(){if(void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return;const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t)}}},1680:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Viewport=void 0;const n=i(3656),o=i(4725),a=i(8460),h=i(844),c=i(2585);let l=t.Viewport=class extends h.Disposable{constructor(e,t,i,s,r,o,h,c){super(),this._viewportElement=e,this._scrollArea=t,this._bufferService=i,this._optionsService=s,this._charSizeService=r,this._renderService=o,this._coreBrowserService=h,this.scrollBarWidth=0,this._currentRowHeight=0,this._currentDeviceCellHeight=0,this._lastRecordedBufferLength=0,this._lastRecordedViewportHeight=0,this._lastRecordedBufferHeight=0,this._lastTouchY=0,this._lastScrollTop=0,this._wheelPartialScroll=0,this._refreshAnimationFrame=null,this._ignoreNextScrollEvent=!1,this._smoothScrollState={startTime:0,origin:-1,target:-1},this._onRequestScrollLines=this.register(new a.EventEmitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this.scrollBarWidth=this._viewportElement.offsetWidth-this._scrollArea.offsetWidth||15,this.register((0,n.addDisposableDomListener)(this._viewportElement,"scroll",this._handleScroll.bind(this))),this._activeBuffer=this._bufferService.buffer,this.register(this._bufferService.buffers.onBufferActivate((e=>this._activeBuffer=e.activeBuffer))),this._renderDimensions=this._renderService.dimensions,this.register(this._renderService.onDimensionsChange((e=>this._renderDimensions=e))),this._handleThemeChange(c.colors),this.register(c.onChangeColors((e=>this._handleThemeChange(e)))),this.register(this._optionsService.onSpecificOptionChange("scrollback",(()=>this.syncScrollArea()))),setTimeout((()=>this.syncScrollArea()))}_handleThemeChange(e){this._viewportElement.style.backgroundColor=e.background.css}reset(){this._currentRowHeight=0,this._currentDeviceCellHeight=0,this._lastRecordedBufferLength=0,this._lastRecordedViewportHeight=0,this._lastRecordedBufferHeight=0,this._lastTouchY=0,this._lastScrollTop=0,this._coreBrowserService.window.requestAnimationFrame((()=>this.syncScrollArea()))}_refresh(e){if(e)return this._innerRefresh(),void(null!==this._refreshAnimationFrame&&this._coreBrowserService.window.cancelAnimationFrame(this._refreshAnimationFrame));null===this._refreshAnimationFrame&&(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh())))}_innerRefresh(){if(this._charSizeService.height>0){this._currentRowHeight=this._renderService.dimensions.device.cell.height/this._coreBrowserService.dpr,this._currentDeviceCellHeight=this._renderService.dimensions.device.cell.height,this._lastRecordedViewportHeight=this._viewportElement.offsetHeight;const e=Math.round(this._currentRowHeight*this._lastRecordedBufferLength)+(this._lastRecordedViewportHeight-this._renderService.dimensions.css.canvas.height);this._lastRecordedBufferHeight!==e&&(this._lastRecordedBufferHeight=e,this._scrollArea.style.height=this._lastRecordedBufferHeight+"px")}const e=this._bufferService.buffer.ydisp*this._currentRowHeight;this._viewportElement.scrollTop!==e&&(this._ignoreNextScrollEvent=!0,this._viewportElement.scrollTop=e),this._refreshAnimationFrame=null}syncScrollArea(e=!1){if(this._lastRecordedBufferLength!==this._bufferService.buffer.lines.length)return this._lastRecordedBufferLength=this._bufferService.buffer.lines.length,void this._refresh(e);this._lastRecordedViewportHeight===this._renderService.dimensions.css.canvas.height&&this._lastScrollTop===this._activeBuffer.ydisp*this._currentRowHeight&&this._renderDimensions.device.cell.height===this._currentDeviceCellHeight||this._refresh(e)}_handleScroll(e){if(this._lastScrollTop=this._viewportElement.scrollTop,!this._viewportElement.offsetParent)return;if(this._ignoreNextScrollEvent)return this._ignoreNextScrollEvent=!1,void this._onRequestScrollLines.fire({amount:0,suppressScrollEvent:!0});const t=Math.round(this._lastScrollTop/this._currentRowHeight)-this._bufferService.buffer.ydisp;this._onRequestScrollLines.fire({amount:t,suppressScrollEvent:!0})}_smoothScroll(){if(this._isDisposed||-1===this._smoothScrollState.origin||-1===this._smoothScrollState.target)return;const e=this._smoothScrollPercent();this._viewportElement.scrollTop=this._smoothScrollState.origin+Math.round(e*(this._smoothScrollState.target-this._smoothScrollState.origin)),e<1?this._coreBrowserService.window.requestAnimationFrame((()=>this._smoothScroll())):this._clearSmoothScrollState()}_smoothScrollPercent(){return this._optionsService.rawOptions.smoothScrollDuration&&this._smoothScrollState.startTime?Math.max(Math.min((Date.now()-this._smoothScrollState.startTime)/this._optionsService.rawOptions.smoothScrollDuration,1),0):1}_clearSmoothScrollState(){this._smoothScrollState.startTime=0,this._smoothScrollState.origin=-1,this._smoothScrollState.target=-1}_bubbleScroll(e,t){const i=this._viewportElement.scrollTop+this._lastRecordedViewportHeight;return!(t<0&&0!==this._viewportElement.scrollTop||t>0&&i0&&(s=e),r=""}}return{bufferElements:n,cursorElement:s}}getLinesScrolled(e){if(0===e.deltaY||e.shiftKey)return 0;let t=this._applyScrollModifier(e.deltaY,e);return e.deltaMode===WheelEvent.DOM_DELTA_PIXEL?(t/=this._currentRowHeight+0,this._wheelPartialScroll+=t,t=Math.floor(Math.abs(this._wheelPartialScroll))*(this._wheelPartialScroll>0?1:-1),this._wheelPartialScroll%=1):e.deltaMode===WheelEvent.DOM_DELTA_PAGE&&(t*=this._bufferService.rows),t}_applyScrollModifier(e,t){const i=this._optionsService.rawOptions.fastScrollModifier;return"alt"===i&&t.altKey||"ctrl"===i&&t.ctrlKey||"shift"===i&&t.shiftKey?e*this._optionsService.rawOptions.fastScrollSensitivity*this._optionsService.rawOptions.scrollSensitivity:e*this._optionsService.rawOptions.scrollSensitivity}handleTouchStart(e){this._lastTouchY=e.touches[0].pageY}handleTouchMove(e){const t=this._lastTouchY-e.touches[0].pageY;return this._lastTouchY=e.touches[0].pageY,0!==t&&(this._viewportElement.scrollTop+=t,this._bubbleScroll(e,t))}};t.Viewport=l=s([r(2,c.IBufferService),r(3,c.IOptionsService),r(4,o.ICharSizeService),r(5,o.IRenderService),r(6,o.ICoreBrowserService),r(7,o.IThemeService)],l)},3107:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferDecorationRenderer=void 0;const n=i(3656),o=i(4725),a=i(844),h=i(2585);let c=t.BufferDecorationRenderer=class extends a.Disposable{constructor(e,t,i,s){super(),this._screenElement=e,this._bufferService=t,this._decorationService=i,this._renderService=s,this._decorationElements=new Map,this._altBufferIsActive=!1,this._dimensionsChanged=!1,this._container=document.createElement("div"),this._container.classList.add("xterm-decoration-container"),this._screenElement.appendChild(this._container),this.register(this._renderService.onRenderedViewportChange((()=>this._doRefreshDecorations()))),this.register(this._renderService.onDimensionsChange((()=>{this._dimensionsChanged=!0,this._queueRefresh()}))),this.register((0,n.addDisposableDomListener)(window,"resize",(()=>this._queueRefresh()))),this.register(this._bufferService.buffers.onBufferActivate((()=>{this._altBufferIsActive=this._bufferService.buffer===this._bufferService.buffers.alt}))),this.register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh()))),this.register(this._decorationService.onDecorationRemoved((e=>this._removeDecoration(e)))),this.register((0,a.toDisposable)((()=>{this._container.remove(),this._decorationElements.clear()})))}_queueRefresh(){void 0===this._animationFrame&&(this._animationFrame=this._renderService.addRefreshCallback((()=>{this._doRefreshDecorations(),this._animationFrame=void 0})))}_doRefreshDecorations(){for(const e of this._decorationService.decorations)this._renderDecoration(e);this._dimensionsChanged=!1}_renderDecoration(e){this._refreshStyle(e),this._dimensionsChanged&&this._refreshXPosition(e)}_createElement(e){var t,i;const s=document.createElement("div");s.classList.add("xterm-decoration"),s.classList.toggle("xterm-decoration-top-layer","top"===(null===(t=null==e?void 0:e.options)||void 0===t?void 0:t.layer)),s.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,s.style.height=(e.options.height||1)*this._renderService.dimensions.css.cell.height+"px",s.style.top=(e.marker.line-this._bufferService.buffers.active.ydisp)*this._renderService.dimensions.css.cell.height+"px",s.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`;const r=null!==(i=e.options.x)&&void 0!==i?i:0;return r&&r>this._bufferService.cols&&(s.style.display="none"),this._refreshXPosition(e,s),s}_refreshStyle(e){const t=e.marker.line-this._bufferService.buffers.active.ydisp;if(t<0||t>=this._bufferService.rows)e.element&&(e.element.style.display="none",e.onRenderEmitter.fire(e.element));else{let i=this._decorationElements.get(e);i||(i=this._createElement(e),e.element=i,this._decorationElements.set(e,i),this._container.appendChild(i),e.onDispose((()=>{this._decorationElements.delete(e),i.remove()}))),i.style.top=t*this._renderService.dimensions.css.cell.height+"px",i.style.display=this._altBufferIsActive?"none":"block",e.onRenderEmitter.fire(i)}}_refreshXPosition(e,t=e.element){var i;if(!t)return;const s=null!==(i=e.options.x)&&void 0!==i?i:0;"right"===(e.options.anchor||"left")?t.style.right=s?s*this._renderService.dimensions.css.cell.width+"px":"":t.style.left=s?s*this._renderService.dimensions.css.cell.width+"px":""}_removeDecoration(e){var t;null===(t=this._decorationElements.get(e))||void 0===t||t.remove(),this._decorationElements.delete(e),e.dispose()}};t.BufferDecorationRenderer=c=s([r(1,h.IBufferService),r(2,h.IDecorationService),r(3,o.IRenderService)],c)},5871:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ColorZoneStore=void 0,t.ColorZoneStore=class{constructor(){this._zones=[],this._zonePool=[],this._zonePoolIndex=0,this._linePadding={full:0,left:0,center:0,right:0}}get zones(){return this._zonePool.length=Math.min(this._zonePool.length,this._zones.length),this._zones}clear(){this._zones.length=0,this._zonePoolIndex=0}addDecoration(e){if(e.options.overviewRulerOptions){for(const t of this._zones)if(t.color===e.options.overviewRulerOptions.color&&t.position===e.options.overviewRulerOptions.position){if(this._lineIntersectsZone(t,e.marker.line))return;if(this._lineAdjacentToZone(t,e.marker.line,e.options.overviewRulerOptions.position))return void this._addLineToZone(t,e.marker.line)}if(this._zonePoolIndex=e.startBufferLine&&t<=e.endBufferLine}_lineAdjacentToZone(e,t,i){return t>=e.startBufferLine-this._linePadding[i||"full"]&&t<=e.endBufferLine+this._linePadding[i||"full"]}_addLineToZone(e,t){e.startBufferLine=Math.min(e.startBufferLine,t),e.endBufferLine=Math.max(e.endBufferLine,t)}}},5744:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OverviewRulerRenderer=void 0;const n=i(5871),o=i(3656),a=i(4725),h=i(844),c=i(2585),l={full:0,left:0,center:0,right:0},d={full:0,left:0,center:0,right:0},_={full:0,left:0,center:0,right:0};let u=t.OverviewRulerRenderer=class extends h.Disposable{get _width(){return this._optionsService.options.overviewRulerWidth||0}constructor(e,t,i,s,r,o,a){var c;super(),this._viewportElement=e,this._screenElement=t,this._bufferService=i,this._decorationService=s,this._renderService=r,this._optionsService=o,this._coreBrowseService=a,this._colorZoneStore=new n.ColorZoneStore,this._shouldUpdateDimensions=!0,this._shouldUpdateAnchor=!0,this._lastKnownBufferLength=0,this._canvas=document.createElement("canvas"),this._canvas.classList.add("xterm-decoration-overview-ruler"),this._refreshCanvasDimensions(),null===(c=this._viewportElement.parentElement)||void 0===c||c.insertBefore(this._canvas,this._viewportElement);const l=this._canvas.getContext("2d");if(!l)throw new Error("Ctx cannot be null");this._ctx=l,this._registerDecorationListeners(),this._registerBufferChangeListeners(),this._registerDimensionChangeListeners(),this.register((0,h.toDisposable)((()=>{var e;null===(e=this._canvas)||void 0===e||e.remove()})))}_registerDecorationListeners(){this.register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh(void 0,!0)))),this.register(this._decorationService.onDecorationRemoved((()=>this._queueRefresh(void 0,!0))))}_registerBufferChangeListeners(){this.register(this._renderService.onRenderedViewportChange((()=>this._queueRefresh()))),this.register(this._bufferService.buffers.onBufferActivate((()=>{this._canvas.style.display=this._bufferService.buffer===this._bufferService.buffers.alt?"none":"block"}))),this.register(this._bufferService.onScroll((()=>{this._lastKnownBufferLength!==this._bufferService.buffers.normal.lines.length&&(this._refreshDrawHeightConstants(),this._refreshColorZonePadding())})))}_registerDimensionChangeListeners(){this.register(this._renderService.onRender((()=>{this._containerHeight&&this._containerHeight===this._screenElement.clientHeight||(this._queueRefresh(!0),this._containerHeight=this._screenElement.clientHeight)}))),this.register(this._optionsService.onSpecificOptionChange("overviewRulerWidth",(()=>this._queueRefresh(!0)))),this.register((0,o.addDisposableDomListener)(this._coreBrowseService.window,"resize",(()=>this._queueRefresh(!0)))),this._queueRefresh(!0)}_refreshDrawConstants(){const e=Math.floor(this._canvas.width/3),t=Math.ceil(this._canvas.width/3);d.full=this._canvas.width,d.left=e,d.center=t,d.right=e,this._refreshDrawHeightConstants(),_.full=0,_.left=0,_.center=d.left,_.right=d.left+d.center}_refreshDrawHeightConstants(){l.full=Math.round(2*this._coreBrowseService.dpr);const e=this._canvas.height/this._bufferService.buffer.lines.length,t=Math.round(Math.max(Math.min(e,12),6)*this._coreBrowseService.dpr);l.left=t,l.center=t,l.right=t}_refreshColorZonePadding(){this._colorZoneStore.setPadding({full:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.full),left:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.left),center:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.center),right:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.right)}),this._lastKnownBufferLength=this._bufferService.buffers.normal.lines.length}_refreshCanvasDimensions(){this._canvas.style.width=`${this._width}px`,this._canvas.width=Math.round(this._width*this._coreBrowseService.dpr),this._canvas.style.height=`${this._screenElement.clientHeight}px`,this._canvas.height=Math.round(this._screenElement.clientHeight*this._coreBrowseService.dpr),this._refreshDrawConstants(),this._refreshColorZonePadding()}_refreshDecorations(){this._shouldUpdateDimensions&&this._refreshCanvasDimensions(),this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this._colorZoneStore.clear();for(const e of this._decorationService.decorations)this._colorZoneStore.addDecoration(e);this._ctx.lineWidth=1;const e=this._colorZoneStore.zones;for(const t of e)"full"!==t.position&&this._renderColorZone(t);for(const t of e)"full"===t.position&&this._renderColorZone(t);this._shouldUpdateDimensions=!1,this._shouldUpdateAnchor=!1}_renderColorZone(e){this._ctx.fillStyle=e.color,this._ctx.fillRect(_[e.position||"full"],Math.round((this._canvas.height-1)*(e.startBufferLine/this._bufferService.buffers.active.lines.length)-l[e.position||"full"]/2),d[e.position||"full"],Math.round((this._canvas.height-1)*((e.endBufferLine-e.startBufferLine)/this._bufferService.buffers.active.lines.length)+l[e.position||"full"]))}_queueRefresh(e,t){this._shouldUpdateDimensions=e||this._shouldUpdateDimensions,this._shouldUpdateAnchor=t||this._shouldUpdateAnchor,void 0===this._animationFrame&&(this._animationFrame=this._coreBrowseService.window.requestAnimationFrame((()=>{this._refreshDecorations(),this._animationFrame=void 0})))}};t.OverviewRulerRenderer=u=s([r(2,c.IBufferService),r(3,c.IDecorationService),r(4,a.IRenderService),r(5,c.IOptionsService),r(6,a.ICoreBrowserService)],u)},2950:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CompositionHelper=void 0;const n=i(4725),o=i(2585),a=i(2584);let h=t.CompositionHelper=class{get isComposing(){return this._isComposing}constructor(e,t,i,s,r,n){this._textarea=e,this._compositionView=t,this._bufferService=i,this._optionsService=s,this._coreService=r,this._renderService=n,this._isComposing=!1,this._isSendingComposition=!1,this._compositionPosition={start:0,end:0},this._dataAlreadySent=""}compositionstart(){this._isComposing=!0,this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent="",this._dataAlreadySent="",this._compositionView.classList.add("active")}compositionupdate(e){this._compositionView.textContent=e.data,this.updateCompositionElements(),setTimeout((()=>{this._compositionPosition.end=this._textarea.value.length}),0)}compositionend(){this._finalizeComposition(!0)}keydown(e){if(this._isComposing||this._isSendingComposition){if(229===e.keyCode)return!1;if(16===e.keyCode||17===e.keyCode||18===e.keyCode)return!1;this._finalizeComposition(!1)}return 229!==e.keyCode||(this._handleAnyTextareaChanges(),!1)}_finalizeComposition(e){if(this._compositionView.classList.remove("active"),this._isComposing=!1,e){const e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0,setTimeout((()=>{if(this._isSendingComposition){let t;this._isSendingComposition=!1,e.start+=this._dataAlreadySent.length,t=this._isComposing?this._textarea.value.substring(e.start,e.end):this._textarea.value.substring(e.start),t.length>0&&this._coreService.triggerDataEvent(t,!0)}}),0)}else{this._isSendingComposition=!1;const e=this._textarea.value.substring(this._compositionPosition.start,this._compositionPosition.end);this._coreService.triggerDataEvent(e,!0)}}_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.lengththis.updateCompositionElements(!0)),0)}}};t.CompositionHelper=h=s([r(2,o.IBufferService),r(3,o.IOptionsService),r(4,o.ICoreService),r(5,n.IRenderService)],h)},9806:(e,t)=>{function i(e,t,i){const s=i.getBoundingClientRect(),r=e.getComputedStyle(i),n=parseInt(r.getPropertyValue("padding-left")),o=parseInt(r.getPropertyValue("padding-top"));return[t.clientX-s.left-n,t.clientY-s.top-o]}Object.defineProperty(t,"__esModule",{value:!0}),t.getCoords=t.getCoordsRelativeToElement=void 0,t.getCoordsRelativeToElement=i,t.getCoords=function(e,t,s,r,n,o,a,h,c){if(!o)return;const l=i(e,t,s);return l?(l[0]=Math.ceil((l[0]+(c?a/2:0))/a),l[1]=Math.ceil(l[1]/h),l[0]=Math.min(Math.max(l[0],1),r+(c?1:0)),l[1]=Math.min(Math.max(l[1],1),n),l):void 0}},9504:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.moveToCellSequence=void 0;const s=i(2584);function r(e,t,i,s){const r=e-n(e,i),a=t-n(t,i),l=Math.abs(r-a)-function(e,t,i){let s=0;const r=e-n(e,i),a=t-n(t,i);for(let n=0;n=0&&et?"A":"B"}function a(e,t,i,s,r,n){let o=e,a=t,h="";for(;o!==i||a!==s;)o+=r?1:-1,r&&o>n.cols-1?(h+=n.buffer.translateBufferLineToString(a,!1,e,o),o=0,e=0,a++):!r&&o<0&&(h+=n.buffer.translateBufferLineToString(a,!1,0,e+1),o=n.cols-1,e=o,a--);return h+n.buffer.translateBufferLineToString(a,!1,e,o)}function h(e,t){const i=t?"O":"[";return s.C0.ESC+i+e}function c(e,t){e=Math.floor(e);let i="";for(let s=0;s0?s-n(s,o):t;const _=s,u=function(e,t,i,s,o,a){let h;return h=r(i,s,o,a).length>0?s-n(s,o):t,e=i&&he?"D":"C",c(Math.abs(o-e),h(d,s));d=l>t?"D":"C";const _=Math.abs(l-t);return c(function(e,t){return t.cols-e}(l>t?e:o,i)+(_-1)*i.cols+1+((l>t?o:e)-1),h(d,s))}},1296:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRenderer=void 0;const n=i(3787),o=i(2550),a=i(2223),h=i(6171),c=i(4725),l=i(8055),d=i(8460),_=i(844),u=i(2585),f="xterm-dom-renderer-owner-",v="xterm-rows",p="xterm-fg-",g="xterm-bg-",m="xterm-focus",S="xterm-selection";let C=1,b=t.DomRenderer=class extends _.Disposable{constructor(e,t,i,s,r,a,c,l,u,p){super(),this._element=e,this._screenElement=t,this._viewportElement=i,this._linkifier2=s,this._charSizeService=a,this._optionsService=c,this._bufferService=l,this._coreBrowserService=u,this._themeService=p,this._terminalClass=C++,this._rowElements=[],this.onRequestRedraw=this.register(new d.EventEmitter).event,this._rowContainer=document.createElement("div"),this._rowContainer.classList.add(v),this._rowContainer.style.lineHeight="normal",this._rowContainer.setAttribute("aria-hidden","true"),this._refreshRowElements(this._bufferService.cols,this._bufferService.rows),this._selectionContainer=document.createElement("div"),this._selectionContainer.classList.add(S),this._selectionContainer.setAttribute("aria-hidden","true"),this.dimensions=(0,h.createRenderDimensions)(),this._updateDimensions(),this.register(this._optionsService.onOptionChange((()=>this._handleOptionsChanged()))),this.register(this._themeService.onChangeColors((e=>this._injectCss(e)))),this._injectCss(this._themeService.colors),this._rowFactory=r.createInstance(n.DomRendererRowFactory,document),this._element.classList.add(f+this._terminalClass),this._screenElement.appendChild(this._rowContainer),this._screenElement.appendChild(this._selectionContainer),this.register(this._linkifier2.onShowLinkUnderline((e=>this._handleLinkHover(e)))),this.register(this._linkifier2.onHideLinkUnderline((e=>this._handleLinkLeave(e)))),this.register((0,_.toDisposable)((()=>{this._element.classList.remove(f+this._terminalClass),this._rowContainer.remove(),this._selectionContainer.remove(),this._widthCache.dispose(),this._themeStyleElement.remove(),this._dimensionsStyleElement.remove()}))),this._widthCache=new o.WidthCache(document),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}_updateDimensions(){const e=this._coreBrowserService.dpr;this.dimensions.device.char.width=this._charSizeService.width*e,this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*e),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.left=0,this.dimensions.device.char.top=0,this.dimensions.device.canvas.width=this.dimensions.device.cell.width*this._bufferService.cols,this.dimensions.device.canvas.height=this.dimensions.device.cell.height*this._bufferService.rows,this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/e),this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/e),this.dimensions.css.cell.width=this.dimensions.css.canvas.width/this._bufferService.cols,this.dimensions.css.cell.height=this.dimensions.css.canvas.height/this._bufferService.rows;for(const e of this._rowElements)e.style.width=`${this.dimensions.css.canvas.width}px`,e.style.height=`${this.dimensions.css.cell.height}px`,e.style.lineHeight=`${this.dimensions.css.cell.height}px`,e.style.overflow="hidden";this._dimensionsStyleElement||(this._dimensionsStyleElement=document.createElement("style"),this._screenElement.appendChild(this._dimensionsStyleElement));const t=`${this._terminalSelector} .${v} span { display: inline-block; height: 100%; vertical-align: top;}`;this._dimensionsStyleElement.textContent=t,this._selectionContainer.style.height=this._viewportElement.style.height,this._screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._screenElement.style.height=`${this.dimensions.css.canvas.height}px`}_injectCss(e){this._themeStyleElement||(this._themeStyleElement=document.createElement("style"),this._screenElement.appendChild(this._themeStyleElement));let t=`${this._terminalSelector} .${v} { color: ${e.foreground.css}; font-family: ${this._optionsService.rawOptions.fontFamily}; font-size: ${this._optionsService.rawOptions.fontSize}px; font-kerning: none; white-space: pre}`;t+=`${this._terminalSelector} .${v} .xterm-dim { color: ${l.color.multiplyOpacity(e.foreground,.5).css};}`,t+=`${this._terminalSelector} span:not(.xterm-bold) { font-weight: ${this._optionsService.rawOptions.fontWeight};}${this._terminalSelector} span.xterm-bold { font-weight: ${this._optionsService.rawOptions.fontWeightBold};}${this._terminalSelector} span.xterm-italic { font-style: italic;}`,t+="@keyframes blink_box_shadow_"+this._terminalClass+" { 50% { border-bottom-style: hidden; }}",t+="@keyframes blink_block_"+this._terminalClass+" { 0% {"+` background-color: ${e.cursor.css};`+` color: ${e.cursorAccent.css}; } 50% { background-color: inherit;`+` color: ${e.cursor.css}; }}`,t+=`${this._terminalSelector} .${v}.${m} .xterm-cursor.xterm-cursor-blink:not(.xterm-cursor-block) { animation: blink_box_shadow_`+this._terminalClass+" 1s step-end infinite;}"+`${this._terminalSelector} .${v}.${m} .xterm-cursor.xterm-cursor-blink.xterm-cursor-block { animation: blink_block_`+this._terminalClass+" 1s step-end infinite;}"+`${this._terminalSelector} .${v} .xterm-cursor.xterm-cursor-block {`+` background-color: ${e.cursor.css};`+` color: ${e.cursorAccent.css};}`+`${this._terminalSelector} .${v} .xterm-cursor.xterm-cursor-outline {`+` outline: 1px solid ${e.cursor.css}; outline-offset: -1px;}`+`${this._terminalSelector} .${v} .xterm-cursor.xterm-cursor-bar {`+` box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${e.cursor.css} inset;}`+`${this._terminalSelector} .${v} .xterm-cursor.xterm-cursor-underline {`+` border-bottom: 1px ${e.cursor.css}; border-bottom-style: solid; height: calc(100% - 1px);}`,t+=`${this._terminalSelector} .${S} { position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;}${this._terminalSelector}.focus .${S} div { position: absolute; background-color: ${e.selectionBackgroundOpaque.css};}${this._terminalSelector} .${S} div { position: absolute; background-color: ${e.selectionInactiveBackgroundOpaque.css};}`;for(const[i,s]of e.ansi.entries())t+=`${this._terminalSelector} .${p}${i} { color: ${s.css}; }${this._terminalSelector} .${p}${i}.xterm-dim { color: ${l.color.multiplyOpacity(s,.5).css}; }${this._terminalSelector} .${g}${i} { background-color: ${s.css}; }`;t+=`${this._terminalSelector} .${p}${a.INVERTED_DEFAULT_COLOR} { color: ${l.color.opaque(e.background).css}; }${this._terminalSelector} .${p}${a.INVERTED_DEFAULT_COLOR}.xterm-dim { color: ${l.color.multiplyOpacity(l.color.opaque(e.background),.5).css}; }${this._terminalSelector} .${g}${a.INVERTED_DEFAULT_COLOR} { background-color: ${e.foreground.css}; }`,this._themeStyleElement.textContent=t}_setDefaultSpacing(){const e=this.dimensions.css.cell.width-this._widthCache.get("W",!1,!1);this._rowContainer.style.letterSpacing=`${e}px`,this._rowFactory.defaultSpacing=e}handleDevicePixelRatioChange(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}_refreshRowElements(e,t){for(let e=this._rowElements.length;e<=t;e++){const e=document.createElement("div");this._rowContainer.appendChild(e),this._rowElements.push(e)}for(;this._rowElements.length>t;)this._rowContainer.removeChild(this._rowElements.pop())}handleResize(e,t){this._refreshRowElements(e,t),this._updateDimensions()}handleCharSizeChanged(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}handleBlur(){this._rowContainer.classList.remove(m)}handleFocus(){this._rowContainer.classList.add(m),this.renderRows(this._bufferService.buffer.y,this._bufferService.buffer.y)}handleSelectionChanged(e,t,i){if(this._selectionContainer.replaceChildren(),this._rowFactory.handleSelectionChanged(e,t,i),this.renderRows(0,this._bufferService.rows-1),!e||!t)return;const s=e[1]-this._bufferService.buffer.ydisp,r=t[1]-this._bufferService.buffer.ydisp,n=Math.max(s,0),o=Math.min(r,this._bufferService.rows-1);if(n>=this._bufferService.rows||o<0)return;const a=document.createDocumentFragment();if(i){const i=e[0]>t[0];a.appendChild(this._createSelectionElement(n,i?t[0]:e[0],i?e[0]:t[0],o-n+1))}else{const i=s===n?e[0]:0,h=n===r?t[0]:this._bufferService.cols;a.appendChild(this._createSelectionElement(n,i,h));const c=o-n-1;if(a.appendChild(this._createSelectionElement(n+1,0,this._bufferService.cols,c)),n!==o){const e=r===o?t[0]:this._bufferService.cols;a.appendChild(this._createSelectionElement(o,0,e))}}this._selectionContainer.appendChild(a)}_createSelectionElement(e,t,i,s=1){const r=document.createElement("div");return r.style.height=s*this.dimensions.css.cell.height+"px",r.style.top=e*this.dimensions.css.cell.height+"px",r.style.left=t*this.dimensions.css.cell.width+"px",r.style.width=this.dimensions.css.cell.width*(i-t)+"px",r}handleCursorMove(){}_handleOptionsChanged(){this._updateDimensions(),this._injectCss(this._themeService.colors),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}clear(){for(const e of this._rowElements)e.replaceChildren()}renderRows(e,t){const i=this._bufferService.buffer,s=i.ybase+i.y,r=Math.min(i.x,this._bufferService.cols-1),n=this._optionsService.rawOptions.cursorBlink,o=this._optionsService.rawOptions.cursorStyle,a=this._optionsService.rawOptions.cursorInactiveStyle;for(let h=e;h<=t;h++){const e=h+i.ydisp,t=this._rowElements[h],c=i.lines.get(e);if(!t||!c)break;t.replaceChildren(...this._rowFactory.createRow(c,e,e===s,o,a,r,n,this.dimensions.css.cell.width,this._widthCache,-1,-1))}}get _terminalSelector(){return`.${f}${this._terminalClass}`}_handleLinkHover(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!0)}_handleLinkLeave(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!1)}_setCellUnderline(e,t,i,s,r,n){i<0&&(e=0),s<0&&(t=0);const o=this._bufferService.rows-1;i=Math.max(Math.min(i,o),0),s=Math.max(Math.min(s,o),0),r=Math.min(r,this._bufferService.cols);const a=this._bufferService.buffer,h=a.ybase+a.y,c=Math.min(a.x,r-1),l=this._optionsService.rawOptions.cursorBlink,d=this._optionsService.rawOptions.cursorStyle,_=this._optionsService.rawOptions.cursorInactiveStyle;for(let o=i;o<=s;++o){const u=o+a.ydisp,f=this._rowElements[o],v=a.lines.get(u);if(!f||!v)break;f.replaceChildren(...this._rowFactory.createRow(v,u,u===h,d,_,c,l,this.dimensions.css.cell.width,this._widthCache,n?o===i?e:0:-1,n?(o===s?t:r)-1:-1))}}};t.DomRenderer=b=s([r(4,u.IInstantiationService),r(5,c.ICharSizeService),r(6,u.IOptionsService),r(7,u.IBufferService),r(8,c.ICoreBrowserService),r(9,c.IThemeService)],b)},3787:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRendererRowFactory=void 0;const n=i(2223),o=i(643),a=i(511),h=i(2585),c=i(8055),l=i(4725),d=i(4269),_=i(6171),u=i(3734);let f=t.DomRendererRowFactory=class{constructor(e,t,i,s,r,n,o){this._document=e,this._characterJoinerService=t,this._optionsService=i,this._coreBrowserService=s,this._coreService=r,this._decorationService=n,this._themeService=o,this._workCell=new a.CellData,this._columnSelectMode=!1,this.defaultSpacing=0}handleSelectionChanged(e,t,i){this._selectionStart=e,this._selectionEnd=t,this._columnSelectMode=i}createRow(e,t,i,s,r,a,h,l,_,f,p){const g=[],m=this._characterJoinerService.getJoinedCharacters(t),S=this._themeService.colors;let C,b=e.getNoBgTrimmedLength();i&&b0&&M===m[0][0]){O=!0;const t=m.shift();I=new d.JoinedCellData(this._workCell,e.translateToString(!0,t[0],t[1]),t[1]-t[0]),P=t[1]-1,b=I.getWidth()}const H=this._isCellInSelection(M,t),F=i&&M===a,W=T&&M>=f&&M<=p;let U=!1;this._decorationService.forEachDecorationAtCell(M,t,void 0,(e=>{U=!0}));let N=I.getChars()||o.WHITESPACE_CELL_CHAR;if(" "===N&&(I.isUnderline()||I.isOverline())&&(N=" "),A=b*l-_.get(N,I.isBold(),I.isItalic()),C){if(y&&(H&&x||!H&&!x&&I.bg===E)&&(H&&x&&S.selectionForeground||I.fg===k)&&I.extended.ext===L&&W===D&&A===R&&!F&&!O&&!U){w+=N,y++;continue}y&&(C.textContent=w),C=this._document.createElement("span"),y=0,w=""}else C=this._document.createElement("span");if(E=I.bg,k=I.fg,L=I.extended.ext,D=W,R=A,x=H,O&&a>=M&&a<=P&&(a=M),!this._coreService.isCursorHidden&&F)if(B.push("xterm-cursor"),this._coreBrowserService.isFocused)h&&B.push("xterm-cursor-blink"),B.push("bar"===s?"xterm-cursor-bar":"underline"===s?"xterm-cursor-underline":"xterm-cursor-block");else if(r)switch(r){case"outline":B.push("xterm-cursor-outline");break;case"block":B.push("xterm-cursor-block");break;case"bar":B.push("xterm-cursor-bar");break;case"underline":B.push("xterm-cursor-underline")}if(I.isBold()&&B.push("xterm-bold"),I.isItalic()&&B.push("xterm-italic"),I.isDim()&&B.push("xterm-dim"),w=I.isInvisible()?o.WHITESPACE_CELL_CHAR:I.getChars()||o.WHITESPACE_CELL_CHAR,I.isUnderline()&&(B.push(`xterm-underline-${I.extended.underlineStyle}`)," "===w&&(w=" "),!I.isUnderlineColorDefault()))if(I.isUnderlineColorRGB())C.style.textDecorationColor=`rgb(${u.AttributeData.toColorRGB(I.getUnderlineColor()).join(",")})`;else{let e=I.getUnderlineColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&I.isBold()&&e<8&&(e+=8),C.style.textDecorationColor=S.ansi[e].css}I.isOverline()&&(B.push("xterm-overline")," "===w&&(w=" ")),I.isStrikethrough()&&B.push("xterm-strikethrough"),W&&(C.style.textDecoration="underline");let $=I.getFgColor(),j=I.getFgColorMode(),z=I.getBgColor(),K=I.getBgColorMode();const q=!!I.isInverse();if(q){const e=$;$=z,z=e;const t=j;j=K,K=t}let V,G,X,J=!1;switch(this._decorationService.forEachDecorationAtCell(M,t,void 0,(e=>{"top"!==e.options.layer&&J||(e.backgroundColorRGB&&(K=50331648,z=e.backgroundColorRGB.rgba>>8&16777215,V=e.backgroundColorRGB),e.foregroundColorRGB&&(j=50331648,$=e.foregroundColorRGB.rgba>>8&16777215,G=e.foregroundColorRGB),J="top"===e.options.layer)})),!J&&H&&(V=this._coreBrowserService.isFocused?S.selectionBackgroundOpaque:S.selectionInactiveBackgroundOpaque,z=V.rgba>>8&16777215,K=50331648,J=!0,S.selectionForeground&&(j=50331648,$=S.selectionForeground.rgba>>8&16777215,G=S.selectionForeground)),J&&B.push("xterm-decoration-top"),K){case 16777216:case 33554432:X=S.ansi[z],B.push(`xterm-bg-${z}`);break;case 50331648:X=c.rgba.toColor(z>>16,z>>8&255,255&z),this._addStyle(C,`background-color:#${v((z>>>0).toString(16),"0",6)}`);break;default:q?(X=S.foreground,B.push(`xterm-bg-${n.INVERTED_DEFAULT_COLOR}`)):X=S.background}switch(V||I.isDim()&&(V=c.color.multiplyOpacity(X,.5)),j){case 16777216:case 33554432:I.isBold()&&$<8&&this._optionsService.rawOptions.drawBoldTextInBrightColors&&($+=8),this._applyMinimumContrast(C,X,S.ansi[$],I,V,void 0)||B.push(`xterm-fg-${$}`);break;case 50331648:const e=c.rgba.toColor($>>16&255,$>>8&255,255&$);this._applyMinimumContrast(C,X,e,I,V,G)||this._addStyle(C,`color:#${v($.toString(16),"0",6)}`);break;default:this._applyMinimumContrast(C,X,S.foreground,I,V,void 0)||q&&B.push(`xterm-fg-${n.INVERTED_DEFAULT_COLOR}`)}B.length&&(C.className=B.join(" "),B.length=0),F||O||U?C.textContent=w:y++,A!==this.defaultSpacing&&(C.style.letterSpacing=`${A}px`),g.push(C),M=P}return C&&y&&(C.textContent=w),g}_applyMinimumContrast(e,t,i,s,r,n){if(1===this._optionsService.rawOptions.minimumContrastRatio||(0,_.excludeFromContrastRatioDemands)(s.getCode()))return!1;const o=this._getContrastCache(s);let a;if(r||n||(a=o.getColor(t.rgba,i.rgba)),void 0===a){const e=this._optionsService.rawOptions.minimumContrastRatio/(s.isDim()?2:1);a=c.color.ensureContrastRatio(r||t,n||i,e),o.setColor((r||t).rgba,(n||i).rgba,null!=a?a:null)}return!!a&&(this._addStyle(e,`color:${a.css}`),!0)}_getContrastCache(e){return e.isDim()?this._themeService.colors.halfContrastCache:this._themeService.colors.contrastCache}_addStyle(e,t){e.setAttribute("style",`${e.getAttribute("style")||""}${t};`)}_isCellInSelection(e,t){const i=this._selectionStart,s=this._selectionEnd;return!(!i||!s)&&(this._columnSelectMode?i[0]<=s[0]?e>=i[0]&&t>=i[1]&&e=i[1]&&e>=s[0]&&t<=s[1]:t>i[1]&&t=i[0]&&e=i[0])}};function v(e,t,i){for(;e.length{Object.defineProperty(t,"__esModule",{value:!0}),t.WidthCache=void 0,t.WidthCache=class{constructor(e){this._flat=new Float32Array(256),this._font="",this._fontSize=0,this._weight="normal",this._weightBold="bold",this._measureElements=[],this._container=e.createElement("div"),this._container.style.position="absolute",this._container.style.top="-50000px",this._container.style.width="50000px",this._container.style.whiteSpace="pre",this._container.style.fontKerning="none";const t=e.createElement("span"),i=e.createElement("span");i.style.fontWeight="bold";const s=e.createElement("span");s.style.fontStyle="italic";const r=e.createElement("span");r.style.fontWeight="bold",r.style.fontStyle="italic",this._measureElements=[t,i,s,r],this._container.appendChild(t),this._container.appendChild(i),this._container.appendChild(s),this._container.appendChild(r),e.body.appendChild(this._container),this.clear()}dispose(){this._container.remove(),this._measureElements.length=0,this._holey=void 0}clear(){this._flat.fill(-9999),this._holey=new Map}setFont(e,t,i,s){e===this._font&&t===this._fontSize&&i===this._weight&&s===this._weightBold||(this._font=e,this._fontSize=t,this._weight=i,this._weightBold=s,this._container.style.fontFamily=this._font,this._container.style.fontSize=`${this._fontSize}px`,this._measureElements[0].style.fontWeight=`${i}`,this._measureElements[1].style.fontWeight=`${s}`,this._measureElements[2].style.fontWeight=`${i}`,this._measureElements[3].style.fontWeight=`${s}`,this.clear())}get(e,t,i){let s=0;if(!t&&!i&&1===e.length&&(s=e.charCodeAt(0))<256)return-9999!==this._flat[s]?this._flat[s]:this._flat[s]=this._measure(e,0);let r=e;t&&(r+="B"),i&&(r+="I");let n=this._holey.get(r);if(void 0===n){let s=0;t&&(s|=1),i&&(s|=2),n=this._measure(e,s),this._holey.set(r,n)}return n}_measure(e,t){const i=this._measureElements[t];return i.textContent=e.repeat(32),i.offsetWidth/32}}},2223:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.TEXT_BASELINE=t.DIM_OPACITY=t.INVERTED_DEFAULT_COLOR=void 0;const s=i(6114);t.INVERTED_DEFAULT_COLOR=257,t.DIM_OPACITY=.5,t.TEXT_BASELINE=s.isFirefox||s.isLegacyEdge?"bottom":"ideographic"},6171:(e,t)=>{function i(e){return 57508<=e&&e<=57558}Object.defineProperty(t,"__esModule",{value:!0}),t.createRenderDimensions=t.excludeFromContrastRatioDemands=t.isRestrictedPowerlineGlyph=t.isPowerlineGlyph=t.throwIfFalsy=void 0,t.throwIfFalsy=function(e){if(!e)throw new Error("value must not be falsy");return e},t.isPowerlineGlyph=i,t.isRestrictedPowerlineGlyph=function(e){return 57520<=e&&e<=57527},t.excludeFromContrastRatioDemands=function(e){return i(e)||function(e){return 9472<=e&&e<=9631}(e)},t.createRenderDimensions=function(){return{css:{canvas:{width:0,height:0},cell:{width:0,height:0}},device:{canvas:{width:0,height:0},cell:{width:0,height:0},char:{width:0,height:0,left:0,top:0}}}}},456:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionModel=void 0,t.SelectionModel=class{constructor(e){this._bufferService=e,this.isSelectAllActive=!1,this.selectionStartLength=0}clearSelection(){this.selectionStart=void 0,this.selectionEnd=void 0,this.isSelectAllActive=!1,this.selectionStartLength=0}get finalSelectionStart(){return this.isSelectAllActive?[0,0]:this.selectionEnd&&this.selectionStart&&this.areSelectionValuesReversed()?this.selectionEnd:this.selectionStart}get finalSelectionEnd(){if(this.isSelectAllActive)return[this._bufferService.cols,this._bufferService.buffer.ybase+this._bufferService.rows-1];if(this.selectionStart){if(!this.selectionEnd||this.areSelectionValuesReversed()){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?e%this._bufferService.cols==0?[this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)-1]:[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[e,this.selectionStart[1]]}if(this.selectionStartLength&&this.selectionEnd[1]===this.selectionStart[1]){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[Math.max(e,this.selectionEnd[0]),this.selectionEnd[1]]}return this.selectionEnd}}areSelectionValuesReversed(){const e=this.selectionStart,t=this.selectionEnd;return!(!e||!t)&&(e[1]>t[1]||e[1]===t[1]&&e[0]>t[0])}handleTrim(e){return this.selectionStart&&(this.selectionStart[1]-=e),this.selectionEnd&&(this.selectionEnd[1]-=e),this.selectionEnd&&this.selectionEnd[1]<0?(this.clearSelection(),!0):(this.selectionStart&&this.selectionStart[1]<0&&(this.selectionStart[1]=0),!1)}}},428:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharSizeService=void 0;const n=i(2585),o=i(8460),a=i(844);let h=t.CharSizeService=class extends a.Disposable{get hasValidSize(){return this.width>0&&this.height>0}constructor(e,t,i){super(),this._optionsService=i,this.width=0,this.height=0,this._onCharSizeChange=this.register(new o.EventEmitter),this.onCharSizeChange=this._onCharSizeChange.event,this._measureStrategy=new c(e,t,this._optionsService),this.register(this._optionsService.onMultipleOptionChange(["fontFamily","fontSize"],(()=>this.measure())))}measure(){const e=this._measureStrategy.measure();e.width===this.width&&e.height===this.height||(this.width=e.width,this.height=e.height,this._onCharSizeChange.fire())}};t.CharSizeService=h=s([r(2,n.IOptionsService)],h);class c{constructor(e,t,i){this._document=e,this._parentElement=t,this._optionsService=i,this._result={width:0,height:0},this._measureElement=this._document.createElement("span"),this._measureElement.classList.add("xterm-char-measure-element"),this._measureElement.textContent="W".repeat(32),this._measureElement.setAttribute("aria-hidden","true"),this._measureElement.style.whiteSpace="pre",this._measureElement.style.fontKerning="none",this._parentElement.appendChild(this._measureElement)}measure(){this._measureElement.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._measureElement.style.fontSize=`${this._optionsService.rawOptions.fontSize}px`;const e={height:Number(this._measureElement.offsetHeight),width:Number(this._measureElement.offsetWidth)};return 0!==e.width&&0!==e.height&&(this._result.width=e.width/32,this._result.height=Math.ceil(e.height)),this._result}}},4269:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharacterJoinerService=t.JoinedCellData=void 0;const n=i(3734),o=i(643),a=i(511),h=i(2585);class c extends n.AttributeData{constructor(e,t,i){super(),this.content=0,this.combinedData="",this.fg=e.fg,this.bg=e.bg,this.combinedData=t,this._width=i}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(e){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.JoinedCellData=c;let l=t.CharacterJoinerService=class e{constructor(e){this._bufferService=e,this._characterJoiners=[],this._nextCharacterJoinerId=0,this._workCell=new a.CellData}register(e){const t={id:this._nextCharacterJoinerId++,handler:e};return this._characterJoiners.push(t),t.id}deregister(e){for(let t=0;t1){const e=this._getJoinedRanges(s,a,n,t,r);for(let t=0;t1){const e=this._getJoinedRanges(s,a,n,t,r);for(let t=0;t{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreBrowserService=void 0,t.CoreBrowserService=class{constructor(e,t){this._textarea=e,this.window=t,this._isFocused=!1,this._cachedIsFocused=void 0,this._textarea.addEventListener("focus",(()=>this._isFocused=!0)),this._textarea.addEventListener("blur",(()=>this._isFocused=!1))}get dpr(){return this.window.devicePixelRatio}get isFocused(){return void 0===this._cachedIsFocused&&(this._cachedIsFocused=this._isFocused&&this._textarea.ownerDocument.hasFocus(),queueMicrotask((()=>this._cachedIsFocused=void 0))),this._cachedIsFocused}}},8934:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.MouseService=void 0;const n=i(4725),o=i(9806);let a=t.MouseService=class{constructor(e,t){this._renderService=e,this._charSizeService=t}getCoords(e,t,i,s,r){return(0,o.getCoords)(window,e,t,i,s,this._charSizeService.hasValidSize,this._renderService.dimensions.css.cell.width,this._renderService.dimensions.css.cell.height,r)}getMouseReportCoords(e,t){const i=(0,o.getCoordsRelativeToElement)(window,e,t);if(this._charSizeService.hasValidSize)return i[0]=Math.min(Math.max(i[0],0),this._renderService.dimensions.css.canvas.width-1),i[1]=Math.min(Math.max(i[1],0),this._renderService.dimensions.css.canvas.height-1),{col:Math.floor(i[0]/this._renderService.dimensions.css.cell.width),row:Math.floor(i[1]/this._renderService.dimensions.css.cell.height),x:Math.floor(i[0]),y:Math.floor(i[1])}}};t.MouseService=a=s([r(0,n.IRenderService),r(1,n.ICharSizeService)],a)},3230:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.RenderService=void 0;const n=i(3656),o=i(6193),a=i(5596),h=i(4725),c=i(8460),l=i(844),d=i(7226),_=i(2585);let u=t.RenderService=class extends l.Disposable{get dimensions(){return this._renderer.value.dimensions}constructor(e,t,i,s,r,h,_,u){if(super(),this._rowCount=e,this._charSizeService=s,this._renderer=this.register(new l.MutableDisposable),this._pausedResizeTask=new d.DebouncedIdleTask,this._isPaused=!1,this._needsFullRefresh=!1,this._isNextRenderRedrawOnly=!0,this._needsSelectionRefresh=!1,this._canvasWidth=0,this._canvasHeight=0,this._selectionState={start:void 0,end:void 0,columnSelectMode:!1},this._onDimensionsChange=this.register(new c.EventEmitter),this.onDimensionsChange=this._onDimensionsChange.event,this._onRenderedViewportChange=this.register(new c.EventEmitter),this.onRenderedViewportChange=this._onRenderedViewportChange.event,this._onRender=this.register(new c.EventEmitter),this.onRender=this._onRender.event,this._onRefreshRequest=this.register(new c.EventEmitter),this.onRefreshRequest=this._onRefreshRequest.event,this._renderDebouncer=new o.RenderDebouncer(_.window,((e,t)=>this._renderRows(e,t))),this.register(this._renderDebouncer),this._screenDprMonitor=new a.ScreenDprMonitor(_.window),this._screenDprMonitor.setListener((()=>this.handleDevicePixelRatioChange())),this.register(this._screenDprMonitor),this.register(h.onResize((()=>this._fullRefresh()))),this.register(h.buffers.onBufferActivate((()=>{var e;return null===(e=this._renderer.value)||void 0===e?void 0:e.clear()}))),this.register(i.onOptionChange((()=>this._handleOptionsChanged()))),this.register(this._charSizeService.onCharSizeChange((()=>this.handleCharSizeChanged()))),this.register(r.onDecorationRegistered((()=>this._fullRefresh()))),this.register(r.onDecorationRemoved((()=>this._fullRefresh()))),this.register(i.onMultipleOptionChange(["customGlyphs","drawBoldTextInBrightColors","letterSpacing","lineHeight","fontFamily","fontSize","fontWeight","fontWeightBold","minimumContrastRatio"],(()=>{this.clear(),this.handleResize(h.cols,h.rows),this._fullRefresh()}))),this.register(i.onMultipleOptionChange(["cursorBlink","cursorStyle"],(()=>this.refreshRows(h.buffer.y,h.buffer.y,!0)))),this.register((0,n.addDisposableDomListener)(_.window,"resize",(()=>this.handleDevicePixelRatioChange()))),this.register(u.onChangeColors((()=>this._fullRefresh()))),"IntersectionObserver"in _.window){const e=new _.window.IntersectionObserver((e=>this._handleIntersectionChange(e[e.length-1])),{threshold:0});e.observe(t),this.register({dispose:()=>e.disconnect()})}}_handleIntersectionChange(e){this._isPaused=void 0===e.isIntersecting?0===e.intersectionRatio:!e.isIntersecting,this._isPaused||this._charSizeService.hasValidSize||this._charSizeService.measure(),!this._isPaused&&this._needsFullRefresh&&(this._pausedResizeTask.flush(),this.refreshRows(0,this._rowCount-1),this._needsFullRefresh=!1)}refreshRows(e,t,i=!1){this._isPaused?this._needsFullRefresh=!0:(i||(this._isNextRenderRedrawOnly=!1),this._renderDebouncer.refresh(e,t,this._rowCount))}_renderRows(e,t){this._renderer.value&&(e=Math.min(e,this._rowCount-1),t=Math.min(t,this._rowCount-1),this._renderer.value.renderRows(e,t),this._needsSelectionRefresh&&(this._renderer.value.handleSelectionChanged(this._selectionState.start,this._selectionState.end,this._selectionState.columnSelectMode),this._needsSelectionRefresh=!1),this._isNextRenderRedrawOnly||this._onRenderedViewportChange.fire({start:e,end:t}),this._onRender.fire({start:e,end:t}),this._isNextRenderRedrawOnly=!0)}resize(e,t){this._rowCount=t,this._fireOnCanvasResize()}_handleOptionsChanged(){this._renderer.value&&(this.refreshRows(0,this._rowCount-1),this._fireOnCanvasResize())}_fireOnCanvasResize(){this._renderer.value&&(this._renderer.value.dimensions.css.canvas.width===this._canvasWidth&&this._renderer.value.dimensions.css.canvas.height===this._canvasHeight||this._onDimensionsChange.fire(this._renderer.value.dimensions))}hasRenderer(){return!!this._renderer.value}setRenderer(e){this._renderer.value=e,this._renderer.value.onRequestRedraw((e=>this.refreshRows(e.start,e.end,!0))),this._needsSelectionRefresh=!0,this._fullRefresh()}addRefreshCallback(e){return this._renderDebouncer.addRefreshCallback(e)}_fullRefresh(){this._isPaused?this._needsFullRefresh=!0:this.refreshRows(0,this._rowCount-1)}clearTextureAtlas(){var e,t;this._renderer.value&&(null===(t=(e=this._renderer.value).clearTextureAtlas)||void 0===t||t.call(e),this._fullRefresh())}handleDevicePixelRatioChange(){this._charSizeService.measure(),this._renderer.value&&(this._renderer.value.handleDevicePixelRatioChange(),this.refreshRows(0,this._rowCount-1))}handleResize(e,t){this._renderer.value&&(this._isPaused?this._pausedResizeTask.set((()=>this._renderer.value.handleResize(e,t))):this._renderer.value.handleResize(e,t),this._fullRefresh())}handleCharSizeChanged(){var e;null===(e=this._renderer.value)||void 0===e||e.handleCharSizeChanged()}handleBlur(){var e;null===(e=this._renderer.value)||void 0===e||e.handleBlur()}handleFocus(){var e;null===(e=this._renderer.value)||void 0===e||e.handleFocus()}handleSelectionChanged(e,t,i){var s;this._selectionState.start=e,this._selectionState.end=t,this._selectionState.columnSelectMode=i,null===(s=this._renderer.value)||void 0===s||s.handleSelectionChanged(e,t,i)}handleCursorMove(){var e;null===(e=this._renderer.value)||void 0===e||e.handleCursorMove()}clear(){var e;null===(e=this._renderer.value)||void 0===e||e.clear()}};t.RenderService=u=s([r(2,_.IOptionsService),r(3,h.ICharSizeService),r(4,_.IDecorationService),r(5,_.IBufferService),r(6,h.ICoreBrowserService),r(7,h.IThemeService)],u)},9312:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionService=void 0;const n=i(9806),o=i(9504),a=i(456),h=i(4725),c=i(8460),l=i(844),d=i(6114),_=i(4841),u=i(511),f=i(2585),v=String.fromCharCode(160),p=new RegExp(v,"g");let g=t.SelectionService=class extends l.Disposable{constructor(e,t,i,s,r,n,o,h,d){super(),this._element=e,this._screenElement=t,this._linkifier=i,this._bufferService=s,this._coreService=r,this._mouseService=n,this._optionsService=o,this._renderService=h,this._coreBrowserService=d,this._dragScrollAmount=0,this._enabled=!0,this._workCell=new u.CellData,this._mouseDownTimeStamp=0,this._oldHasSelection=!1,this._oldSelectionStart=void 0,this._oldSelectionEnd=void 0,this._onLinuxMouseSelection=this.register(new c.EventEmitter),this.onLinuxMouseSelection=this._onLinuxMouseSelection.event,this._onRedrawRequest=this.register(new c.EventEmitter),this.onRequestRedraw=this._onRedrawRequest.event,this._onSelectionChange=this.register(new c.EventEmitter),this.onSelectionChange=this._onSelectionChange.event,this._onRequestScrollLines=this.register(new c.EventEmitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this._mouseMoveListener=e=>this._handleMouseMove(e),this._mouseUpListener=e=>this._handleMouseUp(e),this._coreService.onUserInput((()=>{this.hasSelection&&this.clearSelection()})),this._trimListener=this._bufferService.buffer.lines.onTrim((e=>this._handleTrim(e))),this.register(this._bufferService.buffers.onBufferActivate((e=>this._handleBufferActivate(e)))),this.enable(),this._model=new a.SelectionModel(this._bufferService),this._activeSelectionMode=0,this.register((0,l.toDisposable)((()=>{this._removeMouseDownListeners()})))}reset(){this.clearSelection()}disable(){this.clearSelection(),this._enabled=!1}enable(){this._enabled=!0}get selectionStart(){return this._model.finalSelectionStart}get selectionEnd(){return this._model.finalSelectionEnd}get hasSelection(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;return!(!e||!t||e[0]===t[0]&&e[1]===t[1])}get selectionText(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;if(!e||!t)return"";const i=this._bufferService.buffer,s=[];if(3===this._activeSelectionMode){if(e[0]===t[0])return"";const r=e[0]e.replace(p," "))).join(d.isWindows?"\r\n":"\n")}clearSelection(){this._model.clearSelection(),this._removeMouseDownListeners(),this.refresh(),this._onSelectionChange.fire()}refresh(e){this._refreshAnimationFrame||(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._refresh()))),d.isLinux&&e&&this.selectionText.length&&this._onLinuxMouseSelection.fire(this.selectionText)}_refresh(){this._refreshAnimationFrame=void 0,this._onRedrawRequest.fire({start:this._model.finalSelectionStart,end:this._model.finalSelectionEnd,columnSelectMode:3===this._activeSelectionMode})}_isClickInSelection(e){const t=this._getMouseBufferCoords(e),i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!!(i&&s&&t)&&this._areCoordsInSelection(t,i,s)}isCellInSelection(e,t){const i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!(!i||!s)&&this._areCoordsInSelection([e,t],i,s)}_areCoordsInSelection(e,t,i){return e[1]>t[1]&&e[1]=t[0]&&e[0]=t[0]}_selectWordAtCursor(e,t){var i,s;const r=null===(s=null===(i=this._linkifier.currentLink)||void 0===i?void 0:i.link)||void 0===s?void 0:s.range;if(r)return this._model.selectionStart=[r.start.x-1,r.start.y-1],this._model.selectionStartLength=(0,_.getRangeLength)(r,this._bufferService.cols),this._model.selectionEnd=void 0,!0;const n=this._getMouseBufferCoords(e);return!!n&&(this._selectWordAt(n,t),this._model.selectionEnd=void 0,!0)}selectAll(){this._model.isSelectAllActive=!0,this.refresh(),this._onSelectionChange.fire()}selectLines(e,t){this._model.clearSelection(),e=Math.max(e,0),t=Math.min(t,this._bufferService.buffer.lines.length-1),this._model.selectionStart=[0,e],this._model.selectionEnd=[this._bufferService.cols,t],this.refresh(),this._onSelectionChange.fire()}_handleTrim(e){this._model.handleTrim(e)&&this.refresh()}_getMouseBufferCoords(e){const t=this._mouseService.getCoords(e,this._screenElement,this._bufferService.cols,this._bufferService.rows,!0);if(t)return t[0]--,t[1]--,t[1]+=this._bufferService.buffer.ydisp,t}_getMouseEventScrollAmount(e){let t=(0,n.getCoordsRelativeToElement)(this._coreBrowserService.window,e,this._screenElement)[1];const i=this._renderService.dimensions.css.canvas.height;return t>=0&&t<=i?0:(t>i&&(t-=i),t=Math.min(Math.max(t,-50),50),t/=50,t/Math.abs(t)+Math.round(14*t))}shouldForceSelection(e){return d.isMac?e.altKey&&this._optionsService.rawOptions.macOptionClickForcesSelection:e.shiftKey}handleMouseDown(e){if(this._mouseDownTimeStamp=e.timeStamp,(2!==e.button||!this.hasSelection)&&0===e.button){if(!this._enabled){if(!this.shouldForceSelection(e))return;e.stopPropagation()}e.preventDefault(),this._dragScrollAmount=0,this._enabled&&e.shiftKey?this._handleIncrementalClick(e):1===e.detail?this._handleSingleClick(e):2===e.detail?this._handleDoubleClick(e):3===e.detail&&this._handleTripleClick(e),this._addMouseDownListeners(),this.refresh(!0)}}_addMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.addEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.addEventListener("mouseup",this._mouseUpListener)),this._dragScrollIntervalTimer=this._coreBrowserService.window.setInterval((()=>this._dragScroll()),50)}_removeMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.removeEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.removeEventListener("mouseup",this._mouseUpListener)),this._coreBrowserService.window.clearInterval(this._dragScrollIntervalTimer),this._dragScrollIntervalTimer=void 0}_handleIncrementalClick(e){this._model.selectionStart&&(this._model.selectionEnd=this._getMouseBufferCoords(e))}_handleSingleClick(e){if(this._model.selectionStartLength=0,this._model.isSelectAllActive=!1,this._activeSelectionMode=this.shouldColumnSelect(e)?3:0,this._model.selectionStart=this._getMouseBufferCoords(e),!this._model.selectionStart)return;this._model.selectionEnd=void 0;const t=this._bufferService.buffer.lines.get(this._model.selectionStart[1]);t&&t.length!==this._model.selectionStart[0]&&0===t.hasWidth(this._model.selectionStart[0])&&this._model.selectionStart[0]++}_handleDoubleClick(e){this._selectWordAtCursor(e,!0)&&(this._activeSelectionMode=1)}_handleTripleClick(e){const t=this._getMouseBufferCoords(e);t&&(this._activeSelectionMode=2,this._selectLineAt(t[1]))}shouldColumnSelect(e){return e.altKey&&!(d.isMac&&this._optionsService.rawOptions.macOptionClickForcesSelection)}_handleMouseMove(e){if(e.stopImmediatePropagation(),!this._model.selectionStart)return;const t=this._model.selectionEnd?[this._model.selectionEnd[0],this._model.selectionEnd[1]]:null;if(this._model.selectionEnd=this._getMouseBufferCoords(e),!this._model.selectionEnd)return void this.refresh(!0);2===this._activeSelectionMode?this._model.selectionEnd[1]0?this._model.selectionEnd[0]=this._bufferService.cols:this._dragScrollAmount<0&&(this._model.selectionEnd[0]=0));const i=this._bufferService.buffer;if(this._model.selectionEnd[1]0?(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=this._bufferService.cols),this._model.selectionEnd[1]=Math.min(e.ydisp+this._bufferService.rows,e.lines.length-1)):(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=0),this._model.selectionEnd[1]=e.ydisp),this.refresh()}}_handleMouseUp(e){const t=e.timeStamp-this._mouseDownTimeStamp;if(this._removeMouseDownListeners(),this.selectionText.length<=1&&t<500&&e.altKey&&this._optionsService.rawOptions.altClickMovesCursor){if(this._bufferService.buffer.ybase===this._bufferService.buffer.ydisp){const t=this._mouseService.getCoords(e,this._element,this._bufferService.cols,this._bufferService.rows,!1);if(t&&void 0!==t[0]&&void 0!==t[1]){const e=(0,o.moveToCellSequence)(t[0]-1,t[1]-1,this._bufferService,this._coreService.decPrivateModes.applicationCursorKeys);this._coreService.triggerDataEvent(e,!0)}}}else this._fireEventIfSelectionChanged()}_fireEventIfSelectionChanged(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd,i=!(!e||!t||e[0]===t[0]&&e[1]===t[1]);i?e&&t&&(this._oldSelectionStart&&this._oldSelectionEnd&&e[0]===this._oldSelectionStart[0]&&e[1]===this._oldSelectionStart[1]&&t[0]===this._oldSelectionEnd[0]&&t[1]===this._oldSelectionEnd[1]||this._fireOnSelectionChange(e,t,i)):this._oldHasSelection&&this._fireOnSelectionChange(e,t,i)}_fireOnSelectionChange(e,t,i){this._oldSelectionStart=e,this._oldSelectionEnd=t,this._oldHasSelection=i,this._onSelectionChange.fire()}_handleBufferActivate(e){this.clearSelection(),this._trimListener.dispose(),this._trimListener=e.activeBuffer.lines.onTrim((e=>this._handleTrim(e)))}_convertViewportColToCharacterIndex(e,t){let i=t;for(let s=0;t>=s;s++){const r=e.loadCell(s,this._workCell).getChars().length;0===this._workCell.getWidth()?i--:r>1&&t!==s&&(i+=r-1)}return i}setSelection(e,t,i){this._model.clearSelection(),this._removeMouseDownListeners(),this._model.selectionStart=[e,t],this._model.selectionStartLength=i,this.refresh(),this._fireEventIfSelectionChanged()}rightClickSelect(e){this._isClickInSelection(e)||(this._selectWordAtCursor(e,!1)&&this.refresh(!0),this._fireEventIfSelectionChanged())}_getWordAt(e,t,i=!0,s=!0){if(e[0]>=this._bufferService.cols)return;const r=this._bufferService.buffer,n=r.lines.get(e[1]);if(!n)return;const o=r.translateBufferLineToString(e[1],!1);let a=this._convertViewportColToCharacterIndex(n,e[0]),h=a;const c=e[0]-a;let l=0,d=0,_=0,u=0;if(" "===o.charAt(a)){for(;a>0&&" "===o.charAt(a-1);)a--;for(;h1&&(u+=s-1,h+=s-1);t>0&&a>0&&!this._isCharWordSeparator(n.loadCell(t-1,this._workCell));){n.loadCell(t-1,this._workCell);const e=this._workCell.getChars().length;0===this._workCell.getWidth()?(l++,t--):e>1&&(_+=e-1,a-=e-1),a--,t--}for(;i1&&(u+=e-1,h+=e-1),h++,i++}}h++;let f=a+c-l+_,v=Math.min(this._bufferService.cols,h-a+l+d-_-u);if(t||""!==o.slice(a,h).trim()){if(i&&0===f&&32!==n.getCodePoint(0)){const t=r.lines.get(e[1]-1);if(t&&n.isWrapped&&32!==t.getCodePoint(this._bufferService.cols-1)){const t=this._getWordAt([this._bufferService.cols-1,e[1]-1],!1,!0,!1);if(t){const e=this._bufferService.cols-t.start;f-=e,v+=e}}}if(s&&f+v===this._bufferService.cols&&32!==n.getCodePoint(this._bufferService.cols-1)){const t=r.lines.get(e[1]+1);if((null==t?void 0:t.isWrapped)&&32!==t.getCodePoint(0)){const t=this._getWordAt([0,e[1]+1],!1,!1,!0);t&&(v+=t.length)}}return{start:f,length:v}}}_selectWordAt(e,t){const i=this._getWordAt(e,t);if(i){for(;i.start<0;)i.start+=this._bufferService.cols,e[1]--;this._model.selectionStart=[i.start,e[1]],this._model.selectionStartLength=i.length}}_selectToWordAt(e){const t=this._getWordAt(e,!0);if(t){let i=e[1];for(;t.start<0;)t.start+=this._bufferService.cols,i--;if(!this._model.areSelectionValuesReversed())for(;t.start+t.length>this._bufferService.cols;)t.length-=this._bufferService.cols,i++;this._model.selectionEnd=[this._model.areSelectionValuesReversed()?t.start:t.start+t.length,i]}}_isCharWordSeparator(e){return 0!==e.getWidth()&&this._optionsService.rawOptions.wordSeparator.indexOf(e.getChars())>=0}_selectLineAt(e){const t=this._bufferService.buffer.getWrappedRangeForLine(e),i={start:{x:0,y:t.first},end:{x:this._bufferService.cols-1,y:t.last}};this._model.selectionStart=[0,t.first],this._model.selectionEnd=void 0,this._model.selectionStartLength=(0,_.getRangeLength)(i,this._bufferService.cols)}};t.SelectionService=g=s([r(3,f.IBufferService),r(4,f.ICoreService),r(5,h.IMouseService),r(6,f.IOptionsService),r(7,h.IRenderService),r(8,h.ICoreBrowserService)],g)},4725:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.IThemeService=t.ICharacterJoinerService=t.ISelectionService=t.IRenderService=t.IMouseService=t.ICoreBrowserService=t.ICharSizeService=void 0;const s=i(8343);t.ICharSizeService=(0,s.createDecorator)("CharSizeService"),t.ICoreBrowserService=(0,s.createDecorator)("CoreBrowserService"),t.IMouseService=(0,s.createDecorator)("MouseService"),t.IRenderService=(0,s.createDecorator)("RenderService"),t.ISelectionService=(0,s.createDecorator)("SelectionService"),t.ICharacterJoinerService=(0,s.createDecorator)("CharacterJoinerService"),t.IThemeService=(0,s.createDecorator)("ThemeService")},6731:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.ThemeService=t.DEFAULT_ANSI_COLORS=void 0;const n=i(7239),o=i(8055),a=i(8460),h=i(844),c=i(2585),l=o.css.toColor("#ffffff"),d=o.css.toColor("#000000"),_=o.css.toColor("#ffffff"),u=o.css.toColor("#000000"),f={css:"rgba(255, 255, 255, 0.3)",rgba:4294967117};t.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const e=[o.css.toColor("#2e3436"),o.css.toColor("#cc0000"),o.css.toColor("#4e9a06"),o.css.toColor("#c4a000"),o.css.toColor("#3465a4"),o.css.toColor("#75507b"),o.css.toColor("#06989a"),o.css.toColor("#d3d7cf"),o.css.toColor("#555753"),o.css.toColor("#ef2929"),o.css.toColor("#8ae234"),o.css.toColor("#fce94f"),o.css.toColor("#729fcf"),o.css.toColor("#ad7fa8"),o.css.toColor("#34e2e2"),o.css.toColor("#eeeeec")],t=[0,95,135,175,215,255];for(let i=0;i<216;i++){const s=t[i/36%6|0],r=t[i/6%6|0],n=t[i%6];e.push({css:o.channels.toCss(s,r,n),rgba:o.channels.toRgba(s,r,n)})}for(let t=0;t<24;t++){const i=8+10*t;e.push({css:o.channels.toCss(i,i,i),rgba:o.channels.toRgba(i,i,i)})}return e})());let v=t.ThemeService=class extends h.Disposable{get colors(){return this._colors}constructor(e){super(),this._optionsService=e,this._contrastCache=new n.ColorContrastCache,this._halfContrastCache=new n.ColorContrastCache,this._onChangeColors=this.register(new a.EventEmitter),this.onChangeColors=this._onChangeColors.event,this._colors={foreground:l,background:d,cursor:_,cursorAccent:u,selectionForeground:void 0,selectionBackgroundTransparent:f,selectionBackgroundOpaque:o.color.blend(d,f),selectionInactiveBackgroundTransparent:f,selectionInactiveBackgroundOpaque:o.color.blend(d,f),ansi:t.DEFAULT_ANSI_COLORS.slice(),contrastCache:this._contrastCache,halfContrastCache:this._halfContrastCache},this._updateRestoreColors(),this._setTheme(this._optionsService.rawOptions.theme),this.register(this._optionsService.onSpecificOptionChange("minimumContrastRatio",(()=>this._contrastCache.clear()))),this.register(this._optionsService.onSpecificOptionChange("theme",(()=>this._setTheme(this._optionsService.rawOptions.theme))))}_setTheme(e={}){const i=this._colors;if(i.foreground=p(e.foreground,l),i.background=p(e.background,d),i.cursor=p(e.cursor,_),i.cursorAccent=p(e.cursorAccent,u),i.selectionBackgroundTransparent=p(e.selectionBackground,f),i.selectionBackgroundOpaque=o.color.blend(i.background,i.selectionBackgroundTransparent),i.selectionInactiveBackgroundTransparent=p(e.selectionInactiveBackground,i.selectionBackgroundTransparent),i.selectionInactiveBackgroundOpaque=o.color.blend(i.background,i.selectionInactiveBackgroundTransparent),i.selectionForeground=e.selectionForeground?p(e.selectionForeground,o.NULL_COLOR):void 0,i.selectionForeground===o.NULL_COLOR&&(i.selectionForeground=void 0),o.color.isOpaque(i.selectionBackgroundTransparent)){const e=.3;i.selectionBackgroundTransparent=o.color.opacity(i.selectionBackgroundTransparent,e)}if(o.color.isOpaque(i.selectionInactiveBackgroundTransparent)){const e=.3;i.selectionInactiveBackgroundTransparent=o.color.opacity(i.selectionInactiveBackgroundTransparent,e)}if(i.ansi=t.DEFAULT_ANSI_COLORS.slice(),i.ansi[0]=p(e.black,t.DEFAULT_ANSI_COLORS[0]),i.ansi[1]=p(e.red,t.DEFAULT_ANSI_COLORS[1]),i.ansi[2]=p(e.green,t.DEFAULT_ANSI_COLORS[2]),i.ansi[3]=p(e.yellow,t.DEFAULT_ANSI_COLORS[3]),i.ansi[4]=p(e.blue,t.DEFAULT_ANSI_COLORS[4]),i.ansi[5]=p(e.magenta,t.DEFAULT_ANSI_COLORS[5]),i.ansi[6]=p(e.cyan,t.DEFAULT_ANSI_COLORS[6]),i.ansi[7]=p(e.white,t.DEFAULT_ANSI_COLORS[7]),i.ansi[8]=p(e.brightBlack,t.DEFAULT_ANSI_COLORS[8]),i.ansi[9]=p(e.brightRed,t.DEFAULT_ANSI_COLORS[9]),i.ansi[10]=p(e.brightGreen,t.DEFAULT_ANSI_COLORS[10]),i.ansi[11]=p(e.brightYellow,t.DEFAULT_ANSI_COLORS[11]),i.ansi[12]=p(e.brightBlue,t.DEFAULT_ANSI_COLORS[12]),i.ansi[13]=p(e.brightMagenta,t.DEFAULT_ANSI_COLORS[13]),i.ansi[14]=p(e.brightCyan,t.DEFAULT_ANSI_COLORS[14]),i.ansi[15]=p(e.brightWhite,t.DEFAULT_ANSI_COLORS[15]),e.extendedAnsi){const s=Math.min(i.ansi.length-16,e.extendedAnsi.length);for(let r=0;r{Object.defineProperty(t,"__esModule",{value:!0}),t.CircularList=void 0;const s=i(8460),r=i(844);class n extends r.Disposable{constructor(e){super(),this._maxLength=e,this.onDeleteEmitter=this.register(new s.EventEmitter),this.onDelete=this.onDeleteEmitter.event,this.onInsertEmitter=this.register(new s.EventEmitter),this.onInsert=this.onInsertEmitter.event,this.onTrimEmitter=this.register(new s.EventEmitter),this.onTrim=this.onTrimEmitter.event,this._array=new Array(this._maxLength),this._startIndex=0,this._length=0}get maxLength(){return this._maxLength}set maxLength(e){if(this._maxLength===e)return;const t=new Array(e);for(let i=0;ithis._length)for(let t=this._length;t=e;t--)this._array[this._getCyclicIndex(t+i.length)]=this._array[this._getCyclicIndex(t)];for(let t=0;tthis._maxLength){const e=this._length+i.length-this._maxLength;this._startIndex+=e,this._length=this._maxLength,this.onTrimEmitter.fire(e)}else this._length+=i.length}trimStart(e){e>this._length&&(e=this._length),this._startIndex+=e,this._length-=e,this.onTrimEmitter.fire(e)}shiftElements(e,t,i){if(!(t<=0)){if(e<0||e>=this._length)throw new Error("start argument out of range");if(e+i<0)throw new Error("Cannot shift elements in list beyond index 0");if(i>0){for(let s=t-1;s>=0;s--)this.set(e+s+i,this.get(e+s));const s=e+t+i-this._length;if(s>0)for(this._length+=s;this._length>this._maxLength;)this._length--,this._startIndex++,this.onTrimEmitter.fire(1)}else for(let s=0;s{Object.defineProperty(t,"__esModule",{value:!0}),t.clone=void 0,t.clone=function e(t,i=5){if("object"!=typeof t)return t;const s=Array.isArray(t)?[]:{};for(const r in t)s[r]=i<=1?t[r]:t[r]&&e(t[r],i-1);return s}},8055:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.contrastRatio=t.toPaddedHex=t.rgba=t.rgb=t.css=t.color=t.channels=t.NULL_COLOR=void 0;const s=i(6114);let r=0,n=0,o=0,a=0;var h,c,l,d,_;function u(e){const t=e.toString(16);return t.length<2?"0"+t:t}function f(e,t){return e>>0}}(h||(t.channels=h={})),function(e){function t(e,t){return a=Math.round(255*t),[r,n,o]=_.toChannels(e.rgba),{css:h.toCss(r,n,o,a),rgba:h.toRgba(r,n,o,a)}}e.blend=function(e,t){if(a=(255&t.rgba)/255,1===a)return{css:t.css,rgba:t.rgba};const i=t.rgba>>24&255,s=t.rgba>>16&255,c=t.rgba>>8&255,l=e.rgba>>24&255,d=e.rgba>>16&255,_=e.rgba>>8&255;return r=l+Math.round((i-l)*a),n=d+Math.round((s-d)*a),o=_+Math.round((c-_)*a),{css:h.toCss(r,n,o),rgba:h.toRgba(r,n,o)}},e.isOpaque=function(e){return 255==(255&e.rgba)},e.ensureContrastRatio=function(e,t,i){const s=_.ensureContrastRatio(e.rgba,t.rgba,i);if(s)return _.toColor(s>>24&255,s>>16&255,s>>8&255)},e.opaque=function(e){const t=(255|e.rgba)>>>0;return[r,n,o]=_.toChannels(t),{css:h.toCss(r,n,o),rgba:t}},e.opacity=t,e.multiplyOpacity=function(e,i){return a=255&e.rgba,t(e,a*i/255)},e.toColorRGB=function(e){return[e.rgba>>24&255,e.rgba>>16&255,e.rgba>>8&255]}}(c||(t.color=c={})),function(e){let t,i;if(!s.isNode){const e=document.createElement("canvas");e.width=1,e.height=1;const s=e.getContext("2d",{willReadFrequently:!0});s&&(t=s,t.globalCompositeOperation="copy",i=t.createLinearGradient(0,0,1,1))}e.toColor=function(e){if(e.match(/#[\da-f]{3,8}/i))switch(e.length){case 4:return r=parseInt(e.slice(1,2).repeat(2),16),n=parseInt(e.slice(2,3).repeat(2),16),o=parseInt(e.slice(3,4).repeat(2),16),_.toColor(r,n,o);case 5:return r=parseInt(e.slice(1,2).repeat(2),16),n=parseInt(e.slice(2,3).repeat(2),16),o=parseInt(e.slice(3,4).repeat(2),16),a=parseInt(e.slice(4,5).repeat(2),16),_.toColor(r,n,o,a);case 7:return{css:e,rgba:(parseInt(e.slice(1),16)<<8|255)>>>0};case 9:return{css:e,rgba:parseInt(e.slice(1),16)>>>0}}const s=e.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(s)return r=parseInt(s[1]),n=parseInt(s[2]),o=parseInt(s[3]),a=Math.round(255*(void 0===s[5]?1:parseFloat(s[5]))),_.toColor(r,n,o,a);if(!t||!i)throw new Error("css.toColor: Unsupported css format");if(t.fillStyle=i,t.fillStyle=e,"string"!=typeof t.fillStyle)throw new Error("css.toColor: Unsupported css format");if(t.fillRect(0,0,1,1),[r,n,o,a]=t.getImageData(0,0,1,1).data,255!==a)throw new Error("css.toColor: Unsupported css format");return{rgba:h.toRgba(r,n,o,a),css:e}}}(l||(t.css=l={})),function(e){function t(e,t,i){const s=e/255,r=t/255,n=i/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(r<=.03928?r/12.92:Math.pow((r+.055)/1.055,2.4))+.0722*(n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4))}e.relativeLuminance=function(e){return t(e>>16&255,e>>8&255,255&e)},e.relativeLuminance2=t}(d||(t.rgb=d={})),function(e){function t(e,t,i){const s=e>>24&255,r=e>>16&255,n=e>>8&255;let o=t>>24&255,a=t>>16&255,h=t>>8&255,c=f(d.relativeLuminance2(o,a,h),d.relativeLuminance2(s,r,n));for(;c0||a>0||h>0);)o-=Math.max(0,Math.ceil(.1*o)),a-=Math.max(0,Math.ceil(.1*a)),h-=Math.max(0,Math.ceil(.1*h)),c=f(d.relativeLuminance2(o,a,h),d.relativeLuminance2(s,r,n));return(o<<24|a<<16|h<<8|255)>>>0}function i(e,t,i){const s=e>>24&255,r=e>>16&255,n=e>>8&255;let o=t>>24&255,a=t>>16&255,h=t>>8&255,c=f(d.relativeLuminance2(o,a,h),d.relativeLuminance2(s,r,n));for(;c>>0}e.ensureContrastRatio=function(e,s,r){const n=d.relativeLuminance(e>>8),o=d.relativeLuminance(s>>8);if(f(n,o)>8));if(af(n,d.relativeLuminance(t>>8))?o:t}return o}const a=i(e,s,r),h=f(n,d.relativeLuminance(a>>8));if(hf(n,d.relativeLuminance(i>>8))?a:i}return a}},e.reduceLuminance=t,e.increaseLuminance=i,e.toChannels=function(e){return[e>>24&255,e>>16&255,e>>8&255,255&e]},e.toColor=function(e,t,i,s){return{css:h.toCss(e,t,i,s),rgba:h.toRgba(e,t,i,s)}}}(_||(t.rgba=_={})),t.toPaddedHex=u,t.contrastRatio=f},8969:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreTerminal=void 0;const s=i(844),r=i(2585),n=i(4348),o=i(7866),a=i(744),h=i(7302),c=i(6975),l=i(8460),d=i(1753),_=i(1480),u=i(7994),f=i(9282),v=i(5435),p=i(5981),g=i(2660);let m=!1;class S extends s.Disposable{get onScroll(){return this._onScrollApi||(this._onScrollApi=this.register(new l.EventEmitter),this._onScroll.event((e=>{var t;null===(t=this._onScrollApi)||void 0===t||t.fire(e.position)}))),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(e){for(const t in e)this.optionsService.options[t]=e[t]}constructor(e){super(),this._windowsWrappingHeuristics=this.register(new s.MutableDisposable),this._onBinary=this.register(new l.EventEmitter),this.onBinary=this._onBinary.event,this._onData=this.register(new l.EventEmitter),this.onData=this._onData.event,this._onLineFeed=this.register(new l.EventEmitter),this.onLineFeed=this._onLineFeed.event,this._onResize=this.register(new l.EventEmitter),this.onResize=this._onResize.event,this._onWriteParsed=this.register(new l.EventEmitter),this.onWriteParsed=this._onWriteParsed.event,this._onScroll=this.register(new l.EventEmitter),this._instantiationService=new n.InstantiationService,this.optionsService=this.register(new h.OptionsService(e)),this._instantiationService.setService(r.IOptionsService,this.optionsService),this._bufferService=this.register(this._instantiationService.createInstance(a.BufferService)),this._instantiationService.setService(r.IBufferService,this._bufferService),this._logService=this.register(this._instantiationService.createInstance(o.LogService)),this._instantiationService.setService(r.ILogService,this._logService),this.coreService=this.register(this._instantiationService.createInstance(c.CoreService)),this._instantiationService.setService(r.ICoreService,this.coreService),this.coreMouseService=this.register(this._instantiationService.createInstance(d.CoreMouseService)),this._instantiationService.setService(r.ICoreMouseService,this.coreMouseService),this.unicodeService=this.register(this._instantiationService.createInstance(_.UnicodeService)),this._instantiationService.setService(r.IUnicodeService,this.unicodeService),this._charsetService=this._instantiationService.createInstance(u.CharsetService),this._instantiationService.setService(r.ICharsetService,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(g.OscLinkService),this._instantiationService.setService(r.IOscLinkService,this._oscLinkService),this._inputHandler=this.register(new v.InputHandler(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.coreMouseService,this.unicodeService)),this.register((0,l.forwardEvent)(this._inputHandler.onLineFeed,this._onLineFeed)),this.register(this._inputHandler),this.register((0,l.forwardEvent)(this._bufferService.onResize,this._onResize)),this.register((0,l.forwardEvent)(this.coreService.onData,this._onData)),this.register((0,l.forwardEvent)(this.coreService.onBinary,this._onBinary)),this.register(this.coreService.onRequestScrollToBottom((()=>this.scrollToBottom()))),this.register(this.coreService.onUserInput((()=>this._writeBuffer.handleUserInput()))),this.register(this.optionsService.onMultipleOptionChange(["windowsMode","windowsPty"],(()=>this._handleWindowsPtyOptionChange()))),this.register(this._bufferService.onScroll((e=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp,source:0}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)}))),this.register(this._inputHandler.onScroll((e=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp,source:0}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)}))),this._writeBuffer=this.register(new p.WriteBuffer(((e,t)=>this._inputHandler.parse(e,t)))),this.register((0,l.forwardEvent)(this._writeBuffer.onWriteParsed,this._onWriteParsed))}write(e,t){this._writeBuffer.write(e,t)}writeSync(e,t){this._logService.logLevel<=r.LogLevelEnum.WARN&&!m&&(this._logService.warn("writeSync is unreliable and will be removed soon."),m=!0),this._writeBuffer.writeSync(e,t)}resize(e,t){isNaN(e)||isNaN(t)||(e=Math.max(e,a.MINIMUM_COLS),t=Math.max(t,a.MINIMUM_ROWS),this._bufferService.resize(e,t))}scroll(e,t=!1){this._bufferService.scroll(e,t)}scrollLines(e,t,i){this._bufferService.scrollLines(e,t,i)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){const t=e-this._bufferService.buffer.ydisp;0!==t&&this.scrollLines(t)}registerEscHandler(e,t){return this._inputHandler.registerEscHandler(e,t)}registerDcsHandler(e,t){return this._inputHandler.registerDcsHandler(e,t)}registerCsiHandler(e,t){return this._inputHandler.registerCsiHandler(e,t)}registerOscHandler(e,t){return this._inputHandler.registerOscHandler(e,t)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.coreMouseService.reset()}_handleWindowsPtyOptionChange(){let e=!1;const t=this.optionsService.rawOptions.windowsPty;t&&void 0!==t.buildNumber&&void 0!==t.buildNumber?e=!!("conpty"===t.backend&&t.buildNumber<21376):this.optionsService.rawOptions.windowsMode&&(e=!0),e?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){const e=[];e.push(this.onLineFeed(f.updateWindowsModeWrappedState.bind(null,this._bufferService))),e.push(this.registerCsiHandler({final:"H"},(()=>((0,f.updateWindowsModeWrappedState)(this._bufferService),!1)))),this._windowsWrappingHeuristics.value=(0,s.toDisposable)((()=>{for(const t of e)t.dispose()}))}}}t.CoreTerminal=S},8460:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.forwardEvent=t.EventEmitter=void 0,t.EventEmitter=class{constructor(){this._listeners=[],this._disposed=!1}get event(){return this._event||(this._event=e=>(this._listeners.push(e),{dispose:()=>{if(!this._disposed)for(let t=0;tt.fire(e)))}},5435:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.InputHandler=t.WindowsOptionsReportType=void 0;const n=i(2584),o=i(7116),a=i(2015),h=i(844),c=i(482),l=i(8437),d=i(8460),_=i(643),u=i(511),f=i(3734),v=i(2585),p=i(6242),g=i(6351),m=i(5941),S={"(":0,")":1,"*":2,"+":3,"-":1,".":2},C=131072;function b(e,t){if(e>24)return t.setWinLines||!1;switch(e){case 1:return!!t.restoreWin;case 2:return!!t.minimizeWin;case 3:return!!t.setWinPosition;case 4:return!!t.setWinSizePixels;case 5:return!!t.raiseWin;case 6:return!!t.lowerWin;case 7:return!!t.refreshWin;case 8:return!!t.setWinSizeChars;case 9:return!!t.maximizeWin;case 10:return!!t.fullscreenWin;case 11:return!!t.getWinState;case 13:return!!t.getWinPosition;case 14:return!!t.getWinSizePixels;case 15:return!!t.getScreenSizePixels;case 16:return!!t.getCellSizePixels;case 18:return!!t.getWinSizeChars;case 19:return!!t.getScreenSizeChars;case 20:return!!t.getIconTitle;case 21:return!!t.getWinTitle;case 22:return!!t.pushTitle;case 23:return!!t.popTitle;case 24:return!!t.setWinLines}return!1}var y;!function(e){e[e.GET_WIN_SIZE_PIXELS=0]="GET_WIN_SIZE_PIXELS",e[e.GET_CELL_SIZE_PIXELS=1]="GET_CELL_SIZE_PIXELS"}(y||(t.WindowsOptionsReportType=y={}));let w=0;class E extends h.Disposable{getAttrData(){return this._curAttrData}constructor(e,t,i,s,r,h,_,f,v=new a.EscapeSequenceParser){super(),this._bufferService=e,this._charsetService=t,this._coreService=i,this._logService=s,this._optionsService=r,this._oscLinkService=h,this._coreMouseService=_,this._unicodeService=f,this._parser=v,this._parseBuffer=new Uint32Array(4096),this._stringDecoder=new c.StringToUtf32,this._utf8Decoder=new c.Utf8ToUtf32,this._workCell=new u.CellData,this._windowTitle="",this._iconName="",this._windowTitleStack=[],this._iconNameStack=[],this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone(),this._onRequestBell=this.register(new d.EventEmitter),this.onRequestBell=this._onRequestBell.event,this._onRequestRefreshRows=this.register(new d.EventEmitter),this.onRequestRefreshRows=this._onRequestRefreshRows.event,this._onRequestReset=this.register(new d.EventEmitter),this.onRequestReset=this._onRequestReset.event,this._onRequestSendFocus=this.register(new d.EventEmitter),this.onRequestSendFocus=this._onRequestSendFocus.event,this._onRequestSyncScrollBar=this.register(new d.EventEmitter),this.onRequestSyncScrollBar=this._onRequestSyncScrollBar.event,this._onRequestWindowsOptionsReport=this.register(new d.EventEmitter),this.onRequestWindowsOptionsReport=this._onRequestWindowsOptionsReport.event,this._onA11yChar=this.register(new d.EventEmitter),this.onA11yChar=this._onA11yChar.event,this._onA11yTab=this.register(new d.EventEmitter),this.onA11yTab=this._onA11yTab.event,this._onCursorMove=this.register(new d.EventEmitter),this.onCursorMove=this._onCursorMove.event,this._onLineFeed=this.register(new d.EventEmitter),this.onLineFeed=this._onLineFeed.event,this._onScroll=this.register(new d.EventEmitter),this.onScroll=this._onScroll.event,this._onTitleChange=this.register(new d.EventEmitter),this.onTitleChange=this._onTitleChange.event,this._onColor=this.register(new d.EventEmitter),this.onColor=this._onColor.event,this._parseStack={paused:!1,cursorStartX:0,cursorStartY:0,decodedLength:0,position:0},this._specialColors=[256,257,258],this.register(this._parser),this._dirtyRowTracker=new k(this._bufferService),this._activeBuffer=this._bufferService.buffer,this.register(this._bufferService.buffers.onBufferActivate((e=>this._activeBuffer=e.activeBuffer))),this._parser.setCsiHandlerFallback(((e,t)=>{this._logService.debug("Unknown CSI code: ",{identifier:this._parser.identToString(e),params:t.toArray()})})),this._parser.setEscHandlerFallback((e=>{this._logService.debug("Unknown ESC code: ",{identifier:this._parser.identToString(e)})})),this._parser.setExecuteHandlerFallback((e=>{this._logService.debug("Unknown EXECUTE code: ",{code:e})})),this._parser.setOscHandlerFallback(((e,t,i)=>{this._logService.debug("Unknown OSC code: ",{identifier:e,action:t,data:i})})),this._parser.setDcsHandlerFallback(((e,t,i)=>{"HOOK"===t&&(i=i.toArray()),this._logService.debug("Unknown DCS code: ",{identifier:this._parser.identToString(e),action:t,payload:i})})),this._parser.setPrintHandler(((e,t,i)=>this.print(e,t,i))),this._parser.registerCsiHandler({final:"@"},(e=>this.insertChars(e))),this._parser.registerCsiHandler({intermediates:" ",final:"@"},(e=>this.scrollLeft(e))),this._parser.registerCsiHandler({final:"A"},(e=>this.cursorUp(e))),this._parser.registerCsiHandler({intermediates:" ",final:"A"},(e=>this.scrollRight(e))),this._parser.registerCsiHandler({final:"B"},(e=>this.cursorDown(e))),this._parser.registerCsiHandler({final:"C"},(e=>this.cursorForward(e))),this._parser.registerCsiHandler({final:"D"},(e=>this.cursorBackward(e))),this._parser.registerCsiHandler({final:"E"},(e=>this.cursorNextLine(e))),this._parser.registerCsiHandler({final:"F"},(e=>this.cursorPrecedingLine(e))),this._parser.registerCsiHandler({final:"G"},(e=>this.cursorCharAbsolute(e))),this._parser.registerCsiHandler({final:"H"},(e=>this.cursorPosition(e))),this._parser.registerCsiHandler({final:"I"},(e=>this.cursorForwardTab(e))),this._parser.registerCsiHandler({final:"J"},(e=>this.eraseInDisplay(e,!1))),this._parser.registerCsiHandler({prefix:"?",final:"J"},(e=>this.eraseInDisplay(e,!0))),this._parser.registerCsiHandler({final:"K"},(e=>this.eraseInLine(e,!1))),this._parser.registerCsiHandler({prefix:"?",final:"K"},(e=>this.eraseInLine(e,!0))),this._parser.registerCsiHandler({final:"L"},(e=>this.insertLines(e))),this._parser.registerCsiHandler({final:"M"},(e=>this.deleteLines(e))),this._parser.registerCsiHandler({final:"P"},(e=>this.deleteChars(e))),this._parser.registerCsiHandler({final:"S"},(e=>this.scrollUp(e))),this._parser.registerCsiHandler({final:"T"},(e=>this.scrollDown(e))),this._parser.registerCsiHandler({final:"X"},(e=>this.eraseChars(e))),this._parser.registerCsiHandler({final:"Z"},(e=>this.cursorBackwardTab(e))),this._parser.registerCsiHandler({final:"`"},(e=>this.charPosAbsolute(e))),this._parser.registerCsiHandler({final:"a"},(e=>this.hPositionRelative(e))),this._parser.registerCsiHandler({final:"b"},(e=>this.repeatPrecedingCharacter(e))),this._parser.registerCsiHandler({final:"c"},(e=>this.sendDeviceAttributesPrimary(e))),this._parser.registerCsiHandler({prefix:">",final:"c"},(e=>this.sendDeviceAttributesSecondary(e))),this._parser.registerCsiHandler({final:"d"},(e=>this.linePosAbsolute(e))),this._parser.registerCsiHandler({final:"e"},(e=>this.vPositionRelative(e))),this._parser.registerCsiHandler({final:"f"},(e=>this.hVPosition(e))),this._parser.registerCsiHandler({final:"g"},(e=>this.tabClear(e))),this._parser.registerCsiHandler({final:"h"},(e=>this.setMode(e))),this._parser.registerCsiHandler({prefix:"?",final:"h"},(e=>this.setModePrivate(e))),this._parser.registerCsiHandler({final:"l"},(e=>this.resetMode(e))),this._parser.registerCsiHandler({prefix:"?",final:"l"},(e=>this.resetModePrivate(e))),this._parser.registerCsiHandler({final:"m"},(e=>this.charAttributes(e))),this._parser.registerCsiHandler({final:"n"},(e=>this.deviceStatus(e))),this._parser.registerCsiHandler({prefix:"?",final:"n"},(e=>this.deviceStatusPrivate(e))),this._parser.registerCsiHandler({intermediates:"!",final:"p"},(e=>this.softReset(e))),this._parser.registerCsiHandler({intermediates:" ",final:"q"},(e=>this.setCursorStyle(e))),this._parser.registerCsiHandler({final:"r"},(e=>this.setScrollRegion(e))),this._parser.registerCsiHandler({final:"s"},(e=>this.saveCursor(e))),this._parser.registerCsiHandler({final:"t"},(e=>this.windowOptions(e))),this._parser.registerCsiHandler({final:"u"},(e=>this.restoreCursor(e))),this._parser.registerCsiHandler({intermediates:"'",final:"}"},(e=>this.insertColumns(e))),this._parser.registerCsiHandler({intermediates:"'",final:"~"},(e=>this.deleteColumns(e))),this._parser.registerCsiHandler({intermediates:'"',final:"q"},(e=>this.selectProtected(e))),this._parser.registerCsiHandler({intermediates:"$",final:"p"},(e=>this.requestMode(e,!0))),this._parser.registerCsiHandler({prefix:"?",intermediates:"$",final:"p"},(e=>this.requestMode(e,!1))),this._parser.setExecuteHandler(n.C0.BEL,(()=>this.bell())),this._parser.setExecuteHandler(n.C0.LF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.VT,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.FF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.CR,(()=>this.carriageReturn())),this._parser.setExecuteHandler(n.C0.BS,(()=>this.backspace())),this._parser.setExecuteHandler(n.C0.HT,(()=>this.tab())),this._parser.setExecuteHandler(n.C0.SO,(()=>this.shiftOut())),this._parser.setExecuteHandler(n.C0.SI,(()=>this.shiftIn())),this._parser.setExecuteHandler(n.C1.IND,(()=>this.index())),this._parser.setExecuteHandler(n.C1.NEL,(()=>this.nextLine())),this._parser.setExecuteHandler(n.C1.HTS,(()=>this.tabSet())),this._parser.registerOscHandler(0,new p.OscHandler((e=>(this.setTitle(e),this.setIconName(e),!0)))),this._parser.registerOscHandler(1,new p.OscHandler((e=>this.setIconName(e)))),this._parser.registerOscHandler(2,new p.OscHandler((e=>this.setTitle(e)))),this._parser.registerOscHandler(4,new p.OscHandler((e=>this.setOrReportIndexedColor(e)))),this._parser.registerOscHandler(8,new p.OscHandler((e=>this.setHyperlink(e)))),this._parser.registerOscHandler(10,new p.OscHandler((e=>this.setOrReportFgColor(e)))),this._parser.registerOscHandler(11,new p.OscHandler((e=>this.setOrReportBgColor(e)))),this._parser.registerOscHandler(12,new p.OscHandler((e=>this.setOrReportCursorColor(e)))),this._parser.registerOscHandler(104,new p.OscHandler((e=>this.restoreIndexedColor(e)))),this._parser.registerOscHandler(110,new p.OscHandler((e=>this.restoreFgColor(e)))),this._parser.registerOscHandler(111,new p.OscHandler((e=>this.restoreBgColor(e)))),this._parser.registerOscHandler(112,new p.OscHandler((e=>this.restoreCursorColor(e)))),this._parser.registerEscHandler({final:"7"},(()=>this.saveCursor())),this._parser.registerEscHandler({final:"8"},(()=>this.restoreCursor())),this._parser.registerEscHandler({final:"D"},(()=>this.index())),this._parser.registerEscHandler({final:"E"},(()=>this.nextLine())),this._parser.registerEscHandler({final:"H"},(()=>this.tabSet())),this._parser.registerEscHandler({final:"M"},(()=>this.reverseIndex())),this._parser.registerEscHandler({final:"="},(()=>this.keypadApplicationMode())),this._parser.registerEscHandler({final:">"},(()=>this.keypadNumericMode())),this._parser.registerEscHandler({final:"c"},(()=>this.fullReset())),this._parser.registerEscHandler({final:"n"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"o"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"|"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"}"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"~"},(()=>this.setgLevel(1))),this._parser.registerEscHandler({intermediates:"%",final:"@"},(()=>this.selectDefaultCharset())),this._parser.registerEscHandler({intermediates:"%",final:"G"},(()=>this.selectDefaultCharset()));for(const e in o.CHARSETS)this._parser.registerEscHandler({intermediates:"(",final:e},(()=>this.selectCharset("("+e))),this._parser.registerEscHandler({intermediates:")",final:e},(()=>this.selectCharset(")"+e))),this._parser.registerEscHandler({intermediates:"*",final:e},(()=>this.selectCharset("*"+e))),this._parser.registerEscHandler({intermediates:"+",final:e},(()=>this.selectCharset("+"+e))),this._parser.registerEscHandler({intermediates:"-",final:e},(()=>this.selectCharset("-"+e))),this._parser.registerEscHandler({intermediates:".",final:e},(()=>this.selectCharset("."+e))),this._parser.registerEscHandler({intermediates:"/",final:e},(()=>this.selectCharset("/"+e)));this._parser.registerEscHandler({intermediates:"#",final:"8"},(()=>this.screenAlignmentPattern())),this._parser.setErrorHandler((e=>(this._logService.error("Parsing error: ",e),e))),this._parser.registerDcsHandler({intermediates:"$",final:"q"},new g.DcsHandler(((e,t)=>this.requestStatusString(e,t))))}_preserveStack(e,t,i,s){this._parseStack.paused=!0,this._parseStack.cursorStartX=e,this._parseStack.cursorStartY=t,this._parseStack.decodedLength=i,this._parseStack.position=s}_logSlowResolvingAsync(e){this._logService.logLevel<=v.LogLevelEnum.WARN&&Promise.race([e,new Promise(((e,t)=>setTimeout((()=>t("#SLOW_TIMEOUT")),5e3)))]).catch((e=>{if("#SLOW_TIMEOUT"!==e)throw e;console.warn("async parser handler taking longer than 5000 ms")}))}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(e,t){let i,s=this._activeBuffer.x,r=this._activeBuffer.y,n=0;const o=this._parseStack.paused;if(o){if(i=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,t))return this._logSlowResolvingAsync(i),i;s=this._parseStack.cursorStartX,r=this._parseStack.cursorStartY,this._parseStack.paused=!1,e.length>C&&(n=this._parseStack.position+C)}if(this._logService.logLevel<=v.LogLevelEnum.DEBUG&&this._logService.debug("parsing data"+("string"==typeof e?` "${e}"`:` "${Array.prototype.map.call(e,(e=>String.fromCharCode(e))).join("")}"`),"string"==typeof e?e.split("").map((e=>e.charCodeAt(0))):e),this._parseBuffer.lengthC)for(let t=n;t0&&2===u.getWidth(this._activeBuffer.x-1)&&u.setCellFromCodePoint(this._activeBuffer.x-1,0,1,d.fg,d.bg,d.extended);for(let f=t;f=a)if(h){for(;this._activeBuffer.x=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),u=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)}else if(this._activeBuffer.x=a-1,2===r)continue;if(l&&(u.insertCells(this._activeBuffer.x,r,this._activeBuffer.getNullCell(d),d),2===u.getWidth(a-1)&&u.setCellFromCodePoint(a-1,_.NULL_CELL_CODE,_.NULL_CELL_WIDTH,d.fg,d.bg,d.extended)),u.setCellFromCodePoint(this._activeBuffer.x++,s,r,d.fg,d.bg,d.extended),r>0)for(;--r;)u.setCellFromCodePoint(this._activeBuffer.x++,0,0,d.fg,d.bg,d.extended)}else u.getWidth(this._activeBuffer.x-1)?u.addCodepointToCell(this._activeBuffer.x-1,s):u.addCodepointToCell(this._activeBuffer.x-2,s)}i-t>0&&(u.loadCell(this._activeBuffer.x-1,this._workCell),2===this._workCell.getWidth()||this._workCell.getCode()>65535?this._parser.precedingCodepoint=0:this._workCell.isCombined()?this._parser.precedingCodepoint=this._workCell.getChars().charCodeAt(0):this._parser.precedingCodepoint=this._workCell.content),this._activeBuffer.x0&&0===u.getWidth(this._activeBuffer.x)&&!u.hasContent(this._activeBuffer.x)&&u.setCellFromCodePoint(this._activeBuffer.x,0,1,d.fg,d.bg,d.extended),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(e,t){return"t"!==e.final||e.prefix||e.intermediates?this._parser.registerCsiHandler(e,t):this._parser.registerCsiHandler(e,(e=>!b(e.params[0],this._optionsService.rawOptions.windowOptions)||t(e)))}registerDcsHandler(e,t){return this._parser.registerDcsHandler(e,new g.DcsHandler(t))}registerEscHandler(e,t){return this._parser.registerEscHandler(e,t)}registerOscHandler(e,t){return this._parser.registerOscHandler(e,new p.OscHandler(t))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){var e;if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(0===this._activeBuffer.x&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&(null===(e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y))||void 0===e?void 0:e.isWrapped)){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;const e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);e.hasWidth(this._activeBuffer.x)&&!e.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;const e=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-e),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(e=this._bufferService.cols-1){this._activeBuffer.x=Math.min(e,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(e,t){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=e,this._activeBuffer.y=this._activeBuffer.scrollTop+t):(this._activeBuffer.x=e,this._activeBuffer.y=t),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(e,t){this._restrictCursor(),this._setCursor(this._activeBuffer.x+e,this._activeBuffer.y+t)}cursorUp(e){const t=this._activeBuffer.y-this._activeBuffer.scrollTop;return t>=0?this._moveCursor(0,-Math.min(t,e.params[0]||1)):this._moveCursor(0,-(e.params[0]||1)),!0}cursorDown(e){const t=this._activeBuffer.scrollBottom-this._activeBuffer.y;return t>=0?this._moveCursor(0,Math.min(t,e.params[0]||1)):this._moveCursor(0,e.params[0]||1),!0}cursorForward(e){return this._moveCursor(e.params[0]||1,0),!0}cursorBackward(e){return this._moveCursor(-(e.params[0]||1),0),!0}cursorNextLine(e){return this.cursorDown(e),this._activeBuffer.x=0,!0}cursorPrecedingLine(e){return this.cursorUp(e),this._activeBuffer.x=0,!0}cursorCharAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(e){return this._setCursor(e.length>=2?(e.params[1]||1)-1:0,(e.params[0]||1)-1),!0}charPosAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(e){return this._moveCursor(e.params[0]||1,0),!0}linePosAbsolute(e){return this._setCursor(this._activeBuffer.x,(e.params[0]||1)-1),!0}vPositionRelative(e){return this._moveCursor(0,e.params[0]||1),!0}hVPosition(e){return this.cursorPosition(e),!0}tabClear(e){const t=e.params[0];return 0===t?delete this._activeBuffer.tabs[this._activeBuffer.x]:3===t&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(e){const t=e.params[0];return 1===t&&(this._curAttrData.bg|=536870912),2!==t&&0!==t||(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(e,t,i,s=!1,r=!1){const n=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);n.replaceCells(t,i,this._activeBuffer.getNullCell(this._eraseAttrData()),this._eraseAttrData(),r),s&&(n.isWrapped=!1)}_resetBufferLine(e,t=!1){const i=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);i&&(i.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),t),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+e),i.isWrapped=!1)}eraseInDisplay(e,t=!1){let i;switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:for(i=this._activeBuffer.y,this._dirtyRowTracker.markDirty(i),this._eraseInBufferLine(i++,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);i=this._bufferService.cols&&(this._activeBuffer.lines.get(i+1).isWrapped=!1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 2:for(i=this._bufferService.rows,this._dirtyRowTracker.markDirty(i-1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 3:const e=this._activeBuffer.lines.length-this._bufferService.rows;e>0&&(this._activeBuffer.lines.trimStart(e),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-e,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-e,0),this._onScroll.fire(0))}return!0}eraseInLine(e,t=!1){switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,t);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,t)}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(e){this._restrictCursor();let t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent(n.C0.ESC+"[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent(n.C0.ESC+"[?6c")),!0}sendDeviceAttributesSecondary(e){return e.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent(n.C0.ESC+"[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent(n.C0.ESC+"[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(e.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent(n.C0.ESC+"[>83;40003;0c")),!0}_is(e){return 0===(this._optionsService.rawOptions.termName+"").indexOf(e)}setMode(e){for(let t=0;te?1:2,u=e.params[0];return f=u,v=t?2===u?4:4===u?_(o.modes.insertMode):12===u?3:20===u?_(d.convertEol):0:1===u?_(i.applicationCursorKeys):3===u?d.windowOptions.setWinLines?80===h?2:132===h?1:0:0:6===u?_(i.origin):7===u?_(i.wraparound):8===u?3:9===u?_("X10"===s):12===u?_(d.cursorBlink):25===u?_(!o.isCursorHidden):45===u?_(i.reverseWraparound):66===u?_(i.applicationKeypad):67===u?4:1e3===u?_("VT200"===s):1002===u?_("DRAG"===s):1003===u?_("ANY"===s):1004===u?_(i.sendFocus):1005===u?4:1006===u?_("SGR"===r):1015===u?4:1016===u?_("SGR_PIXELS"===r):1048===u?1:47===u||1047===u||1049===u?_(c===l):2004===u?_(i.bracketedPasteMode):0,o.triggerDataEvent(`${n.C0.ESC}[${t?"":"?"}${f};${v}$y`),!0;var f,v}_updateAttrColor(e,t,i,s,r){return 2===t?(e|=50331648,e&=-16777216,e|=f.AttributeData.fromColorRGB([i,s,r])):5===t&&(e&=-50331904,e|=33554432|255&i),e}_extractColor(e,t,i){const s=[0,0,-1,0,0,0];let r=0,n=0;do{if(s[n+r]=e.params[t+n],e.hasSubParams(t+n)){const i=e.getSubParams(t+n);let o=0;do{5===s[1]&&(r=1),s[n+o+1+r]=i[o]}while(++o=2||2===s[1]&&n+r>=5)break;s[1]&&(r=1)}while(++n+t5)&&(e=1),t.extended.underlineStyle=e,t.fg|=268435456,0===e&&(t.fg&=-268435457),t.updateExtended()}_processSGR0(e){e.fg=l.DEFAULT_ATTR_DATA.fg,e.bg=l.DEFAULT_ATTR_DATA.bg,e.extended=e.extended.clone(),e.extended.underlineStyle=0,e.extended.underlineColor&=-67108864,e.updateExtended()}charAttributes(e){if(1===e.length&&0===e.params[0])return this._processSGR0(this._curAttrData),!0;const t=e.length;let i;const s=this._curAttrData;for(let r=0;r=30&&i<=37?(s.fg&=-50331904,s.fg|=16777216|i-30):i>=40&&i<=47?(s.bg&=-50331904,s.bg|=16777216|i-40):i>=90&&i<=97?(s.fg&=-50331904,s.fg|=16777224|i-90):i>=100&&i<=107?(s.bg&=-50331904,s.bg|=16777224|i-100):0===i?this._processSGR0(s):1===i?s.fg|=134217728:3===i?s.bg|=67108864:4===i?(s.fg|=268435456,this._processUnderline(e.hasSubParams(r)?e.getSubParams(r)[0]:1,s)):5===i?s.fg|=536870912:7===i?s.fg|=67108864:8===i?s.fg|=1073741824:9===i?s.fg|=2147483648:2===i?s.bg|=134217728:21===i?this._processUnderline(2,s):22===i?(s.fg&=-134217729,s.bg&=-134217729):23===i?s.bg&=-67108865:24===i?(s.fg&=-268435457,this._processUnderline(0,s)):25===i?s.fg&=-536870913:27===i?s.fg&=-67108865:28===i?s.fg&=-1073741825:29===i?s.fg&=2147483647:39===i?(s.fg&=-67108864,s.fg|=16777215&l.DEFAULT_ATTR_DATA.fg):49===i?(s.bg&=-67108864,s.bg|=16777215&l.DEFAULT_ATTR_DATA.bg):38===i||48===i||58===i?r+=this._extractColor(e,r,s):53===i?s.bg|=1073741824:55===i?s.bg&=-1073741825:59===i?(s.extended=s.extended.clone(),s.extended.underlineColor=-1,s.updateExtended()):100===i?(s.fg&=-67108864,s.fg|=16777215&l.DEFAULT_ATTR_DATA.fg,s.bg&=-67108864,s.bg|=16777215&l.DEFAULT_ATTR_DATA.bg):this._logService.debug("Unknown SGR attribute: %d.",i);return!0}deviceStatus(e){switch(e.params[0]){case 5:this._coreService.triggerDataEvent(`${n.C0.ESC}[0n`);break;case 6:const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[${e};${t}R`)}return!0}deviceStatusPrivate(e){if(6===e.params[0]){const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[?${e};${t}R`)}return!0}softReset(e){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(e){const t=e.params[0]||1;switch(t){case 1:case 2:this._optionsService.options.cursorStyle="block";break;case 3:case 4:this._optionsService.options.cursorStyle="underline";break;case 5:case 6:this._optionsService.options.cursorStyle="bar"}const i=t%2==1;return this._optionsService.options.cursorBlink=i,!0}setScrollRegion(e){const t=e.params[0]||1;let i;return(e.length<2||(i=e.params[1])>this._bufferService.rows||0===i)&&(i=this._bufferService.rows),i>t&&(this._activeBuffer.scrollTop=t-1,this._activeBuffer.scrollBottom=i-1,this._setCursor(0,0)),!0}windowOptions(e){if(!b(e.params[0],this._optionsService.rawOptions.windowOptions))return!0;const t=e.length>1?e.params[1]:0;switch(e.params[0]){case 14:2!==t&&this._onRequestWindowsOptionsReport.fire(y.GET_WIN_SIZE_PIXELS);break;case 16:this._onRequestWindowsOptionsReport.fire(y.GET_CELL_SIZE_PIXELS);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`${n.C0.ESC}[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:0!==t&&2!==t||(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>10&&this._windowTitleStack.shift()),0!==t&&1!==t||(this._iconNameStack.push(this._iconName),this._iconNameStack.length>10&&this._iconNameStack.shift());break;case 23:0!==t&&2!==t||this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),0!==t&&1!==t||this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop())}return!0}saveCursor(e){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,!0}restoreCursor(e){return this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg,this._charsetService.charset=this._savedCharset,this._activeBuffer.savedCharset&&(this._charsetService.charset=this._activeBuffer.savedCharset),this._restrictCursor(),!0}setTitle(e){return this._windowTitle=e,this._onTitleChange.fire(e),!0}setIconName(e){return this._iconName=e,!0}setOrReportIndexedColor(e){const t=[],i=e.split(";");for(;i.length>1;){const e=i.shift(),s=i.shift();if(/^\d+$/.exec(e)){const i=parseInt(e);if(L(i))if("?"===s)t.push({type:0,index:i});else{const e=(0,m.parseColor)(s);e&&t.push({type:1,index:i,color:e})}}}return t.length&&this._onColor.fire(t),!0}setHyperlink(e){const t=e.split(";");return!(t.length<2)&&(t[1]?this._createHyperlink(t[0],t[1]):!t[0]&&this._finishHyperlink())}_createHyperlink(e,t){this._getCurrentLinkId()&&this._finishHyperlink();const i=e.split(":");let s;const r=i.findIndex((e=>e.startsWith("id=")));return-1!==r&&(s=i[r].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:s,uri:t}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(e,t){const i=e.split(";");for(let e=0;e=this._specialColors.length);++e,++t)if("?"===i[e])this._onColor.fire([{type:0,index:this._specialColors[t]}]);else{const s=(0,m.parseColor)(i[e]);s&&this._onColor.fire([{type:1,index:this._specialColors[t],color:s}])}return!0}setOrReportFgColor(e){return this._setOrReportSpecialColor(e,0)}setOrReportBgColor(e){return this._setOrReportSpecialColor(e,1)}setOrReportCursorColor(e){return this._setOrReportSpecialColor(e,2)}restoreIndexedColor(e){if(!e)return this._onColor.fire([{type:2}]),!0;const t=[],i=e.split(";");for(let e=0;e=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){const e=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,e,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=67108863&this._curAttrData.bg,this._eraseAttrDataInternal}setgLevel(e){return this._charsetService.setgLevel(e),!0}screenAlignmentPattern(){const e=new u.CellData;e.content=1<<22|"E".charCodeAt(0),e.fg=this._curAttrData.fg,e.bg=this._curAttrData.bg,this._setCursor(0,0);for(let t=0;t(this._coreService.triggerDataEvent(`${n.C0.ESC}${e}${n.C0.ESC}\\`),!0))('"q'===e?`P1$r${this._curAttrData.isProtected()?1:0}"q`:'"p'===e?'P1$r61;1"p':"r"===e?`P1$r${i.scrollTop+1};${i.scrollBottom+1}r`:"m"===e?"P1$r0m":" q"===e?`P1$r${{block:2,underline:4,bar:6}[s.cursorStyle]-(s.cursorBlink?1:0)} q`:"P0$r")}markRangeDirty(e,t){this._dirtyRowTracker.markRangeDirty(e,t)}}t.InputHandler=E;let k=class{constructor(e){this._bufferService=e,this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(e){ethis.end&&(this.end=e)}markRangeDirty(e,t){e>t&&(w=e,e=t,t=w),ethis.end&&(this.end=t)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};function L(e){return 0<=e&&e<256}k=s([r(0,v.IBufferService)],k)},844:(e,t)=>{function i(e){for(const t of e)t.dispose();e.length=0}Object.defineProperty(t,"__esModule",{value:!0}),t.getDisposeArrayDisposable=t.disposeArray=t.toDisposable=t.MutableDisposable=t.Disposable=void 0,t.Disposable=class{constructor(){this._disposables=[],this._isDisposed=!1}dispose(){this._isDisposed=!0;for(const e of this._disposables)e.dispose();this._disposables.length=0}register(e){return this._disposables.push(e),e}unregister(e){const t=this._disposables.indexOf(e);-1!==t&&this._disposables.splice(t,1)}},t.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){var t;this._isDisposed||e===this._value||(null===(t=this._value)||void 0===t||t.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){var e;this._isDisposed=!0,null===(e=this._value)||void 0===e||e.dispose(),this._value=void 0}},t.toDisposable=function(e){return{dispose:e}},t.disposeArray=i,t.getDisposeArrayDisposable=function(e){return{dispose:()=>i(e)}}},1505:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.FourKeyMap=t.TwoKeyMap=void 0;class i{constructor(){this._data={}}set(e,t,i){this._data[e]||(this._data[e]={}),this._data[e][t]=i}get(e,t){return this._data[e]?this._data[e][t]:void 0}clear(){this._data={}}}t.TwoKeyMap=i,t.FourKeyMap=class{constructor(){this._data=new i}set(e,t,s,r,n){this._data.get(e,t)||this._data.set(e,t,new i),this._data.get(e,t).set(s,r,n)}get(e,t,i,s){var r;return null===(r=this._data.get(e,t))||void 0===r?void 0:r.get(i,s)}clear(){this._data.clear()}}},6114:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.isChromeOS=t.isLinux=t.isWindows=t.isIphone=t.isIpad=t.isMac=t.getSafariVersion=t.isSafari=t.isLegacyEdge=t.isFirefox=t.isNode=void 0,t.isNode="undefined"==typeof navigator;const i=t.isNode?"node":navigator.userAgent,s=t.isNode?"node":navigator.platform;t.isFirefox=i.includes("Firefox"),t.isLegacyEdge=i.includes("Edge"),t.isSafari=/^((?!chrome|android).)*safari/i.test(i),t.getSafariVersion=function(){if(!t.isSafari)return 0;const e=i.match(/Version\/(\d+)/);return null===e||e.length<2?0:parseInt(e[1])},t.isMac=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(s),t.isIpad="iPad"===s,t.isIphone="iPhone"===s,t.isWindows=["Windows","Win16","Win32","WinCE"].includes(s),t.isLinux=s.indexOf("Linux")>=0,t.isChromeOS=/\bCrOS\b/.test(i)},6106:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SortedList=void 0;let i=0;t.SortedList=class{constructor(e){this._getKey=e,this._array=[]}clear(){this._array.length=0}insert(e){0!==this._array.length?(i=this._search(this._getKey(e)),this._array.splice(i,0,e)):this._array.push(e)}delete(e){if(0===this._array.length)return!1;const t=this._getKey(e);if(void 0===t)return!1;if(i=this._search(t),-1===i)return!1;if(this._getKey(this._array[i])!==t)return!1;do{if(this._array[i]===e)return this._array.splice(i,1),!0}while(++i=this._array.length)&&this._getKey(this._array[i])===e))do{yield this._array[i]}while(++i=this._array.length)&&this._getKey(this._array[i])===e))do{t(this._array[i])}while(++i=t;){let s=t+i>>1;const r=this._getKey(this._array[s]);if(r>e)i=s-1;else{if(!(r0&&this._getKey(this._array[s-1])===e;)s--;return s}t=s+1}}return t}}},7226:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DebouncedIdleTask=t.IdleTaskQueue=t.PriorityTaskQueue=void 0;const s=i(6114);class r{constructor(){this._tasks=[],this._i=0}enqueue(e){this._tasks.push(e),this._start()}flush(){for(;this._ir)return s-t<-20&&console.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(s-t))}ms`),void this._start();s=r}this.clear()}}class n extends r{_requestCallback(e){return setTimeout((()=>e(this._createDeadline(16))))}_cancelCallback(e){clearTimeout(e)}_createDeadline(e){const t=Date.now()+e;return{timeRemaining:()=>Math.max(0,t-Date.now())}}}t.PriorityTaskQueue=n,t.IdleTaskQueue=!s.isNode&&"requestIdleCallback"in window?class extends r{_requestCallback(e){return requestIdleCallback(e)}_cancelCallback(e){cancelIdleCallback(e)}}:n,t.DebouncedIdleTask=class{constructor(){this._queue=new t.IdleTaskQueue}set(e){this._queue.clear(),this._queue.enqueue(e)}flush(){this._queue.flush()}}},9282:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.updateWindowsModeWrappedState=void 0;const s=i(643);t.updateWindowsModeWrappedState=function(e){const t=e.buffer.lines.get(e.buffer.ybase+e.buffer.y-1),i=null==t?void 0:t.get(e.cols-1),r=e.buffer.lines.get(e.buffer.ybase+e.buffer.y);r&&i&&(r.isWrapped=i[s.CHAR_DATA_CODE_INDEX]!==s.NULL_CELL_CODE&&i[s.CHAR_DATA_CODE_INDEX]!==s.WHITESPACE_CELL_CODE)}},3734:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ExtendedAttrs=t.AttributeData=void 0;class i{constructor(){this.fg=0,this.bg=0,this.extended=new s}static toColorRGB(e){return[e>>>16&255,e>>>8&255,255&e]}static fromColorRGB(e){return(255&e[0])<<16|(255&e[1])<<8|255&e[2]}clone(){const e=new i;return e.fg=this.fg,e.bg=this.bg,e.extended=this.extended.clone(),e}isInverse(){return 67108864&this.fg}isBold(){return 134217728&this.fg}isUnderline(){return this.hasExtendedAttrs()&&0!==this.extended.underlineStyle?1:268435456&this.fg}isBlink(){return 536870912&this.fg}isInvisible(){return 1073741824&this.fg}isItalic(){return 67108864&this.bg}isDim(){return 134217728&this.bg}isStrikethrough(){return 2147483648&this.fg}isProtected(){return 536870912&this.bg}isOverline(){return 1073741824&this.bg}getFgColorMode(){return 50331648&this.fg}getBgColorMode(){return 50331648&this.bg}isFgRGB(){return 50331648==(50331648&this.fg)}isBgRGB(){return 50331648==(50331648&this.bg)}isFgPalette(){return 16777216==(50331648&this.fg)||33554432==(50331648&this.fg)}isBgPalette(){return 16777216==(50331648&this.bg)||33554432==(50331648&this.bg)}isFgDefault(){return 0==(50331648&this.fg)}isBgDefault(){return 0==(50331648&this.bg)}isAttributeDefault(){return 0===this.fg&&0===this.bg}getFgColor(){switch(50331648&this.fg){case 16777216:case 33554432:return 255&this.fg;case 50331648:return 16777215&this.fg;default:return-1}}getBgColor(){switch(50331648&this.bg){case 16777216:case 33554432:return 255&this.bg;case 50331648:return 16777215&this.bg;default:return-1}}hasExtendedAttrs(){return 268435456&this.bg}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(268435456&this.bg&&~this.extended.underlineColor)switch(50331648&this.extended.underlineColor){case 16777216:case 33554432:return 255&this.extended.underlineColor;case 50331648:return 16777215&this.extended.underlineColor;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return 268435456&this.bg&&~this.extended.underlineColor?50331648&this.extended.underlineColor:this.getFgColorMode()}isUnderlineColorRGB(){return 268435456&this.bg&&~this.extended.underlineColor?50331648==(50331648&this.extended.underlineColor):this.isFgRGB()}isUnderlineColorPalette(){return 268435456&this.bg&&~this.extended.underlineColor?16777216==(50331648&this.extended.underlineColor)||33554432==(50331648&this.extended.underlineColor):this.isFgPalette()}isUnderlineColorDefault(){return 268435456&this.bg&&~this.extended.underlineColor?0==(50331648&this.extended.underlineColor):this.isFgDefault()}getUnderlineStyle(){return 268435456&this.fg?268435456&this.bg?this.extended.underlineStyle:1:0}}t.AttributeData=i;class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(e){this._ext&=-67108864,this._ext|=67108863&e}get urlId(){return this._urlId}set urlId(e){this._urlId=e}constructor(e=0,t=0){this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}clone(){return new s(this._ext,this._urlId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId}}t.ExtendedAttrs=s},9092:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Buffer=t.MAX_BUFFER_SIZE=void 0;const s=i(6349),r=i(7226),n=i(3734),o=i(8437),a=i(4634),h=i(511),c=i(643),l=i(4863),d=i(7116);t.MAX_BUFFER_SIZE=4294967295,t.Buffer=class{constructor(e,t,i){this._hasScrollback=e,this._optionsService=t,this._bufferService=i,this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.tabs={},this.savedY=0,this.savedX=0,this.savedCurAttrData=o.DEFAULT_ATTR_DATA.clone(),this.savedCharset=d.DEFAULT_CHARSET,this.markers=[],this._nullCell=h.CellData.fromCharData([0,c.NULL_CELL_CHAR,c.NULL_CELL_WIDTH,c.NULL_CELL_CODE]),this._whitespaceCell=h.CellData.fromCharData([0,c.WHITESPACE_CELL_CHAR,c.WHITESPACE_CELL_WIDTH,c.WHITESPACE_CELL_CODE]),this._isClearing=!1,this._memoryCleanupQueue=new r.IdleTaskQueue,this._memoryCleanupPosition=0,this._cols=this._bufferService.cols,this._rows=this._bufferService.rows,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}getNullCell(e){return e?(this._nullCell.fg=e.fg,this._nullCell.bg=e.bg,this._nullCell.extended=e.extended):(this._nullCell.fg=0,this._nullCell.bg=0,this._nullCell.extended=new n.ExtendedAttrs),this._nullCell}getWhitespaceCell(e){return e?(this._whitespaceCell.fg=e.fg,this._whitespaceCell.bg=e.bg,this._whitespaceCell.extended=e.extended):(this._whitespaceCell.fg=0,this._whitespaceCell.bg=0,this._whitespaceCell.extended=new n.ExtendedAttrs),this._whitespaceCell}getBlankLine(e,t){return new o.BufferLine(this._bufferService.cols,this.getNullCell(e),t)}get hasScrollback(){return this._hasScrollback&&this.lines.maxLength>this._rows}get isCursorInViewport(){const e=this.ybase+this.y-this.ydisp;return e>=0&&et.MAX_BUFFER_SIZE?t.MAX_BUFFER_SIZE:i}fillViewportRows(e){if(0===this.lines.length){void 0===e&&(e=o.DEFAULT_ATTR_DATA);let t=this._rows;for(;t--;)this.lines.push(this.getBlankLine(e))}}clear(){this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}resize(e,t){const i=this.getNullCell(o.DEFAULT_ATTR_DATA);let s=0;const r=this._getCorrectBufferLength(t);if(r>this.lines.maxLength&&(this.lines.maxLength=r),this.lines.length>0){if(this._cols0&&this.lines.length<=this.ybase+this.y+n+1?(this.ybase--,n++,this.ydisp>0&&this.ydisp--):this.lines.push(new o.BufferLine(e,i)));else for(let e=this._rows;e>t;e--)this.lines.length>t+this.ybase&&(this.lines.length>this.ybase+this.y+1?this.lines.pop():(this.ybase++,this.ydisp++));if(r0&&(this.lines.trimStart(e),this.ybase=Math.max(this.ybase-e,0),this.ydisp=Math.max(this.ydisp-e,0),this.savedY=Math.max(this.savedY-e,0)),this.lines.maxLength=r}this.x=Math.min(this.x,e-1),this.y=Math.min(this.y,t-1),n&&(this.y+=n),this.savedX=Math.min(this.savedX,e-1),this.scrollTop=0}if(this.scrollBottom=t-1,this._isReflowEnabled&&(this._reflow(e,t),this._cols>e))for(let t=0;t.1*this.lines.length&&(this._memoryCleanupPosition=0,this._memoryCleanupQueue.enqueue((()=>this._batchedMemoryCleanup())))}_batchedMemoryCleanup(){let e=!0;this._memoryCleanupPosition>=this.lines.length&&(this._memoryCleanupPosition=0,e=!1);let t=0;for(;this._memoryCleanupPosition100)return!0;return e}get _isReflowEnabled(){const e=this._optionsService.rawOptions.windowsPty;return e&&e.buildNumber?this._hasScrollback&&"conpty"===e.backend&&e.buildNumber>=21376:this._hasScrollback&&!this._optionsService.rawOptions.windowsMode}_reflow(e,t){this._cols!==e&&(e>this._cols?this._reflowLarger(e,t):this._reflowSmaller(e,t))}_reflowLarger(e,t){const i=(0,a.reflowLargerGetLinesToRemove)(this.lines,this._cols,e,this.ybase+this.y,this.getNullCell(o.DEFAULT_ATTR_DATA));if(i.length>0){const s=(0,a.reflowLargerCreateNewLayout)(this.lines,i);(0,a.reflowLargerApplyNewLayout)(this.lines,s.layout),this._reflowLargerAdjustViewport(e,t,s.countRemoved)}}_reflowLargerAdjustViewport(e,t,i){const s=this.getNullCell(o.DEFAULT_ATTR_DATA);let r=i;for(;r-- >0;)0===this.ybase?(this.y>0&&this.y--,this.lines.length=0;n--){let h=this.lines.get(n);if(!h||!h.isWrapped&&h.getTrimmedLength()<=e)continue;const c=[h];for(;h.isWrapped&&n>0;)h=this.lines.get(--n),c.unshift(h);const l=this.ybase+this.y;if(l>=n&&l0&&(s.push({start:n+c.length+r,newLines:v}),r+=v.length),c.push(...v);let p=_.length-1,g=_[p];0===g&&(p--,g=_[p]);let m=c.length-u-1,S=d;for(;m>=0;){const e=Math.min(S,g);if(void 0===c[p])break;if(c[p].copyCellsFrom(c[m],S-e,g-e,e,!0),g-=e,0===g&&(p--,g=_[p]),S-=e,0===S){m--;const e=Math.max(m,0);S=(0,a.getWrappedLineTrimmedLength)(c,e,this._cols)}}for(let t=0;t0;)0===this.ybase?this.y0){const e=[],t=[];for(let e=0;e=0;c--)if(a&&a.start>n+h){for(let e=a.newLines.length-1;e>=0;e--)this.lines.set(c--,a.newLines[e]);c++,e.push({index:n+1,amount:a.newLines.length}),h+=a.newLines.length,a=s[++o]}else this.lines.set(c,t[n--]);let c=0;for(let t=e.length-1;t>=0;t--)e[t].index+=c,this.lines.onInsertEmitter.fire(e[t]),c+=e[t].amount;const l=Math.max(0,i+r-this.lines.maxLength);l>0&&this.lines.onTrimEmitter.fire(l)}}translateBufferLineToString(e,t,i=0,s){const r=this.lines.get(e);return r?r.translateToString(t,i,s):""}getWrappedRangeForLine(e){let t=e,i=e;for(;t>0&&this.lines.get(t).isWrapped;)t--;for(;i+10;);return e>=this._cols?this._cols-1:e<0?0:e}nextStop(e){for(null==e&&(e=this.x);!this.tabs[++e]&&e=this._cols?this._cols-1:e<0?0:e}clearMarkers(e){this._isClearing=!0;for(let t=0;t{t.line-=e,t.line<0&&t.dispose()}))),t.register(this.lines.onInsert((e=>{t.line>=e.index&&(t.line+=e.amount)}))),t.register(this.lines.onDelete((e=>{t.line>=e.index&&t.linee.index&&(t.line-=e.amount)}))),t.register(t.onDispose((()=>this._removeMarker(t)))),t}_removeMarker(e){this._isClearing||this.markers.splice(this.markers.indexOf(e),1)}}},8437:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLine=t.DEFAULT_ATTR_DATA=void 0;const s=i(3734),r=i(511),n=i(643),o=i(482);t.DEFAULT_ATTR_DATA=Object.freeze(new s.AttributeData);let a=0;class h{constructor(e,t,i=!1){this.isWrapped=i,this._combined={},this._extendedAttrs={},this._data=new Uint32Array(3*e);const s=t||r.CellData.fromCharData([0,n.NULL_CELL_CHAR,n.NULL_CELL_WIDTH,n.NULL_CELL_CODE]);for(let t=0;t>22,2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):i]}set(e,t){this._data[3*e+1]=t[n.CHAR_DATA_ATTR_INDEX],t[n.CHAR_DATA_CHAR_INDEX].length>1?(this._combined[e]=t[1],this._data[3*e+0]=2097152|e|t[n.CHAR_DATA_WIDTH_INDEX]<<22):this._data[3*e+0]=t[n.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|t[n.CHAR_DATA_WIDTH_INDEX]<<22}getWidth(e){return this._data[3*e+0]>>22}hasWidth(e){return 12582912&this._data[3*e+0]}getFg(e){return this._data[3*e+1]}getBg(e){return this._data[3*e+2]}hasContent(e){return 4194303&this._data[3*e+0]}getCodePoint(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):2097151&t}isCombined(e){return 2097152&this._data[3*e+0]}getString(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e]:2097151&t?(0,o.stringFromCodePoint)(2097151&t):""}isProtected(e){return 536870912&this._data[3*e+2]}loadCell(e,t){return a=3*e,t.content=this._data[a+0],t.fg=this._data[a+1],t.bg=this._data[a+2],2097152&t.content&&(t.combinedData=this._combined[e]),268435456&t.bg&&(t.extended=this._extendedAttrs[e]),t}setCell(e,t){2097152&t.content&&(this._combined[e]=t.combinedData),268435456&t.bg&&(this._extendedAttrs[e]=t.extended),this._data[3*e+0]=t.content,this._data[3*e+1]=t.fg,this._data[3*e+2]=t.bg}setCellFromCodePoint(e,t,i,s,r,n){268435456&r&&(this._extendedAttrs[e]=n),this._data[3*e+0]=t|i<<22,this._data[3*e+1]=s,this._data[3*e+2]=r}addCodepointToCell(e,t){let i=this._data[3*e+0];2097152&i?this._combined[e]+=(0,o.stringFromCodePoint)(t):(2097151&i?(this._combined[e]=(0,o.stringFromCodePoint)(2097151&i)+(0,o.stringFromCodePoint)(t),i&=-2097152,i|=2097152):i=t|1<<22,this._data[3*e+0]=i)}insertCells(e,t,i,n){if((e%=this.length)&&2===this.getWidth(e-1)&&this.setCellFromCodePoint(e-1,0,1,(null==n?void 0:n.fg)||0,(null==n?void 0:n.bg)||0,(null==n?void 0:n.extended)||new s.ExtendedAttrs),t=0;--i)this.setCell(e+t+i,this.loadCell(e+i,s));for(let s=0;sthis.length){if(this._data.buffer.byteLength>=4*i)this._data=new Uint32Array(this._data.buffer,0,i);else{const e=new Uint32Array(i);e.set(this._data),this._data=e}for(let i=this.length;i=e&&delete this._combined[s]}const s=Object.keys(this._extendedAttrs);for(let t=0;t=e&&delete this._extendedAttrs[i]}}return this.length=e,4*i*2=0;--e)if(4194303&this._data[3*e+0])return e+(this._data[3*e+0]>>22);return 0}getNoBgTrimmedLength(){for(let e=this.length-1;e>=0;--e)if(4194303&this._data[3*e+0]||50331648&this._data[3*e+2])return e+(this._data[3*e+0]>>22);return 0}copyCellsFrom(e,t,i,s,r){const n=e._data;if(r)for(let r=s-1;r>=0;r--){for(let e=0;e<3;e++)this._data[3*(i+r)+e]=n[3*(t+r)+e];268435456&n[3*(t+r)+2]&&(this._extendedAttrs[i+r]=e._extendedAttrs[t+r])}else for(let r=0;r=t&&(this._combined[r-t+i]=e._combined[r])}}translateToString(e=!1,t=0,i=this.length){e&&(i=Math.min(i,this.getTrimmedLength()));let s="";for(;t>22||1}return s}}t.BufferLine=h},4841:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.getRangeLength=void 0,t.getRangeLength=function(e,t){if(e.start.y>e.end.y)throw new Error(`Buffer range end (${e.end.x}, ${e.end.y}) cannot be before start (${e.start.x}, ${e.start.y})`);return t*(e.end.y-e.start.y)+(e.end.x-e.start.x+1)}},4634:(e,t)=>{function i(e,t,i){if(t===e.length-1)return e[t].getTrimmedLength();const s=!e[t].hasContent(i-1)&&1===e[t].getWidth(i-1),r=2===e[t+1].getWidth(0);return s&&r?i-1:i}Object.defineProperty(t,"__esModule",{value:!0}),t.getWrappedLineTrimmedLength=t.reflowSmallerGetNewLineLengths=t.reflowLargerApplyNewLayout=t.reflowLargerCreateNewLayout=t.reflowLargerGetLinesToRemove=void 0,t.reflowLargerGetLinesToRemove=function(e,t,s,r,n){const o=[];for(let a=0;a=a&&r0&&(e>d||0===l[e].getTrimmedLength());e--)v++;v>0&&(o.push(a+l.length-v),o.push(v)),a+=l.length-1}return o},t.reflowLargerCreateNewLayout=function(e,t){const i=[];let s=0,r=t[s],n=0;for(let o=0;oi(e,r,t))).reduce(((e,t)=>e+t));let o=0,a=0,h=0;for(;hc&&(o-=c,a++);const l=2===e[a].getWidth(o-1);l&&o--;const d=l?s-1:s;r.push(d),h+=d}return r},t.getWrappedLineTrimmedLength=i},5295:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferSet=void 0;const s=i(8460),r=i(844),n=i(9092);class o extends r.Disposable{constructor(e,t){super(),this._optionsService=e,this._bufferService=t,this._onBufferActivate=this.register(new s.EventEmitter),this.onBufferActivate=this._onBufferActivate.event,this.reset(),this.register(this._optionsService.onSpecificOptionChange("scrollback",(()=>this.resize(this._bufferService.cols,this._bufferService.rows)))),this.register(this._optionsService.onSpecificOptionChange("tabStopWidth",(()=>this.setupTabStops())))}reset(){this._normal=new n.Buffer(!0,this._optionsService,this._bufferService),this._normal.fillViewportRows(),this._alt=new n.Buffer(!1,this._optionsService,this._bufferService),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}),this.setupTabStops()}get alt(){return this._alt}get active(){return this._activeBuffer}get normal(){return this._normal}activateNormalBuffer(){this._activeBuffer!==this._normal&&(this._normal.x=this._alt.x,this._normal.y=this._alt.y,this._alt.clearAllMarkers(),this._alt.clear(),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}))}activateAltBuffer(e){this._activeBuffer!==this._alt&&(this._alt.fillViewportRows(e),this._alt.x=this._normal.x,this._alt.y=this._normal.y,this._activeBuffer=this._alt,this._onBufferActivate.fire({activeBuffer:this._alt,inactiveBuffer:this._normal}))}resize(e,t){this._normal.resize(e,t),this._alt.resize(e,t),this.setupTabStops(e)}setupTabStops(e){this._normal.setupTabStops(e),this._alt.setupTabStops(e)}}t.BufferSet=o},511:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CellData=void 0;const s=i(482),r=i(643),n=i(3734);class o extends n.AttributeData{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new n.ExtendedAttrs,this.combinedData=""}static fromCharData(e){const t=new o;return t.setFromCharData(e),t}isCombined(){return 2097152&this.content}getWidth(){return this.content>>22}getChars(){return 2097152&this.content?this.combinedData:2097151&this.content?(0,s.stringFromCodePoint)(2097151&this.content):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):2097151&this.content}setFromCharData(e){this.fg=e[r.CHAR_DATA_ATTR_INDEX],this.bg=0;let t=!1;if(e[r.CHAR_DATA_CHAR_INDEX].length>2)t=!0;else if(2===e[r.CHAR_DATA_CHAR_INDEX].length){const i=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0);if(55296<=i&&i<=56319){const s=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(1);56320<=s&&s<=57343?this.content=1024*(i-55296)+s-56320+65536|e[r.CHAR_DATA_WIDTH_INDEX]<<22:t=!0}else t=!0}else this.content=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|e[r.CHAR_DATA_WIDTH_INDEX]<<22;t&&(this.combinedData=e[r.CHAR_DATA_CHAR_INDEX],this.content=2097152|e[r.CHAR_DATA_WIDTH_INDEX]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.CellData=o},643:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.WHITESPACE_CELL_CODE=t.WHITESPACE_CELL_WIDTH=t.WHITESPACE_CELL_CHAR=t.NULL_CELL_CODE=t.NULL_CELL_WIDTH=t.NULL_CELL_CHAR=t.CHAR_DATA_CODE_INDEX=t.CHAR_DATA_WIDTH_INDEX=t.CHAR_DATA_CHAR_INDEX=t.CHAR_DATA_ATTR_INDEX=t.DEFAULT_EXT=t.DEFAULT_ATTR=t.DEFAULT_COLOR=void 0,t.DEFAULT_COLOR=0,t.DEFAULT_ATTR=256|t.DEFAULT_COLOR<<9,t.DEFAULT_EXT=0,t.CHAR_DATA_ATTR_INDEX=0,t.CHAR_DATA_CHAR_INDEX=1,t.CHAR_DATA_WIDTH_INDEX=2,t.CHAR_DATA_CODE_INDEX=3,t.NULL_CELL_CHAR="",t.NULL_CELL_WIDTH=1,t.NULL_CELL_CODE=0,t.WHITESPACE_CELL_CHAR=" ",t.WHITESPACE_CELL_WIDTH=1,t.WHITESPACE_CELL_CODE=32},4863:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Marker=void 0;const s=i(8460),r=i(844);class n{get id(){return this._id}constructor(e){this.line=e,this.isDisposed=!1,this._disposables=[],this._id=n._nextId++,this._onDispose=this.register(new s.EventEmitter),this.onDispose=this._onDispose.event}dispose(){this.isDisposed||(this.isDisposed=!0,this.line=-1,this._onDispose.fire(),(0,r.disposeArray)(this._disposables),this._disposables.length=0)}register(e){return this._disposables.push(e),e}}t.Marker=n,n._nextId=1},7116:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_CHARSET=t.CHARSETS=void 0,t.CHARSETS={},t.DEFAULT_CHARSET=t.CHARSETS.B,t.CHARSETS[0]={"`":"◆",a:"▒",b:"␉",c:"␌",d:"␍",e:"␊",f:"°",g:"±",h:"␤",i:"␋",j:"┘",k:"┐",l:"┌",m:"└",n:"┼",o:"⎺",p:"⎻",q:"─",r:"⎼",s:"⎽",t:"├",u:"┤",v:"┴",w:"┬",x:"│",y:"≤",z:"≥","{":"π","|":"≠","}":"£","~":"·"},t.CHARSETS.A={"#":"£"},t.CHARSETS.B=void 0,t.CHARSETS[4]={"#":"£","@":"¾","[":"ij","\\":"½","]":"|","{":"¨","|":"f","}":"¼","~":"´"},t.CHARSETS.C=t.CHARSETS[5]={"[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS.R={"#":"£","@":"à","[":"°","\\":"ç","]":"§","{":"é","|":"ù","}":"è","~":"¨"},t.CHARSETS.Q={"@":"à","[":"â","\\":"ç","]":"ê","^":"î","`":"ô","{":"é","|":"ù","}":"è","~":"û"},t.CHARSETS.K={"@":"§","[":"Ä","\\":"Ö","]":"Ü","{":"ä","|":"ö","}":"ü","~":"ß"},t.CHARSETS.Y={"#":"£","@":"§","[":"°","\\":"ç","]":"é","`":"ù","{":"à","|":"ò","}":"è","~":"ì"},t.CHARSETS.E=t.CHARSETS[6]={"@":"Ä","[":"Æ","\\":"Ø","]":"Å","^":"Ü","`":"ä","{":"æ","|":"ø","}":"å","~":"ü"},t.CHARSETS.Z={"#":"£","@":"§","[":"¡","\\":"Ñ","]":"¿","{":"°","|":"ñ","}":"ç"},t.CHARSETS.H=t.CHARSETS[7]={"@":"É","[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS["="]={"#":"ù","@":"à","[":"é","\\":"ç","]":"ê","^":"î",_:"è","`":"ô","{":"ä","|":"ö","}":"ü","~":"û"}},2584:(e,t)=>{var i,s,r;Object.defineProperty(t,"__esModule",{value:!0}),t.C1_ESCAPED=t.C1=t.C0=void 0,function(e){e.NUL="\0",e.SOH="",e.STX="",e.ETX="",e.EOT="",e.ENQ="",e.ACK="",e.BEL="",e.BS="\b",e.HT="\t",e.LF="\n",e.VT="\v",e.FF="\f",e.CR="\r",e.SO="",e.SI="",e.DLE="",e.DC1="",e.DC2="",e.DC3="",e.DC4="",e.NAK="",e.SYN="",e.ETB="",e.CAN="",e.EM="",e.SUB="",e.ESC="",e.FS="",e.GS="",e.RS="",e.US="",e.SP=" ",e.DEL=""}(i||(t.C0=i={})),function(e){e.PAD="€",e.HOP="",e.BPH="‚",e.NBH="ƒ",e.IND="„",e.NEL="…",e.SSA="†",e.ESA="‡",e.HTS="ˆ",e.HTJ="‰",e.VTS="Š",e.PLD="‹",e.PLU="Œ",e.RI="",e.SS2="Ž",e.SS3="",e.DCS="",e.PU1="‘",e.PU2="’",e.STS="“",e.CCH="”",e.MW="•",e.SPA="–",e.EPA="—",e.SOS="˜",e.SGCI="™",e.SCI="š",e.CSI="›",e.ST="œ",e.OSC="",e.PM="ž",e.APC="Ÿ"}(s||(t.C1=s={})),function(e){e.ST=`${i.ESC}\\`}(r||(t.C1_ESCAPED=r={}))},7399:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.evaluateKeyboardEvent=void 0;const s=i(2584),r={48:["0",")"],49:["1","!"],50:["2","@"],51:["3","#"],52:["4","$"],53:["5","%"],54:["6","^"],55:["7","&"],56:["8","*"],57:["9","("],186:[";",":"],187:["=","+"],188:[",","<"],189:["-","_"],190:[".",">"],191:["/","?"],192:["`","~"],219:["[","{"],220:["\\","|"],221:["]","}"],222:["'",'"']};t.evaluateKeyboardEvent=function(e,t,i,n){const o={type:0,cancel:!1,key:void 0},a=(e.shiftKey?1:0)|(e.altKey?2:0)|(e.ctrlKey?4:0)|(e.metaKey?8:0);switch(e.keyCode){case 0:"UIKeyInputUpArrow"===e.key?o.key=t?s.C0.ESC+"OA":s.C0.ESC+"[A":"UIKeyInputLeftArrow"===e.key?o.key=t?s.C0.ESC+"OD":s.C0.ESC+"[D":"UIKeyInputRightArrow"===e.key?o.key=t?s.C0.ESC+"OC":s.C0.ESC+"[C":"UIKeyInputDownArrow"===e.key&&(o.key=t?s.C0.ESC+"OB":s.C0.ESC+"[B");break;case 8:if(e.altKey){o.key=s.C0.ESC+s.C0.DEL;break}o.key=s.C0.DEL;break;case 9:if(e.shiftKey){o.key=s.C0.ESC+"[Z";break}o.key=s.C0.HT,o.cancel=!0;break;case 13:o.key=e.altKey?s.C0.ESC+s.C0.CR:s.C0.CR,o.cancel=!0;break;case 27:o.key=s.C0.ESC,e.altKey&&(o.key=s.C0.ESC+s.C0.ESC),o.cancel=!0;break;case 37:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"D",o.key===s.C0.ESC+"[1;3D"&&(o.key=s.C0.ESC+(i?"b":"[1;5D"))):o.key=t?s.C0.ESC+"OD":s.C0.ESC+"[D";break;case 39:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"C",o.key===s.C0.ESC+"[1;3C"&&(o.key=s.C0.ESC+(i?"f":"[1;5C"))):o.key=t?s.C0.ESC+"OC":s.C0.ESC+"[C";break;case 38:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"A",i||o.key!==s.C0.ESC+"[1;3A"||(o.key=s.C0.ESC+"[1;5A")):o.key=t?s.C0.ESC+"OA":s.C0.ESC+"[A";break;case 40:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"B",i||o.key!==s.C0.ESC+"[1;3B"||(o.key=s.C0.ESC+"[1;5B")):o.key=t?s.C0.ESC+"OB":s.C0.ESC+"[B";break;case 45:e.shiftKey||e.ctrlKey||(o.key=s.C0.ESC+"[2~");break;case 46:o.key=a?s.C0.ESC+"[3;"+(a+1)+"~":s.C0.ESC+"[3~";break;case 36:o.key=a?s.C0.ESC+"[1;"+(a+1)+"H":t?s.C0.ESC+"OH":s.C0.ESC+"[H";break;case 35:o.key=a?s.C0.ESC+"[1;"+(a+1)+"F":t?s.C0.ESC+"OF":s.C0.ESC+"[F";break;case 33:e.shiftKey?o.type=2:e.ctrlKey?o.key=s.C0.ESC+"[5;"+(a+1)+"~":o.key=s.C0.ESC+"[5~";break;case 34:e.shiftKey?o.type=3:e.ctrlKey?o.key=s.C0.ESC+"[6;"+(a+1)+"~":o.key=s.C0.ESC+"[6~";break;case 112:o.key=a?s.C0.ESC+"[1;"+(a+1)+"P":s.C0.ESC+"OP";break;case 113:o.key=a?s.C0.ESC+"[1;"+(a+1)+"Q":s.C0.ESC+"OQ";break;case 114:o.key=a?s.C0.ESC+"[1;"+(a+1)+"R":s.C0.ESC+"OR";break;case 115:o.key=a?s.C0.ESC+"[1;"+(a+1)+"S":s.C0.ESC+"OS";break;case 116:o.key=a?s.C0.ESC+"[15;"+(a+1)+"~":s.C0.ESC+"[15~";break;case 117:o.key=a?s.C0.ESC+"[17;"+(a+1)+"~":s.C0.ESC+"[17~";break;case 118:o.key=a?s.C0.ESC+"[18;"+(a+1)+"~":s.C0.ESC+"[18~";break;case 119:o.key=a?s.C0.ESC+"[19;"+(a+1)+"~":s.C0.ESC+"[19~";break;case 120:o.key=a?s.C0.ESC+"[20;"+(a+1)+"~":s.C0.ESC+"[20~";break;case 121:o.key=a?s.C0.ESC+"[21;"+(a+1)+"~":s.C0.ESC+"[21~";break;case 122:o.key=a?s.C0.ESC+"[23;"+(a+1)+"~":s.C0.ESC+"[23~";break;case 123:o.key=a?s.C0.ESC+"[24;"+(a+1)+"~":s.C0.ESC+"[24~";break;default:if(!e.ctrlKey||e.shiftKey||e.altKey||e.metaKey)if(i&&!n||!e.altKey||e.metaKey)!i||e.altKey||e.ctrlKey||e.shiftKey||!e.metaKey?e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.keyCode>=48&&1===e.key.length?o.key=e.key:e.key&&e.ctrlKey&&("_"===e.key&&(o.key=s.C0.US),"@"===e.key&&(o.key=s.C0.NUL)):65===e.keyCode&&(o.type=1);else{const t=r[e.keyCode],i=null==t?void 0:t[e.shiftKey?1:0];if(i)o.key=s.C0.ESC+i;else if(e.keyCode>=65&&e.keyCode<=90){const t=e.ctrlKey?e.keyCode-64:e.keyCode+32;let i=String.fromCharCode(t);e.shiftKey&&(i=i.toUpperCase()),o.key=s.C0.ESC+i}else if(32===e.keyCode)o.key=s.C0.ESC+(e.ctrlKey?s.C0.NUL:" ");else if("Dead"===e.key&&e.code.startsWith("Key")){let t=e.code.slice(3,4);e.shiftKey||(t=t.toLowerCase()),o.key=s.C0.ESC+t,o.cancel=!0}}else e.keyCode>=65&&e.keyCode<=90?o.key=String.fromCharCode(e.keyCode-64):32===e.keyCode?o.key=s.C0.NUL:e.keyCode>=51&&e.keyCode<=55?o.key=String.fromCharCode(e.keyCode-51+27):56===e.keyCode?o.key=s.C0.DEL:219===e.keyCode?o.key=s.C0.ESC:220===e.keyCode?o.key=s.C0.FS:221===e.keyCode&&(o.key=s.C0.GS)}return o}},482:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Utf8ToUtf32=t.StringToUtf32=t.utf32ToString=t.stringFromCodePoint=void 0,t.stringFromCodePoint=function(e){return e>65535?(e-=65536,String.fromCharCode(55296+(e>>10))+String.fromCharCode(e%1024+56320)):String.fromCharCode(e)},t.utf32ToString=function(e,t=0,i=e.length){let s="";for(let r=t;r65535?(t-=65536,s+=String.fromCharCode(55296+(t>>10))+String.fromCharCode(t%1024+56320)):s+=String.fromCharCode(t)}return s},t.StringToUtf32=class{constructor(){this._interim=0}clear(){this._interim=0}decode(e,t){const i=e.length;if(!i)return 0;let s=0,r=0;if(this._interim){const i=e.charCodeAt(r++);56320<=i&&i<=57343?t[s++]=1024*(this._interim-55296)+i-56320+65536:(t[s++]=this._interim,t[s++]=i),this._interim=0}for(let n=r;n=i)return this._interim=r,s;const o=e.charCodeAt(n);56320<=o&&o<=57343?t[s++]=1024*(r-55296)+o-56320+65536:(t[s++]=r,t[s++]=o)}else 65279!==r&&(t[s++]=r)}return s}},t.Utf8ToUtf32=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(e,t){const i=e.length;if(!i)return 0;let s,r,n,o,a=0,h=0,c=0;if(this.interim[0]){let s=!1,r=this.interim[0];r&=192==(224&r)?31:224==(240&r)?15:7;let n,o=0;for(;(n=63&this.interim[++o])&&o<4;)r<<=6,r|=n;const h=192==(224&this.interim[0])?2:224==(240&this.interim[0])?3:4,l=h-o;for(;c=i)return 0;if(n=e[c++],128!=(192&n)){c--,s=!0;break}this.interim[o++]=n,r<<=6,r|=63&n}s||(2===h?r<128?c--:t[a++]=r:3===h?r<2048||r>=55296&&r<=57343||65279===r||(t[a++]=r):r<65536||r>1114111||(t[a++]=r)),this.interim.fill(0)}const l=i-4;let d=c;for(;d=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(h=(31&s)<<6|63&r,h<128){d--;continue}t[a++]=h}else if(224==(240&s)){if(d>=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,a;if(n=e[d++],128!=(192&n)){d--;continue}if(h=(15&s)<<12|(63&r)<<6|63&n,h<2048||h>=55296&&h<=57343||65279===h)continue;t[a++]=h}else if(240==(248&s)){if(d>=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,a;if(n=e[d++],128!=(192&n)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,this.interim[2]=n,a;if(o=e[d++],128!=(192&o)){d--;continue}if(h=(7&s)<<18|(63&r)<<12|(63&n)<<6|63&o,h<65536||h>1114111)continue;t[a++]=h}}return a}}},225:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeV6=void 0;const i=[[768,879],[1155,1158],[1160,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1539],[1552,1557],[1611,1630],[1648,1648],[1750,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2305,2306],[2364,2364],[2369,2376],[2381,2381],[2385,2388],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2672,2673],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2817,2817],[2876,2876],[2879,2879],[2881,2883],[2893,2893],[2902,2902],[2946,2946],[3008,3008],[3021,3021],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3393,3395],[3405,3405],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3769],[3771,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3984,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4146],[4150,4151],[4153,4153],[4184,4185],[4448,4607],[4959,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6157],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7616,7626],[7678,7679],[8203,8207],[8234,8238],[8288,8291],[8298,8303],[8400,8431],[12330,12335],[12441,12442],[43014,43014],[43019,43019],[43045,43046],[64286,64286],[65024,65039],[65056,65059],[65279,65279],[65529,65531]],s=[[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[917505,917505],[917536,917631],[917760,917999]];let r;t.UnicodeV6=class{constructor(){if(this.version="6",!r){r=new Uint8Array(65536),r.fill(1),r[0]=0,r.fill(0,1,32),r.fill(0,127,160),r.fill(2,4352,4448),r[9001]=2,r[9002]=2,r.fill(2,11904,42192),r[12351]=1,r.fill(2,44032,55204),r.fill(2,63744,64256),r.fill(2,65040,65050),r.fill(2,65072,65136),r.fill(2,65280,65377),r.fill(2,65504,65511);for(let e=0;et[r][1])return!1;for(;r>=s;)if(i=s+r>>1,e>t[i][1])s=i+1;else{if(!(e=131072&&e<=196605||e>=196608&&e<=262141?2:1}}},5981:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.WriteBuffer=void 0;const s=i(8460),r=i(844);class n extends r.Disposable{constructor(e){super(),this._action=e,this._writeBuffer=[],this._callbacks=[],this._pendingData=0,this._bufferOffset=0,this._isSyncWriting=!1,this._syncCalls=0,this._didUserInput=!1,this._onWriteParsed=this.register(new s.EventEmitter),this.onWriteParsed=this._onWriteParsed.event}handleUserInput(){this._didUserInput=!0}writeSync(e,t){if(void 0!==t&&this._syncCalls>t)return void(this._syncCalls=0);if(this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;let i;for(this._isSyncWriting=!0;i=this._writeBuffer.shift();){this._action(i);const e=this._callbacks.shift();e&&e()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(e,t){if(this._pendingData>5e7)throw new Error("write data discarded, use flow control to avoid losing data");if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput)return this._didUserInput=!1,this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t),void this._innerWrite();setTimeout((()=>this._innerWrite()))}this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t)}_innerWrite(e=0,t=!0){const i=e||Date.now();for(;this._writeBuffer.length>this._bufferOffset;){const e=this._writeBuffer[this._bufferOffset],s=this._action(e,t);if(s){const e=e=>Date.now()-i>=12?setTimeout((()=>this._innerWrite(0,e))):this._innerWrite(i,e);return void s.catch((e=>(queueMicrotask((()=>{throw e})),Promise.resolve(!1)))).then(e)}const r=this._callbacks[this._bufferOffset];if(r&&r(),this._bufferOffset++,this._pendingData-=e.length,Date.now()-i>=12)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>50&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),setTimeout((()=>this._innerWrite()))):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}}t.WriteBuffer=n},5941:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.toRgbString=t.parseColor=void 0;const i=/^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/,s=/^[\da-f]+$/;function r(e,t){const i=e.toString(16),s=i.length<2?"0"+i:i;switch(t){case 4:return i[0];case 8:return s;case 12:return(s+s).slice(0,3);default:return s+s}}t.parseColor=function(e){if(!e)return;let t=e.toLowerCase();if(0===t.indexOf("rgb:")){t=t.slice(4);const e=i.exec(t);if(e){const t=e[1]?15:e[4]?255:e[7]?4095:65535;return[Math.round(parseInt(e[1]||e[4]||e[7]||e[10],16)/t*255),Math.round(parseInt(e[2]||e[5]||e[8]||e[11],16)/t*255),Math.round(parseInt(e[3]||e[6]||e[9]||e[12],16)/t*255)]}}else if(0===t.indexOf("#")&&(t=t.slice(1),s.exec(t)&&[3,6,9,12].includes(t.length))){const e=t.length/3,i=[0,0,0];for(let s=0;s<3;++s){const r=parseInt(t.slice(e*s,e*s+e),16);i[s]=1===e?r<<4:2===e?r:3===e?r>>4:r>>8}return i}},t.toRgbString=function(e,t=16){const[i,s,n]=e;return`rgb:${r(i,t)}/${r(s,t)}/${r(n,t)}`}},5770:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.PAYLOAD_LIMIT=void 0,t.PAYLOAD_LIMIT=1e7},6351:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DcsHandler=t.DcsParser=void 0;const s=i(482),r=i(8742),n=i(5770),o=[];t.DcsParser=class{constructor(){this._handlers=Object.create(null),this._active=o,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=o}registerHandler(e,t){void 0===this._handlers[e]&&(this._handlers[e]=[]);const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}reset(){if(this._active.length)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].unhook(!1);this._stack.paused=!1,this._active=o,this._ident=0}hook(e,t){if(this.reset(),this._ident=e,this._active=this._handlers[e]||o,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].hook(t);else this._handlerFb(this._ident,"HOOK",t)}put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._ident,"PUT",(0,s.utf32ToString)(e,t,i))}unhook(e,t=!0){if(this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].unhook(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].unhook(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._ident,"UNHOOK",e);this._active=o,this._ident=0}};const a=new r.Params;a.addParam(0),t.DcsHandler=class{constructor(e){this._handler=e,this._data="",this._params=a,this._hitLimit=!1}hook(e){this._params=e.length>1||e.params[0]?e.clone():a,this._data="",this._hitLimit=!1}put(e,t,i){this._hitLimit||(this._data+=(0,s.utf32ToString)(e,t,i),this._data.length>n.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}unhook(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data,this._params),t instanceof Promise))return t.then((e=>(this._params=a,this._data="",this._hitLimit=!1,e)));return this._params=a,this._data="",this._hitLimit=!1,t}}},2015:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.EscapeSequenceParser=t.VT500_TRANSITION_TABLE=t.TransitionTable=void 0;const s=i(844),r=i(8742),n=i(6242),o=i(6351);class a{constructor(e){this.table=new Uint8Array(e)}setDefault(e,t){this.table.fill(e<<4|t)}add(e,t,i,s){this.table[t<<8|e]=i<<4|s}addMany(e,t,i,s){for(let r=0;rt)),i=(e,i)=>t.slice(e,i),s=i(32,127),r=i(0,24);r.push(25),r.push.apply(r,i(28,32));const n=i(0,14);let o;for(o in e.setDefault(1,0),e.addMany(s,0,2,0),n)e.addMany([24,26,153,154],o,3,0),e.addMany(i(128,144),o,3,0),e.addMany(i(144,152),o,3,0),e.add(156,o,0,0),e.add(27,o,11,1),e.add(157,o,4,8),e.addMany([152,158,159],o,0,7),e.add(155,o,11,3),e.add(144,o,11,9);return e.addMany(r,0,3,0),e.addMany(r,1,3,1),e.add(127,1,0,1),e.addMany(r,8,0,8),e.addMany(r,3,3,3),e.add(127,3,0,3),e.addMany(r,4,3,4),e.add(127,4,0,4),e.addMany(r,6,3,6),e.addMany(r,5,3,5),e.add(127,5,0,5),e.addMany(r,2,3,2),e.add(127,2,0,2),e.add(93,1,4,8),e.addMany(s,8,5,8),e.add(127,8,5,8),e.addMany([156,27,24,26,7],8,6,0),e.addMany(i(28,32),8,0,8),e.addMany([88,94,95],1,0,7),e.addMany(s,7,0,7),e.addMany(r,7,0,7),e.add(156,7,0,0),e.add(127,7,0,7),e.add(91,1,11,3),e.addMany(i(64,127),3,7,0),e.addMany(i(48,60),3,8,4),e.addMany([60,61,62,63],3,9,4),e.addMany(i(48,60),4,8,4),e.addMany(i(64,127),4,7,0),e.addMany([60,61,62,63],4,0,6),e.addMany(i(32,64),6,0,6),e.add(127,6,0,6),e.addMany(i(64,127),6,0,0),e.addMany(i(32,48),3,9,5),e.addMany(i(32,48),5,9,5),e.addMany(i(48,64),5,0,6),e.addMany(i(64,127),5,7,0),e.addMany(i(32,48),4,9,5),e.addMany(i(32,48),1,9,2),e.addMany(i(32,48),2,9,2),e.addMany(i(48,127),2,10,0),e.addMany(i(48,80),1,10,0),e.addMany(i(81,88),1,10,0),e.addMany([89,90,92],1,10,0),e.addMany(i(96,127),1,10,0),e.add(80,1,11,9),e.addMany(r,9,0,9),e.add(127,9,0,9),e.addMany(i(28,32),9,0,9),e.addMany(i(32,48),9,9,12),e.addMany(i(48,60),9,8,10),e.addMany([60,61,62,63],9,9,10),e.addMany(r,11,0,11),e.addMany(i(32,128),11,0,11),e.addMany(i(28,32),11,0,11),e.addMany(r,10,0,10),e.add(127,10,0,10),e.addMany(i(28,32),10,0,10),e.addMany(i(48,60),10,8,10),e.addMany([60,61,62,63],10,0,11),e.addMany(i(32,48),10,9,12),e.addMany(r,12,0,12),e.add(127,12,0,12),e.addMany(i(28,32),12,0,12),e.addMany(i(32,48),12,9,12),e.addMany(i(48,64),12,0,11),e.addMany(i(64,127),12,12,13),e.addMany(i(64,127),10,12,13),e.addMany(i(64,127),9,12,13),e.addMany(r,13,13,13),e.addMany(s,13,13,13),e.add(127,13,0,13),e.addMany([27,156,24,26],13,14,0),e.add(h,0,2,0),e.add(h,8,5,8),e.add(h,6,0,6),e.add(h,11,0,11),e.add(h,13,13,13),e}();class c extends s.Disposable{constructor(e=t.VT500_TRANSITION_TABLE){super(),this._transitions=e,this._parseStack={state:0,handlers:[],handlerPos:0,transition:0,chunkPos:0},this.initialState=0,this.currentState=this.initialState,this._params=new r.Params,this._params.addParam(0),this._collect=0,this.precedingCodepoint=0,this._printHandlerFb=(e,t,i)=>{},this._executeHandlerFb=e=>{},this._csiHandlerFb=(e,t)=>{},this._escHandlerFb=e=>{},this._errorHandlerFb=e=>e,this._printHandler=this._printHandlerFb,this._executeHandlers=Object.create(null),this._csiHandlers=Object.create(null),this._escHandlers=Object.create(null),this.register((0,s.toDisposable)((()=>{this._csiHandlers=Object.create(null),this._executeHandlers=Object.create(null),this._escHandlers=Object.create(null)}))),this._oscParser=this.register(new n.OscParser),this._dcsParser=this.register(new o.DcsParser),this._errorHandler=this._errorHandlerFb,this.registerEscHandler({final:"\\"},(()=>!0))}_identifier(e,t=[64,126]){let i=0;if(e.prefix){if(e.prefix.length>1)throw new Error("only one byte as prefix supported");if(i=e.prefix.charCodeAt(0),i&&60>i||i>63)throw new Error("prefix must be in range 0x3c .. 0x3f")}if(e.intermediates){if(e.intermediates.length>2)throw new Error("only two bytes as intermediates are supported");for(let t=0;ts||s>47)throw new Error("intermediate must be in range 0x20 .. 0x2f");i<<=8,i|=s}}if(1!==e.final.length)throw new Error("final must be a single byte");const s=e.final.charCodeAt(0);if(t[0]>s||s>t[1])throw new Error(`final must be in range ${t[0]} .. ${t[1]}`);return i<<=8,i|=s,i}identToString(e){const t=[];for(;e;)t.push(String.fromCharCode(255&e)),e>>=8;return t.reverse().join("")}setPrintHandler(e){this._printHandler=e}clearPrintHandler(){this._printHandler=this._printHandlerFb}registerEscHandler(e,t){const i=this._identifier(e,[48,126]);void 0===this._escHandlers[i]&&(this._escHandlers[i]=[]);const s=this._escHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearEscHandler(e){this._escHandlers[this._identifier(e,[48,126])]&&delete this._escHandlers[this._identifier(e,[48,126])]}setEscHandlerFallback(e){this._escHandlerFb=e}setExecuteHandler(e,t){this._executeHandlers[e.charCodeAt(0)]=t}clearExecuteHandler(e){this._executeHandlers[e.charCodeAt(0)]&&delete this._executeHandlers[e.charCodeAt(0)]}setExecuteHandlerFallback(e){this._executeHandlerFb=e}registerCsiHandler(e,t){const i=this._identifier(e);void 0===this._csiHandlers[i]&&(this._csiHandlers[i]=[]);const s=this._csiHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearCsiHandler(e){this._csiHandlers[this._identifier(e)]&&delete this._csiHandlers[this._identifier(e)]}setCsiHandlerFallback(e){this._csiHandlerFb=e}registerDcsHandler(e,t){return this._dcsParser.registerHandler(this._identifier(e),t)}clearDcsHandler(e){this._dcsParser.clearHandler(this._identifier(e))}setDcsHandlerFallback(e){this._dcsParser.setHandlerFallback(e)}registerOscHandler(e,t){return this._oscParser.registerHandler(e,t)}clearOscHandler(e){this._oscParser.clearHandler(e)}setOscHandlerFallback(e){this._oscParser.setHandlerFallback(e)}setErrorHandler(e){this._errorHandler=e}clearErrorHandler(){this._errorHandler=this._errorHandlerFb}reset(){this.currentState=this.initialState,this._oscParser.reset(),this._dcsParser.reset(),this._params.reset(),this._params.addParam(0),this._collect=0,this.precedingCodepoint=0,0!==this._parseStack.state&&(this._parseStack.state=2,this._parseStack.handlers=[])}_preserveStack(e,t,i,s,r){this._parseStack.state=e,this._parseStack.handlers=t,this._parseStack.handlerPos=i,this._parseStack.transition=s,this._parseStack.chunkPos=r}parse(e,t,i){let s,r=0,n=0,o=0;if(this._parseStack.state)if(2===this._parseStack.state)this._parseStack.state=0,o=this._parseStack.chunkPos+1;else{if(void 0===i||1===this._parseStack.state)throw this._parseStack.state=1,new Error("improper continuation due to previous async handler, giving up parsing");const t=this._parseStack.handlers;let n=this._parseStack.handlerPos-1;switch(this._parseStack.state){case 3:if(!1===i&&n>-1)for(;n>=0&&(s=t[n](this._params),!0!==s);n--)if(s instanceof Promise)return this._parseStack.handlerPos=n,s;this._parseStack.handlers=[];break;case 4:if(!1===i&&n>-1)for(;n>=0&&(s=t[n](),!0!==s);n--)if(s instanceof Promise)return this._parseStack.handlerPos=n,s;this._parseStack.handlers=[];break;case 6:if(r=e[this._parseStack.chunkPos],s=this._dcsParser.unhook(24!==r&&26!==r,i),s)return s;27===r&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0;break;case 5:if(r=e[this._parseStack.chunkPos],s=this._oscParser.end(24!==r&&26!==r,i),s)return s;27===r&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0}this._parseStack.state=0,o=this._parseStack.chunkPos+1,this.precedingCodepoint=0,this.currentState=15&this._parseStack.transition}for(let i=o;i>4){case 2:for(let s=i+1;;++s){if(s>=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=0&&(s=o[a](this._params),!0!==s);a--)if(s instanceof Promise)return this._preserveStack(3,o,a,n,i),s;a<0&&this._csiHandlerFb(this._collect<<8|r,this._params),this.precedingCodepoint=0;break;case 8:do{switch(r){case 59:this._params.addParam(0);break;case 58:this._params.addSubParam(-1);break;default:this._params.addDigit(r-48)}}while(++i47&&r<60);i--;break;case 9:this._collect<<=8,this._collect|=r;break;case 10:const c=this._escHandlers[this._collect<<8|r];let l=c?c.length-1:-1;for(;l>=0&&(s=c[l](),!0!==s);l--)if(s instanceof Promise)return this._preserveStack(4,c,l,n,i),s;l<0&&this._escHandlerFb(this._collect<<8|r),this.precedingCodepoint=0;break;case 11:this._params.reset(),this._params.addParam(0),this._collect=0;break;case 12:this._dcsParser.hook(this._collect<<8|r,this._params);break;case 13:for(let s=i+1;;++s)if(s>=t||24===(r=e[s])||26===r||27===r||r>127&&r=t||(r=e[s])<32||r>127&&r{Object.defineProperty(t,"__esModule",{value:!0}),t.OscHandler=t.OscParser=void 0;const s=i(5770),r=i(482),n=[];t.OscParser=class{constructor(){this._state=0,this._active=n,this._id=-1,this._handlers=Object.create(null),this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){void 0===this._handlers[e]&&(this._handlers[e]=[]);const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=n}reset(){if(2===this._state)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=n,this._id=-1,this._state=0}_start(){if(this._active=this._handlers[this._id]||n,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].start();else this._handlerFb(this._id,"START")}_put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._id,"PUT",(0,r.utf32ToString)(e,t,i))}start(){this.reset(),this._state=1}put(e,t,i){if(3!==this._state){if(1===this._state)for(;t0&&this._put(e,t,i)}}end(e,t=!0){if(0!==this._state){if(3!==this._state)if(1===this._state&&this._start(),this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].end(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].end(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._id,"END",e);this._active=n,this._id=-1,this._state=0}}},t.OscHandler=class{constructor(e){this._handler=e,this._data="",this._hitLimit=!1}start(){this._data="",this._hitLimit=!1}put(e,t,i){this._hitLimit||(this._data+=(0,r.utf32ToString)(e,t,i),this._data.length>s.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}end(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data),t instanceof Promise))return t.then((e=>(this._data="",this._hitLimit=!1,e)));return this._data="",this._hitLimit=!1,t}}},8742:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Params=void 0;const i=2147483647;class s{static fromArray(e){const t=new s;if(!e.length)return t;for(let i=Array.isArray(e[0])?1:0;i256)throw new Error("maxSubParamsLength must not be greater than 256");this.params=new Int32Array(e),this.length=0,this._subParams=new Int32Array(t),this._subParamsLength=0,this._subParamsIdx=new Uint16Array(e),this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}clone(){const e=new s(this.maxLength,this.maxSubParamsLength);return e.params.set(this.params),e.length=this.length,e._subParams.set(this._subParams),e._subParamsLength=this._subParamsLength,e._subParamsIdx.set(this._subParamsIdx),e._rejectDigits=this._rejectDigits,e._rejectSubDigits=this._rejectSubDigits,e._digitIsSub=this._digitIsSub,e}toArray(){const e=[];for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&e.push(Array.prototype.slice.call(this._subParams,i,s))}return e}reset(){this.length=0,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}addParam(e){if(this._digitIsSub=!1,this.length>=this.maxLength)this._rejectDigits=!0;else{if(e<-1)throw new Error("values lesser than -1 are not allowed");this._subParamsIdx[this.length]=this._subParamsLength<<8|this._subParamsLength,this.params[this.length++]=e>i?i:e}}addSubParam(e){if(this._digitIsSub=!0,this.length)if(this._rejectDigits||this._subParamsLength>=this.maxSubParamsLength)this._rejectSubDigits=!0;else{if(e<-1)throw new Error("values lesser than -1 are not allowed");this._subParams[this._subParamsLength++]=e>i?i:e,this._subParamsIdx[this.length-1]++}}hasSubParams(e){return(255&this._subParamsIdx[e])-(this._subParamsIdx[e]>>8)>0}getSubParams(e){const t=this._subParamsIdx[e]>>8,i=255&this._subParamsIdx[e];return i-t>0?this._subParams.subarray(t,i):null}getSubParamsAll(){const e={};for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&(e[t]=this._subParams.slice(i,s))}return e}addDigit(e){let t;if(this._rejectDigits||!(t=this._digitIsSub?this._subParamsLength:this.length)||this._digitIsSub&&this._rejectSubDigits)return;const s=this._digitIsSub?this._subParams:this.params,r=s[t-1];s[t-1]=~r?Math.min(10*r+e,i):e}}t.Params=s},5741:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.AddonManager=void 0,t.AddonManager=class{constructor(){this._addons=[]}dispose(){for(let e=this._addons.length-1;e>=0;e--)this._addons[e].instance.dispose()}loadAddon(e,t){const i={instance:t,dispose:t.dispose,isDisposed:!1};this._addons.push(i),t.dispose=()=>this._wrappedAddonDispose(i),t.activate(e)}_wrappedAddonDispose(e){if(e.isDisposed)return;let t=-1;for(let i=0;i{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferApiView=void 0;const s=i(3785),r=i(511);t.BufferApiView=class{constructor(e,t){this._buffer=e,this.type=t}init(e){return this._buffer=e,this}get cursorY(){return this._buffer.y}get cursorX(){return this._buffer.x}get viewportY(){return this._buffer.ydisp}get baseY(){return this._buffer.ybase}get length(){return this._buffer.lines.length}getLine(e){const t=this._buffer.lines.get(e);if(t)return new s.BufferLineApiView(t)}getNullCell(){return new r.CellData}}},3785:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLineApiView=void 0;const s=i(511);t.BufferLineApiView=class{constructor(e){this._line=e}get isWrapped(){return this._line.isWrapped}get length(){return this._line.length}getCell(e,t){if(!(e<0||e>=this._line.length))return t?(this._line.loadCell(e,t),t):this._line.loadCell(e,new s.CellData)}translateToString(e,t,i){return this._line.translateToString(e,t,i)}}},8285:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferNamespaceApi=void 0;const s=i(8771),r=i(8460),n=i(844);class o extends n.Disposable{constructor(e){super(),this._core=e,this._onBufferChange=this.register(new r.EventEmitter),this.onBufferChange=this._onBufferChange.event,this._normal=new s.BufferApiView(this._core.buffers.normal,"normal"),this._alternate=new s.BufferApiView(this._core.buffers.alt,"alternate"),this._core.buffers.onBufferActivate((()=>this._onBufferChange.fire(this.active)))}get active(){if(this._core.buffers.active===this._core.buffers.normal)return this.normal;if(this._core.buffers.active===this._core.buffers.alt)return this.alternate;throw new Error("Active buffer is neither normal nor alternate")}get normal(){return this._normal.init(this._core.buffers.normal)}get alternate(){return this._alternate.init(this._core.buffers.alt)}}t.BufferNamespaceApi=o},7975:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ParserApi=void 0,t.ParserApi=class{constructor(e){this._core=e}registerCsiHandler(e,t){return this._core.registerCsiHandler(e,(e=>t(e.toArray())))}addCsiHandler(e,t){return this.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._core.registerDcsHandler(e,((e,i)=>t(e,i.toArray())))}addDcsHandler(e,t){return this.registerDcsHandler(e,t)}registerEscHandler(e,t){return this._core.registerEscHandler(e,t)}addEscHandler(e,t){return this.registerEscHandler(e,t)}registerOscHandler(e,t){return this._core.registerOscHandler(e,t)}addOscHandler(e,t){return this.registerOscHandler(e,t)}}},7090:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeApi=void 0,t.UnicodeApi=class{constructor(e){this._core=e}register(e){this._core.unicodeService.register(e)}get versions(){return this._core.unicodeService.versions}get activeVersion(){return this._core.unicodeService.activeVersion}set activeVersion(e){this._core.unicodeService.activeVersion=e}}},744:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferService=t.MINIMUM_ROWS=t.MINIMUM_COLS=void 0;const n=i(8460),o=i(844),a=i(5295),h=i(2585);t.MINIMUM_COLS=2,t.MINIMUM_ROWS=1;let c=t.BufferService=class extends o.Disposable{get buffer(){return this.buffers.active}constructor(e){super(),this.isUserScrolling=!1,this._onResize=this.register(new n.EventEmitter),this.onResize=this._onResize.event,this._onScroll=this.register(new n.EventEmitter),this.onScroll=this._onScroll.event,this.cols=Math.max(e.rawOptions.cols||0,t.MINIMUM_COLS),this.rows=Math.max(e.rawOptions.rows||0,t.MINIMUM_ROWS),this.buffers=this.register(new a.BufferSet(e,this))}resize(e,t){this.cols=e,this.rows=t,this.buffers.resize(e,t),this._onResize.fire({cols:e,rows:t})}reset(){this.buffers.reset(),this.isUserScrolling=!1}scroll(e,t=!1){const i=this.buffer;let s;s=this._cachedBlankLine,s&&s.length===this.cols&&s.getFg(0)===e.fg&&s.getBg(0)===e.bg||(s=i.getBlankLine(e,t),this._cachedBlankLine=s),s.isWrapped=t;const r=i.ybase+i.scrollTop,n=i.ybase+i.scrollBottom;if(0===i.scrollTop){const e=i.lines.isFull;n===i.lines.length-1?e?i.lines.recycle().copyFrom(s):i.lines.push(s.clone()):i.lines.splice(n+1,0,s.clone()),e?this.isUserScrolling&&(i.ydisp=Math.max(i.ydisp-1,0)):(i.ybase++,this.isUserScrolling||i.ydisp++)}else{const e=n-r+1;i.lines.shiftElements(r+1,e-1,-1),i.lines.set(n,s.clone())}this.isUserScrolling||(i.ydisp=i.ybase),this._onScroll.fire(i.ydisp)}scrollLines(e,t,i){const s=this.buffer;if(e<0){if(0===s.ydisp)return;this.isUserScrolling=!0}else e+s.ydisp>=s.ybase&&(this.isUserScrolling=!1);const r=s.ydisp;s.ydisp=Math.max(Math.min(s.ydisp+e,s.ybase),0),r!==s.ydisp&&(t||this._onScroll.fire(s.ydisp))}};t.BufferService=c=s([r(0,h.IOptionsService)],c)},7994:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CharsetService=void 0,t.CharsetService=class{constructor(){this.glevel=0,this._charsets=[]}reset(){this.charset=void 0,this._charsets=[],this.glevel=0}setgLevel(e){this.glevel=e,this.charset=this._charsets[e]}setgCharset(e,t){this._charsets[e]=t,this.glevel===e&&(this.charset=t)}}},1753:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreMouseService=void 0;const n=i(2585),o=i(8460),a=i(844),h={NONE:{events:0,restrict:()=>!1},X10:{events:1,restrict:e=>4!==e.button&&1===e.action&&(e.ctrl=!1,e.alt=!1,e.shift=!1,!0)},VT200:{events:19,restrict:e=>32!==e.action},DRAG:{events:23,restrict:e=>32!==e.action||3!==e.button},ANY:{events:31,restrict:e=>!0}};function c(e,t){let i=(e.ctrl?16:0)|(e.shift?4:0)|(e.alt?8:0);return 4===e.button?(i|=64,i|=e.action):(i|=3&e.button,4&e.button&&(i|=64),8&e.button&&(i|=128),32===e.action?i|=32:0!==e.action||t||(i|=3)),i}const l=String.fromCharCode,d={DEFAULT:e=>{const t=[c(e,!1)+32,e.col+32,e.row+32];return t[0]>255||t[1]>255||t[2]>255?"":`${l(t[0])}${l(t[1])}${l(t[2])}`},SGR:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${c(e,!0)};${e.col};${e.row}${t}`},SGR_PIXELS:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${c(e,!0)};${e.x};${e.y}${t}`}};let _=t.CoreMouseService=class extends a.Disposable{constructor(e,t){super(),this._bufferService=e,this._coreService=t,this._protocols={},this._encodings={},this._activeProtocol="",this._activeEncoding="",this._lastEvent=null,this._onProtocolChange=this.register(new o.EventEmitter),this.onProtocolChange=this._onProtocolChange.event;for(const e of Object.keys(h))this.addProtocol(e,h[e]);for(const e of Object.keys(d))this.addEncoding(e,d[e]);this.reset()}addProtocol(e,t){this._protocols[e]=t}addEncoding(e,t){this._encodings[e]=t}get activeProtocol(){return this._activeProtocol}get areMouseEventsActive(){return 0!==this._protocols[this._activeProtocol].events}set activeProtocol(e){if(!this._protocols[e])throw new Error(`unknown protocol "${e}"`);this._activeProtocol=e,this._onProtocolChange.fire(this._protocols[e].events)}get activeEncoding(){return this._activeEncoding}set activeEncoding(e){if(!this._encodings[e])throw new Error(`unknown encoding "${e}"`);this._activeEncoding=e}reset(){this.activeProtocol="NONE",this.activeEncoding="DEFAULT",this._lastEvent=null}triggerMouseEvent(e){if(e.col<0||e.col>=this._bufferService.cols||e.row<0||e.row>=this._bufferService.rows)return!1;if(4===e.button&&32===e.action)return!1;if(3===e.button&&32!==e.action)return!1;if(4!==e.button&&(2===e.action||3===e.action))return!1;if(e.col++,e.row++,32===e.action&&this._lastEvent&&this._equalEvents(this._lastEvent,e,"SGR_PIXELS"===this._activeEncoding))return!1;if(!this._protocols[this._activeProtocol].restrict(e))return!1;const t=this._encodings[this._activeEncoding](e);return t&&("DEFAULT"===this._activeEncoding?this._coreService.triggerBinaryEvent(t):this._coreService.triggerDataEvent(t,!0)),this._lastEvent=e,!0}explainEvents(e){return{down:!!(1&e),up:!!(2&e),drag:!!(4&e),move:!!(8&e),wheel:!!(16&e)}}_equalEvents(e,t,i){if(i){if(e.x!==t.x)return!1;if(e.y!==t.y)return!1}else{if(e.col!==t.col)return!1;if(e.row!==t.row)return!1}return e.button===t.button&&e.action===t.action&&e.ctrl===t.ctrl&&e.alt===t.alt&&e.shift===t.shift}};t.CoreMouseService=_=s([r(0,n.IBufferService),r(1,n.ICoreService)],_)},6975:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreService=void 0;const n=i(1439),o=i(8460),a=i(844),h=i(2585),c=Object.freeze({insertMode:!1}),l=Object.freeze({applicationCursorKeys:!1,applicationKeypad:!1,bracketedPasteMode:!1,origin:!1,reverseWraparound:!1,sendFocus:!1,wraparound:!0});let d=t.CoreService=class extends a.Disposable{constructor(e,t,i){super(),this._bufferService=e,this._logService=t,this._optionsService=i,this.isCursorInitialized=!1,this.isCursorHidden=!1,this._onData=this.register(new o.EventEmitter),this.onData=this._onData.event,this._onUserInput=this.register(new o.EventEmitter),this.onUserInput=this._onUserInput.event,this._onBinary=this.register(new o.EventEmitter),this.onBinary=this._onBinary.event,this._onRequestScrollToBottom=this.register(new o.EventEmitter),this.onRequestScrollToBottom=this._onRequestScrollToBottom.event,this.modes=(0,n.clone)(c),this.decPrivateModes=(0,n.clone)(l)}reset(){this.modes=(0,n.clone)(c),this.decPrivateModes=(0,n.clone)(l)}triggerDataEvent(e,t=!1){if(this._optionsService.rawOptions.disableStdin)return;const i=this._bufferService.buffer;t&&this._optionsService.rawOptions.scrollOnUserInput&&i.ybase!==i.ydisp&&this._onRequestScrollToBottom.fire(),t&&this._onUserInput.fire(),this._logService.debug(`sending data "${e}"`,(()=>e.split("").map((e=>e.charCodeAt(0))))),this._onData.fire(e)}triggerBinaryEvent(e){this._optionsService.rawOptions.disableStdin||(this._logService.debug(`sending binary "${e}"`,(()=>e.split("").map((e=>e.charCodeAt(0))))),this._onBinary.fire(e))}};t.CoreService=d=s([r(0,h.IBufferService),r(1,h.ILogService),r(2,h.IOptionsService)],d)},9074:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DecorationService=void 0;const s=i(8055),r=i(8460),n=i(844),o=i(6106);let a=0,h=0;class c extends n.Disposable{get decorations(){return this._decorations.values()}constructor(){super(),this._decorations=new o.SortedList((e=>null==e?void 0:e.marker.line)),this._onDecorationRegistered=this.register(new r.EventEmitter),this.onDecorationRegistered=this._onDecorationRegistered.event,this._onDecorationRemoved=this.register(new r.EventEmitter),this.onDecorationRemoved=this._onDecorationRemoved.event,this.register((0,n.toDisposable)((()=>this.reset())))}registerDecoration(e){if(e.marker.isDisposed)return;const t=new l(e);if(t){const e=t.marker.onDispose((()=>t.dispose()));t.onDispose((()=>{t&&(this._decorations.delete(t)&&this._onDecorationRemoved.fire(t),e.dispose())})),this._decorations.insert(t),this._onDecorationRegistered.fire(t)}return t}reset(){for(const e of this._decorations.values())e.dispose();this._decorations.clear()}*getDecorationsAtCell(e,t,i){var s,r,n;let o=0,a=0;for(const h of this._decorations.getKeyIterator(t))o=null!==(s=h.options.x)&&void 0!==s?s:0,a=o+(null!==(r=h.options.width)&&void 0!==r?r:1),e>=o&&e{var r,n,o;a=null!==(r=t.options.x)&&void 0!==r?r:0,h=a+(null!==(n=t.options.width)&&void 0!==n?n:1),e>=a&&e{Object.defineProperty(t,"__esModule",{value:!0}),t.InstantiationService=t.ServiceCollection=void 0;const s=i(2585),r=i(8343);class n{constructor(...e){this._entries=new Map;for(const[t,i]of e)this.set(t,i)}set(e,t){const i=this._entries.get(e);return this._entries.set(e,t),i}forEach(e){for(const[t,i]of this._entries.entries())e(t,i)}has(e){return this._entries.has(e)}get(e){return this._entries.get(e)}}t.ServiceCollection=n,t.InstantiationService=class{constructor(){this._services=new n,this._services.set(s.IInstantiationService,this)}setService(e,t){this._services.set(e,t)}getService(e){return this._services.get(e)}createInstance(e,...t){const i=(0,r.getServiceDependencies)(e).sort(((e,t)=>e.index-t.index)),s=[];for(const t of i){const i=this._services.get(t.id);if(!i)throw new Error(`[createInstance] ${e.name} depends on UNKNOWN service ${t.id}.`);s.push(i)}const n=i.length>0?i[0].index:t.length;if(t.length!==n)throw new Error(`[createInstance] First service dependency of ${e.name} at position ${n+1} conflicts with ${t.length} static arguments`);return new e(...[...t,...s])}}},7866:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.traceCall=t.setTraceLogger=t.LogService=void 0;const n=i(844),o=i(2585),a={trace:o.LogLevelEnum.TRACE,debug:o.LogLevelEnum.DEBUG,info:o.LogLevelEnum.INFO,warn:o.LogLevelEnum.WARN,error:o.LogLevelEnum.ERROR,off:o.LogLevelEnum.OFF};let h,c=t.LogService=class extends n.Disposable{get logLevel(){return this._logLevel}constructor(e){super(),this._optionsService=e,this._logLevel=o.LogLevelEnum.OFF,this._updateLogLevel(),this.register(this._optionsService.onSpecificOptionChange("logLevel",(()=>this._updateLogLevel()))),h=this}_updateLogLevel(){this._logLevel=a[this._optionsService.rawOptions.logLevel]}_evalLazyOptionalParams(e){for(let t=0;tJSON.stringify(e))).join(", ")})`);const t=s.apply(this,e);return h.trace(`GlyphRenderer#${s.name} return`,t),t}}},7302:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.OptionsService=t.DEFAULT_OPTIONS=void 0;const s=i(8460),r=i(844),n=i(6114);t.DEFAULT_OPTIONS={cols:80,rows:24,cursorBlink:!1,cursorStyle:"block",cursorWidth:1,cursorInactiveStyle:"outline",customGlyphs:!0,drawBoldTextInBrightColors:!0,fastScrollModifier:"alt",fastScrollSensitivity:5,fontFamily:"courier-new, courier, monospace",fontSize:15,fontWeight:"normal",fontWeightBold:"bold",ignoreBracketedPasteMode:!1,lineHeight:1,letterSpacing:0,linkHandler:null,logLevel:"info",logger:null,scrollback:1e3,scrollOnUserInput:!0,scrollSensitivity:1,screenReaderMode:!1,smoothScrollDuration:0,macOptionIsMeta:!1,macOptionClickForcesSelection:!1,minimumContrastRatio:1,disableStdin:!1,allowProposedApi:!1,allowTransparency:!1,tabStopWidth:8,theme:{},rightClickSelectsWord:n.isMac,windowOptions:{},windowsMode:!1,windowsPty:{},wordSeparator:" ()[]{}',\"`",altClickMovesCursor:!0,convertEol:!1,termName:"xterm",cancelEvents:!1,overviewRulerWidth:0};const o=["normal","bold","100","200","300","400","500","600","700","800","900"];class a extends r.Disposable{constructor(e){super(),this._onOptionChange=this.register(new s.EventEmitter),this.onOptionChange=this._onOptionChange.event;const i=Object.assign({},t.DEFAULT_OPTIONS);for(const t in e)if(t in i)try{const s=e[t];i[t]=this._sanitizeAndValidateOption(t,s)}catch(e){console.error(e)}this.rawOptions=i,this.options=Object.assign({},i),this._setupOptions()}onSpecificOptionChange(e,t){return this.onOptionChange((i=>{i===e&&t(this.rawOptions[e])}))}onMultipleOptionChange(e,t){return this.onOptionChange((i=>{-1!==e.indexOf(i)&&t()}))}_setupOptions(){const e=e=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);return this.rawOptions[e]},i=(e,i)=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);i=this._sanitizeAndValidateOption(e,i),this.rawOptions[e]!==i&&(this.rawOptions[e]=i,this._onOptionChange.fire(e))};for(const t in this.rawOptions){const s={get:e.bind(this,t),set:i.bind(this,t)};Object.defineProperty(this.options,t,s)}}_sanitizeAndValidateOption(e,i){switch(e){case"cursorStyle":if(i||(i=t.DEFAULT_OPTIONS[e]),!function(e){return"block"===e||"underline"===e||"bar"===e}(i))throw new Error(`"${i}" is not a valid value for ${e}`);break;case"wordSeparator":i||(i=t.DEFAULT_OPTIONS[e]);break;case"fontWeight":case"fontWeightBold":if("number"==typeof i&&1<=i&&i<=1e3)break;i=o.includes(i)?i:t.DEFAULT_OPTIONS[e];break;case"cursorWidth":i=Math.floor(i);case"lineHeight":case"tabStopWidth":if(i<1)throw new Error(`${e} cannot be less than 1, value: ${i}`);break;case"minimumContrastRatio":i=Math.max(1,Math.min(21,Math.round(10*i)/10));break;case"scrollback":if((i=Math.min(i,4294967295))<0)throw new Error(`${e} cannot be less than 0, value: ${i}`);break;case"fastScrollSensitivity":case"scrollSensitivity":if(i<=0)throw new Error(`${e} cannot be less than or equal to 0, value: ${i}`);break;case"rows":case"cols":if(!i&&0!==i)throw new Error(`${e} must be numeric, value: ${i}`);break;case"windowsPty":i=null!=i?i:{}}return i}}t.OptionsService=a},2660:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkService=void 0;const n=i(2585);let o=t.OscLinkService=class{constructor(e){this._bufferService=e,this._nextId=1,this._entriesWithId=new Map,this._dataByLinkId=new Map}registerLink(e){const t=this._bufferService.buffer;if(void 0===e.id){const i=t.addMarker(t.ybase+t.y),s={data:e,id:this._nextId++,lines:[i]};return i.onDispose((()=>this._removeMarkerFromLink(s,i))),this._dataByLinkId.set(s.id,s),s.id}const i=e,s=this._getEntryIdKey(i),r=this._entriesWithId.get(s);if(r)return this.addLineToLink(r.id,t.ybase+t.y),r.id;const n=t.addMarker(t.ybase+t.y),o={id:this._nextId++,key:this._getEntryIdKey(i),data:i,lines:[n]};return n.onDispose((()=>this._removeMarkerFromLink(o,n))),this._entriesWithId.set(o.key,o),this._dataByLinkId.set(o.id,o),o.id}addLineToLink(e,t){const i=this._dataByLinkId.get(e);if(i&&i.lines.every((e=>e.line!==t))){const e=this._bufferService.buffer.addMarker(t);i.lines.push(e),e.onDispose((()=>this._removeMarkerFromLink(i,e)))}}getLinkData(e){var t;return null===(t=this._dataByLinkId.get(e))||void 0===t?void 0:t.data}_getEntryIdKey(e){return`${e.id};;${e.uri}`}_removeMarkerFromLink(e,t){const i=e.lines.indexOf(t);-1!==i&&(e.lines.splice(i,1),0===e.lines.length&&(void 0!==e.data.id&&this._entriesWithId.delete(e.key),this._dataByLinkId.delete(e.id)))}};t.OscLinkService=o=s([r(0,n.IBufferService)],o)},8343:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.createDecorator=t.getServiceDependencies=t.serviceRegistry=void 0;const i="di$target",s="di$dependencies";t.serviceRegistry=new Map,t.getServiceDependencies=function(e){return e[s]||[]},t.createDecorator=function(e){if(t.serviceRegistry.has(e))return t.serviceRegistry.get(e);const r=function(e,t,n){if(3!==arguments.length)throw new Error("@IServiceName-decorator can only be used to decorate a parameter");!function(e,t,r){t[i]===t?t[s].push({id:e,index:r}):(t[s]=[{id:e,index:r}],t[i]=t)}(r,e,n)};return r.toString=()=>e,t.serviceRegistry.set(e,r),r}},2585:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.IDecorationService=t.IUnicodeService=t.IOscLinkService=t.IOptionsService=t.ILogService=t.LogLevelEnum=t.IInstantiationService=t.ICharsetService=t.ICoreService=t.ICoreMouseService=t.IBufferService=void 0;const s=i(8343);var r;t.IBufferService=(0,s.createDecorator)("BufferService"),t.ICoreMouseService=(0,s.createDecorator)("CoreMouseService"),t.ICoreService=(0,s.createDecorator)("CoreService"),t.ICharsetService=(0,s.createDecorator)("CharsetService"),t.IInstantiationService=(0,s.createDecorator)("InstantiationService"),function(e){e[e.TRACE=0]="TRACE",e[e.DEBUG=1]="DEBUG",e[e.INFO=2]="INFO",e[e.WARN=3]="WARN",e[e.ERROR=4]="ERROR",e[e.OFF=5]="OFF"}(r||(t.LogLevelEnum=r={})),t.ILogService=(0,s.createDecorator)("LogService"),t.IOptionsService=(0,s.createDecorator)("OptionsService"),t.IOscLinkService=(0,s.createDecorator)("OscLinkService"),t.IUnicodeService=(0,s.createDecorator)("UnicodeService"),t.IDecorationService=(0,s.createDecorator)("DecorationService")},1480:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeService=void 0;const s=i(8460),r=i(225);t.UnicodeService=class{constructor(){this._providers=Object.create(null),this._active="",this._onChange=new s.EventEmitter,this.onChange=this._onChange.event;const e=new r.UnicodeV6;this.register(e),this._active=e.version,this._activeProvider=e}dispose(){this._onChange.dispose()}get versions(){return Object.keys(this._providers)}get activeVersion(){return this._active}set activeVersion(e){if(!this._providers[e])throw new Error(`unknown Unicode version "${e}"`);this._active=e,this._activeProvider=this._providers[e],this._onChange.fire(e)}register(e){this._providers[e.version]=e}wcwidth(e){return this._activeProvider.wcwidth(e)}getStringCellWidth(e){let t=0;const i=e.length;for(let s=0;s=i)return t+this.wcwidth(r);const n=e.charCodeAt(s);56320<=n&&n<=57343?r=1024*(r-55296)+n-56320+65536:t+=this.wcwidth(n)}t+=this.wcwidth(r)}return t}}}},t={};function i(s){var r=t[s];if(void 0!==r)return r.exports;var n=t[s]={exports:{}};return e[s].call(n.exports,n,n.exports,i),n.exports}var s={};return(()=>{var e=s;Object.defineProperty(e,"__esModule",{value:!0}),e.Terminal=void 0;const t=i(9042),r=i(3236),n=i(844),o=i(5741),a=i(8285),h=i(7975),c=i(7090),l=["cols","rows"];class d extends n.Disposable{constructor(e){super(),this._core=this.register(new r.Terminal(e)),this._addonManager=this.register(new o.AddonManager),this._publicOptions=Object.assign({},this._core.options);const t=e=>this._core.options[e],i=(e,t)=>{this._checkReadonlyOptions(e),this._core.options[e]=t};for(const e in this._core.options){const s={get:t.bind(this,e),set:i.bind(this,e)};Object.defineProperty(this._publicOptions,e,s)}}_checkReadonlyOptions(e){if(l.includes(e))throw new Error(`Option "${e}" can only be set in the constructor`)}_checkProposedApi(){if(!this._core.optionsService.rawOptions.allowProposedApi)throw new Error("You must set the allowProposedApi option to true to use proposed API")}get onBell(){return this._core.onBell}get onBinary(){return this._core.onBinary}get onCursorMove(){return this._core.onCursorMove}get onData(){return this._core.onData}get onKey(){return this._core.onKey}get onLineFeed(){return this._core.onLineFeed}get onRender(){return this._core.onRender}get onResize(){return this._core.onResize}get onScroll(){return this._core.onScroll}get onSelectionChange(){return this._core.onSelectionChange}get onTitleChange(){return this._core.onTitleChange}get onWriteParsed(){return this._core.onWriteParsed}get element(){return this._core.element}get parser(){return this._parser||(this._parser=new h.ParserApi(this._core)),this._parser}get unicode(){return this._checkProposedApi(),new c.UnicodeApi(this._core)}get textarea(){return this._core.textarea}get rows(){return this._core.rows}get cols(){return this._core.cols}get buffer(){return this._buffer||(this._buffer=this.register(new a.BufferNamespaceApi(this._core))),this._buffer}get markers(){return this._checkProposedApi(),this._core.markers}get modes(){const e=this._core.coreService.decPrivateModes;let t="none";switch(this._core.coreMouseService.activeProtocol){case"X10":t="x10";break;case"VT200":t="vt200";break;case"DRAG":t="drag";break;case"ANY":t="any"}return{applicationCursorKeysMode:e.applicationCursorKeys,applicationKeypadMode:e.applicationKeypad,bracketedPasteMode:e.bracketedPasteMode,insertMode:this._core.coreService.modes.insertMode,mouseTrackingMode:t,originMode:e.origin,reverseWraparoundMode:e.reverseWraparound,sendFocusMode:e.sendFocus,wraparoundMode:e.wraparound}}get options(){return this._publicOptions}set options(e){for(const t in e)this._publicOptions[t]=e[t]}blur(){this._core.blur()}focus(){this._core.focus()}resize(e,t){this._verifyIntegers(e,t),this._core.resize(e,t)}open(e){this._core.open(e)}attachCustomKeyEventHandler(e){this._core.attachCustomKeyEventHandler(e)}registerLinkProvider(e){return this._core.registerLinkProvider(e)}registerCharacterJoiner(e){return this._checkProposedApi(),this._core.registerCharacterJoiner(e)}deregisterCharacterJoiner(e){this._checkProposedApi(),this._core.deregisterCharacterJoiner(e)}registerMarker(e=0){return this._verifyIntegers(e),this._core.registerMarker(e)}registerDecoration(e){var t,i,s;return this._checkProposedApi(),this._verifyPositiveIntegers(null!==(t=e.x)&&void 0!==t?t:0,null!==(i=e.width)&&void 0!==i?i:0,null!==(s=e.height)&&void 0!==s?s:0),this._core.registerDecoration(e)}hasSelection(){return this._core.hasSelection()}select(e,t,i){this._verifyIntegers(e,t,i),this._core.select(e,t,i)}getSelection(){return this._core.getSelection()}getSelectionPosition(){return this._core.getSelectionPosition()}clearSelection(){this._core.clearSelection()}selectAll(){this._core.selectAll()}selectLines(e,t){this._verifyIntegers(e,t),this._core.selectLines(e,t)}dispose(){super.dispose()}scrollLines(e){this._verifyIntegers(e),this._core.scrollLines(e)}scrollPages(e){this._verifyIntegers(e),this._core.scrollPages(e)}scrollToTop(){this._core.scrollToTop()}scrollToBottom(){this._core.scrollToBottom()}scrollToLine(e){this._verifyIntegers(e),this._core.scrollToLine(e)}clear(){this._core.clear()}write(e,t){this._core.write(e,t)}writeln(e,t){this._core.write(e),this._core.write("\r\n",t)}paste(e){this._core.paste(e)}refresh(e,t){this._verifyIntegers(e,t),this._core.refresh(e,t)}reset(){this._core.reset()}clearTextureAtlas(){this._core.clearTextureAtlas()}loadAddon(e){this._addonManager.loadAddon(this,e)}static get strings(){return t}_verifyIntegers(...e){for(const t of e)if(t===1/0||isNaN(t)||t%1!=0)throw new Error("This API only accepts integers")}_verifyPositiveIntegers(...e){for(const t of e)if(t&&(t===1/0||isNaN(t)||t%1!=0||t<0))throw new Error("This API only accepts positive integers")}}e.Terminal=d})(),s})())); +//# sourceMappingURL=xterm.js.map \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d68215a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +flask==3.0.3 +flask-cors==4.0.0 +APScheduler==3.10.4 +python-dotenv==1.0.1 +certbot==2.11.0 +bcrypt==4.2.1 diff --git a/tpanel-v1.3.34-pkg/backend/config.py b/tpanel-v1.3.34-pkg/backend/config.py new file mode 100644 index 0000000..062d635 --- /dev/null +++ b/tpanel-v1.3.34-pkg/backend/config.py @@ -0,0 +1,108 @@ +""" +TPanel - T面板 配置模块 +""" +import os +import json + +BASE_DIR = '/opt/tpanel' +DATA_DIR = os.path.join(BASE_DIR, 'data') +LOG_DIR = os.path.join(BASE_DIR, 'logs') +SITES_DIR = os.path.join(BASE_DIR, 'sites') +BACKUP_DIR = os.path.join(BASE_DIR, 'backups') +SSL_DIR = os.path.join(BASE_DIR, 'ssl') +CONFIG_DIR = os.path.join(BASE_DIR, 'config') +NGINX_CONF_DIR = '/etc/nginx/tpanel' + +DB_PATH = os.path.join(DATA_DIR, 'tpanel.db') + +# Nginx 配置目录(由 install.sh 创建) +os.makedirs(NGINX_CONF_DIR, exist_ok=True) +os.makedirs(LOG_DIR, exist_ok=True) +os.makedirs(SITES_DIR, exist_ok=True) +os.makedirs(BACKUP_DIR, exist_ok=True) +os.makedirs(SSL_DIR, exist_ok=True) +os.makedirs(CONFIG_DIR, exist_ok=True) + +def load_config(): + path = os.path.join(CONFIG_DIR, 'tpanel.conf') + if os.path.exists(path): + with open(path, 'r') as f: + return json.load(f) + return { + 'panel_port': 8848, + 'panel_domain': '', + 'php_versions': ['7.4', '8.0', '8.1', '8.2'], + 'default_php': '8.1', + 'auto_ssl_renew': True, + 'backup_retention_days': 7, + 'security_auto_update': True, + 'firewall_enabled': True, + 'ssh_port': 22, + } + +def save_config(cfg): + path = os.path.join(CONFIG_DIR, 'tpanel.conf') + with open(path, 'w') as f: + json.dump(cfg, f, indent=2) + +def get_setting(key, default=''): + """从数据库读取设置""" + import sqlite3 + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)) + row = cur.fetchone() + conn.close() + return row[0] if row else default + +def set_setting(key, value): + import sqlite3 + conn = sqlite3.connect(DB_PATH) + conn.execute("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?", + (key, value, value)) + conn.commit() + conn.close() + +def get_panel_domain(): + """获取面板绑定的域名,无绑定则返回空字符串""" + return get_setting('panel_domain', '') + +def is_domain_allowed(host): + """检查请求的 Host 是否在允许的域名列表中""" + allowed = get_panel_domain().strip() + if not allowed: + return True # 未绑定域名,不限制 + + allowed = allowed.lower().strip() + host = host.lower().strip() + + # 支持带端口的 host(如 localhost:8848) + host_clean = host.split(':')[0] + allowed_clean = allowed.split(':')[0] + + # 也允许 localhost 和 127.0.0.1 + safe_hosts = ['localhost', '127.0.0.1', '::1'] + if host_clean in safe_hosts: + return True + + return host_clean == allowed_clean or host == allowed +# v1.3.34+: 用于 phpMyAdmin 自动登录 token 签名 +_SECRET_FILE = os.path.join(DATA_DIR, ".secret_key") +def get_secret_key(): + """加载或生成 SECRET_KEY(启动时一次,进程内复用)""" + if os.path.exists(_SECRET_FILE): + with open(_SECRET_FILE, "r") as f: + return f.read().strip() + sk = os.urandom(32).hex() + with open(_SECRET_FILE, "w") as f: + f.write(sk) + try: + os.chmod(_SECRET_FILE, 0o600) + import pwd + uid = pwd.getpwnam("tpanel").pw_uid + gid = pwd.getpwnam("tpanel").pw_gid + os.chown(_SECRET_FILE, uid, gid) + except Exception: + pass + return sk + +SECRET_KEY = get_secret_key() diff --git a/tpanel-v1.3.34-pkg/backend/cron_manager.py b/tpanel-v1.3.34-pkg/backend/cron_manager.py new file mode 100644 index 0000000..195e2aa --- /dev/null +++ b/tpanel-v1.3.34-pkg/backend/cron_manager.py @@ -0,0 +1,258 @@ +""" +TPanel - 定时任务管理模块 +""" +import os +import sqlite3 +import subprocess +from datetime import datetime +from config import DB_PATH + +def _run(cmd, timeout=30, shell=False): + try: + if isinstance(cmd, str) and not shell: + cmd = cmd.split() + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, shell=shell) + return result.returncode, result.stdout.strip(), result.stderr.strip() + except subprocess.TimeoutExpired: + return -1, '', 'Command timed out' + except Exception as e: + return -1, '', str(e) + +def get_all_crons(): + """获取所有定时任务""" + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("""SELECT c.*, s.domain FROM cron_jobs c + LEFT JOIN sites s ON c.site_id = s.id + ORDER BY c.id DESC""") + cols = [d[0] for d in cur.description] + rows = [dict(zip(cols, r)) for r in cur.fetchall()] + conn.close() + return rows + +def create_cron(site_id, name, schedule, command): + """ + 创建定时任务 + schedule: cron 表达式,如 "0 3 * * *" (每天3点) + command: 要执行的命令 + """ + # 验证 cron 表达式格式 + parts = schedule.strip().split() + if len(parts) != 5: + return None, 'Cron 表达式格式错误,需要 5 段:分 时 日 月 周' + + # 生成一个唯一文件名 + import hashlib + token = hashlib.md5(f'{site_id}{name}{command}{datetime.now()}'.encode()).hexdigest()[:12] + script_name = f'cron_{token}.sh' + + # 写入站点目录的 cron 脚本 + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT site_user FROM sites WHERE id = ?", (site_id,)) + row = cur.fetchone() + conn.close() + + if not row: + return None, '站点不存在' + + site_user = row[0] + cron_dir = f'/opt/tpanel/sites/{site_user}/.cron' + os.makedirs(cron_dir, exist_ok=True) + + script_path = os.path.join(cron_dir, script_name) + with open(script_path, 'w') as f: + f.write(f'#!/bin/bash\n{command}\n') + os.chmod(script_path, 0o755) + + # 写入系统 crontab(用 sudo 切换到站点用户执行) + cron_line = f'{schedule} sudo -u {site_user} {script_path} >> /opt/tpanel/logs/cron_{token}.log 2>&1' + + # 读取现有 crontab + code, out, err = _run(f'crontab -l 2>/dev/null || echo ""', shell=True) + existing = out if code == 0 else '' + + # 检查是否已有同名任务 + lines = [l for l in existing.split('\n') if script_name not in l and l.strip()] + lines.append(cron_line) + + # 写回 crontab + new_cron = '\n'.join(lines) + '\n' + code, out, err = _run(f'echo "{new_cron}" | crontab -', shell=True, timeout=10) + + if code != 0: + os.remove(script_path) + return None, f'Crontab 写入失败: {err}' + + # 写入数据库 + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("""INSERT INTO cron_jobs (site_id, name, schedule, command) + VALUES (?, ?, ?, ?)""", + (site_id, name, schedule, command)) + conn.commit() + cron_id = cur.lastrowid + conn.close() + + return cron_id, '定时任务创建成功' + +def delete_cron(cron_id): + """删除定时任务""" + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT name, command FROM cron_jobs WHERE id = ?", (cron_id,)) + row = cur.fetchone() + if not row: + conn.close() + return False, '任务不存在' + + name, command = row + + # 从 crontab 移除 + code, out, err = _run('crontab -l 2>/dev/null || echo ""', shell=True) + if code == 0 and out: + lines = [l for l in out.split('\n') if name not in l and l.strip()] + _run(f'echo "{chr(10).join(lines)}\n" | crontab -', shell=True, timeout=10) + + # 删除脚本文件 + cron_dir = '/opt/tpanel/sites' + for site_dir in os.listdir('/opt/tpanel/sites'): + script = os.path.join(cron_dir, site_dir, '.cron') + if os.path.exists(script): + for f in os.listdir(script): + if name in f: + try: + os.remove(os.path.join(script, f)) + except: + pass + + conn.execute("DELETE FROM cron_jobs WHERE id = ?", (cron_id,)) + conn.commit() + conn.close() + + return True, '任务已删除' + +def enable_cron(cron_id, enabled): + """启用/禁用定时任务""" + conn = sqlite3.connect(DB_PATH) + conn.execute("UPDATE cron_jobs SET enabled = ? WHERE id = ?", (1 if enabled else 0, cron_id)) + + # 如果禁用,从 crontab 注释掉;如果启用,恢复 + cur = conn.execute("SELECT name, schedule, command FROM cron_jobs WHERE id = ?", (cron_id,)) + row = cur.fetchone() + conn.close() + + if not row: + return False, '任务不存在' + + name, schedule, command = row + prefix = '' if enabled else '#' + + # 简单处理:重新生成 crontab + # 获取所有启用的任务重新写入 + conn2 = sqlite3.connect(DB_PATH) + cur2 = conn2.execute("SELECT name, schedule, command, enabled FROM cron_jobs WHERE enabled = 1") + enabled_rows = cur2.fetchall() + conn2.close() + + lines = [] + for r in enabled_rows: + n, s, c = r[0], r[1], r[2] + import hashlib + token = hashlib.md5(f'{n}{c}'.encode()).hexdigest()[:12] + lines.append(f'{s} sudo -u {get_site_user_by_name(n)} /opt/tpanel/sites/{get_site_user_by_name(n)}/.cron/cron_{token}.sh >> /opt/tpanel/logs/cron_{token}.log 2>&1') + + if enabled: + _run(f'echo "{"".join([l + chr(10) for l in lines])}" | crontab -', shell=True, timeout=10) + + return True, f'任务已{"启用" if enabled else "禁用"}' + +def get_site_user_by_name(name): + """根据任务名查找站点用户(辅助)""" + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT site_user FROM sites LIMIT 1") + row = cur.fetchone() + conn.close() + return row[0] if row else 'tpanel' + +def run_cron_now(cron_id): + """立即执行定时任务(手动触发)""" + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT site_id, name, command FROM cron_jobs WHERE id = ?", (cron_id,)) + row = cur.fetchone() + conn.close() + + if not row: + return False, '任务不存在' + + site_id, name, command = row + + conn2 = sqlite3.connect(DB_PATH) + cur2 = conn2.execute("SELECT site_user FROM sites WHERE id = ?", (site_id,)) + row2 = cur2.fetchone() + conn2.close() + + if not row2: + return False, '站点不存在' + + site_user = row2[0] + + # 以站点用户身份执行命令 + code, out, err = _run( + f'sudo -u {site_user} bash -c "{command}"', + shell=True, timeout=60 + ) + + # 更新最后执行时间 + conn3 = sqlite3.connect(DB_PATH) + conn3.execute("UPDATE cron_jobs SET last_run = ? WHERE id = ?", + (datetime.now().isoformat(), cron_id)) + conn3.commit() + conn3.close() + + return code == 0, out if code == 0 else err + +def validate_cron_expression(expr): + """验证 cron 表达式是否有效""" + parts = expr.strip().split() + if len(parts) != 5: + return False, '需要 5 段:分 时 日 月 周' + + labels = ['分', '时', '日', '月', '周'] + ranges = [ + (0, 59), # 分: 0-59 + (0, 23), # 时: 0-23 + (1, 31), # 日: 1-31 + (1, 12), # 月: 1-12 + (0, 6), # 周: 0-6 (0=周日) + ] + + for i, (part, (lo, hi)) in enumerate(zip(parts, ranges)): + if part == '*': + continue + if '/' in part: + base, step = part.split('/') + if not step.isdigit(): + return False, f'{labels[i]} 步长必须是数字' + continue + if ',' in part: + for p in part.split(','): + try: + v = int(p) + if v < lo or v > hi: + return False, f'{labels[i]} 范围 {lo}-{hi}' + except: + return False, f'{labels[i]} 包含无效值' + continue + if '-' in part: + start, end = part.split('-') + try: + if int(start) < lo or int(end) > hi: + return False, f'{labels[i]} 范围 {lo}-{hi}' + except: + return False, f'{labels[i]} 格式错误' + continue + try: + v = int(part) + if v < lo or v > hi: + return False, f'{labels[i]} 范围 {lo}-{hi}' + except: + return False, f'{labels[i]} 包含无效字符' + + return True, '格式正确' \ No newline at end of file diff --git a/tpanel-v1.3.34-pkg/backend/db_init.py b/tpanel-v1.3.34-pkg/backend/db_init.py new file mode 100644 index 0000000..a8ba72b --- /dev/null +++ b/tpanel-v1.3.34-pkg/backend/db_init.py @@ -0,0 +1,146 @@ +""" +TPanel - 数据库初始化 +""" +import sqlite3 +import os +import bcrypt +from config import DB_PATH, BASE_DIR + +def init_db(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + + cur.execute(''' + CREATE TABLE IF NOT EXISTS admin ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_login DATETIME + )''') + + cur.execute(''' + CREATE TABLE IF NOT EXISTS sites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + domain TEXT NOT NULL UNIQUE, + site_user TEXT NOT NULL UNIQUE, + site_path TEXT NOT NULL, + php_version TEXT DEFAULT '8.1', + status TEXT DEFAULT 'running', + ssl_enabled INTEGER DEFAULT 0, + ssl_cert_path TEXT, + ssl_key_path TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )''') + + # v1.3.26: 站点类型列(php / static),default 'php'(老站点全为 php) + # 先检查列是否存在,不存在才加(幂等) + cur.execute("PRAGMA table_info(sites)") + cols = {row[1] for row in cur.fetchall()} + if 'site_type' not in cols: + try: + cur.execute("ALTER TABLE sites ADD COLUMN site_type TEXT DEFAULT 'php'") + except Exception: + pass + + cur.execute(''' + CREATE TABLE IF NOT EXISTS databases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_id INTEGER REFERENCES sites(id) ON DELETE CASCADE, + name TEXT NOT NULL UNIQUE, + db_user TEXT NOT NULL UNIQUE, + db_pass TEXT NOT NULL, + charset TEXT DEFAULT 'utf8mb4', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )''') + + cur.execute(''' + CREATE TABLE IF NOT EXISTS backups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_id INTEGER REFERENCES sites(id) ON DELETE CASCADE, + type TEXT DEFAULT 'local', + file_path TEXT, + size INTEGER, + status TEXT DEFAULT 'success', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )''') + + cur.execute(''' + CREATE TABLE IF NOT EXISTS ssl_certs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_id INTEGER REFERENCES sites(id) ON DELETE CASCADE, + domain TEXT NOT NULL, + cert_path TEXT NOT NULL, + key_path TEXT NOT NULL, + expire_date TEXT, + auto_renew INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )''') + + cur.execute(''' + CREATE TABLE IF NOT EXISTS cron_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_id INTEGER REFERENCES sites(id) ON DELETE CASCADE, + name TEXT NOT NULL, + schedule TEXT NOT NULL, + command TEXT NOT NULL, + enabled INTEGER DEFAULT 1, + last_run DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )''') + + cur.execute(''' + CREATE TABLE IF NOT EXISTS security_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + details TEXT, + ip TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )''') + + cur.execute(''' + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT + )''') + + # v1.3.10+ 软件市场表 + cur.execute(''' + CREATE TABLE IF NOT EXISTS software ( + name TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + category TEXT NOT NULL, + installed INTEGER DEFAULT 0, + version TEXT, + last_check DATETIME, + last_install DATETIME + )''') + + # v1.3.10+ 任务表(用于实时进度) + cur.execute(''' + CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + target TEXT, + status TEXT DEFAULT 'running', + log TEXT DEFAULT '', + started_at DATETIME DEFAULT CURRENT_TIMESTAMP, + finished_at DATETIME, + exit_code INTEGER + )''') + + # 默认管理员账号 admin / tpanel.cn + cur.execute("SELECT id FROM admin WHERE username = ?", ('admin',)) + if not cur.fetchone(): + pw_hash = bcrypt.hashpw(b'tpanel.cn', bcrypt.gensalt()).decode() + cur.execute("INSERT INTO admin (username, password_hash) VALUES (?, ?)", + ('admin', pw_hash)) + conn.commit() + + conn.close() + print("[TPanel] 数据库初始化完成") + +if __name__ == '__main__': + init_db() \ No newline at end of file diff --git a/tpanel-v1.3.34-pkg/backend/file_manager.py b/tpanel-v1.3.34-pkg/backend/file_manager.py new file mode 100644 index 0000000..76b2cc3 --- /dev/null +++ b/tpanel-v1.3.34-pkg/backend/file_manager.py @@ -0,0 +1,204 @@ +""" +TPanel - 文件管理模块 +""" +import os +import zipfile +import tarfile +import shutil +import subprocess +from datetime import datetime + +def _run(cmd, timeout=30): + try: + if isinstance(cmd, str): + cmd = cmd.split() + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return result.returncode, result.stdout.strip(), result.stderr.strip() + except subprocess.TimeoutExpired: + return -1, '', 'Command timed out' + except Exception as e: + return -1, '', str(e) + +def list_directory(path, site_user=None): + """列出目录内容,带安全和权限信息""" + # 安全检查:防止路径遍历 + real_path = os.path.realpath(path) + allowed_base = ['/opt/tpanel/sites', '/opt/tpanel/backups'] + if not any(real_path.startswith(base) for base in allowed_base): + return None, '路径不在允许范围内' + + if not os.path.exists(path): + return None, '目录不存在' + + items = [] + try: + entries = os.listdir(path) + except PermissionError: + return None, '无权限访问' + + for name in sorted(entries): + fp = os.path.join(path, name) + try: + stat = os.stat(fp) + is_dir = os.path.isdir(fp) + + # 文件大小 + if is_dir: + size = sum(os.path.getsize(os.path.join(dp, f)) + for dp, dn, fn in os.walk(fp) for f in fn) if False else 0 + else: + size = stat.st_size + + items.append({ + 'name': name, + 'type': 'dir' if is_dir else 'file', + 'size': size, + 'size_str': format_size(size), + 'modified': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M'), + 'permissions': stat.st_mode & 0o777, + 'perm_str': format_permissions(stat.st_mode & 0o777), + 'readable': os.access(fp, os.R_OK), + 'writable': os.access(fp, os.W_OK), + }) + except Exception: + continue + + return items, None + +def format_size(size): + if size < 1024: + return str(size) + ' B' + elif size < 1024 * 1024: + return f'{size / 1024:.1f} KB' + elif size < 1024 * 1024 * 1024: + return f'{size / (1024 * 1024):.1f} MB' + else: + return f'{size / (1024 * 1024 * 1024):.2f} GB' + +def format_permissions(mode): + chars = ['---', '--x', '-w-', '-wx', 'r--', 'r-x', 'rw-', 'rwx'] + return chars[(mode >> 6) & 7] + chars[(mode >> 3) & 7] + chars[mode & 7] + +def read_file(path, max_size=1024 * 1024): + """读取文件内容(限制1MB)""" + if not os.path.exists(path): + return None, '文件不存在' + if os.path.getsize(path) > max_size: + return None, '文件超过 1MB 限制' + + # 只允许读取配置文件和常见文本格式 + allowed_ext = ['.php', '.html', '.htm', '.css', '.js', '.json', '.txt', '.md', + '.yaml', '.yml', '.xml', '.conf', '.ini', '.log', '.sql'] + ext = os.path.splitext(path)[1].lower() + if ext not in allowed_ext and not any(path.endswith(x) for x in ['/config.php', '/.htaccess']): + return None, '文件类型不允许读取' + + try: + with open(path, 'r', encoding='utf-8', errors='ignore') as f: + return f.read(), None + except Exception as e: + return None, str(e) + +def write_file(path, content): + """写入文件(仅限站点目录)""" + real_path = os.path.realpath(path) + if not real_path.startswith('/opt/tpanel/sites'): + return False, '路径不在允许范围内' + + try: + with open(path, 'w', encoding='utf-8') as f: + f.write(content) + return True, '文件已保存' + except Exception as e: + return False, str(e) + +def upload_file(upload_dir, file_obj, filename): + """上传文件到站点目录""" + real_path = os.path.realpath(upload_dir) + if not real_path.startswith('/opt/tpanel/sites'): + return False, '路径不在允许范围内' + + # 限制文件类型 + allowed = ['.php', '.html', '.htm', '.css', '.js', '.json', '.txt', '.md', + '.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.ico', + '.zip', '.tar', '.gz', '.bz2', + '.pdf', '.doc', '.docx', '.xls', '.xlsx', + '.woff', '.woff2', '.ttf', '.eot'] + ext = os.path.splitext(filename)[1].lower() + if ext not in allowed: + return False, f'文件类型 {ext} 不允许上传' + + dest = os.path.join(upload_dir, filename) + try: + file_obj.save(dest) + # 自动解压 zip/tar.gz + if filename.endswith('.zip'): + try: + with zipfile.ZipFile(dest, 'r') as zf: + zf.extractall(upload_dir) + return True, f'文件已上传并解压:{filename}' + except Exception: + return True, f'文件已上传(解压失败):{filename}' + elif filename.endswith(('.tar.gz', '.tgz')): + try: + with tarfile.open(dest, 'r:gz') as tf: + tf.extractall(upload_dir) + return True, f'文件已上传并解压:{filename}' + except Exception: + return True, f'文件已上传(解压失败):{filename}' + + return True, f'文件已上传:{filename}' + except Exception as e: + return False, str(e) + +def delete_file(path): + """删除文件或目录""" + real_path = os.path.realpath(path) + if not real_path.startswith('/opt/tpanel/sites'): + return False, '路径不在允许范围内' + + try: + if os.path.isdir(path): + shutil.rmtree(path) + else: + os.remove(path) + return True, '已删除' + except Exception as e: + return False, str(e) + +def chmod_file(path, mode): + """修改文件权限(限制范围)""" + real_path = os.path.realpath(path) + if not real_path.startswith('/opt/tpanel/sites'): + return False, '路径不在允许范围内' + + # 限制权限范围(v1.3.20+:接受 755/644 等十进制字符串) + try: + if isinstance(mode, str): + mode = int(mode, 8) # '755' -> 0o755 = 493 + elif isinstance(mode, int) and mode < 0o1000: + # 看起来是 755 这种小数(不是 0o755),自动当八进制解释 + mode = int(str(mode), 8) if mode < 1000 else mode + except (ValueError, TypeError): + return False, '权限值格式错误(应该是 755、644 这种)' + if mode & 0o777 not in [0o755, 0o644, 0o600, 0o700, 0o775, 0o664]: + return False, f'权限值不允许({oct(mode & 0o777)},可选 755/644/600/700/775/664)' + + try: + os.chmod(path, mode & 0o777) + return True, f'权限已修改为 {oct(mode & 0o777)}' + except Exception as e: + return False, str(e) + +def create_directory(path, dirname): + """创建目录""" + real_path = os.path.realpath(path) + if not real_path.startswith('/opt/tpanel/sites'): + return False, '路径不在允许范围内' + + new_path = os.path.join(path, dirname) + try: + os.makedirs(new_path, exist_ok=True) + return True, f'目录已创建:{dirname}' + except Exception as e: + return False, str(e) \ No newline at end of file diff --git a/tpanel-v1.3.34-pkg/backend/main.py b/tpanel-v1.3.34-pkg/backend/main.py new file mode 100644 index 0000000..88fef5a --- /dev/null +++ b/tpanel-v1.3.34-pkg/backend/main.py @@ -0,0 +1,1402 @@ +""" +TPanel - T面板 主程序 +""" +import os +import sys +import secrets +import bcrypt +import sqlite3 +from datetime import datetime +from functools import wraps + +from flask import Flask, jsonify, request, session, redirect, Response +from flask_cors import CORS + +# 导入各模块 +from config import DB_PATH, load_config, save_config, set_setting, get_setting, get_panel_domain, SECRET_KEY +from system import ( + nginx_status, nginx_reload, mysql_status, + create_site_user, delete_site_user, + write_nginx_config, remove_nginx_config, + create_mysql_db, delete_mysql_db, + backup_site, restore_backup, + run_security_update, get_security_status, get_system_stats, write_log, + setup_php_fpm_listen, # v1.3.29 + change_db_password, # v1.3.34 +) +from file_manager import list_directory, read_file, write_file, upload_file, delete_file, chmod_file, create_directory +from ssl_manager import get_all_certs, apply_letsencrypt, renew_cert, renew_all_expiring, deploy_ssl, check_certs_status, _get_real_site_path +from cron_manager import get_all_crons, create_cron, delete_cron, enable_cron, run_cron_now, validate_cron_expression +from remote_backup import run_remote_backup, sync_restore, test_rsync_connection, get_backup_stats + +app = Flask(__name__, static_folder='../frontend') +app.secret_key = secrets.token_hex(32) +CORS(app, supports_credentials=True) + +# ========================== +# 域名绑定中间件 +# ========================== + +@app.before_request +def check_panel_domain(): + """如果面板绑定了域名,只允许该域名访问""" + allowed = get_panel_domain().strip() + import sys + print(f'[DEBUG panel_domain] allowed={allowed!r} path={request.path!r} host={request.host!r}', file=sys.stderr, flush=True) + if not allowed: + return # 未绑定,不限制 + + host = request.host.lower() + allowed_clean = allowed.lower().strip().split(':')[0] + host_clean = host.split(':')[0] + + # v1.3.19+:安全白名单只放行 localhost 字面意思(开发用),所有 IP 走域名匹配 + if host_clean in ('localhost', '::1'): + return + # 127.0.0.1 也允许(用 IP 访问后台运维用) + if host_clean == '127.0.0.1': + return + + if host_clean != allowed_clean: + return jsonify({'code': 403, 'msg': f'面板已绑定域名 {allowed},请使用该域名访问'}), 403 + +# ========================== +# 装饰器 +# ========================== + +def require_auth(f): + @wraps(f) + def decorated(*args, **kwargs): + # v1.3.27 修复:SSE 走 query string 传 token(EventSource 不能自定义 header) + # 优先 header(常规 API),fallback ?token=(SSE) + token = request.headers.get('Authorization', '').replace('Bearer ', '').strip() + if not token: + token = request.args.get('token', '').strip() + expected = get_setting('api_token', '') + if not expected or token != expected: + # 也检查 session + if 'admin' not in session: + return jsonify({'code': 401, 'msg': '未授权'}), 401 + return f(*args, **kwargs) + return decorated + +# ========================== +# 认证 API +# ========================== + +@app.route('/api/auth/login', methods=['POST']) +def api_login(): + data = request.json or {} + username = data.get('username', '').strip() + password = data.get('password', '') + + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT id, password_hash FROM admin WHERE username = ?", (username,)) + row = cur.fetchone() + conn.close() + + if not row: + return jsonify({'code': 401, 'msg': '用户名或密码错误'}) + + if bcrypt.checkpw(password.encode(), row[1].encode()): + session['admin'] = True + session['username'] = username + + # 生成 API token + token = secrets.token_hex(32) + set_setting('api_token', token) + set_setting('last_login', datetime.now().isoformat()) + + write_log('login', f'用户 {username} 登录成功', request.remote_addr) + return jsonify({'code': 0, 'msg': '登录成功', 'token': token}) + + write_log('login_fail', f'用户 {username} 登录失败', request.remote_addr) + return jsonify({'code': 401, 'msg': '用户名或密码错误'}) + +@app.route('/api/auth/logout', methods=['POST']) +def api_logout(): + username = session.get('username', 'unknown') + session.clear() + write_log('logout', f'用户 {username} 退出', request.remote_addr) + return jsonify({'code': 0, 'msg': '已退出'}) + +@app.route('/api/auth/change-password', methods=['POST']) +@require_auth +def api_change_password(): + """v1.3.18 新增:改管理员密码""" + data = request.json or {} + old_password = data.get('old_password', '') + new_password = data.get('new_password', '') + + if not old_password or not new_password: + return jsonify({'code': 400, 'msg': '请提供旧密码和新密码'}) + + if len(new_password) < 6: + return jsonify({'code': 400, 'msg': '新密码至少 6 位'}) + + username = session.get('username', 'admin') + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT password_hash FROM admin WHERE username = ?", (username,)) + row = cur.fetchone() + if not row: + conn.close() + return jsonify({'code': 404, 'msg': '用户不存在'}) + + if not bcrypt.checkpw(old_password.encode(), row[0].encode()): + conn.close() + write_log('change_password_fail', f'用户 {username} 改密码失败(旧密码错)', request.remote_addr) + return jsonify({'code': 401, 'msg': '旧密码错误'}) + + new_hash = bcrypt.hashpw(new_password.encode(), bcrypt.gensalt()).decode() + conn.execute("UPDATE admin SET password_hash = ? WHERE username = ?", (new_hash, username)) + conn.commit() + conn.close() + write_log('change_password', f'用户 {username} 改密码成功', request.remote_addr) + return jsonify({'code': 0, 'msg': '密码已修改,请重新登录'}) + +@app.route('/api/auth/check', methods=['GET']) +def api_check(): + token = request.headers.get('Authorization', '').replace('Bearer ', '') + if token == get_setting('api_token', ''): + return jsonify({'code': 0, 'msg': '有效', 'username': session.get('username', 'admin')}) + if 'admin' in session: + return jsonify({'code': 0, 'msg': '有效', 'username': session.get('username', 'admin')}) + return jsonify({'code': 401, 'msg': '无效'}), 401 + +# ========================== +# 系统状态 +# ========================== + +@app.route('/api/system/stats', methods=['GET']) +@require_auth +def api_system_stats(): + stats = get_system_stats() + security = get_security_status() + stats.update(security) + return jsonify({'code': 0, 'data': stats}) + +# ========================== +# 站点管理 +# ========================== + +@app.route('/api/sites', methods=['GET']) +@require_auth +def api_sites_list(): + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT * FROM sites ORDER BY id DESC") + cols = [d[0] for d in cur.description] + rows = [dict(zip(cols, r)) for r in cur.fetchall()] + conn.close() + return jsonify({'code': 0, 'data': rows}) + +@app.route('/api/sites', methods=['POST']) +@require_auth +def api_sites_create(): + data = request.json or {} + domain = data.get('domain', '').strip().lower() + name = data.get('name', domain) + site_type = data.get('type', 'php') # v1.3.26 新增:'php' | 'static' + php_version = data.get('php_version', '8.2') + site_user = domain.replace('.', '_') + + if not domain: + return jsonify({'code': 400, 'msg': '域名不能为空'}) + + if site_type not in ('php', 'static'): + return jsonify({'code': 400, 'msg': '站点类型必须是 php 或 static'}) + + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT id FROM sites WHERE domain = ?", (domain,)) + if cur.fetchone(): + conn.close() + return jsonify({'code': 400, 'msg': '站点已存在'}) + + site_path = f'/opt/tpanel/sites/{site_user}' + + # 1. 创建 Linux 用户 + ok, msg = create_site_user(site_user) + if not ok: + conn.close() + return jsonify({'code': 500, 'msg': f'创建系统用户失败: {msg}'}) + + # 2. 创建目录并写入默认首页 + os.makedirs(site_path, exist_ok=True) + os.makedirs(f'{site_path}/public', exist_ok=True) + if site_type == 'php': + with open(f'{site_path}/public/index.php', 'w') as f: + f.write(f' + + + + + {domain} - 站点已就绪 + + + +
+

🌿 站点已就绪

+

域名: {domain}

+

类型: 静态页面(不需要 PHP-FPM)

+

管理: TPanel 面板

+
+ 📁 public/
+   📄 index.html ← 你看到的这个页面 +
+ +
+ + +''' + with open(f'{site_path}/public/index.html', 'w') as f: + f.write(static_html) + os.makedirs(f'{site_path}/logs', exist_ok=True) + + # 3. 写 Nginx 配置(v1.3.26 传 site_type 进去决定要不要 PHP-FPM 反代) + ok, msg = write_nginx_config(domain, f'{site_path}/public', php_version, ssl=False, site_type=site_type) + if not ok: + conn.close() + return jsonify({'code': 500, 'msg': f'Nginx 配置失败: {msg}'}) + + # 4. 写入数据库 + cur.execute("""INSERT INTO sites (name, domain, site_user, site_path, php_version, status, site_type) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (name, domain, site_user, f'{site_path}/public', php_version, 'running', site_type)) + conn.commit() + site_id = cur.lastrowid + conn.close() + + write_log('site_create', f'创建站点 {domain}', request.remote_addr) + return jsonify({'code': 0, 'msg': '站点创建成功', 'data': {'id': site_id}}) + +@app.route('/api/sites/', methods=['DELETE']) +@require_auth +def api_sites_delete(site_id): + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT domain, site_user, site_path FROM sites WHERE id = ?", (site_id,)) + row = cur.fetchone() + if not row: + conn.close() + return jsonify({'code': 404, 'msg': '站点不存在'}) + + domain, site_user, site_path = row + + # 1. 删除 Nginx 配置 + remove_nginx_config(domain) + + # 2. 删除系统用户和目录 + delete_site_user(site_user) + import shutil + parent_path = os.path.dirname(site_path) + if os.path.exists(os.path.join(parent_path, site_user)): + shutil.rmtree(os.path.join(parent_path, site_user), ignore_errors=True) + + # 3. 删除数据库 + cur2 = conn.execute("SELECT name, db_user FROM databases WHERE site_id = ?", (site_id,)) + for db_row in cur2.fetchall(): + delete_mysql_db(db_row[0], db_row[1]) + + # 4. 删除站点记录 + conn.execute("DELETE FROM sites WHERE id = ?", (site_id,)) + conn.commit() + conn.close() + + write_log('site_delete', f'删除站点 {domain}', request.remote_addr) + return jsonify({'code': 0, 'msg': '站点已删除'}) + +@app.route('/api/sites//start', methods=['POST']) +@require_auth +def api_sites_start(site_id): + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT domain FROM sites WHERE id = ?", (site_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'code': 404, 'msg': '站点不存在'}) + _, msg = nginx_reload() + return jsonify({'code': 0, 'msg': msg or '已启动'}) + +@app.route('/api/sites//stop', methods=['POST']) +@require_auth +def api_sites_stop(site_id): + return jsonify({'code': 0, 'msg': '停止站点需要 reload nginx,建议通过 Nginx 命令管理'}) + +@app.route('/api/sites//ssl', methods=['POST']) +@require_auth +def api_sites_ssl(site_id): + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT domain, site_path FROM sites WHERE id = ?", (site_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'code': 404, 'msg': '站点不存在'}) + domain, site_path = row + return jsonify({'code': 0, 'msg': 'SSL 功能开发中,请手动配置 certbot'}) + +# ========================== +# 数据库 +# ========================== + +@app.route('/api/databases', methods=['GET']) +@require_auth +def api_databases_list(): + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT * FROM databases ORDER BY id DESC") + cols = [d[0] for d in cur.description] + rows = [dict(zip(cols, r)) for r in cur.fetchall()] + conn.close() + return jsonify({'code': 0, 'data': rows}) + +@app.route('/api/databases', methods=['POST']) +@require_auth +def api_databases_create(): + data = request.json or {} + site_id = data.get('site_id') + db_name = data.get('name', '').strip() + db_user = data.get('user', '').strip() + db_pass = data.get('pass', '') + + if not all([site_id, db_name, db_user, db_pass]): + return jsonify({'code': 400, 'msg': '参数不完整'}) + + ok, msg = create_mysql_db(db_name, db_user, db_pass) + if not ok: + return jsonify({'code': 500, 'msg': msg}) + + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("""INSERT INTO databases (site_id, name, db_user, db_pass) VALUES (?, ?, ?, ?)""", + (site_id, db_name, db_user, db_pass)) + conn.commit() + db_id = cur.lastrowid + conn.close() + + write_log('db_create', f'创建数据库 {db_name}', request.remote_addr) + _sync_pma_bridge() + return jsonify({'code': 0, 'msg': '数据库创建成功', 'data': {'id': db_id}}) + +@app.route('/api/databases/', methods=['DELETE']) +@require_auth +def api_databases_delete(db_id): + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT name, db_user FROM databases WHERE id = ?", (db_id,)) + row = cur.fetchone() + if not row: + conn.close() + return jsonify({'code': 404, 'msg': '数据库不存在'}) + delete_mysql_db(row[0], row[1]) + conn.execute("DELETE FROM databases WHERE id = ?", (db_id,)) + conn.commit() + conn.close() + write_log('db_delete', f'删除数据库 {row[0]}', request.remote_addr) + _sync_pma_bridge() + return jsonify({'code': 0, 'msg': '数据库已删除'}) + +# ========================== +# 备份 +# ========================== + +@app.route('/api/backups', methods=['GET']) +@require_auth +def api_backups_list(): + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("""SELECT b.*, s.domain FROM backups b + LEFT JOIN sites s ON b.site_id = s.id + ORDER BY b.id DESC LIMIT 50""") + cols = [d[0] for d in cur.description] + rows = [dict(zip(cols, r)) for r in cur.fetchall()] + conn.close() + return jsonify({'code': 0, 'data': rows}) + +@app.route('/api/backups', methods=['POST']) +@require_auth +def api_backups_create(): + data = request.json or {} + site_id = data.get('site_id') + + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT site_path, domain FROM sites WHERE id = ?", (site_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'code': 404, 'msg': '站点不存在'}) + + site_path, domain = row + + # 查找该站点的数据库 + conn2 = sqlite3.connect(DB_PATH) + cur2 = conn2.execute("SELECT name, db_user, db_pass FROM databases WHERE site_id = ?", (site_id,)) + db_row = cur2.fetchone() + conn2.close() + + db_name, db_user, db_pass = (db_row if db_row else (None, None, None)) + + ok, path, size = backup_site(site_path, domain, db_name, db_user, db_pass) + if not ok: + return jsonify({'code': 500, 'msg': f'备份失败: {path}'}) + + conn3 = sqlite3.connect(DB_PATH) + cur3 = conn3.execute("INSERT INTO backups (site_id, type, file_path, size, status) VALUES (?, ?, ?, ?, ?)", + (site_id, 'local', path, size, 'success')) + conn3.commit() + backup_id = cur3.lastrowid + conn3.close() + + write_log('backup', f'备份站点 {domain}', request.remote_addr) + return jsonify({'code': 0, 'msg': '备份成功', 'data': {'id': backup_id, 'path': path, 'size': size}}) + +@app.route('/api/backups//restore', methods=['POST']) +@require_auth +def api_backups_restore(backup_id): + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT file_path, site_id FROM backups WHERE id = ?", (backup_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'code': 404, 'msg': '备份不存在'}) + + file_path, site_id = row + + conn2 = sqlite3.connect(DB_PATH) + cur2 = conn2.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,)) + site_row = cur2.fetchone() + conn2.close() + + if not site_row: + return jsonify({'code': 404, 'msg': '站点不存在'}) + + ok, msg = restore_backup(file_path, site_row[0], str(site_id)) + if not ok: + return jsonify({'code': 500, 'msg': msg}) + + write_log('restore', f'恢复备份 {file_path}', request.remote_addr) + return jsonify({'code': 0, 'msg': '恢复成功'}) + +# ========================== +# 安全 +# ========================== + +@app.route('/api/security/logs', methods=['GET']) +@require_auth +def api_security_logs(): + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT * FROM security_logs ORDER BY id DESC LIMIT 100") + cols = [d[0] for d in cur.description] + rows = [dict(zip(cols, r)) for r in cur.fetchall()] + conn.close() + return jsonify({'code': 0, 'data': rows}) + +@app.route('/api/security/status', methods=['GET']) +@require_auth +def api_security_status(): + status = get_security_status() + return jsonify({'code': 0, 'data': status}) + +# ========================== +# 文件管理 +# ========================== + +@app.route('/api/files/list', methods=['GET']) +@require_auth +def api_files_list(): + site_id = request.args.get('site_id', type=int) + path = request.args.get('path', '') + + if not site_id: + return jsonify({'code': 400, 'msg': '缺少 site_id'}) + + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'code': 404, 'msg': '站点不存在'}) + + base_path = row[0] + if path: + target_path = os.path.join(base_path, path) + else: + target_path = base_path + + items, err = list_directory(target_path) + if err: + return jsonify({'code': 400, 'msg': err}) + + return jsonify({'code': 0, 'data': items, 'base_path': base_path}) + +@app.route('/api/files/read', methods=['GET']) +@require_auth +def api_files_read(): + site_id = request.args.get('site_id', type=int) + filepath = request.args.get('path', '') + + if not site_id or not filepath: + return jsonify({'code': 400, 'msg': '参数不完整'}) + + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'code': 404, 'msg': '站点不存在'}) + + full_path = os.path.join(row[0], filepath) + content, err = read_file(full_path) + if err: + return jsonify({'code': 400, 'msg': err}) + + return jsonify({'code': 0, 'data': content}) + +@app.route('/api/files/write', methods=['POST']) +@require_auth +def api_files_write(): + data = request.json or {} + site_id = data.get('site_id') + filepath = data.get('path', '') + content = data.get('content', '') + + if not site_id or not filepath: + return jsonify({'code': 400, 'msg': '参数不完整'}) + + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'code': 404, 'msg': '站点不存在'}) + + full_path = os.path.join(row[0], filepath) + ok, msg = write_file(full_path, content) + if ok: + write_log('file_edit', f'编辑文件 {filepath}', request.remote_addr) + + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +@app.route('/api/files/upload', methods=['POST']) +@require_auth +def api_files_upload(): + site_id = request.form.get('site_id', type=int) + path = request.form.get('path', '') + file = request.files.get('file') + + if not site_id or not file: + return jsonify({'code': 400, 'msg': '参数不完整'}) + + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'code': 404, 'msg': '站点不存在'}) + + upload_dir = os.path.join(row[0], path) if path else row[0] + ok, msg = upload_file(upload_dir, file, file.filename) + if ok: + write_log('file_upload', f'上传文件 {file.filename}', request.remote_addr) + + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +@app.route('/api/files/delete', methods=['POST']) +@require_auth +def api_files_delete(): + data = request.json or {} + site_id = data.get('site_id') + filepath = data.get('path', '') + + if not site_id or not filepath: + return jsonify({'code': 400, 'msg': '参数不完整'}) + + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'code': 404, 'msg': '站点不存在'}) + + full_path = os.path.join(row[0], filepath) + ok, msg = delete_file(full_path) + if ok: + write_log('file_delete', f'删除文件 {filepath}', request.remote_addr) + + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +@app.route('/api/files/mkdir', methods=['POST']) +@require_auth +def api_files_mkdir(): + data = request.json or {} + site_id = data.get('site_id') + dirpath = data.get('path', '') + dirname = data.get('name', '') + + if not site_id or not dirname: + return jsonify({'code': 400, 'msg': '参数不完整'}) + + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'code': 404, 'msg': '站点不存在'}) + + full_path = os.path.join(row[0], dirpath) if dirpath else row[0] + ok, msg = create_directory(full_path, dirname) + + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +@app.route('/api/files/chmod', methods=['POST']) +@require_auth +def api_files_chmod(): + data = request.json or {} + site_id = data.get('site_id') + filepath = data.get('path', '') + mode = data.get('mode', 0) + + if not site_id or not filepath: + return jsonify({'code': 400, 'msg': '参数不完整'}) + + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'code': 404, 'msg': '站点不存在'}) + + full_path = os.path.join(row[0], filepath) + ok, msg = chmod_file(full_path, mode) + + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +# ========================== +# SSL 证书 +# ========================== + +@app.route('/api/ssl/certs', methods=['GET']) +@require_auth +def api_ssl_list(): + certs = get_all_certs() + status = check_certs_status() + return jsonify({'code': 0, 'data': certs, 'status': status}) + +@app.route('/api/ssl/apply', methods=['POST']) +@require_auth +def api_ssl_apply(): + """ + v1.3.25 改:申请 SSL 走任务流(不再同步等 certbot,可能耗时 1-3 分钟) + 任务类型: ssl_apply,target: + 完成后用 on_complete 钩子自动部署 SSL + """ + data = request.json or {} + site_id = data.get('site_id') + domain = data.get('domain', '').strip() + + # v1.3.10 修复:原 bug 是 if not site_id: skip → domain 永远空字符串 + if not domain and site_id: + # 通过 site_id 反查域名 + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT domain FROM sites WHERE id = ?", (site_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'code': 404, 'msg': '站点不存在'}) + domain = row[0] + elif not domain and not site_id: + return jsonify({'code': 400, 'msg': 'site_id 和 domain 至少传一个'}) + + if not domain: + return jsonify({'code': 400, 'msg': '域名不能为空'}) + + # 查重:同一个域名不能同时跑两个任务 + existing = get_running_task_by_type('ssl_apply', domain) + if existing: + return jsonify({'code': 400, 'msg': f'该域名证书申请正在进行中(任务 ID {existing})', 'data': {'task_id': existing}}) + + # 构造 certbot 命令 + 后续 deploy 命令(连入一个 shell 脚本,由任务流串行执行) + # 这样任务失败也能看到 deploy 不会跑的日志 + real_site_path = None + try: + real_site_path = _get_real_site_path(domain, site_id) + except Exception as e: + print(f'[ssl_apply] _get_real_site_path error: {e}', flush=True) + + if not real_site_path: + return jsonify({'code': 400, 'msg': f'找不到站点 {domain} 的真实路径,请确认站点已创建'}) + + # 任务命令: certbot 申请 → 如果成功调 deploy_ssl + cmd = [ + 'bash', '-c', + f''' +set -e +echo "[1/3] 准备 .well-known 验证目录..." +mkdir -p {real_site_path}/.well-known/acme-challenge + +echo "[2/3] 调用 certbot 申请证书(以 webroot 模式,需要 30-60s)..." +sudo certbot certonly --webroot -w {real_site_path} -d {domain} --agree-tos --non-interactive --email admin@{domain} + +echo "[3/3] 证书生成成功,部署到 nginx..." +''' + ] + + def _on_ssl_done(task_id, status): + # v1.3.34 修复:on_complete 钩子在后台线程跑,没有 Flask request context + # 不能用 request.remote_addr,传 None 给 write_log + if status == 'success': + try: + ok, msg = deploy_ssl(domain) + if ok: + write_log('ssl_apply', f'申请+部署 SSL 证书 {domain} 成功', None) + else: + write_log('ssl_apply', f'证书已申请但部署 nginx 失败 {domain}: {msg}', None) + except Exception as e: + write_log('ssl_apply', f'证书部署异常 {domain}: {e}', None) + + task_id = create_task('ssl_apply', domain, cmd, on_complete=_on_ssl_done) + write_log('ssl_apply', f'启动 SSL 申请任务 {domain}(task_id={task_id})', request.remote_addr) + return jsonify({'code': 0, 'msg': f'证书申请任务已启动(task_id={task_id}),请查看进度', 'data': {'task_id': task_id}}) + +@app.route('/api/ssl/renew/', methods=['POST']) +@require_auth +def api_ssl_renew(cert_id): + ok, msg = renew_cert(cert_id=cert_id) + if ok: + write_log('ssl_renew', f'续期证书 ID {cert_id}', request.remote_addr) + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +@app.route('/api/ssl/renew-all', methods=['POST']) +@require_auth +def api_ssl_renew_all(): + success, fail_count, fail_list = renew_all_expiring(days_before=30) + msg = f'续期完成:成功 {success} 个' + if fail_count > 0: + msg += f',失败 {fail_count} 个:{"; ".join(fail_list)}' + write_log('ssl_renew_all', msg, request.remote_addr) + return jsonify({'code': 0, 'msg': msg, 'success': success, 'failed': fail_count}) + +@app.route('/api/ssl/deploy/', methods=['POST']) +@require_auth +def api_ssl_deploy(domain): + ok, msg = deploy_ssl(domain) + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +# ========================== +# 定时任务(安全自动更新 + SSL 续期) +# ========================== + +@app.route('/api/cron/run', methods=['POST']) +@require_auth +def api_cron_run(): + """v1.3.20+:手动触发定时任务走任务流(不阻塞 HTTP)""" + # 检查是否已在跑 + existing = get_running_task_by_type('security_update') + if existing: + return jsonify({'code': 400, 'msg': f'更新任务正在进行(ID {existing})', 'data': {'task_id': existing}}) + + # 安全更新走任务流(跟 /api/security/update 一样) + cmd = ['sudo', 'apt-get', 'update', '-y', '-q'] + task_id = create_task('security_update', 'system', cmd) + write_log('cron_run', f'启动手动安全更新任务(task_id={task_id})', request.remote_addr) + return jsonify({'code': 0, 'msg': f'安全更新任务已启动(task_id={task_id}),请去任务流查看进度', 'data': {'task_id': task_id}}) + + + +# ========================== +# 定时任务 API +# ========================== + +@app.route('/api/cron/jobs', methods=['GET']) +@require_auth +def api_cron_list(): + jobs = get_all_crons() + return jsonify({'code': 0, 'data': jobs}) + +@app.route('/api/cron/jobs', methods=['POST']) +@require_auth +def api_cron_create(): + data = request.json or {} + site_id = data.get('site_id') + name = data.get('name', '').strip() + schedule = data.get('schedule', '').strip() + command = data.get('command', '').strip() + + if not all([site_id, name, schedule, command]): + return jsonify({'code': 400, 'msg': '参数不完整'}) + + # 验证 cron 表达式 + ok, err = validate_cron_expression(schedule) + if not ok: + return jsonify({'code': 400, 'msg': f'Cron 格式错误: {err}'}) + + cron_id, msg = create_cron(site_id, name, schedule, command) + if cron_id: + write_log('cron_create', f'创建定时任务 {name} ({schedule})', request.remote_addr) + return jsonify({'code': 0, 'msg': msg, 'data': {'id': cron_id}}) + else: + return jsonify({'code': 400, 'msg': msg}) + +@app.route('/api/cron/jobs/', methods=['DELETE']) +@require_auth +def api_cron_delete(cron_id): + ok, msg = delete_cron(cron_id) + if ok: + write_log('cron_delete', f'删除定时任务 ID {cron_id}', request.remote_addr) + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +@app.route('/api/cron/jobs//toggle', methods=['POST']) +@require_auth +def api_cron_toggle(cron_id): + data = request.json or {} + enabled = data.get('enabled', True) + ok, msg = enable_cron(cron_id, enabled) + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +@app.route('/api/cron/jobs//run', methods=['POST']) +@require_auth +def api_cron_run_now(cron_id): + ok, msg = run_cron_now(cron_id) + if ok: + write_log('cron_run_now', f'手动执行定时任务 ID {cron_id}', request.remote_addr) + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +# ========================== +# 远程备份 API +# ========================== + +@app.route('/api/backups/remote', methods=['POST']) +@require_auth +def api_remote_backup(): + data = request.json or {} + site_id = data.get('site_id') + remote_host = data.get('remote_host', '').strip() + remote_user = data.get('remote_user', '').strip() + remote_port = data.get('remote_port', 22) + remote_path = data.get('remote_path', '').strip() + key_path = data.get('key_path', '').strip() + + if not all([site_id, remote_host, remote_user, remote_path]): + return jsonify({'code': 400, 'msg': '参数不完整'}) + + ok, msg = run_remote_backup( + site_id, remote_host, remote_user, remote_port, + remote_path, key_path=key_path if key_path else None + ) + if ok: + return jsonify({'code': 0, 'msg': msg}) + else: + return jsonify({'code': 400, 'msg': msg}) + +@app.route('/api/backups/remote/restore/', methods=['POST']) +@require_auth +def api_remote_restore(backup_id): + data = request.json or {} + remote_host = data.get('remote_host', '').strip() + remote_user = data.get('remote_user', '').strip() + remote_port = data.get('remote_port', 22) + remote_path = data.get('remote_path', '').strip() + key_path = data.get('key_path', '').strip() + + if not all([remote_host, remote_user, remote_path]): + return jsonify({'code': 400, 'msg': '参数不完整'}) + + ok, msg = sync_restore( + backup_id, remote_host, remote_user, remote_port, + remote_path, key_path=key_path if key_path else None + ) + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +@app.route('/api/backups/remote/test', methods=['POST']) +@require_auth +def api_test_rsync(): + data = request.json or {} + host = data.get('host', '').strip() + port = data.get('port', 22) + user = data.get('user', '').strip() + key_path = data.get('key_path', '').strip() + + if not host or not user: + return jsonify({'code': 400, 'msg': '主机和用户名不能为空'}) + + ok, msg = test_rsync_connection(host, port, user, key_path if key_path else None) + return jsonify({'code': 0 if ok else 400, 'msg': msg}) + +@app.route('/api/backups/stats', methods=['GET']) +@require_auth +def api_backup_stats(): + stats = get_backup_stats() + return jsonify({'code': 0, 'data': stats}) + +# ========================== +# 设置 +# ========================== + + +@app.route('/api/settings', methods=['GET']) +@require_auth +def api_settings_get(): + cfg = load_config() + return jsonify({'code': 0, 'data': cfg}) + +@app.route('/api/settings', methods=['PUT']) +@require_auth +def api_settings_put(): + """v1.3.19+:修双重存储 bug——既写 tpanel.conf 又同步 sqlite settings""" + data = request.json or {} + allowed_keys = [ + 'panel_domain', 'default_php', 'backup_retention_days', + 'auto_ssl_renew', 'security_auto_update', 'firewall_enabled' + ] + cfg = load_config() + cfg.update({k: v for k, v in data.items() if k in allowed_keys}) + save_config(cfg) + + # 关键修复:把要进 check_panel_domain / get_panel_domain 的字段也写 sqlite settings + # 否则 get_panel_domain() 永远从 sqlite 读到旧值 + if 'panel_domain' in data: + set_setting('panel_domain', data['panel_domain'] or '') + # 其他字段也同步(保证 sqlite 跟 conf 一致) + for k in allowed_keys: + if k in data: + v = data[k] + if isinstance(v, bool): + v = '1' if v else '0' + elif v is None: + v = '' + set_setting(k, str(v)) + + return jsonify({'code': 0, 'msg': '设置已保存'}) + +# ========================== +# 软件市场 + 任务管理(v1.3.10 新增) +# ========================== +from task_manager import ( + list_software, get_software, get_apt_packages, + create_task, get_task, get_running_task_by_type, + init_software_table, get_apt_cmd, + setup_phpmyadmin_nginx +) +import json +import time + +@app.route('/api/software/list', methods=['GET']) +@require_auth +def api_software_list(): + return jsonify({'code': 0, 'data': list_software()}) + +@app.route('/api/software/install/', methods=['POST']) +@require_auth +def api_software_install(name): + sw = get_software(name) + if not sw: + return jsonify({'code': 404, 'msg': '软件不在白名单'}) + if sw['installed']: + return jsonify({'code': 400, 'msg': f'{sw["display_name"]} 已经安装了'}) + # 防止并发装同一个 + existing = get_running_task_by_type('software_install', name) + if existing: + return jsonify({'code': 400, 'msg': f'该软件正在安装中(任务 ID {existing})', 'data': {'task_id': existing}}) + pkgs = get_apt_packages(name) + if not pkgs: + return jsonify({'code': 500, 'msg': '未找到该软件包名'}) + pkg_list = pkgs.split(',') + try: + cmd = get_apt_cmd() + ['install'] + pkg_list + except Exception as e: + return jsonify({'code': 500, 'msg': str(e)}) + # v1.3.29: 装 PHP 走 on_complete 钩子自动配 FPM listen 端口 + on_complete = None + if name.startswith('php') and name[3:].replace('.', '').isdigit(): + # name = 'php7.4' / 'php8.3' → php_version = '7.4' / '8.3' + php_version = name[3:] + + def _on_php_installed(task_id, status, pv=php_version): + """on_complete 钩子:装完后改 FPM listen 端口 + enable + restart""" + if status != 'success': + return + ok, msg = setup_php_fpm_listen(pv) + if ok: + write_log('php_install', f'PHP {pv} FPM 已配置 {msg}', request.remote_addr) + else: + write_log('php_install', f'PHP {pv} FPM 配置失败: {msg}', request.remote_addr) + on_complete = _on_php_installed + task_id = create_task('software_install', name, cmd, on_complete=on_complete) + write_log('software_install', f'开始安装 {name}', request.remote_addr) + return jsonify({'code': 0, 'msg': '安装任务已启动', 'data': {'task_id': task_id}}) + +# v1.3.29: 批量重写所有站点 nginx conf(按照 db 的 php_version 字段用对应端口) +# 用途:手动统一切换所有站点到某个 PHP 版本 +@app.route('/api/system/rewrite-all-nginx', methods=['POST']) +@require_auth +def api_rewrite_all_nginx(): + conn = sqlite3.connect(DB_PATH) + rows = conn.execute("SELECT id, domain, site_path, php_version, ssl_enabled FROM sites").fetchall() + conn.close() + rewritten = 0 + failed = [] + for sid, domain, site_path, php_v, ssl in rows: + # 跳过静态站点 + try: + conn2 = sqlite3.connect(DB_PATH) + st = conn2.execute("SELECT site_type FROM sites WHERE id=?", (sid,)).fetchone() + conn2.close() + site_type = st[0] if st and st[0] else 'php' + except Exception: + site_type = 'php' + ok, msg = write_nginx_config(domain, site_path, php_v, ssl=bool(ssl), site_type=site_type) + if ok: + rewritten += 1 + else: + failed.append((domain, msg)) + # reload nginx + code, out, err = _run(['sudo', 'nginx', '-t']) + if code == 0: + _run(['sudo', 'nginx', '-s', 'reload']) + return jsonify({'code': 0, 'msg': f'重写 {rewritten} 个站点 conf,失败 {len(failed)} 个', 'data': {'rewritten': rewritten, 'failed': failed}}) + +@app.route('/api/software/uninstall/', methods=['POST']) +@require_auth +def api_software_uninstall(name): + sw = get_software(name) + if not sw: + return jsonify({'code': 404, 'msg': '软件不在白名单'}) + pkgs = get_apt_packages(name) + if not pkgs: + return jsonify({'code': 500, 'msg': '未找到该软件包名'}) + pkg_list = pkgs.split(',') + try: + cmd = get_apt_cmd() + ['remove'] + pkg_list + except Exception as e: + return jsonify({'code': 500, 'msg': str(e)}) + existing = get_running_task_by_type('software_uninstall', name) + if existing: + return jsonify({'code': 400, 'msg': f'正在卸载中(任务 ID {existing})'}) + task_id = create_task('software_uninstall', name, cmd) + write_log('software_uninstall', f'开始卸载 {name}', request.remote_addr) + return jsonify({'code': 0, 'msg': '卸载任务已启动', 'data': {'task_id': task_id}}) + +@app.route('/api/tasks/', methods=['GET']) +@require_auth +def api_task_get(task_id): + t = get_task(task_id) + if not t: + return jsonify({'code': 404, 'msg': '任务不存在'}) + return jsonify({'code': 0, 'data': t}) + +@app.route('/api/tasks//stream', methods=['GET']) +@require_auth +def api_task_stream(task_id): + """SSE 流,推送任务日志和状态""" + def generate(): + last_log_len = 0 + # 先发当前状态 + t = get_task(task_id) + if not t: + yield f"data: {json.dumps({'type': 'error', 'msg': '任务不存在'})}\n\n" + return + yield f"data: {json.dumps({'type': 'status', 'status': t['status'], 'log': t['log']})}\n\n" + last_log_len = len(t['log']) + # 轮询直到完成 + for _ in range(1800): # 最多 30 分钟 + time.sleep(1) + t = get_task(task_id) + if not t: + yield f"data: {json.dumps({'type': 'error', 'msg': '任务丢失'})}\n\n" + return + if len(t['log']) != last_log_len: + yield f"data: {json.dumps({'type': 'log', 'log': t['log'][last_log_len:]})}\n\n" + last_log_len = len(t['log']) + if t['status'] in ('success', 'failed'): + yield f"data: {json.dumps({'type': 'done', 'status': t['status'], 'exit_code': t['exit_code']})}\n\n" + return + yield f"data: {json.dumps({'type': 'error', 'msg': 'SSE 超时'})}\n\n" + return Response(generate(), mimetype='text/event-stream', + headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) + +# ========================== +# phpMyAdmin(v1.3.10 新增 - 装完自动配 Nginx 8443) +# ========================== +@app.route('/api/phpmyadmin/status', methods=['GET']) +@require_auth +def api_phpmyadmin_status(): + sw = get_software('phpmyadmin') + if not sw: + return jsonify({'code': 0, 'data': {'installed': False, 'url': ''}}) + # v1.3.23 修复:phpMyAdmin URL 构造不再用 hostname 查到的 127.0.0.1 + # 优先顺序:面板域名 > 用户当前访问的 host > 本机获取的 IP + import socket + panel_dom = get_panel_domain().strip() + if panel_dom: + host = panel_dom + else: + # 从 request.host 拆出 hostname(去掉端口) + try: + host = request.host.split(':')[0] if request.host else '' + except Exception: + host = '' + if not host or host in ('127.0.0.1', 'localhost'): + # fallback: 拿第一个非 loopback 的网卡 IP + try: + ip_fallback = socket.gethostbyname(socket.gethostname()) + if ip_fallback and not ip_fallback.startswith('127.'): + host = ip_fallback + else: + host = '127.0.0.1' + except Exception: + host = '127.0.0.1' + # 三个条件都满足才算真装好: + # 1. software 表标记 installed + # 2. phpMyAdmin 文件实际存在(防 sw 状态滞后) + # 3. Nginx 8443 反代配置已写入 + files_exist = ( + os.path.isdir('/usr/share/phpmyadmin') + and os.path.exists('/usr/share/phpmyadmin/index.php') + ) + nginx_ok = os.path.exists('/etc/nginx/sites-enabled/phpmyadmin.conf') + really_installed = bool(sw['installed']) and files_exist and nginx_ok + url = f'http://{host}:8443/' if really_installed else '' + return jsonify({'code': 0, 'data': { + 'installed': really_installed, + 'url': url, + 'files_exist': files_exist, + 'nginx_ok': nginx_ok, + 'sw_installed': bool(sw['installed']) + }}) + +@app.route('/api/phpmyadmin/install', methods=['POST']) +@require_auth +def api_phpmyadmin_install(): + existing = get_running_task_by_type('software_install', 'phpmyadmin') + if existing: + return jsonify({'code': 400, 'msg': f'phpMyAdmin 正在安装(任务 ID {existing})', 'data': {'task_id': existing}}) + pkgs = get_apt_packages('phpmyadmin') + if not pkgs: + return jsonify({'code': 500, 'msg': '未找到包名'}) + # 装 phpmyadmin(-q 避免交互) + try: + cmd = ['sudo', 'apt-get', '-y', '-q', '-o', 'Dpkg::Options::=--force-confdef', '-o', 'Dpkg::Options::=--force-confnew', 'install'] + pkgs.split(',') + except Exception: + cmd = ['sudo', 'apt-get', '-y', 'install'] + pkgs.split(',') + # v1.3.10:装完后用 on_complete 钩子自动写 Nginx 8443 反代 + def _on_pma_installed(task_id, status): + if status == 'success': + setup_phpmyadmin_nginx(task_id=task_id) + task_id = create_task('software_install', 'phpmyadmin', cmd, on_complete=_on_pma_installed) + return jsonify({'code': 0, 'msg': 'phpMyAdmin 安装任务已启动(装完会自动配置 Nginx 8443)', 'data': {'task_id': task_id}}) + +# ========================== +# v1.3.34 数据库改密 + phpMyAdmin 真自动登录 +# ========================== + +# 数据库改密(更新 MySQL + sqlite 都改) +@app.route('/api/databases//password', methods=['POST']) +@require_auth +def api_change_db_password(db_id): + data = request.json or {} + new_pass = data.get('new_pass', '') + if not new_pass or len(new_pass) < 6: + return jsonify({'code': 400, 'msg': '新密码至少 6 位'}) + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT name, db_user FROM databases WHERE id = ?", (db_id,)) + row = cur.fetchone() + if not row: + conn.close() + return jsonify({'code': 404, 'msg': '数据库不存在'}) + db_name, db_user = row[0], row[1] + ok, msg = change_db_password(db_user, new_pass) + if not ok: + conn.close() + return jsonify({'code': 500, 'msg': msg}) + # 同步更新 sqlite(备份、phpMyAdmin 自动填、面板显示都用这个) + conn.execute("UPDATE databases SET db_pass = ? WHERE id = ?", (new_pass, db_id)) + conn.commit() + conn.close() + write_log('db_change_pass', f'修改数据库 {db_name} 密码', request.remote_addr) + _sync_pma_bridge() + return jsonify({'code': 0, 'msg': '密码修改成功'}) + +# v1.3.34: 数据库改密/增删后同步 phpMyAdmin bridge.json +def _sync_pma_bridge(): + import subprocess + try: + r = subprocess.run( + ['sudo', '-u', 'tpanel', 'python3', '/opt/tpanel/backend/sync_pma_bridge.py'], + capture_output=True, text=True, timeout=10 + ) + if r.returncode != 0: + write_log('pma_bridge_sync_fail', r.stderr, request.remote_addr) + except Exception as e: + write_log('pma_bridge_sync_err', str(e), request.remote_addr) + + +# 生成临时 token(5 分钟有效,用于 phpMyAdmin 自动登录跳转) +# payload: db_id + exp, HMAC-SHA256 签名 +import hmac, hashlib, base64, json, time + +def _sign_token(db_id, ttl=300): + payload = {'db_id': db_id, 'exp': int(time.time()) + ttl} + payload_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode() + sig = hmac.new(SECRET_KEY.encode(), payload_b64.encode(), hashlib.sha256).digest() + sig_b64 = base64.urlsafe_b64encode(sig).decode() + return f'{payload_b64}.{sig_b64}' + +def _verify_token(token): + try: + payload_b64, sig_b64 = token.split('.') + # v1.3.34: sign 时已带 padding,不再补 + sig = base64.urlsafe_b64decode(sig_b64) + expected = hmac.new(SECRET_KEY.encode(), payload_b64.encode(), hashlib.sha256).digest() + if not hmac.compare_digest(sig, expected): + return None + payload = json.loads(base64.urlsafe_b64decode(payload_b64)) + if payload.get('exp', 0) < int(time.time()): + return None + return payload + except Exception: + return None + +@app.route('/api/phpmyadmin/token/', methods=['GET']) +@require_auth +def api_phpmyadmin_token(db_id): + """签发一次性 token(5 分钟有效),前端拼 URL 跳 phpMyAdmin""" + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT name FROM databases WHERE id = ?", (db_id,)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'code': 404, 'msg': '数据库不存在'}) + token = _sign_token(db_id) + return jsonify({'code': 0, 'data': {'token': token, 'expires_in': 300}}) + +# phpMyAdmin Signon 端点(无 auth — SignonURL 走 GET,phpMyAdmin 自动跳过来) +@app.route('/api/phpmyadmin/signon', methods=['GET']) +def api_phpmyadmin_signon(): + """v1.3.34 phpMyAdmin Signon 端点:验 token → 302 到 PHP bridge.php + bridge.php 启动 PHP session + 写 PMA_single_signon_* + 302 回 phpMyAdmin + """ + token = request.args.get('token', '') + db_id = request.args.get('db', '') + if not token or not db_id: + return redirect('http://127.0.0.1:8888/login?msg=missing_token') + # 不在 Python 端验 token(bridge.php 会用 HMAC 验证),直接 302 过去 + return redirect('https://zhangpu.tech/pma/tpanel-bridge.php?token=' + token + '&db=' + db_id) + +# phpMyAdmin 退出(清除 cookie + 重定向回面板) +@app.route('/api/phpmyadmin/logout', methods=['GET']) +def api_phpmyadmin_logout(): + resp = redirect('http://127.0.0.1:8888/dashboard') + resp.delete_cookie('TPanelSignon', domain='127.0.0.1', path='/') + return resp + +# ========================== +# 安全更新(v1.3.10 改为走任务流) +# ========================== +@app.route('/api/security/update', methods=['POST']) +@require_auth +def api_security_update(): + existing = get_running_task_by_type('security_update') + if existing: + return jsonify({'code': 400, 'msg': f'更新任务正在进行(ID {existing})', 'data': {'task_id': existing}}) + try: + # apt-get update + upgrade -y + cmd = ['sudo', 'apt-get', 'update', '-y', '-q'] + except Exception: + cmd = ['sudo', 'apt-get', 'update', '-y'] + task_id = create_task('security_update', 'system', cmd) + write_log('security_update', '启动系统安全更新', request.remote_addr) + return jsonify({'code': 0, 'msg': '更新任务已启动', 'data': {'task_id': task_id}}) + +# ========================== +# v1.3.28: 添加 Sury PHP 第三方源 +# 让 Debian 12 也能装 PHP 5.6 / 7.0 / 7.4 / 8.0 / 8.1 / 8.3 / 8.4 +# 走任务流,因为 apt update 可能耗时 1-2 分钟 +# ========================== +@app.route('/api/system/add-sury-php', methods=['POST']) +@require_auth +def api_add_sury_php(): + # 只支持 Debian/Ubuntu 系 + if not os.path.exists('/etc/debian_version') and not os.path.exists('/etc/lsb-release'): + return jsonify({'code': 400, 'msg': 'Sury PHP 源仅支持 Debian/Ubuntu 系统'}) + + # 检查是否已添加 + if os.path.exists('/etc/apt/sources.list.d/php.list'): + return jsonify({'code': 400, 'msg': 'Sury PHP 源已添加,无需重复操作'}) + + # 查重:同类型任务 + existing = get_running_task_by_type('sury_php', 'sury_php') + if existing: + return jsonify({'code': 400, 'msg': f'Sury 源添加任务正在进行(ID {existing})', 'data': {'task_id': existing}}) + + # 任务命令:装 lsb-release + ca-certificates + curl → 下载 GPG keyring → 配置源 → apt update + cmd = [ + 'bash', '-c', + ''' +set -e +echo "[1/5] 装 lsb-release / ca-certificates / curl..." +sudo -n DEBIAN_FRONTEND=noninteractive apt-get install -y -q lsb-release ca-certificates curl 2>&1 +echo "[2/5] 下载 Sury GPG keyring..." +sudo -n curl -sSLo /tmp/debsuryorg-archive-keyring.deb https://packages.sury.org/debsuryorg-archive-keyring.deb +echo "[3/5] 装 keyring..." +sudo -n dpkg -i /tmp/debsuryorg-archive-keyring.deb +echo "[4/5] 写 /etc/apt/sources.list.d/php.list..." +DISTRO=$(lsb_release -sc) +sudo -n sh -c "echo 'deb [signed-by=/usr/share/keyrings/debsuryorg-archive-keyring.gpg] https://packages.sury.org/php/ ${DISTRO} main' > /etc/apt/sources.list.d/php.list" +echo "[5/5] apt update(可能要 30-90s)..." +sudo -n DEBIAN_FRONTEND=noninteractive apt-get update -q 2>&1 +echo "===Sury PHP 源添加完成===" +''' + ] + task_id = create_task('sury_php', 'sury_php', cmd) + write_log('sury_php', '启动添加 Sury PHP 源任务', request.remote_addr) + return jsonify({'code': 0, 'msg': f'Sury PHP 源添加任务已启动(task_id={task_id}),请查看进度', 'data': {'task_id': task_id}}) + +# ========================== +# 静态文件 +# ========================== + +@app.route('/') +def serve_index(): + return app.send_static_file('index.html') + +@app.route('/') +def serve_static(path): + full = os.path.join(app.static_folder, path) + if os.path.exists(full) and not os.path.isdir(full): + return app.send_static_file(path) + return app.send_static_file('index.html') + +# ========================== +# 健康检查 +# ========================== + +@app.route('/health') +def health(): + return jsonify({'status': 'ok', 'time': datetime.now().isoformat()}) + +if __name__ == '__main__': + import sys + port = int(sys.argv[1]) if len(sys.argv) > 1 else 8848 + # 初始化数据库 + from db_init import init_db + init_db() + # 生成初始 API token + if not get_setting('api_token'): + set_setting('api_token', secrets.token_hex(32)) + print(f"[TPanel] 启动于端口 {port}") + app.run(host='127.0.0.1', port=port, debug=False) \ No newline at end of file diff --git a/tpanel-v1.3.34-pkg/backend/remote_backup.py b/tpanel-v1.3.34-pkg/backend/remote_backup.py new file mode 100644 index 0000000..fe9f3e9 --- /dev/null +++ b/tpanel-v1.3.34-pkg/backend/remote_backup.py @@ -0,0 +1,218 @@ +""" +TPanel - 远程备份管理(rsync) +""" +import os +import sqlite3 +import subprocess +import datetime +from config import DB_PATH + +def _run(cmd, timeout=120, shell=False): + try: + if isinstance(cmd, str) and not shell: + cmd = cmd.split() + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, shell=shell) + return result.returncode, result.stdout.strip(), result.stderr.strip() + except subprocess.TimeoutExpired: + return -1, '', 'Command timed out' + except Exception as e: + return -1, '', str(e) + +def test_rsync_connection(host, port, user, key_path): + """测试到远程服务器的 rsync 连接""" + if not host or not user: + return False, '主机和用户名不能为空' + + extra = '' + if port and str(port) != '22': + extra = f'-e "ssh -p {port}"' + + key = f'-i {key_path}' if key_path else '' + cmd = f'ssh -o StrictHostKeyChecking=no {key} {user}@{host} "echo ok" {extra}' + + code, out, err = _run(cmd, timeout=15, shell=True) + + if code == 0 and 'ok' in out: + return True, '连接成功' + else: + return False, err or '连接失败' + +def get_remote_backups(site_id): + """获取某站点的远程备份列表(通过 rsync 列出远程目录)""" + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT s.site_user FROM sites s WHERE s.id = ?", (site_id,)) + row = cur.fetchone() + conn.close() + if not row: + return [], '站点不存在' + + site_user = row[0] + remote_bak_dir = f'/opt/tpanel/backups/{site_user}/' + + # 尝试通过 SSH 查看远程备份(需要配置) + # 这里返回空列表,实际使用时由用户配置远程路径 + return [], '请配置远程备份服务器' + +def run_remote_backup(site_id, remote_host, remote_user, remote_port, remote_path, key_path=None, use_password=False, password=None): + """ + 执行远程 rsync 备份 + 流程: + 1. 打包本地站点文件 + 2. rsync 推送到远程 + 3. 记录备份日志 + """ + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT site_user, domain FROM sites WHERE id = ?", (site_id,)) + row = cur.fetchone() + conn.close() + + if not row: + return False, '站点不存在' + + site_user, domain = row + site_path = f'/opt/tpanel/sites/{site_user}/' + + timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') + tar_name = f'{domain}_{timestamp}.tar.gz' + local_tar = f'/opt/tpanel/backups/{tar_name}' + + # 1. 打包本地文件 + try: + import tarfile + with tarfile.open(local_tar, 'w:gz') as tar: + tar.add(site_path, arcname=os.path.basename(site_path)) + + tar_size = os.path.getsize(local_tar) + except Exception as e: + return False, f'打包失败: {str(e)}' + + # 2. 构建 rsync 命令 + ssh_cmd = f'ssh -o StrictHostKeyChecking=no -p {remote_port or 22}' + if key_path and os.path.exists(key_path): + ssh_cmd += f' -i {key_path}' + + rsync_cmd = [ + 'rsync', '-avz', '--progress', + '-e', ssh_cmd, + local_tar, + f'{remote_user}@{remote_host}:{remote_path}/{tar_name}' + ] + + code, out, err = _run(rsync_cmd, timeout=600) + + # 删除本地 tar 包(节省空间) + try: + os.remove(local_tar) + except: + pass + + if code != 0: + return False, f'rsync 失败: {err}' + + # 3. 写入备份记录 + conn = sqlite3.connect(DB_PATH) + conn.execute("""INSERT INTO backups (site_id, type, file_path, size, status) + VALUES (?, ?, ?, ?, ?)""", + (site_id, 'remote', f'{remote_host}:{remote_path}/{tar_name}', tar_size, 'success')) + conn.commit() + conn.close() + + # 4. 写安全日志 + from system import write_log + write_log('remote_backup', f'远程备份 {domain} -> {remote_host}', '') + + return True, f'备份成功,已推送至 {remote_host}' + +def sync_restore(backup_id, remote_host, remote_user, remote_port, remote_path, key_path=None): + """ + 从远程恢复备份到本地 + """ + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT site_id, file_path FROM backups WHERE id = ?", (backup_id,)) + row = cur.fetchone() + conn.close() + + if not row: + return False, '备份记录不存在' + + site_id, remote_file = row + + conn2 = sqlite3.connect(DB_PATH) + cur2 = conn2.execute("SELECT site_user, domain FROM sites WHERE id = ?", (site_id,)) + row2 = cur2.fetchone() + conn2.close() + + if not row2: + return False, '站点不存在' + + site_user, domain = row2 + local_dir = f'/opt/tpanel/backups/{site_user}' + os.makedirs(local_dir, exist_ok=True) + + # rsync 从远程拉回 + ssh_cmd = f'ssh -o StrictHostKeyChecking=no -p {remote_port or 22}' + if key_path and os.path.exists(key_path): + ssh_cmd += f' -i {key_path}' + + local_tar = os.path.join(local_dir, os.path.basename(remote_file)) + + rsync_cmd = [ + 'rsync', '-avz', + '-e', ssh_cmd, + f'{remote_user}@{remote_host}:{remote_path}/{os.path.basename(remote_file)}', + local_dir + '/' + ] + + code, out, err = _run(rsync_cmd, timeout=600) + + if code != 0: + return False, f'拉取失败: {err}' + + # 解压恢复 + if os.path.exists(local_tar): + import tarfile + try: + site_path = f'/opt/tpanel/sites/{site_user}/' + with tarfile.open(local_tar, 'r:gz') as tar: + tar.extractall('/opt/tpanel/backups/') + os.remove(local_tar) + except Exception as e: + return False, f'解压失败: {str(e)}' + + from system import write_log + write_log('restore', f'远程恢复 {domain} from {remote_host}', '') + + return True, '恢复成功' + +def get_backup_stats(): + """获取备份统计信息""" + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("""SELECT type, COUNT(*) as cnt, SUM(size) as total_size + FROM backups GROUP BY type""") + rows = cur.fetchall() + conn.close() + + total_local = 0 + total_remote = 0 + count = 0 + + for r in rows: + if r[0] == 'local': + total_local = r[2] or 0 + count += r[1] + elif r[0] == 'remote': + total_remote = r[2] or 0 + + # 计算备份目录总大小 + code, out, _ = _run("du -sm /opt/tpanel/backups 2>/dev/null | awk '{print $1}'", shell=True) + try: + disk_used = int(out.strip()) if out.strip().isdigit() else 0 + except: + disk_used = total_local / (1024 * 1024) + + return { + 'total_backups': count, + 'local_size_mb': round(total_local / (1024 * 1024), 1) if total_local else 0, + 'remote_size_mb': round(total_remote / (1024 * 1024), 1) if total_remote else 0, + 'disk_used_mb': disk_used, + } \ No newline at end of file diff --git a/tpanel-v1.3.34-pkg/backend/ssl_manager.py b/tpanel-v1.3.34-pkg/backend/ssl_manager.py new file mode 100644 index 0000000..25a3cf0 --- /dev/null +++ b/tpanel-v1.3.34-pkg/backend/ssl_manager.py @@ -0,0 +1,388 @@ +""" +TPanel - SSL 证书管理 & 自动续期 +""" +import os +import sqlite3 +import subprocess +import re +from datetime import datetime, timedelta +from config import DB_PATH, SSL_DIR + +LETSENCRYPT_PATH = '/etc/letsencrypt/live' + +def _get_real_site_path(domain, site_id): + """ + v1.3.24 修复:查 sqlite 拿站点的真实 site_path(里面是 zhangpu_tech 之类的下划线版), + 这样 certbot 写 challenge 文件的路径才跟 nginx root 指向一致 + 返回 None 表示找不到(会回退到硬编码的 /opt/tpanel/sites//public) + """ + try: + conn = sqlite3.connect(DB_PATH) + if site_id: + cur = conn.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,)) + else: + cur = conn.execute("SELECT site_path FROM sites WHERE domain = ?", (domain,)) + row = cur.fetchone() + conn.close() + if row and row[0]: + p = row[0] + # 确保末尾有 /public(site_path 存的可能就是 /public) + if not p.rstrip('/').endswith('/public'): + p = p.rstrip('/') + '/public' + if os.path.isdir(p): + return p + except Exception as e: + print(f'[ssl] _get_real_site_path failed: {e}', flush=True) + return None + +def _run(cmd, timeout=120, shell=False): + try: + if isinstance(cmd, str) and not shell: + cmd = cmd.split() + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, shell=shell) + return result.returncode, result.stdout.strip(), result.stderr.strip() + except subprocess.TimeoutExpired: + return -1, '', 'Command timed out' + except Exception as e: + return -1, '', str(e) + +def get_cert_info(cert_path): + """从 PEM 文件读取证书信息(到期日期等)""" + if not os.path.exists(cert_path): + return None + + code, out, err = _run([ + 'openssl', 'x509', '-in', cert_path, + '-noout', '-dates', '-enddate' + ], shell=False) + + expire_str = None + if code == 0: + for line in out.split('\n'): + if 'notAfter=' in line: + expire_str = line.split('=')[1].strip() + break + + if expire_str: + try: + expire_date = datetime.strptime(expire_str, '%b %d %H:%M:%S %Y %Z') + return { + 'expire_date': expire_date.strftime('%Y-%m-%d'), + 'days_left': (expire_date - datetime.now()).days, + 'expire_raw': expire_str, + } + except Exception: + pass + + return {'expire_date': '未知', 'days_left': 0, 'expire_raw': expire_str} + +def get_all_certs(): + """获取所有证书(含到期信息)""" + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT * FROM ssl_certs ORDER BY id DESC") + cols = [d[0] for d in cur.description] + rows = [dict(zip(cols, r)) for r in cur.fetchall()] + conn.close() + + result = [] + for cert in rows: + info = get_cert_info(cert['cert_path']) + cert.update(info or {}) + result.append(cert) + + return result + +def apply_letsencrypt(site_id, domain): + """ + 为站点申请 Let's Encrypt 证书 + 流程:创建验证目录 → 生成 cert → 部署 nginx 配置 → 写入数据库 + """ + # v1.3.24 修复:不要再硬编码 /opt/tpanel/sites//public + # 建站时 domain 里的 . 被换成 _(zhangpu.tech → zhangpu_tech), + # certbot 写到 /opt/tpanel/sites/zhangpu.tech/(空目录), + # 但 nginx root 指向 zhangpu_tech/,LE 服务器拉 403 + site_path = _get_real_site_path(domain, site_id) + le_dir = os.path.join(SSL_DIR, domain) + os.makedirs(le_dir, exist_ok=True) + + # 写入 HTTP 验证文件到站点目录 + well_known = os.path.join(site_path, '.well-known', 'acme-challenge') + os.makedirs(well_known, exist_ok=True) + + # 先测试 nginx 配置能访问到验证文件 + nginx_conf = f'''# SSL verification - {domain} +server {{ + listen 80; + server_name {domain}; + root {site_path}; + + location /.well-known/acme-challenge/ {{ + alias {well_known}/; + try_files $uri =404; + }} + + location / {{ + return 301 https://$host$request_uri; + }} +}} +''' + conf_path = f'/etc/nginx/sites-available/{domain}.ssl.conf' + # v1.3.21+:用 sudo mv 写 /etc/nginx/sites-available + tmp_conf = f'/tmp/tpanel_ssl_{domain}.conf' + with open(tmp_conf, 'w') as f: + f.write(nginx_conf) + code, out, err = _run(['sudo', 'mv', tmp_conf, conf_path]) + if code != 0: + return False, f'写 SSL conf 失败: {err}' + + enabled_path = f'/etc/nginx/sites-enabled/{domain}.ssl.conf' + if os.path.exists(enabled_path): + _run(['sudo', 'rm', '-f', enabled_path]) + _run(['sudo', 'ln', '-sf', conf_path, enabled_path]) + + code, out, err = _run(['sudo', 'nginx', '-t']) + if code != 0: + return False, f'Nginx 配置错误: {err}' + + _run(['sudo', 'nginx', '-s', 'reload']) + + # 申请证书(standalone 模式 + webroot) + # v1.3.25 修复:去掉 --cert-path/--key-path/--chain-path 自定义路径 + # certbot 会忽略这些路径或写到默认位置(/etc/letsencrypt/live//), + # 导致 TPanel 去 /opt/tpanel/ssl// 找时拿不到,报"证书文件未生成" + cmd = [ + 'sudo', 'certbot', 'certonly', + '--webroot', + '-w', site_path, + '-d', domain, + '--agree-tos', + '--non-interactive', + '--email', f'admin@{domain}', + ] + + code, out, err = _run(cmd, timeout=120) + + if code != 0: + # 清理失败配置(v1.3.21+:用 sudo 删软链) + if os.path.exists(enabled_path): + _run(['sudo', 'rm', '-f', enabled_path]) + return False, f'证书申请失败: {err}' + + # v1.3.25: certbot 默认写到 /etc/letsencrypt/live//,从那里读 + le_live = f'/etc/letsencrypt/live/{domain}' + cert_path = os.path.join(le_live, 'fullchain.pem') + key_path = os.path.join(le_live, 'privkey.pem') + + if not os.path.exists(cert_path): + return False, '证书文件未生成' + + # 写入数据库 + info = get_cert_info(cert_path) + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("""INSERT INTO ssl_certs (site_id, domain, cert_path, key_path, expire_date, auto_renew) + VALUES (?, ?, ?, ?, ?, 1)""", + (site_id, domain, cert_path, key_path, info['expire_date'] if info else '')) + conn.commit() + conn.close() + + return True, f'证书申请成功,到期:{info["expire_date"] if info else "未知"}' + +def renew_cert(cert_id=None, domain=None): + """ + 续期证书(certbot renew) + """ + if cert_id: + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT domain FROM ssl_certs WHERE id = ?", (cert_id,)) + row = cur.fetchone() + conn.close() + if row: + domain = row[0] + elif domain: + pass + else: + return False, '请指定证书 ID 或域名' + + # certbot renew 只续期 30 天内到期的证书 + code, out, err = _run( + ['certbot', 'renew', '--cert-name', domain, '--quiet'], + timeout=120 + ) + + if code != 0 and 'No renewals attempted' not in out and 'already valid' not in out: + return False, f'续期失败: {err}' + + # 更新到期日期 + le_dir = os.path.join(SSL_DIR, domain) + cert_path = os.path.join(le_dir, 'fullchain.pem') + info = get_cert_info(cert_path) + + if info: + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("UPDATE ssl_certs SET expire_date = ? WHERE domain = ?", + (info['expire_date'], domain)) + conn.commit() + conn.close() + + return True, f'证书已续期,新到期:{info["expire_date"] if info else "未知"}' + +def renew_all_expiring(days_before=30): + """ + 续期所有即将到期的证书(供定时任务调用) + 返回:(成功数量, 失败数量, 详情列表) + """ + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT * FROM ssl_certs WHERE auto_renew = 1") + rows = cur.fetchall() + conn.close() + + if not rows: + return 0, 0, [] + + success, fail = 0, [] + for row in rows: + cert_id, site_id, domain = row[0], row[1], row[2] + info = get_cert_info(row[3]) # cert_path + + # 检查是否在 30 天内到期 + if info and info['days_left'] <= days_before: + ok, msg = renew_cert(cert_id=cert_id, domain=domain) + if ok: + success += 1 + else: + fail.append(f'{domain}: {msg}') + elif not info or info['days_left'] > days_before: + # 证书已过期或不存在 + pass + + return success, len(fail), fail + +def deploy_ssl(domain): + """ + 将已有证书部署到 Nginx(更新 nginx 配置启用 HTTPS) + v1.3.25: 从 /etc/letsencrypt/live// 读证书(certbot 默认位置) + """ + le_live = f'/etc/letsencrypt/live/{domain}' + cert_path = os.path.join(le_live, 'fullchain.pem') + key_path = os.path.join(le_live, 'privkey.pem') + + if not os.path.exists(cert_path) or not os.path.exists(key_path): + return False, '证书文件不存在' + + site_path = f'/opt/tpanel/sites/{domain}/public' + # v1.3.24: 同样查 sqlite 拿真路径 + site_path = _get_real_site_path(domain, None) or site_path + + # 写入 HTTPS + HTTP 重定向配置 + nginx_conf = f'''# {domain} - HTTPS +server {{ + listen 80; + server_name {domain}; + return 301 https://$server_name$request_uri; +}} + +server {{ + listen 443 ssl http2; + server_name {domain}; + + ssl_certificate {cert_path}; + ssl_certificate_key {key_path}; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + root {site_path}; + index index.php index.html; + + access_log /opt/tpanel/logs/{domain}.access.log; + error_log /opt/tpanel/logs/{domain}.error.log; + + location / {{ + try_files $uri $uri/ /index.php?$query_string; + }} + + location ~ \\.php$ {{ + include fastcgi_params; + fastcgi_pass 127.0.0.1:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + }} + + location ~ /\\.ht {{ + deny all; + }} +}} +''' + conf_path = f'/etc/nginx/sites-available/{domain}.conf' + + # v1.3.34 修复:用 sudo rm 清理(前面已经会 rm -f,这里简化) + + with open(conf_path, 'w') as f: + f.write(nginx_conf) + + # v1.3.34 修复:用 sudo ln -sf (sites-enabled 目录 root-only 可写) + enabled_path = f'/etc/nginx/sites-enabled/{domain}.conf' + # 先 rm 旧的(无论是 symlink 还是普通文件) + _run(['sudo', 'rm', '-f', enabled_path]) + r = _run(['sudo', 'ln', '-sf', conf_path, enabled_path]) + if r[0] != 0: + return False, f'创建 symlink 失败: {r[2]}' + + code, out, err = _run(['sudo', 'nginx', '-t']) + if code != 0: + return False, f'Nginx 配置错误: {err}' + + _run(['sudo', 'nginx', '-s', 'reload']) + + # 更新数据库 ssl_enabled + ssl_certs 表 + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT id FROM sites WHERE domain = ?", (domain,)) + site_row = cur.fetchone() + site_id = site_row[0] if site_row else None + if site_id: + conn.execute("UPDATE sites SET ssl_enabled = 1, ssl_cert_path = ?, ssl_key_path = ? WHERE domain = ?", + (cert_path, key_path, domain)) + + # v1.3.34 修复:必须把证书插到 ssl_certs 表(前端列表才会显示) + info = get_cert_info(cert_path) + expire_date = info["expire_date"] if info else "" + cur2 = conn.execute("SELECT id FROM ssl_certs WHERE domain = ?", (domain,)) + existing = cur2.fetchone() + if existing: + conn.execute("UPDATE ssl_certs SET cert_path = ?, key_path = ?, expire_date = ?, auto_renew = 1, site_id = ? WHERE domain = ?", + (cert_path, key_path, expire_date, site_id, domain)) + else: + conn.execute("INSERT INTO ssl_certs (site_id, domain, cert_path, key_path, expire_date, auto_renew) VALUES (?, ?, ?, ?, ?, 1)", + (site_id, domain, cert_path, key_path, expire_date)) + conn.commit() + conn.close() + + return True, "HTTPS 已启用,到期 " + expire_date + +def check_certs_status(): + """ + 检查所有证书状态,返回统计信息 + """ + certs = get_all_certs() + expired = [] + expiring = [] + valid = [] + + for cert in certs: + info = get_cert_info(cert['cert_path']) + if info: + days = info['days_left'] + if days < 0: + expired.append({**cert, **info}) + elif days <= 7: + expiring.append({**cert, **info}) + else: + valid.append({**cert, **info}) + + return { + 'total': len(certs), + 'valid': len(valid), + 'expiring': len(expiring), + 'expired': len(expired), + 'expiring_list': expiring, + 'expired_list': expired, + } \ No newline at end of file diff --git a/tpanel-v1.3.34-pkg/backend/sync_pma_bridge.py b/tpanel-v1.3.34-pkg/backend/sync_pma_bridge.py new file mode 100755 index 0000000..d3e08d0 --- /dev/null +++ b/tpanel-v1.3.34-pkg/backend/sync_pma_bridge.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +""" +TPanel → phpMyAdmin 自动登录桥接同步脚本(v1.3.34) +当数据库 db_pass 修改后调用,把 secret_key + 所有 db 凭证写到 +/etc/phpmyadmin/conf.d/tpanel-bridge.json(PHP 端读) +""" +import json +import os +import sys +import sqlite3 +import subprocess +import datetime + +DB_PATH = '/opt/tpanel/data/tpanel.db' +BRIDGE_FILE = '/etc/phpmyadmin/conf.d/tpanel-bridge.json' +SECRET_FILE = '/opt/tpanel/data/.secret_key' + + +def sync_bridge(): + """同步所有数据库凭证到 bridge.json""" + if not os.path.exists(SECRET_FILE): + print('SECRET_KEY file missing', file=sys.stderr) + sys.exit(1) + with open(SECRET_FILE, 'r') as f: + secret_key = f.read().strip() + + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("SELECT id, name, db_user, db_pass FROM databases") + dbs = {} + for row in cur.fetchall(): + dbs[str(row[0])] = { + 'name': row[1], + 'user': row[2], + 'pass': row[3], + } + conn.close() + + payload = { + 'secret_key': secret_key, + 'dbs': dbs, + 'updated_at': datetime.datetime.now().isoformat(), + } + # 先写到 /tmp(可写),再 sudo mv + tmp = '/tmp/tpanel-bridge.json.tmp' + with open(tmp, 'w') as f: + json.dump(payload, f) + os.chmod(tmp, 0o644) + + r = subprocess.run(['sudo', 'mv', tmp, BRIDGE_FILE], capture_output=True, text=True) + if r.returncode != 0: + print(f'mv failed: {r.stderr}', file=sys.stderr) + sys.exit(1) + r = subprocess.run(['sudo', 'chmod', '644', BRIDGE_FILE], capture_output=True) + r = subprocess.run(['sudo', 'chown', 'www-data:www-data', BRIDGE_FILE], capture_output=True) + print(f'synced {len(dbs)} dbs to {BRIDGE_FILE}') + + +if __name__ == '__main__': + sync_bridge() \ No newline at end of file diff --git a/tpanel-v1.3.34-pkg/backend/system.py b/tpanel-v1.3.34-pkg/backend/system.py new file mode 100644 index 0000000..557ee9b --- /dev/null +++ b/tpanel-v1.3.34-pkg/backend/system.py @@ -0,0 +1,522 @@ +""" +TPanel - 系统操作模块 +仅使用白名单命令,禁止直接执行用户传入的原始 shell 字符串 +""" +import subprocess +import os +import shutil +import tarfile +import datetime +import time + +def _detect_pkg_manager(): + """检测系统包管理器""" + import shutil + for p in ['apt-get', 'yum', 'dnf']: + if shutil.which(p): + return p + return None + + +def _run(cmd, shell=False, capture=True, timeout=30): + """执行命令,超时保护""" + try: + if isinstance(cmd, str) and not shell: + cmd = cmd.split() + result = subprocess.run( + cmd, + capture_output=capture, + text=True, + timeout=timeout, + shell=shell + ) + return result.returncode, result.stdout.strip(), result.stderr.strip() + except subprocess.TimeoutExpired: + return -1, '', 'Command timed out' + except Exception as e: + return -1, '', str(e) + +def nginx_reload(): + return _run(['sudo', 'nginx', '-t']) + _run(['sudo', 'nginx', '-s', 'reload']) + +def nginx_stop(): + return _run(['sudo', 'nginx', '-s', 'stop']) + +def nginx_start(): + return _run(['sudo', 'nginx']) + +def nginx_status(): + code, out, _ = _run(['ps', 'aux'], capture=True) + running = 'nginx: master' in out + return running + +def mysql_status(): + # Debian 12 默认是 mariadb,CentOS 是 mysql + for svc in ['mariadb', 'mysql']: + code, out, _ = _run(['systemctl', 'is-active', svc], capture=True) + if code == 0: + return True + return False + return out == 'active' + +def create_site_user(username): + """创建 Linux 用户,禁 shell,隔离目录(v1.3.11+ 改用 sudo)""" + # 检查用户是否存在 + code, out, _ = _run(['id', username], capture=True) + if code == 0: + return True, '用户已存在' + + # 创建用户,home 目录即网站根目录,禁 shell + code, out, err = _run( + ['sudo', 'useradd', '-m', '-s', '/usr/sbin/nologin', '-d', f'/home/{username}', username] + ) + if code != 0: + return False, err + return True, '用户创建成功' + +def delete_site_user(username): + code, out, _ = _run(['id', username], capture=True) + if code != 0: + return True, '用户不存在,跳过' + + # 把用户的所有进程 kill 掉再删 + _run(['pkill', '-u', username], capture=True) + code, out, err = _run(['sudo', 'userdel', '-r', username]) + if code != 0: + return False, err + return True, '用户删除成功' + +def set_site_permissions(site_path, site_user): + """设置站点目录权限""" + _run(['sudo', 'chown', '-R', f'{site_user}:{site_user}', site_path]) + _run(['sudo', 'chmod', '-R', '755', site_path]) + _run(['sudo', 'chmod', '-R', '700', site_path + '/storage' if os.path.exists(site_path + '/storage') else site_path]) + +def get_php_fpm_port(php_version): + """ + v1.3.29: PHP 版本 → FPM 端口映射 + - 8.2 继续用 9000(向后兼容老 conf / install.sh 默认配置) + - 其他版本: 90 + 小数点后两位(7.4→9074, 8.0→9080, 8.1→9081, 8.3→9083, 8.4→9084) + - 带小数点的老版本(5.6→9056, 7.0→9070, 7.1→9071, 7.2→9072, 7.3→9073) + - 解析失败的 default: 9000 + """ + pv = (php_version or '').strip() + if pv == '8.2': + return 9000 + try: + parts = pv.split('.') + major = int(parts[0]) + minor = int(parts[1]) if len(parts) > 1 else 0 + return 9000 + major * 10 + minor + except Exception: + return 9000 + + +def write_nginx_config(domain, site_path, php_version='8.1', ssl=False, site_type='php'): + """写入 Nginx 配置 + v1.3.26 新增 site_type 参数: + - 'php'(默认):保留 PHP-FPM 反代 location + - 'static':不写 PHP-FPM 块(纯静态站点,不转发 *.php 到 FPM) + v1.3.29: PHP-FPM 端口随版本变化(多版本并存不冲突) + """ + # PHP-FPM 连接地址(v1.3.6+ 改用 TCP 避免 unix socket 问题,v1.3.29 起按版本分端口) + fpm_port = get_php_fpm_port(php_version) + fpm_sock = f'127.0.0.1:{fpm_port}' + + # index 顺序 + try_files fallback 随类型不同 + if site_type == 'static': + index_line = 'index index.html;' + try_files_line = 'try_files $uri $uri/ =404;' + php_block = '' # 静态站点完全不转发 .php + else: + index_line = 'index index.php index.html;' + try_files_line = 'try_files $uri $uri/ /index.php?$query_string;' + php_block = f''' + location ~ \\.php$ {{ + include fastcgi_params; + fastcgi_pass {fpm_sock}; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + }} +''' + + nginx_conf = f'''# TPanel - {domain} ({site_type}) +server {{ + listen 80; + server_name {domain}; + + root {site_path}; + {index_line} + + access_log /opt/tpanel/logs/{domain}.access.log; + error_log /opt/tpanel/logs/{domain}.error.log; + + location / {{ + {try_files_line} + }} +{php_block} + location ~ /\\.ht {{ + deny all; + }} +}} +''' + if ssl: + nginx_conf = nginx_conf.replace('listen 80;', '''listen 80; + listen 443 ssl http2;''', 1) + + conf_path = f'/etc/nginx/sites-available/{domain}.conf' + # v1.3.15+:tpanel 不可写 /etc/nginx,用 sudo tee(先写 /tmp 临时文件) + tmp_conf = f'/tmp/tpanel_nginx_{domain}.conf' + with open(tmp_conf, 'w') as f: + f.write(nginx_conf) + code, out, err = _run(['sudo', 'mv', tmp_conf, conf_path]) + if code != 0: + return False, f'写 conf 失败: {err}' + + # 启用站点(v1.3.15+:软链在 sites-enabled 也需 sudo) + enabled_path = f'/etc/nginx/sites-enabled/{domain}.conf' + if os.path.exists(enabled_path): + _run(['sudo', 'rm', '-f', enabled_path]) + _run(['sudo', 'ln', '-sf', conf_path, enabled_path]) + + code, out, err = _run(['sudo', 'nginx', '-t']) + if code != 0: + return False, err + + _run(['sudo', 'nginx', '-s', 'reload']) + return True, 'Nginx 配置已更新' + +def remove_nginx_config(domain): + """删除站点 Nginx 配置(v1.3.15+ 用 sudo 删)""" + conf_path = f'/etc/nginx/sites-available/{domain}.conf' + enabled_path = f'/etc/nginx/sites-enabled/{domain}.conf' + + if os.path.exists(enabled_path): + _run(['sudo', 'rm', '-f', enabled_path]) + if os.path.exists(conf_path): + _run(['sudo', 'rm', '-f', conf_path]) + + _run(['sudo', 'nginx', '-s', 'reload']) + +def create_mysql_db(name, db_user, db_pass): + """创建 MySQL 数据库和用户(用 sudo 提权,避免 shell 注入)""" + # 校验 name/user 不含特殊字符(防止 SQL 注入) + import re + if not re.match(r'^[a-zA-Z0-9_]+$', name) or not re.match(r'^[a-zA-Z0-9_]+$', db_user): + return False, '数据库名/用户名只能包含字母数字下划线' + + statements = [ + f"CREATE DATABASE IF NOT EXISTS `{name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;", + f"CREATE USER IF NOT EXISTS '{db_user}'@'localhost' IDENTIFIED BY '{db_pass}';", + f"GRANT ALL PRIVILEGES ON `{name}`.* TO '{db_user}'@'localhost';", + "FLUSH PRIVILEGES;", + ] + for stmt in statements: + code, out, err = _run(['sudo', 'mysql', '-e', stmt], shell=False) + if code != 0: + return False, err + return True, '数据库创建成功' + +def delete_mysql_db(name, db_user): + import re + if not re.match(r'^[a-zA-Z0-9_]+$', name) or not re.match(r'^[a-zA-Z0-9_]+$', db_user): + return False, '数据库名/用户名只能包含字母数字下划线' + statements = [ + f"DROP DATABASE IF EXISTS `{name}`;", + f"DROP USER IF EXISTS '{db_user}'@'localhost';", + "FLUSH PRIVILEGES;", + ] + for stmt in statements: + code, out, err = _run(['sudo', 'mysql', '-e', stmt], shell=False) + if code != 0: + return False, err + return True, '数据库删除成功' + +def get_mysql_size(): + """获取 MySQL 数据目录大小(MB)""" + code, out, _ = _run("du -sm /var/lib/mysql 2>/dev/null || echo 0", shell=True) + try: + return int(out.split()[0]) + except: + return 0 + +def backup_site(site_path, site_name, db_name=None, db_user=None, db_pass=None): + """备份站点文件和数据库""" + import traceback + timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') + # 清理站点名:ygbk.cn → ygbk.cn(保留点) + safe_name = site_name.replace('/', '_') + backup_name = f'{safe_name}_{timestamp}' + backup_path = f'/opt/tpanel/backups/{backup_name}.tar.gz' + + # v1.3.10 修复:预检环境 + try: + os.makedirs('/opt/tpanel/backups', exist_ok=True) + except Exception as e: + return False, f'无法创建 backups 目录: {e}', 0 + if not os.path.isdir(site_path): + return False, f'站点目录不存在: {site_path}', 0 + if not os.access(site_path, os.R_OK): + return False, f'tpanel 用户无法读取 {site_path}(chown 错了?ls -ld {site_path} 看看)', 0 + + try: + # 备份文件 + with tarfile.open(backup_path, 'w:gz') as tar: + tar.add(site_path, arcname=os.path.basename(site_path)) + + # 备份数据库(v1.3.10 修复:用 list 参数防注入 + sudo) + if db_name: + dump_path = f'/opt/tpanel/backups/{backup_name}_db.sql.gz' + try: + if db_user and db_pass: + code, out, err = _run( + ['sudo', 'mysqldump', '-u', db_user, f'-p{db_pass}', db_name], + shell=False, timeout=120 + ) + else: + code, out, err = _run(['sudo', 'mysqldump', db_name], shell=False, timeout=120) + if code == 0 and out: + import gzip + with open(dump_path, 'wb') as df: + df.write(gzip.compress(out.encode('utf-8') if isinstance(out, str) else out)) + with tarfile.open(backup_path, 'a:gz') as tar: + tar.add(dump_path, arcname='database.sql.gz') + os.remove(dump_path) + except Exception as e: + # 数据库备份失败不阻断(文件备份可能成功) + pass + + size = os.path.getsize(backup_path) + return True, backup_path, size + except PermissionError as e: + return False, f'权限错误: {e}(tpanel 读不到 {site_path},请 chown)', 0 + except Exception as e: + return False, f'备份异常: {type(e).__name__}: {e}\n{traceback.format_exc()[-300:]}', 0 + +def restore_backup(backup_path, site_path, site_name): + """恢复备份""" + try: + # v1.3.17+:先 sudo 删干净 site_path(因为可能有 root 拥有的文件,tpanel 删不掉) + # 用 sudo 替换为临时空目录,然后再解压 + backup_site_path = site_path + if os.path.exists(backup_site_path): + # 移动到 .bak 路径(sudo 移) + bak_path = backup_site_path + '.bak.' + str(int(time.time())) + code, _, err = _run(['sudo', 'mv', backup_site_path, bak_path]) + if code != 0: + return False, f'备份旧目录失败: {err}' + + # 解压到临时目录 + temp_dir = f'/opt/tpanel/backups/temp_{site_name}' + os.makedirs(temp_dir, exist_ok=True) + with tarfile.open(backup_path, 'r:gz') as tar: + tar.extractall(temp_dir) + + # 找到网站目录内容 + items = os.listdir(temp_dir) + src_dir = os.path.join(temp_dir, items[0]) if items else temp_dir + + # 把整个 src 目录 sudo mv 到 site_path + code, _, err = _run(['sudo', 'mv', src_dir, backup_site_path]) + if code != 0: + return False, f'恢复目录失败: {err}' + + # v1.3.17+:从 site_path 反推 site_user + # /opt/tpanel/sites/zhangpu_tech/public → zhangpu_tech + path_parts = backup_site_path.rstrip('/').split('/') + site_user = path_parts[-1] if path_parts else site_name + _run(['sudo', 'chown', '-R', f'{site_user}:{site_user}', backup_site_path]) + _run(['sudo', 'chmod', '-R', '755', backup_site_path]) + + shutil.rmtree(temp_dir, ignore_errors=True) + return True, '恢复成功' + except Exception as e: + return False, str(e) + +def run_security_update(): + """执行系统安全更新""" + code, out, err = _run(['sudo', 'apt-get', 'update'], timeout=120) + if code != 0: + return False, err + + # v1.3.20+:apt-get upgrade 也加 sudo(不然 Permission denied dpkg lock) + code, out, err = _run( + ['sudo', 'apt-get', 'upgrade', '-y', '--only-upgrade'], + timeout=300 + ) + if code == 0: + return True, f'安全更新完成' + else: + return False, err + +def get_security_status(): + """获取安全状态""" + # 可升级的安全包数量 + code, out, _ = _run( + "apt list --upgradable 2>/dev/null | grep -c security || echo 0", + shell=True + ) + try: + updatable = int(out.strip()) + except: + updatable = 0 + + # 最近的安全日志条数 + code2, out2, _ = _run( + "journalctl --since '1 day ago' --priority=err 2>/dev/null | wc -l", + shell=True + ) + try: + errors = int(out2.strip()) + except: + errors = 0 + + return {'upgradable_security_packages': updatable, 'recent_errors': errors} + +def get_system_stats(): + """获取系统状态""" + code, cpu_out, _ = _run("cat /proc/loadavg | awk '{print $1,$2,$3}'", shell=True) + code, mem_out, _ = _run("free -m | awk 'NR==2{print $3,$2}'", shell=True) + code, disk_out, _ = _run("df -h / | tail -1 | awk '{print $3,$4}'", shell=True) + code, cpu_pct, _ = _run("top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | sed 's/%us,//'", shell=True) + + # v1.3.10+ 新增:CPU 核心数 + 型号(用于仪表盘显示 + 负载颜色按核心数判断) + # v1.3.35 修复:容器/Docker 里 lscpu 无 "Model name" 行会导致 Unknown CPU + import os as _os + cpu_cores = _os.cpu_count() or 1 + cpu_model = '' + # 1. 优先 lscpu "Model name"(KVM/Xen 等虚拟化都正常) + code, lscpu_out, _ = _run("lscpu | grep 'Model name' | head -1", shell=True) + if code == 0 and lscpu_out and ':' in lscpu_out: + cpu_model = lscpu_out.split(':', 1)[1].strip() + # 2. 兑底:/proc/cpuinfo 的 model name(v1.3.35 修复:必传 shell=True) + if not cpu_model: + code, cpuinfo_out, _ = _run("grep -m1 'model name' /proc/cpuinfo", shell=True) + if code == 0 and cpuinfo_out and ':' in cpuinfo_out: + cpu_model = cpuinfo_out.split(':', 1)[1].strip() + # 3. 兑底:/proc/cpuinfo 拼 vendor + family + model(容器里 lscpu 可能无 Model name) + if not cpu_model: + try: + with open('/proc/cpuinfo', 'r') as f: + ci = f.read() + vendor = family = model_name = '' + for line in ci.splitlines(): + if line.startswith('vendor_id') and ':' in line and not vendor: + vendor = line.split(':', 1)[1].strip() + elif line.startswith('cpu family') and ':' in line and not family: + family = line.split(':', 1)[1].strip() + elif line.startswith('model name') and ':' in line and not model_name: + model_name = line.split(':', 1)[1].strip() + if model_name: break + if model_name: + cpu_model = model_name + elif vendor: + cpu_model = f'{vendor} CPU' + if family: cpu_model += f' (family {family})' + except Exception: + pass + # 4. 兑底:platform.processor()(老 Python 偶尔能拿到) + if not cpu_model: + try: + import platform + cpu_model = platform.processor() or '' + except Exception: + pass + # 5. 兑底:lscpu 看 Vendor ID + Model(某些云主机会输出这个) + if not cpu_model: + code, lscpu_v, _ = _run("lscpu | grep -E 'Vendor ID|Model:' | head -2", shell=True) + if code == 0 and lscpu_v: + parts = [] + for line in lscpu_v.strip().splitlines(): + if ':' in line: + parts.append(line.split(':', 1)[1].strip()) + if parts: + cpu_model = ' '.join(parts) + ' CPU' + if not cpu_model: + cpu_model = 'Unknown CPU' + + nginx_running = nginx_status() + mysql_running = mysql_status() + + return { + 'load': cpu_out, + 'cpu_pct': cpu_pct.strip() + '%' if cpu_pct else 'N/A', + 'cpu_cores': cpu_cores, + 'cpu_model': cpu_model, + 'mem_used_mb': mem_out.split()[0] if mem_out else '0', + 'mem_total_mb': mem_out.split()[1] if mem_out else '0', + 'disk_used': disk_out.split()[0] if disk_out else '0', + 'disk_free': disk_out.split()[1] if disk_out else '0', + 'nginx_running': nginx_running, + 'mysql_running': mysql_running, + } + +def write_log(event_type, details, ip=''): + """写安全日志""" + import sqlite3 + from config import DB_PATH + conn = sqlite3.connect(DB_PATH) + conn.execute("INSERT INTO security_logs (event_type, details, ip) VALUES (?, ?, ?)", + (event_type, details, ip)) + conn.commit() + conn.close() + + +def setup_php_fpm_listen(php_version): + """ + v1.3.29: 装完 PHP 后调用——设置 FPM listen 端口为版本专属端口,并启动服务 + - 写 /etc/php//fpm/pool.d/www.conf(备份原文件为 .bak) + - sudo systemctl enable --now php-fpm + 返回: (ok, msg) + """ + port = get_php_fpm_port(php_version) + www_conf = f'/etc/php/{php_version}/fpm/pool.d/www.conf' + if not os.path.exists(www_conf): + return False, f'找不到 {www_conf}(该版本未安装?)' + + # 备份(幂等:不重复备份) + bak = www_conf + '.tpanel.bak' + if not os.path.exists(bak): + code, _, err = _run(['sudo', 'cp', www_conf, bak]) + if code != 0: + return False, f'备份 {www_conf} 失败: {err}' + + # 修改 listen 行(用 sed 精准替换) + code, _, err = _run(['sudo', 'bash', '-c', + f"sed -i 's|^listen = .*|listen = 127.0.0.1:{port}|' {www_conf}"]) + if code != 0: + return False, f'修改 listen 失败: {err}' + + # 启用 + 启动 + code, _, err = _run(['sudo', 'systemctl', 'enable', f'php{php_version}-fpm']) + if code != 0: + return False, f'enable php{php_version}-fpm 失败: {err}' + + code, out, err = _run(['sudo', 'systemctl', 'restart', f'php{php_version}-fpm']) + if code != 0: + return False, f'restart php{php_version}-fpm 失败: {err}' + + # 验证在监听 + code, out, _ = _run(['sudo', 'ss', '-lntp']) + listening = f'127.0.0.1:{port}' in out + if not listening: + return False, f'php{php_version}-fpm 未在 127.0.0.1:{port} 监听(可能启动失败)' + + return True, f'php{php_version}-fpm 已配置 listen 127.0.0.1:{port} 并启动' + +def change_db_password(db_user, new_pass): + """修改 MySQL 数据库用户密码(v1.3.34+)""" + import re + if not re.match(r"^[a-zA-Z0-9_]+$", db_user): + return False, "用户名只能包含字母数字下划线" + if not new_pass or len(new_pass) < 6: + return False, "密码至少 6 位" + escaped_pass = new_pass.replace("'", "''") + stmt = "ALTER USER '" + db_user + "'@'localhost' IDENTIFIED BY '" + escaped_pass + "';" + code, out, err = _run(["sudo", "mysql", "-e", stmt], shell=False) + if code != 0: + return False, err + code, _, err = _run(["sudo", "mysql", "-e", "FLUSH PRIVILEGES;"], shell=False) + if code != 0: + return False, err + return True, "密码修改成功" diff --git a/tpanel-v1.3.34-pkg/backend/task_manager.py b/tpanel-v1.3.34-pkg/backend/task_manager.py new file mode 100644 index 0000000..384a542 --- /dev/null +++ b/tpanel-v1.3.34-pkg/backend/task_manager.py @@ -0,0 +1,426 @@ +""" +TPanel - 任务管理器 +用于软件安装、安全更新等长任务的执行 + 实时进度推送 +""" +import sqlite3 +import subprocess +import threading +import time +import os +import json +import re +import shutil +from datetime import datetime +from config import DB_PATH + + +def _detect_pkg_manager(): + """检测系统包管理器(apt/yum/dnf)""" + for p in ['apt-get', 'yum', 'dnf']: + if shutil.which(p): + return p + return None + + +def get_apt_cmd(): + """获取系统包管理器 + sudo""" + pkg = _detect_pkg_manager() + if pkg == 'apt-get': + return ['sudo', 'apt-get', '-y'] + elif pkg == 'yum': + return ['sudo', 'yum', '-y'] + elif pkg == 'dnf': + return ['sudo', 'dnf', '-y'] + else: + raise Exception('不支持的包管理器') + + + + + +def _short_version(v): + '''把 debian '7.0.33-89+0~20260514.116+debian12~1.gbpfef6bb' 短化成 '7.0.33' + - 剥 epoch (4:) + - 取 主版本号 (数字.数字.数字) + - 失败返回原值 + ''' + if not v: + return None + v = re.sub(r"^\d+:", "", v) + m = re.match(r"(\d+\.\d+\.\d+)", v) + return m.group(1) if m else v + +def init_software_table(): + """初始化软件列表(幂等)""" + pkg = _detect_pkg_manager() + is_deb = pkg == 'apt-get' + + # 软件白名单:name / 显示名 / 分类 / apt 包名(多个用逗号) + catalog = [ + ('php5.6', 'PHP 5.6', 'PHP', + 'php5.6-fpm,php5.6-cli,php5.6-mysql,php5.6-curl,php5.6-mbstring,php5.6-xml,php5.6-zip,php5.6-gd' + if is_deb else 'php56-php-fpm,php56-php-cli,php56-php-mysqlnd'), + ('php7.0', 'PHP 7.0', 'PHP', + 'php7.0-fpm,php7.0-cli,php7.0-mysql,php7.0-curl,php7.0-mbstring,php7.0-xml,php7.0-zip,php7.0-gd' + if is_deb else 'php70-php-fpm,php70-php-cli,php70-php-mysqlnd'), + ('php7.4', 'PHP 7.4', 'PHP', + 'php7.4-fpm,php7.4-cli,php7.4-mysql,php7.4-curl,php7.4-mbstring,php7.4-xml,php7.4-zip,php7.4-gd' + if is_deb else 'php74-php-fpm,php74-php-cli,php74-php-mysqlnd'), + ('php8.0', 'PHP 8.0', 'PHP', + 'php8.0-fpm,php8.0-cli,php8.0-mysql,php8.0-curl,php8.0-mbstring,php8.0-xml,php8.0-zip,php8.0-gd' + if is_deb else 'php80-php-fpm,php80-php-cli,php80-php-mysqlnd'), + ('php8.1', 'PHP 8.1', 'PHP', + 'php8.1-fpm,php8.1-cli,php8.1-mysql,php8.1-curl,php8.1-mbstring,php8.1-xml,php8.1-zip,php8.1-gd' + if is_deb else 'php81-php-fpm,php81-php-cli,php81-php-mysqlnd'), + ('php8.2', 'PHP 8.2', 'PHP', + 'php8.2-fpm,php8.2-cli,php8.2-mysql,php8.2-curl,php8.2-mbstring,php8.2-xml,php8.2-zip,php8.2-gd' + if is_deb else 'php82-php-fpm,php82-php-cli,php82-php-mysqlnd'), + ('php8.3', 'PHP 8.3', 'PHP', + 'php8.3-fpm,php8.3-cli,php8.3-mysql,php8.3-curl,php8.3-mbstring,php8.3-xml,php8.3-zip,php8.3-gd' + if is_deb else 'php83-php-fpm,php83-php-cli,php83-php-mysqlnd'), + # v1.3.29: 补上 PHP 8.4(Sury 源已支持) + ('php8.4', 'PHP 8.4', 'PHP', + 'php8.4-fpm,php8.4-cli,php8.4-mysql,php8.4-curl,php8.4-mbstring,php8.4-xml,php8.4-zip,php8.4-gd' + if is_deb else 'php84-php-fpm,php84-php-cli,php84-php-mysqlnd'), + ('phpmyadmin', 'phpMyAdmin', '数据库', 'phpmyadmin' if is_deb else 'phpMyAdmin'), + ] + + conn = sqlite3.connect(DB_PATH) + for name, display, cat, pkgs in catalog: + # 探测实际安装状态 + installed = 0 + version = None + first_pkg = pkgs.split(',')[0].split('/')[0] + if is_deb: + r = os.system(f'dpkg -s {first_pkg} >/dev/null 2>&1') + if r == 0: + installed = 1 + # 拿版本 + try: + v = subprocess.check_output( + ['dpkg-query', '-f=${Version}', '-W', first_pkg], + stderr=subprocess.DEVNULL, timeout=5 + ).decode().strip() + version = _short_version(v) if v else None + except Exception: + pass + else: + r = os.system(f'rpm -q {first_pkg} >/dev/null 2>&1') + if r == 0: + installed = 1 + try: + v = subprocess.check_output( + ['rpm', '-q', '--queryformat', '%{VERSION}', first_pkg], + stderr=subprocess.DEVNULL, timeout=5 + ).decode().strip() + version = _short_version(v) if v else None + except Exception: + pass + + # 已有则更新状态(不覆盖显示名等) + row = conn.execute("SELECT name FROM software WHERE name = ?", (name,)).fetchone() + if row: + conn.execute("""UPDATE software SET installed = ?, version = ?, last_check = ? + WHERE name = ?""", + (installed, version, datetime.now().isoformat(), name)) + else: + conn.execute("""INSERT INTO software (name, display_name, category, installed, version, last_check) + VALUES (?, ?, ?, ?, ?, ?)""", + (name, display, cat, installed, version, datetime.now().isoformat())) + conn.commit() + conn.close() + + +def list_software(): + """列出所有软件 + 状态""" + init_software_table() + conn = sqlite3.connect(DB_PATH) + rows = conn.execute("""SELECT name, display_name, category, installed, version, last_install + FROM software ORDER BY category, name""").fetchall() + conn.close() + return [{ + 'name': r[0], 'display_name': r[1], 'category': r[2], + 'installed': bool(r[3]), 'version': r[4], 'last_install': r[5] + } for r in rows] + + +def get_software(name): + """获取单个软件信息""" + conn = sqlite3.connect(DB_PATH) + row = conn.execute("""SELECT name, display_name, category, installed, version, last_install + FROM software WHERE name = ?""", (name,)).fetchone() + conn.close() + if not row: + return None + return { + 'name': row[0], 'display_name': row[1], 'category': row[2], + 'installed': bool(row[3]), 'version': row[4], 'last_install': row[5] + } + + +def get_apt_packages(name): + """从软件名反查 apt 包列表""" + init_software_table() + conn = sqlite3.connect(DB_PATH) + row = conn.execute("SELECT name FROM software WHERE name = ?", (name,)).fetchone() + conn.close() + if not row: + return None + # 直接从 catalog 重算(不存包名到 DB,因为跨系统不一样) + pkg = _detect_pkg_manager() + is_deb = pkg == 'apt-get' + catalog = { + 'php5.6': 'php5.6-fpm,php5.6-cli,php5.6-mysql,php5.6-curl,php5.6-mbstring,php5.6-xml,php5.6-zip,php5.6-gd' if is_deb else 'php56-php-fpm,php56-php-cli', + 'php7.0': 'php7.0-fpm,php7.0-cli,php7.0-mysql,php7.0-curl,php7.0-mbstring,php7.0-xml,php7.0-zip,php7.0-gd' if is_deb else 'php70-php-fpm,php70-php-cli', + 'php7.4': 'php7.4-fpm,php7.4-cli,php7.4-mysql,php7.4-curl,php7.4-mbstring,php7.4-xml,php7.4-zip,php7.4-gd' if is_deb else 'php74-php-fpm,php74-php-cli', + 'php8.0': 'php8.0-fpm,php8.0-cli,php8.0-mysql,php8.0-curl,php8.0-mbstring,php8.0-xml,php8.0-zip,php8.0-gd' if is_deb else 'php80-php-fpm,php80-php-cli', + 'php8.1': 'php8.1-fpm,php8.1-cli,php8.1-mysql,php8.1-curl,php8.1-mbstring,php8.1-xml,php8.1-zip,php8.1-gd' if is_deb else 'php81-php-fpm,php81-php-cli', + 'php8.2': 'php8.2-fpm,php8.2-cli,php8.2-mysql,php8.2-curl,php8.2-mbstring,php8.2-xml,php8.2-zip,php8.2-gd' if is_deb else 'php82-php-fpm,php82-php-cli', + 'php8.3': 'php8.3-fpm,php8.3-cli,php8.3-mysql,php8.3-curl,php8.3-mbstring,php8.3-xml,php8.3-zip,php8.3-gd' if is_deb else 'php83-php-fpm,php83-php-cli', + 'php8.4': 'php8.4-fpm,php8.4-cli,php8.4-mysql,php8.4-curl,php8.4-mbstring,php8.4-xml,php8.4-zip,php8.4-gd' if is_deb else 'php84-php-fpm,php84-php-cli', + 'phpmyadmin': 'phpmyadmin' if is_deb else 'phpMyAdmin', + } + return catalog.get(name) + + +def setup_phpmyadmin_nginx(task_id=None): + """phpMyAdmin 装完后自动配置 Nginx 8443 反代(v1.3.10 新增) + + 写 /etc/nginx/sites-enabled/phpmyadmin.conf + nginx -t + reload + 失败时把错误追加到任务日志(如果有 task_id) + """ + # 1. 找 phpMyAdmin 实际路径(Debian/Ubuntu 装完默认在这里) + candidates = ['/usr/share/phpmyadmin', '/usr/share/phpmyadmin/htdocs'] + pma_dir = None + for c in candidates: + if os.path.isdir(c) and os.path.exists(os.path.join(c, 'index.php')): + pma_dir = c + break + if not pma_dir: + msg = 'setup_phpmyadmin_nginx: 找不到 phpMyAdmin 目录(/usr/share/phpmyadmin 不存在)' + print(f'[TPanel] {msg}', flush=True) + if task_id: + conn = sqlite3.connect(DB_PATH) + conn.execute("UPDATE tasks SET log = log || ? WHERE id = ?", + (f'\n\n{msg}', task_id)) + conn.commit() + conn.close() + return False + + # 2. 写 Nginx 配置文件 + conf = f"""# TPanel phpMyAdmin 反代配置(v1.3.10 自动写入) +# 管理命令:sudo nginx -t && sudo systemctl reload nginx +server {{ + listen 8443 default_server; + listen [::]:8443 default_server; + server_name _; + + root {pma_dir}; + index index.php index.html; + + access_log /var/log/nginx/phpmyadmin.access.log; + error_log /var/log/nginx/phpmyadmin.error.log; + + # 安全加固:屏蔽 phpMyAdmin 已知信息泄露路径 + location ~* /(libraries|setup/frames|sql) {{ + deny all; + return 403; + }} + + location / {{ + try_files $uri $uri/ /index.php?$args; + }} + + location ~ \.php$ {{ + include fastcgi_params; + fastcgi_pass 127.0.0.1:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_read_timeout 300; + }} +}} +""" + conf_path = '/etc/nginx/sites-enabled/phpmyadmin.conf' + try: + # 写文件用 sudo(tpanel 用户没权限写 /etc/nginx) + with open('/tmp/phpmyadmin.conf.tmp', 'w') as f: + f.write(conf) + r = subprocess.run(['sudo', 'mv', '/tmp/phpmyadmin.conf.tmp', conf_path], + capture_output=True, text=True, timeout=10) + if r.returncode != 0: + raise Exception(f'sudo mv 失败: {r.stderr.strip()}') + except Exception as e: + msg = f'setup_phpmyadmin_nginx: 写 {conf_path} 失败: {e}' + print(f'[TPanel] {msg}', flush=True) + if task_id: + conn = sqlite3.connect(DB_PATH) + conn.execute("UPDATE tasks SET log = log || ? WHERE id = ?", + (f'\n\n{msg}', task_id)) + conn.commit() + conn.close() + return False + + # 3. nginx -t 验证 + r = subprocess.run(['sudo', 'nginx', '-t'], capture_output=True, text=True, timeout=10) + if r.returncode != 0: + msg = f'setup_phpmyadmin_nginx: nginx -t 失败:\n{r.stderr.strip()}' + print(f'[TPanel] {msg}', flush=True) + if task_id: + conn = sqlite3.connect(DB_PATH) + conn.execute("UPDATE tasks SET log = log || ? WHERE id = ?", + (f'\n\n{msg}', task_id)) + conn.commit() + conn.close() + return False + + # 4. reload nginx + r = subprocess.run(['sudo', 'systemctl', 'reload', 'nginx'], + capture_output=True, text=True, timeout=10) + if r.returncode != 0: + # reload 失败就 try restart + r2 = subprocess.run(['sudo', 'systemctl', 'restart', 'nginx'], + capture_output=True, text=True, timeout=10) + if r2.returncode != 0: + msg = f'setup_phpmyadmin_nginx: nginx reload/restart 失败: {r2.stderr.strip()}' + print(f'[TPanel] {msg}', flush=True) + if task_id: + conn = sqlite3.connect(DB_PATH) + conn.execute("UPDATE tasks SET log = log || ? WHERE id = ?", + (f'\n\n{msg}', task_id)) + conn.commit() + conn.close() + return False + + # 5. 确认 8443 端口没被占 + r = subprocess.run(['sudo', 'ss', '-tlnp'], capture_output=True, text=True, timeout=5) + if ':8443' not in r.stdout: + msg = 'setup_phpmyadmin_nginx: 警告 - 8443 端口没在监听' + print(f'[TPanel] {msg}', flush=True) + # 不算失败,配置已写入 + + success_msg = f'setup_phpmyadmin_nginx: 成功 - {conf_path} 已写入,nginx 已 reload' + print(f'[TPanel] {success_msg}', flush=True) + if task_id: + conn = sqlite3.connect(DB_PATH) + conn.execute("UPDATE tasks SET log = log || ? WHERE id = ?", + (f'\n\n{success_msg}', task_id)) + conn.commit() + conn.close() + return True + + +def create_task(task_type, target, cmd, on_complete=None): + """创建任务 + 启动后台进程 + + on_complete(v1.3.10 新增):可选回调函数,签名 on_complete(task_id, status) + 在任务结束(success/failed)后、software 表更新后调用。 + 用于实现"装完 X 自动配 Y"这种联动。 + """ + conn = sqlite3.connect(DB_PATH) + cur = conn.execute("INSERT INTO tasks (type, target, status) VALUES (?, ?, 'running')", + (task_type, target)) + task_id = cur.lastrowid + conn.commit() + conn.close() + + def _run(): + try: + proc = subprocess.Popen( + cmd, shell=False, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, bufsize=1 + ) + log_buffer = [] + for line in iter(proc.stdout.readline, ''): + line = line.rstrip() + log_buffer.append(line) + # 写最新 200 行到 DB + conn = sqlite3.connect(DB_PATH) + conn.execute("UPDATE tasks SET log = ? WHERE id = ?", + ('\n'.join(log_buffer[-200:]), task_id)) + conn.commit() + conn.close() + proc.wait() + status = 'success' if proc.returncode == 0 else 'failed' + except Exception as e: + status = 'failed' + conn = sqlite3.connect(DB_PATH) + conn.execute("UPDATE tasks SET log = log || ? WHERE id = ?", + (f'\n\nERROR: {e}', task_id)) + conn.commit() + conn.close() + # on_complete 也要在异常路径上调用(status='failed') + if on_complete: + try: + on_complete(task_id, 'failed') + except Exception as e2: + print(f'[TPanel] on_complete 异常: {e2}', flush=True) + return + + conn = sqlite3.connect(DB_PATH) + conn.execute("UPDATE tasks SET status = ?, exit_code = ?, finished_at = ? WHERE id = ?", + (status, proc.returncode, datetime.now().isoformat(), task_id)) + conn.commit() + conn.close() + # 安装成功:更新 software 表 + if status == 'success' and task_type == 'software_install': + conn = sqlite3.connect(DB_PATH) + conn.execute("UPDATE software SET installed = 1, last_install = ? WHERE name = ?", + (datetime.now().isoformat(), target)) + conn.commit() + conn.close() + + # on_complete 钩子(v1.3.10):success/failed 后都调,让钩子自己判断 + if on_complete: + try: + on_complete(task_id, status) + except Exception as e: + conn = sqlite3.connect(DB_PATH) + conn.execute("UPDATE tasks SET log = log || ? WHERE id = ?", + (f'\n\non_complete 异常: {e}', task_id)) + conn.commit() + conn.close() + + t = threading.Thread(target=_run, daemon=True) + t.start() + return task_id + + +def get_task(task_id): + """获取任务状态 + 日志""" + conn = sqlite3.connect(DB_PATH) + row = conn.execute("""SELECT id, type, target, status, log, started_at, finished_at, exit_code + FROM tasks WHERE id = ?""", (task_id,)).fetchone() + conn.close() + if not row: + return None + return { + 'id': row[0], 'type': row[1], 'target': row[2], 'status': row[3], + 'log': row[4] or '', 'started_at': row[5], 'finished_at': row[6], + 'exit_code': row[7] + } + + +def get_running_task_by_type(task_type, target=None): + """获取正在运行的同类型任务(防并发)""" + conn = sqlite3.connect(DB_PATH) + if target is not None: + row = conn.execute("""SELECT id FROM tasks + WHERE type = ? AND target = ? AND status = 'running'""", + (task_type, target)).fetchone() + else: + row = conn.execute("""SELECT id FROM tasks + WHERE type = ? AND status = 'running'""", + (task_type,)).fetchone() + conn.close() + return row[0] if row else None + + +def cleanup_old_tasks(days=7): + """清理 N 天前的已完成任务""" + conn = sqlite3.connect(DB_PATH) + conn.execute("""DELETE FROM tasks + WHERE status != 'running' + AND finished_at < datetime('now', ?)""", + (f'-{days} days',)) + conn.commit() + conn.close() diff --git a/tpanel-v1.3.34-pkg/frontend/index.html b/tpanel-v1.3.34-pkg/frontend/index.html new file mode 100644 index 0000000..6d860be --- /dev/null +++ b/tpanel-v1.3.34-pkg/frontend/index.html @@ -0,0 +1,3120 @@ + + + + + +T面板 - Linux 网站管理面板 + + + + + + + + + + +
+ + + + + + + + +
+
+
+ +

仪表盘

+
+
+
+
+ 系统正常 +
+ 官方网站 + + +
+
+ +
+ + +
+
+
+
📁 网站总数
+
-
+
个站点
+
+
+
🗄️ 数据库
+
-
+
个数据库
+
+
+
💾 备份状态
+
-
+
份备份
+
+
+
🔐 安全更新
+
-
+
个待安装
+
+
+ +
+
🛡️
+
+
安全状态:已启用自动更新
+
系统每日 03:00 自动执行安全更新
+
+ +
+ +
+
+
+ ⚡ 服务状态 +
+
+
+
+ 🌿 +
+
Nginx
+
Web 服务器
+
+
+ 运行中 +
+
+
+ 🐬 +
+
MySQL
+
数据库服务
+
+
+ 运行中 +
+
+
+ 🐘 +
+
PHP-FPM
+
PHP 解释器
+
+
+ 运行中 +
+
+
+ 🛡️ +
+
UFW 防火墙
+
端口防护
+
+
+ 已启用 +
+
+
+ +
+
+ 💻 系统资源 +
+
+
+
+
CPU 负载
+
+ - +
+
+
+
CPU 核心数
+
+ - +
+
+
+
CPU 型号
+
+ - +
+
+
+
内存
+
+ - +
+
+
+
磁盘
+
+ - +
+
+
+
负载均值
+
+ - +
+
+
+
+
+ + +
+
+
+

网站列表

+ +
+
+ + + + + + + + + + + + + +
域名PHP路径状态SSL创建时间操作
+
+
+ + +
+
+
+

数据库

+ +
+
+ + + + + + + + + + + + +
数据库名用户密码字符集创建时间操作
+
+
+ + +
+
+
+

SSL 证书

+ +
+
+ + + + + + + + + + + +
域名证书路径到期日期自动续期操作
+
+
+ + +
+
+
+

备份记录

+ +
+
+ + + + + + + + + + + + +
站点类型文件大小状态时间操作
+
+
+ + +
+
+
+

安全中心

+ +
+
+
+
🛡️ 安全更新
+
-
+
个可用更新
+
+
+
❌ 近期错误
+
-
+
条系统错误
+
+
+
🔒 防火墙
+
-
+
状态
+
+
+
+
+ 🛡️ 自动漏洞修复 + +
+
+

T面板每日凌晨 03:00 自动执行 apt-get update && apt-get upgrade -y,修复已知安全漏洞。

+

最近执行:-

+
+
+
+ + + +
+
+
+

📁 文件管理

+
+ + + + +
+
+ + +
+ / +
+ + +
+ + + + + + + + + + + +
名称大小修改时间权限操作
+
+ + + + + + + + + + + + +
+ + + +
+
+
+

⏰ 定时任务

+ +
+
+ + + + + + + + + + + + + +
任务名称站点执行周期命令状态上次执行操作
+
+ + + +
+ + +
+
+
+

📦 软件市场

+
+ + +
+
+

+ 一键安装建站必备环境。安装过程中会显示实时进度。 +

+
+
加载中...
+
+
+ + +
+
+ +
+

👤 修改管理员密码

+
+
+ + +
+
+ + +
+
+ +
+ +
+

⚙️ 系统设置

+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ +
+

🔗 面板域名绑定

+
+ + + + 绑定后只能通过该域名访问后台(如留空则不限制访问来源)。绑定后请确保 DNS 已解析到此服务器。 + +
+
+ +
+ ⚠️ 修改域名绑定后需重启面板服务:systemctl restart tpanel +
+
+ +
+

🌐 官方信息

+
+
+ + +
+
+ + +
+
+
+
+ + +
+

📋 安全日志

+
+
+
+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tpanel-v1.3.34-pkg/nginx/tpanel-https-with-pma.conf b/tpanel-v1.3.34-pkg/nginx/tpanel-https-with-pma.conf new file mode 100644 index 0000000..ea8a4d9 --- /dev/null +++ b/tpanel-v1.3.34-pkg/nginx/tpanel-https-with-pma.conf @@ -0,0 +1,56 @@ +server { + server_name zhangpu.tech; + client_max_body_size 100M; + location /static/ { alias /opt/tpanel/frontend/; } + # SSE 任务进度流(v1.3.12 修复"连接断开"):默认 proxy_read_timeout 60s 会主动断 + location ~ ^/api/tasks/[0-9]+/stream$ { + proxy_pass http://127.0.0.1:8888; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 1800s; + proxy_send_timeout 1800s; + proxy_buffering off; + proxy_cache off; + add_header X-Accel-Buffering no; + } + # v1.3.34+: phpMyAdmin 通过 /pma/ 路径访问(同域名 SSL,免8443端口) + # 用 rewrite 把 /pma/X 改成内部 /pma/X 然后 alias 指向 PMA 根目录 + location /pma/ { + # v1.3.34 修复:用 alias + 不带 rewrite(alias 与 rewrite 互斥) + # nginx 会自动把 /pma/X 映射到 /usr/share/phpmyadmin/X + alias /usr/share/phpmyadmin/; + index index.php; + # 安全加固 + location ~ ^/pma/(libraries|setup/frames|sql) { deny all; return 403; } + # PHP 处理 + location ~ \.php$ { + include fastcgi_params; + fastcgi_pass 127.0.0.1:9000; + fastcgi_index index.php; + # 注意:$request_filename 已经包含 alias 解析后的真实路径 + fastcgi_param SCRIPT_FILENAME $request_filename; + fastcgi_read_timeout 300; + } + } + location / { + proxy_pass http://127.0.0.1:8888; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + listen 443 ssl; + ssl_certificate /etc/letsencrypt/live/zhangpu.tech/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/zhangpu.tech/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; +} +server { + if ($host = zhangpu.tech) { return 301 https://$host$request_uri; } + listen 80; + server_name zhangpu.tech; + return 404; +} diff --git a/tpanel-v1.3.34-pkg/nginx/update-nginx.sh b/tpanel-v1.3.34-pkg/nginx/update-nginx.sh new file mode 100755 index 0000000..1b41a46 --- /dev/null +++ b/tpanel-v1.3.34-pkg/nginx/update-nginx.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# v1.3.34: 升级 nginx 配置加 /pma/ 路径(用于 phpMyAdmin 自动登录) +# 同时移除 8443 独立 server block(避免 SSL 错误) +# 用法: sudo bash update-nginx.sh +set -e + +echo "[1/4] 备份当前配置..." +sudo cp /etc/nginx/sites-enabled/tpanel /etc/nginx/sites-enabled/tpanel.bak-v1334-$(date +%s) + +echo "[2/4] 替换 /etc/nginx/sites-enabled/tpanel..." +sudo cp tpanel-https-with-pma.conf /etc/nginx/sites-enabled/tpanel + +# 也删掉旧的 8443 server(避免 SSL 问题) +if [ -f /etc/nginx/sites-enabled/phpmyadmin.conf ]; then + sudo rm /etc/nginx/sites-enabled/phpmyadmin.conf + echo " 移除了旧的 /etc/nginx/sites-enabled/phpmyadmin.conf" +fi + +echo "[3/4] 部署 phpMyAdmin 桥接脚本..." +if [ ! -f php-bridge/tpanel-bridge.php ]; then + echo "ERROR: php-bridge/tpanel-bridge.php 不存在" + exit 1 +fi +sudo cp php-bridge/tpanel-bridge.php /usr/share/phpmyadmin/ +sudo chown www-data:www-data /usr/share/phpmyadmin/tpanel-bridge.php +sudo cp php-bridge/tpanel-signon.php /etc/phpmyadmin/conf.d/ +php -l /etc/phpmyadmin/conf.d/tpanel-signon.php + +echo "[4/4] nginx -t + reload..." +sudo nginx -t && sudo systemctl reload nginx +echo "" +echo "✅ v1.3.34 /pma/ 路径已生效!" +echo "测试: curl -sI https://你的域名/pma/ 应该返回 302 或 200" \ No newline at end of file diff --git a/tpanel-v1.3.34-pkg/php-bridge/README.md b/tpanel-v1.3.34-pkg/php-bridge/README.md new file mode 100644 index 0000000..3980f3c --- /dev/null +++ b/tpanel-v1.3.34-pkg/php-bridge/README.md @@ -0,0 +1,35 @@ +# TPanel phpMyAdmin 自动登录桥接(v1.3.34+) + +## 文件说明 + +| 文件 | 安装到 | 用途 | +|------|--------|------| +| `tpanel-bridge.php` | `/usr/share/phpmyadmin/tpanel-bridge.php` | Signon 端点:验 token + 启动 PHP session + 302 回 phpMyAdmin | +| `tpanel-signon.php` | `/etc/phpmyadmin/conf.d/tpanel-signon.php` | phpMyAdmin 配置:auth_type=signon + SignonSession=TPanelSignon + SignonURL 指向 bridge | + +## 部署时机 + +由 `task_manager.setup_phpmyadmin_nginx` 在 phpMyAdmin 安装完成后自动部署。 + +不需要用户手动操作。 + +## 工作流程 + +``` +点 db_name + ↓ +前端 GET /api/phpmyadmin/token/ + ↓ +后端签 5 分钟有效 HMAC token + ↓ +window.open("/api/phpmyadmin/signon?token=xxx&db=1") + ↓ +后端 302 到 /tpanel-bridge.php?token=xxx&db=1 + ↓ +PHP bridge 验 token + 查 bridge.json 拿 db_user/db_pass + ↓ +session_start() + 设置 $_SESSION[PMA_single_signon_*] + ↓ +302 到 phpMyAdmin (自动登录) +``` + diff --git a/tpanel-v1.3.34-pkg/php-bridge/tpanel-bridge.php b/tpanel-v1.3.34-pkg/php-bridge/tpanel-bridge.php new file mode 100644 index 0000000..b624167 --- /dev/null +++ b/tpanel-v1.3.34-pkg/php-bridge/tpanel-bridge.php @@ -0,0 +1,114 @@ + & db= + * 2. 验证 token(用 TPanel SECRET_KEY 同样的 HMAC 算法) + * 3. 从共享 JSON 文件拿 db_user / db_pass(TPanel 后端写,PHP 读) + * 4. session_start() + 设置 PMA_single_signon_* + 302 回 phpMyAdmin + * + * 安全: + * - SECRET_FILE 由 TPanel 后端 0600 tpanel:tpanel 拥有 + * - 本脚本以 www-data 运行,需 sudo-less 读 tpanel.data 文件 + * - 改用:把 secrets 写到 /etc/phpmyadmin/conf.d/tpanel-bridge.json 让 PHP 读 + */ + +declare(strict_types=1); + +// 不显示 warning(生产友好) +error_reporting(E_ERROR | E_PARSE); + +// 1. 读参数 +$token = $_GET['token'] ?? ''; +$db_id = $_GET['db'] ?? ''; +if ($token === '' || $db_id === '' || !ctype_digit((string)$db_id)) { + http_response_code(400); + echo 'Missing token or db'; + exit; +} + +// 2. 验证 token - 用 HMAC-SHA256,secret 从 bridge.json 读 +$bridge_cfg = '/etc/phpmyadmin/conf.d/tpanel-bridge.json'; +if (!file_exists($bridge_cfg)) { + http_response_code(500); + echo 'Bridge not configured'; + exit; +} +$cfg = json_decode(file_get_contents($bridge_cfg), true); +if (!is_array($cfg) || !isset($cfg['secret_key'])) { + http_response_code(500); + echo 'Bridge misconfigured'; + exit; +} +$secret = $cfg['secret_key']; + +// Token 格式: . (base64 + base64 padding 都保留) +$parts = explode('.', $token); +if (count($parts) !== 2) { + http_response_code(400); + echo 'Bad token'; + exit; +} +[$payload_b64, $sig_b64] = $parts; + +$expected = hash_hmac('sha256', $payload_b64, $secret); +$expected_b64 = rtrim(strtr(base64_encode(hex2bin($expected)), '+/', '-_'), '='); +// 补回 base64 padding(v1.3.34 修复:Python 签时带 padding,PHP 验时不 rstrip) +$pad = strlen($expected_b64) % 4; +if ($pad) { $expected_b64 .= str_repeat('=', 4 - $pad); } +if (!hash_equals($expected_b64, $sig_b64)) { + http_response_code(403); + echo 'Invalid token signature'; + exit; +} + +// 解码 payload(payload 自己也可能带 padding) +$payload_b64_padded = $payload_b64 . str_repeat('=', (-strlen($payload_b64)) % 4); +$payload_json = base64_decode(strtr($payload_b64_padded, '-_', '+/'), true); +if ($payload_json === false) { + http_response_code(400); + echo 'Bad payload'; + exit; +} +$payload = json_decode($payload_json, true); +if (!is_array($payload) || !isset($payload['db_id'], $payload['exp'])) { + http_response_code(400); + echo 'Bad payload fields'; + exit; +} +if ((int)$payload['db_id'] !== (int)$db_id) { + http_response_code(403); + echo 'DB id mismatch'; + exit; +} +if ((int)$payload['exp'] < time()) { + http_response_code(403); + echo 'Token expired'; + exit; +} + +// 3. 拿 db_user / db_pass - 从 bridge.json 里读(TPanel 后端更新它) +if (!isset($cfg['dbs'][$db_id])) { + http_response_code(404); + echo 'DB not in bridge'; + exit; +} +$db = $cfg['dbs'][$db_id]; + +// 4. 启动 PHP session,配置 session 名(与 conf.d/tpanel-signon.php 一致) +session_name('TPanelSignon'); +session_start(); + +$_SESSION['PMA_single_signon_user'] = $db['user']; +$_SESSION['PMA_single_signon_password'] = $db['pass']; +$_SESSION['PMA_single_signon_host'] = '127.0.0.1'; +$_SESSION['PMA_single_signon_port'] = ''; +$_SESSION['PMA_single_signon_socket'] = ''; +$_SESSION['PMA_single_signon_auth_type'] = 'config'; + +// 关 session + 302 回 phpMyAdmin +$db_name = $db['name']; +session_write_close(); +header('Location: https://zhangpu.tech/pma/index.php?db=' . urlencode($db_name)); +exit; \ No newline at end of file diff --git a/tpanel-v1.3.34-pkg/php-bridge/tpanel-signon.php b/tpanel-v1.3.34-pkg/php-bridge/tpanel-signon.php new file mode 100644 index 0000000..cc03362 --- /dev/null +++ b/tpanel-v1.3.34-pkg/php-bridge/tpanel-signon.php @@ -0,0 +1,7 @@ +