PluginProbe
404 Solution / 4.3.3
404 Solution v4.3.3
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 / database / DatabaseTableRepairer.php

DatabaseTableRepairer.php in 404 Solution 4.3.3, at includes/database/DatabaseTableRepairer.php

447 lines 19.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Self-healing table repair + invalid-data retry for plugin SQL queries.
9 *
10 * Extracted from DatabaseCore as part of the (4/6) DatabaseCore decomposition.
11 * Owns two cohesive responsibilities that are both error-driven, single-retry,
12 * recursion-guarded recovery paths for SQL errors observed by queryAndGetResults:
13 *
14 * 1. REPAIR TABLE for "marked as crashed" and "Incorrect key file" errors.
15 * Parses the affected table name out of the wpdb error message, validates
16 * it as a plugin table (abj404 prefix only), runs REPAIR TABLE, and (for
17 * the "Incorrect key file" path) flushes and retries the original query
18 * once. If the table name cannot be sanitized, surfaces a deduplicated
19 * admin notice instead.
20 * 2. Invalid-data retry. When wpdb reports an invalid-data error, asks
21 * WPDBExtension to strip the invalid bytes from the query, then flushes
22 * and retries the stripped query once. Recursion guard prevents infinite
23 * loops if the stripped query also fails.
24 * 3. Duplicate-id repair for the ALTER TABLE auto_increment resequencing
25 * failure mode. Parses the duplicate id out of the error and the target
26 * table out of the SQL, validates both, and deletes the conflicting row.
27 *
28 * This class holds no DatabaseCore back-reference. It receives:
29 * - a query-runner callable bound over DatabaseCore::queryAndGetResults
30 * (signature: function(string, array<string,mixed>): array<string,mixed>);
31 * - a result-harvester callable bound over DatabaseWpdbResultHarvester
32 * (signature: function(array<string,mixed>): void, by-reference);
33 * - a result-type getter bound over DatabaseCore::getCurrentResultType
34 * (signature: function(): string);
35 * - a notice setter bound over DatabaseNoticeStateHolder::setPluginDbNotice
36 * (signature: function(string, string, string, string): void);
37 * - a connection-reset callable bound over
38 * DatabaseConnectionManager::resetForRetry (signature: function(string): bool);
39 * - ABJ_404_Solution_Functions for regex/string helpers and the plugin logger.
40 *
41 * The recursion guards are static properties on this class (they must survive
42 * across helper invocations within a single request) and are functionally
43 * identical to the former DatabaseCore::$tableRepairInProgress and
44 * DatabaseCore::$invalidDataRetryInProgress.
45 */
46 class ABJ_404_Solution_DatabaseTableRepairer {
47
48 /** @var bool Prevent recursive auto-repair attempts on SQL errors. */
49 private static $tableRepairInProgress = false;
50
51 /** @var bool Prevent recursive invalid-data retry attempts. */
52 private static $invalidDataRetryInProgress = false;
53
54 /** @var callable(string, array<string,mixed>): array<string,mixed> */
55 private $queryRunner;
56
57 /** @var callable(array<string,mixed>): void */
58 private $resultHarvester;
59
60 /** @var callable(): string */
61 private $resultTypeGetter;
62
63 /** @var callable(string, string, string, string): void */
64 private $noticeSetter;
65
66 /** @var callable(string): bool */
67 private $connectionResetter;
68
69 /** @var ABJ_404_Solution_Functions */
70 private $f;
71
72 /** @var ABJ_404_Solution_Logging */
73 private $logger;
74
75 /**
76 * @param callable(string, array<string,mixed>): array<string,mixed> $queryRunner
77 * Runs a SQL query through the centralized error-handling pipeline and
78 * returns its result array. Bound by DatabaseCore over queryAndGetResults().
79 * @param callable(array<string,mixed>): void $resultHarvester
80 * Copies wpdb->last_error / rows_affected / insert_id into the result array,
81 * by reference. Bound by DatabaseCore over DatabaseWpdbResultHarvester.
82 * @param callable(): string $resultTypeGetter
83 * Returns the current wpdb result type (ARRAY_A or OBJECT) for retries.
84 * @param callable(string, string, string, string): void $noticeSetter
85 * Persists a plugin-db admin notice (type, message, guidance, errorString).
86 * @param callable(string): bool $connectionResetter
87 * Resets the active wpdb connection before a recovery retry.
88 * @param ABJ_404_Solution_Functions $functions
89 * @param ABJ_404_Solution_Logging $logger
90 */
91 public function __construct(
92 callable $queryRunner,
93 callable $resultHarvester,
94 callable $resultTypeGetter,
95 callable $noticeSetter,
96 callable $connectionResetter,
97 $functions,
98 $logger
99 ) {
100 $this->queryRunner = $queryRunner;
101 $this->resultHarvester = $resultHarvester;
102 $this->resultTypeGetter = $resultTypeGetter;
103 $this->noticeSetter = $noticeSetter;
104 $this->connectionResetter = $connectionResetter;
105 $this->f = $functions;
106 $this->logger = $logger;
107 }
108
109 /**
110 * Reset the static recursion guards. Intended for test setUp/tearDown only.
111 *
112 * @return void
113 */
114 public static function resetRecursionGuardsForTests(): void {
115 self::$tableRepairInProgress = false;
116 self::$invalidDataRetryInProgress = false;
117 }
118
119 /** @return bool */
120 public function isTableRepairInProgress(): bool {
121 return self::$tableRepairInProgress;
122 }
123
124 /** @param bool $value @return void */
125 public function setTableRepairInProgress(bool $value): void {
126 self::$tableRepairInProgress = $value;
127 }
128
129 /**
130 * Validate and sanitize a table name extracted from error messages or SQL.
131 *
132 * Rejects anything that isn't [A-Za-z0-9_]+ and anything that doesn't
133 * contain the plugin's "abj404" prefix substring. Logs (warn) on reject so
134 * the rejection is visible at audit time without surfacing to the admin.
135 *
136 * @param string $name Raw table name (may include backticks).
137 * @return string|null Sanitized name, or null if invalid.
138 */
139 public function sanitizeTableName(string $name): ?string {
140 $name = trim($name, '`');
141 if (!preg_match('/^[a-zA-Z0-9_]+$/', $name)) {
142 $this->logger->warn("sanitizeTableName: rejected invalid table name: " . substr($name, 0, 100));
143 return null;
144 }
145 if (strpos($name, 'abj404') === false) {
146 $this->logger->warn("sanitizeTableName: rejected non-plugin table name: " . $name);
147 return null;
148 }
149 return $name;
150 }
151
152 /**
153 * Run REPAIR TABLE for a table named in a wpdb error string.
154 *
155 * Recognizes both the "is marked as crashed" message and the "Incorrect
156 * key file for table" message. If the table name cannot be sanitized
157 * (typically because it is a temporary table outside our prefix, e.g.
158 * "#sql_xxx_0.MYI" on a corrupted-disk host), falls back to a 24-hour
159 * deduplicated admin notice telling the site owner to contact their host.
160 *
161 * @param string $errorMessage The wpdb->last_error string.
162 * @return void
163 */
164 public function repairTable(string $errorMessage): void {
165 $re1 = "Table '(.*\/)?(.+)' is marked as crashed and ";
166 $re2 = "Incorrect key file for table '(?:.*\/)?([^'.]+?)(?:\\.MYI)?'";
167
168 $matches = array();
169 $this->f->regexMatch($re1, $errorMessage, $matches);
170
171 if (empty($matches) || count($matches) <= 2 || $this->f->strlen($matches[2]) === 0) {
172 $this->f->regexMatch($re2, $errorMessage, $matches);
173 if (!empty($matches) && isset($matches[1]) && $this->f->strlen($matches[1]) > 0) {
174 $matches[2] = $matches[1];
175 }
176 }
177
178 if (!empty($matches) && count($matches) > 2 && $this->f->strlen($matches[2]) > 0) {
179 $rawTableName = $matches[2];
180 $tableToRepair = $this->sanitizeTableName($rawTableName);
181 if ($tableToRepair !== null) {
182 $query = "REPAIR TABLE `{$tableToRepair}`";
183 $result = ($this->queryRunner)($query, array('log_errors' => false));
184 $this->logger->infoMessage("Attempted to repair table " . $tableToRepair . ". Result: " .
185 json_encode($result));
186 } else {
187 $this->logger->warn("The table " . $rawTableName . " needs to be " .
188 "repaired with something like: repair table " . $rawTableName);
189
190 $cooldownKey = 'abj404_corrupted_temp_table_notice_until';
191 $alreadyNotified = function_exists('get_transient') ? get_transient($cooldownKey) : false;
192 if (!$alreadyNotified) {
193 ($this->noticeSetter)(
194 'corrupted_temp_table',
195 function_exists('__') ? __('A database temporary table is corrupted - this is usually caused by a full or failing disk. Please contact your host. (MySQL error 1034)', '404-solution') : 'A database temporary table is corrupted - this is usually caused by a full or failing disk. Please contact your host. (MySQL error 1034)',
196 function_exists('__') ? __('A temporary MySQL table was corrupted, usually caused by disk or hardware issues. The plugin cannot repair it. Please contact your hosting provider.', '404-solution') : 'A temporary MySQL table was corrupted, usually caused by disk or hardware issues. The plugin cannot repair it. Please contact your hosting provider.',
197 $errorMessage
198 );
199 if (function_exists('set_transient')) {
200 // @cache-write-audit: opt-out - admin-notice dedup cooldown
201 // (one notice per 24h per failure type), not a query result.
202 set_transient($cooldownKey, 1, 86400);
203 }
204 }
205 }
206 }
207 }
208
209 /**
210 * Resolve the ALTER TABLE auto_increment resequencing duplicate-id case.
211 *
212 * Parses the duplicate id from the error message and the table name from
213 * the original ALTER TABLE SQL, validates both, and deletes the conflicting
214 * row so the ALTER can be retried by the caller.
215 *
216 * @param string $errorMessage The wpdb->last_error string.
217 * @param string $sqlThatWasRun The ALTER TABLE statement wpdb just ran.
218 * @return void
219 */
220 public function repairDuplicateIDs(string $errorMessage, string $sqlThatWasRun): void {
221 $reForID = 'resulting in duplicate entry \'(.+)\' for key';
222 $reForTableName = "ALTER TABLE (.+) ADD ";
223 $matchesForID = null;
224 $matchesForTableName = null;
225
226 $this->f->regexMatch($reForID, $errorMessage, $matchesForID);
227 $this->f->regexMatch($reForTableName, $sqlThatWasRun, $matchesForTableName);
228 if (is_array($matchesForID) && isset($matchesForID[1]) && $this->f->strlen($matchesForID[1]) > 0 &&
229 is_array($matchesForTableName) && isset($matchesForTableName[1]) && $this->f->strlen($matchesForTableName[1]) > 0) {
230
231 $idWithDuplicate = $matchesForID[1];
232 $tableName = $this->sanitizeTableName($matchesForTableName[1]);
233 if ($tableName === null) {
234 $this->logger->warn("repairDuplicateIDs: rejected invalid table name from SQL: " . substr($matchesForTableName[1], 0, 100));
235 return;
236 }
237
238 if (!is_numeric($idWithDuplicate)) {
239 $this->logger->errorMessage("Invalid ID extracted from error message: " . $idWithDuplicate);
240 return;
241 }
242
243 if ($idWithDuplicate == 1) {
244 $idWithDuplicate = 0;
245 }
246
247 $result = ($this->queryRunner)("DELETE FROM `{$tableName}` where id = %d",
248 array('log_errors' => false, 'query_params' => array(absint($idWithDuplicate))));
249 $this->logger->infoMessage("Attempted to fix a duplicate entry issue. Table: " .
250 $tableName . ", Result: " . json_encode($result));
251 }
252 }
253
254 /**
255 * Attempt REPAIR TABLE after MySQL errno 1034 ("Incorrect key file"), then
256 * retry the original query once.
257 *
258 * On retry success, $result is mutated to the retried result; on retry
259 * failure, $result carries the retried last_error so the surrounding
260 * pipeline can continue its error-handling.
261 *
262 * @param string $query
263 * @param array<string, mixed> $result Passed by reference.
264 * @param ABJ_404_Solution_DatabaseQueryRecoveryTracer|null $tracer
265 * @return void
266 */
267 public function repairCorruptedTableAndRetry(
268 string $query,
269 array &$result,
270 ?ABJ_404_Solution_DatabaseQueryRecoveryTracer $tracer = null
271 ): void {
272 $errorMessage = is_string($result['last_error']) ? $result['last_error'] : '';
273 $this->repairTable($errorMessage);
274 if (stripos($errorMessage, 'abj404') !== false) {
275 global $wpdb;
276 if ($tracer === null) {
277 if (!(($this->connectionResetter)($errorMessage))) {
278 return;
279 }
280 } else {
281 $reset = $tracer->traceOperation(
282 'corrupted_table',
283 'connection_retry_reset',
284 fn(): bool => ($this->connectionResetter)($errorMessage)
285 );
286 if (!$reset) {
287 return;
288 }
289 }
290 $resultType = ($this->resultTypeGetter)();
291 // DAO-bypass-approved: retry-after-repair is part of the DAO's
292 // self-healing pipeline; calling queryAndGetResults() here would
293 // re-enter the error-handler that just invoked us.
294 $retry = function () use ($wpdb, $query, $resultType): array {
295 $retried = array('rows' => $wpdb->get_results($query, $resultType));
296 $retried['last_error'] = (string)($wpdb->last_error ?? '');
297 $retried['last_result'] = $wpdb->last_result ?? array();
298 $retried['rows_affected'] = $wpdb->rows_affected ?? 0;
299 $retried['insert_id'] = $wpdb->insert_id ?? 0;
300 return $retried;
301 };
302 $retried = $tracer === null
303 ? $retry()
304 : $tracer->traceAttempt(
305 'corrupted_table',
306 'corrupted_table',
307 $retry
308 );
309 $result = array_merge($result, $retried);
310 if ($result['last_error'] === '') {
311 $this->logger->infoMessage("Retry after 'Incorrect key file' repair succeeded for plugin table.");
312 }
313 }
314 }
315
316 /**
317 * Attempt a single invalid-data retry by asking WPDBExtension to strip
318 * invalid bytes from the query, then re-running the stripped query.
319 *
320 * Recursion-guarded: if the stripped query also produces an invalid-data
321 * error, the second call short-circuits. The `abj404_invalid_data_retry_query`
322 * filter lets site owners override the stripped query (e.g. to enforce a
323 * stricter sanitization policy).
324 *
325 * @param string $query
326 * @param array<string, mixed> $result Passed by reference.
327 * @param ABJ_404_Solution_DatabaseQueryRecoveryTracer|null $tracer
328 * @return void
329 */
330 public function attemptInvalidDataRetry(
331 $query,
332 &$result,
333 ?ABJ_404_Solution_DatabaseQueryRecoveryTracer $tracer = null
334 ) {
335 if (self::$invalidDataRetryInProgress) {
336 return;
337 }
338 self::$invalidDataRetryInProgress = true;
339 try {
340 $prepareRetry = function () use ($query) {
341 $retryQuery = $this->get_stripped_query_result($query);
342 return function_exists('apply_filters')
343 ? apply_filters('abj404_invalid_data_retry_query', $retryQuery, $query)
344 : $retryQuery;
345 };
346 $retryQuery = $tracer === null
347 ? $prepareRetry()
348 : $tracer->traceOperation(
349 'invalid_data',
350 'retry_prepare',
351 $prepareRetry
352 );
353 if (!is_string($retryQuery) || trim($retryQuery) === '' || $retryQuery === $query) {
354 return;
355 }
356 global $wpdb;
357 $retryError = isset($result['last_error']) && is_scalar($result['last_error'])
358 ? (string)$result['last_error']
359 : '';
360 if ($tracer === null) {
361 if (!(($this->connectionResetter)($retryError))) {
362 return;
363 }
364 } else {
365 $reset = $tracer->traceOperation(
366 'invalid_data',
367 'connection_retry_reset',
368 fn(): bool => ($this->connectionResetter)($retryError)
369 );
370 if (!$reset) {
371 return;
372 }
373 }
374 $resultType = ($this->resultTypeGetter)();
375 // DAO-bypass-approved: retry-after-strip is part of the DAO's
376 // self-healing pipeline; calling queryAndGetResults() here would
377 // re-enter the error-handler that just invoked us.
378 $retry = function () use ($wpdb, $retryQuery, $resultType): array {
379 $retried = array('rows' => $wpdb->get_results($retryQuery, $resultType));
380 ($this->resultHarvester)($retried);
381 return $retried;
382 };
383 $retried = $tracer === null
384 ? $retry()
385 : $tracer->traceAttempt('invalid_data', 'invalid_data', $retry);
386 $result = array_merge($result, $retried);
387 } catch (Throwable $e) {
388 $this->logger->warn("Invalid-data retry failed: " . $e->getMessage());
389 } finally {
390 self::$invalidDataRetryInProgress = false;
391 }
392 }
393
394 /**
395 * Ask WPDBExtension to strip invalid bytes from a query string so the
396 * caller can re-issue a safe version.
397 *
398 * Returns null on any failure (missing extension file, wpdb method
399 * unavailable, DB constants undefined, extension constructor throws).
400 * Logs (warn) on exception so the caller does not need to.
401 *
402 * @param string $query
403 * @return NULL|string|WP_Error
404 */
405 public function get_stripped_query_result($query) {
406 try {
407 if (!class_exists('wpdb')) {
408 return null;
409 }
410 if (!method_exists('wpdb', 'strip_invalid_text_from_query')) {
411 return null;
412 }
413
414 $filename = ABJ404_PATH . 'includes/php/wordpress/WPDBExtension.php';
415 if (!file_exists($filename)) {
416 return null;
417 }
418 require_once $filename;
419
420 $my_custom_db = null;
421 if (class_exists('ABJ_404_Solution_WPDBExtension_PHP7')) {
422 $my_custom_db = new ABJ_404_Solution_WPDBExtension_PHP7(DB_USER, DB_PASSWORD, DB_NAME, DB_HOST);
423 } else if (class_exists('ABJ_404_Solution_WPDBExtension_PHP5')) {
424 $my_custom_db = new ABJ_404_Solution_WPDBExtension_PHP5(DB_USER, DB_PASSWORD, DB_NAME, DB_HOST);
425 }
426 if ($my_custom_db == null) {
427 return null;
428 }
429
430 $result = $my_custom_db->public_strip_invalid_text_from_query($query);
431
432 if (is_wp_error($result)) {
433 return 'WP_Error: ' . $result->get_error_message();
434 }
435
436 return $result;
437
438 } catch (Throwable $e) {
439 $this->logger->warn(
440 'get_stripped_query_result failed; returning null: ' . $e->getMessage()
441 );
442 return null;
443 }
444 }
445
446 }
447