PluginProbe
404 Solution / trunk
404 Solution vtrunk
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 / redirects / RedirectsCleanupRepository.php

RedirectsCleanupRepository.php in 404 Solution trunk, at includes/redirects/RedirectsCleanupRepository.php

436 lines 18.8 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__ . '/RedirectsRetentionPolicy.php';
8 require_once __DIR__ . '/../view-build/ViewReadRuntimeState.php';
9
10 /**
11 * Repository operations for scheduled redirect and log cleanup.
12 */
13 class ABJ_404_Solution_RedirectsCleanupRepository {
14
15 /**
16 * Rows handled per cleanup SELECT batch. Each cleanup SELECT is bounded
17 * with a LIMIT of this size so a large match set (up to ~630k rows on big
18 * installs) never loads into PHP memory all at once during cron.
19 * @var int
20 */
21 const CLEANUP_BATCH_SIZE = 1000;
22
23 /**
24 * Maximum number of cleanup batches handled per run (safety backstop:
25 * caps a run at CLEANUP_BATCH_SIZE * CLEANUP_MAX_BATCHES rows). Prevents an
26 * infinite loop if a row's deletion silently fails and it keeps
27 * re-matching the SELECT.
28 * @var int
29 */
30 const CLEANUP_MAX_BATCHES = 1000;
31
32 /** @var ABJ_404_Solution_DatabaseCore */
33 private $dbCore;
34
35 /** @var ABJ_404_Solution_RedirectsRepositoryInterface */
36 private $redirectsRepo;
37
38 /** @var ABJ_404_Solution_Functions */
39 private $f;
40
41 /** @var ABJ_404_Solution_Logging */
42 private $logger;
43
44 /** @var ABJ_404_Solution_RedirectsRetentionPolicy */
45 private $retentionPolicy;
46
47 /**
48 * @param ABJ_404_Solution_DatabaseCore $dbCore
49 * @param ABJ_404_Solution_RedirectsRepositoryInterface $redirectsRepo
50 * @param ABJ_404_Solution_Functions $functions
51 * @param ABJ_404_Solution_Logging $logger
52 * @param ABJ_404_Solution_RedirectsRetentionPolicy|null $retentionPolicy
53 */
54 public function __construct(
55 ABJ_404_Solution_DatabaseCore $dbCore,
56 ABJ_404_Solution_RedirectsRepositoryInterface $redirectsRepo,
57 ABJ_404_Solution_Functions $functions,
58 ABJ_404_Solution_Logging $logger,
59 ?ABJ_404_Solution_RedirectsRetentionPolicy $retentionPolicy = null
60 ) {
61 $this->dbCore = $dbCore;
62 $this->redirectsRepo = $redirectsRepo;
63 $this->f = $functions;
64 $this->logger = $logger;
65 $this->retentionPolicy = $retentionPolicy !== null ? $retentionPolicy : new ABJ_404_Solution_RedirectsRetentionPolicy();
66 }
67
68 /**
69 * Drive a cleanup SELECT in bounded batches so a large match set never loads
70 * into PHP memory all at once. $runBatch($batchSize) returns the rows for one
71 * LIMIT-bounded batch; $handleRow($row) processes each row and MUST remove it
72 * from the SELECT's match set (delete/trash it) so the next batch advances.
73 * Loops until a batch returns fewer than $batchSize rows (or the iteration cap
74 * is hit). Returns the total number of rows handled.
75 *
76 * @param callable(int):array<int,mixed> $runBatch
77 * @param callable(array<mixed,mixed>):void $handleRow Each $row is a DB
78 * result row (string-keyed at runtime; typed array<mixed,mixed>
79 * because the result-set key type is not statically provable).
80 * @return int
81 */
82 private function runBatchedCleanup(callable $runBatch, callable $handleRow,
83 int $batchSize = self::CLEANUP_BATCH_SIZE, int $maxBatches = self::CLEANUP_MAX_BATCHES): int {
84 $handled = 0;
85 for ($batch = 0; $batch < $maxBatches; $batch++) {
86 $rows = $runBatch($batchSize);
87 $rows = is_array($rows) ? $rows : array();
88 $rowCount = count($rows);
89 foreach ($rows as $row) {
90 if (!is_array($row)) { continue; }
91 $handleRow($row);
92 $handled++;
93 }
94 if ($rowCount < $batchSize) {
95 break;
96 }
97 }
98 return $handled;
99 }
100
101 public function cleanupOrphanedAutoRedirects(): int {
102 $redirectsTable = $this->dbCore->doTableNameReplacements('{wp_abj404_redirects}');
103 if (!$this->dbCore->tableNameResolver()->tableExists($redirectsTable)) {
104 $this->logger->warn("Skipping orphaned redirect cleanup: table missing.");
105 return 0;
106 }
107
108 $query = ABJ_404_Solution_FileSystemService::readFileContents(__DIR__ . "/../sql/getOrphanedAutoRedirects.sql");
109 $query = $this->dbCore->doTableNameReplacements($query);
110 $query = $this->f->doNormalReplacements($query);
111
112 // Bound the SELECT with a LIMIT and loop until exhausted so the orphaned
113 // match set never loads into PHP memory all at once. Each handled row is
114 // deleted, so it drops out of the next batch's match set.
115 $runBatch = function (int $batchSize) use ($query): array {
116 $results = $this->dbCore->queryAndGetResults($query . " LIMIT " . (int)$batchSize);
117 return is_array($results['rows'] ?? null) ? $results['rows'] : array();
118 };
119 $handleRow = function (array $row): void {
120 $id = isset($row['id']) && is_scalar($row['id']) ? (string)$row['id'] : '0';
121 $url = isset($row['url']) && is_string($row['url']) ? $row['url'] : '';
122 $this->logger->debugMessage('Orphaned auto redirect deleted: "' . $url . '" (dest post ' .
123 (isset($row['final_dest']) && is_scalar($row['final_dest']) ? (string)$row['final_dest'] : '?') . ' missing/unpublished).');
124 $this->redirectsRepo->deleteRedirect($id);
125 };
126
127 return $this->runBatchedCleanup($runBatch, $handleRow);
128 }
129
130 /**
131 * @param array<string, mixed> $options
132 * @param int $now
133 * @param string $optionKey
134 * @param string $statusList
135 * @param string $debugMessageType
136 * @return int
137 */
138 public function deleteOldRedirectsByType($options, $now, $optionKey, $statusList, $debugMessageType) {
139 $logsRepo = abj_service('logs_repository');
140
141 $deletionDays = $this->retentionPolicy->daysFromOptions(is_array($options) ? $options : array(), $optionKey);
142 $then = $this->retentionPolicy->cutoffForDays($deletionDays, $now);
143 if ($then === null) {
144 return 0;
145 }
146
147 $this->dbCore->tableNameResolver()->setSqlBigSelects();
148
149 if (!$logsRepo->logsHitsTableExists()) {
150 $this->logger->debugMessage(__FUNCTION__ . " skipping: logs_hits table missing; scheduling rebuild.");
151 $logsRepo->scheduleHitsTableRebuild();
152 return 0;
153 }
154
155 $query = ABJ_404_Solution_FileSystemService::readFileContents(__DIR__ . "/../sql/getMostUnusedRedirects.sql");
156 $query = $this->f->str_replace('{status_list}', $statusList, $query);
157 $query = $this->f->str_replace('{timelimit}', (string)$then, $query);
158 $logsHitsTable = $this->dbCore->doTableNameReplacements('{wp_abj404_logs_hits}');
159 $redirectUrlExpression = "COALESCE(r.canonical_url, r.url)";
160 $comparableRedirectUrl = $this->dbCore->collationHelper()->coerceExpressionToColumnCollation(
161 $redirectUrlExpression,
162 array('table' => $logsHitsTable, 'column' => 'requested_url')
163 );
164 $query = $this->f->str_replace('{logs_hits_url_rhs}', $comparableRedirectUrl, $query);
165
166 // Bound the SELECT with a LIMIT and loop until exhausted so the unused
167 // redirect match set never loads into PHP memory all at once. Each
168 // handled row is deleted, so it drops out of the next batch's match set.
169 $runBatch = function (int $batchSize) use ($query): array {
170 $results = $this->dbCore->queryAndGetResults($query . " LIMIT " . (int)$batchSize);
171 return is_array($results['rows'] ?? null) ? $results['rows'] : array();
172 };
173 $handleRow = function (array $row) use ($debugMessageType): void {
174 $fromUrl = isset($row['from_url']) && is_scalar($row['from_url']) ? (string)$row['from_url'] : '';
175 $lastUsed = isset($row['last_used_formatted']) && is_scalar($row['last_used_formatted'])
176 ? (string)$row['last_used_formatted']
177 : '';
178 $bestGuessDest = isset($row['best_guess_dest']) && is_scalar($row['best_guess_dest'])
179 ? (string)$row['best_guess_dest']
180 : '';
181 if ($debugMessageType === 'Captured 404') {
182 $this->logger->debugMessage("Captured 404 for \"" . $fromUrl .
183 '" deleted (last used: ' . $lastUsed . ').');
184 } else {
185 $this->logger->debugMessage($debugMessageType . " from: " . $fromUrl . ' to: ' .
186 $bestGuessDest . ' deleted (last used: ' . $lastUsed . ').');
187 }
188
189 $this->redirectsRepo->deleteRedirect(isset($row['id']) && is_scalar($row['id']) ? (string)$row['id'] : '0');
190 };
191
192 return $this->runBatchedCleanup($runBatch, $handleRow);
193 }
194
195 public function deleteOldLogsByAge(int $daysToKeep, int $now): int {
196 $cutoffTimestamp = $this->retentionPolicy->cutoffForDays($daysToKeep, $now);
197 if ($cutoffTimestamp === null) {
198 return 0;
199 }
200
201 $deletedTotal = 0;
202 $batchSize = 2000;
203 $maxBatches = 200;
204
205 for ($i = 0; $i < $maxBatches; $i++) {
206 $result = $this->dbCore->queryAndGetResults(
207 "DELETE FROM {wp_abj404_logsv2} WHERE timestamp <= %d LIMIT %d",
208 array(
209 'query_params' => array($cutoffTimestamp, $batchSize),
210 'log_errors' => true,
211 )
212 );
213 $rowsDeletedRaw = $result['rows_affected'] ?? 0;
214 $rowsDeleted = (is_int($rowsDeletedRaw) || is_float($rowsDeletedRaw) || is_string($rowsDeletedRaw))
215 ? (int)$rowsDeletedRaw
216 : 0;
217 if ($rowsDeleted <= 0) {
218 break;
219 }
220 $deletedTotal += $rowsDeleted;
221 if ($rowsDeleted < $batchSize) {
222 break;
223 }
224 }
225
226 return $deletedTotal;
227 }
228
229 public function removeDuplicatesCron(): int {
230 $rowsDeleted = 0;
231 // allow-unbounded-select: LIMIT appended at runtime by runBatchedCleanup (batched); dedup collapses each url so the match set drains
232 $query = "SELECT COUNT(id) as repetitions, url FROM {wp_abj404_redirects} GROUP BY url HAVING repetitions > 1 ";
233
234 // Bound the duplicate-group SELECT with a LIMIT and loop until exhausted
235 // so the full set of duplicated URLs never loads into PHP memory at
236 // once. De-duplicating a URL collapses its repetitions to 1, so it drops
237 // out of the next batch's HAVING repetitions > 1 match set.
238 $runBatch = function (int $batchSize) use ($query): array {
239 $result = $this->dbCore->queryAndGetResults($query . " LIMIT " . (int)$batchSize);
240 return is_array($result['rows'] ?? null) ? $result['rows'] : array();
241 };
242 $handleRow = function (array $outerRow) use (&$rowsDeleted): void {
243 $url = $outerRow['url'];
244
245 // allow-unbounded-select: this assembled query ends in LIMIT 0,1 and returns only the chosen survivor
246 $queryr1 = $this->prepareQueryWp(
247 "select id from {wp_abj404_redirects} where url = {url} order by " .
248 "case status " .
249 "when " . (int)ABJ404_STATUS_MANUAL . " then 0 " .
250 "when " . (int)ABJ404_STATUS_REGEX . " then 1 " .
251 "when " . (int)ABJ404_STATUS_AUTO . " then 2 " .
252 "when " . (int)ABJ404_STATUS_CAPTURED . " then 3 " .
253 "else 4 end asc, timestamp desc, id desc limit 0,1",
254 array("url" => $url)
255 );
256 $result = $this->dbCore->queryAndGetResults($queryr1);
257 $innerRows = is_array($result['rows']) ? $result['rows'] : array();
258 if (count($innerRows) >= 1) {
259 $row = is_array($innerRows[0]) ? $innerRows[0] : array();
260 $original = isset($row['id']) ? $row['id'] : 0;
261
262 $queryl = $this->prepareQueryWp(
263 "delete from {wp_abj404_redirects} where url = {url} and id != {original}",
264 array("url" => $url, "original" => $original)
265 );
266 $deleteResult = $this->dbCore->queryAndGetResults($queryl);
267 $affected = isset($deleteResult['rows_affected']) && is_numeric($deleteResult['rows_affected'])
268 ? (int)$deleteResult['rows_affected'] : 1;
269 $rowsDeleted += max($affected, 1);
270 }
271 };
272
273 $this->runBatchedCleanup($runBatch, $handleRow);
274
275 if ($rowsDeleted > 0) {
276 abj_service('view_read_service')->invalidateStatusCountsCache();
277 }
278
279 return $rowsDeleted;
280 }
281
282 /**
283 * @param array<string, mixed> $options
284 * @return int
285 */
286 public function autoTrashJunkCapturedUrls(array $options): int {
287 $enabled = $options['auto_trash_junk_urls'] ?? '0';
288 if ($enabled !== '1') {
289 return 0;
290 }
291
292 $transientKey = 'abj404_last_auto_trash';
293 if (get_transient($transientKey) !== false) {
294 return 0;
295 }
296 // allow-cache-empty: timestamp marker rate-limits automatic trash cleanup; not a cached query payload.
297 set_transient($transientKey, abj_clock()->now(), HOUR_IN_SECONDS);
298
299 $patternsRaw = $options['auto_trash_junk_patterns'] ?? '';
300 $patternsStr = is_string($patternsRaw) ? $patternsRaw : '';
301 $lines = array_filter(array_map('trim', explode("\n", $patternsStr)));
302
303 if (empty($lines)) {
304 return 0;
305 }
306
307 global $wpdb;
308 $totalTrashed = 0;
309
310 $likeClauses = array();
311 foreach ($lines as $pattern) {
312 $escaped = $wpdb->esc_like($pattern);
313 // DAO-bypass-approved: $wpdb->prepare is read-only string formatting; result goes through queryAndGetResults
314 $likeClauses[] = $wpdb->prepare("url LIKE %s", '%' . $escaped . '%');
315 }
316
317 $wherePatterns = implode(' OR ', $likeClauses);
318 $query = "UPDATE {wp_abj404_redirects}
319 SET disabled = 1
320 WHERE status = " . ABJ404_STATUS_CAPTURED . "
321 AND disabled = 0
322 AND (" . $wherePatterns . ")";
323 $query = $this->dbCore->doTableNameReplacements($query);
324
325 $result = $this->dbCore->queryAndGetResults($query);
326 $affected = $result['rows_affected'] ?? 0;
327 $totalTrashed += is_numeric($affected) ? (int)$affected : 0;
328
329 $cutoff = abj_clock()->now() - (14 * DAY_IN_SECONDS);
330 $logsTable = $this->dbCore->doTableNameReplacements('{wp_abj404_logsv2}');
331 $comparableRedirectUrl = $this->dbCore->collationHelper()->coerceExpressionToColumnCollation(
332 'r.url',
333 array('table' => $logsTable, 'column' => 'requested_url')
334 );
335 $query = "UPDATE {wp_abj404_redirects} r
336 SET r.disabled = 1
337 WHERE r.status = " . ABJ404_STATUS_CAPTURED . "
338 AND r.disabled = 0
339 AND r.timestamp < %d
340 AND NOT EXISTS (
341 SELECT 1 FROM {wp_abj404_logsv2} l
342 WHERE l.requested_url = {logs_url_rhs}
343 LIMIT 1
344 )";
345 $query = $this->f->str_replace('{logs_url_rhs}', $comparableRedirectUrl, $query);
346 // DAO-bypass-approved: $wpdb->prepare is read-only string formatting; result goes through queryAndGetResults
347 $query = $wpdb->prepare($query, $cutoff);
348 $query = $this->dbCore->doTableNameReplacements($query);
349
350 $result = $this->dbCore->queryAndGetResults($query);
351 $affected = $result['rows_affected'] ?? 0;
352 $totalTrashed += is_numeric($affected) ? (int)$affected : 0;
353
354 if ($totalTrashed > 0) {
355 $this->logger->infoMessage("Auto-trashed " . $totalTrashed . " junk/stale captured URLs during maintenance.");
356 ABJ_404_Solution_ViewCacheInvalidator::invalidateCapturedStatusCountsCache();
357 }
358
359 return $totalTrashed;
360 }
361
362 public function expireOldAutoRedirects(int $days, int $now): int {
363 $cutoff = $this->retentionPolicy->cutoffForDays($days, $now);
364 if ($cutoff === null) {
365 return 0;
366 }
367
368 $redirectsTable = $this->dbCore->doTableNameReplacements('{wp_abj404_redirects}');
369 if (!$this->dbCore->tableNameResolver()->tableExists($redirectsTable)) {
370 $this->logger->warn("expireOldAutoRedirects: redirects table missing, skipping.");
371 return 0;
372 }
373
374 // allow-unbounded-select: LIMIT %d appended at runtime by runBatchedCleanup (batched cron cleanup)
375 $sql = "SELECT id FROM `{$redirectsTable}`
376 WHERE status = %d
377 AND disabled = 0
378 AND `timestamp` > 0
379 AND `timestamp` < %d";
380
381 // Each batch SELECTs the next LIMIT-bounded slice of still-enabled
382 // expired auto-redirects, then trashes them. moveRedirectsToTrash sets
383 // disabled = 1, so trashed rows drop out of the next batch's
384 // `disabled = 0` filter and the loop advances without an offset. On a
385 // batch that times out or errors, the closure returns array() so the
386 // loop stops cleanly (preserving the original bail-on-error semantics
387 // for the first batch).
388 $runBatch = function (int $batchSize) use ($sql, $cutoff): array {
389 $result = $this->dbCore->queryAndGetResults($sql . " LIMIT %d", array(
390 'query_params' => array(ABJ404_STATUS_AUTO, $cutoff, (int)$batchSize),
391 ));
392 if (!empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] != '')) {
393 return array();
394 }
395 return is_array($result['rows'] ?? null) ? $result['rows'] : array();
396 };
397 $handleRow = function (array $row): void {
398 $value = $row['id'] ?? reset($row);
399 if ($value !== null && $value !== '') {
400 $this->redirectsRepo->moveRedirectsToTrash(absint($value), 1);
401 }
402 };
403
404 $moved = $this->runBatchedCleanup($runBatch, $handleRow);
405
406 if ($moved > 0) {
407 $this->logger->infoMessage("expireOldAutoRedirects: moved {$moved} expired auto-redirect(s) to trash (threshold: {$days} days).");
408 }
409 return $moved;
410 }
411
412 /**
413 * Token-style wpdb prepare helper used by removeDuplicatesCron.
414 *
415 * @param string $query
416 * @param array<string, mixed> $data
417 * @return string
418 */
419 private function prepareQueryWp($query, $data) {
420 global $wpdb;
421 $orderedValues = [];
422 $preparedQuery = preg_replace_callback('/\{(\w+)\}/', function($matches) use ($data, &$orderedValues) {
423 $key = $matches[1];
424 if (!isset($data[$key])) {
425 return $matches[0];
426 }
427 $value = $data[$key];
428 $orderedValues[] = $value;
429 return is_int($value) ? '%d' : '%s';
430 }, $query);
431 $preparedQuery = $preparedQuery !== null ? $preparedQuery : $query;
432 // DAO-bypass-approved: $wpdb->prepare is read-only string formatting; callers execute the result through queryAndGetResults
433 return $wpdb->prepare($preparedQuery, $orderedValues);
434 }
435 }
436