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 / repositories / RedirectWriteService.php

RedirectWriteService.php in 404 Solution trunk, at includes/repositories/RedirectWriteService.php

501 lines 20.6 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__ . '/../redirects/RedirectCanonicalUrl.php';
8 require_once __DIR__ . '/RedirectsLiveColumnSet.php';
9 require_once __DIR__ . '/RedirectInsertStatement.php';
10
11 /**
12 * Application service for redirect row mutations and purge decisions.
13 *
14 * This keeps write validation, status-count invalidation, and regex-cache
15 * invalidation out of the frontend redirect lookup repository.
16 */
17 class ABJ_404_Solution_RedirectWriteService {
18
19 /** @var ABJ_404_Solution_DatabaseCore */
20 private $dbCore;
21
22 /** @var ABJ_404_Solution_Functions */
23 private $f;
24
25 /** @var ABJ_404_Solution_Logging */
26 private $logger;
27
28 /** @var ABJ_404_Solution_RedirectRegexCacheStore */
29 private $regexCacheStore;
30
31 /** @var ABJ_404_Solution_PluginLogicUrlNormalization|null */
32 private $urlNormalization;
33
34 /** @var ABJ_404_Solution_RedirectsDenormMaintenanceService|null Memoized Step 3c maintenance service. */
35 private $denormMaintenance = null;
36
37 /** @var ABJ_404_Solution_StatusCountsMutationSync|null Memoized cached-count delta writer. */
38 private $countsSync = null;
39
40 /** @var ABJ_404_Solution_RedirectWriteAdmissionPolicy|null Memoized write-admission rules. */
41 private $admissionPolicy = null;
42
43 /**
44 * Memoized view of the columns the live redirects table actually has, so
45 * an install whose upgrade never added engine/score/canonical_url loses
46 * those values instead of the whole row.
47 *
48 * @var ABJ_404_Solution_RedirectsLiveColumnSet|null
49 */
50 private $liveColumns = null;
51
52 /**
53 * @param ABJ_404_Solution_DatabaseCore $dbCore
54 * @param ABJ_404_Solution_Functions|null $functions
55 * @param ABJ_404_Solution_Logging|null $logging
56 * @param ABJ_404_Solution_RedirectRegexCacheStore|null $regexCacheStore
57 */
58 public function __construct(
59 ABJ_404_Solution_DatabaseCore $dbCore,
60 $functions = null,
61 $logging = null,
62 $regexCacheStore = null,
63 ?ABJ_404_Solution_PluginLogicUrlNormalization $urlNormalization = null
64 ) {
65 $this->dbCore = $dbCore;
66 $this->f = $functions !== null ? $functions : abj_service('functions');
67 $this->logger = $logging !== null ? $logging : abj_service('logging');
68 $this->regexCacheStore = $regexCacheStore !== null ? $regexCacheStore : new ABJ_404_Solution_RedirectRegexCacheStore();
69 $this->urlNormalization = $urlNormalization;
70 }
71
72 /** @return ABJ_404_Solution_PluginLogicUrlNormalization */
73 private function urlNormalization() {
74 if ($this->urlNormalization !== null) {
75 return $this->urlNormalization;
76 }
77 return abj_service('plugin_logic')->urlNormalization();
78 }
79
80 /** @param int|string $id */
81 public function deleteRedirect($id): void {
82 $cleanedID = absint(sanitize_text_field((string)$id));
83
84 if (is_numeric($id)) {
85 $before = $this->countsSync()->snapshot('id = %d', array($cleanedID));
86 $query = "delete from {wp_abj404_redirects} where id = %d";
87 $this->dbCore->queryAndGetResults($query, array('query_params' => array($cleanedID)));
88 $this->invalidateRedirectMutationCaches();
89 $this->countsSync()->syncSince($before, 'id = %d', array($cleanedID));
90 }
91 }
92
93 public function setupRedirect(ABJ_404_Solution_RedirectSpec $spec): int {
94 return $this->setupRedirectWithPolicy($spec, array('require_absent_source' => false));
95 }
96
97 public function setupRedirectIfSourceAbsent(ABJ_404_Solution_RedirectSpec $spec): int {
98 return $this->setupRedirectWithPolicy($spec, array('require_absent_source' => true));
99 }
100
101 /**
102 * @param ABJ_404_Solution_RedirectSpec $spec
103 * @param array{require_absent_source: bool} $policy
104 * @return int
105 */
106 private function setupRedirectWithPolicy(ABJ_404_Solution_RedirectSpec $spec, array $policy): int {
107 $fromURL = $spec->getFromURL();
108 $status = $spec->getStatus();
109 $type = $spec->getType();
110 $finalDest = $spec->getFinalDest();
111 $code = $spec->getCode();
112 $disabled = $spec->getDisabled();
113 $engine = $spec->getEngine();
114 $score = $spec->getScore();
115
116 if (!is_numeric($type)) {
117 $this->logger->errorMessage("Wrong data type for redirect. TYPE is non-numeric. From: " .
118 esc_url($fromURL) . " to: " . esc_url($finalDest) . ", Type: " . esc_html((string)$type) . ", Status: " . $status);
119 } else if (!is_numeric($status)) {
120 $this->logger->errorMessage("Wrong data type for redirect. STATUS is non-numeric. From: " .
121 esc_url($fromURL) . " to: " . esc_url($finalDest) . ", Type: " . esc_html((string)$type) . ", Status: " . $status);
122 }
123
124 // (int), not absint(): status and type are stored with %d, which casts
125 // rather than takes an absolute value. absint() here would gate a
126 // status of -2 as ABJ404_STATUS_AUTO and then write -2, so the row that
127 // passed the admission rules is not the row that lands in the table.
128 // -1 keeps its existing meaning of "no rule applies to this value".
129 $statusAsInt = is_numeric($status) ? (int)$status : -1;
130 $typeAsInt = is_numeric($type) ? (int)$type : -1;
131
132 if ($statusAsInt === ABJ404_STATUS_REGEX && !$this->admissionPolicy()->regexSourceIsValid($fromURL)) {
133 return 0;
134 }
135
136 if ($statusAsInt === ABJ404_STATUS_AUTO &&
137 !$this->admissionPolicy()->isValidAutomaticRedirectDestination($typeAsInt, $finalDest)) {
138 $this->logger->debugMessage("Skipping automatic redirect with invalid destination. " .
139 "From: " . esc_url($fromURL) . ", Dest: " . esc_html((string)$finalDest) .
140 ", Type: " . esc_html((string)$type) . ", Status: " . esc_html((string)$status));
141 return 0;
142 }
143
144 $insertId = 0;
145
146 if (!abj_service('request_context')->ignore_doprocess) {
147 $now = abj_clock()->now();
148 $redirectsTable = $this->dbCore->doTableNameReplacements("{wp_abj404_redirects}");
149 $fromURL = $this->urlNormalization()->normalizeRedirectSourceForStatus($fromURL, $statusAsInt);
150 $canonicalUrl = ABJ_404_Solution_RedirectCanonicalUrl::compute($fromURL);
151 $insert = ABJ_404_Solution_RedirectInsertStatement::fromRequest(array(
152 'table' => $redirectsTable,
153 'sourceUrl' => $fromURL,
154 'status' => $status,
155 'type' => $type,
156 'finalDest' => $finalDest,
157 'code' => $code,
158 'disabled' => $disabled,
159 'timestamp' => $now,
160 'canonicalUrl' => $canonicalUrl,
161 'engine' => $engine === null ? null : (string)$engine,
162 'score' => $score === null ? null : (float)$score,
163 'liveColumns' => $this->liveColumns(),
164 'requireAbsentSource' => $policy['require_absent_source'],
165 ));
166 if ($policy['require_absent_source']) {
167 $insertResult = $this->dbCore->transactionExecutor()->executeSerializableMutation(array(
168 'sql' => $insert->sql(),
169 'params' => $insert->params(),
170 'description' => 'atomically recording captured-404 evidence',
171 ));
172 } else {
173 $insertResult = $this->dbCore->queryAndGetResults($insert->sql(), array(
174 'query_params' => $insert->params(),
175 ));
176 }
177 $insertIdRaw = $insertResult['insert_id'] ?? 0;
178 $insertId = is_scalar($insertIdRaw) ? (int)$insertIdRaw : 0;
179
180 // A captured-404 insert is the high-frequency frontend path: on a
181 // busy site it fires continuously and only changes the captured +
182 // high-impact counts. Use the debounced, captured-scoped invalidation
183 // so the SUM(CASE) status-count aggregate is not cold-recomputed on
184 // every admin load and the unrelated redirect-count cache stays warm
185 // (report.md Finding 4). Admin-driven inserts (manual / auto / regex,
186 // e.g. Add Redirect or a slug-change auto-redirect) are low-frequency
187 // and keep the immediate full invalidation so the admin sees the
188 // count change at once.
189 if ($statusAsInt === ABJ404_STATUS_CAPTURED) {
190 ABJ_404_Solution_ViewCacheInvalidator::invalidateCapturedStatusCountsCacheDebounced();
191 } else {
192 abj_service('view_read_service')->invalidateStatusCountsCache();
193 }
194 if ($insertId > 0) {
195 $this->countsSync()->syncInserted($statusAsInt, absint($disabled));
196 }
197 if ($status == ABJ404_STATUS_REGEX) {
198 $this->regexCacheStore->clear();
199 }
200
201 // Step 3c: keep the new row's denorm display columns (dest_for_view
202 // / published_status) current so an off-page sort/filter sees fresh
203 // values immediately, not only after the nightly reconcile.
204 if ($insertId > 0) {
205 $this->recomputeDenormColumns(array($insertId));
206 }
207 }
208
209 return $insertId;
210 }
211
212 /**
213 * @param array<int, int|string> $types
214 * @return array{status: string, rows_affected: int, redirect_types: array<int, int>}
215 */
216 public function deleteSpecifiedRedirects(array $types, string $purgeType): array {
217 $result = array(
218 'status' => 'noop',
219 'rows_affected' => 0,
220 'redirect_types' => array(),
221 );
222
223 if ($purgeType != 'abj404_logs' && $purgeType != 'abj404_redirects') {
224 $this->logger->debugMessage("Error: An invalid purge type was selected. Type: " .
225 wp_kses_post((string)json_encode($purgeType)));
226 $result['status'] = 'invalid_purge_type';
227 return $result;
228 }
229
230 $redirectTypes = array();
231 foreach ($types as $aType) {
232 if (('' . $aType != ABJ404_TYPE_HOME) && ('' . $aType != ABJ404_TYPE_404_DISPLAYED)) {
233 array_push($redirectTypes, absint($aType));
234 }
235 }
236
237 if (empty($redirectTypes)) {
238 $this->logger->debugMessage("Error: No valid redirect types were selected. Types: " .
239 wp_kses_post((string)json_encode($redirectTypes)));
240 $result['status'] = 'no_valid_types';
241 return $result;
242 }
243
244 array_push($redirectTypes, 0);
245
246 $redirectTypes = array_map('absint', $redirectTypes);
247 $result['redirect_types'] = $redirectTypes;
248
249 if ($purgeType == 'abj404_logs') {
250 $result['status'] = 'logs_only';
251 return $result;
252 }
253
254 $typesForSQL = implode(',', $redirectTypes);
255
256 $affectedRows = "status in (" . $typesForSQL . ")";
257 $before = $this->countsSync()->snapshot($affectedRows);
258
259 $query = "update {wp_abj404_redirects} set disabled = 1 where status in (" . $typesForSQL . ")";
260 $purgeResult = $this->dbCore->queryAndGetResults($query);
261 $rowsAffectedRaw = $purgeResult['rows_affected'] ?? 0;
262 $redirectCount = is_scalar($rowsAffectedRaw) ? (int)$rowsAffectedRaw : 0;
263
264 $this->invalidateRedirectMutationCaches();
265 $this->countsSync()->syncSince($before, $affectedRows);
266
267 $result['status'] = 'redirects_purged';
268 $result['rows_affected'] = $redirectCount;
269 return $result;
270 }
271
272 public function updateRedirect(ABJ_404_Solution_RedirectUpdate $update): string {
273 $type = $update->getType();
274 $idForUpdate = $update->getId();
275 if (($type < 0) || ($idForUpdate <= 0)) {
276 $this->logger->errorMessage("Bad data passed for update redirect request. Type: " .
277 esc_html((string)$type) . ", Dest: " . esc_html($update->getDestination()) .
278 ", ID(s): " . esc_html((string)$idForUpdate));
279 return 'bad_update_request';
280 }
281
282 $statusType = $update->getStatusType();
283 if ((int)$statusType === ABJ404_STATUS_REGEX && !$this->admissionPolicy()->regexSourceIsValid($update->getFromUrl())) {
284 return 'invalid_regex_source';
285 }
286
287 $startTs = $update->getStartTs();
288 $endTs = $update->getEndTs();
289 $fromUrl = $this->urlNormalization()->normalizeRedirectSourceForStatus(
290 $update->getFromUrl(),
291 $statusType
292 );
293
294 $redirectsTable = $this->dbCore->doTableNameReplacements("{wp_abj404_redirects}");
295 $before = $this->countsSync()->snapshot('id = %d', array(absint($idForUpdate)));
296
297 $updateData = array(
298 'url' => $fromUrl,
299 'status' => $statusType,
300 'type' => absint($type),
301 'final_dest' => $update->getDestination(),
302 'code' => esc_attr($update->getCode()),
303 );
304 $updateFormats = array('%s', '%d', '%d', '%s', '%d');
305
306 if ($startTs !== null) {
307 $updateData['start_ts'] = (int)$startTs;
308 $updateFormats[] = '%d';
309 }
310 if ($endTs !== null) {
311 $updateData['end_ts'] = (int)$endTs;
312 $updateFormats[] = '%d';
313 }
314
315 $setFragments = array();
316 $idx = 0;
317 foreach ($updateData as $col => $unusedValue) {
318 $format = isset($updateFormats[$idx]) ? $updateFormats[$idx] : '%s';
319 $setFragments[] = '`' . $col . '` = ' . $format;
320 $idx++;
321 }
322
323 // Explicit NULL clearing folded into the same SET clause as the rest
324 // of the row (single merged UPDATE, not a second independent
325 // statement): these carry no bound placeholder (NULL is a SQL
326 // literal here, never passed through %d, which would coerce it to
327 // 0), so appending them after the placeholder fragments does not
328 // shift $updateParams' positional alignment. One statement means
329 // there is nothing for a mid-request DB failure to leave half
330 // applied.
331 if ($startTs === null) {
332 $setFragments[] = '`start_ts` = NULL';
333 }
334 if ($endTs === null) {
335 $setFragments[] = '`end_ts` = NULL';
336 }
337
338 $updateSql = "UPDATE `" . $redirectsTable . "` SET " . implode(', ', $setFragments) .
339 " WHERE `id` = %d";
340 $updateParams = array_values($updateData);
341 $updateParams[] = absint($idForUpdate);
342 $updateResult = $this->dbCore->queryAndGetResults($updateSql, array('query_params' => $updateParams));
343 $updateError = isset($updateResult['last_error']) && is_string($updateResult['last_error']) ? $updateResult['last_error'] : '';
344 if ($updateError !== '') {
345 // queryAndGetResults() already logged this (centralized DAO error
346 // handler) -- reject before any downstream cache invalidation /
347 // denorm recompute runs against a row that may not have changed.
348 return 'db_update_failed';
349 }
350
351 $this->invalidateRedirectMutationCaches();
352 $this->countsSync()->syncSince($before, 'id = %d', array(absint($idForUpdate)));
353
354 $this->moveRedirectsToTrash(absint($idForUpdate), 0);
355
356 // Step 3c: an edit can change the destination type/target, so recompute
357 // the row's denorm display columns from the new final_dest.
358 $this->recomputeDenormColumns(array(absint($idForUpdate)));
359
360 return '';
361 }
362
363 /**
364 * @param array<int, int|string> $ids
365 * @return array<int, array<string, mixed>>
366 */
367 public function getRedirectsByIDs($ids): array {
368 if (!is_array($ids) || empty($ids)) {
369 return array();
370 }
371 $validids = array_map('absint', $ids);
372 $multipleIds = implode(',', $validids);
373
374 // allow-unbounded-select: caller-supplied id keyset (where id IN an absint-filtered list); bounded by the explicit id list
375 $query = "select id, url, type, status, final_dest, code, COALESCE(engine, '') as engine, start_ts, end_ts from {wp_abj404_redirects} " .
376 "where id in (" . $multipleIds . ")";
377 $result = $this->dbCore->queryAndGetResults($query);
378 $rawRows = isset($result['rows']) && is_array($result['rows']) ? $result['rows'] : array();
379
380 $rows = array();
381 foreach ($rawRows as $row) {
382 if (is_array($row)) {
383 $rows[] = $row;
384 }
385 }
386 return $rows;
387 }
388
389 /**
390 * @param int $id
391 * @param string $newstatus
392 */
393 public function updateRedirectTypeStatus($id, $newstatus): string {
394 $before = $this->countsSync()->snapshot('id = %d', array(absint($id)));
395
396 $query = "update {wp_abj404_redirects} set status = %s where id = %d";
397 $result = $this->dbCore->queryAndGetResults($query, array(
398 'query_params' => array($newstatus, absint($id))
399 ));
400
401 $this->invalidateRedirectMutationCaches();
402 $this->countsSync()->syncSince($before, 'id = %d', array(absint($id)));
403
404 return is_string($result['last_error']) ? $result['last_error'] : '';
405 }
406
407 /**
408 * @param int|string $id
409 * @param int|string $trash
410 */
411 public function moveRedirectsToTrash($id, $trash): string {
412 $message = "";
413 $hadError = false;
414 if ($this->f->regexMatch('[0-9]+', '' . $id)) {
415 $before = $this->countsSync()->snapshot('id = %d', array(absint($id)));
416
417 $redirectsTable = $this->dbCore->doTableNameReplacements("{wp_abj404_redirects}");
418 $updateResult = $this->dbCore->queryAndGetResults(
419 "UPDATE `" . $redirectsTable . "` SET disabled = %d WHERE id = %d",
420 array('query_params' => array(absint(esc_html((string)$trash)), absint($id)))
421 );
422 $updateError = isset($updateResult['last_error']) && is_string($updateResult['last_error']) ? $updateResult['last_error'] : '';
423 $hadError = $updateError !== '';
424
425 $this->invalidateRedirectMutationCaches();
426 $this->countsSync()->syncSince($before, 'id = %d', array(absint($id)));
427 } else {
428 $hadError = true;
429 }
430 if ($hadError) {
431 $message = __('Error: Unknown Database Error!', '404-solution');
432 }
433 return $message;
434 }
435
436 /** @return ABJ_404_Solution_RedirectsLiveColumnSet */
437 private function liveColumns(): ABJ_404_Solution_RedirectsLiveColumnSet {
438 if (!($this->liveColumns instanceof ABJ_404_Solution_RedirectsLiveColumnSet)) {
439 $this->liveColumns = new ABJ_404_Solution_RedirectsLiveColumnSet(array(
440 'tableMetadata' => $this->dbCore->tableNameResolver(),
441 'logger' => $this->logger,
442 ));
443 }
444 return $this->liveColumns;
445 }
446
447 /**
448 * Invalidate the caches a redirect mutation affects.
449 *
450 * Invalidation alone is not enough for the tab counts: foreground count
451 * reads are cache-only (the aggregate is deferred to cron), so an
452 * invalidated count keeps serving its last-known value. Every mutation
453 * therefore also brackets itself with ABJ_404_Solution_StatusCountsMutationSync,
454 * which applies the delta the mutation actually caused.
455 *
456 * @return void
457 */
458 private function invalidateRedirectMutationCaches(): void {
459 abj_service('view_read_service')->invalidateStatusCountsCache();
460 $this->regexCacheStore->clear();
461 }
462
463 /** @return ABJ_404_Solution_StatusCountsMutationSync */
464 private function countsSync() {
465 if ($this->countsSync === null) {
466 $this->countsSync = new ABJ_404_Solution_StatusCountsMutationSync($this->dbCore);
467 }
468 return $this->countsSync;
469 }
470
471 /** @return ABJ_404_Solution_RedirectWriteAdmissionPolicy */
472 private function admissionPolicy() {
473 if ($this->admissionPolicy === null) {
474 $this->admissionPolicy = new ABJ_404_Solution_RedirectWriteAdmissionPolicy(
475 $this->f, $this->logger
476 );
477 }
478 return $this->admissionPolicy;
479 }
480
481 /**
482 * Recompute the dest_for_view / published_status denorm columns for the
483 * given redirect ids via the Step 3c maintenance service, built from this
484 * service's own injected db_core + logger. The maintenance write degrades
485 * gracefully on a schema-drifted / read-only host, so no extra guard is
486 * needed here.
487 *
488 * @param array<int, int> $ids
489 * @return void
490 */
491 private function recomputeDenormColumns(array $ids): void {
492 if ($this->denormMaintenance === null) {
493 $this->denormMaintenance = new ABJ_404_Solution_RedirectsDenormMaintenanceService(
494 $this->dbCore,
495 $this->logger
496 );
497 }
498 $this->denormMaintenance->recomputeByRedirectIds($ids);
499 }
500 }
501