| 1 | /**
|
|---|
| 2 | * RSS / Atom feeds + sitemap.xml
|
|---|
| 3 | *
|
|---|
| 4 | * GET /feed.xml -> RSS 2.0
|
|---|
| 5 | * GET /atom.xml -> Atom 1.0
|
|---|
| 6 | * GET /sitemap.xml -> XML sitemap (only if site.robots_index is on)
|
|---|
| 7 | *
|
|---|
| 8 | * All are site-scoped (using res.locals.site) and only include
|
|---|
| 9 | * status='published' posts.
|
|---|
| 10 | */
|
|---|
| 11 |
|
|---|
| 12 | import express from 'express';
|
|---|
| 13 | import db from '../config/database.js';
|
|---|
| 14 |
|
|---|
| 15 | const router = express.Router();
|
|---|
| 16 |
|
|---|
| 17 | function escapeXml(s) {
|
|---|
| 18 | if (s == null) return '';
|
|---|
| 19 | return String(s)
|
|---|
| 20 | .replace(/&/g, '&')
|
|---|
| 21 | .replace(/</g, '<')
|
|---|
| 22 | .replace(/>/g, '>')
|
|---|
| 23 | .replace(/"/g, '"')
|
|---|
| 24 | .replace(/'/g, ''');
|
|---|
| 25 | }
|
|---|
| 26 |
|
|---|
| 27 | function siteOrigin(req) {
|
|---|
| 28 | const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
|
|---|
| 29 | const host = req.headers['x-forwarded-host'] || req.get('host');
|
|---|
| 30 | return `${proto}://${host}`;
|
|---|
| 31 | }
|
|---|
| 32 |
|
|---|
| 33 | function postsForFeed(siteId, limit = 30) {
|
|---|
| 34 | return db.prepare(`
|
|---|
| 35 | SELECT p.id, p.slug, p.title, p.excerpt, p.content, p.published_at, p.updated_at,
|
|---|
| 36 | u.username AS author_username, u.email AS author_email
|
|---|
| 37 | FROM posts p JOIN users u ON u.id = p.author_id
|
|---|
| 38 | WHERE p.site_id = ? AND p.status = 'published'
|
|---|
| 39 | ORDER BY p.published_at DESC
|
|---|
| 40 | LIMIT ?
|
|---|
| 41 | `).all(siteId, limit);
|
|---|
| 42 | }
|
|---|
| 43 |
|
|---|
| 44 | // ==================== RSS 2.0 ====================
|
|---|
| 45 | router.get('/feed.xml', (req, res) => {
|
|---|
| 46 | const site = res.locals.site;
|
|---|
| 47 | if (!site) return res.status(404).send('No site');
|
|---|
| 48 |
|
|---|
| 49 | const origin = siteOrigin(req);
|
|---|
| 50 | const base = origin + (res.locals.siteUrlBase || '');
|
|---|
| 51 | const posts = postsForFeed(site.id);
|
|---|
| 52 | const lastBuild = posts[0]?.published_at || new Date().toISOString();
|
|---|
| 53 |
|
|---|
| 54 | res.set('Content-Type', 'application/rss+xml; charset=utf-8');
|
|---|
| 55 | res.send(`<?xml version="1.0" encoding="UTF-8"?>
|
|---|
| 56 | <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
|---|
| 57 | <channel>
|
|---|
| 58 | <title>${escapeXml(site.title)}</title>
|
|---|
| 59 | <link>${escapeXml(base + '/')}</link>
|
|---|
| 60 | <description>${escapeXml(site.description || site.tagline || '')}</description>
|
|---|
| 61 | <language>${escapeXml(site.language || 'nl')}</language>
|
|---|
| 62 | <lastBuildDate>${new Date(lastBuild).toUTCString()}</lastBuildDate>
|
|---|
| 63 | <atom:link href="${escapeXml(base + '/feed.xml')}" rel="self" type="application/rss+xml" />
|
|---|
| 64 | ${posts.map(p => ` <item>
|
|---|
| 65 | <title>${escapeXml(p.title || '(untitled)')}</title>
|
|---|
| 66 | <link>${escapeXml(base + '/' + p.slug)}</link>
|
|---|
| 67 | <guid isPermaLink="true">${escapeXml(base + '/' + p.slug)}</guid>
|
|---|
| 68 | <pubDate>${new Date(p.published_at).toUTCString()}</pubDate>
|
|---|
| 69 | <author>${escapeXml((p.author_email || 'noreply@localhost') + ' (' + p.author_username + ')')}</author>
|
|---|
| 70 | <description>${escapeXml(p.excerpt || '')}</description>
|
|---|
| 71 | </item>`).join('\n')}
|
|---|
| 72 | </channel>
|
|---|
| 73 | </rss>`);
|
|---|
| 74 | });
|
|---|
| 75 |
|
|---|
| 76 | // ==================== Atom 1.0 ====================
|
|---|
| 77 | router.get('/atom.xml', (req, res) => {
|
|---|
| 78 | const site = res.locals.site;
|
|---|
| 79 | if (!site) return res.status(404).send('No site');
|
|---|
| 80 |
|
|---|
| 81 | const origin = siteOrigin(req);
|
|---|
| 82 | const base = origin + (res.locals.siteUrlBase || '');
|
|---|
| 83 | const posts = postsForFeed(site.id);
|
|---|
| 84 | const updated = posts[0]?.updated_at || posts[0]?.published_at || new Date().toISOString();
|
|---|
| 85 |
|
|---|
| 86 | res.set('Content-Type', 'application/atom+xml; charset=utf-8');
|
|---|
| 87 | res.send(`<?xml version="1.0" encoding="UTF-8"?>
|
|---|
| 88 | <feed xmlns="http://www.w3.org/2005/Atom">
|
|---|
| 89 | <title>${escapeXml(site.title)}</title>
|
|---|
| 90 | <link href="${escapeXml(base + '/')}" />
|
|---|
| 91 | <link href="${escapeXml(base + '/atom.xml')}" rel="self" />
|
|---|
| 92 | <id>${escapeXml(base + '/')}</id>
|
|---|
| 93 | <updated>${new Date(updated).toISOString()}</updated>
|
|---|
| 94 | <subtitle>${escapeXml(site.description || site.tagline || '')}</subtitle>
|
|---|
| 95 | ${posts.map(p => ` <entry>
|
|---|
| 96 | <title>${escapeXml(p.title || '(untitled)')}</title>
|
|---|
| 97 | <link href="${escapeXml(base + '/' + p.slug)}" />
|
|---|
| 98 | <id>${escapeXml(base + '/' + p.slug)}</id>
|
|---|
| 99 | <updated>${new Date(p.updated_at || p.published_at).toISOString()}</updated>
|
|---|
| 100 | <published>${new Date(p.published_at).toISOString()}</published>
|
|---|
| 101 | <author><name>${escapeXml(p.author_username)}</name></author>
|
|---|
| 102 | <summary>${escapeXml(p.excerpt || '')}</summary>
|
|---|
| 103 | </entry>`).join('\n')}
|
|---|
| 104 | </feed>`);
|
|---|
| 105 | });
|
|---|
| 106 |
|
|---|
| 107 | // ==================== Sitemap.xml ====================
|
|---|
| 108 | router.get('/sitemap.xml', (req, res) => {
|
|---|
| 109 | const site = res.locals.site;
|
|---|
| 110 | if (!site) return res.status(404).send('No site');
|
|---|
| 111 |
|
|---|
| 112 | // Honour the per-site robots_index flag.
|
|---|
| 113 | if (site.robots_index === 0) {
|
|---|
| 114 | res.set('X-Robots-Tag', 'noindex');
|
|---|
| 115 | return res.status(404).send('No sitemap');
|
|---|
| 116 | }
|
|---|
| 117 |
|
|---|
| 118 | const origin = siteOrigin(req);
|
|---|
| 119 | const base = origin + (res.locals.siteUrlBase || '');
|
|---|
| 120 | const posts = db.prepare(`
|
|---|
| 121 | SELECT slug, COALESCE(updated_at, published_at) AS lastmod
|
|---|
| 122 | FROM posts
|
|---|
| 123 | WHERE site_id = ? AND status = 'published'
|
|---|
| 124 | ORDER BY lastmod DESC
|
|---|
| 125 | LIMIT 1000
|
|---|
| 126 | `).all(site.id);
|
|---|
| 127 |
|
|---|
| 128 | const urls = [
|
|---|
| 129 | { loc: base + '/', lastmod: posts[0]?.lastmod, priority: '1.0' },
|
|---|
| 130 | { loc: base + '/archive', priority: '0.7' },
|
|---|
| 131 | ...posts.map(p => ({
|
|---|
| 132 | loc: base + '/' + p.slug,
|
|---|
| 133 | lastmod: p.lastmod,
|
|---|
| 134 | priority: '0.8',
|
|---|
| 135 | })),
|
|---|
| 136 | ];
|
|---|
| 137 |
|
|---|
| 138 | res.set('Content-Type', 'application/xml; charset=utf-8');
|
|---|
| 139 | res.send(`<?xml version="1.0" encoding="UTF-8"?>
|
|---|
| 140 | <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|---|
| 141 | ${urls.map(u => ` <url>
|
|---|
| 142 | <loc>${escapeXml(u.loc)}</loc>${u.lastmod ? `\n <lastmod>${new Date(u.lastmod).toISOString().slice(0,10)}</lastmod>` : ''}
|
|---|
| 143 | <priority>${u.priority}</priority>
|
|---|
| 144 | </url>`).join('\n')}
|
|---|
| 145 | </urlset>`);
|
|---|
| 146 | });
|
|---|
| 147 |
|
|---|
| 148 | export default router;
|
|---|