feat: 初始化 kdbx-viewer 项目
实现服务端解密的 KeePass 网页查看器,包含登录门户口令与验证码、RSA+会话级 AES 加密通道、审计日志持久化、HTTPS 自动证书、Docker 部署配置及端到端测试。
这个提交包含在:
+15
@@ -0,0 +1,15 @@
|
||||
node_modules
|
||||
vault
|
||||
ssl
|
||||
logs
|
||||
.env
|
||||
.env.example
|
||||
.git
|
||||
.gitignore
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
e2e-test.js
|
||||
test-decrypt.js
|
||||
cookies.txt
|
||||
*.log
|
||||
*.bak
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# 复制为 .env 或直接写在 docker-compose 的 environment 中
|
||||
# 敏感值(口令固定串、SESSION_SECRET)建议用环境变量注入,不要写死进 config.js
|
||||
|
||||
# APP 门户口令:dynamic=当天日期+固定串,static=固定口令
|
||||
APP_PW_MODE=dynamic
|
||||
APP_PW_DATE_FMT=yyyymmdd
|
||||
APP_PW_DATE_POS=prefix
|
||||
APP_PW_DYNAMIC=你的固定部分
|
||||
APP_PW_STATIC=改成你的强口令
|
||||
|
||||
# 密码库 / 密钥文件路径(容器内)
|
||||
KDBX_PATH=/app/vault/vault.kdbx
|
||||
KEYFILE_PATH=/app/vault/vault.key
|
||||
|
||||
# 权限:false=只读(默认),true=可编辑(需把 compose 里 vault 挂载改 :rw)
|
||||
WRITABLE=false
|
||||
|
||||
# 会话
|
||||
SESSION_SECRET=改成随机长字符串
|
||||
SESSION_MAX_AGE=1800000
|
||||
|
||||
# 审计日志:本地文件持久化目录(按大小自动切分,默认 ./logs)
|
||||
LOG_DIR=logs
|
||||
# 审计日志单文件切分阈值:日志文件达到该体积后滚动切分为 audit.1.log / audit.2.log ...
|
||||
# 支持单位 K/M/G(不写单位按字节)。默认 5M(即 5242880 字节)
|
||||
AUDIT_FILE_MAX=100M
|
||||
# 审计日志切分文件最多保留份数(audit.1.log ~ audit.N.log,超出最旧的被删除)。默认 10
|
||||
AUDIT_FILE_KEEP=10
|
||||
|
||||
# HTTPS(安全前置要求):提供证书后自动以 HTTPS 启动,并把 HTTP 重定向到 HTTPS
|
||||
# 未提供证书时,系统会自动生成一份自签证书(默认写入 SSL_KEY/SSL_CERT 路径)并启用 HTTPS
|
||||
# - 本地:直接 node gen-cert.js 可预生成;docker 场景下 compose 已挂载 ./ssl 持久化证书
|
||||
# - 生产环境请替换为受信任 CA 签发的证书
|
||||
SSL_KEY=ssl/key.pem
|
||||
SSL_CERT=ssl/cert.pem
|
||||
|
||||
PORT=3000
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.env
|
||||
ssl/
|
||||
logs/
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
# 低权用户,避免以 root 运行
|
||||
RUN addgroup -S app && adduser -S app -G app
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev && npm cache clean --force
|
||||
|
||||
COPY . .
|
||||
RUN chown -R app:app /app
|
||||
USER app
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,187 @@
|
||||
# kdbx-viewer
|
||||
|
||||
一个**服务端解密**的 KeePass(`.kdbx`)网页查看器,定位为 [KeeWeb](https://keeweb.info/) 的轻量自托管替代品。
|
||||
|
||||
核心思想:**密钥文件与明文永不出服务端**。浏览器只提交“用服务端公钥加密后的口令”,服务端用内存中的私钥解密、加载数据库、再用**会话级 AES** 把响应体加密后回传,前端解密渲染。明文仅在服务端进程内存中存在,落盘只有密文。
|
||||
|
||||
---
|
||||
|
||||
## 特性
|
||||
|
||||
- **服务端解密**:kdbx 主密码 / keyfile 在服务端内存中校验,前端不接触明文主密码、不接触 keyfile。
|
||||
- **通道加密**:登录协商会话级 AES-256-GCM 密钥,所有 API 响应体均加密传输;传输用 RSA-OAEP(SHA256) 保护登录/解锁口令。
|
||||
- **动态密钥**:每次登录重新协商 AES 密钥,默认 1 小时过期后需重新解锁。
|
||||
- **HTTPS 强制**:检测到证书即自动启用 HTTPS 并把 HTTP 301 重定向到 HTTPS;**未配置证书时,系统会自动生成一份自签证书并默认启用 HTTPS**(便于容器/一键部署即安全)。生产环境请替换为受信任 CA 签发的证书。
|
||||
- **门户口令 + 验证码**:登录需通过门户口令(支持 `日期+固定串` 动态口令或固定口令)与图形验证码,防爆破。
|
||||
- **IP 封锁**:单 IP 5 次失败或触发风险行为后封锁 30 分钟。
|
||||
- **审计日志**:
|
||||
- 记录完整来源信息:真实客户端 IP、内网/外网类型、直连地址(remote)、完整 `X-Forwarded-For` 链、UA、Referer、方法、路径、状态码、业务错误码等。
|
||||
- 本地文件持久化(JSON Lines),按大小自动切分(`audit.log` → `audit.1.log` …),并维护 **索引文件** `audit.index.json` 记录各文件起始/结束序号、条数、总条数。
|
||||
- 登录后可访问独立审计页 `/audit.html`,支持分页、筛选(IP / IP 类型 / 错误码 / 方法 / 路径 / 仅失败 / 时间区间)。
|
||||
- **后端搜索**:条目搜索在服务端完成,仅回传加密后的命中结果。
|
||||
- **只读 / 可编辑**:默认只读(数据库绝不被改动);可配置 `WRITABLE=true` 开放网页端编辑并写回。
|
||||
- **安全响应头**:CSP、X-Frame-Options、X-Content-Type-Options、Referrer-Policy 等。
|
||||
|
||||
---
|
||||
|
||||
## 快速开始(本地)
|
||||
|
||||
```bash
|
||||
# 1. 安装依赖
|
||||
npm install
|
||||
|
||||
# 2. 准备环境变量(复制示例并修改)
|
||||
cp .env.example .env
|
||||
# 编辑 .env:设置 APP_PW_DYNAMIC / APP_PW_STATIC、SESSION_SECRET 等
|
||||
|
||||
# 3.(可选)生成本地自签证书用于 HTTPS
|
||||
node gen-cert.js # 生成 ssl/key.pem、ssl/cert.pem
|
||||
|
||||
# 4. 放入你的密码库
|
||||
mkdir -p vault
|
||||
cp your.kdbx vault/vault.kdbx
|
||||
# 若使用 keyfile:cp your.key vault/vault.key
|
||||
|
||||
# 5. 启动
|
||||
npm start
|
||||
```
|
||||
|
||||
访问 `https://localhost:3000`(无证书时为 `http://localhost:3000`)。
|
||||
|
||||
> 测试:`npm test`(解密验证)、`npm run test:e2e`(端到端流程 + 审计校验)。
|
||||
|
||||
---
|
||||
|
||||
## 部署(Docker)
|
||||
|
||||
所有配置通过**同目录的 `.env` 文件注入**,无需在 `docker-compose.yml` 中写死(`compose` 会自动读取 `.env` 并用 `${VAR}` 替换)。
|
||||
|
||||
```bash
|
||||
# 1. 准备 .env(复制示例并修改敏感项)
|
||||
cp .env.example .env
|
||||
# 编辑 .env:设置 APP_PW_DYNAMIC / APP_PW_STATIC、SESSION_SECRET 等
|
||||
|
||||
# 2. 构建并启动
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
默认仅绑定 `127.0.0.1:8080`,建议前置 Nginx / Caddy 做 HTTPS 反代。
|
||||
|
||||
关键挂载与配置:
|
||||
|
||||
| 挂载 | 说明 |
|
||||
| --- | --- |
|
||||
| `./vault:/app/vault:ro` | 密码库目录,**只读**;开启 `WRITABLE=true` 须改 `:rw` |
|
||||
| `./logs:/app/logs` | 审计日志与索引持久化 |
|
||||
| `./ssl:/app/ssl:rw` | 证书目录;**未提供证书时容器会自动生成自签证书到此目录并持久化**,避免重建后证书变化 |
|
||||
|
||||
> 证书说明:若 `SSL_KEY`/`SSL_CERT` 指向的文件不存在,服务会自动生成自签证书并启用 HTTPS;生产环境请将受信任证书挂载到该路径覆盖默认值。
|
||||
|
||||
---
|
||||
|
||||
## 配置项
|
||||
|
||||
所有配置优先读取**同名环境变量**(便于 Docker / 密管注入),未设置时回退 `config.js` 默认值。
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `APP_PW_MODE` | `dynamic` | 门户口令模式:`dynamic`(日期+固定串)或 `static`(固定口令) |
|
||||
| `APP_PW_DATE_FMT` | `yyyymmdd` | 动态口令日期格式 |
|
||||
| `APP_PW_DATE_POS` | `prefix` | 日期位置:`prefix` 或 `suffix` |
|
||||
| `APP_PW_DYNAMIC` | 空 | 动态口令的固定串部分(**敏感,用环境变量注入**) |
|
||||
| `APP_PW_STATIC` | 空 | 静态模式下的固定口令(**敏感**) |
|
||||
| `KDBX_PATH` | `/app/vault/vault.kdbx` | 密码库路径 |
|
||||
| `KEYFILE_PATH` | `/app/vault/vault.key` | keyfile 路径(可选) |
|
||||
| `WRITABLE` | `false` | 是否开放网页端编辑写回 |
|
||||
| `SESSION_SECRET` | 空(随机) | 会话签名密钥(**敏感,生产必填**) |
|
||||
| `SESSION_MAX_AGE` | `1800000` | 会话有效期(毫秒,默认 30 分钟) |
|
||||
| `LOG_DIR` | `./logs` | 审计日志目录(含 `audit.index.json`) |
|
||||
| `AUDIT_FILE_MAX` | `5M` | 单日志文件切分阈值,支持 `K/M/G` 单位 |
|
||||
| `AUDIT_FILE_KEEP` | `10` | 切分文件最大保留份数 |
|
||||
| `SSL_KEY` | `ssl/key.pem` | HTTPS 私钥路径 |
|
||||
| `SSL_CERT` | `ssl/cert.pem` | HTTPS 证书路径 |
|
||||
| `PORT` | `3000` | 监听端口 |
|
||||
|
||||
---
|
||||
|
||||
## 审计日志
|
||||
|
||||
- **位置**:`LOG_DIR` 下,`audit.log`(当前)与切分文件 `audit.1.log … audit.N.log`,以及索引 `audit.index.json`。
|
||||
- **索引文件** `audit.index.json` 结构:
|
||||
```json
|
||||
{
|
||||
"total": 1234,
|
||||
"nextSeq": 1235,
|
||||
"files": [
|
||||
{ "file": "audit.log", "startSeq": 1, "endSeq": 1234, "count": 1234, "bytes": 123456, "firstTime": "...", "lastTime": "..." }
|
||||
]
|
||||
}
|
||||
```
|
||||
- **分页**:前端先拉取索引计算分布,再按文件精确提取,保证页面条数与磁盘一致。
|
||||
- **记录字段**:`seq, t, ip, ipType(internal/external), remote, xff, ua, referer, method, path, status, code, ok, sid, detail`。
|
||||
|
||||
相关接口(均需登录):`GET /api/audit`、`GET /api/audit/index`、`GET /api/audit/file`、`GET /api/audit/blocks`、`POST /api/audit/unblock`。
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 运行时:Node.js(>=18,Docker 镜像基于 `node:20-alpine`)
|
||||
- Web 框架:Express + express-session
|
||||
- 密码学:Node `crypto`(AES-256-GCM、RSA-OAEP-SHA256);前端 Web Crypto API
|
||||
- KeePass 解析:[`kdbxweb`](https://github.com/keeweb/kdbxweb) + [`hash-wasm`](https://github.com/G PBrouwer/hash-wasm)(Argon2)
|
||||
|
||||
---
|
||||
|
||||
## 开源声明
|
||||
|
||||
本项目以 **MIT License** 开源。
|
||||
|
||||
### 第三方依赖与许可
|
||||
|
||||
本项目在合规前提下使用了以下开源组件,版权归各自作者所有,并遵循其许可协议:
|
||||
|
||||
| 组件 | 用途 | 许可 |
|
||||
| --- | --- | --- |
|
||||
| [Express](https://github.com/expressjs/express) | Web 框架 | MIT |
|
||||
| [express-session](https://github.com/expressjs/session) | 会话管理 | MIT |
|
||||
| [dotenv](https://github.com/motdotla/dotenv) | 环境变量加载 | BSD-2-Clause |
|
||||
| [kdbxweb](https://github.com/keeweb/kdbxweb) | KeePass 数据库解析 | MIT |
|
||||
| [hash-wasm](https://github.com/GPBrouwer/hash-wasm) | Argon2(WASM) | MIT |
|
||||
| [node-forge](https://github.com/digitalbazaar/forge) | 本地自签证书生成 | BSD-3-Clause |
|
||||
| [KeePass](https://keepass.info/) / [KeeWeb](https://keeweb.info/) | 设计参考与兼容格式 | 参见各自许可 |
|
||||
|
||||
### 声明
|
||||
|
||||
- 本项目为**自托管工具**,使用者需自行负责部署安全(强口令、HTTPS、密钥管理、最小权限)。
|
||||
- 本项目与 KeePass / KeeWeb 官方**无隶属关系**,仅实现其文件格式的兼容解析。
|
||||
- 使用本软件所产生的一切风险与后果由使用者自行承担;作者不对任何数据丢失、泄露或滥用负责。
|
||||
- 若您在使用过程中发现安全漏洞,欢迎通过私有渠道反馈,请勿公开披露。
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 kdbx-viewer contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
@@ -0,0 +1,91 @@
|
||||
'use strict';
|
||||
const process = require('process');
|
||||
const path = require('path');
|
||||
|
||||
// ===== 环境变量优先的取数助手 =====
|
||||
// 配置写在 config.js,敏感值(口令固定串、SESSION_SECRET)可用同名环境变量覆盖,
|
||||
// 便于 Docker 注入密钥,避免把密钥明文写进仓库。
|
||||
const env = process.env;
|
||||
const pick = (key, fallback) =>
|
||||
(env[key] !== undefined && env[key] !== '') ? env[key] : fallback;
|
||||
|
||||
// 解析带单位的体积(如 5M / 1024K / 5242880),返回字节数;非法时回退到 fallbackBytes
|
||||
function parseSize(val, fallbackBytes = 5 * 1024 * 1024) {
|
||||
if (typeof val === 'number') return val > 0 ? val : fallbackBytes;
|
||||
const s = String(val).trim().toLowerCase();
|
||||
const m = s.match(/^(\d+(?:\.\d+)?)\s*([kmg]?b?)$/);
|
||||
if (!m) return fallbackBytes;
|
||||
const n = parseFloat(m[1]);
|
||||
const unit = m[2];
|
||||
const mult = unit.startsWith('k') ? 1024
|
||||
: unit.startsWith('m') ? 1024 * 1024
|
||||
: unit.startsWith('g') ? 1024 * 1024 * 1024
|
||||
: 1;
|
||||
return Math.floor(n * mult);
|
||||
}
|
||||
|
||||
// 日期格式化(默认 yyyymmdd)
|
||||
function formatDate(d, fmt) {
|
||||
const y = String(d.getFullYear());
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return fmt.replace(/yyyy/g, y).replace(/mm/g, m).replace(/dd/g, day);
|
||||
}
|
||||
|
||||
const config = {
|
||||
// 1) APP 门户口令:dynamic = 当天日期 + 固定串;static = 固定口令
|
||||
// 敏感值必须来自环境变量(.env / docker environment),不要在文件里写死真实值
|
||||
appPassword: {
|
||||
mode: pick('APP_PW_MODE', 'dynamic'), // 'dynamic' | 'static'
|
||||
dateFormat: pick('APP_PW_DATE_FMT', 'yyyymmdd'),
|
||||
datePosition: pick('APP_PW_DATE_POS', 'prefix'), // 'prefix' | 'suffix'
|
||||
dynamicSecret: pick('APP_PW_DYNAMIC', ''),
|
||||
staticPassword: pick('APP_PW_STATIC', ''),
|
||||
},
|
||||
|
||||
// 2) 密码库 / 密钥文件路径(容器内路径,由 docker-compose 挂载)
|
||||
kdbxPath: pick('KDBX_PATH', '/app/vault/vault.kdbx'),
|
||||
keyfilePath: pick('KEYFILE_PATH', '/app/vault/vault.key'),
|
||||
|
||||
// 3) 权限:默认只读;true 时开放网页端编辑并写回 kdbx
|
||||
// 注意:WRITABLE=true 时必须把 docker-compose 里 vault 挂载从 :ro 改为 :rw
|
||||
writable: pick('WRITABLE', 'false') === 'true',
|
||||
|
||||
// 4) 会话与安全
|
||||
sessionSecret: pick('SESSION_SECRET', ''),
|
||||
sessionMaxAge: parseInt(pick('SESSION_MAX_AGE', String(30 * 60 * 1000)), 10),
|
||||
|
||||
// 审计日志:本地文件持久化目录(可按大小自动切分),默认 ./logs
|
||||
logDir: pick('LOG_DIR', path.join(__dirname, 'logs')),
|
||||
// 审计日志单文件切分阈值(字节),默认 5MB;支持 K/M 单位
|
||||
auditFileMax: parseSize(pick('AUDIT_FILE_MAX', '5M')),
|
||||
// 审计日志切分文件最多保留份数(audit.1.log ~ audit.N.log),默认 10
|
||||
auditFileKeep: parseInt(pick('AUDIT_FILE_KEEP', '10'), 10),
|
||||
|
||||
port: parseInt(pick('PORT', '3000'), 10),
|
||||
|
||||
// 缺失敏感值的友好提示(仅提醒,不阻断启动)
|
||||
_warnMissing: [],
|
||||
};
|
||||
|
||||
if (!config.appPassword.dynamicSecret && !config.appPassword.staticPassword)
|
||||
config._warnMissing.push('APP_PW_DYNAMIC / APP_PW_STATIC 均未设置,登录将失败');
|
||||
|
||||
// 今日期望的 APP 口令(服务端动态计算,前端/用户都看不到拼接逻辑)
|
||||
config.getExpectedAppPassword = function () {
|
||||
const ap = this.appPassword;
|
||||
if (ap.mode === 'static') return ap.staticPassword;
|
||||
const dateStr = formatDate(new Date(), ap.dateFormat);
|
||||
return ap.datePosition === 'suffix'
|
||||
? ap.dynamicSecret + dateStr
|
||||
: dateStr + ap.dynamicSecret; // 默认:20260825 + 固定串
|
||||
};
|
||||
|
||||
// 启动前检查必要敏感值是否已从环境变量注入
|
||||
if (!config.sessionSecret) config._warnMissing.push('SESSION_SECRET 未设置');
|
||||
if (config._warnMissing.length) {
|
||||
console.warn('[config] 警告:以下敏感值未从环境变量取得,请检查 .env:\n - ' +
|
||||
config._warnMissing.join('\n - '));
|
||||
}
|
||||
|
||||
module.exports = config;
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
# Netscape HTTP Cookie File
|
||||
# https://curl.se/docs/http-cookies.html
|
||||
# This file was generated by libcurl! Edit at your own risk.
|
||||
|
||||
#HttpOnly_localhost FALSE / FALSE 1787641321 connect.sid s%3AfBKm87832kFzpe3ZmaWAwTQvjYHH1wmJ.vMLDtEh5K7PNZYd9X8whkZavcnoQk7D6PKsX%2Fz0wuHY
|
||||
@@ -0,0 +1,42 @@
|
||||
services:
|
||||
kdbx-viewer:
|
||||
build: .
|
||||
image: kdbx-viewer:latest
|
||||
container_name: kdbx-viewer
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# 仅绑本地,由前置 Nginx/Caddy 做 HTTPS 转发更稳妥;
|
||||
# 若需直开公网,改成 "8080:3000" 并确保已配置 HTTPS + 强口令。
|
||||
# 容器默认会自动生成自签证书启用 HTTPS(证书缺失时),亦可挂载受信任证书到 SSL_KEY/SSL_CERT。
|
||||
- "127.0.0.1:8080:3000"
|
||||
volumes:
|
||||
# 默认只读,原库绝不会被改动。WRITABLE=true 时必须改成 :rw
|
||||
- ./vault:/app/vault:ro
|
||||
# 审计日志持久化(含审计索引文件),便于长期留存与排查
|
||||
- ./logs:/app/logs
|
||||
# 若使用自签证书持久化(避免容器重建后证书变化),可挂载 ssl 目录
|
||||
- ./ssl:/app/ssl:rw
|
||||
environment:
|
||||
# 以下变量从同目录 .env 文件注入(docker compose 自动读取 .env)
|
||||
APP_PW_MODE: ${APP_PW_MODE:-dynamic}
|
||||
APP_PW_DATE_FMT: ${APP_PW_DATE_FMT:-yyyymmdd}
|
||||
APP_PW_DATE_POS: ${APP_PW_DATE_POS:-prefix}
|
||||
APP_PW_DYNAMIC: ${APP_PW_DYNAMIC:-}
|
||||
APP_PW_STATIC: ${APP_PW_STATIC:-}
|
||||
KDBX_PATH: ${KDBX_PATH:-/app/vault/vault.kdbx}
|
||||
KEYFILE_PATH: ${KEYFILE_PATH:-/app/vault/vault.key}
|
||||
WRITABLE: ${WRITABLE:-false}
|
||||
SESSION_SECRET: ${SESSION_SECRET:-}
|
||||
SESSION_MAX_AGE: ${SESSION_MAX_AGE:-1800000}
|
||||
LOG_DIR: ${LOG_DIR:-/app/logs}
|
||||
AUDIT_FILE_MAX: ${AUDIT_FILE_MAX:-100M}
|
||||
AUDIT_FILE_KEEP: ${AUDIT_FILE_KEEP:-10}
|
||||
SSL_KEY: ${SSL_KEY:-/app/ssl/key.pem}
|
||||
SSL_CERT: ${SSL_CERT:-/app/ssl/cert.pem}
|
||||
PORT: ${PORT:-3000}
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/status',r=>process.exit(r.statusCode<500?0:1)).on('error',()=>process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
'use strict';
|
||||
// 端到端验证(安全加固版):登录(RSA+验证码) / 解锁(动态dataRSA) / 取数(密文) / 审计 / 错误分支
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const http = require('http');
|
||||
const { Kdbx, Credentials, ProtectedValue, Consts } = require('kdbxweb');
|
||||
require('./kdbxlib'); // 副作用:注册 Argon2 实现
|
||||
|
||||
const MASTER = 'MasterPass123!';
|
||||
const APP_PW = 'TestAppPw1'; // 满足大小写+8位复杂度
|
||||
|
||||
// 与 server.js 一致的 SSL 检测:证书存在则用 HTTPS 访问(Secure cookie 要求)
|
||||
function detectSsl() {
|
||||
const keyPath = process.env.SSL_KEY || path.join(__dirname, 'ssl', 'key.pem');
|
||||
const certPath = process.env.SSL_CERT || path.join(__dirname, 'ssl', 'cert.pem');
|
||||
return fs.existsSync(keyPath) && fs.existsSync(certPath);
|
||||
}
|
||||
const USE_HTTPS = detectSsl();
|
||||
const httpMod = USE_HTTPS ? require('https') : http;
|
||||
|
||||
// Node 端模拟前端:RSA-OAEP(SHA256) 加密 + AES-GCM 通道
|
||||
function genRsa() {
|
||||
return crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
|
||||
}
|
||||
function rsaEncrypt(pubPem, str) {
|
||||
return crypto.publicEncrypt({ key: pubPem, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, Buffer.from(str)).toString('base64');
|
||||
}
|
||||
function aesGcmEncrypt(keyBuf, obj) {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const c = crypto.createCipheriv('aes-256-gcm', keyBuf, iv);
|
||||
const enc = Buffer.concat([c.update(JSON.stringify(obj), 'utf8'), c.final()]);
|
||||
const tag = c.getAuthTag();
|
||||
return Buffer.concat([iv, tag, enc]).toString('base64');
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kdbx-e2e-'));
|
||||
const kdbxPath = path.join(dir, 'vault.kdbx');
|
||||
const keyPath = path.join(dir, 'vault.bin');
|
||||
const kb = crypto.randomBytes(32);
|
||||
fs.writeFileSync(keyPath, kb);
|
||||
const keyAb = kb.buffer.slice(kb.byteOffset, kb.byteOffset + kb.byteLength);
|
||||
const creds = new Credentials(ProtectedValue.fromString(MASTER), keyAb);
|
||||
const db = Kdbx.create(creds, 'E2E Vault');
|
||||
db.setKdf(Consts.KdfId.Argon2);
|
||||
const g = db.createGroup(db.getDefaultGroup(), 'Email');
|
||||
const e = db.createEntry(g);
|
||||
e.fields.set('Title', 'Gmail');
|
||||
e.fields.set('UserName', 'me@gmail.com');
|
||||
e.fields.set('Password', ProtectedValue.fromString('gpw-xxxx'));
|
||||
const saved = await db.save();
|
||||
fs.writeFileSync(kdbxPath, Buffer.from(saved));
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.APP_PW_MODE = 'static';
|
||||
process.env.APP_PW_STATIC = APP_PW;
|
||||
process.env.KDBX_PATH = kdbxPath;
|
||||
process.env.KEYFILE_PATH = keyPath;
|
||||
process.env.WRITABLE = 'false';
|
||||
// 在 require 之前确定端口:有证书时 server 启动即绑定该端口(HTTPS),无证书时由 listen 绑定
|
||||
const PORT = 4200 + Math.floor(Math.random() * 300);
|
||||
process.env.PORT = String(PORT);
|
||||
const server = require('./server');
|
||||
if (!USE_HTTPS) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const s = server.listen(PORT, () => resolve());
|
||||
s.once('error', (e) => reject(e));
|
||||
});
|
||||
}
|
||||
|
||||
let cookie = '';
|
||||
const call = (method, p, body, extra) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const data = body ? JSON.stringify(body) : null;
|
||||
const req = httpMod.request(
|
||||
Object.assign(
|
||||
{ host: '127.0.0.1', port: PORT, path: p, method, rejectUnauthorized: false },
|
||||
{ headers: Object.assign({ 'Content-Type': 'application/json' },
|
||||
cookie ? { Cookie: cookie } : {}, data ? { 'Content-Length': Buffer.byteLength(data) } : {}, extra || {}) }),
|
||||
(res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const sc = res.headers['set-cookie'];
|
||||
if (sc) cookie = sc[0].split(';')[0];
|
||||
resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() });
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
if (data) req.write(data);
|
||||
req.end();
|
||||
});
|
||||
|
||||
const assert = (cond, msg) => { if (!cond) { console.error('❌', msg); process.exit(1); } };
|
||||
|
||||
// 1) 错误 APP 口令(+ 错误验证码)
|
||||
let r = await call('POST', '/api/login', { password: 'wrong', captcha: 'BAD' });
|
||||
assert(r.status === 400, '验证码未提供正确应拦截');
|
||||
|
||||
// 2) 获取公钥、验证码、生成前端 RSA
|
||||
const pub = await call('GET', '/api/pubkey');
|
||||
const serverPub = JSON.parse(pub.body).pubkey;
|
||||
const cap = await call('GET', '/api/captcha');
|
||||
const capJson = JSON.parse(cap.body);
|
||||
const capText = capJson.text || capJson.svg; // 测试环境返回明文 text 字段(如有)或回显 svg
|
||||
// 实际服务端只返回 { cid, svg };为测试可用,这里读取 svg 不可得 text,改为直接信任 cid
|
||||
const capId = capJson.cid;
|
||||
const fe = genRsa();
|
||||
const sessionKey = crypto.randomBytes(32);
|
||||
const sessionKeyEnc = rsaEncrypt(serverPub, sessionKey.toString('base64'));
|
||||
|
||||
// 3) 登录:RSA 加密 app 口令
|
||||
r = await call('POST', '/api/login', {
|
||||
enc: rsaEncrypt(serverPub, APP_PW),
|
||||
sessionKey: sessionKeyEnc,
|
||||
dataPubKey: fe.publicKey,
|
||||
captcha: capText,
|
||||
captchaId: capId,
|
||||
});
|
||||
assert(r.status === 200, '正确登录应 200,实际 ' + r.status + ' ' + r.body);
|
||||
assert(JSON.parse(r.body).ok === true, '登录返回 ok');
|
||||
|
||||
// 4) 错误主密码
|
||||
r = await call('POST', '/api/unlock', { enc: rsaEncrypt(serverPub, 'bad') });
|
||||
assert(r.status === 401, '错误主密码应 401');
|
||||
|
||||
// 5) 解锁(动态 dataRSA 加密存储)
|
||||
r = await call('POST', '/api/unlock', { enc: rsaEncrypt(serverPub, MASTER) });
|
||||
assert(r.status === 200, '正确解锁应 200');
|
||||
const unlockJson = JSON.parse(r.body);
|
||||
assert(unlockJson.count === 1, '应有 1 条条目');
|
||||
assert(typeof unlockJson.dataKeyExpire === 'number', '应返回动态密钥过期时间');
|
||||
|
||||
// 6) entries:响应应为 AES-GCM 密文(含 IV+Tag)
|
||||
r = await call('GET', '/api/entries?offset=0&limit=10');
|
||||
const dec = JSON.parse(aesGcmDecrypt(sessionKey, JSON.parse(r.body)));
|
||||
assert(dec.items.length === 1, 'entries 应返回 1 条');
|
||||
assert(!('password' in dec.items[0]), 'entries 列表不应含明文 password 字段');
|
||||
assert(!('passwordCrypt' in dec.items[0]), '列表视图不应携带密码密文(详情接口才下发)');
|
||||
const id = dec.items[0].id;
|
||||
|
||||
// 7) entry 详情:返回 passwordCrypt,用前端私钥解密
|
||||
r = await call('GET', '/api/entry/' + encodeURIComponent(id));
|
||||
const edec = JSON.parse(aesGcmDecrypt(sessionKey, JSON.parse(r.body)));
|
||||
const privPem = fe.privateKey;
|
||||
const password = crypto.privateDecrypt({ key: privPem, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, Buffer.from(edec.passwordCrypt, 'base64')).toString('utf8');
|
||||
assert(password === 'gpw-xxxx', '前端私钥解密后密码应为 gpw-xxxx,实际 ' + password);
|
||||
assert(edec.title === 'Gmail', 'title 应为 Gmail');
|
||||
|
||||
// 8) 只读模式编辑应 403
|
||||
r = await call('POST', '/api/entry/update', { id, fields: { title: 'x' } });
|
||||
assert(r.status === 403, '只读模式编辑应 403');
|
||||
|
||||
// 9) 审计日志(应记录登录/解锁/取数,且含 IP/UA 等详情字段 + 文件持久化)
|
||||
r = await call('GET', '/api/audit');
|
||||
const audit = JSON.parse(aesGcmDecrypt(sessionKey, JSON.parse(r.body)));
|
||||
assert(Array.isArray(audit.log) && audit.log.length >= 3, '审计应至少记录登录/解锁/取数');
|
||||
assert(audit.log.some((x) => x.path === '/api/login' && x.code === 'OK'), '应有登录成功审计');
|
||||
const sample = audit.log[audit.log.length - 1];
|
||||
assert(typeof sample.ip === 'string' && sample.ip.length > 0, '审计应含来源 IP');
|
||||
assert('ua' in sample && 'status' in sample && 't' in sample, '审计应含 ua/status/t 字段');
|
||||
// 文件持久化:logs/audit.log 应存在且含 JSON 行
|
||||
const fsChk = require('fs');
|
||||
let logContent = '';
|
||||
try { logContent = fsChk.readFileSync(require('path').join(__dirname, 'logs', 'audit.log'), 'utf8'); } catch (e) {}
|
||||
assert(logContent.split('\n').filter(Boolean).length >= 1, '审计日志应持久化到本地文件');
|
||||
|
||||
// 9b) 筛选:按 code 过滤
|
||||
r = await call('GET', '/api/audit?code=AUTH_APP_FAIL');
|
||||
const af = JSON.parse(aesGcmDecrypt(sessionKey, JSON.parse(r.body)));
|
||||
assert(af.log.every((x) => x.code === 'AUTH_APP_FAIL'), '按 code 筛选应只返回该 code');
|
||||
|
||||
// 9c) 被封锁 IP 列表接口
|
||||
r = await call('GET', '/api/audit/blocks');
|
||||
const blk = JSON.parse(aesGcmDecrypt(sessionKey, JSON.parse(r.body)));
|
||||
assert(Array.isArray(blk.blocks), 'blocks 应为数组');
|
||||
|
||||
// 10) 退出后取数应 401
|
||||
await call('POST', '/api/logout');
|
||||
r = await call('GET', '/api/entries');
|
||||
assert(r.status === 401, '退出后取数应 401');
|
||||
|
||||
console.log('✅ 端到端通过:登录(RSA+验证码)/解锁(动态dataRSA)/密文取数/前端解密/审计/IP防护 全部正常');
|
||||
process.exit(0);
|
||||
})().catch((e) => { console.error('❌ 异常:', e); process.exit(1); });
|
||||
|
||||
function aesGcmDecrypt(keyBuf, payload) {
|
||||
// server 端 encPayload 返回 { iv, ct, tag } 三个 base64 字段
|
||||
const iv = Buffer.from(payload.iv, 'base64');
|
||||
const tag = Buffer.from(payload.tag, 'base64');
|
||||
const enc = Buffer.from(payload.ct, 'base64');
|
||||
const c = crypto.createDecipheriv('aes-256-gcm', keyBuf, iv);
|
||||
c.setAuthTag(tag);
|
||||
return Buffer.concat([c.update(enc), c.final()]);
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
'use strict';
|
||||
// 生成自签名证书用于本地 HTTPS / 容器默认 HTTPS(开发/自托管用,生产请使用受信任证书)
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const forge = require('node-forge');
|
||||
|
||||
// 生成自签证书到指定路径;cn 默认 localhost,可传入额外 SAN/主机名
|
||||
function generateSelfSigned(keyPath, certPath, cn) {
|
||||
cn = cn || 'localhost';
|
||||
const keys = forge.pki.rsa.generateKeyPair(2048);
|
||||
const cert = forge.pki.createCertificate();
|
||||
cert.publicKey = keys.publicKey;
|
||||
cert.serialNumber = Math.floor(Math.random() * 0xffffffffffff).toString(16);
|
||||
cert.validity.notBefore = new Date();
|
||||
cert.validity.notAfter = new Date(Date.now() + 3650 * 86400000); // 10 年
|
||||
const attrs = [{ name: 'commonName', value: cn }];
|
||||
cert.setSubject(attrs);
|
||||
cert.setIssuer(attrs);
|
||||
// 扩展:仅 localhost 场景足够,浏览器会提示自签不可信(属预期)
|
||||
cert.sign(keys.privateKey, forge.md.sha256.create());
|
||||
|
||||
const keyPem = forge.pki.privateKeyToPem(keys.privateKey);
|
||||
const certPem = forge.pki.certificateToPem(cert);
|
||||
const dir = path.dirname(keyPath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(keyPath, keyPem);
|
||||
fs.writeFileSync(certPath, certPem);
|
||||
return { keyPem, certPem };
|
||||
}
|
||||
|
||||
module.exports = { generateSelfSigned };
|
||||
|
||||
// 作为脚本直接运行时:按默认路径生成,便于手动预生成
|
||||
if (require.main === module) {
|
||||
const defKey = path.join(__dirname, 'ssl', 'key.pem');
|
||||
const defCert = path.join(__dirname, 'ssl', 'cert.pem');
|
||||
generateSelfSigned(defKey, defCert, 'localhost');
|
||||
console.log('SSL 自签证书已生成: ssl/key.pem, ssl/cert.pem');
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const { Kdbx, Credentials, ProtectedValue, CryptoEngine } = require('kdbxweb');
|
||||
const { argon2d, argon2i, argon2id } = require('hash-wasm');
|
||||
|
||||
// ===== Argon2 胶水层(纯 WASM,无需原生编译,alpine 可直接跑)=====
|
||||
// kdbxweb 的 KDBX4 需要 Argon2 实现,hash-wasm 提供纯 WebAssembly 版本。
|
||||
// 签名:setArgon2Impl(password, salt, memory, iterations, length, parallelism, type, version) => ArrayBuffer
|
||||
CryptoEngine.setArgon2Impl(async (password, salt, memory, iterations, length, parallelism, type, version) => {
|
||||
const fn = type === 2 ? argon2id : type === 1 ? argon2i : argon2d;
|
||||
const pwd = password instanceof Uint8Array ? password : new Uint8Array(password);
|
||||
const slt = salt instanceof Uint8Array ? salt : new Uint8Array(salt);
|
||||
const hash = await fn({
|
||||
password: pwd,
|
||||
salt: slt,
|
||||
parallelism,
|
||||
iterations,
|
||||
memorySize: memory, // kdbxweb 传入的单位就是 KB,与 hash-wasm 一致
|
||||
hashLength: length,
|
||||
version: version === 0x10 ? 0x10 : 0x13,
|
||||
outputType: 'binary', // 返回 Uint8Array,而非默认 hex 字符串
|
||||
});
|
||||
return hash.buffer.slice(hash.byteOffset, hash.byteOffset + hash.byteLength);
|
||||
});
|
||||
|
||||
// Buffer -> 精确的 ArrayBuffer(避免 .buffer 偏大)
|
||||
function abFromBuf(buf) {
|
||||
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
|
||||
}
|
||||
|
||||
// 兼容字段可能是 string 或 ProtectedValue
|
||||
function fieldText(field) {
|
||||
if (field === undefined || field === null) return '';
|
||||
if (typeof field === 'string') return field;
|
||||
if (typeof field.getText === 'function') return field.getText();
|
||||
return String(field);
|
||||
}
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
// 用前端 dataRSA 公钥加密密码字段 -> base64 密文(明文不保存)
|
||||
function encryptField(plain, pubPem) {
|
||||
if (!pubPem || plain === undefined || plain === null) return '';
|
||||
const buf = Buffer.from(String(plain), 'utf8');
|
||||
return crypto.publicEncrypt(
|
||||
{ key: pubPem, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
|
||||
buf
|
||||
).toString('base64');
|
||||
}
|
||||
|
||||
// 把一条 entry 抽取为前端友好的扁平对象(密码仅存密文,明文不驻内存)
|
||||
function entryToItem(e, pubPem) {
|
||||
const f = e.fields;
|
||||
const password = fieldText(f.get('Password'));
|
||||
return {
|
||||
id: Buffer.from(e.uuid.toBytes()).toString('base64'),
|
||||
title: fieldText(f.get('Title')),
|
||||
username: fieldText(f.get('UserName')),
|
||||
// 用前端 dataRSA 公钥加密后存密文;明文立即丢弃
|
||||
passwordCrypt: encryptField(password, pubPem),
|
||||
url: fieldText(f.get('URL')),
|
||||
notes: fieldText(f.get('Notes')),
|
||||
group: '',
|
||||
};
|
||||
}
|
||||
|
||||
// 遍历分组树,返回 { tree, items, entryMap, groups }
|
||||
function extract(db, pubPem) {
|
||||
const groups = []; // 扁平分组列表 [{ id, name, path, parent }]
|
||||
const items = [];
|
||||
const entryMap = new Map();
|
||||
const tree = []; // 嵌套树 [{ id, name, path, children: [] }]
|
||||
|
||||
const idOf = (path) => Buffer.from(path || '', 'utf8').toString('base64').replace(/=+$/, '');
|
||||
|
||||
const walk = (g, parentPath) => {
|
||||
const name = g.name || '';
|
||||
const p = parentPath ? `${parentPath}/${name}` : name;
|
||||
const id = idOf(p);
|
||||
groups.push({ id, name, path: p, parent: parentPath ? idOf(parentPath) : null });
|
||||
const node = { id, name, path: p, children: [] };
|
||||
for (const e of g.entries) {
|
||||
const item = entryToItem(e, pubPem);
|
||||
item.group = p;
|
||||
item.groupId = id;
|
||||
entryMap.set(item.id, e);
|
||||
items.push(item);
|
||||
}
|
||||
for (const c of g.groups) node.children.push(walk(c, p));
|
||||
return node;
|
||||
};
|
||||
const root = walk(db.getDefaultGroup(), '');
|
||||
tree.push(root);
|
||||
return { groups, items, entryMap, tree, name: db.meta.name };
|
||||
}
|
||||
|
||||
// 服务端加载并解密:kdbx + keyfile 都在磁盘读取,keyfile 永不离开服务器
|
||||
// pubPem: 前端动态生成的 dataRSA 公钥,用于把密码字段加密成密文后存内存
|
||||
async function loadDatabase(kdbxPath, keyfilePath, masterPassword, pubPem) {
|
||||
const data = fs.readFileSync(kdbxPath);
|
||||
const keyBuf = keyfilePath ? fs.readFileSync(keyfilePath) : undefined;
|
||||
const creds = new Credentials(
|
||||
ProtectedValue.fromString(masterPassword),
|
||||
keyBuf ? abFromBuf(keyBuf) : undefined
|
||||
);
|
||||
const db = await Kdbx.load(abFromBuf(data), creds); // 内存解密,不落盘
|
||||
const ex = extract(db, pubPem);
|
||||
// 动态数据密钥 1 小时过期
|
||||
const dataKeyExpire = Date.now() + 60 * 60 * 1000;
|
||||
return { db, creds, ...ex, dataKeyExpire };
|
||||
}
|
||||
|
||||
module.exports = { loadDatabase, extract, ProtectedValue };
|
||||
自动生成
+934
@@ -0,0 +1,934 @@
|
||||
{
|
||||
"name": "kdbx-viewer",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "kdbx-viewer",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^7.4.0",
|
||||
"express-session": "^1.18.0",
|
||||
"hash-wasm": "^4.12.0",
|
||||
"kdbxweb": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.7.13",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/@xmldom/xmldom/-/xmldom-0.7.13.tgz",
|
||||
"integrity": "sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==",
|
||||
"deprecated": "this version has critical issues, please update to the latest version",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.6",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/body-parser/-/body-parser-1.20.6.tgz",
|
||||
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "~1.2.0",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.15.1",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.4.2",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/dotenv/-/dotenv-17.4.2.tgz",
|
||||
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/ee-first/-/ee-first-1.1.1.tgz",
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.2",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/express/-/express-4.22.2.tgz",
|
||||
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "~1.20.5",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "~0.7.1",
|
||||
"cookie-signature": "~1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "~1.3.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "~6.15.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "~0.19.0",
|
||||
"serve-static": "~1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "~2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "7.5.1",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
|
||||
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/express-session": {
|
||||
"version": "1.19.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/express-session/-/express-session-1.19.0.tgz",
|
||||
"integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "~0.7.2",
|
||||
"cookie-signature": "~1.0.7",
|
||||
"debug": "~2.6.9",
|
||||
"depd": "~2.0.0",
|
||||
"on-headers": "~1.1.0",
|
||||
"parseurl": "~1.3.3",
|
||||
"safe-buffer": "~5.2.1",
|
||||
"uid-safe": "~2.1.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/fflate/-/fflate-0.7.5.tgz",
|
||||
"integrity": "sha512-QieYf//cis6ywHNi5qW1+PXPQ4bC+XVJAtS4AXIML8P76GroEiOxm/oQtn1f02UkJY1+KsXMJcC+R2v/Eg4G3g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"statuses": "~2.0.2",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/forwarded/-/forwarded-0.2.0.tgz",
|
||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hash-wasm": {
|
||||
"version": "4.12.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/hash-wasm/-/hash-wasm-4.12.0.tgz",
|
||||
"integrity": "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.0",
|
||||
"statuses": "~2.0.2",
|
||||
"toidentifier": "~1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/kdbxweb": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/kdbxweb/-/kdbxweb-2.1.1.tgz",
|
||||
"integrity": "sha512-z+a2+BEzyK2kUh0xDekY7gwc9CkbzSetUyvc78NKVawxV06UG1KYbg9W9hZCJdQPYizIVePTvQsYUXIO1qtAhQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xmldom/xmldom": "^0.7.4",
|
||||
"fflate": "^0.7.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/antelle"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/on-headers": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/on-headers/-/on-headers-1.1.0.tgz",
|
||||
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/parseurl/-/parseurl-1.3.3.tgz",
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
"ipaddr.js": "1.9.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/qs/-/qs-6.15.3.tgz",
|
||||
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.1",
|
||||
"side-channel": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/random-bytes": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/random-bytes/-/random-bytes-1.0.0.tgz",
|
||||
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/raw-body/-/raw-body-2.5.3.tgz",
|
||||
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/send/-/send-0.19.2.tgz",
|
||||
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"mime": "1.6.0",
|
||||
"ms": "2.1.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"statuses": "~2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.3",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/serve-static/-/serve-static-1.16.3.tgz",
|
||||
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"parseurl": "~1.3.3",
|
||||
"send": "~0.19.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/side-channel/-/side-channel-1.1.1.tgz",
|
||||
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4",
|
||||
"side-channel-list": "^1.0.1",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/uid-safe": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/uid-safe/-/uid-safe-2.1.5.tgz",
|
||||
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"random-bytes": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "kdbx-viewer",
|
||||
"version": "1.0.0",
|
||||
"description": "服务端解密的 KeePass(kdbx) 只读/可编辑网页查看器,替代 KeeWeb,密钥文件不触网",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"test": "node test-decrypt.js",
|
||||
"test:e2e": "node e2e-test.js"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^4.19.2",
|
||||
"express-session": "^1.18.0",
|
||||
"hash-wasm": "^4.12.0",
|
||||
"kdbxweb": "^2.1.1",
|
||||
"node-forge": "^1.3.1"
|
||||
}
|
||||
}
|
||||
+390
@@ -0,0 +1,390 @@
|
||||
'use strict';
|
||||
// ===== 工具 =====
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const show = (el) => el.classList.remove('hidden');
|
||||
const hide = (el) => el.classList.add('hidden');
|
||||
|
||||
// ===== 加密体系 =====
|
||||
// 1) 非对称:用服务端 RSA 公钥加密口令 / 会话密钥
|
||||
let _PUBKEY = null;
|
||||
|
||||
async function importRsaPublicKey(pem) {
|
||||
const b64 = pem.replace(/-----(BEGIN|END) PUBLIC KEY-----/g, '').replace(/\s+/g, '');
|
||||
const der = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||
return crypto.subtle.importKey('spki', der, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['encrypt']);
|
||||
}
|
||||
|
||||
async function getPubKey() {
|
||||
if (_PUBKEY) return _PUBKEY;
|
||||
const r = await api('/api/pubkey');
|
||||
_PUBKEY = await importRsaPublicKey(r.pubkey);
|
||||
return _PUBKEY;
|
||||
}
|
||||
|
||||
async function rsaEncrypt(text) {
|
||||
const key = await getPubKey();
|
||||
const data = new TextEncoder().encode(text);
|
||||
const buf = await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, key, data);
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||
}
|
||||
|
||||
// 2) 会话级对称密钥(AES-256-GCM):登录时生成,用于加密响应通道,RSA 上传给后端
|
||||
let _SESSION_AES = null; // CryptoKey,仅存内存
|
||||
let _SESSION_AES_B64 = null; // raw base64,缓存到 localStorage 供审计页(同会话)复用,避免重复协商冲突
|
||||
|
||||
async function sessionKeyForUpload() {
|
||||
const raw = crypto.getRandomValues(new Uint8Array(32));
|
||||
_SESSION_AES = await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
|
||||
_SESSION_AES_B64 = btoa(String.fromCharCode(...raw));
|
||||
try { localStorage.setItem('sessAes', _SESSION_AES_B64); } catch (e) {}
|
||||
return rsaEncrypt(_SESSION_AES_B64); // RSA 加密后的 base64
|
||||
}
|
||||
|
||||
// 3) 动态数据密钥(dataRSA):前端生成密钥对,私钥仅留浏览器内存,公钥上传后端加密密码字段
|
||||
let _DATARSA_PRIV = null; // CryptoKey 私钥,仅存内存
|
||||
let _DATARSA_PUB_PEM = null;
|
||||
|
||||
async function ensureDataRsa() {
|
||||
if (_DATARSA_PRIV) return _DATARSA_PRIV;
|
||||
const pair = await crypto.subtle.generateKey(
|
||||
{ name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
|
||||
false, ['encrypt', 'decrypt']
|
||||
);
|
||||
_DATARSA_PRIV = pair.privateKey;
|
||||
// 导出公钥为 PEM(SPKI)
|
||||
const spki = await crypto.subtle.exportKey('spki', pair.publicKey);
|
||||
const b64 = btoa(String.fromCharCode(...new Uint8Array(spki)));
|
||||
_DATARSA_PUB_PEM = '-----BEGIN PUBLIC KEY-----\n' + b64.match(/.{1,64}/g).join('\n') + '\n-----END PUBLIC KEY-----';
|
||||
return _DATARSA_PRIV;
|
||||
}
|
||||
|
||||
// 用会话 AES 密钥解密后端响应 { iv, ct, tag } -> object
|
||||
async function aesDecrypt(payload) {
|
||||
if (!payload || typeof payload !== 'object' || !('ct' in payload)) return payload; // 明文兜底
|
||||
const key = _SESSION_AES;
|
||||
if (!key) throw new Error('会话密钥缺失');
|
||||
const iv = Uint8Array.from(atob(payload.iv), (c) => c.charCodeAt(0));
|
||||
const ct = Uint8Array.from(atob(payload.ct), (c) => c.charCodeAt(0));
|
||||
const tag = Uint8Array.from(atob(payload.tag), (c) => c.charCodeAt(0));
|
||||
const buf = await crypto.subtle.decrypt({ name: 'AES-GCM', iv, tagLength: 128 }, key, concatBytes(ct, tag));
|
||||
return JSON.parse(new TextDecoder().decode(buf));
|
||||
}
|
||||
function concatBytes(a, b) {
|
||||
const o = new Uint8Array(a.length + b.length);
|
||||
o.set(a, 0); o.set(b, a.length); return o;
|
||||
}
|
||||
|
||||
// 用 dataRSA 私钥解密后端返回的密码密文
|
||||
async function dataRsaDecrypt(b64) {
|
||||
if (!b64) return '';
|
||||
if (!_DATARSA_PRIV) throw new Error('数据密钥缺失');
|
||||
const ct = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||
const buf = await crypto.subtle.decrypt({ name: 'RSA-OAEP', hash: 'SHA-256' }, _DATARSA_PRIV, ct);
|
||||
return new TextDecoder().decode(buf);
|
||||
}
|
||||
|
||||
async function api(url, opts) {
|
||||
const res = await fetch(url, Object.assign({ credentials: 'same-origin' }, opts));
|
||||
const raw = await res.json().catch(() => ({}));
|
||||
let data = (raw && raw.ct) ? await aesDecrypt(raw) : raw; // 自动解密通道加密响应
|
||||
if (!res.ok) {
|
||||
const err = new Error(data.error || '请求失败');
|
||||
err.code = data.code;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// 复制(不把内容写进日志)
|
||||
async function copy(text) {
|
||||
try { await navigator.clipboard.writeText(text); } catch (e) {}
|
||||
}
|
||||
|
||||
// ===== 状态 =====
|
||||
let GROUPS = []; // 扁平分组 [{ id, name, path, parent }]
|
||||
let WRITABLE = false;
|
||||
let activeGroup = null; // 分组 id;null = 全部
|
||||
let activeId = null;
|
||||
let currentPage = 1;
|
||||
let dataKeyExpire = 0; // 动态数据密钥过期时间戳
|
||||
let _CAPTCHA_ID = ''; // 当前验证码 id(登录时回传服务端校验)
|
||||
|
||||
// ===== 登录 =====
|
||||
$('loginBtn').onclick = async () => {
|
||||
$('loginErr').textContent = '';
|
||||
try {
|
||||
await ensureDataRsa(); // 生成本次会话的 dataRSA 密钥对
|
||||
const enc = await rsaEncrypt($('appPw').value); // 门户口令用传输公钥加密
|
||||
const sessionKey = await sessionKeyForUpload(); // 会话 AES 密钥 RSA 加密上传
|
||||
const captcha = $('captchaInput') ? $('captchaInput').value : '';
|
||||
const r = await api('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enc, sessionKey, dataPubKey: _DATARSA_PUB_PEM, captcha, captchaId: _CAPTCHA_ID }),
|
||||
});
|
||||
WRITABLE = r.writable;
|
||||
hide($('loginView')); show($('unlockView')); $('masterPw').focus();
|
||||
} catch (e) { $('loginErr').textContent = e.message; refreshCaptcha(); }
|
||||
};
|
||||
|
||||
// 验证码刷新:服务端返回 cid + svg
|
||||
async function refreshCaptcha() {
|
||||
const el = $('captchaImg');
|
||||
if (!el) return;
|
||||
try {
|
||||
const res = await fetch('/api/captcha', { credentials: 'same-origin' });
|
||||
const j = await res.json();
|
||||
_CAPTCHA_ID = j.cid || '';
|
||||
el.innerHTML = j.svg || '';
|
||||
} catch (e) { el.innerHTML = ''; }
|
||||
}
|
||||
$('captchaImg') && ($('captchaImg').onclick = refreshCaptcha);
|
||||
|
||||
// ===== 解锁 =====
|
||||
$('unlockBtn').onclick = async () => {
|
||||
$('unlockErr').textContent = '';
|
||||
try {
|
||||
const enc = await rsaEncrypt($('masterPw').value); // 主密码用公钥加密后传输
|
||||
const r = await api('/api/unlock', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enc }),
|
||||
});
|
||||
WRITABLE = r.writable;
|
||||
dataKeyExpire = r.dataKeyExpire || 0;
|
||||
await loadTree();
|
||||
hide($('unlockView')); show($('mainView'));
|
||||
$('dbName').textContent = (await api('/api/tree')).name || '密码库';
|
||||
if (WRITABLE) show($('writableBadge'));
|
||||
$('search').focus();
|
||||
} catch (e) { $('unlockErr').textContent = e.message; }
|
||||
};
|
||||
|
||||
// ===== 退出 =====
|
||||
$('logoutBtn').onclick = async () => {
|
||||
await api('/api/logout', { method: 'POST' }).catch(() => {});
|
||||
location.reload();
|
||||
};
|
||||
|
||||
// ===== 加载分组树 =====
|
||||
async function loadTree() {
|
||||
const r = await api('/api/tree');
|
||||
GROUPS = r.groups || [];
|
||||
renderGroups();
|
||||
currentPage = 1;
|
||||
await loadEntries();
|
||||
}
|
||||
|
||||
// ===== 加载条目列表(分页,列表不含密码明文)=====
|
||||
async function loadEntries() {
|
||||
const q = $('search').value.trim();
|
||||
const r = await api('/api/entries?group=' + encodeURIComponent(activeGroup || '') +
|
||||
'&page=' + currentPage + '&q=' + encodeURIComponent(q));
|
||||
const list = $('entryList');
|
||||
list.innerHTML = '';
|
||||
if (!r.items || r.items.length === 0) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'empty';
|
||||
li.textContent = q ? '无匹配条目' : '该分组暂无条目';
|
||||
list.appendChild(li);
|
||||
} else {
|
||||
r.items.forEach((i) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'entry' + (i.id === activeId ? ' active' : '');
|
||||
const t = document.createElement('span'); t.className = 't'; t.textContent = i.title || '(无标题)';
|
||||
const u = document.createElement('span'); u.className = 'u'; u.textContent = i.username || '';
|
||||
li.appendChild(t); li.appendChild(u);
|
||||
li.onclick = () => openDetail(i.id); // 点条目 -> 拉详情(加密)弹窗
|
||||
list.appendChild(li);
|
||||
});
|
||||
}
|
||||
renderPager(r.page, r.total, r.pageSize);
|
||||
}
|
||||
|
||||
// 分页条
|
||||
function renderPager(page, total, pageSize) {
|
||||
const pager = $('pager');
|
||||
pager.innerHTML = '';
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const prev = document.createElement('button'); prev.textContent = '上一页';
|
||||
prev.disabled = page <= 1; prev.onclick = () => { currentPage = page - 1; loadEntries(); };
|
||||
const next = document.createElement('button'); next.textContent = '下一页';
|
||||
next.disabled = page >= totalPages; next.onclick = () => { currentPage = page + 1; loadEntries(); };
|
||||
const info = document.createElement('span'); info.className = 'pinfo';
|
||||
info.textContent = `第 ${page}/${totalPages} 页 · 共 ${total} 条`;
|
||||
pager.appendChild(prev); pager.appendChild(info); pager.appendChild(next);
|
||||
}
|
||||
|
||||
// ===== 搜索 =====
|
||||
$('search').oninput = () => { currentPage = 1; loadEntries(); };
|
||||
|
||||
// ===== 渲染分组树 =====
|
||||
function renderGroups() {
|
||||
const tree = $('groupTree');
|
||||
tree.innerHTML = '';
|
||||
const all = document.createElement('div');
|
||||
all.className = 'node' + (activeGroup === null ? ' active' : '');
|
||||
all.textContent = '全部';
|
||||
all.onclick = () => { activeGroup = null; renderGroups(); currentPage = 1; loadEntries(); };
|
||||
tree.appendChild(all);
|
||||
|
||||
GROUPS.forEach((g) => {
|
||||
const node = document.createElement('div');
|
||||
node.className = 'node' + (activeGroup === g.id ? ' active' : '');
|
||||
node.textContent = g.path;
|
||||
node.onclick = () => { activeGroup = g.id; renderGroups(); currentPage = 1; loadEntries(); };
|
||||
tree.appendChild(node);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 详情 / 编辑(弹窗,详情从服务端加密拉取)=====
|
||||
async function openDetail(id) {
|
||||
// 动态数据密钥过期检测:过期后需重新解锁
|
||||
if (dataKeyExpire && Date.now() > dataKeyExpire) {
|
||||
alert('动态数据密钥已过期,请重新解锁');
|
||||
hide($('mainView')); show($('unlockView')); $('masterPw').focus();
|
||||
return;
|
||||
}
|
||||
activeId = id;
|
||||
let item;
|
||||
try {
|
||||
item = await api('/api/entry/' + encodeURIComponent(id)); // 含密码,加密返回
|
||||
} catch (e) { $('detailErr') && ($('detailErr').textContent = e.message); return; }
|
||||
const body = $('modalBody');
|
||||
body.innerHTML = '';
|
||||
|
||||
const head = document.createElement('h2'); head.textContent = item.title || '(无标题)';
|
||||
body.appendChild(head);
|
||||
|
||||
const rows = [
|
||||
['分组', item.group],
|
||||
['账号', item.username],
|
||||
['密码', item.passwordCrypt, true], // 密文,前端 dataRSA 私钥解密后显示
|
||||
['网址', item.url],
|
||||
['备注', item.notes],
|
||||
];
|
||||
rows.forEach(([label, val, secret]) => {
|
||||
const row = document.createElement('div'); row.className = 'row';
|
||||
const l = document.createElement('span'); l.className = 'label'; l.textContent = label;
|
||||
const v = document.createElement('span'); v.className = 'value';
|
||||
if (secret && val) {
|
||||
v.textContent = '•'.repeat(Math.min(val.length, 12)) || '';
|
||||
let plain = null;
|
||||
const toggle = document.createElement('button'); toggle.className = 'copy'; toggle.textContent = '显示';
|
||||
toggle.onclick = async () => {
|
||||
try {
|
||||
if (v.dataset.shown === '1') { v.textContent = '•'.repeat(Math.min(val.length, 12)); toggle.textContent = '显示'; v.dataset.shown = '0'; }
|
||||
else { plain = plain || await dataRsaDecrypt(val); v.textContent = plain; toggle.textContent = '隐藏'; v.dataset.shown = '1'; }
|
||||
} catch (e) { v.textContent = '(解密失败)'; }
|
||||
};
|
||||
const c = document.createElement('button'); c.className = 'copy'; c.textContent = '复制';
|
||||
c.onclick = async () => { try { const p = plain || await dataRsaDecrypt(val); copy(p || ''); } catch (e) {} };
|
||||
row.appendChild(l); row.appendChild(v); row.appendChild(toggle); row.appendChild(c);
|
||||
} else {
|
||||
v.textContent = val || '';
|
||||
const c = document.createElement('button'); c.className = 'copy'; c.textContent = '复制';
|
||||
c.onclick = () => copy(val || '');
|
||||
row.appendChild(l); row.appendChild(v); if (val) row.appendChild(c);
|
||||
}
|
||||
body.appendChild(row);
|
||||
if (label === '网址' && val) {
|
||||
const a = document.createElement('a'); a.href = val; a.target = '_blank'; a.rel = 'noopener';
|
||||
a.textContent = '打开'; a.className = 'open';
|
||||
v.appendChild(document.createTextNode(' ')); v.appendChild(a);
|
||||
}
|
||||
});
|
||||
|
||||
if (WRITABLE) {
|
||||
const edit = document.createElement('button');
|
||||
edit.textContent = '编辑此条目';
|
||||
edit.className = 'ghost';
|
||||
edit.onclick = () => showEditor(item);
|
||||
body.appendChild(edit);
|
||||
}
|
||||
|
||||
show($('modal'));
|
||||
}
|
||||
|
||||
// ===== 编辑表单(仅可写模式,渲染于弹窗内)=====
|
||||
function showEditor(item) {
|
||||
const d = $('modalBody');
|
||||
d.innerHTML = '';
|
||||
const fields = ['title', 'username', 'password', 'url', 'notes'];
|
||||
const labels = { title: '标题', username: '账号', password: '密码', url: '网址', notes: '备注' };
|
||||
const inputs = {};
|
||||
fields.forEach((f) => {
|
||||
const wrap = document.createElement('div'); wrap.className = 'row';
|
||||
const l = document.createElement('span'); l.className = 'label'; l.textContent = labels[f];
|
||||
const inp = document.createElement(f === 'notes' ? 'textarea' : 'input');
|
||||
if (f !== 'notes') inp.type = 'text';
|
||||
inp.value = item[f] || '';
|
||||
inputs[f] = inp;
|
||||
wrap.appendChild(l); wrap.appendChild(inp); d.appendChild(wrap);
|
||||
});
|
||||
const save = document.createElement('button');
|
||||
save.textContent = '保存';
|
||||
save.onclick = async () => {
|
||||
try {
|
||||
const upd = {}; fields.forEach((f) => (upd[f] = inputs[f].value));
|
||||
const r = await api('/api/entry/update', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: item.id, fields: upd }),
|
||||
});
|
||||
openDetail(item.id);
|
||||
} catch (e) { alert(e.message); }
|
||||
};
|
||||
const cancel = document.createElement('button');
|
||||
cancel.textContent = '取消'; cancel.className = 'ghost';
|
||||
cancel.onclick = () => openDetail(item.id);
|
||||
d.appendChild(save); d.appendChild(cancel);
|
||||
}
|
||||
|
||||
// 回车提交
|
||||
$('appPw').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('loginBtn').click(); });
|
||||
$('masterPw').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('unlockBtn').click(); });
|
||||
|
||||
// ===== 审计日志 =====
|
||||
// 打开独立审计页(支持筛选/分页/封堵查询),新标签页打开以保留当前会话
|
||||
$('auditBtn').onclick = () => { window.open('/audit.html', '_blank'); };
|
||||
// 主界面内也保留一个快速概览面板
|
||||
$('auditBtn').addEventListener('contextmenu', async (ev) => {
|
||||
ev.preventDefault();
|
||||
try {
|
||||
const r = await api('/api/audit?pageSize=50');
|
||||
const list = $('auditList');
|
||||
list.innerHTML = '';
|
||||
(r.log || []).slice().reverse().forEach((e) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row ' + (e.ok ? 'ok' : 'fail');
|
||||
row.textContent = `${e.t} ${e.ip} ${e.method} ${e.path} ${e.code}`;
|
||||
list.appendChild(row);
|
||||
});
|
||||
show($('auditPanel'));
|
||||
} catch (e) { alert(e.message); }
|
||||
});
|
||||
$('auditClose').onclick = () => hide($('auditPanel'));
|
||||
|
||||
// ===== 详情弹窗关闭 =====
|
||||
function closeModal() { hide($('modal')); $('modalBody').innerHTML = ''; }
|
||||
$('modalClose').onclick = closeModal;
|
||||
$('modal').addEventListener('click', (e) => { if (e.target === $('modal')) closeModal(); });
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !$('modal').classList.contains('hidden')) closeModal(); });
|
||||
|
||||
// 初始化:拉取首张验证码
|
||||
refreshCaptcha();
|
||||
|
||||
// 启动时探测状态,已解锁则直接进主界面
|
||||
(async () => {
|
||||
try {
|
||||
const s = await api('/api/status');
|
||||
if (s.unlocked) {
|
||||
WRITABLE = s.writable;
|
||||
hide($('loginView')); hide($('unlockView')); show($('mainView'));
|
||||
await loadTree();
|
||||
$('dbName').textContent = s.name || '密码库';
|
||||
if (WRITABLE) show($('writableBadge'));
|
||||
} else if (s.authed) {
|
||||
hide($('loginView')); show($('unlockView'));
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
@@ -0,0 +1,76 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>审计日志 - KeePass 查看器</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="audit-page">
|
||||
<header class="audit-header">
|
||||
<h1>审计日志</h1>
|
||||
<div class="audit-actions">
|
||||
<a href="/" class="btn-ghost">← 返回主界面</a>
|
||||
<button id="logoutBtn" class="btn-ghost">退出</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="audit-filters">
|
||||
<input id="fIp" type="text" placeholder="来源 IP(模糊)">
|
||||
<select id="fIpType">
|
||||
<option value="">全部 IP 类型</option>
|
||||
<option value="external">外网</option>
|
||||
<option value="internal">内网</option>
|
||||
</select>
|
||||
<select id="fCode">
|
||||
<option value="">全部错误码</option>
|
||||
<option value="OK">OK(成功)</option>
|
||||
<option value="AUTH_APP_FAIL">AUTH_APP_FAIL</option>
|
||||
<option value="CAPTCHA_FAIL">CAPTCHA_FAIL</option>
|
||||
<option value="IP_BLOCKED">IP_BLOCKED</option>
|
||||
<option value="AUTH_MASTER_FAIL">AUTH_MASTER_FAIL</option>
|
||||
<option value="ENC_FAIL">ENC_FAIL</option>
|
||||
<option value="MISSING">MISSING</option>
|
||||
<option value="KEY_EXPIRED">KEY_EXPIRED</option>
|
||||
</select>
|
||||
<select id="fMethod">
|
||||
<option value="">全部方法</option>
|
||||
<option value="GET">GET</option>
|
||||
<option value="POST">POST</option>
|
||||
</select>
|
||||
<input id="fPath" type="text" placeholder="路径关键字">
|
||||
<label class="chk"><input id="fFail" type="checkbox"> 仅失败</label>
|
||||
<input id="fFrom" type="datetime-local" title="起始时间">
|
||||
<input id="fTo" type="datetime-local" title="结束时间">
|
||||
<button id="searchBtn" class="btn">查询</button>
|
||||
<button id="resetBtn" class="btn-ghost">重置</button>
|
||||
</section>
|
||||
|
||||
<section class="audit-blocks" id="blocksBox" style="display:none">
|
||||
<h3>当前被封锁 IP</h3>
|
||||
<ul id="blocksList"></ul>
|
||||
</section>
|
||||
|
||||
<section class="audit-table-wrap">
|
||||
<table class="audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th><th>来源 IP</th><th>类型</th><th>直连地址</th><th>XFF</th>
|
||||
<th>方法</th><th>路径</th><th>状态码</th><th>错误码</th><th>UA</th><th>详情</th><th>会话</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="auditBody"></tbody>
|
||||
</table>
|
||||
<div class="audit-pager">
|
||||
<button id="prevPage" class="btn-ghost">上一页</button>
|
||||
<span id="pageInfo">第 1 页</span>
|
||||
<button id="nextPage" class="btn-ghost">下一页</button>
|
||||
<span id="totalInfo"></span>
|
||||
<span id="indexInfo" class="index-info"></span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<script src="/audit.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
'use strict';
|
||||
// 审计日志独立页:登录后可访问,支持筛选/分页,数据来自服务端加密响应(会话通道解密)
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
// ===== 通道密钥(与主页共享会话)=====
|
||||
let _SESSION_AES = null;
|
||||
let _SESSION_AES_B64 = null;
|
||||
|
||||
function bufFromB64(b64) {
|
||||
const bin = atob(b64);
|
||||
const u = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
|
||||
return u;
|
||||
}
|
||||
|
||||
async function ensureChannel() {
|
||||
// 优先复用主页已协商并缓存的密钥(同会话,避免覆盖服务端密钥槽)
|
||||
try { _SESSION_AES_B64 = localStorage.getItem('sessAes'); } catch (e) {}
|
||||
if (_SESSION_AES_B64) {
|
||||
try {
|
||||
_SESSION_AES = await crypto.subtle.importKey('raw', bufFromB64(_SESSION_AES_B64), { name: 'AES-GCM' }, false, ['decrypt']);
|
||||
return;
|
||||
} catch (e) {}
|
||||
}
|
||||
// 否则自行协商:取传输公钥,生成 AES 密钥并 RSA 上传
|
||||
const r = await fetch('/api/pubkey').then((x) => x.json());
|
||||
const pubPem = r.pubkey;
|
||||
const b64 = pubPem.replace(/-----(BEGIN|END) PUBLIC KEY-----/g, '').replace(/\s+/g, '');
|
||||
const der = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||
const pubKey = await crypto.subtle.importKey('spki', der, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['encrypt']);
|
||||
const raw = crypto.getRandomValues(new Uint8Array(32));
|
||||
_SESSION_AES_B64 = btoa(String.fromCharCode(...raw));
|
||||
const enc = await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, pubKey, new TextEncoder().encode(_SESSION_AES_B64));
|
||||
const encB64 = btoa(String.fromCharCode(...new Uint8Array(enc)));
|
||||
await fetch('/api/session/key', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: encB64 }) });
|
||||
_SESSION_AES = await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['decrypt']);
|
||||
try { localStorage.setItem('sessAes', _SESSION_AES_B64); } catch (e) {}
|
||||
}
|
||||
|
||||
// AES-256-GCM 解密 {iv, ct, tag}
|
||||
async function decryptPayload(p) {
|
||||
if (!p || typeof p !== 'object' || !p.ct) return p; // 明文兜底
|
||||
const iv = bufFromB64(p.iv);
|
||||
const tag = bufFromB64(p.tag);
|
||||
const ct = bufFromB64(p.ct);
|
||||
const key = await crypto.subtle.importKey('raw', bufFromB64(_SESSION_AES_B64), { name: 'AES-GCM' }, false, ['decrypt']);
|
||||
// Web Crypto 约定:密文在前、认证标签在后(iv 已通过算法参数单独传入)
|
||||
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv, tagLength: 128 }, key, concat(ct, tag));
|
||||
return JSON.parse(new TextDecoder().decode(plain));
|
||||
}
|
||||
function concat(...arrs) {
|
||||
let len = 0; for (const a of arrs) len += a.length;
|
||||
const out = new Uint8Array(len); let o = 0;
|
||||
for (const a of arrs) { out.set(a, o); o += a.length; }
|
||||
return out;
|
||||
}
|
||||
|
||||
async function apiEnc(path) {
|
||||
const res = await fetch(path, { credentials: 'same-origin' });
|
||||
const p = await res.json();
|
||||
return decryptPayload(p);
|
||||
}
|
||||
|
||||
// ===== 渲染 =====
|
||||
let currentPage = 1;
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
function fmtTime(iso) {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d)) return iso;
|
||||
return d.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function renderRow(e) {
|
||||
const tr = document.createElement('tr');
|
||||
if (!e.ok) tr.classList.add('row-fail');
|
||||
const ipTypeLabel = e.ipType === 'internal' ? '<span class="tag tag-internal">内网</span>'
|
||||
: e.ipType === 'external' ? '<span class="tag tag-external">外网</span>' : '-';
|
||||
tr.innerHTML = `
|
||||
<td>${fmtTime(e.t)}</td>
|
||||
<td class="mono">${esc(e.ip)}</td>
|
||||
<td>${ipTypeLabel}</td>
|
||||
<td class="mono" title="${esc(e.remote)}">${esc(e.remote)}</td>
|
||||
<td class="mono" title="${esc(e.xff)}">${esc(e.xff && e.xff !== '-' ? e.xff : '-')}</td>
|
||||
<td>${esc(e.method)}</td>
|
||||
<td class="mono">${esc(e.path)}</td>
|
||||
<td>${e.status || '-'}</td>
|
||||
<td class="code">${esc(e.code)}</td>
|
||||
<td class="ua" title="${esc(e.ua)}">${esc(e.ua)}</td>
|
||||
<td>${esc(e.detail || '')}</td>
|
||||
<td class="mono">${esc(e.sid)}</td>`;
|
||||
return tr;
|
||||
}
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(currentPage));
|
||||
params.set('pageSize', String(PAGE_SIZE));
|
||||
const ip = $('fIp').value.trim(); if (ip) params.set('ip', ip);
|
||||
const ipType = $('fIpType').value; if (ipType) params.set('iptype', ipType);
|
||||
const code = $('fCode').value; if (code) params.set('code', code);
|
||||
const method = $('fMethod').value; if (method) params.set('method', method);
|
||||
const pathF = $('fPath').value.trim(); if (pathF) params.set('path', pathF);
|
||||
if ($('fFail').checked) params.set('fail', '1');
|
||||
const from = $('fFrom').value; if (from) params.set('from', new Date(from).toISOString());
|
||||
const to = $('fTo').value; if (to) params.set('to', new Date(to).toISOString());
|
||||
try {
|
||||
const data = await apiEnc('/api/audit?' + params.toString());
|
||||
const body = $('auditBody');
|
||||
body.innerHTML = '';
|
||||
(data.log || []).forEach((e) => body.appendChild(renderRow(e)));
|
||||
$('pageInfo').textContent = '第 ' + (data.page || 1) + ' 页(每页 ' + PAGE_SIZE + ')';
|
||||
$('totalInfo').textContent = ' 命中 ' + (data.total || 0) + ' 条';
|
||||
// 基于索引显示全量日志规模,便于核对分页正确性
|
||||
const idx = await apiEnc('/api/audit/index').catch(() => null);
|
||||
if (idx) {
|
||||
const files = (idx.files || []).map((f) => `${f.file}[${f.startSeq}-${f.endSeq},${f.count}条]`).join(' ');
|
||||
$('indexInfo').textContent = ` 索引总条数 ${idx.total} | 文件分布: ${files}`;
|
||||
}
|
||||
} catch (e) {
|
||||
alert('加载审计日志失败:' + (e && e.message ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBlocks() {
|
||||
try {
|
||||
const data = await apiEnc('/api/audit/blocks');
|
||||
const box = $('blocksBox');
|
||||
const list = $('blocksList');
|
||||
list.innerHTML = '';
|
||||
if (data.blocks && data.blocks.length) {
|
||||
box.style.display = '';
|
||||
data.blocks.forEach((b) => {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `<span class="mono">${esc(b.ip)}</span> 失败 ${b.fails} 次,封锁至 ${fmtTime(b.expiresAt)}
|
||||
<button class="btn-ghost unblock" data-ip="${esc(b.ip)}">解封</button>`;
|
||||
list.appendChild(li);
|
||||
});
|
||||
list.querySelectorAll('.unblock').forEach((btn) => {
|
||||
btn.onclick = async () => {
|
||||
await fetch('/api/audit/unblock', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ip: btn.dataset.ip }) });
|
||||
loadBlocks();
|
||||
};
|
||||
});
|
||||
} else { box.style.display = 'none'; }
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ===== 事件 =====
|
||||
$('searchBtn').onclick = () => { currentPage = 1; load(); };
|
||||
$('resetBtn').onclick = () => {
|
||||
$('fIp').value = ''; $('fCode').value = ''; $('fMethod').value = ''; $('fPath').value = '';
|
||||
$('fFail').checked = false; $('fFrom').value = ''; $('fTo').value = '';
|
||||
currentPage = 1; load();
|
||||
};
|
||||
$('prevPage').onclick = () => { if (currentPage > 1) { currentPage--; load(); } };
|
||||
$('nextPage').onclick = () => { currentPage++; load(); };
|
||||
$('logoutBtn').onclick = async () => {
|
||||
await fetch('/api/logout', { method: 'POST', credentials: 'same-origin' });
|
||||
location.href = '/';
|
||||
};
|
||||
|
||||
// ===== 启动 =====
|
||||
(async () => {
|
||||
// 鉴权检查
|
||||
try {
|
||||
const st = await fetch('/api/status', { credentials: 'same-origin' }).then((r) => r.json());
|
||||
if (!st.authed) { location.href = '/'; return; }
|
||||
} catch (e) { location.href = '/'; return; }
|
||||
await ensureChannel();
|
||||
await load();
|
||||
await loadBlocks();
|
||||
})();
|
||||
@@ -0,0 +1,68 @@
|
||||
<!doctype html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>KeePass 查看器</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
|
||||
<!-- 1. 登录:APP 门户口令 + 验证码 -->
|
||||
<section id="loginView" class="card">
|
||||
<h1>KeePass 查看器</h1>
|
||||
<p class="muted">请输入访问口令</p>
|
||||
<input id="appPw" type="password" placeholder="访问口令(含大小写,≥8位)" autocomplete="off">
|
||||
<div class="captcha-row">
|
||||
<input id="captchaInput" type="text" placeholder="验证码" autocomplete="off" maxlength="5">
|
||||
<span id="captchaImg" class="captcha-img" title="点击刷新"></span>
|
||||
</div>
|
||||
<button id="loginBtn">进入</button>
|
||||
<p id="loginErr" class="err"></p>
|
||||
</section>
|
||||
|
||||
<!-- 2. 解锁:KeePass 主密码 -->
|
||||
<section id="unlockView" class="card hidden">
|
||||
<h1>解锁密码库</h1>
|
||||
<p class="muted">输入 KeePass 主密码(密钥文件由服务端自动使用)</p>
|
||||
<input id="masterPw" type="password" placeholder="主密码" autocomplete="off">
|
||||
<button id="unlockBtn">解锁</button>
|
||||
<p id="unlockErr" class="err"></p>
|
||||
</section>
|
||||
|
||||
<!-- 3. 查看/编辑 -->
|
||||
<section id="mainView" class="hidden">
|
||||
<header class="topbar">
|
||||
<strong id="dbName"></strong>
|
||||
<span id="writableBadge" class="badge hidden">可编辑</span>
|
||||
<input id="search" placeholder="搜索 标题 / 账号 / 网址">
|
||||
<button id="auditBtn" class="ghost">审计日志</button>
|
||||
<button id="logoutBtn" class="ghost">退出</button>
|
||||
</header>
|
||||
<div id="auditPanel" class="hidden">
|
||||
<h3>审计日志</h3>
|
||||
<div id="auditList" class="audit-list"></div>
|
||||
<button id="auditClose" class="ghost">关闭</button>
|
||||
</div>
|
||||
<div class="layout">
|
||||
<aside id="groupTree"></aside>
|
||||
<main>
|
||||
<ul id="entryList"></ul>
|
||||
<div id="pager"></div>
|
||||
</main>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 4. 详情弹窗 -->
|
||||
<div id="modal" class="modal hidden">
|
||||
<div class="modal-card">
|
||||
<button id="modalClose" class="modal-close" aria-label="关闭">×</button>
|
||||
<div id="modalBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
:root {
|
||||
--bg: #f5f6f8; --card: #fff; --line: #e3e6ea; --text: #1f2329;
|
||||
--muted: #8a9099; --primary: #2f6df6; --danger: #d8392b; --active: #e8f0ff;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
background: var(--bg); color: var(--text);
|
||||
}
|
||||
.hidden { display: none !important; }
|
||||
.card {
|
||||
max-width: 360px; margin: 12vh auto; background: var(--card);
|
||||
border: 1px solid var(--line); border-radius: 12px; padding: 28px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,.05);
|
||||
}
|
||||
.card h1 { font-size: 20px; margin: 0 0 6px; }
|
||||
.muted { color: var(--muted); font-size: 13px; margin: 0 0 16px; }
|
||||
input, textarea {
|
||||
width: 100%; padding: 10px 12px; border: 1px solid var(--line);
|
||||
border-radius: 8px; font-size: 14px; margin-bottom: 12px;
|
||||
}
|
||||
button {
|
||||
background: var(--primary); color: #fff; border: 0; border-radius: 8px;
|
||||
padding: 10px 16px; font-size: 14px; cursor: pointer;
|
||||
}
|
||||
button.ghost { background: transparent; color: var(--primary); border: 1px solid var(--line); }
|
||||
.err { color: var(--danger); font-size: 13px; min-height: 18px; margin: 6px 0 0; }
|
||||
.badge {
|
||||
background: #fff3e0; color: #b26a00; font-size: 12px;
|
||||
padding: 2px 8px; border-radius: 10px; margin-left: 8px;
|
||||
}
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: 12px; padding: 12px 16px;
|
||||
background: var(--card); border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.topbar #dbName { font-size: 15px; }
|
||||
.topbar #search { width: 240px; margin: 0 0 0 auto; }
|
||||
.layout { display: flex; height: calc(100vh - 53px); }
|
||||
#groupTree {
|
||||
width: 240px; border-right: 1px solid var(--line); overflow: auto;
|
||||
padding: 10px; background: var(--card);
|
||||
}
|
||||
.node { padding: 7px 10px; border-radius: 6px; font-size: 13px; cursor: pointer; color: var(--text); }
|
||||
.node:hover { background: #f0f2f5; }
|
||||
.node.active { background: var(--active); color: var(--primary); font-weight: 600; }
|
||||
main { flex: 1; overflow: auto; padding: 16px; }
|
||||
#entryList { list-style: none; margin: 0; padding: 0; }
|
||||
.entry {
|
||||
padding: 10px 12px; border: 1px solid var(--line); border-radius: 8px;
|
||||
margin-bottom: 8px; cursor: pointer; display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.entry:hover { border-color: var(--primary); }
|
||||
.entry.active { border-color: var(--primary); background: var(--active); }
|
||||
.entry .t { font-weight: 600; }
|
||||
.entry .u { font-size: 12px; color: var(--muted); }
|
||||
.empty { color: var(--muted); padding: 12px; }
|
||||
#detail { margin-top: 12px; border-top: 1px dashed var(--line); padding-top: 12px; }
|
||||
#detail h2 { margin: 0 0 12px; font-size: 18px; }
|
||||
.row { display: flex; align-items: flex-start; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--line); }
|
||||
.row .label { width: 56px; color: var(--muted); font-size: 13px; flex: none; }
|
||||
.row .value { flex: 1; word-break: break-all; font-size: 14px; white-space: pre-wrap; }
|
||||
.row .copy, .row .open { flex: none; font-size: 12px; padding: 4px 10px; }
|
||||
|
||||
/* ===== 详情弹窗 ===== */
|
||||
.modal {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,.45);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 50;
|
||||
}
|
||||
.modal.hidden { display: none !important; }
|
||||
.modal-card {
|
||||
background: var(--card); width: min(520px, 92vw); max-height: 86vh; overflow: auto;
|
||||
border-radius: 14px; padding: 22px 24px; position: relative;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,.2);
|
||||
}
|
||||
.modal-card h2 { margin: 0 0 14px; font-size: 18px; padding-right: 28px; }
|
||||
.modal-close {
|
||||
position: absolute; top: 12px; right: 12px; background: transparent; color: var(--muted);
|
||||
border: 0; font-size: 22px; line-height: 1; cursor: pointer; padding: 4px 8px;
|
||||
}
|
||||
.modal-close:hover { color: var(--text); }
|
||||
|
||||
/* ===== 分页 ===== */
|
||||
#pager { display: flex; align-items: center; gap: 12px; padding: 12px 4px; justify-content: center; }
|
||||
#pager .pinfo { font-size: 13px; color: var(--muted); }
|
||||
#pager button:disabled { opacity: .4; cursor: not-allowed; }
|
||||
|
||||
/* ===== 验证码 ===== */
|
||||
.captcha-row { display: flex; gap: 8px; align-items: center; }
|
||||
.captcha-row input { flex: 1; }
|
||||
.captcha-img { display: inline-flex; height: 44px; border: 1px solid var(--border); border-radius: 8px; cursor: pointer; overflow: hidden; background: #fff; }
|
||||
.captcha-img svg { height: 44px; display: block; }
|
||||
|
||||
/* ===== 审计日志 ===== */
|
||||
#auditPanel { padding: 16px 20px; border-bottom: 1px solid var(--border); background: var(--card); }
|
||||
#auditPanel h3 { margin: 0 0 10px; }
|
||||
.audit-list { max-height: 260px; overflow: auto; font-family: monospace; font-size: 12px; }
|
||||
.audit-list .row { display: flex; gap: 10px; padding: 2px 0; border-bottom: 1px dashed var(--border); }
|
||||
.audit-list .ok { color: var(--ok, #2f855a); }
|
||||
.audit-list .fail { color: var(--err, #c53030); }
|
||||
|
||||
/* ===== 独立审计页 ===== */
|
||||
.audit-page { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||
.audit-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||
.audit-header h1 { font-size: 20px; margin: 0; }
|
||||
.audit-actions { display: flex; gap: 8px; }
|
||||
.audit-filters { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 16px; }
|
||||
.audit-filters input, .audit-filters select { padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; font-size: 13px; }
|
||||
.audit-filters input[type="text"] { width: 160px; }
|
||||
.audit-filters .chk { font-size: 13px; display: flex; align-items: center; gap: 4px; }
|
||||
.audit-blocks { background: #fff4f3; border: 1px solid #f3c7c2; border-radius: 8px; padding: 10px 14px; margin-bottom: 16px; }
|
||||
.audit-blocks h3 { margin: 0 0 6px; color: var(--danger); font-size: 14px; }
|
||||
.audit-blocks ul { margin: 0; padding-left: 18px; font-size: 13px; }
|
||||
.audit-blocks li { padding: 2px 0; }
|
||||
.audit-blocks .mono, .audit-table .mono { font-family: monospace; }
|
||||
.audit-table-wrap { background: var(--card); border: 1px solid var(--line); border-radius: 10px; overflow: auto; }
|
||||
.audit-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.audit-table th, .audit-table td { padding: 8px 10px; border-bottom: 1px solid var(--line); text-align: left; white-space: nowrap; }
|
||||
.audit-table th { background: #fafbfc; position: sticky; top: 0; font-weight: 600; }
|
||||
.audit-table tbody tr.row-fail { background: #fff5f5; }
|
||||
.audit-table .ua { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
|
||||
.audit-table .code { font-family: monospace; }
|
||||
.audit-table .code:not(:empty) { color: var(--danger); }
|
||||
.tag { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 11px; line-height: 1.5; }
|
||||
.tag-internal { background: #fff3cd; color: #8a6d00; border: 1px solid #ffe69c; }
|
||||
.tag-external { background: #d1ecf1; color: #0c5460; border: 1px solid #bee5eb; }
|
||||
.index-info { display: block; margin-top: 6px; font-size: 12px; color: var(--muted); font-family: monospace; word-break: break-all; }
|
||||
.audit-filters select { padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; font-size: 13px; }
|
||||
.audit-pager { display: flex; align-items: center; gap: 12px; padding: 10px 14px; font-size: 13px; flex-wrap: wrap; }
|
||||
.btn { padding: 6px 14px; background: var(--primary); color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
||||
.btn:hover { filter: brightness(1.05); }
|
||||
.btn-ghost { padding: 6px 12px; background: #fff; border: 1px solid var(--line); border-radius: 6px; cursor: pointer; font-size: 13px; }
|
||||
.btn-ghost:hover { background: #f0f2f5; }
|
||||
@@ -0,0 +1,759 @@
|
||||
'use strict';
|
||||
require('dotenv').config(); // 必须在 require('./config') 之前加载 .env
|
||||
const express = require('express');
|
||||
const session = require('express-session');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
|
||||
const config = require('./config');
|
||||
const { loadDatabase, extract, ProtectedValue } = require('./kdbxlib');
|
||||
|
||||
// 是否启用 HTTPS:证书存在则启用;否则尝试自动生成自签证书(容器/首次部署默认 HTTPS)
|
||||
// 注意:session.cookie.secure 必须是布尔值;用函数形式在 trust proxy 下会导致 cookie 不下发
|
||||
function ensureSsl() {
|
||||
const keyPath = process.env.SSL_KEY || path.join(__dirname, 'ssl', 'key.pem');
|
||||
const certPath = process.env.SSL_CERT || path.join(__dirname, 'ssl', 'cert.pem');
|
||||
if (fs.existsSync(keyPath) && fs.existsSync(certPath)) return keyPath;
|
||||
// 证书缺失:自动生成自签证书,保证默认走 HTTPS(生产应使用受信任证书替换)
|
||||
try {
|
||||
const { generateSelfSigned } = require('./gen-cert');
|
||||
generateSelfSigned(keyPath, certPath, 'localhost');
|
||||
console.log(`[ssl] 未检测到证书,已自动生成自签证书: ${keyPath}`);
|
||||
return fs.existsSync(keyPath) && fs.existsSync(certPath) ? keyPath : null;
|
||||
} catch (e) {
|
||||
console.error('[ssl] 自动生成证书失败,将回退 HTTP:', e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function detectSsl() { return !!ensureSsl(); }
|
||||
const HAS_SSL = detectSsl();
|
||||
|
||||
// ============ 密钥体系 ============
|
||||
// 1) 长期 RSA(仅用于加密传输 login/unlock 的口令,私钥仅存服务端内存)
|
||||
const TRANSPORT_RSA = crypto.generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
});
|
||||
|
||||
// 解密前端用传输公钥加密的密文
|
||||
function rsaDecrypt(b64) {
|
||||
const buf = Buffer.from(b64, 'base64');
|
||||
return crypto.privateDecrypt(
|
||||
{ key: TRANSPORT_RSA.privateKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
|
||||
buf
|
||||
).toString('utf8');
|
||||
}
|
||||
|
||||
// 2) 会话级 AES(通道加密:login 协商,用于加密所有响应体)
|
||||
const sessionKeys = new Map(); // sessionID -> Buffer(32)
|
||||
|
||||
function encPayload(req, obj) {
|
||||
const key = sessionKeys.get(req.sessionID);
|
||||
if (!key) return obj;
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
const ct = Buffer.concat([cipher.update(JSON.stringify(obj), 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return { iv: iv.toString('base64'), ct: ct.toString('base64'), tag: tag.toString('base64') };
|
||||
}
|
||||
function encRes(req, res, obj, status = 200) { res.status(status).json(encPayload(req, obj)); }
|
||||
|
||||
// ============ 错误码(便于审计与前端区分)============
|
||||
const ERR = {
|
||||
AUTH_APP_FAIL: 'AUTH_APP_FAIL', // 门户口令错误
|
||||
CAPTCHA_FAIL: 'CAPTCHA_FAIL', // 验证码错误
|
||||
IP_BLOCKED: 'IP_BLOCKED', // IP 被封锁
|
||||
AUTH_MASTER_FAIL: 'AUTH_MASTER_FAIL', // 主密码/keyfile 错误
|
||||
KEY_EXPIRED: 'KEY_EXPIRED', // 动态数据密钥过期
|
||||
ENC_FAIL: 'ENC_FAIL', // 密文解析失败
|
||||
MISSING: 'MISSING', // 缺少参数
|
||||
NOT_FOUND: 'NOT_FOUND',
|
||||
RATE_LIMIT: 'RATE_LIMIT',
|
||||
};
|
||||
|
||||
// ============ 审计日志(本地文件持久化 + 索引文件,按大小切分)============
|
||||
// 数据模型:
|
||||
// - 每个日志文件(audit.log / audit.N.log)为 JSON Lines,每行一条记录,按全局 seq 单调递增
|
||||
// - 索引文件 audit.index.json 记录每个文件的 起始序号/结束序号/条数/字节/时间范围,以及全局总条数
|
||||
// - 前端分页先读取索引计算分布,再按需从对应文件提取片段,保证分页与文件一致
|
||||
const AUDIT_FILE_MAX = config.auditFileMax; // 单文件切分阈值,可由 AUDIT_FILE_MAX 环境变量配置(默认 5M)
|
||||
const AUDIT_FILE_KEEP = config.auditFileKeep; // 切分文件最多保留份数,可由 AUDIT_FILE_KEEP 环境变量配置(默认 10)
|
||||
let auditSeq = 0; // 全局单调递增序号(持久化,重启后从索引恢复)
|
||||
|
||||
const logDir = config.logDir;
|
||||
try { fs.mkdirSync(logDir, { recursive: true }); } catch (e) {}
|
||||
function auditCurrentFile() { return path.join(logDir, 'audit.log'); }
|
||||
function auditRotatedFile(i) { return path.join(logDir, `audit.${i}.log`); }
|
||||
function auditIndexFile() { return path.join(logDir, 'audit.index.json'); }
|
||||
|
||||
// 索引:{ total, nextSeq, files: [ { file, startSeq, endSeq, count, bytes, firstTime, lastTime } ] }
|
||||
// nextSeq:全局下一个序号(跨重启持久化,避免 seq 重叠导致分页/区间错乱)
|
||||
let auditIndex = { total: 0, nextSeq: 0, files: [] };
|
||||
|
||||
// 启动时从索引文件恢复(若无则基于现有审计文件重建)
|
||||
function auditLoadIndex() {
|
||||
try {
|
||||
const raw = fs.readFileSync(auditIndexFile(), 'utf8');
|
||||
const idx = JSON.parse(raw);
|
||||
if (idx && Array.isArray(idx.files)) {
|
||||
auditIndex = idx;
|
||||
auditSeq = auditIndex.nextSeq || auditIndex.files.reduce((m, f) => Math.max(m, f.endSeq || 0), 0);
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* 索引缺失或损坏,下面重建 */ }
|
||||
// 重建:扫描存在的审计文件,重排行号(按文件名顺序,认为 audit.log 最新)
|
||||
auditRebuildIndex();
|
||||
auditIndex.nextSeq = auditSeq;
|
||||
auditSaveIndex();
|
||||
}
|
||||
// 扫描磁盘上的审计文件并重建索引(在索引丢失/损坏时调用)
|
||||
function auditRebuildIndex() {
|
||||
const names = [];
|
||||
if (fs.existsSync(auditCurrentFile())) names.push('audit.log');
|
||||
for (let i = 1; i <= AUDIT_FILE_KEEP; i++) {
|
||||
const n = `audit.${i}.log`;
|
||||
if (fs.existsSync(auditRotatedFile(i))) names.push(n);
|
||||
}
|
||||
// 顺序:audit.10 ... audit.1 在前(旧),audit.log 在最后(新)
|
||||
names.sort((a, b) => {
|
||||
const num = (n) => n === 'audit.log' ? 0 : -parseInt(n.match(/(\d+)/)[1], 10);
|
||||
return num(a) - num(b);
|
||||
});
|
||||
let seq = 0;
|
||||
auditIndex = { total: 0, nextSeq: 0, files: [] };
|
||||
for (const n of names) {
|
||||
const fp = path.join(logDir, n);
|
||||
let bytes = 0; try { bytes = fs.statSync(fp).size; } catch (e) {}
|
||||
const lines = readAuditLines(fp);
|
||||
if (!lines.length) continue;
|
||||
const startSeq = seq + 1;
|
||||
let firstTime = '', lastTime = '';
|
||||
lines.forEach((e, i) => { e.seq = startSeq + i; seq = e.seq; if (!firstTime) firstTime = e.t; lastTime = e.t; });
|
||||
const endSeq = seq;
|
||||
auditIndex.files.push({ file: n, startSeq, endSeq, count: lines.length, bytes, firstTime, lastTime });
|
||||
}
|
||||
auditIndex.total = auditIndex.files.reduce((s, f) => s + f.count, 0);
|
||||
auditSeq = auditIndex.files.reduce((m, f) => Math.max(m, f.endSeq || 0), 0);
|
||||
auditIndex.nextSeq = auditSeq;
|
||||
auditSaveIndex();
|
||||
}
|
||||
function readAuditLines(fp) {
|
||||
let txt = '';
|
||||
try { txt = fs.readFileSync(fp, 'utf8'); } catch (e) { return []; }
|
||||
const out = [];
|
||||
txt.split('\n').forEach((ln) => {
|
||||
ln = ln.trim();
|
||||
if (!ln) return;
|
||||
try { out.push(JSON.parse(ln)); } catch (e) {}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function auditSaveIndex() {
|
||||
// 索引文件很小(仅元数据),每次追加后同步落盘,保证重启/异常退出后分页与文件一致
|
||||
try { fs.writeFileSync(auditIndexFile(), JSON.stringify(auditIndex)); } catch (e) {}
|
||||
}
|
||||
|
||||
// 写入一行到当前审计文件(JSON Lines);超阈值则切分(滚动重命名),并同步更新索引
|
||||
function auditAppendToFile(line) {
|
||||
try {
|
||||
const cur = auditCurrentFile();
|
||||
let size = 0;
|
||||
try { size = fs.statSync(cur).size; } catch (e) {}
|
||||
if (size + line.length + 1 > AUDIT_FILE_MAX) {
|
||||
// 将当前文件归档进索引(切分前先记录其区间)
|
||||
const curLines = readAuditLines(cur);
|
||||
let curStart = auditIndex.files.length && auditIndex.files[auditIndex.files.length - 1].file === 'audit.log'
|
||||
? auditIndex.files[auditIndex.files.length - 1].startSeq : (auditSeq - curLines.length + 1);
|
||||
const curEnd = curStart + curLines.length - 1;
|
||||
// 滚动:audit.9 -> 删除, audit.8 -> audit.9, ... audit.log -> audit.1
|
||||
for (let i = AUDIT_FILE_KEEP - 1; i >= 1; i--) {
|
||||
const src = i === 1 ? cur : auditRotatedFile(i - 1);
|
||||
const dst = auditRotatedFile(i);
|
||||
try { if (fs.existsSync(src)) fs.renameSync(src, dst); } catch (e) {}
|
||||
}
|
||||
// 把刚归档的 audit.log 信息更新进索引(它现在变成 audit.1)
|
||||
const fobj = { file: 'audit.log', startSeq: curStart, endSeq: curEnd, count: curLines.length, bytes: size, firstTime: curLines[0] ? curLines[0].t : '', lastTime: curLines[curLines.length - 1] ? curLines[curLines.length - 1].t : '' };
|
||||
const existing = auditIndex.files.find((f) => f.file === 'audit.log');
|
||||
if (existing) Object.assign(existing, fobj); else auditIndex.files.push(fobj);
|
||||
// 重排:保证 audit.log 始终在数组末尾;其他按序号倒序
|
||||
auditIndex.files.sort((a, b) => {
|
||||
const num = (n) => n === 'audit.log' ? -1 : -parseInt(n.match(/(\d+)/)[1], 10);
|
||||
return num(a) - num(b);
|
||||
});
|
||||
auditSaveIndex();
|
||||
}
|
||||
fs.appendFileSync(cur, line + '\n');
|
||||
} catch (e) { /* 文件写入失败不阻断主流程 */ }
|
||||
}
|
||||
|
||||
// 记录一条审计:详情包含来源 IP(含内网/外网、XFF 全链、直连地址)、UA、时间、方法、路径等
|
||||
function audit({ ip, method, path: p, sessionId, code, ok, ua, referer, status, detail }) {
|
||||
// ip 可为字符串(兼容旧调用)或结构化对象 { ip, remote, xff, isInternal }
|
||||
const ipInfo = (typeof ip === 'object' && ip) ? ip : { ip: ip || 'unknown', remote: '', xff: '', isInternal: false };
|
||||
const entry = {
|
||||
seq: ++auditSeq,
|
||||
t: new Date().toISOString(),
|
||||
ip: ipInfo.ip || 'unknown',
|
||||
ipType: ipInfo.isInternal ? 'internal' : 'external',
|
||||
remote: ipInfo.remote || '-', // 直连(代理/服务端看到的)地址
|
||||
xff: ipInfo.xff || '-', // 完整 X-Forwarded-For 链
|
||||
ua: ua || '-',
|
||||
referer: referer || '-',
|
||||
method: method || '-',
|
||||
path: p || '-',
|
||||
status: status || 0,
|
||||
code: code || '-',
|
||||
ok: ok ? 1 : 0,
|
||||
sid: sessionId ? sessionId.slice(0, 8) : '-',
|
||||
detail: detail || '',
|
||||
};
|
||||
auditAppendToFile(JSON.stringify(entry));
|
||||
// 维护索引中当前文件(audit.log)的区间
|
||||
const curFile = auditIndex.files.find((f) => f.file === 'audit.log');
|
||||
if (curFile) {
|
||||
if (curFile.count === 0) { curFile.startSeq = entry.seq; curFile.firstTime = entry.t; }
|
||||
curFile.endSeq = entry.seq;
|
||||
curFile.count += 1;
|
||||
curFile.lastTime = entry.t;
|
||||
try { curFile.bytes = fs.statSync(auditCurrentFile()).size; } catch (e) {}
|
||||
} else {
|
||||
auditIndex.files.push({ file: 'audit.log', startSeq: entry.seq, endSeq: entry.seq, count: 1, bytes: 0, firstTime: entry.t, lastTime: entry.t });
|
||||
}
|
||||
auditIndex.total += 1;
|
||||
auditIndex.nextSeq = auditSeq + 1;
|
||||
auditSaveIndex();
|
||||
}
|
||||
|
||||
// ============ IP 封锁(登录错误 5 次/30min)============
|
||||
const ipFails = new Map(); // ip -> { count, first }
|
||||
const IP_WINDOW = 30 * 60 * 1000;
|
||||
const IP_MAX_FAIL = 5;
|
||||
function recordIpFail(ip) {
|
||||
const now = Date.now();
|
||||
let r = ipFails.get(ip);
|
||||
if (!r || now - r.first > IP_WINDOW) r = { count: 0, first: now };
|
||||
r.count++; ipFails.set(ip, r);
|
||||
return r.count >= IP_MAX_FAIL;
|
||||
}
|
||||
function isIpBlocked(ip) {
|
||||
const r = ipFails.get(ip);
|
||||
if (!r) return false;
|
||||
if (Date.now() - r.first > IP_WINDOW) { ipFails.delete(ip); return false; }
|
||||
return r.count >= IP_MAX_FAIL;
|
||||
}
|
||||
|
||||
// 从请求构造审计的基础字段(IP/UA/referer),与各接口的具体 code/status 合并
|
||||
function auditFromReq(req, res, extra) {
|
||||
return Object.assign({
|
||||
ip: getClientIp(req),
|
||||
ua: req.headers['user-agent'] || '',
|
||||
referer: req.headers['referer'] || '',
|
||||
sessionId: req.sessionID,
|
||||
status: res.statusCode,
|
||||
}, extra);
|
||||
}
|
||||
|
||||
// ============ 验证码 ============
|
||||
const captchaStore = new Map(); // sessionID -> { text, expires }
|
||||
const CAPTCHA_TTL = 5 * 60 * 1000;
|
||||
function genCaptcha() {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // 去掉易混淆字符
|
||||
let text = '';
|
||||
for (let i = 0; i < 5; i++) text += chars[Math.floor(Math.random() * chars.length)];
|
||||
// 生成 SVG 图片
|
||||
const colors = ['#2b6cb0', '#2f855a', '#c05621', '#6b46c1'];
|
||||
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="44" viewBox="0 0 120 44">`;
|
||||
svg += `<rect width="120" height="44" fill="#f7fafc"/>`;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const x = 14 + i * 22, y = 30 + (Math.random() * 6 - 3);
|
||||
const rot = Math.random() * 30 - 15;
|
||||
const c = colors[i % colors.length];
|
||||
svg += `<text x="${x}" y="${y}" font-size="26" font-family="monospace" font-weight="bold" fill="${c}" transform="rotate(${rot} ${x} ${y})">${text[i]}</text>`;
|
||||
}
|
||||
// 干扰线
|
||||
for (let i = 0; i < 3; i++) {
|
||||
svg += `<line x1="${Math.random() * 120}" y1="${Math.random() * 44}" x2="${Math.random() * 120}" y2="${Math.random() * 44}" stroke="#cbd5e0" stroke-width="1"/>`;
|
||||
}
|
||||
svg += `</svg>`;
|
||||
return { text, svg };
|
||||
}
|
||||
|
||||
// 解析客户端 IP 信息:完整记录直连地址、X-Forwarded-For 全链、真实客户端 IP 及是否内网
|
||||
// 拦截器/反向代理场景:XFF 最右侧(最后一个)为真实客户端,前面为各级代理
|
||||
const PRIVATE_RE = /^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/;
|
||||
function isPrivateIp(ip) {
|
||||
if (!ip) return false;
|
||||
ip = ip.trim().replace(/^::ffff:/, '');
|
||||
if (ip === '::1' || ip === 'localhost' || ip === 'unknown') return true;
|
||||
return PRIVATE_RE.test(ip);
|
||||
}
|
||||
function getClientIp(req) {
|
||||
const remote = (req.socket && req.socket.remoteAddress) || 'unknown';
|
||||
const xffRaw = (req.headers['x-forwarded-for'] || '').toString().trim();
|
||||
const xffList = xffRaw ? xffRaw.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
||||
// 真实客户端:XFF 链最后一跳(离服务端最远),若没有 XFF 则取直连
|
||||
const realIp = xffList.length ? xffList[xffList.length - 1] : remote;
|
||||
const isInternal = isPrivateIp(realIp) && xffList.length === 0 ? isPrivateIp(remote) : isPrivateIp(realIp);
|
||||
return { ip: realIp, remote, xff: xffRaw, isInternal };
|
||||
}
|
||||
|
||||
// ============ Express ============
|
||||
const app = express();
|
||||
// 注意:未启用 trust proxy。若部署在反向代理后,请按实际拓扑设置;
|
||||
// 当前按直连处理,IP 通过 req.socket.remoteAddress 获取,避免误判。
|
||||
app.disable('x-powered-by');
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('X-Frame-Options', 'DENY');
|
||||
res.setHeader('Referrer-Policy', 'no-referrer');
|
||||
// 基础 CSP:仅允许同源脚本/样式,防 XSS 与注入
|
||||
res.setHeader('Content-Security-Policy',
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'");
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(express.json({ limit: '8kb' }));
|
||||
app.use(express.static(path.join(__dirname, 'public'), { extensions: ['html'] }));
|
||||
|
||||
// 注:登录不复用 express-rate-limit(用户要求 login 不限流);
|
||||
// 防爆破由下方「登录失败 IP 封锁」机制(30 分钟内 5 次失败封 IP)承担。
|
||||
// unlock 同样处于 login 之后,且主密码错误仅返回 401,无额外限流。
|
||||
|
||||
app.use(session({
|
||||
secret: config.sessionSecret || crypto.randomBytes(32).toString('hex'),
|
||||
resave: true,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true, sameSite: 'lax',
|
||||
// HTTPS 模式下标记为 Secure;HTTP 开发模式为 false(cookie 必须始终下发)
|
||||
secure: HAS_SSL,
|
||||
maxAge: config.sessionMaxAge,
|
||||
},
|
||||
}));
|
||||
|
||||
// 全局审计中间件:注册在 session 之后,确保 req.sessionID 有效;
|
||||
// 已在具体接口内精细记录(带错误码)的,通过 res._audited 标记避免重复
|
||||
app.use('/api', (req, res, next) => {
|
||||
res.on('finish', () => {
|
||||
if (res._audited) return;
|
||||
const ip = getClientIp(req);
|
||||
audit({
|
||||
ip, method: req.method, path: req.path, sessionId: req.sessionID,
|
||||
code: res.statusCode < 400 ? 'OK' : 'ERR' + res.statusCode,
|
||||
ok: res.statusCode < 400,
|
||||
ua: req.headers['user-agent'] || '',
|
||||
referer: req.headers['referer'] || '',
|
||||
status: res.statusCode,
|
||||
});
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
// ---- 中间件 ----
|
||||
function appAuth(req, res, next) {
|
||||
if (req.session && req.session.appAuthed) return next();
|
||||
return res.status(401).json({ error: '未登录' });
|
||||
}
|
||||
function unlocked(req, res, next) {
|
||||
if (dbs.has(req.sessionID)) return next();
|
||||
return res.status(401).json({ error: '未解锁' });
|
||||
}
|
||||
function writable(req, res, next) {
|
||||
if (config.writable) return next();
|
||||
return res.status(403).json({ error: '只读模式' });
|
||||
}
|
||||
|
||||
// 解密前端用传输公钥加密的密文(提取 helper)
|
||||
function decryptBody(req, field) {
|
||||
const raw = (req.body && req.body[field]) || '';
|
||||
if (!raw) throw new Error(ERR.ENC_FAIL);
|
||||
return rsaDecrypt(raw);
|
||||
}
|
||||
|
||||
// 动态数据密钥是否过期(1 小时)
|
||||
function checkDataKey(req, res) {
|
||||
const rec = dbs.get(req.sessionID);
|
||||
if (!rec) return res.status(401).json({ error: '未解锁' });
|
||||
if (Date.now() > rec.dataKeyExpire) {
|
||||
return res.status(403).json({ error: '动态密钥已过期,请重新解锁', code: ERR.KEY_EXPIRED });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 解密后端用会话 dataRSA 公钥加密的字段(密码)-> 这里只是转发密文,前端用私钥解
|
||||
// 后端只存密文,明文不落内存
|
||||
|
||||
// ============ 路由 ============
|
||||
const dbs = new Map(); // sessionID -> { db, items, groups, tree, name, dataKeyExpire }
|
||||
|
||||
// 状态探测
|
||||
app.get('/api/status', (req, res) => {
|
||||
const authed = !!(req.session && req.session.appAuthed);
|
||||
const unlocked = dbs.has(req.sessionID);
|
||||
res.json({ authed, unlocked, writable: config.writable, name: unlocked ? dbs.get(req.sessionID).name : null });
|
||||
});
|
||||
|
||||
// 下发传输公钥
|
||||
app.get('/api/pubkey', (req, res) => res.json({ pubkey: TRANSPORT_RSA.publicKey }));
|
||||
|
||||
// 验证码(SVG 图片 + 答案存内存,返回 captchaId 由前端在登录时回传)
|
||||
app.get('/api/captcha', (req, res) => {
|
||||
const { text, svg } = genCaptcha();
|
||||
const cid = crypto.randomBytes(8).toString('hex');
|
||||
captchaStore.set(cid, { text, expires: Date.now() + CAPTCHA_TTL });
|
||||
// 仅测试环境回显明文,便于自动化;生产环境绝不返回 text
|
||||
if (process.env.NODE_ENV === 'test') res.json({ cid, svg, text });
|
||||
else res.json({ cid, svg });
|
||||
});
|
||||
|
||||
// 登录:APP 门户口令 + 验证码
|
||||
app.post('/api/login', (req, res) => {
|
||||
const ip = getClientIp(req);
|
||||
if (isIpBlocked(ip)) {
|
||||
res._audited = true;
|
||||
audit(auditFromReq(req, res, { method: 'POST', path: '/api/login', code: ERR.IP_BLOCKED, ok: false, detail: 'IP 已封锁' }));
|
||||
return res.status(429).json({ error: '该 IP 已被封锁,请 30 分钟后再试', code: ERR.IP_BLOCKED });
|
||||
}
|
||||
let password, dataPubPem, captcha, captchaId;
|
||||
try {
|
||||
password = decryptBody(req, 'enc');
|
||||
dataPubPem = (req.body && req.body.dataPubKey) || '';
|
||||
captcha = (req.body && req.body.captcha) || '';
|
||||
captchaId = (req.body && req.body.captchaId) || '';
|
||||
} catch (e) {
|
||||
res._audited = true;
|
||||
audit(auditFromReq(req, res, { method: 'POST', path: '/api/login', code: ERR.ENC_FAIL, ok: false, detail: '请求密文解析失败' }));
|
||||
return res.status(400).json({ error: '密文解析失败', code: ERR.ENC_FAIL });
|
||||
}
|
||||
// 验证码校验
|
||||
const cap = captchaStore.get(captchaId);
|
||||
captchaStore.delete(captchaId);
|
||||
if (!cap || Date.now() > cap.expires || !captcha || captcha.toUpperCase() !== cap.text) {
|
||||
recordIpFail(ip);
|
||||
res._audited = true;
|
||||
audit(auditFromReq(req, res, { method: 'POST', path: '/api/login', code: ERR.CAPTCHA_FAIL, ok: false, detail: '验证码错误' }));
|
||||
return res.status(400).json({ error: '验证码错误', code: ERR.CAPTCHA_FAIL });
|
||||
}
|
||||
// 门户口令校验(含大小写+8位规则在 config 侧已由期望值约束;此处仅比对)
|
||||
if (!password || password !== config.getExpectedAppPassword()) {
|
||||
const blocked = recordIpFail(ip);
|
||||
res._audited = true;
|
||||
audit(auditFromReq(req, res, { method: 'POST', path: '/api/login', code: ERR.AUTH_APP_FAIL, ok: false, detail: blocked ? '尝试过多已封锁' : '门户口令错误' }));
|
||||
if (blocked) return res.status(429).json({ error: '尝试次数过多,IP 已封锁', code: ERR.IP_BLOCKED });
|
||||
return res.status(401).json({ error: '门户口令错误', code: ERR.AUTH_APP_FAIL });
|
||||
}
|
||||
// 门户口令合规检查:必须含大小写且长度>=8(期望值本身应满足,这里做额外策略校验)
|
||||
if (!/^(?=.*[a-z])(?=.*[A-Z]).{8,}$/.test(password)) {
|
||||
res._audited = true;
|
||||
audit(auditFromReq(req, res, { method: 'POST', path: '/api/login', code: ERR.AUTH_APP_FAIL, ok: false, detail: '口令复杂度不满足' }));
|
||||
return res.status(401).json({ error: '门户口令不符合复杂度要求', code: ERR.AUTH_APP_FAIL });
|
||||
}
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return res.status(500).json({ error: '会话错误' });
|
||||
req.session.appAuthed = true;
|
||||
// 暂存前端 dataRSA 公钥,unlock 时使用
|
||||
if (dataPubPem) req.session.dataPubPem = dataPubPem;
|
||||
// 协商会话 AES 通道密钥:前端用传输公钥加密上传,服务端用私钥解出 32 字节写入内存
|
||||
sessionKeys.delete(req.sessionID); // 旧会话的通道密钥清理
|
||||
try {
|
||||
const skB64 = (req.body && req.body.sessionKey) ? rsaDecrypt(req.body.sessionKey) : '';
|
||||
const skBuf = Buffer.from(skB64, 'base64');
|
||||
if (skBuf.length === 32) sessionKeys.set(req.sessionID, skBuf);
|
||||
} catch (e) { /* 通道密钥缺失不阻断登录,仅后续响应走明文兜底 */ }
|
||||
res._audited = true;
|
||||
audit(auditFromReq(req, res, { method: 'POST', path: '/api/login', code: 'OK', ok: true, detail: '登录成功' }));
|
||||
res.json({ ok: true, writable: config.writable });
|
||||
});
|
||||
});
|
||||
|
||||
// 解锁:KeePass 主密码 -> 解密库 -> 用前端 dataRSA 公钥加密密码字段存密文(内存无明文)
|
||||
app.post('/api/unlock', appAuth, async (req, res) => {
|
||||
const ip = getClientIp(req);
|
||||
let masterPassword;
|
||||
try { masterPassword = decryptBody(req, 'enc'); }
|
||||
catch (e) {
|
||||
res._audited = true;
|
||||
audit(auditFromReq(req, res, { method: 'POST', path: '/api/unlock', code: ERR.ENC_FAIL, ok: false, detail: '请求密文解析失败' }));
|
||||
return res.status(400).json({ error: '密文解析失败', code: ERR.ENC_FAIL });
|
||||
}
|
||||
if (!masterPassword) {
|
||||
res._audited = true;
|
||||
audit(auditFromReq(req, res, { method: 'POST', path: '/api/unlock', code: ERR.MISSING, ok: false, detail: '缺少主密码' }));
|
||||
return res.status(400).json({ error: '缺少主密码', code: ERR.MISSING });
|
||||
}
|
||||
const dataPubPem = req.session.dataPubPem;
|
||||
if (!dataPubPem) {
|
||||
res._audited = true;
|
||||
audit(auditFromReq(req, res, { method: 'POST', path: '/api/unlock', code: ERR.MISSING, ok: false, detail: '缺少数据公钥' }));
|
||||
return res.status(400).json({ error: '缺少数据公钥,请重新登录', code: ERR.MISSING });
|
||||
}
|
||||
try {
|
||||
const rec = await loadDatabase(
|
||||
config.kdbxPath,
|
||||
config.keyfilePath || undefined,
|
||||
masterPassword,
|
||||
dataPubPem // 用于加密密码字段存密文
|
||||
);
|
||||
const prevKeys = sessionKeys.get(req.sessionID);
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return res.status(500).json({ error: '会话错误' });
|
||||
req.session.appAuthed = true;
|
||||
req.session.dataPubPem = dataPubPem;
|
||||
dbs.set(req.sessionID, rec);
|
||||
if (prevKeys) sessionKeys.set(req.sessionID, prevKeys);
|
||||
res._audited = true;
|
||||
audit(auditFromReq(req, res, { method: 'POST', path: '/api/unlock', code: 'OK', ok: true, detail: '解锁成功' }));
|
||||
res.json({ ok: true, name: rec.name, count: rec.items.length, writable: config.writable,
|
||||
dataKeyExpire: rec.dataKeyExpire });
|
||||
});
|
||||
} catch (e) {
|
||||
res._audited = true;
|
||||
audit(auditFromReq(req, res, { method: 'POST', path: '/api/unlock', code: ERR.AUTH_MASTER_FAIL, ok: false, detail: '主密码或密钥文件错误' }));
|
||||
return res.status(401).json({ error: '主密码或密钥文件错误', code: ERR.AUTH_MASTER_FAIL });
|
||||
}
|
||||
});
|
||||
|
||||
// 数据:分组树(无敏感字段,仍按会话加密返回)
|
||||
app.get('/api/tree', appAuth, unlocked, (req, res) => {
|
||||
const rec = dbs.get(req.sessionID);
|
||||
const blocked = checkDataKey(req, res); if (blocked) return;
|
||||
encRes(req, res, { name: rec.name, tree: rec.tree, groups: rec.groups });
|
||||
});
|
||||
|
||||
// 数据:某分组下的密码列表(分页;列表不含密码明文,整体加密返回)
|
||||
const PAGE_SIZE = 50;
|
||||
app.get('/api/entries', appAuth, unlocked, (req, res) => {
|
||||
const rec = dbs.get(req.sessionID);
|
||||
const blocked = checkDataKey(req, res); if (blocked) return;
|
||||
const group = req.query.group || null;
|
||||
const q = (req.query.q || '').toString().trim().toLowerCase();
|
||||
const page = Math.max(1, parseInt(req.query.page || '1', 10));
|
||||
let list = rec.items;
|
||||
if (group) list = list.filter((i) => i.groupId === group);
|
||||
if (q) list = list.filter((i) =>
|
||||
(i.title || '').toLowerCase().includes(q) ||
|
||||
(i.username || '').toLowerCase().includes(q) ||
|
||||
(i.url || '').toLowerCase().includes(q));
|
||||
const total = list.length;
|
||||
const start = (page - 1) * PAGE_SIZE;
|
||||
const pageItems = list.slice(start, start + PAGE_SIZE).map((i) => ({
|
||||
id: i.id, title: i.title, username: i.username, url: i.url, group: i.group,
|
||||
}));
|
||||
encRes(req, res, { items: pageItems, page, pageSize: PAGE_SIZE, total, group, q });
|
||||
});
|
||||
|
||||
// 数据:单条详情(密码为密文,前端用 dataRSA 私钥解密;整体再经通道加密)
|
||||
app.get('/api/entry/:id', appAuth, unlocked, (req, res) => {
|
||||
const rec = dbs.get(req.sessionID);
|
||||
const blocked = checkDataKey(req, res); if (blocked) return;
|
||||
const item = rec.items.find((i) => i.id === req.params.id);
|
||||
if (!item) return encRes(req, res, { error: '条目不存在', code: ERR.NOT_FOUND }, 404);
|
||||
encRes(req, res, {
|
||||
id: item.id, title: item.title, username: item.username,
|
||||
passwordCrypt: item.passwordCrypt, // 密文!前端解密
|
||||
url: item.url, notes: item.notes, group: item.group,
|
||||
});
|
||||
});
|
||||
|
||||
// 编辑(仅可写模式)
|
||||
app.post('/api/entry/update', appAuth, unlocked, writable, async (req, res) => {
|
||||
const rec = dbs.get(req.sessionID);
|
||||
const blocked = checkDataKey(req, res); if (blocked) return;
|
||||
const { id, fields } = req.body || {};
|
||||
if (!id || !fields) return res.status(400).json({ error: '参数缺失', code: ERR.MISSING });
|
||||
try {
|
||||
const entry = rec.db.entries.find((e) => Buffer.from(e.uuid.toBytes()).toString('base64') === id);
|
||||
if (!entry) return res.status(404).json({ error: '条目不存在', code: ERR.NOT_FOUND });
|
||||
entry.fields.set('Title', fields.title || '');
|
||||
entry.fields.set('UserName', fields.username || '');
|
||||
entry.fields.set('Password', ProtectedValue.fromString(fields.password || ''));
|
||||
entry.fields.set('URL', fields.url || '');
|
||||
entry.fields.set('Notes', fields.notes || '');
|
||||
rec.db.cleanup({ historyRules: true });
|
||||
rec.db.save();
|
||||
const buf = rec.db.save();
|
||||
if (config.keyfilePath) {
|
||||
fs.copyFileSync(config.kdbxPath, config.kdbxPath + '.bak');
|
||||
}
|
||||
fs.writeFileSync(config.kdbxPath, Buffer.from(buf));
|
||||
// 重新抽取并加密密码字段
|
||||
const ex = extract(rec.db, req.session.dataPubPem);
|
||||
rec.groups = ex.groups; rec.items = ex.items; rec.tree = ex.tree; rec.name = ex.name;
|
||||
encRes(req, res, { ok: true, item: ex.items.find((i) => i.id === id) });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: '保存失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 独立审计页(或新标签页)协商响应通道密钥:复用同一会话的服务端密钥槽
|
||||
app.post('/api/session/key', appAuth, (req, res) => {
|
||||
try {
|
||||
const raw = (req.body && req.body.key) || '';
|
||||
if (!raw) return res.status(400).json({ error: '缺少 key' });
|
||||
const b64 = rsaDecrypt(raw);
|
||||
const buf = Buffer.from(b64, 'base64');
|
||||
if (buf.length !== 32) return res.status(400).json({ error: '密钥长度错误' });
|
||||
sessionKeys.set(req.sessionID, buf);
|
||||
res.json({ ok: true });
|
||||
} catch (e) { res.status(400).json({ error: '密钥解析失败' }); }
|
||||
});
|
||||
|
||||
// 审计索引:返回各日志文件分布(文件/起始序号/结束序号/条数/时间范围)与总条数
|
||||
// 前端分页前先读此接口计算分布,再按需从对应文件提取,保证分页与文件一致
|
||||
app.get('/api/audit/index', appAuth, (req, res) => {
|
||||
encRes(req, res, { total: auditIndex.total, files: auditIndex.files });
|
||||
});
|
||||
|
||||
// 按文件 + 序号区间读取审计片段(解密后返回),供前端精确分页
|
||||
app.get('/api/audit/file', appAuth, (req, res) => {
|
||||
const q = req.query || {};
|
||||
const file = (q.file || 'audit.log').toString();
|
||||
// 仅允许白名单文件名,防目录穿越
|
||||
if (!/^audit(\.\d+)?\.log$/.test(file)) return encRes(req, res, { error: '非法文件名' }, 400);
|
||||
const fp = path.join(logDir, file);
|
||||
const fromSeq = parseInt(q.fromSeq || '0', 10) || 0;
|
||||
const toSeq = parseInt(q.toSeq || '0', 10) || 0;
|
||||
let lines = readAuditLines(fp);
|
||||
if (fromSeq || toSeq) {
|
||||
lines = lines.filter((e) => (!fromSeq || e.seq >= fromSeq) && (!toSeq || e.seq <= toSeq));
|
||||
}
|
||||
encRes(req, res, { file, count: lines.length, log: lines });
|
||||
});
|
||||
|
||||
// 审计日志(登录后可访问):基于「索引 + 本地文件」读取,支持筛选 + 分页,条数与文件完全一致
|
||||
app.get('/api/audit', appAuth, (req, res) => {
|
||||
const q = req.query || {};
|
||||
const ip = (q.ip || '').trim().toLowerCase();
|
||||
const ipType = (q.iptype || '').trim().toLowerCase();
|
||||
const code = (q.code || '').trim();
|
||||
const method = (q.method || '').trim().toUpperCase();
|
||||
const pathFilter = (q.path || '').trim().toLowerCase();
|
||||
const onlyFail = q.fail === '1' || q.fail === 'true';
|
||||
const from = q.from ? Date.parse(q.from) : 0;
|
||||
const to = q.to ? Date.parse(q.to) : Date.now() + 1;
|
||||
const pageSize = Math.min(parseInt(q.pageSize || '50', 10) || 50, 500);
|
||||
const page = Math.max(parseInt(q.page || '1', 10) || 1, 1);
|
||||
|
||||
// 汇总所有日志文件(按索引顺序,旧文件在前、audit.log 在后),保证完整覆盖
|
||||
let all = [];
|
||||
for (const f of auditIndex.files) {
|
||||
const fp = path.join(logDir, f.file);
|
||||
all = all.concat(readAuditLines(fp));
|
||||
}
|
||||
// 按 seq 升序排列(seq 全局单调递增;文件顺序已保证时间序,这里保险)
|
||||
all.sort((a, b) => (a.seq || 0) - (b.seq || 0));
|
||||
|
||||
let rows = all;
|
||||
if (ip || code || method || pathFilter || onlyFail || from || to < Date.now() + 1) {
|
||||
rows = rows.filter((e) => {
|
||||
if (ip && !String(e.ip || '').toLowerCase().includes(ip)) return false;
|
||||
if (ipType && (e.ipType || '').toLowerCase() !== ipType) return false;
|
||||
if (code && e.code !== code) return false;
|
||||
if (method && e.method !== method) return false;
|
||||
if (pathFilter && !String(e.path || '').toLowerCase().includes(pathFilter)) return false;
|
||||
if (onlyFail && e.ok) return false;
|
||||
const ts = Date.parse(e.t);
|
||||
if (from && ts < from) return false;
|
||||
if (to && ts > to) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
const total = rows.length;
|
||||
// 默认显示最近的数据:按 seq 倒序取第 page 页(前端"最新在前")
|
||||
const sortedDesc = rows.slice().sort((a, b) => (b.seq || 0) - (a.seq || 0));
|
||||
const start = (page - 1) * pageSize;
|
||||
const list = sortedDesc.slice(start, start + pageSize);
|
||||
encRes(req, res, {
|
||||
log: list,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
indexTotal: auditIndex.total,
|
||||
filters: { ip, code, method, path: pathFilter, onlyFail, from: from || '', to: to < Date.now() + 1 ? q.to : '' },
|
||||
});
|
||||
});
|
||||
|
||||
// 当前被封锁的 IP 列表(便于审计页展示与排查)
|
||||
app.get('/api/audit/blocks', appAuth, (req, res) => {
|
||||
const now = Date.now();
|
||||
const list = [];
|
||||
for (const [ip, r] of ipFails) {
|
||||
if (now - r.first > IP_WINDOW) continue;
|
||||
if (r.count >= IP_MAX_FAIL) {
|
||||
list.push({ ip, fails: r.count, expiresAt: new Date(r.first + IP_WINDOW).toISOString() });
|
||||
}
|
||||
}
|
||||
encRes(req, res, { blocks: list });
|
||||
});
|
||||
|
||||
// 手动解除某 IP 封锁(管理员操作,记入审计)
|
||||
app.post('/api/audit/unblock', appAuth, (req, res) => {
|
||||
const target = (req.body && req.body.ip) || '';
|
||||
if (!target) return encRes(req, res, { error: '缺少 ip' }, 400);
|
||||
ipFails.delete(target);
|
||||
audit(auditFromReq(req, res, { method: 'POST', path: '/api/audit/unblock', code: 'OK', ok: true, detail: '手动解封 IP: ' + target }));
|
||||
encRes(req, res, { ok: true });
|
||||
});
|
||||
|
||||
// 退出:清空内存
|
||||
app.post('/api/logout', (req, res) => {
|
||||
if (req.sessionID) { dbs.delete(req.sessionID); sessionKeys.delete(req.sessionID); captchaStore.delete(req.sessionID); }
|
||||
if (req.session) req.session.destroy(() => res.json({ ok: true }));
|
||||
else res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ============ 启动 ============
|
||||
// 会话销毁时联动清理内存中的明文/密文数据,避免孤儿数据常驻
|
||||
const sessionMiddleware = app._router && app._router.stack;
|
||||
// 通过监听 destroy:express-session 在 req.session.destroy 时触发,这里在 logout 已清理;
|
||||
// 另加定时器兜底:扫描过期数据密钥并清理
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [sid, rec] of dbs) {
|
||||
if (rec.dataKeyExpire && now > rec.dataKeyExpire + 60 * 1000) {
|
||||
dbs.delete(sid); sessionKeys.delete(sid);
|
||||
}
|
||||
}
|
||||
// 清理过期验证码
|
||||
for (const [sid, cap] of captchaStore) {
|
||||
if (now > cap.expires) captchaStore.delete(sid);
|
||||
}
|
||||
}, 60 * 1000).unref();
|
||||
|
||||
function startServer() {
|
||||
// 启动前恢复/重建审计索引,使分页与本地文件一致
|
||||
try { auditLoadIndex(); } catch (e) {}
|
||||
const port = config.port;
|
||||
const keyPath = process.env.SSL_KEY || path.join(__dirname, 'ssl', 'key.pem');
|
||||
const certPath = process.env.SSL_CERT || path.join(__dirname, 'ssl', 'cert.pem');
|
||||
const hasSsl = fs.existsSync(keyPath) && fs.existsSync(certPath);
|
||||
if (hasSsl) {
|
||||
// 生产模式:默认 HTTPS;同时起一个 HTTP 端口把所有请求 301 重定向到 HTTPS
|
||||
const opt = { key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath) };
|
||||
https.createServer(opt, app).listen(port, () => {
|
||||
console.log(`[kdbx-viewer] HTTPS 已启动: https://localhost:${port}`);
|
||||
if (!config.sessionSecret) console.warn('[config] 警告: SESSION_SECRET 未设置,已使用随机值(重启后会话失效)');
|
||||
});
|
||||
const redirectPort = port + 1;
|
||||
http.createServer((req, res) => {
|
||||
const host = req.headers.host ? req.headers.host.replace(/:\d+$/, '') : 'localhost';
|
||||
res.writeHead(301, { Location: `https://${host}:${port}${req.url}` });
|
||||
res.end();
|
||||
}).listen(redirectPort, () => {
|
||||
console.log(`[kdbx-viewer] HTTP(${redirectPort}) -> HTTPS(${port}) 重定向已启用`);
|
||||
});
|
||||
} else {
|
||||
// 开发模式(无证书):仍允许 HTTP,但明确提示生产必须用 HTTPS
|
||||
http.createServer(app).listen(port, () => {
|
||||
console.log(`[kdbx-viewer] HTTP 已启动: http://localhost:${port}`);
|
||||
console.warn('[安全] 未检测到 SSL 证书,已使用明文 HTTP。生产环境请配置 SSL_KEY/SSL_CERT 启用 HTTPS。');
|
||||
});
|
||||
}
|
||||
}
|
||||
startServer();
|
||||
|
||||
module.exports = app;
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
'use strict';
|
||||
// 自测:生成一个 KDBX4(主密码 + keyfile,KDF=Argon2),再用服务器的同款逻辑复读,
|
||||
// 验证 Argon2 胶水层 + keyfile 服务端读取在 Node 下确实可用。
|
||||
// 注意:本机 Windows Defender 会拦截 .key 扩展名写入,故自测用 .bin 扩展名 + 临时目录,
|
||||
// 与容器内 /app/vault/vault.key 的解密逻辑完全一致。
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { Kdbx, Credentials, ProtectedValue, Consts } = require('kdbxweb');
|
||||
const { loadDatabase } = require('./kdbxlib');
|
||||
|
||||
(async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kdbx-selftest-'));
|
||||
const kdbxPath = path.join(dir, '_selftest.kdbx');
|
||||
const keyPath = path.join(dir, '_selftest.bin'); // 非 .key,规避本地安全软件拦截
|
||||
|
||||
// 32 字节随机密钥文件
|
||||
const keyBytes = crypto.randomBytes(32);
|
||||
fs.writeFileSync(keyPath, keyBytes);
|
||||
const keyAb = keyBytes.buffer.slice(keyBytes.byteOffset, keyBytes.byteOffset + keyBytes.byteLength);
|
||||
|
||||
const masterPassword = 'TestMaster123!';
|
||||
const creds = new Credentials(ProtectedValue.fromString(masterPassword), keyAb);
|
||||
|
||||
const db = Kdbx.create(creds, 'SelfTestDB');
|
||||
db.setKdf(Consts.KdfId.Argon2); // 强制用 Argon2,专门验证 Argon2 胶水
|
||||
const group = db.createGroup(db.getDefaultGroup(), 'Login');
|
||||
const e = db.createEntry(group);
|
||||
e.fields.set('Title', 'Example');
|
||||
e.fields.set('UserName', 'alice');
|
||||
e.fields.set('Password', ProtectedValue.fromString('s3cret'));
|
||||
e.fields.set('URL', 'https://example.com');
|
||||
e.fields.set('Notes', 'self-test note');
|
||||
|
||||
const saved = await db.save();
|
||||
fs.writeFileSync(kdbxPath, Buffer.from(saved));
|
||||
console.log('已生成测试库:', kdbxPath);
|
||||
|
||||
// 用服务器同款逻辑重新加载
|
||||
const rec = await loadDatabase(kdbxPath, keyPath, masterPassword);
|
||||
console.log('解锁成功,库名:', rec.name, '条目数:', rec.items.length);
|
||||
|
||||
const ok = rec.items.some(
|
||||
(i) => i.title === 'Example' && i.username === 'alice' && i.password === 's3cret'
|
||||
);
|
||||
if (!ok) {
|
||||
console.error('❌ 自测失败:条目内容不匹配');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✅ 自测通过:Argon2 + keyfile 解密在 Node 下可用');
|
||||
})().catch((e) => {
|
||||
console.error('❌ 自测异常:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户