source: Klonkt/src/routes/embed.js@ 38120c7

main
Last change on this file since 38120c7 was 30271e6, checked in by Robin Genis <roboburr@…>, 3 months ago

fix(fediverse): player card only for playable (hosted) audio; scope embed strictly

A link-only track (external, media_id NULL) has no hosted audio, so it must not get
a Klonkt player card — and /embed?post= must not fall back to ALL site tracks (that
showed unrelated songs). New hasPlayableAudio() gates the player card + cover-
suppression on a real file-backed track; link-only audio keeps its cover. /embed?post
now scopes strictly to the post's tracks (no all-site fallback).

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

  • Property mode set to 100644
File size: 2.9 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 // Strictly scope to this post's tracks — do NOT fall back to all-site
48 // tracks (that showed unrelated songs for a link-only-track post).
49 const byId = new Map(tracks.map((t) => [t.id, t]));
50 tracks = ids.map((id) => byId.get(id)).filter(Boolean);
51 }
52 } catch { /* fall back to the full site player */ }
53 }
54
55 res.render('pages/embed-player', {
56 site,
57 embedTracks: tracks,
58 siteUrlBase: res.locals.siteUrlBase || '',
59 });
60});
61
62export default router;
Note: See TracBrowser for help on using the repository browser.