PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.0
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / Logging.php

Logging.php in 404 Solution 4.2.0, at includes/Logging.php

858 lines 37.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 /* Static functions that can be used from anywhere. */
9
10 class ABJ_404_Solution_Logging {
11
12 /** If an error happens then we will also output these.
13 * @var array<int, string>
14 */
15 private static $storedDebugMessages = array();
16
17 /** Used to store the last line sent from the debug file. */
18 const LAST_SENT_LINE = 'last_sent_line';
19
20 /** Used to store the the debug filename. */
21 const DEBUG_FILE_KEY = 'debug_file_key';
22
23 /** @var self|null */
24 private static $instance = null;
25
26 /** @var int Latest error-log line emailed during this PHP request. */
27 private static $lastSentErrorLineThisRequest = 0;
28 /** @var string Latest error signature emailed during this PHP request. */
29 private static $lastSentErrorSignatureThisRequest = '';
30 /** @var string Debug file path associated with the request-local dedupe state. */
31 private static $lastSentDebugFilePathThisRequest = '';
32
33 /**
34 * Factory for the DI container.
35 *
36 * This avoids recursion when the container's 'logging' service is defined in terms of getInstance().
37 *
38 * @return ABJ_404_Solution_Logging
39 */
40 public static function createForContainer() {
41 // Create a fresh instance without consulting the container.
42 $logger = new ABJ_404_Solution_Logging();
43
44 // Flush any pending errors captured before the logger existed.
45 if (isset($GLOBALS['abj404_pending_errors']) && is_array($GLOBALS['abj404_pending_errors'])) {
46 foreach ($GLOBALS['abj404_pending_errors'] as $message) {
47 $logger->errorMessage($message);
48 }
49 unset($GLOBALS['abj404_pending_errors']); // Clear after flushing
50 }
51
52 // Also sync singleton for legacy callers.
53 self::$instance = $logger;
54
55 return $logger;
56 }
57
58 /** @return self */
59 public static function getInstance() {
60 if (self::$instance !== null) {
61 return self::$instance;
62 }
63
64 // If the DI container is initialized, prefer it.
65 if (class_exists('ABJ_404_Solution_ServiceContainer')) {
66 $service = ABJ_404_Solution_ServiceContainer::safeGet('logging');
67 if ($service instanceof ABJ_404_Solution_Logging) {
68 self::$instance = $service;
69 return self::$instance;
70 }
71 }
72
73 self::$instance = new ABJ_404_Solution_Logging();
74
75 // log any errors that were stored before the logger existed.
76 if (isset($GLOBALS['abj404_pending_errors']) && is_array($GLOBALS['abj404_pending_errors'])) {
77 foreach ($GLOBALS['abj404_pending_errors'] as $message) {
78 self::$instance->errorMessage($message);
79 }
80 unset($GLOBALS['abj404_pending_errors']); // Clear after flushing
81 }
82
83 return self::$instance;
84 }
85
86 private function __construct() {
87 }
88
89 /** @return boolean true if debug mode is on. false otherwise. */
90 function isDebug() {
91 $abj404logic = abj_service('plugin_logic');
92 $options = $abj404logic->getOptions(true);
93
94 return (array_key_exists('debug_mode', $options) && $options['debug_mode'] == true);
95 }
96
97 /** for the current timezone.
98 * @return string */
99 function getTimestamp() {
100 $date = null;
101 $timezoneStringRaw = get_option('timezone_string');
102 $timezoneString = is_string($timezoneStringRaw) ? $timezoneStringRaw : '';
103
104 if (!empty($timezoneString)) {
105 $date = new DateTime("now", new DateTimeZone($timezoneString));
106 } else {
107 $gmtOffsetRaw = get_option('gmt_offset');
108 // WordPress's gmt_offset is hours and may be fractional
109 // (e.g. 5.5 India, 5.75 Nepal, -3.5 Newfoundland).
110 $gmtOffsetHours = is_scalar($gmtOffsetRaw) ? (float)$gmtOffsetRaw : 0.0;
111 $totalMinutes = (int) round($gmtOffsetHours * 60);
112 $sign = $totalMinutes < 0 ? '-' : '+';
113 $absMinutes = abs($totalMinutes);
114 $tzString = sprintf('%s%02d:%02d', $sign, intdiv($absMinutes, 60), $absMinutes % 60);
115
116 try {
117 $date = new DateTime("now", new DateTimeZone($tzString));
118 } catch (Exception $e) {
119 // Use error_log (not $this->warn) because this method is part
120 // of the logging path; calling warn here would risk recursion
121 // if the timezone failure also breaks warn's own DateTime use.
122 @error_log('404 Solution: timezone constructor failed (' . $e->getMessage() . '); using server default');
123 $date = new DateTime();
124 }
125 }
126
127 return $date->format('Y-m-d H:i:s T');
128 }
129
130 /** Send a message to the log file if debug mode is on.
131 * This goes to a file and is used by every other class so it goes here.
132 * @param string $message
133 * @param \Exception|null $e If present then a stack trace is included.
134 * @return void
135 */
136 function debugMessage(string $message, $e = null): void {
137 $stacktrace = "";
138 if ($e != null) {
139 $stacktrace = ", Stacktrace: " . $e->getTraceAsString();
140 }
141
142 $timestamp = $this->getTimestamp() . ' (DEBUG): ';
143 if ($this->isDebug()) {
144 $this->writeLineToDebugFile($timestamp . $message . $stacktrace);
145
146 } else {
147 array_push(self::$storedDebugMessages, $timestamp . $message . $stacktrace);
148 }
149 }
150
151 /** Send a message to the log.
152 * This goes to a file and is used by every other class so it goes here.
153 * @param string $message
154 * @return void
155 */
156 function infoMessage(string $message): void {
157 $timestamp = $this->getTimestamp() . ' (INFO): ';
158 $this->writeLineToDebugFile($timestamp . $message);
159 }
160
161 /** Send a message to the log.
162 * This goes to a file and is used by every other class so it goes here.
163 * @param string $message
164 * @return void
165 */
166 function warn(string $message): void {
167 $timestamp = $this->getTimestamp() . ' (WARN): ';
168 $this->writeLineToDebugFile($timestamp . $message);
169 }
170
171 /** Always send a message to the error_log.
172 * This goes to a file and is used by every other class so it goes here.
173 * @param string $message
174 * @param \Exception|null $e
175 * @return void
176 */
177 function errorMessage(string $message, $e = null): void {
178 if ($e == null) {
179 $e = new Exception;
180 }
181 $stacktrace = $e->getTraceAsString();
182
183 $savedDebugMessages = implode("\n", self::$storedDebugMessages);
184 self::$storedDebugMessages = array();
185
186 $timestamp = $this->getTimestamp() . ' (ERROR): ';
187 $referrer = '';
188 if (array_key_exists('HTTP_REFERER', $_SERVER) && !empty($_SERVER['HTTP_REFERER'])) {
189 $referrer = $_SERVER['HTTP_REFERER'];
190 }
191 $requestedURL = '';
192 if (array_key_exists('REQUEST_URI', $_SERVER) && !empty($_SERVER['REQUEST_URI'])) {
193 $requestedURL = $_SERVER['REQUEST_URI'];
194 }
195 $this->writeLineToDebugFile($timestamp . $message . ", PHP version: " . PHP_VERSION .
196 ", WP ver: " . get_bloginfo('version') . ", Plugin ver: " . ABJ404_VERSION .
197 ", Referrer: " . $referrer . ", Requested URL: " . $requestedURL .
198 ", \nStored debug messages: \n" . $savedDebugMessages . ", \nTrace: " . $stacktrace);
199 }
200
201 /** Log the user capabilities.
202 * @param string $msg
203 * @return void
204 */
205 function logUserCapabilities(string $msg): void {
206 $f = abj_service('functions');
207 $abj404logic = abj_service('plugin_logic');
208 $user = wp_get_current_user();
209 $usercaps = $f->str_replace(',"', ', "', wp_kses_post((string)json_encode($user->get_role_caps())));
210
211 $userIsPluginAdminStr = "false";
212 if ($abj404logic->userIsPluginAdmin()) {
213 $userIsPluginAdminStr = "true";
214 }
215
216 $this->debugMessage("User caps msg: " . esc_html($msg == '' ? '(none)' : $msg) . ", is_admin(): " . is_admin() .
217 ", current_user_can('manage_options'): " . current_user_can('manage_options') .
218 ", current_user_can('administrator'): " . current_user_can('administrator') .
219 ", userIsPluginAdmin(): " . $userIsPluginAdminStr .
220 ", user_login: " . esc_html($user->user_login ?? '(none)') .
221 ", user caps: " . wp_kses_post((string)json_encode($user->caps)) . ", get_role_caps: " .
222 $usercaps . ", WP ver: " . get_bloginfo('version') . ", mbstring: " .
223 (extension_loaded('mbstring') ? 'true' : 'false'));
224 }
225
226 /** Write the line to the debug file.
227 *
228 * Sanitizes PII at write-time for GDPR compliance (defense in depth).
229 * Fix for disk space error (reported by 1 user - 2% of errors)
230 * Handles file write failures gracefully to prevent error loops when disk is full.
231 * Uses error suppression and returns status instead of throwing exceptions.
232 *
233 * @param string $line
234 * @return bool True on success, false on failure
235 */
236 function writeLineToDebugFile($line) {
237 // Sanitize PII at write-time (GDPR compliance)
238 // This protects all 372 logging calls across the codebase
239 $sanitizedLine = $this->sanitizeLogLine($line);
240
241 // Suppress errors to prevent fatal error when disk is full
242 $result = @file_put_contents($this->getDebugFilePath(), $sanitizedLine . "\n", FILE_APPEND);
243
244 if ($result === false) {
245 // Disk full or permissions issue - log to error_log instead to avoid infinite loop
246 // Don't use errorMessage() here as it would call this function again
247 error_log('404 Solution: Unable to write to debug log (possibly disk full): ' .
248 $this->getDebugFilePath());
249 return false;
250 }
251
252 return true;
253 }
254
255 /** Email the log file to the plugin developer.
256 *
257 * Cron-context entry: builds a FeedbackTransport payload from the freshly-
258 * scanned latest-error line plus dedup state, and dispatches via
259 * FeedbackTransport::sendNow() (sync HTTP POST + email fallback). Returns
260 * true iff any transport (HTTP or email) succeeded; the dedup pointer is
261 * advanced before sending so a transport failure does not cause repeated
262 * sends of the same error line on the next cron tick.
263 *
264 * @return bool
265 */
266 function emailErrorLogIfNecessary(): bool {
267 $abj404dao = abj_service('data_access');
268 $abj404logic = abj_service('plugin_logic');
269 $options = $abj404logic->getOptions(true);
270
271 if (!file_exists($this->getDebugFilePath())) {
272 $this->debugMessage("No log file found so no errors were found.");
273 return false;
274 }
275
276 // get the number of the last line with an error message.
277 $latestErrorLineFound = $this->getLatestErrorLine();
278
279 // if no error was found then we're done.
280 if ($latestErrorLineFound['num'] == -1) {
281 $this->debugMessage("No errors found in the log file.");
282 return false;
283 }
284
285 // -------------------
286 // get/check the last line that was emailed to the admin.
287 $sentDateFile = $this->getDebugFilePathSentFile();
288 $debugFilePath = $this->getDebugFilePath();
289
290 $sentLine = -1;
291 if (file_exists($sentDateFile)) {
292 $sentLine = absint(
293 ABJ_404_Solution_Functions::readFileContents($sentDateFile, false));
294 $this->debugMessage("Last sent line from file: " . $sentLine);
295 }
296 if ($sentLine < 1 && array_key_exists(self::LAST_SENT_LINE, $options)) {
297 $sentLine = is_scalar($options[self::LAST_SENT_LINE]) ? (int)$options[self::LAST_SENT_LINE] : -1;
298 $this->debugMessage("Last sent line from options: " . $sentLine);
299 }
300
301 // if we already sent the error line then don't send the log file again.
302 if (self::$lastSentDebugFilePathThisRequest === $debugFilePath) {
303 $sentLine = max($sentLine, self::$lastSentErrorLineThisRequest);
304 }
305 $latestSignature = (string)($latestErrorLineFound['line'] ?? '');
306 if ($latestErrorLineFound['num'] <= $sentLine
307 || (self::$lastSentDebugFilePathThisRequest === $debugFilePath
308 && $latestSignature !== '' && $latestSignature === self::$lastSentErrorSignatureThisRequest)) {
309 $this->debugMessage("The latest error line from the log file was already emailed. " . $latestErrorLineFound['num'] .
310 ' <= ' . $sentLine);
311 return false;
312 }
313
314 // only email the error file if the latest version of the plugin is installed.
315 if (!$abj404dao->shouldEmailErrorFile()) {
316 return false;
317 }
318
319 // update the latest error line emailed to the developer.
320 $options[self::LAST_SENT_LINE] = $latestErrorLineFound['num'];
321 self::$lastSentErrorLineThisRequest = (int)$latestErrorLineFound['num'];
322 self::$lastSentErrorSignatureThisRequest = $latestSignature;
323 self::$lastSentDebugFilePathThisRequest = $debugFilePath;
324 $abj404logic->updateOptions($options);
325 file_put_contents($sentDateFile, $latestErrorLineFound['num']);
326 $fileContents = file_get_contents($sentDateFile);
327 if ($fileContents != $latestErrorLineFound['num']) {
328 $this->errorMessage("There was an issue writing to the file " . $sentDateFile);
329 return false;
330 }
331
332 $payload = ABJ_404_Solution_FeedbackTransport::buildPayload('error', array(
333 'error_signature' => (string)($latestErrorLineFound['line'] ?? ''),
334 'previously_sent_line' => (int)$sentLine,
335 'error_count_in_log' => (int)$latestErrorLineFound['total_error_count'],
336 ));
337 return ABJ_404_Solution_FeedbackTransport::sendNow($payload, 'error');
338 }
339
340 /**
341 * Roll a 1-in-N dice and send a full debug zip as a heartbeat if it hits.
342 * Called during daily maintenance for opted-in sites when no error email was sent.
343 *
344 * Dispatches via FeedbackTransport::sendNow() (HTTP POST + email fallback)
345 * with type='heartbeat' so the same payload shape is shared with the error
346 * path.
347 *
348 * @param int $oneInN Probability denominator (default 200 = ~once per 6 months).
349 * @return bool True if a heartbeat was sent.
350 */
351 function sendHeartbeatIfDueRandom(int $oneInN = 200): bool {
352 if (!file_exists($this->getDebugFilePath())) {
353 return false;
354 }
355 if (mt_rand(1, $oneInN) !== 1) {
356 return false;
357 }
358 $this->debugMessage("Heartbeat dice roll hit (1-in-{$oneInN}). Sending heartbeat log.");
359 $errorInfo = $this->getLatestErrorLine();
360
361 $payload = ABJ_404_Solution_FeedbackTransport::buildPayload('heartbeat', array(
362 'error_signature' => 'Heartbeat: no errors to report.',
363 'previously_sent_line' => 0,
364 'error_count_in_log' => (int)$errorInfo['total_error_count'],
365 ));
366 ABJ_404_Solution_FeedbackTransport::sendNow($payload, 'heartbeat');
367 return true;
368 }
369
370 /**
371 * Email-fallback for FeedbackTransport when the HTTP POST of an error or
372 * heartbeat report fails. Builds an HTML email body purely from the
373 * FeedbackTransport payload (single source of truth shared with the HTTP
374 * path) and attaches a zip of the current debug log file(s).
375 *
376 * Public because FeedbackTransport::sendNow() invokes it via the service
377 * container for type='error' and type='heartbeat'.
378 *
379 * @param array<string, mixed> $payload FeedbackTransport-built payload.
380 * @return bool True if wp_mail() reported success, false otherwise.
381 */
382 function emailLogFileToDeveloper(array $payload): bool {
383 $isHeartbeat = (isset($payload['report_type']) && $payload['report_type'] === 'heartbeat');
384 $errorLineMessage = isset($payload['error_signature']) && is_scalar($payload['error_signature'])
385 ? (string)$payload['error_signature'] : '';
386 $totalErrorCount = isset($payload['error_count_in_log']) && is_scalar($payload['error_count_in_log'])
387 ? (int)$payload['error_count_in_log'] : 0;
388 $previouslySentLine = isset($payload['previously_sent_line']) && is_scalar($payload['previously_sent_line'])
389 ? (int)$payload['previously_sent_line'] : 0;
390
391 $this->debugMessage("Creating zip file of error log file. " .
392 "Previously sent error line: " . $previouslySentLine);
393 $logFileZip = $this->getZipFilePath();
394 if (file_exists($logFileZip)) {
395 ABJ_404_Solution_Functions::safeUnlink($logFileZip);
396 }
397 $zip = new ZipArchive;
398 if ($zip->open($logFileZip, ZipArchive::CREATE) === true) {
399 if (file_exists($this->getDebugFilePath())) {
400 $zip->addFile($this->getDebugFilePath(), basename($this->getDebugFilePath()));
401 }
402 if (file_exists($this->getDebugFilePathOld())) {
403 $zip->addFile($this->getDebugFilePathOld(), basename($this->getDebugFilePathOld()));
404 }
405 $zip->close();
406 }
407
408 $logTableSizeMB = round((int)($payload['log_table_size_bytes'] ?? 0) / (1024 * 1024), 2);
409 $debugFileSizeMB = round((int)($payload['debug_file_size_bytes'] ?? 0) / (1024 * 1024), 2);
410
411 $to = ABJ404_AUTHOR_EMAIL;
412 $subject = ABJ404_PP . ($isHeartbeat ? ' heartbeat' : ' error') . ' log file. Plugin version: ' . ABJ404_VERSION;
413 $extensions = isset($payload['extensions']) && is_array($payload['extensions']) ? $payload['extensions'] : array();
414 $activePlugins = isset($payload['active_plugins']) && is_array($payload['active_plugins']) ? $payload['active_plugins'] : array();
415 $isMultisite = !empty($payload['is_multisite']);
416
417 $bodyLines = array();
418 $bodyLines[] = $subject . ". Sent " . date('Y/m/d h:i:s T');
419 $bodyLines[] = " ";
420 $bodyLines[] = "Error: " . $errorLineMessage;
421 $bodyLines[] = " ";
422 $bodyLines[] = "PHP version: " . (string)($payload['php_version'] ?? PHP_VERSION);
423 $bodyLines[] = "WordPress version: " . (string)($payload['wp_version'] ?? '');
424 $bodyLines[] = "Plugin version: " . (string)($payload['plugin_version'] ?? ABJ404_VERSION);
425 $bodyLines[] = "MySQL version: " . (string)($payload['db_version'] ?? '');
426 $bodyLines[] = "Site URL: " . (string)($payload['site_url'] ?? '');
427 $bodyLines[] = "Multisite: " . ($isMultisite ? 'yes' : 'no');
428 if ($isMultisite && function_exists('is_plugin_active_for_network')) {
429 $bodyLines[] = "Network activated: " . (is_plugin_active_for_network(plugin_basename(ABJ404_FILE)) ? 'yes' : 'no');
430 }
431 $bodyLines[] = "WP_MEMORY_LIMIT: " . (defined('WP_MEMORY_LIMIT') ? WP_MEMORY_LIMIT : '');
432 $bodyLines[] = "Extensions: " . implode(", ", $extensions);
433 $bodyLines[] = " ";
434 $bodyLines[] = "--- WordPress Content Counts ---";
435 $bodyLines[] = "Published posts: " . (string)($payload['published_posts_count'] ?? '0');
436 $bodyLines[] = "Published pages: " . (string)($payload['published_pages_count'] ?? '0');
437 $bodyLines[] = "Categories: " . (string)($payload['categories_count'] ?? '0');
438 $bodyLines[] = "Tags: " . (string)($payload['tags_count'] ?? '0');
439 $bodyLines[] = " ";
440 $bodyLines[] = "--- 404 Solution Counts ---";
441 $bodyLines[] = "Total redirects (active): " . (string)($payload['redirects_active_total'] ?? '0');
442 $bodyLines[] = " - Manual redirects: " . (string)($payload['redirects_manual_count'] ?? '0');
443 $bodyLines[] = " - Automatic redirects: " . (string)($payload['redirects_automatic_count'] ?? '0');
444 $bodyLines[] = " - Regex redirects: " . (string)($payload['redirects_regex_count'] ?? '0');
445 $bodyLines[] = " - Trashed redirects: " . (string)($payload['redirects_trashed_count'] ?? '0');
446 $bodyLines[] = "Captured 404s (active): " . (string)($payload['captured_404s_active_total'] ?? '0');
447 $bodyLines[] = " - Captured (new): " . (string)($payload['captured_404s_new_count'] ?? '0');
448 $bodyLines[] = " - Ignored: " . (string)($payload['captured_404s_ignored_count'] ?? '0');
449 $bodyLines[] = " - Later: " . (string)($payload['captured_404s_later_count'] ?? '0');
450 $bodyLines[] = " - Trashed: " . (string)($payload['captured_404s_trashed_count'] ?? '0');
451 $bodyLines[] = "Log entries in database: " . (string)($payload['log_entries_count'] ?? '0');
452 $bodyLines[] = "Log table size: " . $logTableSizeMB . " MB";
453 $bodyLines[] = " ";
454 $bodyLines[] = "Total error count in log file: " . $totalErrorCount;
455 $bodyLines[] = "Debug file name: " . $this->getDebugFilename();
456 $bodyLines[] = "Debug file size: " . $debugFileSizeMB . " MB";
457 $bodyLines[] = "Active plugins: <pre>" .
458 json_encode($activePlugins, JSON_PRETTY_PRINT) . "</pre>";
459
460 $body = implode("<BR/>\n", $bodyLines);
461
462 $headers = array('Content-Type: text/html; charset=UTF-8');
463 $headers[] = 'From: ' . get_option('admin_email');
464
465 $attachments = array();
466 if (file_exists($logFileZip)) {
467 $attachments[] = $logFileZip;
468 }
469
470 $this->debugMessage("Sending error log zip file as attachment.");
471 $result = wp_mail($to, $subject, $body, $headers, $attachments);
472
473 if (file_exists($logFileZip)) {
474 ABJ_404_Solution_Functions::safeUnlink($logFileZip);
475 }
476 $this->debugMessage("Mail sent. Log zip file deleted.");
477 return (bool)$result;
478 }
479
480 /**
481 * @return array{num: int, line: string|null, total_error_count: int}
482 */
483 function getLatestErrorLine(): array {
484 $f = abj_service('functions');
485 $latestErrorLineFound = array();
486 $latestErrorLineFound['num'] = -1;
487 $latestErrorLineFound['line'] = null;
488 $latestErrorLineFound['total_error_count'] = 0;
489 $linesRead = 0;
490 $handle = null;
491 $collectingErrorLines = false;
492 try {
493 $debugPath = $this->getDebugFilePath();
494 // Check existence before fopen so PHP does not emit a warning on a
495 // missing debug file. The file is absent on fresh installs and in
496 // most test fixtures. Return the empty initialized array (no error
497 // line) in that case rather than letting fopen warn and return
498 // false. failOnWarning=true in phpunit.xml means an unguarded
499 // warning here trips the whole preflight gate.
500 if (!is_string($debugPath) || $debugPath === '' || !file_exists($debugPath)) {
501 return $latestErrorLineFound;
502 }
503 if ($handle = fopen($debugPath, "r")) {
504 // read the file one line at a time.
505 while (($line = fgets($handle)) !== false) {
506 $linesRead++;
507 // if the line has an error then save the line number.
508 $hasError = stripos($line, '(ERROR)');
509 $isDeleteError = stripos($line, 'SQL query error: DELETE command denied to user');
510 if ($hasError !== false && $isDeleteError === false) {
511 $latestErrorLineFound['num'] = $linesRead;
512 $latestErrorLineFound['line'] = $line;
513 $latestErrorLineFound['total_error_count'] += 1;
514 $collectingErrorLines = true;
515
516 } else if ($collectingErrorLines &&
517 !$f->regexMatch("^\d{4}[-]\d{2}[-]\d{2} .*\(\w+\):\s.*$", $line)) {
518 // if we're collecting error lines and we haven't found the
519 // beginning of a new debug message then continue collecting lines.
520 $latestErrorLineFound['line'] .= "<BR/>\n" . $line;
521
522 } else {
523 // this must be the beginning of a new debug message so we'll stop
524 // collecting error lines.
525 $collectingErrorLines = false;
526 }
527 }
528 } else {
529 $this->errorMessage("Error reading log file (1).");
530 }
531
532 } catch (Exception $e) {
533 $this->errorMessage("Error reading log file. (2)", $e);
534 }
535
536 if ($handle != null) {
537 fclose($handle);
538 }
539
540 return $latestErrorLineFound;
541 }
542
543 /**
544 * Get sanitized log excerpt for support emails.
545 * Collects last 15 ERROR/WARN entries (already sanitized at write-time)
546 * plus the last 20 lines for recent context (admin actions, AJAX calls).
547 * If no errors/warnings found, includes only the last 20 lines.
548 *
549 * @return string Sanitized log excerpt or message if no errors found
550 */
551 function getSanitizedLogExcerptForSupport() {
552 $f = abj_service('functions');
553 $errorEntries = array();
554 $recentLines = array();
555 $maxEntries = 15;
556 $maxRecentLines = 20;
557 $totalLines = 0;
558 $handle = null;
559
560 try {
561 $debugFilePath = $this->getDebugFilePath();
562
563 if (!file_exists($debugFilePath)) {
564 return "No log file available";
565 }
566
567 if ($handle = fopen($debugFilePath, "r")) {
568 $currentEntry = array();
569 $collectingEntry = false;
570
571 // Read file line by line
572 while (($line = fgets($handle)) !== false) {
573 $totalLines++;
574
575 // Keep a sliding window of recent lines (for fallback if no errors)
576 $recentLines[] = $line;
577 if (count($recentLines) > $maxRecentLines) {
578 array_shift($recentLines);
579 }
580
581 // Check if this is an ERROR or WARN line
582 $hasError = stripos($line, '(ERROR)') !== false;
583 $hasWarn = stripos($line, '(WARN)') !== false;
584 $isDeleteError = stripos($line, 'SQL query error: DELETE command denied to user') !== false;
585
586 // Start collecting if we find ERROR or WARN (but skip known benign errors)
587 if (($hasError || $hasWarn) && !$isDeleteError) {
588 // If we were collecting a previous entry, save it
589 if ($collectingEntry && !empty($currentEntry)) {
590 $errorEntries[] = $currentEntry;
591 // Keep only last N entries (sliding window)
592 if (count($errorEntries) > $maxEntries) {
593 array_shift($errorEntries);
594 }
595 }
596
597 // Start new entry (no sanitization needed - already done at write-time)
598 $currentEntry = array($line);
599 $collectingEntry = true;
600
601 } else if ($collectingEntry &&
602 !$f->regexMatch("^\d{4}[-]\d{2}[-]\d{2} .*\(\w+\):\s.*$", $line)) {
603 // Continue collecting multiline error (no sanitization needed - already done at write-time)
604 $currentEntry[] = $line;
605
606 } else {
607 // New log entry started, save previous if exists
608 if ($collectingEntry && !empty($currentEntry)) {
609 $errorEntries[] = $currentEntry;
610 if (count($errorEntries) > $maxEntries) {
611 array_shift($errorEntries);
612 }
613 }
614 $collectingEntry = false;
615 $currentEntry = array();
616 }
617 }
618
619 // Save last entry if we were still collecting
620 if ($collectingEntry && !empty($currentEntry)) {
621 $errorEntries[] = $currentEntry;
622 if (count($errorEntries) > $maxEntries) {
623 array_shift($errorEntries);
624 }
625 }
626
627 fclose($handle);
628
629 } else {
630 return "Log file not readable";
631 }
632
633 } catch (Exception $e) { // allow-silent-catch: log excerpt for support bundle; "Error reading log file" is itself diagnostic and gets embedded in the bundle output
634 return "Error reading log file";
635 }
636
637 // Format output
638 if (empty($errorEntries)) {
639 // No errors/warnings found - include last N lines for context
640 if (empty($recentLines)) {
641 return "Log file is empty";
642 }
643 $output = "No ERROR/WARN entries found. Last " . count($recentLines) . " log lines:\n\n";
644 $output .= implode("", $recentLines);
645 return trim($output);
646 }
647
648 $output = "Last " . count($errorEntries) . " ERROR/WARN entries:\n\n";
649 foreach ($errorEntries as $entry) {
650 $output .= implode("\n", $entry) . "\n\n";
651 }
652
653 if (!empty($recentLines)) {
654 $output .= "Recent context (last " . count($recentLines) . " lines):\n\n";
655 $output .= implode("", $recentLines);
656 }
657
658 return trim($output);
659 }
660
661 /**
662 * Sanitize a single log line for privacy (GDPR compliance).
663 * Delegates to PiiRedactor for all pattern matching and masking.
664 *
665 * @param string $line Log line to sanitize
666 * @return string Sanitized line with PII masked adaptively
667 */
668 public function sanitizeLogLine($line) {
669 try {
670 /** @var ABJ_404_Solution_PiiRedactor $redactor */
671 $redactor = abj_service('pii_redactor');
672 } catch (\Exception $e) {
673 // allow-silent-catch: early boot before container is initialized; fall through to raw line
674 return $line;
675 }
676 return $redactor->redact($line);
677 }
678
679 /** Return the path to the debug file.
680 * @return string
681 */
682 function getDebugFilePath() {
683 $debugFileName = $this->getDebugFilename();
684 return $this->getFilePathAndMoveOldFile(abj404_getUploadsDir(), $debugFileName);
685 }
686
687 /** @return string */
688 function getDebugFilename(): string {
689 // The is_object() / method_exists() guards below catch the static
690 // unreachability cases (container miss, factory returned null), but
691 // they cannot catch a Throwable raised from inside the resolved call
692 // — getOptions() may surface a DB read failure, uniqidReal() may
693 // raise on a corrupt random source, updateOptions() may fail to
694 // persist. writeLineToDebugFile() promises non-throwing; any escape
695 // from this method violates that contract. Absorb every Throwable
696 // and return the deterministic fallback name so logging stays
697 // available even when upstream services are degraded.
698 try {
699 // get the UUID here.
700 $abj404logic = abj_service('plugin_logic');
701 // abj_service returns null when the container is uninitialised
702 // or the factory threw — common during very-early boot, the
703 // test harness, and self-healing recovery from broken installs.
704 if (!is_object($abj404logic) || !method_exists($abj404logic, 'getOptions')) {
705 return 'abj404_debug.txt';
706 }
707 $options = $abj404logic->getOptions(true);
708 $debugFileKey = null;
709 if (is_array($options) && array_key_exists(self::DEBUG_FILE_KEY, $options)) {
710 $debugFileKey = is_string($options[self::DEBUG_FILE_KEY]) ? $options[self::DEBUG_FILE_KEY] : null;
711 }
712 // if the key doesn't exist then create it.
713 if ($debugFileKey === null || trim($debugFileKey) === '') {
714 // delete any lingering debug files.
715 $this->deleteDebugFile();
716
717 // create a probably unique UUID and store it to the database.
718 $syncUtils = abj_service('sync_utils');
719 if (!is_object($syncUtils) || !method_exists($syncUtils, 'uniqidReal')) {
720 return 'abj404_debug.txt';
721 }
722 $debugFileKey = $syncUtils->uniqidReal();
723 $options[self::DEBUG_FILE_KEY] = $debugFileKey;
724 if (method_exists($abj404logic, 'updateOptions')) {
725 $abj404logic->updateOptions($options);
726 }
727 }
728
729 return 'abj404_debug_' . $debugFileKey . '.txt';
730 } catch (\Throwable $e) { // allow-silent-catch: debug filename derivation; fallback to default name still produces a valid path for log writes
731 return 'abj404_debug.txt';
732 }
733 }
734
735 /** @return string */
736 function getDebugFilePathOld(): string {
737 return $this->getDebugFilePath() . "_old.txt";
738 }
739
740 /** Return the path to the file that stores the latest error line in the log file.
741 * @return string
742 */
743 function getDebugFilePathSentFile() {
744 return $this->getFilePathAndMoveOldFile(abj404_getUploadsDir(), 'abj404_debug_sent_line.txt');
745 }
746
747 /** Return the path to the zip file for sending the debug file.
748 * @return string
749 */
750 function getZipFilePath() {
751 return $this->getFilePathAndMoveOldFile(abj404_getUploadsDir(), 'abj404_debug.zip');
752 }
753
754 /** This is for legacy support. On new installations it creates a directory and returns
755 * a file path. On old installations it moved the old file to the new location.
756 * If the directory can't be created then it falls back to the old location.
757 * @param string $directory
758 * @param string $filename
759 * @return string
760 */
761 function getFilePathAndMoveOldFile($directory, $filename) {
762 $f = abj_service('functions');
763 // create the directory and move the file
764 if (!$f->createDirectoryWithErrorMessages($directory)) {
765 return ABJ404_PATH . $filename;
766 }
767
768 if (file_exists(ABJ404_PATH . $filename)) {
769 // move the file to the new location
770 rename(ABJ404_PATH . $filename, $directory . $filename);
771 }
772
773 return $directory . $filename;
774 }
775
776 /** @return void */
777 function limitDebugFileSize(): void {
778 // delete the sent_line file since it's now incorrect.
779 if (file_exists($this->getDebugFilePathSentFile())) {
780 ABJ_404_Solution_Functions::safeUnlink($this->getDebugFilePathSentFile());
781 }
782
783 // update the last sent error line since the debug file will be deleted.
784 $this->removeLastSentErrorLineFromDatabase();
785
786 // delete _old log file
787 ABJ_404_Solution_Functions::safeUnlink($this->getDebugFilePathOld());
788 // rename current log file to _old
789 rename($this->getDebugFilePath(), $this->getDebugFilePathOld());
790 }
791
792 /** @return void */
793 function removeLastSentErrorLineFromDatabase(): void {
794 // update the last sent error line since the debug file will be deleted.
795 $abj404logic = abj_service('plugin_logic');
796 $options = $abj404logic->getOptions(true);
797 $options[self::LAST_SENT_LINE] = 0;
798 $abj404logic->updateOptions($options);
799 }
800
801 /** Deletes all files named abj404_debug_*.txt
802 * @return boolean true if the file was deleted.
803 */
804 function deleteDebugFile() {
805 $abj404logic = abj_service('plugin_logic');
806 $allIsWell = true;
807
808 // since the debug file is being deleted we reset the last error line that was sent.
809 if (file_exists($this->getDebugFilePathSentFile())) {
810 ABJ_404_Solution_Functions::safeUnlink($this->getDebugFilePathSentFile());
811 }
812 // update the last sent error line since the debug file will be deleted.
813 $this->removeLastSentErrorLineFromDatabase();
814
815 // delete the debug file(s).
816 // list any files in the directory and delete any files named debug_*.txt
817 $uploadDir = abj404_getUploadsDir();
818 // Check if the directory exists
819 if (is_dir($uploadDir)) {
820 // Get all files matching the pattern abj404_debug_*.txt
821 $files = glob($uploadDir . '/abj404_debug_*.txt');
822 if (!is_array($files)) { $files = array(); }
823 foreach ($files as $file) { // Loop through the files and delete them
824 if (is_file($file)) {
825 // Delete the file
826 if (!ABJ_404_Solution_Functions::safeUnlink($file)) {
827 $allIsWell = false;
828 }
829 }
830 }
831 }
832
833 // reset the UUID since we deleted the log file.
834 $options = $abj404logic->getOptions(true);
835 $options[self::DEBUG_FILE_KEY] = null;
836 $abj404logic->updateOptions($options);
837
838 return $allIsWell;
839 }
840
841 /**
842 * @return int file size in bytes
843 */
844 function getDebugFileSize() {
845 $file1Size = 0;
846 $file2Size = 0;
847 if (file_exists($this->getDebugFilePath())) {
848 $file1Size = filesize($this->getDebugFilePath());
849 }
850 if (file_exists($this->getDebugFilePathOld())) {
851 $file2Size = filesize($this->getDebugFilePathOld());
852 }
853
854 return $file1Size + $file2Size;
855 }
856
857 }
858