/**
* RSS / Atom feeds + sitemap.xml
*
* GET /feed.xml -> RSS 2.0
* GET /atom.xml -> Atom 1.0
* GET /sitemap.xml -> XML sitemap (only if site.robots_index is on)
*
* All are site-scoped (using res.locals.site) and only include
* status='published' posts.
*/
import express from 'express';
import db from '../config/database.js';
const router = express.Router();
function escapeXml(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function siteOrigin(req) {
const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
const host = req.headers['x-forwarded-host'] || req.get('host');
return `${proto}://${host}`;
}
function postsForFeed(siteId, limit = 30) {
return db.prepare(`
SELECT p.id, p.slug, p.title, p.excerpt, p.content, p.published_at, p.updated_at,
u.username AS author_username, u.email AS author_email
FROM posts p JOIN users u ON u.id = p.author_id
WHERE p.site_id = ? AND p.status = 'published'
ORDER BY p.published_at DESC
LIMIT ?
`).all(siteId, limit);
}
// ==================== RSS 2.0 ====================
router.get('/feed.xml', (req, res) => {
const site = res.locals.site;
if (!site) return res.status(404).send('No site');
const origin = siteOrigin(req);
const base = origin + (res.locals.siteUrlBase || '');
const posts = postsForFeed(site.id);
const lastBuild = posts[0]?.published_at || new Date().toISOString();
res.set('Content-Type', 'application/rss+xml; charset=utf-8');
res.send(`
${escapeXml(site.title)}
${escapeXml(base + '/')}
${escapeXml(site.description || site.tagline || '')}
${escapeXml(site.language || 'nl')}
${new Date(lastBuild).toUTCString()}
${posts.map(p => ` -
${escapeXml(p.title || '(untitled)')}
${escapeXml(base + '/' + p.slug)}
${escapeXml(base + '/' + p.slug)}
${new Date(p.published_at).toUTCString()}
${escapeXml((p.author_email || 'noreply@localhost') + ' (' + p.author_username + ')')}
${escapeXml(p.excerpt || '')}
`).join('\n')}
`);
});
// ==================== Atom 1.0 ====================
router.get('/atom.xml', (req, res) => {
const site = res.locals.site;
if (!site) return res.status(404).send('No site');
const origin = siteOrigin(req);
const base = origin + (res.locals.siteUrlBase || '');
const posts = postsForFeed(site.id);
const updated = posts[0]?.updated_at || posts[0]?.published_at || new Date().toISOString();
res.set('Content-Type', 'application/atom+xml; charset=utf-8');
res.send(`
${escapeXml(site.title)}
${escapeXml(base + '/')}
${new Date(updated).toISOString()}
${escapeXml(site.description || site.tagline || '')}
${posts.map(p => `
${escapeXml(p.title || '(untitled)')}
${escapeXml(base + '/' + p.slug)}
${new Date(p.updated_at || p.published_at).toISOString()}
${new Date(p.published_at).toISOString()}
${escapeXml(p.author_username)}
${escapeXml(p.excerpt || '')}
`).join('\n')}
`);
});
// ==================== Sitemap.xml ====================
router.get('/sitemap.xml', (req, res) => {
const site = res.locals.site;
if (!site) return res.status(404).send('No site');
// Honour the per-site robots_index flag.
if (site.robots_index === 0) {
res.set('X-Robots-Tag', 'noindex');
return res.status(404).send('No sitemap');
}
const origin = siteOrigin(req);
const base = origin + (res.locals.siteUrlBase || '');
const posts = db.prepare(`
SELECT slug, COALESCE(updated_at, published_at) AS lastmod
FROM posts
WHERE site_id = ? AND status = 'published'
ORDER BY lastmod DESC
LIMIT 1000
`).all(site.id);
const urls = [
{ loc: base + '/', lastmod: posts[0]?.lastmod, priority: '1.0' },
{ loc: base + '/archive', priority: '0.7' },
...posts.map(p => ({
loc: base + '/' + p.slug,
lastmod: p.lastmod,
priority: '0.8',
})),
];
res.set('Content-Type', 'application/xml; charset=utf-8');
res.send(`
${urls.map(u => `
${escapeXml(u.loc)}${u.lastmod ? `\n ${new Date(u.lastmod).toISOString().slice(0,10)}` : ''}
${u.priority}
`).join('\n')}
`);
});
export default router;