source: Klonkt/src/routes/embed.js@ 4c9c60b

main
Last change on this file since 4c9c60b was 4c9c60b, checked in by Robin Genis <roboburr@…>, 3 months ago

feat(fediverse): player card for music posts (embed player, no raw mp3)

A post with audio now advertises a player card (twitter:player + og:video) pointing
at /embed?post=<slug>, so Mastodon shows an inline player that loads our embeddable
player — audio still streams via the gated /audio/stream, so no downloadable mp3 is
exposed. /embed?post=<slug> scopes the player to that post's tracks. Premium-gated
(the embed player is premium); non-premium falls back to the plain link card.

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

  • Property mode set to 100644
File size: 2.8 KB
Line 
1/**
2 * Embeddable player (premium feature #7).
3 *
4 * GET /embed -> a standalone, compact audio player page (no shell),
5 * intended to be placed in an <iframe> on EXTERNAL sites.
6 *
7 * The page is served by us (klonkt-origin), so audio requests from within
8 * the iframe remain same-origin → the /audio/stream gate lets them through,
9 * even when the iframe is on a foreign site. We only override Helmet's frameguard
10 * + frame-ancestors so that external sites are allowed to embed us. Hub: /user/:slug/embed.
11 */
12
13import express from 'express';
14import db from '../config/database.js';
15import { premiumUnlocked } from '../services/PatreonService.js';
16
17const router = express.Router();
18
19router.get('/embed', (req, res, next) => {
20 if (!premiumUnlocked()) return next();
21 const site = res.locals.site;
22 if (!site) return next();
23
24 // Allow embedding on external sites (override the global frameguard/CSP).
25 res.removeHeader('X-Frame-Options');
26 res.setHeader(
27 'Content-Security-Policy',
28 "default-src 'self'; media-src 'self' blob: https:; img-src 'self' data: https:; style-src 'unsafe-inline'; script-src 'unsafe-inline' 'self'; frame-ancestors *",
29 );
30
31 let tracks = (res.locals.audioTracks || []).map((t) => ({
32 id: t.id, title: t.title, artist: t.artist, duration: t.duration, url: t.media_url,
33 })).filter((t) => t.url);
34
35 // ?post=<slug> → scope the player to that post's tracks (for the fediverse
36 // player card). Resolve [[track]]/[[album]]/[[playlist]] shortcodes → track ids.
37 const postSlug = (req.query.post || '').toString();
38 if (postSlug) {
39 try {
40 const post = db.prepare("SELECT content FROM posts WHERE site_id = ? AND slug = ? AND status = 'published'").get(site.id, postSlug);
41 if (post && post.content) {
42 const ids = []; const seen = new Set();
43 const add = (id) => { if (id && !seen.has(id)) { seen.add(id); ids.push(id); } };
44 for (const m of post.content.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) add(m[1]);
45 for (const m of post.content.matchAll(/\[\[album:([^\]]+)\]\]/g)) for (const r of db.prepare('SELECT id FROM audio_tracks WHERE site_id = ? AND album = ? ORDER BY position').all(site.id, m[1].trim())) add(r.id);
46 for (const m of post.content.matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) for (const r of db.prepare('SELECT track_id FROM playlist_tracks WHERE playlist_id = ? ORDER BY position').all(m[1])) add(r.track_id);
47 if (ids.length) {
48 const byId = new Map(tracks.map((t) => [t.id, t]));
49 const scoped = ids.map((id) => byId.get(id)).filter(Boolean);
50 if (scoped.length) tracks = scoped;
51 }
52 }
53 } catch { /* fall back to the full site player */ }
54 }
55
56 res.render('pages/embed-player', {
57 site,
58 embedTracks: tracks,
59 siteUrlBase: res.locals.siteUrlBase || '',
60 });
61});
62
63export default router;
Note: See TracBrowser for help on using the repository browser.