#!/usr/bin/env node const { execSync, 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 CONFIG_PATH = path.join(__dirname, '.env.json'); const PKG = require('./package.json'); const db = require('./db'); const HELP = ` RainWeb CLI v${PKG.version} Usage: node cli.js [options] 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 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 `; 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); } } // === 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'})`); } } catch {} } // === Port === function getConfigPort() { try { const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); return config.port || 3001; } catch { return 3001; } } function setConfigPort(port) { let config = {}; try { config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); } catch {} config.port = port; fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2)); } 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()}`); } } // === 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.'); } // === 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.'); } // === 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()}`); } // === Start / Stop / Restart === function findPidFile() { return path.join(__dirname, 'server.pid'); } function isRunning(pid) { 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)'); } } 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 再执行'); } catch {} } 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); }); } main().catch(console.error);