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 来自 query(GET)或 body(PUT/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 /categories(admin 全量):防版主越权改 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:≤100,emoji 或 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:空字符串清空,或 #hex(3/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]); // 楼层后端算:楼主=1,replies[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 }); }); // 帖子编辑:作者本人/本版版主/admin(authorOrModeratorGuard 已覆盖"作者"场景), // 任何时间不设时限。部分更新(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_markdown:0/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;