- migrateSchema v2: tags/views/parent_id/status 列 + post_likes 表 - 接口: search/tags/tag/archive/prevnext/点赞三连/评论审核三连/feed.xml - 评论: 嵌套回复/审核模式(comment_moderate)/限流/邮件通知(comment_notify) - cli backup 命令 + status 显示最近备份;backups/ 入 gitignore - ssr blogSSR 阅读量计数;settings 白名单补新键
329 lines
11 KiB
JavaScript
329 lines
11 KiB
JavaScript
#!/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 <command> [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);
|