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:
dekun
2026-07-17 20:35:24 +08:00
parent 703fd912f8
commit 3e52b8eb87
11 changed files with 1081 additions and 421 deletions
+4
View File
@@ -4,3 +4,7 @@ data.json
settings.json settings.json
.env .env
__pycache__/ __pycache__/
uploads/
backups/
backup_meta.json
.restore_tmp_*/
+46 -17
View File
@@ -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 / 反向代理说明模板
- [ ] 凭证编辑(当前仅支持添加、删除) - [ ] 凭证编辑(当前仅支持添加、删除)
- [ ] 导出/导入加密备份包
- [ ] 登录失败次数限制 - [ ] 登录失败次数限制
+104 -169
View File
@@ -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 LTSWindows 见附录) | | 系统 | Ubuntu 20.04 / 22.04 / 24.04 LTSWindows 见附录) |
| 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)**。
+17 -8
View File
@@ -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
--- ---
+52 -78
View File
@@ -9,6 +9,7 @@
**crypto_key** 是一款在浏览器中使用的本地/局域网凭证管理工具,用于集中保存: **crypto_key** 是一款在浏览器中使用的本地/局域网凭证管理工具,用于集中保存:
- 交易所 APIBinance、OKX、Gate - 交易所 APIBinance、OKX、Gate
- **钱包助记词**、**钱包密钥**(支持图片附件)
- 网站账号(可点击打开网址) - 网站账号(可点击打开网址)
- 邮箱账户(可点击发邮件或打开网页登录) - 邮箱账户(可点击发邮件或打开网页登录)
- 企业微信 / 钉钉机器人 Webhook - 企业微信 / 钉钉机器人 Webhook
@@ -31,7 +32,7 @@
3. 首次部署时,若未配置 `.env`,系统可能自动生成默认账号: 3. 首次部署时,若未配置 `.env`,系统可能自动生成默认账号:
- 用户名:`admin` - 用户名:`admin`
- 密码:`admin123` - 密码:`admin123`
4. **登录后请立即进入「系统设置」修改用户名和密码** 4. **登录后请立即进入「系统设置 → 账户密码」修改。**
> 登录状态保存在服务端 SessionHttpOnly Cookie),关闭浏览器后需重新登录(约 30 分钟无操作也会失效)。 > 登录状态保存在服务端 SessionHttpOnly 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 SecretOKX 额外必填密码 |
| 钱包助记词 | 名称、链/网络、助记词、地址、图片、备注 |
| 钱包密钥 | 名称、链/网络、私钥、地址、图片、备注 |
| 网站账号 | 名称、网址、用户名、密码 | | 网站账号 | 名称、网址、用户名、密码 |
| 邮箱账户 | 名称、邮箱地址、密码;可选网页登录地址 | | 邮箱账户 | 名称、邮箱地址、密码;可选网页登录地址 |
| 企业微信机器人 | 名称、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:可以。服务启动后首次读取会自动迁移为加密格式,建议迁移前备份原文件。
--- ---
+106 -10
View File
@@ -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)
+189
View File
@@ -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
View File
@@ -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 => ({ "&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;" }[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() {
+5 -66
View File
@@ -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"
+232
View File
@@ -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 20PM2 需要)..."
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
View File
@@ -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: