Compare commits
10 Commits
7b831d5bc4
...
42fb5f40f9
| Author | SHA1 | Date | |
|---|---|---|---|
|
42fb5f40f9
|
|||
|
60f5c8ae9c
|
|||
|
1109ceb2bb
|
|||
|
d4deaa7716
|
|||
|
43f1f6e9f2
|
|||
|
e75d2e1c53
|
|||
|
c4762c6437
|
|||
|
a25a610d73
|
|||
|
8016c26e32
|
|||
|
d8a771161c
|
@@ -0,0 +1,9 @@
|
|||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
yarn.lock
|
||||||
|
tasks/
|
||||||
|
*.md
|
||||||
@@ -19,3 +19,6 @@ COMMENTS_PER_PAGE=5
|
|||||||
|
|
||||||
# WEBSOCKET
|
# WEBSOCKET
|
||||||
NEXT_PUBLIC_DISABLE_WEBSOCKET=false
|
NEXT_PUBLIC_DISABLE_WEBSOCKET=false
|
||||||
|
|
||||||
|
# SENTRY
|
||||||
|
NEXT_PUBLIC_SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxx
|
||||||
|
|||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
# ─── Étape 1 : dépendances ───────────────────────────────────────────────────
|
||||||
|
FROM node:22-alpine AS deps
|
||||||
|
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# ─── Étape 2 : build de production ───────────────────────────────────────────
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Variables nécessaires au build (publiques uniquement — pas de secrets)
|
||||||
|
ARG NEXT_PUBLIC_DIRECTUS_API_URL
|
||||||
|
ARG NEXT_PUBLIC_DIRECTUS_API_WS_URL
|
||||||
|
ARG NEXT_PUBLIC_SENTRY_DSN
|
||||||
|
ARG SENTRY_AUTH_TOKEN
|
||||||
|
|
||||||
|
ENV NEXT_PUBLIC_DIRECTUS_API_URL=$NEXT_PUBLIC_DIRECTUS_API_URL
|
||||||
|
ENV NEXT_PUBLIC_DIRECTUS_API_WS_URL=$NEXT_PUBLIC_DIRECTUS_API_WS_URL
|
||||||
|
ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN
|
||||||
|
ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ─── Étape 3 : image de production minimale ──────────────────────────────────
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
|
# Utilisateur non-root pour la sécurité
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
|
adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
# Le mode standalone copie uniquement ce qui est nécessaire à l'exécution
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# server.js généré par output: 'standalone'
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable new-cap */
|
||||||
import CredentialsProvider from 'next-auth/providers/credentials'
|
import CredentialsProvider from 'next-auth/providers/credentials'
|
||||||
import {readMe, withToken} from '@directus/sdk'
|
import {readMe, withToken} from '@directus/sdk'
|
||||||
import {directusClient} from '@/lib/directus.js'
|
import {directusClient} from '@/lib/directus.js'
|
||||||
@@ -5,9 +6,39 @@ import {directusClient} from '@/lib/directus.js'
|
|||||||
const apiUrl = process.env.DIRECTUS_API_URL
|
const apiUrl = process.env.DIRECTUS_API_URL
|
||||||
const nextauthSecret = process.env.NEXTAUTH_SECRET
|
const nextauthSecret = process.env.NEXTAUTH_SECRET
|
||||||
|
|
||||||
|
// On rafraîchit 60s avant l'échéance réelle pour éviter les races
|
||||||
|
const REFRESH_MARGIN_MS = 60 * 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appelle l'endpoint Directus /auth/refresh et retourne les nouveaux tokens.
|
||||||
|
* Lève une erreur si le refresh échoue (refresh_token expiré ou révoqué).
|
||||||
|
*
|
||||||
|
* @param {string} refreshToken
|
||||||
|
* @returns {Promise<{accessToken: string, refreshToken: string, accessTokenExpires: number}>}
|
||||||
|
*/
|
||||||
|
async function refreshDirectusToken(refreshToken) {
|
||||||
|
const res = await fetch(`${apiUrl}/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({refresh_token: refreshToken, mode: 'json'}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Directus refresh failed with status ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const {data} = await res.json()
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: data.access_token,
|
||||||
|
refreshToken: data.refresh_token,
|
||||||
|
accessTokenExpires: Date.now() + (data.expires ?? 900_000) - REFRESH_MARGIN_MS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const options = {
|
export const options = {
|
||||||
providers: [
|
providers: [
|
||||||
CredentialsProvider({ // eslint-disable-line new-cap
|
CredentialsProvider({
|
||||||
name: 'Credentials',
|
name: 'Credentials',
|
||||||
credentials: {
|
credentials: {
|
||||||
email: {},
|
email: {},
|
||||||
@@ -36,18 +67,13 @@ export const options = {
|
|||||||
signIn: '/login'
|
signIn: '/login'
|
||||||
},
|
},
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async jwt({
|
async jwt({token, user, account}) {
|
||||||
token,
|
// Connexion initiale : enrichissement du token avec les données Directus
|
||||||
user,
|
|
||||||
account
|
|
||||||
}) {
|
|
||||||
if (account && user) {
|
if (account && user) {
|
||||||
const userData = await directusClient.request(
|
const userData = await directusClient.request(
|
||||||
withToken(
|
withToken(
|
||||||
user.data.access_token,
|
user.data.access_token,
|
||||||
readMe({
|
readMe({fields: ['*']})
|
||||||
fields: ['*']
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -55,11 +81,24 @@ export const options = {
|
|||||||
...token,
|
...token,
|
||||||
accessToken: user.data.access_token,
|
accessToken: user.data.access_token,
|
||||||
refreshToken: user.data.refresh_token,
|
refreshToken: user.data.refresh_token,
|
||||||
user: userData
|
accessTokenExpires: Date.now() + (user.data.expires ?? 900_000) - REFRESH_MARGIN_MS,
|
||||||
|
user: userData,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Token encore valide : on le retourne sans modification
|
||||||
|
if (Date.now() < token.accessTokenExpires) {
|
||||||
return token
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token expiré : tentative de rafraîchissement silencieux
|
||||||
|
try {
|
||||||
|
const refreshed = await refreshDirectusToken(token.refreshToken)
|
||||||
|
return {...token, ...refreshed}
|
||||||
|
} catch (refreshError) {
|
||||||
|
console.error('Refresh token Directus échoué :', refreshError.message)
|
||||||
|
return {...token, error: 'RefreshAccessTokenError'}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async session({session, token}) {
|
async session({session, token}) {
|
||||||
session.user.userId = token.user.id
|
session.user.userId = token.user.id
|
||||||
@@ -69,6 +108,7 @@ export const options = {
|
|||||||
session.user.token = token.user.token
|
session.user.token = token.user.token
|
||||||
session.user.email = token.user.email
|
session.user.email = token.user.email
|
||||||
session.user.password = token.user.password
|
session.user.password = token.user.password
|
||||||
|
session.error = token.error
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -1,4 +1,5 @@
|
|||||||
import {createDirectus, rest, readItems} from '@directus/sdk'
|
import {createDirectus, rest, readItems} from '@directus/sdk'
|
||||||
|
import {unstable_cache as nextCache} from 'next/cache'
|
||||||
import Container from '@mui/material/Container'
|
import Container from '@mui/material/Container'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
@@ -19,7 +20,7 @@ const navButton = {
|
|||||||
icon: <AdminPanelSettingsIcon fontSize='large' />
|
icon: <AdminPanelSettingsIcon fontSize='large' />
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getData() {
|
async function fetchConstitution() {
|
||||||
if (!apiUrl) {
|
if (!apiUrl) {
|
||||||
throw new Error('DIRECTUS_API_URL is required')
|
throw new Error('DIRECTUS_API_URL is required')
|
||||||
}
|
}
|
||||||
@@ -58,6 +59,14 @@ async function getData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mise en cache des données constitution — revalidation toutes les 5 minutes.
|
||||||
|
// Le tag 'constitution' permet une invalidation à la demande via revalidateTag().
|
||||||
|
const getData = nextCache(
|
||||||
|
fetchConstitution,
|
||||||
|
['constitution-data'],
|
||||||
|
{revalidate: 300, tags: ['constitution']}
|
||||||
|
)
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
const {titres, articles} = await getData()
|
const {titres, articles} = await getData()
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export default function AuthForm({
|
|||||||
endAdornment={
|
endAdornment={
|
||||||
<InputAdornment position='end'>
|
<InputAdornment position='end'>
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label='password visibility'
|
aria-label={showPassword ? 'Masquer le mot de passe' : 'Afficher le mot de passe'}
|
||||||
size='large'
|
size='large'
|
||||||
onClick={handleClickShowPassword}
|
onClick={handleClickShowPassword}
|
||||||
onMouseDown={handleMouseDownPassword}
|
onMouseDown={handleMouseDownPassword}
|
||||||
@@ -167,7 +167,7 @@ export default function AuthForm({
|
|||||||
endAdornment={
|
endAdornment={
|
||||||
<InputAdornment position='end'>
|
<InputAdornment position='end'>
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label='password visibility'
|
aria-label={showPasswordVerification ? 'Masquer la vérification du mot de passe' : 'Afficher la vérification du mot de passe'}
|
||||||
size='large'
|
size='large'
|
||||||
onClick={handleClickShowPasswordVerifiation}
|
onClick={handleClickShowPasswordVerifiation}
|
||||||
onMouseDown={handleMouseDownPasswordVerification}
|
onMouseDown={handleMouseDownPasswordVerification}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import {useEffect, useState, useMemo} from 'react'
|
import {useEffect, useState} from 'react'
|
||||||
import {signOut} from 'next-auth/react'
|
import {signOut} from 'next-auth/react'
|
||||||
import {useRouter} from 'next/navigation'
|
import {useRouter} from 'next/navigation'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
@@ -70,7 +70,7 @@ export default function Sign({session, navButton}) {
|
|||||||
console.log('WebSocket authenticated successfully!')
|
console.log('WebSocket authenticated successfully!')
|
||||||
|
|
||||||
// Subscribe to version changes
|
// Subscribe to version changes
|
||||||
;(async () => {
|
const startSubscription = async () => {
|
||||||
const {subscription} = await client.subscribe('directus_versions', {
|
const {subscription} = await client.subscribe('directus_versions', {
|
||||||
event: 'create',
|
event: 'create',
|
||||||
query: {
|
query: {
|
||||||
@@ -86,7 +86,9 @@ export default function Sign({session, navButton}) {
|
|||||||
for await (const item of subscription) {
|
for await (const item of subscription) {
|
||||||
console.log('New version created:', item)
|
console.log('New version created:', item)
|
||||||
}
|
}
|
||||||
})()
|
}
|
||||||
|
|
||||||
|
startSubscription().catch(error => console.error('Subscription error:', error))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -117,12 +119,12 @@ export default function Sign({session, navButton}) {
|
|||||||
{session ? (
|
{session ? (
|
||||||
<Stack direction='row' spacing={2}>
|
<Stack direction='row' spacing={2}>
|
||||||
<LightTooltip title='Se déconnecter' placement='right'>
|
<LightTooltip title='Se déconnecter' placement='right'>
|
||||||
<Fab size='large' color='error' onClick={() => setIsOpen(true)}>
|
<Fab size='large' color='error' aria-label='Se déconnecter' onClick={() => setIsOpen(true)}>
|
||||||
<LogoutIcon fontSize='large' />
|
<LogoutIcon fontSize='large' />
|
||||||
</Fab>
|
</Fab>
|
||||||
</LightTooltip>
|
</LightTooltip>
|
||||||
<LightTooltip title={navButton.title} placement='right'>
|
<LightTooltip title={navButton.title} placement='right'>
|
||||||
<Fab sx={{mr: 3}} size='large' color={navButton.color} onClick={() => router.push(navButton.path)}>
|
<Fab sx={{mr: 3}} size='large' color={navButton.color} aria-label={navButton.title} onClick={() => router.push(navButton.path)}>
|
||||||
{navButton.icon}
|
{navButton.icon}
|
||||||
</Fab>
|
</Fab>
|
||||||
</LightTooltip>
|
</LightTooltip>
|
||||||
@@ -130,12 +132,12 @@ export default function Sign({session, navButton}) {
|
|||||||
) : (
|
) : (
|
||||||
<Stack direction='row' spacing={2}>
|
<Stack direction='row' spacing={2}>
|
||||||
<LightTooltip title='Se connecter' placement='left'>
|
<LightTooltip title='Se connecter' placement='left'>
|
||||||
<Fab size='large' color='success' onClick={() => router.push('/login')}>
|
<Fab size='large' color='success' aria-label='Se connecter' onClick={() => router.push('/login')}>
|
||||||
<LoginIcon fontSize='large' />
|
<LoginIcon fontSize='large' />
|
||||||
</Fab>
|
</Fab>
|
||||||
</LightTooltip>
|
</LightTooltip>
|
||||||
<LightTooltip title='S’enregistrer' placement='right'>
|
<LightTooltip title="S'enregistrer" placement='right'>
|
||||||
<Fab size='large' color='success' onClick={() => router.push('/register')}>
|
<Fab size='large' color='success' aria-label="S'enregistrer" onClick={() => router.push('/register')}>
|
||||||
<PersonAddIcon fontSize='large' />
|
<PersonAddIcon fontSize='large' />
|
||||||
</Fab>
|
</Fab>
|
||||||
</LightTooltip>
|
</LightTooltip>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export default function VersionSearch({onSearchChange, placeholder = 'Rechercher
|
|||||||
size='small'
|
size='small'
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={searchValue}
|
value={searchValue}
|
||||||
|
inputProps={{'aria-label': 'Rechercher dans les versions'}}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
startAdornment: (
|
startAdornment: (
|
||||||
<InputAdornment position='start'>
|
<InputAdornment position='start'>
|
||||||
|
|||||||
@@ -100,7 +100,14 @@ function VersionItem({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Status indicator */}
|
{/* Status indicator */}
|
||||||
<Box sx={{display: 'flex', flexDirection: 'column', alignItems: 'center', pt: 0.5}}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
pt: 0.5
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: 12,
|
width: 12,
|
||||||
@@ -151,17 +158,29 @@ function VersionItem({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<Box sx={{display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0}}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.5,
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
<VoteButtons
|
<VoteButtons
|
||||||
hasCountsVisible
|
hasCountsVisible
|
||||||
versionId={version.id}
|
versionId={version.id}
|
||||||
isDisabled={isVoteDisabled}
|
isDisabled={isVoteDisabled}
|
||||||
onVoteResult={onVoteResult}
|
onVoteResult={onVoteResult}
|
||||||
/>
|
/>
|
||||||
<IconButton size='small' onClick={handleCompare} title='Comparer'>
|
<IconButton size='small' aria-label='Comparer les versions' title='Comparer' onClick={handleCompare}>
|
||||||
<CompareArrowsIcon fontSize='small' />
|
<CompareArrowsIcon fontSize='small' />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton size='small' onClick={() => setExpanded(!expanded)}>
|
<IconButton
|
||||||
|
size='small'
|
||||||
|
aria-label={expanded ? 'Réduire les détails' : 'Afficher les détails'}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
>
|
||||||
{expanded ? <ExpandLessIcon fontSize='small' /> : <ExpandMoreIcon fontSize='small' />}
|
{expanded ? <ExpandLessIcon fontSize='small' /> : <ExpandMoreIcon fontSize='small' />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -169,7 +188,14 @@ function VersionItem({
|
|||||||
|
|
||||||
{/* Expanded content */}
|
{/* Expanded content */}
|
||||||
<Collapse in={expanded}>
|
<Collapse in={expanded}>
|
||||||
<Box sx={{mt: 1.5, display: 'flex', flexDirection: 'column', gap: 1}}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
mt: 1.5,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Preview */}
|
{/* Preview */}
|
||||||
{version.delta?.contenu && (
|
{version.delta?.contenu && (
|
||||||
<Typography
|
<Typography
|
||||||
@@ -188,7 +214,14 @@ function VersionItem({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions row */}
|
{/* Actions row */}
|
||||||
<Box sx={{display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap'}}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
flexWrap: 'wrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
<CopyButton
|
<CopyButton
|
||||||
content={version.delta?.contenu || version.name || ''}
|
content={version.delta?.contenu || version.name || ''}
|
||||||
label='Copier'
|
label='Copier'
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_DIRECTUS_API_URL: ${NEXT_PUBLIC_DIRECTUS_API_URL}
|
||||||
|
NEXT_PUBLIC_DIRECTUS_API_WS_URL: ${NEXT_PUBLIC_DIRECTUS_API_WS_URL}
|
||||||
|
NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN}
|
||||||
|
# SENTRY_AUTH_TOKEN: ${SENTRY_AUTH_TOKEN} # décommenter pour uploader les source maps
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
env_file: .env
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export async function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||||
|
await import('./sentry.server.config.js')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NEXT_RUNTIME === 'edge') {
|
||||||
|
await import('./sentry.edge.config.js')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {captureRequestError as onRequestError} from '@sentry/nextjs'
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import {NextResponse} from 'next/server'
|
|
||||||
import {createRateLimiter} from '@/lib/rate-limit.js'
|
|
||||||
|
|
||||||
// 5 inscriptions max par IP toutes les 15 minutes
|
|
||||||
const checkRegister = createRateLimiter({windowMs: 15 * 60 * 1000, max: 5})
|
|
||||||
|
|
||||||
// 10 tentatives de connexion max par IP toutes les 5 minutes
|
|
||||||
const checkSignin = createRateLimiter({windowMs: 5 * 60 * 1000, max: 10})
|
|
||||||
|
|
||||||
const limiters = {
|
|
||||||
'/api/auth/register': checkRegister,
|
|
||||||
'/api/auth/callback/credentials': checkSignin,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extrait l'IP cliente depuis les headers HTTP.
|
|
||||||
* Priorité à X-Real-IP (Nginx), puis X-Forwarded-For.
|
|
||||||
*/
|
|
||||||
function getClientIp(request) {
|
|
||||||
const realIp = request.headers.get('x-real-ip')
|
|
||||||
if (realIp) {
|
|
||||||
return realIp.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
const forwarded = request.headers.get('x-forwarded-for')
|
|
||||||
if (forwarded) {
|
|
||||||
return forwarded.split(',')[0].trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'unknown'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function middleware(request) {
|
|
||||||
const {pathname} = request.nextUrl
|
|
||||||
const check = limiters[pathname]
|
|
||||||
|
|
||||||
if (!check) {
|
|
||||||
return NextResponse.next()
|
|
||||||
}
|
|
||||||
|
|
||||||
const ip = getClientIp(request)
|
|
||||||
const result = check(`${ip}:${pathname}`)
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
return NextResponse.next()
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{message: 'Trop de tentatives. Veuillez réessayer dans quelques minutes.'},
|
|
||||||
{
|
|
||||||
status: 429,
|
|
||||||
headers: {'Retry-After': String(result.retryAfter)},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
matcher: ['/api/auth/register', '/api/auth/callback/credentials'],
|
|
||||||
}
|
|
||||||
+27
-2
@@ -1,10 +1,18 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
|
import {withSentryConfig} from '@sentry/nextjs'
|
||||||
|
|
||||||
// Les URL Directus sont lues à l'exécution — elles s'adaptent à l'environnement
|
// Les URL Directus sont lues à l'exécution — elles s'adaptent à l'environnement
|
||||||
// (dev local ou production) sans rebuild.
|
// (dev local ou production) sans rebuild.
|
||||||
const apiUrl = process.env.NEXT_PUBLIC_DIRECTUS_API_URL ?? ''
|
const apiUrl = process.env.NEXT_PUBLIC_DIRECTUS_API_URL ?? ''
|
||||||
const wsUrl = process.env.NEXT_PUBLIC_DIRECTUS_API_WS_URL ?? ''
|
const wsUrl = process.env.NEXT_PUBLIC_DIRECTUS_API_WS_URL ?? ''
|
||||||
|
|
||||||
|
// Le SDK Directus dérive l'URL WebSocket depuis apiUrl (https→wss, http→ws).
|
||||||
|
// On l'inclut toujours dans connect-src pour garantir que CSP autorise la connexion,
|
||||||
|
// même si NEXT_PUBLIC_DIRECTUS_API_WS_URL pointe vers un hôte différent.
|
||||||
|
const derivedWsUrl = apiUrl
|
||||||
|
.replace(/^https:\/\//, 'wss://')
|
||||||
|
.replace(/^http:\/\//, 'ws://')
|
||||||
|
|
||||||
// Tokens CSP — les guillemets simples font partie de la spec CSP, pas de JS
|
// Tokens CSP — les guillemets simples font partie de la spec CSP, pas de JS
|
||||||
const SELF = '\'self\''
|
const SELF = '\'self\''
|
||||||
const NONE = '\'none\''
|
const NONE = '\'none\''
|
||||||
@@ -19,7 +27,7 @@ const cspDirectives = [
|
|||||||
`default-src ${SELF}`,
|
`default-src ${SELF}`,
|
||||||
`script-src ${SELF} ${UNSAFE_INLINE}`,
|
`script-src ${SELF} ${UNSAFE_INLINE}`,
|
||||||
`style-src ${SELF} ${UNSAFE_INLINE}`,
|
`style-src ${SELF} ${UNSAFE_INLINE}`,
|
||||||
`connect-src ${SELF} ${apiUrl} ${wsUrl}`.trim(),
|
`connect-src ${SELF} ${apiUrl} ${wsUrl} ${derivedWsUrl}`.trim().replace(/ {2,}/g, ' '),
|
||||||
`img-src ${SELF} data: blob:`,
|
`img-src ${SELF} data: blob:`,
|
||||||
`font-src ${SELF}`,
|
`font-src ${SELF}`,
|
||||||
`object-src ${NONE}`,
|
`object-src ${NONE}`,
|
||||||
@@ -52,6 +60,8 @@ const securityHeaders = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
// Active le mode standalone : bundle minimal autonome pour Docker
|
||||||
|
output: 'standalone',
|
||||||
experimental: {
|
experimental: {
|
||||||
// Optimiser les imports pour réduire la mémoire
|
// Optimiser les imports pour réduire la mémoire
|
||||||
optimizePackageImports: ['@mui/material', '@mui/icons-material', '@emotion/react', '@emotion/styled'],
|
optimizePackageImports: ['@mui/material', '@mui/icons-material', '@emotion/react', '@emotion/styled'],
|
||||||
@@ -70,4 +80,19 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default nextConfig
|
export default withSentryConfig(nextConfig, {
|
||||||
|
// Organisation et projet Sentry pour l'upload des source maps
|
||||||
|
// Nécessite SENTRY_AUTH_TOKEN en CI pour activer l'upload
|
||||||
|
silent: !process.env.CI,
|
||||||
|
|
||||||
|
// Ne pas uploader les source maps si le token est absent (dev local)
|
||||||
|
sourcemaps: {
|
||||||
|
disable: !process.env.SENTRY_AUTH_TOKEN,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Évite d'exposer les source maps aux utilisateurs finaux
|
||||||
|
hideSourceMaps: true,
|
||||||
|
|
||||||
|
// Tunnel Sentry via notre propre domaine (contourne les adblockers)
|
||||||
|
// tunnelRoute: '/monitoring',
|
||||||
|
})
|
||||||
|
|||||||
Generated
-11944
File diff suppressed because it is too large
Load Diff
+13
-2
@@ -18,6 +18,7 @@
|
|||||||
"@mui/lab": "^7.0.1-beta.20",
|
"@mui/lab": "^7.0.1-beta.20",
|
||||||
"@mui/material": "^7.3.6",
|
"@mui/material": "^7.3.6",
|
||||||
"@mui/material-nextjs": "^7.3.6",
|
"@mui/material-nextjs": "^7.3.6",
|
||||||
|
"@sentry/nextjs": "^10.48.0",
|
||||||
"@uiw/react-md-editor": "^4.0.11",
|
"@uiw/react-md-editor": "^4.0.11",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
@@ -25,9 +26,11 @@
|
|||||||
"marked": "^17.0.1",
|
"marked": "^17.0.1",
|
||||||
"next": "^16.1.0",
|
"next": "^16.1.0",
|
||||||
"next-auth": "^5.0.0-beta.30",
|
"next-auth": "^5.0.0-beta.30",
|
||||||
|
"node-gyp": "^12.3.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-virtuoso": "^4.18.1",
|
"react-virtuoso": "^4.18.1",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"use-debounce": "^10.0.5"
|
"use-debounce": "^10.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -58,9 +61,17 @@
|
|||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"files": "lib/__tests__/**/*.js",
|
"files": "lib/__tests__/**/*.js",
|
||||||
"envs": ["node", "es2020"],
|
"envs": [
|
||||||
|
"node",
|
||||||
|
"es2020"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"camelcase": ["error", {"properties": "never"}],
|
"camelcase": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"properties": "never"
|
||||||
|
}
|
||||||
|
],
|
||||||
"capitalized-comments": "off"
|
"capitalized-comments": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,55 @@
|
|||||||
export {auth as proxy} from './auth.js'
|
import {NextResponse} from 'next/server'
|
||||||
|
import {auth} from './auth.js'
|
||||||
|
import {createRateLimiter} from '@/lib/rate-limit.js'
|
||||||
|
|
||||||
|
// 5 inscriptions max par IP toutes les 15 minutes
|
||||||
|
const checkRegister = createRateLimiter({windowMs: 15 * 60 * 1000, max: 5})
|
||||||
|
|
||||||
|
// 10 tentatives de connexion max par IP toutes les 5 minutes
|
||||||
|
const checkSignin = createRateLimiter({windowMs: 5 * 60 * 1000, max: 10})
|
||||||
|
|
||||||
|
const limiters = {
|
||||||
|
'/api/auth/register': checkRegister,
|
||||||
|
'/api/auth/callback/credentials': checkSignin,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrait l'IP cliente depuis les headers HTTP.
|
||||||
|
* Priorité à X-Real-IP (Nginx), puis X-Forwarded-For.
|
||||||
|
*/
|
||||||
|
function getClientIp(request) {
|
||||||
|
const realIp = request.headers.get('x-real-ip')
|
||||||
|
if (realIp) {
|
||||||
|
return realIp.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const forwarded = request.headers.get('x-forwarded-for')
|
||||||
|
if (forwarded) {
|
||||||
|
return forwarded.split(',')[0].trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function proxy(request) {
|
||||||
|
const {pathname} = request.nextUrl
|
||||||
|
const check = limiters[pathname]
|
||||||
|
|
||||||
|
// Rate limiting sur les routes d'authentification critiques
|
||||||
|
if (check) {
|
||||||
|
const ip = getClientIp(request)
|
||||||
|
const result = check(`${ip}:${pathname}`)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{message: 'Trop de tentatives. Veuillez réessayer dans quelques minutes.'},
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: {'Retry-After': String(result.retryAfter)},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return auth(request)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import * as Sentry from '@sentry/nextjs'
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
|
|
||||||
|
// Taux d'échantillonnage des traces de performance (0 = désactivé, 1 = 100%)
|
||||||
|
// Valeur basse en production pour limiter le volume
|
||||||
|
tracesSampleRate: 0.1,
|
||||||
|
|
||||||
|
// Capture des replays de session uniquement sur les erreurs
|
||||||
|
replaysOnErrorSampleRate: 1,
|
||||||
|
replaysSessionSampleRate: 0,
|
||||||
|
|
||||||
|
integrations: [
|
||||||
|
Sentry.replayIntegration({
|
||||||
|
// Masquer les champs sensibles dans les replays
|
||||||
|
maskAllText: false,
|
||||||
|
blockAllMedia: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Ne pas afficher les erreurs Sentry dans la console en production
|
||||||
|
debug: false,
|
||||||
|
})
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import * as Sentry from '@sentry/nextjs'
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
|
|
||||||
|
tracesSampleRate: 0.1,
|
||||||
|
|
||||||
|
debug: false,
|
||||||
|
})
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import * as Sentry from '@sentry/nextjs'
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
|
|
||||||
|
// Taux d'échantillonnage des traces serveur
|
||||||
|
tracesSampleRate: 0.1,
|
||||||
|
|
||||||
|
debug: false,
|
||||||
|
})
|
||||||
+10
-10
@@ -12,16 +12,16 @@
|
|||||||
|
|
||||||
- [x] **Headers CSP** — `next.config.mjs` (renommé depuis .js) avec CSP + 4 headers sécurité
|
- [x] **Headers CSP** — `next.config.mjs` (renommé depuis .js) avec CSP + 4 headers sécurité
|
||||||
- [x] **Tests unitaires** — Vitest sur `lib/format.js`, `lib/version-utils.js`, `lib/rate-limit.js`
|
- [x] **Tests unitaires** — Vitest sur `lib/format.js`, `lib/version-utils.js`, `lib/rate-limit.js`
|
||||||
- [ ] **Tests extensions Directus** — mocks VersionsService
|
- [x] **Tests extensions Directus** — mocks VersionsService
|
||||||
- [ ] **Refresh token explicite** — callback `jwt` dans NextAuth options
|
- [x] **Refresh token explicite** — callback `jwt` dans NextAuth options
|
||||||
- [ ] **Pipeline CI** — GitHub Actions (lint + test + build)
|
- [ ] **Pipeline CI** — Forgejo Actions / Woodpecker CI (lint + test + build) ⏸ en attente
|
||||||
- [ ] **Sentry** — tracking erreurs frontend + API routes
|
- [x] **Sentry** — tracking erreurs frontend + API routes
|
||||||
|
|
||||||
## Améliorations moyennes (P3)
|
## Améliorations moyennes (P3)
|
||||||
|
|
||||||
- [ ] ISR page d'accueil (`revalidate`)
|
- [x] ISR page d'accueil (`revalidate`)
|
||||||
- [ ] Dockerisation frontend (`output: standalone`)
|
- [x] Dockerisation frontend (`output: standalone`)
|
||||||
- [ ] Audit accessibilité WCAG 2.1
|
- [x] Audit accessibilité WCAG 2.1
|
||||||
- [ ] Responsive mobile dashboard
|
- [ ] Responsive mobile dashboard ⚠️ à vérifier manuellement (DevTools mobile / vrai appareil)
|
||||||
- [ ] Lazy loading jsPDF + md-editor
|
- [x] Lazy loading jsPDF + md-editor (déjà implémenté : dynamic import + next/dynamic)
|
||||||
- [ ] Migration NextAuth v5 stable
|
- [ ] Migration NextAuth v5 stable ⏸ bloquée — v5 stable non publiée (beta.30 = dernière dispo)
|
||||||
|
|||||||
Reference in New Issue
Block a user