From 69ab5676b4714c5d3fc142e4423b96b6258c7505 Mon Sep 17 00:00:00 2001 From: miaomiao <2733773236@qq.com> Date: Sat, 5 Sep 2026 21:01:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=20RainWeb=20CLI=20=E8=BF=90?= =?UTF-8?q?=E7=BB=B4=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 7 +- README.md | 32 +- cli.js | 862 ++++++++++++++++++++++++++++++++++++------------------ 3 files changed, 609 insertions(+), 292 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 813c968..1d602ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,10 +9,13 @@ npm start # 生产:node server.js(serve 构建产物 + /api) npm run dev # 开发:vite dev server(5173,/api 与 /uploads 代理到 3101) npm run build # 构建 React 前端 → public/dist/(index.html + admin.html + assets) npm run preview # 预览构建产物 -node cli.js # status | start | stop | restart | port [N] | password [pwd] | config | captcha | upgrade +node cli.js # status | health | db-check | start | stop | restart | port [N] | password | captcha | config | backup | upgrade ``` - 端口:`.env.json`(gitignored)`{"port": N}` 或环境变量 `PORT`,默认 3001;vite 代理目标硬编码 3101(vite.config.js),后端端口改了就同步改。 +- CLI:`status`、`health`、`db-check`、`config`、`backup list` 支持 `--json`;`health`/`db-check` 检查失败返回非 0。`backup prune` 必须明确 `--keep N`,默认只预览,只有 `--yes` 才删除;`backup restore ` 必须服务停止并带 `--yes`,不会默认覆盖。 +- CLI 的 `start/stop/restart` 只操作经过 cwd 与命令行双重校验、确认属于本项目 `server.js` 的 PID,停止会先优雅等待。`upgrade` 仅供自托管机器本地执行:升级前备份,要求 git 工作区干净,执行 `git pull --ff-only`、`npm install`、`npm run build`,服务原本运行时重启并健康检查;失败返回非 0。 +- CLI 密码命令默认交互输入;`config` 对密码、secret、token、JWT、私钥和 API key 等敏感配置统一脱敏。验证码参数需使用受支持的类型和值。 - 验证方式:后端改动启动后 `curl` 接口;前端改动 `npm run dev` 热更新,或 `npm run build` 后刷新验证(需强刷一次,见下)。 - **部署后浏览器必须拿新 HTML**:`serveIndex` 响应带 `Cache-Control: no-store`;构建产物 assets 文件名带 hash,经 `/assets` 挂载长期缓存(immutable)。若用户白屏且 console 报 "MIME type text/html",先查是否 `npm run build` 缺失/旧产物。 - 首次启动自动建库播种:管理员 `admin / admin123`,并写入示例博文/论坛帖子;`/setup.html` 初始化向导(`POST /api/setup/complete` 用 `setup_complete` 门禁,非 adminOnly——新装无 token)。 @@ -52,7 +55,7 @@ node cli.js # status | start | stop | restart | port [N] | password [pw ## 版本与更新 - 版本号在根目录 `VERSION` 文件,与 `package.json` 的 version 需同步。 -- **Web 更新接口已移除**(P0 删除 `/api/update/check`、`/api/update/run`,无 RCE 面)。升级只走 `cli.js upgrade`:git pull(本地 origin:git.rainnya.asia 镜像)+ npm install + 重启。 +- **Web 更新接口已移除**(P0 删除 `/api/update/check`、`/api/update/run`,无 RCE 面)。升级只走自托管机器本地的 `cli.js upgrade`:升级前备份、检查干净工作区、`git pull --ff-only`(本地 origin:git.rainnya.asia 镜像)+ npm install + npm run build + 服务重启/健康检查。 - 上传附件在 `uploads/`(gitignored,头像在 `uploads/avatars/`,白名单扩展名),壁纸在 `public/wallpaper/`(仅 .gitkeep 入库)。 ## 约定 diff --git a/README.md b/README.md index e6570bf..7c9c0ed 100644 --- a/README.md +++ b/README.md @@ -106,15 +106,23 @@ npm run cli -- | 命令 | 说明 | |------|------| -| `status` | 查看运行状态 | -| `start` | 启动服务器 | -| `stop` | 停止服务器 | -| `restart` | 重启服务器 | +| `status [--json]` | 查看版本、服务、数据库和备份状态 | +| `health [--json]` | HTTP 健康检查和 SQLite 完整性检查;不健康时返回非 0 | +| `db-check [--json]` | 只读检查数据库完整性和关键表 | +| `start` | 校验 PID 后启动服务器,已运行时不会重复启动 | +| `stop` | 只停止确认属于本项目的 `server.js`,优雅等待退出 | +| `restart` | 优雅停止并等待后启动服务器 | | `port [number]` | 查看/修改端口 | -| `password [new-pass]` | 修改管理员密码 | -| `captcha` | 交互式配置验证码 | -| `config` | 查看所有配置 | -| `upgrade` | 一键升级(git pull + npm install + 重启) | +| `password` | 交互式修改管理员密码(兼容旧的末尾参数写法) | +| `captcha` | 交互式配置验证码;也支持 `--type`、`--login`、`--register`、`--forum`、`--failed`、`--threshold` | +| `config [--json]` | 查看配置,敏感值统一脱敏 | +| `backup` | 创建数据库备份并立即做完整性检查 | +| `backup list [--json]` | 列出备份并检查 SQLite 完整性 | +| `backup prune --keep N [--yes]` | 预览清理旧备份;只有显式 `--yes` 才删除 | +| `backup restore --yes` | 服务停止后恢复指定备份;显式确认才覆盖,旧库会保留 | +| `upgrade` | 升级前备份,检查干净工作区,`git pull --ff-only`、安装、构建并在服务原本运行时重启验活 | + +通用选项:`--yes` 跳过确认,`--json` 输出 JSON,HTTP 检查可用 `--timeout 100-60000` 设置毫秒超时。密码默认交互输入,不建议在命令行中传递明文密码。 ## 技术栈 @@ -214,16 +222,18 @@ npm start ## 升级 ```bash -# 方法1: 一键升级(推荐) +# 方法1:一键升级(推荐;仅限自托管机器本地执行) node cli.js upgrade -# 方法2: 手动 -git pull +# 方法2:手动 +git pull --ff-only npm install npm run build node cli.js restart ``` +`upgrade` 要求 git 工作区干净,只执行 fast-forward 更新;失败会返回非 0,不会假报成功。`backup prune` 默认只预览不删除,`backup restore` 必须同时满足服务已停止和 `--yes`。 + ## 版本 当前版本记录在项目根目录的 `VERSION` 文件中,导航栏标题右侧会显示当前版本号。 diff --git a/cli.js b/cli.js index c862d23..9cbd593 100644 --- a/cli.js +++ b/cli.js @@ -1,328 +1,632 @@ #!/usr/bin/env node -const { execSync, spawn } = require('child_process'); +const { execFileSync, spawn } = require('child_process'); const fs = require('fs'); const path = require('path'); const readline = require('readline'); const bcrypt = require('bcryptjs'); const http = require('http'); +const Database = require('better-sqlite3'); -const CONFIG_PATH = path.join(__dirname, '.env.json'); +const PROJECT_ROOT = __dirname; +const CONFIG_PATH = path.join(PROJECT_ROOT, '.env.json'); +const DB_PATH = path.join(PROJECT_ROOT, 'data', 'rainweb.db'); +const BACKUP_DIR = path.join(PROJECT_ROOT, 'backups'); +const PID_PATH = path.join(PROJECT_ROOT, 'server.pid'); +const SERVER_PATH = path.join(PROJECT_ROOT, 'server.js'); const PKG = require('./package.json'); -const db = require('./db'); + +// 只读命令不加载 db.js,避免检查状态时意外触发数据库迁移或写入。 +let writableDb = null; +function getWritableDb() { + if (!writableDb) writableDb = require('./db'); + return writableDb; +} const HELP = ` RainWeb CLI v${PKG.version} -Usage: node cli.js [options] +用法:node cli.js <命令> [参数] [选项] -Commands: - status Show server and system status - start Start the server - restart Restart the server - stop Stop the server - port [number] Show or change listen port (default: 3001) - password [new-pass] Change admin password (leave empty for prompt) - captcha Interactive captcha rule configuration - config Show all current settings - backup Backup database to backups/ (readonly, safe to run while running) - upgrade Git pull + npm install + restart (one-click upgrade) - help Show this help +命令: + status 查看版本、服务、数据库和备份状态 + health HTTP 健康检查和 SQLite 完整性检查 + db-check 只读检查 SQLite 完整性和关键表 + start 启动服务器(防重复启动) + stop 优雅停止本项目的 server.js + restart 停止后等待并启动服务器 + port [端口] 查看或修改监听端口 + password 交互式修改管理员密码 + captcha 交互式配置验证码规则 + config 查看配置(敏感值统一脱敏) + backup 创建数据库备份 + backup list 列出并检查备份完整性 + backup prune --keep <数量> 预览清理旧备份;加 --yes 才会删除 + backup restore <文件> --yes 服务停止后恢复备份,显式确认才覆盖 + upgrade 备份、检查工作区、快进升级、构建并验活 + help 显示帮助 -Examples: - node cli.js status - node cli.js port 8080 - node cli.js password MyNewP@ss123 - node cli.js captcha - node cli.js backup - node cli.js upgrade +通用选项: + --yes 跳过需要确认的操作 + --json 以 JSON 输出(status/health/config/backup list 等支持) + --timeout <毫秒> 设置 HTTP 检查超时(100-60000) + +说明: + password 默认交互输入;为兼容旧脚本仍接受末尾明文参数,但不在帮助示例中推荐。 + upgrade 仅用于自托管机器上的本地操作,不会修改云端或远程服务。 `; -async function main() { - const cmd = process.argv[2] || 'help'; - - switch (cmd) { - case 'status': return cmdStatus(); - case 'start': return cmdStart(); - case 'restart': return cmdRestart(); - case 'stop': return cmdStop(); - case 'port': return cmdPort(); - case 'password': return cmdPassword(); - case 'captcha': return cmdCaptcha(); - case 'config': return cmdConfig(); - case 'backup': return cmdBackup(); - case 'upgrade': return cmdUpgrade(); - case 'help': - default: - console.log(HELP); +class CliError extends Error { + constructor(message, code = 1) { + super(message); + this.name = 'CliError'; + this.exitCode = code; } } -// === Status === -async function cmdStatus() { - console.log(`RainWeb v${PKG.version}`); - console.log(`Node.js: ${process.version}`); - console.log(`Platform: ${process.platform}`); - console.log(`Data DB: ${fs.existsSync(path.join(__dirname, 'data', 'rainweb.db')) ? fs.statSync(path.join(__dirname, 'data', 'rainweb.db')).size + ' bytes' : 'NOT FOUND'}`); - - // 最近一次备份时间 - const latestBackup = getLatestBackup(); - console.log('Last backup: ' + (latestBackup ? formatMtime(latestBackup.mtime) + ' (' + latestBackup.file + ')' : '从未备份')); - - // Check if server is running - try { - await httpGet('http://localhost:' + (getConfigPort())); - console.log('Server: RUNNING'); - } catch { - console.log('Server: STOPPED'); - } - - // Show admin info - try { - await db.getDb(); - const admin = db.get("SELECT id, username, email, email_verified FROM users WHERE role = 'admin'"); - if (admin) { - console.log(`Admin: ${admin.username} (email: ${admin.email || 'not set'}, verified: ${admin.email_verified ? 'yes' : 'no'})`); +function parseArgs(argv) { + const positionals = []; + const options = { yes: false, json: false }; + const valueOptions = new Set(['keep', 'timeout', 'type', 'login', 'register', 'forum', 'failed', 'threshold']); + const booleanOptions = new Set(['yes', 'json']); + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (token === '--') { + positionals.push(...argv.slice(i + 1)); + break; } - } catch {} + if (!token.startsWith('--')) { + positionals.push(token); + continue; + } + const match = token.match(/^--([^=]+)(?:=(.*))?$/); + if (!match) throw new CliError(`无法解析选项:${token}`); + const name = match[1]; + let value = match[2]; + if (booleanOptions.has(name)) { + if (value !== undefined) throw new CliError(`选项 --${name} 不接受参数`); + options[name] = true; + continue; + } + if (!valueOptions.has(name)) throw new CliError(`未知选项:--${name}`); + if (value === undefined) { + value = argv[i + 1]; + if (!value || value.startsWith('--')) throw new CliError(`选项 --${name} 缺少参数`); + i += 1; + } + options[name] = value; + } + return { positionals, ...options }; +} + +function readJson(file, fallback = {}) { + try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; } } -// === Port === function getConfigPort() { - try { - const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); - return config.port || 3001; - } catch { return 3001; } + const config = readJson(CONFIG_PATH); + const value = Number(process.env.PORT || config.port || 3001); + return Number.isInteger(value) && value >= 1 && value <= 65535 ? value : 3001; } function setConfigPort(port) { - let config = {}; - try { config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); } catch {} + const config = readJson(CONFIG_PATH); config.port = port; - fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2)); + fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n'); } -function cmdPort() { - const arg = process.argv[3]; - if (arg) { - const port = parseInt(arg); - if (isNaN(port) || port < 1 || port > 65535) { - console.error('Invalid port number. Use 1-65535.'); - process.exit(1); - } - setConfigPort(port); - console.log(`Port set to ${port}. Restart to apply.`); - } else { - console.log(`Current port: ${getConfigPort()}`); - } +function dbExists() { return fs.existsSync(DB_PATH); } + +function openReadonlyDatabase(file = DB_PATH) { + if (!fs.existsSync(file)) throw new CliError(`数据库不存在:${file}`); + try { return new Database(file, { readonly: true, fileMustExist: true }); } + catch (error) { throw new CliError(`无法打开数据库:${error.message}`); } } -// === Password === -async function cmdPassword() { - await warnIfServerRunning(); - let newPass = process.argv[3]; - if (!newPass) { - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - newPass = await new Promise(resolve => { - rl.question('New admin password (min 6 chars): ', resolve); - }); - rl.close(); - } - if (!newPass || newPass.length < 6) { - console.error('Password must be at least 6 characters.'); - process.exit(1); - } - - await db.getDb(); - const admin = db.get("SELECT id FROM users WHERE role = 'admin'"); - if (!admin) { console.error('No admin user found.'); process.exit(1); } - - const hash = bcrypt.hashSync(newPass, 10); - db.run('UPDATE users SET password = ? WHERE id = ?', [hash, admin.id]); - console.log('Admin password updated successfully.'); +function inspectDatabase(file = DB_PATH) { + const connection = openReadonlyDatabase(file); + try { + const quickCheck = connection.pragma('quick_check', { simple: true }); + const foreignKeyErrors = connection.prepare('PRAGMA foreign_key_check').all(); + const tables = connection.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name").all().map((row) => row.name); + const requiredTables = ['users', 'site_settings', 'tickets', 'ticket_messages', 'ticket_events']; + const missingTables = requiredTables.filter((name) => !tables.includes(name)); + return { + file, + size: fs.statSync(file).size, + userVersion: connection.pragma('user_version', { simple: true }), + quickCheck, + foreignKeyErrors, + missingTables, + valid: quickCheck === 'ok' && foreignKeyErrors.length === 0 && missingTables.length === 0, + }; + } catch (error) { + throw new CliError(`数据库完整性检查失败:${error.message}`); + } finally { connection.close(); } } -// === Captcha === -async function cmdCaptcha() { - await warnIfServerRunning(); - await db.getDb(); - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - const q = (q) => new Promise(resolve => rl.question(q, resolve)); - - console.log('=== Captcha Rule Configuration ===\n'); - console.log('Current settings:'); - ['login','register','forum','failed'].forEach(k => { - const v = db.get("SELECT value FROM site_settings WHERE key = 'captcha_" + k + "'"); - console.log(` ${k}: ${v ? v.value : '0'}`); - }); - const type = db.get("SELECT value FROM site_settings WHERE key = 'captcha_type'"); - console.log(` type: ${type ? type.value : 'builtin'}\n`); - - const typeAns = await q('Captcha type (builtin/recaptcha/both) [' + (type ? type.value : 'builtin') + ']: '); - if (typeAns) db.run("UPDATE site_settings SET value=? WHERE key='captcha_type'", [typeAns]); - - for (const scope of ['login', 'register', 'forum']) { - const current = db.get("SELECT value FROM site_settings WHERE key='captcha_" + scope + "'"); - const ans = await q(`Enable captcha for ${scope}? (y/n) [${current && current.value === '1' ? 'y' : 'n'}]: `); - db.run("UPDATE site_settings SET value=? WHERE key='captcha_" + scope + "'", [ans.toLowerCase() === 'y' ? '1' : '0']); - } - - const failAns = await q('Enable captcha after failed attempts? (y/n): '); - db.run("UPDATE site_settings SET value=? WHERE key='captcha_failed'", [failAns.toLowerCase() === 'y' ? '1' : '0']); - if (failAns.toLowerCase() === 'y') { - const threshold = await q('Failed attempts threshold (default 5): '); - if (threshold) db.run("UPDATE site_settings SET value=? WHERE key='captcha_failed_threshold'", [threshold]); - } - - rl.close(); - console.log('\nCaptcha rules updated.'); +function formatBytes(bytes) { + if (!Number.isFinite(bytes)) return '未知'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MiB`; + return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GiB`; } -// === Config === -async function cmdConfig() { - await db.getDb(); - const rows = db.all("SELECT key, value FROM site_settings ORDER BY key"); - console.log('=== Site Configuration ===\n'); - const secrets = ['smtp_pass', 'recaptcha_secret_key']; - rows.forEach(r => { - let val = r.value; - if (secrets.includes(r.key) && val) val = '****' + val.slice(-4); - console.log(` ${r.key}: ${val || '(empty)'}`); - }); - console.log(`\n listen_port: ${getConfigPort()}`); +function formatMtime(date) { + const pad = (value) => String(value).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; } -// === Start / Stop / Restart === -function findPidFile() { return path.join(__dirname, 'server.pid'); } +function print(value, args) { + if (args.json) console.log(JSON.stringify(value, null, 2)); + else console.log(value); +} + +function maskValue(key, value) { + if (value === undefined || value === null || value === '') return '(空)'; + const sensitive = /(pass(word)?|secret|token|private|jwt|credential|api[_-]?key|client[_-]?secret|pin[_-]?(hash|salt)|terminal_pin)/i.test(key); + if (!sensitive) return String(value); + const text = String(value); + return text.length <= 4 ? '****' : `****${text.slice(-4)}`; +} + +function getPidFromFile() { + if (!fs.existsSync(PID_PATH)) return null; + const raw = fs.readFileSync(PID_PATH, 'utf8').trim(); + if (!/^\d+$/.test(raw)) return { invalid: true, raw }; + return Number(raw); +} function isRunning(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; try { process.kill(pid, 0); return true; } catch { return false; } } -async function cmdStop() { - const pidFile = findPidFile(); - if (fs.existsSync(pidFile)) { - const pid = parseInt(fs.readFileSync(pidFile, 'utf8')); - if (isRunning(pid)) { - try { process.kill(pid); console.log('Server stopped (PID: ' + pid + ')'); } catch { console.log('Could not stop process.'); } - } else { console.log('Server not running.'); } - fs.unlinkSync(pidFile); - } else { - // Try to find node process - console.log('No PID file found. Try: taskkill /F /IM node.exe (Windows) or pkill node (Linux/Mac)'); +function readProcessDetails(pid) { + if (!isRunning(pid)) return null; + let cwd = ''; + let command = ''; + if (process.platform === 'win32') { + try { + const script = "$p=Get-CimInstance Win32_Process -Filter 'ProcessId = ' + $args[0]; if ($p) { $p | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress }"; + const output = execFileSync('powershell.exe', ['-NoProfile', '-Command', script, String(pid)], { encoding: 'utf8' }).trim(); + const processInfo = output ? JSON.parse(output) : null; + command = processInfo && processInfo.CommandLine ? processInfo.CommandLine : ''; + } catch {} } -} - -async function cmdStart() { - const port = getConfigPort(); - const proc = spawn('node', ['server.js'], { - cwd: __dirname, - stdio: 'inherit', - env: { ...process.env, PORT: String(port) }, - detached: true, - }); - proc.unref(); - fs.writeFileSync(findPidFile(), String(proc.pid)); - console.log(`Server starting on port ${port} (PID: ${proc.pid})`); -} - -async function cmdRestart() { - await cmdStop(); - await new Promise(r => setTimeout(r, 1000)); - await cmdStart(); -} - -// === Backup === -async function cmdBackup() { - const srcPath = path.join(__dirname, 'data', 'rainweb.db'); - if (!fs.existsSync(srcPath)) { - console.error('数据库不存在:' + srcPath + '(首次启动 server 后才会创建)'); - process.exit(1); - } - - const backupDir = path.join(__dirname, 'backups'); - fs.mkdirSync(backupDir, { recursive: true }); - - const now = new Date(); - const destPath = path.join(backupDir, - `rainweb-${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}-${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}.db`); - - // 只读打开源库,不写数据,安全(server 运行中也可执行) - // better-sqlite3 v13:src.backup(destPath) 接受目标文件路径(string),返回 Promise - const Database = require('better-sqlite3'); - const src = new Database(srcPath, { readonly: true }); try { - await src.backup(destPath); - } finally { - src.close(); - } - - const size = fs.statSync(destPath).size; - console.log(`备份成功:${destPath}(${size} bytes)`); -} - -// 读取 backups/ 目录下最新的备份文件(按 mtime),无备份返回 null -function getLatestBackup() { - const backupDir = path.join(__dirname, 'backups'); - if (!fs.existsSync(backupDir)) return null; - let latest = null; - for (const f of fs.readdirSync(backupDir)) { - if (!f.startsWith('rainweb-') || !f.endsWith('.db')) continue; - const filePath = path.join(backupDir, f); - const mtime = fs.statSync(filePath).mtime; - if (!latest || mtime > latest.mtime) latest = { file: f, mtime }; - } - return latest; -} - -function pad2(n) { return String(n).padStart(2, '0'); } - -function formatMtime(date) { - return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`; -} - -// === Upgrade === -async function cmdUpgrade() { - console.log('=== RainWeb Upgrade ===\n'); - - if (!fs.existsSync(path.join(__dirname, '.git'))) { - console.error('不是 git 仓库,无法 upgrade。请先 git clone 安装。'); - process.exit(1); - } - - console.log('1. Pulling latest code via git...'); - try { - execSync('git pull', { cwd: __dirname, stdio: 'inherit' }); - } catch { - console.error('Git pull failed. Check for conflicts.'); - process.exit(1); - } - - console.log('\n2. Installing dependencies...'); - try { - execSync('npm install', { cwd: __dirname, stdio: 'inherit' }); - } catch { - console.error('npm install failed.'); - process.exit(1); - } - - console.log('\n3. Restarting server...'); - await cmdRestart(); - console.log('\n=== Upgrade complete! ==='); -} - -// === Helper === -// 写命令(password/captcha)在连接库前探测 server 是否运行,仅提示不阻止执行 -async function warnIfServerRunning() { - try { - await httpGet('http://localhost:' + (getConfigPort())); - console.log('警告:server 正在运行,并发写库可能失败或等待,建议先停止 server 再执行'); + if (process.platform !== 'win32') { + cwd = fs.realpathSync(`/proc/${pid}/cwd`); + command = fs.readFileSync(`/proc/${pid}/cmdline`).toString().replace(/\0/g, ' ').trim(); + } } catch {} + if (!command && process.platform !== 'win32') { + try { command = execFileSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8' }).trim(); } catch {} + } + const normalizedCommand = command.replace(/\\/g, '/'); + const normalizedServer = SERVER_PATH.replace(/\\/g, '/'); + const hasServerScript = normalizedCommand.includes(normalizedServer) + || (cwd === PROJECT_ROOT && /(?:^|\s)server\.js(?:\s|$)/.test(command)); + const owned = (process.platform === 'win32' ? hasServerScript : cwd === PROJECT_ROOT && hasServerScript) + && !/cli\.js/.test(command); + return { pid, cwd, command, owned }; } -function httpGet(url) { - return new Promise((resolve, reject) => { - http.get(url, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(d)); }) - .on('error', reject); +function listCandidatePids() { + try { return fs.readdirSync('/proc').filter((entry) => /^\d+$/.test(entry)).map(Number); } + catch { + try { return execFileSync('ps', ['-A', '-o', 'pid='], { encoding: 'utf8' }).split(/\s+/).filter(Boolean).map(Number); } + catch { return []; } + } +} + +function findOwnedServers() { + return listCandidatePids().filter((pid) => pid !== process.pid).map(readProcessDetails).filter((details) => details && details.owned); +} + +function getOwnedServer() { + const pidInfo = getPidFromFile(); + if (pidInfo && pidInfo.invalid) throw new CliError(`PID 文件格式错误:${PID_PATH}`); + if (Number.isInteger(pidInfo)) { + if (!isRunning(pidInfo)) { + try { fs.unlinkSync(PID_PATH); } catch {} + } else { + const details = readProcessDetails(pidInfo); + if (!details || !details.owned) throw new CliError(`拒绝操作:${PID_PATH} 中的 PID ${pidInfo} 不属于本项目的 server.js`); + return details; + } + } + const servers = findOwnedServers(); + if (servers.length > 1) throw new CliError(`发现多个本项目 server.js 进程(${servers.map((item) => item.pid).join(', ')}),请先人工处理`); + return servers[0] || null; +} + +async function waitForExit(pid, timeoutMs = 10000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!isRunning(pid)) return true; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return !isRunning(pid); +} + +function probeHttp(port, timeoutMs = 3000) { + return new Promise((resolve) => { + const started = Date.now(); + const request = http.get({ hostname: '127.0.0.1', port, path: '/', timeout: timeoutMs }, (response) => { + response.resume(); + response.on('end', () => resolve({ reachable: true, healthy: response.statusCode >= 200 && response.statusCode < 400, statusCode: response.statusCode, latencyMs: Date.now() - started })); + }); + request.on('timeout', () => request.destroy(new Error('请求超时'))); + request.on('error', (error) => resolve({ reachable: false, healthy: false, error: error.message, latencyMs: Date.now() - started })); }); } -main().catch(console.error); +function getTimeout(args) { + const value = args.timeout === undefined ? 3000 : Number(args.timeout); + if (!Number.isInteger(value) || value < 100 || value > 60000) throw new CliError('--timeout 必须是 100-60000 的整数(毫秒)'); + return value; +} + +async function waitForStartedServer(pid, port, timeoutMs = 12000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + const details = readProcessDetails(pid); + if (!details || !details.owned) throw new CliError(`新启动的 PID ${pid} 未能确认属于本项目 server.js`); + last = await probeHttp(port, 1000); + if (last.healthy) return last; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new CliError(`服务器启动后健康检查失败:${last && last.error ? last.error : `HTTP ${last && last.statusCode ? last.statusCode : '无响应'}`}`); +} + +async function stopServer({ quiet = false } = {}) { + const server = getOwnedServer(); + if (!server) { + if (!quiet) console.log('服务器当前未运行。'); + return { stopped: false, pid: null }; + } + process.kill(server.pid, 'SIGTERM'); + let exited = await waitForExit(server.pid, 10000); + if (!exited) { + const stillOwned = readProcessDetails(server.pid); + if (!stillOwned || !stillOwned.owned) throw new CliError(`PID ${server.pid} 在等待期间不再确认属于本项目,未强制结束`); + process.kill(server.pid, 'SIGKILL'); + exited = await waitForExit(server.pid, 3000); + } + if (!exited) throw new CliError(`服务器未能退出(PID: ${server.pid})`); + if (fs.existsSync(PID_PATH) && getPidFromFile() === server.pid) fs.unlinkSync(PID_PATH); + if (!quiet) console.log(`服务器已停止(PID: ${server.pid})。`); + return { stopped: true, pid: server.pid }; +} + +async function startServer({ quiet = false } = {}) { + const existing = getOwnedServer(); + if (existing) { + if (!quiet) console.log(`服务器已经运行(PID: ${existing.pid})。`); + return { started: false, alreadyRunning: true, pid: existing.pid, port: getConfigPort() }; + } + const port = getConfigPort(); + const child = spawn(process.execPath, [SERVER_PATH], { cwd: PROJECT_ROOT, stdio: 'ignore', detached: true, env: { ...process.env, PORT: String(port) } }); + if (!child.pid) throw new CliError('无法启动服务器进程'); + fs.writeFileSync(PID_PATH, `${child.pid}\n`, { mode: 0o644 }); + try { await waitForStartedServer(child.pid, port); } + catch (error) { + if (isRunning(child.pid)) { + const details = readProcessDetails(child.pid); + if (details && details.owned) { process.kill(child.pid, 'SIGTERM'); await waitForExit(child.pid, 3000); } + } + if (fs.existsSync(PID_PATH) && getPidFromFile() === child.pid) fs.unlinkSync(PID_PATH); + throw error; + } + child.unref(); + if (!quiet) console.log(`服务器已启动(端口: ${port},PID: ${child.pid})。`); + return { started: true, alreadyRunning: false, pid: child.pid, port }; +} + +function listBackupFiles() { + if (!fs.existsSync(BACKUP_DIR)) return []; + return fs.readdirSync(BACKUP_DIR).filter((file) => /^rainweb-.+\.db$/.test(file)).map((file) => path.join(BACKUP_DIR, file)).filter((file) => fs.statSync(file).isFile()).sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs); +} + +function inspectBackup(file) { + try { + const result = inspectDatabase(file); + const integrityValid = result.quickCheck === 'ok' && result.foreignKeyErrors.length === 0; + return { file: path.basename(file), size: result.size, modifiedAt: formatMtime(fs.statSync(file).mtime), valid: integrityValid, schemaReady: result.missingTables.length === 0, quickCheck: result.quickCheck, foreignKeyErrors: result.foreignKeyErrors.length, missingTables: result.missingTables, userVersion: result.userVersion }; + } catch (error) { + return { file: path.basename(file), size: fs.existsSync(file) ? fs.statSync(file).size : 0, modifiedAt: fs.existsSync(file) ? formatMtime(fs.statSync(file).mtime) : '', valid: false, schemaReady: false, error: error.message }; + } +} + +async function createBackup() { + if (!dbExists()) throw new CliError(`数据库不存在:${DB_PATH}`); + fs.mkdirSync(BACKUP_DIR, { recursive: true }); + const now = new Date(); + const stamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}${String(now.getMilliseconds()).padStart(3, '0')}`; + const destination = path.join(BACKUP_DIR, `rainweb-${stamp}.db`); + const source = new Database(DB_PATH, { readonly: true, fileMustExist: true }); + try { await source.backup(destination); } finally { source.close(); } + const check = inspectBackup(destination); + if (!check.valid) { try { fs.unlinkSync(destination); } catch {} throw new CliError(`备份已生成但完整性检查失败:${check.error || check.quickCheck}`); } + return { path: destination, ...check }; +} + +function resolveBackupFile(input) { + if (!input) throw new CliError('请指定备份文件名,例如 backup restore rainweb-YYYYMMDD-HHmmss.db'); + const candidate = path.resolve(BACKUP_DIR, input); + if (path.dirname(candidate) !== path.resolve(BACKUP_DIR)) throw new CliError('备份文件必须位于 backups/ 目录内'); + if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) throw new CliError(`备份文件不存在:${candidate}`); + return candidate; +} + +function readSettingRows() { + if (!dbExists()) return []; + const connection = openReadonlyDatabase(); + try { return connection.prepare('SELECT key, value FROM site_settings ORDER BY key').all(); } finally { connection.close(); } +} + +function normalizeSwitch(value, label) { + const normalized = String(value || '').toLowerCase(); + if (['1', 'on', 'yes', 'y', 'true'].includes(normalized)) return '1'; + if (['0', 'off', 'no', 'n', 'false'].includes(normalized)) return '0'; + throw new CliError(`${label} 必须是 on/off、yes/no 或 1/0`); +} + +function ask(rl, question) { return new Promise((resolve) => rl.question(question, resolve)); } + +async function cmdStatus(args) { + const port = getConfigPort(); + const httpState = await probeHttp(port, getTimeout(args)); + let server = null; + let serverError = null; + try { server = getOwnedServer(); } catch (error) { serverError = error.message; } + let database = null; + if (dbExists()) { try { database = inspectDatabase(); } catch (error) { database = { valid: false, error: error.message }; } } + const latest = listBackupFiles()[0]; + const result = { version: PKG.version, node: process.version, platform: process.platform, port, server: { running: !!server, pid: server ? server.pid : null, error: serverError, http: httpState }, database: database || { valid: false, error: '数据库不存在' }, latestBackup: latest ? inspectBackup(latest) : null }; + if (args.json) return print(result, args); + console.log(`RainWeb v${result.version}`); + console.log(`Node.js:${result.node}`); + console.log(`平台:${result.platform}`); + console.log(`端口:${result.port}`); + console.log(`服务:${result.server.running ? `运行中(PID: ${result.server.pid})` : result.server.error || '未运行'}`); + console.log(`HTTP:${httpState.healthy ? `正常(${httpState.statusCode},${httpState.latencyMs} ms)` : '不可用'}`); + console.log(`数据库:${result.database.valid ? '完整性正常' : result.database.error || '检查失败'}`); + console.log(`最近备份:${result.latestBackup ? `${result.latestBackup.file}(${formatBytes(result.latestBackup.size)})` : '无'}`); +} + +async function cmdHealth(args) { + const port = getConfigPort(); + const [httpState, database] = await Promise.all([probeHttp(port, getTimeout(args)), Promise.resolve().then(() => (dbExists() ? inspectDatabase() : { valid: false, error: '数据库不存在' }))]); + const result = { healthy: httpState.healthy && database.valid, port, http: httpState, database }; + print(args.json ? result : `HTTP:${httpState.healthy ? '正常' : `失败(${httpState.error || httpState.statusCode || '无响应'})`}\n数据库:${database.valid ? '完整性正常' : database.error || '检查失败'}\n总体:${result.healthy ? '健康' : '不健康'}`, args); + return result.healthy ? 0 : 2; +} + +async function cmdDbCheck(args) { + const result = dbExists() ? inspectDatabase() : { valid: false, error: '数据库不存在', file: DB_PATH }; + const foreignKeyCount = Array.isArray(result.foreignKeyErrors) ? result.foreignKeyErrors.length : result.foreignKeyErrors; + print(args.json ? result : `数据库:${result.file}\n版本:${result.userVersion ?? '未知'}\nquick_check:${result.quickCheck || '未执行'}\n外键错误:${foreignKeyCount ?? '未知'}\n关键表缺失:${result.missingTables ? result.missingTables.join(', ') || '无' : '未知'}\n结果:${result.valid ? '通过' : result.error || '失败'}`, args); + return result.valid ? 0 : 2; +} + +async function cmdPort(args) { + const value = args.positionals[1]; + if (value === undefined) return print(args.json ? { port: getConfigPort() } : `当前端口:${getConfigPort()}`, args); + if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 65535) throw new CliError('端口必须是 1-65535 的整数'); + setConfigPort(Number(value)); + print(args.json ? { port: Number(value), message: '端口已保存,重启后生效' } : `端口已设置为 ${value},重启后生效。`, args); +} + +async function cmdPassword(args) { + await warnIfServerRunning(); + let newPass = args.positionals[1]; + if (!newPass) { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + newPass = await ask(rl, '请输入新的管理员密码(至少 6 位):'); + const confirm = await ask(rl, '请再次输入密码:'); + rl.close(); + if (newPass !== confirm) throw new CliError('两次输入的密码不一致'); + } + if (!newPass || newPass.length < 6) throw new CliError('密码至少需要 6 位'); + const db = getWritableDb(); + db.getDb(); + const admin = db.get("SELECT id FROM users WHERE role = 'admin'"); + if (!admin) throw new CliError('未找到管理员账号'); + db.run('UPDATE users SET password = ? WHERE id = ?', [bcrypt.hashSync(newPass, 10), admin.id]); + print(args.json ? { message: '管理员密码已更新' } : '管理员密码已更新。', args); +} + +async function cmdCaptcha(args) { + await warnIfServerRunning(); + const db = getWritableDb(); + db.getDb(); + const scopes = ['login', 'register', 'forum']; + const currentType = db.getSetting('captcha_type') || 'builtin'; + const allowedTypes = new Set(['none', 'builtin', 'recaptcha', 'turnstile', 'both']); + const typeArg = args.type; + if (typeArg && !allowedTypes.has(typeArg)) throw new CliError('--type 必须是 none、builtin、recaptcha、turnstile 或 both'); + const hasOptions = typeArg || scopes.some((scope) => args[scope] !== undefined) || args.failed !== undefined || args.threshold !== undefined; + const updates = {}; + if (hasOptions) { + updates.captcha_type = typeArg || currentType; + for (const scope of scopes) if (args[scope] !== undefined) updates[`captcha_${scope}`] = normalizeSwitch(args[scope], `--${scope}`); + if (args.failed !== undefined) updates.captcha_failed = normalizeSwitch(args.failed, '--failed'); + if (args.threshold !== undefined) { + if (!/^\d+$/.test(String(args.threshold)) || Number(args.threshold) < 1) throw new CliError('--threshold 必须是正整数'); + updates.captcha_failed_threshold = String(Number(args.threshold)); + } + } else { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + console.log('验证码配置(直接回车保留当前值)'); + const type = await ask(rl, `类型 none/builtin/recaptcha/turnstile/both [${currentType}]:`); + const selectedType = type || currentType; + if (!allowedTypes.has(selectedType)) { rl.close(); throw new CliError('验证码类型不合法'); } + updates.captcha_type = selectedType; + for (const scope of scopes) { + const current = db.getSetting(`captcha_${scope}`) || '0'; + const answer = await ask(rl, `启用 ${scope} 验证码?(y/n) [${current === '1' ? 'y' : 'n'}]:`); + updates[`captcha_${scope}`] = answer ? normalizeSwitch(answer, scope) : current; + } + const failed = await ask(rl, `失败尝试后启用验证码?(y/n) [${db.getSetting('captcha_failed') === '1' ? 'y' : 'n'}]:`); + updates.captcha_failed = failed ? normalizeSwitch(failed, 'failed') : (db.getSetting('captcha_failed') || '0'); + if (updates.captcha_failed === '1') { + const threshold = await ask(rl, `失败次数阈值 [${db.getSetting('captcha_failed_threshold') || '5'}]:`); + if (threshold) { + if (!/^\d+$/.test(threshold) || Number(threshold) < 1) { rl.close(); throw new CliError('失败次数阈值必须是正整数'); } + updates.captcha_failed_threshold = threshold; + } + } + rl.close(); + } + for (const [key, value] of Object.entries(updates)) db.setSetting(key, value); + print(args.json ? { message: '验证码规则已更新', settings: updates } : '验证码规则已更新。', args); +} + +async function cmdConfig(args) { + const settings = Object.fromEntries(readSettingRows().map((row) => [row.key, maskValue(row.key, row.value)])); + const envConfig = readJson(CONFIG_PATH); + const safeEnv = Object.fromEntries(Object.entries(envConfig).map(([key, value]) => [key, maskValue(key, value)])); + const result = { settings, env: safeEnv, listen_port: getConfigPort() }; + if (args.json) return print(result, args); + console.log('=== RainWeb 配置 ==='); + for (const [key, value] of Object.entries(settings)) console.log(` ${key}:${value}`); + for (const [key, value] of Object.entries(safeEnv)) console.log(` env.${key}:${value}`); + console.log(` listen_port:${result.listen_port}`); +} + +async function cmdBackup(args) { + const action = args.positionals[1] || 'create'; + if (action === 'create') { + const result = await createBackup(); + print(args.json ? result : `备份成功:${result.path}\n完整性:通过(${formatBytes(result.size)})`, args); + return; + } + if (action === 'list') { + const result = listBackupFiles().map(inspectBackup); + print(args.json ? result : (result.length ? result.map((item) => `${item.valid ? '正常' : '损坏'}${item.valid && !item.schemaReady ? '(待迁移)' : ''} ${item.file} ${formatBytes(item.size)} ${item.modifiedAt}`).join('\n') : '暂无备份。'), args); + return; + } + if (action === 'prune') { + if (args.keep === undefined) throw new CliError('prune 必须明确指定 --keep <数量>;未指定时不会删除任何文件'); + if (!/^\d+$/.test(String(args.keep)) || Number(args.keep) < 1) throw new CliError('--keep 必须是正整数'); + const keep = Number(args.keep); + const remove = listBackupFiles().slice(keep); + const result = { keep, candidates: remove.map((file) => path.basename(file)), deleted: [] }; + if (args.yes) for (const file of remove) { fs.unlinkSync(file); result.deleted.push(path.basename(file)); } + print(args.json ? result : (args.yes ? `已删除 ${result.deleted.length} 个旧备份,保留最新 ${keep} 个。` : `预览:将删除 ${remove.length} 个旧备份;加 --yes 才会执行。\n${result.candidates.join('\n')}`), args); + return; + } + if (action === 'restore') { + if (!args.yes) throw new CliError('restore 会覆盖当前数据库,必须显式使用 --yes;且服务必须已停止'); + if (getOwnedServer()) throw new CliError('restore 必须先停止 RainWeb 服务'); + const httpState = await probeHttp(getConfigPort(), getTimeout(args)); + if (httpState.reachable) throw new CliError('检测到 HTTP 服务仍在运行,拒绝 restore'); + const sourcePath = resolveBackupFile(args.positionals[2]); + const check = inspectBackup(sourcePath); + if (!check.valid) throw new CliError(`备份完整性检查失败,拒绝恢复:${check.error || check.quickCheck}`); + if (!dbExists()) fs.mkdirSync(path.dirname(DB_PATH), { recursive: true }); + const tempPath = `${DB_PATH}.restore-${process.pid}-${Date.now()}`; + const source = new Database(sourcePath, { readonly: true, fileMustExist: true }); + try { await source.backup(tempPath); } finally { source.close(); } + let oldPath = null; + try { + if (fs.existsSync(DB_PATH)) { oldPath = `${DB_PATH}.before-restore-${Date.now()}`; fs.renameSync(DB_PATH, oldPath); } + fs.renameSync(tempPath, DB_PATH); + } catch (error) { + try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch {} + try { if (oldPath && fs.existsSync(oldPath) && !fs.existsSync(DB_PATH)) fs.renameSync(oldPath, DB_PATH); } catch {} + throw new CliError(`恢复失败,已尝试保留原数据库:${error.message}`); + } + print(args.json ? { message: '数据库已恢复', source: sourcePath, previousDatabase: oldPath } : `数据库已恢复:${sourcePath}\n原数据库保留为:${oldPath || '无'}。`, args); + return; + } + throw new CliError(`未知 backup 子命令:${action}`); +} + +function runCommand(command, commandArgs, label) { + console.log(`${label}:${command} ${commandArgs.join(' ')}`); + try { execFileSync(command, commandArgs, { cwd: PROJECT_ROOT, stdio: 'inherit' }); } + catch (error) { throw new CliError(`${label}失败(退出码 ${error.status ?? '未知'})`); } +} + +function getNpmCommand() { + return process.platform === 'win32' ? 'npm.cmd' : 'npm'; +} + +async function cmdUpgrade(args) { + if (args.json) throw new CliError('upgrade 不支持 --json,请使用普通输出查看升级过程'); + if (!fs.existsSync(path.join(PROJECT_ROOT, '.git'))) throw new CliError('当前目录不是 git 仓库,无法升级'); + let status; + try { status = execFileSync('git', ['status', '--porcelain'], { cwd: PROJECT_ROOT, encoding: 'utf8' }); } + catch (error) { throw new CliError(`无法读取 git 工作区:${error.message}`); } + if (status.trim()) throw new CliError('git 工作区不干净,已停止升级;请先处理本地修改或未跟踪文件'); + const backup = await createBackup(); + console.log(`升级前备份完成:${backup.file}`); + const wasRunning = !!getOwnedServer(); + if (wasRunning) await stopServer(); + try { + runCommand('git', ['pull', '--ff-only'], 'git 快进更新'); + runCommand(getNpmCommand(), ['install'], '依赖安装'); + runCommand(getNpmCommand(), ['run', 'build'], '前端构建'); + if (wasRunning) { + await startServer(); + const health = await cmdHealth({ json: false }); + if (health !== 0) throw new CliError('升级后健康检查未通过'); + } + } catch (error) { + if (wasRunning) { + let running = false; + try { running = !!getOwnedServer(); } catch {} + if (!running) { + try { await startServer(); } catch (restartError) { console.error(`升级失败后恢复启动也失败:${restartError.message}`); } + } + } + throw error instanceof CliError ? error : new CliError(error.message); + } + console.log('升级完成。'); +} + +async function cmdPortlessStart() { await startServer(); } +async function cmdPortlessStop() { await stopServer(); } +async function cmdPortlessRestart() { await stopServer(); await startServer(); } + +async function warnIfServerRunning() { + let owned = null; + try { owned = getOwnedServer(); } catch {} + if (owned) console.log('警告:server 正在运行,并发写库可能等待;建议先停止服务。'); +} + +async function dispatch(args) { + const command = args.positionals[0] || 'help'; + switch (command) { + case 'status': return cmdStatus(args); + case 'health': return cmdHealth(args); + case 'db-check': return cmdDbCheck(args); + case 'start': return cmdPortlessStart(args); + case 'stop': return cmdPortlessStop(args); + case 'restart': return cmdPortlessRestart(args); + case 'port': return cmdPort(args); + case 'password': return cmdPassword(args); + case 'captcha': return cmdCaptcha(args); + case 'config': return cmdConfig(args); + case 'backup': return cmdBackup(args); + case 'upgrade': return cmdUpgrade(args); + case 'help': return console.log(HELP); + default: throw new CliError(`未知命令:${command}\n\n${HELP}`); + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const result = await dispatch(args); + if (Number.isInteger(result)) process.exitCode = result; +} + +main().catch((error) => { + const exitCode = error.exitCode || 1; + if (process.argv.includes('--json')) console.error(JSON.stringify({ error: error.message, exitCode })); + else console.error(`错误:${error.message}`); + process.exitCode = exitCode; +});