const express = require('express'); const jwt = require('jsonwebtoken'); const db = require('../db'); const { SECRET } = require('../middleware/auth'); const { resolveAvatar } = require('../lib/avatar'); const router = express.Router(); // 可选鉴权:有效 token 解析出 req.user(用于论坛私密模式门禁判定是否登录),匿名放行 function optionalAuth(req, res, next) { const header = req.headers.authorization; if (header && header.startsWith('Bearer ')) { try { req.user = jwt.verify(header.slice(7), SECRET); } catch { /* 无效 token 按匿名 */ } } next(); } // 论坛私密模式门禁:forum_guest_visible='0' 且请求未登录 → 403 //(与 routes/forum.js requireGuestVisible 同策略,公开主页内容流同样受私密开关约束) function requireForumVisible(req, res, next) { if (db.getSetting('forum_guest_visible') === '1' || req.user) return next(); return res.status(403).json({ error: '论坛已设为私密' }); } // 分页参数解析:page≥1 整数、pageSize 1-50,非法用默认 1/10 function parsePage(query) { const page = parseInt(query.page); const pageSize = parseInt(query.pageSize); return { page: Number.isInteger(page) && page >= 1 ? page : 1, pageSize: Number.isInteger(pageSize) && pageSize >= 1 && pageSize <= 50 ? pageSize : 10, }; } // 列表内容净化:剥 [image:]/[file:] 标签与 [lock:] 块(防止锁块残留/锁定内容泄露),再截断 function sanitizeContent(c, maxLen) { let s = String(c || '') .replace(/\[(image|file):[^\]]*\]/g, '') .replace(/\[lock(?::[^\]]*)?\][\s\S]*?\[\/lock\]/g, '') .trim(); if (maxLen && s.length > maxLen) s = s.slice(0, maxLen) + '…'; return s; } // 公开个人主页:身份信息 + 统计(绝不返回 email/rainid_user_id/password) // GET /api/users/:id router.get('/:id', (req, res) => { const uid = parseInt(req.params.id); if (!Number.isInteger(uid) || uid < 1) return res.status(404).json({ error: '用户不存在' }); const user = db.get( `SELECT id, username, role, avatar, bio, nickname, title, title_color, website, qq, CASE WHEN qq IS NOT NULL AND trim(qq) <> '' THEN '' WHEN email GLOB '[0-9]*@qq.com' THEN substr(email, 1, instr(email, '@') - 1) ELSE '' END AS qq_from_email, created_at, last_active_at FROM users WHERE id = ?`, [uid]); if (!user) return res.status(404).json({ error: '用户不存在' }); // 头像按优先级解析(自传 > QQ > qq_from_email > email 前缀 > RainID),兼容无 avatar 用户 user.avatar = resolveAvatar(user); // 展示名:昵称优先,空则 username;QQ 号脱敏(前3后4,如 273****3776),空则省略 user.display_name = String(user.nickname || '').trim() || user.username || ''; if (user.qq && /^\d{5,12}$/.test(user.qq)) { user.qq = user.qq.slice(0, 3) + '****' + user.qq.slice(-4); } else { delete user.qq; // 空则省略字段 } delete user.qq_from_email; // 内部辅助列不外露 // 统计:论坛帖子 / 论坛回复 / 已发布博文 / 帖子获得的赞 // 注:post_likes 目前只记录博文点赞(blog.js /posts/:id/like),故按 blog_posts 聚合; // post_id 与 forum_posts.id 存在冲突,不能 JOIN forum_posts(会错配作者)。 user.stats = { posts: (db.get('SELECT COUNT(*) c FROM forum_posts WHERE author_id = ?', [uid]) || {}).c || 0, replies: (db.get('SELECT COUNT(*) c FROM forum_replies WHERE author_id = ?', [uid]) || {}).c || 0, articles: (db.get('SELECT COUNT(*) c FROM blog_posts WHERE author_id = ? AND published = 1', [uid]) || {}).c || 0, likes: (db.get('SELECT COUNT(*) c FROM post_likes l JOIN blog_posts bp ON bp.id = l.post_id WHERE bp.author_id = ?', [uid]) || {}).c || 0, }; res.json(user); }); // 公开个人主页:论坛帖子流(标题/分类/回复数,不含正文,无锁泄露风险) // GET /api/users/:id/posts?page=1&pageSize=10 router.get('/:id/posts', optionalAuth, requireForumVisible, (req, res) => { const uid = parseInt(req.params.id); if (!Number.isInteger(uid) || uid < 1) return res.status(404).json({ error: '用户不存在' }); if (!db.get('SELECT id FROM users WHERE id = ?', [uid])) return res.status(404).json({ error: '用户不存在' }); const { page, pageSize } = parsePage(req.query); const total = (db.get('SELECT COUNT(*) c FROM forum_posts WHERE author_id = ?', [uid]) || {}).c || 0; const list = db.all( `SELECT fp.id, fp.title, fp.category_id, fc.name as category_name, fp.created_at, fp.is_pinned, (SELECT COUNT(*) FROM forum_replies WHERE post_id = fp.id) as reply_count FROM forum_posts fp LEFT JOIN forum_categories fc ON fp.category_id = fc.id WHERE fp.author_id = ? ORDER BY fp.created_at DESC, fp.id DESC LIMIT ? OFFSET ?`, [uid, pageSize, (page - 1) * pageSize]); res.json({ list, total, page, pageSize, totalPages: Math.ceil(total / pageSize) }); }); // 公开个人主页:论坛回复流(content 净化 + 截断 120 字,附帖子标题) // GET /api/users/:id/replies?page=1&pageSize=10 router.get('/:id/replies', optionalAuth, requireForumVisible, (req, res) => { const uid = parseInt(req.params.id); if (!Number.isInteger(uid) || uid < 1) return res.status(404).json({ error: '用户不存在' }); if (!db.get('SELECT id FROM users WHERE id = ?', [uid])) return res.status(404).json({ error: '用户不存在' }); const { page, pageSize } = parsePage(req.query); const total = (db.get('SELECT COUNT(*) c FROM forum_replies WHERE author_id = ?', [uid]) || {}).c || 0; const list = db.all( `SELECT fr.id, fr.content, fr.post_id, fp.title as post_title, fp.category_id, fr.created_at FROM forum_replies fr LEFT JOIN forum_posts fp ON fr.post_id = fp.id WHERE fr.author_id = ? ORDER BY fr.created_at DESC, fr.id DESC LIMIT ? OFFSET ?`, [uid, pageSize, (page - 1) * pageSize]); // 先净化(剥附件/锁块)再截断,避免锁块内容残留泄露 list.forEach(r => { r.content = sanitizeContent(r.content, 120); }); res.json({ list, total, page, pageSize, totalPages: Math.ceil(total / pageSize) }); }); module.exports = router;