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;
for (const file of walk(BUILD_DIR)) {
@@ -28,7 +42,7 @@ for (const file of walk(BUILD_DIR)) {
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 anchor = html.match(/<meta charset="[^"]+"\s*\/?>/);
@@ -41,4 +55,4 @@ for (const file of walk(BUILD_DIR)) {
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">
import { onMount } from 'svelte';
import { claimAudio, releaseAudio } from '$lib/audio-bus.svelte';
import { formatDate, formatDuration, t as format, type Bundle } from '$lib/i18n';
import type { Episode, Locale } from '$lib/types';
import Icon from './Icon.svelte';
@@ -7,11 +8,13 @@
let {
episodes,
locale,
t
t,
playTemplate = undefined
}: {
episodes: Episode[];
locale: Locale;
t: Bundle;
playTemplate?: string;
} = $props();
let audio: HTMLAudioElement;
@@ -56,6 +59,7 @@
if (index < 0 || index >= episodes.length) return;
currentIndex = index;
const episode = episodes[index];
claimAudio(audio);
audio.src = episode.audioUrl;
audio.play();
updateMediaSession(episode);
@@ -72,6 +76,7 @@
}
function onEnded() {
releaseAudio(audio);
if (currentIndex + 1 < episodes.length) {
playAt(currentIndex + 1);
} else {
@@ -90,7 +95,10 @@
bind:this={audio}
preload="none"
onplay={() => (playing = true)}
onpause={() => (playing = false)}
onpause={() => {
playing = false;
releaseAudio(audio);
}}
onended={onEnded}
ontimeupdate={() => (currentTime = audio.currentTime)}
ondurationchange={() => (duration = audio.duration)}
@@ -119,7 +127,11 @@
</a>
</h3>
<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}
</p>
</div>
@@ -128,7 +140,7 @@
onclick={() => toggle(i)}
aria-label={currentIndex === i && playing
? 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'} />
</button>
+8
View File
@@ -75,6 +75,14 @@ export const mastodon = {
maxPosts: 10
} 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 = {
mastodon: mastodon.accountUrl,
facebook: '',
+6
View File
@@ -43,6 +43,9 @@
"trending": "Trending",
"podcast": "Latest episodes",
"podcastSubtitle": "The audio podcast on KUTE — Castopod",
"music": "Tracks",
"musicSubtitle": "Random selection on MIZIK — Funkwhale",
"seeAllTracks": "Explore MIZIK",
"timeline": "News",
"timelineSubtitle": "The Mastodon timeline on BOKANTE",
"seeMore": "See more",
@@ -104,6 +107,9 @@
"mute": "Mute",
"unmute": "Unmute"
},
"music": {
"play": "Play {title}"
},
"timeline": {
"reload": "Refresh",
"postFrom": "Post from {date}",
+6
View File
@@ -43,6 +43,9 @@
"trending": "Tendances",
"podcast": "Derniers épisodes",
"podcastSubtitle": "Le podcast audio sur KUTE — Castopod",
"music": "Morceaux",
"musicSubtitle": "Sélection aléatoire sur MIZIK — Funkwhale",
"seeAllTracks": "Explorer MIZIK",
"timeline": "Actualités",
"timelineSubtitle": "La timeline Mastodon sur BOKANTE",
"seeMore": "Voir plus",
@@ -104,6 +107,9 @@
"mute": "Couper le son",
"unmute": "Rétablir le son"
},
"music": {
"play": "Lire {title}"
},
"timeline": {
"reload": "Rafraîchir",
"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 { getFunkwhaleTracks } from '$lib/server/funkwhale';
import { getMastodonPosts } from '$lib/server/mastodon';
import {
getLiveStream,
@@ -15,12 +16,13 @@ export const entries: EntryGenerator = () => [{}, { locale: 'en' }];
export const load: PageServerLoad = async ({ params }) => {
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(),
getRecentVideos(),
getTrendingVideos(),
getShorts(),
castopod.enabled ? getCastopodEpisodes() : Promise.resolve([]),
funkwhale.enabled ? getFunkwhaleTracks() : Promise.resolve([]),
getMastodonPosts(),
Promise.all(
categories.map(async (category) => ({
@@ -37,6 +39,7 @@ export const load: PageServerLoad = async ({ params }) => {
trending,
shorts,
episodes,
tracks,
posts,
categorySections: categorySections.filter((section) => section.videos.length > 0)
};
+17 -1
View File
@@ -1,5 +1,5 @@
<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 AudioPlayer from '$lib/components/AudioPlayer.svelte';
import HeroSection from '$lib/components/HeroSection.svelte';
@@ -73,6 +73,22 @@
</section>
{/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}
<section class="section" aria-labelledby="timeline-heading">
<div class="section-header" use:reveal={0}>
+1 -1
View File
@@ -19,7 +19,7 @@ ErrorDocument 404 /404.html
# Headers de sécurité
<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 X-Frame-Options "DENY"
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
X-Frame-Options: DENY
X-Content-Type-Options: nosniff