423 lines
23 KiB
JavaScript
423 lines
23 KiB
JavaScript
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 ? '<p style="color:#b3261e"><strong>(管理员代收:文章作者未设置邮箱)</strong></p>' : '') +
|
||
`<p>您的文章《<strong>${post.title}</strong>》收到一条新评论:</p>
|
||
<p style="padding:12px;background:#f5f5f5;border-radius:8px;margin:12px 0">${String(comment.content).replace(/</g, '<')}</p>
|
||
<p style="color:#79747e">评论者:${commenterName}</p>
|
||
<a class="btn" href="${link}">查看评论</a>`),
|
||
});
|
||
} 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;
|