const express = require('express'); const jwt = require('jsonwebtoken'); const rateLimit = require('express-rate-limit'); const db = require('../db'); const { authMiddleware, adminOnly, SECRET } = require('../middleware/auth'); const locks = require('../lib/locks'); const { attachAuthor } = require('../lib/author'); const router = express.Router(); // 评论限流:10 分钟窗口内最多 15 次 const commentLimiter = rateLimit({ windowMs: 10 * 60 * 1000, max: 15, standardHeaders: true, legacyHeaders: false, message: { error: '评论过于频繁,请稍后再试' }, }); // 可选鉴权:有效 token 时解析出 req.user,匿名请求直接放行(用于草稿的"作者可见"判断) function optionalAuth(req, res, next) { const header = req.headers.authorization; if (header && header.startsWith('Bearer ')) { try { req.user = jwt.verify(header.slice(7), SECRET); } catch { /* 无效 token 按匿名处理 */ } } next(); } // LIKE 通配符转义(% _ \),配合 ESCAPE '\' 使用 function escapeLike(s) { return String(s).replace(/[\\%_]/g, (m) => '\\' + m); } // 标签规范化:逗号分隔、去空白、去空项、逗号无空格连接(保证 LIKE 边界匹配可靠) function normalizeTags(tags) { return String(tags || '').split(',').map(t => t.trim()).filter(Boolean).join(','); } // 评论邮件通知:comment_notify='1' 且评论者不是文章作者时通知作者(失败不影响评论创建)。 // 作者未设置邮箱时回退通知任意 admin(标题/正文标明「管理员代收」); // SMTP 未配置(getTransporter 返回 null)时打 console.warn 而非完全静默。 async function notifyCommentToAuthor(post, userId, comment) { try { if (db.getSetting('comment_notify') !== '1') return; if (post.author_id === userId) return; // 作者本人评论不通知 const { getTransporter, emailTemplate } = require('./email'); const transporter = getTransporter(); if (!transporter) { console.warn('评论通知未发送:SMTP 未配置(getTransporter 返回 null),请先配置 SMTP'); return; } const author = db.get('SELECT id, email FROM users WHERE id = ?', [post.author_id]); let recipient = (author && author.email) || ''; let adminProxy = false; if (!recipient) { // 回退:作者未设邮箱 → 通知任意 admin(管理员代收) const admin = db.get("SELECT email FROM users WHERE role = 'admin' AND email <> '' AND email IS NOT NULL LIMIT 1"); if (!admin || !admin.email) return; // 无收件人可发 recipient = admin.email; adminProxy = true; } const siteName = db.getSetting('site_name') || 'RainWeb'; const siteUrl = db.getSetting('site_url') || ''; const base = siteUrl.replace(/\/$/, ''); const link = base + '/blog/' + post.id; const user = db.get('SELECT username FROM users WHERE id = ?', [userId]); const commenterName = (user && user.username) || '匿名'; await transporter.sendMail({ from: `"${db.getSetting('smtp_from_name')}" <${db.getSetting('smtp_from_email')}>`, to: recipient, subject: `您有新评论 - ${post.title}` + (adminProxy ? '(管理员代收)' : ''), html: emailTemplate('新评论通知', (adminProxy ? '
(管理员代收:文章作者未设置邮箱)
' : '') + `您的文章《${post.title}》收到一条新评论:
${String(comment.content).replace(/
评论者:${commenterName}
查看评论`), }); } catch (e) { console.error('评论通知邮件发送失败:', e.message); } } // [lock:] 真锁:按 viewer 视角剥离单篇文章,返回带 locks/unlocked/viewer 字段的 post。 // 列表与详情共用——未解锁块内容(含 [image:]/[file:] 附件标签)一律不进 API 响应。 function applyLocks(post, req) { if (!post) return post; const viewer = { userId: req.user ? req.user.id : null, isAdmin: !!(req.user && req.user.role === 'admin'), isAuthor: !!(req.user && req.user.id === post.author_id), hasCommented: false, }; if (req.user) { viewer.hasCommented = !!db.get( 'SELECT 1 x FROM blog_comments WHERE post_id = ? AND author_id = ?', [post.id, req.user.id]); } const locksMeta = (() => { try { return JSON.parse(post.locks || '[]'); } catch { return []; } })(); const blocks = locks.parseLocks(post.content).blocks; const { stripped, unlocked } = locks.stripLocks(post.content, { locksMeta, viewer }); post.content = stripped; const lockMetaList = blocks.map(b => ({ index: b.index, type: b.type })); // 不含 hash // 附件真锁:对已解锁索引签发附件 token,前端用它加载块内 [image:]/[file:] 附件 //(password 块被 admin/作者解锁时同样签发——他们本来就可见该块) lockMetaList.forEach(m => { if (unlocked.includes(m.index)) m.token = locks.makeLockToken('blog', post.id, m.index); }); post.locks = lockMetaList; post.unlocked = unlocked; post.viewer = { loggedIn: !!req.user, isAdmin: viewer.isAdmin, isAuthor: viewer.isAuthor, hasCommented: viewer.hasCommented }; return post; } // 公开列表:仅返回已发布文章 router.get('/posts', optionalAuth, (req, res, next) => { if (req.query.all !== '1') { const rows = db.all("SELECT bp.*, u.username as author_name, u.avatar, u.qq, u.nickname AS author_nickname, u.title AS author_title, u.title_color AS author_title_color, u.role AS author_role, CASE WHEN u.qq IS NOT NULL AND trim(u.qq) <> '' THEN '' WHEN u.email GLOB '[0-9]*@qq.com' THEN substr(u.email, 1, instr(u.email, '@') - 1) ELSE '' END AS qq_from_email FROM blog_posts bp LEFT JOIN users u ON bp.author_id = u.id WHERE bp.published = 1 ORDER BY bp.created_at DESC"); rows.forEach(p => { applyLocks(p, req); attachAuthor(p); }); return res.json(rows); } // ?all=1:仅管理员可见,交给下方鉴权路由处理 next(); }); // ?all=1:返回全部文章(含草稿),仅管理员可见 router.get('/posts', authMiddleware, adminOnly, (req, res) => { const rows = db.all("SELECT bp.*, u.username as author_name, u.avatar, u.qq, u.nickname AS author_nickname, u.title AS author_title, u.title_color AS author_title_color, u.role AS author_role, CASE WHEN u.qq IS NOT NULL AND trim(u.qq) <> '' THEN '' WHEN u.email GLOB '[0-9]*@qq.com' THEN substr(u.email, 1, instr(u.email, '@') - 1) ELSE '' END AS qq_from_email FROM blog_posts bp LEFT JOIN users u ON bp.author_id = u.id ORDER BY bp.created_at DESC"); rows.forEach(p => { applyLocks(p, req); attachAuthor(p); }); res.json(rows); }); // 搜索:标题/正文/摘要模糊匹配(参数化 + 通配符转义),仅已发布,最多 20 条 router.get('/search', (req, res) => { const q = (req.query.q || '').trim(); if (!q) return res.json([]); const pattern = '%' + escapeLike(q) + '%'; const posts = db.all( `SELECT bp.id, bp.title, bp.excerpt, bp.tags, bp.created_at, u.username as author_name, u.avatar, u.qq, u.nickname AS author_nickname, u.title AS author_title, u.title_color AS author_title_color, u.role AS author_role, CASE WHEN u.qq IS NOT NULL AND trim(u.qq) <> '' THEN '' WHEN u.email GLOB '[0-9]*@qq.com' THEN substr(u.email, 1, instr(u.email, '@') - 1) ELSE '' END AS qq_from_email FROM blog_posts bp LEFT JOIN users u ON bp.author_id = u.id WHERE bp.published = 1 AND (bp.title LIKE ? ESCAPE '\\' OR bp.content LIKE ? ESCAPE '\\' OR bp.excerpt LIKE ? ESCAPE '\\') ORDER BY bp.created_at DESC LIMIT 20`, [pattern, pattern, pattern]); posts.forEach(p => { attachAuthor(p); }); res.json(posts); }); // 标签聚合:返回 [{name, count}],按 count 降序,空标签跳过 router.get('/tags', (req, res) => { const posts = db.all("SELECT tags FROM blog_posts WHERE published = 1 AND tags != ''"); const map = new Map(); posts.forEach(p => { String(p.tags || '').split(',').forEach(t => { const name = t.trim(); if (name) map.set(name, (map.get(name) || 0) + 1); }); }); const result = Array.from(map.entries()).map(([name, count]) => ({ name, count })) .sort((a, b) => b.count - a.count); res.json(result); }); // 按标签精确匹配(边界匹配,避免子串误中,如 'java' 不匹配 'javascript') router.get('/tag/:name', (req, res) => { const name = String(req.params.name || '').trim(); if (!name) return res.json([]); const posts = db.all( `SELECT bp.id, bp.title, bp.excerpt, bp.tags, bp.created_at, u.username as author_name, u.avatar, u.qq, u.nickname AS author_nickname, u.title AS author_title, u.title_color AS author_title_color, u.role AS author_role, CASE WHEN u.qq IS NOT NULL AND trim(u.qq) <> '' THEN '' WHEN u.email GLOB '[0-9]*@qq.com' THEN substr(u.email, 1, instr(u.email, '@') - 1) ELSE '' END AS qq_from_email FROM blog_posts bp LEFT JOIN users u ON bp.author_id = u.id WHERE bp.published = 1 AND (bp.tags = ? OR bp.tags LIKE ? OR bp.tags LIKE ? OR bp.tags LIKE ?) ORDER BY bp.created_at DESC`, [name, name + ',%', '%,' + name + ',%', '%,' + name]); posts.forEach(p => { attachAuthor(p); }); res.json(posts); }); // 归档:按月统计已发布文章数 router.get('/archive', (req, res) => { res.json(db.all("SELECT strftime('%Y-%m', created_at) as month, COUNT(*) as count FROM blog_posts WHERE published = 1 GROUP BY month ORDER BY month DESC")); }); router.get('/posts/:id', optionalAuth, (req, res) => { const post = db.get( "SELECT bp.*, u.username as author_name, u.avatar, u.qq, u.nickname AS author_nickname, u.title AS author_title, u.title_color AS author_title_color, u.role AS author_role, CASE WHEN u.qq IS NOT NULL AND trim(u.qq) <> '' THEN '' WHEN u.email GLOB '[0-9]*@qq.com' THEN substr(u.email, 1, instr(u.email, '@') - 1) ELSE '' END AS qq_from_email FROM blog_posts bp LEFT JOIN users u ON bp.author_id = u.id WHERE bp.id = ?", [req.params.id]); if (!post) return res.status(404).json({ error: '文章不存在' }); // 未发布文章(草稿):仅管理员或作者本人可见,其余按 404 处理避免泄露存在性 if (post.published !== 1) { if (!req.user || !(req.user.role === 'admin' || req.user.id === post.author_id)) return res.status(404).json({ error: '文章不存在' }); } else { // 已发布文章:阅读量 +1(草稿预览不计数) db.run('UPDATE blog_posts SET views = views + 1 WHERE id = ?', [post.id]); post.views = (post.views || 0) + 1; } // [lock:] 真锁:按 viewer 视角剥离,未解锁块内容不进响应 applyLocks(post, req); // 作者头像(自传 > QQ > RainID) attachAuthor(post); res.json(post); }); // 上一篇 / 下一篇(按 created_at/id 相邻,仅已发布) router.get('/posts/:id/prevnext', (req, res) => { const post = db.get('SELECT id, created_at FROM blog_posts WHERE id = ?', [req.params.id]); if (!post) return res.status(404).json({ error: '文章不存在' }); const prev = db.get( `SELECT id, title FROM blog_posts WHERE published = 1 AND (created_at < ? OR (created_at = ? AND id < ?)) ORDER BY created_at DESC, id DESC LIMIT 1`, [post.created_at, post.created_at, post.id]); const next = db.get( `SELECT id, title FROM blog_posts WHERE published = 1 AND (created_at > ? OR (created_at = ? AND id > ?)) ORDER BY created_at ASC, id ASC LIMIT 1`, [post.created_at, post.created_at, post.id]); res.json({ prev: prev || null, next: next || null }); }); // 点赞状态(匿名 liked=false) router.get('/posts/:id/like', optionalAuth, (req, res) => { const post = db.get('SELECT id FROM blog_posts WHERE id = ?', [req.params.id]); if (!post) return res.status(404).json({ error: '文章不存在' }); const count = db.get('SELECT COUNT(*) as c FROM post_likes WHERE post_id = ?', [post.id]).c; let liked = false; if (req.user) { liked = !!db.get('SELECT 1 as x FROM post_likes WHERE post_id = ? AND user_id = ?', [post.id, req.user.id]); } res.json({ liked, count }); }); // 点赞 router.post('/posts/:id/like', authMiddleware, (req, res) => { const post = db.get('SELECT id FROM blog_posts WHERE id = ?', [req.params.id]); if (!post) return res.status(404).json({ error: '文章不存在' }); db.run('INSERT OR IGNORE INTO post_likes (post_id, user_id) VALUES (?, ?)', [post.id, req.user.id]); const count = db.get('SELECT COUNT(*) as c FROM post_likes WHERE post_id = ?', [post.id]).c; res.json({ liked: true, count }); }); // 取消点赞 router.delete('/posts/:id/like', authMiddleware, (req, res) => { const post = db.get('SELECT id FROM blog_posts WHERE id = ?', [req.params.id]); if (!post) return res.status(404).json({ error: '文章不存在' }); db.run('DELETE FROM post_likes WHERE post_id = ? AND user_id = ?', [post.id, req.user.id]); const count = db.get('SELECT COUNT(*) as c FROM post_likes WHERE post_id = ?', [post.id]).c; res.json({ liked: false, count }); }); // [lock:] 解锁:返回块内原文(含 [image:]/[file:] 标签,前端自行渲染)。 // login 块需登录;reply 块需已评论(含待审核);password 块 bcrypt 校验, // 失败统一 401(不区分密码错/块不存在);admin/作者恒可解锁。 router.post('/posts/:id/locks/:index/unlock', authMiddleware, locks.unlockLimiter, (req, res) => { const post = db.get('SELECT id, author_id, published, content, locks FROM blog_posts WHERE id = ?', [req.params.id]); if (!post) return res.status(404).json({ error: '文章不存在' }); if (post.published !== 1 && !(req.user.role === 'admin' || req.user.id === post.author_id)) return res.status(404).json({ error: '文章不存在' }); const index = parseInt(req.params.index); if (!Number.isInteger(index) || index < 0) return res.status(401).json({ error: '解锁失败' }); const locksMeta = (() => { try { return JSON.parse(post.locks || '[]'); } catch { return []; } })(); const hasCommented = !!db.get( 'SELECT 1 x FROM blog_comments WHERE post_id = ? AND author_id = ?', [post.id, req.user.id]); const result = locks.verifyUnlock({ blocks: locks.parseLocks(post.content).blocks, locksMeta, index, password: req.body.password, viewer: { userId: req.user.id, isAdmin: req.user.role === 'admin', isAuthor: req.user.id === post.author_id }, replyQualified: hasCommented, }); if (!result.ok) { return res.status(result.status).json({ error: result.status === 403 ? '评论后解锁' : '解锁失败' }); } // 附件真锁:随解锁内容一并签发附件 token(前端据此加载块内附件) res.json({ ok: true, content: result.inner, lockToken: locks.makeLockToken('blog', post.id, index) }); }); router.post('/posts', authMiddleware, adminOnly, (req, res) => { const { title, content, excerpt, published, use_markdown, tags } = req.body; if (!title || !content) return res.status(400).json({ error: '标题和内容不能为空' }); const id = db.run( 'INSERT INTO blog_posts (title, content, excerpt, author_id, published, use_markdown, tags, locks) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [title, content, excerpt || '', req.user.id, published !== undefined ? (published ? 1 : 0) : 1, use_markdown !== undefined ? (use_markdown ? 1 : 0) : 1, normalizeTags(tags), locks.saveLocks(content)]); res.json(db.get('SELECT * FROM blog_posts WHERE id = ?', [id])); }); router.put('/posts/:id', authMiddleware, adminOnly, (req, res) => { const { title, content, excerpt, published, use_markdown, tags } = req.body; if (!db.get('SELECT id FROM blog_posts WHERE id = ?', [req.params.id])) return res.status(404).json({ error: '文章不存在' }); db.run( "UPDATE blog_posts SET title=?, content=?, excerpt=?, published=?, use_markdown=?, tags=?, locks=?, updated_at=datetime('now') WHERE id=?", [title || '', content || '', excerpt || '', published !== undefined ? (published ? 1 : 0) : 1, use_markdown !== undefined ? (use_markdown ? 1 : 0) : 1, normalizeTags(tags), locks.saveLocks(content || ''), req.params.id]); res.json(db.get('SELECT * FROM blog_posts WHERE id = ?', [req.params.id])); }); router.delete('/posts/:id', authMiddleware, adminOnly, (req, res) => { if (!db.get('SELECT id FROM blog_posts WHERE id = ?', [req.params.id])) return res.status(404).json({ error: '文章不存在' }); db.run('DELETE FROM blog_comments WHERE post_id = ?', [req.params.id]); db.run('DELETE FROM post_likes WHERE post_id = ?', [req.params.id]); db.run('DELETE FROM blog_posts WHERE id = ?', [req.params.id]); res.json({ message: '删除成功' }); }); // Comments // 待审核评论列表(管理接口,须在 /comments/:postId 之前注册,避免 "pending" 被当作 postId) router.get('/comments/pending', authMiddleware, adminOnly, (req, res) => { const comments = db.all( `SELECT bc.*, u.username as author_name, u.avatar, u.qq, u.nickname AS author_nickname, u.title AS author_title, u.title_color AS author_title_color, u.role AS author_role, CASE WHEN u.qq IS NOT NULL AND trim(u.qq) <> '' THEN '' WHEN u.email GLOB '[0-9]*@qq.com' THEN substr(u.email, 1, instr(u.email, '@') - 1) ELSE '' END AS qq_from_email, bp.title as post_title FROM blog_comments bc LEFT JOIN users u ON bc.author_id = u.id LEFT JOIN blog_posts bp ON bc.post_id = bp.id WHERE bc.status = 'pending' ORDER BY bc.created_at ASC`); comments.forEach(c => { attachAuthor(c); }); res.json(comments); }); // 全量评论列表(管理接口,adminOnly):?status=all|pending|approved|rejected&page=&pageSize= router.get('/comments', authMiddleware, adminOnly, (req, res) => { const status = String(req.query.status || 'all'); if (!['all', 'pending', 'approved', 'rejected'].includes(status)) return res.status(400).json({ error: '无效的 status' }); let page = parseInt(req.query.page); let pageSize = parseInt(req.query.pageSize); if (!Number.isInteger(page) || page < 1) page = 1; if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 50) pageSize = 20; const where = status === 'all' ? '' : 'WHERE bc.status = ?'; const params = status === 'all' ? [] : [status]; const total = (db.get(`SELECT COUNT(*) c FROM blog_comments bc ${where}`, params) || {}).c || 0; const list = db.all( `SELECT bc.id, bc.post_id, bp.title as post_title, bc.content, bc.author_id, u.username as author_name, u.avatar, u.qq, u.nickname AS author_nickname, u.title AS author_title, u.title_color AS author_title_color, u.role AS author_role, CASE WHEN u.qq IS NOT NULL AND trim(u.qq) <> '' THEN '' WHEN u.email GLOB '[0-9]*@qq.com' THEN substr(u.email, 1, instr(u.email, '@') - 1) ELSE '' END AS qq_from_email, bc.status, bc.created_at FROM blog_comments bc LEFT JOIN users u ON bc.author_id = u.id LEFT JOIN blog_posts bp ON bc.post_id = bp.id ${where} ORDER BY bc.created_at DESC, bc.id DESC LIMIT ? OFFSET ?`, [...params, pageSize, (page - 1) * pageSize]); list.forEach(c => { attachAuthor(c); }); res.json({ list, total, page, pageSize, totalPages: Math.ceil(total / pageSize) }); }); // 评论列表:仅返回已通过审核(approved)的评论(带作者头像) router.get('/comments/:postId', (req, res) => { if (!db.get('SELECT id FROM blog_posts WHERE id = ?', [req.params.postId])) return res.status(404).json({ error: '文章不存在' }); const comments = db.all( "SELECT bc.*, u.username as author_name, u.avatar, u.qq, u.nickname AS author_nickname, u.title AS author_title, u.title_color AS author_title_color, u.role AS author_role, CASE WHEN u.qq IS NOT NULL AND trim(u.qq) <> '' THEN '' WHEN u.email GLOB '[0-9]*@qq.com' THEN substr(u.email, 1, instr(u.email, '@') - 1) ELSE '' END AS qq_from_email FROM blog_comments bc LEFT JOIN users u ON bc.author_id = u.id WHERE bc.post_id = ? AND bc.status = 'approved' ORDER BY bc.created_at ASC", [req.params.postId]); comments.forEach(c => { attachAuthor(c); }); res.json(comments); }); // 创建评论:支持嵌套回复 parent_id;审核模式(comment_moderate='1')下新评论进待审 router.post('/comments/:postId', authMiddleware, commentLimiter, async (req, res) => { const { content } = req.body; const parent_id = parseInt(req.body.parent_id) || 0; if (!content) return res.status(400).json({ error: '评论内容不能为空' }); const post = db.get('SELECT * FROM blog_posts WHERE id = ?', [req.params.postId]); if (!post) return res.status(404).json({ error: '文章不存在' }); if (parent_id) { const parent = db.get('SELECT * FROM blog_comments WHERE id = ?', [parent_id]); if (!parent) return res.status(400).json({ error: '回复的评论不存在' }); if (parent.post_id !== post.id) return res.status(400).json({ error: '回复的评论不属于该文章' }); } const status = db.getSetting('comment_moderate') === '1' ? 'pending' : 'approved'; const id = db.run('INSERT INTO blog_comments (post_id, content, author_id, parent_id, status) VALUES (?, ?, ?, ?, ?)', [post.id, content, req.user.id, parent_id, status]); const comment = db.get( `SELECT bc.*, u.username as author_name, u.avatar, u.qq, u.nickname AS author_nickname, u.title AS author_title, u.title_color AS author_title_color, u.role AS author_role, CASE WHEN u.qq IS NOT NULL AND trim(u.qq) <> '' THEN '' WHEN u.email GLOB '[0-9]*@qq.com' THEN substr(u.email, 1, instr(u.email, '@') - 1) ELSE '' END AS qq_from_email FROM blog_comments bc LEFT JOIN users u ON bc.author_id = u.id WHERE bc.id = ?`, [id]); attachAuthor(comment); // 邮件通知(失败不影响评论创建) notifyCommentToAuthor(post, req.user.id, comment); res.json(comment); }); // 审核:通过 router.post('/comments/:id/approve', authMiddleware, adminOnly, (req, res) => { const comment = db.get('SELECT * FROM blog_comments WHERE id = ?', [req.params.id]); if (!comment) return res.status(404).json({ error: '评论不存在' }); db.run("UPDATE blog_comments SET status = 'approved' WHERE id = ?", [comment.id]); res.json({ message: '已通过', comment: db.get('SELECT * FROM blog_comments WHERE id = ?', [comment.id]) }); }); // 审核:拒绝 router.post('/comments/:id/reject', authMiddleware, adminOnly, (req, res) => { const comment = db.get('SELECT * FROM blog_comments WHERE id = ?', [req.params.id]); if (!comment) return res.status(404).json({ error: '评论不存在' }); db.run("UPDATE blog_comments SET status = 'rejected' WHERE id = ?", [comment.id]); res.json({ message: '已拒绝', comment: db.get('SELECT * FROM blog_comments WHERE id = ?', [comment.id]) }); }); router.delete('/comments/:id', authMiddleware, (req, res) => { const comment = db.get('SELECT * FROM blog_comments WHERE id = ?', [req.params.id]); if (!comment) return res.status(404).json({ error: '评论不存在' }); if (comment.author_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: '无权限' }); db.run('DELETE FROM blog_comments WHERE id = ?', [req.params.id]); res.json({ message: '删除成功' }); }); module.exports = router;