Every error page requested /assets/css/branding-<BRAND>.css and /assets/css/style.css, and both 404'd: fitme3070 has no assets/css/ directory at all — the backoffice styles are inlined in includes/header.php. Cosmetic on its own, but actively misleading. Those two 404s are the loudest thing in the console at precisely the moment someone opens the console to find out what broke, and they read like the cause rather than a side effect of the error page rendering. They sent this investigation after the wrong file twice. Now each link is emitted only if the file is actually on disk, so a console on an error page shows the real failure and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
165 lines
7.5 KiB
PHP
165 lines
7.5 KiB
PHP
<?php
|
|
// ─── Global error / exception handler ────────────────────────────────────────
|
|
//
|
|
// Registered early in config/config.php (after APP_ENV is defined).
|
|
// Prod: renders a clean branded page, hides all technical detail.
|
|
// Dev: renders the same page but expands a debug panel with full trace.
|
|
// All errors are written to logs/php_errors.jsonl (max 500 entries, rotated).
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
define('ERROR_LOG_FILE', __DIR__ . '/../logs/php_errors.jsonl');
|
|
define('ERROR_LOG_MAX', 500);
|
|
|
|
function _error_log_write(string $type, string $message, string $file = '', int $line = 0, string $trace = ''): void {
|
|
$userId = isset($_SESSION['user_id']) ? (int) $_SESSION['user_id'] : null;
|
|
$userName = isset($_SESSION['user']['name']) ? (string) $_SESSION['user']['name'] : null;
|
|
$url = (isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '') ;
|
|
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
|
|
|
$entry = json_encode([
|
|
'ts' => date('c'),
|
|
'type' => $type,
|
|
'message' => $message,
|
|
'file' => $file,
|
|
'line' => $line,
|
|
'trace' => $trace,
|
|
'user_id' => $userId,
|
|
'user_name' => $userName,
|
|
'url' => $url,
|
|
'ip' => $ip,
|
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
|
|
$logFile = ERROR_LOG_FILE;
|
|
|
|
// Append then rotate if over limit.
|
|
file_put_contents($logFile, $entry . "\n", FILE_APPEND | LOCK_EX);
|
|
|
|
// Trim to last ERROR_LOG_MAX lines when the file grows large.
|
|
if (filesize($logFile) > 256 * 1024) {
|
|
$lines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
|
if (count($lines) > ERROR_LOG_MAX) {
|
|
$lines = array_slice($lines, -ERROR_LOG_MAX);
|
|
file_put_contents($logFile, implode("\n", $lines) . "\n", LOCK_EX);
|
|
}
|
|
}
|
|
}
|
|
|
|
function _error_page(string $title, string $detail, string $trace = ''): void {
|
|
$isProd = defined('APP_ENV') && APP_ENV === 'prod';
|
|
$cssBase = (defined('APP_BASE_URL') ? rtrim(APP_BASE_URL, '/') : '');
|
|
|
|
http_response_code(500);
|
|
if (ob_get_level()) ob_end_clean();
|
|
?>
|
|
<!doctype html>
|
|
<html lang="pt">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Erro — <?= htmlspecialchars(defined('APP_NAME') ? APP_NAME : 'Plataforma') ?></title>
|
|
<?php
|
|
// Only link stylesheets that actually exist. fitme3070 has no assets/css/ at
|
|
// all — its backoffice styles are inlined in includes/header.php — so these
|
|
// two links were guaranteed 404s on every error page. Harmless in themselves,
|
|
// but they are the loudest thing in the console at exactly the moment someone
|
|
// is reading the console to find out what broke, and they sent this
|
|
// investigation after the wrong file twice.
|
|
$_eh_brand = (function_exists('env') ? env('BRAND', 'andmore') : 'andmore') ?: 'andmore';
|
|
$_eh_css = [
|
|
'/assets/css/branding-' . $_eh_brand . '.css',
|
|
'/assets/css/style.css',
|
|
];
|
|
foreach ($_eh_css as $_eh_href):
|
|
if ($cssBase === '' || !is_file(dirname(__DIR__) . $_eh_href)) continue;
|
|
?>
|
|
<link rel="stylesheet" href="<?= htmlspecialchars($cssBase . $_eh_href) ?>">
|
|
<?php endforeach; ?>
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
|
<style>
|
|
body { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 2rem; }
|
|
.err-card { max-width: 540px; width: 100%; text-align: center; }
|
|
.err-icon { font-size: 3rem; margin-bottom: 1rem; opacity: .6; }
|
|
.err-debug { text-align: left; font-size: .78rem; font-family: monospace;
|
|
white-space: pre-wrap; word-break: break-all;
|
|
max-height: 320px; overflow-y: auto;
|
|
background: rgba(0,0,0,.25); border-radius: 6px; padding: 1rem; margin-top: 1rem; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="err-card">
|
|
<div class="err-icon"><i class="bi bi-exclamation-triangle"></i></div>
|
|
<h2 class="mb-2">Algo correu mal</h2>
|
|
<?php if ($isProd): ?>
|
|
<p class="text-muted">Ocorreu um erro inesperado. Por favor tente novamente ou contacte o suporte.</p>
|
|
<?php else: ?>
|
|
<p class="text-muted"><?= htmlspecialchars($title) ?></p>
|
|
<?php if ($detail): ?>
|
|
<div class="err-debug"><?= htmlspecialchars($detail) ?></div>
|
|
<?php endif; ?>
|
|
<?php if ($trace): ?>
|
|
<div class="err-debug" style="margin-top:.5rem; opacity:.7;"><?= htmlspecialchars($trace) ?></div>
|
|
<?php endif; ?>
|
|
<?php endif; ?>
|
|
<div class="mt-4 d-flex gap-2 justify-content-center flex-wrap">
|
|
<button onclick="history.back()" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>Voltar</button>
|
|
<a href="javascript:location.reload()" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1"></i>Tentar novamente</a>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
<?php
|
|
exit(1);
|
|
}
|
|
|
|
// ── Uncaught exceptions ───────────────────────────────────────────────────────
|
|
set_exception_handler(function (Throwable $e): void {
|
|
_error_log_write(
|
|
get_class($e),
|
|
$e->getMessage(),
|
|
$e->getFile(),
|
|
$e->getLine(),
|
|
$e->getTraceAsString()
|
|
);
|
|
_error_page(
|
|
get_class($e) . ': ' . $e->getMessage(),
|
|
$e->getMessage() . "\n\nFicheiro: " . $e->getFile() . ':' . $e->getLine(),
|
|
$e->getTraceAsString()
|
|
);
|
|
});
|
|
|
|
// ── PHP errors ────────────────────────────────────────────────────────────────
|
|
set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline): bool {
|
|
if (!(error_reporting() & $errno)) return false;
|
|
|
|
$label = match(true) {
|
|
(bool)($errno & E_NOTICE) => 'Notice',
|
|
(bool)($errno & E_WARNING) => 'Warning',
|
|
(bool)($errno & E_DEPRECATED) => 'Deprecated',
|
|
(bool)($errno & E_USER_NOTICE) => 'User Notice',
|
|
(bool)($errno & E_USER_WARNING) => 'User Warning',
|
|
(bool)($errno & E_USER_DEPRECATED)=> 'User Deprecated',
|
|
default => "Error ($errno)",
|
|
};
|
|
|
|
_error_log_write($label, $errstr, $errfile, $errline);
|
|
|
|
// In prod, only show error page for fatal-level user errors.
|
|
if (defined('APP_ENV') && APP_ENV === 'prod') {
|
|
$fatal = E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR;
|
|
if (!($errno & $fatal)) return false;
|
|
}
|
|
|
|
_error_page("PHP $label: $errstr", "$errstr\n\nFicheiro: $errfile:$errline");
|
|
return true;
|
|
});
|
|
|
|
// ── Fatal errors (parse errors, out-of-memory, etc.) ─────────────────────────
|
|
register_shutdown_function(function (): void {
|
|
$e = error_get_last();
|
|
if (!$e) return;
|
|
$fatal = E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR;
|
|
if (!($e['type'] & $fatal)) return;
|
|
_error_log_write('Fatal', $e['message'], $e['file'], $e['line']);
|
|
_error_page("Fatal Error: {$e['message']}", "{$e['message']}\n\nFicheiro: {$e['file']}:{$e['line']}");
|
|
});
|