const db = require('./db'); const marked = require('marked'); const createDOMPurify = require('dompurify'); const { JSDOM } = require('jsdom'); const DOMPurify = createDOMPurify(new JSDOM('').window); const locks = require('./lib/locks'); function escapeHtml(s) { return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } function ssrPage(title, contentHtml, metaDesc, extra = {}) { const siteName = db.getSetting('site_name') || 'RainWeb'; const siteDesc = db.getSetting('site_description') || '个人云管理平台'; const siteFavicon = db.getSetting('site_favicon') || ''; const color = db.getSetting('primary_color') || '#6750a4'; const baseUrl = extra.url || ''; // og:title 允许详情页覆盖(去「- 站点名」后缀,防社交卡片标题被截断) const ogTitle = escapeHtml(extra.ogTitle || (title + ' - ' + siteName)); const ogDesc = escapeHtml(metaDesc || siteDesc); const canonical = baseUrl ? `` : ''; // RSS 自发现:extra.rssUrl 传入对应内容源(博客 /feed.xml、论坛 /feed/forum.xml、版块 /feed/forum/c/:id.xml) const rssUrl = extra.rssUrl ? `` : ''; const jsonld = extra.jsonld || ''; // 详情页用 summary_large_image(配 og:image),其余页面 summary const twitterCard = extra.twitterCard || 'summary'; return ` ${escapeHtml(title)} - ${escapeHtml(siteName)} ${canonical} ${rssUrl} ${baseUrl ? `` : ''} ${extra.ogImage ? `` : ''} ${extra.ogImage ? `` : ''} ${jsonld} ${extra.breadcrumb || ''}
${contentHtml}
`; } function renderSSR(content, useMarkdown) { // Extract custom tags before markdown, restore after const images = []; const files = []; let html = String(content).replace(/\[image:([^\]]+)\]/g, (m, f) => { images.push(f); return '\x00IMG' + (images.length - 1) + '\x00'; }); html = html.replace(/\[file:([^\]]+)\]/g, (m, f) => { files.push(f); return '\x00FILE' + (files.length - 1) + '\x00'; }); if (useMarkdown) { html = marked.parse(html, { breaks: true, gfm: true }); html = DOMPurify.sanitize(html); } else { html = html.replace(//g, '>'); html = html.replace(/\n/g, '
'); } html = html.replace(/\x00IMG(\d+)\x00/g, (m, i) => { const fn = images[parseInt(i)]; return fn ? `` : ''; }); html = html.replace(/\x00FILE(\d+)\x00/g, (m, i) => { const fn = files[parseInt(i)]; return fn ? `📎 ${fn}` : ''; }); return html; } // [lock:] 锁定占位(SSR 无用户视角:login/reply/password 块全部剥离,未解锁内容不进 HTML) function lockPlaceholderDiv(type) { const text = type === 'reply' ? '评论后查看' : (type === 'password' ? '密码解锁' : '登录后查看'); return `
🔒 ${text}
`; } // 作者显示名:昵称优先,空则 username(fallback 为匿名/管理员兜底) function authorDisplay(row, fallback) { const nick = String(row && row.nickname || '').trim(); return nick || (row && row.author_name) || fallback || ''; } // stripLocks → renderSSR → 占位符替换(渲染后替换保证 div 不被转义/净化) function renderLocksAware(content, useMarkdown) { const { stripped } = locks.stripLocks(content, { maskPasswords: true, viewer: {} }); const typeByIndex = {}; locks.parseLocks(content).blocks.forEach(b => { typeByIndex[b.index] = b.type; }); const html = renderSSR(stripped, useMarkdown); return html.replace(/@@LOCK(\d+)@@/g, (m, i) => lockPlaceholderDiv(typeByIndex[parseInt(i)] || 'login')); } function blogSSR(req, res) { const post = db.get('SELECT bp.*, u.username as author_name, u.nickname FROM blog_posts bp LEFT JOIN users u ON bp.author_id = u.id WHERE bp.id = ?', [req.params.id]); if (!post || !post.published) return res.status(404).send('文章不存在'); // 阅读量 +1(SEO 页访问也计入) db.run('UPDATE blog_posts SET views = views + 1 WHERE id = ?', [post.id]); // [lock:] 真锁:SSR 无用户 → 全剥离;摘要/首图用剥离后文本(防锁定内容进 meta/OG) const locked = locks.stripLocks(post.content, { maskPasswords: true, viewer: {} }); const visibleContent = locked.stripped; const body = renderLocksAware(post.content, post.use_markdown); // 摘要用剥离后文本且剔除 @@LOCK 占位符(防锁定占位进 meta description/OG) const excerpt = post.excerpt || visibleContent.slice(0, 150).replace(/@@LOCK\d+@@/g, ''); const siteName = db.getSetting('site_name') || 'RainWeb'; const siteUrl = db.getSetting('site_url') || (req.protocol + '://' + req.get('host')); const baseDomain = siteUrl.replace(/\/$/, ''); const baseUrl = baseDomain + '/blog/' + post.id; // 首图:正文 [image:...] 标签优先(锁定块内图片不入选),无则默认 OG 图(Article.image 必填) const firstImage = (visibleContent.match(/\[image:([^\]]+)\]/) || [])[1]; const ogImage = firstImage ? baseDomain + '/uploads/' + encodeURIComponent(firstImage) : baseDomain + '/og-default.png'; // 面包屑 schema(与页面可见面包屑「首页 / 标题」对应) const breadcrumbJsonld = JSON.stringify({ '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: [ { '@type': 'ListItem', position: 1, name: '首页', item: baseDomain + '/' }, { '@type': 'ListItem', position: 2, name: post.title, item: baseUrl } ] }).replace(/${breadcrumbJsonld} `; // 分享入口:指向 /blog/:id/share → 302 到 SPA 列表页 → 前端客户端导航到完整版详情 const shareLink = `
open_in_new 在完整版中打开
`; const html = ssrPage(post.title, ` ${shareLink}

${escapeHtml(post.title)}

${escapeHtml(authorDisplay(post, '管理员'))} · ${escapeHtml(post.created_at)}
${body}
`, excerpt, { url: baseUrl, ogType: 'article', ogImage, ogTitle: post.title, twitterCard: 'summary_large_image', jsonld: jsonldHtml, breadcrumb: '', rssUrl: baseDomain + '/feed.xml' }); res.send(html); } function forumSSR(req, res) { // 论坛游客可见开关:私密模式(forum_guest_visible!=='1')下 SSR 详情返回 404, // 页面不对外索引(SEO 权衡:私密论坛内容不应进搜索引擎)。 if (db.getSetting('forum_guest_visible') !== '1') { res.status(404); return res.send('论坛不可见

论坛已设为私密,请登录后访问。

'); } const post = db.get( `SELECT fp.*, u.username as author_name, u.nickname, 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).send('帖子不存在'); const replies = db.all( `SELECT fr.*, u.username as author_name, u.nickname 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]); const body = renderLocksAware(post.content, post.use_markdown); const siteName = db.getSetting('site_name') || 'RainWeb'; const siteUrl = db.getSetting('site_url') || (req.protocol + '://' + req.get('host')); const baseDomain = siteUrl.replace(/\/$/, ''); const baseUrl = baseDomain + '/forum/' + post.id; const ogImage = baseDomain + '/og-default.png'; // 面包屑 schema(与页面可见面包屑「首页 / 论坛 / 标题」对应) const breadcrumbJsonld = JSON.stringify({ '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: [ { '@type': 'ListItem', position: 1, name: '首页', item: baseDomain + '/' }, { '@type': 'ListItem', position: 2, name: '论坛', item: baseDomain + '/forum.html' }, { '@type': 'ListItem', position: 3, name: post.title, item: baseUrl } ] }).replace(/${breadcrumbJsonld} `; const repliesHtml = replies.map(r => `
${escapeHtml(authorDisplay(r, '匿名'))} · ${escapeHtml(r.created_at)}
${escapeHtml(r.content)}
` ).join(''); // 分享入口:指向 /forum/:id/share → 302 到 SPA 列表页 → 前端客户端导航到完整版详情 const shareLink = `
open_in_new 在完整版中打开
`; const html = ssrPage(post.title, ` ${shareLink}
${escapeHtml(post.category_name || '论坛')}

${escapeHtml(post.title)}

${escapeHtml(authorDisplay(post, '匿名'))} · ${escapeHtml(post.created_at)}
${body}

回复 (${replies.length})

${repliesHtml || '

暂无回复

'}`, post.title, { url: baseUrl, ogType: 'article', ogImage, ogTitle: post.title, twitterCard: 'summary_large_image', jsonld: jsonldHtml, breadcrumb: '', rssUrl: baseDomain + '/feed/forum.xml' }); res.send(html); } // 论坛版块页 SSR(P5):/forum/c/:id——版块名/描述/公告 + 首屏帖子 + 面包屑 JSON-LD。 // 私密模式(forum_guest_visible!=='1')返回 404 + noindex(不对外索引)。 function categorySSR(req, res) { const privateNotice = () => { res.status(404); return res.send('版块不可见

论坛已设为私密,请登录后访问。

'); }; if (db.getSetting('forum_guest_visible') !== '1') return privateNotice(); const cat = db.get('SELECT * FROM forum_categories WHERE id = ?', [req.params.id]); if (!cat) return res.status(404).send('版块不存在'); const posts = db.all( `SELECT fp.*, u.username as author_name, u.nickname, (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 WHERE fp.category_id = ? ORDER BY fp.is_pinned DESC, fp.created_at DESC LIMIT 20`, [cat.id]); const moderators = db.all( 'SELECT u.username FROM forum_moderators fm LEFT JOIN users u ON fm.user_id = u.id WHERE fm.category_id = ?', [cat.id]); const siteName = db.getSetting('site_name') || 'RainWeb'; const siteUrl = db.getSetting('site_url') || (req.protocol + '://' + req.get('host')); const baseDomain = siteUrl.replace(/\/$/, ''); const baseUrl = baseDomain + '/forum/c/' + cat.id; const ogImage = baseDomain + '/og-default.png'; // 面包屑 schema(首页 / 论坛 / 版块)+ 版块 CollectionPage/ItemList const breadcrumbJsonld = JSON.stringify({ '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: [ { '@type': 'ListItem', position: 1, name: '首页', item: baseDomain + '/' }, { '@type': 'ListItem', position: 2, name: '论坛', item: baseDomain + '/forum.html' }, { '@type': 'ListItem', position: 3, name: cat.name, item: baseUrl } ] }).replace(/ ({ '@type': 'ListItem', position: i + 1, url: baseDomain + '/forum/' + p.id, name: p.title })) } }).replace(/${breadcrumbJsonld} `; const moderatorsHtml = moderators.length ? `版主:${moderators.map(m => escapeHtml(m.username)).join('、')}` : ''; const announcementHtml = cat.announcement ? `
公告:${escapeHtml(cat.announcement)}
` : ''; const postsHtml = posts.map(p => { const badge = p.is_pinned ? '置顶' : (p.is_essence ? '精华' : ''); return `
${badge}${escapeHtml(p.title)}
${escapeHtml(authorDisplay(p, '匿名'))} · ${escapeHtml(p.created_at)} · ${p.reply_count} 回复
`; }).join(''); // 分享入口:指向 /forum/c/:id/share → 302 到 SPA 列表页 → 前端客户端导航到版块页 const shareLink = `
open_in_new 在完整版中打开
`; const html = ssrPage(cat.name, ` ${shareLink}

${escapeHtml(cat.name)}

${moderatorsHtml} ${cat.description ? `
${escapeHtml(cat.description)}
` : ''} ${announcementHtml}

帖子 (${posts.length})

${postsHtml || '

暂无帖子

'}`, cat.description || cat.name, { url: baseUrl, ogType: 'website', ogImage, ogTitle: cat.name, jsonld: jsonldHtml, breadcrumb: '', rssUrl: baseDomain + '/feed/forum/c/' + cat.id + '.xml' }); res.send(html); } // lastmod 规范化:'YYYY-MM-DD HH:MM:SS'(UTC)→ 'YYYY-MM-DD'(ISO 8601 日期,sitemap 协议要求) function toSitemapDate(ts) { const s = String(ts || ''); return s.slice(0, 10); } function sitemapXml(req, res) { const siteUrl = db.getSetting('site_url') || (req.protocol + '://' + req.get('host')); const baseUrl = siteUrl.replace(/\/$/, ''); // lastmod:updated_at 优先,无则 created_at(均格式化为 YYYY-MM-DD) const blogPosts = db.all('SELECT id, created_at, updated_at, title FROM blog_posts WHERE published = 1 ORDER BY created_at DESC'); // 论坛私密模式下不从 sitemap 输出论坛帖子/版块 URL(避免收录后 404) const forumVisible = db.getSetting('forum_guest_visible') === '1'; const forumPosts = forumVisible ? db.all('SELECT id, created_at, updated_at FROM forum_posts ORDER BY created_at DESC') : []; // forum_categories 无 created_at 列;版块 URL 无 lastmod(最近有帖子时帖子的 lastmod 已足够) const forumCats = forumVisible ? db.all('SELECT id FROM forum_categories ORDER BY sort_order ASC') : []; // 核心页:首页 + 博客列表 + 论坛列表 + 归档(无分页 URL);Google 忽略 ,不再输出 let urls = `${baseUrl}/ ${baseUrl}/blog.html ${baseUrl}/forum.html ${baseUrl}/archive.html`; forumCats.forEach(c => { urls += `${baseUrl}/forum/c/${c.id}`; }); blogPosts.forEach(p => { urls += `${baseUrl}/blog/${p.id}${toSitemapDate(p.updated_at || p.created_at)}`; }); forumPosts.forEach(p => { urls += `${baseUrl}/forum/${p.id}${toSitemapDate(p.updated_at || p.created_at)}`; }); res.header('Content-Type', 'application/xml'); res.send(` ${urls}`); } module.exports = { blogSSR, forumSSR, categorySSR, sitemapXml };