feat: 更新 Docker 部署说明,新增环境变量初始化脚本,优化登录 API 处理逻辑

This commit is contained in:
katelya
2026-03-02 23:55:34 +08:00
parent dd48836d77
commit 8afed18a6f
8 changed files with 388 additions and 15 deletions

View File

@@ -7,16 +7,25 @@ This repository now supports two deployment modes:
## Quick Start (Docker)
1. Copy env template:
1. Initialize `.env` and secrets (safe to rerun):
```bash
cp .env.example .env
npm run docker:init-env
```
2. Fill at least these required values in `.env`:
Alternative shell entrypoint:
```bash
bash scripts/bootstrap-env.sh
```
What this does:
- if `.env` is missing, copy from `.env.example`
- if `CONFIG_ENCRYPTION_KEY` / `SESSION_SECRET` are empty or placeholder values, generate secure random values
- if those keys are already real values, keep them unchanged (prevents breaking decryption of existing storage configs)
2. Fill at least these values in `.env`:
- `CONFIG_ENCRYPTION_KEY`
- `SESSION_SECRET`
- `BASIC_USER` / `BASIC_PASS` (optional, set both to enable login)
- one bootstrap storage config (for example Telegram: `TG_BOT_TOKEN` + `TG_CHAT_ID`)
- optional settings store mode:
@@ -26,7 +35,7 @@ cp .env.example .env
3. Start services:
```bash
docker compose up -d --build
npm run docker:up
```
4. Open:
@@ -34,6 +43,17 @@ docker compose up -d --build
- Legacy UI: `http://<host>:8080/`
- Vue3 App: `http://<host>:8080/app/`
Expected startup status:
```bash
docker compose ps
```
You should see:
- `kvault-api` -> `Up ... (healthy)`
- `kvault-web` -> `Up ...`
- `kvault-redis` -> `Up ... (healthy)` when started with `--profile redis`
### Optional: start with local Redis settings store
If you prefer Redis for basic app settings (also compatible with Upstash/KVrocks protocol):
@@ -47,6 +67,28 @@ If you prefer Redis for basic app settings (also compatible with Upstash/KVrocks
docker compose --profile redis up -d --build
```
## Login API (curl)
`/api/auth/login` accepts both payload shapes:
- new: `{ "username": "...", "password": "..." }`
- compatible: `{ "user": "...", "pass": "..." }`
Example:
```bash
curl -i -X POST "http://localhost:8080/api/auth/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"your_password"}'
```
Compatibility example:
```bash
curl -i -X POST "http://localhost:8080/api/auth/login" \
-H "Content-Type: application/json" \
-d '{"user":"admin","pass":"your_password"}'
```
## Architecture
- `api`: Node.js Hono backend (`server/`)
@@ -65,6 +107,11 @@ docker compose --profile redis up -d --build
Persistent data is stored in Docker volume `kvault_data` (and `kvault_redis` when Redis profile is enabled).
## Networking Notes
- `ports` publishes container ports to host (`web` uses `${WEB_PORT:-8080}:80`)
- `expose` is internal-only for compose services (`api:8787`, `redis:6379`)
## Important Environment Variables
| Variable | Description |
@@ -83,6 +130,24 @@ Persistent data is stored in Docker volume `kvault_data` (and `kvault_redis` whe
| `TG_BOT_TOKEN` + `TG_CHAT_ID` | Telegram bootstrap storage |
| `R2_*` / `S3_*` / `DISCORD_*` / `HF_*` | Optional bootstrap configs for other backends |
## Security Notes
- Never expose or commit tokens/secrets (`TG_BOT_TOKEN`, `DISCORD_BOT_TOKEN`, `HF_TOKEN`, `SESSION_SECRET`, `CONFIG_ENCRYPTION_KEY`, etc.)
- If any token/secret may be leaked, rotate it immediately and restart related services
## Manage List API
`GET /api/manage/list` now defaults to the first page when query parameters are omitted.
Supported query parameters:
- `limit` (or `pageSize` / `size`): items per page, default `100`, max `1000`
- `cursor` (or `offset`): next offset returned by previous response
- `page` (or `current`): page number (1-based), used when `cursor` is not provided
- `storage`: `all`/`telegram`/`r2`/`s3`/`discord`/`huggingface`
- `search`: fuzzy match on file name and id
- `listType` (or `list_type`): `all`/`None`/`White`/`Block`
- `includeStats` (or `stats`): `1|true|yes` to include summary stats
## Deployment Notes
- Legacy and Vue3 frontends coexist in Docker mode.
@@ -124,6 +189,36 @@ Persistent data is stored in Docker volume `kvault_data` (and `kvault_redis` whe
- Usually suitable when Docker/Compose is available.
- Requirements: enable Docker/Compose, import `docker-compose.yml`, map persistent volume, and expose port 8080 (or custom `WEB_PORT`).
## FAQ
### `.env` missing
Run:
```bash
npm run docker:init-env
```
This recreates `.env` from `.env.example` and only auto-fills secret keys when needed.
### `Failed to decrypt storage config "...". Check CONFIG_ENCRYPTION_KEY.`
Cause: `CONFIG_ENCRYPTION_KEY` changed after encrypted configs were written to SQLite.
Fix:
- restore the original `CONFIG_ENCRYPTION_KEY`
- if the original key is lost, delete/recreate affected storage configs in DB
- avoid editing `CONFIG_ENCRYPTION_KEY` on running instances unless you are doing a planned migration
### Docker Compose buildx/bake warning
Some Docker versions print a bake-related hint/warning during `docker compose build`.
Options:
- ignore it (build still works)
- enable bake explicitly: `set COMPOSE_BAKE=true` (PowerShell: `$env:COMPOSE_BAKE='true'`)
- or disable it: `set COMPOSE_BAKE=false`
## Local Development
- Backend:

View File

@@ -112,9 +112,11 @@
1. 复制环境变量模板:
```bash
cp .env.example .env
npm run docker:init-env
```
该命令会在 `.env` 不存在时自动创建,并仅在密钥为空或占位符时生成 `CONFIG_ENCRYPTION_KEY``SESSION_SECRET`,不会每次重置已有密钥。
2. 至少填写以下关键变量:
- `CONFIG_ENCRYPTION_KEY`
- `SESSION_SECRET`
@@ -126,7 +128,7 @@ cp .env.example .env
3. 启动服务:
```bash
docker compose up -d --build
npm run docker:up
```
如需启用本地 Redis用于基础设置存储
@@ -140,6 +142,27 @@ docker compose --profile redis up -d --build
完整 Docker 说明请查看 [README-DOCKER.md](README-DOCKER.md)。
### Docker 登录 APIcurl 示例)
`/api/auth/login` 同时兼容两种请求体:
- `{"username":"...","password":"..."}`
- `{"user":"...","pass":"..."}`
```bash
curl -i -X POST "http://localhost:8080/api/auth/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"your_password"}'
```
```bash
curl -i -X POST "http://localhost:8080/api/auth/login" \
-H "Content-Type: application/json" \
-d '{"user":"admin","pass":"your_password"}'
```
安全提示:请勿泄露或提交 `.env` 内 token/secret`TG_BOT_TOKEN``DISCORD_BOT_TOKEN``HF_TOKEN``SESSION_SECRET``CONFIG_ENCRYPTION_KEY`);若疑似泄露请立即轮换并重启服务。
---
## 存储配置

View File

@@ -7,8 +7,15 @@ services:
- .env
volumes:
- kvault_data:/app/data
# expose is internal-only (other services in the same compose network can reach it).
expose:
- "8787"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:8787/api/health').then((r)=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
interval: 10s
timeout: 3s
retries: 5
start_period: 10s
restart: unless-stopped
redis:
@@ -17,8 +24,15 @@ services:
command: ["redis-server", "--appendonly", "yes"]
volumes:
- kvault_redis:/data
# expose is internal-only (other services in the same compose network can reach it).
expose:
- "6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
start_period: 5s
restart: unless-stopped
profiles:
- redis
@@ -29,7 +43,9 @@ services:
dockerfile: frontend/Dockerfile
container_name: kvault-web
depends_on:
- api
api:
condition: service_healthy
# ports publishes container port to host, so browser/users can access web UI.
ports:
- "${WEB_PORT:-8080}:80"
restart: unless-stopped

View File

@@ -24,7 +24,18 @@ export async function onRequestPost(context) {
}
const body = await request.json();
const { username, password } = body;
const username = String(body?.username ?? body?.user ?? '').trim();
const password = String(body?.password ?? body?.pass ?? '');
if (!username || password === '') {
return new Response(JSON.stringify({
success: false,
message: 'Missing username or password.'
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
// 验证凭据
if (username === env.BASIC_USER && password === env.BASIC_PASS) {

View File

@@ -6,10 +6,11 @@
"ci-test": "concurrently --kill-others --success first \"npm start\" \"wait-on http://localhost:8080 && mocha --exit\"",
"test": "mocha",
"start": "npx wrangler pages dev ./ --kv \"img_url\" --port 8080 --binding BASIC_USER=admin --binding BASIC_PASS=123 --persist-to ./data",
"docker:init-env": "node scripts/bootstrap-env.js",
"server:dev": "npm --prefix server run dev",
"frontend:dev": "npm --prefix frontend run dev",
"frontend:build": "npm --prefix frontend run build",
"docker:up": "docker compose up -d --build",
"docker:up": "npm run docker:init-env && docker compose up -d --build",
"docker:down": "docker compose down"
},
"dependencies": {

181
scripts/bootstrap-env.js vendored Normal file
View File

@@ -0,0 +1,181 @@
#!/usr/bin/env node
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const repoRoot = path.resolve(__dirname, '..');
const keyTargets = ['CONFIG_ENCRYPTION_KEY', 'SESSION_SECRET'];
const placeholderValues = new Set([
'replace_with_a_long_random_secret',
'replace_with_another_long_random_secret',
'replace_me',
'change_me',
'changeme',
'your_secret_here',
'your_session_secret',
'placeholder',
]);
const placeholderPatterns = [
/^replace_with/i,
/^change_this/i,
/^your[_-]?(secret|password)/i,
/^example$/i,
/^default$/i,
/^todo$/i,
/^<.+>$/,
];
function usage() {
console.log('Usage: node scripts/bootstrap-env.js [--env-path path] [--example-path path]');
}
function parseArgs(argv) {
const output = {
envFile: path.join(repoRoot, '.env'),
exampleFile: path.join(repoRoot, '.env.example'),
};
for (let i = 2; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--help' || arg === '-h') {
output.help = true;
continue;
}
if (arg === '--env-path' && argv[i + 1]) {
output.envFile = path.resolve(repoRoot, argv[i + 1]);
i += 1;
continue;
}
if (arg.startsWith('--env-path=')) {
output.envFile = path.resolve(repoRoot, arg.slice('--env-path='.length));
continue;
}
if (arg === '--example-path' && argv[i + 1]) {
output.exampleFile = path.resolve(repoRoot, argv[i + 1]);
i += 1;
continue;
}
if (arg.startsWith('--example-path=')) {
output.exampleFile = path.resolve(repoRoot, arg.slice('--example-path='.length));
continue;
}
}
return output;
}
function escapeRegExp(input) {
return String(input).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function getEol(text) {
return text.includes('\r\n') ? '\r\n' : '\n';
}
function parseEnvValue(raw) {
let value = String(raw || '').trim();
if (
(value.startsWith('"') && value.endsWith('"'))
|| (value.startsWith("'") && value.endsWith("'"))
) {
return value.slice(1, -1);
}
const commentPos = value.indexOf(' #');
if (commentPos >= 0) {
value = value.slice(0, commentPos).trim();
}
return value;
}
function readEnvValue(content, key) {
const matcher = new RegExp(`^\\s*(?:export\\s+)?${escapeRegExp(key)}\\s*=\\s*(.*)$`, 'm');
const matched = content.match(matcher);
if (!matched) return '';
return parseEnvValue(matched[1]);
}
function upsertEnvValue(content, key, value) {
const eol = getEol(content || '\n');
const matcher = new RegExp(`^(\\s*(?:export\\s+)?${escapeRegExp(key)}\\s*=\\s*).*$`, 'm');
if (matcher.test(content)) {
return content.replace(matcher, (_, prefix) => `${prefix}${value}`);
}
const suffix = content && !content.endsWith('\n') && !content.endsWith('\r\n') ? eol : '';
return `${content}${suffix}${key}=${value}${eol}`;
}
function isPlaceholder(value) {
const normalized = String(value || '').trim();
if (!normalized) return true;
if (placeholderValues.has(normalized)) return true;
return placeholderPatterns.some((pattern) => pattern.test(normalized));
}
function generateSecret() {
return crypto.randomBytes(48).toString('base64url');
}
function ensureEnvFile(envFile, exampleFile) {
if (fs.existsSync(envFile)) {
return false;
}
if (!fs.existsSync(exampleFile)) {
throw new Error(`Template file not found: ${exampleFile}`);
}
fs.copyFileSync(exampleFile, envFile);
return true;
}
function run() {
const options = parseArgs(process.argv);
if (options.help) {
usage();
return;
}
const envFile = options.envFile;
const exampleFile = options.exampleFile;
const created = ensureEnvFile(envFile, exampleFile);
let content = fs.readFileSync(envFile, 'utf8');
const generatedKeys = [];
for (const key of keyTargets) {
const current = readEnvValue(content, key);
if (!isPlaceholder(current)) continue;
content = upsertEnvValue(content, key, generateSecret());
generatedKeys.push(key);
}
if (generatedKeys.length > 0) {
fs.writeFileSync(envFile, content, 'utf8');
}
if (created) {
console.log(`[bootstrap-env] Created ${path.basename(envFile)} from ${path.basename(exampleFile)}.`);
}
if (generatedKeys.length > 0) {
console.log(`[bootstrap-env] Generated secure values for: ${generatedKeys.join(', ')}.`);
} else {
console.log('[bootstrap-env] CONFIG_ENCRYPTION_KEY and SESSION_SECRET already configured, no changes made.');
}
}
try {
run();
} catch (error) {
console.error(`[bootstrap-env] ${error.message}`);
process.exit(1);
}

5
scripts/bootstrap-env.sh Normal file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
node "$SCRIPT_DIR/bootstrap-env.js" "$@"

View File

@@ -35,6 +35,27 @@ function createApp() {
return String(value);
}
function firstNonEmpty(...values) {
for (const value of values) {
if (value == null) continue;
if (Array.isArray(value)) {
const nested = firstNonEmpty(...value);
if (nested != null) return nested;
continue;
}
if (value instanceof File) continue;
const normalized = String(value).trim();
if (normalized) return normalized;
}
return '';
}
function parseBoundedInt(value, fallback, min = 1, max = 1000) {
const parsed = Number.parseInt(String(value || ''), 10);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(min, Math.min(max, parsed));
}
function authResult(c) {
const { authService } = getServices(c);
return authService.checkAuthentication(c.req.raw);
@@ -109,8 +130,12 @@ function createApp() {
}
const body = await c.req.json().catch(() => ({}));
const username = String(body.username || '');
const password = String(body.password || '');
const username = firstNonEmpty(body.username, body.user);
const password = String(body.password ?? body.pass ?? '');
if (!username || password === '') {
return c.json({ success: false, message: 'Missing username or password.' }, 400);
}
if (username !== container.config.basicUser || password !== container.config.basicPass) {
return c.json({ success: false, message: 'Invalid username or password.' }, 401);
@@ -644,8 +669,24 @@ function createApp() {
if (unauthorized) return unauthorized;
const { fileRepo } = getServices(c);
const limit = Number(c.req.query('limit') || 100);
const cursor = c.req.query('cursor') || null;
const limit = parseBoundedInt(
firstNonEmpty(c.req.query('limit'), c.req.query('pageSize'), c.req.query('size')),
100,
1,
1000
);
let cursor = firstNonEmpty(c.req.query('cursor'), c.req.query('offset'));
if (!cursor) {
const current = parseBoundedInt(
firstNonEmpty(c.req.query('page'), c.req.query('current')),
1,
1,
Number.MAX_SAFE_INTEGER
);
cursor = current > 1 ? String((current - 1) * limit) : null;
}
const storage = c.req.query('storage') || 'all';
const search = c.req.query('search') || '';
const listType = c.req.query('listType') || c.req.query('list_type') || 'all';