| 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 | import { siteOpenTracks } from '../services/ActivityPubService.js';
|
|---|
| 15 |
|
|---|
| 16 | const router = express.Router();
|
|---|
| 17 |
|
|---|
| 18 | function escapeXml(s) {
|
|---|
| 19 | if (s == null) return '';
|
|---|
| 20 | return String(s)
|
|---|
| 21 | .replace(/&/g, '&')
|
|---|
| 22 | .replace(/</g, '<')
|
|---|
| 23 | .replace(/>/g, '>')
|
|---|
| 24 | .replace(/"/g, '"')
|
|---|
| 25 | .replace(/'/g, ''');
|
|---|
| 26 | }
|
|---|
| 27 |
|
|---|
| 28 | function 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 |
|
|---|
| 34 | function 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 ====================
|
|---|
| 46 | /**
|
|---|
| 47 | * Twee feeds, een bouwer.
|
|---|
| 48 | *
|
|---|
| 49 | * /feed.xml de site: posts EN open tracks door elkaar, chronologisch
|
|---|
| 50 | * /tracks.xml alleen de open tracks -- de hele site als muziekkanaal
|
|---|
| 51 | *
|
|---|
| 52 | * WAAROM DIE TWEEDE (Robins vraag, 10-8). Hij vroeg naar een "hele site, open
|
|---|
| 53 | * tracks"-library voor Funkwhale. Als AS2-object helpt zo'n ding daar niet: een
|
|---|
| 54 | * Funkwhale-KANAAL wijst nergens naar een library, en hun library is een eigen
|
|---|
| 55 | * actor met inbox en sleutel -- de optie die op 7 augustus is afgewezen. Maar de
|
|---|
| 56 | * vorm waarin Funkwhale een kanaal WEL uitgeeft is RSS met iTunes en een
|
|---|
| 57 | * enclosure per item, en dat is precies wat Klonkt hier al doet. Naast een echte
|
|---|
| 58 | * kanaalfeed gelegd (audio.pepemoss.com/api/v1/channels/tnd/rss): dezelfde
|
|---|
| 59 | * namespaces, dezelfde enclosure.
|
|---|
| 60 | *
|
|---|
| 61 | * Het enige verschil was dat onze feed ook gewone posts draagt. Een muziekkanaal
|
|---|
| 62 | * met blogberichten ertussen is er geen, dus die feed staat er nu apart -- een
|
|---|
| 63 | * URL om te plakken, zonder dat er iets naar buiten geduwd wordt.
|
|---|
| 64 | */
|
|---|
| 65 | function stuurFeed(req, res, { alleenTracks }) {
|
|---|
| 66 | const site = res.locals.site;
|
|---|
| 67 | if (!site) return res.status(404).send('No site');
|
|---|
| 68 |
|
|---|
| 69 | const origin = siteOrigin(req);
|
|---|
| 70 | const base = origin + (res.locals.siteUrlBase || '');
|
|---|
| 71 | const posts = alleenTracks ? [] : postsForFeed(site.id);
|
|---|
| 72 |
|
|---|
| 73 | // De tracks die deze site aan de federatie heeft opengezet, elk als eigen
|
|---|
| 74 | // item met een <enclosure> (shaer-0nh). Dat laatste is wat een podcast-app
|
|---|
| 75 | // zoekt: zonder enclosure is een feed voor hem leeg, hoe veel items er ook
|
|---|
| 76 | // in staan -- en de actor adverteert deze feed nu juist als kanaal-feed.
|
|---|
| 77 | //
|
|---|
| 78 | // EEN ITEM PER TRACK, want RSS 2.0 staat maar EEN enclosure per item toe.
|
|---|
| 79 | // Een album in een item proppen zou betekenen dat er van vijf nummers vier
|
|---|
| 80 | // verdwijnen. Dezelfde keuze als in de outbox: de post is het bericht, de
|
|---|
| 81 | // track is de publicatie.
|
|---|
| 82 | //
|
|---|
| 83 | // Welke tracks open zijn beslist de AP-service, niet deze route: dat is een
|
|---|
| 84 | // poortregel en die hoort op een plek te staan.
|
|---|
| 85 | const tracks = siteOpenTracks(site.id);
|
|---|
| 86 | const lastBuild = posts[0]?.published_at || tracks[0]?.created_at || new Date().toISOString();
|
|---|
| 87 |
|
|---|
| 88 | // De itunes-velden waar een podcast-app een kanaal aan herkent. Funkwhale
|
|---|
| 89 | // bouwde onze kanaalpagina langs de RSS-kant op en liet de categorie leeg,
|
|---|
| 90 | // want die leest hij hier -- niet uit `category` op de AP-actor.
|
|---|
| 91 | //
|
|---|
| 92 | // De categorie volgt DEZELFDE regel als daar: alleen 'Music' als er ook
|
|---|
| 93 | // werkelijk publieke muziek is. Een blog zonder open track is naar buiten
|
|---|
| 94 | // toe geen muziekkanaal, en gated muziek telt niet mee -- afwezig is
|
|---|
| 95 | // afwezig, ook in een categorie.
|
|---|
| 96 | const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
|
|---|
| 97 | const kanaalKunst = abs(site.profile_photo || site.og_image_default || null);
|
|---|
| 98 | const kanaalTags = [
|
|---|
| 99 | ` <itunes:author>${escapeXml(site.author || site.title || '')}</itunes:author>`,
|
|---|
| 100 | site.description || site.tagline
|
|---|
| 101 | ? ` <itunes:summary>${escapeXml(site.description || site.tagline)}</itunes:summary>` : null,
|
|---|
| 102 | kanaalKunst ? ` <itunes:image href="${escapeXml(kanaalKunst)}" />` : null,
|
|---|
| 103 | tracks.length ? ' <itunes:category text="Music" />' : null,
|
|---|
| 104 | ].filter(Boolean).join('\n');
|
|---|
| 105 |
|
|---|
| 106 | const wanneer = (d) => { const t = Date.parse(d); return Number.isNaN(t) ? 0 : t; };
|
|---|
| 107 | const items = [
|
|---|
| 108 | ...posts.map((p) => ({ op: wanneer(p.published_at), xml: ` <item>
|
|---|
| 109 | <title>${escapeXml(p.title || '(untitled)')}</title>
|
|---|
| 110 | <link>${escapeXml(base + '/' + p.slug)}</link>
|
|---|
| 111 | <guid isPermaLink="true">${escapeXml(base + '/' + p.slug)}</guid>
|
|---|
| 112 | <pubDate>${new Date(p.published_at).toUTCString()}</pubDate>
|
|---|
| 113 | <author>${escapeXml((p.author_email || 'noreply@localhost') + ' (' + p.author_username + ')')}</author>
|
|---|
| 114 | <description>${escapeXml(p.excerpt || '')}</description>
|
|---|
| 115 | </item>` })),
|
|---|
| 116 | ...tracks.map((t) => {
|
|---|
| 117 | const fn = t.filename || (t.storage_path || '').split('/').pop();
|
|---|
| 118 | // Geen <link>: Klonkt heeft geen trackpagina, en een post over vijf
|
|---|
| 119 | // nummers is niet de pagina van dit ene nummer. De guid is daarom geen
|
|---|
| 120 | // permalink maar de stabiele AP-id van de track.
|
|---|
| 121 | return { op: wanneer(t.created_at), xml: ` <item>
|
|---|
| 122 | <title>${escapeXml(t.title || 'Audio')}</title>
|
|---|
| 123 | <guid isPermaLink="false">${escapeXml(`${base}/ap/users/${encodeURIComponent(site.slug)}/tracks/${encodeURIComponent(t.id)}`)}</guid>
|
|---|
| 124 | <pubDate>${new Date(t.created_at || Date.now()).toUTCString()}</pubDate>
|
|---|
| 125 | <description>${escapeXml(t.artist || '')}</description>
|
|---|
| 126 | <enclosure url="${escapeXml(`${base}/audio/stream/${encodeURIComponent(fn)}`)}" length="${Number(t.size) || 0}" type="${escapeXml(t.mime_type || 'audio/mpeg')}" />${t.duration ? `
|
|---|
| 127 | <itunes:duration>${Math.round(t.duration)}</itunes:duration>` : ''}${t.artist ? `
|
|---|
| 128 | <itunes:author>${escapeXml(t.artist)}</itunes:author>` : ''}${abs(t.cover_url) ? `
|
|---|
| 129 | <itunes:image href="${escapeXml(abs(t.cover_url))}" />` : ''}
|
|---|
| 130 | </item>` };
|
|---|
| 131 | }),
|
|---|
| 132 | ].sort((a, b) => b.op - a.op).map((x) => x.xml).join('\n');
|
|---|
| 133 |
|
|---|
| 134 | res.set('Content-Type', 'application/rss+xml; charset=utf-8');
|
|---|
| 135 | res.send(`<?xml version="1.0" encoding="UTF-8"?>
|
|---|
| 136 | <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">
|
|---|
| 137 | <channel>
|
|---|
| 138 | <title>${escapeXml(alleenTracks ? `${site.title} \u2014 muziek` : site.title)}</title>
|
|---|
| 139 | <link>${escapeXml(base + '/')}</link>
|
|---|
| 140 | <description>${escapeXml(site.description || site.tagline || '')}</description>
|
|---|
| 141 | <language>${escapeXml(site.language || 'nl')}</language>
|
|---|
| 142 | <lastBuildDate>${new Date(lastBuild).toUTCString()}</lastBuildDate>
|
|---|
| 143 | <atom:link href="${escapeXml(base + (alleenTracks ? '/tracks.xml' : '/feed.xml'))}" rel="self" type="application/rss+xml" />
|
|---|
| 144 | ${kanaalTags}
|
|---|
| 145 | ${items}
|
|---|
| 146 | </channel>
|
|---|
| 147 | </rss>`);
|
|---|
| 148 | }
|
|---|
| 149 |
|
|---|
| 150 | router.get('/feed.xml', (req, res) => stuurFeed(req, res, { alleenTracks: false }));
|
|---|
| 151 | // De hele site als muziekkanaal: alleen wat op de federatie openstaat.
|
|---|
| 152 | router.get('/tracks.xml', (req, res) => stuurFeed(req, res, { alleenTracks: true }));
|
|---|
| 153 |
|
|---|
| 154 | // ==================== Atom 1.0 ====================
|
|---|
| 155 | router.get('/atom.xml', (req, res) => {
|
|---|
| 156 | const site = res.locals.site;
|
|---|
| 157 | if (!site) return res.status(404).send('No site');
|
|---|
| 158 |
|
|---|
| 159 | const origin = siteOrigin(req);
|
|---|
| 160 | const base = origin + (res.locals.siteUrlBase || '');
|
|---|
| 161 | const posts = postsForFeed(site.id);
|
|---|
| 162 | const updated = posts[0]?.updated_at || posts[0]?.published_at || new Date().toISOString();
|
|---|
| 163 |
|
|---|
| 164 | res.set('Content-Type', 'application/atom+xml; charset=utf-8');
|
|---|
| 165 | res.send(`<?xml version="1.0" encoding="UTF-8"?>
|
|---|
| 166 | <feed xmlns="http://www.w3.org/2005/Atom">
|
|---|
| 167 | <title>${escapeXml(site.title)}</title>
|
|---|
| 168 | <link href="${escapeXml(base + '/')}" />
|
|---|
| 169 | <link href="${escapeXml(base + '/atom.xml')}" rel="self" />
|
|---|
| 170 | <id>${escapeXml(base + '/')}</id>
|
|---|
| 171 | <updated>${new Date(updated).toISOString()}</updated>
|
|---|
| 172 | <subtitle>${escapeXml(site.description || site.tagline || '')}</subtitle>
|
|---|
| 173 | ${posts.map(p => ` <entry>
|
|---|
| 174 | <title>${escapeXml(p.title || '(untitled)')}</title>
|
|---|
| 175 | <link href="${escapeXml(base + '/' + p.slug)}" />
|
|---|
| 176 | <id>${escapeXml(base + '/' + p.slug)}</id>
|
|---|
| 177 | <updated>${new Date(p.updated_at || p.published_at).toISOString()}</updated>
|
|---|
| 178 | <published>${new Date(p.published_at).toISOString()}</published>
|
|---|
| 179 | <author><name>${escapeXml(p.author_username)}</name></author>
|
|---|
| 180 | <summary>${escapeXml(p.excerpt || '')}</summary>
|
|---|
| 181 | </entry>`).join('\n')}
|
|---|
| 182 | </feed>`);
|
|---|
| 183 | });
|
|---|
| 184 |
|
|---|
| 185 | // ==================== Sitemap.xml ====================
|
|---|
| 186 | router.get('/sitemap.xml', (req, res) => {
|
|---|
| 187 | const site = res.locals.site;
|
|---|
| 188 | if (!site) return res.status(404).send('No site');
|
|---|
| 189 |
|
|---|
| 190 | // Honour the per-site robots_index flag.
|
|---|
| 191 | if (site.robots_index === 0) {
|
|---|
| 192 | res.set('X-Robots-Tag', 'noindex');
|
|---|
| 193 | return res.status(404).send('No sitemap');
|
|---|
| 194 | }
|
|---|
| 195 |
|
|---|
| 196 | const origin = siteOrigin(req);
|
|---|
| 197 | const base = origin + (res.locals.siteUrlBase || '');
|
|---|
| 198 | const posts = db.prepare(`
|
|---|
| 199 | SELECT slug, COALESCE(updated_at, published_at) AS lastmod
|
|---|
| 200 | FROM posts
|
|---|
| 201 | WHERE site_id = ? AND status = 'published'
|
|---|
| 202 | ORDER BY lastmod DESC
|
|---|
| 203 | LIMIT 1000
|
|---|
| 204 | `).all(site.id);
|
|---|
| 205 |
|
|---|
| 206 | const urls = [
|
|---|
| 207 | { loc: base + '/', lastmod: posts[0]?.lastmod, priority: '1.0' },
|
|---|
| 208 | { loc: base + '/archive', priority: '0.7' },
|
|---|
| 209 | ...posts.map(p => ({
|
|---|
| 210 | loc: base + '/' + p.slug,
|
|---|
| 211 | lastmod: p.lastmod,
|
|---|
| 212 | priority: '0.8',
|
|---|
| 213 | })),
|
|---|
| 214 | ];
|
|---|
| 215 |
|
|---|
| 216 | res.set('Content-Type', 'application/xml; charset=utf-8');
|
|---|
| 217 | res.send(`<?xml version="1.0" encoding="UTF-8"?>
|
|---|
| 218 | <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|---|
| 219 | ${urls.map(u => ` <url>
|
|---|
| 220 | <loc>${escapeXml(u.loc)}</loc>${u.lastmod ? `\n <lastmod>${new Date(u.lastmod).toISOString().slice(0,10)}</lastmod>` : ''}
|
|---|
| 221 | <priority>${u.priority}</priority>
|
|---|
| 222 | </url>`).join('\n')}
|
|---|
| 223 | </urlset>`);
|
|---|
| 224 | });
|
|---|
| 225 |
|
|---|
| 226 | export default router;
|
|---|