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 / admin / actions / EditRedirectHandler.php

EditRedirectHandler.php in 404 Solution trunk, at includes/admin/actions/EditRedirectHandler.php

485 lines 21.1 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 /**
8 * Handles the Edit Redirect form: $_POST['action']=='editRedirect'.
9 *
10 * Triggered by the admin's edit form on the Redirects and Captured-404 tabs.
11 * Verifies the abj404editRedirect link nonce, then writes through to
12 * redirectsRepo->updateRedirect()/saveRedirectConditions() via the
13 * shared RedirectFormResolver. On success attempts a PRG redirect to the
14 * caller's source page (so the post-update view does not re-render the edit
15 * form), and rewrites $sub/$action by reference as a defense-in-depth
16 * in-request route when headers are already sent.
17 *
18 * Extracted from PluginLogicAdminActions::handleActionEdit() +
19 * updateRedirectData() (148 lines) (M201, design-audit-2026-06-02). Called
20 * from View.php's admin-page render and from PluginLogicAdminActions's
21 * thin compat shims (used by tests).
22 */
23 class ABJ_404_Solution_EditRedirectHandler {
24
25 /** @var ABJ_404_Solution_PluginLogicAdminActions */
26 private $parent;
27
28 /** @var ABJ_404_Solution_RedirectFormResolver */
29 private $resolver;
30
31 /**
32 * Cardinality cap on the conditions[] POST payload. Every accepted
33 * condition becomes its own INSERT statement inside
34 * RedirectConditionsRepository::saveRedirectConditions()'s replacement
35 * transaction, so this bounds the SQL workload (and in-memory statement
36 * array) a single edit request can generate. The admin UI's "+ Add
37 * Condition" button has no client-side cap, but a redirect with dozens
38 * of conditions is already an edge case -- 50 is comfortably above any
39 * realistic legitimate use while still rejecting a forged or accidental
40 * oversized request (e.g. 50,000 conditions).
41 */
42 const MAX_REDIRECT_CONDITIONS = 50;
43
44 public function __construct(
45 ABJ_404_Solution_PluginLogicAdminActions $parent,
46 ABJ_404_Solution_RedirectFormResolver $resolver
47 ) {
48 $this->parent = $parent;
49 $this->resolver = $resolver;
50 }
51
52 /**
53 * Process the editRedirect POST. Returns a human-readable message and
54 * rewrites $sub/$action by reference on success (defense-in-depth route
55 * when wp_safe_redirect() can no longer fire).
56 *
57 * @param string $sub admin subpage tab key (by ref)
58 * @param string $action admin action verb (by ref)
59 * @return string
60 */
61 public function handle(&$sub, &$action): string {
62 $message = "";
63
64 if (!array_key_exists('action', $_POST) || $_POST['action'] != "editRedirect") {
65 return $message;
66 }
67
68 $f = $this->parent->getFunctions();
69 $id = ABJ_404_Solution_RequestInputNormalizer::getPostOrGetSanitize('id');
70 $ids = ABJ_404_Solution_RequestInputNormalizer::getPostOrGetSanitize('ids_multiple');
71 if ($id === '' && $ids === '') {
72 return $message;
73 }
74 if (!$f->regexMatch('[0-9]+', '' . $id) && !$f->regexMatch('[0-9]+', '' . $ids)) {
75 return $message;
76 }
77 if (!is_admin() || !$this->parent->verifyLinkNonce('abj404editRedirect')) {
78 return $message;
79 }
80
81 $message = $this->updateRedirectData();
82 if ($message != "") {
83 return $message . __('Error: Unable to update redirect data.', '404-solution');
84 }
85
86 $redirect = $this->buildPostEditRedirect();
87
88 if (!headers_sent()) {
89 wp_safe_redirect(admin_url($this->getMenuParentScript() . $redirect['redirect_url']));
90 }
91
92 $sub = $redirect['source_page'];
93 $action = '';
94 return __('Redirect Information Updated Successfully!', '404-solution');
95 }
96
97 /**
98 * Parse the Edit Redirect POST form, validate, and write through to
99 * redirectsRepo. Returns the error message (or '' on success).
100 *
101 * Public so the legacy PluginLogicAdminActions::updateRedirectData()
102 * shim and existing tests can call it directly.
103 *
104 * @return string
105 */
106 public function updateRedirectData(): string {
107 $target = $this->resolveUpdateTarget();
108 $message = $target['message'];
109 $logger = $this->parent->getLogger();
110 if ($message !== '') {
111 return $message;
112 }
113
114 $statusTypeForValidation = ABJ404_STATUS_MANUAL;
115 if (isset($_POST['is_regex_url']) && $_POST['is_regex_url'] != '0') {
116 $statusTypeForValidation = ABJ404_STATUS_REGEX;
117 }
118 $sourceResolution = array(
119 'statusType' => $statusTypeForValidation,
120 'url' => $target['fromURL'],
121 'autoPromoted' => false,
122 'urlRewritten' => false,
123 );
124 $originalFromURL = $target['fromURL'];
125 if ($target['fromURL'] !== '') {
126 $sourceResolution = $this->resolver->resolveSource(
127 $statusTypeForValidation,
128 $target['fromURL']
129 );
130 $statusTypeForValidation = $sourceResolution['statusType'];
131 $target['fromURL'] = $sourceResolution['url'];
132 }
133
134 $typeAndDest = $this->resolver->getRedirectTypeAndDest(array(
135 'isRegex' => $statusTypeForValidation === ABJ404_STATUS_REGEX,
136 'sourcePattern' => $target['fromURL'],
137 ));
138 $typeAndDestMessage = is_string($typeAndDest['message']) ? $typeAndDest['message'] : '';
139 if ($typeAndDestMessage != "") {
140 return $typeAndDestMessage;
141 }
142
143 $context = $this->buildUpdateContext($typeAndDest, $statusTypeForValidation);
144 if (!$this->contextHasDestination($context)) {
145 $message .= __('Error: Data not formatted properly.', '404-solution') . "<BR/>";
146 $logger->errorMessage("Update redirect data issue. Type: " . esc_html((string)$context['tdType']) .
147 ", dest: " . esc_html($context['tdDest']));
148 return $message;
149 }
150
151 if ($target['fromURL'] != "") {
152 return $message . $this->updateSingleRedirect(
153 $target['fromURL'],
154 $context,
155 $sourceResolution,
156 $originalFromURL
157 );
158 }
159
160 if (!empty($target['ids_multiple'])) {
161 return $message . $this->updateMultipleRedirects($target['ids_multiple'], $context);
162 }
163
164 $logger->errorMessage("Issue determining which redirect(s) to update. " .
165 "fromURL: " . $target['fromURL'] . ", ids_multiple: " . implode(',', $target['ids_multiple']));
166 return $message;
167 }
168
169 /**
170 * @return array{fromURL: string, ids_multiple: array<int, int>, message: string}
171 */
172 private function resolveUpdateTarget(): array {
173 $message = "";
174 $fromURL = "";
175 $idsMultiple = array();
176
177 if (
178 (!array_key_exists('url', $_POST) || $_POST['url'] == "") &&
179 (array_key_exists('ids_multiple', $_POST) && $_POST['ids_multiple'] != "")) {
180 $idsMultiple = array_map('absint', explode(',', (string)$_POST['ids_multiple']));
181
182 } else if (array_key_exists('url', $_POST) && $_POST['url'] != "" &&
183 (!array_key_exists('ids_multiple', $_POST) || $_POST['ids_multiple'] == "")) {
184
185 $fromURL = stripslashes((string)$_POST['url']);
186 } else {
187 $message .= __('Error: URL is a required field.', '404-solution') . "<BR/>";
188 }
189
190 return array('fromURL' => $fromURL, 'ids_multiple' => $idsMultiple, 'message' => $message);
191 }
192
193 /**
194 * @param array<string, mixed> $typeAndDest
195 * @return array{tdTypeRaw: string, tdType: int, tdDest: string, code: string, statusType: int, startTs: int|null, endTs: int|null}
196 */
197 private function buildUpdateContext(array $typeAndDest, int $statusType): array {
198 $tdTypeRaw = is_scalar($typeAndDest['type']) ? (string)$typeAndDest['type'] : '';
199 $tdType = ($tdTypeRaw !== '') ? (int)$tdTypeRaw : -1;
200 $tdDest = is_scalar($typeAndDest['dest']) ? (string)$typeAndDest['dest'] : '';
201 $code = isset($_POST['code']) && is_string($_POST['code']) ? $_POST['code'] : '';
202
203 $startDateRaw = isset($_POST['redirect_start_date']) && is_string($_POST['redirect_start_date']) ? trim($_POST['redirect_start_date']) : '';
204 $endDateRaw = isset($_POST['redirect_end_date']) && is_string($_POST['redirect_end_date']) ? trim($_POST['redirect_end_date']) : '';
205 $startTs = ABJ_404_Solution_RedirectScheduleTimezone::toEpoch($startDateRaw, '00:00:00');
206 $endTs = ABJ_404_Solution_RedirectScheduleTimezone::toEpoch($endDateRaw, '23:59:59');
207
208 return array(
209 'tdTypeRaw' => $tdTypeRaw,
210 'tdType' => $tdType,
211 'tdDest' => $tdDest,
212 'code' => $code,
213 'statusType' => $statusType,
214 'startTs' => $startTs,
215 'endTs' => $endTs,
216 );
217 }
218
219 /**
220 * @param array{tdTypeRaw: string, tdType: int, tdDest: string, code: string, statusType: int, startTs: int|null, endTs: int|null} $context
221 */
222 private function contextHasDestination(array $context): bool {
223 $isGoneCode = $context['code'] === '410' || $context['code'] === '451';
224 return $context['tdTypeRaw'] !== '' && ($context['tdDest'] !== "" || $isGoneCode);
225 }
226
227 /**
228 * @param array{tdTypeRaw: string, tdType: int, tdDest: string, code: string, statusType: int, startTs: int|null, endTs: int|null} $context
229 * @param array{statusType: int, url: string, autoPromoted: bool, urlRewritten: bool} $sourceResolution
230 */
231 private function updateSingleRedirect(
232 string $fromURL,
233 array $context,
234 array $sourceResolution,
235 string $originalFromURL
236 ): string {
237 $redirectsRepo = $this->parent->getRedirectsRepo();
238 $id = isset($_POST['id']) && is_scalar($_POST['id']) ? (int)$_POST['id'] : 0;
239 $updateError = $redirectsRepo->updateRedirect(ABJ_404_Solution_RedirectUpdate::fromArray(array(
240 'id' => $id,
241 'type' => $context['tdType'],
242 'fromUrl' => (string)$fromURL,
243 'destination' => $context['tdDest'],
244 'code' => $context['code'],
245 'statusType' => (string)$context['statusType'],
246 'startTs' => $context['startTs'],
247 'endTs' => $context['endTs'],
248 )));
249 $errorCode = is_scalar($updateError) ? (string)$updateError : '';
250 if ($errorCode !== '') {
251 return $this->formatUpdateRedirectError($errorCode) . "<BR/>";
252 }
253 if ($sourceResolution['autoPromoted']) {
254 $this->resolver->saveRegexAutoPromoteNotice(
255 $id,
256 $originalFromURL,
257 $fromURL,
258 $sourceResolution['urlRewritten']
259 );
260 }
261
262 if ($id > 0) {
263 $sanitized = $this->sanitizeRedirectConditions();
264 $conditionsError = $redirectsRepo->saveRedirectConditions($id, $sanitized['conditions']);
265 if ($conditionsError !== '') {
266 return $this->formatSaveConditionsError($conditionsError) . "<BR/>";
267 }
268 // After the save, not instead of it: the conditions that fit were
269 // stored and the rest of the edit stands. This reports what did
270 // not fit so the admin is not left believing all of it was kept.
271 if ($sanitized['droppedOverMaximum'] > 0) {
272 return $this->formatConditionsCappedNotice($sanitized['droppedOverMaximum']) . "<BR/>";
273 }
274 }
275 return '';
276 }
277
278 /**
279 * @param array<int, int> $idsMultiple
280 * @param array{tdTypeRaw: string, tdType: int, tdDest: string, code: string, statusType: int, startTs: int|null, endTs: int|null} $context
281 */
282 private function updateMultipleRedirects(array $idsMultiple, array $context): string {
283 $message = "";
284 $redirectsRepo = $this->parent->getRedirectsRepo();
285 $redirectsMultiple = $redirectsRepo->getRedirectsByIDs($idsMultiple);
286 if (empty($redirectsMultiple)) {
287 // Every selected row is gone: another admin deleted them, or
288 // deleteOldRedirectsCron did, while the list page holding the
289 // checkboxes was still open. Without this branch the loop below
290 // runs zero times and returns "", which handleActionEdit() reads
291 // as success and answers "Redirect Information Updated
292 // Successfully!" after writing nothing. Same "the id no longer has
293 // a row" condition the edit screen reports (production report 349),
294 // on the write side. Below error level for the same reason: the
295 // plugin worked correctly, the selection did not survive.
296 $this->parent->getLogger()->debugMessage("Bulk redirect update: no redirect rows exist " .
297 "for requested id(s): " . esc_html(implode(', ', array_map('strval', $idsMultiple))));
298 return sprintf(
299 /* translators: %s is a comma-separated list of redirect id numbers. */
300 _n(
301 'Redirect %s was not found. It may have been deleted since this page was opened.',
302 'Redirects %s were not found. They may have been deleted since this page was opened.',
303 count($idsMultiple),
304 '404-solution'
305 ),
306 implode(', ', array_map('strval', $idsMultiple))
307 ) . "<BR/>";
308 }
309 foreach ($redirectsMultiple as $redirect) {
310 $redirectUrl = is_string($redirect['url']) ? $redirect['url'] : '';
311 $redirectId = is_scalar($redirect['id']) ? (int)$redirect['id'] : 0;
312 $updateError = $redirectsRepo->updateRedirect(ABJ_404_Solution_RedirectUpdate::fromArray(array(
313 'id' => $redirectId,
314 'type' => $context['tdType'],
315 'fromUrl' => (string)$redirectUrl,
316 'destination' => $context['tdDest'],
317 'code' => $context['code'],
318 'statusType' => (string)$context['statusType'],
319 )));
320 $errorCode = is_scalar($updateError) ? (string)$updateError : '';
321 if ($errorCode !== '') {
322 $message .= $this->formatUpdateRedirectError($errorCode) . "<BR/>";
323 continue;
324 }
325 }
326 return $message;
327 }
328
329 private function formatUpdateRedirectError(string $errorCode): string {
330 if ($errorCode === 'bad_update_request') {
331 return __('Error: Bad data passed for update redirect request.', '404-solution');
332 }
333
334 return sprintf(
335 __('Error: Unable to update redirect data. Repository result: %s', '404-solution'),
336 esc_html($errorCode)
337 );
338 }
339
340 /**
341 * @param string $errorMessage Underlying DB/transaction error text (per
342 * the Error visibility philosophy, surfaced rather than genericized).
343 */
344 private function formatSaveConditionsError(string $errorMessage): string {
345 return sprintf(
346 __('Error: Unable to save redirect conditions. Repository result: %s', '404-solution'),
347 esc_html($errorMessage)
348 );
349 }
350
351 /**
352 * Build the post-edit PRG redirect querystring + the source page that
353 * the in-request render should target.
354 *
355 * @return array{source_page: string, redirect_url: string}
356 */
357 private function buildPostEditRedirect(): array {
358 // Through RedirectEditRequest's list, not a second copy of it: the read
359 // side answers the same question for the back link and the hidden
360 // inputs, and two lists of "which subpage is valid" drift apart.
361 $source_page = ABJ_404_Solution_RequestInputNormalizer::getPostOrGetSanitize('source_page');
362 if (!ABJ_404_Solution_RedirectEditRequest::isListSubpage($source_page)) {
363 $source_page = ABJ_404_Solution_RedirectEditRequest::DEFAULT_SUBPAGE;
364 }
365
366 $redirect_url = "?page=" . ABJ404_PP . "&subpage=" . $source_page . "&updated=1";
367
368 $source_filter = ABJ_404_Solution_RequestInputNormalizer::getPostOrGetSanitize('source_filter', '');
369 if ($source_filter !== '' && $source_filter !== '0') {
370 $redirect_url .= "&filter=" . urlencode($source_filter);
371 }
372
373 $source_orderby = ABJ_404_Solution_RequestInputNormalizer::getPostOrGetSanitize('source_orderby', '');
374 $source_order = ABJ_404_Solution_RequestInputNormalizer::getPostOrGetSanitize('source_order', '');
375 if ($source_orderby !== '' && $source_order !== ''
376 && !($source_orderby === "url" && $source_order === "ASC")) {
377 $redirect_url .= "&orderby=" . urlencode($source_orderby);
378 $redirect_url .= "&order=" . urlencode($source_order);
379 }
380
381 $source_paged = ABJ_404_Solution_RequestInputNormalizer::getPostOrGetSanitize('source_paged', '');
382 if ($source_paged !== '' && (int)$source_paged > 1) {
383 $redirect_url .= "&paged=" . urlencode($source_paged);
384 }
385
386 return array('source_page' => $source_page, 'redirect_url' => $redirect_url);
387 }
388
389 /**
390 * Resolve the admin parent script the plugin's menu page is registered
391 * under. Used to build correct admin_url() after a successful edit.
392 *
393 * @return string
394 */
395 private function getMenuParentScript(): string {
396 $options = abj_service('options_repository')->getOptions(true);
397 return ABJ_404_Solution_AdminPageUrlBuilder::pageFile(is_array($options) ? $options : array());
398 }
399
400 /**
401 * Sanitize the conditions[] POST payload into the shape redirectsRepo
402 * accepts. Whitelists condition types and operators; coerces logic to
403 * AND/OR.
404 *
405 * Reports how many were dropped for being over the maximum, separately
406 * from the ones the loop below skips for being unrecognized. The two are
407 * not the same event: an unknown condition type is garbage and dropping it
408 * silently is right, while a VALID rule dropped for arriving 51st changes
409 * how the redirect behaves versus what the admin configured, and the only
410 * signal they would otherwise get is the rows being absent next time they
411 * happen to open the screen.
412 *
413 * @return array{conditions: array<int, array<string, mixed>>, droppedOverMaximum: int}
414 */
415 private function sanitizeRedirectConditions(): array {
416 $rawConditions = (isset($_POST['conditions']) && is_array($_POST['conditions']))
417 ? $_POST['conditions'] : [];
418 // Enforce the domain maximum before sanitizing/inserting, rather than
419 // rejecting the entire edit over an oversized payload: the rest of the
420 // save is the admin's real work and is valid.
421 $droppedOverMaximum = max(0, count($rawConditions) - self::MAX_REDIRECT_CONDITIONS);
422 if ($droppedOverMaximum > 0) {
423 $rawConditions = array_slice($rawConditions, 0, self::MAX_REDIRECT_CONDITIONS);
424 }
425 $sanitizedConditions = [];
426 $allowedConditionTypes = [
427 'login_status', 'user_role', 'referrer',
428 'user_agent', 'ip_range', 'http_header',
429 ];
430 $allowedOperators = [
431 'equals', 'not_equals', 'contains',
432 'not_contains', 'regex', 'cidr',
433 ];
434 foreach ($rawConditions as $rawCond) {
435 if (!is_array($rawCond)) {
436 continue;
437 }
438 $condType = isset($rawCond['condition_type']) && is_string($rawCond['condition_type'])
439 ? sanitize_text_field($rawCond['condition_type']) : '';
440 if (!in_array($condType, $allowedConditionTypes, true)) {
441 continue;
442 }
443 $condLogic = (isset($rawCond['logic']) && strtoupper((string)$rawCond['logic']) === 'OR') ? 'OR' : 'AND';
444 $condOperator = isset($rawCond['operator']) && is_string($rawCond['operator'])
445 ? sanitize_text_field($rawCond['operator']) : 'equals';
446 if (!in_array($condOperator, $allowedOperators, true)) {
447 $condOperator = 'equals';
448 }
449 $condValue = isset($rawCond['value']) && is_string($rawCond['value'])
450 ? sanitize_text_field(wp_unslash($rawCond['value'])) : '';
451 $condSortOrder = isset($rawCond['sort_order']) ? absint($rawCond['sort_order']) : 0;
452
453 $sanitizedConditions[] = [
454 'logic' => $condLogic,
455 'condition_type' => $condType,
456 'operator' => $condOperator,
457 'value' => $condValue,
458 'sort_order' => $condSortOrder,
459 ];
460 }
461 return array(
462 'conditions' => $sanitizedConditions,
463 'droppedOverMaximum' => $droppedOverMaximum,
464 );
465 }
466
467 /**
468 * @param int $droppedCount How many valid conditions were dropped for
469 * arriving past MAX_REDIRECT_CONDITIONS.
470 */
471 private function formatConditionsCappedNotice(int $droppedCount): string {
472 return sprintf(
473 /* translators: 1: number of conditions that were not saved. 2: the maximum allowed. */
474 _n(
475 'Note: %1$s condition was not saved. A redirect may have at most %2$s conditions.',
476 'Note: %1$s conditions were not saved. A redirect may have at most %2$s conditions.',
477 $droppedCount,
478 '404-solution'
479 ),
480 number_format_i18n($droppedCount),
481 number_format_i18n(self::MAX_REDIRECT_CONDITIONS)
482 );
483 }
484 }
485