Files
rainblogweb/routes/forum.js
T

647 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const express = require('express');
const jwt = require('jsonwebtoken');
const db = require('../db');
const getDb = require('../db').getDb;
const { authMiddleware, adminOnly, SECRET } = require('../middleware/auth');
const { resolveCaptcha } = require('./auth');
const locks = require('../lib/locks');
const { authorAvatar } = require('../lib/avatar');
const { attachAuthor } = require('../lib/author');
const router = express.Router();
// 论坛游客可见开关:forum_guest_visible='1'(默认)游客可浏览论坛;
// '0'(私密模式)时所有读接口要求登录(401),发帖/回复始终需登录。
// 与 ssr.js forumSSR 的 404 门禁联动(SEO 权衡:私密模式下论坛详情页不再对外索引)。
function requireGuestVisible(req, res, next) {
if (db.getSetting('forum_guest_visible') === '1') return next();
return authMiddleware(req, res, next);
}
// 可选鉴权:有效 token 解析出 req.user(用于 [lock:] viewer 判定与作者/管理员识别),
// 匿名/无效 token 直接放行。公共模式下详情页读接口需要它(requireGuestVisible 不解析 token)。
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();
}
// ── 版主权限 ────────────────────────────────────
// admin 恒为版主;非 admin 查 forum_moderators 归属表
function isModerator(userId, categoryId) {
if (!userId) return false;
const u = db.get('SELECT role FROM users WHERE id = ?', [userId]);
if (u && u.role === 'admin') return true;
return !!db.get('SELECT 1 FROM forum_moderators WHERE category_id = ? AND user_id = ?', [categoryId, userId]);
}
// 站长 = username 'admin' 的账号(超级管理员)。
// 站长发的帖子是"站长帖":任何人(本版版主/其他 admin)都无权删改/置顶/加精,仅站长本人可操作。
function isOwnerPost(post) {
const author = db.get('SELECT username FROM users WHERE id = ?', [post.author_id]);
return author && author.username === 'admin';
}
// pin/essence 用:admin/版主(作者不含)——站长帖仅站长本人可操作
function moderatorPostGuard(req, res, next) {
const post = db.get('SELECT * FROM forum_posts WHERE id = ?', [req.params.id]);
if (!post) return res.status(404).json({ error: '帖子不存在' });
// 站长帖保护:仅站长本人可置顶/加精,版主/其他 admin 一律无权操作站长帖子
if (isOwnerPost(post) && req.user.id !== post.author_id)
return res.status(403).json({ error: '无权限' });
if (!isModerator(req.user.id, post.category_id)) return res.status(403).json({ error: '无权限' });
req.post = post;
next();
}
// DELETE/编辑帖子用:作者/admin/版主——站长帖仅站长本人可删改
function authorOrModeratorGuard(req, res, next) {
const post = db.get('SELECT * FROM forum_posts WHERE id = ?', [req.params.id]);
if (!post) return res.status(404).json({ error: '帖子不存在' });
// 站长帖保护:仅站长本人可删改,版主/其他 admin 一律无权
if (isOwnerPost(post) && req.user.id !== post.author_id)
return res.status(403).json({ error: '无权限' });
if (post.author_id !== req.user.id && !isModerator(req.user.id, post.category_id))
return res.status(403).json({ error: '无权限' });
req.post = post;
next();
}
// DELETE 回复用:回复作者/admin/所属帖版主。
// 站长保护只针对帖子本体:站长帖里的回复按回复作者判定(独立于帖子作者),
// 回复作者是站长时才仅站长本人可删,否则按原逻辑(回复作者/版主/admin)——不查帖子作者,勿误伤。
function replyAuthorOrModeratorGuard(req, res, next) {
const reply = db.get('SELECT * FROM forum_replies WHERE id = ?', [req.params.id]);
if (!reply) return res.status(404).json({ error: '回复不存在' });
// 回复作者是站长:仅站长本人可删除该回复(站长保护延伸到站长发的回复本体)
if (isOwnerPost(reply) && req.user.id !== reply.author_id)
return res.status(403).json({ error: '无权限' });
const post = db.get('SELECT category_id FROM forum_posts WHERE id = ?', [reply.post_id]);
const categoryId = post ? post.category_id : 0;
if (reply.author_id !== req.user.id && !isModerator(req.user.id, categoryId))
return res.status(403).json({ error: '无权限' });
req.reply = reply;
next();
}
// 公告编辑用:admin/版主
function categoryModeratorGuard(req, res, next) {
const cat = db.get('SELECT * FROM forum_categories WHERE id = ?', [req.params.id]);
if (!cat) return res.status(404).json({ error: '分类不存在' });
if (!isModerator(req.user.id, cat.id)) return res.status(403).json({ error: '无权限' });
req.category = cat;
next();
}
// ── 版块级禁言 ──────────────────────────────────
// 检查用户是否在禁言期:命中返回禁言行(muted_until 为 NULL 表示永久),否则 null。
// admin 豁免(管理员不受禁言);版主不豁免——版主互禁设计下版主不会被禁言,但保险起见同样检查
function isMuted(userId, categoryId) {
if (!userId || !categoryId) return null;
const u = db.get('SELECT role FROM users WHERE id = ?', [userId]);
if (u && u.role === 'admin') return null;
return db.get(
`SELECT muted_until FROM forum_mutes
WHERE category_id = ? AND user_id = ? AND (muted_until IS NULL OR muted_until > datetime('now'))`,
[categoryId, userId]) || null;
}
// 禁言管理权限:category_id 来自 queryGET)或 bodyPUT/DELETE),仅本版版主/admin
function muteCategoryGuard(req, res, next) {
const catId = Number(req.query.category_id || (req.body && req.body.category_id));
if (!catId) return res.status(400).json({ error: '缺少 category_id' });
const cat = db.get('SELECT id FROM forum_categories WHERE id = ?', [catId]);
if (!cat) return res.status(404).json({ error: '分类不存在' });
if (!isModerator(req.user.id, catId)) return res.status(403).json({ error: '无权限' });
req.muteCategoryId = catId;
next();
}
// DB 的 'YYYY-MM-DD HH:MM:SS'UTC)转 ISO 8601 输出
function toIso(s) {
return s ? String(s).replace(' ', 'T') : null;
}
// 禁言拦截(发帖/回复共用):命中返回 403 响应,未命中继续
function assertNotMuted(req, res, categoryId) {
const m = isMuted(req.user.id, categoryId);
if (!m) return true;
res.status(403).json({
error: '你已被本版块禁言' + (m.muted_until ? '至 ' + toIso(m.muted_until) : '(永久)')
});
return false;
}
// 版块聚合字段:帖子数 / 今日新帖 / 最后回复时间 / 版主(逗号分隔用户名)
const CAT_AGG_SELECT = `fc.*,
(SELECT COUNT(*) FROM forum_posts fp WHERE fp.category_id = fc.id) AS post_count,
(SELECT COUNT(*) FROM forum_posts fp WHERE fp.category_id = fc.id AND date(fp.created_at) = date('now')) AS today_count,
(SELECT MAX(fp.updated_at) FROM forum_posts fp WHERE fp.category_id = fc.id) AS last_post_at,
COALESCE((SELECT group_concat(u.username, ',') FROM forum_moderators fm LEFT JOIN users u ON fm.user_id = u.id WHERE fm.category_id = fc.id), '') AS moderators`;
router.get('/categories', requireGuestVisible, (req, res) => {
res.json(db.all(`SELECT ${CAT_AGG_SELECT} FROM forum_categories fc ORDER BY fc.sort_order ASC`));
});
// 单版块详情(含聚合,供版块页 L2 头部)
router.get('/categories/:id', requireGuestVisible, (req, res) => {
const cat = db.get(`SELECT ${CAT_AGG_SELECT} FROM forum_categories fc WHERE fc.id = ?`, [req.params.id]);
if (!cat) return res.status(404).json({ error: '分类不存在' });
res.json(cat);
});
// H4:分类增删改仅限管理员(原仅 authMiddleware,任意登录用户可越权)
router.post('/categories', authMiddleware, adminOnly, (req, res) => {
try {
const { name, description, sort_order, announcement, sub_categories, icon, icon_color, feed_enabled } = req.body;
if (!name) return res.status(400).json({ error: '名称不能为空' });
const existing = db.get('SELECT id FROM forum_categories WHERE name = ?', [name]);
if (existing) return res.status(400).json({ error: '分类已存在' });
const sc = Array.isArray(sub_categories) ? sub_categories.join(',') : (sub_categories || '');
const id = db.run('INSERT INTO forum_categories (name, description, sort_order, announcement, sub_categories, icon, icon_color, feed_enabled) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[name, description || '', sort_order || 0, announcement || '', sc, icon || '', icon_color || '', feed_enabled !== undefined ? (feed_enabled ? 1 : 0) : 1]);
res.json(db.get('SELECT * FROM forum_categories WHERE id = ?', [id]));
} catch (e) { console.error('Create category error:', e.message); res.status(500).json({ error: '操作失败' }); }
});
router.put('/categories/:id', authMiddleware, adminOnly, (req, res) => {
try {
const { name, description, sort_order, announcement, sub_categories, icon, icon_color, feed_enabled } = req.body;
const existing = db.get('SELECT id, feed_enabled FROM forum_categories WHERE id = ?', [req.params.id]);
if (!existing) return res.status(404).json({ error: '分类不存在' });
const sc = Array.isArray(sub_categories) ? sub_categories.join(',') : (sub_categories || '');
db.run('UPDATE forum_categories SET name=?, description=?, sort_order=?, announcement=?, sub_categories=?, icon=?, icon_color=?, feed_enabled=? WHERE id=?',
[name || '', description || '', sort_order || 0, announcement || '', sc, icon || '', icon_color || '',
feed_enabled !== undefined ? (feed_enabled ? 1 : 0) : (existing.feed_enabled || 1), req.params.id]);
const updated = db.get('SELECT * FROM forum_categories WHERE id = ?', [req.params.id]);
if (!updated) return res.status(500).json({ error: '更新后读取失败' });
res.json(updated);
} catch (e) { console.error('Update category error:', e.message); res.status(500).json({ error: '操作失败' }); }
});
// 版主列表设置(仅管理员):事务内 DELETE 全部 + 校验用户存在后循环 INSERT
router.put('/categories/:id/moderators', authMiddleware, adminOnly, (req, res) => {
try {
const existing = db.get('SELECT id FROM forum_categories WHERE id = ?', [req.params.id]);
if (!existing) return res.status(404).json({ error: '分类不存在' });
const raw = Array.isArray(req.body.user_ids) ? req.body.user_ids : [];
const ids = raw.map(Number).filter(n => Number.isInteger(n) && n > 0);
// 用原生 prepared 语句执行:db.run 会吞异常,事务内必须让 FK/校验错误真正抛出并回滚
getDb().transaction(() => {
getDb().prepare('DELETE FROM forum_moderators WHERE category_id = ?').run(req.params.id);
for (const uid of ids) {
const u = db.get('SELECT id FROM users WHERE id = ?', [uid]);
if (!u) throw new Error('用户不存在: ' + uid);
getDb().prepare('INSERT INTO forum_moderators (category_id, user_id) VALUES (?, ?)').run(req.params.id, uid);
}
})();
res.json({ message: '保存成功', user_ids: ids });
} catch (e) {
console.error('Update moderators error:', e.message);
res.status(400).json({ error: e.message });
}
});
// 公告编辑(admin/版主)
router.put('/categories/:id/announcement', authMiddleware, categoryModeratorGuard, (req, res) => {
const announcement = String(req.body.announcement || '');
db.run('UPDATE forum_categories SET announcement = ? WHERE id = ?', [announcement, req.params.id]);
res.json({ message: '公告已更新', announcement });
});
// 版块 profile 编辑(admin/版主):名称/描述/图标/图标底色——部分更新,只改提供的字段。
// 独立接口不放开通用 PUT /categoriesadmin 全量):防版主越权改 sort_order/公告/子版块等。
router.put('/categories/:id/profile', authMiddleware, categoryModeratorGuard, (req, res) => {
try {
const updates = {};
// name:非空字符串,≤50
if (req.body.name !== undefined) {
const name = String(req.body.name).trim();
if (!name) return res.status(400).json({ error: '名称不能为空' });
if (name.length > 50) return res.status(400).json({ error: '名称不能超过 50 个字符' });
updates.name = name;
}
// description:≤500
if (req.body.description !== undefined) {
const desc = String(req.body.description || '');
if (desc.length > 500) return res.status(400).json({ error: '描述不能超过 500 个字符' });
updates.description = desc;
}
// icon:≤100emoji 或 http(s) 图片 URL 或 /uploads/ 相对路径(本站上传的图标)
if (req.body.icon !== undefined) {
const icon = String(req.body.icon || '').trim();
if (icon.length > 100) return res.status(400).json({ error: '图标不能超过 100 个字符' });
if (icon && !/^(https?:\/\/|\/uploads\/|[\u{1F000}-\u{1FAFF}\u{1F1E6}-\u{1F1FF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{2190}-\u{21FF}])/u.test(icon)) {
return res.status(400).json({ error: '图标需为 emoji 或 http(s) 图片 URL' });
}
updates.icon = icon;
}
// icon_color:空字符串清空,或 #hex3/6 位)
if (req.body.icon_color !== undefined) {
const color = String(req.body.icon_color || '').trim();
if (color && !/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(color)) {
return res.status(400).json({ error: '图标底色需为 #hex 色值' });
}
updates.icon_color = color;
}
// feed_enabled:版块级 RSS 开关(后台 RSS 管理页用),'1'/'0'
if (req.body.feed_enabled !== undefined) {
const fe = String(req.body.feed_enabled);
if (fe !== '0' && fe !== '1') return res.status(400).json({ error: 'feed_enabled 需为 0 或 1' });
updates.feed_enabled = fe;
}
if (!Object.keys(updates).length) return res.status(400).json({ error: '没有可更新的字段' });
const cols = Object.keys(updates).map(k => k + ' = ?').join(', ');
db.run(`UPDATE forum_categories SET ${cols} WHERE id = ?`, [...Object.values(updates), req.params.id]);
const cat = db.get(`SELECT ${CAT_AGG_SELECT} FROM forum_categories fc WHERE fc.id = ?`, [req.params.id]);
if (!cat) return res.status(500).json({ error: '更新后读取失败' });
res.json({
message: '版块信息已更新',
category: { id: cat.id, name: cat.name, description: cat.description, icon: cat.icon || '', icon_color: cat.icon_color || '', feed_enabled: cat.feed_enabled }
});
} catch (e) {
console.error('Update category profile error:', e.message);
res.status(500).json({ error: '操作失败' });
}
});
// 删除分类(仅管理员):FK 约束下必须先删依赖行——事务内删 replies→posts→moderators→category。
// 之前直接 DELETE category 在 foreign_keys=ON 时 FK 报错被 db.run 吞掉,造成"删除成功"假象。
router.delete('/categories/:id', authMiddleware, adminOnly, (req, res) => {
const existing = db.get('SELECT id FROM forum_categories WHERE id = ?', [req.params.id]);
if (!existing) return res.status(404).json({ error: '分类不存在' });
try {
getDb().transaction(() => {
getDb().prepare('DELETE FROM forum_replies WHERE post_id IN (SELECT id FROM forum_posts WHERE category_id = ?)').run(req.params.id);
getDb().prepare('DELETE FROM forum_posts WHERE category_id = ?').run(req.params.id);
getDb().prepare('DELETE FROM forum_moderators WHERE category_id = ?').run(req.params.id);
getDb().prepare('DELETE FROM forum_categories WHERE id = ?').run(req.params.id);
})();
res.json({ message: '删除成功' });
} catch (e) {
console.error('Delete category error:', e.message);
res.status(500).json({ error: '操作失败' });
}
});
// 我管理的版块列表(供版主管理页):返回版块详情数组,admin 返回全部版块
router.get('/moderated', authMiddleware, (req, res) => {
const u = db.get('SELECT role FROM users WHERE id = ?', [req.user.id]);
const isAdmin = u && u.role === 'admin';
const rows = isAdmin
? db.all(`SELECT ${CAT_AGG_SELECT} FROM forum_categories fc ORDER BY fc.sort_order ASC`)
: db.all(`SELECT ${CAT_AGG_SELECT} FROM forum_categories fc
JOIN forum_moderators fm ON fm.category_id = fc.id
WHERE fm.user_id = ? ORDER BY fc.sort_order ASC`, [req.user.id]);
const categories = rows.map(r => ({
id: r.id, name: r.name, icon: r.icon || '', icon_color: r.icon_color || '',
post_count: r.post_count || 0, announcement: r.announcement || '',
moderators: r.moderators || ''
}));
// category_ids 保留兼容旧调用
res.json({ category_ids: categories.map(c => c.id), categories });
});
// ── 禁言 API(均需登录;管理操作仅本版版主/admin)──────────────────
// 检查当前用户是否被禁言(前台发帖提示用)
router.get('/mutes/check', requireGuestVisible, authMiddleware, (req, res) => {
const catId = Number(req.query.category_id);
if (!catId) return res.status(400).json({ error: '缺少 category_id' });
const cat = db.get('SELECT id FROM forum_categories WHERE id = ?', [catId]);
if (!cat) return res.status(404).json({ error: '分类不存在' });
const row = db.get(
`SELECT muted_until FROM forum_mutes
WHERE category_id = ? AND user_id = ? AND (muted_until IS NULL OR muted_until > datetime('now'))`,
[catId, req.user.id]);
if (!row) return res.json({ muted: false });
res.json({ muted: true, permanent: !row.muted_until, muted_until: toIso(row.muted_until) });
});
// 本版禁言列表(只回未过期,顺手清理过期行)
router.get('/mutes', requireGuestVisible, authMiddleware, muteCategoryGuard, (req, res) => {
const catId = req.muteCategoryId;
getDb().prepare("DELETE FROM forum_mutes WHERE category_id = ? AND muted_until IS NOT NULL AND muted_until <= datetime('now')").run(catId);
const rows = db.all(
`SELECT fm.user_id, u.username, u.avatar, u.qq, fm.muted_until, fm.created_at
FROM forum_mutes fm LEFT JOIN users u ON fm.user_id = u.id
WHERE fm.category_id = ? AND (fm.muted_until IS NULL OR fm.muted_until > datetime('now'))
ORDER BY fm.created_at DESC`, [catId]);
res.json(rows.map(r => ({
user_id: r.user_id, username: r.username || '已注销用户',
author_avatar: authorAvatar(r),
permanent: !r.muted_until, muted_until: toIso(r.muted_until), created_at: toIso(r.created_at)
})));
});
// 添加/更新禁言(duration: 1|7|30|'forever';存在则 UPDATE 否则 INSERT
router.put('/mutes', requireGuestVisible, authMiddleware, muteCategoryGuard, (req, res) => {
try {
const catId = req.muteCategoryId;
let uid = Number(req.body.user_id);
// 前端按用户名禁言:user_id 缺失时按 username 解析(版主无 /api/auth/users 权限)
if (!uid && req.body.username) {
const byName = db.get('SELECT id, role FROM users WHERE username = ?', [String(req.body.username).trim()]);
if (!byName) return res.status(404).json({ error: '用户不存在' });
uid = byName.id;
}
if (!uid) return res.status(400).json({ error: '缺少 user_id' });
// 目标用户必须存在
const target = db.get('SELECT id, role FROM users WHERE id = ?', [uid]);
if (!target) return res.status(404).json({ error: '用户不存在' });
// 不能禁言 admin
if (target.role === 'admin') return res.status(400).json({ error: '不能禁言管理员' });
// 不能禁言本版版主(版主互不禁)
if (db.get('SELECT 1 FROM forum_moderators WHERE category_id = ? AND user_id = ?', [catId, uid]))
return res.status(400).json({ error: '不能禁言本版版主' });
// 计算到期时间(muted_until 为 NULL = 永久)
let mutedUntil = null;
const durStr = String(req.body.duration);
if (durStr === 'forever') {
mutedUntil = null;
} else if (durStr === '1' || durStr === '7' || durStr === '30') {
mutedUntil = db.get(`SELECT datetime('now', '+${durStr} day') AS t`).t;
} else {
return res.status(400).json({ error: '无效的禁言时长' });
}
const existing = db.get('SELECT 1 FROM forum_mutes WHERE category_id = ? AND user_id = ?', [catId, uid]);
if (existing) {
db.run('UPDATE forum_mutes SET muted_until = ?, created_by = ? WHERE category_id = ? AND user_id = ?',
[mutedUntil, req.user.id, catId, uid]);
} else {
db.run('INSERT INTO forum_mutes (category_id, user_id, muted_until, created_by) VALUES (?, ?, ?, ?)',
[catId, uid, mutedUntil, req.user.id]);
}
res.json({ message: '已禁言', user_id: uid, permanent: !mutedUntil, muted_until: toIso(mutedUntil) });
} catch (e) {
console.error('Mute user error:', e.message);
res.status(500).json({ error: '操作失败' });
}
});
// 解除禁言
router.delete('/mutes', requireGuestVisible, authMiddleware, muteCategoryGuard, (req, res) => {
try {
const catId = req.muteCategoryId;
const uid = Number(req.body.user_id);
if (!uid) return res.status(400).json({ error: '缺少 user_id' });
const existing = db.get('SELECT 1 FROM forum_mutes WHERE category_id = ? AND user_id = ?', [catId, uid]);
if (!existing) return res.status(404).json({ error: '该用户不在禁言名单中' });
db.run('DELETE FROM forum_mutes WHERE category_id = ? AND user_id = ?', [catId, uid]);
res.json({ message: '已解除禁言', user_id: uid });
} catch (e) {
console.error('Unmute error:', e.message);
res.status(500).json({ error: '操作失败' });
}
});
// 帖子列表:置顶优先;支持分页 / 子版块过滤 / 标题搜索。
// 兼容策略:无 page 参数返回数组(老调用不破坏),带 page 返回 {list,total,page,pageSize,totalPages}
// [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 forum_replies 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:] 附件
lockMetaList.forEach(m => {
if (unlocked.includes(m.index)) m.token = locks.makeLockToken('forum', 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;
}
// 帖子列表:置顶优先;支持分页 / 子版块过滤 / 标题搜索。
// 兼容策略:无 page 参数返回数组(老调用不破坏),带 page 返回 {list,total,page,pageSize,totalPages}
router.get('/posts', requireGuestVisible, optionalAuth, (req, res) => {
const { category_id, sub_category, q } = req.query;
const where = [];
const params = [];
if (category_id) { where.push('fp.category_id = ?'); params.push(category_id); }
if (sub_category) { where.push('fp.sub_category = ?'); params.push(sub_category); }
if (q) { where.push('fp.title LIKE ?'); params.push('%' + q + '%'); }
const whereSql = where.length ? 'WHERE ' + where.join(' AND ') : '';
const baseSql = `SELECT fp.*, 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,
(SELECT COUNT(*) FROM forum_replies WHERE post_id = fp.id) as reply_count
FROM forum_posts fp LEFT JOIN users u ON fp.author_id = u.id
${whereSql}`;
if (req.query.page === undefined) {
const rows = db.all(baseSql + ' ORDER BY fp.is_pinned DESC, fp.created_at DESC', params);
rows.forEach(p => { applyLocks(p, req); attachAuthor(p); });
return res.json(rows);
}
const page = parseInt(req.query.page) || 1;
const pageSize = Math.min(Math.max(parseInt(req.query.pageSize) || 20, 1), 100);
const total = (db.get(`SELECT COUNT(*) as c FROM forum_posts fp ${whereSql}`, params) || {}).c || 0;
const list = db.all(baseSql + ' ORDER BY fp.is_pinned DESC, fp.created_at DESC LIMIT ? OFFSET ?',
[...params, pageSize, (page - 1) * pageSize]);
list.forEach(p => applyLocks(p, req));
// 作者头像(自传 > QQ > RainID
list.forEach(p => { attachAuthor(p); });
res.json({ list, total, page, pageSize, totalPages: Math.ceil(total / pageSize) });
});
// 详情(含 replies):同样受游客可见开关门禁
router.get('/posts/:id', requireGuestVisible, optionalAuth, (req, res) => {
const post = db.get(
`SELECT fp.*, 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,
fc.name as category_name
FROM forum_posts fp LEFT JOIN users u ON fp.author_id = u.id
LEFT JOIN forum_categories fc ON fp.category_id = fc.id
WHERE fp.id = ?`, [req.params.id]);
if (!post) return res.status(404).json({ error: '帖子不存在' });
const replies = db.all(
`SELECT fr.*, 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 forum_replies fr LEFT JOIN users u ON fr.author_id = u.id
WHERE fr.post_id = ? ORDER BY fr.created_at ASC`, [req.params.id]);
// 楼层后端算:楼主=1replies[i].floor=i+2——删除回复后不错位(前端自增序号会错位)
replies.forEach((r, i) => { r.floor = i + 2; });
// [lock:] 真锁:按 viewer 视角剥离,未解锁块内容不进响应
applyLocks(post, req);
// 作者/回复头像(自传 > QQ > RainID
attachAuthor(post);
replies.forEach(r => { attachAuthor(r); });
res.json({ post, replies });
});
// 帖子编辑:作者本人/本版版主/adminauthorOrModeratorGuard 已覆盖"作者"场景),
// 任何时间不设时限。部分更新(title?/content?/sub_category?/use_markdown?),标题/正文必填其一。
// 不校验验证码(编辑非发帖);回复不可编辑(无对应接口)。
router.put('/posts/:id', authMiddleware, authorOrModeratorGuard, (req, res) => {
try {
const updates = {};
// title:非空且 ≤100
if (req.body.title !== undefined) {
const title = String(req.body.title).trim();
if (!title) return res.status(400).json({ error: '标题不能为空' });
if (title.length > 100) return res.status(400).json({ error: '标题不能超过 100 个字符' });
updates.title = title;
}
// content:非空
if (req.body.content !== undefined) {
const content = String(req.body.content || '');
if (!content.trim()) return res.status(400).json({ error: '内容不能为空' });
updates.content = content;
// 内容变更时同步锁定元数据([lock:] 块索引/密码 hash 与正文保持一致)
updates.locks = locks.saveLocks(content);
}
// sub_category:允许空字符串(清空子版块)
if (req.body.sub_category !== undefined) {
updates.sub_category = String(req.body.sub_category || '');
}
// use_markdown0/1
if (req.body.use_markdown !== undefined) {
updates.use_markdown = req.body.use_markdown ? 1 : 0;
}
if (!Object.keys(updates).length) return res.status(400).json({ error: '没有可更新的字段' });
const cols = Object.keys(updates).map(k => k + ' = ?').join(', ');
// 成功编辑:刷新 updated_at + edit_count 自增(前端展示「已更新 x 次」)
db.run(`UPDATE forum_posts SET ${cols}, updated_at = datetime('now'), edit_count = edit_count + 1 WHERE id = ?`,
[...Object.values(updates), req.params.id]);
const post = db.get(
`SELECT fp.*, 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,
fc.name as category_name
FROM forum_posts fp LEFT JOIN users u ON fp.author_id = u.id
LEFT JOIN forum_categories fc ON fp.category_id = fc.id
WHERE fp.id = ?`, [req.params.id]);
if (!post) return res.status(500).json({ error: '更新后读取失败' });
attachAuthor(post);
res.json(post);
} catch (e) {
console.error('Edit post error:', e.message);
res.status(500).json({ error: '操作失败' });
}
});
router.post('/posts', authMiddleware, async (req, res) => {
const { category_id, title, content, use_markdown, sub_category } = req.body;
if (!title || !content) return res.status(400).json({ error: '标题和内容不能为空' });
// 禁言拦截:本版块被禁言期间禁止发帖(admin 豁免)
if (category_id && !assertNotMuted(req, res, category_id)) return;
// 服务端强制验证码校验:内置 proof 或第三方 token 任一通过即可
if (!(await resolveCaptcha(req, 'forum'))) {
return res.status(400).json({ error: '请先完成验证码验证' });
}
const id = db.run(
'INSERT INTO forum_posts (category_id, title, content, author_id, use_markdown, sub_category, locks) VALUES (?, ?, ?, ?, ?, ?, ?)',
[category_id, title, content, req.user.id, use_markdown !== undefined ? (use_markdown ? 1 : 0) : 1, sub_category || '', locks.saveLocks(content)]);
const post = db.get(
`SELECT fp.*, 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 forum_posts fp LEFT JOIN users u ON fp.author_id = u.id
WHERE fp.id = ?`, [id]);
attachAuthor(post);
res.json(post);
});
router.post('/posts/:id/replies', authMiddleware, (req, res) => {
const { content } = req.body;
if (!content) return res.status(400).json({ error: '回复内容不能为空' });
const post = db.get('SELECT id, category_id FROM forum_posts WHERE id = ?', [req.params.id]);
if (!post) return res.status(404).json({ error: '帖子不存在' });
// 禁言拦截:本版块被禁言期间禁止回复(admin 豁免)
if (!assertNotMuted(req, res, post.category_id)) return;
const id = db.run(
'INSERT INTO forum_replies (post_id, content, author_id) VALUES (?, ?, ?)',
[req.params.id, content, req.user.id]);
// 修 bug:回复后应刷新帖子 updated_at(否则版块"最后回复时间"不随回复更新)
db.run("UPDATE forum_posts SET updated_at = datetime('now') WHERE id = ?", [req.params.id]);
const reply = db.get(
`SELECT fr.*, 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 forum_replies fr LEFT JOIN users u ON fr.author_id = u.id WHERE fr.id = ?`, [id]);
attachAuthor(reply);
res.json(reply);
});
// [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, content, locks FROM forum_posts WHERE id = ?', [req.params.id]);
if (!post) 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 hasReplied = !!db.get(
'SELECT 1 x FROM forum_replies 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: hasReplied,
});
if (!result.ok) {
return res.status(result.status).json({ error: result.status === 403 ? '回复后解锁' : '解锁失败' });
}
// 附件真锁:随解锁内容一并签发附件 token(前端据此加载块内附件)
res.json({ ok: true, content: result.inner, lockToken: locks.makeLockToken('forum', post.id, index) });
});
// 置顶 / 取消置顶(admin/版主)
router.put('/posts/:id/pin', authMiddleware, moderatorPostGuard, (req, res) => {
const pinned = req.body.pinned ? 1 : 0;
db.run('UPDATE forum_posts SET is_pinned = ? WHERE id = ?', [pinned, req.params.id]);
res.json({ message: pinned ? '已置顶' : '已取消置顶', is_pinned: pinned });
});
// 加精 / 取消加精(admin/版主)
router.put('/posts/:id/essence', authMiddleware, moderatorPostGuard, (req, res) => {
const essence = req.body.essence ? 1 : 0;
db.run('UPDATE forum_posts SET is_essence = ? WHERE id = ?', [essence, req.params.id]);
res.json({ message: essence ? '已加精' : '已取消加精', is_essence: essence });
});
router.delete('/posts/:id', authMiddleware, authorOrModeratorGuard, (req, res) => {
getDb().transaction(() => {
getDb().prepare('DELETE FROM forum_replies WHERE post_id = ?').run(req.params.id);
getDb().prepare('DELETE FROM forum_posts WHERE id = ?').run(req.params.id);
})();
res.json({ message: '删除成功' });
});
router.delete('/replies/:id', authMiddleware, replyAuthorOrModeratorGuard, (req, res) => {
db.run('DELETE FROM forum_replies WHERE id = ?', [req.params.id]);
res.json({ message: '删除成功' });
});
module.exports = router;