query('PRAGMA integrity_check')->fetchColumn(); $checks[] = self::check( $integrity === 'ok' ? 'ok' : 'error', 'database', t('health.sqlite_integrity'), $integrity === 'ok' ? t('health.sqlite_ok') : $integrity, ); } catch (Throwable $e) { $checks[] = self::check('error', 'database', t('health.sqlite_integrity'), $e->getMessage()); } try { $fk = $db->query('PRAGMA foreign_key_check')->fetchAll(PDO::FETCH_ASSOC); $checks[] = self::check( !$fk ? 'ok' : 'error', 'database', t('health.foreign_keys'), !$fk ? t('health.no_fk_error') : t('health.fk_errors', ['count' => count($fk)]), ); } catch (Throwable $e) { $checks[] = self::check('error', 'database', t('health.foreign_keys'), $e->getMessage()); } $migrationFiles = array_map('basename', glob($root . '/migrations/*.sql') ?: []); $applied = $db->query('SELECT name FROM _migrations')->fetchAll(PDO::FETCH_COLUMN); $pending = array_values(array_diff($migrationFiles, $applied)); $checks[] = self::check( !$pending ? 'ok' : 'warning', 'database', t('health.migrations'), !$pending ? t('health.migrations_ok', ['count' => count($applied)]) : t('health.migrations_pending', ['count' => count($pending)]), ); $dbFile = $root . '/data/refuge.sqlite'; $checks[] = self::check( is_file($dbFile) && is_readable($dbFile) ? 'ok' : 'error', 'storage', t('health.database_file'), is_file($dbFile) ? self::bytes((int) filesize($dbFile)) : t('health.file_missing'), ); $free = @disk_free_space($root); $status = $free === false ? 'warning' : ($free >= 1_073_741_824 ? 'ok' : ($free >= 268_435_456 ? 'warning' : 'error')); $checks[] = self::check( $status, 'storage', t('health.disk_space'), $free === false ? t('health.unknown') : self::bytes((int) $free), ); $backups = BackupService::list(); if (!$backups) { $checks[] = self::check('error', 'continuity', t('health.last_backup'), t('health.no_backup')); } else { $latest = $backups[0]; $date = DateTimeImmutable::createFromFormat( 'd/m/Y H:i:s', (string) $latest['date'], new DateTimeZone('Europe/Paris'), ); $days = $date ? (int) $date->diff(new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')))->format('%a') : 999; $checks[] = self::check( $days <= 7 ? 'ok' : ($days <= 30 ? 'warning' : 'error'), 'continuity', t('health.last_backup'), t('health.backup_age', ['date' => $latest['date'], 'days' => $days]), ); } [$missing, $orphans] = self::photoConsistency($db, $root); $checks[] = self::check( $missing === 0 ? 'ok' : 'warning', 'files', t('health.missing_photos'), t('health.file_count', ['count' => $missing]), ); $checks[] = self::check( $orphans === 0 ? 'ok' : 'warning', 'files', t('health.orphan_photos'), t('health.file_count', ['count' => $orphans]), ); $display = (string) ini_get('display_errors'); $production = getenv('APP_ENV') !== 'development'; $checks[] = self::check( !$production || !in_array(strtolower($display), ['1', 'on', 'yes', 'true'], true) ? 'ok' : 'warning', 'security', t('health.display_errors'), $production ? t('health.production_value', ['value' => $display ?: 'Off']) : t('health.development_mode'), ); $logErrors = filter_var(ini_get('log_errors'), FILTER_VALIDATE_BOOL); $errorLog = trim((string) ini_get('error_log')); $checks[] = self::check( $logErrors ? 'ok' : 'warning', 'security', t('health.error_log'), $logErrors ? ($errorLog ?: t('health.system_log')) : t('health.logging_disabled'), ); $applicationLog = $root . '/storage/logs/php-errors.log'; $recent = self::recentLogLines($applicationLog, 7); $checks[] = self::check( $recent === 0 ? 'ok' : 'warning', 'security', t('health.recent_errors'), $recent ? t('health.recent_error_count', ['count' => $recent]) : t('health.no_recent_error'), ); $summary = ['ok' => 0, 'warning' => 0, 'error' => 0]; foreach ($checks as $c) { $summary[$c['status']]++; } return [ 'checks' => $checks, 'summary' => $summary, 'generated_at' => new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'))->format('d/m/Y H:i:s'), 'php' => PHP_VERSION, 'sqlite' => (string) $db->query('SELECT sqlite_version()')->fetchColumn(), ]; } private static function photoConsistency(PDO $db, string $root): array { $expected = []; $missing = 0; try { foreach ($db->query('SELECT animal_id,filename FROM animal_photos')->fetchAll(PDO::FETCH_ASSOC) as $row) { $path = $root . '/public/media/animals/' . (int) $row['animal_id'] . '/' . basename((string) $row['filename']); $expected[$path] = true; if (!is_file($path)) { $missing++; } } } catch (Throwable) { return [0, 0]; } $orphans = 0; foreach (glob($root . '/public/media/animals/*/*') ?: [] as $file) { if (is_file($file) && !isset($expected[$file]) && !str_starts_with(basename($file), '.')) { $orphans++; } } return [$missing, $orphans]; } private static function check(string $status, string $group, string $title, string $detail): array { return compact('status', 'group', 'title', 'detail'); } private static function recentLogLines(string $path, int $days): int { if (!is_file($path) || filesize($path) === 0) { return 0; } $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; $dates = []; for ($i = 0; $i < $days; $i++) { $dates[] = new DateTimeImmutable("-$i days")->format('d-M-Y'); } $count = 0; foreach (array_slice($lines, -1000) as $line) { foreach ($dates as $date) { if (str_contains($line, $date)) { $count++; break; } } } return $count; } private static function bytes(int $bytes): string { foreach (['o', 'Ko', 'Mo', 'Go', 'To'] as $unit) { if ($bytes < 1024 || $unit === 'To') { return number_format($bytes, $unit === 'o' ? 0 : 1, ',', ' ') . ' ' . $unit; } $bytes = (int) round($bytes / 1024); } return ''; } }