feat: add wallet types, image upload, backup tabs, and manage.sh
Add mnemonic/key credentials with image upload, tabbed UI for add/query and settings (auth/types/backup), daily backup to /root, and interactive deploy script that updates via git pull only. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,3 +4,7 @@ data.json
|
|||||||
settings.json
|
settings.json
|
||||||
.env
|
.env
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
uploads/
|
||||||
|
backups/
|
||||||
|
backup_meta.json
|
||||||
|
.restore_tmp_*/
|
||||||
|
|||||||
+46
-17
@@ -4,7 +4,48 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v2.0.0(当前)
|
## v2.1.0(当前)
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- **钱包助记词 / 钱包密钥** 内置类型(链/网络、助记词或私钥、地址、备注)
|
||||||
|
- **图片上传**:字段类型 `image`,存储于 `uploads/`,查询时可预览
|
||||||
|
- **主界面 Tab**:添加凭证 / 查询凭证
|
||||||
|
- **查询类型筛选**:下拉框(含「全部类型」)
|
||||||
|
- **系统设置 Tab**:账户密码 / 自定义类型 / 备份恢复
|
||||||
|
- **前端备份恢复**:下载 `.tar.gz`、立即备份、上传恢复(含 `.env`、`data.json`、`settings.json`、`uploads`)
|
||||||
|
- **每日自动备份**:服务内线程(03:00)+ 部署脚本 cron,备份到 `/root/crypto_key_backups/`
|
||||||
|
- **交互式管理脚本** `scripts/manage.sh`:
|
||||||
|
1. 一键部署
|
||||||
|
2. 更新(**仅 `git pull`,严禁 scp**)
|
||||||
|
3. 一键卸载
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
- `scripts/install-ubuntu.sh` 改为调用 `manage.sh deploy`
|
||||||
|
- 自定义字段类型增加:`textarea`、`image`
|
||||||
|
|
||||||
|
### 升级注意
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_key
|
||||||
|
sudo bash scripts/manage.sh
|
||||||
|
# 选择 2) 更新
|
||||||
|
```
|
||||||
|
|
||||||
|
或:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_key
|
||||||
|
git pull
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pm2 restart api-key-manager
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v2.0.0
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|
||||||
@@ -39,21 +80,10 @@
|
|||||||
|
|
||||||
### 升级注意(从 v1.x 升级)
|
### 升级注意(从 v1.x 升级)
|
||||||
|
|
||||||
1. 备份现有 `data.json`:
|
1. 备份现有 `data.json`
|
||||||
```bash
|
2. 安装新依赖并配置 `.env`
|
||||||
cp data.json data.json.bak
|
3. 启动服务,旧明文数据将自动迁移为加密格式
|
||||||
```
|
4. PM2:`pm2 restart api-key-manager`
|
||||||
2. 安装新依赖:
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
3. 配置 `.env`(可复制 `.env.example`,或首次运行自动生成):
|
|
||||||
- `SECRET_KEY`
|
|
||||||
- `AUTH_USERNAME` / `AUTH_PASSWORD`
|
|
||||||
- `DATA_ENCRYPTION_KEY`
|
|
||||||
4. 启动服务,旧明文数据将 **自动迁移** 为加密格式。
|
|
||||||
5. 使用默认或自定义账号登录,在「系统设置」中修改密码。
|
|
||||||
6. PM2 用户执行:`pm2 restart api-key-manager`
|
|
||||||
|
|
||||||
> **重要**:升级后切勿删除或修改 `.env` 中的 `DATA_ENCRYPTION_KEY`,否则无法解密已有数据。
|
> **重要**:升级后切勿删除或修改 `.env` 中的 `DATA_ENCRYPTION_KEY`,否则无法解密已有数据。
|
||||||
|
|
||||||
@@ -101,5 +131,4 @@
|
|||||||
|
|
||||||
- [ ] 可选 HTTPS / 反向代理说明模板
|
- [ ] 可选 HTTPS / 反向代理说明模板
|
||||||
- [ ] 凭证编辑(当前仅支持添加、删除)
|
- [ ] 凭证编辑(当前仅支持添加、删除)
|
||||||
- [ ] 导出/导入加密备份包
|
|
||||||
- [ ] 登录失败次数限制
|
- [ ] 登录失败次数限制
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
> 仓库:[https://git.bz121.com/dekun/crypto_key](https://git.bz121.com/dekun/crypto_key.git)
|
> 仓库:[https://git.bz121.com/dekun/crypto_key](https://git.bz121.com/dekun/crypto_key.git)
|
||||||
> 部署路径:**`/opt/crypto_key`**
|
> 部署路径:**`/opt/crypto_key`**
|
||||||
> 进程守护:**PM2** · 端口:**5200**
|
> 进程守护:**PM2** · 端口:**5200**
|
||||||
|
> **代码更新一律使用 `git pull`,严禁 scp**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -16,13 +17,16 @@
|
|||||||
→ .env (登录账号 + 加密主密钥)
|
→ .env (登录账号 + 加密主密钥)
|
||||||
→ data.json (加密后的凭证数据)
|
→ data.json (加密后的凭证数据)
|
||||||
→ settings.json (自定义类型配置)
|
→ settings.json (自定义类型配置)
|
||||||
|
→ uploads/ (图片附件)
|
||||||
|
→ /root/crypto_key_backups/ (每日自动备份)
|
||||||
```
|
```
|
||||||
|
|
||||||
| 文件 | 说明 | 是否提交 Git |
|
| 文件 | 说明 | 是否提交 Git |
|
||||||
|------|------|----------------|
|
|------|------|----------------|
|
||||||
| `.env` | `SECRET_KEY`、`AUTH_*`、`DATA_ENCRYPTION_KEY` | 否 |
|
| `.env` | `SECRET_KEY`、`AUTH_*`、`DATA_ENCRYPTION_KEY` | 否 |
|
||||||
| `data.json` | 加密凭证库 | 否 |
|
| `data.json` | 加密凭证库 | 否 |
|
||||||
| `settings.json` | 自定义类型定义 | 否(建议备份) |
|
| `settings.json` | 自定义类型定义 | 否 |
|
||||||
|
| `uploads/` | 上传的图片 | 否 |
|
||||||
| `venv/` | Python 虚拟环境 | 否 |
|
| `venv/` | Python 虚拟环境 | 否 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -34,21 +38,12 @@
|
|||||||
| 系统 | Ubuntu 20.04 / 22.04 / 24.04 LTS(Windows 见附录) |
|
| 系统 | Ubuntu 20.04 / 22.04 / 24.04 LTS(Windows 见附录) |
|
||||||
| Python | 3.8+ |
|
| Python | 3.8+ |
|
||||||
| Node.js | 16+(PM2 守护时需要) |
|
| Node.js | 16+(PM2 守护时需要) |
|
||||||
| 权限 | `sudo`(一键安装脚本) |
|
| 权限 | `sudo`(管理脚本) |
|
||||||
| 磁盘 | ≥ 200MB |
|
| 磁盘 | ≥ 200MB |
|
||||||
|
|
||||||
### Python 依赖
|
|
||||||
|
|
||||||
```
|
|
||||||
flask>=3.0.0
|
|
||||||
python-dotenv>=1.0.0
|
|
||||||
cryptography>=42.0.0
|
|
||||||
werkzeug>=3.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 三、首次部署(Ubuntu 推荐)
|
## 三、交互式一键部署(推荐)
|
||||||
|
|
||||||
### 3.1 克隆仓库
|
### 3.1 克隆仓库
|
||||||
|
|
||||||
@@ -58,114 +53,88 @@ sudo git clone https://git.bz121.com/dekun/crypto_key.git /opt/crypto_key
|
|||||||
cd /opt/crypto_key
|
cd /opt/crypto_key
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.2 配置环境变量
|
### 3.2 运行管理脚本
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
sudo bash scripts/manage.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
编辑 `.env`(若留占位符,首次启动会自动生成合法密钥):
|
交互菜单:
|
||||||
|
|
||||||
| 变量 | 说明 |
|
```
|
||||||
|------|------|
|
1) 一键部署
|
||||||
| `SECRET_KEY` | Flask 会话密钥,随机长字符串 |
|
2) 更新(git pull,严禁 scp)
|
||||||
| `AUTH_USERNAME` | 登录用户名 |
|
3) 一键卸载
|
||||||
| `AUTH_PASSWORD` | 登录密码(保存为哈希) |
|
0) 退出
|
||||||
| `DATA_ENCRYPTION_KEY` | Fernet 密钥,用于加密 data.json |
|
```
|
||||||
|
|
||||||
生成 Fernet 密钥:
|
也可直接传参:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
sudo bash scripts/manage.sh deploy # 等同选项 1
|
||||||
|
sudo bash scripts/manage.sh update # 等同选项 2
|
||||||
|
sudo bash scripts/manage.sh uninstall # 等同选项 3
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.3 一键安装并启动
|
兼容旧入口:`sudo bash scripts/install-ubuntu.sh`(内部调用 deploy)。
|
||||||
|
|
||||||
```bash
|
### 3.3 部署脚本会做什么
|
||||||
sudo bash scripts/install-ubuntu.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
脚本将:安装系统依赖 → 创建 `venv` → `pip install` → PM2 启动 → `pm2 save`。
|
1. 安装 python3 / venv / git / Node / PM2
|
||||||
|
2. `git clone` 或 **`git pull`** 到 `/opt/crypto_key`
|
||||||
|
3. 创建 venv、`pip install -r requirements.txt`
|
||||||
|
4. 配置每日 03:00 cron 备份到 `/root/crypto_key_backups/`
|
||||||
|
5. PM2 启动 `api-key-manager`
|
||||||
|
|
||||||
### 3.4 验证
|
### 3.4 验证
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pm2 status
|
pm2 status
|
||||||
# api-key-manager 应为 online
|
|
||||||
|
|
||||||
curl -s http://127.0.0.1:5200/api/auth/status
|
curl -s http://127.0.0.1:5200/api/auth/status
|
||||||
# {"logged_in":false}
|
# {"logged_in":false}
|
||||||
```
|
```
|
||||||
|
|
||||||
浏览器访问:
|
浏览器:`http://127.0.0.1:5200` 或 `http://<服务器IP>:5200`
|
||||||
|
默认可能是 `admin` / `admin123`,请尽快修改。
|
||||||
| 场景 | 地址 |
|
|
||||||
|------|------|
|
|
||||||
| 服务器本机 | http://127.0.0.1:5200 |
|
|
||||||
| 局域网 | `http://<服务器IP>:5200` |
|
|
||||||
|
|
||||||
```bash
|
|
||||||
hostname -I | awk '{print $1}'
|
|
||||||
```
|
|
||||||
|
|
||||||
首次登录默认可能是 `admin` / `admin123`,请登录后在 **系统设置** 中修改。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 四、手动部署(逐步)
|
## 四、更新升级(仅 git pull)
|
||||||
|
|
||||||
### 4.1 系统依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt update
|
|
||||||
sudo apt install -y python3 python3-venv python3-pip git curl
|
|
||||||
|
|
||||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
|
||||||
sudo apt install -y nodejs
|
|
||||||
sudo npm install -g pm2
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 Python 环境
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/crypto_key
|
cd /opt/crypto_key
|
||||||
python3 -m venv venv
|
sudo bash scripts/manage.sh
|
||||||
|
# 选择 2) 更新
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本流程:更新前自动备份 → `git pull` → 安装依赖 → 刷新 cron → 重启 PM2。
|
||||||
|
|
||||||
|
**禁止** 使用 scp / rsync 覆盖代码目录;数据文件(`.env`、`data.json`)本来就不在 Git 里,不会被 pull 冲掉。
|
||||||
|
|
||||||
|
手动更新:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/crypto_key
|
||||||
|
git pull
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
pm2 restart api-key-manager
|
||||||
|
|
||||||
### 4.3 配置 .env
|
|
||||||
|
|
||||||
见第三节 3.2。
|
|
||||||
|
|
||||||
### 4.4 PM2 启动
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/crypto_key
|
|
||||||
mkdir -p logs
|
|
||||||
chmod +x pm2-start.sh pm2-stop.sh
|
|
||||||
./pm2-start.sh
|
|
||||||
# 或: pm2 start ecosystem.config.cjs && pm2 save
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.5 开机自启
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pm2 startup
|
|
||||||
# 按提示执行输出的 sudo 命令
|
|
||||||
pm2 save
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.6 目录权限(推荐)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo chown -R $USER:$USER /opt/crypto_key
|
|
||||||
chmod 600 /opt/crypto_key/.env /opt/crypto_key/data.json 2>/dev/null || true
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 五、Windows 本地部署
|
## 五、一键卸载
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash scripts/manage.sh
|
||||||
|
# 选择 3) 一键卸载,输入 yes 确认
|
||||||
|
```
|
||||||
|
|
||||||
|
会停止 PM2、移除 cron、删除 `/opt/crypto_key`;卸载前尽量再打一份包到 `/root/crypto_key_backups/`,**保留备份目录**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、Windows 本地部署
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd C:\path\to\crypto_key
|
cd C:\path\to\crypto_key
|
||||||
@@ -173,93 +142,63 @@ python -m venv venv
|
|||||||
.\venv\Scripts\activate
|
.\venv\Scripts\activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
copy .env.example .env
|
copy .env.example .env
|
||||||
# 编辑 .env 或首次运行 python app.py 自动生成
|
|
||||||
python app.py
|
python app.py
|
||||||
```
|
```
|
||||||
|
|
||||||
PM2 守护(可选):
|
自动备份目录回退为项目下 `backups\`(无 `/root` 写权限时)。
|
||||||
|
|
||||||
```powershell
|
|
||||||
npm install -g pm2
|
|
||||||
.\pm2-start.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 六、PM2 运维
|
## 七、PM2 运维
|
||||||
|
|
||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `pm2 status` | 查看状态 |
|
| `pm2 status` | 查看状态 |
|
||||||
| `pm2 logs api-key-manager` | 查看日志 |
|
| `pm2 logs api-key-manager` | 查看日志 |
|
||||||
| `pm2 restart api-key-manager` | 重启 |
|
| `pm2 restart api-key-manager` | 重启 |
|
||||||
| `pm2 stop api-key-manager` | 停止 |
|
|
||||||
| `./pm2-start.sh` | 启动(Linux) |
|
| `./pm2-start.sh` | 启动(Linux) |
|
||||||
| `.\pm2-start.ps1` | 启动(Windows) |
|
|
||||||
|
|
||||||
配置文件:`ecosystem.config.cjs`(进程名 `api-key-manager`,Python 使用 `venv` 内解释器)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 七、更新升级
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/crypto_key
|
|
||||||
cp data.json data.json.bak.$(date +%F)
|
|
||||||
cp .env .env.bak.$(date +%F)
|
|
||||||
|
|
||||||
git pull
|
|
||||||
source venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
pm2 restart api-key-manager
|
|
||||||
```
|
|
||||||
|
|
||||||
升级说明与破坏性变更见 [CHANGELOG.md](./CHANGELOG.md)。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 八、备份与恢复
|
## 八、备份与恢复
|
||||||
|
|
||||||
### 8.1 必须备份的文件
|
### 8.1 自动备份
|
||||||
|
|
||||||
|
- 应用内守护线程:每天 **03:00**
|
||||||
|
- cron(部署脚本安装):每天 **03:00**
|
||||||
|
- 目录:`/root/crypto_key_backups/crypto_key_backup_YYYYMMDD_HHMMSS.tar.gz`
|
||||||
|
- 保留约 30 天,过期自动清理
|
||||||
|
|
||||||
|
### 8.2 前端备份恢复
|
||||||
|
|
||||||
|
系统设置 → **备份恢复**:
|
||||||
|
|
||||||
|
- 下载备份
|
||||||
|
- 立即备份到服务器
|
||||||
|
- 上传 `.tar.gz` 恢复
|
||||||
|
|
||||||
|
备份内容:`.env`、`data.json`、`settings.json`、`uploads/`。
|
||||||
|
|
||||||
|
### 8.3 命令行恢复
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo cp /opt/crypto_key/.env /opt/crypto_key/.env.bak
|
# 解压到项目目录(示例)
|
||||||
sudo cp /opt/crypto_key/data.json /opt/crypto_key/data.json.bak
|
cd /opt/crypto_key
|
||||||
sudo cp /opt/crypto_key/settings.json /opt/crypto_key/settings.json.bak 2>/dev/null || true
|
tar xzvf /root/crypto_key_backups/某备份.tar.gz
|
||||||
|
pm2 restart api-key-manager
|
||||||
```
|
```
|
||||||
|
|
||||||
### 8.2 恢复
|
**`.env` 中 `DATA_ENCRYPTION_KEY` 必须与备份一致。**
|
||||||
|
|
||||||
1. 将 `.env`、`data.json`、`settings.json` 放回项目根目录。
|
|
||||||
2. **`.env` 中的 `DATA_ENCRYPTION_KEY` 必须与备份时一致**,否则无法解密。
|
|
||||||
3. `pm2 restart api-key-manager`。
|
|
||||||
|
|
||||||
### 8.3 迁移到新服务器
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 旧服务器打包
|
|
||||||
tar czvf crypto_key_backup.tar.gz .env data.json settings.json
|
|
||||||
|
|
||||||
# 新服务器克隆代码后解压到 /opt/crypto_key/
|
|
||||||
tar xzvf crypto_key_backup.tar.gz -C /opt/crypto_key/
|
|
||||||
# 再执行 venv 安装与 PM2 启动
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 九、防火墙
|
## 九、防火墙
|
||||||
|
|
||||||
服务监听 **0.0.0.0:5200**,局域网可访问。建议限制来源:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 仅允许局域网段(示例)
|
|
||||||
sudo ufw allow from 192.168.0.0/16 to any port 5200 proto tcp
|
sudo ufw allow from 192.168.0.0/16 to any port 5200 proto tcp
|
||||||
|
|
||||||
# 或测试时临时开放
|
|
||||||
sudo ufw allow 5200/tcp
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**切勿**将 5200 端口直接暴露到公网;务必启用登录并修改默认密码。
|
切勿将 5200 直接暴露公网。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -267,12 +206,11 @@ sudo ufw allow 5200/tcp
|
|||||||
|
|
||||||
| 现象 | 处理 |
|
| 现象 | 处理 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| PM2 `errored` | `pm2 logs api-key-manager --err`;检查 `venv` 与依赖 |
|
| PM2 `errored` | `pm2 logs api-key-manager --err` |
|
||||||
| 无法登录 | 检查 `.env` 中 `AUTH_*`;删除错误密码哈希后重启,用自动生成默认账号 |
|
| 无法登录 | 检查 `.env` 中 `AUTH_*` |
|
||||||
| 数据无法解密 | `DATA_ENCRYPTION_KEY` 是否与备份一致 |
|
| 数据无法解密 | `DATA_ENCRYPTION_KEY` 是否与备份一致 |
|
||||||
| 局域网无法访问 | `ss -tlnp \| grep 5200` 应显示 `0.0.0.0:5200`;检查 ufw |
|
| 局域网无法访问 | `ss -tlnp \| grep 5200`;检查 ufw |
|
||||||
| 401 未登录 | 重新登录;检查 `SECRET_KEY` 是否被改动(会导致 Session 失效) |
|
| 图片 404 | 确认 `uploads/` 存在且备份已含图片文件 |
|
||||||
| 旧明文 data.json | 启动后自动迁移;迁移前务必备份 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -280,24 +218,20 @@ sudo ufw allow 5200/tcp
|
|||||||
|
|
||||||
```
|
```
|
||||||
/opt/crypto_key/
|
/opt/crypto_key/
|
||||||
├── app.py # Flask 入口
|
├── app.py
|
||||||
├── storage.py # 加密存储与迁移
|
├── storage.py
|
||||||
├── env_config.py # .env 读写
|
├── backup.py
|
||||||
├── index.html # 前端(登录 + 主界面 + 设置)
|
├── env_config.py
|
||||||
|
├── index.html
|
||||||
├── requirements.txt
|
├── requirements.txt
|
||||||
├── ecosystem.config.cjs # PM2
|
├── ecosystem.config.cjs
|
||||||
├── .env.example
|
|
||||||
├── settings.json # 自定义类型(运行时)
|
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ └── install-ubuntu.sh
|
│ ├── manage.sh # 交互式:部署 / 更新 / 卸载
|
||||||
├── pm2-start.sh / pm2-stop.sh
|
│ └── install-ubuntu.sh # 兼容入口 → deploy
|
||||||
├── pm2-start.ps1 / pm2-stop.ps1 / pm2-restart.ps1
|
├── uploads/ # 图片(运行时)
|
||||||
├── logs/
|
├── logs/
|
||||||
├── venv/
|
├── venv/
|
||||||
├── README.md
|
└── ...
|
||||||
├── USAGE.md # 使用说明
|
|
||||||
├── DEPLOY.md # 本文档
|
|
||||||
└── CHANGELOG.md # 更新说明
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -305,14 +239,15 @@ sudo ufw allow 5200/tcp
|
|||||||
## 十二、命令速查
|
## 十二、命令速查
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 首次部署
|
# 首次
|
||||||
sudo git clone https://git.bz121.com/dekun/crypto_key.git /opt/crypto_key
|
sudo git clone https://git.bz121.com/dekun/crypto_key.git /opt/crypto_key
|
||||||
cd /opt/crypto_key && cp .env.example .env && sudo bash scripts/install-ubuntu.sh
|
cd /opt/crypto_key && sudo bash scripts/manage.sh # 选 1
|
||||||
|
|
||||||
# 日常
|
# 更新(git pull only)
|
||||||
pm2 status
|
sudo bash scripts/manage.sh # 选 2
|
||||||
pm2 restart api-key-manager
|
|
||||||
pm2 logs api-key-manager --lines 50
|
# 卸载
|
||||||
|
sudo bash scripts/manage.sh # 选 3
|
||||||
```
|
```
|
||||||
|
|
||||||
使用细节请参阅 **[USAGE.md](./USAGE.md)**。
|
使用细节请参阅 **[USAGE.md](./USAGE.md)**。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# crypto_key — 凭证保险库
|
# crypto_key — 凭证保险库
|
||||||
|
|
||||||
本地 / 局域网 Web 工具,集中管理交易所 API、网站、邮箱、机器人、微信/QQ 及自定义类型凭证。数据 **AES 加密** 存储,**用户名密码登录**,浏览器不缓存明文。
|
本地 / 局域网 Web 工具,集中管理交易所 API、钱包助记词/密钥、网站、邮箱、机器人、微信/QQ 及自定义类型凭证。数据 **AES 加密** 存储,**用户名密码登录**,浏览器不缓存明文。
|
||||||
|
|
||||||
| 项目 | 说明 |
|
| 项目 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
@@ -44,22 +44,30 @@ python app.py
|
|||||||
## 主要功能
|
## 主要功能
|
||||||
|
|
||||||
- 登录鉴权(凭据在 `.env`,Session 在服务端)
|
- 登录鉴权(凭据在 `.env`,Session 在服务端)
|
||||||
- 内置类型:交易所、网站、邮箱、企微/钉钉机器人、微信、QQ
|
- 内置类型:交易所、**钱包助记词**、**钱包密钥**、网站、邮箱、企微/钉钉机器人、微信、QQ
|
||||||
|
- **图片上传**:助记词/密钥等可附带截图
|
||||||
- 自定义类型:系统设置中添加(小红书、抖音等)
|
- 自定义类型:系统设置中添加(小红书、抖音等)
|
||||||
- 查询后显示 + 类型/关键词搜索 + 敏感字段打码(复制为明文)
|
- Tab 界面:添加凭证 / 查询凭证;查询类型为下拉筛选
|
||||||
|
- 系统设置 Tab:账户密码 / 自定义类型 / **备份恢复**
|
||||||
|
- 每日自动备份到 `/root/crypto_key_backups/`(无权限时回退到项目 `backups/`)
|
||||||
- 旧版明文 `data.json` 自动迁移加密
|
- 旧版明文 `data.json` 自动迁移加密
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Ubuntu 一键部署
|
## Ubuntu 一键部署(交互式)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo git clone https://git.bz121.com/dekun/crypto_key.git /opt/crypto_key
|
sudo git clone https://git.bz121.com/dekun/crypto_key.git /opt/crypto_key
|
||||||
cd /opt/crypto_key
|
cd /opt/crypto_key
|
||||||
cp .env.example .env
|
sudo bash scripts/manage.sh
|
||||||
sudo bash scripts/install-ubuntu.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
菜单:
|
||||||
|
|
||||||
|
1. 一键部署
|
||||||
|
2. 更新(**仅 git pull**,严禁 scp)
|
||||||
|
3. 一键卸载
|
||||||
|
|
||||||
详见 [DEPLOY.md](./DEPLOY.md)。
|
详见 [DEPLOY.md](./DEPLOY.md)。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -70,10 +78,11 @@ sudo bash scripts/install-ubuntu.sh
|
|||||||
|------|------|
|
|------|------|
|
||||||
| `app.py` | Flask 后端 |
|
| `app.py` | Flask 后端 |
|
||||||
| `storage.py` | 加密存储与迁移 |
|
| `storage.py` | 加密存储与迁移 |
|
||||||
|
| `backup.py` | 备份/恢复与每日自动备份 |
|
||||||
| `env_config.py` | `.env` 管理 |
|
| `env_config.py` | `.env` 管理 |
|
||||||
| `index.html` | 前端单页 |
|
| `index.html` | 前端单页 |
|
||||||
| `ecosystem.config.cjs` | PM2 配置 |
|
| `ecosystem.config.cjs` | PM2 配置 |
|
||||||
| `scripts/install-ubuntu.sh` | Ubuntu 安装脚本 |
|
| `scripts/manage.sh` | 交互式部署/更新/卸载 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -81,7 +90,7 @@ sudo bash scripts/install-ubuntu.sh
|
|||||||
|
|
||||||
- 仅在可信局域网使用,勿将 5200 映射到公网
|
- 仅在可信局域网使用,勿将 5200 映射到公网
|
||||||
- 定期备份 `.env` 与 `data.json`(密钥丢失将无法解密)
|
- 定期备份 `.env` 与 `data.json`(密钥丢失将无法解密)
|
||||||
- 勿将 `.env`、`data.json` 提交到 Git
|
- 勿将 `.env`、`data.json`、`uploads/` 提交到 Git
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
**crypto_key** 是一款在浏览器中使用的本地/局域网凭证管理工具,用于集中保存:
|
**crypto_key** 是一款在浏览器中使用的本地/局域网凭证管理工具,用于集中保存:
|
||||||
|
|
||||||
- 交易所 API(Binance、OKX、Gate)
|
- 交易所 API(Binance、OKX、Gate)
|
||||||
|
- **钱包助记词**、**钱包密钥**(支持图片附件)
|
||||||
- 网站账号(可点击打开网址)
|
- 网站账号(可点击打开网址)
|
||||||
- 邮箱账户(可点击发邮件或打开网页登录)
|
- 邮箱账户(可点击发邮件或打开网页登录)
|
||||||
- 企业微信 / 钉钉机器人 Webhook
|
- 企业微信 / 钉钉机器人 Webhook
|
||||||
@@ -31,7 +32,7 @@
|
|||||||
3. 首次部署时,若未配置 `.env`,系统可能自动生成默认账号:
|
3. 首次部署时,若未配置 `.env`,系统可能自动生成默认账号:
|
||||||
- 用户名:`admin`
|
- 用户名:`admin`
|
||||||
- 密码:`admin123`
|
- 密码:`admin123`
|
||||||
4. **登录后请立即进入「系统设置」修改用户名和密码。**
|
4. **登录后请立即进入「系统设置 → 账户密码」修改。**
|
||||||
|
|
||||||
> 登录状态保存在服务端 Session(HttpOnly Cookie),关闭浏览器后需重新登录(约 30 分钟无操作也会失效)。
|
> 登录状态保存在服务端 Session(HttpOnly Cookie),关闭浏览器后需重新登录(约 30 分钟无操作也会失效)。
|
||||||
|
|
||||||
@@ -39,139 +40,112 @@
|
|||||||
|
|
||||||
## 3. 界面总览
|
## 3. 界面总览
|
||||||
|
|
||||||
登录成功后主界面包含三块:
|
登录后顶部为两个主 Tab:
|
||||||
|
|
||||||
| 区域 | 作用 |
|
| Tab | 作用 |
|
||||||
|------|------|
|
|-----|------|
|
||||||
| **添加凭证** | 选择类型、填写字段、保存 |
|
| **添加凭证** | 选择类型、填写字段(可含图片)、保存 |
|
||||||
| **查询凭证** | 按类型/关键词筛选,点击「确认」后展示列表 |
|
| **查询凭证** | 类型下拉筛选 + 关键词,确认后展示列表 |
|
||||||
| **凭证列表** | 展示查询结果;支持打码显示与复制 |
|
|
||||||
|
|
||||||
右上角:
|
右上角:
|
||||||
|
|
||||||
- **系统设置**:修改登录账号、管理自定义类型
|
- **系统设置**:账户密码 / 自定义类型 / 备份恢复
|
||||||
- **退出**:清除登录状态
|
- **退出**:清除登录状态
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 添加凭证
|
## 4. 添加凭证
|
||||||
|
|
||||||
### 4.1 选择类型
|
1. 切换到 **「添加凭证」** Tab。
|
||||||
|
2. 在凭证类型输入框搜索并点击类型(如「钱包助记词」)。
|
||||||
|
3. 填写表单;若有图片字段,选择本地图片后会自动上传。
|
||||||
|
4. 点击 **「添加」**。成功后建议切到「查询凭证」查看。
|
||||||
|
|
||||||
1. 在「添加凭证」区域的 **凭证类型** 输入框中输入关键字(如「交易所」「邮箱」「小红书」)。
|
### 各类型主要字段
|
||||||
2. 从下拉列表中点击需要的类型。
|
|
||||||
3. 下方会显示「已选:xxx」,并出现对应表单。
|
|
||||||
|
|
||||||
### 4.2 各类型必填说明
|
|
||||||
|
|
||||||
| 类型 | 主要字段 |
|
| 类型 | 主要字段 |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| 交易所 API | 交易所、账户名称、API Key、API Secret;**OKX 额外必填 Passphrase 密码** |
|
| 交易所 API | 交易所、账户名称、API Key、API Secret;OKX 额外必填密码 |
|
||||||
|
| 钱包助记词 | 名称、链/网络、助记词、地址、图片、备注 |
|
||||||
|
| 钱包密钥 | 名称、链/网络、私钥、地址、图片、备注 |
|
||||||
| 网站账号 | 名称、网址、用户名、密码 |
|
| 网站账号 | 名称、网址、用户名、密码 |
|
||||||
| 邮箱账户 | 名称、邮箱地址、密码;可选网页登录地址 |
|
| 邮箱账户 | 名称、邮箱地址、密码;可选网页登录地址 |
|
||||||
| 企业微信机器人 | 名称、Webhook |
|
| 企业微信 / 钉钉机器人 | 名称、Webhook |
|
||||||
| 钉钉机器人 | 名称、Webhook |
|
| 微信 / QQ | 名称、微信号或 QQ 号、手机号 |
|
||||||
| 微信 | 名称、微信号、手机号 |
|
|
||||||
| QQ | 名称、QQ 号、手机号 |
|
|
||||||
| 自定义类型 | 按你在系统设置中定义的字段填写 |
|
| 自定义类型 | 按你在系统设置中定义的字段填写 |
|
||||||
|
|
||||||
### 4.3 提交
|
|
||||||
|
|
||||||
1. 填写完成后点击 **「添加」**。
|
|
||||||
2. 添加成功后会提示:**不会自动显示在列表中**。
|
|
||||||
3. 需要到「查询凭证」区域选择类型并点击 **「确认」** 才能查看。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 查询凭证
|
## 5. 查询凭证
|
||||||
|
|
||||||
### 5.1 基本查询
|
1. 切换到 **「查询凭证」** Tab。
|
||||||
|
2. 在 **类型筛选** 下拉框选择类型(或「全部类型」)。
|
||||||
1. 在「查询凭证」中选择或搜索 **类型**(下拉框支持输入过滤)。
|
3. (可选)关键词:名称、账号、邮箱、地址等。
|
||||||
2. (可选)在 **关键词** 中输入名称、账号、邮箱等片段。
|
4. 若类型为交易所 API,可再选 Binance / OKX / Gate。
|
||||||
3. 若类型为 **交易所 API**,可再选 Binance / OKX / Gate。
|
5. 点击 **「确认」** 显示列表。
|
||||||
4. 点击右侧 **「确认」**。
|
|
||||||
5. 列表显示该条件下所有记录。
|
|
||||||
|
|
||||||
### 5.2 列表操作
|
|
||||||
|
|
||||||
| 操作 | 说明 |
|
| 操作 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **隐藏敏感字段** | 勾选后界面以圆点显示密码、Key 等;**复制按钮仍为真实明文** |
|
| **隐藏敏感字段** | 勾选后以圆点显示;复制仍为明文 |
|
||||||
| **复制** | 每条字段右侧「复制」按钮 |
|
| **复制** | 字段右侧「复制」 |
|
||||||
| **链接** | 网站「网址」、邮箱「邮箱地址」、邮箱「网页登录地址」可点击跳转 |
|
| **图片** | 助记词/密钥等记录可预览已上传图片 |
|
||||||
| **删除** | 卡片右上角「删除」,需确认 |
|
| **删除** | 卡片右上角,需确认 |
|
||||||
|
|
||||||
### 5.3 未查询时
|
|
||||||
|
|
||||||
默认显示:「选择类型并点击「确认」查看」——不会展示任何凭证内容。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 系统设置
|
## 6. 系统设置
|
||||||
|
|
||||||
点击右上角 **「系统设置」** 进入。
|
右上角进入,内含三个 Tab:
|
||||||
|
|
||||||
### 6.1 修改登录账号(写入 .env)
|
### 6.1 账户密码
|
||||||
|
|
||||||
1. 填写 **新用户名**、**新密码**(至少 6 位)。
|
修改登录用户名/密码(写入 `.env`),需填写当前密码确认。
|
||||||
2. 填写 **当前密码** 以确认身份。
|
|
||||||
3. 点击 **「保存登录设置」**。
|
|
||||||
4. 修改后请使用新账号重新登录。
|
|
||||||
|
|
||||||
> 登录凭据保存在服务器 `.env` 文件中,不会出现在浏览器缓存。
|
### 6.2 自定义类型
|
||||||
|
|
||||||
### 6.2 自定义凭证类型
|
新增类型时字段类型可选:文本 / 敏感 / 多行 / 链接 / 邮箱 / 手机 / **图片**。
|
||||||
|
|
||||||
适用于小红书、抖音、快手等未内置的类型。
|
删除自定义类型会同时删除该类型下所有凭证。
|
||||||
|
|
||||||
**新增类型:**
|
### 6.3 备份恢复
|
||||||
|
|
||||||
1. **类型 ID**:英文小写+下划线,如 `xiaohongshu`(创建后不可改)。
|
| 操作 | 说明 |
|
||||||
2. **显示名称**:如 `小红书`。
|
|------|------|
|
||||||
3. **字段列表**:点击「+ 添加字段」
|
| **下载备份** | 浏览器下载 `.tar.gz`(含 .env、data.json、settings.json、uploads) |
|
||||||
- `key`:英文标识,如 `account`
|
| **立即备份到服务器** | 写入 `/root/crypto_key_backups/`(无权限时为项目 `backups/`) |
|
||||||
- `显示名`:如 `账号`
|
| **选择备份文件恢复** | 上传 `.tar.gz` 覆盖当前数据 |
|
||||||
- `类型`:文本 / 敏感 / 链接 / 邮箱 / 手机
|
|
||||||
- **必填**:是否必填
|
|
||||||
4. 点击 **「保存自定义类型」**。
|
|
||||||
|
|
||||||
**搜索 / 删除:**
|
服务器每日 **03:00** 自动备份(应用内线程 + 部署时安装的 cron)。
|
||||||
|
|
||||||
- 顶部搜索框可过滤已有自定义类型。
|
> 恢复后 `.env` 中的 `DATA_ENCRYPTION_KEY` 必须与备份一致,否则无法解密。
|
||||||
- 「删除」会同时删除该类型下的 **所有凭证记录**。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. 安全与使用建议
|
## 7. 安全与使用建议
|
||||||
|
|
||||||
1. **仅在可信局域网使用**,不要将 5200 端口映射到公网。
|
1. **仅在可信局域网使用**,不要将 5200 端口映射到公网。
|
||||||
2. **定期备份** `data.json` 和 `.env`(见 [DEPLOY.md](./DEPLOY.md) 备份章节)。
|
2. 使用「备份恢复」或定期检查 `/root/crypto_key_backups/`。
|
||||||
3. **不要** 将 `.env`、`data.json` 提交到 Git 或发给他人。
|
3. **不要** 将 `.env`、`data.json`、`uploads/` 提交到 Git。
|
||||||
4. 若更换服务器,需同时拷贝 `.env` 与 `data.json`,否则无法解密数据。
|
4. 助记词/私钥属于高敏感信息,复制后尽快清除剪贴板。
|
||||||
5. 复制敏感内容后,尽快粘贴使用,避免长时间留在系统剪贴板。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. 常见问题
|
## 8. 常见问题
|
||||||
|
|
||||||
**Q:登录提示用户名或密码错误?**
|
**Q:登录提示用户名或密码错误?**
|
||||||
A:检查 `.env` 中 `AUTH_USERNAME` / `AUTH_PASSWORD`;或使用部署时的默认 `admin` / `admin123` 后在设置中修改。
|
A:检查 `.env` 中 `AUTH_*`;或默认 `admin` / `admin123` 后在设置中修改。
|
||||||
|
|
||||||
**Q:添加成功但查询不到?**
|
**Q:添加成功但查询不到?**
|
||||||
A:确认查询的类型与添加时一致;交易所需选对 Binance/OKX/Gate。
|
A:确认查询类型与添加时一致;可先选「全部类型」再搜关键词。
|
||||||
|
|
||||||
**Q:局域网手机打不开?**
|
**Q:图片上传失败?**
|
||||||
A:确认手机与服务器同一 WiFi;防火墙放行 5200;使用 `http://服务器IP:5200` 而非 127.0.0.1。
|
A:仅支持 jpg/png/gif/webp/bmp,单文件不超过约 16MB。
|
||||||
|
|
||||||
**Q:复制按钮无效?**
|
**Q:恢复备份后打不开数据?**
|
||||||
A:使用 `http://` 访问(非 `file://`);浏览器需允许剪贴板权限。
|
A:确认备份包内 `.env` 的 `DATA_ENCRYPTION_KEY` 完整。
|
||||||
|
|
||||||
**Q:更新代码后无法登录或数据打不开?**
|
**Q:更新代码怎么做?**
|
||||||
A:执行 `pip install -r requirements.txt` 并重启 PM2;确认 `.env` 中 `DATA_ENCRYPTION_KEY` 未变。
|
A:服务器上 `sudo bash scripts/manage.sh` → 选 **2 更新**(仅 git pull,不要用 scp)。
|
||||||
|
|
||||||
**Q:旧版明文 data.json 还能用吗?**
|
|
||||||
A:可以。服务启动后首次读取会自动迁移为加密格式,建议迁移前备份原文件。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,17 @@
|
|||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Flask, jsonify, request, send_from_directory, session
|
import io
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, request, send_file, send_from_directory, session
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
|
import backup as backup_mod
|
||||||
import env_config
|
import env_config
|
||||||
from storage import (
|
from storage import (
|
||||||
BUILTIN_TYPES,
|
BUILTIN_TYPES,
|
||||||
all_type_defs,
|
|
||||||
filter_records,
|
filter_records,
|
||||||
get_type_def,
|
get_type_def,
|
||||||
load_records,
|
load_records,
|
||||||
@@ -29,9 +33,14 @@ app.config.update(
|
|||||||
SESSION_COOKIE_HTTPONLY=True,
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
SESSION_COOKIE_SAMESITE="Lax",
|
SESSION_COOKIE_SAMESITE="Lax",
|
||||||
PERMANENT_SESSION_LIFETIME=1800,
|
PERMANENT_SESSION_LIFETIME=1800,
|
||||||
|
MAX_CONTENT_LENGTH=16 * 1024 * 1024,
|
||||||
)
|
)
|
||||||
|
|
||||||
BASE_DIR = env_config.BASE_DIR
|
BASE_DIR = env_config.BASE_DIR
|
||||||
|
UPLOAD_DIR = BASE_DIR / "uploads"
|
||||||
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
ALLOWED_IMAGE_EXT = frozenset({".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"})
|
||||||
PUBLIC_API = {"/api/auth/login", "/api/auth/status"}
|
PUBLIC_API = {"/api/auth/login", "/api/auth/status"}
|
||||||
|
|
||||||
|
|
||||||
@@ -94,6 +103,7 @@ def get_settings():
|
|||||||
"builtin_types": BUILTIN_TYPES,
|
"builtin_types": BUILTIN_TYPES,
|
||||||
"custom_types": s.get("custom_types", []),
|
"custom_types": s.get("custom_types", []),
|
||||||
"username": env_config.get_auth_username(),
|
"username": env_config.get_auth_username(),
|
||||||
|
"backup": backup_mod.get_backup_status(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -130,19 +140,24 @@ def add_custom_type():
|
|||||||
return jsonify({"error": "类型 ID 已存在"}), 400
|
return jsonify({"error": "类型 ID 已存在"}), 400
|
||||||
if not fields:
|
if not fields:
|
||||||
return jsonify({"error": "至少添加一个字段"}), 400
|
return jsonify({"error": "至少添加一个字段"}), 400
|
||||||
|
allowed_types = {"text", "secret", "url", "email", "phone", "select", "textarea", "image"}
|
||||||
norm_fields = []
|
norm_fields = []
|
||||||
for f in fields:
|
for f in fields:
|
||||||
key = (f.get("key") or "").strip().lower()
|
key = (f.get("key") or "").strip().lower()
|
||||||
if not re.match(r"^[a-z][a-z0-9_]{0,31}$", key):
|
if not re.match(r"^[a-z][a-z0-9_]{0,31}$", key):
|
||||||
return jsonify({"error": f"无效字段 key: {key}"}), 400
|
return jsonify({"error": f"无效字段 key: {key}"}), 400
|
||||||
norm_fields.append(
|
ftype = f.get("type") or "text"
|
||||||
{
|
if ftype not in allowed_types:
|
||||||
"key": key,
|
return jsonify({"error": f"无效字段类型: {ftype}"}), 400
|
||||||
"label": (f.get("label") or key).strip(),
|
entry = {
|
||||||
"type": f.get("type") or "text",
|
"key": key,
|
||||||
"required": bool(f.get("required")),
|
"label": (f.get("label") or key).strip(),
|
||||||
}
|
"type": ftype,
|
||||||
)
|
"required": bool(f.get("required")),
|
||||||
|
}
|
||||||
|
if f.get("secret"):
|
||||||
|
entry["secret"] = True
|
||||||
|
norm_fields.append(entry)
|
||||||
s = load_settings()
|
s = load_settings()
|
||||||
entry = {"id": type_id, "label": label, "builtin": False, "fields": norm_fields}
|
entry = {"id": type_id, "label": label, "builtin": False, "fields": norm_fields}
|
||||||
s["custom_types"].append(entry)
|
s["custom_types"].append(entry)
|
||||||
@@ -164,6 +179,82 @@ def delete_custom_type(type_id):
|
|||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/upload", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def upload_image():
|
||||||
|
f = request.files.get("file")
|
||||||
|
if not f or not f.filename:
|
||||||
|
return jsonify({"error": "未选择文件"}), 400
|
||||||
|
ext = Path(f.filename).suffix.lower()
|
||||||
|
if ext not in ALLOWED_IMAGE_EXT:
|
||||||
|
return jsonify({"error": "仅支持 jpg/png/gif/webp/bmp"}), 400
|
||||||
|
name = f"{uuid.uuid4().hex}{ext}"
|
||||||
|
path = UPLOAD_DIR / name
|
||||||
|
f.save(path)
|
||||||
|
return jsonify({"path": name, "url": f"/api/uploads/{name}"}), 201
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/uploads/<filename>", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def get_upload(filename):
|
||||||
|
safe = secure_filename(filename)
|
||||||
|
if not safe or safe != filename:
|
||||||
|
return jsonify({"error": "无效文件名"}), 400
|
||||||
|
path = UPLOAD_DIR / safe
|
||||||
|
if not path.exists() or not path.is_file():
|
||||||
|
return jsonify({"error": "文件不存在"}), 404
|
||||||
|
return send_from_directory(UPLOAD_DIR, safe)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/backup/status", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def backup_status():
|
||||||
|
return jsonify(backup_mod.get_backup_status())
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/backup/export", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def backup_export():
|
||||||
|
data, filename = backup_mod.create_backup_bytes()
|
||||||
|
# 同时落盘一份到自动备份目录
|
||||||
|
try:
|
||||||
|
backup_mod.create_backup_archive()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return send_file(
|
||||||
|
path_or_file=io.BytesIO(data),
|
||||||
|
mimetype="application/gzip",
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/backup/now", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def backup_now():
|
||||||
|
try:
|
||||||
|
path = backup_mod.create_backup_archive()
|
||||||
|
return jsonify({"ok": True, **backup_mod.get_backup_status(), "path": str(path)})
|
||||||
|
except OSError as e:
|
||||||
|
return jsonify({"error": f"备份失败: {e}"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/backup/restore", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def backup_restore():
|
||||||
|
f = request.files.get("file")
|
||||||
|
if not f or not f.filename:
|
||||||
|
return jsonify({"error": "请上传备份文件 (.tar.gz)"}), 400
|
||||||
|
if not f.filename.endswith(".tar.gz") and not f.filename.endswith(".tgz"):
|
||||||
|
return jsonify({"error": "备份文件须为 .tar.gz"}), 400
|
||||||
|
try:
|
||||||
|
backup_mod.restore_from_archive(f.stream)
|
||||||
|
env_config.reload_env()
|
||||||
|
return jsonify({"ok": True, "message": "恢复成功,建议刷新页面"})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": f"恢复失败: {e}"}), 400
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/credentials", methods=["GET"])
|
@app.route("/api/credentials", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
def list_credentials():
|
def list_credentials():
|
||||||
@@ -177,6 +268,8 @@ def list_credentials():
|
|||||||
for r in records
|
for r in records
|
||||||
if r.get("type_id") == "exchange" and r.get("fields", {}).get("exchange") == ex
|
if r.get("type_id") == "exchange" and r.get("fields", {}).get("exchange") == ex
|
||||||
]
|
]
|
||||||
|
if q:
|
||||||
|
records = filter_records(records, q=q)
|
||||||
else:
|
else:
|
||||||
records = filter_records(records, type_id=type_id or None, q=q or None)
|
records = filter_records(records, type_id=type_id or None, q=q or None)
|
||||||
return jsonify(records)
|
return jsonify(records)
|
||||||
@@ -222,5 +315,8 @@ def accounts_delete_compat(account_id):
|
|||||||
return delete_credential(account_id)
|
return delete_credential(account_id)
|
||||||
|
|
||||||
|
|
||||||
|
backup_mod.start_auto_backup_scheduler()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(host="0.0.0.0", port=5200, debug=False)
|
app.run(host="0.0.0.0", port=5200, debug=False)
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""备份与恢复:打包 .env / data.json / settings.json / uploads"""
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tarfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
UPLOAD_DIR = BASE_DIR / "uploads"
|
||||||
|
BACKUP_META = BASE_DIR / "backup_meta.json"
|
||||||
|
|
||||||
|
# Linux 生产环境写到 /root;其它环境回退到项目 backups/
|
||||||
|
ROOT_BACKUP_DIR = Path("/root/crypto_key_backups")
|
||||||
|
LOCAL_BACKUP_DIR = BASE_DIR / "backups"
|
||||||
|
|
||||||
|
BACKUP_FILES = (".env", "data.json", "settings.json")
|
||||||
|
|
||||||
|
_scheduler_started = False
|
||||||
|
_scheduler_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def backup_dir() -> Path:
|
||||||
|
if Path("/root").exists() and os.access("/root", os.W_OK):
|
||||||
|
try:
|
||||||
|
ROOT_BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
return ROOT_BACKUP_DIR
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
LOCAL_BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
return LOCAL_BACKUP_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def _load_meta() -> dict:
|
||||||
|
if not BACKUP_META.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(BACKUP_META.read_text(encoding="utf-8"))
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_meta(data: dict):
|
||||||
|
BACKUP_META.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def create_backup_archive(dest: Optional[Path] = None) -> Path:
|
||||||
|
"""创建 tar.gz 备份,返回文件路径。"""
|
||||||
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
out_dir = dest.parent if dest else backup_dir()
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_path = dest or (out_dir / f"crypto_key_backup_{ts}.tar.gz")
|
||||||
|
|
||||||
|
with tarfile.open(out_path, "w:gz") as tar:
|
||||||
|
for name in BACKUP_FILES:
|
||||||
|
p = BASE_DIR / name
|
||||||
|
if p.exists():
|
||||||
|
tar.add(p, arcname=name)
|
||||||
|
if UPLOAD_DIR.exists():
|
||||||
|
for f in UPLOAD_DIR.rglob("*"):
|
||||||
|
if f.is_file():
|
||||||
|
tar.add(f, arcname=str(Path("uploads") / f.relative_to(UPLOAD_DIR)))
|
||||||
|
|
||||||
|
meta = _load_meta()
|
||||||
|
meta["last_backup"] = datetime.now().isoformat(timespec="seconds")
|
||||||
|
meta["last_backup_path"] = str(out_path)
|
||||||
|
meta["last_backup_size"] = out_path.stat().st_size
|
||||||
|
_save_meta(meta)
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
|
||||||
|
def create_backup_bytes() -> Tuple[bytes, str]:
|
||||||
|
"""生成内存中的备份包,供前端下载。"""
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
||||||
|
for name in BACKUP_FILES:
|
||||||
|
p = BASE_DIR / name
|
||||||
|
if p.exists():
|
||||||
|
tar.add(p, arcname=name)
|
||||||
|
if UPLOAD_DIR.exists():
|
||||||
|
for f in UPLOAD_DIR.rglob("*"):
|
||||||
|
if f.is_file():
|
||||||
|
tar.add(f, arcname=str(Path("uploads") / f.relative_to(UPLOAD_DIR)))
|
||||||
|
buf.seek(0)
|
||||||
|
filename = f"crypto_key_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.tar.gz"
|
||||||
|
return buf.read(), filename
|
||||||
|
|
||||||
|
|
||||||
|
def restore_from_archive(fileobj) -> None:
|
||||||
|
"""从 tar.gz 恢复到项目目录。"""
|
||||||
|
# 先解压到临时目录再覆盖,避免半成品
|
||||||
|
tmp = BASE_DIR / f".restore_tmp_{int(time.time())}"
|
||||||
|
if tmp.exists():
|
||||||
|
shutil.rmtree(tmp)
|
||||||
|
tmp.mkdir(parents=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with tarfile.open(fileobj=fileobj, mode="r:gz") as tar:
|
||||||
|
for member in tar.getmembers():
|
||||||
|
name = member.name.replace("\\", "/").lstrip("./")
|
||||||
|
if not name or name.startswith("/") or ".." in name.split("/"):
|
||||||
|
raise ValueError(f"非法备份路径: {member.name}")
|
||||||
|
allowed = (
|
||||||
|
name in BACKUP_FILES
|
||||||
|
or name == "uploads"
|
||||||
|
or name.startswith("uploads/")
|
||||||
|
)
|
||||||
|
if not allowed:
|
||||||
|
continue
|
||||||
|
member.name = name
|
||||||
|
tar.extract(member, path=tmp)
|
||||||
|
|
||||||
|
for name in BACKUP_FILES:
|
||||||
|
src = tmp / name
|
||||||
|
if src.exists():
|
||||||
|
shutil.copy2(src, BASE_DIR / name)
|
||||||
|
|
||||||
|
uploads_src = tmp / "uploads"
|
||||||
|
if uploads_src.exists():
|
||||||
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
for f in uploads_src.rglob("*"):
|
||||||
|
if f.is_file():
|
||||||
|
rel = f.relative_to(uploads_src)
|
||||||
|
dest = UPLOAD_DIR / rel
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(f, dest)
|
||||||
|
|
||||||
|
meta = _load_meta()
|
||||||
|
meta["last_restore"] = datetime.now().isoformat(timespec="seconds")
|
||||||
|
_save_meta(meta)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_backup_status() -> dict:
|
||||||
|
meta = _load_meta()
|
||||||
|
return {
|
||||||
|
"last_backup": meta.get("last_backup"),
|
||||||
|
"last_backup_path": meta.get("last_backup_path"),
|
||||||
|
"last_backup_size": meta.get("last_backup_size"),
|
||||||
|
"last_restore": meta.get("last_restore"),
|
||||||
|
"auto_backup_dir": str(backup_dir()),
|
||||||
|
"auto_backup_enabled": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _seconds_until_hour(hour: int = 3) -> float:
|
||||||
|
now = datetime.now()
|
||||||
|
target = now.replace(hour=hour, minute=0, second=0, microsecond=0)
|
||||||
|
if target <= now:
|
||||||
|
target += timedelta(days=1)
|
||||||
|
return (target - now).total_seconds()
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_backup_loop():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
time.sleep(max(60, _seconds_until_hour(3)))
|
||||||
|
create_backup_archive()
|
||||||
|
# 清理超过 30 天的自动备份
|
||||||
|
_cleanup_old_backups(keep_days=30)
|
||||||
|
except Exception:
|
||||||
|
time.sleep(3600)
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_old_backups(keep_days: int = 30):
|
||||||
|
cutoff = time.time() - keep_days * 86400
|
||||||
|
d = backup_dir()
|
||||||
|
for f in d.glob("crypto_key_backup_*.tar.gz"):
|
||||||
|
try:
|
||||||
|
if f.stat().st_mtime < cutoff:
|
||||||
|
f.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def start_auto_backup_scheduler():
|
||||||
|
"""启动每日自动备份后台线程(仅启动一次)。"""
|
||||||
|
global _scheduler_started
|
||||||
|
with _scheduler_lock:
|
||||||
|
if _scheduler_started:
|
||||||
|
return
|
||||||
|
_scheduler_started = True
|
||||||
|
t = threading.Thread(target=_auto_backup_loop, name="crypto-key-backup", daemon=True)
|
||||||
|
t.start()
|
||||||
+290
-72
@@ -26,7 +26,7 @@
|
|||||||
border-radius: 6px; color: var(--text); font-size: 0.82rem;
|
border-radius: 6px; color: var(--text); font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
input, textarea { font-family: var(--mono); }
|
input, textarea { font-family: var(--mono); }
|
||||||
textarea { min-height: 60px; resize: vertical; }
|
textarea { min-height: 80px; resize: vertical; }
|
||||||
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); }
|
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); }
|
||||||
.grid { display: grid; gap: 10px; }
|
.grid { display: grid; gap: 10px; }
|
||||||
@media (min-width:700px) { .grid-3 { grid-template-columns: repeat(3,1fr); } .span-all { grid-column: 1/-1; } }
|
@media (min-width:700px) { .grid-3 { grid-template-columns: repeat(3,1fr); } .span-all { grid-column: 1/-1; } }
|
||||||
@@ -58,6 +58,7 @@
|
|||||||
.row a:hover { text-decoration: underline; }
|
.row a:hover { text-decoration: underline; }
|
||||||
.empty { text-align: center; padding: 40px; color: var(--muted); border: 1px dashed var(--border); border-radius: var(--r); }
|
.empty { text-align: center; padding: 40px; color: var(--muted); border: 1px dashed var(--border); border-radius: var(--r); }
|
||||||
.err { color: var(--err); font-size: 0.82rem; margin-top: 8px; }
|
.err { color: var(--err); font-size: 0.82rem; margin-top: 8px; }
|
||||||
|
.okmsg { color: var(--ok); font-size: 0.82rem; margin-top: 8px; }
|
||||||
.toast { position: fixed; bottom: 20px; right: 20px; padding: 10px 14px; background: var(--surface); border: 1px solid var(--ok); color: var(--ok); border-radius: 6px; opacity: 0; transition: .2s; z-index: 99; pointer-events: none; }
|
.toast { position: fixed; bottom: 20px; right: 20px; padding: 10px 14px; background: var(--surface); border: 1px solid var(--ok); color: var(--ok); border-radius: 6px; opacity: 0; transition: .2s; z-index: 99; pointer-events: none; }
|
||||||
.toast.on { opacity: 1; }
|
.toast.on { opacity: 1; }
|
||||||
.login-box { max-width: 380px; margin: 80px auto; }
|
.login-box { max-width: 380px; margin: 80px auto; }
|
||||||
@@ -66,11 +67,22 @@
|
|||||||
.type-item { padding: 8px 12px; cursor: pointer; font-size: 0.82rem; border-bottom: 1px solid var(--border); }
|
.type-item { padding: 8px 12px; cursor: pointer; font-size: 0.82rem; border-bottom: 1px solid var(--border); }
|
||||||
.type-item:hover, .type-item.on { background: var(--bg); color: var(--accent); }
|
.type-item:hover, .type-item.on { background: var(--bg); color: var(--accent); }
|
||||||
.field-builder { border: 1px solid var(--border); border-radius: 6px; padding: 10px; margin-top: 8px; }
|
.field-builder { border: 1px solid var(--border); border-radius: 6px; padding: 10px; margin-top: 8px; }
|
||||||
.fb-row { display: grid; grid-template-columns: 1fr 1fr 100px 60px 50px; gap: 6px; margin-bottom: 6px; align-items: end; }
|
.fb-row { display: grid; grid-template-columns: 1fr 1fr 110px 60px 50px; gap: 6px; margin-bottom: 6px; align-items: end; }
|
||||||
@media (max-width:700px) { .fb-row { grid-template-columns: 1fr 1fr; } }
|
@media (max-width:700px) { .fb-row { grid-template-columns: 1fr 1fr; } }
|
||||||
.toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; }
|
.toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; }
|
||||||
.chk { display: flex; align-items: center; gap: 6px; font-size: 0.82rem; color: var(--muted); cursor: pointer; }
|
.chk { display: flex; align-items: center; gap: 6px; font-size: 0.82rem; color: var(--muted); cursor: pointer; }
|
||||||
.chk input { width: auto; }
|
.chk input { width: auto; }
|
||||||
|
.tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid var(--border); padding-bottom: 0; flex-wrap: wrap; }
|
||||||
|
.tab {
|
||||||
|
padding: 10px 16px; background: transparent; border: none; border-bottom: 2px solid transparent;
|
||||||
|
color: var(--muted); cursor: pointer; font-size: 0.9rem; font-family: var(--font); margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
.tab:hover { color: var(--text); }
|
||||||
|
.tab.on { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
|
||||||
|
.img-preview { max-width: 220px; max-height: 160px; border-radius: 6px; border: 1px solid var(--border); margin-top: 8px; display: block; }
|
||||||
|
.img-preview.lg { max-width: 320px; max-height: 240px; }
|
||||||
|
.backup-info { font-size: 0.82rem; color: var(--muted); line-height: 1.7; margin: 10px 0 16px; }
|
||||||
|
.backup-actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -105,11 +117,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="panel">
|
<div class="tabs" id="mainTabs">
|
||||||
|
<button type="button" class="tab on" data-tab="add">添加凭证</button>
|
||||||
|
<button type="button" class="tab" data-tab="query">查询凭证</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section id="tabAdd" class="panel">
|
||||||
<h2>添加凭证</h2>
|
<h2>添加凭证</h2>
|
||||||
<div class="type-search">
|
<div class="type-search">
|
||||||
<label>凭证类型(可搜索)</label>
|
<label>凭证类型(可搜索)</label>
|
||||||
<input type="text" id="addTypeSearch" placeholder="搜索:交易所、邮箱、小红书…">
|
<input type="text" id="addTypeSearch" placeholder="搜索:交易所、助记词、邮箱…">
|
||||||
<div id="addTypeList" class="type-list hidden"></div>
|
<div id="addTypeList" class="type-list hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
<p id="addTypeSelected" class="sub" style="margin:8px 0">未选择类型</p>
|
<p id="addTypeSelected" class="sub" style="margin:8px 0">未选择类型</p>
|
||||||
@@ -120,31 +137,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section id="tabQuery" class="hidden">
|
||||||
<h2>查询凭证</h2>
|
<div class="panel">
|
||||||
<div class="search-row">
|
<h2>查询凭证</h2>
|
||||||
<div>
|
<div class="search-row">
|
||||||
<label>类型筛选</label>
|
<div>
|
||||||
<input type="text" id="queryTypeSearch" placeholder="搜索类型…">
|
<label>类型筛选</label>
|
||||||
<select id="queryType" class="hidden"></select>
|
<select id="queryType"></select>
|
||||||
|
</div>
|
||||||
|
<div id="queryExchangeWrap" class="hidden">
|
||||||
|
<label>交易所</label>
|
||||||
|
<select id="queryExchange">
|
||||||
|
<option value="binance">Binance</option>
|
||||||
|
<option value="okx">OKX</option>
|
||||||
|
<option value="gate">Gate</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>关键词(可选)</label>
|
||||||
|
<input type="text" id="queryQ" placeholder="名称、账号、邮箱、地址…">
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-p" id="btnQuery">确认</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="queryExchangeWrap" class="hidden">
|
|
||||||
<label>交易所</label>
|
|
||||||
<select id="queryExchange">
|
|
||||||
<option value="binance">Binance</option>
|
|
||||||
<option value="okx">OKX</option>
|
|
||||||
<option value="gate">Gate</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>关键词(可选)</label>
|
|
||||||
<input type="text" id="queryQ" placeholder="名称、账号、邮箱…">
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-p" id="btnQuery">确认</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<span id="listCount" class="sub">未查询</span>
|
<span id="listCount" class="sub">未查询</span>
|
||||||
<label class="chk"><input type="checkbox" id="maskToggle" checked> 隐藏敏感字段</label>
|
<label class="chk"><input type="checkbox" id="maskToggle" checked> 隐藏敏感字段</label>
|
||||||
@@ -155,11 +170,17 @@
|
|||||||
|
|
||||||
<div id="viewSettings" class="wrap hidden">
|
<div id="viewSettings" class="wrap hidden">
|
||||||
<div class="top">
|
<div class="top">
|
||||||
<div><h1>系统设置</h1><p class="sub">登录凭据写入 .env · 自定义凭证类型</p></div>
|
<div><h1>系统设置</h1><p class="sub">账户 · 自定义类型 · 备份恢复</p></div>
|
||||||
<button type="button" class="btn btn-g" id="btnBackApp">返回</button>
|
<button type="button" class="btn btn-g" id="btnBackApp">返回</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="panel">
|
<div class="tabs" id="settingsTabs">
|
||||||
|
<button type="button" class="tab on" data-stab="auth">账户密码</button>
|
||||||
|
<button type="button" class="tab" data-stab="types">自定义类型</button>
|
||||||
|
<button type="button" class="tab" data-stab="backup">备份恢复</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section id="stabAuth" class="panel">
|
||||||
<h2>登录账号(写入 .env)</h2>
|
<h2>登录账号(写入 .env)</h2>
|
||||||
<form id="authForm" class="grid grid-3">
|
<form id="authForm" class="grid grid-3">
|
||||||
<div><label>新用户名</label><input type="text" id="newUser" required></div>
|
<div><label>新用户名</label><input type="text" id="newUser" required></div>
|
||||||
@@ -170,7 +191,7 @@
|
|||||||
<p id="authErr" class="err hidden"></p>
|
<p id="authErr" class="err hidden"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section id="stabTypes" class="panel hidden">
|
||||||
<h2>自定义类型(如小红书、抖音)</h2>
|
<h2>自定义类型(如小红书、抖音)</h2>
|
||||||
<div class="type-search">
|
<div class="type-search">
|
||||||
<label>搜索已有自定义类型</label>
|
<label>搜索已有自定义类型</label>
|
||||||
@@ -191,12 +212,27 @@
|
|||||||
<button type="button" class="btn btn-p" id="btnSaveType" style="margin-top:12px">保存自定义类型</button>
|
<button type="button" class="btn btn-p" id="btnSaveType" style="margin-top:12px">保存自定义类型</button>
|
||||||
<p id="typeErr" class="err hidden"></p>
|
<p id="typeErr" class="err hidden"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="stabBackup" class="panel hidden">
|
||||||
|
<h2>备份与恢复</h2>
|
||||||
|
<div class="backup-info" id="backupInfo">加载中…</div>
|
||||||
|
<p class="sub" style="margin-bottom:12px">备份包含:.env、data.json、settings.json、uploads(图片)。服务器每日 03:00 自动备份到 /root/crypto_key_backups/。</p>
|
||||||
|
<div class="backup-actions">
|
||||||
|
<button type="button" class="btn btn-p" id="btnBackupDownload">下载备份</button>
|
||||||
|
<button type="button" class="btn btn-g" id="btnBackupNow">立即备份到服务器</button>
|
||||||
|
<label class="btn btn-g" style="cursor:pointer;margin:0">
|
||||||
|
选择备份文件恢复
|
||||||
|
<input type="file" id="restoreFile" accept=".tar.gz,.tgz,application/gzip" class="hidden">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p id="backupMsg" class="okmsg hidden"></p>
|
||||||
|
<p id="backupErr" class="err hidden"></p>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="toast" class="toast"></div>
|
<div id="toast" class="toast"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const SENSITIVE = new Set(["secret", "password"]);
|
|
||||||
const EX_LABEL = { binance: "Binance", okx: "OKX", gate: "Gate" };
|
const EX_LABEL = { binance: "Binance", okx: "OKX", gate: "Gate" };
|
||||||
|
|
||||||
let allTypes = [];
|
let allTypes = [];
|
||||||
@@ -204,16 +240,22 @@
|
|||||||
let masked = true;
|
let masked = true;
|
||||||
let queryActive = false;
|
let queryActive = false;
|
||||||
let displayed = [];
|
let displayed = [];
|
||||||
|
let imageDrafts = {};
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
async function api(path, opts = {}) {
|
async function api(path, opts = {}) {
|
||||||
|
const headers = { ...(opts.headers || {}) };
|
||||||
|
if (!(opts.body instanceof FormData) && !headers["Content-Type"]) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
|
|
||||||
...opts,
|
...opts,
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
const data = res.headers.get("content-type")?.includes("json") ? await res.json() : null;
|
const ct = res.headers.get("content-type") || "";
|
||||||
|
const data = ct.includes("json") ? await res.json() : null;
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
showLogin();
|
showLogin();
|
||||||
throw new Error("未登录");
|
throw new Error("未登录");
|
||||||
@@ -229,6 +271,13 @@
|
|||||||
toast._t = setTimeout(() => t.classList.remove("on"), 1800);
|
toast._t = setTimeout(() => t.classList.remove("on"), 1800);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSensitive(fd) {
|
||||||
|
if (!fd) return false;
|
||||||
|
if (fd.secret || fd.type === "secret") return true;
|
||||||
|
const k = fd.key || "";
|
||||||
|
return ["password", "secret", "mnemonic", "private_key", "webhook"].some(x => k.includes(x));
|
||||||
|
}
|
||||||
|
|
||||||
function mask(v) {
|
function mask(v) {
|
||||||
if (!masked || v == null || v === "") return v ?? "";
|
if (!masked || v == null || v === "") return v ?? "";
|
||||||
return "•".repeat(Math.min(String(v).length, 24));
|
return "•".repeat(Math.min(String(v).length, 24));
|
||||||
@@ -259,9 +308,32 @@
|
|||||||
function showSettings() {
|
function showSettings() {
|
||||||
$("viewApp").classList.add("hidden");
|
$("viewApp").classList.add("hidden");
|
||||||
$("viewSettings").classList.remove("hidden");
|
$("viewSettings").classList.remove("hidden");
|
||||||
|
switchSettingsTab("auth");
|
||||||
loadSettingsView();
|
loadSettingsView();
|
||||||
|
refreshBackupInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function switchMainTab(name) {
|
||||||
|
document.querySelectorAll("#mainTabs .tab").forEach(t => t.classList.toggle("on", t.dataset.tab === name));
|
||||||
|
$("tabAdd").classList.toggle("hidden", name !== "add");
|
||||||
|
$("tabQuery").classList.toggle("hidden", name !== "query");
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchSettingsTab(name) {
|
||||||
|
document.querySelectorAll("#settingsTabs .tab").forEach(t => t.classList.toggle("on", t.dataset.stab === name));
|
||||||
|
$("stabAuth").classList.toggle("hidden", name !== "auth");
|
||||||
|
$("stabTypes").classList.toggle("hidden", name !== "types");
|
||||||
|
$("stabBackup").classList.toggle("hidden", name !== "backup");
|
||||||
|
if (name === "backup") refreshBackupInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll("#mainTabs .tab").forEach(btn => {
|
||||||
|
btn.onclick = () => switchMainTab(btn.dataset.tab);
|
||||||
|
});
|
||||||
|
document.querySelectorAll("#settingsTabs .tab").forEach(btn => {
|
||||||
|
btn.onclick = () => switchSettingsTab(btn.dataset.stab);
|
||||||
|
});
|
||||||
|
|
||||||
function fieldVisible(fd, fields) {
|
function fieldVisible(fd, fields) {
|
||||||
const when = fd.when;
|
const when = fd.when;
|
||||||
if (!when) return true;
|
if (!when) return true;
|
||||||
@@ -295,6 +367,10 @@
|
|||||||
function readFormState(typeDef) {
|
function readFormState(typeDef) {
|
||||||
const state = {};
|
const state = {};
|
||||||
typeDef.fields.forEach(fd => {
|
typeDef.fields.forEach(fd => {
|
||||||
|
if (fd.type === "image") {
|
||||||
|
state[fd.key] = imageDrafts[fd.key] || "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
const inp = $("fld_" + fd.key);
|
const inp = $("fld_" + fd.key);
|
||||||
if (inp) state[fd.key] = inp.value;
|
if (inp) state[fd.key] = inp.value;
|
||||||
});
|
});
|
||||||
@@ -309,35 +385,80 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadImageFile(file) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const { res, data } = await api("/api/upload", { method: "POST", body: fd, headers: {} });
|
||||||
|
if (!res.ok) throw new Error(data?.error || "上传失败");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
function buildAddForm(typeDef) {
|
function buildAddForm(typeDef) {
|
||||||
const form = $("addForm");
|
const form = $("addForm");
|
||||||
form.innerHTML = "";
|
form.innerHTML = "";
|
||||||
|
imageDrafts = {};
|
||||||
if (!typeDef) return;
|
if (!typeDef) return;
|
||||||
typeDef.fields.forEach(fd => {
|
typeDef.fields.forEach(fd => {
|
||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
wrap.dataset.key = fd.key;
|
wrap.dataset.key = fd.key;
|
||||||
|
if (fd.type === "image" || fd.type === "textarea") wrap.classList.add("span-all");
|
||||||
const lab = document.createElement("label");
|
const lab = document.createElement("label");
|
||||||
lab.textContent = fd.label + (fd.required ? " *" : "");
|
lab.textContent = fd.label + (fd.required ? " *" : "");
|
||||||
let input;
|
wrap.appendChild(lab);
|
||||||
if (fd.type === "select") {
|
|
||||||
input = document.createElement("select");
|
if (fd.type === "image") {
|
||||||
|
const file = document.createElement("input");
|
||||||
|
file.type = "file";
|
||||||
|
file.accept = "image/*";
|
||||||
|
file.id = "fld_" + fd.key;
|
||||||
|
const preview = document.createElement("img");
|
||||||
|
preview.className = "img-preview hidden";
|
||||||
|
const status = document.createElement("p");
|
||||||
|
status.className = "sub";
|
||||||
|
status.style.marginTop = "6px";
|
||||||
|
file.onchange = async () => {
|
||||||
|
if (!file.files?.[0]) return;
|
||||||
|
status.textContent = "上传中…";
|
||||||
|
try {
|
||||||
|
const data = await uploadImageFile(file.files[0]);
|
||||||
|
imageDrafts[fd.key] = data.path;
|
||||||
|
preview.src = data.url;
|
||||||
|
preview.classList.remove("hidden");
|
||||||
|
status.textContent = "已上传";
|
||||||
|
} catch (e) {
|
||||||
|
status.textContent = e.message || "上传失败";
|
||||||
|
status.className = "err";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
wrap.appendChild(file);
|
||||||
|
wrap.appendChild(preview);
|
||||||
|
wrap.appendChild(status);
|
||||||
|
} else if (fd.type === "select") {
|
||||||
|
const input = document.createElement("select");
|
||||||
|
input.id = "fld_" + fd.key;
|
||||||
(fd.options || []).forEach(o => {
|
(fd.options || []).forEach(o => {
|
||||||
const opt = document.createElement("option");
|
const opt = document.createElement("option");
|
||||||
opt.value = o; opt.textContent = EX_LABEL[o] || o;
|
opt.value = o; opt.textContent = EX_LABEL[o] || o;
|
||||||
input.appendChild(opt);
|
input.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
input.addEventListener("change", () => syncFormVisibility(typeDef));
|
||||||
|
wrap.appendChild(input);
|
||||||
|
} else if (fd.type === "textarea" || fd.key === "mnemonic") {
|
||||||
|
const input = document.createElement("textarea");
|
||||||
|
input.id = "fld_" + fd.key;
|
||||||
|
input.placeholder = fd.key === "mnemonic" ? "输入助记词,单词之间用空格分隔" : "";
|
||||||
|
wrap.appendChild(input);
|
||||||
} else {
|
} else {
|
||||||
input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = fd.type === "secret" ? "password" : "text";
|
input.type = (fd.type === "secret" || isSensitive(fd)) ? "password" : "text";
|
||||||
|
input.id = "fld_" + fd.key;
|
||||||
if (fd.type === "url") input.placeholder = "https://";
|
if (fd.type === "url") input.placeholder = "https://";
|
||||||
if (fd.type === "email") input.placeholder = "user@mail.com";
|
if (fd.type === "email") input.placeholder = "user@mail.com";
|
||||||
|
const onUpd = () => syncFormVisibility(typeDef);
|
||||||
|
input.addEventListener("input", onUpd);
|
||||||
|
input.addEventListener("change", onUpd);
|
||||||
|
wrap.appendChild(input);
|
||||||
}
|
}
|
||||||
input.id = "fld_" + fd.key;
|
|
||||||
const onUpd = () => syncFormVisibility(typeDef);
|
|
||||||
input.addEventListener("input", onUpd);
|
|
||||||
input.addEventListener("change", onUpd);
|
|
||||||
wrap.appendChild(lab);
|
|
||||||
wrap.appendChild(input);
|
|
||||||
form.appendChild(wrap);
|
form.appendChild(wrap);
|
||||||
});
|
});
|
||||||
syncFormVisibility(typeDef);
|
syncFormVisibility(typeDef);
|
||||||
@@ -358,18 +479,17 @@
|
|||||||
if (fd.type === "url" && val.startsWith("http")) {
|
if (fd.type === "url" && val.startsWith("http")) {
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = val; a.target = "_blank"; a.rel = "noopener";
|
a.href = val; a.target = "_blank"; a.rel = "noopener";
|
||||||
a.textContent = mask(val);
|
a.textContent = isSensitive(fd) ? mask(val) : val;
|
||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
if (fd.type === "email" && val.includes("@")) {
|
if (fd.type === "email" && val.includes("@")) {
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = "mailto:" + val;
|
a.href = "mailto:" + val;
|
||||||
a.textContent = mask(val);
|
a.textContent = isSensitive(fd) ? mask(val) : val;
|
||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
const span = document.createElement("span");
|
const span = document.createElement("span");
|
||||||
span.textContent = SENSITIVE.has(fd.type) || fd.key.includes("password") || fd.key.includes("secret") || fd.key === "webhook"
|
span.textContent = isSensitive(fd) ? mask(val) : val;
|
||||||
? mask(val) : mask(val);
|
|
||||||
return span;
|
return span;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,7 +500,7 @@
|
|||||||
const h = document.createElement("div");
|
const h = document.createElement("div");
|
||||||
h.className = "card-h";
|
h.className = "card-h";
|
||||||
const left = document.createElement("div");
|
const left = document.createElement("div");
|
||||||
left.innerHTML = `<span class="badge">${typeDef?.label || rec.type_id}</span> <strong style="margin-left:8px">${mask(rec.title)}</strong>`;
|
left.innerHTML = `<span class="badge">${typeDef?.label || rec.type_id}</span> <strong style="margin-left:8px">${esc(rec.title)}</strong>`;
|
||||||
const del = document.createElement("button");
|
const del = document.createElement("button");
|
||||||
del.className = "btn btn-d"; del.textContent = "删除";
|
del.className = "btn btn-d"; del.textContent = "删除";
|
||||||
del.onclick = async () => {
|
del.onclick = async () => {
|
||||||
@@ -400,9 +520,22 @@
|
|||||||
if (val === undefined || val === "") return;
|
if (val === undefined || val === "") return;
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "row";
|
row.className = "row";
|
||||||
|
if (fd.type === "image") row.style.alignItems = "flex-start";
|
||||||
row.innerHTML = `<span class="row-l">${fd.label}</span>`;
|
row.innerHTML = `<span class="row-l">${fd.label}</span>`;
|
||||||
const v = document.createElement("div");
|
const v = document.createElement("div");
|
||||||
v.className = "row-v";
|
v.className = "row-v";
|
||||||
|
|
||||||
|
if (fd.type === "image") {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.className = "img-preview lg";
|
||||||
|
img.src = `/api/uploads/${encodeURIComponent(val)}`;
|
||||||
|
img.alt = fd.label;
|
||||||
|
v.appendChild(img);
|
||||||
|
row.appendChild(v);
|
||||||
|
card.appendChild(row);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (fd.key === "web_url" && val.startsWith("http")) {
|
if (fd.key === "web_url" && val.startsWith("http")) {
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = val; a.target = "_blank"; a.textContent = mask(val);
|
a.href = val; a.target = "_blank"; a.textContent = mask(val);
|
||||||
@@ -420,8 +553,7 @@
|
|||||||
v.appendChild(renderValue(fd, val));
|
v.appendChild(renderValue(fd, val));
|
||||||
}
|
}
|
||||||
row.appendChild(v);
|
row.appendChild(v);
|
||||||
const isSec = fd.type === "secret" || fd.key.includes("password") || fd.key === "webhook" || fd.key.includes("secret");
|
if (isSensitive(fd) || ["text","phone","select","textarea"].includes(fd.type)) {
|
||||||
if (isSec || ["text","phone","select"].includes(fd.type)) {
|
|
||||||
const btn = document.createElement("button");
|
const btn = document.createElement("button");
|
||||||
btn.type = "button"; btn.className = "btn btn-s"; btn.textContent = "复制";
|
btn.type = "button"; btn.className = "btn btn-s"; btn.textContent = "复制";
|
||||||
btn.onclick = () => copyText(val, btn);
|
btn.onclick = () => copyText(val, btn);
|
||||||
@@ -432,6 +564,10 @@
|
|||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? "").replace(/[&<>"']/g, c => ({ "&":"&","<":"<",">":">","\"":""","'":"'" }[c]));
|
||||||
|
}
|
||||||
|
|
||||||
function renderList() {
|
function renderList() {
|
||||||
const box = $("listBox");
|
const box = $("listBox");
|
||||||
box.innerHTML = "";
|
box.innerHTML = "";
|
||||||
@@ -453,8 +589,25 @@
|
|||||||
const { data } = await api("/api/settings");
|
const { data } = await api("/api/settings");
|
||||||
allTypes = [...data.builtin_types, ...data.custom_types];
|
allTypes = [...data.builtin_types, ...data.custom_types];
|
||||||
const sel = $("queryType");
|
const sel = $("queryType");
|
||||||
sel.innerHTML = allTypes.map(t => `<option value="${t.id}">${t.label}</option>`).join("");
|
const prev = sel.value;
|
||||||
|
sel.innerHTML = '<option value="">全部类型</option>' + allTypes.map(t => `<option value="${t.id}">${t.label}</option>`).join("");
|
||||||
|
if (prev && [...sel.options].some(o => o.value === prev)) sel.value = prev;
|
||||||
$("newUser").value = data.username || "";
|
$("newUser").value = data.username || "";
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshBackupInfo() {
|
||||||
|
try {
|
||||||
|
const { data } = await api("/api/backup/status");
|
||||||
|
const size = data.last_backup_size ? ` · ${(data.last_backup_size / 1024).toFixed(1)} KB` : "";
|
||||||
|
$("backupInfo").innerHTML = `
|
||||||
|
最近备份:${data.last_backup || "尚无"} ${size}<br>
|
||||||
|
备份目录:${data.auto_backup_dir || "-"}<br>
|
||||||
|
最近恢复:${data.last_restore || "无"}
|
||||||
|
`;
|
||||||
|
} catch {
|
||||||
|
$("backupInfo").textContent = "无法获取备份状态";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$("loginForm").onsubmit = async (e) => {
|
$("loginForm").onsubmit = async (e) => {
|
||||||
@@ -502,23 +655,11 @@
|
|||||||
if (!res.ok) { $("addErr").textContent = data?.error || "添加失败"; $("addErr").classList.remove("hidden"); return; }
|
if (!res.ok) { $("addErr").textContent = data?.error || "添加失败"; $("addErr").classList.remove("hidden"); return; }
|
||||||
$("addForm").innerHTML = "";
|
$("addForm").innerHTML = "";
|
||||||
selectedAddType = null;
|
selectedAddType = null;
|
||||||
|
imageDrafts = {};
|
||||||
$("addTypeSearch").value = "";
|
$("addTypeSearch").value = "";
|
||||||
$("addTypeSelected").textContent = "未选择类型";
|
$("addTypeSelected").textContent = "未选择类型";
|
||||||
queryActive = false;
|
toast("已添加,请到「查询凭证」查看");
|
||||||
displayed = [];
|
switchMainTab("query");
|
||||||
masked = true;
|
|
||||||
$("maskToggle").checked = true;
|
|
||||||
renderList();
|
|
||||||
toast("已添加,请查询查看");
|
|
||||||
};
|
|
||||||
|
|
||||||
$("queryTypeSearch").oninput = () => {
|
|
||||||
const q = $("queryTypeSearch").value.toLowerCase();
|
|
||||||
const sel = $("queryType");
|
|
||||||
sel.classList.remove("hidden");
|
|
||||||
[...sel.options].forEach(o => {
|
|
||||||
o.hidden = q && !o.text.toLowerCase().includes(q) && !o.value.includes(q);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
$("queryType").onchange = () => {
|
$("queryType").onchange = () => {
|
||||||
@@ -528,9 +669,12 @@
|
|||||||
$("btnQuery").onclick = async () => {
|
$("btnQuery").onclick = async () => {
|
||||||
const type_id = $("queryType").value;
|
const type_id = $("queryType").value;
|
||||||
const q = $("queryQ").value.trim();
|
const q = $("queryQ").value.trim();
|
||||||
let url = `/api/credentials?type_id=${encodeURIComponent(type_id)}`;
|
let url = `/api/credentials?`;
|
||||||
if (q) url += `&q=${encodeURIComponent(q)}`;
|
const params = [];
|
||||||
if (type_id === "exchange") url += `&exchange=${encodeURIComponent($("queryExchange").value)}`;
|
if (type_id) params.push(`type_id=${encodeURIComponent(type_id)}`);
|
||||||
|
if (q) params.push(`q=${encodeURIComponent(q)}`);
|
||||||
|
if (type_id === "exchange") params.push(`exchange=${encodeURIComponent($("queryExchange").value)}`);
|
||||||
|
url += params.join("&");
|
||||||
const { data } = await api(url);
|
const { data } = await api(url);
|
||||||
displayed = data;
|
displayed = data;
|
||||||
queryActive = true;
|
queryActive = true;
|
||||||
@@ -561,7 +705,15 @@
|
|||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<input placeholder="key" class="fb-key">
|
<input placeholder="key" class="fb-key">
|
||||||
<input placeholder="显示名" class="fb-label">
|
<input placeholder="显示名" class="fb-label">
|
||||||
<select class="fb-type"><option value="text">文本</option><option value="secret">敏感</option><option value="url">链接</option><option value="email">邮箱</option><option value="phone">手机</option></select>
|
<select class="fb-type">
|
||||||
|
<option value="text">文本</option>
|
||||||
|
<option value="secret">敏感</option>
|
||||||
|
<option value="textarea">多行</option>
|
||||||
|
<option value="url">链接</option>
|
||||||
|
<option value="email">邮箱</option>
|
||||||
|
<option value="phone">手机</option>
|
||||||
|
<option value="image">图片</option>
|
||||||
|
</select>
|
||||||
<label class="chk"><input type="checkbox" class="fb-req">必填</label>
|
<label class="chk"><input type="checkbox" class="fb-req">必填</label>
|
||||||
<button type="button" class="btn btn-d fb-del">删</button>`;
|
<button type="button" class="btn btn-d fb-del">删</button>`;
|
||||||
row.querySelector(".fb-del").onclick = () => row.remove();
|
row.querySelector(".fb-del").onclick = () => row.remove();
|
||||||
@@ -602,7 +754,7 @@
|
|||||||
customs.forEach(t => {
|
customs.forEach(t => {
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
div.className = "card";
|
div.className = "card";
|
||||||
div.innerHTML = `<strong>${t.label}</strong> <code style="color:var(--muted)">${t.id}</code> · ${t.fields.length} 个字段`;
|
div.innerHTML = `<strong>${esc(t.label)}</strong> <code style="color:var(--muted)">${esc(t.id)}</code> · ${t.fields.length} 个字段`;
|
||||||
const btn = document.createElement("button");
|
const btn = document.createElement("button");
|
||||||
btn.className = "btn btn-d"; btn.textContent = "删除"; btn.style.marginTop = "8px";
|
btn.className = "btn btn-d"; btn.textContent = "删除"; btn.style.marginTop = "8px";
|
||||||
btn.onclick = async () => {
|
btn.onclick = async () => {
|
||||||
@@ -619,10 +771,76 @@
|
|||||||
|
|
||||||
$("customSearch").oninput = loadSettingsView;
|
$("customSearch").oninput = loadSettingsView;
|
||||||
|
|
||||||
|
$("btnBackupDownload").onclick = async () => {
|
||||||
|
$("backupErr").classList.add("hidden");
|
||||||
|
$("backupMsg").classList.add("hidden");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/backup/export", { credentials: "same-origin" });
|
||||||
|
if (res.status === 401) { showLogin(); return; }
|
||||||
|
if (!res.ok) throw new Error("下载失败");
|
||||||
|
const blob = await res.blob();
|
||||||
|
const cd = res.headers.get("Content-Disposition") || "";
|
||||||
|
const m = cd.match(/filename="?([^"]+)"?/);
|
||||||
|
const name = m ? m[1] : `crypto_key_backup_${Date.now()}.tar.gz`;
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = name;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
$("backupMsg").textContent = "备份已下载";
|
||||||
|
$("backupMsg").classList.remove("hidden");
|
||||||
|
refreshBackupInfo();
|
||||||
|
} catch (e) {
|
||||||
|
$("backupErr").textContent = e.message || "下载失败";
|
||||||
|
$("backupErr").classList.remove("hidden");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$("btnBackupNow").onclick = async () => {
|
||||||
|
$("backupErr").classList.add("hidden");
|
||||||
|
$("backupMsg").classList.add("hidden");
|
||||||
|
const { res, data } = await api("/api/backup/now", { method: "POST", body: "{}" });
|
||||||
|
if (!res.ok) {
|
||||||
|
$("backupErr").textContent = data?.error || "备份失败";
|
||||||
|
$("backupErr").classList.remove("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$("backupMsg").textContent = "已备份到:" + (data.path || data.last_backup_path || "");
|
||||||
|
$("backupMsg").classList.remove("hidden");
|
||||||
|
refreshBackupInfo();
|
||||||
|
toast("服务器备份完成");
|
||||||
|
};
|
||||||
|
|
||||||
|
$("restoreFile").onchange = async () => {
|
||||||
|
const file = $("restoreFile").files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (!confirm("恢复将覆盖当前 .env、data.json、settings.json 与 uploads,确定继续?")) {
|
||||||
|
$("restoreFile").value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$("backupErr").classList.add("hidden");
|
||||||
|
$("backupMsg").classList.add("hidden");
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const { res, data } = await api("/api/backup/restore", { method: "POST", body: fd, headers: {} });
|
||||||
|
$("restoreFile").value = "";
|
||||||
|
if (!res.ok) {
|
||||||
|
$("backupErr").textContent = data?.error || "恢复失败";
|
||||||
|
$("backupErr").classList.remove("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$("backupMsg").textContent = data.message || "恢复成功";
|
||||||
|
$("backupMsg").classList.remove("hidden");
|
||||||
|
toast("恢复成功");
|
||||||
|
await loadTypes();
|
||||||
|
refreshBackupInfo();
|
||||||
|
};
|
||||||
|
|
||||||
async function initApp() {
|
async function initApp() {
|
||||||
await loadTypes();
|
await loadTypes();
|
||||||
$("queryExchangeWrap").classList.toggle("hidden", $("queryType").value !== "exchange");
|
$("queryExchangeWrap").classList.toggle("hidden", $("queryType").value !== "exchange");
|
||||||
if (!$("fieldRows").children.length) addFieldRow();
|
if (!$("fieldRows").children.length) addFieldRow();
|
||||||
|
switchMainTab("add");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function boot() {
|
async function boot() {
|
||||||
|
|||||||
@@ -1,68 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Ubuntu 一键安装 — 部署到 /opt/crypto_key
|
# 兼容入口:转调交互式管理脚本的一键部署
|
||||||
# 用法: sudo bash scripts/install-ubuntu.sh
|
# 推荐直接使用: sudo bash scripts/manage.sh
|
||||||
|
|
||||||
set -e
|
set -euo pipefail
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
REPO_URL="https://git.bz121.com/dekun/crypto_key.git"
|
exec bash "${SCRIPT_DIR}/manage.sh" deploy
|
||||||
INSTALL_DIR="/opt/crypto_key"
|
|
||||||
APP_USER="${SUDO_USER:-$USER}"
|
|
||||||
|
|
||||||
echo "==> 安装系统依赖..."
|
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
|
||||||
apt-get update -qq
|
|
||||||
apt-get install -y -qq python3 python3-venv python3-pip git curl ca-certificates
|
|
||||||
|
|
||||||
if ! command -v node >/dev/null 2>&1; then
|
|
||||||
echo "==> 安装 Node.js (用于 PM2)..."
|
|
||||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
|
||||||
apt-get install -y -qq nodejs
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! command -v pm2 >/dev/null 2>&1; then
|
|
||||||
echo "==> 安装 PM2..."
|
|
||||||
npm install -g pm2
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "==> 拉取代码到 ${INSTALL_DIR} ..."
|
|
||||||
mkdir -p /opt
|
|
||||||
if [ -d "${INSTALL_DIR}/.git" ]; then
|
|
||||||
cd "${INSTALL_DIR}"
|
|
||||||
git pull origin main 2>/dev/null || git pull origin master 2>/dev/null || git pull
|
|
||||||
else
|
|
||||||
git clone "${REPO_URL}" "${INSTALL_DIR}"
|
|
||||||
cd "${INSTALL_DIR}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "==> 创建 Python 虚拟环境..."
|
|
||||||
python3 -m venv venv
|
|
||||||
source venv/bin/activate
|
|
||||||
pip install -q --upgrade pip
|
|
||||||
pip install -q -r requirements.txt
|
|
||||||
|
|
||||||
mkdir -p logs
|
|
||||||
chmod +x pm2-start.sh pm2-stop.sh 2>/dev/null || true
|
|
||||||
|
|
||||||
if [ -n "${APP_USER}" ] && [ "${APP_USER}" != "root" ]; then
|
|
||||||
chown -R "${APP_USER}:${APP_USER}" "${INSTALL_DIR}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "==> 启动 PM2 守护..."
|
|
||||||
cd "${INSTALL_DIR}"
|
|
||||||
pm2 delete api-key-manager 2>/dev/null || true
|
|
||||||
pm2 start ecosystem.config.cjs
|
|
||||||
pm2 save
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=========================================="
|
|
||||||
echo " 安装完成"
|
|
||||||
echo " 目录: ${INSTALL_DIR}"
|
|
||||||
LAN_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
|
|
||||||
echo " 本机: http://127.0.0.1:5200"
|
|
||||||
echo " 局域网: http://${LAN_IP:-<你的IP>}:5200"
|
|
||||||
echo " 状态: pm2 status"
|
|
||||||
echo " 日志: pm2 logs api-key-manager"
|
|
||||||
echo "=========================================="
|
|
||||||
echo ""
|
|
||||||
echo "开机自启请执行: pm2 startup"
|
|
||||||
echo "按提示复制 sudo 命令执行后,再运行: pm2 save"
|
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# crypto_key 交互式管理脚本
|
||||||
|
# 用法: sudo bash scripts/manage.sh
|
||||||
|
# 菜单: 1 一键部署 | 2 更新(git pull) | 3 一键卸载
|
||||||
|
# 严禁使用 scp;所有代码更新一律 git pull
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_URL="https://git.bz121.com/dekun/crypto_key.git"
|
||||||
|
INSTALL_DIR="/opt/crypto_key"
|
||||||
|
APP_NAME="api-key-manager"
|
||||||
|
BACKUP_ROOT="/root/crypto_key_backups"
|
||||||
|
CRON_MARKER="# crypto_key-auto-backup"
|
||||||
|
|
||||||
|
red() { printf '\033[31m%s\033[0m\n' "$*"; }
|
||||||
|
grn() { printf '\033[32m%s\033[0m\n' "$*"; }
|
||||||
|
ylw() { printf '\033[33m%s\033[0m\n' "$*"; }
|
||||||
|
info() { echo "==> $*"; }
|
||||||
|
|
||||||
|
require_root() {
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
red "请使用 root 或 sudo 运行: sudo bash scripts/manage.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_app_user() {
|
||||||
|
echo "${SUDO_USER:-${USER:-root}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_deps() {
|
||||||
|
info "安装系统依赖..."
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq python3 python3-venv python3-pip git curl ca-certificates
|
||||||
|
|
||||||
|
if ! command -v node >/dev/null 2>&1; then
|
||||||
|
info "安装 Node.js 20(PM2 需要)..."
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||||
|
apt-get install -y -qq nodejs
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v pm2 >/dev/null 2>&1; then
|
||||||
|
info "安装 PM2..."
|
||||||
|
npm install -g pm2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_python() {
|
||||||
|
info "创建/更新 Python 虚拟环境..."
|
||||||
|
cd "${INSTALL_DIR}"
|
||||||
|
python3 -m venv venv
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -q --upgrade pip
|
||||||
|
pip install -q -r requirements.txt
|
||||||
|
mkdir -p logs uploads
|
||||||
|
chmod +x pm2-start.sh pm2-stop.sh scripts/manage.sh scripts/install-ubuntu.sh 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_env() {
|
||||||
|
cd "${INSTALL_DIR}"
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
if [ -f .env.example ]; then
|
||||||
|
cp .env.example .env
|
||||||
|
ylw "已从 .env.example 生成 .env(首次启动会自动补全密钥)"
|
||||||
|
else
|
||||||
|
touch .env
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_backup_cron() {
|
||||||
|
mkdir -p "${BACKUP_ROOT}"
|
||||||
|
local cron_line="0 3 * * * cd ${INSTALL_DIR} && ./venv/bin/python -c \"from backup import create_backup_archive; create_backup_archive()\" >> ${BACKUP_ROOT}/cron.log 2>&1 ${CRON_MARKER}"
|
||||||
|
# 先去掉旧标记行,再写入
|
||||||
|
(crontab -l 2>/dev/null | grep -v "${CRON_MARKER}" || true; echo "${cron_line}") | crontab -
|
||||||
|
info "已配置每日 03:00 自动备份到 ${BACKUP_ROOT}"
|
||||||
|
}
|
||||||
|
|
||||||
|
remove_backup_cron() {
|
||||||
|
if crontab -l 2>/dev/null | grep -q "${CRON_MARKER}"; then
|
||||||
|
crontab -l 2>/dev/null | grep -v "${CRON_MARKER}" | crontab - || true
|
||||||
|
info "已移除自动备份 cron"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
start_pm2() {
|
||||||
|
local app_user
|
||||||
|
app_user="$(detect_app_user)"
|
||||||
|
cd "${INSTALL_DIR}"
|
||||||
|
if [ -n "${app_user}" ] && [ "${app_user}" != "root" ]; then
|
||||||
|
chown -R "${app_user}:${app_user}" "${INSTALL_DIR}" || true
|
||||||
|
fi
|
||||||
|
# 以调用用户身份跑 PM2(若可能)
|
||||||
|
if [ -n "${app_user}" ] && [ "${app_user}" != "root" ] && id "${app_user}" >/dev/null 2>&1; then
|
||||||
|
sudo -u "${app_user}" bash -c "cd '${INSTALL_DIR}' && pm2 delete '${APP_NAME}' 2>/dev/null || true; pm2 start ecosystem.config.cjs && pm2 save"
|
||||||
|
else
|
||||||
|
pm2 delete "${APP_NAME}" 2>/dev/null || true
|
||||||
|
pm2 start ecosystem.config.cjs
|
||||||
|
pm2 save
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
do_deploy() {
|
||||||
|
info "一键部署到 ${INSTALL_DIR}"
|
||||||
|
install_deps
|
||||||
|
mkdir -p /opt
|
||||||
|
if [ -d "${INSTALL_DIR}/.git" ]; then
|
||||||
|
info "目录已存在,执行 git pull 更新代码(不用 scp)..."
|
||||||
|
cd "${INSTALL_DIR}"
|
||||||
|
git pull origin main 2>/dev/null || git pull origin master 2>/dev/null || git pull
|
||||||
|
else
|
||||||
|
info "git clone ${REPO_URL} ..."
|
||||||
|
git clone "${REPO_URL}" "${INSTALL_DIR}"
|
||||||
|
fi
|
||||||
|
setup_env
|
||||||
|
setup_python
|
||||||
|
setup_backup_cron
|
||||||
|
start_pm2
|
||||||
|
|
||||||
|
local lan_ip
|
||||||
|
lan_ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
|
||||||
|
echo ""
|
||||||
|
grn "========== 部署完成 =========="
|
||||||
|
echo " 目录: ${INSTALL_DIR}"
|
||||||
|
echo " 本机: http://127.0.0.1:5200"
|
||||||
|
echo " 局域网: http://${lan_ip:-<IP>}:5200"
|
||||||
|
echo " 自动备份: ${BACKUP_ROOT}"
|
||||||
|
echo " 状态: pm2 status"
|
||||||
|
echo " 日志: pm2 logs ${APP_NAME}"
|
||||||
|
echo "=============================="
|
||||||
|
echo ""
|
||||||
|
echo "开机自启: pm2 startup (按提示执行 sudo 命令后再 pm2 save)"
|
||||||
|
}
|
||||||
|
|
||||||
|
do_update() {
|
||||||
|
if [ ! -d "${INSTALL_DIR}/.git" ]; then
|
||||||
|
red "未找到 ${INSTALL_DIR},请先选择「1 一键部署」"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
info "更新(仅 git pull,严禁 scp)..."
|
||||||
|
cd "${INSTALL_DIR}"
|
||||||
|
|
||||||
|
# 更新前备份关键数据
|
||||||
|
mkdir -p "${BACKUP_ROOT}"
|
||||||
|
if [ -f "${INSTALL_DIR}/venv/bin/python" ]; then
|
||||||
|
"${INSTALL_DIR}/venv/bin/python" -c "from backup import create_backup_archive; print(create_backup_archive())" \
|
||||||
|
&& grn "更新前已自动备份" || ylw "更新前备份跳过(可手动备份)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
git fetch --all --prune
|
||||||
|
git pull origin main 2>/dev/null || git pull origin master 2>/dev/null || git pull
|
||||||
|
|
||||||
|
setup_python
|
||||||
|
setup_backup_cron
|
||||||
|
start_pm2
|
||||||
|
grn "更新完成(git pull + 依赖 + PM2 重启)"
|
||||||
|
}
|
||||||
|
|
||||||
|
do_uninstall() {
|
||||||
|
ylw "即将卸载 crypto_key"
|
||||||
|
echo " - 停止并删除 PM2 进程 ${APP_NAME}"
|
||||||
|
echo " - 移除自动备份 cron"
|
||||||
|
echo " - 删除目录 ${INSTALL_DIR}"
|
||||||
|
echo " - 保留 ${BACKUP_ROOT}(如存在)"
|
||||||
|
read -r -p "确认卸载?输入 yes 继续: " confirm
|
||||||
|
if [ "${confirm}" != "yes" ]; then
|
||||||
|
echo "已取消"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local app_user
|
||||||
|
app_user="$(detect_app_user)"
|
||||||
|
if [ -n "${app_user}" ] && [ "${app_user}" != "root" ] && id "${app_user}" >/dev/null 2>&1; then
|
||||||
|
sudo -u "${app_user}" pm2 delete "${APP_NAME}" 2>/dev/null || true
|
||||||
|
sudo -u "${app_user}" pm2 save 2>/dev/null || true
|
||||||
|
else
|
||||||
|
pm2 delete "${APP_NAME}" 2>/dev/null || true
|
||||||
|
pm2 save 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
remove_backup_cron
|
||||||
|
|
||||||
|
if [ -d "${INSTALL_DIR}" ]; then
|
||||||
|
# 卸载前再备份一次
|
||||||
|
mkdir -p "${BACKUP_ROOT}"
|
||||||
|
local stamp
|
||||||
|
stamp="$(date +%Y%m%d_%H%M%S)"
|
||||||
|
tar czf "${BACKUP_ROOT}/uninstall_backup_${stamp}.tar.gz" \
|
||||||
|
-C "${INSTALL_DIR}" .env data.json settings.json uploads 2>/dev/null || true
|
||||||
|
rm -rf "${INSTALL_DIR}"
|
||||||
|
grn "已删除 ${INSTALL_DIR}"
|
||||||
|
fi
|
||||||
|
grn "卸载完成。备份目录保留在: ${BACKUP_ROOT}"
|
||||||
|
}
|
||||||
|
|
||||||
|
show_menu() {
|
||||||
|
echo ""
|
||||||
|
echo "========================================"
|
||||||
|
echo " crypto_key 管理脚本"
|
||||||
|
echo " 仓库: ${REPO_URL}"
|
||||||
|
echo " 部署: ${INSTALL_DIR}"
|
||||||
|
echo "========================================"
|
||||||
|
echo " 1) 一键部署"
|
||||||
|
echo " 2) 更新(git pull,严禁 scp)"
|
||||||
|
echo " 3) 一键卸载"
|
||||||
|
echo " 0) 退出"
|
||||||
|
echo "========================================"
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
require_root
|
||||||
|
# 非交互:支持参数直接执行
|
||||||
|
if [ "${1:-}" = "deploy" ] || [ "${1:-}" = "1" ]; then do_deploy; exit 0; fi
|
||||||
|
if [ "${1:-}" = "update" ] || [ "${1:-}" = "2" ]; then do_update; exit 0; fi
|
||||||
|
if [ "${1:-}" = "uninstall" ] || [ "${1:-}" = "3" ]; then do_uninstall; exit 0; fi
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
show_menu
|
||||||
|
read -r -p "请选择 [0-3]: " choice
|
||||||
|
case "${choice}" in
|
||||||
|
1) do_deploy ;;
|
||||||
|
2) do_update ;;
|
||||||
|
3) do_uninstall ;;
|
||||||
|
0) echo "再见"; exit 0 ;;
|
||||||
|
*) red "无效选项" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
+36
-1
@@ -94,6 +94,32 @@ BUILTIN_TYPES = [
|
|||||||
{"key": "note", "label": "备注", "type": "text"},
|
{"key": "note", "label": "备注", "type": "text"},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "wallet_mnemonic",
|
||||||
|
"label": "钱包助记词",
|
||||||
|
"builtin": True,
|
||||||
|
"fields": [
|
||||||
|
{"key": "title", "label": "名称", "type": "text", "required": True},
|
||||||
|
{"key": "chain", "label": "链/网络", "type": "text"},
|
||||||
|
{"key": "mnemonic", "label": "助记词", "type": "textarea", "required": True, "secret": True},
|
||||||
|
{"key": "address", "label": "钱包地址", "type": "text"},
|
||||||
|
{"key": "image", "label": "图片", "type": "image"},
|
||||||
|
{"key": "note", "label": "备注", "type": "text"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wallet_key",
|
||||||
|
"label": "钱包密钥",
|
||||||
|
"builtin": True,
|
||||||
|
"fields": [
|
||||||
|
{"key": "title", "label": "名称", "type": "text", "required": True},
|
||||||
|
{"key": "chain", "label": "链/网络", "type": "text"},
|
||||||
|
{"key": "private_key", "label": "私钥", "type": "secret", "required": True},
|
||||||
|
{"key": "address", "label": "钱包地址", "type": "text"},
|
||||||
|
{"key": "image", "label": "图片", "type": "image"},
|
||||||
|
{"key": "note", "label": "备注", "type": "text"},
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -208,7 +234,7 @@ def save_records(records: list):
|
|||||||
|
|
||||||
|
|
||||||
def record_title(type_id: str, fields: dict) -> str:
|
def record_title(type_id: str, fields: dict) -> str:
|
||||||
for key in ("title", "username", "email", "wechat_id", "qq_number"):
|
for key in ("title", "username", "email", "wechat_id", "qq_number", "address"):
|
||||||
if fields.get(key):
|
if fields.get(key):
|
||||||
return str(fields[key])
|
return str(fields[key])
|
||||||
if type_id == "exchange":
|
if type_id == "exchange":
|
||||||
@@ -218,6 +244,15 @@ def record_title(type_id: str, fields: dict) -> str:
|
|||||||
return "未命名"
|
return "未命名"
|
||||||
|
|
||||||
|
|
||||||
|
def is_sensitive_field(fd: dict) -> bool:
|
||||||
|
if fd.get("secret"):
|
||||||
|
return True
|
||||||
|
if fd.get("type") in ("secret",):
|
||||||
|
return True
|
||||||
|
key = fd.get("key", "")
|
||||||
|
return any(x in key for x in ("password", "secret", "mnemonic", "private_key", "webhook"))
|
||||||
|
|
||||||
|
|
||||||
def _field_applies(fd: dict, fields_in: dict) -> bool:
|
def _field_applies(fd: dict, fields_in: dict) -> bool:
|
||||||
when = fd.get("when")
|
when = fd.get("when")
|
||||||
if not when:
|
if not when:
|
||||||
|
|||||||
Reference in New Issue
Block a user