From 8afed18a6fd08f5bd06b2d148164af7e2a2efc2c Mon Sep 17 00:00:00 2001 From: katelya Date: Mon, 2 Mar 2026 23:55:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=20Docker=20=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E8=AF=B4=E6=98=8E=EF=BC=8C=E6=96=B0=E5=A2=9E=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E5=8F=98=E9=87=8F=E5=88=9D=E5=A7=8B=E5=8C=96=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=EF=BC=8C=E4=BC=98=E5=8C=96=E7=99=BB=E5=BD=95=20API=20?= =?UTF-8?q?=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README-DOCKER.md | 107 +++++++++++++++++++-- README.md | 27 +++++- docker-compose.yml | 18 +++- functions/api/auth/login.js | 13 ++- package.json | 3 +- scripts/bootstrap-env.js | 181 ++++++++++++++++++++++++++++++++++++ scripts/bootstrap-env.sh | 5 + server/app.js | 49 +++++++++- 8 files changed, 388 insertions(+), 15 deletions(-) create mode 100644 scripts/bootstrap-env.js create mode 100644 scripts/bootstrap-env.sh diff --git a/README-DOCKER.md b/README-DOCKER.md index c67590e..bbd6c81 100644 --- a/README-DOCKER.md +++ b/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://:8080/` - Vue3 App: `http://: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: diff --git a/README.md b/README.md index 83bad6b..c4f7673 100644 --- a/README.md +++ b/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`);若疑似泄露请立即轮换并重启服务。 + --- ## 存储配置 diff --git a/docker-compose.yml b/docker-compose.yml index 6034695..cec12ef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/functions/api/auth/login.js b/functions/api/auth/login.js index 9f93a6a..c0d5e99 100644 --- a/functions/api/auth/login.js +++ b/functions/api/auth/login.js @@ -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) { diff --git a/package.json b/package.json index ee9b2c6..11f2d07 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/bootstrap-env.js b/scripts/bootstrap-env.js new file mode 100644 index 0000000..ccbb08f --- /dev/null +++ b/scripts/bootstrap-env.js @@ -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); +} diff --git a/scripts/bootstrap-env.sh b/scripts/bootstrap-env.sh new file mode 100644 index 0000000..5076a1a --- /dev/null +++ b/scripts/bootstrap-env.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +node "$SCRIPT_DIR/bootstrap-env.js" "$@" diff --git a/server/app.js b/server/app.js index e8a9716..645a607 100644 --- a/server/app.js +++ b/server/app.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';