mirror of
https://github.com/katelya77/K-Vault.git
synced 2026-05-06 22:10:57 +08:00
feat: 更新 Docker 部署说明,新增环境变量初始化脚本,优化登录 API 处理逻辑
This commit is contained in:
107
README-DOCKER.md
107
README-DOCKER.md
@@ -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:
|
||||
|
||||
27
README.md
27
README.md
@@ -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 登录 API(curl 示例)
|
||||
|
||||
`/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`);若疑似泄露请立即轮换并重启服务。
|
||||
|
||||
---
|
||||
|
||||
## 存储配置
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
181
scripts/bootstrap-env.js
vendored
Normal 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
5
scripts/bootstrap-env.sh
Normal file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
node "$SCRIPT_DIR/bootstrap-env.js" "$@"
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user