From 3e52b8eb879deccb665d097557c1a59579203723 Mon Sep 17 00:00:00 2001 From: dekun Date: Fri, 17 Jul 2026 20:35:24 +0800 Subject: [PATCH] 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 --- .gitignore | 4 + CHANGELOG.md | 63 +++++-- DEPLOY.md | 273 +++++++++++----------------- README.md | 25 ++- USAGE.md | 130 ++++++-------- app.py | 116 ++++++++++-- backup.py | 189 ++++++++++++++++++++ index.html | 362 ++++++++++++++++++++++++++++++-------- scripts/install-ubuntu.sh | 71 +------- scripts/manage.sh | 232 ++++++++++++++++++++++++ storage.py | 37 +++- 11 files changed, 1081 insertions(+), 421 deletions(-) create mode 100644 backup.py create mode 100644 scripts/manage.sh diff --git a/.gitignore b/.gitignore index 5f8150a..567bcc7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ data.json settings.json .env __pycache__/ +uploads/ +backups/ +backup_meta.json +.restore_tmp_*/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ed6aa5e..dd8a178 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 升级) -1. 备份现有 `data.json`: - ```bash - cp data.json data.json.bak - ``` -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` +1. 备份现有 `data.json` +2. 安装新依赖并配置 `.env` +3. 启动服务,旧明文数据将自动迁移为加密格式 +4. PM2:`pm2 restart api-key-manager` > **重要**:升级后切勿删除或修改 `.env` 中的 `DATA_ENCRYPTION_KEY`,否则无法解密已有数据。 @@ -101,5 +131,4 @@ - [ ] 可选 HTTPS / 反向代理说明模板 - [ ] 凭证编辑(当前仅支持添加、删除) -- [ ] 导出/导入加密备份包 - [ ] 登录失败次数限制 diff --git a/DEPLOY.md b/DEPLOY.md index a05a6e6..3c97489 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -2,7 +2,8 @@ > 仓库:[https://git.bz121.com/dekun/crypto_key](https://git.bz121.com/dekun/crypto_key.git) > 部署路径:**`/opt/crypto_key`** -> 进程守护:**PM2** · 端口:**5200** +> 进程守护:**PM2** · 端口:**5200** +> **代码更新一律使用 `git pull`,严禁 scp** --- @@ -16,13 +17,16 @@ → .env (登录账号 + 加密主密钥) → data.json (加密后的凭证数据) → settings.json (自定义类型配置) + → uploads/ (图片附件) + → /root/crypto_key_backups/ (每日自动备份) ``` | 文件 | 说明 | 是否提交 Git | |------|------|----------------| | `.env` | `SECRET_KEY`、`AUTH_*`、`DATA_ENCRYPTION_KEY` | 否 | | `data.json` | 加密凭证库 | 否 | -| `settings.json` | 自定义类型定义 | 否(建议备份) | +| `settings.json` | 自定义类型定义 | 否 | +| `uploads/` | 上传的图片 | 否 | | `venv/` | Python 虚拟环境 | 否 | --- @@ -34,21 +38,12 @@ | 系统 | Ubuntu 20.04 / 22.04 / 24.04 LTS(Windows 见附录) | | Python | 3.8+ | | Node.js | 16+(PM2 守护时需要) | -| 权限 | `sudo`(一键安装脚本) | +| 权限 | `sudo`(管理脚本) | | 磁盘 | ≥ 200MB | -### Python 依赖 - -``` -flask>=3.0.0 -python-dotenv>=1.0.0 -cryptography>=42.0.0 -werkzeug>=3.0.0 -``` - --- -## 三、首次部署(Ubuntu 推荐) +## 三、交互式一键部署(推荐) ### 3.1 克隆仓库 @@ -58,114 +53,88 @@ sudo git clone https://git.bz121.com/dekun/crypto_key.git /opt/crypto_key cd /opt/crypto_key ``` -### 3.2 配置环境变量 +### 3.2 运行管理脚本 ```bash -cp .env.example .env +sudo bash scripts/manage.sh ``` -编辑 `.env`(若留占位符,首次启动会自动生成合法密钥): +交互菜单: -| 变量 | 说明 | -|------|------| -| `SECRET_KEY` | Flask 会话密钥,随机长字符串 | -| `AUTH_USERNAME` | 登录用户名 | -| `AUTH_PASSWORD` | 登录密码(保存为哈希) | -| `DATA_ENCRYPTION_KEY` | Fernet 密钥,用于加密 data.json | +``` +1) 一键部署 +2) 更新(git pull,严禁 scp) +3) 一键卸载 +0) 退出 +``` -生成 Fernet 密钥: +也可直接传参: ```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 -sudo bash scripts/install-ubuntu.sh -``` +### 3.3 部署脚本会做什么 -脚本将:安装系统依赖 → 创建 `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 验证 ```bash pm2 status -# api-key-manager 应为 online - curl -s http://127.0.0.1:5200/api/auth/status # {"logged_in":false} ``` -浏览器访问: - -| 场景 | 地址 | -|------|------| -| 服务器本机 | http://127.0.0.1:5200 | -| 局域网 | `http://<服务器IP>:5200` | - -```bash -hostname -I | awk '{print $1}' -``` - -首次登录默认可能是 `admin` / `admin123`,请登录后在 **系统设置** 中修改。 +浏览器:`http://127.0.0.1:5200` 或 `http://<服务器IP>:5200` +默认可能是 `admin` / `admin123`,请尽快修改。 --- -## 四、手动部署(逐步) - -### 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 环境 +## 四、更新升级(仅 git pull) ```bash 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 pip install -r requirements.txt -``` - -### 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 +pm2 restart api-key-manager ``` --- -## 五、Windows 本地部署 +## 五、一键卸载 + +```bash +sudo bash scripts/manage.sh +# 选择 3) 一键卸载,输入 yes 确认 +``` + +会停止 PM2、移除 cron、删除 `/opt/crypto_key`;卸载前尽量再打一份包到 `/root/crypto_key_backups/`,**保留备份目录**。 + +--- + +## 六、Windows 本地部署 ```powershell cd C:\path\to\crypto_key @@ -173,93 +142,63 @@ python -m venv venv .\venv\Scripts\activate pip install -r requirements.txt copy .env.example .env -# 编辑 .env 或首次运行 python app.py 自动生成 python app.py ``` -PM2 守护(可选): - -```powershell -npm install -g pm2 -.\pm2-start.ps1 -``` +自动备份目录回退为项目下 `backups\`(无 `/root` 写权限时)。 --- -## 六、PM2 运维 +## 七、PM2 运维 | 命令 | 说明 | |------|------| | `pm2 status` | 查看状态 | | `pm2 logs api-key-manager` | 查看日志 | | `pm2 restart api-key-manager` | 重启 | -| `pm2 stop api-key-manager` | 停止 | | `./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 -sudo cp /opt/crypto_key/.env /opt/crypto_key/.env.bak -sudo cp /opt/crypto_key/data.json /opt/crypto_key/data.json.bak -sudo cp /opt/crypto_key/settings.json /opt/crypto_key/settings.json.bak 2>/dev/null || true +# 解压到项目目录(示例) +cd /opt/crypto_key +tar xzvf /root/crypto_key_backups/某备份.tar.gz +pm2 restart api-key-manager ``` -### 8.2 恢复 - -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 启动 -``` +**`.env` 中 `DATA_ENCRYPTION_KEY` 必须与备份一致。** --- ## 九、防火墙 -服务监听 **0.0.0.0:5200**,局域网可访问。建议限制来源: - ```bash -# 仅允许局域网段(示例) 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` 与依赖 | -| 无法登录 | 检查 `.env` 中 `AUTH_*`;删除错误密码哈希后重启,用自动生成默认账号 | +| PM2 `errored` | `pm2 logs api-key-manager --err` | +| 无法登录 | 检查 `.env` 中 `AUTH_*` | | 数据无法解密 | `DATA_ENCRYPTION_KEY` 是否与备份一致 | -| 局域网无法访问 | `ss -tlnp \| grep 5200` 应显示 `0.0.0.0:5200`;检查 ufw | -| 401 未登录 | 重新登录;检查 `SECRET_KEY` 是否被改动(会导致 Session 失效) | -| 旧明文 data.json | 启动后自动迁移;迁移前务必备份 | +| 局域网无法访问 | `ss -tlnp \| grep 5200`;检查 ufw | +| 图片 404 | 确认 `uploads/` 存在且备份已含图片文件 | --- @@ -280,24 +218,20 @@ sudo ufw allow 5200/tcp ``` /opt/crypto_key/ -├── app.py # Flask 入口 -├── storage.py # 加密存储与迁移 -├── env_config.py # .env 读写 -├── index.html # 前端(登录 + 主界面 + 设置) +├── app.py +├── storage.py +├── backup.py +├── env_config.py +├── index.html ├── requirements.txt -├── ecosystem.config.cjs # PM2 -├── .env.example -├── settings.json # 自定义类型(运行时) +├── ecosystem.config.cjs ├── scripts/ -│ └── install-ubuntu.sh -├── pm2-start.sh / pm2-stop.sh -├── pm2-start.ps1 / pm2-stop.ps1 / pm2-restart.ps1 +│ ├── manage.sh # 交互式:部署 / 更新 / 卸载 +│ └── install-ubuntu.sh # 兼容入口 → deploy +├── uploads/ # 图片(运行时) ├── logs/ ├── venv/ -├── README.md -├── USAGE.md # 使用说明 -├── DEPLOY.md # 本文档 -└── CHANGELOG.md # 更新说明 +└── ... ``` --- @@ -305,14 +239,15 @@ sudo ufw allow 5200/tcp ## 十二、命令速查 ```bash -# 首次部署 +# 首次 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 -# 日常 -pm2 status -pm2 restart api-key-manager -pm2 logs api-key-manager --lines 50 +# 更新(git pull only) +sudo bash scripts/manage.sh # 选 2 + +# 卸载 +sudo bash scripts/manage.sh # 选 3 ``` 使用细节请参阅 **[USAGE.md](./USAGE.md)**。 diff --git a/README.md b/README.md index 5bb7cc2..fce44bd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # crypto_key — 凭证保险库 -本地 / 局域网 Web 工具,集中管理交易所 API、网站、邮箱、机器人、微信/QQ 及自定义类型凭证。数据 **AES 加密** 存储,**用户名密码登录**,浏览器不缓存明文。 +本地 / 局域网 Web 工具,集中管理交易所 API、钱包助记词/密钥、网站、邮箱、机器人、微信/QQ 及自定义类型凭证。数据 **AES 加密** 存储,**用户名密码登录**,浏览器不缓存明文。 | 项目 | 说明 | |------|------| @@ -44,22 +44,30 @@ python app.py ## 主要功能 - 登录鉴权(凭据在 `.env`,Session 在服务端) -- 内置类型:交易所、网站、邮箱、企微/钉钉机器人、微信、QQ +- 内置类型:交易所、**钱包助记词**、**钱包密钥**、网站、邮箱、企微/钉钉机器人、微信、QQ +- **图片上传**:助记词/密钥等可附带截图 - 自定义类型:系统设置中添加(小红书、抖音等) -- 查询后显示 + 类型/关键词搜索 + 敏感字段打码(复制为明文) +- Tab 界面:添加凭证 / 查询凭证;查询类型为下拉筛选 +- 系统设置 Tab:账户密码 / 自定义类型 / **备份恢复** +- 每日自动备份到 `/root/crypto_key_backups/`(无权限时回退到项目 `backups/`) - 旧版明文 `data.json` 自动迁移加密 --- -## Ubuntu 一键部署 +## Ubuntu 一键部署(交互式) ```bash 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 +sudo bash scripts/manage.sh ``` +菜单: + +1. 一键部署 +2. 更新(**仅 git pull**,严禁 scp) +3. 一键卸载 + 详见 [DEPLOY.md](./DEPLOY.md)。 --- @@ -70,10 +78,11 @@ sudo bash scripts/install-ubuntu.sh |------|------| | `app.py` | Flask 后端 | | `storage.py` | 加密存储与迁移 | +| `backup.py` | 备份/恢复与每日自动备份 | | `env_config.py` | `.env` 管理 | | `index.html` | 前端单页 | | `ecosystem.config.cjs` | PM2 配置 | -| `scripts/install-ubuntu.sh` | Ubuntu 安装脚本 | +| `scripts/manage.sh` | 交互式部署/更新/卸载 | --- @@ -81,7 +90,7 @@ sudo bash scripts/install-ubuntu.sh - 仅在可信局域网使用,勿将 5200 映射到公网 - 定期备份 `.env` 与 `data.json`(密钥丢失将无法解密) -- 勿将 `.env`、`data.json` 提交到 Git +- 勿将 `.env`、`data.json`、`uploads/` 提交到 Git --- diff --git a/USAGE.md b/USAGE.md index 3fea646..b24ef94 100644 --- a/USAGE.md +++ b/USAGE.md @@ -9,6 +9,7 @@ **crypto_key** 是一款在浏览器中使用的本地/局域网凭证管理工具,用于集中保存: - 交易所 API(Binance、OKX、Gate) +- **钱包助记词**、**钱包密钥**(支持图片附件) - 网站账号(可点击打开网址) - 邮箱账户(可点击发邮件或打开网页登录) - 企业微信 / 钉钉机器人 Webhook @@ -31,7 +32,7 @@ 3. 首次部署时,若未配置 `.env`,系统可能自动生成默认账号: - 用户名:`admin` - 密码:`admin123` -4. **登录后请立即进入「系统设置」修改用户名和密码。** +4. **登录后请立即进入「系统设置 → 账户密码」修改。** > 登录状态保存在服务端 Session(HttpOnly Cookie),关闭浏览器后需重新登录(约 30 分钟无操作也会失效)。 @@ -39,139 +40,112 @@ ## 3. 界面总览 -登录成功后主界面包含三块: +登录后顶部为两个主 Tab: -| 区域 | 作用 | -|------|------| -| **添加凭证** | 选择类型、填写字段、保存 | -| **查询凭证** | 按类型/关键词筛选,点击「确认」后展示列表 | -| **凭证列表** | 展示查询结果;支持打码显示与复制 | +| Tab | 作用 | +|-----|------| +| **添加凭证** | 选择类型、填写字段(可含图片)、保存 | +| **查询凭证** | 类型下拉筛选 + 关键词,确认后展示列表 | 右上角: -- **系统设置**:修改登录账号、管理自定义类型 +- **系统设置**:账户密码 / 自定义类型 / 备份恢复 - **退出**:清除登录状态 --- ## 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 | -| 微信 | 名称、微信号、手机号 | -| QQ | 名称、QQ 号、手机号 | +| 企业微信 / 钉钉机器人 | 名称、Webhook | +| 微信 / QQ | 名称、微信号或 QQ 号、手机号 | | 自定义类型 | 按你在系统设置中定义的字段填写 | -### 4.3 提交 - -1. 填写完成后点击 **「添加」**。 -2. 添加成功后会提示:**不会自动显示在列表中**。 -3. 需要到「查询凭证」区域选择类型并点击 **「确认」** 才能查看。 - --- ## 5. 查询凭证 -### 5.1 基本查询 - -1. 在「查询凭证」中选择或搜索 **类型**(下拉框支持输入过滤)。 -2. (可选)在 **关键词** 中输入名称、账号、邮箱等片段。 -3. 若类型为 **交易所 API**,可再选 Binance / OKX / Gate。 -4. 点击右侧 **「确认」**。 -5. 列表显示该条件下所有记录。 - -### 5.2 列表操作 +1. 切换到 **「查询凭证」** Tab。 +2. 在 **类型筛选** 下拉框选择类型(或「全部类型」)。 +3. (可选)关键词:名称、账号、邮箱、地址等。 +4. 若类型为交易所 API,可再选 Binance / OKX / Gate。 +5. 点击 **「确认」** 显示列表。 | 操作 | 说明 | |------|------| -| **隐藏敏感字段** | 勾选后界面以圆点显示密码、Key 等;**复制按钮仍为真实明文** | -| **复制** | 每条字段右侧「复制」按钮 | -| **链接** | 网站「网址」、邮箱「邮箱地址」、邮箱「网页登录地址」可点击跳转 | -| **删除** | 卡片右上角「删除」,需确认 | - -### 5.3 未查询时 - -默认显示:「选择类型并点击「确认」查看」——不会展示任何凭证内容。 +| **隐藏敏感字段** | 勾选后以圆点显示;复制仍为明文 | +| **复制** | 字段右侧「复制」 | +| **图片** | 助记词/密钥等记录可预览已上传图片 | +| **删除** | 卡片右上角,需确认 | --- ## 6. 系统设置 -点击右上角 **「系统设置」** 进入。 +右上角进入,内含三个 Tab: -### 6.1 修改登录账号(写入 .env) +### 6.1 账户密码 -1. 填写 **新用户名**、**新密码**(至少 6 位)。 -2. 填写 **当前密码** 以确认身份。 -3. 点击 **「保存登录设置」**。 -4. 修改后请使用新账号重新登录。 +修改登录用户名/密码(写入 `.env`),需填写当前密码确认。 -> 登录凭据保存在服务器 `.env` 文件中,不会出现在浏览器缓存。 +### 6.2 自定义类型 -### 6.2 自定义凭证类型 +新增类型时字段类型可选:文本 / 敏感 / 多行 / 链接 / 邮箱 / 手机 / **图片**。 -适用于小红书、抖音、快手等未内置的类型。 +删除自定义类型会同时删除该类型下所有凭证。 -**新增类型:** +### 6.3 备份恢复 -1. **类型 ID**:英文小写+下划线,如 `xiaohongshu`(创建后不可改)。 -2. **显示名称**:如 `小红书`。 -3. **字段列表**:点击「+ 添加字段」 - - `key`:英文标识,如 `account` - - `显示名`:如 `账号` - - `类型`:文本 / 敏感 / 链接 / 邮箱 / 手机 - - **必填**:是否必填 -4. 点击 **「保存自定义类型」**。 +| 操作 | 说明 | +|------|------| +| **下载备份** | 浏览器下载 `.tar.gz`(含 .env、data.json、settings.json、uploads) | +| **立即备份到服务器** | 写入 `/root/crypto_key_backups/`(无权限时为项目 `backups/`) | +| **选择备份文件恢复** | 上传 `.tar.gz` 覆盖当前数据 | -**搜索 / 删除:** +服务器每日 **03:00** 自动备份(应用内线程 + 部署时安装的 cron)。 -- 顶部搜索框可过滤已有自定义类型。 -- 「删除」会同时删除该类型下的 **所有凭证记录**。 +> 恢复后 `.env` 中的 `DATA_ENCRYPTION_KEY` 必须与备份一致,否则无法解密。 --- ## 7. 安全与使用建议 1. **仅在可信局域网使用**,不要将 5200 端口映射到公网。 -2. **定期备份** `data.json` 和 `.env`(见 [DEPLOY.md](./DEPLOY.md) 备份章节)。 -3. **不要** 将 `.env`、`data.json` 提交到 Git 或发给他人。 -4. 若更换服务器,需同时拷贝 `.env` 与 `data.json`,否则无法解密数据。 -5. 复制敏感内容后,尽快粘贴使用,避免长时间留在系统剪贴板。 +2. 使用「备份恢复」或定期检查 `/root/crypto_key_backups/`。 +3. **不要** 将 `.env`、`data.json`、`uploads/` 提交到 Git。 +4. 助记词/私钥属于高敏感信息,复制后尽快清除剪贴板。 --- ## 8. 常见问题 **Q:登录提示用户名或密码错误?** -A:检查 `.env` 中 `AUTH_USERNAME` / `AUTH_PASSWORD`;或使用部署时的默认 `admin` / `admin123` 后在设置中修改。 +A:检查 `.env` 中 `AUTH_*`;或默认 `admin` / `admin123` 后在设置中修改。 **Q:添加成功但查询不到?** -A:确认查询的类型与添加时一致;交易所需选对 Binance/OKX/Gate。 +A:确认查询类型与添加时一致;可先选「全部类型」再搜关键词。 -**Q:局域网手机打不开?** -A:确认手机与服务器同一 WiFi;防火墙放行 5200;使用 `http://服务器IP:5200` 而非 127.0.0.1。 +**Q:图片上传失败?** +A:仅支持 jpg/png/gif/webp/bmp,单文件不超过约 16MB。 -**Q:复制按钮无效?** -A:使用 `http://` 访问(非 `file://`);浏览器需允许剪贴板权限。 +**Q:恢复备份后打不开数据?** +A:确认备份包内 `.env` 的 `DATA_ENCRYPTION_KEY` 完整。 -**Q:更新代码后无法登录或数据打不开?** -A:执行 `pip install -r requirements.txt` 并重启 PM2;确认 `.env` 中 `DATA_ENCRYPTION_KEY` 未变。 - -**Q:旧版明文 data.json 还能用吗?** -A:可以。服务启动后首次读取会自动迁移为加密格式,建议迁移前备份原文件。 +**Q:更新代码怎么做?** +A:服务器上 `sudo bash scripts/manage.sh` → 选 **2 更新**(仅 git pull,不要用 scp)。 --- diff --git a/app.py b/app.py index 4ea3abf..4e529b3 100644 --- a/app.py +++ b/app.py @@ -5,13 +5,17 @@ import re import uuid 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 from storage import ( BUILTIN_TYPES, - all_type_defs, filter_records, get_type_def, load_records, @@ -29,9 +33,14 @@ app.config.update( SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax", PERMANENT_SESSION_LIFETIME=1800, + MAX_CONTENT_LENGTH=16 * 1024 * 1024, ) 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"} @@ -94,6 +103,7 @@ def get_settings(): "builtin_types": BUILTIN_TYPES, "custom_types": s.get("custom_types", []), "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 if not fields: return jsonify({"error": "至少添加一个字段"}), 400 + allowed_types = {"text", "secret", "url", "email", "phone", "select", "textarea", "image"} norm_fields = [] for f in fields: key = (f.get("key") or "").strip().lower() if not re.match(r"^[a-z][a-z0-9_]{0,31}$", key): return jsonify({"error": f"无效字段 key: {key}"}), 400 - norm_fields.append( - { - "key": key, - "label": (f.get("label") or key).strip(), - "type": f.get("type") or "text", - "required": bool(f.get("required")), - } - ) + ftype = f.get("type") or "text" + if ftype not in allowed_types: + return jsonify({"error": f"无效字段类型: {ftype}"}), 400 + entry = { + "key": key, + "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() entry = {"id": type_id, "label": label, "builtin": False, "fields": norm_fields} s["custom_types"].append(entry) @@ -164,6 +179,82 @@ def delete_custom_type(type_id): 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/", 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"]) @login_required def list_credentials(): @@ -177,6 +268,8 @@ def list_credentials(): for r in records if r.get("type_id") == "exchange" and r.get("fields", {}).get("exchange") == ex ] + if q: + records = filter_records(records, q=q) else: records = filter_records(records, type_id=type_id or None, q=q or None) return jsonify(records) @@ -222,5 +315,8 @@ def accounts_delete_compat(account_id): return delete_credential(account_id) +backup_mod.start_auto_backup_scheduler() + + if __name__ == "__main__": app.run(host="0.0.0.0", port=5200, debug=False) diff --git a/backup.py b/backup.py new file mode 100644 index 0000000..7d2ff8e --- /dev/null +++ b/backup.py @@ -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() diff --git a/index.html b/index.html index b5874dc..d7293c5 100644 --- a/index.html +++ b/index.html @@ -26,7 +26,7 @@ border-radius: 6px; color: var(--text); font-size: 0.82rem; } 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); } .grid { display: grid; gap: 10px; } @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; } .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; } + .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.on { opacity: 1; } .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: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; } - .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; } } .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 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; } @@ -105,11 +117,16 @@ -
+
+ + +
+ +

添加凭证

未选择类型

@@ -120,31 +137,29 @@
-
-

查询凭证

-
-
- - - + - -
未查询 @@ -155,11 +170,17 @@