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 / RedirectsRetentionService.php

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

375 lines 14.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__ . '/RedirectsCleanupRepository.php';
9 require_once __DIR__ . '/RedirectDeadDestinationScanner.php';
10
11 /**
12 * Scheduled-maintenance workflow for the redirects table.
13 *
14 * Owns the redirect/log retention pruning, dead-destination flagging,
15 * auto-redirect expiry, junk auto-trash, orphan cleanup, and the coordinated
16 * cron run. Previously fronted by RedirectsRetentionServiceInterface, but that
17 * interface had a single implementer and no narrowing consumer, so it was
18 * inlined to remove the unrealized abstraction.
19 */
20 class ABJ_404_Solution_RedirectsRetentionService {
21
22 /** @var ABJ_404_Solution_DatabaseCore */
23 private $dbCore;
24
25 /** @var ABJ_404_Solution_DatabaseConnectionManager */
26 private $connectionManager;
27
28 /** @var ABJ_404_Solution_Functions */
29 private $f;
30
31 /** @var ABJ_404_Solution_Logging */
32 private $logger;
33
34 /** @var ABJ_404_Solution_RedirectsRetentionPolicy */
35 private $retentionPolicy;
36
37 /** @var ABJ_404_Solution_RedirectsCleanupRepository */
38 private $cleanupRepository;
39
40 /** @var ABJ_404_Solution_RedirectDeadDestinationScanner */
41 private $deadDestinationScanner;
42
43 /**
44 * @param ABJ_404_Solution_DatabaseCore $dbCore
45 * @param ABJ_404_Solution_RedirectsRepositoryInterface $redirectsRepo
46 * @param ABJ_404_Solution_Functions|null $functions
47 * @param ABJ_404_Solution_Logging|null $logging
48 * @param ABJ_404_Solution_DatabaseConnectionManager|null $connectionManager
49 * @param ABJ_404_Solution_RedirectsRetentionPolicy|null $retentionPolicy
50 * @param ABJ_404_Solution_RedirectsCleanupRepository|null $cleanupRepository
51 * @param ABJ_404_Solution_RedirectDeadDestinationScanner|null $deadDestinationScanner
52 */
53 public function __construct(
54 ABJ_404_Solution_DatabaseCore $dbCore,
55 ABJ_404_Solution_RedirectsRepositoryInterface $redirectsRepo,
56 $functions = null,
57 $logging = null,
58 $connectionManager = null,
59 ?ABJ_404_Solution_RedirectsRetentionPolicy $retentionPolicy = null,
60 ?ABJ_404_Solution_RedirectsCleanupRepository $cleanupRepository = null,
61 ?ABJ_404_Solution_RedirectDeadDestinationScanner $deadDestinationScanner = null
62 ) {
63 $this->dbCore = $dbCore;
64 $this->f = $functions !== null ? $functions : abj_service('functions');
65 $this->logger = $logging !== null ? $logging : abj_service('logging');
66 $this->connectionManager = $connectionManager !== null ? $connectionManager : $dbCore->connectionManager();
67 $this->retentionPolicy = $retentionPolicy !== null
68 ? $retentionPolicy
69 : new ABJ_404_Solution_RedirectsRetentionPolicy();
70 $this->cleanupRepository = $cleanupRepository !== null
71 ? $cleanupRepository
72 : new ABJ_404_Solution_RedirectsCleanupRepository(
73 $dbCore,
74 $redirectsRepo,
75 $this->f,
76 $this->logger,
77 $this->retentionPolicy
78 );
79 $this->deadDestinationScanner = $deadDestinationScanner !== null
80 ? $deadDestinationScanner
81 : new ABJ_404_Solution_RedirectDeadDestinationScanner($dbCore, $this->logger);
82 }
83
84 /** @return int Number of orphaned auto redirects removed. */
85 public function cleanupOrphanedAutoRedirects(): int {
86 return $this->cleanupRepository->cleanupOrphanedAutoRedirects();
87 }
88
89 /**
90 * @param array<string, mixed> $options
91 * @param int $now
92 * @param string $optionKey
93 * @param string $statusList
94 * @param string $debugMessageType
95 * @return int
96 */
97 public function deleteOldRedirectsByType($options, $now, $optionKey, $statusList, $debugMessageType) {
98 return $this->cleanupRepository->deleteOldRedirectsByType($options, $now, $optionKey, $statusList, $debugMessageType);
99 }
100
101 /**
102 * @param int $daysToKeep
103 * @param int $now
104 * @return int
105 */
106 public function deleteOldLogsByAge(int $daysToKeep, int $now): int {
107 return $this->cleanupRepository->deleteOldLogsByAge($daysToKeep, $now);
108 }
109
110 /** @return string Human-readable summary of the cron run. */
111 function deleteOldRedirectsCron() {
112 $viewRead = abj_service('view_read_service');
113 $abj404logic = abj_service('plugin_logic');
114
115 $options = abj_service('options_repository')->getOptions(true);
116 $now = abj_clock()->now();
117 $manually_fired = $this->isManualMaintenanceRun();
118
119 $upgradesEtc = abj_service('database_upgrades');
120 $upgradesEtc->components()->bootstrapUpgrade()->createDatabaseTables(false);
121
122 $this->connectionManager->ensureConnection();
123
124 $this->deleteTempExportFile($abj404logic);
125
126 $duplicateRowsDeleted = $this->removeDuplicatesCron();
127 $deletions = $this->deleteConfiguredOldRedirects($options, $now);
128 $orphanedCount = $this->cleanupOrphanedAutoRedirects();
129 $junkTrashedCount = $this->autoTrashJunkCapturedUrls($options);
130 $oldLogRowsDeletedBySize = $this->deleteOldLogsBySize($viewRead, $options);
131
132 $logsSizeBytes = $viewRead->getLogDiskUsage();
133 $logSizeMB = round($logsSizeBytes / (1024 * 1000), 2);
134
135 $renamed = $this->limitDebugFileSize();
136 $renamed = $renamed ? "true" : "false";
137
138 $oldLogRowsDeleted = $deletions['logRowsDeletedByAge'] + $oldLogRowsDeletedBySize;
139
140 $message = "deleteOldRedirectsCron. Old captured URLs removed: " .
141 $deletions['capturedURLs'] . ", Old automatic redirects removed: " . $deletions['autoRedirects'] .
142 ", Old manual redirects removed: " . $deletions['manualRedirects'] .
143 ", Orphaned auto redirects removed: " . $orphanedCount .
144 ", Junk URLs auto-trashed: " . $junkTrashedCount .
145 ", Old log lines removed: " . $oldLogRowsDeleted .
146 " (age: " . $deletions['logRowsDeletedByAge'] . ", size: " . $oldLogRowsDeletedBySize . ")" .
147 ", New log size: " . $logSizeMB . "MB" .
148 ", Duplicate rows deleted: " . $duplicateRowsDeleted . ", Debug file size limited: " .
149 $renamed;
150
151 $message = $this->appendAdminNotificationMessage($message, $options, $manually_fired, $abj404logic);
152 $message = $this->appendDeveloperLogMessage($message, $options);
153
154 $this->flagDeadDestinationRedirects();
155
156 $abj404permalinkCache = abj_service('permalink_cache');
157 $rowsUpdated = $abj404permalinkCache->updatePermalinkCache(15);
158 $message .= ", Permlink cache rows updated: " . $rowsUpdated;
159
160 $manually_fired_String = ($manually_fired) ? 'true' : 'false';
161 $message .= ", User initiated: " . $manually_fired_String;
162
163 $this->logger->infoMessage($message);
164
165 $upgradesEtc = abj_service('database_upgrades');
166 $upgradesEtc->components()->bootstrapUpgrade()->createDatabaseTables();
167
168 $this->dbCore->queryAndGetResults("optimize table {wp_abj404_redirects}");
169
170 $upgradesEtc->components()->pluginUpdateUpgrade()->updatePluginCheck();
171
172 return $message;
173 }
174
175 private function isManualMaintenanceRun(): bool {
176 $manuallyFired = abj_service('functions')->getPostOrGetSanitize('manually_fired', 'false');
177 return $this->f->strtolower($manuallyFired) == 'true';
178 }
179
180 /**
181 * @param mixed $abj404logic
182 * @return void
183 */
184 private function deleteTempExportFile($abj404logic): void {
185 $tempFile = null;
186 if (is_object($abj404logic) && method_exists($abj404logic, 'importExport')) {
187 $importExport = $abj404logic->importExport();
188 if (is_object($importExport) && method_exists($importExport, 'getExportFilename')) {
189 $tempFile = $importExport->getExportFilename();
190 }
191 }
192 if (is_string($tempFile) && $tempFile !== '' && file_exists($tempFile)) {
193 ABJ_404_Solution_FileSystemService::safeUnlink($tempFile);
194 }
195 }
196
197 /**
198 * @param array<string, mixed> $options
199 * @param int $now
200 * @return array{capturedURLs: int, autoRedirects: int, manualRedirects: int, logRowsDeletedByAge: int}
201 */
202 private function deleteConfiguredOldRedirects(array $options, int $now): array {
203 $deleted = array(
204 'capturedURLs' => 0,
205 'autoRedirects' => 0,
206 'manualRedirects' => 0,
207 'logRowsDeletedByAge' => 0,
208 );
209
210 if ($this->retentionPolicy->daysFromOptions($options, 'capture_deletion') > 0) {
211 $statusList = ABJ404_STATUS_CAPTURED . ", " . ABJ404_STATUS_IGNORED . ", " . ABJ404_STATUS_LATER;
212 $deleted['capturedURLs'] = $this->deleteOldRedirectsByType($options, $now, 'capture_deletion', $statusList, 'Captured 404');
213 $deleted['logRowsDeletedByAge'] = $this->deleteOldLogsByAge(
214 $this->retentionPolicy->daysFromOptions($options, 'capture_deletion'),
215 $now
216 );
217 }
218
219 if ($this->retentionPolicy->daysFromOptions($options, 'auto_deletion') > 0) {
220 $deleted['autoRedirects'] = $this->deleteOldRedirectsByType(
221 $options,
222 $now,
223 'auto_deletion',
224 (string)ABJ404_STATUS_AUTO,
225 'Automatic redirect'
226 );
227 }
228
229 if ($this->retentionPolicy->daysFromOptions($options, 'manual_deletion') > 0) {
230 $statusList = ABJ404_STATUS_MANUAL . ", " . ABJ404_STATUS_REGEX;
231 $deleted['manualRedirects'] = $this->deleteOldRedirectsByType(
232 $options,
233 $now,
234 'manual_deletion',
235 $statusList,
236 'Manual redirect'
237 );
238 }
239
240 return $deleted;
241 }
242
243 /**
244 * @param ABJ_404_Solution_ViewReadServiceInterface $viewRead
245 * @param array<string, mixed> $options
246 * @return int
247 */
248 private function deleteOldLogsBySize($viewRead, array $options): int {
249 $logsSizeBytes = $viewRead->getLogDiskUsage();
250 $maxLogSizeRaw = array_key_exists('maximum_log_disk_usage', $options) ? $options['maximum_log_disk_usage'] : 100;
251 $maxLogSizeBytes = (is_int($maxLogSizeRaw) || is_float($maxLogSizeRaw) || is_string($maxLogSizeRaw))
252 ? (int)$maxLogSizeRaw * 1024 * 1000
253 : 100 * 1024 * 1000;
254
255 if ($logsSizeBytes <= $maxLogSizeBytes) {
256 return 0;
257 }
258
259 $totalLogLines = $viewRead->getLogsCount(0);
260 $averageSizePerLine = max($logsSizeBytes, 1) / max($totalLogLines, 1);
261 $logLinesToKeep = ceil($maxLogSizeBytes / $averageSizePerLine);
262 $logLinesToDelete = max($totalLogLines - $logLinesToKeep, 0);
263 if ($logLinesToDelete <= 0) {
264 return 0;
265 }
266
267 $query = ABJ_404_Solution_FileSystemService::readFileContents(__DIR__ . "/../sql/deleteOldLogs.sql");
268 $query = $this->f->str_replace('{lines_to_delete}', (string)$logLinesToDelete, $query);
269 $results = $this->dbCore->queryAndGetResults($query);
270 $oldLogRowsDeletedBySizeRaw = $results['rows_affected'] ?? 0;
271 return (is_int($oldLogRowsDeletedBySizeRaw) || is_float($oldLogRowsDeletedBySizeRaw) || is_string($oldLogRowsDeletedBySizeRaw))
272 ? (int)$oldLogRowsDeletedBySizeRaw
273 : 0;
274 }
275
276 /**
277 * @param string $message
278 * @param array<string, mixed> $options
279 * @param bool $manuallyFired
280 * @param mixed $abj404logic
281 * @return string
282 */
283 private function appendAdminNotificationMessage(string $message, array $options, bool $manuallyFired, $abj404logic): string {
284 $adminEmailVal = array_key_exists('admin_notification_email', $options) ? $options['admin_notification_email'] : '';
285 $adminEmail = is_string($adminEmailVal) ? $adminEmailVal : '';
286 if ($this->f->strlen(trim($adminEmail)) <= 5) {
287 return $message . ', Admin email notification option turned off.';
288 }
289
290 if ($manuallyFired) {
291 return $message . ', The admin email notification option is skipped for user initiated maintenance runs.';
292 }
293
294 if (!is_object($abj404logic) || !method_exists($abj404logic, 'pageOrdering')) {
295 $this->logger->warn('Admin email notification skipped: plugin logic pageOrdering unavailable.');
296 return $message . ', Admin email notification option unavailable.';
297 }
298
299 $pageOrdering = $abj404logic->pageOrdering();
300 if (!is_object($pageOrdering) || !method_exists($pageOrdering, 'emailCaptured404Notification')) {
301 $this->logger->warn('Admin email notification skipped: page ordering email sender unavailable.');
302 return $message . ', Admin email notification option unavailable.';
303 }
304
305 return $message . ', ' . $pageOrdering->emailCaptured404Notification();
306 }
307
308 /**
309 * @param string $message
310 * @param array<string, mixed> $options
311 * @return string
312 */
313 private function appendDeveloperLogMessage(string $message, array $options): string {
314 if (!isset($options['send_error_logs']) || $options['send_error_logs'] != '1') {
315 return $message;
316 }
317
318 if ($this->logger->emailErrorLogIfNecessary()) {
319 return $message . ", Log file emailed to developer.";
320 }
321
322 if ($this->logger->sendHeartbeatIfDueRandom(200)) {
323 return $message . ", Heartbeat log emailed to developer.";
324 }
325
326 return $message;
327 }
328
329 /** @return bool Whether the debug log file was rotated. */
330 function limitDebugFileSize(): bool {
331 $renamed = false;
332
333 $mbFileSize = $this->logger->getDebugFileSize() / 1024 / 1000;
334 if ($mbFileSize > 10) {
335 $this->logger->limitDebugFileSize();
336 $renamed = true;
337 }
338
339 return $renamed;
340 }
341
342 /** @return int Number of duplicate redirect rows removed. */
343 function removeDuplicatesCron(): int {
344 return $this->cleanupRepository->removeDuplicatesCron();
345 }
346
347 /**
348 * @param array<string, mixed> $options Plugin options array.
349 * @return int Number of captured URLs auto-trashed.
350 */
351 function autoTrashJunkCapturedUrls(array $options): int {
352 return $this->cleanupRepository->autoTrashJunkCapturedUrls($options);
353 }
354
355 /**
356 * Flag redirects whose destination URL appears in the 404 log as a recent 404.
357 *
358 * @return void
359 */
360 public function flagDeadDestinationRedirects(): void {
361 $this->deadDestinationScanner->flagDeadDestinationRedirects();
362 }
363
364 /**
365 * Move auto-created redirects to trash if they are older than the configured expiration.
366 *
367 * @return int Number of redirects moved to trash.
368 */
369 public function expireOldAutoRedirects(): int {
370 $options = abj_service('options_repository')->getOptions();
371 $days = $this->retentionPolicy->daysFromOptions(is_array($options) ? $options : array(), 'auto_302_expiration_days');
372 return $this->cleanupRepository->expireOldAutoRedirects($days, abj_clock()->now());
373 }
374 }
375