source: Klonkt/src/routes/feed.js@ 4d81de5

main
Last change on this file since 4d81de5 was 4d81de5, checked in by Robin <roboburr@…>, 5 weeks ago

De feed vertelt ook WAT voor kanaal dit is

Funkwhale maakte van dev@… een kanaal en las de rss_url netjes uit
de Link-array op onze actor -- maar liet content_category leeg. Dat is de
aanwijzing: hij bouwt zo'n kanaal langs de RSS-kant op, en leest de categorie
daar. Niet uit category op de AP-actor.

Dus itunes-velden erbij op kanaalniveau: author, summary, image (de site-avatar,
absoluut gemaakt) en category. En per track de eigen artiest en hoes, naast de
duur die er al stond.

EN IK HEB MIJN EIGEN CATEGORIE-REGEL AANGESCHERPT. Die keek naar ELKE track,
ook een gated -- zodat een site met uitsluitend afgeschermde muziek toch "hier
is muziek" naar de hele fediverse riep. Dat botst met de regel die we overal
aanhouden: een gesloten track is AFWEZIG, niet stilletjes aanwezig. Nu tellen
alleen opengezette tracks, en zeggen de feed en de actor hetzelfde. Er staat
een test op die precies dat vastlegt: een gated track maakt er nog geen
muziekkanaal van.

Dat maakt de regel strenger dan hij vanmiddag was. Bewust: naar buiten toe is
een kanaal zonder publieke muziek geen muziekkanaal.

4 tests.

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

  • Property mode set to 100644
File size: 8.6 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 // De itunes-velden waar een podcast-app een kanaal aan herkent. Funkwhale
70 // bouwde onze kanaalpagina langs de RSS-kant op en liet de categorie leeg,
71 // want die leest hij hier -- niet uit `category` op de AP-actor.
72 //
73 // De categorie volgt DEZELFDE regel als daar: alleen 'Music' als er ook
74 // werkelijk publieke muziek is. Een blog zonder open track is naar buiten
75 // toe geen muziekkanaal, en gated muziek telt niet mee -- afwezig is
76 // afwezig, ook in een categorie.
77 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
78 const kanaalKunst = abs(site.profile_photo || site.og_image_default || null);
79 const kanaalTags = [
80 ` <itunes:author>${escapeXml(site.author || site.title || '')}</itunes:author>`,
81 site.description || site.tagline
82 ? ` <itunes:summary>${escapeXml(site.description || site.tagline)}</itunes:summary>` : null,
83 kanaalKunst ? ` <itunes:image href="${escapeXml(kanaalKunst)}" />` : null,
84 tracks.length ? ' <itunes:category text="Music" />' : null,
85 ].filter(Boolean).join('\n');
86
87 const wanneer = (d) => { const t = Date.parse(d); return Number.isNaN(t) ? 0 : t; };
88 const items = [
89 ...posts.map((p) => ({ op: wanneer(p.published_at), xml: ` <item>
90 <title>${escapeXml(p.title || '(untitled)')}</title>
91 <link>${escapeXml(base + '/' + p.slug)}</link>
92 <guid isPermaLink="true">${escapeXml(base + '/' + p.slug)}</guid>
93 <pubDate>${new Date(p.published_at).toUTCString()}</pubDate>
94 <author>${escapeXml((p.author_email || 'noreply@localhost') + ' (' + p.author_username + ')')}</author>
95 <description>${escapeXml(p.excerpt || '')}</description>
96 </item>` })),
97 ...tracks.map((t) => {
98 const fn = t.filename || (t.storage_path || '').split('/').pop();
99 // Geen <link>: Klonkt heeft geen trackpagina, en een post over vijf
100 // nummers is niet de pagina van dit ene nummer. De guid is daarom geen
101 // permalink maar de stabiele AP-id van de track.
102 return { op: wanneer(t.created_at), xml: ` <item>
103 <title>${escapeXml(t.title || 'Audio')}</title>
104 <guid isPermaLink="false">${escapeXml(`${base}/ap/users/${encodeURIComponent(site.slug)}/tracks/${encodeURIComponent(t.id)}`)}</guid>
105 <pubDate>${new Date(t.created_at || Date.now()).toUTCString()}</pubDate>
106 <description>${escapeXml(t.artist || '')}</description>
107 <enclosure url="${escapeXml(`${base}/audio/stream/${encodeURIComponent(fn)}`)}" length="${Number(t.size) || 0}" type="${escapeXml(t.mime_type || 'audio/mpeg')}" />${t.duration ? `
108 <itunes:duration>${Math.round(t.duration)}</itunes:duration>` : ''}${t.artist ? `
109 <itunes:author>${escapeXml(t.artist)}</itunes:author>` : ''}${abs(t.cover_url) ? `
110 <itunes:image href="${escapeXml(abs(t.cover_url))}" />` : ''}
111 </item>` };
112 }),
113 ].sort((a, b) => b.op - a.op).map((x) => x.xml).join('\n');
114
115 res.set('Content-Type', 'application/rss+xml; charset=utf-8');
116 res.send(`<?xml version="1.0" encoding="UTF-8"?>
117<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">
118 <channel>
119 <title>${escapeXml(site.title)}</title>
120 <link>${escapeXml(base + '/')}</link>
121 <description>${escapeXml(site.description || site.tagline || '')}</description>
122 <language>${escapeXml(site.language || 'nl')}</language>
123 <lastBuildDate>${new Date(lastBuild).toUTCString()}</lastBuildDate>
124 <atom:link href="${escapeXml(base + '/feed.xml')}" rel="self" type="application/rss+xml" />
125${kanaalTags}
126${items}
127 </channel>
128</rss>`);
129});
130
131// ==================== Atom 1.0 ====================
132router.get('/atom.xml', (req, res) => {
133 const site = res.locals.site;
134 if (!site) return res.status(404).send('No site');
135
136 const origin = siteOrigin(req);
137 const base = origin + (res.locals.siteUrlBase || '');
138 const posts = postsForFeed(site.id);
139 const updated = posts[0]?.updated_at || posts[0]?.published_at || new Date().toISOString();
140
141 res.set('Content-Type', 'application/atom+xml; charset=utf-8');
142 res.send(`<?xml version="1.0" encoding="UTF-8"?>
143<feed xmlns="http://www.w3.org/2005/Atom">
144 <title>${escapeXml(site.title)}</title>
145 <link href="${escapeXml(base + '/')}" />
146 <link href="${escapeXml(base + '/atom.xml')}" rel="self" />
147 <id>${escapeXml(base + '/')}</id>
148 <updated>${new Date(updated).toISOString()}</updated>
149 <subtitle>${escapeXml(site.description || site.tagline || '')}</subtitle>
150${posts.map(p => ` <entry>
151 <title>${escapeXml(p.title || '(untitled)')}</title>
152 <link href="${escapeXml(base + '/' + p.slug)}" />
153 <id>${escapeXml(base + '/' + p.slug)}</id>
154 <updated>${new Date(p.updated_at || p.published_at).toISOString()}</updated>
155 <published>${new Date(p.published_at).toISOString()}</published>
156 <author><name>${escapeXml(p.author_username)}</name></author>
157 <summary>${escapeXml(p.excerpt || '')}</summary>
158 </entry>`).join('\n')}
159</feed>`);
160});
161
162// ==================== Sitemap.xml ====================
163router.get('/sitemap.xml', (req, res) => {
164 const site = res.locals.site;
165 if (!site) return res.status(404).send('No site');
166
167 // Honour the per-site robots_index flag.
168 if (site.robots_index === 0) {
169 res.set('X-Robots-Tag', 'noindex');
170 return res.status(404).send('No sitemap');
171 }
172
173 const origin = siteOrigin(req);
174 const base = origin + (res.locals.siteUrlBase || '');
175 const posts = db.prepare(`
176 SELECT slug, COALESCE(updated_at, published_at) AS lastmod
177 FROM posts
178 WHERE site_id = ? AND status = 'published'
179 ORDER BY lastmod DESC
180 LIMIT 1000
181 `).all(site.id);
182
183 const urls = [
184 { loc: base + '/', lastmod: posts[0]?.lastmod, priority: '1.0' },
185 { loc: base + '/archive', priority: '0.7' },
186 ...posts.map(p => ({
187 loc: base + '/' + p.slug,
188 lastmod: p.lastmod,
189 priority: '0.8',
190 })),
191 ];
192
193 res.set('Content-Type', 'application/xml; charset=utf-8');
194 res.send(`<?xml version="1.0" encoding="UTF-8"?>
195<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
196${urls.map(u => ` <url>
197 <loc>${escapeXml(u.loc)}</loc>${u.lastmod ? `\n <lastmod>${new Date(u.lastmod).toISOString().slice(0,10)}</lastmod>` : ''}
198 <priority>${u.priority}</priority>
199 </url>`).join('\n')}
200</urlset>`);
201});
202
203export default router;
Note: See TracBrowser for help on using the repository browser.