cap-rel / feedback-client
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).
Requires
- php: >=7.4
- ext-curl: *
- ext-json: *
- ext-mbstring: *
Requires (Dev)
- phpstan/phpstan: ^1.10
- phpunit/phpunit: ^9.6
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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 :
| Brique | Classes | Rôle |
|---|---|---|
| Client HTTP | Client\IntakeClient, Client\IntakeClientOptions, Transport\CurlTransport | Appelle l'API d'envoi (section 8 du cahier des charges) |
| Relais serveur | Relay\Relay, Relay\RelayConfig, Relay\RelaySubmission, Relay\RelayResult | Reçoit le signalement du navigateur, le transmet au client |
| Widget navigateur | Widget\WidgetRenderer + assets/feedback-widget.js + assets/feedback-widget.css | Bouton, fenêtre, capture, envoi par fetch |
| Adaptateur Dolibarr | Dolibarr\DolibarrBridge, Dolibarr\DolibarrLogger | Hook 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 :
| HTTP | Exception | Cas |
|---|---|---|
| (réseau) | TransportException | Erreur cURL, délai dépassé, DNS... |
| 401 | AuthenticationException | Clé d'envoi inconnue ou révoquée |
| 403 | OrganizationInactiveException | Organisation inactive, projet non rattaché |
| 404 | EndpointNotFoundException | Mauvais préfixe d'URL |
| 413 | PayloadTooLargeException | Corps ou capture trop gros |
| 422 | ValidationException | Validation échouée, détail par champ dans ->errors |
| 429 | RateLimitException | Débit dépassé, délai dans ->retryAfterSeconds |
| 503 | ServiceUnavailableException | Service indisponible |
| autre 4xx/5xx | UnexpectedStatusException | Réponse inattendue |
| 200/201 malformé | MalformedResponseException | Structure {data: ...} absente |
| (local) | ValidationException | Limite 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-Aftern'est attendu que s'il tient dans le budget restant ; sinonRateLimitExceptionest 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 toutpassword|passwd|pwd|token|secret|api_key|apikey|dolapikeysuivi 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
| Appel | Méthode | Corps | Succès |
|---|---|---|---|
| Signalement | POST /api/v1/intake/findings | JSON, ou multipart avec capture | 201 (200 pour un rejeu idempotent) |
| Heartbeat | POST /api/v1/intake/heartbeat | JSON {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.
| Champ | Règle |
|---|---|
context.app, context.app_version, context.platform.name, context.platform.version | coupés à 64 caractères |
context.user_agent | coupé à 512 caractères |
context.viewport | coupé à 32 caractères |
context.locale | coupé à 16 caractères |
context.page_url, context.route, context.network_errors[].url | coupés à 1024 caractères |
context.network_errors[].method | coupé à 16 caractères ; at à 64 |
context.rights | les 200 premiers, chacun coupé à 128 caractères |
context.log_excerpt | les 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_errors | les 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.
| Champ | Type | Exemple |
|---|---|---|
type | texte | bug |
title | texte | Enregistrement impossible |
body | texte | La fiche ne s'enregistre pas. |
reporter[email] | texte | jean@example.com |
reporter[name] | texte | Jean Dupont |
reporter[external_ref] | texte | a1b2c3d4e5f6:42 |
reporter[is_referent] | booléen 1 / 0 | 0 |
installed_version | texte | 2.0.1 |
context[app] | texte | monmodule |
context[channel] | texte | web |
context[platform][name] | texte (objet imbriqué) | Dolibarr |
context[platform][version] | texte (objet imbriqué) | 18.0.10 |
context[app_version] | texte | 2.0.1 |
context[page_url] | texte | https://... (chaîne de requête retirée) |
context[route] | texte | /monmodule/card.php |
context[user_agent] | texte | Mozilla/5.0 ... |
context[viewport] | texte | 1920x1080 |
context[locale] | texte | fr-FR |
context[rights][] | tableau | monmodule->feedback->report |
context[log_excerpt][] | tableau | ligne de journal |
context[network_errors][0][method] | texte (tableau d'objets) | GET |
context[network_errors][0][url] | texte | https://... |
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_consent | booléen 1 / 0 | 1 |
screenshot | fichier | partie 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) :
- vérifie le jeton CSRF si
RelayConfig->csrfTokenValidatorest fourni ; - résout le déclarant via
RelayConfig->reporterResolver(session de l'application) ; sans déclarant authentifié, le relais refuse ; - exige le consentement (
screenshot_consent) quand une capture est jointe ; - 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) ;
- transmet au client, qui filtre les secrets et retire la chaîne de requête des URL ;
- renvoie un
RelayResult: succès avecreferenceettrackingUrl, 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 :
| HTTP | code | Cas |
|---|---|---|
| 200 | - | Succès ({ok: true, ref, tracking_url, finding_id}) |
| 400 | invalid_submission, validation_failed, consent_required | Saisie invalide (validation locale ou 422 de l'API), consentement manquant |
| 403 | invalid_csrf_token, invalid_session, missing_right | Jeton CSRF, déclarant absent, droit manquant |
| 405 | method_not_allowed | Méthode autre que POST |
| 409 | idempotency_key_reused | Clé d'idempotence réutilisée avec un autre contenu (refus définitif) |
| 413 | screenshot_too_large, payload_too_large | Capture trop grosse (locale ou API) |
| 429 | rate_limit_exceeded | Débit dépassé |
| 502 | not_configured, unauthenticated, forbidden, service_unavailable, unexpected_response, not_found, server_error | capTests 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() :
| Attribut | Rôle |
|---|---|
data-caprel-feedback | Conteneur du widget |
data-relay-url | Adresse du relais (POST du fetch) |
data-csrf-token | Jeton CSRF de la page hôte |
data-app-version | Version vue par le navigateur (diagnostic) |
data-route | Route 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
| Option | Rôle |
|---|---|
module | Nom technique du module (pour les chemins et la convention de constantes) |
api_base_url, api_key | Configuration capTests (ou configFromDolibarr()) |
installed_version | Version installée du module, envoyée comme installed_version |
relay_page | URL de la page de relais (défaut : dol_buildpath('/<module>/feedback_relay.php')) |
is_referent | booléen : le déclarant est-il référent ? (serveur seul, jamais le navigateur) |
rights_provider | callable(\User): string[] surchargeant les droits envoyés |
log_excerpt_provider | callable(): string[] extrait de journal à joindre |
heartbeat | false pour désactiver le battement de présence |
texts | surcharge des textes du widget |
logger | LoggerInterface (défaut : DolibarrLogger vers dol_syslog) |
transport | TransportInterface (défaut : CurlTransport) |
sleeper | callable(int): void pour remplacer la fonction d'attente (tests) |
show_on_all_pages | true 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_referentviennent 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, ouCSRFCHECK_WITH_TOKENsous 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).