Skip to content

Durcissement production : sécurité, robustesse K8s et correctifs UI - #111

Merged
TheSawkit merged 103 commits into
mainfrom
dev
Aug 1, 2026
Merged

Durcissement production : sécurité, robustesse K8s et correctifs UI#111
TheSawkit merged 103 commits into
mainfrom
dev

Conversation

@TheSawkit

Copy link
Copy Markdown
Owner

Sécurité

  • 11 vulnérabilités fermées (pnpm audit : 0). Next 16.2.10 → 16.2.12, dont un contournement de middleware en App Router : proxy.ts porte le guard d'authentification, donc contourner le proxy contournait l'auth sur les routes protégées. Plus SSRF dans les Server Actions et les rewrites, DoS App Router, confusion de cache, divulgation des IDs de Server Actions.
  • sharp forcé en >=0.35.0 (libvips CVE-2026-33196), sans risque : images.unoptimized fait qu'il n'est jamais appelé.
  • NEXT_SERVER_ACTIONS_ENCRYPTION_KEY en secret de build — sans clé fixe, une Server Action chiffrée par un pod échoue sur l'autre pendant un rolling update.

Kubernetes

Diagnostiqué sur le cluster : les pods redémarraient jusqu'à 9 fois, cause Liveness probe failed: context deadline exceeded. Le timeoutSeconds de 1 s par défaut est dépassé dès que l'event loop rend du SSR ; le pod est tué, sa charge se reporte, l'incident se propage.

  • Probes avec timeouts explicites + startupProbe.
  • Profil Pod Security restricted : runAsNonRoot, readOnlyRootFilesystem, capabilities retirées, seccompProfile, automountServiceAccountToken: false.
  • PDB, topologySpreadConstraints, unhealthyPodEvictionPolicy: AlwaysAllow.
  • Dimensionnement pour a2-ram4-disk20-perf1 (2 pods/nœud) et HPA lissé par behavior.
  • NetworkPolicy en default-deny, hors CI (voir DEPLOYMENT.md § 7 bis).

CI/CD

  • Déploiement déclaratif en kubectl apply --server-side : les resources, probes et PDB partent avec le commit qui les contient. Le mode server-side est requis — en client-side, l'absence de spec.replicas le remettrait à 1 à chaque déploiement au lieu de le laisser au HPA.
  • infra.yml : workflow edge séparé, chart pinné. --force-conflicts + --rollback-on-failure — la release Helm de l'ingress était en échec depuis le 7 juillet, ce qui laissait l'edge à un seul replica. C'est la cause des coupures Cloudflare.
  • NEXT_DEPLOYMENT_ID : protection contre le version skew, correctif de fond des 404 pendant un rolling update.

UI

Le bandeau à la une débordait de 69 px sur mobile (mesuré sur iPhone 13), systématiquement — overflow-hidden masquait le débordement en rognant le badge et le haut du titre.

Divers

  • 404 racine : un chemin non résolu renvoyait 500 au lieu de 404.
  • CI déclenchée aussi sur dev.
  • .prettierignore pour le lockfile.

À merger en « Create a merge commit », pas en squash — voir CONTRIBUTING.md. Un squash écrit sur main un commit absent de dev, git ne peut plus savoir que les deux historiques se sont rencontrés, et chaque PR suivante reliste tout ce qui a déjà été livré.

TheSawkit and others added 30 commits July 20, 2026 22:59
Un titre non encore sorti — ou obscur — a vote_average = 0, ce qui affichait un
badge doré « N/A » à étoile. Trompeur : il suggère une note là où il n'y en a pas.

Le badge n'est désormais rendu que si vote_average > 0, aussi bien sur les cartes
(MediaCard) que sur la bannière détail (MediaBanner). Décision prise côté serveur
à partir de la donnée, donc correcte dès le premier rendu — pas de dépendance à
l'hydratation client (une tentative via useIsUnreleased ne mettait pas à jour le
badge dans le shell prérendu par Cache Components).

Vérifié dans le navigateur sur un build standalone de production : « The End of
Oak Street » (sortie août 2026, note 0) n'affiche plus de badge ; « Fight Club »
affiche toujours 8.4.
…earchDropdown

- Fin du doublon xl/2xl : sm 4 · md 8 · lg 12 · xl 16 · 2xl 24, le 3xl disparaît
  (rounded-2xl→xl, rounded-3xl→2xl — zéro changement visuel)
- Tokens sémantiques --radius-banner (22px) et --radius-app-icon (22.5%) remplacent
  les derniers rounded-[Npx] arbitraires (BottomTabBar, SpotlightPick, icône PWA)
- TiltCard prend un radius token string comme GlowBorder, plus jamais un nombre
- SearchDropdown passe sur glass-popover : lisible sur tout contenu, dans les 2 thèmes
- tooltip.tsx référençait des tokens inexistants (bg-foreground) et n'était monté
  nulle part hors du provider
- TooltipProvider retiré de app/providers.tsx, @radix-ui/react-tooltip désinstallé
- TMDB : timeout 10s, erreurs réseau (DNS/reset/hang) retournées en data au lieu de
  traverser la frontière use cache et tuer le rendu ; 404 d'ids morts cachés 1h
- Watchmode : timeout 8s + cooldown global 5 min après 429/401/402 — plus un seul
  appel dépensé pendant l'épuisement du quota mensuel
- iTunes lookup (icônes providers) : timeout 5s
- lib/monotonic-now.ts : horloge monotone pour les durées — Date.now() pendant un
  prerender Cache Components avorte le shell en 500
- reportSwallowed envoie chaque fallback avalé à Bugsink en warning, dédupliqué
  5 min par label+message (horloge monotone, safe en prerender)
- ErrorCard capture les erreurs client sans digest (les erreurs serveur arrivent
  déjà via onRequestError) — les 12 error.tsx transmettent désormais l'objet error
- global-error.tsx capture les crashs de shell
- Le profil chargeait la watchlist avec .limit(1000) : au-delà, compteurs et grilles
  faux (2074 entrées réelles → 299/699 affichés) ; passage sur fetchAllRows,
  vérifié en rendu réel : 471 à voir / 1601 vus, conformes au SQL
- Même correction : épisodes du continue watching, progression d'une série,
  notes utilisateur, amis, demandes en attente, playlists (caps 100 arbitraires)
- Le badge Avis affiche un vrai count SQL au lieu de la taille de la première page
- Les chips posées sur les vignettes (badge S/E, Marquer vu, note, statut)
  mélangeaient scrim sombre fixe et texte thémé — illisibles en light
- Passage sur le matériau surface opaque (bg-surface + border-border) :
  blanc en light, noir en dark, automatique, sans blur (interdit en répété)
- Badge note des posters : text-rating-gold (or vif en dark, ambre en light)
- Scrims à texte blanc sur image (play trailers, X playlist) conservés tels quels
- MediaCard et SeasonCard : pointer-events-none sur les overlays masqués,
  réactivés au survol — opacity-0 ne coupe pas les événements pointeur et
  Tailwind v4 borne hover: à @media (hover:hover)
- corrige deux mutations involontaires en PWA : un tap au bas d'une card
  ajoutait le titre à la watchlist, un tap sur une card de saison marquait
  la saison entière comme vue
- tests : helper pageButton — watchlist/list-controls/resilience ciblaient
  un bouton de card au lieu de la page détail
- tests : garde anti-régression touch-ghost-controls (aucun contrôle
  invisible ne doit être tapable)
- shadow-control-lift porte un highlight inset à 0.7 en clair (0.18 en sombre) :
  sur un fond rouge translucide il dessine un contour blanc détaché
- WatchButton, EpisodeWatchButton, SeasonWatchButton et la pastille
  « Vu » du dashboard passent à shadow-card-sm
- le token reste inchangé pour son usage sur fond opaque (StreamingSettings)
Le slot est monté dans le layout, donc rendu sur chaque page : sur /dashboard
il déclenchait cinq requêtes dont trois doublons (états des prompts et compte
watchlist demandés deux fois, plateformes hors du cache déjà utilisé par
buildLibrarySections).

- lib/data/prompts.ts : getCachedPromptStates + getCachedWatchlistCount,
  même pattern React.cache que lib/data/watchlist.ts
- StreamingPromptSection passe sur getCachedStreamingProviders
- le compte reste un count exact plutôt que la liste complète, puisque le slot
  s'affiche aussi sur les pages qui n'ont pas besoin de la watchlist

Cinq requêtes ramenées à deux par rendu du dashboard.
- countVisit() était évalué derrière un court-circuit `canImport &&`, donc le
  compteur de visites n'avançait que pour les comptes à watchlist vide. Il est
  maintenant lu en tête d'effet, indépendamment de qui s'en sert.
- le bandeau fermé restait annoncé par les lecteurs d'écran et focusable :
  translate-y ne le sort que visuellement, d'où aria-hidden + inert.
- isPromptKey / isPromptState remplacent les `as` posés avant vérification sur
  les lignes user_prompts.
- usePWAInstall annule son timer avant d'en reposer un, si le navigateur
  réémet beforeinstallprompt.
Les endpoints recommendations et similar répondent 404 pour les titres de
niche, et un id disparaît quand TMDB fusionne ou supprime une fiche. Chaque
dashboard poussait donc plusieurs warnings dans Bugsink pour une absence que
tous les appelants gèrent déjà par un fallback.

TMDBNotFoundError, sans dépendance pour rester importable côté client, permet
à reportSwallowed de la traiter en console.debug. Toute autre erreur (réseau,
5xx, quota) garde son warning et sa remontée.
Le dashboard affichait « Tes statistiques » juste au-dessus de « Vos prochains
visionnages » : settings tutoyait déjà, le reste vouvoyait. 93 chaînes alignées
sur le tutoiement, y compris les impératifs pluriels (« Découvrez » → « Découvre »)
que le premier passage sur vous/votre/vos avait laissés.

app/manifest.ts duplique quatre descriptions de metadata.ts et suit le même
changement.

Concerne aussi /terms et /privacy : « Tes droits », « Ton compte ».
Le tutoiement généralisé avait emporté les pages légales, où le vouvoiement
reste la convention. Seul le bloc legal français revient en arrière : le
dashboard, l'explorer et le reste de pages.ts gardent le tutoiement.
Deux défauts distincts rendaient la watchlist inaccessible sur mobile :

- le dégradé décoratif de MediaCard couvre toute la carte en `inset-0` sans
  `pointer-events-none`, donc il absorbait les taps destinés aux boutons placés
  au-dessus de lui — y compris quand ceux-ci étaient parfaitement visibles.
- les contrôles révélés au survol restaient inertes sur tactile, où `hover:` ne
  se déclenche jamais : sans être ni visibles ni atteignables, le bouton
  watchlist d'une card et l'icône de saison non commencée n'avaient aucun
  équivalent au doigt.

`pointer-coarse:` (Tailwind 4.1+) les affiche et les active sur tactile, ce qui
lève l'alternative « invisible mais tapable » / « inatteignable » sans toucher
au comportement desktop.

Vérifié sur dashboard, library, /movie/[id] et /tv/[id] en viewport 390×844 :
plus aucun bouton dont le tap n'atteint pas sa cible. touch-ghost-controls et
media-card-tap restent verts.
Annule l'affichage permanent introduit par cdb111d : sur une card, l'overlay
de survol doit rester invisible et inerte au doigt, pas occuper l'affiche.

Seul `pointer-events-none` est conservé sur le dégradé décoratif de MediaCard :
il couvre la carte en `inset-0` et n'a aucune raison d'être une cible de
pointeur, même si ce n'était pas lui qui bloquait les boutons.
Next bloque `/_next/*` en cross-origin depuis Next 15.2 : tester depuis un
téléphone sur l'IP du Mac faisait rejeter le HMR, les payloads RSC et les
Server Actions. L'app se chargeait mais paraissait morte — chaque lien
repassait en navigation pleine page (donc sans barre de progression) et aucun
bouton ne réagissait, sans la moindre erreur visible côté client.

`allowedDevOrigins` reçoit les IPv4 non internes de la machine, résolues au
chargement de la config plutôt qu'écrites en dur puisque l'adresse suit le
réseau. Sans effet en production, où l'option est ignorée.

Vérifié en WebKit (le moteur de Safari) sur iPhone 13 émulé, via l'IP :
navigation client rétablie et barre de progression de retour.
`navigator.serviceWorker.ready` n'est jamais résolue tant qu'aucun worker n'est
activé. Le serveur de dev ne génère pas `sw.js` : le statut restait donc sur
« loading » indéfiniment, ce qui masquait le CTA d'activation (il exige `off`)
et laissait le réglage des notifications inutilisable. Même impasse en
production si l'enregistrement du worker échoue.

`activeServiceWorker()` interroge d'abord `getRegistration()`, qui répond tout
de suite, puis borne l'attente de `ready`. Sans worker, le statut devient
`unsupported` — refus honnête plutôt qu'attente sans fin.
`isSettingsTab` vivait dans SettingsNav, marqué `'use client'`, et la page est
un Server Component : Next refuse l'appel — « Attempted to call isSettingsTab()
from the server » — et toute la page tombait sur ErrorCard. Introduit avec le
deep-link `?section=` des CTA, invisible au build puisque l'erreur est à
l'exécution, et non couvert : aucun test ne visitait /settings.

Le tableau d'onglets et son garde passent dans `components/settings/tabs.ts`,
sans directive, importable des deux côtés de la frontière.

Ajoute 8 cas E2E sur /settings, dont chaque `?section=` visée par un CTA et une
valeur inconnue, pour que la page ne puisse plus casser en silence.
Il était le seul des quatre à ne pas passer par PromptBanner : une carte pleine
largeur posée dans le flux du dashboard, là où les trois autres partagent le
bandeau sous la navbar. Deux styles pour un même rôle.

- `streaming` entre dans BANNER_PRIORITY, en dernier
- PromptSlot calcule son éligibilité côté serveur, en réutilisant le cache de
  getCachedStreamingProviders déjà lu par buildLibrarySections
- SETTINGS_TARGET remplace l'URL de réglages codée en dur dans le gestionnaire
  d'action, chaque prompt déclarant l'onglet qu'il ouvre
- StreamingPromptCard et StreamingPromptSection disparaissent

Conséquence assumée : le services tombe sous la règle commune d'un seul CTA
tous les trois jours, donc il s'affichera plus rarement qu'avant, où il vivait
hors de la file. Import et services restent exclusifs de fait (moins de 3
titres contre 5 au moins).

Vérifié en rendu réel : bandeau à y=64 avec l'icône téléviseur, carte inline
absente, et le cas du moteur qui affirmait l'inverse est mis à jour.
TheSawkit and others added 29 commits July 29, 2026 01:36
Le champ acteur des listes (bibliothèque, watchlist de profil, playlists) était un
texte libre : une faute d'orthographe rendait zéro résultat sans rien dire, et il
fallait connaître la graphie exacte du nom pour filtrer quoi que ce soit.

Une liste de suggestions TMDB s'ouvre sous le champ dès deux caractères, servie par
`/api/search?type=person` — la route existante, donc le rate-limit du proxy (30/min)
et le cache CDN couvrent déjà ces appels. Chaque entrée montre la photo et les deux
titres les plus connus de la personne, de quoi départager les homonymes.

Sélectionner une suggestion verrouille le filtre sur l'ID TMDB : `filterListByActor`
compare alors `member.id` au lieu du nom normalisé, ce qui supprime les confusions
entre homonymes et les variantes de graphie selon la langue. Le verrou est relâché
dès que le texte est retapé (`withActorQuery` remet `actorId` à null), sinon on
filtrerait sur l'ancien acteur avec un nouveau texte affiché. La sélection saute le
debounce de 400 ms, elle n'a rien à attendre.

L'état persiste en localStorage comme le reste des contrôles de liste, et quatre
tests figent les transitions acteur : verrou, relâchement, effacement, et
non-régression sur le tri et les genres.
…atars

Les photos Google ne s'affichaient plus nulle part. Ce n'est pas la config des
images — `lh3.googleusercontent.com` figure déjà dans `remotePatterns` et dans la
CSP `img-src` — c'est le `Referer` : notre en-tête `Referrer-Policy` envoie l'origine
avec chaque requête et Google coupe la connexion sur les avatars hotlinkés
(`transferSize: 0`, aucune réponse HTTP). Mesuré sur la même URL, mêmes conditions :
0/6 chargements avec la politique par défaut, 6/6 avec `no-referrer`. En curl la
requête passait, faute d'en-têtes `Sec-Fetch-*`, d'où l'illusion d'une URL saine.

Les cinq rendus d'avatar passent donc en `referrerPolicy="no-referrer"`, sans effet
sur Supabase, DiceBear et les blobs locaux qui ne filtrent pas là-dessus.

Aucun d'eux n'avait de `onError` : une URL morte laissait un carré vide au lieu des
initiales. `useBrokenImage` mémorise la source qui a échoué — la clé est la source
elle-même, donc un avatar remplacé est réessayé au lieu de rester cassé.

Enfin la photo du provider est copiée dans le bucket `avatars` à la première connexion
OAuth, derrière `after()` pour ne rien ajouter à la latence du login : le rendu ne
dépend plus d'un CDN tiers et l'IP des visiteurs ne part plus chez Google à chaque
affichage. Un avatar déjà uploadé n'est jamais écrasé. `user_metadata` étant
modifiable par l'utilisateur, l'URL source est filtrée avant le fetch — https et hôte
`lh<n>.googleusercontent.com` uniquement, sans quoi la copie serait un SSRF ouvert.
- PodDisruptionBudget maxUnavailable: 1 — correct quelle que soit la taille
  du ReplicaSet, contrairement à minAvailable qui autoriserait l'éviction de
  9 pods sur 10
- topologySpreadConstraints en ScheduleAnyway : répartit sans jamais bloquer
  le scheduling quand le pool n'a qu'un nœud disponible
- requests 500m/1Gi (au lieu de 250m/512Mi) : le HPA cible un pourcentage de
  la request, 75% de 250m déclenchait un scale-up permanent qui entraînait le
  node pool dans le même va-et-vient
- ephemeral-storage borné : un pod ne peut plus déclencher un DiskPressure
  qui évincerait tous les pods du nœud (20 Go seulement)
- NODE_OPTIONS borne le heap V8 à 768 Mo sous la limite de 1 Gi
- startupProbe (60 s) remplace les initialDelaySeconds : la liveness ne peut
  plus tuer un pod avant qu'il soit prêt
- spec.replicas retiré — le champ appartient au HPA
- image en placeholder __IMAGE_TAG__ : un apply brut échoue visiblement au
  lieu de reverter le cluster sur :latest
- ingress affinity-mode: balanced — persistent gardait un client collé à un
  pod disparu

Risque : le premier apply peut brièvement ramener le Deployment à 1 pod si la
migration vers server-side apply ne préserve pas le champ replicas. Vérifier
`kubectl -n reelmark get hpa` après le premier déploiement.
- deploy.yml passe de `kubectl set image` à `kubectl apply --server-side` :
  les resources, probes, PDB et HPA partent en prod avec le commit qui les
  contient. Le mode server-side est requis, pas cosmétique — en client-side
  l'absence de spec.replicas dans le manifeste le remettrait à son défaut
  (1 pod) à chaque déploiement au lieu de le laisser au HPA
- infra.yml (nouveau) gère ingress-nginx via Helm et k8s/ingress.yaml, sur
  changement des fichiers edge ou en workflow_dispatch — l'edge ne bouge pas
  à chaque push applicatif
- ingress-nginx passe à 2 replicas avec anti-affinité `required` sur hostname :
  les deux pods ne peuvent pas partager un nœud, ce qui interdit au cluster
  autoscaler Infomaniak (1→10, non configurable) de redescendre à 1 nœud. Le
  plancher de 2 nœuds devient structurel — c'est le correctif des coupures
  Cloudflare 1016/523 et des 404 « default backend »
- chart pinné à 4.15.1 : une version flottante ferait passer l'edge en majeure
  sans revue au premier déclenchement
- concurrency group sur les deux workflows, permissions minimales sur infra
Un secret GitHub vide produisait un bundle client avec des valeurs undefined
pour Supabase, sans aucune erreur au build — l'app partait en prod cassée.
Même garde que celle déjà en place sur tmdb_token, sur les trois variables
sans lesquelles le client est inutilisable.

.dockerignore exclut docs, context-ai et tsconfig.tsbuildinfo (645 Ko) qui
invalidaient le cache de build à chaque modification.
- plancher de 2 nœuds imposé par l'anti-affinité ingress, l'autoscaling du
  node pool Infomaniak (1→10) n'étant pas configurable
- dimensionnement détaillé pour a2-ram4-disk20-perf1 (2 pods par nœud)
- deux workflows, deux rythmes : app à chaque push, edge sur demande
- raison du server-side apply
- Cache Rule Cloudflare sur /_next/static — sort les assets des pods et fait
  tomber la charge CPU qui déclenche le HPA
- section Scaling corrigée (disait 2→5 à 70%, le manifeste dit 2→10)
- runAsNonRoot en uid/gid 1001 (l'utilisateur créé par le Dockerfile),
  seccompProfile RuntimeDefault, allowPrivilegeEscalation: false, toutes les
  capabilities retirées
- readOnlyRootFilesystem: true, avec deux emptyDir bornés sur les seuls
  chemins que le serveur standalone écrit à l'exécution — /app/.next/cache et
  /tmp. Le fsGroup: 1001 du pod est ce qui les rend inscriptibles : sans lui
  un emptyDir reste root:root en 0755 et le conteneur non-root échoue au
  premier write de cache
- automountServiceAccountToken: false — l'app ne parle jamais à l'API
  Kubernetes, le token monté par défaut est une surface d'attaque offerte
- timeoutSeconds explicites sur les trois probes. Le défaut de 1 s suffit à
  faire redémarrer un pod dont l'event loop est simplement occupé à rendre du
  SSR, et la charge se reporte alors sur les survivants — la panne en cascade
  classique de la liveness probe
- terminationGracePeriodSeconds 30 → 45. Next termine les requêtes en vol et
  les callbacks after() avant de sortir sur SIGTERM et demande 10 à 30 s de
  drain ; toutes les Server Actions du projet déferrent leur revalidation
  dans after()

Réf: kubernetes.io/docs/concepts/security/pod-security-standards
     nextjs.org/docs/app/guides/self-hosting
     blog.colinbreck.com/kubernetes-liveness-and-readiness-probes
nginx bufferise les réponses upstream par défaut : il attend la réponse
complète avant de l'envoyer au client, ce qui annule le streaming des sections
<Suspense> des fiches détail. La bannière ne peignait plus avant que les
données Supabase soient arrivées, alors que les pages sont précisément
construites pour ne bloquer que sur le trio TMDB.

Réf: nextjs.org/docs/app/guides/self-hosting#streaming-and-suspense
Trois flux seulement : entrée depuis ingress-nginx sur 3000, DNS vers
kube-system, HTTPS sortant hors plages RFC1918. Un pod compromis ne peut donc
ni scanner le cluster ni joindre l'API server.

Volontairement hors CI : une erreur de périmètre coupe l'app sans message
clair. La procédure de pose et de retour arrière est dans DEPLOYMENT.md, avec
le point à vérifier au préalable (Bugsink joignable en IP publique).
- profil restricted, chemins inscriptibles et rôle du fsGroup
- drain SIGTERM aligné sur les callbacks after()
- seuils de probes et raison du timeoutSeconds explicite
- section 7 bis : pose et retrait de la NetworkPolicy
- note streaming (proxy-buffering)

Sources officielles liées en bas de section.
- NEXT_DEPLOYMENT_ID reçoit le SHA du commit. Next suffixe alors les assets en
  `?dpl=<sha>` et compare l'identifiant du client à celui du serveur : en cas
  d'écart il déclenche un rechargement complet au lieu de demander un chunk
  que le pod ne sert plus. C'est le correctif de fond des 404 pendant un
  rolling update — l'affinité par cookie n'en réduisait que la probabilité
- NEXT_SERVER_ACTIONS_ENCRYPTION_KEY par secret BuildKit, optionnel avec
  avertissement. Sans clé fixe, chaque build tire la sienne et une Server
  Action chiffrée par l'ancien pod échoue sur le nouveau (« Failed to find
  Server Action ») pendant toute la fenêtre du déploiement

Réf: nextjs.org/docs/app/api-reference/config/next-config-js/deploymentId
     nextjs.org/docs/app/guides/self-hosting#multi-server-deployments
- HPA behavior : sans lui le contrôleur double le nombre de pods toutes les
  15 s. Chaque paire de pods réclame un nœud au cluster autoscaler, qui met 1
  à 3 min à le provisionner — la montée brutale commande des machines dont le
  pic n'a plus besoin quand elles arrivent. Fenêtre de 60 s et +2 pods/min à
  la montée, 50%/min à la descente
- PDB unhealthyPodEvictionPolicy: AlwaysAllow — un pod déjà cassé
  immobilisait le budget et bloquait le drain d'un nœud, alors que l'évincer
  restaure la disponibilité au lieu de la dégrader
…l'origine

- génération et rôle de NEXT_SERVER_ACTIONS_ENCRYPTION_KEY
- effet de NEXT_DEPLOYMENT_ID sur les 404 de rolling update
- section 7 ter : le LB Octavia est joignable sans passer par Cloudflare, donc
  sans WAF ni rate limiting — et cf-connecting-ip est alors forgeable. Fix par
  whitelist des plages Cloudflare, avec le prérequis à vérifier d'abord (le
  SNAT d'Octavia peut masquer l'IP source et faire tout rejeter)
Le fichier affirmait que le resize/format était délégué à Cloudflare Polish.
C'est faux : avec images.unoptimized, next/image émet un src pointant sur
image.tmdb.org, un domaine tiers. Les posters ne traversent jamais le proxy
Cloudflare du domaine, et Polish ne couvre que les images servies par
l'origine.

L'état actuel reste le bon compromis à ce dimensionnement — la note explique
désormais pourquoi, et ce que coûteraient les deux alternatives.
Le namespace reelmark est créé par k8s/app.yaml, donc par le workflow
applicatif. Sur un cluster vierge, lancer infra.yml en premier — ce que la
doc recommandait pour que le second nœud existe avant le déploiement — faisait
échouer l'étape « Apply application Ingress ». Elle sort maintenant proprement
avec un message qui dit quoi faire.
proxy-buffers-number et proxy-busy-buffers-size ne sont plus lus depuis que le
buffering est désactivé : nginx passe la réponse au client au fil de l'eau et
ne garde qu'un seul buffer, dimensionné par proxy_buffer_size. Les laisser
donnait à croire que la taille des buffers était réglée.

proxy-buffer-size reste, avec la raison : c'est lui qui borne la lecture des
en-têtes, et les cookies de session Supabase dépassent le défaut de 4k.

Réf: nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering
- whitelist Cloudflare en deux étapes, avec le contrôle préalable qui évite de
  couper le site (le SNAT d'Octavia peut masquer l'IP source). Les plages sont
  lues depuis la source officielle au moment de la pose plutôt que recopiées
  dans le dépôt, où elles se périmeraient en silence
- note sur la survie de l'annotation aux déploiements (Server-Side Apply ne
  touche pas aux champs d'un autre field manager)
- section surveillance : pourquoi un Prometheus in-cluster est disproportionné
  à ce dimensionnement, et les commandes à passer après un incident
- secret.example.yaml renvoyait à MIGRATION.md, qui n'existe pas
- `app/[lang]/layout.tsx` fait office de layout racine : son `notFound()` n'avait
  aucune frontière parente et remontait jusqu'à `global-error`, soit un 500 pour
  chaque chemin qu'un crawler tente (`/config.json`, un fichier statique absent)
- `app/not-found.tsx` lui donne une destination ; il porte son propre `<html>`
  pour la même raison que `global-error`
- la CI se déclenche désormais aussi sur `dev`, pour qu'une régression ne puisse
  plus atteindre `main` sans avoir été testée
Le commit récupéré du worktree posait ses couleurs et son texte en dur
(`#0a0a0a`, « Cette page n'existe pas »), alors que `global-error.tsx` — soumis
exactement aux mêmes contraintes, puisqu'il porte lui aussi son propre `<html>`
hors de tout layout — montrait déjà comment faire : `import './globals.css'`,
classes de tokens, `next/font`, et clés i18n. Les clés `notFoundTitle` et
`notFoundDescription` existaient déjà.

La détection de langue côté navigateur, dupliquée entre les deux écrans, part
dans `lib/i18n/client-language.ts`. Elle reste côté client à dessein : lire les
cookies au serveur ferait basculer en rendu dynamique une page que le build
prérend en statique.
Squasher `dev → main` écrit sur main un commit qui n'existe pas dans dev : git
ne peut plus savoir que les deux historiques se sont rencontrés, chaque PR
suivante reliste tout ce qui a déjà été livré, et l'écart se creuse à chaque
release. C'est l'origine des « plus de commits que prévu » observés sur les PR.

Le squash reste le bon outil pour une branche de feature, éphémère par nature.
…d'auth

next 16.2.10 → 16.2.12, qui ferme huit CVE. Une compte plus que les autres
ici : le contournement de middleware/proxy en App Router. `proxy.ts` porte le
guard d'authentification du projet, donc un contournement du proxy est un
contournement de l'auth sur les routes protégées.

Les sept autres : SSRF dans les Server Actions, SSRF via les rewrites, déni de
service en App Router, déni de service sur l'API d'optimisation d'images,
confusion de cache sur les corps de réponse (×2), charge utile non bornée en
runtime Edge, et divulgation non authentifiée des IDs de Server Actions.

sharp est forcé en >=0.35.0 : Next réclame ^0.34.5, dont libvips traîne
CVE-2026-33196. Le forçage est sans risque — `images.unoptimized` fait que
sharp n'est jamais appelé, et ignoredBuiltDependencies l'empêche d'être
compilé.

brace-expansion n'a pas eu besoin d'override : le bump d'eslint-config-next
tire minimatch@10, qui prend déjà une version saine.

`pnpm audit` : 0 vulnérabilité.
Validé : ESLint, tsc, 326 tests unitaires, build production, 49 tests E2E.
Mesuré sur iPhone 13 : la card fait 191 px de haut, le bloc de contenu en
faisait 260 avec le titre du jour et 296 avec un titre sur trois lignes. Le
débordement était systématique, pas un cas limite — `overflow-hidden` le
masquait en rognant le badge et le haut du titre.

`text-4xl` déborde dès que le titre passe à deux lignes, ce qui arrive à partir
de 23 caractères selon les glyphes. Un palier indexé sur la longueur de la
chaîne serait donc faux : « WWW WWW WWW WWW WWW WWW » (23) tient sur deux
lignes là où « Spider-Man: Brand New Day » (25) tient sur une. `text-3xl` plus
un `line-clamp-2` couvre tous les cas mesurés — jusqu'à 75 caractères — avec
11 px de marge au pire, et reste plus lisible que le `text-2xl` qu'un palier
aurait imposé aux titres longs.

Le synopsis passe en `sm:` : sur 191 px il coûtait 52 px pour deux lignes
illisibles. Espacements resserrés sur mobile uniquement, desktop inchangé
(marge de 218 px, vérifiée dans Chrome).

Vérifié dans les deux thèmes, mobile et desktop. 326 tests unitaires,
49 tests E2E.
`prettier --check .` échouait sur pnpm-lock.yaml, et faisait tomber le job de
formatage du CI depuis la mise à jour des dépendances — pnpm avait réécrit le
fichier à son propre format.

Le formater ne réglerait rien : pnpm le réécrit à chaque install, donc le CI
redeviendrait rouge au prochain changement de dépendance, pour un diff de
300 ko à chaque fois. Prettier respecte déjà .gitignore, ce qui couvre .next,
public/sw.js et les rapports de test ; seul le lockfile, versionné par
nécessité, devait être écarté explicitement.

`pnpm format` ne modifie aucun autre fichier du dépôt.
Deux causes cumulées, constatées sur le cluster :

- le Deployment de l'ingress porte des champs encore détenus par le field
  manager `kubectl-client-side-apply`, hérités de son installation manuelle.
  Helm refusait d'y toucher : `.spec.replicas`, les fieldRef POD_NAME et
  POD_NAMESPACE, et `resources.requests.memory`. `--force-conflicts` lève ces
  conflits sans recréer la ressource, donc sans coupure.
- `--atomic` est déprécié depuis Helm v4 et aggravait la panne : il tentait un
  rollback vers une release réussie qui n'existait pas. L'historique montrait
  les révisions 6, 7 et 8 toutes en échec depuis le 7 juillet — l'edge tournait
  donc à un seul replica depuis, ce qui explique les coupures Cloudflare.
  `--rollback-on-failure` le remplace.

Réparé en production : révision 9 `deployed`, deux replicas sur des nœuds
distincts, PDB actif, aucune interruption pendant la bascule.
# Conflicts:
#	.github/workflows/infra.yml
@TheSawkit
TheSawkit merged commit f420ee8 into main Aug 1, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant