Protocole HFR

Endpoints, form fields, constantes et edge cases du protocole HFR utilisés par Redface 2.


Préambule

HFR expose deux surfaces consommables côté client :

  1. HTML sur les endpoints forum*.php — le scraping historique, toujours en place pour la lecture des posts, les MPs, le login et toutes les mutations en v1 (bddpost.php, vote.php, addflag.php, delflag.php).
  2. JSON sur /webservices/rest_api.php — une API REST partielle exposée par MesDiscussions (le moteur du forum), retrouvée et instrumentée fin avril 2026, qui couvre la portion browsing : catégories, sous-catégories, listings de topics, drapeaux personnels, metadata d’un topic. Décision de stratégie hybride : ADR-003.

Cette page documente les invariants des deux surfaces — constantes, form fields, anti-CSRF, anti-bot, optimisations JS inline, contrat REST — que le LLM qui écrit le parser, le client réseau ou les mappers REST doit respecter.

La documentation HTML est issue de la rétro-ingénierie du code de Redface v1 (Java + Retrofit 1.9) et de fixtures HTML réelles capturées depuis forum.hardware.fr. La documentation REST est issue de tests live 2026-05-01 sur le contrat MesDiscussions V1 (doc Confluence retrouvée sur Wayback Machine) et des 6 fixtures JSON capturées au même moment.


Endpoints par fonctionnalité

Fonctionnalité Méthode Endpoint Auth requise
Page d’accueil (catégories) GET /hfr/ ou / non
Liste de topics d’une sous-catégorie GET /forum2.php?config=hfr.inc&cat={cat}&subcat={subcat}&page={page} non (logué ↔ lu/non-lu visible)
Liste topics (rewrite SEO) GET /hfr/{cat_slug}/{subcat_slug}/liste_sujet-{page}.htm non
Lecture d’un topic GET /forum2.php?config=hfr.inc&cat={cat}&post={post}&page={page} non
Posts citants d’un post (#783) GET /forum2.php?config=hfr.inc&cat={cat}&post={post}&numreponse={numreponse}&quote_only=1 non
Drapeaux (accueil Redface 2) — REST GET /webservices/rest_api.php?uri=forums/hardwarefr/topics/{participated,read,favorites}/&page={page}&results_per_page={n} oui
Drapeaux par catégorie — REST GET /webservices/rest_api.php?uri=forums/hardwarefr/categories/{cat}/topics/{participated,read,favorites}/&page={page}&results_per_page={n} oui
Login POST /login_validation.php?config=hfr.inc&redirect=&url=
Formulaire reply GET /message.php?config=hfr.inc&cat={cat}&post={post}&page={page}&p=1&subcat={subcat}&sondage=0&owntopic=0&new=0 oui
Formulaire quote GET /message.php?config=hfr.inc&cat={cat}&post={post}&numrep={numreponse}&ref={ref}&page={page}&p=1&subcat={subcat}&sondage=0&owntopic=0&new=0#formulaire oui
Formulaire edit post GET /message.php?config=hfr.inc&cat={cat}&post={post}&page={page}&p=1&subcat={subcat}&sondage=0&owntopic=0&new=0&numreponse={numreponse} oui
Formulaire nouveau topic GET /message.php?config=hfr.inc&cat={cat}&subcat={subcat}&sondage=0&owntopic=0&new=0 oui
Reply / quote (post) POST /bddpost.php?config=hfr.inc oui
Edit (post) POST /bdd.php?config=hfr.inc oui
Edit FP (premier post) POST /bdd.php?config=hfr.inc avec champs spécifiques oui
Suppression post/topic owned POST /bdd.php?config=hfr.inc avec delete=1 oui
Nouveau topic POST /bddpost.php?config=hfr.inc oui
Vote sondage POST /user/vote.php?config=hfr.inc oui
Clore sondage (owner) GET /user/close_sondage.php?config=hfr.inc&cat={cat}&post={topicId} oui
MP (envoi) POST /bddpost.php?config=hfr.inc&cat=prive&pseudo={dest} oui
Conversation MP GET /forum2.php?config=hfr.inc&cat=prive&post={mp_id}&page={page} oui
Liste des MPs GET /forum1.php?config=hfr.inc&cat=prive&page={page}&subcat=&sondage=0&owntopic=0&trash=0&trash_post=0&moderation=0&new=0&nojs=0&subcatgroup=0 oui
Ajouter aux drapeaux GET /user/addflag.php?config=hfr.inc&cat={cat}&post={post}&numreponse={numreponse} oui
Retirer un drapeau GET /user/delflag.php?config=hfr.inc&cat={cat}&subcat={subcat}&post={topicId}&page={page}&p=1&sondage=0&owntopic={1,2,3}&new=0 oui
Profil public GET /hfr/profil-{user_id}.htm non
Annuaire staff (responsables) GET /message-smi-mp-aj.php?config=hfr.inc&user_id=0&responsable=1 non
Paramètres utilisateur GET /editprofil.php?config=hfr.inc&page={1..7} oui
Modération (alerte) GET/POST /user/modo.php?config=hfr.inc&cat={cat}&post={topicId}&numreponse={numreponse}&page={page}&ref=1 (voir ci-dessous) oui
Historique des sanctions (#294) GET /modo/historique.php?config=hfr.inc oui
Recherche (form) GET /search.php?config=hfr.inc non
Recherche (résultats) GET /forum1.php?recherches=1&config=hfr.inc&search={query}&cat={catEncoded}&... non

Note sur PRIVATE_MESSAGE_CAT_ID : la catégorie des MPs est la chaîne "prive" et non un entier. Attention lors du typage côté Kotlin — cat: String pour les endpoints MP ou sentinel dédié.

Note sur l’URL “Liste des MPs” : l’endpoint canonique est forum1.php?config=hfr.inc&cat=prive&..., pas message.php?config=hfr.inc (qui ouvre le composer d’un MP isolé). Vérifié dans le legacy v1 (HFREndpoints.PRIVATE_MESSAGES_URL, prouvé en prod ~10 ans) et reproduit dans :core:network HfrClient.getPrivateMessageListPage() de Phase 1B.1. Toute la chaîne de query params (subcat=, sondage=0, owntopic=0, etc.) est conservée à l’identique du legacy par défensif — HFR pourrait accepter une URL plus courte mais ce n’est pas testé.

Note sur quote_only=1 — liste des posts citants (#783, capturé live 2026-08-30) : l’endpoint forum2.php?config=hfr.inc&cat={cat}&post={post}&numreponse={numreponse}&quote_only=1 renvoie une page topic filtrée ne contenant que les posts qui citent le numreponse cible, dans le DOM messagetable standard (mêmes sélecteurs que la lecture normale — parsé par HfrParser.parseCitingPosts). Accessible anonymement (aucun cookie requis ; c’est le canal de lecture du badge « cité N fois », un sujet = une source canonique). Caveat — pas de bijection avec citedCount : la liste renvoyée n’égale pas forcément le compteur serveur affiché (« Message cité N fois »). Le compteur agrège des occurrences (un même post peut citer la cible plusieurs fois ; une citation par un post depuis supprimé compte encore), là où la liste expose des posts distincts ; HFR déduplique côté serveur (observé mais non contractuel — d’où la garde distinctBy { numreponse } du repo, gate Fable R2). Il n’y a pas de pagination de ce filtre : page=2 répète la première page au lieu d’avancer. Le titre de la feuille reste donc le citedCount serveur (autorité de comptage) et le corps liste exactement les rows distinctes renvoyées, sans jamais présenter leur nombre comme un second compteur.

Alerte modération — modo.php (#293)

Contrat capturé le 05/09/2026, avec deux comptes et un cycle complet : envoi, jonction, puis traitement par la modération. Les huit fixtures moderation_alert_*.html et leurs sidecars .source.txt vivent dans core/parser/src/test/resources/fixtures/.

L’ouverture du menu « Alerter » déclenche un GET authentifié volontaire :

https://forum.hardware.fr/user/modo.php?config=hfr.inc&cat={cat}&post={topicId}&numreponse={numreponse}&page={page}&ref=1

Construire cette URL depuis le post affiché, sans parser son lien de modération, qui peut être cryptlinké. numreponse est unique par catégorie. Le GET d’entrée utilise ref=1 ; les actions des formulaires capturés portent ref=18, conservé tel quel pour le POST. Le flux reste disponible sur un sujet verrouillé et nécessite une session connectée. La feuille n’apparaît qu’une fois l’état connu ; une barre de progression fine superposée en haut du topic signale le chargement initial. Retour annule le GET et ses retours tardifs sont ignorés. Pendant l’envoi, la feuille reste ouverte ; le champ raison ne prend le focus qu’une fois la feuille complètement déployée, sans délai arbitraire.

Le GET renvoie l’un des six états suivants, propres au post et au compte :

État Signal HFR Fixture
Form Formulaire POST avec textarea[name=raison] moderation_alert_form.html
JoinPrompt Formulaire POST avec cfmodoalert=1, sans textarea ; un autre membre a alerté moderation_alert_join_confirm.html
PendingMine Votre demande n’est pas encore traitée moderation_alert_pending_mine.html
PendingJoined La demande à laquelle vous vous êtes joint n’est pas encore traitée moderation_alert_pending_joined.html
TreatedMine(treatedAt) Votre demande a été traitée ; nouveau signalement interdit moderation_alert_treated_mine.html
TreatedJoined(treatedAt) Une demande a été traitée ; nouveau signalement interdit moderation_alert_treated_joined.html

Les états sans formulaire sont lus dans div.hop, en normalisant espaces et entités HTML. La date après « traitée le » reste une chaîne HFR (2026-09-05 17:27:28 dans les deux captures), sans supposer son fuseau horaire. Les userscripts de la capture treated_mine ne doivent pas influencer cette classification. Une page non reconnue produit Unknown(excerpt) et un message générique dans l’application, sans proposer d’envoi.

Les deux POST authentifiés, encodés application/x-www-form-urlencoded, utilisent l’action du formulaire, avec tous ses paramètres de query conservés :

Action utilisateur Champs du corps Confirmation HFR Fixture
Envoyer hash_check, referer_page si présent, raison, Submit=Valider votre message Un message a été envoyé avec succès aux modérateurs moderation_alert_sent.html
Me joindre hash_check, referer_page si présent, cfmodoalert=1, Submit=Confirmer ; pas de raison Vous êtes désormais joint à la demande de modération moderation_alert_joined.html

Résoudre une action relative modo.php?... contre https://forum.hardware.fr/user/. Accepter aussi l’action absolue observée, limitée à la même origine et au chemin /user/modo.php. Le hash_check provient de ce formulaire, jamais du cache global ; les 32 zéros des fixtures sont une sanitation. Ni le token ni le brouillon ne sont persistés.

Le formulaire avertit que le message est adressé directement aux modérateurs, uniquement pour leur demander de venir sur le sujet en cas de problème. L’application exige une raison contenant au moins un caractère non blanc, sans limite de longueur ajoutée. L’acceptation d’une raison vide par HFR n’a pas été vérifiée. La jonction exige une confirmation explicite. Aucun POST n’est relancé automatiquement ; le client de mutation conserve les cookies authentifiés et désactive le rejeu de transport. Fermer la feuille annule le GET en cours ; un POST déjà confirmé poursuit son exécution, comme les autres écritures du topic. Ses retours tardifs sont ignorés après fermeture ou changement de compte.

ModerationAlertPageParser renvoie Sent, Joined ou Rejected(message) pour les réponses POST. Le repository laisse remonter les exceptions réseau/session ; le topic les présente via sa snackbar, sans confondre un échec de transport avec une confirmation HFR.

Lien entrant modo.php

Les liens HTTP et HTTPS vers /user/modo.php partagent la validation d’URL pour un intent Android VIEW et un tap in-app. Ils exigent cat, post et numreponse entiers strictement positifs ; sinon, l’URL reste ouverte dans le navigateur. page est optionnel (défaut 1, valeur minimale 1). config, ref, hash_check et le fragment sont ignorés : seul le contexte du post est transmis, jamais un token issu du lien.

Tap in-app — option B (#1287) : HfrInAppUriHandler intercepte la route d’alerte et prépare une feuille d’info racine. La feuille n’apparaît qu’une fois l’état connu ; une barre de progression fine superposée en haut de l’écran signale le chargement. Après lecture de l’état de session, le seul appel est ModerationRepository.loadAlert(cat, post, numreponse, page) : GET modo.php seul, aucun chargement de page de sujet ni résolution de page du post. Le drapeau HFR reste intact ; le chargement authentifié du sujet qui le déplace (#1243) n’est pas déclenché pour consulter l’info. Les états PendingMine, PendingJoined, TreatedMine, TreatedJoined et Unknown affichent le texte HFR (repli générique si vide), ainsi que treatedAt si présent. Les fixtures modo.php et le modèle parsé ne fournissent ni auteur ni extrait du post : aucun aperçu n’est inventé ou chargé depuis le sujet.

Sans session, la feuille affiche « Connectez-vous pour consulter cette alerte » sans GET modo.php. Une erreur propose « Réessayer ». « Voir le message » reste disponible dans ces deux cas et dans l’info : cette action explicite navigue vers le post sans moderationAlertFor. Form et JoinPrompt naviguent directement avec ce déclencheur, en fermant toute feuille d’info déjà ouverte, pour afficher le post avant de proposer un signalement. Dans ces navigations seulement, une page égale à 1 est résolue selon #750 avant le chargement du sujet ; une page supérieure à 1 est conservée. Fermer annule le GET et ignore ses retours tardifs ; changer de compte invalide le résultat et consulte l’état du nouveau compte. Retour annule aussi la lecture initiale sans quitter l’écran. « Réessayer » et un changement de compte sur une feuille ouverte conservent celle-ci, avec l’ancien message immédiatement effacé pendant le rechargement. Aucun chargement n’est relancé à la rotation.

Intent externe — comportement #293 conservé : la route navigue d’abord vers le topic avec TopicRoute.moderationAlertFor, transmis à TopicRequest.moderationAlertFor. La page réelle est résolue selon #750 si page == 1. Le flux d’entrée ci-dessous vaut aussi pour le repli in-app Form/JoinPrompt.

Au premier Loaded de la génération d’entrée, le topic consomme le déclencheur : si le post est présent, il s’y positionne et ouvre la même feuille que le menu « Alerter ». Le GET utilise le contexte de la page affichée et récupère un formulaire frais ; aucun POST n’est automatique. Si le post est absent, aucune feuille ne s’ouvre, même si une émission ultérieure contient le post. L’état initial de session est attendu avant l’ouverture ; une session anonyme conserve l’atterrissage et reçoit la snackbar « Connectez-vous pour alerter la modération ». La rotation conserve le ViewModel ; un refresh ou une navigation interne ne rejoue pas le déclencheur. Une navigation qui remplace l’entrée pendant le chargement ou l’attente de session l’annule.

Historique des sanctions — /modo/historique.php (#294)

Contrat capturé le 06/09/2026 : les fixtures réelles sanctions_history_empty.html (XaTelitte, aucune sanction) et sanctions_history_one.html (XaTriX, une sanction levée), avec leurs sidecars .source.txt, vivent dans core/parser/src/test/resources/fixtures/.

L’ouverture de « Mes sanctions » depuis Réglages → Compte déclenche un GET authentifié volontaire :

https://forum.hardware.fr/modo/historique.php?config=hfr.inc

En anonyme, HFR répond HTTP 200 avec un corps vide. Le parser renvoie SanctionsHistory.SignInRequired quand le tableau attendu est absent, jamais un historique vide. Le ViewModel observe la session et ne lance aucune requête en anonyme ; il annule la lecture et efface les données au changement de compte. Aucun cache persistant ni préchargement.

Le pseudo vient de h1#md_arbo_tree_3 b. Sélectionner la table.main contenant un tr.cBackHeader avec un th « Nom du posteur » : la première table.main.fastsearchMain est la recherche rapide, pas l’historique. Le tableau des sanctions est le deuxième dans les captures. Chaque tr.profil contient sept td, indépendamment de l’alternance de la classe cBackTab2 :

Colonne HFR Champ Sanction Traitement
Nom du posteur pseudo Texte
Sanction kind Texte, par exemple « Teletubbies »
Modéré par moderator Texte
Catégorie Interdite category Texte
Date de la sanction issuedAt Date brute normalisée
Sanction levée le liftedAt Date brute normalisée ; vide/blanc → null (« En cours »)
Raison de la sanction reason Texte brut, éventuellement vide

Les dates restent des String au format dd-MM-yyyy à HH:mm, en heure de Paris : 13-06-2026 à 22:13 devient « 13-06-2026 à 22:13 », sans conversion de fuseau. Une ligne avec moins de sept cellules est ignorée. L’en-tête seul est un historique chargé vide, avec le pseudo du h1 (SanctionsHistory.Loaded).

La sanction capturée est « Teletubbies », par TotalRecall, catégorie « Intelligence Artificielle », du 13-06-2026 à 22:13 au 18-06-2026 à 22:13, raison « Promo de juin : pour deux TALC, un TT offert. ». Une sanction en cours et une raison vide n’ont pas été observées dans ces fixtures : ces cas sont traités défensivement et couverts par des variantes DOM en mémoire des captures réelles. Les erreurs de transport/parsing restent des échecs Result, classés pour l’affichage avec « Réessayer » ; une session expirée détectée par le client rejoint « Connexion requise ».

Retirer un drapeau — delflag.php (#99, Phase 2 finish)

Suppression unitaire d’un drapeau = GET authentifié (les mutations drapeaux restent HTML, cf. ADR-003 — la sémantique REST PUT topics/{id}/ reste opaque). Forme vérifiée sur HFR réel pour un favori (owntopic=3, compte de test authentifié, fixtures flag_delete_success.html / flag_delete_already_removed.html) :

/user/delflag.php?config=hfr.inc&cat={cat}&subcat={subcat}&post={topicId}&page={page}&p=1&sondage=0&owntopic={TYPE}&new=0
  • owntopic={TYPE} = le type de drapeau à retirer : CYAN=1, RED=2, FAVORITE=3 (même discriminant que le flag_owntopic REST, cf. core/model/.../Flag.kt). HFR clé la suppression sur ce champ. FAVORITE=3 est vérifié en live le 2026-05-28 (curl authentifié + recoupement REST : le bucket favorites/ passe de results_count 1 → 0). numreponse n’est PAS requis pour le delflag favori : l’URL ci-dessus (sans numreponse) retire bien le favori — donc l’impl #99, qui n’expose pas la position, est correcte. CYAN=1 et RED=2 restent inférés du mapping owntopic (non testés en isolation faute de drapeau cyan/red jetable — ils sont automatiques, cf. ci-dessous).
  • {cat} = catégorie ; {subcat} = sous-catégorie, nullable → on émet subcat= (vide) quand elle est absente (les listings REST drapeaux ne la portent pas toujours).
  • {post} = topicId ; {page} = lastReadPage du drapeau (sa page courante).
  • Succès : page HTML contenant le texte littéral « Drapeau effacé avec succès » (dans un <div class="hop">), HTTP 200.
  • Échec / déjà retiré : page HTML sans ce texte (ex. « Aucun favori n’est repertorié »), HTTP 200 aussi. Le texte de succès est donc le seul signal.

Côté code : HfrClient.removeFlag(cat, subcat, topicId, type, page) construit l’URL et mappe FlagTypeowntopic ; FlagDeleteResponseParser classe la réponse (succès vs échec) ; DefaultFlagRepository.removeFlag(flag) retire l’item des caches mémoire et Room uniquement en cas de succès et ré-émet la liste mise à jour, sinon ne touche à aucun cache. Pas d’undo optimiste : addflag n’est pas prouvé pour tous les types, donc on ne ré-ajoute jamais spéculativement.

Ajouter / re-poser un drapeau — addflag.php (vérifié live 2026-05-28)

/user/addflag.php?config=hfr.inc&cat={cat}&post={topicId}&numreponse={position}&page={page}&ref={index}&p=1&sondage=0&owntopic={N}&subcat={subcat} (GET authentifié, lien « Mettre un favori sur cette position » de chaque post en vue topic).

addflag.php ne pose QUE des favoris. Testé en live avec owntopic=0/1/2/3 : les quatre renvoient « Favori positionné avec succès » et le recoupement REST confirme que le topic atterrit dans le bucket favorites/ (jamais read/). owntopic est donc ignoré par addflag — il ne sert pas à choisir le type. Effet de bord observé : poser un favori fait aussi entrer le topic dans participated/ (cyan), retiré par le même delflag.

Conséquence : les drapeaux CYAN (participé) et RED (lu) sont automatiques — posés par HFR au 1er post (cyan) ou à la lecture (red), pas via une URL. Il n’existe aucun moyen de les (re)poser par requête.

API REST : lecture seule pour les drapeaux (vérifié live 2026-05-28)

Les mutations de drapeaux ne sont pas possibles via /webservices/rest_api.php. POST / PUT / DELETE sur …/topics/{id}/flags/favorites/ ou …/topics/{id}/ renvoient HTTP 200 mais {"error":true,"error_code":501,"error_description":"unknow resource identifier"} et ne mutent rien (le bucket reste inchangé). C’est la confirmation empirique d’ADR-003 : le REST couvre le browsing/lecture des drapeaux, les mutations passent obligatoirement par addflag.php / delflag.php (HTML).

Pas d’undo (décision actée par le contrat)

Un « annuler la suppression » n’est pas réalisable : addflag.php ne sait reposer que des favoris, et cyan/red sont automatiques (rien à re-poser par URL). Un undo de favori serait théoriquement possible (addflag + numreponse de la position) mais le Flag du listing n’expose pas cette position de façon fiable. #99 s’en tient donc à une confirmation avant suppression, sans undo.

Hors scope #99 : la suppression en masse (form POST manageaction.php) n’est pas implémentée.

Profil public — /hfr/profil-{userId}.htm (#208, Phase 2 finish)

GET anonyme (pas de session requise). URL exacte :

GET /hfr/profil-{userId}.htm
  • {userId} = identifiant numérique HFR. La clé de navigation canonique est toujours userId, pas le pseudo (qui peut changer).
  • Auth non requise pour les profils publics. Le contenu est identique en session anonyme et authentifiée — seule la présence des cookies de session change, pas le rendu de la page profil.
  • Utilise l’@AnonymousClient dans HfrClient.getProfile pour ne pas déclencher de mise à jour des drapeaux.

Structure HTML :

  • body#unique__user_page__view_user_profile — identifiant de corps de page fiable.
  • h4.Ext — titre « Informations sur : {pseudo} ».
  • table.main tr.profil — rows de données, chaque row ayant td.profilCase2 (label) et td.profilCase3 (valeur).
  • div.avatar_center img[src] — image avatar. En session live, src est une URL absolue CDN ; dans les fixtures browser-save, un chemin relatif — le parser reconstruit l’URL canonique depuis le pattern mesdiscussions-{N}.png.

Champs promus par le parser :

Label HFR Champ UserProfile Notes
Pseudo pseudo  
Nombre de messages postés postCount: Int?  
Date d'arrivée sur le forum registeredAt: String? Format DD/MM/YYYY brut
Ville location: String? Null si vide
Signature des messages signatureText: String? Texte plat (Jsoup.text() côté parser — voir limites)

Champs HFR non promus dans UserProfile : Email (obfusqué par HFR → "Vous n'avez pas accès à cette information"), Date de naissance, Sexe, Profession, Loisirs, Citation personnelle. Ces champs sont conservés dans rawFields pour forward-compatibility.

Rôle de l’auteur — source SECONDAIRE, champ « Statut » (#1112, #221) : le champ « Statut » (td.profilCase3 de la tr.profil dont le label vaut « Statut ») porte le rôle d’un auteur. Extrait par HfrParser.parseAuthorRole(html): AuthorRole? (≠ parseUserProfile, qui le laisse dans rawFields) via le mapping partagé authorRoleFromLabel (le même que l’annuaire staff). Indexé sur Post.profileId, réservé à une demande explicite mono-utilisateur (écran profil, PR C). Libellé absent, vide ou non reconnu → null. La source primaire du badge est l’annuaire staff (ci-dessous).

Limites connues :

  • Emails obfusqués : ne jamais tenter de déobfusquer (cf. AGENTS.md § “Emails obfusqués”).
  • registeredAt reste String : le format HFR DD/MM/YYYY n’est pas un ISO standard. Promotion en LocalDate ou Instant reportée à un use-case concret (tri, calcul “membre depuis N ans”).
  • signatureText : la signature est rendue par HFR depuis BBCode propriétaire comme un fragment HTML (<br>, <div>, inline styling). Le parser flatten via Jsoup.text() pour que l’UI puisse Text(...) directement sans afficher les balises HTML littérales. Round-trip BBCode et rendu stylé hors scope MVP.
  • Les posts « Publicité » (régies intégrées) n’ont pas de lien profil → Post.profileId = null.

Fixtures :

  • core/parser/src/test/resources/fixtures/profile/profile_xatrix_authenticated.html — userId=54596, session auth, fixture complète avec signature.
  • core/parser/src/test/resources/fixtures/profile/profile_ezzz_anonymous.html — userId=15867, session anonyme, pas de localisation (cas Statut = Membre).
  • core/parser/src/test/resources/fixtures/profile/profile_moderator_anonymous.html — userId=15461 (Ernestor), session anonyme, cas Statut = Modérateur (#1112, capture curl assainie).

Annuaire staff (responsables) — source PRIMAIRE du rôle (#1112, #221)

GET anonyme (pas de session requise). URL exacte, globale (pas de cat) :

GET /message-smi-mp-aj.php?config=hfr.inc&user_id=0&responsable=1
  • Réponse = fragment AJAX HTML (« Contacter un responsable ») : une <table class="main"> d’ancres <a class="s1Topic" onclick="fillfield_private('pseudo')">pseudo <i>(Rôle)</i></a>.
  • Clé = pseudo, jamais un profileId : l’endpoint n’expose aucun id numérique. Les pseudos HFR étant uniques, aucune confirmation par profileId n’est nécessaire (pas de N+1).
  • Caractère global : un seul GET donne TOUS les responsables du forum → source idéale du badge d’une liste de posts (1 GET + lookups locaux par pseudo).
  • Client anonyme (règle prefetch-non-authentifié : un read ne doit pas marquer de drapeaux lus).
  • Extrait par HfrParser.parseStaffList(html): Map<String, AuthorRole> (StaffParser) : pseudo = argument de fillfield_private('…') (dé-échappé ; repli défensif anchor.ownText()), rôle = texte du <i> mappé via authorRoleFromLabel (libellé inconnu → entrée ignorée). Pseudos bruts en sortie ; la canonicalisation (canonicalizePseudo) est faite par le repository.

Libellés observés (capture 2026-08-27, 64 entrées) : Modérateur (55) → MODERATOR ; Administrateur (2) → ADMIN ; Super Administrateur (3) → SUPER_ADMIN ; Développeur (1) → DEVELOPER ; Architecte / Développeur principal (3) → ARCHITECT.

Fixture :

  • core/parser/src/test/resources/fixtures/staff/staff_responsables_anonymous.html — annuaire global anonyme (#1112, capture curl, 64 entrées, aucune PII).

Form fields critiques

POST /user/vote.php — vote sondage (#779)

Contrat vérifié en live avec le compte de test authentifié :

POST /user/vote.php?config=hfr.inc
Content-Type: application/x-www-form-urlencoded

Le POST réussit sans aucun header Referer. La protection CSRF est le hash_check; les champs cat, page et numeropost nécessaires au routage sont déjà dans le body. Le client ne construit donc aucun Referer et n’ajoute aucun paramètre de contexte à la signature réseau.

Ordre déterministe du payload, identique au formulaire observé :

  1. hash_check — token non vide de 32 caractères hexadécimaux en session authentifiée ; la même forme est parsable logged-out avec une valeur vide, mais le repository refuse alors le POST ;
  2. champs cachés, verbatim et dans l’ordre DOM : cat, p, page, sondage, owntopic, subcat, numeropost ;
  3. choix cochés dans l’ordre des options du formulaire, jamais dans l’ordre d’un Set appelant :
    • mono (radios) : un seul reponse=<value> ;
    • multi (checkboxes) : un reponseN=1 par choix.

Les réponses succès et déjà-voté sont toutes deux HTTP 200 text/html. Le message se trouve dans div.hop : « Votre vote a bien été pris en compte ! » (avec meta-refresh) ou « Désolé, vous avez déjà voté ! » (sans refresh). Le parser normalise casse, diacritiques et espaces avant de reconnaître exclusivement ces deux marqueurs. Toute autre réponse — y compris un <meta http-equiv="Refresh" content="…; url=…"> sans marqueur — échoue avec UnexpectedResponse (fail-close).

Le vote est non idempotent : exactement un POST, aucun retry automatique. Avant la requête, le repository refuse le token vide, une sélection vide/inconnue, plusieurs choix sur un mono, une sélection au-delà de maxSelections, ou des champs cat/page/numeropost absents ou non numériques. PollVoteForm et son hash_check sont transitoires, jamais persistés ni loggés.

Clore un sondage — GET /user/close_sondage.php (#1201)

Le créateur du sujet peut clore le sondage de son topic à tout moment. Contrat capturé live le 2026-08-31 (XaTelitte, topic de test créé puis supprimé).

GET /user/close_sondage.php?cat={cat}&config=hfr.inc&post={topicId}
  • Authentifié, sans hash_check, sans Referer (même famille GET que addflag.php/delflag.php). Paramètres : cat, config=hfr.inc, post (= topic id). Pas de numreponse.
  • Réponse succès (HTTP 200, text/html) : message dans div.hop « Le sondage a bien été clos », sans meta-refresh. Fixture : close_sondage_success.html.
  • Après clôture : la page du topic n’offre plus le lien « Clore la partie sondage » et porte le marqueur <b class="s1Ext">Ce sondage est clos</b> (déjà parsé en lecture — Poll.closed, #1170).
  • Irréversible (pas de réouverture). Un seul GET, aucun retry.
  • Contrôle owner-only : le lien <a href="/user/close_sondage.php?…">Clore la partie sondage</a> n’est rendu que pour l’owner d’un sondage ouvert (sous les boutons Voter / Voir les résultats), et HFR le rend sur chaque page du topic. Le gate applicatif parse la présence de ce lien (Poll.canClose, #1206) — signal autoritaire owner + sondage ouvert, valable sur toutes les pages — et non le proxy Topic.isFirstPostOwner (faux hors page 1, d’où le bouton bridé corrigé en #1206).

Création/édition d’un sondage : un sondage se crée/modifie en éditant le 1er post — l’option n’existe pas dans le formulaire de nouveau sujet. POST bdd.php (édition FP) avec have_sondage=1, textreponse0 = la question, textreponse1..10 = les options, max_votes (choix multiples), allowvisitor, date d’expiration jour/mois/annee/heure/minute, subcat obligatoire, content_form conservé, jamais delete=1.

POST bddpost.php (reply, quote ou nouveau topic)

Contrat recapturé sur HFR réel le 2026-05-17 avec le compte de test XaTelitte (affiché xatelitte par HFR), topic Redface 2 cat=23, post=35395, subcat=550, post de test numreponse=2784595, puis complété avec des topics temporaires owned en Programmation / Divers. Fixtures de référence : write_reply_form_open_topic.html, write_quote_form_test_post.html, write_quote_form_bbcode_rich.html, write_create_topic_form_android_cat.html, write_reply_success_response.html, write_quote_success_response.html, write_empty_message_error.html, write_invalid_token_error.html, write_antiflood_error.html, write_locked_topic_page.html, write_reply_locked_topic_forced_form.html, write_locked_topic_error.html.

Field Valeur Obligatoire Description
hash_check <token> extrait de la page GET précédente oui Anti-CSRF. Voir section dédiée.
verifrequet "1100" oui Constante anti-bot. String, pas entier.
cat ID catégorie oui ou "prive" pour MP
post ID topic oui si reply Vide si nouveau topic.
subcat ID sous-catégorie oui Observé 550 pour Android. subcat=0 est valide et postable pour une catégorie sans sous-catégorie (cf. note ci-dessous, #213).
numreponse "" oui Vide sur reply/quote/create. Ne pas confondre avec numrep.
numrep "" ou numreponse cité oui Vide en reply simple. Renseigné en quote.
MsgIcon "1" conventionnel Icône du message (1 = défaut)
signature "1" conventionnel Checkbox ; absent du POST si décochée (comportement HTML standard).
wysiwyg "0" conventionnel Mode BBCode brut
new "0" observé dans le formulaire GET oui Valeur POST création non validée par une réponse succès dédiée ; ne pas supposer 1 sans nouvelle capture.
page page topic courante oui Observé 20 sur reply/quote/edit ; 1 sur nouveau topic.
p "1" conventionnel  
sondage "0" ou "1" oui si topic "1" si nouveau topic avec sondage
sond "0" oui Présent dans les formulaires observés.
cache "cache" oui Présent dans les formulaires observés.
owntopic "0" ou "1" oui Topic favori ?
config "hfr.inc" oui Toujours hfr.inc pour HFR
sujet titre topic oui Champ réel observé. Pas subject. En reply/quote il reprend le titre existant.
content_form contenu BBCode oui Le corps du message
pseudo pseudo connecté présent dans le form Présent même en session loguée (xatelitte observé). Le cookie HFR reste l’autorité d’authentification.
password "" en session loguée présent dans le form Champ legacy ; ne jamais stocker ni préremplir côté app.
from_subcat ID sous-catégorie nouveau topic Présent sur le formulaire de création topic.
toread1..5 options visibles nouveau topic Présents sur le formulaire de création topic, à traiter comme opaques avant implémentation sondage/options.

Note subcat=0 — catégorie sans sous-catégorie (#213) : certaines catégories n’ont pas de sous-catégorie (ex. cat=32 « Intelligence artificielle »). Pour ces topics, HFR rend le formulaire bddpost avec subcat=0 et le POST part bien avec subcat=0. 0 est donc une valeur de POST légitime, pas un sentinel. Seule la valeur -1 (SUBCAT_UNKNOWN, attribuée côté app quand aucun formulaire de réponse n’est présent) est bloquante. La postabilité d’un topic est pilotée par la présence du formulaire bddpost dans la page (form[action*=bddpost.php]), rendu uniquement en session authentifiée sur un topic non verrouillé — c’est le drapeau Topic.canReply. Le subcat de POST est lu sur l’input[name=subcat] de ce formulaire, jamais sur le widget de recherche rapide qui embarque aussi un subcat sur la même page. Source : capture live du formulaire de réponse IA (session authentifiée, cat=32) ; le hash_check capturé n’a pas été committé (token de session). La fixture topic-page IA complète reste un plus de non-régression non bloquant à capturer plus tard ; le contrat subcat=0 est couvert par des tests unitaires aval (ReplyContext/EditPostContext/EditFirstPostContext/PostEditorState) et par un test parser synthétique ciblé sur la seule branche subcat=0 du formulaire bddpost.

Note ref optionnel sur le quote (#146/#227) : le GET quote message.php?…&numrep={numreponse}&ref={ref}… identifie le post cité par numrep={numreponse} seul ; ref est positionnel/cosmétique et n’est pas requis. Vérifié deux fois : (1) hfr-mcp (FetchQuote, internal/hfr/reader.go) cite via message.php?…&numrep=…&page=1&p=1&new=0 sans ref ; (2) test live in-app sur cat=32 (le GET sans ref renvoie l’éditeur prérempli avec le bon [quotemsg=…]). Conséquence : « Citer » est piloté par Topic.canReply et ne lit jamais le lien quote de la pageHfrClient.getReplyForm omet &ref= quand il est null.

Note liens obfusqués md_*cryptlink (#227, MAJ 2026-05-31 sur HTML brut authentifié réel) : HFR obfusque les liens d’action de toolbar dans des <span class="md_noclass_cryptlink{HEX}">, déchiffrés côté client par son JS (md_forum_decryptlink.init(), common.js) — anti-aspirateur, intermittent/dépendant du topic+session. Un client sans JS (Redface 2) doit décoder. Algorithme : base-16 alphabet custom "0A12B34C56D78E9F" (une paire de chars = un octet), 3 préfixes (md_cryptlink, md_noclass_cryptlink, md_blank_cryptlink). Implémenté dans CryptlinkDecoder (:core:parser), câblé via materialize() en tête de TopicPageParser.parse() (décode tous les spans en <a href> avant extraction toolbar ; coût mesuré ~74 µs sur la pire page de 123 KB / 47 spans, ~3 % du parse Jsoup, ~0 sur page claire ; idempotent, no-op si clair).

Le FORMAT de l’URL des liens toolbar diffère selon l’auth (les deux obfusqués) :

  • Anonyme : message.php?…&numrep={N}&ref={M} (quote) / …&numreponse={N} (edit).
  • Authentifié (ce que reçoit l’app loggée) : URL « jolie » /hfr/<cat>/citer-<a>-<numreponse>-<page>-<ref>.htm, /hfr/<cat>/editer-<a>-<numreponse>-<page>.htm, /hfr/<cat>/repondre-…htm. Prouvé sur capture brute authentifiée (cat=32 « Welcome to AI » : 10 liens editer- obfusqués dans .toolbar .left, 0 en message.php).

Approche RF2 (asymétrie volontaire selon la nature de l’action) :

  • « Citer » = capacité universelle (quiconque peut répondre peut citer n’importe quel post ; le lien ne porte aucune info de permission). → self-gen depuis numreponse (cf. note ref ci-dessus), ne lit jamais le lien, format-robuste.
  • « Modifier » = permission par-post ; la présence du lien edit EST le signal autoritaire de HFR (own + non-locké + ban/TT). → parsé : parseHasEditLink reconnaît message.php?…numreponse= ET le slug joli /editer-\d (après materialize).
  • Profil (/hfr/profil-{id}.htm) : ship EN CLAIR même en brut authentifié → parseProfileId n’a jamais été cassé (le décodeur ne lui sert pas).

⚠️ Piège fixtures : un browser-save (« Enregistrer la page », ex. write_ia_topic_page.html) est capturé après exécution du JS → les liens y sont déjà déchiffrés/réécrits, trompeur pour raisonner sur le format brut. Toujours raisonner sur du HTML brut sans JS (curl/hfr-mcp).

Modèle de permission/erreur HFR à supporter (cf. #242) : banni d’une section → lecture bloquée (HFR ne sert pas le topic) ; TT (restreint) → la lecture passe mais le POST renvoie une erreur précise. RF2 agit puis lit la réponse de HFR (comme antiflood/locked/token).

Reply simple

Le formulaire reply est obtenu par GET message.php. Le POST part vers bddpost.php?config=hfr.inc.

Champs observés sur le topic Redface 2 page 20 :

  • post=35395
  • cat=23
  • subcat=550
  • page=20
  • numreponse=""
  • numrep=""
  • sujet="Redface 2 — PHASE 2 @ ALPHA"
  • content_form vide avant saisie utilisateur

Quote

Le lien quote+ utilise numrep, pas numreponse :

GET /message.php?...&post=35395&numrep=2784595&ref=0&page=20&...

HFR préremplit ensuite content_form :

[quotemsg=2784595,768,1214571]...[/quotemsg]

Le premier paramètre est le numreponse cité. Le troisième paramètre observé correspond à l’ID utilisateur de l’auteur cité. Le second paramètre (768 dans la capture du post 2784595, 640 dans une capture antérieure page 16) est une position/index HFR à traiter comme opaque tant que son calcul exact n’a pas été confirmé. Pour le MVP, Redface 2 doit récupérer le formulaire quote côté HFR et réutiliser le content_form prérempli, au lieu de reconstruire [quotemsg=...] localement.

Succès et erreurs reply

Réponse succès observée après POST bddpost.php?config=hfr.inc :

Votre réponse a été postée avec succès !

La réponse succès est identique pour une réponse simple et pour une quote (write_reply_success_response.html, write_quote_success_response.html) et ne contient pas le message posté. HFR fournit un <meta http-equiv="Refresh"> exploitable :

  • reply simple : refresh vers le topic avec ancre #bas ;
  • quote : refresh vers le topic avec ancre #t{numreponse_cité} dans la capture dédiée.

Pour reply / quote / edit, l’URL de refresh suit la forme …/{slug}-sujet_{topicId}_{page}.htm#{ancre}. Le segment sujet_{topicId}_{page} porte deux entiers exploitables : le topicId (1er groupe) et la page d’arrivée (2e groupe). ReplySubmitResponseParser les extrait tous deux, plus le numreponse quand l’ancre est #t{N}.

Create-topic (#214 / #206) : la réponse de succès live write_create_topic_success_response.html utilise la phrase distincte « Votre message a été posté avec succès ! » et refresh vers la liste de la catégorie (…/liste_sujet-1.htm), pas vers le topic créé. HFR ne renvoie donc ni topicId, ni numreponse sur un create réussi. La navigation directe vers le sujet créé est impossible avec ce contrat ; Redface 2 revient sur la liste cible et met en évidence la ligne dont le titre correspond exactement au sujet posté.

Le client doit recharger la page topic et localiser le nouveau numreponse dans le topic. Lors du test anti-flood, les trois réponses consécutives acceptées ont créé numreponse=2784599, 2784600, puis 2784601.

Erreurs observées :

Cas Réponse HFR Impact client
content_form vide avec hash_check valide Vous devez remplir tous les champs avant de poster ce message Rester dans l’éditeur, afficher l’erreur.
hash_check invalide avec contenu non vide Une erreur est survenue lors de l'envoi des données. Essayez de vider le cache de votre navigateur Recharger le formulaire avant retry ; ne pas rejouer le POST tel quel.
Plus de 3 réponses consécutives en 10 minutes Afin de prevenir les tentatives de flood, vous ne pouvez poster plus de 3 réponses consécutives dans un intervalle de 10 minutes Bloquer temporairement l’envoi et proposer de réessayer plus tard.
Topic fermé Désolé ce sujet a été fermé... Bloquer l’éditeur et revenir au topic.

Note anonyme : GET message.php en session anonyme ne redirige pas immédiatement vers le login. HFR sert le même composer avec champs pseudo et password visibles (write_reply_anonymous_form.html, write_create_topic_anonymous_form.html). Redface 2 ne doit pas exposer ce mode legacy : l’app passe par login HFR explicite avant tout POST.

Note topic fermé : la page topic fermée ne contient pas de lien reply (write_locked_topic_page.html). Si l’utilisateur force l’URL message.php?...&post=14227, HFR sert quand même un composer (write_reply_locked_topic_forced_form.html) mais le POST bddpost.php refuse (write_locked_topic_error.html). Le client ne doit donc pas se contenter de l’existence du formulaire pour conclure qu’un topic est éditable.

POST bdd.php (edit)

Contrat recapturé sur HFR réel le 2026-05-17 avec le post de test numreponse=2784595 sur le topic Redface 2, puis complété avec des topics temporaires owned en Programmation / Divers (cat=10, subcat=388). Fixtures de référence : write_edit_form_test_post.html, write_edit_success_response.html, write_created_owned_topic_page.html, write_edit_first_post_form.html, write_edit_first_post_success_response.html, write_edit_form_bbcode_rich.html, write_delete_post_form.html, write_delete_post_success_response.html, write_delete_topic_form.html, write_delete_topic_success_response.html, write_deleted_topic_404.html.

Field Valeur Description
hash_check <token> Anti-CSRF
verifrequet "1100" Anti-bot
cat ID catégorie  
post ID topic  
numreponse ID post Post à éditer
numrep "" Présent mais vide dans le formulaire d’édition observé.
page page topic courante Observé 20.
subcat ID sous-catégorie Observé 550.
sujet titre topic Champ réel observé. Pas subject.
content_form nouveau contenu BBCode  
have_sondage, textreponse0..10, allowvisitor, max_votes, jour, mois, annee, heure, minute données sondage Champs observés sur le formulaire FP ; édition active de sondage non validée dans cette campagne. Côté MVP, ces champs sont isolés dans TopicPollForm.fields, exclus de hiddenFields, et forwardés seulement si have_sondage est coché.
delete "1" Présent uniquement si l’utilisateur coche la suppression dans le formulaire d’édition.

Le fait qu’une édition concerne le premier post (FP) vs un post normal est déduit côté client (isFirstPostOwner) puis pris en compte dans la construction du form. HFR expose aussi des champs supplémentaires sur le FP : sujet éditable en input texte, subcat en <select>, options sondage, et champs toread1..5.

Réponse succès observée après POST bdd.php?config=hfr.inc :

Votre message a été édité avec succès !

La réponse succès ne contient pas le contenu édité. HFR fournit un refresh vers l’ancre du post édité (#t{numreponse}), observé pour un post normal et pour le FP owned. Le client doit recharger la page topic ou mettre à jour son cache local après succès.

Suppression post/topic

La suppression utilise le même formulaire d’édition que bdd.php :

  • post normal owned : le formulaire expose <input name="delete" value="1"> avec le libellé Effacer ce message ;
  • premier post owned : le formulaire expose <input name="delete" value="1"> avec le libellé Effacer l'intégralité du sujet.

Le POST conserve les champs d’édition (hash_check, cat, post, numreponse, sujet, content_form, etc.) et ajoute delete=1. HFR répond dans les deux cas avec le même message générique :

Message effacé avec succès !

Pour un post normal, la réponse succès contient un refresh vers la page du topic. Dans la capture du topic temporaire post=148749, la suppression du FP a supprimé le topic complet : l’URL forum2.php?...&post=148749 répond ensuite HTTP 404, et la réponse succès contient un refresh vers la liste de sous-catégorie. Redface 2 doit donc :

  1. traiter delete=1 sur FP comme une action destructrice distincte “supprimer le topic” ;
  2. demander une confirmation UI forte avant d’envoyer le POST ;
  3. après succès, revenir à la liste de topics plutôt que tenter de recharger le topic supprimé.

Limites de cette campagne de capture

Les inconnues restantes sont explicitement limitées aux points suivants :

  • succès de création topic : formulaire GET capturé (write_create_topic_form_android_cat.html) et réponse POST succès capturée (write_create_topic_success_response.html). Contrat connu : refresh vers la liste, aucun id du topic créé. Limite restante : la navigation directe #206 est impossible ; workaround livré = highlight exact du titre dans la liste d’arrivée ;
  • création/édition de sondage : contrat désormais capturé live (#1201, 2026-08-31) — voir la section « Clore un sondage » ci-dessus (création par édition du FP, clôture par close_sondage.php). Le POST de vote est vérifié séparément dans la section /user/vote.php ;
  • verifrequet=1100 : valeur observée dans tous les formulaires, pas de variant négatif testé. Impact : envoyer la valeur observée telle quelle ;
  • second paramètre de [quotemsg=numreponse,X,user_id] : valeurs observées 768, 640, 1; le sens exact reste opaque. Impact : ne pas reconstruire localement les quotes, réutiliser le content_form prérempli par HFR ;
  • edit FP du topic Redface 2 : non testé car XaTelitte ne possède pas le FP ; edit FP owned validé sur topic temporaire Programmation.

POST login_validation.php

URL complète : POST https://forum.hardware.fr/login_validation.php?config=hfr.inc

Form-encoded body (application/x-www-form-urlencoded) :

Field Valeur Description
pseudo username  
password password (plaintext) Attention : HFR attend le password en clair dans le form POST (over HTTPS). Ne pas hasher côté client. Pas de hash_check ni de GET préalable pour le login lui-même.

Détection de la réponse (mirror de l’impl :core:network/auth/AuthRemoteDataSource Phase 1B.1) :

Cas Marqueur Action client
Succès cookie Set-Cookie: md_user=<pseudo form-url-encoded> présent et décodable vers le pseudo soumis AuthState.Authenticated(pseudo), cookies commités dans PersistentCookieJar
Mauvais identifiants body contient Votre mot de passe ou nom d'utilisateur n'est pas valide LoginError.InvalidCredentials
Anti-flood body contient Afin de prévenir les tentatives de flood LoginError.RateLimited (l’utilisateur attend quelques minutes et retente)
Cookie md_user absent aucun cookie d’identité exploitable LoginError.Unknown("expected md_user cookie not set")
Cookie md_user présent mais valeur décodée ≠ pseudo soumis défensif : AuthRemoteDataSource refuse de revendiquer une autre identité LoginError.Unknown("md_user cookie does not match requested pseudo (...)")
Tout autre format aucun marqueur reconnu LoginError.Unknown(detail)

HFR encode la valeur du cookie md_user comme un form body (application/x-www-form-urlencoded) : espace +, accents %XX, etc. Le client doit donc décoder md_user avant comparaison (Colonel MythOColonel+MythO). Ce contrat est couvert par le test pseudo with space matches md_user cookie URL-form-encoded.

Le POST login utilise un cookie jar de staging avec redirects désactivés : les Set-Cookie posés par une réponse 200 ou par la redirection 302 de login restent visibles pour classification, puis ne sont commités dans le PersistentCookieJar qu’après classification Authenticated. Une réponse classée InvalidCredentials, RateLimited ou Unknown ne doit jamais installer une session par effet de bord.


Constantes anti-CSRF et anti-bot

hash_check — anti-CSRF

Chaque page HFR qui autorise un POST (édition, création, action sur le profil) embarque un champ caché dans le DOM :

<input type="hidden" name="hash_check" value="<token>" />

Règle : avant tout POST, le client doit :

  1. Faire un GET sur la page d’édition/création appropriée.
  2. Extraire hash_check via la regex ou sélecteur CSS input[name="hash_check"].
  3. Injecter la valeur dans le form POST.
  4. Si hash_check est absent du DOM → fail fast visible (erreur explicite, pas silencieuse). Le POST ne doit jamais partir sans.

En v1, le code HashcheckExtractor.java utilisait la regex suivante — à reproduire ou équivalent Jsoup :

val hashCheck = document.select("input[name=hash_check]").attr("value")
require(hashCheck.isNotBlank()) { "hash_check absent — le POST serait silencieusement rejeté par HFR" }

verifrequet = "1100"

Constante anti-bot statique, présente dans les formulaires d’écriture bddpost.php / bdd.php observés. Valeur littérale "1100" (string, pas un nombre dynamique). Le formulaire de vote /user/vote.php ne la porte pas et ne doit pas l’inventer.

En Kotlin, à constanter dans :core:network :

object HfrConstants {
    const val VERIF_REQUET = "1100"
    const val CONFIG = "hfr.inc"
    const val PRIVATE_MESSAGE_CAT = "prive"
}

numreponse — unique par catégorie, pas globalement

Le numreponse d’un post est unique au sein d’une catégorie (cat=X). Deux posts dans deux catégories différentes peuvent avoir le même numreponse. Le triplet (cat, post, numreponse) est unique globalement.

Conséquences pour le code

  • Base Room : numreponse seul n’est pas une clé primaire valide. Utiliser une clé composite :

    @Entity(
        tableName = "posts",
        primaryKeys = ["cat", "numreponse"],
        indices = [Index(value = ["cat", "post"])],
    )
    data class PostEntity(
        val cat: Int,
        val post: Int,         // topic ID
        val numreponse: Int,
        // ...
    )
    
  • Deep linking : toujours inclure cat ET numreponse (optionnellement post pour la page). Un deep link qui ne fournit qu’un numreponse est ambigu.

  • Recherche : les résultats titre (titre=1) renvoient (cat, topicId) sans numreponse : ouvrir le topic page 1. Les résultats contenu (titre=0) et certains résultats mixtes (titre=3) peuvent ajouter sous le titre un lien forum2.php?...page=1&numreponse=M avec une citation « Dernier message correspondant » : le numreponse est exploitable, mais le page du href vaut toujours 1 (vérifié 2026-06-10 sur les captures anonyme ET authentifiée : 34/34 ancres portent page=1&p=1) — il n’indique PAS la page réelle du post. La vraie page se résout côté serveur : GET forum2.php?config=hfr.inc&cat={cat}&post={post}&page=1&numreponse={M} répond par une redirection 301 vers la pretty URL de la bonne page, avec fragment #t{M} et Location relatif (le segment sous-catégorie peut être présent ou absent — ancrer le parsing sur sujet_{post}_{page}.htm, jamais sur la profondeur du chemin). Preuve live (2026-06-10, anonyme, #277) : GET …cat=23&post=35421&page=1&numreponse=2786758301 Location: /hfr/gsmgpspda/redface-dev-sujet_35421_3.htm#t2786758 (page 3). Implémentation : HfrClient.resolveTopicPageUrl + TopicPageUrlParser. Ne jamais inventer un numreponse quand ce second lien est absent. Réserve connue (non vérifiée live) : la sonde de résolution est anonyme (invariant drapeaux), donc la 301 pagine au défaut serveur (40 posts/page) ; pour un compte dont le réglage « posts par page » diffère de 40, la page résolue peut diverger de la page vue authentifié — l’atterrissage retombe alors sur le comportement fallback (scrollTo introuvable, haut de page). À requalifier si un compte à réglage non-défaut confirme la divergence (suivi : #277).

Endpoint recherche (Phase 2G-A/B)

Contrat live capturé le 2026-05-22 (cf. core/parser/src/test/resources/fixtures/search_*.html + .source.txt) :

  • Le form /search.php?config=hfr.inc est en POST. Sa soumission renvoie une page d’attente avec <meta http-equiv="Refresh" content="1; url=/forum1.php?recherches=1&..."> qui redirige vers le GET canonique. Skipper la page d’attente et hit directement /forum1.php?recherches=1&... est la voie utilisée par HfrClient.searchTopics(...).
  • Paramètres exhaustifs envoyés (l’ordre n’est pas significatif côté HFR mais reste stable pour la testabilité) : recherches=1, cat= (toutes) ou cat=<id>*hfr.inc (explicite), orderSearch=<0|1>, config=hfr.inc, pseud=, search=<query>, titre=<0|1|3>, jour=<j>, mois=<m>, annee=<a>, resSearch=20, daterange=2, subcat=0, searchtype=1, trash=0, trash_post=0, moderation=0.
  • Champ titre :
    • 1 = titres seuls ;
    • 3 = titres + contenu des messages, défaut mobile Redface 2 ;
    • 0 = contenu des messages seul.
  • Champ orderSearch :
    • 1 = tri par date du dernier message du topic, utilisé pour titre=1 ;
    • 0 = tri par date du message correspondant, utilisé pour titre=0 et titre=3.
  • jour/mois/annee sont requis par le form HFR même quand daterange=2 les rend fonctionnellement ignorés. Côté repo, on les calcule depuis un java.time.Clock injectable pour que les tests soient reproductibles.
  • Le résultat se décline en 4 shapes structurellement distinctes :
    1. no-results : page minimaliste <div class="hop"> + texte « Désolé, aucune réponse n’a été trouvée ! ». Mapping → SearchResultPage(topics=[], pivotCategories=[]).
    2. pivot single : bannière « Le moteur de recherche a trouvé des résultats dans les catégories suivantes » + <select name="cat"> dans <div class="search"> avec 1 option value="<id>*hfr.inc" marquée selected + listing standard forum1.php. Déclenché quand cat= et une seule catégorie matche.
    3. pivot multi : même structure que pivot single mais N options dans le pivot. La liste pipe-delimited <input name="post_cat_list" value="|1*hfr|16*hfr|...|13*hfr|"> mirrore les options (terminée par un pipe vide, le parser doit tolérer).
    4. explicit cat : aucune bannière, aucun pivot — listing forum1.php standard. Déclenché quand le request scope la recherche à une catégorie précise (cat=<id>*hfr.inc).
  • Ligne topic :
    • le lien principal a.cCatTopic pointe vers le topic (/hfr/...-sujet_<post>_<page>.htm) ;
    • quand le match vient d’un message, HFR ajoute souvent un second lien forum2.php?...page=1&numreponse=M contenant <div class="citation"><b>Dernier message correspondant :</b>... ; le numreponse de ce lien est la seule source fiable pour le scroll vers le post exact — mais son page vaut toujours 1 et ne doit pas servir à la navigation : la page réelle se résout via la redirection serveur (cf. § numreponse ci-dessus / HfrClient.resolveTopicPageUrl, #277) ;
    • les lignes de titre ou certains résultats mixtes n’ont pas ce second lien : page, numreponse et extrait restent null.
  • Le footer de forum1.php contient un <select name="cat" onchange=...> (le « Goto category » présent sur tous les listings) avec des valeurs entières (<option value="10">). Ne pas confondre avec le pivot recherche, dont les valeurs sont au format <id>*hfr.inc et qui vit uniquement dans <div class="search">.
  • HFR upper-case la query echo dans le HTML retourné (<input value="KOTLIN" name="search">). Le match SQL est insensible à la casse — on n’a pas à se soucier de la casse côté UI.
  • Confidentialité : la query utilisateur apparaît en clair dans l’URL des résultats. Le repo logue uniquement la longueur (queryLength=N) et la présence d’une cat, et rebrande les IOException qui contiendraient l’URL complète avant propagation.

POST /transsearch.php — recherche intra-topic

Recherche d’un mot-clé à l’intérieur d’un seul topic (#546/#576/#585), (distincte de la recherche globale forum1.php?recherches=1 ci-dessus). Câblée par HfrClient.searchInTopic (TopicSearch / TopicSearchRepository(Impl)), parsée par TopicSearchFormParser. Contrat reverse-engineered live (cohérent avec la note interne reference_hfr_transsearch_contract).

  • Authentifié par conception : transsearch.php rejette un hash_check vide — la recherche intra-topic n’est donc disponible que connecté. C’est une exception locale et bornée à la règle « prefetch non authentifié » (ce n’est pas un prefetch : c’est une action utilisateur explicite).
  • Champs du POST : hash_check (anti-CSRF, requis — voir § Constantes anti-CSRF), post (= topic id), cat, config=hfr.inc, p=1, sondage=0, owntopic, word (le terme), spseudo (filtre par pseudo, vide = tout le monde), filter (= 1 uniquement en mode « occurrences correspondantes seules », absent sinon — filter absent ≠ filter=0), dep=0, firstnum, currentnum.
  • Gestion du curseur :
    • currentnum = curseur de navigation géré côté JS par HFR. Le form statique n’a pas de currentnum ; on l’omet pour une recherche fraîche.
    • firstnum = ancre de page attendue par HFR sur une recherche fraîche ; on la passe avec dep=0. Pour un pas (résultat suivant), firstnum ET dep sont omis : re-envoyer firstnum ré-ancre HFR sur la première occurrence et le curseur ne progresse jamais (bug de stepping vérifié live, #546). Pour balayer tout le sujet (mode « tout le sujet »), firstnum est omis.
    • fin (= curseur de fin / sentinelle) est absent des posts → fin atteinte.
  • Réponse = une page de topic re-parsée (même HTML que forum2.php), positionnée sur l’occurrence courante ; la navigation next/prev rejoue le POST avec le curseur ajusté.
  • NoResults vs erreur réseau : « aucun résultat » est un état nominal (réponse valide sans occurrence — y compris la règle MyISAM « 50 % des lignes » de HFR qui renvoie « aucun résultat » sans que ce soit un bug), distinct d’une IOException. word et spseudo ne sont jamais loggés (même précédent que le POST de réponse).

listenumreponse — optimisation JS inline

Chaque page topic HFR embarque un script inline du type :

<script type="text/javascript">
var listenumreponse = new Array();
listenumreponse[0] = 1234567;
listenumreponse[1] = 1234570;
// ...
listenumreponse[39] = 1234999;
</script>

Ce tableau contient les numreponse des posts de la page courante. Redface v1 ne l’utilisait pas. Opportunité pour v2 :

  • Identifier rapidement quels posts sont sur une page sans parser tous les blocs HTML
  • Détecter si un numreponse attendu (ex : après un reply) est présent dans la réponse
  • Optimiser le prefetch et la réactualisation

Extraction recommandée :

val listeNumreponse: List<Int> = Regex("""listenumreponse\[\d+\]\s*=\s*(\d+)""")
    .findAll(html)
    .map { it.groupValues[1].toInt() }
    .toList()

Voir le skill /parse-fixture (étape 3) pour la procédure d’extraction des variables JS inline.


HFR peut “crypter” certaines URLs externes (anti-scraping, tracking). Redface v1 ne gère pas explicitement ce cas (pas de transformer dédié) — les URLs sont simplement relayées au navigateur.

Décision v2 : idem v1 — ne pas déchiffrer, relayer en l’état. Si l’UX en pâtit, revoir cette décision en Phase 2 via un transformer dédié.


Smileys

Deux sources distinctes :

Type URL de base Exemple
Smileys built-in HFR https://forum-images.hardware.fr/icones/ :jap:/icones/smilies/jap.gif
Smileys custom utilisateur https://forum-images.hardware.fr/images/perso/{user_id}/ Upload par l’utilisateur via editprofil.php?page=5

Règles de rendu :

  • Source de vérité côté lecture : c’est l’attribut src de l’<img> produit par HFR qui pilote le rendu, pas une table embarquée client-side. Le parser pose PostInline.Smiley(kind, imageUrl) directement à partir de l’alt/title (pour le kind) et du src (pour imageUrl) — :core:ui consomme imageUrl tel quel via AsyncImage. Pas de reconstruction d’URL à partir du nom (les chemins perso peuvent contenir des sous-dossiers numérotés images/perso/<N>/, des espaces encodés, et des variantes — la source HTML est la seule fiable).
  • Cache Coil agressif (les smileys ne changent jamais) : CachePolicy.ENABLED + disque infini.
  • GIFs animés : builtins comme perso peuvent être des .gif animés (:bounce:, :pt1cable:, majorité des perso). Le décodeur coil-gif (AnimatedImageDecoder.Factory, API 28+) est enregistré sur le SingletonImageLoader côté :app pour autoplay sans configuration par-call-site.
  • Tailles différentes : les smileys ne sont pas tous 16×16. Le crawl exhaustif wikismilies.php réalisé pendant le dogfood a trouvé 34 139 smileys perso, avec une distribution très concentrée sur une ligne HFR de 50 px : 70×50 (8047), 50×50 (2811), 67×50 (1142), puis beaucoup de variantes W×50; les micro-smileys existent aussi (15×15 701, 19×19 399, 16×16 206). Depuis #175 (Phase 2F, PR #222), RF2 fait du rendu INTRINSÈQUE (:core:ui PostMediaDisplayPolicy), plus de bucket fixe : la taille native du smiley est mesurée via Coil (ImageLoader.execute() + IntrinsicMediaSizeCache) et appliquée sans upscale — un sprite 15×15 reste 15×15 (comme le rendu web HFR, qui montre l’<img> à sa taille native sous img { max-width: 90% }). Caps : absolu 70 sp hauteur / 240 sp largeur (SMILEY_MAX_HEIGHT_SP / SMILEY_MAX_WIDTH_SP, intrinsicSmileyDisplaySize) puis relatif 0,9 × largeur du conteneur (SMILEY_RELATIVE_MAX_WIDTH_FRACTION via BoxWithConstraints, réplique le max-width:90% de RF1). Alignement AboveBaseline + lineHeight libérée (TextUnit.Unspecified) pour laisser la ligne croître au lieu de chevaucher (corrige le bug #129). Cold-fallback avant mesure : builtin 16×16 (builtinPreseedSize), perso 70×50 (persoColdFallbackSize). L’enfant AsyncImage suit le placeholder sp via Modifier.fillMaxSize() (accessibilité fontScale), ContentScale.Fit. Avant #175 (Phase 1) : bucket fixe (builtin 18×18, perso 70×50, Fit, upscale des micro-smileys) — remplacé car peu fidèle au web. Les images inline [img] ont ensuite migré vers le même principe de mesure native : elles utilisent imageDisplayBox avec no-upscale et caps dédiés, tandis que le bucket 240×180 ne sert plus qu’aux tests du résolveur historique. Validation visuelle app↔web : #131.
  • Largeur maximale des images de contenu (#991, 2026-09-02) : les images [img] / fImage gardent le cap historique par défaut 0,95 × largeur du conteneur, exposé par le réglage « Largeur maximale des images » (90 %, 95 % (défaut), 99 %, 100 %). La valeur est indépendante de l’agrandissement des GIF et du mode pleine largeur des posts. Elle est appliquée une seule fois par LocalPostImageMaxWidth aux trois chemins de rendu : inline mesuré (ParagraphProseimageDisplayBox), bloc mesuré (BlockImageimageDisplaySizePx) et slot bloc froid (coldBlockSlotDp). Sur le chemin inline, la largeur mesurée réserve en plus le padding horizontal 4 dp de chaque côté : en colonne étroite, 99 % et 100 % peuvent donc coïncider (ex. 352 dp de bitmap dans une colonne 360 dp) ; les chemins bloc restent distincts. Les smileys, cc-image et slots inline froids restent bornés par le cap smiley séparé 0,9 × largeur (SMILEY_RELATIVE_MAX_WIDTH_FRACTION) ; la préférence n’entre pas dans IntrinsicMediaSizeCache, qui ne stocke que la taille native mesurée.
  • Extraire le code smiley (:jap:, :bounce:) depuis l’attribut alt/title de l’<img> pour pouvoir le re-saisir côté éditeur — c’est un sujet éditeur Phase 2, pas le chemin principal de lecture.
  • Les smileys custom d’un utilisateur sont exposés dans son profil (section perso).
  • Un catalogue “wiki smileys” est disponible via message-smi-mp-aj.php (recherche de smileys) — utilisé par l’éditeur Phase 2F-B (#11 partiel), pas en lecture.

Endpoint wiki search (Phase 2F-B)

Contrat live capturé le 2026-05-22 (cf. core/parser/src/test/resources/fixtures/smiley_search_jap.html + .source.txt) :

GET /message-smi-mp-aj.php?config=hfr.inc&user_id={user_id}&findsmilies={query}
Paramètre Source Notes
config constante "hfr.inc" Idem reste du protocole.
user_id parsé depuis find_smilies_timer('hfr.inc', N) du form HTML (cf. SmileyUserIdExtractor) Plumbé via ReplyForm.userId / TopicForm.userId. 0 accepté pour les probes anonymes (le endpoint ne refuse pas), mais un user id authentifié laisse HFR pager les favoris en premier.
findsmilies la query utilisateur, URL-encodée HFR gate sur query.length > 2 dans find_smilies_timer ; le client reproduit ce seuil + le debounce 300 ms du JS web.

Réponse : fragment HTML (pas de <html>/<body> wrapper), une <img src="…" alt="[:token]" title="[:token]" onclick="putSmiley(this.alt,this.src)" /> par smiley match. HFR peut émettre le même (alt, src) deux fois — le parser dédup. Tokens préservés verbatim : variantes avec espaces ([:haha jap]), underscores ([:menkahoure_4]), tirets ([:55-]), :N variants ([:eneytihi:5]).

L’endpoint accepte session anonyme (user_id=0) ; côté client, l’appel passe par le client OkHttp anonymous pour ne pas exposer la query dans les diagnostics authentifiés.


Sessions, cookies et 403

Cookies HFR

Cookie Rôle Durée
md_user Pseudo utilisateur — indicateur de session active 1 an
md_pass Token de session 1 an
md_forum Identifiant de forum session
Cookies divers (tracking interne HFR) variable

Indicateur de session active : présence du cookie md_user. Si une réponse HTTP redirige vers la page de login ou si le DOM contient le formulaire login à la place du payload demandé, la session a expiré.

Détection et recovery de session expirée

Les fetchers authentifiés doivent distinguer une vraie liste vide d’une page login servie en HTTP 200. Phase 1B hardening : HfrClient lève SessionExpiredException au minimum quand :

  1. l’URL finale après redirection pointe vers /login.php ou /login_validation.php ;
  2. le HTML contient un formulaire login HFR (login_validation.php, champ pseudo, champ password).

Ce signal est branché sur les endpoints authentifiés HfrApiClient.getCategoryFlagTopics() (REST per-cat, fan-out 1 appel par catégorie publique), HfrClient.getPrivateMessageListPage() (HTML) et HfrClient.getTopicPage(useAuth = true) (HTML). Le chemin getTopicPage(useAuth = false) garde son passthrough anonyme pour le prefetch : il ne peut pas avoir de session expirée et le caller veut le HTML brut retourné par HFR. L’UI drapeaux affiche alors un état “session expirée” avec action de reconnexion, au lieu de parser la page login comme une liste vide.

L’utilisateur ré-entre son mot de passe (Option A : pas de re-login transparent, le password n’est pas stocké — voir architecture.md#stockage-sécurisé-des-credentials).

403 / rate limiting

HFR n’expose pas officiellement de header Retry-After. En pratique :

  • HTTP 429 ou 403 sur des requêtes répétées → backoff exponentiel (2s, 4s, 8s, max 60s)
  • Rate limit client-side : max 2 requêtes/s sur les endpoints POST (reply, edit, flag)
  • Aucune information officielle sur les seuils HFR — ces valeurs sont empiriques

Règle critique : prefetch non-authentifié

Les requêtes de prefetch (pages suivantes d’un topic, drapeaux préchargés) ne doivent jamais inclure les cookies de session HFR.

Raison : HFR met à jour les drapeaux (topics marqués comme lus) sur toute requête authentifiée. Un prefetch avec session marquerait silencieusement les topics comme lus, ce qui est exactement le bug que Redface v1 présentait.

Implémentation :

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides @Singleton @HfrBaseUrl
    fun provideHfrBaseUrl(): HttpUrl = HfrConstants.BASE_URL.toHttpUrl()

    @Provides @Singleton @AuthenticatedClient
    fun provideAuthClient(base: OkHttpClient, jar: CookieJar): OkHttpClient =
        base.newBuilder().cookieJar(jar).build()

    @Provides @Singleton @AnonymousClient
    fun provideAnonymousClient(base: OkHttpClient): OkHttpClient =
        base.newBuilder().cookieJar(CookieJar.NO_COOKIES).build()
}

@Singleton
class HfrClient @Inject constructor(
    @AuthenticatedClient private val authenticated: OkHttpClient,
    @AnonymousClient private val anonymous: OkHttpClient,
    @HfrBaseUrl private val baseUrl: HttpUrl,
) {
    suspend fun getTopicPage(
        cat: Int,
        post: Int,
        page: Int,
        useAuth: Boolean = true,  // false ⇒ @AnonymousClient ⇒ pas de mise à jour des drapeaux
    ): String { /* ... */ }
}

Le contrat se ramène à un flag de booléen côté caller : useAuth = true (default) pour la lecture explicite, useAuth = false pour tout appel de prefetch / pré-chauffage de cache. Un test Konsist enforcera la règle Phase 1A — toute fonction dont le nom commence par prefetch* (ou tout call-site marqué prefetch dans le repository) doit passer useAuth = false. La règle est trackée dans #42 et reste désactivée tant qu’aucun call-site prefetch n’existe — elle s’active avec la PR 1A-bind.

Confirmé par Corran Horn sur le topic HFR Redface 2 : « en utilisant un cookie d’un compte anonyme pour pas péter les drapeaux ».

Acté — ADR-018 décision 7 (accepté 2026-08-25, reprise de l’ADR-013 supersédée) : exception bornée aux MP — prefetch authentifié limité aux pages adjacentes (N−1/N+1) de la conversation cat=prive actuellement ouverte ; prefetch depuis la liste interdit. Justification mesurée live dans #361 : l’état lu/non-lu MP est un dot binaire par conversation, effacé par le GET d’ouverture (le prefetch intra-conversation n’a donc pas d’effet supplémentaire dans le cas nominal ; reste une race nouveau-message documentée et assumée dans l’ADR) ; le GET de la liste est inerte ; aucune position de lecture serveur n’existe pour les MP. La règle générale ci-dessus reste en vigueur partout ailleurs.

Marquer un MP comme non lu — nonlu.php (vérifié live 2026-06-11, #361)

GET /user/nonlu.php?config=hfr.inc&cat=prive&subcat=0&post={threadId}&page={N}&p=1&sondage=0&owntopic=0&new=0
  • Lien unique dans l’en-tête de chaque page de conversation (icône œil) ; mutation par GET simple, sans hash_check.
  • Granularité binaire, conversation entière : le paramètre page n’encode aucune position — nonlu posé depuis la page 3 puis lecture de la seule page 2 → conversation entièrement « lue ». Pas d’état intermédiaire.
  • Séquence exercée live (post=3161381) : nonlu → NON-LU ; GET page 1 → lu ; nonlu → NON-LU ; GET page 3 → lu. État final identique à l’état initial : la compensation est sans perte précisément parce que l’état est binaire.
  • Conséquence ADR-018 décision 7 : un « marquer comme non lu » manuel suspend le prefetch de la conversation jusqu’à sa réouverture (sinon le prefetch adjacent effacerait le non-lu que l’utilisateur vient de poser).

MP/DT — ajout/retrait de membres (newdest)

Gestion des destinataires d’une conversation MultiMP / DT par son owner (#606/#612). Câblée par PrivateMessageWriteRepository / HfrClient (parser PrivateMessageReplyFormParser, champ porté par ReplyForm).

  • Surface réelle : le formulaire de réponse d’une conversation MP est embarqué dans la page de conversation message.php (PAS forum2.php). C’est de là que le champ newdest est lu et réémis.
  • newdestdest : dest est le destinataire d’une nouvelle composition (compose) ; newdest est la liste CSV des membres actuels d’une conversation existante, prérempli par HFR et éditable uniquement par l’owner. L’owner ajoute/retire un membre en modifiant cette CSV puis en envoyant un POST de réponse : la mutation de la liste de membres passe donc par le même POST que la réponse (bddpost.php cat=prive, numrep préservé), pas par un endpoint dédié.
  • Règles de validation (vérifiées) : au moins 1 membre conservé, ordre et casse préservés, le champ n’est honoré que si l’utilisateur courant est l’owner de la conversation (guard côté repo).
  • La mutation produit un message système « Modération » dans le fil (trace serveur de l’ajout/retrait).
  • Un non-owner ne voit pas newdest préempli : pour lui le POST de réponse ne touche pas la composition de la conversation.

MP/DT — citer un message (1:1 vérifié le 2026-08-12, DT le 2026-08-17)

Seul inconnu serveur du chantier #1040 (partage de la surface de lecture Topic → MP), d’abord tranché en 1:1 par deux captures authentifiées de la même conversation cat=prive dans la même session : private_message_quote_form.html et son témoin private_message_reply_form.html. Le contrat a ensuite été reproduit en DT par private_message_dt_quote_form.html (#1074). Aucun POST n’a été émis.

Chaque message d’une page cat=prive porte son lien « citer » :

GET /message.php?config=hfr.inc&cat=prive&post={threadId}&numrep={numreponse cité}&ref={rang dans la page}&page={N}&p=1&subcat=0&sondage=0&owntopic=0&new=0#formulaire

Le formulaire renvoyé est un form[name=hop] vers bddpost.php?config=hfr.incle même endpoint et la même forme qu’une citation de topic. Ce qui le distingue :

  • numrep = le message cité. Capture faite sur le 4ᵉ message d’une page qui en comptait 5 : ce n’est donc pas un préremplissage « dernier message ».
  • content_form est prérempli par HFR avec [quotemsg={numrep},{ref},{userId de l'auteur cité}]…[/quotemsg] — même forme que le topic, à réutiliser verbatim (jamais reconstruire le tag localement, cf. § Quote ci-dessus).
  • aucun champ caché ref n’est servi : le rang ne voyage que dans l’URL et dans le tag BBCode. (Le formulaire de réponse d’un DT owner, lui, en porte un — ref=0.)
  • numreponse reste vide : la citation ne détourne pas le champ de l’édition.
  • options en vraies cases à cocher (signature cochée par défaut) et MsgIcon=1 pré-coché, comme tout formulaire message.php.

La capture DT sert exactement les mêmes 20 champs cachés que la citation 1:1, avec cat=prive, numreponse="", numrep = message cité, aucun champ caché ref et aucun newdest. Elle ne rend pas non plus de ligne « Destinataires ». Le ref=0 et l’absence de numreponse observés auparavant appartiennent au formulaire de réponse DT owner : ils ne se transportent pas au formulaire de citation. Sur appareil, « Citer » dans un DT ouvre l’éditeur prérempli par HFR sans erreur ; aucun envoi live n’a été tenté.

numrep a trois sens selon le formulaire servi — ne jamais en déduire une sémantique unique :

Formulaire Origine numrep content_form
Réponse rapide embarquée forum2.php?cat=prive dernier message de la page (préremplissage) vide
Réponse simple message.php suivi depuis form#repondre_form vide vide
Citation message.php suivi depuis le lien « citer » message cité [quotemsg=…]

Le premier « message » d’une page N > 1 est une « Reprise du message précédent », servie avec ref=0 — mesuré, plus supposé. La capture thread_multipage du 2026-08-25 (#1107) a lu, dans une seule session authentifiée, trois pages adjacentes de la même conversation cat=prive : sur N comme sur N+1, la première ancre est bien le récapitulatif du dernier message de la page précédente — jeux d’ancres réelles disjoints entre les trois pages — et son lien « citer » porte ref=0. Ce n’est donc pas un rang manquant mais un rang nul déclaré par HFR ; le message réel existe sur la page N−1 — l’égalité d’ancres entre pages le mesure — vraisemblablement avec son vrai rang, que la sonde n’y extrait pas. Le fail-closed MP (ref >= 1) masque « Citer » sur ce récapitulatif, en 1:1 comme en DT — trou de couverture suivi par #1110. La même capture a mesuré le rabattement serveur : une demande de page au-delà de la dernière est servie comme la dernière, l’URL effective en fait foi.

Conséquence pour le lot 4 de #1040 : la citation MP/DT ne demande aucun nouveau parser ni champ supplémentaire — ReplyFormParser transporte déjà tous les champs cachés verbatim et le content_form prérempli. La citation simple et le panier multiple sont livrés avec un scope MP typé. Pour plusieurs citations, le client rejoue séquentiellement le contrat unitaire avec la page et le ref propres à chaque sélection, puis concatène les préremplissages ; il n’applique pas le fallback topic sans ref. Les captures ci-dessus prouvent seulement chaque formulaire pris isolément : aucune capture live n’a encore observé cette séquence de GET ni l’acceptation d’un POST portant plusieurs blocs [quotemsg].

Écriture MPStorage — read-modify-write bdd.php cat=prive (#593/#597)

Écriture dans le document MPStorage (premier post du MP de stockage, cf. models.md et ADR-014). Câblée par DefaultMpStorageRepository / MpStorageEnvelopeWriter. Le contrat bdd.php cat=prive en écriture n’a pas été observé live — l’implémentation est fail-closed et restée opt-in OFF par défaut.

  • Pipeline RMW pur : GET du formulaire d’édition du premier post (content_form) → mutation de l’enveloppe JSON en mémoire → POST bdd.php cat=prive du content_form complet. C’est un remplacement intégral (last-write-wins, pas de verrou) ; les clés tierces du rawEnvelope survivent au round-trip (exigence de compatibilité userscripts non négociable).
  • Verify-after-write : relecture après POST ; restauration bornée à la corruption réelle (l’enveloppe relue n’est plus une enveloppe v0.1 valide, garde isJsonEnvelope), jamais un rollback aveugle.
  • Cap de taille : MAX_CONTENT_FORM_BYTES = 64 KiB (MpStorageRepository) ; dépassement ⇒ MpStorageWriteResult.TooLarge, aucun POST envoyé (fail-closed — HFR tronque silencieusement, ce qui corromprait le document ; la list DTCloud n’est jamais prunée par les userscripts).
  • Deux modes d’écriture :
    • writeBackFlag(entry)manuel, add-or-update : pose ou met à jour la position de reprise pour une conversation, créant l’entrée si absente.
    • writeBackFlagIfPresent(entry) (#597) — UPDATE-ONLY : ne réécrit que si la conversation est déjà présente dans le document, sinon MpStorageWriteResult.SkippedNotPresent et aucun POST. C’est le mode du déclencheur automatique de synchronisation de la position de lecture DT — anti-pollution : on ne crée jamais d’entrée pour une conversation que MPStorage ne suit pas déjà.
  • Opt-in : l’écriture est gardée par le réglage KEY_SYNC_PRIVATE_MESSAGES_WRITE_ENABLED (?: false, défaut OFF, DataStoreUserPreferencesRepository) ; opt-in OFF ⇒ MpStorageWriteResult.DisabledByPreference, aucune requête.
  • Arbitrage de clé write-back (#597) : la clé d’identification d’une entrée (threadId du MP vs id de topic DT côté forum) comporte un risque résiduel d’écriture dans la mauvaise entrée ; il est borné par le mode UPDATE-ONLY (jamais de création silencieuse) mais non nul — à requalifier avant d’activer l’opt-in par défaut (suivi #6/#577).

Autres edge cases documentés

Ton du message (MsgIcon)

Sur une page de sujet, HFR rend le ton dans l’icône du numéro de message : td.messCase1 > div.right > a[href^="#t"] > img, avec une source /icones/message/iconN.gif. N = 1 est le ton par défaut et devient Post.msgIcon = null ; les autres valeurs sont conservées, y compris les valeurs historiques supérieures à 16 observées en lecture (icon20.gif). Le formulaire complet expose pour sa part 16 radios MsgIcon, de 1 à 16, dont une cochée ; le picker d’écriture reste donc borné à ces 16 choix.

Ne jamais détecter ce champ via alt="mood" : les blocs « Publicité » portent aussi une image icon1.gif avec cet attribut, mais sans ancre de post a[name^="t"]. parsePosts écarte ces blocs avant l’extraction. Contrat capturé le 2026-09-04 dans core/parser/src/test/resources/fixtures/topic_dev_p75_msgicon.html (28 posts, dont des tons 6, 10 et 20), avec son sidecar .source.txt.

Posts édités

Marqueur dans le HTML des posts : un div.edited en fin de contenu, ex. <div class="edited"><a …>Message cité 1 fois</a><br />Message édité par jubjub le 14-03-2016&nbsp;à&nbsp;12:09:00</div>. Le lien « Message cité N fois » est optionnel et peut exister sans ligne « Message édité » (post cité jamais édité) — et inversement. Extrait côté parser (#362) en champ Post.editedAt: Instant? via HfrDateParser.parseEditedAtOrNull (regex non ancrée, le préfixe citation est toléré ; null si pas de marqueur d’édition). Le div.edited reste par ailleurs retiré du contenu rendu (PostContentParser).

En-tête d’une citation rendue — deux formes de lien, #t{numreponse} autoritaire

Le bandeau « Pseudo a écrit : » d’une citation rendue (table.citation b.s1 a.Topic, ou table.oldcitation pour un profil au style classique) porte le permalien du message cité. HFR en sert deux formes selon la session, et un client qui n’en connaît qu’une casse le saut vers le message cité pour l’autre moitié des lecteurs (#625, corrigé par #1092) :

Session Forme du href Page Message cité
Anonyme …/{slug}-sujet_{topicId}_{page}.htm#t{numreponse} (permalien statique) 2ᵉ entier du segment sujet_… fragment #t{…}
Authentifiée …/forum2.php?config=hfr.inc&cat={cat}&…&page={N}&…&numreponse={M}&…#t{numreponse} paramètre page= fragment #t{…}

La forme authentifiée est la même sur les sujets et sur les MP (cat=prive) : ce n’était pas un défaut propre aux MP.

Piège mesuré : numreponse= en query n’est pas le message cité. Sur les pages de sujet authentifiées, le paramètre numreponse= de ce href vaut 0 — seul le fragment #t{num} identifie le message cité. Lire le paramètre donne un correctif vert en MP (où il porte bien la même valeur que le fragment) et cassé sur les sujets. Le fragment est donc la source unique pour les deux formes et les deux surfaces.

Preuves dans l’arbre, ancres a.Topic d’un table.citation / table.oldcitation : write_ia_topic_page.htmlsujet authentifié, …&cat=32&post=7&page=1&…&numreponse=0&…#t29892 (page réelle dans page=, cible dans le fragment, numreponse=0) ; le fragment de HTML brut authentifié épinglé par PostContentParserTest (topic RF2 page 25) porte de même …&page=25&…&numreponse=0&…#t2785311. private_message_thread_with_quote.htmlMP authentifié, …&cat=prive&page=1&…&numreponse=1980000101&…#t1980000101 (les deux coïncident : c’est ce qui rend un correctif fondé sur la query vert en MP et faux sur les sujets). topic_loisirs_chutes_p4344.htmlpermalien anonyme, …sujet_27848_4344.htm#t74598425.

Côté implémentation, PostContentParser.parseQuote essaie la forme statique puis la forme authentifiée (CITATION_HREF_REGEX, sinon DYNAMIC_CITATION_HREF_REGEX) et alimente PostBlock.Quote.page / .numreponse (cf. models.md). Les deux formes sont épinglées par PostContentParserTest. Un [quote] nu (table.quote / table.oldquote) n’a pas d’ancre auteur : auteur, page et numreponse restent null, sans erreur.

Posts supprimés / modérés

Structure HTML altérée : le <table class="messagetable"> peut ne plus contenir que le bandeau d’auteur + une mention de suppression. Le parser doit gérer ce cas sans crasher — Post.content devient un PostContent vide ou un bloc sentinel "Message supprimé".

Emails obfusqués

HFR obfusque les emails dans les profils publics. Le texte visible est souvent "Vous n'avez pas accès à cette information" ou un email brouillé. Ne pas tenter de déobfusquer — conserver la string brute.

Pagination edge case

Si la meta description HTML Pages : N est absente ou malformée, utiliser UNKNOWN_PAGES_COUNT = -1 (sentinel) et recalculer côté client en naviguant. Fixture topic_last_page.html couvre ce cas (page partielle avec moins de 40 posts).

postsPerPage configurable

Le nombre de posts par page est un réglage utilisateur HFR (editprofil.php?page=3), pas une constante. Ne jamais hardcoder 40 dans le code. Le parser lit la valeur depuis la page de paramètres à la connexion et la stocke dans UserSettings.postsPerPage.


API REST HFR — endpoints et contrat (Phase 1C-A, ADR-003)

Toutes les URLs REST passent par le même point d’entrée :

GET https://forum.hardware.fr/webservices/rest_api.php?uri=<URI>[&page=N&results_per_page=M]

Endpoints retenus en v1

Domaine URI Auth Notes
Liste des catégories publiques forums/hardwarefr/categories/ non (auth ajoute des liens drapeaux) 19 catégories ; cat=24 Blabla est conditionnelle, exclue de la nav publique
Sous-catégories d’une catégorie forums/hardwarefr/categories/{cat}/subcategories/ non Ordre éditorial préservé
Liste des topics — catégorie entière forums/hardwarefr/categories/{cat}/topics/last/ non / oui Auth ajoute is_read, flag_owntopic, last_position, last_post_read_id
Liste des topics — sous-catégorie forums/hardwarefr/categories/{cat}/subcategories/{sub}/topics/last/ non / oui idem, plus links.subcategory.href
Metadata d’un topic forums/hardwarefr/categories/{cat}/topics/{topic}/ non / oui 1 KB vs 220 KB pour la même page HTML
Drapeaux par catégorie forums/hardwarefr/categories/{cat}/topics/{participated,read,favorites}/ oui Format plat (resources = topics). Voie consommée en Phase 1D-1 (#110) : DefaultFlagRepository itère sur les catégories publiques pour reconstituer la liste complète des drapeaux.
Drapeaux globaux forums/hardwarefr/topics/{participated,read,favorites}/ oui Format groupé par catégorie (resources = list of category groups). Non consommé en Phase 1D-1 : forme groupée non capturée, à valider via fixture live + DTO dédié dans une PR de suivi (perf : remplacerait N requêtes par-cat par 1 requête globale).

Endpoints HTML-only (pas de REST disponible côté HFR)

  • …/posts/ (lecture posts d’un topic) → HTTP 500 inconditionnel côté serveur, vérifié exhaustivement le 2026-05-02 (18+ variantes CSRF testées). Reste HTML : forum2.php?cat=N&post=M&page=P.
  • MPs (liste, lecture, envoi) → aucun endpoint REST exposé (300+ variantes scannées). Reste HTML : forum1.php?cat=prive, forum2.php?cat=prive&post=N, bddpost.php.
  • Mutations drapeaux (PUT topics/{id}/) → routé mais sémantique métier opaque (downgrade refusé, no-op refusé, hors-bornes silently ignored). Reste HTML : addflag.php / delflag.php.
  • Création topic / réponse (POST topics/last/, POST topics/{id}/posts/) → routé et fonctionnel (testé live), mais reporté à v2 : DELETE REST = 501, donc rollback de test impossible côté CI sans cycle sandbox.

Helper de réécriture HATEOAS — obligatoire

Tout href retourné dans un payload REST pointe sur https://forum.hardware.fr/api/<…>. Apache n’a jamais activé le rewrite /api/ côté HFR, ces URLs renvoient 404 telles quelles. Le client doit réécrire :

https://forum.hardware.fr/api/<path>          (HATEOAS, non callable)
        →
https://forum.hardware.fr/webservices/rest_api.php?uri=<path>   (callable)

Implémenté de façon validante (host + scheme + préfixe /api/ enforcés) dans :core:network HfrApiClient.rewriteHateoasHref(href). Les query params HATEOAS (page, results_per_page) sont copiés comme params top-level — ils ne sont jamais bakés dans la valeur uri.

Contraintes serveur

  • Verbes : GET / POST / PUT supportés. PATCH renvoie 501 ; DELETE renvoie 501 sur les ressources existantes.
  • hash_check (CSRF) requis pour les POST / PUT, à extraire d’une page HTML auth.
  • Pagination : ?page=N&results_per_page=M. Défaut HATEOAS = 25 ; cible Redface 2 = 50 (limite les requêtes sur les sous-cats actives).
  • Limite parallélisme : ≤ 10 connexions TCP simultanées vers HFR (saturation Apache mesurée à 20). OkHttp maxRequestsPerHost = 5 par défaut reste sous le seuil.
  • name / title peuvent contenir des entités HTML non décodées (&amp;) — décoder côté mapper.

Champs présents en JSON / absents en HTML

  • views (compteur de vues) : absent du JSON. Si on a besoin du chiffre, c’est HTML uniquement.
  • total_pages (topic) : déductible via ceil(links.posts.count / N), où N est la valeur du query param results_per_page exposé par links.posts.href du payload (typiquement 40 dans les fixtures actuelles, mais ne jamais le hardcoder — c’est l’API qui décide). C’est différent du réglage HTML utilisateur postsPerPage (cf. § postsPerPage configurable) qui s’applique au rendu de forum2.php, pas à la liste de topics REST.
  • last_position ≠ page : last_position est l’index/offset du dernier post lu dans le topic global (et non un numéro de page). La page de reprise est exposée séparément, encodée dans links.posts.href?page=N du même topic auth ; c’est cette valeur qu’il faut consommer pour TopicSummary.lastReadPage.
  • flag_owntopic (auth uniquement) : drapeau le plus fort SUR le sujet, PAS le bucket d’appartenance (vérifié live 2026-06-11, #384, fixture rest_cat13_participated_favorites.json : le bucket participated renvoie des sujets participés-ET-favoris avec flag_owntopic=3, et le bucket read renvoie flag_owntopic=0). Valeurs observées : 1 = participé (cyan), 2 = lu uniquement (rouge), 3 = favori/étoile (jaune), 0 = aucun/lu. Conséquences côté Redface 2 :

    Donnée Source Usage
    Flag.type bucket REST demandé au fetch (participated/read/favorites) — jamais flag_owntopic routage, filtres, clé du cache Room par type (#384)
    Flag.isFavorite flag_owntopic == 3 décoration seule : pastille jaune d’un favori listé dans « Mes sujets » (parité site)
    autre valeur (0, 4, négatif, null) dégrade silencieusement (isFavorite = false), pas de crash

    Pour les listings de topics (TopicSummary, hors listes de drapeaux), le mapping direct 1→CYAN / 2→RED / 3→FAVORITE / sinon null reste valable : là le champ décrit bien le drapeau du sujet, il n’y a pas de bucket demandé.

    Indépendant de is_read : un sujet drapeau cyan peut être lu (is_read = true, hasUnread = false) ou non lu — les deux axes coexistent dans TopicSummary.

  • last_post_read_id ≠ « premier non lu » : last_post_read_id est l’id du dernier post lu par l’utilisateur dans ce topic. Le legacy HTML forum1f.php exposait au contraire un #t{numreponse} pointant le premier post non lu. Redface 2 (Phase 1D-1) consomme last_post_read_id tel quel via Flag.lastPostReadId et l’utilise comme ancre de scroll en deep link — re-ancrer le lecteur sur le dernier post lu est suffisamment proche de l’UX « où je m’étais arrêté » sans inférer un premier-non-lu que le payload REST n’expose pas.
  • tns3 filename avatar : nom du fichier, le préfixe d’URL est à reconstituer côté UI.

Fixtures HTML

Les fixtures de test du parser vivent dans core/parser/src/test/resources/fixtures/ (à créer en Phase 0). Chaque fixture doit être :

  • Capturée depuis HFR réel (jamais fabriquée par une IA ou à la main)
  • Nettoyée des données sensibles avant commit : cookies, hash_check, emails, identifiants réels, URLs signées
  • Annotée avec sa source HFR (URL ou cat=X, post=Y, numreponse=Z) dans un fichier .source.txt frère ou en commentaire en tête du HTML

Catalogue complet : voir contributing.md#fixtures-html-pour-le-parser.

Pour capturer une fixture : utiliser le MCP hfr-mcp avec hfr_read output=path/to/fixture.html (écrit le HTML brut), puis appliquer le skill /parse-fixture pour générer l’analyse structurée.

Exception cat=prive : hfr_read et hfr_quote exigent une catégorie entière, donc hfr-mcp ne peut pas capturer une page de MP. Le chemin HTTP authentifié, la sanitisation exigée et le sidecar de provenance sont décrits dans capture-fixture-citation-mp.md.

Fixtures REST

Les fixtures JSON de test des mappers REST vivent dans core/data/src/test/resources/fixtures/ (Phase 1C-A — capturées 2026-05-01). Mêmes règles que les fixtures HTML : capturées live, nettoyées des données sensibles avant commit, accompagnées d’un .source.txt qui documente la commande curl d’origine et les caveats.

Catalogue initial :

  • rest_categories.json / rest_categories_auth.json — 19 catégories anonymes / authentifiées (avec liens drapeaux).
  • rest_subcategories_cat13.json — 15 sous-catégories de Discussions (cat=13).
  • rest_topics_cat23_subcat550_p1.json — page 1 des 25 derniers topics Tech Mobiles / Android.
  • rest_topic_meta_35395.json — metadata du topic communauté Redface 2.
  • rest_cat23_participated.json — un topic en mode authentifié (drapeau « participé »).

Sources


Haut de page

Redface 2 — Specs v0.12.0 — Un projet communautaire pour Hardware.fr

This site uses Just the Docs, a documentation theme for Jekyll.