forked from ORGANISATION-KA-INTERNATIONALE/FEDIVERSE-OKI
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:
@@ -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)`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
{#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>
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user