PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.0
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.0, at includes/database/DatabaseTableRepairer.php

372 lines 16.4 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 * - ABJ_404_Solution_Functions for regex/string helpers and the plugin logger.
38 *
39 * The recursion guards are static properties on this class (they must survive
40 * across helper invocations within a single request) and are functionally
41 * identical to the former DatabaseCore::$tableRepairInProgress and
42 * DatabaseCore::$invalidDataRetryInProgress.
43 */
44 class ABJ_404_Solution_DatabaseTableRepairer {
45
46 /** @var bool Prevent recursive auto-repair attempts on SQL errors. */
47 private static $tableRepairInProgress = false;
48
49 /** @var bool Prevent recursive invalid-data retry attempts. */
50 private static $invalidDataRetryInProgress = false;
51
52 /** @var callable(string, array<string,mixed>): array<string,mixed> */
53 private $queryRunner;
54
55 /** @var callable(array<string,mixed>): void */
56 private $resultHarvester;
57
58 /** @var callable(): string */
59 private $resultTypeGetter;
60
61 /** @var callable(string, string, string, string): void */
62 private $noticeSetter;
63
64 /** @var ABJ_404_Solution_Functions */
65 private $f;
66
67 /** @var ABJ_404_Solution_Logging */
68 private $logger;
69
70 /**
71 * @param callable(string, array<string,mixed>): array<string,mixed> $queryRunner
72 * Runs a SQL query through the centralized error-handling pipeline and
73 * returns its result array. Bound by DatabaseCore over queryAndGetResults().
74 * @param callable(array<string,mixed>): void $resultHarvester
75 * Copies wpdb->last_error / rows_affected / insert_id into the result array,
76 * by reference. Bound by DatabaseCore over DatabaseWpdbResultHarvester.
77 * @param callable(): string $resultTypeGetter
78 * Returns the current wpdb result type (ARRAY_A or OBJECT) for retries.
79 * @param callable(string, string, string, string): void $noticeSetter
80 * Persists a plugin-db admin notice (type, message, guidance, errorString).
81 * @param ABJ_404_Solution_Functions $functions
82 * @param ABJ_404_Solution_Logging $logger
83 */
84 public function __construct(
85 callable $queryRunner,
86 callable $resultHarvester,
87 callable $resultTypeGetter,
88 callable $noticeSetter,
89 $functions,
90 $logger
91 ) {
92 $this->queryRunner = $queryRunner;
93 $this->resultHarvester = $resultHarvester;
94 $this->resultTypeGetter = $resultTypeGetter;
95 $this->noticeSetter = $noticeSetter;
96 $this->f = $functions;
97 $this->logger = $logger;
98 }
99
100 /**
101 * Reset the static recursion guards. Intended for test setUp/tearDown only.
102 *
103 * @return void
104 */
105 public static function resetRecursionGuardsForTests(): void {
106 self::$tableRepairInProgress = false;
107 self::$invalidDataRetryInProgress = false;
108 }
109
110 /** @return bool */
111 public function isTableRepairInProgress(): bool {
112 return self::$tableRepairInProgress;
113 }
114
115 /** @param bool $value @return void */
116 public function setTableRepairInProgress(bool $value): void {
117 self::$tableRepairInProgress = $value;
118 }
119
120 /**
121 * Validate and sanitize a table name extracted from error messages or SQL.
122 *
123 * Rejects anything that isn't [A-Za-z0-9_]+ and anything that doesn't
124 * contain the plugin's "abj404" prefix substring. Logs (warn) on reject so
125 * the rejection is visible at audit time without surfacing to the admin.
126 *
127 * @param string $name Raw table name (may include backticks).
128 * @return string|null Sanitized name, or null if invalid.
129 */
130 public function sanitizeTableName(string $name): ?string {
131 $name = trim($name, '`');
132 if (!preg_match('/^[a-zA-Z0-9_]+$/', $name)) {
133 $this->logger->warn("sanitizeTableName: rejected invalid table name: " . substr($name, 0, 100));
134 return null;
135 }
136 if (strpos($name, 'abj404') === false) {
137 $this->logger->warn("sanitizeTableName: rejected non-plugin table name: " . $name);
138 return null;
139 }
140 return $name;
141 }
142
143 /**
144 * Run REPAIR TABLE for a table named in a wpdb error string.
145 *
146 * Recognizes both the "is marked as crashed" message and the "Incorrect
147 * key file for table" message. If the table name cannot be sanitized
148 * (typically because it is a temporary table outside our prefix, e.g.
149 * "#sql_xxx_0.MYI" on a corrupted-disk host), falls back to a 24-hour
150 * deduplicated admin notice telling the site owner to contact their host.
151 *
152 * @param string $errorMessage The wpdb->last_error string.
153 * @return void
154 */
155 public function repairTable(string $errorMessage): void {
156 $re1 = "Table '(.*\/)?(.+)' is marked as crashed and ";
157 $re2 = "Incorrect key file for table '(?:.*\/)?([^'.]+?)(?:\\.MYI)?'";
158
159 $matches = array();
160 $this->f->regexMatch($re1, $errorMessage, $matches);
161
162 if (empty($matches) || count($matches) <= 2 || $this->f->strlen($matches[2]) === 0) {
163 $this->f->regexMatch($re2, $errorMessage, $matches);
164 if (!empty($matches) && isset($matches[1]) && $this->f->strlen($matches[1]) > 0) {
165 $matches[2] = $matches[1];
166 }
167 }
168
169 if (!empty($matches) && count($matches) > 2 && $this->f->strlen($matches[2]) > 0) {
170 $rawTableName = $matches[2];
171 $tableToRepair = $this->sanitizeTableName($rawTableName);
172 if ($tableToRepair !== null) {
173 $query = "REPAIR TABLE `{$tableToRepair}`";
174 $result = ($this->queryRunner)($query, array('log_errors' => false));
175 $this->logger->infoMessage("Attempted to repair table " . $tableToRepair . ". Result: " .
176 json_encode($result));
177 } else {
178 $this->logger->warn("The table " . $rawTableName . " needs to be " .
179 "repaired with something like: repair table " . $rawTableName);
180
181 $cooldownKey = 'abj404_corrupted_temp_table_notice_until';
182 $alreadyNotified = function_exists('get_transient') ? get_transient($cooldownKey) : false;
183 if (!$alreadyNotified) {
184 ($this->noticeSetter)(
185 'corrupted_temp_table',
186 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)',
187 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.',
188 $errorMessage
189 );
190 if (function_exists('set_transient')) {
191 // @cache-write-audit: opt-out - admin-notice dedup cooldown
192 // (one notice per 24h per failure type), not a query result.
193 set_transient($cooldownKey, 1, 86400);
194 }
195 }
196 }
197 }
198 }
199
200 /**
201 * Resolve the ALTER TABLE auto_increment resequencing duplicate-id case.
202 *
203 * Parses the duplicate id from the error message and the table name from
204 * the original ALTER TABLE SQL, validates both, and deletes the conflicting
205 * row so the ALTER can be retried by the caller.
206 *
207 * @param string $errorMessage The wpdb->last_error string.
208 * @param string $sqlThatWasRun The ALTER TABLE statement wpdb just ran.
209 * @return void
210 */
211 public function repairDuplicateIDs(string $errorMessage, string $sqlThatWasRun): void {
212 $reForID = 'resulting in duplicate entry \'(.+)\' for key';
213 $reForTableName = "ALTER TABLE (.+) ADD ";
214 $matchesForID = null;
215 $matchesForTableName = null;
216
217 $this->f->regexMatch($reForID, $errorMessage, $matchesForID);
218 $this->f->regexMatch($reForTableName, $sqlThatWasRun, $matchesForTableName);
219 if (is_array($matchesForID) && isset($matchesForID[1]) && $this->f->strlen($matchesForID[1]) > 0 &&
220 is_array($matchesForTableName) && isset($matchesForTableName[1]) && $this->f->strlen($matchesForTableName[1]) > 0) {
221
222 $idWithDuplicate = $matchesForID[1];
223 $tableName = $this->sanitizeTableName($matchesForTableName[1]);
224 if ($tableName === null) {
225 $this->logger->warn("repairDuplicateIDs: rejected invalid table name from SQL: " . substr($matchesForTableName[1], 0, 100));
226 return;
227 }
228
229 if (!is_numeric($idWithDuplicate)) {
230 $this->logger->errorMessage("Invalid ID extracted from error message: " . $idWithDuplicate);
231 return;
232 }
233
234 if ($idWithDuplicate == 1) {
235 $idWithDuplicate = 0;
236 }
237
238 $result = ($this->queryRunner)("DELETE FROM `{$tableName}` where id = %d",
239 array('log_errors' => false, 'query_params' => array(absint($idWithDuplicate))));
240 $this->logger->infoMessage("Attempted to fix a duplicate entry issue. Table: " .
241 $tableName . ", Result: " . json_encode($result));
242 }
243 }
244
245 /**
246 * Attempt REPAIR TABLE after MySQL errno 1034 ("Incorrect key file"), then
247 * retry the original query once.
248 *
249 * On retry success, $result is mutated to the retried result; on retry
250 * failure, $result carries the retried last_error so the surrounding
251 * pipeline can continue its error-handling.
252 *
253 * @param string $query
254 * @param array<string, mixed> $result Passed by reference.
255 * @return void
256 */
257 public function repairCorruptedTableAndRetry(string $query, array &$result): void {
258 $errorMessage = is_string($result['last_error']) ? $result['last_error'] : '';
259 $this->repairTable($errorMessage);
260 if (stripos($errorMessage, 'abj404') !== false) {
261 global $wpdb;
262 $wpdb->flush();
263 $resultType = ($this->resultTypeGetter)();
264 // DAO-bypass-approved: retry-after-repair is part of the DAO's
265 // self-healing pipeline; calling queryAndGetResults() here would
266 // re-enter the error-handler that just invoked us.
267 $result['rows'] = $wpdb->get_results($query, $resultType);
268 $result['last_error'] = (string)($wpdb->last_error ?? '');
269 $result['last_result'] = $wpdb->last_result ?? array();
270 $result['rows_affected'] = $wpdb->rows_affected ?? 0;
271 $result['insert_id'] = $wpdb->insert_id ?? 0;
272 if ($result['last_error'] === '') {
273 $this->logger->infoMessage("Retry after 'Incorrect key file' repair succeeded for plugin table.");
274 }
275 }
276 }
277
278 /**
279 * Attempt a single invalid-data retry by asking WPDBExtension to strip
280 * invalid bytes from the query, then re-running the stripped query.
281 *
282 * Recursion-guarded: if the stripped query also produces an invalid-data
283 * error, the second call short-circuits. The `abj404_invalid_data_retry_query`
284 * filter lets site owners override the stripped query (e.g. to enforce a
285 * stricter sanitization policy).
286 *
287 * @param string $query
288 * @param array<string, mixed> $result Passed by reference.
289 * @return void
290 */
291 public function attemptInvalidDataRetry($query, &$result) {
292 if (self::$invalidDataRetryInProgress) {
293 return;
294 }
295 self::$invalidDataRetryInProgress = true;
296 try {
297 $retryQuery = $this->get_stripped_query_result($query);
298 $retryQuery = function_exists('apply_filters')
299 ? apply_filters('abj404_invalid_data_retry_query', $retryQuery, $query)
300 : $retryQuery;
301 if (!is_string($retryQuery) || trim($retryQuery) === '' || $retryQuery === $query) {
302 return;
303 }
304 global $wpdb;
305 $wpdb->flush();
306 $resultType = ($this->resultTypeGetter)();
307 // DAO-bypass-approved: retry-after-strip is part of the DAO's
308 // self-healing pipeline; calling queryAndGetResults() here would
309 // re-enter the error-handler that just invoked us.
310 $result['rows'] = $wpdb->get_results($retryQuery, $resultType);
311 ($this->resultHarvester)($result);
312 } catch (Throwable $e) {
313 $this->logger->warn("Invalid-data retry failed: " . $e->getMessage());
314 } finally {
315 self::$invalidDataRetryInProgress = false;
316 }
317 }
318
319 /**
320 * Ask WPDBExtension to strip invalid bytes from a query string so the
321 * caller can re-issue a safe version.
322 *
323 * Returns null on any failure (missing extension file, wpdb method
324 * unavailable, DB constants undefined, extension constructor throws).
325 * Logs (warn) on exception so the caller does not need to.
326 *
327 * @param string $query
328 * @return NULL|string|WP_Error
329 */
330 public function get_stripped_query_result($query) {
331 try {
332 if (!class_exists('wpdb')) {
333 return null;
334 }
335 if (!method_exists('wpdb', 'strip_invalid_text_from_query')) {
336 return null;
337 }
338
339 $filename = ABJ404_PATH . 'includes/php/wordpress/WPDBExtension.php';
340 if (!file_exists($filename)) {
341 return null;
342 }
343 require_once $filename;
344
345 $my_custom_db = null;
346 if (class_exists('ABJ_404_Solution_WPDBExtension_PHP7')) {
347 $my_custom_db = new ABJ_404_Solution_WPDBExtension_PHP7(DB_USER, DB_PASSWORD, DB_NAME, DB_HOST);
348 } else if (class_exists('ABJ_404_Solution_WPDBExtension_PHP5')) {
349 $my_custom_db = new ABJ_404_Solution_WPDBExtension_PHP5(DB_USER, DB_PASSWORD, DB_NAME, DB_HOST);
350 }
351 if ($my_custom_db == null) {
352 return null;
353 }
354
355 $result = $my_custom_db->public_strip_invalid_text_from_query($query);
356
357 if (is_wp_error($result)) {
358 return 'WP_Error: ' . $result->get_error_message();
359 }
360
361 return $result;
362
363 } catch (Throwable $e) {
364 $this->logger->warn(
365 'get_stripped_query_result failed; returning null: ' . $e->getMessage()
366 );
367 return null;
368 }
369 }
370
371 }
372