PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.0
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / redirects / RedirectsCleanupRepository.php

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

369 lines 13.9 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 /** @var ABJ_404_Solution_DatabaseCore */
16 private $dbCore;
17
18 /** @var ABJ_404_Solution_RedirectsRepositoryInterface */
19 private $redirectsRepo;
20
21 /** @var ABJ_404_Solution_Functions */
22 private $f;
23
24 /** @var ABJ_404_Solution_Logging */
25 private $logger;
26
27 /** @var ABJ_404_Solution_RedirectsRetentionPolicy */
28 private $retentionPolicy;
29
30 /**
31 * @param ABJ_404_Solution_DatabaseCore $dbCore
32 * @param ABJ_404_Solution_RedirectsRepositoryInterface $redirectsRepo
33 * @param ABJ_404_Solution_Functions $functions
34 * @param ABJ_404_Solution_Logging $logger
35 * @param ABJ_404_Solution_RedirectsRetentionPolicy|null $retentionPolicy
36 */
37 public function __construct(
38 ABJ_404_Solution_DatabaseCore $dbCore,
39 ABJ_404_Solution_RedirectsRepositoryInterface $redirectsRepo,
40 ABJ_404_Solution_Functions $functions,
41 ABJ_404_Solution_Logging $logger,
42 ?ABJ_404_Solution_RedirectsRetentionPolicy $retentionPolicy = null
43 ) {
44 $this->dbCore = $dbCore;
45 $this->redirectsRepo = $redirectsRepo;
46 $this->f = $functions;
47 $this->logger = $logger;
48 $this->retentionPolicy = $retentionPolicy !== null ? $retentionPolicy : new ABJ_404_Solution_RedirectsRetentionPolicy();
49 }
50
51 public function cleanupOrphanedAutoRedirects(): int {
52 $redirectsTable = $this->dbCore->doTableNameReplacements('{wp_abj404_redirects}');
53 if (!$this->dbCore->tableNameResolver()->tableExists($redirectsTable)) {
54 $this->logger->warn("Skipping orphaned redirect cleanup: table missing.");
55 return 0;
56 }
57
58 $query = ABJ_404_Solution_FileSystemService::readFileContents(__DIR__ . "/../sql/getOrphanedAutoRedirects.sql");
59 $query = $this->dbCore->doTableNameReplacements($query);
60 $query = $this->f->doNormalReplacements($query);
61
62 $results = $this->dbCore->queryAndGetResults($query);
63 $rows = is_array($results['rows']) ? $results['rows'] : [];
64 $deletedCount = 0;
65
66 foreach ($rows as $row) {
67 if (!is_array($row)) {
68 continue;
69 }
70 $id = isset($row['id']) && is_scalar($row['id']) ? (string)$row['id'] : '0';
71 $url = isset($row['url']) && is_string($row['url']) ? $row['url'] : '';
72 $this->logger->debugMessage('Orphaned auto redirect deleted: "' . $url . '" (dest post ' .
73 (isset($row['final_dest']) && is_scalar($row['final_dest']) ? (string)$row['final_dest'] : '?') . ' missing/unpublished).');
74 $this->redirectsRepo->deleteRedirect($id);
75 $deletedCount++;
76 }
77
78 return $deletedCount;
79 }
80
81 /**
82 * @param array<string, mixed> $options
83 * @param int $now
84 * @param string $optionKey
85 * @param string $statusList
86 * @param string $debugMessageType
87 * @return int
88 */
89 public function deleteOldRedirectsByType($options, $now, $optionKey, $statusList, $debugMessageType) {
90 $logsRepo = abj_service('logs_repository');
91 $deletedCount = 0;
92
93 $deletionDays = $this->retentionPolicy->daysFromOptions(is_array($options) ? $options : array(), $optionKey);
94 $then = $this->retentionPolicy->cutoffForDays($deletionDays, $now);
95 if ($then === null) {
96 return 0;
97 }
98
99 $this->dbCore->tableNameResolver()->setSqlBigSelects();
100
101 if (!$logsRepo->logsHitsTableExists()) {
102 $this->logger->debugMessage(__FUNCTION__ . " skipping: logs_hits table missing; scheduling rebuild.");
103 $logsRepo->scheduleHitsTableRebuild();
104 return 0;
105 }
106
107 $query = ABJ_404_Solution_FileSystemService::readFileContents(__DIR__ . "/../sql/getMostUnusedRedirects.sql");
108 $query = $this->f->str_replace('{status_list}', $statusList, $query);
109 $query = $this->f->str_replace('{timelimit}', (string)$then, $query);
110
111 $results = $this->dbCore->queryAndGetResults($query);
112 $rows = is_array($results['rows']) ? $results['rows'] : array();
113
114 foreach ($rows as $rowRaw) {
115 if (!is_array($rowRaw)) {
116 continue;
117 }
118 $row = $rowRaw;
119 $fromUrl = isset($row['from_url']) && is_scalar($row['from_url']) ? (string)$row['from_url'] : '';
120 $lastUsed = isset($row['last_used_formatted']) && is_scalar($row['last_used_formatted'])
121 ? (string)$row['last_used_formatted']
122 : '';
123 $bestGuessDest = isset($row['best_guess_dest']) && is_scalar($row['best_guess_dest'])
124 ? (string)$row['best_guess_dest']
125 : '';
126 if ($debugMessageType === 'Captured 404') {
127 $this->logger->debugMessage("Captured 404 for \"" . $fromUrl .
128 '" deleted (last used: ' . $lastUsed . ').');
129 } else {
130 $this->logger->debugMessage($debugMessageType . " from: " . $fromUrl . ' to: ' .
131 $bestGuessDest . ' deleted (last used: ' . $lastUsed . ').');
132 }
133
134 $this->redirectsRepo->deleteRedirect(isset($row['id']) && is_scalar($row['id']) ? (string)$row['id'] : '0');
135 $deletedCount++;
136 }
137
138 return $deletedCount;
139 }
140
141 public function deleteOldLogsByAge(int $daysToKeep, int $now): int {
142 $cutoffTimestamp = $this->retentionPolicy->cutoffForDays($daysToKeep, $now);
143 if ($cutoffTimestamp === null) {
144 return 0;
145 }
146
147 $deletedTotal = 0;
148 $batchSize = 2000;
149 $maxBatches = 200;
150
151 for ($i = 0; $i < $maxBatches; $i++) {
152 $result = $this->dbCore->queryAndGetResults(
153 "DELETE FROM {wp_abj404_logsv2} WHERE timestamp <= %d LIMIT %d",
154 array(
155 'query_params' => array($cutoffTimestamp, $batchSize),
156 'log_errors' => true,
157 )
158 );
159 $rowsDeletedRaw = $result['rows_affected'] ?? 0;
160 $rowsDeleted = (is_int($rowsDeletedRaw) || is_float($rowsDeletedRaw) || is_string($rowsDeletedRaw))
161 ? (int)$rowsDeletedRaw
162 : 0;
163 if ($rowsDeleted <= 0) {
164 break;
165 }
166 $deletedTotal += $rowsDeleted;
167 if ($rowsDeleted < $batchSize) {
168 break;
169 }
170 }
171
172 return $deletedTotal;
173 }
174
175 public function removeDuplicatesCron(): int {
176 $rowsDeleted = 0;
177 $query = "SELECT COUNT(id) as repetitions, url FROM {wp_abj404_redirects} GROUP BY url HAVING repetitions > 1 ";
178 $result = $this->dbCore->queryAndGetResults($query);
179 $outerRows = is_array($result['rows']) ? $result['rows'] : array();
180 foreach ($outerRows as $outerRow) {
181 if (!is_array($outerRow)) {
182 continue;
183 }
184 $row = $outerRow;
185 $url = $row['url'];
186
187 $queryr1 = $this->prepareQueryWp(
188 "select id from {wp_abj404_redirects} where url = {url} order by timestamp desc limit 0,1",
189 array("url" => $url)
190 );
191 $result = $this->dbCore->queryAndGetResults($queryr1);
192 $innerRows = is_array($result['rows']) ? $result['rows'] : array();
193 if (count($innerRows) >= 1) {
194 $row = is_array($innerRows[0]) ? $innerRows[0] : array();
195 $original = isset($row['id']) ? $row['id'] : 0;
196
197 $queryl = $this->prepareQueryWp(
198 "delete from {wp_abj404_redirects} where url = {url} and id != {original}",
199 array("url" => $url, "original" => $original)
200 );
201 $deleteResult = $this->dbCore->queryAndGetResults($queryl);
202 $affected = isset($deleteResult['rows_affected']) && is_numeric($deleteResult['rows_affected'])
203 ? (int)$deleteResult['rows_affected'] : 1;
204 $rowsDeleted += max($affected, 1);
205 }
206 }
207
208 if ($rowsDeleted > 0) {
209 abj_service('view_read_service')->invalidateStatusCountsCache();
210 }
211
212 return $rowsDeleted;
213 }
214
215 /**
216 * @param array<string, mixed> $options
217 * @return int
218 */
219 public function autoTrashJunkCapturedUrls(array $options): int {
220 $enabled = $options['auto_trash_junk_urls'] ?? '0';
221 if ($enabled !== '1') {
222 return 0;
223 }
224
225 $transientKey = 'abj404_last_auto_trash';
226 if (get_transient($transientKey) !== false) {
227 return 0;
228 }
229 // allow-cache-empty: timestamp marker rate-limits automatic trash cleanup; not a cached query payload.
230 set_transient($transientKey, abj_clock()->now(), HOUR_IN_SECONDS);
231
232 $patternsRaw = $options['auto_trash_junk_patterns'] ?? '';
233 $patternsStr = is_string($patternsRaw) ? $patternsRaw : '';
234 $lines = array_filter(array_map('trim', explode("\n", $patternsStr)));
235
236 if (empty($lines)) {
237 return 0;
238 }
239
240 global $wpdb;
241 $totalTrashed = 0;
242
243 $likeClauses = array();
244 foreach ($lines as $pattern) {
245 $escaped = $wpdb->esc_like($pattern);
246 // DAO-bypass-approved: $wpdb->prepare is read-only string formatting; result goes through queryAndGetResults
247 $likeClauses[] = $wpdb->prepare("url LIKE %s", '%' . $escaped . '%');
248 }
249
250 $wherePatterns = implode(' OR ', $likeClauses);
251 $query = "UPDATE {wp_abj404_redirects}
252 SET disabled = 1
253 WHERE status = " . ABJ404_STATUS_CAPTURED . "
254 AND disabled = 0
255 AND (" . $wherePatterns . ")";
256 $query = $this->dbCore->doTableNameReplacements($query);
257
258 $result = $this->dbCore->queryAndGetResults($query);
259 $affected = $result['rows_affected'] ?? 0;
260 $totalTrashed += is_numeric($affected) ? (int)$affected : 0;
261
262 $cutoff = abj_clock()->now() - (14 * DAY_IN_SECONDS);
263 // DAO-bypass-approved: $wpdb->prepare is read-only string formatting; result goes through queryAndGetResults
264 $query = $wpdb->prepare("UPDATE {wp_abj404_redirects} r
265 SET r.disabled = 1
266 WHERE r.status = " . ABJ404_STATUS_CAPTURED . "
267 AND r.disabled = 0
268 AND r.timestamp < %d
269 AND NOT EXISTS (
270 SELECT 1 FROM {wp_abj404_logsv2} l
271 WHERE l.requested_url = r.url
272 LIMIT 1
273 )",
274 $cutoff
275 );
276 $query = $this->dbCore->doTableNameReplacements($query);
277
278 $result = $this->dbCore->queryAndGetResults($query);
279 $affected = $result['rows_affected'] ?? 0;
280 $totalTrashed += is_numeric($affected) ? (int)$affected : 0;
281
282 if ($totalTrashed > 0) {
283 $this->logger->infoMessage("Auto-trashed " . $totalTrashed . " junk/stale captured URLs during maintenance.");
284 delete_transient(ABJ_404_Solution_ViewReadRuntimeState::CACHE_KEY_CAPTURED_STATUS);
285 }
286
287 return $totalTrashed;
288 }
289
290 public function expireOldAutoRedirects(int $days, int $now): int {
291 $cutoff = $this->retentionPolicy->cutoffForDays($days, $now);
292 if ($cutoff === null) {
293 return 0;
294 }
295
296 $redirectsTable = $this->dbCore->doTableNameReplacements('{wp_abj404_redirects}');
297 if (!$this->dbCore->tableNameResolver()->tableExists($redirectsTable)) {
298 $this->logger->warn("expireOldAutoRedirects: redirects table missing, skipping.");
299 return 0;
300 }
301
302 $sql = "SELECT id FROM `{$redirectsTable}`
303 WHERE status = %d
304 AND disabled = 0
305 AND `timestamp` > 0
306 AND `timestamp` < %d";
307
308 $result = $this->dbCore->queryAndGetResults($sql, array(
309 'query_params' => array(ABJ404_STATUS_AUTO, $cutoff),
310 ));
311
312 if (!empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] != '')) {
313 return 0;
314 }
315
316 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
317 $ids = array();
318 foreach ($rows as $row) {
319 if (is_array($row)) {
320 $value = $row['id'] ?? reset($row);
321 } elseif (is_object($row)) {
322 $value = $row->id ?? null;
323 } else {
324 $value = $row;
325 }
326 if ($value !== null && $value !== '') {
327 $ids[] = absint($value);
328 }
329 }
330
331 if (empty($ids)) {
332 return 0;
333 }
334
335 $moved = 0;
336 foreach ($ids as $id) {
337 $this->redirectsRepo->moveRedirectsToTrash($id, 1);
338 $moved++;
339 }
340
341 $this->logger->infoMessage("expireOldAutoRedirects: moved {$moved} expired auto-redirect(s) to trash (threshold: {$days} days).");
342 return $moved;
343 }
344
345 /**
346 * Token-style wpdb prepare helper used by removeDuplicatesCron.
347 *
348 * @param string $query
349 * @param array<string, mixed> $data
350 * @return string
351 */
352 private function prepareQueryWp($query, $data) {
353 global $wpdb;
354 $orderedValues = [];
355 $preparedQuery = preg_replace_callback('/\{(\w+)\}/', function($matches) use ($data, &$orderedValues) {
356 $key = $matches[1];
357 if (!isset($data[$key])) {
358 return $matches[0];
359 }
360 $value = $data[$key];
361 $orderedValues[] = $value;
362 return is_int($value) ? '%d' : '%s';
363 }, $query);
364 $preparedQuery = $preparedQuery !== null ? $preparedQuery : $query;
365 // DAO-bypass-approved: $wpdb->prepare is read-only string formatting; callers execute the result through queryAndGetResults
366 return $wpdb->prepare($preparedQuery, $orderedValues);
367 }
368 }
369