Files
rainblogweb/routes/users.js
T

116 lines
6.1 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 { 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;