logger = $logger; $this->noticeState = $noticeState; } public function isTableFullError(string $error): bool { $lower = strtolower($error); return stripos($lower, 'is full') !== false || stripos($lower, 'table full') !== false; } /** * Free space in logsv2 by deleting the oldest 1000 entries. Rate-limited * to once per hour via a transient cooldown. */ public function autoTrimLogsv2IfNeeded(string $tableName, string $errorMessage): bool { if (!preg_match('/^[a-zA-Z0-9_]+$/', $tableName) || strpos($tableName, 'abj404_logsv2') === false) { $this->logger->warn("autoTrimLogsv2IfNeeded: rejected unexpected table name: " . substr($tableName, 0, 100)); return false; } $cooldownKey = 'abj404_logsv2_trim_cooldown_until'; $alreadyTrimmed = function_exists('get_transient') ? get_transient($cooldownKey) : false; if ($alreadyTrimmed) { return false; } global $wpdb; $trimSql = "DELETE FROM `{$tableName}` ORDER BY timestamp ASC LIMIT 1000"; // DAO-bypass-approved: recovery trim must run on the active wpdb handle so last_error reflects the immediate retry context. ABJ_404_Solution_DatabaseDriverCall::query($wpdb, $trimSql); $ttl = defined('HOUR_IN_SECONDS') ? (int) HOUR_IN_SECONDS : 3600; // @cache-write-audit: opt-out - log-trim cooldown marker, not query result data. if (function_exists('set_transient')) { set_transient($cooldownKey, 1, $ttl); } if (!empty($wpdb->last_error)) { $this->logger->warn("Log table full: auto-trim failed: " . $wpdb->last_error); } else { $this->logger->warn("Log table full: auto-trimmed 1000 oldest entries to free space."); } return true; } public function setLogsv2FullNotice(string $errorMessage): void { ABJ_404_Solution_DeferredTranslation::whenReady(function () use ($errorMessage): void { $this->noticeState->setPluginDbNotice( 'log_table_full', __('The 404 Solution log table is full and cannot accept new entries. This is usually caused by a full disk. Please contact your host or manually prune the logs table.', '404-solution'), __('The 404 Solution log table is full. The plugin automatically trimmed the oldest 1,000 log entries to free space, but logging may still be limited. Please contact your hosting provider about disk space.', '404-solution'), $errorMessage ); }); } /** * Clear the log-full notice only after wpdb evidence confirms an accepted * logsv2 INSERT and no driver error remains. A positive raw result is * sufficient; rows_affected is optional metadata and, when supplied, must * also be a positive integer. * * @param array $writeEvidence * @return void */ public function clearLogsv2FullNoticeAfterWrite(array $writeEvidence): void { if (!array_key_exists('result', $writeEvidence) || !array_key_exists('last_error', $writeEvidence)) { return; } if (!is_string($writeEvidence['last_error']) || $writeEvidence['last_error'] !== '') { return; } $result = $writeEvidence['result']; $rowsAffected = $writeEvidence['rows_affected'] ?? null; if ($rowsAffected !== null && !is_int($rowsAffected)) { return; } $acceptedResult = is_int($result) && $result > 0; if (!$acceptedResult) { return; } if (is_int($rowsAffected) && $rowsAffected <= 0) { return; } $this->noticeState->clearPluginDbNoticeIfType('log_table_full'); } public function getWpdbRecentQueryContextForLogs(): string { global $wpdb; if (!isset($wpdb) || !is_object($wpdb)) { return ''; } if (!defined('SAVEQUERIES') || SAVEQUERIES !== true) { return ''; } if (empty($wpdb->queries) || !is_array($wpdb->queries)) { return ''; } $recent = array_slice($wpdb->queries, -5); $parts = []; foreach ($recent as $q) { if (!is_array($q)) { continue; } $sql = $q[0] ?? ''; $time = $q[1] ?? null; $caller = $q[2] ?? ''; $hash = is_string($sql) ? substr(sha1($sql), 0, 10) : 'n/a'; $who = $this->extractWpComponentFromString(is_string($caller) ? $caller : ''); $t = is_numeric($time) ? round((float)$time, 3) : 'n/a'; $parts[] = "{$who}:{$hash}@{$t}"; } return implode(', ', $parts); } private function extractWpComponentFromString(string $text): string { $normalized = str_replace('\\', '/', $text); foreach (array('/wp-content/mu-plugins/' => 'mu-plugin', '/wp-content/plugins/' => 'plugin', '/wp-content/themes/' => 'theme') as $needle => $label) { $pos = strpos($normalized, $needle); if ($pos !== false) { $rest = substr($normalized, $pos + strlen($needle)); $name = explode('/', ltrim($rest, '/'))[0] ?? ''; return $name !== '' ? "{$label}:{$name}" : "{$label}:unknown"; } } return 'unknown'; } }