385 lines
22 KiB
JavaScript
385 lines
22 KiB
JavaScript
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, '"').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 ? `<link rel="canonical" href="${escapeHtml(baseUrl)}">` : '';
|
||
// RSS 自发现:extra.rssUrl 传入对应内容源(博客 /feed.xml、论坛 /feed/forum.xml、版块 /feed/forum/c/:id.xml)
|
||
const rssUrl = extra.rssUrl
|
||
? `<link rel="alternate" type="application/rss+xml" title="${escapeHtml(siteName)} RSS" href="${escapeHtml(extra.rssUrl)}">`
|
||
: '';
|
||
const jsonld = extra.jsonld || '';
|
||
// 详情页用 summary_large_image(配 og:image),其余页面 summary
|
||
const twitterCard = extra.twitterCard || 'summary';
|
||
|
||
return `<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>${escapeHtml(title)} - ${escapeHtml(siteName)}</title>
|
||
<meta name="description" content="${ogDesc}">
|
||
<meta name="keywords" content="${escapeHtml(siteName)},${escapeHtml(title)},博客,论坛">
|
||
<meta name="robots" content="index,follow">
|
||
<link rel="icon" href="${escapeHtml(siteFavicon) || 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🌧</text></svg>'}">
|
||
${canonical}
|
||
${rssUrl}
|
||
<!-- Open Graph -->
|
||
<meta property="og:title" content="${ogTitle}">
|
||
<meta property="og:description" content="${ogDesc}">
|
||
<meta property="og:type" content="${escapeHtml(extra.ogType || 'website')}">
|
||
<meta property="og:site_name" content="${escapeHtml(siteName)}">
|
||
${baseUrl ? `<meta property="og:url" content="${escapeHtml(baseUrl)}">` : ''}
|
||
${extra.ogImage ? `<meta property="og:image" content="${escapeHtml(extra.ogImage)}">` : ''}
|
||
<!-- Twitter Card -->
|
||
<meta name="twitter:card" content="${escapeHtml(twitterCard)}">
|
||
<meta name="twitter:title" content="${ogTitle}">
|
||
<meta name="twitter:description" content="${ogDesc}">
|
||
${extra.ogImage ? `<meta name="twitter:image" content="${escapeHtml(extra.ogImage)}">` : ''}
|
||
${jsonld}
|
||
<link rel="stylesheet" href="/css/style.css">
|
||
<style>
|
||
.ssr-content{max-width:720px;margin:0 auto;padding:24px 16px}
|
||
.ssr-content h1{font-size:28px;font-weight:600;margin-bottom:8px;color:var(--md-ref-on-surface)}
|
||
.ssr-content .meta{font-size:14px;color:var(--md-ref-on-surface-variant);margin-bottom:24px;padding-bottom:16px;border-bottom:1px solid var(--md-ref-outline-variant)}
|
||
.ssr-content .body{font-size:16px;line-height:1.8;white-space:pre-wrap;color:var(--md-ref-on-surface)}
|
||
.ssr-content .body h1,.ssr-content .body h2,.ssr-content .body h3{margin:20px 0 10px}
|
||
.ssr-content .body p{margin:10px 0}
|
||
.ssr-content .body img{max-width:100%;border-radius:8px}
|
||
.ssr-nav{display:flex;align-items:center;gap:8px;padding:0 16px;height:56px;background:var(--md-ref-surface-container);border-bottom:1px solid var(--md-ref-outline-variant);position:sticky;top:0;z-index:100}
|
||
.ssr-nav a{color:var(--md-ref-primary);text-decoration:none;font-size:14px;font-weight:500;display:flex;align-items:center;gap:4px}
|
||
.ssr-nav span{color:var(--md-ref-on-surface-variant);font-size:14px;font-weight:500;flex:1}
|
||
@media(prefers-color-scheme:dark){:root{--md-ref-background:#1c1b1f;--md-ref-on-surface:#e6e1e5;--md-ref-on-surface-variant:#cac4d0;--md-ref-surface-container:#25232a;--md-ref-primary:${color};--md-card-bg:#25232a;--md-ref-outline-variant:#49454f;--md-shadow:rgba(0,0,0,0.32)}}
|
||
body{font-family:'Segoe UI',Roboto,sans-serif;background:var(--md-ref-background);color:var(--md-ref-on-surface);margin:0}
|
||
.breadcrumb{display:flex;align-items:center;gap:4px;font-size:13px;color:var(--md-ref-on-surface-variant);margin-bottom:12px;padding:0 16px;padding-top:12px}
|
||
.breadcrumb a{color:var(--md-ref-primary);text-decoration:none}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<nav class="ssr-nav">
|
||
<a href="/"><span class="material-icons" style="font-size:20px">arrow_back</span> 返回</a>
|
||
<span>${escapeHtml(siteName)}</span>
|
||
</nav>
|
||
${extra.breadcrumb || ''}
|
||
<div class="ssr-content">${contentHtml}</div>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
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, '<').replace(/>/g, '>');
|
||
html = html.replace(/\n/g, '<br>');
|
||
}
|
||
|
||
html = html.replace(/\x00IMG(\d+)\x00/g, (m, i) => {
|
||
const fn = images[parseInt(i)];
|
||
return fn ? `<img src="/uploads/${encodeURIComponent(fn)}" alt="" loading="lazy" style="max-width:100%;border-radius:8px;margin:8px 0">` : '';
|
||
});
|
||
html = html.replace(/\x00FILE(\d+)\x00/g, (m, i) => {
|
||
const fn = files[parseInt(i)];
|
||
return fn ? `<a href="/uploads/${encodeURIComponent(fn)}" target="_blank" style="color:#6750a4;text-decoration:underline">📎 ${fn}</a>` : '';
|
||
});
|
||
return html;
|
||
}
|
||
|
||
// [lock:] 锁定占位(SSR 无用户视角:login/reply/password 块全部剥离,未解锁内容不进 HTML)
|
||
function lockPlaceholderDiv(type) {
|
||
const text = type === 'reply' ? '评论后查看' : (type === 'password' ? '密码解锁' : '登录后查看');
|
||
return `<div class="lock-placeholder">🔒 ${text}</div>`;
|
||
}
|
||
|
||
// 作者显示名:昵称优先,空则 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(/</g, '\\u003c');
|
||
const jsonld = JSON.stringify({
|
||
'@context': 'https://schema.org',
|
||
'@type': 'Article',
|
||
headline: post.title,
|
||
image: ogImage,
|
||
author: { '@type': 'Person', name: authorDisplay(post, '管理员') },
|
||
datePublished: post.created_at,
|
||
dateModified: post.updated_at || post.created_at,
|
||
description: excerpt,
|
||
mainEntityOfPage: baseUrl,
|
||
publisher: { '@type': 'Organization', name: siteName }
|
||
}).replace(/</g, '\\u003c');
|
||
const jsonldHtml = `<script type="application/ld+json">${breadcrumbJsonld}</script>
|
||
<script type="application/ld+json">${jsonld}</script>`;
|
||
|
||
// 分享入口:指向 /blog/:id/share → 302 到 SPA 列表页 → 前端客户端导航到完整版详情
|
||
const shareLink = `<div style="margin-bottom:12px"><a href="${baseUrl}/share" style="display:inline-flex;align-items:center;gap:4px;font-size:13px;color:#6750a4;text-decoration:none"><span class="material-icons" style="font-size:16px">open_in_new</span> 在完整版中打开</a></div>`;
|
||
|
||
const html = ssrPage(post.title,
|
||
`<nav class="breadcrumb"><a href="/">首页</a><span>/</span><span>${escapeHtml(post.title)}</span></nav>
|
||
${shareLink}
|
||
<h1>${escapeHtml(post.title)}</h1>
|
||
<div class="meta">${escapeHtml(authorDisplay(post, '管理员'))} · ${escapeHtml(post.created_at)}</div>
|
||
<div class="body">${body}</div>`, 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('<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="robots" content="noindex"><title>论坛不可见</title></head><body style="font-family:sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0"><p>论坛已设为私密,请登录后访问。</p></body></html>');
|
||
}
|
||
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(/</g, '\\u003c');
|
||
const jsonld = JSON.stringify({
|
||
'@context': 'https://schema.org',
|
||
'@type': 'DiscussionForumPosting',
|
||
headline: post.title,
|
||
author: { '@type': 'Person', name: authorDisplay(post, '匿名') },
|
||
datePublished: post.created_at,
|
||
dateModified: post.updated_at || post.created_at,
|
||
interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/CommentAction', userInteractionCount: replies.length }
|
||
}).replace(/</g, '\\u003c');
|
||
const jsonldHtml = `<script type="application/ld+json">${breadcrumbJsonld}</script>
|
||
<script type="application/ld+json">${jsonld}</script>`;
|
||
|
||
const repliesHtml = replies.map(r =>
|
||
`<div style="padding:12px;margin-bottom:8px;background:var(--md-card-bg);border-radius:8px;border:1px solid var(--md-ref-outline-variant)">
|
||
<div style="font-size:13px;color:var(--md-ref-on-surface-variant);margin-bottom:4px">${escapeHtml(authorDisplay(r, '匿名'))} · ${escapeHtml(r.created_at)}</div>
|
||
<div style="font-size:14px;line-height:1.6;white-space:pre-wrap">${escapeHtml(r.content)}</div>
|
||
</div>`
|
||
).join('');
|
||
|
||
// 分享入口:指向 /forum/:id/share → 302 到 SPA 列表页 → 前端客户端导航到完整版详情
|
||
const shareLink = `<div style="margin-bottom:12px"><a href="${baseUrl}/share" style="display:inline-flex;align-items:center;gap:4px;font-size:13px;color:#6750a4;text-decoration:none"><span class="material-icons" style="font-size:16px">open_in_new</span> 在完整版中打开</a></div>`;
|
||
|
||
const html = ssrPage(post.title,
|
||
`<nav class="breadcrumb"><a href="/">首页</a><span>/</span><a href="/forum.html">论坛</a><span>/</span><span>${escapeHtml(post.title)}</span></nav>
|
||
${shareLink}
|
||
<div style="margin-bottom:8px"><span style="font-size:14px;color:var(--md-ref-on-surface-variant)">${escapeHtml(post.category_name || '论坛')}</span></div>
|
||
<h1>${escapeHtml(post.title)}</h1>
|
||
<div class="meta">${escapeHtml(authorDisplay(post, '匿名'))} · ${escapeHtml(post.created_at)}</div>
|
||
<div class="body">${body}</div>
|
||
<h3 style="margin-top:32px;font-weight:500">回复 (${replies.length})</h3>
|
||
${repliesHtml || '<p style="color:var(--md-ref-on-surface-variant)">暂无回复</p>'}`, 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('<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="robots" content="noindex"><title>版块不可见</title></head><body style="font-family:sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0"><p>论坛已设为私密,请登录后访问。</p></body></html>');
|
||
};
|
||
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(/</g, '\\u003c');
|
||
const listJsonld = JSON.stringify({
|
||
'@context': 'https://schema.org',
|
||
'@type': 'CollectionPage',
|
||
name: cat.name,
|
||
url: baseUrl,
|
||
mainEntity: {
|
||
'@type': 'ItemList',
|
||
name: cat.name,
|
||
itemListElement: posts.map((p, i) => ({
|
||
'@type': 'ListItem',
|
||
position: i + 1,
|
||
url: baseDomain + '/forum/' + p.id,
|
||
name: p.title
|
||
}))
|
||
}
|
||
}).replace(/</g, '\\u003c');
|
||
const jsonldHtml = `<script type="application/ld+json">${breadcrumbJsonld}</script>
|
||
<script type="application/ld+json">${listJsonld}</script>`;
|
||
|
||
const moderatorsHtml = moderators.length
|
||
? `<span style="font-size:13px;color:var(--md-ref-on-surface-variant)">版主:${moderators.map(m => escapeHtml(m.username)).join('、')}</span>`
|
||
: '';
|
||
const announcementHtml = cat.announcement
|
||
? `<div style="padding:10px 14px;margin:12px 0;background:var(--md-card-bg);border-radius:8px;border:1px solid var(--md-ref-outline-variant);font-size:14px;color:var(--md-ref-on-surface-variant)"><b>公告</b>:${escapeHtml(cat.announcement)}</div>`
|
||
: '';
|
||
const postsHtml = posts.map(p => {
|
||
const badge = p.is_pinned
|
||
? '<span style="font-size:12px;color:#6750a4;border:1px solid #6750a4;border-radius:4px;padding:0 6px;margin-right:6px">置顶</span>'
|
||
: (p.is_essence
|
||
? '<span style="font-size:12px;color:#e6a23c;border:1px solid #e6a23c;border-radius:4px;padding:0 6px;margin-right:6px">精华</span>'
|
||
: '');
|
||
return `<a href="${baseDomain}/forum/${p.id}" style="display:block;padding:12px;margin-bottom:8px;background:var(--md-card-bg);border-radius:8px;border:1px solid var(--md-ref-outline-variant);text-decoration:none;color:inherit">
|
||
<div style="font-size:15px;font-weight:500">${badge}${escapeHtml(p.title)}</div>
|
||
<div style="font-size:13px;color:var(--md-ref-on-surface-variant);margin-top:4px">${escapeHtml(authorDisplay(p, '匿名'))} · ${escapeHtml(p.created_at)} · ${p.reply_count} 回复</div>
|
||
</a>`;
|
||
}).join('');
|
||
|
||
// 分享入口:指向 /forum/c/:id/share → 302 到 SPA 列表页 → 前端客户端导航到版块页
|
||
const shareLink = `<div style="margin-bottom:12px"><a href="${baseUrl}/share" style="display:inline-flex;align-items:center;gap:4px;font-size:13px;color:#6750a4;text-decoration:none"><span class="material-icons" style="font-size:16px">open_in_new</span> 在完整版中打开</a></div>`;
|
||
|
||
const html = ssrPage(cat.name,
|
||
`<nav class="breadcrumb"><a href="/">首页</a><span>/</span><a href="/forum.html">论坛</a><span>/</span><span>${escapeHtml(cat.name)}</span></nav>
|
||
${shareLink}
|
||
<h1>${escapeHtml(cat.name)}</h1>
|
||
${moderatorsHtml}
|
||
${cat.description ? `<div style="font-size:14px;color:var(--md-ref-on-surface-variant);margin-top:4px">${escapeHtml(cat.description)}</div>` : ''}
|
||
${announcementHtml}
|
||
<h3 style="margin-top:28px;font-weight:500">帖子 (${posts.length})</h3>
|
||
${postsHtml || '<p style="color:var(--md-ref-on-surface-variant)">暂无帖子</p>'}`, 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 忽略 <priority>,不再输出
|
||
let urls = `<url><loc>${baseUrl}/</loc></url>
|
||
<url><loc>${baseUrl}/blog.html</loc></url>
|
||
<url><loc>${baseUrl}/forum.html</loc></url>
|
||
<url><loc>${baseUrl}/archive.html</loc></url>`;
|
||
forumCats.forEach(c => {
|
||
urls += `<url><loc>${baseUrl}/forum/c/${c.id}</loc></url>`;
|
||
});
|
||
blogPosts.forEach(p => {
|
||
urls += `<url><loc>${baseUrl}/blog/${p.id}</loc><lastmod>${toSitemapDate(p.updated_at || p.created_at)}</lastmod></url>`;
|
||
});
|
||
forumPosts.forEach(p => {
|
||
urls += `<url><loc>${baseUrl}/forum/${p.id}</loc><lastmod>${toSitemapDate(p.updated_at || p.created_at)}</lastmod></url>`;
|
||
});
|
||
|
||
res.header('Content-Type', 'application/xml');
|
||
res.send(`<?xml version="1.0" encoding="UTF-8"?>
|
||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>`);
|
||
}
|
||
|
||
module.exports = { blogSSR, forumSSR, categorySSR, sitemapXml };
|