647 lines
35 KiB
JavaScript
647 lines
35 KiB
JavaScript
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;
|