diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87d072d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/.idea +/vendor +/composer.lock \ No newline at end of file diff --git a/README.md b/README.md index 646300b..78319e0 100644 --- a/README.md +++ b/README.md @@ -6,45 +6,23 @@ Sends more info about the error than [Tracy\Logger](https://github.com/nette/tra Installation ------------ -```` +````bash composer require adt/error-logger ```` -Place this to your bootstrap.php after calling `$configurator->enableDebugger()` and before calling `$configurator->createContainer()`: -```` -$logger = \ADT\ErrorLogger::install($email = 'errors@example.com', $maxEmailsPerDay = 10, $maxEmailsPerRequest = 10); -```` -and this after calling `$configurator->createContainer()`: -``` -if ($logger) { - $logger->setup($container); -} -``` - -**Sensitive fields:** - -You can specify keys of array, which will be hidden in POST dump. +Place this to your bootstrap.php after calling `$configurator->enableDebugger()`: -Example: - -```` +````php $logger = \ADT\ErrorLogger::install( - 'errors@example.com', - 10, - 10, - $sensitiveFields = [ - 'password', - ] + $email = 'errors@example.com', + $maxEmailsPerDay = 100, + $maxEmailsPerRequest = 10, + $includeExceptionFile = true, + $errorMessageSanitizeRegex = '~\d|(/[^\s]*)|(\w+://)~', // removes all numbers, absolut paths and protocols + $emailSnooze = '1 day' ); +if (!\Tracy\Debugger::$productionMode) { + // Do not send emails + $logger->mailer = null; +} ```` - -POST dump: - -``` -POST:array (4) - username => "my_username" (11) - password => "*****" (5) - login => "Sign in" (7) - _do => "signForm-submit" (15) -``` - diff --git a/composer.json b/composer.json index 17e1686..61dbee9 100644 --- a/composer.json +++ b/composer.json @@ -12,8 +12,7 @@ ], "require": { "php": ">=7.4", - "tracy/tracy": ">=2.6.0", - "nette/di": "^2.4|^3.0" + "tracy/tracy": ">=2.6.0" }, "suggest": { "adt/tracy-git": "Useful for displaying information about currently deployed application version." diff --git a/src/ErrorLogger.php b/src/ErrorLogger.php index 1d6640c..6b8cfc4 100644 --- a/src/ErrorLogger.php +++ b/src/ErrorLogger.php @@ -3,211 +3,173 @@ namespace ADT; use DateTime; -use Exception; -use Nette\DI\Container; use RuntimeException; use Tracy\Debugger; -use Tracy\Dumper; use Tracy\Helpers; use Tracy\Logger; -class ErrorLogger extends Logger +final class ErrorLogger extends Logger { /** - * Maximální počet odeslaných emailů denně + * Maximum number of emails per day * @var int */ - protected int $maxEmailsPerDay; + public int $maxEmailsPerDay; /** - * Maximální počet odeslaných emailů v rámci jednoho requestu + * Maximum number of emails per request * @var int */ - protected int $maxEmailsPerRequest; + public int $maxEmailsPerRequest; /** - * Počet odeslaných emailů v rámci aktuálního requestu - * @var int + * Regular expression which removes matches before checking if email was already sent + * @var string */ - protected int $sentEmailsPerRequest = 0; + public string $errorMessageSanitizeRegex; /** - * Pole s citlivými údaji, jejižch hodnoty se nemají zobrazovat ve výpisu. - * @var array + * Include exception file as an attachment? */ - protected array $sensitiveFields = []; + public bool $includeExceptionFile; /** - * Ma se vlozit error message do emailu + * Number of emails in current request + * @var int */ - protected bool $includeErrorMessage = true; - - protected ?Container $container = null; - - public static function install($email, $maxEmailsPerDay = NULL, $maxEmailsPerRequest = NULL, $sensitiveFields = [], $includeErrorMessage = true): ?Logger + private int $sentEmailsPerRequest = 0; + + public static function install( + $email, + $maxEmailsPerDay = 100, + $maxEmailsPerRequest = 10, + $includeExceptionFile = true, + $errorMessageSanitizeRegex = '~\d|(/[^\s]*)|(\w+://)~', // removes all numbers, absolut paths and protocols + $emailSnooze = '1 day' + ): ?self { - if (!Debugger::$productionMode) { - return null; - } - Debugger::$email = $email; - $logger = new static(Debugger::$logDirectory, Debugger::$email, Debugger::getBlueScreen()); + $logger = new self(Debugger::$logDirectory, Debugger::$email, Debugger::getBlueScreen()); - $logger->maxEmailsPerDay = $maxEmailsPerDay ?: 10; - $logger->maxEmailsPerRequest = $maxEmailsPerRequest ?: 10; - $logger->sensitiveFields = $sensitiveFields; - $logger->includeErrorMessage = $includeErrorMessage; + $logger->maxEmailsPerDay = $maxEmailsPerDay; + $logger->maxEmailsPerRequest = $maxEmailsPerRequest; + $logger->includeExceptionFile = $includeExceptionFile; + $logger->errorMessageSanitizeRegex = $errorMessageSanitizeRegex; + $logger->emailSnooze = $emailSnooze; Debugger::setLogger($logger); return $logger; } - public function setup(Container $container) - { - $this->container = $container; - } - protected function sendEmail($message): void { if ( - $this->email + !$this->email + || + !$this->mailer + ) { + return; + } + + if ($this->sentEmailsPerRequest >= $this->maxEmailsPerRequest) { + // Limit per request exceeded + return; + } + + $exceptionFile = $message instanceof \Throwable + ? $this->getExceptionFile($message) + : null; + $line = self::formatLogLine($message, $exceptionFile); + + $messageHash = md5($this->sanitizeString($message)); + + // ERROR SNOOZE + + $errorSnoozeLog = $this->directory . '/error-snooze.log'; + + $snooze = is_numeric($this->emailSnooze) + ? $this->emailSnooze + : strtotime($this->emailSnooze) - time(); + + // Delete email-sent file from yesterday + if (($filemtime = @filemtime($errorSnoozeLog)) && ($filemtime + $snooze < time())) { + @unlink($errorSnoozeLog); + } + + if ( + ($logContent = @file_get_contents($errorSnoozeLog)) && - $this->mailer + (strstr($logContent, $messageHash) !== false) ) { - $exceptionFile = $this->getExceptionFile($message); - if (!file_exists($exceptionFile)) { - $exceptionFile = null; - } - $line = static::formatLogLine($message, $exceptionFile); - $logFile = $this->directory . '/email-sent'; - - // we delete email-sent file from yesterday - if (date('Y-m-d', @filemtime($logFile)) < (new DateTime('midnight'))->format('Y-m-d')) { - @unlink($logFile); - } - - $messageHash = md5(preg_replace('~(Resource id #)\d+~', '$1', $message)); - - if ( - // ještě se vejdeme do limitu v rámci aktuálního requestu - $this->sentEmailsPerRequest < $this->maxEmailsPerRequest - && - // tento hash jsme ještě neposlali - ( - !($logContent = @file_get_contents($logFile)) - || - (strstr($logContent, $messageHash) === false) - ) - && - // ještě se vejdeme do limitu v rámci aktuálního dne - substr_count($logContent, date('Y-m-d')) < $this->maxEmailsPerDay - ) { - if (!@file_put_contents($logFile, $line . ' ' . $messageHash . PHP_EOL, FILE_APPEND | LOCK_EX)) { - throw new RuntimeException("Unable to write to log file '" . $logFile . "'. Is directory writable?"); - } - - // sestavíme zprávu - if (is_array($message)) { - $stringMessage = implode(' ', $message); - } else { - $stringMessage = $message; - } - - $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); - if (count($backtrace) > 3) { //pokud jsou 3 tak jde pouze o exception a je ulozena nette chybova stranka - $backtraceString = ""; - - for ($i = 0; $i < count($backtrace); $i++) { - $backtraceData = $backtrace[$i] + [ - 'file' => '_unknown_', - 'line' => '_unknown_', - 'function' => '_unknown_', - ]; - - $backtraceString = "#$i {$backtraceData['file']}({$backtraceData['line']}): " - . (isset($backtraceData['class']) ? $backtraceData['class'] . '::' : '') - . "{$backtraceData['function']}()\n"; - } - - $stringMessage .= "\n\n" . $backtraceString; - } - - - // přidáme doplnující info - referer, browser... - $stringMessage .= "\n\n" . - (isset($_SERVER['HTTP_HOST']) ? 'LINK:' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'] . "\n" : '') . - 'SERVER:' . Dumper::toText($_SERVER) . "\n\n" . - 'GET:' . Dumper::toText($_GET, [Dumper::DEPTH => 10]) . "\n\n" . - 'POST:' . Dumper::toText($this->hideSensitiveFieldValue($_POST), [Dumper::DEPTH => 10]); - - if ($this->container && ($securityUser = $this->container->getByType('\Nette\Security\User', FALSE))) { - // obalujeme do try protoze SecurityUser je zavisly na databazi a pokud je chyba v db, tak nam error nedojde - try { - $stringMessage .= "\n\n" . - 'securityUser:' . Dumper::toText($securityUser->identity, [Dumper::DEPTH => 1]); - } catch (Exception $e) {} - } - - if ($this->container && ($git = $this->container->getByType('\ADT\TracyGit\Git', FALSE)) !== NULL && ($gitInfo = $git->getInfo())) { - $stringMessage .= "\n\n"; - - foreach ($gitInfo as $key => $value) { - $stringMessage .= $key . ": " . $value . "\n"; - } - } - - // odešleme chybu emailem - call_user_func($this->mailer, $stringMessage, implode(', ', (array)$this->email), $exceptionFile); - - $this->sentEmailsPerRequest++; - } + // Duplicate error + return; + } + + self::writeToLogFile($errorSnoozeLog, $line . ' ' . $messageHash); + + // MAX EMAILS PER DAY + + $maxEmailsPerDayLog = $this->directory . '/max-emails-per-day.log'; + + // delete file from yesterday + if (($filemtime = @filemtime($maxEmailsPerDayLog)) && date('Y-m-d', $filemtime) < (new DateTime('midnight'))->format('Y-m-d')) { + @unlink($maxEmailsPerDayLog); + } elseif (($lines = @file($maxEmailsPerDayLog)) && count($lines) >= $this->maxEmailsPerDay) { + // Limit per day exceeded + return; } + + self::writeToLogFile($maxEmailsPerDayLog, $line . ' ' . $messageHash); + + // SEND EMAIL + + call_user_func($this->mailer, $message, implode(', ', (array)$this->email), $exceptionFile); + + $this->sentEmailsPerRequest++; } /** * @internal */ - public function defaultMailer($message, string $email, ?string $attachment = null): void + public function defaultMailer($message, string $email, ?string $exceptionFile = null): void { $host = preg_replace('#[^\w.-]+#', '', $_SERVER['HTTP_HOST'] ?? php_uname('n')); $separator = md5(time()); $eol = "\n"; - $body = ''; - if ($this->includeErrorMessage) { - $body = - "--" . $separator . $eol . - - // Text email - "Content-Type: text/plain; charset=\"UTF-8\"" . $eol . - "Content-Transfer-Encoding: 8bit" . $eol . $eol . - $this->formatMessage($message) . "\n\nsource: " . Helpers::getSource() . $eol . - "--" . $separator . $eol; - - if ($attachment) { - $body .= - // Attachment - "Content-Type: application/octet-stream; name=\"" . basename($attachment) . "\"" . $eol . - "Content-Transfer-Encoding: base64" . $eol . - "Content-Disposition: attachment" . $eol . $eol . - chunk_split(base64_encode(file_get_contents($attachment))) . $eol . - "--" . $separator . "--"; - } + $body = + "--" . $separator . $eol . + + // Text email + "Content-Type: text/plain; charset=\"UTF-8\"" . $eol . + "Content-Transfer-Encoding: 8bit" . $eol . $eol . + $this->formatMessage($message) . "\n\nsource: " . Helpers::getSource() . $eol . + "--" . $separator . $eol; + + if ($exceptionFile && $this->includeExceptionFile) { + $body .= + // Attachment + "Content-Type: application/octet-stream; name=\"" . basename($exceptionFile) . "\"" . $eol . + "Content-Transfer-Encoding: base64" . $eol . + "Content-Disposition: attachment" . $eol . $eol . + chunk_split(base64_encode(file_get_contents($exceptionFile))) . $eol . + "--" . $separator . "--"; } $parts = str_replace( ["\r\n", "\n"], ["\n", PHP_EOL], [ - 'headers' => implode("\n", [ + 'headers' => implode("\n", array_filter([ + ($this->fromEmail ? 'From: ' . $this->fromEmail : ''), 'X-Mailer: Tracy', 'MIME-Version: 1.0', 'Content-Type: multipart/mixed; boundary="' . $separator . '"', 'Content-Transfer-Encoding: 7bit', - ]) . "\n", + ])) . "\n", 'subject' => "PHP: An error occurred on the server $host", 'body' => $body ] @@ -216,17 +178,15 @@ public function defaultMailer($message, string $email, ?string $attachment = nul mail($email, $parts['subject'], $parts['body'], $parts['headers']); } - protected function hideSensitiveFieldValue($array): array + private function sanitizeString($string): string { - $sensitiveFields = $this->sensitiveFields; - $replacement = '*****'; - - array_walk_recursive($array, function (&$value, $key) use ($sensitiveFields, $replacement) { - if (in_array($key, $sensitiveFields, TRUE)) { - $value = $replacement; - } - }); + return preg_replace($this->errorMessageSanitizeRegex, '', $string); + } - return $array; + private static function writeToLogFile($filename, $data) + { + if (!@file_put_contents($filename, $data . PHP_EOL, FILE_APPEND | LOCK_EX)) { + throw new RuntimeException("Unable to write to log file '" . $filename . "'. Is directory writable?"); + } } }