| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* Handles direct wpdb write recovery for log queue flushes. |
| 9 |
*/ |
| 10 |
class ABJ_404_Solution_LogsWriteRecoveryPolicy { |
| 11 |
|
| 12 |
/** @var ABJ_404_Solution_Logging */ |
| 13 |
private $logger; |
| 14 |
|
| 15 |
/** @var ABJ_404_Solution_DatabaseNoticeStateHolder */ |
| 16 |
private $noticeState; |
| 17 |
|
| 18 |
/** |
| 19 |
* @param ABJ_404_Solution_Logging $logger |
| 20 |
* @param ABJ_404_Solution_DatabaseNoticeStateHolder $noticeState |
| 21 |
*/ |
| 22 |
public function __construct( |
| 23 |
$logger, |
| 24 |
ABJ_404_Solution_DatabaseNoticeStateHolder $noticeState |
| 25 |
) { |
| 26 |
$this->logger = $logger; |
| 27 |
$this->noticeState = $noticeState; |
| 28 |
} |
| 29 |
|
| 30 |
public function isTableFullError(string $error): bool { |
| 31 |
$lower = strtolower($error); |
| 32 |
return stripos($lower, 'is full') !== false || stripos($lower, 'table full') !== false; |
| 33 |
} |
| 34 |
|
| 35 |
/** |
| 36 |
* Free space in logsv2 by deleting the oldest 1000 entries. Rate-limited |
| 37 |
* to once per hour via a transient cooldown. |
| 38 |
*/ |
| 39 |
public function autoTrimLogsv2IfNeeded(string $tableName, string $errorMessage): bool { |
| 40 |
if (!preg_match('/^[a-zA-Z0-9_]+$/', $tableName) || strpos($tableName, 'abj404_logsv2') === false) { |
| 41 |
$this->logger->warn("autoTrimLogsv2IfNeeded: rejected unexpected table name: " . substr($tableName, 0, 100)); |
| 42 |
return false; |
| 43 |
} |
| 44 |
$cooldownKey = 'abj404_logsv2_trim_cooldown_until'; |
| 45 |
$alreadyTrimmed = function_exists('get_transient') ? get_transient($cooldownKey) : false; |
| 46 |
if ($alreadyTrimmed) { |
| 47 |
return false; |
| 48 |
} |
| 49 |
global $wpdb; |
| 50 |
$trimSql = "DELETE FROM `{$tableName}` ORDER BY timestamp ASC LIMIT 1000"; |
| 51 |
// DAO-bypass-approved: recovery trim must run on the active wpdb handle so last_error reflects the immediate retry context. |
| 52 |
$wpdb->query($trimSql); |
| 53 |
$ttl = defined('HOUR_IN_SECONDS') ? (int) HOUR_IN_SECONDS : 3600; |
| 54 |
// @cache-write-audit: opt-out - log-trim cooldown marker, not query result data. |
| 55 |
if (function_exists('set_transient')) { |
| 56 |
set_transient($cooldownKey, 1, $ttl); |
| 57 |
} |
| 58 |
if (!empty($wpdb->last_error)) { |
| 59 |
$this->logger->warn("Log table full: auto-trim failed: " . $wpdb->last_error); |
| 60 |
} else { |
| 61 |
$this->logger->warn("Log table full: auto-trimmed 1000 oldest entries to free space."); |
| 62 |
} |
| 63 |
return true; |
| 64 |
} |
| 65 |
|
| 66 |
public function setLogsv2FullNotice(string $errorMessage): void { |
| 67 |
$this->noticeState->setPluginDbNotice( |
| 68 |
'log_table_full', |
| 69 |
function_exists('__') ? __('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 and cannot accept new entries. This is usually caused by a full disk. Please contact your host or manually prune the logs table.', |
| 70 |
function_exists('__') ? __('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') : '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.', |
| 71 |
$errorMessage |
| 72 |
); |
| 73 |
} |
| 74 |
|
| 75 |
public function getWpdbRecentQueryContextForLogs(): string { |
| 76 |
global $wpdb; |
| 77 |
if (!isset($wpdb) || !is_object($wpdb)) { |
| 78 |
return ''; |
| 79 |
} |
| 80 |
if (!defined('SAVEQUERIES') || SAVEQUERIES !== true) { |
| 81 |
return ''; |
| 82 |
} |
| 83 |
if (empty($wpdb->queries) || !is_array($wpdb->queries)) { |
| 84 |
return ''; |
| 85 |
} |
| 86 |
$recent = array_slice($wpdb->queries, -5); |
| 87 |
$parts = []; |
| 88 |
foreach ($recent as $q) { |
| 89 |
if (!is_array($q)) { |
| 90 |
continue; |
| 91 |
} |
| 92 |
$sql = $q[0] ?? ''; |
| 93 |
$time = $q[1] ?? null; |
| 94 |
$caller = $q[2] ?? ''; |
| 95 |
$hash = is_string($sql) ? substr(sha1($sql), 0, 10) : 'n/a'; |
| 96 |
$who = $this->extractWpComponentFromString(is_string($caller) ? $caller : ''); |
| 97 |
$t = is_numeric($time) ? round((float)$time, 3) : 'n/a'; |
| 98 |
$parts[] = "{$who}:{$hash}@{$t}"; |
| 99 |
} |
| 100 |
return implode(', ', $parts); |
| 101 |
} |
| 102 |
|
| 103 |
private function extractWpComponentFromString(string $text): string { |
| 104 |
$normalized = str_replace('\\', '/', $text); |
| 105 |
foreach (array('/wp-content/mu-plugins/' => 'mu-plugin', '/wp-content/plugins/' => 'plugin', '/wp-content/themes/' => 'theme') as $needle => $label) { |
| 106 |
$pos = strpos($normalized, $needle); |
| 107 |
if ($pos !== false) { |
| 108 |
$rest = substr($normalized, $pos + strlen($needle)); |
| 109 |
$name = explode('/', ltrim($rest, '/'))[0] ?? ''; |
| 110 |
return $name !== '' ? "{$label}:{$name}" : "{$label}:unknown"; |
| 111 |
} |
| 112 |
} |
| 113 |
return 'unknown'; |
| 114 |
} |
| 115 |
} |
| 116 |
|