Compare commits
14
Commits
ce19fa3b5d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cc5c405bb
|
||
|
|
c8841a253f
|
||
|
|
4e6341c5d5
|
||
|
|
b5a3b278ba
|
||
|
|
99daeb014c
|
||
|
|
f5a4014e41
|
||
|
|
d66c679fd1
|
||
|
|
cab293d62e | ||
|
|
5936de59ff
|
||
|
|
6804e3626f
|
||
|
|
461b063cff
|
||
|
|
6601b00545
|
||
|
|
f88b0184ea
|
||
|
|
43964ec91d
|
+1
-1
@@ -3,7 +3,7 @@ API_URL=http://localhost:1337/api
|
||||
NEXT_PUBLIC_API_URL=$API_URL
|
||||
NEXT_PUBLIC_API_URL_ROOT=http://localhost:1337
|
||||
SITE_URL=http://localhost:3001
|
||||
NEXT_PUBLIC_READ_TOKEN=
|
||||
API_READ_TOKEN=
|
||||
|
||||
# IDENTITÉ DU SITE (branding)
|
||||
NEXT_PUBLIC_SITE_NAME=PAWÒL-NU. Paroles et traductions.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import {describe, it, expect, vi, afterEach} from 'vitest'
|
||||
import {jwennToutAwtis} from '../../../../lib/oki-api'
|
||||
import {GET} from '../route'
|
||||
|
||||
vi.mock('../../../../lib/oki-api', () => ({
|
||||
jwennToutAwtis: vi.fn()
|
||||
}))
|
||||
|
||||
describe('GET /api/awtis', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renvoie les données de jwennToutAwtis en JSON, sans exposer de jeton', async () => {
|
||||
jwennToutAwtis.mockResolvedValue({data: [{id: 1, alias: 'Foo'}], meta: {}})
|
||||
|
||||
const response = await GET()
|
||||
const body = await response.json()
|
||||
|
||||
expect(body).toEqual({data: [{id: 1, alias: 'Foo'}], meta: {}})
|
||||
expect(JSON.stringify(body)).not.toMatch(/bearer|token/i)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import {jwennToutAwtis} from '../../../lib/oki-api'
|
||||
|
||||
export async function GET() {
|
||||
const data = await jwennToutAwtis()
|
||||
return Response.json(data)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 l’ancien 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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import Autocomplete from '@mui/material/Autocomplete'
|
||||
import Avatar from '@mui/material/Avatar'
|
||||
import Container from '@mui/material/Container'
|
||||
|
||||
import {jwennToutAwtis} from '../../lib/oki-api'
|
||||
import {formatKuveti} from '../../lib/kuveti'
|
||||
|
||||
const IMAGE_URL = process.env.NEXT_PUBLIC_API_URL_ROOT || 'http://localhost:1337'
|
||||
@@ -27,7 +26,7 @@ export default function ChecheAwtis() {
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const {data} = await jwennToutAwtis()
|
||||
const {data} = await fetch('/api/awtis').then(response => response.json())
|
||||
|
||||
const filteredData = data.map(artiste => {
|
||||
const firstLetter = artiste.alias[0].toUpperCase()
|
||||
|
||||
@@ -15,19 +15,36 @@ describe('ReponsMastodon', () => {
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('affiche un lien direct vers le fil bokante', () => {
|
||||
it('affiche un lien direct vers le fil BOKANTE', () => {
|
||||
render(<ReponsMastodon bokanteStatusId='112233' />)
|
||||
const lien = screen.getByRole('link', {name: 'Mastodon'})
|
||||
const lien = screen.getByRole('link', {name: 'BOKANTE (Mastodon)'})
|
||||
expect(lien).toHaveAttribute('href', 'https://bokante.o-k-i.net/@pawol_nu/112233')
|
||||
})
|
||||
|
||||
it('redirige vers authorize_interaction avec le domaine extrait d\'un identifiant complet', async () => {
|
||||
it('redirige vers BOKANTE par défaut quand on continue', async () => {
|
||||
window.open = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<ReponsMastodon bokanteStatusId='112233' />)
|
||||
|
||||
await user.click(screen.getByRole('button', {name: /répondre via mastodon/i}))
|
||||
await user.type(screen.getByLabelText(/@vous@votre-instance/i), '@quelqun@mastodon.social')
|
||||
await user.click(screen.getByRole('button', {name: /répondre sur mastodon/i}))
|
||||
await user.click(screen.getByRole('button', {name: /continuer/i}))
|
||||
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://bokante.o-k-i.net/authorize_interaction?uri=' + encodeURIComponent('https://bokante.o-k-i.net/@pawol_nu/112233'),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
})
|
||||
|
||||
it('permet de choisir une autre instance dans le select', async () => {
|
||||
window.open = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<ReponsMastodon bokanteStatusId='112233' />)
|
||||
|
||||
await user.click(screen.getByRole('button', {name: /répondre sur mastodon/i}))
|
||||
await user.click(screen.getByLabelText(/instance mastodon/i))
|
||||
await user.click(screen.getByRole('option', {name: /autre instance/i}))
|
||||
await user.type(screen.getByLabelText(/@vous@votre-instance/i), 'mastodon.social')
|
||||
await user.click(screen.getByRole('button', {name: /continuer/i}))
|
||||
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
@@ -37,17 +54,19 @@ describe('ReponsMastodon', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('accepte aussi juste un nom d\'instance sans @', async () => {
|
||||
it('accepte un identifiant complet pour une autre instance', async () => {
|
||||
window.open = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<ReponsMastodon bokanteStatusId='112233' />)
|
||||
|
||||
await user.click(screen.getByRole('button', {name: /répondre via mastodon/i}))
|
||||
await user.type(screen.getByLabelText(/@vous@votre-instance/i), 'mastodon.social')
|
||||
await user.click(screen.getByRole('button', {name: /répondre sur mastodon/i}))
|
||||
await user.click(screen.getByLabelText(/instance mastodon/i))
|
||||
await user.click(screen.getByRole('option', {name: /autre instance/i}))
|
||||
await user.type(screen.getByLabelText(/@vous@votre-instance/i), '@quelqun@mastodon.social')
|
||||
await user.click(screen.getByRole('button', {name: /continuer/i}))
|
||||
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
expect.stringContaining('https://mastodon.social/authorize_interaction'),
|
||||
'https://mastodon.social/authorize_interaction?uri=' + encodeURIComponent('https://bokante.o-k-i.net/@pawol_nu/112233'),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
|
||||
@@ -10,11 +10,24 @@ import DialogContent from '@mui/material/DialogContent'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import Link from 'next/link'
|
||||
import FormControl from '@mui/material/FormControl'
|
||||
import InputLabel from '@mui/material/InputLabel'
|
||||
import Select from '@mui/material/Select'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
import Link from '@mui/material/Link'
|
||||
import {Mastodon} from '@icons-pack/react-simple-icons'
|
||||
|
||||
const BOKANTE_URL = process.env.NEXT_PUBLIC_BOKANTE_URL || 'https://bokante.o-k-i.net'
|
||||
const BOKANTE_ACCOUNT = process.env.NEXT_PUBLIC_BOKANTE_ACCOUNT || 'pawol_nu'
|
||||
|
||||
const INSTANCES_SUGGEREES = [
|
||||
{domain: 'bokante.o-k-i.net', label: 'BOKANTE - notre instance'},
|
||||
{domain: 'mastodon.social', label: 'mastodon.social'},
|
||||
{domain: 'piaille.fr', label: 'piaille.fr'},
|
||||
]
|
||||
|
||||
const AUTRE_INSTANCE = 'autre'
|
||||
|
||||
function extraireInstance(saisie) {
|
||||
const valeur = saisie.trim().replace(/^@/, '').replace(/^https?:\/\//, '').replace(/\/$/, '')
|
||||
return valeur.includes('@') ? valeur.split('@').pop() : valeur
|
||||
@@ -22,7 +35,8 @@ function extraireInstance(saisie) {
|
||||
|
||||
export default function ReponsMastodon({bokanteStatusId}) {
|
||||
const [ouvert, setOuvert] = useState(false)
|
||||
const [saisie, setSaisie] = useState('')
|
||||
const [instance, setInstance] = useState(INSTANCES_SUGGEREES[0].domain)
|
||||
const [autreInstance, setAutreInstance] = useState('')
|
||||
|
||||
if (!bokanteStatusId) {
|
||||
return null
|
||||
@@ -31,43 +45,92 @@ export default function ReponsMastodon({bokanteStatusId}) {
|
||||
const tootUrl = `${BOKANTE_URL}/@${BOKANTE_ACCOUNT}/${bokanteStatusId}`
|
||||
|
||||
const repondre = () => {
|
||||
const instance = extraireInstance(saisie)
|
||||
if (!instance) {
|
||||
const domaine = instance === AUTRE_INSTANCE ? extraireInstance(autreInstance) : instance
|
||||
if (!domaine) {
|
||||
return
|
||||
}
|
||||
|
||||
window.open(`https://${instance}/authorize_interaction?uri=${encodeURIComponent(tootUrl)}`, '_blank', 'noopener,noreferrer')
|
||||
window.open(`https://${domaine}/authorize_interaction?uri=${encodeURIComponent(tootUrl)}`, '_blank', 'noopener,noreferrer')
|
||||
setOuvert(false)
|
||||
setAutreInstance('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{maxWidth: 700, margin: '1em auto 0', textAlign: 'center'}}>
|
||||
<Typography variant='body2' sx={{mb: 1}}>
|
||||
Rejoignez la discussion sur{' '}
|
||||
<Link href={tootUrl} target='_blank' rel='noopener noreferrer'>Mastodon</Link>
|
||||
Rejoignez la discussion sur le Fediverse via{' '}
|
||||
<Link
|
||||
href={tootUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
underline='hover'
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
borderRadius: 1,
|
||||
bgcolor: 'action.hover',
|
||||
color: 'primary.main',
|
||||
fontWeight: 500,
|
||||
textDecoration: 'none',
|
||||
'&:hover': {
|
||||
bgcolor: 'action.selected'
|
||||
}
|
||||
}}
|
||||
>
|
||||
BOKANTE (Mastodon)
|
||||
</Link>
|
||||
</Typography>
|
||||
<Button variant='outlined' size='small' onClick={() => setOuvert(true)}>
|
||||
Répondre via Mastodon
|
||||
<Button
|
||||
variant='outlined'
|
||||
size='small'
|
||||
startIcon={<Mastodon size={16} color='#6364FF' title='' />}
|
||||
onClick={() => setOuvert(true)}
|
||||
>
|
||||
Répondre sur Mastodon
|
||||
</Button>
|
||||
<Dialog open={ouvert} onClose={() => setOuvert(false)}>
|
||||
<DialogTitle>Répondre depuis votre compte Mastodon</DialogTitle>
|
||||
<DialogTitle>Répondre depuis Mastodon</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant='body2' sx={{mb: 2}}>
|
||||
Indiquez votre identifiant (@vous@votre-instance) ou juste le nom de votre
|
||||
instance, vous serez redirigé·e vers votre compte pour répondre.
|
||||
Choisissez l’instance Mastodon sur laquelle vous avez un compte.
|
||||
BOKANTE est l’instance de OKI : vous pouvez aussi utiliser
|
||||
n’importe quelle autre instance du Fediverse.
|
||||
</Typography>
|
||||
<FormControl fullWidth sx={{mb: 2}}>
|
||||
<InputLabel id='instance-mastodon-label'>Instance Mastodon</InputLabel>
|
||||
<Select
|
||||
labelId='instance-mastodon-label'
|
||||
id='instance-mastodon'
|
||||
value={instance}
|
||||
label='Instance Mastodon'
|
||||
onChange={event => setInstance(event.target.value)}
|
||||
>
|
||||
{INSTANCES_SUGGEREES.map(({domain, label}) => (
|
||||
<MenuItem key={domain} value={domain}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuItem value={AUTRE_INSTANCE}>Autre instance…</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{instance === AUTRE_INSTANCE && (
|
||||
<TextField
|
||||
autoFocus
|
||||
fullWidth
|
||||
label='@vous@votre-instance'
|
||||
value={saisie}
|
||||
onChange={event => setSaisie(event.target.value)}
|
||||
helperText='Indiquez votre identifiant (@vous@votre-instance) ou juste le nom de votre instance, vous serez redirigé·e vers votre compte pour répondre.'
|
||||
value={autreInstance}
|
||||
onChange={event => setAutreInstance(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
repondre()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOuvert(false)}>Annuler</Button>
|
||||
|
||||
+91
-23
@@ -3,7 +3,11 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Box from '@mui/material/Box'
|
||||
import Chip from '@mui/material/Chip'
|
||||
import Select from '@mui/material/Select'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
import ListSubheader from '@mui/material/ListSubheader'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import FormControl from '@mui/material/FormControl'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import {useMediaQuery} from '@mui/material'
|
||||
@@ -12,6 +16,9 @@ import slugify from 'slugify'
|
||||
|
||||
import {styled} from '@mui/material/styles'
|
||||
import ExplicitIcon from '@mui/icons-material/Explicit'
|
||||
import TranslateIcon from '@mui/icons-material/Translate'
|
||||
import StarIcon from '@mui/icons-material/Star'
|
||||
import ArticleIcon from '@mui/icons-material/Article'
|
||||
|
||||
import Image from 'next/image'
|
||||
|
||||
@@ -88,15 +95,35 @@ const LANG_NAMES = {
|
||||
pt: 'Português', ja: '日本語', ko: '한국어',
|
||||
}
|
||||
|
||||
const CONTINENTS = ['Afrique', 'Asie', 'Europe']
|
||||
|
||||
const TRAD_FIELDS = [
|
||||
{field: 'anglais', title: 'English'},
|
||||
{field: 'francais', title: 'Français'},
|
||||
{field: 'espagnol', title: 'Español'},
|
||||
{field: 'allemand', title: 'Deutsch'},
|
||||
{field: 'italien', title: 'Italiano'},
|
||||
{field: 'portugais', title: 'Português'},
|
||||
{field: 'japonais', title: '日本語'},
|
||||
{field: 'coreen', title: '한국어'},
|
||||
// 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},
|
||||
{field: 'swahili', title: 'Kiswahili', continent: 'Afrique', vedette: true},
|
||||
{field: 'hausa', title: 'Hausa', continent: 'Afrique', vedette: true},
|
||||
{field: 'arabe', title: 'العربية', continent: 'Afrique'},
|
||||
{field: 'oromo', title: 'Oromoo', continent: 'Afrique'},
|
||||
{field: 'igbo', title: 'Igbo', continent: 'Afrique'},
|
||||
{field: 'zoulou', title: 'isiZulu', continent: 'Afrique'},
|
||||
{field: 'malgache', title: 'Malagasy', continent: 'Afrique'},
|
||||
{field: 'xhosa', title: 'isiXhosa', continent: 'Afrique'},
|
||||
{field: 'tswana', title: 'Setswana', continent: 'Afrique'},
|
||||
{field: 'tsonga', title: 'Xitsonga', continent: 'Afrique'},
|
||||
{field: 'sesotho', title: 'Sesotho', continent: 'Afrique'},
|
||||
// Asie
|
||||
{field: 'japonais', title: '日本語', continent: 'Asie'},
|
||||
{field: 'coreen', title: '한국어', continent: 'Asie'},
|
||||
// Europe
|
||||
{field: 'anglais', title: 'English', continent: 'Europe'},
|
||||
{field: 'francais', title: 'Français', continent: 'Europe'},
|
||||
{field: 'espagnol', title: 'Español', continent: 'Europe'},
|
||||
{field: 'allemand', title: 'Deutsch', continent: 'Europe'},
|
||||
{field: 'italien', title: 'Italiano', continent: 'Europe'},
|
||||
{field: 'portugais', title: 'Português', continent: 'Europe'},
|
||||
]
|
||||
|
||||
const langToArray = parole => {
|
||||
@@ -106,7 +133,7 @@ const langToArray = parole => {
|
||||
|
||||
return TRAD_FIELDS
|
||||
.filter(({field}) => parole.traductions[field])
|
||||
.map(({field, title}) => ({title, lang: parole.traductions[field]}))
|
||||
.map(({field, title, continent, vedette}) => ({field, title, continent, vedette, lang: parole.traductions[field]}))
|
||||
}
|
||||
|
||||
const ExplicitTooltip = Tooltip
|
||||
@@ -118,7 +145,6 @@ export default function Teks({parole}) {
|
||||
const coverFmt = formatKuveti(parole.couverture)
|
||||
const [tab, setTab] = useState(0)
|
||||
const [, setCurrentTrack] = useCurrentTrack()
|
||||
const options = ['Transcription', ...langArray.map(({title}) => title)]
|
||||
|
||||
useEffect(() => {
|
||||
const isBrowser = () => typeof window !== 'undefined'
|
||||
@@ -228,18 +254,60 @@ export default function Teks({parole}) {
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{maxWidth: 700, margin: '0 auto'}} className={classes.gridText}>
|
||||
<Box sx={{display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: 1, borderBottom: 1, borderColor: 'divider', mb: 2, pb: 2}}>
|
||||
{options.map((label, index) => (
|
||||
<Chip
|
||||
key={label}
|
||||
clickable
|
||||
color='primary'
|
||||
label={label}
|
||||
sx={{fontWeight: tab === index ? 600 : 400}}
|
||||
variant={tab === index ? 'filled' : 'outlined'}
|
||||
onClick={() => setTab(index)}
|
||||
/>
|
||||
))}
|
||||
<Box sx={{display: 'flex', justifyContent: 'center', borderBottom: 1, borderColor: 'divider', mb: 2, pb: 2}}>
|
||||
<FormControl size='small' sx={{minWidth: 240, width: isMobile ? '100%' : 'auto'}}>
|
||||
<Select
|
||||
value={tab}
|
||||
MenuProps={{PaperProps: {sx: {maxHeight: 420}}}}
|
||||
renderValue={value => {
|
||||
const label = value === 0 ? 'Transcription' : langArray[value - 1]?.title
|
||||
const Icon = value === 0 ? ArticleIcon : TranslateIcon
|
||||
return (
|
||||
<Box sx={{display: 'flex', alignItems: 'center', gap: 1, fontWeight: value === 0 ? 700 : 400}}>
|
||||
<Icon fontSize='small' color='primary' />
|
||||
{label}
|
||||
</Box>
|
||||
)
|
||||
}}
|
||||
onChange={event => setTab(event.target.value)}
|
||||
>
|
||||
<MenuItem value={0} sx={{display: 'flex', gap: 0.75, fontWeight: 700}}>
|
||||
<ArticleIcon fontSize='inherit' color='primary' />
|
||||
Transcription
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
{CONTINENTS.flatMap(continent => {
|
||||
const items = langArray
|
||||
.map((item, index) => ({...item, index}))
|
||||
.filter(item => item.continent === continent)
|
||||
|
||||
if (items.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
<ListSubheader
|
||||
key={`entet-${continent}`}
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
color: 'primary.main',
|
||||
lineHeight: '2.5em'
|
||||
}}
|
||||
>
|
||||
{continent}
|
||||
</ListSubheader>,
|
||||
...items.map(({title, index, vedette}) => (
|
||||
<MenuItem key={title} value={index + 1} sx={{display: 'flex', gap: 0.75}}>
|
||||
{vedette && <StarIcon fontSize='inherit' color='primary' />}
|
||||
{title}
|
||||
</MenuItem>
|
||||
))
|
||||
]
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
{tab === 0 && (
|
||||
<>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import qs from 'qs'
|
||||
|
||||
const OKI_API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:1337'
|
||||
const AWTIS_POU_CHAK_PAJ = process.env.NEXT_PUBLIC_AWTIS_POU_CHAK_PAJ || 6
|
||||
const readToken = process.env.NEXT_PUBLIC_READ_TOKEN || 'read-token'
|
||||
const readToken = process.env.API_READ_TOKEN || 'read-token'
|
||||
|
||||
const headers = {
|
||||
next: {revalidate: 60},
|
||||
|
||||
+41
-1
@@ -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
|
||||
}))
|
||||
|
||||
@@ -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'}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user