Files
rainblogweb/ssr.js
T

385 lines
22 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 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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, '&lt;').replace(/>/g, '&gt;');
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);
}
// 论坛版块页 SSRP5):/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(/\/$/, '');
// lastmodupdated_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 };