PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.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 / RedirectsRepository.php

RedirectsRepository.php in 404 Solution 4.2.0, at includes/RedirectsRepository.php

1,364 lines 52.2 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 require_once __DIR__ . '/RedirectsRepositoryInterface.php';
8
9 /**
10 * Redirect CRUD, conditions, regex matching, cleanup, and cron maintenance.
11 *
12 * Extracted from the DataAccess monolith (Phase 2 of the DataAccess refactor).
13 * Methods originate from two sources:
14 * - DataAccessTrait_Redirects (entirely absorbed)
15 * - DataAccessTrait_Stats (redirect update/query methods relocated)
16 *
17 * Receives a DatabaseCore instance for all query execution.
18 */
19 class ABJ_404_Solution_RedirectsRepository implements ABJ_404_Solution_RedirectsRepositoryInterface {
20
21 /** Maximum number of regex redirects to cache per-request (memory guard) */
22 const REGEX_CACHE_MAX_COUNT = 50;
23
24 /** @var ABJ_404_Solution_DatabaseCore */
25 private $dbCore;
26
27 /** @var ABJ_404_Solution_Functions */
28 private $f;
29
30 /** @var ABJ_404_Solution_Logging */
31 private $logger;
32
33 /** @var array<int, array<string, mixed>>|null Per-request cache for regex redirects */
34 private static $regexRedirectsCache = null;
35
36 /** @var bool Flag indicating if regex cache should be skipped (too many redirects) */
37 private static $regexCacheDisabled = false;
38
39 /**
40 * Per-instance memoized cache of column-existence probes against the
41 * redirects table.
42 *
43 * @var array<string, bool>
44 */
45 private $redirectsTableColumnsCache = array();
46
47 /**
48 * @param ABJ_404_Solution_DatabaseCore $dbCore
49 * @param ABJ_404_Solution_Functions|null $functions
50 * @param ABJ_404_Solution_Logging|null $logging
51 */
52 public function __construct(
53 ABJ_404_Solution_DatabaseCore $dbCore,
54 $functions = null,
55 $logging = null
56 ) {
57 $this->dbCore = $dbCore;
58 $this->f = $functions !== null ? $functions : abj_service('functions');
59 $this->logger = $logging !== null ? $logging : abj_service('logging');
60 }
61
62 // =========================================================================
63 // Regex cache accessors (static state moved from DataAccess)
64 // =========================================================================
65
66 /** @inheritDoc */
67 public function clearRegexRedirectsCache(): void {
68 self::$regexRedirectsCache = null;
69 self::$regexCacheDisabled = false;
70 }
71
72 /** @return array<int, array<string, mixed>>|null */
73 public static function getRegexRedirectsCache() {
74 return self::$regexRedirectsCache;
75 }
76
77 /** @param array<int, array<string, mixed>>|null $cache @return void */
78 public static function setRegexRedirectsCache($cache): void {
79 self::$regexRedirectsCache = $cache;
80 }
81
82 /** @return bool */
83 public static function isRegexCacheDisabled(): bool {
84 return self::$regexCacheDisabled;
85 }
86
87 /** @param bool $disabled @return void */
88 public static function setRegexCacheDisabled(bool $disabled): void {
89 self::$regexCacheDisabled = $disabled;
90 }
91
92 // =========================================================================
93 // Static utilities (from DataAccessTrait_Redirects)
94 // =========================================================================
95
96 /** @inheritDoc */
97 public static function computeRedirectsCanonicalUrl($url): string {
98 if (!is_string($url)) {
99 return '/';
100 }
101 $trimmed = trim($url, '/');
102 if ($trimmed === '') {
103 return '/';
104 }
105 return '/' . $trimmed;
106 }
107
108 /** @inheritDoc */
109 public static function hitsCanonicalUrlSqlExpression(string $columnExpr): string {
110 return "CONCAT('/', TRIM(BOTH '/' FROM " . $columnExpr . "))";
111 }
112
113 // =========================================================================
114 // Query preparation helpers (from DataAccessTrait_ViewQueries, shared utility)
115 // =========================================================================
116
117 /**
118 * @param string $query
119 * @param array<string, mixed> $data
120 * @return array{0: string, 1: array<int, mixed>}
121 */
122 private function prepare_query($query, $data) {
123 $ordered_values = [];
124 $prepared_query = preg_replace_callback('/\{(\w+)\}/', function($matches) use ($data, &$ordered_values) {
125 $key = $matches[1];
126 if (!isset($data[$key])) {
127 return $matches[0];
128 }
129 $value = $data[$key];
130 $ordered_values[] = $value;
131 $placeholder_type = is_int($value) ? '%d' : '%s';
132 return $placeholder_type;
133 }, $query);
134
135 return [$prepared_query !== null ? $prepared_query : $query, $ordered_values];
136 }
137
138 /**
139 * @param string $query
140 * @param array<string, mixed> $data
141 * @return string
142 */
143 private function prepare_query_wp($query, $data) {
144 global $wpdb;
145 list($prepared_query, $ordered_values) = $this->prepare_query($query, $data);
146 // DAO-bypass-approved: $wpdb->prepare is read-only string formatting; callers execute the result through queryAndGetResults
147 return $wpdb->prepare($prepared_query, $ordered_values);
148 }
149
150 // =========================================================================
151 // Redirect CRUD (from DataAccessTrait_Redirects)
152 // =========================================================================
153
154 /** @inheritDoc */
155 function deleteRedirect($id) {
156 $cleanedID = absint(sanitize_text_field((string)$id));
157
158 if (is_numeric($id)) {
159 // allow-no-watermark-bump: DAO layer; admin callers bump via markViewDoneInvalidatedByAdminMutation()
160 $query = "delete from {wp_abj404_redirects} where id = %d";
161 $this->dbCore->queryAndGetResults($query, array('query_params' => array($cleanedID)));
162
163 abj_service('view_read_service')->invalidateStatusCountsCache();
164 $this->clearRegexRedirectsCache();
165 }
166 }
167
168 /** @inheritDoc */
169 function setupRedirect($fromURL, $status, $type, $final_dest, $code, $disabled = 0, $engine = null, $score = null) {
170 if (!is_numeric($type)) {
171 $this->logger->errorMessage("Wrong data type for redirect. TYPE is non-numeric. From: " .
172 esc_url($fromURL) . " to: " . esc_url($final_dest) . ", Type: " .esc_html($type) . ", Status: " . $status);
173 } else if (!is_numeric($status)) {
174 $this->logger->errorMessage("Wrong data type for redirect. STATUS is non-numeric. From: " .
175 esc_url($fromURL) . " to: " . esc_url($final_dest) . ", Type: " .esc_html($type) . ", Status: " . $status);
176 }
177
178 $statusAsInt = is_numeric($status) ? absint($status) : -1;
179 $typeAsInt = is_numeric($type) ? absint($type) : -1;
180
181 if ($statusAsInt === ABJ404_STATUS_AUTO &&
182 !$this->isValidAutomaticRedirectDestination($typeAsInt, $final_dest)) {
183 $this->logger->debugMessage("Skipping automatic redirect with invalid destination. " .
184 "From: " . esc_url($fromURL) . ", Dest: " . esc_html((string)$final_dest) .
185 ", Type: " . esc_html((string)$type) . ", Status: " . esc_html((string)$status));
186 return 0;
187 }
188
189 $insertId = 0;
190
191 if (!abj_service('request_context')->ignore_doprocess) {
192 $now = time();
193 $redirectsTable = $this->dbCore->doTableNameReplacements("{wp_abj404_redirects}");
194
195 $abj404logic = abj_service('plugin_logic');
196 $fromURL = $abj404logic->normalizeToRelativePath($fromURL);
197
198 $insertData = array(
199 'url' => $fromURL,
200 'status' => $status,
201 'type' => $type,
202 'final_dest' => $final_dest,
203 'code' => $code,
204 'disabled' => $disabled,
205 'timestamp' => $now,
206 );
207 $insertFormats = array('%s', '%d', '%d', '%s', '%d', '%d', '%d');
208
209 if ($this->redirectsTableHasColumn('canonical_url')) {
210 $insertData['canonical_url'] = self::computeRedirectsCanonicalUrl($fromURL);
211 $insertFormats[] = '%s';
212 }
213 if ($engine !== null) {
214 $insertData['engine'] = substr((string)$engine, 0, 64);
215 $insertFormats[] = '%s';
216 }
217 if ($score !== null) {
218 $insertData['score'] = round((float)$score, 2);
219 $insertFormats[] = '%f';
220 }
221
222 $insertSql = "INSERT INTO `" . $redirectsTable . "` (`" .
223 implode('`, `', array_keys($insertData)) . "`) VALUES (" .
224 implode(', ', $insertFormats) . ")";
225 $insertResult = $this->dbCore->queryAndGetResults($insertSql, array(
226 'query_params' => array_values($insertData),
227 ));
228 $insertIdRaw = $insertResult['insert_id'] ?? 0;
229 $insertId = is_scalar($insertIdRaw) ? (int)$insertIdRaw : 0;
230
231 abj_service('view_read_service')->invalidateStatusCountsCache();
232 if ($status == ABJ404_STATUS_REGEX) {
233 $this->clearRegexRedirectsCache();
234 }
235 }
236
237 return $insertId;
238 }
239
240 /**
241 * @param int $type
242 * @param mixed $finalDest
243 * @return bool
244 */
245 private function isValidAutomaticRedirectDestination($type, $finalDest) {
246 $destId = absint(is_scalar($finalDest) ? $finalDest : 0);
247
248 if ($type === ABJ404_TYPE_POST) {
249 if ($destId <= 0) {
250 return false;
251 }
252 if (!function_exists('get_post')) {
253 return true;
254 }
255 $ref = ABJ_404_Solution_PostRef::fromWpPost(get_post($destId));
256 if ($ref === null) {
257 return false;
258 }
259 return $ref->isPublished();
260 }
261
262 if ($type === ABJ404_TYPE_CAT || $type === ABJ404_TYPE_TAG) {
263 if ($destId <= 0) {
264 return false;
265 }
266 if (!function_exists('get_term')) {
267 return true;
268 }
269 $taxonomy = ($type === ABJ404_TYPE_CAT) ? 'category' : 'post_tag';
270 $term = get_term($destId, $taxonomy);
271 if ($term === null || is_wp_error($term)) {
272 return false;
273 }
274 return is_object($term);
275 }
276
277 if ($type === ABJ404_TYPE_HOME) {
278 return true;
279 }
280
281 return false;
282 }
283
284 /** @inheritDoc */
285 function getActiveRedirectForURL($url, $degradedMode = false) {
286 $url = $this->f->sanitizeInvalidUTF8($url);
287
288 if (function_exists('mb_check_encoding') && !mb_check_encoding($url, 'UTF-8')) {
289 return array('id' => 0);
290 }
291
292 $abj404logic = abj_service('plugin_logic');
293 $candidates = $abj404logic->getNormalizedUrlCandidates($url);
294 foreach ($candidates as $candidate) {
295 $redirect = $this->getActiveRedirectForNormalizedUrl($candidate, $degradedMode);
296 if ($redirect['id'] !== 0) {
297 return $redirect;
298 }
299 }
300
301 return array('id' => 0);
302 }
303
304 /** @inheritDoc */
305 function getExistingRedirectForURL($url) {
306 $url = $this->f->sanitizeInvalidUTF8($url);
307
308 if (function_exists('mb_check_encoding') && !mb_check_encoding($url, 'UTF-8')) {
309 return array('id' => 0);
310 }
311
312 $abj404logic = abj_service('plugin_logic');
313 $candidates = $abj404logic->getNormalizedUrlCandidates($url);
314 foreach ($candidates as $candidate) {
315 $redirect = $this->getExistingRedirectForNormalizedUrl($candidate);
316 if ($redirect['id'] !== 0) {
317 return $redirect;
318 }
319 }
320
321 return array('id' => 0);
322 }
323
324 /**
325 * @param string $url
326 * @param bool $degradedMode
327 * @return array<string, mixed>
328 */
329 private function getActiveRedirectForNormalizedUrl($url, $degradedMode = false) {
330 $redirect = array();
331
332 $url1 = $url;
333 $url2 = $url;
334 if (substr($url, -1) === '/') {
335 $url2 = rtrim($url, '/');
336 } else {
337 $url2 = $url2 . '/';
338 }
339
340 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getPermalinkFromURL.sql");
341
342 if ($degradedMode && $this->redirectsTableMissingScheduledColumns()) {
343 $query = $this->stripScheduledRedirectPredicates($query);
344 }
345
346 $query = $this->prepare_query_wp($query, array("url1" => $url1, "url2" => $url2));
347 $query = $this->dbCore->doTableNameReplacements($query);
348 $query = $this->f->doNormalReplacements($query);
349 $results = $this->dbCore->queryAndGetResults($query);
350 $rows = $results['rows'];
351
352 if (is_array($rows)) {
353 if (empty($rows)) {
354 $redirect['id'] = 0;
355 } else {
356 foreach ($rows[0] as $key => $value) {
357 $redirect[$key] = $value;
358 }
359 }
360 }
361
362 if (!isset($redirect['id'])) {
363 $redirect['id'] = 0;
364 }
365
366 return $redirect;
367 }
368
369 /**
370 * @return bool
371 */
372 private function redirectsTableMissingScheduledColumns(): bool {
373 $cacheKey = 'abj404_redirects_scheduled_cols_status';
374 if (function_exists('get_transient')) {
375 $cached = get_transient($cacheKey);
376 if ($cached === 'missing') { return true; }
377 if ($cached === 'present') { return false; }
378 }
379
380 $tableName = $this->dbCore->doTableNameReplacements('{wp_abj404_redirects}');
381 $columns = $this->getRedirectsTableColumns($tableName);
382
383 if (empty($columns)) {
384 return false;
385 }
386
387 $colsLower = array_map('strtolower', $columns);
388 $missing = !in_array('start_ts', $colsLower, true)
389 || !in_array('end_ts', $colsLower, true);
390
391 if (function_exists('set_transient')) {
392 $hour = defined('HOUR_IN_SECONDS') ? (int) HOUR_IN_SECONDS : 3600;
393 // allow-cache-empty: value is always 'missing' or 'present' (non-empty string literal)
394 set_transient(
395 $cacheKey,
396 $missing ? 'missing' : 'present',
397 $missing ? 5 * 60 : 24 * $hour
398 );
399 }
400
401 return $missing;
402 }
403
404 /**
405 * @param string $tableName
406 * @return array<int, string>
407 */
408 private function getRedirectsTableColumns(string $tableName): array {
409 global $wpdb;
410 if (!isset($wpdb)) {
411 return [];
412 }
413 // @utf8-audit: opt-out — getRedirectsTableColumns receives system-generated redirects table names only.
414 $result = $this->dbCore->queryAndGetResults(
415 "SHOW COLUMNS FROM `" . esc_sql($tableName) . "`",
416 array('log_errors' => false)
417 );
418 $rows = isset($result['rows']) && is_array($result['rows']) ? $result['rows'] : [];
419 $lastError = isset($result['last_error']) && is_string($result['last_error']) ? $result['last_error'] : '';
420 if ($lastError !== '') {
421 return [];
422 }
423 $columns = [];
424 foreach ($rows as $row) {
425 if (is_array($row) && isset($row['Field']) && is_string($row['Field'])) {
426 $columns[] = $row['Field'];
427 }
428 }
429 return $columns;
430 }
431
432 /**
433 * @param string $sql
434 * @return string
435 */
436 private function stripScheduledRedirectPredicates(string $sql): string {
437 $stripped = preg_replace(
438 '/^[^\n]*\br\.(?:start_ts|end_ts)\b[^\n]*\R?/m',
439 '',
440 $sql
441 );
442 return is_string($stripped) ? $stripped : $sql;
443 }
444
445 /**
446 * @param string $url
447 * @return array<string, mixed>
448 */
449 private function getExistingRedirectForNormalizedUrl($url) {
450 $redirect = array();
451
452 $query = $this->prepare_query_wp('select * from {wp_abj404_redirects} where BINARY url = BINARY {url} ' .
453 " and disabled = 0 ", array("url" => $url));
454 $results = $this->dbCore->queryAndGetResults($query);
455 $rows = $results['rows'];
456
457 if (is_array($rows)) {
458 if (empty($rows)) {
459 $redirect['id'] = 0;
460 } else {
461 foreach ($rows[0] as $key => $value) {
462 $redirect[$key] = $value;
463 }
464 }
465 }
466
467 if (!isset($redirect['id'])) {
468 $redirect['id'] = 0;
469 }
470
471 return $redirect;
472 }
473
474 /**
475 * @param string $columnName
476 * @return bool
477 */
478 private function redirectsTableHasColumn(string $columnName): bool {
479 $key = strtolower($columnName);
480 if ($this->redirectsTableColumnsCache !== array()) {
481 return isset($this->redirectsTableColumnsCache[$key]);
482 }
483 global $wpdb;
484 if (!isset($wpdb)) {
485 return true;
486 }
487 $redirectsTable = $this->dbCore->doTableNameReplacements("{wp_abj404_redirects}");
488 // @utf8-audit: opt-out — redirectsTableHasColumn probes an internally resolved plugin table name.
489 $result = $this->dbCore->queryAndGetResults(
490 "SHOW COLUMNS FROM `" . esc_sql($redirectsTable) . "`",
491 array('log_errors' => false, 'log_too_slow' => false)
492 );
493 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
494 if ($rows === array()) {
495 return true;
496 }
497 $primed = array();
498 foreach ($rows as $row) {
499 if (!is_array($row)) { continue; }
500 foreach ($row as $field => $value) {
501 if (strtolower((string)$field) !== 'field') { continue; }
502 $primed[strtolower((string)$value)] = true;
503 }
504 }
505 if ($primed === array()) {
506 return true;
507 }
508 $this->redirectsTableColumnsCache = $primed;
509 return isset($this->redirectsTableColumnsCache[$key]);
510 }
511
512 /** @inheritDoc */
513 function deleteSpecifiedRedirects() {
514 $message = "";
515
516 if (!array_key_exists('sanity_purge', $_POST) || $_POST['sanity_purge'] != "1") {
517 $message = __('Error: You didn\'t check the I understand checkbox. No purging of records for you!', '404-solution');
518 return $message;
519 }
520
521 if (!isset($_POST['types']) || $_POST['types'] == '') {
522 $message = __('Error: No redirect types were selected. No purges will be done.', '404-solution');
523 return $message;
524 }
525
526 if (is_array($_POST['types'])) {
527 $type = array_map('sanitize_text_field', $_POST['types']);
528 } else {
529 $type = sanitize_text_field($_POST['types']);
530 }
531
532 if (!is_array($type)) {
533 $message = __('An unknown error has occurred.', '404-solution');
534 return $message;
535 }
536
537 $redirectTypes = array();
538 foreach ($type as $aType) {
539 if (('' . $aType != ABJ404_TYPE_HOME) && ('' . $aType != ABJ404_TYPE_404_DISPLAYED)) {
540 array_push($redirectTypes, absint($aType));
541 }
542 }
543
544 if (empty($redirectTypes)) {
545 $message = __('Error: No valid redirect types were selected. Exiting.', '404-solution');
546 $this->logger->debugMessage("Error: No valid redirect types were selected. Types: " .
547 wp_kses_post((string)json_encode($redirectTypes)));
548 return $message;
549 }
550 $purge = isset($_POST['purgetype']) ? sanitize_text_field($_POST['purgetype']) : '';
551
552 if ($purge != 'abj404_logs' && $purge != 'abj404_redirects') {
553 $message = __('Error: An invalid purge type was selected. Exiting.', '404-solution');
554 $this->logger->debugMessage("Error: An invalid purge type was selected. Type: " .
555 wp_kses_post((string)json_encode($purge)));
556 return $message;
557 }
558
559 array_push($redirectTypes, 0);
560
561 $redirectTypes = array_map('absint', $redirectTypes);
562 $typesForSQL = implode(',', $redirectTypes);
563
564 if ($purge == 'abj404_redirects') {
565 // allow-no-watermark-bump: DAO layer; admin callers bump via markViewDoneInvalidatedByAdminMutation()
566 $query = "update {wp_abj404_redirects} set disabled = 1 where status in (" . $typesForSQL . ")";
567 $purgeResult = $this->dbCore->queryAndGetResults($query);
568 $rowsAffectedRaw = $purgeResult['rows_affected'] ?? 0;
569 $redirectCount = is_scalar($rowsAffectedRaw) ? (int)$rowsAffectedRaw : 0;
570
571 abj_service('view_read_service')->invalidateStatusCountsCache();
572 $this->clearRegexRedirectsCache();
573
574 $message .= sprintf( _n( '%s redirect entry was moved to the trash.',
575 '%s redirect entries were moved to the trash.', $redirectCount, '404-solution'), $redirectCount);
576 }
577
578 return $message;
579 }
580
581 // =========================================================================
582 // Redirect conditions (from DataAccessTrait_Redirects)
583 // =========================================================================
584
585 /** @inheritDoc */
586 public function getRedirectConditions(int $redirectId): array {
587 $table = $this->dbCore->doTableNameReplacements('{wp_abj404_redirect_conditions}');
588
589 if (!$this->dbCore->tableExists($table)) {
590 return [];
591 }
592
593 $result = $this->dbCore->queryAndGetResults(
594 "SELECT id, redirect_id, logic, condition_type, operator, value, sort_order
595 FROM `{$table}`
596 WHERE redirect_id = %d
597 ORDER BY sort_order ASC, id ASC",
598 array('query_params' => array($redirectId), 'log_errors' => false)
599 );
600
601 $lastError = isset($result['last_error']) && is_string($result['last_error']) ? $result['last_error'] : '';
602 if ($lastError !== '') {
603 $this->logger->warn("getRedirectConditions: DB error for redirect_id={$redirectId}: " . $lastError);
604 return [];
605 }
606
607 $rows = isset($result['rows']) && is_array($result['rows']) ? $result['rows'] : [];
608 return $rows;
609 }
610
611 /** @inheritDoc */
612 public function saveRedirectConditions(int $redirectId, array $conditions): void {
613 $table = $this->dbCore->doTableNameReplacements('{wp_abj404_redirect_conditions}');
614
615 if (!$this->dbCore->tableExists($table)) {
616 $this->logger->warn("saveRedirectConditions: conditions table missing, skipping save for redirect_id={$redirectId}.");
617 return;
618 }
619
620 $deleteResult = $this->dbCore->queryAndGetResults(
621 "DELETE FROM `{$table}` WHERE redirect_id = %d",
622 array('query_params' => array($redirectId), 'log_errors' => false)
623 );
624 $deleteError = isset($deleteResult['last_error']) && is_string($deleteResult['last_error']) ? $deleteResult['last_error'] : '';
625 if ($deleteError !== '') {
626 $this->logger->warn("saveRedirectConditions: error deleting old conditions for redirect_id={$redirectId}: " . $deleteError);
627 }
628
629 if (empty($conditions)) {
630 return;
631 }
632
633 $allowedTypes = [
634 'login_status', 'user_role', 'referrer',
635 'user_agent', 'ip_range', 'http_header',
636 ];
637 $allowedOperators = [
638 'equals', 'contains', 'regex',
639 'not_equals', 'not_contains', 'cidr',
640 ];
641 $allowedLogic = ['AND', 'OR'];
642
643 foreach ($conditions as $index => $cond) {
644 if (!is_array($cond)) {
645 continue;
646 }
647
648 $logic = isset($cond['logic']) && is_string($cond['logic'])
649 ? strtoupper(trim($cond['logic'])) : 'AND';
650 $type = isset($cond['condition_type']) && is_string($cond['condition_type'])
651 ? trim($cond['condition_type']) : '';
652 $operator = isset($cond['operator']) && is_string($cond['operator'])
653 ? trim($cond['operator']) : 'equals';
654 $value = isset($cond['value']) && is_string($cond['value'])
655 ? trim($cond['value']) : '';
656 $sortOrder = isset($cond['sort_order']) ? absint(is_scalar($cond['sort_order']) ? $cond['sort_order'] : 0) : $index;
657
658 if (!in_array($logic, $allowedLogic, true)) {
659 $logic = 'AND';
660 }
661 if (!in_array($type, $allowedTypes, true)) {
662 $this->logger->warn("saveRedirectConditions: unknown condition_type '{$type}', skipping.");
663 continue;
664 }
665 if (!in_array($operator, $allowedOperators, true)) {
666 $operator = 'equals';
667 }
668 if (strlen($value) > 1024) {
669 $value = substr($value, 0, 1024);
670 }
671
672 $insertResult = $this->dbCore->queryAndGetResults(
673 "INSERT INTO `{$table}` (`redirect_id`, `logic`, `condition_type`, `operator`, `value`, `sort_order`)
674 VALUES (%d, %s, %s, %s, %s, %d)",
675 array(
676 'query_params' => array($redirectId, $logic, $type, $operator, $value, $sortOrder),
677 'log_errors' => false,
678 )
679 );
680 $insertError = isset($insertResult['last_error']) && is_string($insertResult['last_error']) ? $insertResult['last_error'] : '';
681 if ($insertError !== '') {
682 $this->logger->warn("saveRedirectConditions: error inserting condition #{$index} for redirect_id={$redirectId}: " . $insertError);
683 }
684 }
685 }
686
687 // =========================================================================
688 // Redirect updates (from DataAccessTrait_Stats)
689 // =========================================================================
690
691 /** @inheritDoc */
692 function updateRedirect($type, $dest, $fromURL, $idForUpdate, $redirectCode, $statusType, $startTs = null, $endTs = null) {
693 if (($type < 0) || ($idForUpdate <= 0)) {
694 $this->logger->errorMessage("Bad data passed for update redirect request. Type: " .
695 esc_html((string)$type) . ", Dest: " . esc_html($dest) . ", ID(s): " . esc_html((string)$idForUpdate));
696 echo __('Error: Bad data passed for update redirect request.', '404-solution');
697 return '';
698 }
699
700 $redirectsTable = $this->dbCore->doTableNameReplacements("{wp_abj404_redirects}");
701
702 $updateData = array(
703 'url' => $fromURL,
704 'status' => $statusType,
705 'type' => absint($type),
706 'final_dest' => $dest,
707 'code' => esc_attr($redirectCode),
708 );
709 $updateFormats = array('%s', '%d', '%d', '%s', '%d');
710
711 if ($startTs !== null) {
712 $updateData['start_ts'] = (int)$startTs;
713 $updateFormats[] = '%d';
714 }
715 if ($endTs !== null) {
716 $updateData['end_ts'] = (int)$endTs;
717 $updateFormats[] = '%d';
718 }
719
720 $setFragments = array();
721 $idx = 0;
722 foreach ($updateData as $col => $unusedValue) {
723 $format = isset($updateFormats[$idx]) ? $updateFormats[$idx] : '%s';
724 $setFragments[] = '`' . $col . '` = ' . $format;
725 $idx++;
726 }
727 $updateSql = "UPDATE `" . $redirectsTable . "` SET " . implode(', ', $setFragments) .
728 " WHERE `id` = %d";
729 $updateParams = array_values($updateData);
730 $updateParams[] = absint($idForUpdate);
731 $this->dbCore->queryAndGetResults($updateSql, array('query_params' => $updateParams));
732
733 $nullParts = [];
734 if ($startTs === null) {
735 $nullParts[] = '`start_ts` = NULL';
736 }
737 if ($endTs === null) {
738 $nullParts[] = '`end_ts` = NULL';
739 }
740 if (!empty($nullParts)) {
741 $nullSql = "UPDATE `" . $redirectsTable . "` SET " . implode(', ', $nullParts) .
742 " WHERE id = %d";
743 $this->dbCore->queryAndGetResults($nullSql, array('query_params' => array(absint($idForUpdate))));
744 }
745
746 abj_service('view_read_service')->invalidateStatusCountsCache();
747 $this->clearRegexRedirectsCache();
748
749 $this->moveRedirectsToTrash(absint($idForUpdate), 0);
750
751 return '';
752 }
753
754 /** @inheritDoc */
755 function getRedirectsByIDs($ids) {
756 if (!is_array($ids) || empty($ids)) {
757 return array();
758 }
759 $validids = array_map('absint', $ids);
760 $multipleIds = implode(',', $validids);
761
762 $query = "select id, url, type, status, final_dest, code, COALESCE(engine, '') as engine, start_ts, end_ts from {wp_abj404_redirects} " .
763 "where id in (" . $multipleIds . ")";
764 $result = $this->dbCore->queryAndGetResults($query);
765 $rawRows = isset($result['rows']) && is_array($result['rows']) ? $result['rows'] : array();
766
767 $rows = array();
768 foreach ($rawRows as $row) {
769 if (is_array($row)) {
770 $rows[] = $row;
771 }
772 }
773 return $rows;
774 }
775
776 /** @inheritDoc */
777 function updateRedirectTypeStatus($id, $newstatus) {
778 // allow-no-watermark-bump: DAO layer; admin callers bump via markViewDoneInvalidatedByAdminMutation()
779 $query = "update {wp_abj404_redirects} set status = %s where id = %d";
780 $result = $this->dbCore->queryAndGetResults($query, array(
781 'query_params' => array($newstatus, absint($id))
782 ));
783
784 abj_service('view_read_service')->invalidateStatusCountsCache();
785 $this->clearRegexRedirectsCache();
786
787 return is_string($result['last_error']) ? $result['last_error'] : '';
788 }
789
790 /** @inheritDoc */
791 function moveRedirectsToTrash($id, $trash) {
792 $message = "";
793 $hadError = false;
794 if ($this->f->regexMatch('[0-9]+', '' . $id)) {
795
796 $redirectsTable = $this->dbCore->doTableNameReplacements("{wp_abj404_redirects}");
797 $updateResult = $this->dbCore->queryAndGetResults(
798 "UPDATE `" . $redirectsTable . "` SET disabled = %d WHERE id = %d",
799 array('query_params' => array(absint(esc_html((string)$trash)), absint($id)))
800 );
801 $updateError = isset($updateResult['last_error']) && is_string($updateResult['last_error']) ? $updateResult['last_error'] : '';
802 $hadError = $updateError !== '';
803
804 abj_service('view_read_service')->invalidateStatusCountsCache();
805 $this->clearRegexRedirectsCache();
806 } else {
807 $hadError = true;
808 }
809 if ($hadError) {
810 $message = __('Error: Unknown Database Error!', '404-solution');
811 }
812 return $message;
813 }
814
815 // =========================================================================
816 // Cron maintenance (from DataAccessTrait_Redirects)
817 // =========================================================================
818
819 /** @inheritDoc */
820 public function cleanupOrphanedAutoRedirects(): int {
821 $redirectsTable = $this->dbCore->doTableNameReplacements('{wp_abj404_redirects}');
822 if (!$this->dbCore->tableExists($redirectsTable)) {
823 $this->logger->warn("Skipping orphaned redirect cleanup: table missing.");
824 return 0;
825 }
826
827 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getOrphanedAutoRedirects.sql");
828 $query = $this->dbCore->doTableNameReplacements($query);
829 $query = $this->f->doNormalReplacements($query);
830
831 $results = $this->dbCore->queryAndGetResults($query);
832 $rows = is_array($results['rows']) ? $results['rows'] : [];
833 $deletedCount = 0;
834
835 foreach ($rows as $row) {
836 if (!is_array($row)) {
837 continue;
838 }
839 $id = isset($row['id']) && is_scalar($row['id']) ? (string)$row['id'] : '0';
840 $url = isset($row['url']) && is_string($row['url']) ? $row['url'] : '';
841 $this->logger->debugMessage('Orphaned auto redirect deleted: "' . $url . '" (dest post ' .
842 (isset($row['final_dest']) && is_scalar($row['final_dest']) ? (string)$row['final_dest'] : '?') . ' missing/unpublished).');
843 $this->deleteRedirect($id);
844 $deletedCount++;
845 }
846
847 return $deletedCount;
848 }
849
850 /**
851 * @param array<string, mixed> $options
852 * @param int $now
853 * @param string $optionKey
854 * @param string $statusList
855 * @param string $debugMessageType
856 * @return int
857 */
858 private function deleteOldRedirectsByType($options, $now, $optionKey, $statusList, $debugMessageType) {
859 $logsRepo = abj_service('logs_repository');
860 $deletedCount = 0;
861
862 $rawDays = $options[$optionKey] ?? 0;
863 $deletionDays = intval(is_scalar($rawDays) ? $rawDays : 0);
864 if ($deletionDays <= 0) {
865 return 0;
866 }
867 $deletionTime = $deletionDays * 86400;
868 $then = $now - $deletionTime;
869
870 $this->dbCore->setSqlBigSelects();
871
872 if (!$logsRepo->logsHitsTableExists()) {
873 $this->logger->debugMessage(__FUNCTION__ . " skipping: logs_hits table missing; scheduling rebuild.");
874 $logsRepo->scheduleHitsTableRebuild();
875 return 0;
876 }
877
878 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getMostUnusedRedirects.sql");
879 $query = $this->f->str_replace('{status_list}', $statusList, $query);
880 $query = $this->f->str_replace('{timelimit}', (string)$then, $query);
881
882 $results = $this->dbCore->queryAndGetResults($query);
883 $rows = is_array($results['rows']) ? $results['rows'] : array();
884
885 foreach ($rows as $rowRaw) {
886 if (!is_array($rowRaw)) {
887 continue;
888 }
889 $row = $rowRaw;
890 if ($debugMessageType === 'Captured 404') {
891 $this->logger->debugMessage("Captured 404 for \"" . (is_string($row['from_url'] ?? '') ? $row['from_url'] : '') .
892 '" deleted (last used: ' . (is_string($row['last_used_formatted'] ?? '') ? $row['last_used_formatted'] : '') . ').');
893 } else {
894 $this->logger->debugMessage($debugMessageType . " from: " . (is_string($row['from_url'] ?? '') ? $row['from_url'] : '') . ' to: ' .
895 (is_string($row['best_guess_dest'] ?? '') ? $row['best_guess_dest'] : '') . ' deleted (last used: ' . (is_string($row['last_used_formatted'] ?? '') ? $row['last_used_formatted'] : '') . ').');
896 }
897
898 $this->deleteRedirect(isset($row['id']) && is_scalar($row['id']) ? (string)$row['id'] : '0');
899 $deletedCount++;
900 }
901
902 return $deletedCount;
903 }
904
905 /**
906 * @param int $daysToKeep
907 * @param int $now
908 * @return int
909 */
910 private function deleteOldLogsByAge(int $daysToKeep, int $now): int {
911 if ($daysToKeep <= 0) {
912 return 0;
913 }
914
915 $cutoffTimestamp = max(0, $now - ($daysToKeep * 86400));
916 $deletedTotal = 0;
917 $batchSize = 2000;
918 $maxBatches = 200;
919
920 for ($i = 0; $i < $maxBatches; $i++) {
921 $result = $this->dbCore->queryAndGetResults(
922 "DELETE FROM {wp_abj404_logsv2} WHERE timestamp <= %d LIMIT %d",
923 array(
924 'query_params' => array($cutoffTimestamp, $batchSize),
925 'log_errors' => true,
926 )
927 );
928 $rowsDeletedRaw = $result['rows_affected'] ?? 0;
929 $rowsDeleted = (is_int($rowsDeletedRaw) || is_float($rowsDeletedRaw) || is_string($rowsDeletedRaw))
930 ? (int)$rowsDeletedRaw
931 : 0;
932 if ($rowsDeleted <= 0) {
933 break;
934 }
935 $deletedTotal += $rowsDeleted;
936 if ($rowsDeleted < $batchSize) {
937 break;
938 }
939 }
940
941 return $deletedTotal;
942 }
943
944 /** @inheritDoc */
945 function deleteOldRedirectsCron() {
946 $viewRead = abj_service('view_read_service');
947 $abj404logic = abj_service('plugin_logic');
948
949 $options = $abj404logic->getOptions();
950 $now = time();
951 $capturedURLsCount = 0;
952 $autoRedirectsCount = 0;
953 $manualRedirectsCount = 0;
954 $oldLogRowsDeletedBySize = 0;
955 $oldLogRowsDeletedByAge = 0;
956
957 $manually_fired = abj_service('functions')->getPostOrGetSanitize('manually_fired', 'false');
958 if ($this->f->strtolower($manually_fired) == 'true') {
959 $manually_fired = true;
960 } else {
961 $manually_fired = false;
962 }
963
964 $upgradesEtc = abj_service('database_upgrades');
965 $upgradesEtc->createDatabaseTables(false);
966
967 $this->dbCore->ensureConnection();
968
969 $tempFile = $abj404logic->getExportFilename();
970 if (file_exists($tempFile)) {
971 ABJ_404_Solution_Functions::safeUnlink($tempFile);
972 }
973
974 $duplicateRowsDeleted = $this->removeDuplicatesCron();
975
976 if (array_key_exists('capture_deletion', $options) && $options['capture_deletion'] != '0') {
977 $status_list = ABJ404_STATUS_CAPTURED . ", " . ABJ404_STATUS_IGNORED . ", " . ABJ404_STATUS_LATER;
978 $capturedURLsCount = $this->deleteOldRedirectsByType($options, $now, 'capture_deletion', $status_list, 'Captured 404');
979 $captureDeletionDays = intval(is_scalar($options['capture_deletion']) ? $options['capture_deletion'] : 0);
980 $oldLogRowsDeletedByAge = $this->deleteOldLogsByAge($captureDeletionDays, $now);
981 }
982
983 if (isset($options['auto_deletion']) && $options['auto_deletion'] != '0') {
984 $status_list = (string)ABJ404_STATUS_AUTO;
985 $autoRedirectsCount = $this->deleteOldRedirectsByType($options, $now, 'auto_deletion', $status_list, 'Automatic redirect');
986 }
987
988 if (isset($options['manual_deletion']) && $options['manual_deletion'] != '0') {
989 $status_list = ABJ404_STATUS_MANUAL . ", " . ABJ404_STATUS_REGEX;
990 $manualRedirectsCount = $this->deleteOldRedirectsByType($options, $now, 'manual_deletion', $status_list, 'Manual redirect');
991 }
992
993 $orphanedCount = $this->cleanupOrphanedAutoRedirects();
994
995 $junkTrashedCount = $this->autoTrashJunkCapturedUrls($options);
996
997 $logsSizeBytes = $viewRead->getLogDiskUsage();
998 $maxLogSizeBytes = (array_key_exists('maximum_log_disk_usage', $options) ? $options['maximum_log_disk_usage'] : 100) * 1024 * 1000;
999
1000 if ($logsSizeBytes > $maxLogSizeBytes) {
1001 $totalLogLines = $viewRead->getLogsCount(0);
1002 $averageSizePerLine = max($logsSizeBytes, 1) / max($totalLogLines, 1);
1003 $logLinesToKeep = ceil($maxLogSizeBytes / $averageSizePerLine);
1004 $logLinesToDelete = max($totalLogLines - $logLinesToKeep, 0);
1005 if ($logLinesToDelete > 0) {
1006 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/deleteOldLogs.sql");
1007 $query = $this->f->str_replace('{lines_to_delete}', (string)$logLinesToDelete, $query);
1008 $results = $this->dbCore->queryAndGetResults($query);
1009 $oldLogRowsDeletedBySizeRaw = $results['rows_affected'] ?? 0;
1010 $oldLogRowsDeletedBySize = (is_int($oldLogRowsDeletedBySizeRaw) || is_float($oldLogRowsDeletedBySizeRaw) || is_string($oldLogRowsDeletedBySizeRaw))
1011 ? (int)$oldLogRowsDeletedBySizeRaw
1012 : 0;
1013 }
1014 }
1015
1016 $logsSizeBytes = $viewRead->getLogDiskUsage();
1017 $logSizeMB = round($logsSizeBytes / (1024 * 1000), 2);
1018
1019 $renamed = $this->limitDebugFileSize();
1020 $renamed = $renamed ? "true" : "false";
1021
1022 $oldLogRowsDeleted = $oldLogRowsDeletedByAge + $oldLogRowsDeletedBySize;
1023
1024 $message = "deleteOldRedirectsCron. Old captured URLs removed: " .
1025 $capturedURLsCount . ", Old automatic redirects removed: " . $autoRedirectsCount .
1026 ", Old manual redirects removed: " . $manualRedirectsCount .
1027 ", Orphaned auto redirects removed: " . $orphanedCount .
1028 ", Junk URLs auto-trashed: " . $junkTrashedCount .
1029 ", Old log lines removed: " . $oldLogRowsDeleted .
1030 " (age: " . $oldLogRowsDeletedByAge . ", size: " . $oldLogRowsDeletedBySize . ")" .
1031 ", New log size: " . $logSizeMB . "MB" .
1032 ", Duplicate rows deleted: " . $duplicateRowsDeleted . ", Debug file size limited: " .
1033 $renamed;
1034
1035 $adminEmailVal = array_key_exists('admin_notification_email', $options) ? $options['admin_notification_email'] : '';
1036 if ($adminEmailVal !== null &&
1037 $this->f->strlen(trim(is_string($adminEmailVal) ? $adminEmailVal : '')) > 5) {
1038
1039 if ($manually_fired) {
1040 $message .= ', The admin email notification option is skipped for user '
1041 . 'initiated maintenance runs.';
1042 } else {
1043 $message .= ', ' . $abj404logic->emailCaptured404Notification();
1044 }
1045 } else {
1046 $message .= ', Admin email notification option turned off.';
1047 }
1048
1049 if (isset($options['send_error_logs']) &&
1050 $options['send_error_logs'] == '1') {
1051 if ($this->logger->emailErrorLogIfNecessary()) {
1052 $message .= ", Log file emailed to developer.";
1053 } else {
1054 if ($this->logger->sendHeartbeatIfDueRandom(200)) {
1055 $message .= ", Heartbeat log emailed to developer.";
1056 }
1057 }
1058 }
1059
1060 $this->flagDeadDestinationRedirects();
1061
1062 $abj404permalinkCache = abj_service('permalink_cache');
1063 $rowsUpdated = $abj404permalinkCache->updatePermalinkCache(15);
1064 $message .= ", Permlink cache rows updated: " . $rowsUpdated;
1065
1066 $manually_fired_String = ($manually_fired) ? 'true' : 'false';
1067 $message .= ", User initiated: " . $manually_fired_String;
1068
1069 $this->logger->infoMessage($message);
1070
1071 $upgradesEtc = abj_service('database_upgrades');
1072 $upgradesEtc->createDatabaseTables();
1073
1074 $this->dbCore->queryAndGetResults("optimize table {wp_abj404_redirects}");
1075
1076 $upgradesEtc->updatePluginCheck();
1077
1078 return $message;
1079 }
1080
1081 /** @inheritDoc */
1082 function limitDebugFileSize(): bool {
1083 $renamed = false;
1084
1085 $mbFileSize = $this->logger->getDebugFileSize() / 1024 / 1000;
1086 if ($mbFileSize > 10) {
1087 $this->logger->limitDebugFileSize();
1088 $renamed = true;
1089 }
1090
1091 return $renamed;
1092 }
1093
1094 /** @inheritDoc */
1095 function removeDuplicatesCron(): int {
1096 $rowsDeleted = 0;
1097 $query = "SELECT COUNT(id) as repetitions, url FROM {wp_abj404_redirects} GROUP BY url HAVING repetitions > 1 ";
1098 $result = $this->dbCore->queryAndGetResults($query);
1099 $outerRows = is_array($result['rows']) ? $result['rows'] : array();
1100 foreach ($outerRows as $outerRow) {
1101 if (!is_array($outerRow)) {
1102 continue;
1103 }
1104 $row = $outerRow;
1105 $url = $row['url'];
1106
1107 $queryr1 = $this->prepare_query_wp(
1108 "select id from {wp_abj404_redirects} where url = {url} order by timestamp desc limit 0,1",
1109 array("url" => $url)
1110 );
1111 $result = $this->dbCore->queryAndGetResults($queryr1);
1112 $innerRows = is_array($result['rows']) ? $result['rows'] : array();
1113 if (count($innerRows) >= 1) {
1114 $row = is_array($innerRows[0]) ? $innerRows[0] : array();
1115 $original = isset($row['id']) ? $row['id'] : 0;
1116
1117 $queryl = $this->prepare_query_wp(
1118 "delete from {wp_abj404_redirects} where url = {url} and id != {original}", // allow-no-watermark-bump: DAO layer; admin callers bump via markViewDoneInvalidatedByAdminMutation()
1119 array("url" => $url, "original" => $original)
1120 );
1121 $deleteResult = $this->dbCore->queryAndGetResults($queryl);
1122 $affected = isset($deleteResult['rows_affected']) && is_numeric($deleteResult['rows_affected'])
1123 ? (int)$deleteResult['rows_affected'] : 1;
1124 $rowsDeleted += max($affected, 1);
1125 }
1126 }
1127
1128 if ($rowsDeleted > 0) {
1129 abj_service('view_read_service')->invalidateStatusCountsCache();
1130 }
1131
1132 return $rowsDeleted;
1133 }
1134
1135 /** @inheritDoc */
1136 function autoTrashJunkCapturedUrls(array $options): int {
1137 $enabled = $options['auto_trash_junk_urls'] ?? '0';
1138 if ($enabled !== '1') {
1139 return 0;
1140 }
1141
1142 $transientKey = 'abj404_last_auto_trash';
1143 if (get_transient($transientKey) !== false) {
1144 return 0;
1145 }
1146 set_transient($transientKey, time(), HOUR_IN_SECONDS);
1147
1148 $patternsRaw = $options['auto_trash_junk_patterns'] ?? '';
1149 $patternsStr = is_string($patternsRaw) ? $patternsRaw : '';
1150 $lines = array_filter(array_map('trim', explode("\n", $patternsStr)));
1151
1152 if (empty($lines)) {
1153 return 0;
1154 }
1155
1156 global $wpdb;
1157 $totalTrashed = 0;
1158
1159 $likeClauses = array();
1160 foreach ($lines as $pattern) {
1161 $escaped = $wpdb->esc_like($pattern);
1162 // DAO-bypass-approved: $wpdb->prepare is read-only string formatting; result goes through queryAndGetResults
1163 $likeClauses[] = $wpdb->prepare("url LIKE %s", '%' . $escaped . '%');
1164 }
1165
1166 $wherePatterns = implode(' OR ', $likeClauses);
1167 // allow-no-watermark-bump: DAO layer; admin callers bump via markViewDoneInvalidatedByAdminMutation()
1168 $query = "UPDATE {wp_abj404_redirects}
1169 SET disabled = 1
1170 WHERE status = " . ABJ404_STATUS_CAPTURED . "
1171 AND disabled = 0
1172 AND (" . $wherePatterns . ")";
1173 $query = $this->dbCore->doTableNameReplacements($query);
1174
1175 $result = $this->dbCore->queryAndGetResults($query);
1176 $affected = $result['rows_affected'] ?? 0;
1177 $totalTrashed += is_numeric($affected) ? (int)$affected : 0;
1178
1179 $cutoff = time() - (14 * DAY_IN_SECONDS);
1180 // allow-no-watermark-bump: DAO layer; admin callers bump via markViewDoneInvalidatedByAdminMutation()
1181 // DAO-bypass-approved: $wpdb->prepare is read-only string formatting; result goes through queryAndGetResults
1182 $query = $wpdb->prepare("UPDATE {wp_abj404_redirects} r
1183 SET r.disabled = 1
1184 WHERE r.status = " . ABJ404_STATUS_CAPTURED . "
1185 AND r.disabled = 0
1186 AND r.timestamp < %d
1187 AND NOT EXISTS (
1188 SELECT 1 FROM {wp_abj404_logsv2} l
1189 WHERE l.requested_url = r.url
1190 LIMIT 1
1191 )",
1192 $cutoff
1193 );
1194 $query = $this->dbCore->doTableNameReplacements($query);
1195
1196 $result = $this->dbCore->queryAndGetResults($query);
1197 $affected = $result['rows_affected'] ?? 0;
1198 $totalTrashed += is_numeric($affected) ? (int)$affected : 0;
1199
1200 if ($totalTrashed > 0) {
1201 $this->logger->infoMessage("Auto-trashed " . $totalTrashed . " junk/stale captured URLs during maintenance.");
1202 delete_transient(ABJ_404_Solution_DataAccess::CACHE_KEY_CAPTURED_STATUS);
1203 }
1204
1205 return $totalTrashed;
1206 }
1207
1208 // =========================================================================
1209 // Redirect maintenance (moved from DataAccessTrait_Maintenance, Phase 5)
1210 // =========================================================================
1211
1212 /** @inheritDoc */
1213 public function flagDeadDestinationRedirects(): void {
1214 $cutoff = time() - 7 * 86400;
1215 $flaggedIds = array();
1216
1217 $hitsTable = $this->dbCore->doTableNameReplacements('{wp_abj404_logs_hits}');
1218 $hitsTableExists = $this->dbCore->tableExists($hitsTable);
1219
1220 if (!$hitsTableExists || !$this->logsHitsHasFailedHitsColumn()) {
1221 /** @var ABJ_404_Solution_LogsRepository|null $logsRepo */
1222 $logsRepo = abj_service('logs_repository');
1223 if ($logsRepo !== null) {
1224 $logsRepo->scheduleHitsTableRebuild();
1225 }
1226 $this->storeDeadDestIdsTransient($flaggedIds);
1227 return;
1228 }
1229
1230 $sql = "SELECT DISTINCT r.id
1231 FROM {wp_abj404_redirects} r
1232 INNER JOIN {wp_abj404_logs_hits} h
1233 ON BINARY h.requested_url = BINARY CONCAT('/', TRIM(BOTH '/' FROM r.final_dest))
1234 WHERE h.last_used > %d
1235 AND h.failed_hits > 0
1236 AND r.disabled = 0
1237 AND r.final_dest != ''
1238 AND r.final_dest != '0'";
1239 $sql = $this->dbCore->doTableNameReplacements($sql);
1240
1241 $result = $this->dbCore->queryAndGetResults($sql, array(
1242 'query_params' => array($cutoff),
1243 'timeout' => 30,
1244 ));
1245
1246 if (empty($result['timed_out']) && (!isset($result['last_error']) || $result['last_error'] == '')) {
1247 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
1248 foreach ($rows as $row) {
1249 if (is_array($row)) {
1250 $value = $row['id'] ?? reset($row);
1251 } elseif (is_object($row)) {
1252 $value = $row->id ?? null;
1253 } else {
1254 $value = $row;
1255 }
1256 if ($value !== null && $value !== '') {
1257 $flaggedIds[] = (string)$value;
1258 }
1259 }
1260 }
1261
1262 $this->storeDeadDestIdsTransient($flaggedIds);
1263
1264 if (!empty($flaggedIds)) {
1265 $this->logger->infoMessage(
1266 __CLASS__ . '/' . __FUNCTION__ . ': Flagged ' . count($flaggedIds) .
1267 ' redirect(s) with dead destinations: ' . implode(', ', $flaggedIds)
1268 );
1269 }
1270 }
1271
1272 /**
1273 * @param array<int, string> $flaggedIds
1274 * @return void
1275 */
1276 private function storeDeadDestIdsTransient(array $flaggedIds): void {
1277 if (function_exists('set_transient')) {
1278 $ttl = defined('HOUR_IN_SECONDS') ? 25 * (int) HOUR_IN_SECONDS : 90000;
1279 // allow-cache-empty: flaggedIds is a diagnostic list (dead-destination redirect IDs); an empty array is a valid "no dead destinations" result.
1280 set_transient('abj404_dead_dest_ids', $flaggedIds, $ttl);
1281 }
1282 }
1283
1284 /**
1285 * @return bool
1286 */
1287 private function logsHitsHasFailedHitsColumn(): bool {
1288 $tableName = $this->dbCore->doTableNameReplacements('{wp_abj404_logs_hits}');
1289 $sql = "SELECT 1 FROM information_schema.columns "
1290 . "WHERE table_schema = DATABASE() "
1291 . "AND table_name = %s "
1292 . "AND column_name = 'failed_hits' LIMIT 1";
1293 $result = $this->dbCore->queryAndGetResults($sql, array(
1294 'query_params' => array($tableName),
1295 'log_errors' => false,
1296 ));
1297 if (!empty($result['last_error'])) {
1298 return false;
1299 }
1300 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
1301 return !empty($rows);
1302 }
1303
1304 /** @inheritDoc */
1305 public function expireOldAutoRedirects(): int {
1306 $options = abj_service('plugin_logic')->getOptions();
1307 $daysRaw = isset($options['auto_302_expiration_days']) ? $options['auto_302_expiration_days'] : 0;
1308 $days = is_numeric($daysRaw) ? (int)$daysRaw : 0;
1309 if ($days <= 0) {
1310 return 0;
1311 }
1312
1313 $redirectsTable = $this->dbCore->doTableNameReplacements('{wp_abj404_redirects}');
1314 if (!$this->dbCore->tableExists($redirectsTable)) {
1315 $this->logger->warn("expireOldAutoRedirects: redirects table missing, skipping.");
1316 return 0;
1317 }
1318
1319 $cutoff = time() - ($days * 86400);
1320
1321 $sql = "SELECT id FROM `{$redirectsTable}`
1322 WHERE status = %d
1323 AND disabled = 0
1324 AND `timestamp` > 0
1325 AND `timestamp` < %d";
1326
1327 $result = $this->dbCore->queryAndGetResults($sql, array(
1328 'query_params' => array(ABJ404_STATUS_AUTO, $cutoff),
1329 ));
1330
1331 if (!empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] != '')) {
1332 return 0;
1333 }
1334
1335 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
1336 $ids = array();
1337 foreach ($rows as $row) {
1338 if (is_array($row)) {
1339 $value = $row['id'] ?? reset($row);
1340 } elseif (is_object($row)) {
1341 $value = $row->id ?? null;
1342 } else {
1343 $value = $row;
1344 }
1345 if ($value !== null && $value !== '') {
1346 $ids[] = absint($value);
1347 }
1348 }
1349
1350 if (empty($ids)) {
1351 return 0;
1352 }
1353
1354 $moved = 0;
1355 foreach ($ids as $id) {
1356 $this->moveRedirectsToTrash($id, 1);
1357 $moved++;
1358 }
1359
1360 $this->logger->infoMessage("expireOldAutoRedirects: moved {$moved} expired auto-redirect(s) to trash (threshold: {$days} days).");
1361 return $moved;
1362 }
1363 }
1364