Search by

cap-rel / feedback-client

Eric-CAP-REL

Client PHP, relais serveur et widget navigateur pour relayer les signalements des utilisateurs finaux vers l'API d'envoi de capTests (bouton "Signaler un problème" pour pages PHP classiques et modules Dolibarr).

Package info

inligit.fr/cap-rel/feedback-client

pkg:composer/cap-rel/feedback-client

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

v1.0.0 2026-10-29 11:30 UTC

This package is auto-updated.

Last update: 2026-10-29 17:45:42 UTC


README

Paquet PHP qui ajoute un bouton "Signaler un problème" dans une application PHP classique (page rendue côté serveur, module Dolibarr) et qui relaye le signalement des utilisateurs finaux vers l'API d'envoi de capTests, depuis le serveur de l'application. Le navigateur ne voit jamais la clé d'envoi.

  • Licence : GPL-3.0-or-later
  • PHP : >= 7.4 (testé sur 7.4 et 8.4)
  • Extensions requises : ext-curl, ext-json, ext-mbstring
  • Dépendances d'exécution : aucune (hors extensions PHP)
  • Espace de noms : CapRel\FeedbackClient\

Installation

composer require cap-rel/feedback-client

Le paquet ne dépend pas de Dolibarr : l'adaptateur Dolibarr est isolé dans src/Dolibarr/ et n'est chargé que par un module Dolibarr.

Architecture en un coup d'oeil

Navigateur                          Serveur de l'application          capTests
---------                          -----------------------          --------
[Widget JS/CSS]
  bouton "Signaler un problème"
  fenêtre de saisie + capture
        |
        | POST multipart (texte, capture, jeton CSRF)
        v
                       [Relais Relay]
                         - vérifie le jeton CSRF de l'application
                         - résout le déclarant depuis la session
                           (le navigateur n'est JAMAIS cru pour
                           l'identité ni pour is_referent)
                         - exige le consentement si capture jointe
                         - filtre les secrets (journaux, URL)
                           et retire la chaîne de requête des URL
        |
        | POST /api/v1/intake/findings (JSON ou multipart)
        | Authorization: Bearer <clé> + Idempotency-Key
        v
                                                        [API d'envoi capTests]
                                                          200 {data: {ref, ...}}
        |
        | JSON {ok, ref, tracking_url, finding_id}
        v
  fenêtre de confirmation avec la référence
  et le lien de suivi

Quatre briques, utilisables séparément ou ensemble :

BriqueClassesRôle
Client HTTPClient\IntakeClient, Client\IntakeClientOptions, Transport\CurlTransportAppelle l'API d'envoi (section 8 du cahier des charges)
Relais serveurRelay\Relay, Relay\RelayConfig, Relay\RelaySubmission, Relay\RelayResultReçoit le signalement du navigateur, le transmet au client
Widget navigateurWidget\WidgetRenderer + assets/feedback-widget.js + assets/feedback-widget.cssBouton, fenêtre, capture, envoi par fetch
Adaptateur DolibarrDolibarr\DolibarrBridge, Dolibarr\DolibarrLoggerHook printTopRightMenu, page de relais, jeton CSRF Dolibarr

Deux briques transversales : Sanitize\SecretFilter (filtrage des secrets) et Logging\LoggerInterface (journalisation injectable, sans dépendance PSR-3).

Configuration

Le paquet n'a pas de fichier de configuration : tout se passe en PHP, par constructeur ou par propriétés publiques.

Client HTTP

use CapRel\FeedbackClient\Client\IntakeClient;
use CapRel\FeedbackClient\Client\IntakeClientOptions;
use CapRel\FeedbackClient\Transport\CurlTransport;

$options = new IntakeClientOptions('https://captests.example.com', 'ctk_...');
$options->maxTotalSeconds = 20;         // budget de temps global d'un envoi (toutes tentatives et attentes)
$options->timeoutSeconds = 8;           // délai total par tentative (plafonné au budget restant)
$options->connectTimeoutSeconds = 3;    // délai de connexion (plafonné au budget restant)
$options->maxAttempts = 3;              // tentatives au total (1 + reprises)
$options->baseDelayMilliseconds = 500;  // délai avant la 1re reprise, doublé ensuite (max 5000 ms)
$options->retryOnRateLimit = true;      // reprend sur 429 en respectant Retry-After
$options->maxRetryAfterSeconds = 30;    // plafond appliqué au délai Retry-After

$client = new IntakeClient(new CurlTransport(), $options, $logger);

CurlTransport accepte un paramètre verifySsl (true par défaut) ; passez false uniquement en développement contre un serveur local en HTTP.

Envoi d'un signalement

use CapRel\FeedbackClient\Client\FindingPayload;
use CapRel\FeedbackClient\Client\ReporterInfo;
use CapRel\FeedbackClient\Client\Screenshot;

$payload = new FindingPayload();
$payload->type = 'bug';                 // 'bug' ou 'idea'
$payload->title = 'Le total ne se recalcule pas';
$payload->body = 'En cliquant sur Enregistrer, le total reste à 0.';
$payload->reporter = new ReporterInfo('jean@example.com', 'Jean Dupont', '42', false);
$payload->installedVersion = '2.0.1';   // version installée (serveur seul)
$payload->appName = 'monmodule';
$payload->channel = 'web';              // pwa, web, native ou other
$payload->platform = array('name' => 'PHP', 'version' => PHP_VERSION);
$payload->appVersion = '2.0.1';         // version vue par le navigateur
$payload->pageUrl = 'https://app.example.com/commande/card.php?id=12';
$payload->route = '/commande/card.php';
$payload->userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
$payload->viewport = '1366x768';
$payload->locale = 'fr-FR';
$payload->rights = array('monmodule->feedback->report');
$payload->logExcerpt = array('2026-10-28 10:00:01 INFO  ...');
$payload->occurredAt = date('c');
$payload->screenshot = new Screenshot($pngBytes, 'image/png', 'capture.png');
$payload->screenshotConsent = true;

// La clé d'idempotence est générée par le paquet ; passez-la explicitement
// uniquement si vous rejouez vous-même un envoi.
$receipt = $client->sendFinding($payload);
echo $receipt->ref;          // référence client, ex. "CSPN-12"
echo $receipt->trackingUrl;  // adresse de suivi
echo $receipt->findingId;    // identifiant interne

sendHeartbeat(string $installedVersion) envoie le battement de présence vers POST /api/v1/intake/heartbeat (réponse 204).

Erreurs typées

Chaque code de réponse de l'API lève une exception dédiée, toutes filles de CapRel\FeedbackClient\Exception\IntakeException :

HTTPExceptionCas
(réseau)TransportExceptionErreur cURL, délai dépassé, DNS...
401AuthenticationExceptionClé d'envoi inconnue ou révoquée
403OrganizationInactiveExceptionOrganisation inactive, projet non rattaché
404EndpointNotFoundExceptionMauvais préfixe d'URL
413PayloadTooLargeExceptionCorps ou capture trop gros
422ValidationExceptionValidation échouée, détail par champ dans ->errors
429RateLimitExceptionDébit dépassé, délai dans ->retryAfterSeconds
503ServiceUnavailableExceptionService indisponible
autre 4xx/5xxUnexpectedStatusExceptionRéponse inattendue
200/201 malforméMalformedResponseExceptionStructure {data: ...} absente
(local)ValidationExceptionLimite dépassée détectée avant l'envoi (->local === true)

Reprises

Une nouvelle tentative n'a lieu que quand elle est sûre : erreur réseau, 503, et éventuellement 429 en respectant le délai Retry-After (plafonné par maxRetryAfterSeconds). Jamais sur les autres 4xx. La clé Idempotency-Key est générée une fois par signalement et réutilisée à l'identique à chaque tentative, pour que l'API dédoublonne.

Budget de temps

Un envoi ne doit jamais bloquer au-delà de la limite d'exécution de PHP (max_execution_time, 30 secondes par défaut) : PHP couperait la requête en plein envoi et le navigateur recevrait une réponse illisible. Un budget de temps global, maxTotalSeconds (20 secondes par défaut), couvre toutes les tentatives et toutes les attentes :

  • le délai total de chaque tentative (timeoutSeconds, 8 secondes) et le délai de connexion (connectTimeoutSeconds, 3 secondes) sont plafonnés au budget restant ;
  • une attente de reprise (délai doublé à chaque tentative) n'est effectuée que si elle tient dans le budget restant, sinon l'envoi échoue immédiatement avec l'exception de la dernière erreur ;
  • sur un 429, Retry-After n'est attendu que s'il tient dans le budget restant ; sinon RateLimitException est levée tout de suite, avec le délai proposé dans ->retryAfterSeconds.

L'horloge (clock) et la fonction d'attente (sleeper) sont injectables, ce qui permet de tester le budget sans attendre réellement.

Limites vérifiées avant l'envoi

Le client vérifie localement, avant tout envoi, les limites de la section 8.2 qui font le signalement, et échoue tôt avec une ValidationException portant le détail des champs : type bug ou idea, titre 255 caractères, corps 20 000, déclarant (adresse valide, nom 255, référence 128), version installée 64, capture 5120 Ko (PNG, JPEG ou WebP, consentement obligatoire), clé d'idempotence 64 caractères.

Le contexte (app, user_agent, page_url, extrait de journal, erreurs réseau...) n'est jamais refusé : il est coupé aux mêmes limites que capTests avant l'envoi (voir "Limites du contexte" dans "Contrat avec capTests" ci-dessous), chaque coupe étant journalisée par le nom du champ, jamais la valeur.

Filtrage des secrets

Sanitize\SecretFilter filtre, côté application, avant tout envoi :

  • l'extrait de journal (SecretFilter::filterText()) : jetons JWT, Bearer <jeton>, Authorization: <valeur>, et tout password|passwd|pwd|token|secret|api_key|apikey|dolapikey suivi de = ou : et d'une valeur (insensible à la casse) ;
  • les URL (SecretFilter::filterUrl()) : même filtrage, plus la chaîne de requête retirée (SecretFilter::stripQueryString()), pour ne pas divulguer ?dolapikey=..., ?token=..., etc.

Chaque remplacement est compté (FilterResult->replacements) et journalisé. Si le moteur de filtrage échoue, le contenu est supprimé (marqueur [ligne retirée : filtrage impossible]) et l'événement est journalisé par le logger du paquet : le contenu n'est jamais envoyé non filtré. La spec exige ce filtrage côté application ET côté capTests : le paquet réalise le filtrage application ; le filtrage capTests reste côté serveur capTests.

Contrat avec capTests

Points d'entrée

AppelMéthodeCorpsSuccès
SignalementPOST /api/v1/intake/findingsJSON, ou multipart avec capture201 (200 pour un rejeu idempotent)
HeartbeatPOST /api/v1/intake/heartbeatJSON {installed_version}204

Les deux appels portent Authorization: Bearer <clé> ; le signalement porte en plus Idempotency-Key (générée par le paquet, réutilisée à l'identique en cas de reprise).

Réponses

Succès : enveloppe {"data": {...}, "meta": {...}}. Le signalement retourne data.finding_id, data.ref et data.tracking_url.

Erreurs : enveloppe {"error": {...}, "meta": {...}} :

{
  "error": {"code": "validation_failed", "message": "Les données fournies sont invalides.", "details": {"body": ["..."]}},
  "meta": {"request_id": "...", "timestamp": "..."}
}
  • error.code : code stable (unauthenticated, forbidden, not_found, validation_failed, idempotency_key_reused, rate_limit_exceeded, server_error, service_unavailable, bad_request, conflict) ;
  • error.message : message lisible, en français ;
  • error.details : facultatif, table des champs en erreur pour un 422 de validation (format des erreurs Laravel) ;
  • meta.request_id : identifiant de la requête côté capTests, repris dans les journaux du paquet à chaque erreur.

Le client choisit son exception d'après le statut HTTP, puis l'affine avec error.code (un code inconnu garde l'exception du statut) et expose ces valeurs sur les exceptions : apiCode et requestId. Le 422 idempotency_key_reused lève IdempotencyKeyReusedException, distincte de la validation des champs. Une réponse non JSON ou sans enveloppe lève MalformedResponseException.

Limites du contexte : coupées, jamais refusées

Le contexte est un diagnostic : une valeur hors bornes est coupée, jamais refusée (règles identiques à capTests, section 18.5). Le paquet applique la coupe avant l'envoi, ce qui réduit aussi la taille envoyée ; chaque coupe est journalisée par le nom du champ, jamais la valeur.

ChampRègle
context.app, context.app_version, context.platform.name, context.platform.versioncoupés à 64 caractères
context.user_agentcoupé à 512 caractères
context.viewportcoupé à 32 caractères
context.localecoupé à 16 caractères
context.page_url, context.route, context.network_errors[].urlcoupés à 1024 caractères
context.network_errors[].methodcoupé à 16 caractères ; at à 64
context.rightsles 200 premiers, chacun coupé à 128 caractères
context.log_excerptles dernières lignes qui tiennent dans 200 lignes et 32768 octets ; une dernière ligne plus grande que le budget garde son début
context.network_errorsles 20 dernières ; une entrée sans URL est écartée
context.channelécarté s'il n'est pas pwa, web, native ou other
context.occurred_atécarté s'il n'est pas une date ISO 8601 bien formée

Dans context.network_errors, un statut 0 signifie "sans réponse" (une erreur de niveau réseau, sans code HTTP) : capTests l'affiche ainsi.

Encodage multipart (signalement avec capture)

Quand une capture est jointe, le corps est multipart/form-data : les champs du signalement sont des champs de formulaire à crochets (format validé nativement par Laravel côté capTests), plus la partie fichier screenshot.

ChampTypeExemple
typetextebug
titletexteEnregistrement impossible
bodytexteLa fiche ne s'enregistre pas.
reporter[email]textejean@example.com
reporter[name]texteJean Dupont
reporter[external_ref]textea1b2c3d4e5f6:42
reporter[is_referent]booléen 1 / 00
installed_versiontexte2.0.1
context[app]textemonmodule
context[channel]texteweb
context[platform][name]texte (objet imbriqué)Dolibarr
context[platform][version]texte (objet imbriqué)18.0.10
context[app_version]texte2.0.1
context[page_url]textehttps://... (chaîne de requête retirée)
context[route]texte/monmodule/card.php
context[user_agent]texteMozilla/5.0 ...
context[viewport]texte1920x1080
context[locale]textefr-FR
context[rights][]tableaumonmodule->feedback->report
context[log_excerpt][]tableauligne de journal
context[network_errors][0][method]texte (tableau d'objets)GET
context[network_errors][0][url]textehttps://...
context[network_errors][0][status]texte (entier)500
context[network_errors][0][at]texte (date ISO 8601)2026-10-28T10:00:00+00:00
context[occurred_at]texte (date ISO 8601)2026-10-28T10:00:00+00:00
screenshot_consentbooléen 1 / 01
screenshotfichierpartie fichier, type MIME image/png, image/jpeg ou image/webp, 5120 Ko max

Règles d'encodage : les booléens sont transmis 1 / 0, les tableaux avec un suffixe [] (context[rights][], context[log_excerpt][]), les objets imbriqués avec des crochets emboîtés (context[platform][name]), les tableaux d'objets avec un index numérique (context[network_errors][0][url]). Sans capture, le même contenu part en JSON avec exactement les mêmes noms de champs et les mêmes valeurs : un test de bout en bout vérifie que le corps multipart, une fois décodé par PHP, redonne le même contenu que le JSON du même signalement.

Identifiant du déclarant

reporter.external_ref vaut <identifiant d'instance>:<id utilisateur> : voir "Identifiant d'instance" dans la section du relais ci-dessous.

Utilisation hors Dolibarr

Une page PHP classique suffit. L'exemple complet et commenté est dans examples/standalone/report.php : une seule page qui porte le widget, sert les assets JS/CSS (pass-through depuis vendor/, avec une liste blanche de deux fichiers) et fait office de relais.

use CapRel\FeedbackClient\Client\IntakeClient;
use CapRel\FeedbackClient\Client\IntakeClientOptions;
use CapRel\FeedbackClient\Client\ReporterInfo;
use CapRel\FeedbackClient\Relay\Relay;
use CapRel\FeedbackClient\Relay\RelayConfig;
use CapRel\FeedbackClient\Relay\RelaySubmission;
use CapRel\FeedbackClient\Transport\CurlTransport;
use CapRel\FeedbackClient\Widget\WidgetRenderer;

// 1. Relais : reçoit le POST du navigateur, transmet à l'API.
$config = new RelayConfig(function () {
    // Le déclarant vient de la session de l'application, jamais du
    // navigateur.
    return new ReporterInfo($_SESSION['user_email'], $_SESSION['user_name'], $_SESSION['user_id'], false);
}, 'a1b2c3d4e5f6'); // identifiant d'instance, obligatoire (voir ci-dessous)
$config->installedVersion = '2.0.1';
$config->appName = 'monappli';
$config->csrfTokenValidator = function (string $token): bool {
    return hash_equals($_SESSION['csrf'], $token);
};

$client = new IntakeClient(new CurlTransport(), new IntakeClientOptions($apiUrl, $apiKey));
$result = (new Relay($client, $config))->handle(RelaySubmission::fromGlobals());
header('Content-Type: application/json; charset=utf-8');
echo $result->toJson();

// 2. Widget : le bouton, avec sa configuration en attributs data-*.
echo WidgetRenderer::render(array(
    'relay_url' => 'report.php',      // adresse du relais (cette page)
    'csrf_token' => $csrfToken,       // jeton CSRF fourni par la page hôte
    'app_version' => '2.0.1',
));

// 3. Assets : à charger dans le <head>.
echo WidgetRenderer::renderAssets('report.php?asset=feedback-widget.js', 'report.php?asset=feedback-widget.css');

Le relais : ce qu'il garantit

Relay::handle(RelaySubmission) :

  1. vérifie le jeton CSRF si RelayConfig->csrfTokenValidator est fourni ;
  2. résout le déclarant via RelayConfig->reporterResolver (session de l'application) ; sans déclarant authentifié, le relais refuse ;
  3. exige le consentement (screenshot_consent) quand une capture est jointe ;
  4. construit le payload : champs du navigateur (texte, titre, type, page, route, version vue par le navigateur, dimensions, langue, et pour une PWA extrait de journal, erreurs réseau et date d'occurrence) + champs connus du seul serveur (déclarant, version installée, plate-forme, droits, extrait de journal) ;
  5. transmet au client, qui filtre les secrets et retire la chaîne de requête des URL ;
  6. renvoie un RelayResult : succès avec reference et trackingUrl, ou erreur affichable. La clé d'envoi et les détails internes ne figurent jamais dans la réponse.

Statuts HTTP et champ code. La réponse du relais porte un statut HTTP significatif, et le corps JSON d'une erreur contient un champ code stable (identique d'un appel à l'autre), qui reprend error.code de capTests quand l'échec vient d'une réponse de l'API :

HTTPcodeCas
200-Succès ({ok: true, ref, tracking_url, finding_id})
400invalid_submission, validation_failed, consent_requiredSaisie invalide (validation locale ou 422 de l'API), consentement manquant
403invalid_csrf_token, invalid_session, missing_rightJeton CSRF, déclarant absent, droit manquant
405method_not_allowedMéthode autre que POST
409idempotency_key_reusedClé d'idempotence réutilisée avec un autre contenu (refus définitif)
413screenshot_too_large, payload_too_largeCapture trop grosse (locale ou API)
429rate_limit_exceededDébit dépassé
502not_configured, unauthenticated, forbidden, service_unavailable, unexpected_response, not_found, server_errorcapTests injoignable, clé refusée, réponse inattendue

Le corps d'une erreur est {ok: false, error, code, errors?}. Le widget lit le corps JSON quel que soit le statut HTTP (la décision se prend sur payload.ok), et affiche un message générique si le corps n'est pas lisible (une page d'erreur HTML, par exemple).

Identifiant d'instance (obligatoire). Le deuxième argument du constructeur de RelayConfig est l'identifiant de l'instance de l'application. Le relais préfixe l'external_ref du déclarant avec : <identifiant d'instance>:<id utilisateur>. capTests rapproche les déclarants par organisation et external_ref : sans ce préfixe, l'utilisateur n° 3 d'une instance de test serait confondu avec l'utilisateur n° 3 de la production, et verrait ses remarques. Sous Dolibarr, le paquet dérive cet identifiant de $conf->file->instance_unique_id et n'envoie que les 12 premiers caractères de hash('sha256', 'feedback-client:' . $conf->file->instance_unique_id) : la valeur brute sert aussi de clé de chiffrement à Dolibarr, elle n'est jamais envoyée. Hors Dolibarr, choisissez une valeur stable par installation (par exemple le hash tronqué d'une valeur secrète de votre application). La longueur totale reste dans la limite de 128 caractères de l'API.

Champs lus depuis le navigateur : token, title, body, type, page_url, route, app_version, viewport, locale, occurred_at, log_excerpt[], network_errors, screenshot (fichier), screenshot_consent, et l'en-tête Idempotency-Key (voir la section "Relais pour une PWA" ci-dessous). Les champs d'identité éventuellement postés (reporter_email, reporter_name, is_referent...) sont délibérément ignorés.

Le widget navigateur

assets/feedback-widget.js + assets/feedback-widget.css, sans framework ni étape de compilation. Configuration par attributs data-* posés par WidgetRenderer::render() :

AttributRôle
data-caprel-feedbackConteneur du widget
data-relay-urlAdresse du relais (POST du fetch)
data-csrf-tokenJeton CSRF de la page hôte
data-app-versionVersion vue par le navigateur (diagnostic)
data-routeRoute de l'application (défaut : chemin de la page)
data-text-<clé>Remplacement d'un texte (voir WidgetRenderer::defaultTexts())

Fonctionnalités : fenêtre de saisie modale, capture facultative par getDisplayMedia (avec aperçu et possibilité de la retirer) ou image choisie à la main, case de consentement obligatoire avec une capture, envoi par fetch en multipart, message de succès avec la référence et le lien de suivi. Pour la capture par getDisplayMedia : la fenêtre est masquée (classe CSS) pendant la capture pour ne pas apparaître sur l'image, l'image est copiée après la reprise de la vidéo (playing, deux requestAnimationFrame, puis requestVideoFrameCallback quand le navigateur le fournit), réduite à 1920 pixels de large au plus en conservant les proportions, encodée en PNG, puis réencodée en WebP (ou JPEG) avec une qualité décroissante si elle dépasse 5120 Ko. Si la taille ne peut pas être ramenée sous la limite, la capture n'est pas jointe et un message clair est affiché. Une annulation par l'utilisateur affiche "Capture annulée." et non une erreur. Les pistes du flux sont toujours arrêtées, succès, refus ou erreur. La réponse du relais est lue en JSON quel que soit le statut HTTP (la décision se prend sur payload.ok) ; un corps illisible affiche un message générique.

Compatible CSP stricte : aucun gestionnaire inline (onclick, style inline), aucun eval. Textes en français par défaut, remplaçables un par un. Accessible : navigation clavier, libellés associés aux champs, focus posé à l'ouverture et rendu à la fermeture de la fenêtre.

Les assets ne sont pas forcément accessibles en HTTP depuis vendor/ : servez les par pass-through (voir l'exemple standalone et la page de relais de l'exemple Dolibarr, qui répondent à ?asset=feedback-widget.js et ?asset=feedback-widget.css). L'URL est versionnée : ?asset=feedback-widget.js&v=<empreinte>, l'empreinte étant les 12 premiers caractères du sha256 du contenu du fichier (Widget\AssetCache). Ainsi, un fichier modifié a une URL modifiée, et la réponse peut être mise en cache un an (Cache-Control: private, max-age=31536000, immutable, private parce que la réponse est servie à une session authentifiée). Sans v (ou avec une empreinte périmée), la réponse est private, no-cache. Un ETag est toujours envoyé, et un If-None-Match correspondant est répondu par un 304 sans corps.

Relais pour une PWA (canal pwa)

Une PWA (par exemple le bouton FeedbackButton de @cap-rel/smartcommon) peut utiliser le même relais. Elle envoie ses champs à plat (jamais de context[...], de canal ni d'identité : le relais construit le context lui-même) et garde un signalement en file tant que le réseau est coupé, en le renvoyant ensuite avec la même clé Idempotency-Key :

  • champs : title, body, type, page_url, route, app_version, viewport, locale, occurred_at, log_excerpt[], network_errors[i][method|url|status|at], screenshot, screenshot_consent ;
  • en-tête Idempotency-Key : retransmis tel quel à capTests quand il est non vide, limité à 64 caractères et composé de [A-Za-z0-9_-] ; sinon il est journalisé et remplacé par une clé générée par le paquet ;
  • log_excerpt[] : fusionné avec l'extrait du fournisseur serveur (les lignes navigateur en dernier), envoyé tel quel : c'est le paquet qui le coupe aux limites de l'API avant l'envoi (200 lignes, 32768 octets, les plus récentes gardées, une dernière ligne plus grande garde son début). Un extrait trop long est un simple diagnostic, il ne fait pas refuser le signalement ;
  • occurred_at : conservé quand c'est une date ISO 8601 bien formée, vieille de moins de 30 jours et pas dans le futur (5 minutes de tolérance pour l'horloge du téléphone), puis normalisée avant l'envoi ; sinon l'heure du serveur est utilisée et le rejet est journalisé.

La PWA s'authentifie par son jeton smartauth, lu côté serveur : pas de jeton CSRF (csrfTokenValidator laissé à null), et le reporterResolver lit l'utilisateur depuis ce jeton, jamais depuis un champ envoyé par le navigateur. Le canal vaut pwa :

use CapRel\FeedbackClient\Client\IntakeClient;
use CapRel\FeedbackClient\Client\IntakeClientOptions;
use CapRel\FeedbackClient\Client\ReporterInfo;
use CapRel\FeedbackClient\Relay\Relay;
use CapRel\FeedbackClient\Relay\RelayConfig;
use CapRel\FeedbackClient\Relay\RelaySubmission;
use CapRel\FeedbackClient\Transport\CurlTransport;

$smartauthUser = authenticateFromTheBearerToken(); // lecture du jeton, côté serveur

$config = new RelayConfig(static function () use ($smartauthUser): ReporterInfo {
    // Le déclarant vient du jeton smartauth, jamais du navigateur.
    return new ReporterInfo($smartauthUser['email'], $smartauthUser['name'], $smartauthUser['id'], false);
}, $instanceId); // identifiant d'instance, obligatoire (voir ci-dessus)
$config->channel = 'pwa';
$config->installedVersion = '2.0.1';
$config->appName = 'monmodule';
// csrfTokenValidator laissé à null : la PWA porte un jeton smartauth.

$client = new IntakeClient(new CurlTransport(), new IntakeClientOptions($apiUrl, $apiKey));
$result = (new Relay($client, $config))->handle(RelaySubmission::fromGlobals());

header('Content-Type: application/json; charset=utf-8');
http_response_code($result->ok ? 200 : $result->httpStatus);
echo $result->toJson();

La réponse au navigateur est {ok: true, ref, tracking_url, finding_id} ou {ok: false, error, code, errors?}, avec les statuts 400, 403, 409, 413, 429 et 502 (tableau ci-dessus). Quand la PWA renvoie un signalement avec une clé déjà utilisée pour un autre contenu, le relais répond 409 (conflit avec la clé déjà enregistrée) avec le code stable idempotency_key_reused : aucun renvoi avec cette clé ne peut réussir. À l'envoi, la PWA ne met en file que sur 429, 502, 503 ou une erreur réseau : tout autre statut est un refus définitif, affiché sans mise en file. Au renvoi d'un signalement de sa file hors ligne, elle garde le signalement sur 401 (session expirée) et 408 (requête interrompue) en plus de 429, 502 et 503 : tout autre statut est un refus définitif, elle retire le signalement de sa file au lieu de le renvoyer. Le filtrage des secrets s'applique aussi aux lignes log_excerpt venues du navigateur, y compris sous forme JSON ({"password":"..."}).

Intégration Dolibarr pas à pas

Le paquet ne peut pas créer seul les fichiers d'un module. Un exemple de module complet et fonctionnel est fourni dans examples/dolibarr/ ; les étapes ci-dessous reprennent sa structure.

1. Dépendance Composer du module

Dans le composer.json du module (le module a son propre autoloader, chargé par dol_include_once('/monmodule/vendor/autoload.php')) :

{
    "require": {
        "cap-rel/feedback-client": "*"
    }
}

Règles impératives côté module : inclusion des fichiers du paquet uniquement par dol_include_once('/monmodule/...'), jamais par chemin relatif ni __DIR__ ; texte injecté en JavaScript via dol_escape_js($langs->transnoentities(...)), jamais trans() ; journal via dol_syslog (le paquet fournit DolibarrLogger qui fait exactement cela) ; jamais d'appel à l'API REST native de Dolibarr (/api/index.php, DOLAPIKEY).

2. Descripteur du module

Déclarer le droit d'envoi et les contextes de hook main (qui déclenche printTopRightMenu dans htdocs/main.inc.php) et login (qui déclenche afterLogin sur la page de connexion) :

$this->rights[1] = array(
    500101,                                   // numéro de droit
    'Envoyer un signalement de retour utilisateur',
    'a',                                      // type : a = action
    null,
    'feedback',                               // module (partie droite)
    'report',                                 // sous-module (partie gauche)
);
$this->module_parts = array('hooks' => array('main', 'login'));

3. Classe d'actions du hook

class/actions_monmodule.class.php (nom imposé par Dolibarr : les hooks printTopRightMenu et afterLogin sont exécutés dans les contextes main et login, la classe est chargée automatiquement depuis class/actions_<module>.class.php) :

class ActionsMonmodule
{
    public function printTopRightMenu(array $parameters, &$object, string &$action, \HookManager $hookmanager): int
    {
        global $db, $user, $langs;

        dol_include_once('/monmodule/vendor/autoload.php');
        dol_include_once('/monmodule/core/modules/modMonmodule.class.php');

        $config = DolibarrBridge::configFromDolibarr('monmodule');
        $bridge = new DolibarrBridge($user, $langs, array(
            'module' => 'monmodule',
            'api_base_url' => $config['api_base_url'],
            'api_key' => $config['api_key'],
            'installed_version' => (new modMonmodule($db))->getVersion(),
        ));

        echo $bridge->renderAssets();
        echo $bridge->renderButton();

        return 0;
    }

    public function afterLogin(array $parameters, &$object, string &$action, \HookManager $hookmanager): int
    {
        global $db, $user, $langs;

        dol_include_once('/monmodule/vendor/autoload.php');
        dol_include_once('/monmodule/core/modules/modMonmodule.class.php');

        $config = DolibarrBridge::configFromDolibarr('monmodule');
        $bridge = new DolibarrBridge($user, $langs, array(
            'module' => 'monmodule',
            'api_base_url' => $config['api_base_url'],
            'api_key' => $config['api_key'],
            'installed_version' => (new modMonmodule($db))->getVersion(),
        ));

        $bridge->heartbeatAfterLogin();   // déclare la version installée

        return 0;
    }
}

renderButton() n'affiche le bouton que si l'utilisateur a le droit monmodule->feedback->report et que le module est configuré ; renderAssets() émet les balises <script>/<link> pointant vers la page de relais.

Le battement de présence est envoyé à la connexion. heartbeatAfterLogin() déclare la version installée du module vers capTests (POST /api/v1/intake/heartbeat), au plus une fois par jour et par instance, et de nouveau dès que la version du module change. La date et la version du dernier envoi réussi sont mémorisées dans une constante du module (<MODULE>_FEEDBACK_HEARTBEAT_LAST). Une seule tentative, avec un délai court (3 secondes) : un échec est journalisé et ne bloque jamais la connexion. Le battement n'est jamais envoyé depuis la page de relais : un envoi de signalement ne doit pas attendre la réponse d'un heartbeat, et le signalement porte déjà installed_version.

4. Page de relais du module

feedback_relay.php, à la racine du module :

define('CSRFCHECK_WITH_TOKEN', 1);   // main.inc.php vérifie le jeton Dolibarr
define('NOTOKENRENEWAL', 1);         // garde le jeton de session intact
// ... inclusion classique de main.inc.php (boucle sur les profondeurs) ...

global $db, $user, $langs;
if (!($db instanceof \DoliDB) || !($user instanceof \User) || !($langs instanceof \Translate)) {
    accessforbidden();
}

dol_include_once('/monmodule/vendor/autoload.php');
dol_include_once('/monmodule/core/modules/modMonmodule.class.php');

$config = DolibarrBridge::configFromDolibarr('monmodule');
$bridge = new DolibarrBridge($user, $langs, array(
    'module' => 'monmodule',
    'api_base_url' => $config['api_base_url'],
    'api_key' => $config['api_key'],
    'installed_version' => (new modMonmodule($db))->getVersion(),
));
$bridge->handleRelayRequest();   // sert les assets, puis relaie, puis exit

La page de relais n'accepte que POST (les autres méthodes reçoivent 405) et fait trois vérifications avant de relayer : module configuré, droit feedback->report, jeton CSRF Dolibarr (hash_equals(currentToken(), GETPOST('token')), en complément de la vérification de main.inc.php). Elle sert aussi les assets (?asset=feedback-widget.js|feedback-widget.css&v=<empreinte>), avec une liste blanche stricte et le contrat de cache de Widget\AssetCache (immutable un an avec l'empreinte courante, no-cache sinon, ETag et 304). Elle n'envoie aucun battement de présence : celui-ci est envoyé à la connexion (voir section 3). Les erreurs répondent avec un statut HTTP significatif et un champ code stable (voir "Statuts HTTP et champ code").

Pourquoi les deux constantes CSRF sont nécessaires. Le bouton embarque currentToken() (le jeton de session) et le paquet compare avec currentToken(). Avec MAIN_SECURITY_CSRF_TOKEN_RENEWAL_ON_EACH_CALL, main.inc.php renouvelle le jeton à chaque requête, y compris le chargement des assets servis par la page de relais : le jeton de la page ne correspondrait plus, et chaque envoi serait refusé. NOTOKENRENEWAL empêche ce renouvellement pour la page de relais : le contrôle de main.inc.php compare alors avec $_SESSION['token'], qui vaut currentToken() : les deux restent cohérents. C'est la méthode documentée par Dolibarr pour les appels ajax (currentToken() côté script, NOTOKENRENEWAL côté page appelée).

5. Constantes de configuration

Deux constantes, alimentées par la page d'admin du module (voir examples/dolibarr/admin/feedback_setup.php) :

  • MONMODULE_FEEDBACK_API_URL : adresse de l'instance capTests ;
  • MONMODULE_FEEDBACK_API_KEY : clé d'envoi (secrète, serveur seul).

DolibarrBridge::configFromDolibarr('monmodule') lit ces constantes selon la convention <MODULE>_FEEDBACK_API_URL / <MODULE>_FEEDBACK_API_KEY (module en majuscules).

6. Fichier de langue

Les textes du widget passent par les clés de langue du module (préfixe FeedbackReport..., ex. FeedbackReportOpen, FeedbackReportSubmit...), rendues avec $langs->transnoentities() et injectées via dol_escape_js() dans les attributs data-text-*. Voir examples/dolibarr/langs/fr_FR/monmodule.lang.

Options de DolibarrBridge

OptionRôle
moduleNom technique du module (pour les chemins et la convention de constantes)
api_base_url, api_keyConfiguration capTests (ou configFromDolibarr())
installed_versionVersion installée du module, envoyée comme installed_version
relay_pageURL de la page de relais (défaut : dol_buildpath('/<module>/feedback_relay.php'))
is_referentbooléen : le déclarant est-il référent ? (serveur seul, jamais le navigateur)
rights_providercallable(\User): string[] surchargeant les droits envoyés
log_excerpt_providercallable(): string[] extrait de journal à joindre
heartbeatfalse pour désactiver le battement de présence
textssurcharge des textes du widget
loggerLoggerInterface (défaut : DolibarrLogger vers dol_syslog)
transportTransportInterface (défaut : CurlTransport)
sleepercallable(int): void pour remplacer la fonction d'attente (tests)
show_on_all_pagestrue pour afficher le bouton hors des écrans du module

Mise en garde sur is_referent : un référent voit toutes les remarques de son organisation, y compris celles envoyées par les autres utilisateurs. La valeur par défaut est false (non référent) et doit le rester : réservez ce statut à un droit explicite du module, et ne le déduisez jamais du statut d'administrateur Dolibarr.

Sécurité : ce que le paquet garantit, ce qui reste à l'application

Garanties du paquet :

  • la clé d'envoi ne quitte jamais le serveur : le navigateur ne reçoit que la référence et l'adresse de suivi ;
  • le déclarant et is_referent viennent exclusivement de la session de l'application ; les champs postés par le navigateur pour l'identité sont ignorés ;
  • le consentement est exigé dès qu'une capture est jointe ;
  • les secrets (JWT, Bearer, Authorization:, mots de passe, clés API) sont filtrés de l'extrait de journal et des URL, et la chaîne de requête est retirée des URL, avant l'envoi ;
  • les limites de la section 8.2 sont appliquées localement avant l'envoi : ce qui fait le signalement est refusé, le contexte est coupé (jamais refusé) ;
  • les reprises ne concernent que les cas sûrs (réseau, 503, 429 avec Retry-After), avec une clé d'idempotence stable par signalement ;
  • chaque chemin d'erreur est journalisé avant de retourner ou de lever ;
  • le widget n'utilise aucun gestionnaire inline : compatible CSP stricte ;
  • la réponse au navigateur ne contient ni la clé, ni les détails internes.

Ce qui reste à l'application hôte :

  • fournir un jeton CSRF et le valider côté relais (csrfTokenValidator, ou CSRFCHECK_WITH_TOKEN sous Dolibarr) ;
  • authentifier l'utilisateur avant de résoudre le déclarant ;
  • servir les assets JS/CSS (pass-through, liste blanche) ;
  • ne joindre un extrait de journal que s'il est restreint aux requêtes de l'utilisateur courant (un journal partagé mélangerait d'autres utilisateurs) ;
  • protéger la clé d'envoi (constante Dolibarr en lecture seule pour les utilisateurs non administrateurs, variable d'environnement hors Dolibarr) ;
  • utiliser HTTPS entre l'application et capTests.

Journalisation

Tout chemin d'erreur journalise sa raison avant de retourner ou de lever. Le paquet fournit une interface minimale, sans dépendance PSR-3 :

use CapRel\FeedbackClient\Logging\CallbackLogger;
use CapRel\FeedbackClient\Logging\LogLevel;
use CapRel\FeedbackClient\Logging\NullLogger;

$logger = new CallbackLogger(function (string $level, string $message, array $context): void {
    error_log('[' . $level . '] ' . $message . ' ' . json_encode($context));
});
// ou new NullLogger() pour couper le journal, ou DolibarrLogger sous Dolibarr

Niveaux : LogLevel::INFO (envoi réussi), WARNING (rejet : CSRF, consentement, droit), ERROR (échec d'envoi, raison complète).

Développement

composer test      # PHPUnit
composer phpstan   # PHPStan niveau 8
composer ci        # les deux, dans l'ordre

La suite couvre le client (chaque code de réponse, les reprises avec la même clé d'idempotence, JSON contre multipart, les limites, le filtrage des secrets), le relais (usurpation d'identité impossible, consentement exigé avec une capture), l'adaptateur Dolibarr (sans Dolibarr démarré, via des fakes), le rendu du widget, et un test bout en bout contre un serveur PHP éphémère (php -S sur un port local) qui simule l'API d'envoi. La suite passe sous PHP 7.4 et sous PHP 8.4 ; PHPStan niveau 8 est sans erreur, adaptateur Dolibarr compris (stubs Dolibarr).