/** * RSS / Atom feeds + sitemap.xml * * GET /feed.xml -> RSS 2.0 * GET /atom.xml -> Atom 1.0 * GET /sitemap.xml -> XML sitemap (only if site.robots_index is on) * * All are site-scoped (using res.locals.site) and only include * status='published' posts. */ import express from 'express'; import db from '../config/database.js'; import { siteOpenTracks } from '../services/ActivityPubService.js'; const router = express.Router(); function escapeXml(s) { if (s == null) return ''; return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function siteOrigin(req) { const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http'; const host = req.headers['x-forwarded-host'] || req.get('host'); return `${proto}://${host}`; } function postsForFeed(siteId, limit = 30) { return db.prepare(` SELECT p.id, p.slug, p.title, p.excerpt, p.content, p.published_at, p.updated_at, u.username AS author_username, u.email AS author_email FROM posts p JOIN users u ON u.id = p.author_id WHERE p.site_id = ? AND p.status = 'published' ORDER BY p.published_at DESC LIMIT ? `).all(siteId, limit); } // ==================== RSS 2.0 ==================== /** * Twee feeds, een bouwer. * * /feed.xml de site: posts EN open tracks door elkaar, chronologisch * /tracks.xml alleen de open tracks -- de hele site als muziekkanaal * * WAAROM DIE TWEEDE (Robins vraag, 10-8). Hij vroeg naar een "hele site, open * tracks"-library voor Funkwhale. Als AS2-object helpt zo'n ding daar niet: een * Funkwhale-KANAAL wijst nergens naar een library, en hun library is een eigen * actor met inbox en sleutel -- de optie die op 7 augustus is afgewezen. Maar de * vorm waarin Funkwhale een kanaal WEL uitgeeft is RSS met iTunes en een * enclosure per item, en dat is precies wat Klonkt hier al doet. Naast een echte * kanaalfeed gelegd (audio.pepemoss.com/api/v1/channels/tnd/rss): dezelfde * namespaces, dezelfde enclosure. * * Het enige verschil was dat onze feed ook gewone posts draagt. Een muziekkanaal * met blogberichten ertussen is er geen, dus die feed staat er nu apart -- een * URL om te plakken, zonder dat er iets naar buiten geduwd wordt. */ function stuurFeed(req, res, { alleenTracks }) { const site = res.locals.site; if (!site) return res.status(404).send('No site'); const origin = siteOrigin(req); const base = origin + (res.locals.siteUrlBase || ''); const posts = alleenTracks ? [] : postsForFeed(site.id); // De tracks die deze site aan de federatie heeft opengezet, elk als eigen // item met een (shaer-0nh). Dat laatste is wat een podcast-app // zoekt: zonder enclosure is een feed voor hem leeg, hoe veel items er ook // in staan -- en de actor adverteert deze feed nu juist als kanaal-feed. // // EEN ITEM PER TRACK, want RSS 2.0 staat maar EEN enclosure per item toe. // Een album in een item proppen zou betekenen dat er van vijf nummers vier // verdwijnen. Dezelfde keuze als in de outbox: de post is het bericht, de // track is de publicatie. // // Welke tracks open zijn beslist de AP-service, niet deze route: dat is een // poortregel en die hoort op een plek te staan. const tracks = siteOpenTracks(site.id); const lastBuild = posts[0]?.published_at || tracks[0]?.created_at || new Date().toISOString(); // De itunes-velden waar een podcast-app een kanaal aan herkent. Funkwhale // bouwde onze kanaalpagina langs de RSS-kant op en liet de categorie leeg, // want die leest hij hier -- niet uit `category` op de AP-actor. // // De categorie volgt DEZELFDE regel als daar: alleen 'Music' als er ook // werkelijk publieke muziek is. Een blog zonder open track is naar buiten // toe geen muziekkanaal, en gated muziek telt niet mee -- afwezig is // afwezig, ook in een categorie. const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`); const kanaalKunst = abs(site.profile_photo || site.og_image_default || null); const kanaalTags = [ ` ${escapeXml(site.author || site.title || '')}`, site.description || site.tagline ? ` ${escapeXml(site.description || site.tagline)}` : null, kanaalKunst ? ` ` : null, tracks.length ? ' ' : null, ].filter(Boolean).join('\n'); const wanneer = (d) => { const t = Date.parse(d); return Number.isNaN(t) ? 0 : t; }; const items = [ ...posts.map((p) => ({ op: wanneer(p.published_at), xml: ` ${escapeXml(p.title || '(untitled)')} ${escapeXml(base + '/' + p.slug)} ${escapeXml(base + '/' + p.slug)} ${new Date(p.published_at).toUTCString()} ${escapeXml((p.author_email || 'noreply@localhost') + ' (' + p.author_username + ')')} ${escapeXml(p.excerpt || '')} ` })), ...tracks.map((t) => { const fn = t.filename || (t.storage_path || '').split('/').pop(); // Geen : 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. return { op: wanneer(t.created_at), xml: ` ${escapeXml(t.title || 'Audio')} ${escapeXml(`${base}/ap/users/${encodeURIComponent(site.slug)}/tracks/${encodeURIComponent(t.id)}`)} ${new Date(t.created_at || Date.now()).toUTCString()} ${escapeXml(t.artist || '')} ${t.duration ? ` ${Math.round(t.duration)}` : ''}${t.artist ? ` ${escapeXml(t.artist)}` : ''}${abs(t.cover_url) ? ` ` : ''} ` }; }), ].sort((a, b) => b.op - a.op).map((x) => x.xml).join('\n'); res.set('Content-Type', 'application/rss+xml; charset=utf-8'); res.send(` ${escapeXml(alleenTracks ? `${site.title} \u2014 muziek` : site.title)} ${escapeXml(base + '/')} ${escapeXml(site.description || site.tagline || '')} ${escapeXml(site.language || 'nl')} ${new Date(lastBuild).toUTCString()} ${kanaalTags} ${items} `); } router.get('/feed.xml', (req, res) => stuurFeed(req, res, { alleenTracks: false })); // De hele site als muziekkanaal: alleen wat op de federatie openstaat. router.get('/tracks.xml', (req, res) => stuurFeed(req, res, { alleenTracks: true })); // ==================== Atom 1.0 ==================== router.get('/atom.xml', (req, res) => { const site = res.locals.site; if (!site) return res.status(404).send('No site'); const origin = siteOrigin(req); const base = origin + (res.locals.siteUrlBase || ''); const posts = postsForFeed(site.id); const updated = posts[0]?.updated_at || posts[0]?.published_at || new Date().toISOString(); res.set('Content-Type', 'application/atom+xml; charset=utf-8'); res.send(` ${escapeXml(site.title)} ${escapeXml(base + '/')} ${new Date(updated).toISOString()} ${escapeXml(site.description || site.tagline || '')} ${posts.map(p => ` ${escapeXml(p.title || '(untitled)')} ${escapeXml(base + '/' + p.slug)} ${new Date(p.updated_at || p.published_at).toISOString()} ${new Date(p.published_at).toISOString()} ${escapeXml(p.author_username)} ${escapeXml(p.excerpt || '')} `).join('\n')} `); }); // ==================== Sitemap.xml ==================== router.get('/sitemap.xml', (req, res) => { const site = res.locals.site; if (!site) return res.status(404).send('No site'); // Honour the per-site robots_index flag. if (site.robots_index === 0) { res.set('X-Robots-Tag', 'noindex'); return res.status(404).send('No sitemap'); } const origin = siteOrigin(req); const base = origin + (res.locals.siteUrlBase || ''); const posts = db.prepare(` SELECT slug, COALESCE(updated_at, published_at) AS lastmod FROM posts WHERE site_id = ? AND status = 'published' ORDER BY lastmod DESC LIMIT 1000 `).all(site.id); const urls = [ { loc: base + '/', lastmod: posts[0]?.lastmod, priority: '1.0' }, { loc: base + '/archive', priority: '0.7' }, ...posts.map(p => ({ loc: base + '/' + p.slug, lastmod: p.lastmod, priority: '0.8', })), ]; res.set('Content-Type', 'application/xml; charset=utf-8'); res.send(` ${urls.map(u => ` ${escapeXml(u.loc)}${u.lastmod ? `\n ${new Date(u.lastmod).toISOString().slice(0,10)}` : ''} ${u.priority} `).join('\n')} `); }); export default router;