Files
xamxam/app/src/ErrorHandler.php
T
Pontoporeia ae66c2baad Integrate Monolog: replace four logging systems with single PSR-3 factory
- Add monolog/monolog dependency (^3.10)  
- Create app/Logger.php central factory with channels: app, admin, error, audit
- Each channel gets RotatingFileHandler (30-day retention) with pass-through LineFormatter
  preserving existing JSON format contracts
- Rewrite AppLogger as thin facade delegating to Logger::get('app')
- Rewrite ErrorHandler::log() to delegate to Logger::get('error')
- Rewrite AdminLogger file output to delegate to Logger::get('admin'), keep DB writes
- Add Monolog file shadow to Audit via Logger::get('audit') (Option A per monolog-plan)
- Log level controlled by LOG_LEVEL env var (defaults: DEBUG in cli-server, WARNING otherwise)
- Graceful NullHandler fallback when log directory is not writable
- Update SystemController LOG_FILES: remove php_error, add app/admin/error/audit
- JSON app logs parsed to readable one-liners in the log viewer
- Remove nginx config tab (parametres + fragment + template + css)
- Friendly empty-state message when app log files don't exist yet (notYet)
- PHP tail fallback when exec() unavailable
- All 228 PHPUnit tests pass, no call sites changed
2026-05-20 12:28:31 +02:00

184 lines
7.7 KiB
PHP

<?php
/**
* ErrorHandler — shared error-normalisation and logging utilities.
*
* Provides:
* - userMessage($e) — turns raw exceptions into user-friendly French messages
* - log($context, $e, array $extra) — structured error_log with trace
*
* Usage from any action/controller:
* ErrorHandler::log('thesis_edit', $e, ['thesis_id' => $id]);
* App::flash('error', ErrorHandler::userMessage($e));
*/
class ErrorHandler
{
/**
* Map SQLite FK constraint errors to human-readable field names.
*
* SQLite FK errors reference the *child* table (where the FK column lives),
* e.g. "INSERT INTO theses" when orientation_id FK fails.
*
* We map child table → the form field(s) whose values populate its FK columns.
*/
private const FK_TABLE_MAP = [
// Main thesis table — FK columns: orientation_id, ap_program_id,
// finality_id, access_type_id, license_id
'theses' => 'Orientation, AP, Finalité, Type d\'accès ou Licence',
// Junction tables — each maps to one specific field
'thesis_authors' => 'Auteur·ice',
'thesis_supervisors' => 'Composition du jury',
'thesis_languages' => 'Langue(s)',
'thesis_formats' => 'Format(s)',
'thesis_tags' => 'Mots-clés',
'thesis_files' => 'Fichiers',
// Admin / system tables
'share_links' => 'Lien étudiant·e',
'file_access_requests' => "Demande d'accès",
'form_help_blocks' => "Bloc d'aide",
'site_settings' => 'Paramètres',
'admin_log' => 'Journal admin',
// Parent tables (when referenced directly in "table" pattern)
'orientations' => 'Orientation',
'ap_programs' => 'AP',
'finality_types' => 'Finalité',
'access_types' => "Type d'accès",
'license_types' => 'Licence',
'authors' => 'Auteur·ice',
'supervisors' => 'Composition du jury',
'languages' => 'Langue(s)',
'format_types' => 'Format(s)',
'tags' => 'Mots-clés',
];
/**
* Map an exception to a user-facing message.
*
* Domain exceptions (validation, duplicates) carry their own message;
* system exceptions (PDO, generic) get sanitised explanations with
* specific field identification where possible.
*/
public static function userMessage(\Throwable $e): string
{
// ── Domain exceptions with meaningful messages ──────────────────────
if ($e instanceof DuplicateThesisException) {
return $e->getMessage(); // Already user-friendly
}
// ── Database errors ─────────────────────────────────────────────────
if ($e instanceof \PDOException) {
return self::pdoMessage($e);
}
// ── Validation errors (Exception, RuntimeException) ──────────────
// These are thrown with user-friendly French messages — pass through.
if ($e instanceof \Exception) {
return $e->getMessage();
}
// ── Everything else — generic fallback ──────────────────────────────
return 'Une erreur inattendue est survenue.'
. ' Veuillez réessayer ou contacter l\'équipe.';
}
/**
* Build a user-facing message from a PDOException.
*
* For FK constraint failures, the SQLite error message includes the
* table name (e.g. "FOREIGN KEY constraint failed INSERT INTO thesis_formats").
* We extract it and map to a field label.
*/
private static function pdoMessage(\PDOException $e): string
{
$msg = $e->getMessage();
if (str_contains($msg, 'FOREIGN KEY constraint failed')) {
$field = self::extractFkField($msg);
if ($field !== null) {
return "Erreur de base de données : la référence pour « {$field} » est invalide."
. ' Vérifiez que la valeur existe dans la base de données.';
}
return 'Erreur de base de données : une contrainte de référence est invalide.'
. ' Vérifiez les champs Orientation, AP, Finalité, Langues, Formats.';
}
// ── UNIQUE constraint ───────────────────────────────────────────────
if (str_contains($msg, 'UNIQUE constraint failed')) {
return 'Erreur de base de données : une valeur en double a été détectée.'
. ' Veuillez réessayer ou contacter l\'équipe.';
}
// ── NOT NULL ────────────────────────────────────────────────────────
if (str_contains($msg, 'NOT NULL constraint failed')) {
return 'Erreur de base de données : un champ obligatoire est manquant.'
. ' Veuillez réessayer ou contacter l\'équipe.';
}
// ── Generic SQL error — don't leak raw SQL to users ────────────────
return 'Une erreur de base de données est survenue.'
. ' Veuillez réessayer ou contacter l\'équipe.';
}
/**
* Try to extract the referenced table name from a SQLite FK error message.
*
* SQLite FK errors typically contain the table name in the message body:
* "FOREIGN KEY constraint failed INSERT INTO thesis_formats (thesis_id, format_id) VALUES (?, ?)"
*
* Also handles quoted table names in newer SQLite messages:
* "FOREIGN KEY constraint failed (table \"orientations\")"
*/
private static function extractFkField(string $msg): ?string
{
// Pattern 1: "table \"tablename\"" (SQLite 3.37+)
if (preg_match('/table\s+"([^"]+)"/i', $msg, $m)) {
$table = strtolower($m[1]);
return self::FK_TABLE_MAP[$table] ?? null;
}
// Pattern 2: "INSERT INTO tablename" or "UPDATE tablename"
if (preg_match('/(?:INSERT\s+INTO|UPDATE)\s+"?(\w+)"?/i', $msg, $m)) {
$table = strtolower($m[1]);
return self::FK_TABLE_MAP[$table] ?? null;
}
// Pattern 3: "REFERENCES tablename" — the constraint itself
if (preg_match('/REFERENCES\s+"?(\w+)"?/i', $msg, $m)) {
$table = strtolower($m[1]);
return self::FK_TABLE_MAP[$table] ?? null;
}
return null;
}
/**
* Write a structured error log entry — delegates to Monolog 'error' channel.
*
* @param string $context e.g. 'thesis_edit', 'partage_submit', 'csv_import'
* @param \Throwable $e
* @param array $extra arbitrary key-value context to include in the log
*/
public static function log(string $context, \Throwable $e, array $extra = []): void
{
$entry = [
'timestamp' => date('c'),
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
'context' => $context,
'exception' => get_class($e),
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
];
if (!empty($extra)) {
$entry['extra'] = $extra;
}
$line = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
Logger::get('error')->error($line);
}
}