PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
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.1.19, at includes/Logging.php

1,157 lines 48.7 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 /**
27 * Factory for the DI container.
28 *
29 * This avoids recursion when the container's 'logging' service is defined in terms of getInstance().
30 *
31 * @return ABJ_404_Solution_Logging
32 */
33 public static function createForContainer() {
34 // Create a fresh instance without consulting the container.
35 $logger = new ABJ_404_Solution_Logging();
36
37 // Flush any pending errors captured before the logger existed.
38 if (isset($GLOBALS['abj404_pending_errors']) && is_array($GLOBALS['abj404_pending_errors'])) {
39 foreach ($GLOBALS['abj404_pending_errors'] as $message) {
40 $logger->errorMessage($message);
41 }
42 unset($GLOBALS['abj404_pending_errors']); // Clear after flushing
43 }
44
45 // Also sync singleton for legacy callers.
46 self::$instance = $logger;
47
48 return $logger;
49 }
50
51 /** @return self */
52 public static function getInstance() {
53 if (self::$instance !== null) {
54 return self::$instance;
55 }
56
57 // If the DI container is initialized, prefer it.
58 if (class_exists('ABJ_404_Solution_ServiceContainer')) {
59 $service = ABJ_404_Solution_ServiceContainer::safeGet('logging');
60 if ($service instanceof ABJ_404_Solution_Logging) {
61 self::$instance = $service;
62 return self::$instance;
63 }
64 }
65
66 self::$instance = new ABJ_404_Solution_Logging();
67
68 // log any errors that were stored before the logger existed.
69 if (isset($GLOBALS['abj404_pending_errors']) && is_array($GLOBALS['abj404_pending_errors'])) {
70 foreach ($GLOBALS['abj404_pending_errors'] as $message) {
71 self::$instance->errorMessage($message);
72 }
73 unset($GLOBALS['abj404_pending_errors']); // Clear after flushing
74 }
75
76 return self::$instance;
77 }
78
79 private function __construct() {
80 }
81
82 /** @return boolean true if debug mode is on. false otherwise. */
83 function isDebug() {
84 $abj404logic = abj_service('plugin_logic');
85 $options = $abj404logic->getOptions(true);
86
87 return (array_key_exists('debug_mode', $options) && $options['debug_mode'] == true);
88 }
89
90 /** for the current timezone.
91 * @return string */
92 function getTimestamp() {
93 $date = null;
94 $timezoneStringRaw = get_option('timezone_string');
95 $timezoneString = is_string($timezoneStringRaw) ? $timezoneStringRaw : '';
96
97 if (!empty($timezoneString)) {
98 $date = new DateTime("now", new DateTimeZone($timezoneString));
99 } else {
100 $gmtOffsetRaw = get_option('gmt_offset');
101 // WordPress's gmt_offset is hours and may be fractional
102 // (e.g. 5.5 India, 5.75 Nepal, -3.5 Newfoundland).
103 $gmtOffsetHours = is_scalar($gmtOffsetRaw) ? (float)$gmtOffsetRaw : 0.0;
104 $totalMinutes = (int) round($gmtOffsetHours * 60);
105 $sign = $totalMinutes < 0 ? '-' : '+';
106 $absMinutes = abs($totalMinutes);
107 $tzString = sprintf('%s%02d:%02d', $sign, intdiv($absMinutes, 60), $absMinutes % 60);
108
109 try {
110 $date = new DateTime("now", new DateTimeZone($tzString));
111 } catch (Exception $e) {
112 // Use error_log (not $this->warn) because this method is part
113 // of the logging path; calling warn here would risk recursion
114 // if the timezone failure also breaks warn's own DateTime use.
115 @error_log('404 Solution: timezone constructor failed (' . $e->getMessage() . '); using server default');
116 $date = new DateTime();
117 }
118 }
119
120 return $date->format('Y-m-d H:i:s T');
121 }
122
123 /** Send a message to the log file if debug mode is on.
124 * This goes to a file and is used by every other class so it goes here.
125 * @param string $message
126 * @param \Exception|null $e If present then a stack trace is included.
127 * @return void
128 */
129 function debugMessage(string $message, $e = null): void {
130 $stacktrace = "";
131 if ($e != null) {
132 $stacktrace = ", Stacktrace: " . $e->getTraceAsString();
133 }
134
135 $timestamp = $this->getTimestamp() . ' (DEBUG): ';
136 if ($this->isDebug()) {
137 $this->writeLineToDebugFile($timestamp . $message . $stacktrace);
138
139 } else {
140 array_push(self::$storedDebugMessages, $timestamp . $message . $stacktrace);
141 }
142 }
143
144 /** Send a message to the log.
145 * This goes to a file and is used by every other class so it goes here.
146 * @param string $message
147 * @return void
148 */
149 function infoMessage(string $message): void {
150 $timestamp = $this->getTimestamp() . ' (INFO): ';
151 $this->writeLineToDebugFile($timestamp . $message);
152 }
153
154 /** Send a message to the log.
155 * This goes to a file and is used by every other class so it goes here.
156 * @param string $message
157 * @return void
158 */
159 function warn(string $message): void {
160 $timestamp = $this->getTimestamp() . ' (WARN): ';
161 $this->writeLineToDebugFile($timestamp . $message);
162 }
163
164 /** Always send a message to the error_log.
165 * This goes to a file and is used by every other class so it goes here.
166 * @param string $message
167 * @param \Exception|null $e
168 * @return void
169 */
170 function errorMessage(string $message, $e = null): void {
171 if ($e == null) {
172 $e = new Exception;
173 }
174 $stacktrace = $e->getTraceAsString();
175
176 $savedDebugMessages = implode("\n", self::$storedDebugMessages);
177 self::$storedDebugMessages = array();
178
179 $timestamp = $this->getTimestamp() . ' (ERROR): ';
180 $referrer = '';
181 if (array_key_exists('HTTP_REFERER', $_SERVER) && !empty($_SERVER['HTTP_REFERER'])) {
182 $referrer = $_SERVER['HTTP_REFERER'];
183 }
184 $requestedURL = '';
185 if (array_key_exists('REQUEST_URI', $_SERVER) && !empty($_SERVER['REQUEST_URI'])) {
186 $requestedURL = $_SERVER['REQUEST_URI'];
187 }
188 $this->writeLineToDebugFile($timestamp . $message . ", PHP version: " . PHP_VERSION .
189 ", WP ver: " . get_bloginfo('version') . ", Plugin ver: " . ABJ404_VERSION .
190 ", Referrer: " . $referrer . ", Requested URL: " . $requestedURL .
191 ", \nStored debug messages: \n" . $savedDebugMessages . ", \nTrace: " . $stacktrace);
192 }
193
194 /** Log the user capabilities.
195 * @param string $msg
196 * @return void
197 */
198 function logUserCapabilities(string $msg): void {
199 $f = abj_service('functions');
200 $abj404logic = abj_service('plugin_logic');
201 $user = wp_get_current_user();
202 $usercaps = $f->str_replace(',"', ', "', wp_kses_post((string)json_encode($user->get_role_caps())));
203
204 $userIsPluginAdminStr = "false";
205 if ($abj404logic->userIsPluginAdmin()) {
206 $userIsPluginAdminStr = "true";
207 }
208
209 $this->debugMessage("User caps msg: " . esc_html($msg == '' ? '(none)' : $msg) . ", is_admin(): " . is_admin() .
210 ", current_user_can('manage_options'): " . current_user_can('manage_options') .
211 ", current_user_can('administrator'): " . current_user_can('administrator') .
212 ", userIsPluginAdmin(): " . $userIsPluginAdminStr .
213 ", user_login: " . esc_html($user->user_login ?? '(none)') .
214 ", user caps: " . wp_kses_post((string)json_encode($user->caps)) . ", get_role_caps: " .
215 $usercaps . ", WP ver: " . get_bloginfo('version') . ", mbstring: " .
216 (extension_loaded('mbstring') ? 'true' : 'false'));
217 }
218
219 /** Write the line to the debug file.
220 *
221 * Sanitizes PII at write-time for GDPR compliance (defense in depth).
222 * Fix for disk space error (reported by 1 user - 2% of errors)
223 * Handles file write failures gracefully to prevent error loops when disk is full.
224 * Uses error suppression and returns status instead of throwing exceptions.
225 *
226 * @param string $line
227 * @return bool True on success, false on failure
228 */
229 function writeLineToDebugFile($line) {
230 // Sanitize PII at write-time (GDPR compliance)
231 // This protects all 372 logging calls across the codebase
232 $sanitizedLine = $this->sanitizeLogLine($line);
233
234 // Suppress errors to prevent fatal error when disk is full
235 $result = @file_put_contents($this->getDebugFilePath(), $sanitizedLine . "\n", FILE_APPEND);
236
237 if ($result === false) {
238 // Disk full or permissions issue - log to error_log instead to avoid infinite loop
239 // Don't use errorMessage() here as it would call this function again
240 error_log('404 Solution: Unable to write to debug log (possibly disk full): ' .
241 $this->getDebugFilePath());
242 return false;
243 }
244
245 return true;
246 }
247
248 /** Email the log file to the plugin developer.
249 *
250 * Cron-context entry: builds a FeedbackTransport payload from the freshly-
251 * scanned latest-error line plus dedup state, and dispatches via
252 * FeedbackTransport::sendNow() (sync HTTP POST + email fallback). Returns
253 * true iff any transport (HTTP or email) succeeded; the dedup pointer is
254 * advanced before sending so a transport failure does not cause repeated
255 * sends of the same error line on the next cron tick.
256 *
257 * @return bool
258 */
259 function emailErrorLogIfNecessary(): bool {
260 $abj404dao = abj_service('data_access');
261 $abj404logic = abj_service('plugin_logic');
262 $options = $abj404logic->getOptions(true);
263
264 if (!file_exists($this->getDebugFilePath())) {
265 $this->debugMessage("No log file found so no errors were found.");
266 return false;
267 }
268
269 // get the number of the last line with an error message.
270 $latestErrorLineFound = $this->getLatestErrorLine();
271
272 // if no error was found then we're done.
273 if ($latestErrorLineFound['num'] == -1) {
274 $this->debugMessage("No errors found in the log file.");
275 return false;
276 }
277
278 // -------------------
279 // get/check the last line that was emailed to the admin.
280 $sentDateFile = $this->getDebugFilePathSentFile();
281
282 $sentLine = -1;
283 if (file_exists($sentDateFile)) {
284 $sentLine = absint(
285 ABJ_404_Solution_Functions::readFileContents($sentDateFile, false));
286 $this->debugMessage("Last sent line from file: " . $sentLine);
287 }
288 if ($sentLine < 1 && array_key_exists(self::LAST_SENT_LINE, $options)) {
289 $sentLine = is_scalar($options[self::LAST_SENT_LINE]) ? (int)$options[self::LAST_SENT_LINE] : -1;
290 $this->debugMessage("Last sent line from options: " . $sentLine);
291 }
292
293 // if we already sent the error line then don't send the log file again.
294 if ($latestErrorLineFound['num'] <= $sentLine) {
295 $this->debugMessage("The latest error line from the log file was already emailed. " . $latestErrorLineFound['num'] .
296 ' <= ' . $sentLine);
297 return false;
298 }
299
300 // only email the error file if the latest version of the plugin is installed.
301 if (!$abj404dao->shouldEmailErrorFile()) {
302 return false;
303 }
304
305 // update the latest error line emailed to the developer.
306 $options[self::LAST_SENT_LINE] = $latestErrorLineFound['num'];
307 $abj404logic->updateOptions($options);
308 file_put_contents($sentDateFile, $latestErrorLineFound['num']);
309 $fileContents = file_get_contents($sentDateFile);
310 if ($fileContents != $latestErrorLineFound['num']) {
311 $this->errorMessage("There was an issue writing to the file " . $sentDateFile);
312 return false;
313 }
314
315 $payload = ABJ_404_Solution_FeedbackTransport::buildPayload('error', array(
316 'error_signature' => (string)($latestErrorLineFound['line'] ?? ''),
317 'previously_sent_line' => (int)$sentLine,
318 'error_count_in_log' => (int)$latestErrorLineFound['total_error_count'],
319 ));
320 return ABJ_404_Solution_FeedbackTransport::sendNow($payload, 'error');
321 }
322
323 /**
324 * Roll a 1-in-N dice and send a full debug zip as a heartbeat if it hits.
325 * Called during daily maintenance for opted-in sites when no error email was sent.
326 *
327 * Dispatches via FeedbackTransport::sendNow() (HTTP POST + email fallback)
328 * with type='heartbeat' so the same payload shape is shared with the error
329 * path.
330 *
331 * @param int $oneInN Probability denominator (default 200 = ~once per 6 months).
332 * @return bool True if a heartbeat was sent.
333 */
334 function sendHeartbeatIfDueRandom(int $oneInN = 200): bool {
335 if (!file_exists($this->getDebugFilePath())) {
336 return false;
337 }
338 if (mt_rand(1, $oneInN) !== 1) {
339 return false;
340 }
341 $this->debugMessage("Heartbeat dice roll hit (1-in-{$oneInN}). Sending heartbeat log.");
342 $errorInfo = $this->getLatestErrorLine();
343
344 $payload = ABJ_404_Solution_FeedbackTransport::buildPayload('heartbeat', array(
345 'error_signature' => 'Heartbeat: no errors to report.',
346 'previously_sent_line' => 0,
347 'error_count_in_log' => (int)$errorInfo['total_error_count'],
348 ));
349 ABJ_404_Solution_FeedbackTransport::sendNow($payload, 'heartbeat');
350 return true;
351 }
352
353 /**
354 * Email-fallback for FeedbackTransport when the HTTP POST of an error or
355 * heartbeat report fails. Builds an HTML email body purely from the
356 * FeedbackTransport payload (single source of truth shared with the HTTP
357 * path) and attaches a zip of the current debug log file(s).
358 *
359 * Public because FeedbackTransport::sendNow() invokes it via the service
360 * container for type='error' and type='heartbeat'.
361 *
362 * @param array<string, mixed> $payload FeedbackTransport-built payload.
363 * @return bool True if wp_mail() reported success, false otherwise.
364 */
365 function emailLogFileToDeveloper(array $payload): bool {
366 $isHeartbeat = (isset($payload['report_type']) && $payload['report_type'] === 'heartbeat');
367 $errorLineMessage = isset($payload['error_signature']) && is_scalar($payload['error_signature'])
368 ? (string)$payload['error_signature'] : '';
369 $totalErrorCount = isset($payload['error_count_in_log']) && is_scalar($payload['error_count_in_log'])
370 ? (int)$payload['error_count_in_log'] : 0;
371 $previouslySentLine = isset($payload['previously_sent_line']) && is_scalar($payload['previously_sent_line'])
372 ? (int)$payload['previously_sent_line'] : 0;
373
374 $this->debugMessage("Creating zip file of error log file. " .
375 "Previously sent error line: " . $previouslySentLine);
376 $logFileZip = $this->getZipFilePath();
377 if (file_exists($logFileZip)) {
378 ABJ_404_Solution_Functions::safeUnlink($logFileZip);
379 }
380 $zip = new ZipArchive;
381 if ($zip->open($logFileZip, ZipArchive::CREATE) === true) {
382 if (file_exists($this->getDebugFilePath())) {
383 $zip->addFile($this->getDebugFilePath(), basename($this->getDebugFilePath()));
384 }
385 if (file_exists($this->getDebugFilePathOld())) {
386 $zip->addFile($this->getDebugFilePathOld(), basename($this->getDebugFilePathOld()));
387 }
388 $zip->close();
389 }
390
391 $logTableSizeMB = round((int)($payload['log_table_size_bytes'] ?? 0) / (1024 * 1024), 2);
392 $debugFileSizeMB = round((int)($payload['debug_file_size_bytes'] ?? 0) / (1024 * 1024), 2);
393
394 $to = ABJ404_AUTHOR_EMAIL;
395 $subject = ABJ404_PP . ($isHeartbeat ? ' heartbeat' : ' error') . ' log file. Plugin version: ' . ABJ404_VERSION;
396 $extensions = isset($payload['extensions']) && is_array($payload['extensions']) ? $payload['extensions'] : array();
397 $activePlugins = isset($payload['active_plugins']) && is_array($payload['active_plugins']) ? $payload['active_plugins'] : array();
398 $isMultisite = !empty($payload['is_multisite']);
399
400 $bodyLines = array();
401 $bodyLines[] = $subject . ". Sent " . date('Y/m/d h:i:s T');
402 $bodyLines[] = " ";
403 $bodyLines[] = "Error: " . $errorLineMessage;
404 $bodyLines[] = " ";
405 $bodyLines[] = "PHP version: " . (string)($payload['php_version'] ?? PHP_VERSION);
406 $bodyLines[] = "WordPress version: " . (string)($payload['wp_version'] ?? '');
407 $bodyLines[] = "Plugin version: " . (string)($payload['plugin_version'] ?? ABJ404_VERSION);
408 $bodyLines[] = "MySQL version: " . (string)($payload['db_version'] ?? '');
409 $bodyLines[] = "Site URL: " . (string)($payload['site_url'] ?? '');
410 $bodyLines[] = "Multisite: " . ($isMultisite ? 'yes' : 'no');
411 if ($isMultisite && function_exists('is_plugin_active_for_network')) {
412 $bodyLines[] = "Network activated: " . (is_plugin_active_for_network(plugin_basename(ABJ404_FILE)) ? 'yes' : 'no');
413 }
414 $bodyLines[] = "WP_MEMORY_LIMIT: " . (defined('WP_MEMORY_LIMIT') ? WP_MEMORY_LIMIT : '');
415 $bodyLines[] = "Extensions: " . implode(", ", $extensions);
416 $bodyLines[] = " ";
417 $bodyLines[] = "--- WordPress Content Counts ---";
418 $bodyLines[] = "Published posts: " . (string)($payload['published_posts_count'] ?? '0');
419 $bodyLines[] = "Published pages: " . (string)($payload['published_pages_count'] ?? '0');
420 $bodyLines[] = "Categories: " . (string)($payload['categories_count'] ?? '0');
421 $bodyLines[] = "Tags: " . (string)($payload['tags_count'] ?? '0');
422 $bodyLines[] = " ";
423 $bodyLines[] = "--- 404 Solution Counts ---";
424 $bodyLines[] = "Total redirects (active): " . (string)($payload['redirects_active_total'] ?? '0');
425 $bodyLines[] = " - Manual redirects: " . (string)($payload['redirects_manual_count'] ?? '0');
426 $bodyLines[] = " - Automatic redirects: " . (string)($payload['redirects_automatic_count'] ?? '0');
427 $bodyLines[] = " - Regex redirects: " . (string)($payload['redirects_regex_count'] ?? '0');
428 $bodyLines[] = " - Trashed redirects: " . (string)($payload['redirects_trashed_count'] ?? '0');
429 $bodyLines[] = "Captured 404s (active): " . (string)($payload['captured_404s_active_total'] ?? '0');
430 $bodyLines[] = " - Captured (new): " . (string)($payload['captured_404s_new_count'] ?? '0');
431 $bodyLines[] = " - Ignored: " . (string)($payload['captured_404s_ignored_count'] ?? '0');
432 $bodyLines[] = " - Later: " . (string)($payload['captured_404s_later_count'] ?? '0');
433 $bodyLines[] = " - Trashed: " . (string)($payload['captured_404s_trashed_count'] ?? '0');
434 $bodyLines[] = "Log entries in database: " . (string)($payload['log_entries_count'] ?? '0');
435 $bodyLines[] = "Log table size: " . $logTableSizeMB . " MB";
436 $bodyLines[] = " ";
437 $bodyLines[] = "Total error count in log file: " . $totalErrorCount;
438 $bodyLines[] = "Debug file name: " . $this->getDebugFilename();
439 $bodyLines[] = "Debug file size: " . $debugFileSizeMB . " MB";
440 $bodyLines[] = "Active plugins: <pre>" .
441 json_encode($activePlugins, JSON_PRETTY_PRINT) . "</pre>";
442
443 $body = implode("<BR/>\n", $bodyLines);
444
445 $headers = array('Content-Type: text/html; charset=UTF-8');
446 $headers[] = 'From: ' . get_option('admin_email');
447
448 $attachments = array();
449 if (file_exists($logFileZip)) {
450 $attachments[] = $logFileZip;
451 }
452
453 $this->debugMessage("Sending error log zip file as attachment.");
454 $result = wp_mail($to, $subject, $body, $headers, $attachments);
455
456 if (file_exists($logFileZip)) {
457 ABJ_404_Solution_Functions::safeUnlink($logFileZip);
458 }
459 $this->debugMessage("Mail sent. Log zip file deleted.");
460 return (bool)$result;
461 }
462
463 /**
464 * @return array{num: int, line: string|null, total_error_count: int}
465 */
466 function getLatestErrorLine(): array {
467 $f = abj_service('functions');
468 $latestErrorLineFound = array();
469 $latestErrorLineFound['num'] = -1;
470 $latestErrorLineFound['line'] = null;
471 $latestErrorLineFound['total_error_count'] = 0;
472 $linesRead = 0;
473 $handle = null;
474 $collectingErrorLines = false;
475 try {
476 $debugPath = $this->getDebugFilePath();
477 // Check existence before fopen so PHP does not emit a warning on a
478 // missing debug file. The file is absent on fresh installs and in
479 // most test fixtures. Return the empty initialized array (no error
480 // line) in that case rather than letting fopen warn and return
481 // false. failOnWarning=true in phpunit.xml means an unguarded
482 // warning here trips the whole preflight gate.
483 if (!is_string($debugPath) || $debugPath === '' || !file_exists($debugPath)) {
484 return $latestErrorLineFound;
485 }
486 if ($handle = fopen($debugPath, "r")) {
487 // read the file one line at a time.
488 while (($line = fgets($handle)) !== false) {
489 $linesRead++;
490 // if the line has an error then save the line number.
491 $hasError = stripos($line, '(ERROR)');
492 $isDeleteError = stripos($line, 'SQL query error: DELETE command denied to user');
493 if ($hasError !== false && $isDeleteError === false) {
494 $latestErrorLineFound['num'] = $linesRead;
495 $latestErrorLineFound['line'] = $line;
496 $latestErrorLineFound['total_error_count'] += 1;
497 $collectingErrorLines = true;
498
499 } else if ($collectingErrorLines &&
500 !$f->regexMatch("^\d{4}[-]\d{2}[-]\d{2} .*\(\w+\):\s.*$", $line)) {
501 // if we're collecting error lines and we haven't found the
502 // beginning of a new debug message then continue collecting lines.
503 $latestErrorLineFound['line'] .= "<BR/>\n" . $line;
504
505 } else {
506 // this must be the beginning of a new debug message so we'll stop
507 // collecting error lines.
508 $collectingErrorLines = false;
509 }
510 }
511 } else {
512 $this->errorMessage("Error reading log file (1).");
513 }
514
515 } catch (Exception $e) {
516 $this->errorMessage("Error reading log file. (2)", $e);
517 }
518
519 if ($handle != null) {
520 fclose($handle);
521 }
522
523 return $latestErrorLineFound;
524 }
525
526 /**
527 * Get sanitized log excerpt for support emails
528 * Collects last 15 ERROR/WARN entries (already sanitized at write-time)
529 * If no errors/warnings found, includes last 20 lines of log for context
530 *
531 * @return string Sanitized log excerpt or message if no errors found
532 */
533 function getSanitizedLogExcerptForSupport() {
534 $f = abj_service('functions');
535 $errorEntries = array();
536 $recentLines = array();
537 $maxEntries = 15;
538 $maxRecentLines = 20;
539 $totalLines = 0;
540 $handle = null;
541
542 try {
543 $debugFilePath = $this->getDebugFilePath();
544
545 if (!file_exists($debugFilePath)) {
546 return "No log file available";
547 }
548
549 if ($handle = fopen($debugFilePath, "r")) {
550 $currentEntry = array();
551 $collectingEntry = false;
552
553 // Read file line by line
554 while (($line = fgets($handle)) !== false) {
555 $totalLines++;
556
557 // Keep a sliding window of recent lines (for fallback if no errors)
558 $recentLines[] = $line;
559 if (count($recentLines) > $maxRecentLines) {
560 array_shift($recentLines);
561 }
562
563 // Check if this is an ERROR or WARN line
564 $hasError = stripos($line, '(ERROR)') !== false;
565 $hasWarn = stripos($line, '(WARN)') !== false;
566 $isDeleteError = stripos($line, 'SQL query error: DELETE command denied to user') !== false;
567
568 // Start collecting if we find ERROR or WARN (but skip known benign errors)
569 if (($hasError || $hasWarn) && !$isDeleteError) {
570 // If we were collecting a previous entry, save it
571 if ($collectingEntry && !empty($currentEntry)) {
572 $errorEntries[] = $currentEntry;
573 // Keep only last N entries (sliding window)
574 if (count($errorEntries) > $maxEntries) {
575 array_shift($errorEntries);
576 }
577 }
578
579 // Start new entry (no sanitization needed - already done at write-time)
580 $currentEntry = array($line);
581 $collectingEntry = true;
582
583 } else if ($collectingEntry &&
584 !$f->regexMatch("^\d{4}[-]\d{2}[-]\d{2} .*\(\w+\):\s.*$", $line)) {
585 // Continue collecting multiline error (no sanitization needed - already done at write-time)
586 $currentEntry[] = $line;
587
588 } else {
589 // New log entry started, save previous if exists
590 if ($collectingEntry && !empty($currentEntry)) {
591 $errorEntries[] = $currentEntry;
592 if (count($errorEntries) > $maxEntries) {
593 array_shift($errorEntries);
594 }
595 }
596 $collectingEntry = false;
597 $currentEntry = array();
598 }
599 }
600
601 // Save last entry if we were still collecting
602 if ($collectingEntry && !empty($currentEntry)) {
603 $errorEntries[] = $currentEntry;
604 if (count($errorEntries) > $maxEntries) {
605 array_shift($errorEntries);
606 }
607 }
608
609 fclose($handle);
610
611 } else {
612 return "Log file not readable";
613 }
614
615 } catch (Exception $e) {
616 return "Error reading log file";
617 }
618
619 // Format output
620 if (empty($errorEntries)) {
621 // No errors/warnings found - include last N lines for context
622 if (empty($recentLines)) {
623 return "Log file is empty";
624 }
625 $output = "No ERROR/WARN entries found. Last " . count($recentLines) . " log lines:\n\n";
626 $output .= implode("", $recentLines);
627 return trim($output);
628 }
629
630 $output = "Last " . count($errorEntries) . " ERROR/WARN entries:\n\n";
631 foreach ($errorEntries as $entry) {
632 $output .= implode("\n", $entry) . "\n\n";
633 }
634
635 return trim($output);
636 }
637
638 /**
639 * Mask email address with adaptive length-based masking
640 * Shows 1-3 chars of username and ≤30% of domain based on length
641 *
642 * Examples:
643 * - joe@mail.com → j***@m***-a1b2
644 * - john@gmail.com → j***@gm***-c3d4
645 * - jennifer@example.com → jen***@exa***-e5f6
646 *
647 * @param string $email Email address to mask
648 * @return string Masked email with consistent hash
649 */
650 private function maskEmailAdaptive($email) {
651 if (empty($email) || strpos($email, '@') === false) {
652 return $email;
653 }
654
655 // Split email into parts
656 $parts = explode('@', $email);
657 if (count($parts) != 2) {
658 // Invalid email (multiple @), mask entire string as text
659 return $this->maskTextAdaptive($email);
660 }
661
662 list($username, $fullDomain) = $parts;
663
664 // Strip TLD from domain (remove .com, .org, .co.uk, etc.)
665 $domainParts = explode('.', $fullDomain);
666 if (count($domainParts) > 1) {
667 // Remove last part (.com), or last 2 parts if it's .co.uk style
668 if (in_array(end($domainParts), array('uk', 'au', 'nz', 'za'))) {
669 // .co.uk style - remove last 2 parts
670 array_pop($domainParts);
671 array_pop($domainParts);
672 } else {
673 // .com style - remove last part
674 array_pop($domainParts);
675 }
676 }
677 $domain = implode('.', $domainParts);
678
679 // Calculate visible characters for username (1-3 based on length)
680 $usernameLen = strlen($username);
681 if ($usernameLen <= 4) {
682 $usernameVisible = 1;
683 } elseif ($usernameLen <= 9) {
684 $usernameVisible = 2;
685 } else {
686 $usernameVisible = 3;
687 }
688
689 // Calculate visible characters for domain (≤30%)
690 $domainLen = strlen($domain);
691 $domainVisible = max(1, (int) ceil($domainLen * 0.3));
692
693 // Create masked parts
694 $maskedUsername = substr($username, 0, $usernameVisible) . '***';
695 $maskedDomain = empty($domain) ? '' : substr($domain, 0, $domainVisible) . '***';
696
697 // Generate consistent hash with WordPress salt for security
698 if (defined('AUTH_SALT')) {
699 $hash = substr(md5(AUTH_SALT . $email), 0, 4);
700 } else {
701 $hash = substr(md5($email), 0, 4);
702 }
703
704 // Format: username***@domain***-hash
705 if (!empty($maskedDomain)) {
706 return $maskedUsername . '@' . $maskedDomain . '-' . $hash;
707 } else {
708 return $maskedUsername . '@-' . $hash;
709 }
710 }
711
712 /**
713 * Mask text (names, usernames) with adaptive length-based masking
714 * Shows 1-3 chars based on length + consistent hash
715 *
716 * Examples:
717 * - Joe → J***-a1b2
718 * - John → J***-c3d4
719 * - Jennifer → Jen***-e5f6
720 *
721 * @param string $text Text to mask
722 * @return string Masked text with consistent hash
723 */
724 private function maskTextAdaptive($text) {
725 if (empty($text)) {
726 return $text;
727 }
728
729 $text = trim($text);
730 $textLen = strlen($text);
731
732 // Calculate visible characters (1-3 based on length)
733 if ($textLen <= 4) {
734 $visible = 1;
735 } elseif ($textLen <= 9) {
736 $visible = 2;
737 } else {
738 $visible = 3;
739 }
740
741 $masked = substr($text, 0, $visible) . '***';
742
743 // Generate consistent hash with WordPress salt
744 if (defined('AUTH_SALT')) {
745 $hash = substr(md5(AUTH_SALT . $text), 0, 4);
746 } else {
747 $hash = substr(md5($text), 0, 4);
748 }
749
750 return $masked . '-' . $hash;
751 }
752
753 /**
754 * Look up the live WordPress table prefix for PII redaction.
755 *
756 * Reads $wpdb->prefix when available so a custom prefix like
757 * 'wp_siddur_' can be normalised to 'wp_' in log lines. Returns ''
758 * when $wpdb is not loaded (very early boot, some test fixtures), in
759 * which case the caller must skip the rewrite rather than guess.
760 *
761 * @return string
762 */
763 private function getActualPrefixForRedaction(): string {
764 if (isset($GLOBALS['wpdb']) && is_object($GLOBALS['wpdb'])) {
765 $wpdb = $GLOBALS['wpdb'];
766 if (isset($wpdb->prefix) && is_string($wpdb->prefix) && $wpdb->prefix !== '') {
767 return $wpdb->prefix;
768 }
769 }
770 return '';
771 }
772
773 /**
774 * Look up the live database name for PII redaction.
775 *
776 * Prefers $wpdb->dbname (set by WP after wp-config) and falls back to
777 * the DB_NAME constant (defined the moment wp-config loads). Returns
778 * '' when neither is available so the caller can skip the rewrite.
779 *
780 * @return string
781 */
782 private function getActualDatabaseNameForRedaction(): string {
783 if (isset($GLOBALS['wpdb']) && is_object($GLOBALS['wpdb'])) {
784 $wpdb = $GLOBALS['wpdb'];
785 if (isset($wpdb->dbname) && is_string($wpdb->dbname) && $wpdb->dbname !== '') {
786 return $wpdb->dbname;
787 }
788 }
789 if (defined('DB_NAME')) {
790 $name = constant('DB_NAME');
791 if (is_string($name) && $name !== '') {
792 return $name;
793 }
794 }
795 return '';
796 }
797
798 /**
799 * Sanitize a single log line for privacy (GDPR compliance)
800 * Uses adaptive masking with consistent hashing for debugging
801 *
802 * @param string $line Log line to sanitize
803 * @return string Sanitized line with PII masked adaptively
804 */
805 public function sanitizeLogLine($line) {
806 $f = abj_service('functions');
807
808 // Strip query strings from URLs (everything after ? in http/https URLs)
809 // This removes tokens, emails, session IDs, search terms, etc. from URLs
810 $line = preg_replace('/(https?:\/\/[^\s?]+)\?[^\s]*/', '$1', $line) ?? $line;
811
812 // F6: strip query strings from path-only URIs too (e.g. REQUEST_URI
813 // appended by errorMessage(): "Requested URL: /admin.php?page=foo&secret=xyz").
814 // The scheme'd-URL rule above does not catch these because REQUEST_URI
815 // carries no scheme. Without this strip, short fragments (under the
816 // transport-side `\d{4,}` normalization floor) survive truncation
817 // into recent_error_signatures.
818 //
819 // Lookbehind blocks matches inside email addresses, scheme tails
820 // (`http:/`), and other contexts where a slash is already part of a
821 // token. The path body is `[^\s?#]*`, which stops at whitespace,
822 // fragment start, or query start, so adjacent log fields stay intact.
823 $line = preg_replace('/(?<![A-Za-z0-9:@])(\/[^\s?#]*)\?\S*/', '$1', $line) ?? $line;
824
825 // Mask email addresses with adaptive length-based masking
826 // Example: john@example.com -> j***@exa***-a1b2
827 $line = preg_replace_callback(
828 '/\S+@\S+/',
829 function($matches) {
830 return $this->maskEmailAdaptive($matches[0]);
831 },
832 $line
833 ) ?? $line;
834
835 // Redact IP addresses using existing md5lastOctet function
836 // Keeps first octets, hashes last (e.g., 192.168.1.100 -> 192.168.1.md5hash)
837 $line = preg_replace_callback(
838 '/\b(?:\d{1,3}\.){3}\d{1,3}\b/',
839 function($matches) use ($f) {
840 return $f->md5lastOctet($matches[0]);
841 },
842 $line
843 ) ?? $line;
844
845 // Redact IPv6 addresses (including compressed forms) using existing md5lastOctet function
846 // Negative lookbehind prevents matching mid-hex-string; handles ::1, 2001:db8::1, etc.
847 $line = preg_replace_callback(
848 '/(?<![0-9A-Fa-f:])(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?::[0-9a-fA-F]{1,4}){1,6}|:(?:(?::[0-9a-fA-F]{1,4}){1,7}|:))(?![0-9A-Fa-f:])/',
849 function($matches) use ($f) {
850 return $f->md5lastOctet($matches[0]);
851 },
852 $line
853 ) ?? $line;
854
855 // Mask usernames with adaptive length-based masking
856 // Example: "Current user: john" -> "Current user: j***-a1b2"
857 $line = preg_replace_callback(
858 '/\b(current\s+)?user(name)?:\s*(\S+)/i',
859 function($matches) {
860 $prefix = $matches[1] . 'user' . $matches[2] . ': ';
861 $username = $matches[3];
862 return $prefix . $this->maskTextAdaptive($username);
863 },
864 $line
865 ) ?? $line;
866
867 // Mask display names with adaptive length-based masking
868 // Example: "Display name: John Doe" -> "Display name: J***-a1b2"
869 $line = preg_replace_callback(
870 '/\bdisplay\s+name:\s*([^\n,]+)/i',
871 function($matches) {
872 $name = trim($matches[1]);
873 return 'display name: ' . $this->maskTextAdaptive($name);
874 },
875 $line
876 ) ?? $line;
877
878 // Redact absolute file paths to prevent server-path disclosure.
879 //
880 // Stack traces format paths as:
881 // #0 /home/user/public_html/wp-includes/class.php(123): method()
882 // thrown in /var/www/html/wp-content/plugins/foo/bar.php on line 45
883 //
884 // The document root varies per host (/home/user/, /var/www/, /srv/www/,
885 // /Users/username/, etc.) but WordPress always has recognisable sub-dirs.
886 // Replace everything before the WP marker so the marker itself is kept
887 // (aids debugging) while the host-specific prefix is hidden.
888 //
889 // Output uses the canonical short form (e.g. " /wp-content/...") that
890 // matches what a default install would log. WP.org topic 18908598:
891 // the reporter manually rewrote /home/user/site/wp-content/... to
892 // /wp-content/... before sharing logs; the auto-redactor produces the
893 // same shape so logs stay diagnosable without further hand-editing.
894 //
895 // Covered markers: wp-content, wp-admin, wp-includes, wp-login.php,
896 // wp-config.php, wp-cron.php, wp-blog-header.php
897 $wpUnixMarkers = 'wp-content|wp-admin|wp-includes|wp-login\\.php|wp-config\\.php|wp-cron\\.php|wp-blog-header\\.php';
898 // Unix paths: preceded by start-of-string, whitespace, or (#/digit/paren
899 // that appear in stack-trace lines like "#0 /path..." or "(thrown in /path...")
900 $line = preg_replace(
901 '/(^|[\s\(])(\/[^\s\(]+?)\/(' . $wpUnixMarkers . ')\b/i',
902 '$1/$3',
903 $line
904 ) ?? $line;
905 // Windows paths: same markers, backslash separators.
906 // e.g. C:\inetpub\wwwroot\wp-content\ -> \wp-content\
907 // Each \\ in the pattern string matches one literal backslash in the path.
908 $line = preg_replace(
909 '/\b[a-z]:\\\\[^\s]+\\\\(' . $wpUnixMarkers . ')\b/i',
910 '\\\\$1',
911 $line
912 ) ?? $line;
913
914 // Redact the actual database name to a generic 'dbname' placeholder
915 // and the actual table prefix to the default 'wp_' so messages like
916 // Table 'mydb_xyz.wp_siddur_abj404_view_build' doesn't exist
917 // become
918 // Table 'dbname.wp_abj404_view_build' doesn't exist
919 // The output mimics a vanilla WordPress install so the maintainer
920 // can still recognise table names at a glance, while the host's
921 // schema name and obfuscation prefix stay private. Driven by WP.org
922 // topic 18908598 where the reporter redacted both manually before
923 // sharing the log.
924 //
925 // Both helpers fall back to '' when $wpdb is not available (very
926 // early boot, test fixtures with no DB), in which case the rewrite
927 // is skipped instead of guessing.
928 $dbname = $this->getActualDatabaseNameForRedaction();
929 if ($dbname !== '' && strlen($dbname) >= 3 && $dbname !== 'dbname') {
930 // Match the dbname only in qualified-identifier contexts: a
931 // following '.' (Table 'db.table') or '`' (`db`.`table`). The
932 // negative lookbehind keeps it from matching mid-identifier or
933 // mid-word, so a dbname that happens to be a common substring
934 // does not bleed into unrelated log text.
935 $line = preg_replace(
936 '/(?<![A-Za-z0-9_-])' . preg_quote($dbname, '/') . '(?=[.`])/',
937 'dbname',
938 $line
939 ) ?? $line;
940 }
941 $prefix = $this->getActualPrefixForRedaction();
942 if ($prefix !== '' && $prefix !== 'wp_') {
943 // Match the prefix when it precedes a table-name character
944 // (letter), so 'wp_siddur_abj404_X' becomes 'wp_abj404_X' but
945 // a standalone occurrence (or one mid-token) is left alone.
946 $line = preg_replace(
947 '/(?<![A-Za-z0-9_-])' . preg_quote($prefix, '/') . '(?=[A-Za-z])/',
948 'wp_',
949 $line
950 ) ?? $line;
951 }
952
953 // Hash long tokens consistently (40+ chars)
954 // Example: "abc123def456..." -> "token-a1b2c3d4"
955 $line = preg_replace_callback(
956 '/\b([A-Za-z0-9_-]{40,})\b/',
957 function($matches) {
958 $hash = substr(md5($matches[1]), 0, 8);
959 return 'token-' . $hash;
960 },
961 $line
962 ) ?? $line;
963
964 // Hash WordPress nonces consistently
965 // Example: "_wpnonce=abc123" -> "_wpnonce=nonce-a1b2c3d4"
966 $line = preg_replace_callback(
967 '/_wpnonce=([A-Za-z0-9]+)/',
968 function($matches) {
969 $hash = substr(md5($matches[1]), 0, 8);
970 return '_wpnonce=nonce-' . $hash;
971 },
972 $line
973 ) ?? $line;
974
975 return $line;
976 }
977
978 /** Return the path to the debug file.
979 * @return string
980 */
981 function getDebugFilePath() {
982 $debugFileName = $this->getDebugFilename();
983 return $this->getFilePathAndMoveOldFile(abj404_getUploadsDir(), $debugFileName);
984 }
985
986 /** @return string */
987 function getDebugFilename(): string {
988 // The is_object() / method_exists() guards below catch the static
989 // unreachability cases (container miss, factory returned null), but
990 // they cannot catch a Throwable raised from inside the resolved call
991 // — getOptions() may surface a DB read failure, uniqidReal() may
992 // raise on a corrupt random source, updateOptions() may fail to
993 // persist. writeLineToDebugFile() promises non-throwing; any escape
994 // from this method violates that contract. Absorb every Throwable
995 // and return the deterministic fallback name so logging stays
996 // available even when upstream services are degraded.
997 try {
998 // get the UUID here.
999 $abj404logic = abj_service('plugin_logic');
1000 // abj_service returns null when the container is uninitialised
1001 // or the factory threw — common during very-early boot, the
1002 // test harness, and self-healing recovery from broken installs.
1003 if (!is_object($abj404logic) || !method_exists($abj404logic, 'getOptions')) {
1004 return 'abj404_debug.txt';
1005 }
1006 $options = $abj404logic->getOptions(true);
1007 $debugFileKey = null;
1008 if (is_array($options) && array_key_exists(self::DEBUG_FILE_KEY, $options)) {
1009 $debugFileKey = is_string($options[self::DEBUG_FILE_KEY]) ? $options[self::DEBUG_FILE_KEY] : null;
1010 }
1011 // if the key doesn't exist then create it.
1012 if ($debugFileKey === null || trim($debugFileKey) === '') {
1013 // delete any lingering debug files.
1014 $this->deleteDebugFile();
1015
1016 // create a probably unique UUID and store it to the database.
1017 $syncUtils = abj_service('sync_utils');
1018 if (!is_object($syncUtils) || !method_exists($syncUtils, 'uniqidReal')) {
1019 return 'abj404_debug.txt';
1020 }
1021 $debugFileKey = $syncUtils->uniqidReal();
1022 $options[self::DEBUG_FILE_KEY] = $debugFileKey;
1023 if (method_exists($abj404logic, 'updateOptions')) {
1024 $abj404logic->updateOptions($options);
1025 }
1026 }
1027
1028 return 'abj404_debug_' . $debugFileKey . '.txt';
1029 } catch (\Throwable $e) {
1030 return 'abj404_debug.txt';
1031 }
1032 }
1033
1034 /** @return string */
1035 function getDebugFilePathOld(): string {
1036 return $this->getDebugFilePath() . "_old.txt";
1037 }
1038
1039 /** Return the path to the file that stores the latest error line in the log file.
1040 * @return string
1041 */
1042 function getDebugFilePathSentFile() {
1043 return $this->getFilePathAndMoveOldFile(abj404_getUploadsDir(), 'abj404_debug_sent_line.txt');
1044 }
1045
1046 /** Return the path to the zip file for sending the debug file.
1047 * @return string
1048 */
1049 function getZipFilePath() {
1050 return $this->getFilePathAndMoveOldFile(abj404_getUploadsDir(), 'abj404_debug.zip');
1051 }
1052
1053 /** This is for legacy support. On new installations it creates a directory and returns
1054 * a file path. On old installations it moved the old file to the new location.
1055 * If the directory can't be created then it falls back to the old location.
1056 * @param string $directory
1057 * @param string $filename
1058 * @return string
1059 */
1060 function getFilePathAndMoveOldFile($directory, $filename) {
1061 $f = abj_service('functions');
1062 // create the directory and move the file
1063 if (!$f->createDirectoryWithErrorMessages($directory)) {
1064 return ABJ404_PATH . $filename;
1065 }
1066
1067 if (file_exists(ABJ404_PATH . $filename)) {
1068 // move the file to the new location
1069 rename(ABJ404_PATH . $filename, $directory . $filename);
1070 }
1071
1072 return $directory . $filename;
1073 }
1074
1075 /** @return void */
1076 function limitDebugFileSize(): void {
1077 // delete the sent_line file since it's now incorrect.
1078 if (file_exists($this->getDebugFilePathSentFile())) {
1079 ABJ_404_Solution_Functions::safeUnlink($this->getDebugFilePathSentFile());
1080 }
1081
1082 // update the last sent error line since the debug file will be deleted.
1083 $this->removeLastSentErrorLineFromDatabase();
1084
1085 // delete _old log file
1086 ABJ_404_Solution_Functions::safeUnlink($this->getDebugFilePathOld());
1087 // rename current log file to _old
1088 rename($this->getDebugFilePath(), $this->getDebugFilePathOld());
1089 }
1090
1091 /** @return void */
1092 function removeLastSentErrorLineFromDatabase(): void {
1093 // update the last sent error line since the debug file will be deleted.
1094 $abj404logic = abj_service('plugin_logic');
1095 $options = $abj404logic->getOptions(true);
1096 $options[self::LAST_SENT_LINE] = 0;
1097 $abj404logic->updateOptions($options);
1098 }
1099
1100 /** Deletes all files named abj404_debug_*.txt
1101 * @return boolean true if the file was deleted.
1102 */
1103 function deleteDebugFile() {
1104 $abj404logic = abj_service('plugin_logic');
1105 $allIsWell = true;
1106
1107 // since the debug file is being deleted we reset the last error line that was sent.
1108 if (file_exists($this->getDebugFilePathSentFile())) {
1109 ABJ_404_Solution_Functions::safeUnlink($this->getDebugFilePathSentFile());
1110 }
1111 // update the last sent error line since the debug file will be deleted.
1112 $this->removeLastSentErrorLineFromDatabase();
1113
1114 // delete the debug file(s).
1115 // list any files in the directory and delete any files named debug_*.txt
1116 $uploadDir = abj404_getUploadsDir();
1117 // Check if the directory exists
1118 if (is_dir($uploadDir)) {
1119 // Get all files matching the pattern abj404_debug_*.txt
1120 $files = glob($uploadDir . '/abj404_debug_*.txt');
1121 if (!is_array($files)) { $files = array(); }
1122 foreach ($files as $file) { // Loop through the files and delete them
1123 if (is_file($file)) {
1124 // Delete the file
1125 if (!ABJ_404_Solution_Functions::safeUnlink($file)) {
1126 $allIsWell = false;
1127 }
1128 }
1129 }
1130 }
1131
1132 // reset the UUID since we deleted the log file.
1133 $options = $abj404logic->getOptions(true);
1134 $options[self::DEBUG_FILE_KEY] = null;
1135 $abj404logic->updateOptions($options);
1136
1137 return $allIsWell;
1138 }
1139
1140 /**
1141 * @return int file size in bytes
1142 */
1143 function getDebugFileSize() {
1144 $file1Size = 0;
1145 $file2Size = 0;
1146 if (file_exists($this->getDebugFilePath())) {
1147 $file1Size = filesize($this->getDebugFilePath());
1148 }
1149 if (file_exists($this->getDebugFilePathOld())) {
1150 $file2Size = filesize($this->getDebugFilePathOld());
1151 }
1152
1153 return $file1Size + $file2Size;
1154 }
1155
1156 }
1157