Aller au contenu

HTTP

apiFetch, le client HTTP des pages d'admin et des pages publiques, et la lecture de l'adresse IP du client derrière un reverse proxy.

Une page d'admin parle à l'API du core et des modules sous /za/api/*. apiFetch est le client unique de ces appels, côté navigateur : chaque module portait sa propre fonction api(), et les copies ne s'accordaient ni sur les en-têtes envoyés ni sur l'erreur levée. Côté serveur, le même sous-chemin donne l'adresse IP du client, sans jamais faire confiance à un en-tête que le client peut forger.

Import

import { apiFetch, getIpFromRequest, getIpFromHeaders, truncateIp } from '@fraza/core/shared/http';

Sous-chemin neutre : le seul fichier qu'il tire en plus est le catalogue neutre des erreurs d'API, core/api/errors.js, sans aucun import, et check-bundle le vérifie à chaque build du core. apiFetch s'importe donc dans un fichier 'use client' sans entraîner de code serveur. Les lecteurs d'IP n'ont de sens que côté serveur, là où une requête a des en-têtes.

API

SignatureRetourRôle
apiFetch(path, { method, body, signal, headers }?)Promise<*>Appelle une route et retourne sa charge JSON.
getIpFromRequest(request)stringAdresse IP du client d'une route API.
getIpFromHeaders(headersList)stringAdresse IP du client d'une server action.
truncateIp(ip, { v4, v6 }?)string | nullRéseau de l'adresse, en notation CIDR.

apiFetch(path, { method, body, signal, headers }?)

'use client';

import { apiFetch } from '@fraza/core/shared/http';

const API = '/za/api/booking';

export async function saveAppointment(id, data) {
  const { appointment } = await apiFetch(`${API}/appointments/${id}`, { method: 'PATCH', body: data });
  return appointment;
}

// Dans la page : l'erreur porte déjà un message en français, prêt pour le toast.
// try { await saveAppointment(id, data); } catch (error) { toast.error(error.message); }
OptionDéfautRôle
methodPOST avec un corps, GET sansMéthode HTTP.
bodyaucunUn objet ou un tableau est envoyé en JSON. Un FormData, un Blob, un URLSearchParams, un flux ou une chaîne passe tel quel.
signalaucunAbortSignal d'un AbortController, pour annuler l'appel.
headersaucunEn-têtes ajoutés à l'appel. Ils sont tous gardés.

Le comportement, dans l'ordre :

  1. Un corps objet ou tableau est sérialisé, et Content-Type: application/json est posé seulement si l'appelant n'en a pas fourni. Un FormData garde le Content-Type que fetch calcule avec sa frontière multipart, qu'un en-tête écrit à la main perdrait.
  2. Accept: application/json est posé s'il manque, et credentials: 'include' toujours : le cookie de session doit voyager pour que le routeur du core authentifie l'appel et vérifie son origine.
  3. Une réponse vide (204, ou un corps vide) retourne null. Une réponse qui n'est pas du JSON, comme la page d'erreur d'un proxy, revient en texte.
  4. Une réponse hors 2xx lève une Error. Son message est le message de la réponse, celui qu'apiError() écrit pour l'utilisateur, ou à défaut le message du catalogue des erreurs pour ce statut, celui que le serveur aurait envoyé : utile quand un proxy répond à la place de l'application. Un 5xx absent du catalogue prend le message du 500. L'erreur porte status, code, le code de la charge ou null, et payload, la charge reçue.
  5. Une panne réseau lève une Error de status 0, avec l'erreur d'origine dans cause. Une annulation par signal est relancée telle quelle : l'appelant reconnaît son AbortError et ne l'affiche pas.
useEffect(() => {
  const controller = new AbortController();
  apiFetch(`${API}/slots?date=${date}`, { signal: controller.signal })
    .then(setSlots)
    .catch((error) => {
      if (error.name !== 'AbortError') setError(error.message);
    });
  return () => controller.abort();
}, [date]);

Adresse IP du client

getIpFromHeaders(headersList) et getIpFromRequest(request)

Extraient l'adresse IP du client : getIpFromHeaders depuis le headers() d'une server action, getIpFromRequest depuis la Request d'une route API. Elles ne lisent X-Forwarded-For et X-Real-IP que lorsque FRAZA_TRUST_PROXY déclare un proxy : sans cette précaution, n'importe quel client pourrait forger l'en-tête et contourner la limitation en variant la valeur. La valeur lue est en plus validée comme adresse IPv4 ou IPv6, groupes et :: compris : une chaîne arbitraire, ou une graphie IPv6 malformée, ne devient jamais une clé de compteur.

FRAZA_TRUST_PROXY porte le nombre de reverse proxys devant l'application, true valant 1. X-Forwarded-For est lu par la droite : chaque proxy ajoute en fin d'en-tête l'adresse qui vient de le joindre, donc avec N proxys déclarés, l'adresse du client est la N-ième en partant de la fin. Ce qui se trouve à sa gauche a voyagé avec la requête et reste falsifiable : la lecture ne part donc jamais de la première entrée.

En-tête reçuFRAZA_TRUST_PROXYAdresse retenue
203.0.113.7true203.0.113.7
1.2.3.4, 203.0.113.7, dont 1.2.3.4 forgé par le clienttrue203.0.113.7
1.2.3.4, 203.0.113.7, 172.70.0.1, derrière Cloudflare puis nginx2203.0.113.7

Quand l'en-tête compte moins d'entrées que de proxys déclarés, la requête n'a pas traversé toute la chaîne : la première entrée est retenue, faute de mieux. X-Real-IP n'est lu que si X-Forwarded-For ne donne aucune adresse valide. Il ne porte qu'une valeur, et n'est donc fiable que si le proxy le plus proche de l'application l'écrase.

Sans proxy, laisser la variable à false. Le choix de la valeur est détaillé dans Déploiement.

Quand rien n'est exploitable, le retour dépend de l'environnement : 127.0.0.1 en développement, pour que la limitation se comporte en local comme pour un vrai client, et 'unknown' sinon. Le limiteur range alors l'appelant dans le compteur commun ip:unknown, et groupe une adresse IPv6 par son /64.

truncateIp(ip, { v4, v6 }?)

Rend le réseau d'une adresse, en notation CIDR : ce qu'il faut garder pour dire d'où vient une requête sans nommer l'abonné. Les longueurs par défaut, /24 en IPv4 et /48 en IPv6, sont la part qu'un fournisseur attribue à tout un réseau plutôt qu'à un foyer.

import { getIpFromRequest, truncateIp } from '@fraza/core/shared/http';

truncateIp('203.0.113.7');                          // '203.0.113.0/24'
truncateIp('2001:db8:85a3:8d3:1319:8a2e:370:7348'); // '2001:db8:85a3::/48'
truncateIp('::ffff:203.0.113.7');                   // '203.0.113.0/24'
truncateIp('203.0.113.7', { v4: 16 });              // '203.0.0.0/16'
truncateIp(getIpFromRequest(request));              // null pour 'unknown'

Une adresse IPv4 portée par une adresse IPv6 (::ffff:…) est lue comme l'IPv4 qu'elle porte. Une valeur déjà en CIDR est coupée à nouveau, jamais élargie : appliquer la fonction deux fois donne le même résultat, ce qui permet à une migration de repasser sur des valeurs déjà réduites. Tout ce qui n'est pas une adresse, 'unknown' compris, rend null : rien de ce que la requête a écrit n'est gardé tel quel. L'IPv6 s'écrit sous sa forme courte, la plus longue suite de groupes nuls remplacée par ::.

Variables d'environnement

VariableRôle
FRAZA_TRUST_PROXYNombre de reverse proxys de confiance devant l'application. Sans elle, les en-têtes de proxy sont ignorés.

Notes

path est passé tel quel à fetch. Dans le navigateur, un chemin absolu comme /za/api/drive/nodes vise l'origine de la page. Côté serveur, fetch exige une URL complète : un composant serveur lit la base directement plutôt que de s'appeler lui-même par HTTP.

apiFetch ne réessaie pas. Une requête mutante rejouée en silence peut s'appliquer deux fois. C'est à l'appelant de décider, et le status de l'erreur lui donne de quoi le faire.

Sur cette page