source: Klonkt/src/routes/feed.js@ 34e63f0

main
Last change on this file since 34e63f0 was 47ca083, checked in by Robin <roboburr@…>, 5 weeks ago

De feed draagt de muziek, niet alleen de wegwijzer ernaartoe

De actor adverteert /feed.xml als kanaal-feed sinds bdcb3a3. Maar die feed was
een blogfeed: twee items, NUL enclosures. Een podcast-app zoekt precies op
<enclosure>, dus voor hem was hij leeg hoe veel items er ook in stonden. Ik had
de wegwijzer gebouwd zonder te kijken waar hij heen wees.

Elke opengezette track is nu een eigen item met een enclosure: url, lengte in
bytes uit media.size, en het echte mime-type. Plus itunes:duration, want een
lezer die een lengte kan tonen hoort er een te krijgen.

EEN ITEM PER TRACK, en dat is geen stijlkeuze: RSS 2.0 staat maar EEN enclosure
per item toe. Een album in een item proppen zou van vijf nummers er vier laten
verdwijnen. Dezelfde tweedeling als in de outbox -- de post is het bericht, de
track is de publicatie -- en ze staan door elkaar op datum.

Geen <link> op een track-item: Klonkt heeft geen trackpagina, en een post over
vijf nummers is niet de pagina van dit ene nummer. De guid is daarom geen
permalink maar de stabiele AP-id van de track, zodat een lezer een track
herkent die hij al kende.

Welke tracks open zijn beslist siteOpenTracks in de AP-service, niet deze
route: die poortregel hoort op een plek te staan, en de test bewaakt dat een
gesloten track titel noch bestandsnaam lekt.

6 tests.

Co-Authored-By: Claude Opus 5 <noreply@…>

  • Property mode set to 100644
File size: 7.4 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';
14import { siteOpenTracks } from '../services/ActivityPubService.js';
15
16const router = express.Router();
17
18function escapeXml(s) {
19 if (s == null) return '';
20 return String(s)
21 .replace(/&/g, '&amp;')
22 .replace(/</g, '&lt;')
23 .replace(/>/g, '&gt;')
24 .replace(/"/g, '&quot;')
25 .replace(/'/g, '&apos;');
26}
27
28function siteOrigin(req) {
29 const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
30 const host = req.headers['x-forwarded-host'] || req.get('host');
31 return `${proto}://${host}`;
32}
33
34function postsForFeed(siteId, limit = 30) {
35 return db.prepare(`
36 SELECT p.id, p.slug, p.title, p.excerpt, p.content, p.published_at, p.updated_at,
37 u.username AS author_username, u.email AS author_email
38 FROM posts p JOIN users u ON u.id = p.author_id
39 WHERE p.site_id = ? AND p.status = 'published'
40 ORDER BY p.published_at DESC
41 LIMIT ?
42 `).all(siteId, limit);
43}
44
45// ==================== RSS 2.0 ====================
46router.get('/feed.xml', (req, res) => {
47 const site = res.locals.site;
48 if (!site) return res.status(404).send('No site');
49
50 const origin = siteOrigin(req);
51 const base = origin + (res.locals.siteUrlBase || '');
52 const posts = postsForFeed(site.id);
53
54 // De tracks die deze site aan de federatie heeft opengezet, elk als eigen
55 // item met een <enclosure> (shaer-0nh). Dat laatste is wat een podcast-app
56 // zoekt: zonder enclosure is een feed voor hem leeg, hoe veel items er ook
57 // in staan -- en de actor adverteert deze feed nu juist als kanaal-feed.
58 //
59 // EEN ITEM PER TRACK, want RSS 2.0 staat maar EEN enclosure per item toe.
60 // Een album in een item proppen zou betekenen dat er van vijf nummers vier
61 // verdwijnen. Dezelfde keuze als in de outbox: de post is het bericht, de
62 // track is de publicatie.
63 //
64 // Welke tracks open zijn beslist de AP-service, niet deze route: dat is een
65 // poortregel en die hoort op een plek te staan.
66 const tracks = siteOpenTracks(site.id);
67 const lastBuild = posts[0]?.published_at || new Date().toISOString();
68
69 const wanneer = (d) => { const t = Date.parse(d); return Number.isNaN(t) ? 0 : t; };
70 const items = [
71 ...posts.map((p) => ({ op: wanneer(p.published_at), xml: ` <item>
72 <title>${escapeXml(p.title || '(untitled)')}</title>
73 <link>${escapeXml(base + '/' + p.slug)}</link>
74 <guid isPermaLink="true">${escapeXml(base + '/' + p.slug)}</guid>
75 <pubDate>${new Date(p.published_at).toUTCString()}</pubDate>
76 <author>${escapeXml((p.author_email || 'noreply@localhost') + ' (' + p.author_username + ')')}</author>
77 <description>${escapeXml(p.excerpt || '')}</description>
78 </item>` })),
79 ...tracks.map((t) => {
80 const fn = t.filename || (t.storage_path || '').split('/').pop();
81 // Geen <link>: Klonkt heeft geen trackpagina, en een post over vijf
82 // nummers is niet de pagina van dit ene nummer. De guid is daarom geen
83 // permalink maar de stabiele AP-id van de track.
84 return { op: wanneer(t.created_at), xml: ` <item>
85 <title>${escapeXml(t.title || 'Audio')}</title>
86 <guid isPermaLink="false">${escapeXml(`${base}/ap/users/${encodeURIComponent(site.slug)}/tracks/${encodeURIComponent(t.id)}`)}</guid>
87 <pubDate>${new Date(t.created_at || Date.now()).toUTCString()}</pubDate>
88 <description>${escapeXml(t.artist || '')}</description>
89 <enclosure url="${escapeXml(`${base}/audio/stream/${encodeURIComponent(fn)}`)}" length="${Number(t.size) || 0}" type="${escapeXml(t.mime_type || 'audio/mpeg')}" />${t.duration ? `
90 <itunes:duration>${Math.round(t.duration)}</itunes:duration>` : ''}
91 </item>` };
92 }),
93 ].sort((a, b) => b.op - a.op).map((x) => x.xml).join('\n');
94
95 res.set('Content-Type', 'application/rss+xml; charset=utf-8');
96 res.send(`<?xml version="1.0" encoding="UTF-8"?>
97<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
98 <channel>
99 <title>${escapeXml(site.title)}</title>
100 <link>${escapeXml(base + '/')}</link>
101 <description>${escapeXml(site.description || site.tagline || '')}</description>
102 <language>${escapeXml(site.language || 'nl')}</language>
103 <lastBuildDate>${new Date(lastBuild).toUTCString()}</lastBuildDate>
104 <atom:link href="${escapeXml(base + '/feed.xml')}" rel="self" type="application/rss+xml" />
105${items}
106 </channel>
107</rss>`);
108});
109
110// ==================== Atom 1.0 ====================
111router.get('/atom.xml', (req, res) => {
112 const site = res.locals.site;
113 if (!site) return res.status(404).send('No site');
114
115 const origin = siteOrigin(req);
116 const base = origin + (res.locals.siteUrlBase || '');
117 const posts = postsForFeed(site.id);
118 const updated = posts[0]?.updated_at || posts[0]?.published_at || new Date().toISOString();
119
120 res.set('Content-Type', 'application/atom+xml; charset=utf-8');
121 res.send(`<?xml version="1.0" encoding="UTF-8"?>
122<feed xmlns="http://www.w3.org/2005/Atom">
123 <title>${escapeXml(site.title)}</title>
124 <link href="${escapeXml(base + '/')}" />
125 <link href="${escapeXml(base + '/atom.xml')}" rel="self" />
126 <id>${escapeXml(base + '/')}</id>
127 <updated>${new Date(updated).toISOString()}</updated>
128 <subtitle>${escapeXml(site.description || site.tagline || '')}</subtitle>
129${posts.map(p => ` <entry>
130 <title>${escapeXml(p.title || '(untitled)')}</title>
131 <link href="${escapeXml(base + '/' + p.slug)}" />
132 <id>${escapeXml(base + '/' + p.slug)}</id>
133 <updated>${new Date(p.updated_at || p.published_at).toISOString()}</updated>
134 <published>${new Date(p.published_at).toISOString()}</published>
135 <author><name>${escapeXml(p.author_username)}</name></author>
136 <summary>${escapeXml(p.excerpt || '')}</summary>
137 </entry>`).join('\n')}
138</feed>`);
139});
140
141// ==================== Sitemap.xml ====================
142router.get('/sitemap.xml', (req, res) => {
143 const site = res.locals.site;
144 if (!site) return res.status(404).send('No site');
145
146 // Honour the per-site robots_index flag.
147 if (site.robots_index === 0) {
148 res.set('X-Robots-Tag', 'noindex');
149 return res.status(404).send('No sitemap');
150 }
151
152 const origin = siteOrigin(req);
153 const base = origin + (res.locals.siteUrlBase || '');
154 const posts = db.prepare(`
155 SELECT slug, COALESCE(updated_at, published_at) AS lastmod
156 FROM posts
157 WHERE site_id = ? AND status = 'published'
158 ORDER BY lastmod DESC
159 LIMIT 1000
160 `).all(site.id);
161
162 const urls = [
163 { loc: base + '/', lastmod: posts[0]?.lastmod, priority: '1.0' },
164 { loc: base + '/archive', priority: '0.7' },
165 ...posts.map(p => ({
166 loc: base + '/' + p.slug,
167 lastmod: p.lastmod,
168 priority: '0.8',
169 })),
170 ];
171
172 res.set('Content-Type', 'application/xml; charset=utf-8');
173 res.send(`<?xml version="1.0" encoding="UTF-8"?>
174<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
175${urls.map(u => ` <url>
176 <loc>${escapeXml(u.loc)}</loc>${u.lastmod ? `\n <lastmod>${new Date(u.lastmod).toISOString().slice(0,10)}</lastmod>` : ''}
177 <priority>${u.priority}</priority>
178 </url>`).join('\n')}
179</urlset>`);
180});
181
182export default router;
Note: See TracBrowser for help on using the repository browser.