*/ private static $storedDebugMessages = array(); /** Used to store the last line sent from the debug file. */ const LAST_SENT_LINE = 'last_sent_line'; /** Used to store the the debug filename. */ const DEBUG_FILE_KEY = 'debug_file_key'; /** @var self|null */ private static $instance = null; /** * Factory for the DI container. * * This avoids recursion when the container's 'logging' service is defined in terms of getInstance(). * * @return ABJ_404_Solution_Logging */ public static function createForContainer() { // Create a fresh instance without consulting the container. $logger = new ABJ_404_Solution_Logging(); // Flush any pending errors captured before the logger existed. if (isset($GLOBALS['abj404_pending_errors']) && is_array($GLOBALS['abj404_pending_errors'])) { foreach ($GLOBALS['abj404_pending_errors'] as $message) { $logger->errorMessage($message); } unset($GLOBALS['abj404_pending_errors']); // Clear after flushing } // Also sync singleton for legacy callers. self::$instance = $logger; return $logger; } /** @return self */ public static function getInstance() { if (self::$instance !== null) { return self::$instance; } // If the DI container is initialized, prefer it. if (class_exists('ABJ_404_Solution_ServiceContainer')) { $service = ABJ_404_Solution_ServiceContainer::safeGet('logging'); if ($service instanceof ABJ_404_Solution_Logging) { self::$instance = $service; return self::$instance; } } self::$instance = new ABJ_404_Solution_Logging(); // log any errors that were stored before the logger existed. if (isset($GLOBALS['abj404_pending_errors']) && is_array($GLOBALS['abj404_pending_errors'])) { foreach ($GLOBALS['abj404_pending_errors'] as $message) { self::$instance->errorMessage($message); } unset($GLOBALS['abj404_pending_errors']); // Clear after flushing } return self::$instance; } private function __construct() { } /** @return boolean true if debug mode is on. false otherwise. */ function isDebug() { $abj404logic = abj_service('plugin_logic'); $options = $abj404logic->getOptions(true); return (array_key_exists('debug_mode', $options) && $options['debug_mode'] == true); } /** for the current timezone. * @return string */ function getTimestamp() { $date = null; $timezoneStringRaw = get_option('timezone_string'); $timezoneString = is_string($timezoneStringRaw) ? $timezoneStringRaw : ''; if (!empty($timezoneString)) { $date = new DateTime("now", new DateTimeZone($timezoneString)); } else { $gmtOffsetRaw = get_option('gmt_offset'); // WordPress's gmt_offset is hours and may be fractional // (e.g. 5.5 India, 5.75 Nepal, -3.5 Newfoundland). $gmtOffsetHours = is_scalar($gmtOffsetRaw) ? (float)$gmtOffsetRaw : 0.0; $totalMinutes = (int) round($gmtOffsetHours * 60); $sign = $totalMinutes < 0 ? '-' : '+'; $absMinutes = abs($totalMinutes); $tzString = sprintf('%s%02d:%02d', $sign, intdiv($absMinutes, 60), $absMinutes % 60); try { $date = new DateTime("now", new DateTimeZone($tzString)); } catch (Exception $e) { // Use error_log (not $this->warn) because this method is part // of the logging path; calling warn here would risk recursion // if the timezone failure also breaks warn's own DateTime use. @error_log('404 Solution: timezone constructor failed (' . $e->getMessage() . '); using server default'); $date = new DateTime(); } } return $date->format('Y-m-d H:i:s T'); } /** Send a message to the log file if debug mode is on. * This goes to a file and is used by every other class so it goes here. * @param string $message * @param \Exception|null $e If present then a stack trace is included. * @return void */ function debugMessage(string $message, $e = null): void { $stacktrace = ""; if ($e != null) { $stacktrace = ", Stacktrace: " . $e->getTraceAsString(); } $timestamp = $this->getTimestamp() . ' (DEBUG): '; if ($this->isDebug()) { $this->writeLineToDebugFile($timestamp . $message . $stacktrace); } else { array_push(self::$storedDebugMessages, $timestamp . $message . $stacktrace); } } /** Send a message to the log. * This goes to a file and is used by every other class so it goes here. * @param string $message * @return void */ function infoMessage(string $message): void { $timestamp = $this->getTimestamp() . ' (INFO): '; $this->writeLineToDebugFile($timestamp . $message); } /** Send a message to the log. * This goes to a file and is used by every other class so it goes here. * @param string $message * @return void */ function warn(string $message): void { $timestamp = $this->getTimestamp() . ' (WARN): '; $this->writeLineToDebugFile($timestamp . $message); } /** Always send a message to the error_log. * This goes to a file and is used by every other class so it goes here. * @param string $message * @param \Exception|null $e * @return void */ function errorMessage(string $message, $e = null): void { if ($e == null) { $e = new Exception; } $stacktrace = $e->getTraceAsString(); $savedDebugMessages = implode("\n", self::$storedDebugMessages); self::$storedDebugMessages = array(); $timestamp = $this->getTimestamp() . ' (ERROR): '; $referrer = ''; if (array_key_exists('HTTP_REFERER', $_SERVER) && !empty($_SERVER['HTTP_REFERER'])) { $referrer = $_SERVER['HTTP_REFERER']; } $requestedURL = ''; if (array_key_exists('REQUEST_URI', $_SERVER) && !empty($_SERVER['REQUEST_URI'])) { $requestedURL = $_SERVER['REQUEST_URI']; } $this->writeLineToDebugFile($timestamp . $message . ", PHP version: " . PHP_VERSION . ", WP ver: " . get_bloginfo('version') . ", Plugin ver: " . ABJ404_VERSION . ", Referrer: " . $referrer . ", Requested URL: " . $requestedURL . ", \nStored debug messages: \n" . $savedDebugMessages . ", \nTrace: " . $stacktrace); } /** Log the user capabilities. * @param string $msg * @return void */ function logUserCapabilities(string $msg): void { $f = abj_service('functions'); $abj404logic = abj_service('plugin_logic'); $user = wp_get_current_user(); $usercaps = $f->str_replace(',"', ', "', wp_kses_post((string)json_encode($user->get_role_caps()))); $userIsPluginAdminStr = "false"; if ($abj404logic->userIsPluginAdmin()) { $userIsPluginAdminStr = "true"; } $this->debugMessage("User caps msg: " . esc_html($msg == '' ? '(none)' : $msg) . ", is_admin(): " . is_admin() . ", current_user_can('manage_options'): " . current_user_can('manage_options') . ", current_user_can('administrator'): " . current_user_can('administrator') . ", userIsPluginAdmin(): " . $userIsPluginAdminStr . ", user_login: " . esc_html($user->user_login ?? '(none)') . ", user caps: " . wp_kses_post((string)json_encode($user->caps)) . ", get_role_caps: " . $usercaps . ", WP ver: " . get_bloginfo('version') . ", mbstring: " . (extension_loaded('mbstring') ? 'true' : 'false')); } /** Write the line to the debug file. * * Sanitizes PII at write-time for GDPR compliance (defense in depth). * Fix for disk space error (reported by 1 user - 2% of errors) * Handles file write failures gracefully to prevent error loops when disk is full. * Uses error suppression and returns status instead of throwing exceptions. * * @param string $line * @return bool True on success, false on failure */ function writeLineToDebugFile($line) { // Sanitize PII at write-time (GDPR compliance) // This protects all 372 logging calls across the codebase $sanitizedLine = $this->sanitizeLogLine($line); // Suppress errors to prevent fatal error when disk is full $result = @file_put_contents($this->getDebugFilePath(), $sanitizedLine . "\n", FILE_APPEND); if ($result === false) { // Disk full or permissions issue - log to error_log instead to avoid infinite loop // Don't use errorMessage() here as it would call this function again error_log('404 Solution: Unable to write to debug log (possibly disk full): ' . $this->getDebugFilePath()); return false; } return true; } /** Email the log file to the plugin developer. * * Cron-context entry: builds a FeedbackTransport payload from the freshly- * scanned latest-error line plus dedup state, and dispatches via * FeedbackTransport::sendNow() (sync HTTP POST + email fallback). Returns * true iff any transport (HTTP or email) succeeded; the dedup pointer is * advanced before sending so a transport failure does not cause repeated * sends of the same error line on the next cron tick. * * @return bool */ function emailErrorLogIfNecessary(): bool { $abj404dao = abj_service('data_access'); $abj404logic = abj_service('plugin_logic'); $options = $abj404logic->getOptions(true); if (!file_exists($this->getDebugFilePath())) { $this->debugMessage("No log file found so no errors were found."); return false; } // get the number of the last line with an error message. $latestErrorLineFound = $this->getLatestErrorLine(); // if no error was found then we're done. if ($latestErrorLineFound['num'] == -1) { $this->debugMessage("No errors found in the log file."); return false; } // ------------------- // get/check the last line that was emailed to the admin. $sentDateFile = $this->getDebugFilePathSentFile(); $sentLine = -1; if (file_exists($sentDateFile)) { $sentLine = absint( ABJ_404_Solution_Functions::readFileContents($sentDateFile, false)); $this->debugMessage("Last sent line from file: " . $sentLine); } if ($sentLine < 1 && array_key_exists(self::LAST_SENT_LINE, $options)) { $sentLine = is_scalar($options[self::LAST_SENT_LINE]) ? (int)$options[self::LAST_SENT_LINE] : -1; $this->debugMessage("Last sent line from options: " . $sentLine); } // if we already sent the error line then don't send the log file again. if ($latestErrorLineFound['num'] <= $sentLine) { $this->debugMessage("The latest error line from the log file was already emailed. " . $latestErrorLineFound['num'] . ' <= ' . $sentLine); return false; } // only email the error file if the latest version of the plugin is installed. if (!$abj404dao->shouldEmailErrorFile()) { return false; } // update the latest error line emailed to the developer. $options[self::LAST_SENT_LINE] = $latestErrorLineFound['num']; $abj404logic->updateOptions($options); file_put_contents($sentDateFile, $latestErrorLineFound['num']); $fileContents = file_get_contents($sentDateFile); if ($fileContents != $latestErrorLineFound['num']) { $this->errorMessage("There was an issue writing to the file " . $sentDateFile); return false; } $payload = ABJ_404_Solution_FeedbackTransport::buildPayload('error', array( 'error_signature' => (string)($latestErrorLineFound['line'] ?? ''), 'previously_sent_line' => (int)$sentLine, 'error_count_in_log' => (int)$latestErrorLineFound['total_error_count'], )); return ABJ_404_Solution_FeedbackTransport::sendNow($payload, 'error'); } /** * Roll a 1-in-N dice and send a full debug zip as a heartbeat if it hits. * Called during daily maintenance for opted-in sites when no error email was sent. * * Dispatches via FeedbackTransport::sendNow() (HTTP POST + email fallback) * with type='heartbeat' so the same payload shape is shared with the error * path. * * @param int $oneInN Probability denominator (default 200 = ~once per 6 months). * @return bool True if a heartbeat was sent. */ function sendHeartbeatIfDueRandom(int $oneInN = 200): bool { if (!file_exists($this->getDebugFilePath())) { return false; } if (mt_rand(1, $oneInN) !== 1) { return false; } $this->debugMessage("Heartbeat dice roll hit (1-in-{$oneInN}). Sending heartbeat log."); $errorInfo = $this->getLatestErrorLine(); $payload = ABJ_404_Solution_FeedbackTransport::buildPayload('heartbeat', array( 'error_signature' => 'Heartbeat: no errors to report.', 'previously_sent_line' => 0, 'error_count_in_log' => (int)$errorInfo['total_error_count'], )); ABJ_404_Solution_FeedbackTransport::sendNow($payload, 'heartbeat'); return true; } /** * Email-fallback for FeedbackTransport when the HTTP POST of an error or * heartbeat report fails. Builds an HTML email body purely from the * FeedbackTransport payload (single source of truth shared with the HTTP * path) and attaches a zip of the current debug log file(s). * * Public because FeedbackTransport::sendNow() invokes it via the service * container for type='error' and type='heartbeat'. * * @param array $payload FeedbackTransport-built payload. * @return bool True if wp_mail() reported success, false otherwise. */ function emailLogFileToDeveloper(array $payload): bool { $isHeartbeat = (isset($payload['report_type']) && $payload['report_type'] === 'heartbeat'); $errorLineMessage = isset($payload['error_signature']) && is_scalar($payload['error_signature']) ? (string)$payload['error_signature'] : ''; $totalErrorCount = isset($payload['error_count_in_log']) && is_scalar($payload['error_count_in_log']) ? (int)$payload['error_count_in_log'] : 0; $previouslySentLine = isset($payload['previously_sent_line']) && is_scalar($payload['previously_sent_line']) ? (int)$payload['previously_sent_line'] : 0; $this->debugMessage("Creating zip file of error log file. " . "Previously sent error line: " . $previouslySentLine); $logFileZip = $this->getZipFilePath(); if (file_exists($logFileZip)) { ABJ_404_Solution_Functions::safeUnlink($logFileZip); } $zip = new ZipArchive; if ($zip->open($logFileZip, ZipArchive::CREATE) === true) { if (file_exists($this->getDebugFilePath())) { $zip->addFile($this->getDebugFilePath(), basename($this->getDebugFilePath())); } if (file_exists($this->getDebugFilePathOld())) { $zip->addFile($this->getDebugFilePathOld(), basename($this->getDebugFilePathOld())); } $zip->close(); } $logTableSizeMB = round((int)($payload['log_table_size_bytes'] ?? 0) / (1024 * 1024), 2); $debugFileSizeMB = round((int)($payload['debug_file_size_bytes'] ?? 0) / (1024 * 1024), 2); $to = ABJ404_AUTHOR_EMAIL; $subject = ABJ404_PP . ($isHeartbeat ? ' heartbeat' : ' error') . ' log file. Plugin version: ' . ABJ404_VERSION; $extensions = isset($payload['extensions']) && is_array($payload['extensions']) ? $payload['extensions'] : array(); $activePlugins = isset($payload['active_plugins']) && is_array($payload['active_plugins']) ? $payload['active_plugins'] : array(); $isMultisite = !empty($payload['is_multisite']); $bodyLines = array(); $bodyLines[] = $subject . ". Sent " . date('Y/m/d h:i:s T'); $bodyLines[] = " "; $bodyLines[] = "Error: " . $errorLineMessage; $bodyLines[] = " "; $bodyLines[] = "PHP version: " . (string)($payload['php_version'] ?? PHP_VERSION); $bodyLines[] = "WordPress version: " . (string)($payload['wp_version'] ?? ''); $bodyLines[] = "Plugin version: " . (string)($payload['plugin_version'] ?? ABJ404_VERSION); $bodyLines[] = "MySQL version: " . (string)($payload['db_version'] ?? ''); $bodyLines[] = "Site URL: " . (string)($payload['site_url'] ?? ''); $bodyLines[] = "Multisite: " . ($isMultisite ? 'yes' : 'no'); if ($isMultisite && function_exists('is_plugin_active_for_network')) { $bodyLines[] = "Network activated: " . (is_plugin_active_for_network(plugin_basename(ABJ404_FILE)) ? 'yes' : 'no'); } $bodyLines[] = "WP_MEMORY_LIMIT: " . (defined('WP_MEMORY_LIMIT') ? WP_MEMORY_LIMIT : ''); $bodyLines[] = "Extensions: " . implode(", ", $extensions); $bodyLines[] = " "; $bodyLines[] = "--- WordPress Content Counts ---"; $bodyLines[] = "Published posts: " . (string)($payload['published_posts_count'] ?? '0'); $bodyLines[] = "Published pages: " . (string)($payload['published_pages_count'] ?? '0'); $bodyLines[] = "Categories: " . (string)($payload['categories_count'] ?? '0'); $bodyLines[] = "Tags: " . (string)($payload['tags_count'] ?? '0'); $bodyLines[] = " "; $bodyLines[] = "--- 404 Solution Counts ---"; $bodyLines[] = "Total redirects (active): " . (string)($payload['redirects_active_total'] ?? '0'); $bodyLines[] = " - Manual redirects: " . (string)($payload['redirects_manual_count'] ?? '0'); $bodyLines[] = " - Automatic redirects: " . (string)($payload['redirects_automatic_count'] ?? '0'); $bodyLines[] = " - Regex redirects: " . (string)($payload['redirects_regex_count'] ?? '0'); $bodyLines[] = " - Trashed redirects: " . (string)($payload['redirects_trashed_count'] ?? '0'); $bodyLines[] = "Captured 404s (active): " . (string)($payload['captured_404s_active_total'] ?? '0'); $bodyLines[] = " - Captured (new): " . (string)($payload['captured_404s_new_count'] ?? '0'); $bodyLines[] = " - Ignored: " . (string)($payload['captured_404s_ignored_count'] ?? '0'); $bodyLines[] = " - Later: " . (string)($payload['captured_404s_later_count'] ?? '0'); $bodyLines[] = " - Trashed: " . (string)($payload['captured_404s_trashed_count'] ?? '0'); $bodyLines[] = "Log entries in database: " . (string)($payload['log_entries_count'] ?? '0'); $bodyLines[] = "Log table size: " . $logTableSizeMB . " MB"; $bodyLines[] = " "; $bodyLines[] = "Total error count in log file: " . $totalErrorCount; $bodyLines[] = "Debug file name: " . $this->getDebugFilename(); $bodyLines[] = "Debug file size: " . $debugFileSizeMB . " MB"; $bodyLines[] = "Active plugins:
" .
          json_encode($activePlugins, JSON_PRETTY_PRINT) . "
"; $body = implode("
\n", $bodyLines); $headers = array('Content-Type: text/html; charset=UTF-8'); $headers[] = 'From: ' . get_option('admin_email'); $attachments = array(); if (file_exists($logFileZip)) { $attachments[] = $logFileZip; } $this->debugMessage("Sending error log zip file as attachment."); $result = wp_mail($to, $subject, $body, $headers, $attachments); if (file_exists($logFileZip)) { ABJ_404_Solution_Functions::safeUnlink($logFileZip); } $this->debugMessage("Mail sent. Log zip file deleted."); return (bool)$result; } /** * @return array{num: int, line: string|null, total_error_count: int} */ function getLatestErrorLine(): array { $f = abj_service('functions'); $latestErrorLineFound = array(); $latestErrorLineFound['num'] = -1; $latestErrorLineFound['line'] = null; $latestErrorLineFound['total_error_count'] = 0; $linesRead = 0; $handle = null; $collectingErrorLines = false; try { $debugPath = $this->getDebugFilePath(); // Check existence before fopen so PHP does not emit a warning on a // missing debug file. The file is absent on fresh installs and in // most test fixtures. Return the empty initialized array (no error // line) in that case rather than letting fopen warn and return // false. failOnWarning=true in phpunit.xml means an unguarded // warning here trips the whole preflight gate. if (!is_string($debugPath) || $debugPath === '' || !file_exists($debugPath)) { return $latestErrorLineFound; } if ($handle = fopen($debugPath, "r")) { // read the file one line at a time. while (($line = fgets($handle)) !== false) { $linesRead++; // if the line has an error then save the line number. $hasError = stripos($line, '(ERROR)'); $isDeleteError = stripos($line, 'SQL query error: DELETE command denied to user'); if ($hasError !== false && $isDeleteError === false) { $latestErrorLineFound['num'] = $linesRead; $latestErrorLineFound['line'] = $line; $latestErrorLineFound['total_error_count'] += 1; $collectingErrorLines = true; } else if ($collectingErrorLines && !$f->regexMatch("^\d{4}[-]\d{2}[-]\d{2} .*\(\w+\):\s.*$", $line)) { // if we're collecting error lines and we haven't found the // beginning of a new debug message then continue collecting lines. $latestErrorLineFound['line'] .= "
\n" . $line; } else { // this must be the beginning of a new debug message so we'll stop // collecting error lines. $collectingErrorLines = false; } } } else { $this->errorMessage("Error reading log file (1)."); } } catch (Exception $e) { $this->errorMessage("Error reading log file. (2)", $e); } if ($handle != null) { fclose($handle); } return $latestErrorLineFound; } /** * Get sanitized log excerpt for support emails * Collects last 15 ERROR/WARN entries (already sanitized at write-time) * If no errors/warnings found, includes last 20 lines of log for context * * @return string Sanitized log excerpt or message if no errors found */ function getSanitizedLogExcerptForSupport() { $f = abj_service('functions'); $errorEntries = array(); $recentLines = array(); $maxEntries = 15; $maxRecentLines = 20; $totalLines = 0; $handle = null; try { $debugFilePath = $this->getDebugFilePath(); if (!file_exists($debugFilePath)) { return "No log file available"; } if ($handle = fopen($debugFilePath, "r")) { $currentEntry = array(); $collectingEntry = false; // Read file line by line while (($line = fgets($handle)) !== false) { $totalLines++; // Keep a sliding window of recent lines (for fallback if no errors) $recentLines[] = $line; if (count($recentLines) > $maxRecentLines) { array_shift($recentLines); } // Check if this is an ERROR or WARN line $hasError = stripos($line, '(ERROR)') !== false; $hasWarn = stripos($line, '(WARN)') !== false; $isDeleteError = stripos($line, 'SQL query error: DELETE command denied to user') !== false; // Start collecting if we find ERROR or WARN (but skip known benign errors) if (($hasError || $hasWarn) && !$isDeleteError) { // If we were collecting a previous entry, save it if ($collectingEntry && !empty($currentEntry)) { $errorEntries[] = $currentEntry; // Keep only last N entries (sliding window) if (count($errorEntries) > $maxEntries) { array_shift($errorEntries); } } // Start new entry (no sanitization needed - already done at write-time) $currentEntry = array($line); $collectingEntry = true; } else if ($collectingEntry && !$f->regexMatch("^\d{4}[-]\d{2}[-]\d{2} .*\(\w+\):\s.*$", $line)) { // Continue collecting multiline error (no sanitization needed - already done at write-time) $currentEntry[] = $line; } else { // New log entry started, save previous if exists if ($collectingEntry && !empty($currentEntry)) { $errorEntries[] = $currentEntry; if (count($errorEntries) > $maxEntries) { array_shift($errorEntries); } } $collectingEntry = false; $currentEntry = array(); } } // Save last entry if we were still collecting if ($collectingEntry && !empty($currentEntry)) { $errorEntries[] = $currentEntry; if (count($errorEntries) > $maxEntries) { array_shift($errorEntries); } } fclose($handle); } else { return "Log file not readable"; } } catch (Exception $e) { return "Error reading log file"; } // Format output if (empty($errorEntries)) { // No errors/warnings found - include last N lines for context if (empty($recentLines)) { return "Log file is empty"; } $output = "No ERROR/WARN entries found. Last " . count($recentLines) . " log lines:\n\n"; $output .= implode("", $recentLines); return trim($output); } $output = "Last " . count($errorEntries) . " ERROR/WARN entries:\n\n"; foreach ($errorEntries as $entry) { $output .= implode("\n", $entry) . "\n\n"; } return trim($output); } /** * Mask email address with adaptive length-based masking * Shows 1-3 chars of username and ≤30% of domain based on length * * Examples: * - joe@mail.com → j***@m***-a1b2 * - john@gmail.com → j***@gm***-c3d4 * - jennifer@example.com → jen***@exa***-e5f6 * * @param string $email Email address to mask * @return string Masked email with consistent hash */ private function maskEmailAdaptive($email) { if (empty($email) || strpos($email, '@') === false) { return $email; } // Split email into parts $parts = explode('@', $email); if (count($parts) != 2) { // Invalid email (multiple @), mask entire string as text return $this->maskTextAdaptive($email); } list($username, $fullDomain) = $parts; // Strip TLD from domain (remove .com, .org, .co.uk, etc.) $domainParts = explode('.', $fullDomain); if (count($domainParts) > 1) { // Remove last part (.com), or last 2 parts if it's .co.uk style if (in_array(end($domainParts), array('uk', 'au', 'nz', 'za'))) { // .co.uk style - remove last 2 parts array_pop($domainParts); array_pop($domainParts); } else { // .com style - remove last part array_pop($domainParts); } } $domain = implode('.', $domainParts); // Calculate visible characters for username (1-3 based on length) $usernameLen = strlen($username); if ($usernameLen <= 4) { $usernameVisible = 1; } elseif ($usernameLen <= 9) { $usernameVisible = 2; } else { $usernameVisible = 3; } // Calculate visible characters for domain (≤30%) $domainLen = strlen($domain); $domainVisible = max(1, (int) ceil($domainLen * 0.3)); // Create masked parts $maskedUsername = substr($username, 0, $usernameVisible) . '***'; $maskedDomain = empty($domain) ? '' : substr($domain, 0, $domainVisible) . '***'; // Generate consistent hash with WordPress salt for security if (defined('AUTH_SALT')) { $hash = substr(md5(AUTH_SALT . $email), 0, 4); } else { $hash = substr(md5($email), 0, 4); } // Format: username***@domain***-hash if (!empty($maskedDomain)) { return $maskedUsername . '@' . $maskedDomain . '-' . $hash; } else { return $maskedUsername . '@-' . $hash; } } /** * Mask text (names, usernames) with adaptive length-based masking * Shows 1-3 chars based on length + consistent hash * * Examples: * - Joe → J***-a1b2 * - John → J***-c3d4 * - Jennifer → Jen***-e5f6 * * @param string $text Text to mask * @return string Masked text with consistent hash */ private function maskTextAdaptive($text) { if (empty($text)) { return $text; } $text = trim($text); $textLen = strlen($text); // Calculate visible characters (1-3 based on length) if ($textLen <= 4) { $visible = 1; } elseif ($textLen <= 9) { $visible = 2; } else { $visible = 3; } $masked = substr($text, 0, $visible) . '***'; // Generate consistent hash with WordPress salt if (defined('AUTH_SALT')) { $hash = substr(md5(AUTH_SALT . $text), 0, 4); } else { $hash = substr(md5($text), 0, 4); } return $masked . '-' . $hash; } /** * Look up the live WordPress table prefix for PII redaction. * * Reads $wpdb->prefix when available so a custom prefix like * 'wp_siddur_' can be normalised to 'wp_' in log lines. Returns '' * when $wpdb is not loaded (very early boot, some test fixtures), in * which case the caller must skip the rewrite rather than guess. * * @return string */ private function getActualPrefixForRedaction(): string { if (isset($GLOBALS['wpdb']) && is_object($GLOBALS['wpdb'])) { $wpdb = $GLOBALS['wpdb']; if (isset($wpdb->prefix) && is_string($wpdb->prefix) && $wpdb->prefix !== '') { return $wpdb->prefix; } } return ''; } /** * Look up the live database name for PII redaction. * * Prefers $wpdb->dbname (set by WP after wp-config) and falls back to * the DB_NAME constant (defined the moment wp-config loads). Returns * '' when neither is available so the caller can skip the rewrite. * * @return string */ private function getActualDatabaseNameForRedaction(): string { if (isset($GLOBALS['wpdb']) && is_object($GLOBALS['wpdb'])) { $wpdb = $GLOBALS['wpdb']; if (isset($wpdb->dbname) && is_string($wpdb->dbname) && $wpdb->dbname !== '') { return $wpdb->dbname; } } if (defined('DB_NAME')) { $name = constant('DB_NAME'); if (is_string($name) && $name !== '') { return $name; } } return ''; } /** * Sanitize a single log line for privacy (GDPR compliance) * Uses adaptive masking with consistent hashing for debugging * * @param string $line Log line to sanitize * @return string Sanitized line with PII masked adaptively */ public function sanitizeLogLine($line) { $f = abj_service('functions'); // Strip query strings from URLs (everything after ? in http/https URLs) // This removes tokens, emails, session IDs, search terms, etc. from URLs $line = preg_replace('/(https?:\/\/[^\s?]+)\?[^\s]*/', '$1', $line) ?? $line; // F6: strip query strings from path-only URIs too (e.g. REQUEST_URI // appended by errorMessage(): "Requested URL: /admin.php?page=foo&secret=xyz"). // The scheme'd-URL rule above does not catch these because REQUEST_URI // carries no scheme. Without this strip, short fragments (under the // transport-side `\d{4,}` normalization floor) survive truncation // into recent_error_signatures. // // Lookbehind blocks matches inside email addresses, scheme tails // (`http:/`), and other contexts where a slash is already part of a // token. The path body is `[^\s?#]*`, which stops at whitespace, // fragment start, or query start, so adjacent log fields stay intact. $line = preg_replace('/(? j***@exa***-a1b2 $line = preg_replace_callback( '/\S+@\S+/', function($matches) { return $this->maskEmailAdaptive($matches[0]); }, $line ) ?? $line; // Redact IP addresses using existing md5lastOctet function // Keeps first octets, hashes last (e.g., 192.168.1.100 -> 192.168.1.md5hash) $line = preg_replace_callback( '/\b(?:\d{1,3}\.){3}\d{1,3}\b/', function($matches) use ($f) { return $f->md5lastOctet($matches[0]); }, $line ) ?? $line; // Redact IPv6 addresses (including compressed forms) using existing md5lastOctet function // Negative lookbehind prevents matching mid-hex-string; handles ::1, 2001:db8::1, etc. $line = preg_replace_callback( '/(?md5lastOctet($matches[0]); }, $line ) ?? $line; // Mask usernames with adaptive length-based masking // Example: "Current user: john" -> "Current user: j***-a1b2" $line = preg_replace_callback( '/\b(current\s+)?user(name)?:\s*(\S+)/i', function($matches) { $prefix = $matches[1] . 'user' . $matches[2] . ': '; $username = $matches[3]; return $prefix . $this->maskTextAdaptive($username); }, $line ) ?? $line; // Mask display names with adaptive length-based masking // Example: "Display name: John Doe" -> "Display name: J***-a1b2" $line = preg_replace_callback( '/\bdisplay\s+name:\s*([^\n,]+)/i', function($matches) { $name = trim($matches[1]); return 'display name: ' . $this->maskTextAdaptive($name); }, $line ) ?? $line; // Redact absolute file paths to prevent server-path disclosure. // // Stack traces format paths as: // #0 /home/user/public_html/wp-includes/class.php(123): method() // thrown in /var/www/html/wp-content/plugins/foo/bar.php on line 45 // // The document root varies per host (/home/user/, /var/www/, /srv/www/, // /Users/username/, etc.) but WordPress always has recognisable sub-dirs. // Replace everything before the WP marker so the marker itself is kept // (aids debugging) while the host-specific prefix is hidden. // // Output uses the canonical short form (e.g. " /wp-content/...") that // matches what a default install would log. WP.org topic 18908598: // the reporter manually rewrote /home/user/site/wp-content/... to // /wp-content/... before sharing logs; the auto-redactor produces the // same shape so logs stay diagnosable without further hand-editing. // // Covered markers: wp-content, wp-admin, wp-includes, wp-login.php, // wp-config.php, wp-cron.php, wp-blog-header.php $wpUnixMarkers = 'wp-content|wp-admin|wp-includes|wp-login\\.php|wp-config\\.php|wp-cron\\.php|wp-blog-header\\.php'; // Unix paths: preceded by start-of-string, whitespace, or (#/digit/paren // that appear in stack-trace lines like "#0 /path..." or "(thrown in /path...") $line = preg_replace( '/(^|[\s\(])(\/[^\s\(]+?)\/(' . $wpUnixMarkers . ')\b/i', '$1/$3', $line ) ?? $line; // Windows paths: same markers, backslash separators. // e.g. C:\inetpub\wwwroot\wp-content\ -> \wp-content\ // Each \\ in the pattern string matches one literal backslash in the path. $line = preg_replace( '/\b[a-z]:\\\\[^\s]+\\\\(' . $wpUnixMarkers . ')\b/i', '\\\\$1', $line ) ?? $line; // Redact the actual database name to a generic 'dbname' placeholder // and the actual table prefix to the default 'wp_' so messages like // Table 'mydb_xyz.wp_siddur_abj404_view_build' doesn't exist // become // Table 'dbname.wp_abj404_view_build' doesn't exist // The output mimics a vanilla WordPress install so the maintainer // can still recognise table names at a glance, while the host's // schema name and obfuscation prefix stay private. Driven by WP.org // topic 18908598 where the reporter redacted both manually before // sharing the log. // // Both helpers fall back to '' when $wpdb is not available (very // early boot, test fixtures with no DB), in which case the rewrite // is skipped instead of guessing. $dbname = $this->getActualDatabaseNameForRedaction(); if ($dbname !== '' && strlen($dbname) >= 3 && $dbname !== 'dbname') { // Match the dbname only in qualified-identifier contexts: a // following '.' (Table 'db.table') or '`' (`db`.`table`). The // negative lookbehind keeps it from matching mid-identifier or // mid-word, so a dbname that happens to be a common substring // does not bleed into unrelated log text. $line = preg_replace( '/(?getActualPrefixForRedaction(); if ($prefix !== '' && $prefix !== 'wp_') { // Match the prefix when it precedes a table-name character // (letter), so 'wp_siddur_abj404_X' becomes 'wp_abj404_X' but // a standalone occurrence (or one mid-token) is left alone. $line = preg_replace( '/(? "token-a1b2c3d4" $line = preg_replace_callback( '/\b([A-Za-z0-9_-]{40,})\b/', function($matches) { $hash = substr(md5($matches[1]), 0, 8); return 'token-' . $hash; }, $line ) ?? $line; // Hash WordPress nonces consistently // Example: "_wpnonce=abc123" -> "_wpnonce=nonce-a1b2c3d4" $line = preg_replace_callback( '/_wpnonce=([A-Za-z0-9]+)/', function($matches) { $hash = substr(md5($matches[1]), 0, 8); return '_wpnonce=nonce-' . $hash; }, $line ) ?? $line; return $line; } /** Return the path to the debug file. * @return string */ function getDebugFilePath() { $debugFileName = $this->getDebugFilename(); return $this->getFilePathAndMoveOldFile(abj404_getUploadsDir(), $debugFileName); } /** @return string */ function getDebugFilename(): string { // The is_object() / method_exists() guards below catch the static // unreachability cases (container miss, factory returned null), but // they cannot catch a Throwable raised from inside the resolved call // — getOptions() may surface a DB read failure, uniqidReal() may // raise on a corrupt random source, updateOptions() may fail to // persist. writeLineToDebugFile() promises non-throwing; any escape // from this method violates that contract. Absorb every Throwable // and return the deterministic fallback name so logging stays // available even when upstream services are degraded. try { // get the UUID here. $abj404logic = abj_service('plugin_logic'); // abj_service returns null when the container is uninitialised // or the factory threw — common during very-early boot, the // test harness, and self-healing recovery from broken installs. if (!is_object($abj404logic) || !method_exists($abj404logic, 'getOptions')) { return 'abj404_debug.txt'; } $options = $abj404logic->getOptions(true); $debugFileKey = null; if (is_array($options) && array_key_exists(self::DEBUG_FILE_KEY, $options)) { $debugFileKey = is_string($options[self::DEBUG_FILE_KEY]) ? $options[self::DEBUG_FILE_KEY] : null; } // if the key doesn't exist then create it. if ($debugFileKey === null || trim($debugFileKey) === '') { // delete any lingering debug files. $this->deleteDebugFile(); // create a probably unique UUID and store it to the database. $syncUtils = abj_service('sync_utils'); if (!is_object($syncUtils) || !method_exists($syncUtils, 'uniqidReal')) { return 'abj404_debug.txt'; } $debugFileKey = $syncUtils->uniqidReal(); $options[self::DEBUG_FILE_KEY] = $debugFileKey; if (method_exists($abj404logic, 'updateOptions')) { $abj404logic->updateOptions($options); } } return 'abj404_debug_' . $debugFileKey . '.txt'; } catch (\Throwable $e) { return 'abj404_debug.txt'; } } /** @return string */ function getDebugFilePathOld(): string { return $this->getDebugFilePath() . "_old.txt"; } /** Return the path to the file that stores the latest error line in the log file. * @return string */ function getDebugFilePathSentFile() { return $this->getFilePathAndMoveOldFile(abj404_getUploadsDir(), 'abj404_debug_sent_line.txt'); } /** Return the path to the zip file for sending the debug file. * @return string */ function getZipFilePath() { return $this->getFilePathAndMoveOldFile(abj404_getUploadsDir(), 'abj404_debug.zip'); } /** This is for legacy support. On new installations it creates a directory and returns * a file path. On old installations it moved the old file to the new location. * If the directory can't be created then it falls back to the old location. * @param string $directory * @param string $filename * @return string */ function getFilePathAndMoveOldFile($directory, $filename) { $f = abj_service('functions'); // create the directory and move the file if (!$f->createDirectoryWithErrorMessages($directory)) { return ABJ404_PATH . $filename; } if (file_exists(ABJ404_PATH . $filename)) { // move the file to the new location rename(ABJ404_PATH . $filename, $directory . $filename); } return $directory . $filename; } /** @return void */ function limitDebugFileSize(): void { // delete the sent_line file since it's now incorrect. if (file_exists($this->getDebugFilePathSentFile())) { ABJ_404_Solution_Functions::safeUnlink($this->getDebugFilePathSentFile()); } // update the last sent error line since the debug file will be deleted. $this->removeLastSentErrorLineFromDatabase(); // delete _old log file ABJ_404_Solution_Functions::safeUnlink($this->getDebugFilePathOld()); // rename current log file to _old rename($this->getDebugFilePath(), $this->getDebugFilePathOld()); } /** @return void */ function removeLastSentErrorLineFromDatabase(): void { // update the last sent error line since the debug file will be deleted. $abj404logic = abj_service('plugin_logic'); $options = $abj404logic->getOptions(true); $options[self::LAST_SENT_LINE] = 0; $abj404logic->updateOptions($options); } /** Deletes all files named abj404_debug_*.txt * @return boolean true if the file was deleted. */ function deleteDebugFile() { $abj404logic = abj_service('plugin_logic'); $allIsWell = true; // since the debug file is being deleted we reset the last error line that was sent. if (file_exists($this->getDebugFilePathSentFile())) { ABJ_404_Solution_Functions::safeUnlink($this->getDebugFilePathSentFile()); } // update the last sent error line since the debug file will be deleted. $this->removeLastSentErrorLineFromDatabase(); // delete the debug file(s). // list any files in the directory and delete any files named debug_*.txt $uploadDir = abj404_getUploadsDir(); // Check if the directory exists if (is_dir($uploadDir)) { // Get all files matching the pattern abj404_debug_*.txt $files = glob($uploadDir . '/abj404_debug_*.txt'); if (!is_array($files)) { $files = array(); } foreach ($files as $file) { // Loop through the files and delete them if (is_file($file)) { // Delete the file if (!ABJ_404_Solution_Functions::safeUnlink($file)) { $allIsWell = false; } } } } // reset the UUID since we deleted the log file. $options = $abj404logic->getOptions(true); $options[self::DEBUG_FILE_KEY] = null; $abj404logic->updateOptions($options); return $allIsWell; } /** * @return int file size in bytes */ function getDebugFileSize() { $file1Size = 0; $file2Size = 0; if (file_exists($this->getDebugFilePath())) { $file1Size = filesize($this->getDebugFilePath()); } if (file_exists($this->getDebugFilePathOld())) { $file2Size = filesize($this->getDebugFilePathOld()); } return $file1Size + $file2Size; } }