/** * 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;