Porte la section Funkwhale (MIZIK) et corrige la CSP par page

- src/lib/server/funkwhale.ts : 50 morceaux aléatoires de mizik.o-k-i.net,
  filtre local, mapping vers le lecteur audio intégré (10 affichés)
- Section « Morceaux » sur l'accueil entre podcast et timeline, FR/EN
- Bus audio partagé (audio-bus.svelte.ts) : un seul flux à la fois entre
  le lecteur podcast et le lecteur musique
- Lecteur audio : méta générique (artiste — album) pour les morceaux
- CSP : mizik.o-k-i.net ajouté à img-src/media-src
- Fix critique : la balise meta CSP embarque désormais la politique complète
  (lue depuis _headers) — default-src 'self' seul bloquait par intersection
  les images et médias des instances ; frame-ancestors retiré de la meta
  (ignoré hors en-tête)
This commit is contained in:
sucupira
2026-07-23 01:19:22 -04:00
parent 28c2334927
commit ffe7f12fb9
11 changed files with 156 additions and 11 deletions
+16 -2
View File
@@ -14,6 +14,20 @@ function* walk(dir) {
} }
} }
// La politique complète (hors script-src) est lue depuis build/_headers,
// unique source de vérité partagée avec .htaccess
const headers = readFileSync(join(BUILD_DIR, '_headers'), 'utf8');
const cspLine = headers
.split('\n')
.map((line) => line.trim())
.find((line) => line.startsWith('Content-Security-Policy:'));
if (!cspLine) throw new Error('[csp] Content-Security-Policy introuvable dans build/_headers');
const sharedPolicy = cspLine
.slice('Content-Security-Policy:'.length)
.trim()
// frame-ancestors est ignoré (et logué) dans une balise meta : il reste porté par l'en-tête HTTP
.replace(/frame-ancestors 'none';\s*/, '');
let pages = 0; let pages = 0;
for (const file of walk(BUILD_DIR)) { for (const file of walk(BUILD_DIR)) {
@@ -28,7 +42,7 @@ for (const file of walk(BUILD_DIR)) {
if (hashes.size === 0) continue; if (hashes.size === 0) continue;
const csp = `default-src 'self'; script-src 'self' ${[...hashes].join(' ')}`; const csp = `default-src 'self'; script-src 'self' ${[...hashes].join(' ')}; ${sharedPolicy}`;
const meta = `<meta http-equiv="Content-Security-Policy" content="${csp}" />`; const meta = `<meta http-equiv="Content-Security-Policy" content="${csp}" />`;
const anchor = html.match(/<meta charset="[^"]+"\s*\/?>/); const anchor = html.match(/<meta charset="[^"]+"\s*\/?>/);
@@ -41,4 +55,4 @@ for (const file of walk(BUILD_DIR)) {
pages++; pages++;
} }
console.log(`[csp] meta CSP injectée dans ${pages} page(s)`); console.log(`[csp] meta CSP complète injectée dans ${pages} page(s)`);
+14
View File
@@ -0,0 +1,14 @@
let current: HTMLAudioElement | null = $state(null);
export function claimAudio(element: HTMLAudioElement) {
if (current && current !== element) {
current.pause();
}
current = element;
}
export function releaseAudio(element: HTMLAudioElement) {
if (current === element) {
current = null;
}
}
+16 -4
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { claimAudio, releaseAudio } from '$lib/audio-bus.svelte';
import { formatDate, formatDuration, t as format, type Bundle } from '$lib/i18n'; import { formatDate, formatDuration, t as format, type Bundle } from '$lib/i18n';
import type { Episode, Locale } from '$lib/types'; import type { Episode, Locale } from '$lib/types';
import Icon from './Icon.svelte'; import Icon from './Icon.svelte';
@@ -7,11 +8,13 @@
let { let {
episodes, episodes,
locale, locale,
t t,
playTemplate = undefined
}: { }: {
episodes: Episode[]; episodes: Episode[];
locale: Locale; locale: Locale;
t: Bundle; t: Bundle;
playTemplate?: string;
} = $props(); } = $props();
let audio: HTMLAudioElement; let audio: HTMLAudioElement;
@@ -56,6 +59,7 @@
if (index < 0 || index >= episodes.length) return; if (index < 0 || index >= episodes.length) return;
currentIndex = index; currentIndex = index;
const episode = episodes[index]; const episode = episodes[index];
claimAudio(audio);
audio.src = episode.audioUrl; audio.src = episode.audioUrl;
audio.play(); audio.play();
updateMediaSession(episode); updateMediaSession(episode);
@@ -72,6 +76,7 @@
} }
function onEnded() { function onEnded() {
releaseAudio(audio);
if (currentIndex + 1 < episodes.length) { if (currentIndex + 1 < episodes.length) {
playAt(currentIndex + 1); playAt(currentIndex + 1);
} else { } else {
@@ -90,7 +95,10 @@
bind:this={audio} bind:this={audio}
preload="none" preload="none"
onplay={() => (playing = true)} onplay={() => (playing = true)}
onpause={() => (playing = false)} onpause={() => {
playing = false;
releaseAudio(audio);
}}
onended={onEnded} onended={onEnded}
ontimeupdate={() => (currentTime = audio.currentTime)} ontimeupdate={() => (currentTime = audio.currentTime)}
ondurationchange={() => (duration = audio.duration)} ondurationchange={() => (duration = audio.duration)}
@@ -119,7 +127,11 @@
</a> </a>
</h3> </h3>
<p class="episode-meta text-muted"> <p class="episode-meta text-muted">
{format(t.podcast.episodeFrom, { date: formatDate(episode.date, locale) })} {#if episode.date}
{format(t.podcast.episodeFrom, { date: formatDate(episode.date, locale) })}
{:else}
{episode.podcast}{#if episode.description}{episode.description}{/if}
{/if}
{#if episode.duration > 0}· {formatDuration(episode.duration)}{/if} {#if episode.duration > 0}· {formatDuration(episode.duration)}{/if}
</p> </p>
</div> </div>
@@ -128,7 +140,7 @@
onclick={() => toggle(i)} onclick={() => toggle(i)}
aria-label={currentIndex === i && playing aria-label={currentIndex === i && playing
? t.podcast.pause ? t.podcast.pause
: format(t.podcast.play, { title: episode.title })} : format(playTemplate ?? t.podcast.play, { title: episode.title })}
> >
<Icon name={currentIndex === i && playing ? 'pause' : 'play'} /> <Icon name={currentIndex === i && playing ? 'pause' : 'play'} />
</button> </button>
+8
View File
@@ -75,6 +75,14 @@ export const mastodon = {
maxPosts: 10 maxPosts: 10
} as const; } as const;
export const funkwhale = {
enabled: true,
url: 'https://mizik.o-k-i.net',
displayName: 'mizik.o-k-i.net',
tracksCount: 10,
fetchSize: 50
} as const;
export const social = { export const social = {
mastodon: mastodon.accountUrl, mastodon: mastodon.accountUrl,
facebook: '', facebook: '',
+6
View File
@@ -43,6 +43,9 @@
"trending": "Trending", "trending": "Trending",
"podcast": "Latest episodes", "podcast": "Latest episodes",
"podcastSubtitle": "The audio podcast on KUTE — Castopod", "podcastSubtitle": "The audio podcast on KUTE — Castopod",
"music": "Tracks",
"musicSubtitle": "Random selection on MIZIK — Funkwhale",
"seeAllTracks": "Explore MIZIK",
"timeline": "News", "timeline": "News",
"timelineSubtitle": "The Mastodon timeline on BOKANTE", "timelineSubtitle": "The Mastodon timeline on BOKANTE",
"seeMore": "See more", "seeMore": "See more",
@@ -104,6 +107,9 @@
"mute": "Mute", "mute": "Mute",
"unmute": "Unmute" "unmute": "Unmute"
}, },
"music": {
"play": "Play {title}"
},
"timeline": { "timeline": {
"reload": "Refresh", "reload": "Refresh",
"postFrom": "Post from {date}", "postFrom": "Post from {date}",
+6
View File
@@ -43,6 +43,9 @@
"trending": "Tendances", "trending": "Tendances",
"podcast": "Derniers épisodes", "podcast": "Derniers épisodes",
"podcastSubtitle": "Le podcast audio sur KUTE — Castopod", "podcastSubtitle": "Le podcast audio sur KUTE — Castopod",
"music": "Morceaux",
"musicSubtitle": "Sélection aléatoire sur MIZIK — Funkwhale",
"seeAllTracks": "Explorer MIZIK",
"timeline": "Actualités", "timeline": "Actualités",
"timelineSubtitle": "La timeline Mastodon sur BOKANTE", "timelineSubtitle": "La timeline Mastodon sur BOKANTE",
"seeMore": "Voir plus", "seeMore": "Voir plus",
@@ -104,6 +107,9 @@
"mute": "Couper le son", "mute": "Couper le son",
"unmute": "Rétablir le son" "unmute": "Rétablir le son"
}, },
"music": {
"play": "Lire {title}"
},
"timeline": { "timeline": {
"reload": "Rafraîchir", "reload": "Rafraîchir",
"postFrom": "Post du {date}", "postFrom": "Post du {date}",
+66
View File
@@ -0,0 +1,66 @@
import { funkwhale } from '$lib/config';
import { fetchJson } from './http';
import type { Episode } from '$lib/types';
interface FwUpload {
listen_url?: string;
duration?: number;
}
interface FwTrack {
id?: number;
title?: string;
is_local?: boolean;
artist?: { name?: string; channel?: string | null };
album?: { title?: string; cover?: { urls?: { medium_square_crop?: string; original?: string } } };
uploads?: FwUpload[];
}
export async function getFunkwhaleTracks(count: number = funkwhale.tracksCount): Promise<Episode[]> {
const data = await fetchJson<{ results?: FwTrack[] }>(
`${funkwhale.url}/api/v1/tracks?ordering=random&page_size=${funkwhale.fetchSize}&scope=all`
);
if (!data?.results) return [];
const localDomain = new URL(funkwhale.url).host;
const tracks: Episode[] = [];
for (const item of data.results) {
// Ne garder que les morceaux locaux à l'instance
if (item.is_local === false) continue;
if (item.artist?.channel) {
try {
if (new URL(item.artist.channel).host !== localDomain) continue;
} catch {
// channel non-URL : on conserve
}
}
const upload = item.uploads?.[0];
if (!upload?.listen_url) continue;
const audioUrl = upload.listen_url.startsWith('http')
? upload.listen_url
: `${funkwhale.url}${upload.listen_url}`;
tracks.push({
id: `fw-${item.id ?? tracks.length}`,
title: item.title ?? '',
description: item.album?.title ?? '',
audioUrl,
audioType: 'audio/mpeg',
duration: upload.duration ?? 0,
date: '',
image: item.album?.cover?.urls?.medium_square_crop ?? item.album?.cover?.urls?.original ?? null,
link: `${funkwhale.url}/library/tracks/${item.id ?? ''}`,
podcast: item.artist?.name ?? ''
});
}
// Sélection aléatoire parmi les morceaux récupérés
for (let i = tracks.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[tracks[i], tracks[j]] = [tracks[j], tracks[i]];
}
return tracks.slice(0, count);
}
+5 -2
View File
@@ -1,5 +1,6 @@
import { categories, castopod, categoryNames } from '$lib/config'; import { categories, castopod, categoryNames, funkwhale } from '$lib/config';
import { getCastopodEpisodes } from '$lib/server/castopod'; import { getCastopodEpisodes } from '$lib/server/castopod';
import { getFunkwhaleTracks } from '$lib/server/funkwhale';
import { getMastodonPosts } from '$lib/server/mastodon'; import { getMastodonPosts } from '$lib/server/mastodon';
import { import {
getLiveStream, getLiveStream,
@@ -15,12 +16,13 @@ export const entries: EntryGenerator = () => [{}, { locale: 'en' }];
export const load: PageServerLoad = async ({ params }) => { export const load: PageServerLoad = async ({ params }) => {
const locale = params.locale === 'en' ? 'en' : 'fr'; const locale = params.locale === 'en' ? 'en' : 'fr';
const [live, recent, trending, shorts, episodes, posts, categorySections] = await Promise.all([ const [live, recent, trending, shorts, episodes, tracks, posts, categorySections] = await Promise.all([
getLiveStream(), getLiveStream(),
getRecentVideos(), getRecentVideos(),
getTrendingVideos(), getTrendingVideos(),
getShorts(), getShorts(),
castopod.enabled ? getCastopodEpisodes() : Promise.resolve([]), castopod.enabled ? getCastopodEpisodes() : Promise.resolve([]),
funkwhale.enabled ? getFunkwhaleTracks() : Promise.resolve([]),
getMastodonPosts(), getMastodonPosts(),
Promise.all( Promise.all(
categories.map(async (category) => ({ categories.map(async (category) => ({
@@ -37,6 +39,7 @@ export const load: PageServerLoad = async ({ params }) => {
trending, trending,
shorts, shorts,
episodes, episodes,
tracks,
posts, posts,
categorySections: categorySections.filter((section) => section.videos.length > 0) categorySections: categorySections.filter((section) => section.videos.length > 0)
}; };
+17 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { castopod, hero, popularTags, site, about } from '$lib/config'; import { castopod, funkwhale, hero, popularTags, site, about } from '$lib/config';
import { asset } from '$app/paths'; import { asset } from '$app/paths';
import AudioPlayer from '$lib/components/AudioPlayer.svelte'; import AudioPlayer from '$lib/components/AudioPlayer.svelte';
import HeroSection from '$lib/components/HeroSection.svelte'; import HeroSection from '$lib/components/HeroSection.svelte';
@@ -73,6 +73,22 @@
</section> </section>
{/if} {/if}
{#if data.tracks.length > 0}
<section class="section" aria-labelledby="music-heading">
<div class="section-header" use:reveal={0}>
<FlagChip />
<KineticText as="h2" id="music-heading" class="section-title" text={t.home.music} />
</div>
<p class="section-sub text-muted">{t.home.musicSubtitle}</p>
<AudioPlayer episodes={data.tracks} {locale} {t} playTemplate={t.music.play} />
<p class="section-more">
<a href={funkwhale.url} target="_blank" rel="external noopener noreferrer" class="btn">
<Icon name="mizik-note" /> <span>{t.home.seeAllTracks}</span>
</a>
</p>
</section>
{/if}
{#if data.posts.length > 0} {#if data.posts.length > 0}
<section class="section" aria-labelledby="timeline-heading"> <section class="section" aria-labelledby="timeline-heading">
<div class="section-header" use:reveal={0}> <div class="section-header" use:reveal={0}>
+1 -1
View File
@@ -19,7 +19,7 @@ ErrorDocument 404 /404.html
# Headers de sécurité # Headers de sécurité
<IfModule mod_headers.c> <IfModule mod_headers.c>
Header always set Content-Security-Policy "style-src 'self' 'unsafe-inline'; img-src 'self' data: https://gade.o-k-i.net https://kute.o-k-i.net https://bokante.o-k-i.net; font-src 'self'; connect-src 'self'; media-src 'self' https://gade.o-k-i.net https://kute.o-k-i.net https://bokante.o-k-i.net; frame-src https://gade.o-k-i.net; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'" Header always set Content-Security-Policy "style-src 'self' 'unsafe-inline'; img-src 'self' data: https://gade.o-k-i.net https://kute.o-k-i.net https://bokante.o-k-i.net https://mizik.o-k-i.net; font-src 'self'; connect-src 'self'; media-src 'self' https://gade.o-k-i.net https://kute.o-k-i.net https://bokante.o-k-i.net https://mizik.o-k-i.net; frame-src https://gade.o-k-i.net; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
Header always set Strict-Transport-Security "max-age=31536000" Header always set Strict-Transport-Security "max-age=31536000"
Header always set X-Frame-Options "DENY" Header always set X-Frame-Options "DENY"
Header always set X-Content-Type-Options "nosniff" Header always set X-Content-Type-Options "nosniff"
+1 -1
View File
@@ -1,5 +1,5 @@
/* /*
Content-Security-Policy: style-src 'self' 'unsafe-inline'; img-src 'self' data: https://gade.o-k-i.net https://kute.o-k-i.net https://bokante.o-k-i.net; font-src 'self'; connect-src 'self'; media-src 'self' https://gade.o-k-i.net https://kute.o-k-i.net https://bokante.o-k-i.net; frame-src https://gade.o-k-i.net; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none' Content-Security-Policy: style-src 'self' 'unsafe-inline'; img-src 'self' data: https://gade.o-k-i.net https://kute.o-k-i.net https://bokante.o-k-i.net https://mizik.o-k-i.net; font-src 'self'; connect-src 'self'; media-src 'self' https://gade.o-k-i.net https://kute.o-k-i.net https://bokante.o-k-i.net https://mizik.o-k-i.net; frame-src https://gade.o-k-i.net; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'
Strict-Transport-Security: max-age=31536000 Strict-Transport-Security: max-age=31536000
X-Frame-Options: DENY X-Frame-Options: DENY
X-Content-Type-Options: nosniff X-Content-Type-Options: nosniff