$options Voir enregistrer(). */ public static function connexion(string $action, array $options = []): void { self::enregistrer(self::CONNEXION, $action, $options); } /** * Trace une modification en base. * * @param string $action Code stable, ex. « PLANNING_VALIDE ». * @param string $cibleTable Table principale touchée. * @param int|null $cibleId Identifiant de la ligne visée. * @param string $description Phrase lisible dans la page DSI. * @param array $contexte Détail structuré (semaine, lieu…). */ public static function ecriture( string $action, string $cibleTable, ?int $cibleId, string $description, array $contexte = [] ): void { self::enregistrer(self::ECRITURE, $action, [ 'cible_table' => $cibleTable, 'cible_id' => $cibleId, 'description' => $description, 'contexte' => $contexte, ]); } /** * Trace un refus d'accès. Le résultat vaut REFUS, jamais SUCCES. * * @param array $options Voir enregistrer(). */ public static function refus(string $action, string $description, array $options = []): void { self::enregistrer(self::ACCES_REFUSE, $action, array_merge($options, [ 'description' => $description, 'resultat' => 'REFUS', ])); } /** * Trace une action d'administration technique (comptes, jetons, journal). * * @param array $options Voir enregistrer(). */ public static function administration(string $action, array $options = []): void { self::enregistrer(self::ADMINISTRATION, $action, $options); } /** * Écrit une ligne de journal. Ne lève jamais d'exception. * * L'acteur est déduit de la session, sauf si l'appelant le fournit : c'est * indispensable à la connexion, où la trace est écrite AVANT que la session * porte le compte (ou pour un jeton refusé, qui n'ouvre aucune session). * * @param string $categorie Une des constantes de classe. * @param string $action Code stable de l'action. * @param array $options resultat, cible_table, cible_id, * description, contexte, id_compte, * role, acteur. */ public static function enregistrer(string $categorie, string $action, array $options = []): void { if (self::$pdo === null) { return; } try { $session = Access::sessionSnapshot(); $contexte = $options['contexte'] ?? []; $stmt = self::$pdo->prepare( 'INSERT INTO journal_activite (categorie, action, resultat, id_compte, role, acteur, cible_table, cible_id, description, contexte, adresse_ip, agent_utilisateur) VALUES (:categorie, :action, :resultat, :id_compte, :role, :acteur, :cible_table, :cible_id, :description, :contexte, :adresse_ip, :agent_utilisateur)' ); $stmt->execute([ 'categorie' => $categorie, 'action' => mb_substr($action, 0, 60), 'resultat' => $options['resultat'] ?? 'SUCCES', 'id_compte' => $options['id_compte'] ?? $session['id_compte'], 'role' => $options['role'] ?? $session['role'], 'acteur' => self::tronquer($options['acteur'] ?? $session['acteur'], 180), 'cible_table' => self::tronquer($options['cible_table'] ?? null, 64), 'cible_id' => $options['cible_id'] ?? null, 'description' => self::tronquer($options['description'] ?? null, 500), // JSON_INVALID_UTF8_SUBSTITUTE : une donnée mal encodée ne doit // pas faire échouer l'encodage, donc la trace tout entière. 'contexte' => $contexte === [] ? null : json_encode($contexte, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE), // REMOTE_ADDR et non X-Forwarded-For : cet en-tête est fourni par // le client et falsifiable. Derrière un reverse proxy, c'est au // proxy de réécrire REMOTE_ADDR. 'adresse_ip' => self::tronquer($_SERVER['REMOTE_ADDR'] ?? null, 45), 'agent_utilisateur' => self::tronquer($_SERVER['HTTP_USER_AGENT'] ?? null, 255), ]); } catch (Throwable $e) { // Règle 1 : le journal ne casse jamais l'action journalisée. error_log(sprintf('[PTA][journal] écriture impossible (%s) : %s', $action, $e->getMessage())); } } /** Tronque à la longueur de la colonne, en préservant les caractères accentués. */ private static function tronquer(?string $valeur, int $longueur): ?string { if ($valeur === null) { return null; } $valeur = trim($valeur); return $valeur === '' ? null : mb_substr($valeur, 0, $longueur); } }