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 :
- 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). - 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}"e_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: Stringpour 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&..., pasmessage.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’endpointforum2.php?config=hfr.inc&cat={cat}&post={post}&numreponse={numreponse}"e_only=1renvoie une page topic filtrée ne contenant que les posts qui citent lenumreponsecible, dans le DOMmessagetablestandard (mêmes sélecteurs que la lecture normale — parsé parHfrParser.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 aveccitedCount: 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 gardedistinctBy { numreponse }du repo, gate Fable R2). Il n’y a pas de pagination de ce filtre :page=2répète la première page au lieu d’avancer. Le titre de la feuille reste donc lecitedCountserveur (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 leflag_owntopicREST, cf.core/model/.../Flag.kt). HFR clé la suppression sur ce champ.FAVORITE=3est vérifié en live le 2026-05-28 (curl authentifié + recoupement REST : le bucketfavorites/passe deresults_count1 → 0).numreponsen’est PAS requis pour le delflag favori : l’URL ci-dessus (sansnumreponse) retire bien le favori — donc l’impl #99, qui n’expose pas la position, est correcte.CYAN=1etRED=2restent inférés du mappingowntopic(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 émetsubcat=(vide) quand elle est absente (les listings REST drapeaux ne la portent pas toujours).{post}=topicId;{page}=lastReadPagedu 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 FlagType→owntopic ; 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 toujoursuserId, 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’
@AnonymousClientdansHfrClient.getProfilepour 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 ayanttd.profilCase2(label) ettd.profilCase3(valeur).div.avatar_center img[src]— image avatar. En session live,srcest une URL absolue CDN ; dans les fixtures browser-save, un chemin relatif — le parser reconstruit l’URL canonique depuis le patternmesdiscussions-{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”).
registeredAtresteString: le format HFRDD/MM/YYYYn’est pas un ISO standard. Promotion enLocalDateouInstantreporté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 viaJsoup.text()pour que l’UI puisseText(...)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 (casStatut = Membre).core/parser/src/test/resources/fixtures/profile/profile_moderator_anonymous.html— userId=15461 (Ernestor), session anonyme, casStatut = 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 parprofileIdn’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 defillfield_private('…')(dé-échappé ; repli défensifanchor.ownText()), rôle = texte du<i>mappé viaauthorRoleFromLabel(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é :
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 ;- champs cachés, verbatim et dans l’ordre DOM :
cat,p,page,sondage,owntopic,subcat,numeropost; - choix cochés dans l’ordre des options du formulaire, jamais dans l’ordre d’un
Setappelant :- mono (radios) : un seul
reponse=<value>; - multi (checkboxes) : un
reponseN=1par choix.
- mono (radios) : un seul
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, sansReferer(même famille GET queaddflag.php/delflag.php). Paramètres :cat,config=hfr.inc,post(= topic id). Pas denumreponse. - Réponse succès (HTTP 200,
text/html) : message dansdiv.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 proxyTopic.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 formulairebddpostavecsubcat=0et le POST part bien avecsubcat=0.0est 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 formulairebddpostdans la page (form[action*=bddpost.php]), rendu uniquement en session authentifiée sur un topic non verrouillé — c’est le drapeauTopic.canReply. Lesubcatde POST est lu sur l’input[name=subcat]de ce formulaire, jamais sur le widget de recherche rapide qui embarque aussi unsubcatsur la même page. Source : capture live du formulaire de réponse IA (session authentifiée, cat=32) ; lehash_checkcapturé 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 contratsubcat=0est couvert par des tests unitaires aval (ReplyContext/EditPostContext/EditFirstPostContext/PostEditorState) et par un test parser synthétique ciblé sur la seule branchesubcat=0du formulairebddpost.
Note
refoptionnel sur le quote (#146/#227) : le GET quotemessage.php?…&numrep={numreponse}&ref={ref}…identifie le post cité parnumrep={numreponse}seul ;refest positionnel/cosmétique et n’est pas requis. Vérifié deux fois : (1)hfr-mcp(FetchQuote,internal/hfr/reader.go) cite viamessage.php?…&numrep=…&page=1&p=1&new=0sansref; (2) test live in-app sur cat=32 (le GET sansrefrenvoie l’éditeur prérempli avec le bon[quotemsg=…]). Conséquence : « Citer » est piloté parTopic.canReplyet ne lit jamais le lien quote de la page —HfrClient.getReplyFormomet&ref=quand il estnull.
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é dansCryptlinkDecoder(:core:parser), câblé viamaterialize()en tête deTopicPageParser.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 liensediter-obfusqués dans.toolbar .left, 0 enmessage.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. noterefci-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é :
parseHasEditLinkreconnaîtmessage.php?…numreponse=ET le slug joli/editer-\d(aprèsmaterialize).- Profil (
/hfr/profil-{id}.htm) : ship EN CLAIR même en brut authentifié →parseProfileIdn’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=35395cat=23subcat=550page=20numreponse=""numrep=""sujet="Redface 2 — PHASE 2 @ ALPHA"content_formvide 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.phpen session anonyme ne redirige pas immédiatement vers le login. HFR sert le même composer avec champspseudoetpasswordvisibles (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’URLmessage.php?...&post=14227, HFR sert quand même un composer (write_reply_locked_topic_forced_form.html) mais le POSTbddpost.phprefuse (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 :
- traiter
delete=1sur FP comme une action destructrice distincte “supprimer le topic” ; - demander une confirmation UI forte avant d’envoyer le POST ;
- 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ées768,640,1; le sens exact reste opaque. Impact : ne pas reconstruire localement les quotes, réutiliser lecontent_formprérempli par HFR ; - edit FP du topic Redface 2 : non testé car
XaTelittene 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 MythO ↔ Colonel+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 :
- Faire un GET sur la page d’édition/création appropriée.
- Extraire
hash_checkvia la regex ou sélecteur CSSinput[name="hash_check"]. - Injecter la valeur dans le form POST.
- Si
hash_checkest 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 :
numreponseseul 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
catETnumreponse(optionnellementpostpour la page). Un deep link qui ne fournit qu’unnumreponseest ambigu. -
Recherche : les résultats titre (
titre=1) renvoient(cat, topicId)sansnumreponse: ouvrir le topic page 1. Les résultats contenu (titre=0) et certains résultats mixtes (titre=3) peuvent ajouter sous le titre un lienforum2.php?...page=1&numreponse=Mavec une citation « Dernier message correspondant » : lenumreponseest exploitable, mais lepagedu href vaut toujours1(vérifié 2026-06-10 sur les captures anonyme ET authentifiée : 34/34 ancres portentpage=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}etLocationrelatif (le segment sous-catégorie peut être présent ou absent — ancrer le parsing sursujet_{post}_{page}.htm, jamais sur la profondeur du chemin). Preuve live (2026-06-10, anonyme, #277) :GET …cat=23&post=35421&page=1&numreponse=2786758→301 Location: /hfr/gsmgpspda/redface-dev-sujet_35421_3.htm#t2786758(page 3). Implémentation :HfrClient.resolveTopicPageUrl+TopicPageUrlParser. Ne jamais inventer unnumreponsequand 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 (scrollTointrouvable, 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.incest 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 parHfrClient.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) oucat=<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é pourtitre=1;0= tri par date du message correspondant, utilisé pourtitre=0ettitre=3.
jour/mois/anneesont requis par le form HFR même quanddaterange=2les rend fonctionnellement ignorés. Côté repo, on les calcule depuis unjava.time.Clockinjectable pour que les tests soient reproductibles.- Le résultat se décline en 4 shapes structurellement distinctes :
no-results: page minimaliste<div class="hop">+ texte « Désolé, aucune réponse n’a été trouvée ! ». Mapping →SearchResultPage(topics=[], pivotCategories=[]).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 optionvalue="<id>*hfr.inc"marquéeselected+ listing standardforum1.php. Déclenché quandcat=et une seule catégorie matche.pivot multi: même structure quepivot singlemais 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).explicit cat: aucune bannière, aucun pivot — listingforum1.phpstandard. Déclenché quand le request scope la recherche à une catégorie précise (cat=<id>*hfr.inc).
- Ligne topic :
- le lien principal
a.cCatTopicpointe 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=Mcontenant<div class="citation"><b>Dernier message correspondant :</b>...; lenumreponsede ce lien est la seule source fiable pour le scroll vers le post exact — mais sonpagevaut toujours1et ne doit pas servir à la navigation : la page réelle se résout via la redirection serveur (cf. §numreponseci-dessus /HfrClient.resolveTopicPageUrl, #277) ; - les lignes de titre ou certains résultats mixtes n’ont pas ce second lien :
page,numreponseet extrait restentnull.
- le lien principal
- Le footer de
forum1.phpcontient 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.incet 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’unecat, et rebrande lesIOExceptionqui 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.phprejette unhash_checkvide — 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(=1uniquement en mode « occurrences correspondantes seules », absent sinon —filterabsent ≠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 decurrentnum; on l’omet pour une recherche fraîche.firstnum= ancre de page attendue par HFR sur une recherche fraîche ; on la passe avecdep=0. Pour un pas (résultat suivant),firstnumETdepsont omis : re-envoyerfirstnumré-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 »),firstnumest 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.wordetspseudone 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
numreponseattendu (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.
cryptlink — protection des URLs externes
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
srcde l’<img>produit par HFR qui pilote le rendu, pas une table embarquée client-side. Le parser posePostInline.Smiley(kind, imageUrl)directement à partir de l’alt/title(pour lekind) et dusrc(pourimageUrl) —:core:uiconsommeimageUrltel quel viaAsyncImage. Pas de reconstruction d’URL à partir du nom (les chemins perso peuvent contenir des sous-dossiers numérotésimages/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
.gifanimés (:bounce:,:pt1cable:, majorité des perso). Le décodeurcoil-gif(AnimatedImageDecoder.Factory, API 28+) est enregistré sur leSingletonImageLoadercôté:apppour autoplay sans configuration par-call-site. - Tailles différentes : les smileys ne sont pas tous 16×16. Le crawl exhaustif
wikismilies.phpré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 variantesW×50; les micro-smileys existent aussi (15×15701,19×19399,16×16206). Depuis #175 (Phase 2F, PR #222), RF2 fait du rendu INTRINSÈQUE (:core:uiPostMediaDisplayPolicy), plus de bucket fixe : la taille native du smiley est mesurée via Coil (ImageLoader.execute()+IntrinsicMediaSizeCache) et appliquée sans upscale — un sprite15×15reste15×15(comme le rendu web HFR, qui montre l’<img>à sa taille native sousimg { max-width: 90% }). Caps : absolu70 sphauteur /240 splargeur (SMILEY_MAX_HEIGHT_SP/SMILEY_MAX_WIDTH_SP,intrinsicSmileyDisplaySize) puis relatif0,9 × largeur du conteneur(SMILEY_RELATIVE_MAX_WIDTH_FRACTIONviaBoxWithConstraints, réplique lemax-width:90%de RF1). AlignementAboveBaseline+lineHeightlibérée (TextUnit.Unspecified) pour laisser la ligne croître au lieu de chevaucher (corrige le bug #129). Cold-fallback avant mesure : builtin16×16(builtinPreseedSize), perso70×50(persoColdFallbackSize). L’enfantAsyncImagesuit le placeholderspviaModifier.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 utilisentimageDisplayBoxavec no-upscale et caps dédiés, tandis que le bucket240×180ne 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]/fImagegardent le cap historique par défaut0,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 parLocalPostImageMaxWidthaux trois chemins de rendu : inline mesuré (ParagraphProse→imageDisplayBox), bloc mesuré (BlockImage→imageDisplaySizePx) et slot bloc froid (coldBlockSlotDp). Sur le chemin inline, la largeur mesurée réserve en plus le padding horizontal4 dpde chaque côté : en colonne étroite,99 %et100 %peuvent donc coïncider (ex.352 dpde bitmap dans une colonne360 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 dansIntrinsicMediaSizeCache, qui ne stocke que la taille native mesurée. - Extraire le code smiley (
:jap:,:bounce:) depuis l’attributalt/titlede 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 :
- l’URL finale après redirection pointe vers
/login.phpou/login_validation.php; - le HTML contient un formulaire login HFR (
login_validation.php, champpseudo, champpassword).
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=priveactuellement 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
pagen’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(PASforum2.php). C’est de là que le champnewdestest lu et réémis. newdest≠dest:destest le destinataire d’une nouvelle composition (compose) ;newdestest 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.phpcat=prive,numreppré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
newdestpré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.inc — le 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_formest 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é
refn’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.) numreponsereste vide : la citation ne détourne pas le champ de l’édition.- options en vraies cases à cocher (
signaturecochée par défaut) etMsgIcon=1pré-coché, comme tout formulairemessage.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 → POSTbdd.phpcat=priveducontent_formcomplet. C’est un remplacement intégral (last-write-wins, pas de verrou) ; les clés tierces durawEnvelopesurvivent 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 ; lalistDTCloud 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, sinonMpStorageWriteResult.SkippedNotPresentet 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 à 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ètrenumreponse=de ce href vaut0— 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.Topicd’untable.citation/table.oldcitation:write_ia_topic_page.html— sujet authentifié,…&cat=32&post=7&page=1&…&numreponse=0&…#t29892(page réelle danspage=, cible dans le fragment,numreponse=0) ; le fragment de HTML brut authentifié épinglé parPostContentParserTest(topic RF2 page 25) porte de même…&page=25&…&numreponse=0&…#t2785311.private_message_thread_with_quote.html— MP 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.html— permalien 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 :DELETEREST = 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 = 5par défaut reste sous le seuil. name/titlepeuvent contenir des entités HTML non décodées (&) — 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 viaceil(links.posts.count / N), oùNest la valeur du query paramresults_per_pageexposé parlinks.posts.hrefdu 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 utilisateurpostsPerPage(cf. § postsPerPage configurable) qui s’applique au rendu deforum2.php, pas à la liste de topics REST.last_position≠ page :last_positionest 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 danslinks.posts.href?page=Ndu même topic auth ; c’est cette valeur qu’il faut consommer pourTopicSummary.lastReadPage.-
flag_owntopic(auth uniquement) : drapeau le plus fort SUR le sujet, PAS le bucket d’appartenance (vérifié live 2026-06-11, #384, fixturerest_cat13_participated_favorites.json: le bucketparticipatedrenvoie des sujets participés-ET-favoris avecflag_owntopic=3, et le bucketreadrenvoieflag_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.typebucket REST demandé au fetch ( participated/read/favorites) — jamaisflag_owntopicroutage, filtres, clé du cache Room par type (#384) Flag.isFavoriteflag_owntopic == 3dé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 crashPour les listings de topics (
TopicSummary, hors listes de drapeaux), le mapping direct1→CYAN / 2→RED / 3→FAVORITE / sinon nullreste 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 dansTopicSummary. last_post_read_id≠ « premier non lu » :last_post_read_idest l’id du dernier post lu par l’utilisateur dans ce topic. Le legacy HTMLforum1f.phpexposait au contraire un#t{numreponse}pointant le premier post non lu. Redface 2 (Phase 1D-1) consommelast_post_read_idtel quel viaFlag.lastPostReadIdet 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.tns3filename 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.txtfrè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
- Redface v1 code
- Redface v1 fixtures
- Références écosystème HFR — inventaire complet : doc MesDiscussions (user / modo / admin / SDK) archivée sur Wayback Machine, clients tiers Android/iOS/autres, parsers
- Skill
/parse-fixturepour l’analyse d’une fixture - MCP
hfr-mcppour interagir avec forum.hardware.fr depuis les agents LLM