source: Klonkt/src/routes/feed.js@ 7bc636b

main
Last change on this file since 7bc636b was 7bc636b, checked in by Robin <robin@…>, 4 months ago

Initial commit — PrutFolio v1 source (pulled from Hetzner /srv/prutfolio)

  • Property mode set to 100644
File size: 5.3 KB
Line 
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
12import express from 'express';
13import db from '../config/database.js';
14
15const router = express.Router();
16
17function escapeXml(s) {
18 if (s == null) return '';
19 return String(s)
20 .replace(/&/g, '&amp;')
21 .replace(/</g, '&lt;')
22 .replace(/>/g, '&gt;')
23 .replace(/"/g, '&quot;')
24 .replace(/'/g, '&apos;');
25}
26
27function 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
33function 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 ====================
45router.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 ====================
77router.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 ====================
108router.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
148export default router;
Note: See TracBrowser for help on using the repository browser.