| 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 |
|
|---|
| 13 | import express from 'express';
|
|---|
| 14 | import db from '../config/database.js';
|
|---|
| 15 | import { premiumUnlocked } from '../services/PatreonService.js';
|
|---|
| 16 |
|
|---|
| 17 | const router = express.Router();
|
|---|
| 18 |
|
|---|
| 19 | router.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 |
|
|---|
| 63 | export default router;
|
|---|