Ctrl K
ComunidadGuías…

middleware.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Middleware Next.js — fait trois choses pour chaque requete HTML :
//   1. Routage de locale : si le path n'a pas de prefixe de langue,
//      détecte la locale (cookie → Accept-Language → défaut "en") et
//      redirige vers /{locale}/path.
//   2. Genere un nonce et pose un Content-Security-Policy strict (script-src
//      avec nonce + strict-dynamic). Next.js v13+ lit le header `x-nonce`
//      (et `Content-Security-Policy`) sur la requete et l'applique
//      automatiquement aux <script> qu'il inline pour l'hydration.
//   3. Pour les routes protegees, valide la session JWT et redirige vers
//      /{locale}/login si manquante.
//
// CSP rationale :
//   - script-src 'self' 'nonce-<x>' 'strict-dynamic' : interdit tout script
//     inline non-nonce ; le nonce permet aux scripts de Next d'en charger
//     d'autres dynamiquement (chunks). Pas d'allowlist statique a maintenir.
//   - style-src 'self' 'unsafe-inline' : compromis pragmatique car React
//     inline les style="..." attributes (ex. style={{ color: ... }}). Les
//     attributs HTML ne supportent pas les nonces, et les CSS injections
//     sont nettement moins critiques que les scripts.
//   - img-src 'self' data: blob: : data: pour le QR code 2FA (base64).
//   - frame-ancestors 'none' + form-action 'self' + base-uri 'self' :
//     defense en profondeur classique anti-clickjacking et anti-takeover.
//   - upgrade-insecure-requests : downgrade auto HTTP→HTTPS si jamais une
//     ressource passe avec un schema relatif.
//   - 'unsafe-eval' uniquement en dev : Next HMR l'exige. En prod : strict.

import NextAuth from "next-auth";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { authConfig } from "@/lib/auth.config";

const { auth } = NextAuth(authConfig);

const LOCALES = ["en", "es", "fr"] as const;
type Locale = (typeof LOCALES)[number];
// Self-host : langue par défaut de l'instance. L'utilisateur peut changer via
// le LocaleSwitcher (pose un cookie NEXT_LOCALE). Mettre "en"/"es" si besoin.
const DEFAULT_LOCALE: Locale = "fr";

const PROTECTED_PREFIXES = [
  "/dashboard",
  "/projects",
  "/orgs",
  "/settings",
  "/shares",
  "/vault",
  "/admin",
  // C-0050 : /account/cli, ouverte par `physalis login` — le retour après le
  // login doit ramener sur la page d'approbation avec son ?code=.
  "/account",
];

function getLocaleFromPath(path: string): Locale | null {
  const match = path.match(/^\/([a-z]{2})(\/|$)/);
  if (match && LOCALES.includes(match[1] as Locale)) return match[1] as Locale;
  return null;
}

function detectLocale(req: NextRequest): Locale {
  // 1. Préférence explicite de l'utilisateur (cookie posé par LocaleSwitcher).
  const cookie = req.cookies.get("NEXT_LOCALE")?.value;
  if (cookie && LOCALES.includes(cookie as Locale)) return cookie as Locale;

  // 2. Sinon langue par défaut de l'instance (DEFAULT_LOCALE). On n'utilise
  //    PAS Accept-Language en self-host : l'opérateur choisit la langue par
  //    défaut, l'utilisateur change via le sélecteur. (Pour revenir à la
  //    détection navigateur, ré-introduire la boucle sur `accept-language`.)
  return DEFAULT_LOCALE;
}

function stripLocale(path: string): string {
  return path.replace(/^\/[a-z]{2}(\/|$)/, "/");
}

function isProtectedPath(path: string): boolean {
  return PROTECTED_PREFIXES.some(
    (p) => path === p || path.startsWith(p + "/"),
  );
}

function buildCsp(nonce: string): string {
  const dev = process.env.NODE_ENV !== "production";
  const scriptSrc = [
    "'self'",
    `'nonce-${nonce}'`,
    "'strict-dynamic'",
    ...(dev ? ["'unsafe-eval'"] : []),
  ].join(" ");

  return [
    "default-src 'self'",
    `script-src ${scriptSrc}`,
    "style-src 'self' 'unsafe-inline'",
    "img-src 'self' data: blob:",
    "font-src 'self'",
    "connect-src 'self'",
    "frame-ancestors 'none'",
    "form-action 'self'",
    "base-uri 'self'",
    "object-src 'none'",
    "upgrade-insecure-requests",
  ].join("; ");
}

export default auth((req) => {
  const path = req.nextUrl.pathname;

  // Generation du nonce (16 bytes hex via Web Crypto, dispo en Edge runtime).
  const nonceBytes = new Uint8Array(16);
  crypto.getRandomValues(nonceBytes);
  const nonce = Array.from(nonceBytes, (b) =>
    b.toString(16).padStart(2, "0"),
  ).join("");
  const csp = buildCsp(nonce);

  // 1. Routage de locale : redirige /{path} → /{locale}/{path}
  const pathLocale = getLocaleFromPath(path);
  if (!pathLocale) {
    const locale = detectLocale(req);
    const target = new URL(`/${locale}${path === "/" ? "" : path}`, req.url);
    target.search = req.nextUrl.search;
    const response = NextResponse.redirect(target);
    response.headers.set("Content-Security-Policy", csp);
    return response;
  }

  // 2. Auth check : redirige vers /{locale}/login si route protégée + pas authentifié.
  const pathWithoutLocale = stripLocale(path);
  if (isProtectedPath(pathWithoutLocale) && !req.auth) {
    const url = new URL(`/${pathLocale}/login`, req.url);
    url.searchParams.set("callbackUrl", path);
    const response = NextResponse.redirect(url);
    response.headers.set("Content-Security-Policy", csp);
    return response;
  }

  // 3. Pose le nonce + CSP dans les headers de la requete pour que Next les
  //    lise pour l'hydration. Pose aussi la CSP sur la response pour que le
  //    navigateur l'applique. Les deux doivent etre coherents.
  const requestHeaders = new Headers(req.headers);
  requestHeaders.set("x-nonce", nonce);
  requestHeaders.set("Content-Security-Policy", csp);
  // next-intl lit ce header dans getRequestConfig (i18n/request.ts) pour
  // peupler `requestLocale`. Sans lui, la locale reste undefined → fallback
  // sur defaultLocale partout, et changer d'URL /en|/es ne traduit rien.
  requestHeaders.set("x-next-intl-locale", pathLocale);

  const response = NextResponse.next({
    request: { headers: requestHeaders },
  });
  response.headers.set("Content-Security-Policy", csp);
  return response;
});

// Matcher : toutes les routes HTML. On exclut :
//   - /api : pas de HTML
//   - /_next/static, /_next/image, favicon.ico : assets statiques
//   - les fichiers avec extension image
//   - les requetes de prefetch RSC (sinon le prefetch genere un nonce qui
//     ne correspondra pas a celui de la navigation reelle)
export const config = {
  matcher: [
    {
      // ⚠️ Porte depuis le middleware SaaS le 2026-09-16 — ce fichier en est un
      // jumeau mono-tenant tenu a la main. Le routage de locale est identique
      // des deux cotes, donc le defaut l'etait aussi : `/robots.txt` redirige
      // vers `/en/robots.txt`, qui n'existe pas, donc 404. La liste
      // d'extensions juste apres ne porte que des images et des archives.
      source:
        "/((?!api|_next/static|_next/image|favicon.ico|robots.txt|.*\\.(?:png|jpg|jpeg|gif|svg|webp|ico|zip)).*)",
      missing: [
        { type: "header", key: "next-router-prefetch" },
        { type: "header", key: "purpose", value: "prefetch" },
      ],
    },
  ],
};