4 Commits
Author SHA1 Message Date
cedric 4cc5c405bb feat: add ayisyen
Déploiement FRONT BETA / check (push) Successful in 2m12s
Déploiement FRONT PROD / check (push) Successful in 2m14s
Déploiement FRONT BETA / deploy (push) Successful in 31s
Déploiement FRONT PROD / deploy (push) Successful in 49s
2026-08-11 07:31:20 +04:00
cedric c8841a253f fix: rendre le proxy MIZIK plus robuste et tolérer l'ancien nommage des credentials
Déploiement FRONT BETA / check (push) Successful in 2m18s
Déploiement FRONT PROD / check (push) Successful in 2m8s
Déploiement FRONT BETA / deploy (push) Successful in 25s
Déploiement FRONT PROD / deploy (push) Successful in 21s
2026-07-15 23:04:19 +04:00
cedric 4e6341c5d5 refactor: renommer middleware.js en proxy.js pour Next.js 16
Déploiement FRONT BETA / check (push) Successful in 2m8s
Déploiement FRONT PROD / check (push) Successful in 2m15s
Déploiement FRONT BETA / deploy (push) Successful in 24s
Déploiement FRONT PROD / deploy (push) Successful in 23s
2026-07-08 00:29:26 +04:00
cedric b5a3b278ba feat: ajouter les en-têtes de sécurité pour atteindre 100/100 sur MDN Observatory 2026-07-08 00:27:57 +04:00
11 changed files with 220 additions and 15 deletions
@@ -4,6 +4,7 @@ import {GET} from '../route'
function fakeUpstreamResponse({status = 200, headers = {}, body = 'audio-bytes'} = {}) {
return {
status,
ok: status >= 200 && status < 300,
body,
headers: {
get: key => headers[key.toLowerCase()] ?? null
@@ -52,4 +53,16 @@ describe('GET /api/mizik-stream/[id]', () => {
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('audio/mpeg')
})
it('renvoie une erreur JSON si le serveur audio répond en erreur', async () => {
global.fetch = vi.fn(async () => fakeUpstreamResponse({status: 401, headers: {'content-type': 'application/json'}, body: '{"error": "unauthorized"}'}))
const request = new Request('http://localhost/api/mizik-stream/7')
const response = await GET(request, {params: Promise.resolve({id: '7'})})
expect(response.status).toBe(401)
expect(response.headers.get('content-type')).toContain('application/json')
const json = await response.json()
expect(json.error).toContain('401')
})
})
+10 -2
View File
@@ -1,6 +1,8 @@
const MIZIK_URL = process.env.NEXT_PUBLIC_OKI_MIZIK_URL || 'https://funkwhale-server.com'
const MIZIK_API_USER = process.env.MIZIK_API_USER || 'user'
const MIZIK_API_PASSWORD = process.env.MIZIK_API_PASSWORD || 'password'
// Les variables publiques sont conservées en fallback pendant la migration
// des déploiements qui utiliseraient encore lancien nommage.
const MIZIK_API_USER = process.env.MIZIK_API_USER || process.env.NEXT_PUBLIC_MIZIK_API_USER || 'user'
const MIZIK_API_PASSWORD = process.env.MIZIK_API_PASSWORD || process.env.NEXT_PUBLIC_MIZIK_API_PASSWORD || 'password'
const FORWARDED_HEADERS = ['content-type', 'content-length', 'content-range', 'accept-ranges']
@@ -14,6 +16,12 @@ export async function GET(request, props) {
headers: range ? {range} : {}
})
if (!upstreamResponse.ok) {
const message = `Le serveur audio a répondu ${upstreamResponse.status}`
console.error(message, upstreamUrl.replace(/p=[^&]+/, 'p=***'))
return Response.json({error: message}, {status: upstreamResponse.status})
}
const headers = new Headers()
for (const key of FORWARDED_HEADERS) {
const value = upstreamResponse.headers.get(key)
+4
View File
@@ -1,3 +1,4 @@
import {headers} from 'next/headers'
import {notFound} from 'next/navigation'
import Box from '@mui/material/Box'
@@ -67,6 +68,8 @@ export default async function AwtisPajSlug(props) {
const params = await props.params
const {slug} = params
const anAwtis = await jwennAwtis(slug)
const requestHeaders = await headers()
const nonce = requestHeaders.get('x-nonce') || undefined
const {photo} = anAwtis
const kuvetiFormat = formatKuveti(photo)
@@ -91,6 +94,7 @@ export default async function AwtisPajSlug(props) {
<section>
<script
type='application/ld+json'
nonce={nonce}
dangerouslySetInnerHTML={{__html: JSON.stringify(jsonLd)}}
/>
</section>
+6 -3
View File
@@ -7,10 +7,10 @@ import {useServerInsertedHTML} from 'next/navigation'
import {CacheProvider as DefaultCacheProvider} from '@emotion/react'
export default function NextAppDirEmotionCacheProvider(props) {
const {options, CacheProvider = DefaultCacheProvider, children} = props
const {options, CacheProvider = DefaultCacheProvider, children, nonce} = props
const [registry] = React.useState(() => {
const cache = createCache(options)
const cache = createCache({...options, nonce})
cache.compat = true
const prevInsert = cache.insert
let inserted = []
@@ -65,12 +65,14 @@ export default function NextAppDirEmotionCacheProvider(props) {
<style
key={name}
dangerouslySetInnerHTML={{__html: style}}
nonce={nonce}
data-emotion={`${registry.cache.key}-global ${name}`}
/>
))}
{styles !== '' && (
<style
dangerouslySetInnerHTML={{__html: styles}}
nonce={nonce}
data-emotion={dataEmotionAttribute}
/>
)}
@@ -84,5 +86,6 @@ export default function NextAppDirEmotionCacheProvider(props) {
NextAppDirEmotionCacheProvider.propTypes = {
options: PropTypes.object.isRequired,
CacheProvider: PropTypes.func,
children: PropTypes.node.isRequired
children: PropTypes.node.isRequired,
nonce: PropTypes.string
}
+8 -3
View File
@@ -1,3 +1,4 @@
import {headers} from 'next/headers'
import PlausibleProvider from 'next-plausible'
import TopLoader from '../components/top-loader'
import Navigasyon from '../components/navigasyon'
@@ -61,16 +62,20 @@ const jsonLd = {
}
export default async function RootLayout({children}) {
const requestHeaders = await headers()
const nonce = requestHeaders.get('x-nonce') || undefined
const inner = (
<>
<TopLoader color='#ffeb3b' />
<ThemeRegistry>
<TopLoader color='#ffeb3b' nonce={nonce} />
<ThemeRegistry nonce={nonce}>
<Navigasyon />
{children}
</ThemeRegistry>
<section>
<script
type='application/ld+json'
nonce={nonce}
dangerouslySetInnerHTML={{__html: JSON.stringify(jsonLd)}}
/>
</section>
@@ -81,7 +86,7 @@ export default async function RootLayout({children}) {
<html suppressHydrationWarning lang='fr'>
<body>
{plausibleUrl
? <PlausibleProvider src={plausibleUrl}>{inner}</PlausibleProvider>
? <PlausibleProvider src={plausibleUrl} scriptProps={{nonce}}>{inner}</PlausibleProvider>
: inner}
</body>
</html>
+4
View File
@@ -1,3 +1,4 @@
import {headers} from 'next/headers'
import {notFound} from 'next/navigation'
import Box from '@mui/material/Box'
@@ -71,6 +72,8 @@ export default async function AnPawolPaj(props) {
const {slug} = params
const anTeks = await jwennAnTeks(slug)
const requestHeaders = await headers()
const nonce = requestHeaders.get('x-nonce') || undefined
const {couverture} = anTeks
const teksKuvetiFormat = formatKuveti(couverture)
@@ -126,6 +129,7 @@ export default async function AnPawolPaj(props) {
<section>
<script
type='application/ld+json'
nonce={nonce}
dangerouslySetInnerHTML={{__html: JSON.stringify(jsonLd)}}
/>
</section>
+5 -4
View File
@@ -64,12 +64,12 @@ const theme = createTheme({
})
export default function ThemeRegistry(props) {
const {children} = props
const {children, nonce} = props
return (
<>
<InitColorSchemeScript attribute='class' />
<NextAppDirEmotionCacheProvider options={{key: 'mui'}}>
<InitColorSchemeScript attribute='class' nonce={nonce} />
<NextAppDirEmotionCacheProvider options={{key: 'mui'}} nonce={nonce}>
<ThemeProvider theme={theme}>
<CssBaseline enableColorScheme />
<ChanjeTem />
@@ -81,5 +81,6 @@ export default function ThemeRegistry(props) {
}
ThemeRegistry.propTypes = {
children: PropTypes.node.isRequired
children: PropTypes.node.isRequired,
nonce: PropTypes.string
}
+1
View File
@@ -99,6 +99,7 @@ const CONTINENTS = ['Afrique', 'Asie', 'Europe']
const TRAD_FIELDS = [
// Afrique — langues vedettes en premier
{field: 'ayisyen', title: 'Ayisyen 🇭🇹', continent: 'Afrique', vedette: true},
{field: 'yoruba', title: 'Yorùbá', continent: 'Afrique', vedette: true},
{field: 'lingala', title: 'Lingála', continent: 'Afrique', vedette: true},
{field: 'wolof', title: 'Wolof', continent: 'Afrique', vedette: true},
+7 -2
View File
@@ -3,8 +3,13 @@
import PropTypes from 'prop-types'
import NextTopLoader from 'nextjs-toploader'
export default function TopLoader({color}) {
return <NextTopLoader color={color} />
export default function TopLoader({color, nonce}) {
return <NextTopLoader color={color} nonce={nonce} />
}
TopLoader.propTypes = {
color: PropTypes.string.isRequired,
nonce: PropTypes.string
}
TopLoader.propTypes = {
+41 -1
View File
@@ -28,9 +28,49 @@ function buildRemotePatterns() {
return patterns
}
async function headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload'
},
{
key: 'X-Content-Type-Options',
value: 'nosniff'
},
{
key: 'X-Frame-Options',
value: 'SAMEORIGIN'
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin'
},
{
key: 'Cross-Origin-Resource-Policy',
value: 'same-site'
},
{
key: 'Cross-Origin-Opener-Policy',
value: 'same-origin'
},
{
key: 'Permissions-Policy',
value: 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()'
}
]
}
]
}
module.exports = (withPWA({
turbopack: {},
poweredByHeader: false,
images: {
remotePatterns: buildRemotePatterns()
}
},
headers
}))
+121
View File
@@ -0,0 +1,121 @@
/* eslint-disable @next/next/no-server-import-in-page */
import {NextResponse} from 'next/server'
const nonceCharset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
function generateNonce() {
const array = new Uint8Array(16)
crypto.getRandomValues(array)
let nonce = ''
for (const byte of array) {
nonce += nonceCharset[byte % 64]
}
return nonce
}
function extractOrigin(url) {
if (!url) {
return ''
}
try {
return new URL(url).origin
} catch {
return ''
}
}
function extractHostSource(raw) {
if (!raw) {
return ''
}
const [hostname, port] = raw.split(':')
if (!hostname) {
return ''
}
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return `http://${hostname}${port ? `:${port}` : ''}`
}
return `https://${hostname}${port ? `:${port}` : ''}`
}
function buildCsp(nonce) {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || process.env.NEXT_PUBLIC_API_URL_ROOT || ''
const plausibleUrl = process.env.NEXT_PUBLIC_PLAUSIBLE_URL || ''
const mizikUrl = process.env.NEXT_PUBLIC_OKI_MIZIK_URL || ''
const imageDomainsRaw = process.env.NEXT_PUBLIC_DOMAINS_IMAGE || ''
const apiOrigin = extractOrigin(apiUrl)
const plausibleOrigin = extractOrigin(plausibleUrl)
const mizikOrigin = extractOrigin(mizikUrl)
const imageOrigins = imageDomainsRaw
.split(' ')
.filter(Boolean)
.map(entry => extractHostSource(entry))
.filter(Boolean)
const imageOriginsSet = new Set([apiOrigin, ...imageOrigins])
const imageOriginsUnique = [...imageOriginsSet].filter(Boolean)
const connectSrc = ['\'self\'', apiOrigin, plausibleOrigin, mizikOrigin].filter(Boolean)
const imgSrc = ['\'self\'', 'data:', 'blob:', ...imageOriginsUnique].filter(Boolean)
const mediaSrc = ['\'self\'', apiOrigin, mizikOrigin].filter(Boolean)
// Frame-src is intentionally broad: the application embeds user-supplied
// PeerTube instances in addition to known platforms (Tidal, Deezer, Spotify,
// Soundcloud, Apple Music). Restricting this to a fixed list would break
// user-generated content.
const frameSrc = ['\'self\'', 'https:']
const directives = [
'default-src \'self\'',
`script-src 'nonce-${nonce}' 'strict-dynamic'`,
`style-src-elem 'self' 'nonce-${nonce}'`,
'style-src-attr \'unsafe-inline\'',
`img-src ${imgSrc.join(' ')}`,
'font-src \'self\'',
`connect-src ${connectSrc.join(' ')}`,
`frame-src ${frameSrc.join(' ')}`,
`media-src ${mediaSrc.join(' ')}`,
'manifest-src \'self\'',
'object-src \'none\'',
'base-uri \'self\'',
'form-action \'self\'',
'frame-ancestors \'self\'',
'upgrade-insecure-requests'
]
return directives.join('; ')
}
export function proxy(request) {
const nonce = generateNonce()
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-nonce', nonce)
const response = NextResponse.next({
request: {
headers: requestHeaders
}
})
response.headers.set('Content-Security-Policy', buildCsp(nonce))
return response
}
export const config = {
matcher: [
{
source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
missing: [
{type: 'header', key: 'next-router-prefetch'},
{type: 'header', key: 'purpose', value: 'prefetch'}
]
}
]
}