PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
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 / DataAccessTrait_Redirects.php

DataAccessTrait_Redirects.php in 404 Solution 4.1.19, at includes/DataAccessTrait_Redirects.php

1,236 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 trait ABJ_404_Solution_DataAccess_RedirectsTrait {
8
9 /**
10 * @param int|string $id
11 * @return void
12 */
13 function deleteRedirect($id) {
14 global $wpdb;
15 $cleanedID = absint(sanitize_text_field((string)$id));
16
17 // no nonce here because this action is not always user generated.
18
19 if (is_numeric($id)) {
20 $query = "delete from {wp_abj404_redirects} where id = %d";
21 $this->queryAndGetResults($query, array('query_params' => array($cleanedID)));
22
23 // Invalidate caches
24 $this->invalidateStatusCountsCache();
25 $this->clearRegexRedirectsCache();
26 }
27 }
28
29 /**
30 * Remove auto-created redirects whose destination post no longer exists or is not published.
31 *
32 * @return int Number of orphaned redirects deleted.
33 */
34 public function cleanupOrphanedAutoRedirects(): int {
35 // Guard: skip if redirects table doesn't exist (prevents recurring cron errors)
36 $redirectsTable = $this->doTableNameReplacements('{wp_abj404_redirects}');
37 if (!$this->tableExists($redirectsTable)) {
38 $this->logger->warn("Skipping orphaned redirect cleanup: table missing.");
39 return 0;
40 }
41
42 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getOrphanedAutoRedirects.sql");
43 $query = $this->doTableNameReplacements($query);
44 $query = $this->f->doNormalReplacements($query);
45
46 $results = $this->queryAndGetResults($query);
47 $rows = is_array($results['rows']) ? $results['rows'] : [];
48 $deletedCount = 0;
49
50 foreach ($rows as $row) {
51 if (!is_array($row)) {
52 continue;
53 }
54 $id = isset($row['id']) && is_scalar($row['id']) ? (string)$row['id'] : '0';
55 $url = isset($row['url']) && is_string($row['url']) ? $row['url'] : '';
56 $this->logger->debugMessage('Orphaned auto redirect deleted: "' . $url . '" (dest post ' .
57 (isset($row['final_dest']) && is_scalar($row['final_dest']) ? (string)$row['final_dest'] : '?') . ' missing/unpublished).');
58 $this->deleteRedirect($id);
59 $deletedCount++;
60 }
61
62 return $deletedCount;
63 }
64
65 /** Helper method to delete old redirects of a specific type.
66 * Extracted common logic from deleteOldRedirectsCron() to eliminate duplication.
67 *
68 * @param array<string, mixed> $options Plugin options
69 * @param int $now Current timestamp
70 * @param string $optionKey Option key for deletion threshold ('capture_deletion', 'auto_deletion', 'manual_deletion')
71 * @param string $statusList Comma-separated list of status codes to delete
72 * @param string $debugMessageType Type description for debug logging ('Captured 404', 'Automatic redirect', 'Manual redirect')
73 * @return int Count of deleted redirects
74 */
75 private function deleteOldRedirectsByType($options, $now, $optionKey, $statusList, $debugMessageType) {
76 $abj404dao = abj_service('data_access');
77 $deletedCount = 0;
78
79 // Calculate time threshold
80 $rawDays = $options[$optionKey] ?? 0;
81 $deletionDays = intval(is_scalar($rawDays) ? $rawDays : 0);
82 if ($deletionDays <= 0) {
83 return 0;
84 }
85 $deletionTime = $deletionDays * 86400;
86 $then = $now - $deletionTime;
87
88 // setSqlBigSelects() must run before any branch that touches the
89 // deletion query path so a large logs_hits/redirects join cannot
90 // trip MAX_JOIN_SIZE on legacy hosts that still default it small.
91 // Kept above the rollup-existence guard so callers still see the
92 // session pragma flip even on the skip path.
93 $this->setSqlBigSelects();
94
95 // F6 audit: getMostUnusedRedirects.sql joins logs_hits. If the rollup
96 // does not exist yet (fresh install, never built), schedule a rebuild
97 // and skip this run. The next daily cron will have it. Without the
98 // table the LEFT JOIN errors instead of degrading to "never used".
99 if (!$this->logsHitsTableExists()) {
100 $this->logger->debugMessage(__FUNCTION__ . " skipping: logs_hits table missing; scheduling rebuild.");
101 $this->scheduleHitsTableRebuild();
102 return 0;
103 }
104
105 // Load and prepare SQL query
106 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getMostUnusedRedirects.sql");
107 $query = $this->f->str_replace('{status_list}', $statusList, $query);
108 $query = $this->f->str_replace('{timelimit}', (string)$then, $query);
109
110 // Execute query and get results
111 $results = $this->queryAndGetResults($query);
112 $rows = is_array($results['rows']) ? $results['rows'] : array();
113
114 // Delete each redirect and log
115 foreach ($rows as $rowRaw) {
116 if (!is_array($rowRaw)) {
117 continue;
118 }
119 $row = $rowRaw;
120 // Build debug message based on redirect type
121 if ($debugMessageType === 'Captured 404') {
122 $this->logger->debugMessage("Captured 404 for \"" . (is_string($row['from_url'] ?? '') ? $row['from_url'] : '') .
123 '" deleted (last used: ' . (is_string($row['last_used_formatted'] ?? '') ? $row['last_used_formatted'] : '') . ').');
124 } else {
125 // Auto and Manual redirects show from/to URLs
126 $this->logger->debugMessage($debugMessageType . " from: " . (is_string($row['from_url'] ?? '') ? $row['from_url'] : '') . ' to: ' .
127 (is_string($row['best_guess_dest'] ?? '') ? $row['best_guess_dest'] : '') . ' deleted (last used: ' . (is_string($row['last_used_formatted'] ?? '') ? $row['last_used_formatted'] : '') . ').');
128 }
129
130 $abj404dao->deleteRedirect(isset($row['id']) && is_scalar($row['id']) ? (string)$row['id'] : '0');
131 $deletedCount++;
132 }
133
134 return $deletedCount;
135 }
136
137 /**
138 * Delete old rows from the logs/protocol table based on age.
139 *
140 * Uses batch deletes to avoid a long-running single table lock.
141 *
142 * @param int $daysToKeep
143 * @param int $now
144 * @return int
145 */
146 private function deleteOldLogsByAge(int $daysToKeep, int $now): int {
147 if ($daysToKeep <= 0) {
148 return 0;
149 }
150
151 $cutoffTimestamp = max(0, $now - ($daysToKeep * 86400));
152 $deletedTotal = 0;
153 $batchSize = 2000;
154 $maxBatches = 200;
155
156 for ($i = 0; $i < $maxBatches; $i++) {
157 $result = $this->queryAndGetResults(
158 "DELETE FROM {wp_abj404_logsv2} WHERE timestamp <= %d LIMIT %d",
159 array(
160 'query_params' => array($cutoffTimestamp, $batchSize),
161 'log_errors' => true,
162 )
163 );
164 $rowsDeletedRaw = $result['rows_affected'] ?? 0;
165 $rowsDeleted = (is_int($rowsDeletedRaw) || is_float($rowsDeletedRaw) || is_string($rowsDeletedRaw))
166 ? (int)$rowsDeletedRaw
167 : 0;
168 if ($rowsDeleted <= 0) {
169 break;
170 }
171 $deletedTotal += $rowsDeleted;
172 if ($rowsDeleted < $batchSize) {
173 break;
174 }
175 }
176
177 return $deletedTotal;
178 }
179
180 /** Delete old redirects based on how old they are. This runs daily.
181 * @return string
182 */
183 function deleteOldRedirectsCron() {
184 global $wpdb;
185 $abj404dao = abj_service('data_access');
186 $abj404logic = abj_service('plugin_logic');
187
188 $options = $abj404logic->getOptions();
189 $now = time();
190 $capturedURLsCount = 0;
191 $autoRedirectsCount = 0;
192 $manualRedirectsCount = 0;
193 $oldLogRowsDeletedBySize = 0;
194 $oldLogRowsDeletedByAge = 0;
195
196 // If true then the user clicked the button to execute the mantenance.
197 $manually_fired = $abj404dao->getPostOrGetSanitize('manually_fired', 'false');
198 if ($this->f->strtolower($manually_fired) == 'true') {
199 $manually_fired = true;
200 } else {
201 $manually_fired = false;
202 }
203
204 $upgradesEtc = abj_service('database_upgrades');
205 $upgradesEtc->createDatabaseTables(false);
206
207 // Ensure database connection is active for long-running maintenance operations
208 // This prevents "MySQL server has gone away" errors
209 $this->ensureConnection();
210
211 // delete the export file
212 $tempFile = $abj404logic->getExportFilename();
213 if (file_exists($tempFile)) {
214 ABJ_404_Solution_Functions::safeUnlink($tempFile);
215 }
216
217 $duplicateRowsDeleted = $abj404dao->removeDuplicatesCron();
218
219 // Remove Captured URLs
220 if (array_key_exists('capture_deletion', $options) && $options['capture_deletion'] != '0') {
221 $status_list = ABJ404_STATUS_CAPTURED . ", " . ABJ404_STATUS_IGNORED . ", " . ABJ404_STATUS_LATER;
222 $capturedURLsCount = $this->deleteOldRedirectsByType($options, $now, 'capture_deletion', $status_list, 'Captured 404');
223 $captureDeletionDays = intval(is_scalar($options['capture_deletion']) ? $options['capture_deletion'] : 0);
224 $oldLogRowsDeletedByAge = $this->deleteOldLogsByAge($captureDeletionDays, $now);
225 }
226
227 // Remove Automatic Redirects
228 if (isset($options['auto_deletion']) && $options['auto_deletion'] != '0') {
229 $status_list = (string)ABJ404_STATUS_AUTO;
230 $autoRedirectsCount = $this->deleteOldRedirectsByType($options, $now, 'auto_deletion', $status_list, 'Automatic redirect');
231 }
232
233 // Remove Manual Redirects
234 if (isset($options['manual_deletion']) && $options['manual_deletion'] != '0') {
235 $status_list = ABJ404_STATUS_MANUAL . ", " . ABJ404_STATUS_REGEX;
236 $manualRedirectsCount = $this->deleteOldRedirectsByType($options, $now, 'manual_deletion', $status_list, 'Manual redirect');
237 }
238
239 // Remove orphaned auto redirects (destination post deleted/unpublished)
240 $orphanedCount = $this->cleanupOrphanedAutoRedirects();
241
242 // Auto-trash junk/bot captured URLs
243 $junkTrashedCount = $this->autoTrashJunkCapturedUrls($options);
244
245 //Clean up old logs. prepare the query. get the disk usage in bytes. compare to the max requested
246 // disk usage (MB to bytes). delete 1k rows at a time until the size is acceptable.
247 $logsSizeBytes = $abj404dao->getLogDiskUsage();
248 $maxLogSizeBytes = (array_key_exists('maximum_log_disk_usage', $options) ? $options['maximum_log_disk_usage'] : 100) * 1024 * 1000;
249
250 // Disk-size gate first: skip the trim entirely when we're under budget.
251 // This keeps the daily cron path fast in the common case (no over-quota,
252 // no scan, no destructive query) without relying on a row-count
253 // approximation for any decision that drives DELETE.
254 //
255 // When we ARE over budget, pay for an exact COUNT(id) before computing
256 // logLinesToDelete. An information_schema.TABLE_ROWS approximation
257 // would be cheap, but for InnoDB it can drift by orders of magnitude
258 // — using it as the denominator of a destructive DELETE … LIMIT N
259 // query risks over-deleting retained logs (approx too low →
260 // averageSizePerLine inflated → logLinesToKeep too small) or
261 // under-deleting enough to miss the disk cap (approx too high →
262 // reverse). The exact COUNT cost is paid only on the rare ticks
263 // where we actually need to trim.
264 if ($logsSizeBytes > $maxLogSizeBytes) {
265 $totalLogLines = $abj404dao->getLogsCount(0);
266 $averageSizePerLine = max($logsSizeBytes, 1) / max($totalLogLines, 1);
267 $logLinesToKeep = ceil($maxLogSizeBytes / $averageSizePerLine);
268 $logLinesToDelete = max($totalLogLines - $logLinesToKeep, 0);
269 if ($logLinesToDelete > 0) {
270 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/deleteOldLogs.sql");
271 $query = $this->f->str_replace('{lines_to_delete}', (string)$logLinesToDelete, $query);
272 $results = $this->queryAndGetResults($query);
273 $oldLogRowsDeletedBySizeRaw = $results['rows_affected'] ?? 0;
274 $oldLogRowsDeletedBySize = (is_int($oldLogRowsDeletedBySizeRaw) || is_float($oldLogRowsDeletedBySizeRaw) || is_string($oldLogRowsDeletedBySizeRaw))
275 ? (int)$oldLogRowsDeletedBySizeRaw
276 : 0;
277 }
278 }
279
280 $logsSizeBytes = $abj404dao->getLogDiskUsage();
281 $logSizeMB = round($logsSizeBytes / (1024 * 1000), 2);
282
283 $renamed = $abj404dao->limitDebugFileSize();
284 $renamed = $renamed ? "true" : "false";
285
286 $oldLogRowsDeleted = $oldLogRowsDeletedByAge + $oldLogRowsDeletedBySize;
287
288 $message = "deleteOldRedirectsCron. Old captured URLs removed: " .
289 $capturedURLsCount . ", Old automatic redirects removed: " . $autoRedirectsCount .
290 ", Old manual redirects removed: " . $manualRedirectsCount .
291 ", Orphaned auto redirects removed: " . $orphanedCount .
292 ", Junk URLs auto-trashed: " . $junkTrashedCount .
293 ", Old log lines removed: " . $oldLogRowsDeleted .
294 " (age: " . $oldLogRowsDeletedByAge . ", size: " . $oldLogRowsDeletedBySize . ")" .
295 ", New log size: " . $logSizeMB . "MB" .
296 ", Duplicate rows deleted: " . $duplicateRowsDeleted . ", Debug file size limited: " .
297 $renamed;
298
299 // only send a 404 notification email during daily maintenance.
300 $adminEmailVal = array_key_exists('admin_notification_email', $options) ? $options['admin_notification_email'] : '';
301 if ($adminEmailVal !== null &&
302 $this->f->strlen(trim(is_string($adminEmailVal) ? $adminEmailVal : '')) > 5) {
303
304 if ($manually_fired) {
305 $message .= ', The admin email notification option is skipped for user '
306 . 'initiated maintenance runs.';
307 } else {
308 $message .= ', ' . $abj404logic->emailCaptured404Notification();
309 }
310 } else {
311 $message .= ', Admin email notification option turned off.';
312 }
313
314 if (isset($options['send_error_logs']) &&
315 $options['send_error_logs'] == '1') {
316 if ($this->logger->emailErrorLogIfNecessary()) {
317 $message .= ", Log file emailed to developer.";
318 } else {
319 // No error to report — roll the heartbeat dice.
320 if ($this->logger->sendHeartbeatIfDueRandom(200)) {
321 $message .= ", Heartbeat log emailed to developer.";
322 }
323 }
324 }
325
326 // Flag redirects whose destination URL is generating 404s (dead-destination detection).
327 // This drives the "suspended redirect" warning in the admin table and allows
328 // the frontend pipeline to skip known-bad destinations.
329 $abj404dao->flagDeadDestinationRedirects();
330
331 // add some entries to the permalink cache if necessary
332 $abj404permalinkCache = abj_service('permalink_cache');
333 $rowsUpdated = $abj404permalinkCache->updatePermalinkCache(15);
334 $message .= ", Permlink cache rows updated: " . $rowsUpdated;
335
336 $manually_fired_String = ($manually_fired) ? 'true' : 'false';
337 $message .= ", User initiated: " . $manually_fired_String;
338
339 $this->logger->infoMessage($message);
340
341 // fix any lingering errors
342 $upgradesEtc = abj_service('database_upgrades');
343 $upgradesEtc->createDatabaseTables();
344
345 $this->queryAndGetResults("optimize table {wp_abj404_redirects}");
346
347 $upgradesEtc->updatePluginCheck();
348
349 return $message;
350 }
351
352 /** @return bool */
353 function limitDebugFileSize(): bool {
354 $renamed = false;
355
356 $mbFileSize = $this->logger->getDebugFileSize() / 1024 / 1000;
357 if ($mbFileSize > 10) {
358 $this->logger->limitDebugFileSize();
359 $renamed = true;
360 }
361
362 return $renamed;
363 }
364
365 /** Remove duplicates.
366 * @return int
367 */
368 function removeDuplicatesCron(): int {
369 $rowsDeleted = 0;
370 $query = "SELECT COUNT(id) as repetitions, url FROM {wp_abj404_redirects} GROUP BY url HAVING repetitions > 1 ";
371 $result = $this->queryAndGetResults($query);
372 $outerRows = is_array($result['rows']) ? $result['rows'] : array();
373 foreach ($outerRows as $outerRow) {
374 if (!is_array($outerRow)) {
375 continue;
376 }
377 $row = $outerRow;
378 $url = $row['url'];
379
380 // Fix HIGH #2 (5th review): Use prepared statements instead of manual escaping
381 $queryr1 = $this->prepare_query_wp(
382 "select id from {wp_abj404_redirects} where url = {url} order by timestamp desc limit 0,1",
383 array("url" => $url)
384 );
385 $result = $this->queryAndGetResults($queryr1);
386 $innerRows = is_array($result['rows']) ? $result['rows'] : array();
387 if (count($innerRows) >= 1) {
388 $row = is_array($innerRows[0]) ? $innerRows[0] : array();
389 $original = isset($row['id']) ? $row['id'] : 0;
390
391 // Fix HIGH #2 (5th review): Use prepared statements instead of manual escaping
392 $queryl = $this->prepare_query_wp(
393 "delete from {wp_abj404_redirects} where url = {url} and id != {original}",
394 array("url" => $url, "original" => $original)
395 );
396 $deleteResult = $this->queryAndGetResults($queryl);
397 $affected = isset($deleteResult['rows_affected']) && is_numeric($deleteResult['rows_affected'])
398 ? (int)$deleteResult['rows_affected'] : 1;
399 $rowsDeleted += max($affected, 1);
400 }
401 }
402
403 // Invalidate status counts cache if any duplicates were removed
404 if ($rowsDeleted > 0) {
405 $this->invalidateStatusCountsCache();
406 }
407
408 return $rowsDeleted;
409 }
410
411 /**
412 * Canonical URL form stored in {wp_abj404_redirects}.canonical_url.
413 *
414 * Mirrors the SQL expression CONCAT('/', TRIM(BOTH '/' FROM url)) so the
415 * captured-page JOIN against logs_hits.requested_url is a single indexed
416 * equality lookup instead of evaluating CONCAT/TRIM per redirect row.
417 * Both sides (the persisted column here and the rollup pre-aggregation
418 * in getRedirectsForViewTempTable.sql) MUST produce byte-identical
419 * output for the JOIN to match.
420 *
421 * Examples: 'foo' → '/foo', '/foo/' → '/foo', '' → '/', '/' → '/'.
422 *
423 * @param mixed $url
424 * @return string
425 */
426 public static function computeRedirectsCanonicalUrl($url): string {
427 if (!is_string($url)) {
428 return '/';
429 }
430 $trimmed = trim($url, '/');
431 if ($trimmed === '') {
432 return '/';
433 }
434 return '/' . $trimmed;
435 }
436
437 /**
438 * Per-instance memoized cache of column-existence probes against the
439 * redirects table. Keyed by lowercased column name. Empty array means
440 * the cache has not been primed yet for any column.
441 *
442 * Per-instance (not static) so the DAO singleton resets on process end
443 * without leaking state across test methods that build their own DAO.
444 *
445 * @var array<string, bool>
446 */
447 private $redirectsTableColumnsCache = array();
448
449 /**
450 * Probe whether the redirects table has the named column right now.
451 *
452 * Used by setupRedirect() to drop columns from the INSERT payload when
453 * they are missing on this site (e.g. canonical_url on installs where
454 * dbDelta silently failed to ALTER ADD it). Result is cached for the
455 * life of the DAO instance: the first call runs SHOW COLUMNS FROM
456 * {wp_abj404_redirects}, every subsequent call hits the cache.
457 *
458 * Defensive against host failure: if SHOW COLUMNS errors out (table
459 * missing, permission denied, etc.), returns true so the INSERT path
460 * proceeds with the full payload. Better to surface the eventual
461 * INSERT failure (which is already routed through queryAndGetResults
462 * with auto-repair) than to silently strip a column we cannot probe.
463 *
464 * @param string $columnName
465 * @return bool
466 */
467 private function redirectsTableHasColumn(string $columnName): bool {
468 $key = strtolower($columnName);
469 if ($this->redirectsTableColumnsCache !== array()) {
470 // Cache primed: definitive yes/no for any column we saw on the
471 // table. Absence in the cache means the column does not exist.
472 return isset($this->redirectsTableColumnsCache[$key]);
473 }
474 global $wpdb;
475 if (!isset($wpdb)) {
476 // No DB handle (early-bootstrap path or test harness without
477 // wpdb global). Default permissive so the caller's normal
478 // payload path runs; do not cache so a later call retries.
479 return true;
480 }
481 $redirectsTable = $this->doTableNameReplacements("{wp_abj404_redirects}");
482 // Probe via queryAndGetResults so the DAO's centralized error
483 // classification, retry, and timeout handling apply. log_errors=false
484 // because a missing-table or permission-denied response is a probe
485 // signal here, not a bug to surface to the admin.
486 // @utf8-audit: opt-out - $redirectsTable is built by doTableNameReplacements
487 // from $wpdb->prefix plus the literal token {wp_abj404_redirects}; no user
488 // input flows in, so invalid-UTF-8 to SQL is structurally impossible.
489 $result = $this->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 // Probe failed (table missing, permission denied, host-side
496 // restriction). Default permissive so the centralized DAO error
497 // handler classifies any resulting INSERT failure with full
498 // context. Do not cache: the next call may succeed once the
499 // missing-table auto-repair runs.
500 return true;
501 }
502 $primed = array();
503 foreach ($rows as $row) {
504 if (!is_array($row)) { continue; }
505 foreach ($row as $field => $value) {
506 if (strtolower((string)$field) !== 'field') { continue; }
507 $primed[strtolower((string)$value)] = true;
508 }
509 }
510 if ($primed === array()) {
511 // SHOW COLUMNS returned rows but none had a parseable Field
512 // entry. Treat as a probe failure (do not cache).
513 return true;
514 }
515 $this->redirectsTableColumnsCache = $primed;
516 return isset($this->redirectsTableColumnsCache[$key]);
517 }
518
519 /**
520 * SQL expression that emits the canonical form of an arbitrary URL column.
521 *
522 * Used by the (rare) callsites that still need to canonicalize at JOIN
523 * time — e.g. logs_hits rebuild, which canonicalizes logsv2.requested_url
524 * before grouping. Persistent canonical_url on redirects rows is the fast
525 * path; this expression is only for columns we cannot pre-compute.
526 *
527 * @param string $columnExpr A SQL column reference, e.g. "r.url".
528 * @return string SQL fragment.
529 */
530 public static function hitsCanonicalUrlSqlExpression(string $columnExpr): string {
531 return "CONCAT('/', TRIM(BOTH '/' FROM " . $columnExpr . "))";
532 }
533
534 /**
535 * Store a redirect for future use.
536 * @global type $wpdb
537 * @param string $fromURL
538 * @param string $status ABJ404_STATUS_MANUAL etc
539 * @param string $type ABJ404_TYPE_POST, ABJ404_TYPE_CAT, ABJ404_TYPE_TAG, etc.
540 * @param string $final_dest
541 * @param string $code
542 * @param int $disabled
543 * @param string|null $engine The matching engine that created this redirect (null for manual/unknown)
544 * @param float|null $score Match confidence score (0-100), NULL for manual redirects
545 * @return int
546 */
547 function setupRedirect($fromURL, $status, $type, $final_dest, $code, $disabled = 0, $engine = null, $score = null) {
548 global $wpdb;
549
550 // nonce is verified outside of this method. We can't verify here because
551 // automatic redirects are sometimes created without user interaction.
552
553 if (!is_numeric($type)) {
554 $this->logger->errorMessage("Wrong data type for redirect. TYPE is non-numeric. From: " .
555 esc_url($fromURL) . " to: " . esc_url($final_dest) . ", Type: " .esc_html($type) . ", Status: " . $status);
556 } else if (!is_numeric($status)) {
557 $this->logger->errorMessage("Wrong data type for redirect. STATUS is non-numeric. From: " .
558 esc_url($fromURL) . " to: " . esc_url($final_dest) . ", Type: " .esc_html($type) . ", Status: " . $status);
559 }
560
561 $statusAsInt = is_numeric($status) ? absint($status) : -1;
562 $typeAsInt = is_numeric($type) ? absint($type) : -1;
563
564 // Guard: automatic redirects must point to a currently valid destination.
565 // This prevents persisting "auto" rows with missing/unpublished targets.
566 if ($statusAsInt === ABJ404_STATUS_AUTO &&
567 !$this->isValidAutomaticRedirectDestination($typeAsInt, $final_dest)) {
568 $this->logger->debugMessage("Skipping automatic redirect with invalid destination. " .
569 "From: " . esc_url($fromURL) . ", Dest: " . esc_html((string)$final_dest) .
570 ", Type: " . esc_html((string)$type) . ", Status: " . esc_html((string)$status));
571 return 0;
572 }
573
574 $insertId = 0;
575
576 // if we should not capture a 404 then don't.
577 if (!abj_service('request_context')->ignore_doprocess) {
578 $now = time();
579 $redirectsTable = $this->doTableNameReplacements("{wp_abj404_redirects}");
580
581 // Normalize to relative path before storing (Issue #24)
582 // Fix HIGH #1 (5th review): Abort operation if normalization fails
583 // Storing un-normalized URLs causes permanent lookup failures
584 $abj404logic = abj_service('plugin_logic');
585 $fromURL = $abj404logic->normalizeToRelativePath($fromURL);
586
587 $insertData = array(
588 'url' => $fromURL,
589 'status' => $status,
590 'type' => $type,
591 'final_dest' => $final_dest,
592 'code' => $code,
593 'disabled' => $disabled,
594 'timestamp' => $now,
595 );
596 $insertFormats = array('%s', '%d', '%d', '%s', '%d', '%d', '%d');
597 // Schema-drift tolerance: canonical_url shipped in 4.1.11. On
598 // installs where dbDelta silently failed to add it, every
599 // captured-404 INSERT errors with "Unknown column 'canonical_url'
600 // in 'field list'" (1671 such errors observed on a single 4.1.12
601 // site over 10 days in the May 10 debug zip). Probe before
602 // referencing the column. The probe result is cached per-request
603 // by self::redirectsTableHasColumn() so the SHOW COLUMNS cost
604 // amortizes across multiple captured-404 hits in the same request.
605 // Pre-compute the canonical form so the captured-page JOIN to
606 // logs_hits.requested_url is a single indexed equality lookup
607 // instead of CONCAT('/', TRIM(...)) per row at query time. The
608 // formula must stay in lockstep with hitsCanonicalUrlSqlExpression()
609 // (read side) and the buildRedirectsCanonicalUrlChunk() backfill.
610 if ($this->redirectsTableHasColumn('canonical_url')) {
611 $insertData['canonical_url'] = self::computeRedirectsCanonicalUrl($fromURL);
612 $insertFormats[] = '%s';
613 }
614 if ($engine !== null) {
615 $insertData['engine'] = substr((string)$engine, 0, 64);
616 $insertFormats[] = '%s';
617 }
618 if ($score !== null) {
619 $insertData['score'] = round((float)$score, 2);
620 $insertFormats[] = '%f';
621 }
622
623 $insertSql = "INSERT INTO `" . $redirectsTable . "` (`" .
624 implode('`, `', array_keys($insertData)) . "`) VALUES (" .
625 implode(', ', $insertFormats) . ")";
626 $insertResult = $this->queryAndGetResults($insertSql, array(
627 'query_params' => array_values($insertData),
628 ));
629 $insertIdRaw = $insertResult['insert_id'] ?? 0;
630 $insertId = is_scalar($insertIdRaw) ? (int)$insertIdRaw : 0;
631
632 // Invalidate caches
633 $this->invalidateStatusCountsCache();
634 // Clear regex cache in case a regex redirect was added
635 if ($status == ABJ404_STATUS_REGEX) {
636 $this->clearRegexRedirectsCache();
637 }
638 }
639
640 return $insertId;
641 }
642
643 /**
644 * Automatic redirects are only valid for published posts or existing terms.
645 * If a destination is missing or unpublished, skip creating the auto redirect.
646 *
647 * @param int $type
648 * @param mixed $finalDest
649 * @return bool
650 */
651 private function isValidAutomaticRedirectDestination($type, $finalDest) {
652 $destId = absint(is_scalar($finalDest) ? $finalDest : 0);
653
654 if ($type === ABJ404_TYPE_POST) {
655 if ($destId <= 0) {
656 return false;
657 }
658 if (!function_exists('get_post')) {
659 return true;
660 }
661
662 // Boundary normalizer: WP_Post shape-probing lives in the VO.
663 $ref = ABJ_404_Solution_PostRef::fromWpPost(get_post($destId));
664 if ($ref === null) {
665 return false;
666 }
667 return $ref->isPublished();
668 }
669
670 if ($type === ABJ404_TYPE_CAT || $type === ABJ404_TYPE_TAG) {
671 if ($destId <= 0) {
672 return false;
673 }
674 if (!function_exists('get_term')) {
675 return true;
676 }
677
678 $taxonomy = ($type === ABJ404_TYPE_CAT) ? 'category' : 'post_tag';
679 $term = get_term($destId, $taxonomy);
680 if ($term === null || is_wp_error($term)) {
681 return false;
682 }
683 return is_object($term);
684 }
685
686 // Homepage is always a valid auto redirect destination.
687 if ($type === ABJ404_TYPE_HOME) {
688 return true;
689 }
690
691 // Auto redirects should not target other types.
692 return false;
693 }
694
695 /** Get the redirect for the URL.
696 *
697 * @param string $url
698 * @param bool $degradedMode When true, the lookup tolerates a partially-
699 * migrated schema by stripping predicates that reference columns
700 * not yet present (e.g. r.start_ts / r.end_ts on installs that
701 * have not run the 4.1.x scheduled-redirect migration). Skipping
702 * scheduled-redirect filtering is far better than 100% of redirects
703 * failing silently. The pipeline only enables this mode when
704 * DB_VERSION lags ABJ404_VERSION and recovery has not closed the
705 * gap, so the happy path pays no extra cost.
706 * @return array<string, mixed>
707 */
708 function getActiveRedirectForURL($url, $degradedMode = false) {
709 // Strip invalid UTF-8/control bytes but keep valid unicode for multilingual slugs.
710 $url = $this->f->sanitizeInvalidUTF8($url);
711
712 // Reject URLs still invalid after sanitization (bot garbage like %c0, null bytes)
713 if (function_exists('mb_check_encoding') && !mb_check_encoding($url, 'UTF-8')) {
714 return array('id' => 0);
715 }
716
717 // Normalize to relative path before querying (Issue #24)
718 // Fix HIGH #1 (5th review): Abort operation if normalization fails
719 // Querying with un-normalized URLs causes lookup failures
720 $abj404logic = abj_service('plugin_logic');
721 $candidates = $abj404logic->getNormalizedUrlCandidates($url);
722 foreach ($candidates as $candidate) {
723 $redirect = $this->getActiveRedirectForNormalizedUrl($candidate, $degradedMode);
724 if ($redirect['id'] !== 0) {
725 return $redirect;
726 }
727 }
728
729 return array('id' => 0);
730 }
731
732 /** Get the redirect for the URL.
733 * @param string $url
734 * @return array<string, mixed>
735 */
736 function getExistingRedirectForURL($url) {
737 // Strip invalid UTF-8/control bytes but keep valid unicode for multilingual slugs.
738 $url = $this->f->sanitizeInvalidUTF8($url);
739
740 // Reject URLs still invalid after sanitization (bot garbage like %c0, null bytes)
741 if (function_exists('mb_check_encoding') && !mb_check_encoding($url, 'UTF-8')) {
742 return array('id' => 0);
743 }
744
745 // Normalize to relative path before querying (Issue #24)
746 // Fix HIGH #1 (5th review): Abort operation if normalization fails
747 // Querying with un-normalized URLs causes lookup failures
748 $abj404logic = abj_service('plugin_logic');
749 $candidates = $abj404logic->getNormalizedUrlCandidates($url);
750 foreach ($candidates as $candidate) {
751 $redirect = $this->getExistingRedirectForNormalizedUrl($candidate);
752 if ($redirect['id'] !== 0) {
753 return $redirect;
754 }
755 }
756
757 return array('id' => 0);
758 }
759
760 /**
761 * @param string $url
762 * @param bool $degradedMode See getActiveRedirectForURL().
763 * @return array<string, mixed>
764 */
765 private function getActiveRedirectForNormalizedUrl($url, $degradedMode = false) {
766 $redirect = array();
767
768 // we look for two URLs that might match. one with a trailing slash and one without.
769 // the one the user entered takes priority in case the admin added separate redirects for
770 // cases with and without the slash (and for backward compatibility).
771 $url1 = $url;
772 $url2 = $url;
773 if (substr($url, -1) === '/') {
774 $url2 = rtrim($url, '/');
775 } else {
776 $url2 = $url2 . '/';
777 }
778
779 // join to the wp_posts table to make sure the post exists.
780 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/getPermalinkFromURL.sql");
781
782 // Degraded mode: when the migration that adds r.start_ts/r.end_ts has
783 // not run (or is failing repeatedly), the columns may be missing from
784 // wp_abj404_redirects on this install. Strip the scheduled-redirect
785 // predicates so the lookup still serves manual + automatic redirects.
786 // Trade-off: scheduled redirects whose start/end window is currently
787 // active will still match (good); scheduled redirects whose window has
788 // not yet opened or has already closed will *also* match during this
789 // window (acceptable — far better than 100% of redirects failing).
790 if ($degradedMode && $this->redirectsTableMissingScheduledColumns()) {
791 $query = $this->stripScheduledRedirectPredicates($query);
792 }
793
794 // Fix HIGH #2 (5th review): Use prepared statements instead of manual escaping
795 $query = $this->prepare_query_wp($query, array("url1" => $url1, "url2" => $url2));
796 $query = $this->doTableNameReplacements($query);
797 $query = $this->f->doNormalReplacements($query);
798 $results = $this->queryAndGetResults($query);
799 $rows = $results['rows'];
800
801 if (is_array($rows)) {
802 if (empty($rows)) {
803 $redirect['id'] = 0;
804 } else {
805 foreach ($rows[0] as $key => $value) {
806 $redirect[$key] = $value;
807 }
808 }
809 }
810
811 if (!isset($redirect['id'])) {
812 $redirect['id'] = 0;
813 }
814
815 return $redirect;
816 }
817
818 /**
819 * Determine whether wp_abj404_redirects is missing the scheduled-redirect
820 * columns (start_ts / end_ts) added in the 4.1.x migration. Result is
821 * cached in a transient so subsequent 404s do not re-run SHOW COLUMNS.
822 *
823 * The cache is short-lived on the "missing" branch (5 min) so that once
824 * the migration finally runs we pick up the new columns quickly. On the
825 * "present" branch we cache for 24 h — columns don't disappear once added,
826 * so a long TTL keeps the happy path fast.
827 */
828 private function redirectsTableMissingScheduledColumns(): bool {
829 $cacheKey = 'abj404_redirects_scheduled_cols_status';
830 if (function_exists('get_transient')) {
831 $cached = get_transient($cacheKey);
832 if ($cached === 'missing') { return true; }
833 if ($cached === 'present') { return false; }
834 }
835
836 $tableName = $this->doTableNameReplacements('{wp_abj404_redirects}');
837 $columns = $this->getRedirectsTableColumns($tableName);
838
839 // If we couldn't read the schema at all, do NOT strip predicates —
840 // returning false keeps the standard query, which fails loudly rather
841 // than masking a deeper problem.
842 if (empty($columns)) {
843 return false;
844 }
845
846 $colsLower = array_map('strtolower', $columns);
847 $missing = !in_array('start_ts', $colsLower, true)
848 || !in_array('end_ts', $colsLower, true);
849
850 if (function_exists('set_transient')) {
851 $hour = defined('HOUR_IN_SECONDS') ? (int) HOUR_IN_SECONDS : 3600;
852 set_transient(
853 $cacheKey,
854 $missing ? 'missing' : 'present',
855 $missing ? 5 * 60 : 24 * $hour
856 );
857 }
858
859 return $missing;
860 }
861
862 /**
863 * Read column names for a table via SHOW COLUMNS. Returns [] on failure
864 * so callers can decide to fall back to the standard query.
865 *
866 * @return array<int, string>
867 */
868 private function getRedirectsTableColumns(string $tableName): array {
869 global $wpdb;
870 if (!isset($wpdb)) {
871 return [];
872 }
873 // @utf8-audit: opt-out — $tableName is always a system value
874 // (doTableNameReplacements / $wpdb->prefix); never user input.
875 $result = $this->queryAndGetResults(
876 "SHOW COLUMNS FROM `" . esc_sql($tableName) . "`",
877 array('log_errors' => false)
878 );
879 $rows = isset($result['rows']) && is_array($result['rows']) ? $result['rows'] : [];
880 $lastError = isset($result['last_error']) && is_string($result['last_error']) ? $result['last_error'] : '';
881 if ($lastError !== '') {
882 return [];
883 }
884 $columns = [];
885 foreach ($rows as $row) {
886 if (is_array($row) && isset($row['Field']) && is_string($row['Field'])) {
887 $columns[] = $row['Field'];
888 }
889 }
890 return $columns;
891 }
892
893 /**
894 * Strip lines from getPermalinkFromURL.sql that reference r.start_ts or
895 * r.end_ts. Used in degraded mode when those columns are missing on this
896 * install.
897 */
898 private function stripScheduledRedirectPredicates(string $sql): string {
899 $stripped = preg_replace(
900 '/^[^\n]*\br\.(?:start_ts|end_ts)\b[^\n]*\R?/m',
901 '',
902 $sql
903 );
904 return is_string($stripped) ? $stripped : $sql;
905 }
906
907 /**
908 * @param string $url
909 * @return array<string, mixed>
910 */
911 private function getExistingRedirectForNormalizedUrl($url) {
912 $redirect = array();
913
914 // a disabled value of '1' means in the trash.
915 $query = $this->prepare_query_wp('select * from {wp_abj404_redirects} where BINARY url = BINARY {url} ' .
916 " and disabled = 0 ", array("url" => $url));
917 $results = $this->queryAndGetResults($query);
918 $rows = $results['rows'];
919
920 if (is_array($rows)) {
921 if (empty($rows)) {
922 $redirect['id'] = 0;
923 } else {
924 foreach ($rows[0] as $key => $value) {
925 $redirect[$key] = $value;
926 }
927 }
928 }
929
930 if (!isset($redirect['id'])) {
931 $redirect['id'] = 0;
932 }
933
934 return $redirect;
935 }
936
937 // Published-content lookup queries (getPublishedPagesAndPostsIDs,
938 // getPublishedImagesIDs, getPublishedTags, addURLToTermsRows,
939 // getPublishedCategories) live in DataAccessTrait_PublishedContent.
940
941 /** Delete stored redirects based on passed in POST data.
942 * @return string
943 */
944 function deleteSpecifiedRedirects() {
945 global $wpdb;
946 $message = "";
947
948 // nonce already verified.
949
950 if (!array_key_exists('sanity_purge', $_POST) || $_POST['sanity_purge'] != "1") {
951 $message = __('Error: You didn\'t check the I understand checkbox. No purging of records for you!', '404-solution');
952 return $message;
953 }
954
955 if (!isset($_POST['types']) || $_POST['types'] == '') {
956 $message = __('Error: No redirect types were selected. No purges will be done.', '404-solution');
957 return $message;
958 }
959
960 if (is_array($_POST['types'])) {
961 $type = array_map('sanitize_text_field', $_POST['types']);
962 } else {
963 $type = sanitize_text_field($_POST['types']);
964 }
965
966 if (!is_array($type)) {
967 $message = __('An unknown error has occurred.', '404-solution');
968 return $message;
969 }
970
971 $redirectTypes = array();
972 foreach ($type as $aType) {
973 if (('' . $aType != ABJ404_TYPE_HOME) && ('' . $aType != ABJ404_TYPE_404_DISPLAYED)) {
974 array_push($redirectTypes, absint($aType));
975 }
976 }
977
978 if (empty($redirectTypes)) {
979 $message = __('Error: No valid redirect types were selected. Exiting.', '404-solution');
980 $this->logger->debugMessage("Error: No valid redirect types were selected. Types: " .
981 wp_kses_post((string)json_encode($redirectTypes)));
982 return $message;
983 }
984 $purge = isset($_POST['purgetype']) ? sanitize_text_field($_POST['purgetype']) : '';
985
986 if ($purge != 'abj404_logs' && $purge != 'abj404_redirects') {
987 $message = __('Error: An invalid purge type was selected. Exiting.', '404-solution');
988 $this->logger->debugMessage("Error: An invalid purge type was selected. Type: " .
989 wp_kses_post((string)json_encode($purge)));
990 return $message;
991 }
992
993 // always add the type "0" because it's an invalid type that may exist in the databse.
994 // Adding it here does some cleanup if any is necessary.
995 array_push($redirectTypes, 0);
996
997 // Ensure all values are integers to prevent SQL injection
998 $redirectTypes = array_map('absint', $redirectTypes);
999 $typesForSQL = implode(',', $redirectTypes);
1000
1001 if ($purge == 'abj404_redirects') {
1002 $query = "update {wp_abj404_redirects} set disabled = 1 where status in (" . $typesForSQL . ")";
1003 $purgeResult = $this->queryAndGetResults($query);
1004 $rowsAffectedRaw = $purgeResult['rows_affected'] ?? 0;
1005 $redirectCount = is_scalar($rowsAffectedRaw) ? (int)$rowsAffectedRaw : 0;
1006
1007 // Invalidate caches so the admin table reflects the purge immediately
1008 $this->invalidateStatusCountsCache();
1009 $this->clearRegexRedirectsCache();
1010
1011 $message .= sprintf( _n( '%s redirect entry was moved to the trash.',
1012 '%s redirect entries were moved to the trash.', $redirectCount, '404-solution'), $redirectCount);
1013 }
1014
1015 return $message;
1016 }
1017
1018 /**
1019 * This returns only the first column of the first row of the result.
1020 * @global type $wpdb
1021 * @param string $query a query that starts with "select count(id) from ..."
1022 * @param array<int, mixed> $valueParams values to use to prepare the query.
1023 * @return int the count (result) of the query.
1024 */
1025
1026 /**
1027 * Get all conditions for a redirect, ordered by sort_order.
1028 *
1029 * Returns an empty array when the redirect has no conditions or when the
1030 * conditions table does not yet exist (graceful degradation).
1031 *
1032 * @param int $redirectId
1033 * @return array<int, array<string, mixed>>
1034 */
1035 public function getRedirectConditions(int $redirectId): array {
1036 $table = $this->doTableNameReplacements('{wp_abj404_redirect_conditions}');
1037
1038 // Guard: conditions table may not exist on older installs before upgrade runs.
1039 if (!$this->tableExists($table)) {
1040 return [];
1041 }
1042
1043 $result = $this->queryAndGetResults(
1044 "SELECT id, redirect_id, logic, condition_type, operator, value, sort_order
1045 FROM `{$table}`
1046 WHERE redirect_id = %d
1047 ORDER BY sort_order ASC, id ASC",
1048 array('query_params' => array($redirectId), 'log_errors' => false)
1049 );
1050
1051 $lastError = isset($result['last_error']) && is_string($result['last_error']) ? $result['last_error'] : '';
1052 if ($lastError !== '') {
1053 $this->logger->warn("getRedirectConditions: DB error for redirect_id={$redirectId}: " . $lastError);
1054 return [];
1055 }
1056
1057 $rows = isset($result['rows']) && is_array($result['rows']) ? $result['rows'] : [];
1058 return $rows;
1059 }
1060
1061 /**
1062 * Save conditions for a redirect (replaces all existing conditions).
1063 *
1064 * Passes through empty arrays safely — all existing conditions are deleted
1065 * and nothing is inserted, which is the correct behaviour for "no conditions".
1066 *
1067 * @param int $redirectId
1068 * @param array<int, array<string, mixed>> $conditions Array of condition arrays.
1069 * Each must contain: logic, condition_type, operator, value, sort_order.
1070 * @return void
1071 */
1072 public function saveRedirectConditions(int $redirectId, array $conditions): void {
1073 $table = $this->doTableNameReplacements('{wp_abj404_redirect_conditions}');
1074
1075 // Guard: conditions table may not exist yet.
1076 if (!$this->tableExists($table)) {
1077 $this->logger->warn("saveRedirectConditions: conditions table missing — skipping save for redirect_id={$redirectId}.");
1078 return;
1079 }
1080
1081 // Delete existing conditions for this redirect.
1082 $deleteResult = $this->queryAndGetResults(
1083 "DELETE FROM `{$table}` WHERE redirect_id = %d",
1084 array('query_params' => array($redirectId), 'log_errors' => false)
1085 );
1086 $deleteError = isset($deleteResult['last_error']) && is_string($deleteResult['last_error']) ? $deleteResult['last_error'] : '';
1087 if ($deleteError !== '') {
1088 $this->logger->warn("saveRedirectConditions: error deleting old conditions for redirect_id={$redirectId}: " . $deleteError);
1089 }
1090
1091 if (empty($conditions)) {
1092 return;
1093 }
1094
1095 $allowedTypes = [
1096 'login_status', 'user_role', 'referrer',
1097 'user_agent', 'ip_range', 'http_header',
1098 ];
1099 $allowedOperators = [
1100 'equals', 'contains', 'regex',
1101 'not_equals', 'not_contains', 'cidr',
1102 ];
1103 $allowedLogic = ['AND', 'OR'];
1104
1105 foreach ($conditions as $index => $cond) {
1106 if (!is_array($cond)) {
1107 continue;
1108 }
1109
1110 $logic = isset($cond['logic']) && is_string($cond['logic'])
1111 ? strtoupper(trim($cond['logic'])) : 'AND';
1112 $type = isset($cond['condition_type']) && is_string($cond['condition_type'])
1113 ? trim($cond['condition_type']) : '';
1114 $operator = isset($cond['operator']) && is_string($cond['operator'])
1115 ? trim($cond['operator']) : 'equals';
1116 $value = isset($cond['value']) && is_string($cond['value'])
1117 ? trim($cond['value']) : '';
1118 $sortOrder = isset($cond['sort_order']) ? absint(is_scalar($cond['sort_order']) ? $cond['sort_order'] : 0) : $index;
1119
1120 // Validate required fields.
1121 if (!in_array($logic, $allowedLogic, true)) {
1122 $logic = 'AND';
1123 }
1124 if (!in_array($type, $allowedTypes, true)) {
1125 $this->logger->warn("saveRedirectConditions: unknown condition_type '{$type}' — skipping.");
1126 continue;
1127 }
1128 if (!in_array($operator, $allowedOperators, true)) {
1129 $operator = 'equals';
1130 }
1131 // Truncate value to column max (1024 chars).
1132 if (strlen($value) > 1024) {
1133 $value = substr($value, 0, 1024);
1134 }
1135
1136 $insertResult = $this->queryAndGetResults(
1137 "INSERT INTO `{$table}` (`redirect_id`, `logic`, `condition_type`, `operator`, `value`, `sort_order`)
1138 VALUES (%d, %s, %s, %s, %s, %d)",
1139 array(
1140 'query_params' => array($redirectId, $logic, $type, $operator, $value, $sortOrder),
1141 'log_errors' => false,
1142 )
1143 );
1144 $insertError = isset($insertResult['last_error']) && is_string($insertResult['last_error']) ? $insertResult['last_error'] : '';
1145 if ($insertError !== '') {
1146 $this->logger->warn("saveRedirectConditions: error inserting condition #{$index} for redirect_id={$redirectId}: " . $insertError);
1147 }
1148 }
1149 }
1150
1151 /**
1152 * Auto-trash captured URLs that match known junk/bot patterns, and
1153 * captured URLs with 0 hits older than 14 days.
1154 *
1155 * Rate-limited to once per hour via transient.
1156 *
1157 * @param array<string, mixed> $options Plugin options.
1158 * @return int Number of URLs trashed.
1159 */
1160 function autoTrashJunkCapturedUrls(array $options): int {
1161 // Feature must be enabled
1162 $enabled = $options['auto_trash_junk_urls'] ?? '0';
1163 if ($enabled !== '1') {
1164 return 0;
1165 }
1166
1167 // Rate limit: once per hour
1168 $transientKey = 'abj404_last_auto_trash';
1169 if (get_transient($transientKey) !== false) {
1170 return 0;
1171 }
1172 set_transient($transientKey, time(), HOUR_IN_SECONDS);
1173
1174 $patternsRaw = $options['auto_trash_junk_patterns'] ?? '';
1175 $patternsStr = is_string($patternsRaw) ? $patternsRaw : '';
1176 $lines = array_filter(array_map('trim', explode("\n", $patternsStr)));
1177
1178 if (empty($lines)) {
1179 return 0;
1180 }
1181
1182 global $wpdb;
1183 $totalTrashed = 0;
1184
1185 // Build LIKE conditions for each pattern
1186 $likeClauses = array();
1187 foreach ($lines as $pattern) {
1188 $escaped = $wpdb->esc_like($pattern);
1189 $likeClauses[] = $wpdb->prepare("url LIKE %s", '%' . $escaped . '%');
1190 }
1191
1192 // Trash captured URLs matching junk patterns (case-insensitive via LIKE)
1193 $wherePatterns = implode(' OR ', $likeClauses);
1194 $query = "UPDATE {wp_abj404_redirects}
1195 SET disabled = 1
1196 WHERE status = " . ABJ404_STATUS_CAPTURED . "
1197 AND disabled = 0
1198 AND (" . $wherePatterns . ")";
1199 $query = $this->doTableNameReplacements($query);
1200
1201 $result = $this->queryAndGetResults($query);
1202 $affected = $result['rows_affected'] ?? 0;
1203 $totalTrashed += is_numeric($affected) ? (int)$affected : 0;
1204
1205 // Trash captured URLs with 0 log hits older than 14 days.
1206 // logshits is not a column — it's computed from the logs table.
1207 $cutoff = time() - (14 * DAY_IN_SECONDS);
1208 $query = $wpdb->prepare(
1209 "UPDATE {wp_abj404_redirects} r
1210 SET r.disabled = 1
1211 WHERE r.status = " . ABJ404_STATUS_CAPTURED . "
1212 AND r.disabled = 0
1213 AND r.timestamp < %d
1214 AND NOT EXISTS (
1215 SELECT 1 FROM {wp_abj404_logsv2} l
1216 WHERE l.requested_url = r.url
1217 LIMIT 1
1218 )",
1219 $cutoff
1220 );
1221 $query = $this->doTableNameReplacements($query);
1222
1223 $result = $this->queryAndGetResults($query);
1224 $affected = $result['rows_affected'] ?? 0;
1225 $totalTrashed += is_numeric($affected) ? (int)$affected : 0;
1226
1227 if ($totalTrashed > 0) {
1228 $this->logger->infoMessage("Auto-trashed " . $totalTrashed . " junk/stale captured URLs during maintenance.");
1229 // Invalidate the cached status counts so the UI reflects the change
1230 delete_transient(self::CACHE_KEY_CAPTURED_STATUS);
1231 }
1232
1233 return $totalTrashed;
1234 }
1235 }
1236