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

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

287 lines 11.2 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 * Owns parsing of the admin redirect form's destination field and the
9 * regex auto-promote policy. Used by both AddRedirectHandler (handler
10 * for the Add Redirect form) and EditRedirectHandler (handler for the
11 * Edit Redirect form). Extracted from PluginLogicAdminActions as part
12 * of M201 split (design-audit-2026-06-02).
13 *
14 * Two responsibilities, both genuinely shared:
15 *
16 * - getRedirectTypeAndDest(): translate the form's
17 * redirect_to_data_field_id POST value into a {type, dest, message}
18 * triple. Handles the external-URL branch (parse, validate scheme,
19 * filter through abj404_validate_external_redirect) and the
20 * internal post/page branch (split on '|').
21 *
22 * - maybeAutoPromoteRegex() + saveRegexAutoPromoteNotice(): if a
23 * manually entered URL pattern looks like an unambiguous regex,
24 * promote its status to ABJ404_STATUS_REGEX and apply a glob
25 * rewrite. The transient notice the admin sees afterwards is
26 * persisted by saveRegexAutoPromoteNotice().
27 *
28 * Neither belongs uniquely to Add or Edit; both must apply both. A
29 * shared resolver collapses the duplicated wiring previously inlined
30 * in addAdminRedirect() and updateRedirectData() into one place.
31 */
32 class ABJ_404_Solution_RedirectFormResolver {
33
34 /** @var ABJ_404_Solution_Functions */
35 private $f;
36
37 /** @var ABJ_404_Solution_Logging */
38 private $logger;
39
40 /** @var ABJ_404_Solution_PluginLogicUrlNormalization */
41 private $urlNormalization;
42
43 /** @var ABJ_404_Solution_RegexDestinationTemplateValidator */
44 private $regexDestinationValidator;
45
46 /**
47 * Parameters are intentionally untyped: the legacy DI sites that flow
48 * through PluginLogicAdminActions pass test doubles that don't extend the
49 * canonical classes (anonymous-class logger spies, custom Functions
50 * subclasses). The @param docblocks document the intent for static
51 * analysis. Matches the pattern in AdminActionsDependencies.
52 *
53 * @param ABJ_404_Solution_Functions $f
54 * @param ABJ_404_Solution_Logging $logger
55 * @param ABJ_404_Solution_PluginLogicUrlNormalization $urlNormalization
56 */
57 public function __construct(
58 $f,
59 $logger,
60 $urlNormalization,
61 ?ABJ_404_Solution_RegexDestinationTemplateValidator $regexDestinationValidator = null
62 ) {
63 $this->f = $f;
64 $this->logger = $logger;
65 $this->urlNormalization = $urlNormalization;
66 $this->regexDestinationValidator = $regexDestinationValidator !== null
67 ? $regexDestinationValidator
68 : new ABJ_404_Solution_RegexDestinationTemplateValidator($f);
69 }
70
71 /**
72 * Parse the redirect destination field from $_POST.
73 *
74 * @param array{isRegex?: bool, sourcePattern?: string} $context
75 * @return array<string, mixed> {type: string, dest: string, message: string}
76 */
77 public function getRedirectTypeAndDest(array $context = array()): array {
78
79 $response = array();
80 $response['type'] = "";
81 $response['dest'] = "";
82 $response['message'] = "";
83 $isRegex = isset($context['isRegex']) && $context['isRegex'] === true;
84 $sourcePattern = isset($context['sourcePattern']) && is_string($context['sourcePattern'])
85 ? $context['sourcePattern'] : '';
86
87 if ($isRegex && $sourcePattern !== '') {
88 $sourceValidation = $this->regexDestinationValidator->validateSourcePattern($sourcePattern);
89 if (!$sourceValidation['valid']) {
90 $response['message'] = $this->regexValidationMessage($sourceValidation);
91 return $response;
92 }
93 }
94
95 $postedCode = isset($_POST['code']) && is_scalar($_POST['code']) ? (string)$_POST['code'] : '';
96 if ($postedCode === '410' || $postedCode === '451') {
97 $response['type'] = (string)ABJ404_TYPE_HOME;
98 $response['dest'] = '';
99 return $response;
100 }
101
102 if (!isset($_POST['redirect_to_data_field_id']) || $_POST['redirect_to_data_field_id'] === '') {
103 $response['message'] = __('Error: Redirect destination is required.', '404-solution') . "<BR/>";
104 return $response;
105 }
106
107 if ($_POST['redirect_to_data_field_id'] == ABJ404_TYPE_EXTERNAL . '|' . ABJ404_TYPE_EXTERNAL) {
108 $externalDestination = $this->resolveExternalDestination($isRegex, $sourcePattern);
109 $response['type'] = ABJ404_TYPE_EXTERNAL;
110 $response['dest'] = $externalDestination['dest'];
111 $response['message'] = $externalDestination['message'];
112 return $response;
113 }
114
115 $info = explode("|", ABJ_404_Solution_RequestInputNormalizer::readText(
116 $_POST, array('name' => 'redirect_to_data_field_id')));
117 if (count($info) == 2) {
118 $response['dest'] = absint($info[0]);
119 $response['type'] = $info[1];
120 } else {
121 $infoJson = json_encode($info);
122 $this->logger->errorMessage("Unexpected info while updating redirect: " .
123 wp_kses_post(is_string($infoJson) ? $infoJson : ''));
124 }
125
126 return $response;
127 }
128
129 /**
130 * @return array{dest: string, message: string}
131 */
132 private function resolveExternalDestination(bool $isRegex, string $sourcePattern): array {
133 $rawPostedDestination = isset($_POST['redirect_to_user_field'])
134 ? ABJ_404_Solution_RequestInputNormalizer::normalizeScalar($_POST['redirect_to_user_field'])
135 : '';
136 $rawEnteredURLResult = ABJ_404_Solution_RequestInputNormalizer::getPostOrGetSanitizeUrl(
137 'redirect_to_user_field'
138 );
139 $rawEnteredURL = is_string($rawEnteredURLResult) ? $rawEnteredURLResult : null;
140 $normalizedDestination = $this->urlNormalization->normalizeExternalDestinationUrl($rawEnteredURL);
141 $isRelativeRegexDestination = $isRegex
142 && isset($rawPostedDestination[0])
143 && $rawPostedDestination[0] === '/';
144
145 if ($isRelativeRegexDestination) {
146 $validation = $this->regexDestinationValidator->validate($sourcePattern, $rawPostedDestination);
147 return array(
148 'dest' => $normalizedDestination,
149 'message' => $this->regexValidationMessage($validation),
150 );
151 }
152
153 $absoluteDestination = $this->validateAbsoluteDestination($normalizedDestination);
154 if ($absoluteDestination['message'] !== '' || !$isRegex) {
155 return $absoluteDestination;
156 }
157
158 $validation = $this->regexDestinationValidator->validateReplacement(
159 $sourcePattern,
160 $rawPostedDestination
161 );
162 $absoluteDestination['message'] = $this->regexValidationMessage($validation);
163 return $absoluteDestination;
164 }
165
166 /**
167 * @return array{dest: string, message: string}
168 */
169 private function validateAbsoluteDestination(string $destination): array {
170 $destination = esc_url($destination, array('http', 'https'));
171 if ($destination === '') {
172 return array(
173 'dest' => '',
174 'message' => __('Error: You selected external URL but did not enter a URL.', '404-solution') . "<BR/>",
175 );
176 }
177 if ($this->f->strlen($destination) < 8) {
178 return array(
179 'dest' => $destination,
180 'message' => __('Error: External URL is too short.', '404-solution') . "<BR/>",
181 );
182 }
183 if ($this->f->strpos($destination, "://") === false) {
184 return array(
185 'dest' => $destination,
186 'message' => __("Error: External URL doesn't contain ://", '404-solution') . "<BR/>",
187 );
188 }
189
190 $parsedUrl = parse_url($destination);
191 if (!is_array($parsedUrl) || !isset($parsedUrl['scheme'])
192 || !in_array(strtolower($parsedUrl['scheme']), array('http', 'https'), true)) {
193 return array(
194 'dest' => $destination,
195 'message' => __('Error: External URL must use http:// or https:// protocol only.', '404-solution') . "<BR/>",
196 );
197 }
198
199 $validatedUrl = apply_filters('abj404_validate_external_redirect', $destination);
200 if ($validatedUrl === false) {
201 return array(
202 'dest' => $destination,
203 'message' => __('Error: External redirect URL failed validation.', '404-solution') . "<BR/>",
204 );
205 }
206
207 return array('dest' => (string)$validatedUrl, 'message' => '');
208 }
209
210 /**
211 * @param array{valid: bool, message: string, detail: string} $validation
212 */
213 private function regexValidationMessage(array $validation): string {
214 if ($validation['valid']) {
215 return '';
216 }
217 if ($validation['detail'] !== '') {
218 $this->logger->warn('Regex redirect validation failed: ' . $validation['detail']);
219 }
220 return $validation['message'] . "<BR/>";
221 }
222
223 /**
224 * Sanitize, classify, and normalize a redirect source without allowing
225 * ordinary path normalization to alter regex syntax.
226 *
227 * @param int $statusTypeIn
228 * @param string $fromURL
229 * @return array{statusType: int, url: string, autoPromoted: bool, urlRewritten: bool}
230 */
231 public function resolveSource($statusTypeIn, $fromURL): array {
232 $source = $this->urlNormalization->sanitizeRedirectSource($fromURL);
233 $result = $this->maybeAutoPromoteRegex($statusTypeIn, $source);
234 $result['url'] = $this->urlNormalization->normalizeRedirectSourceForStatus(
235 $result['url'],
236 $result['statusType']
237 );
238 return $result;
239 }
240
241 /**
242 * Decide whether a manually entered URL pattern should be auto-promoted
243 * to ABJ404_STATUS_REGEX and apply a glob rewrite when so.
244 *
245 * @param int $statusTypeIn
246 * @param string $fromURL
247 * @return array{statusType: int, url: string, autoPromoted: bool, urlRewritten: bool}
248 */
249 public function maybeAutoPromoteRegex($statusTypeIn, $fromURL): array {
250 $result = array(
251 'statusType' => (int)$statusTypeIn,
252 'url' => is_string($fromURL) ? $fromURL : '',
253 'autoPromoted' => false,
254 'urlRewritten' => false,
255 );
256
257 if ((int)$statusTypeIn === ABJ404_STATUS_REGEX) {
258 return $result;
259 }
260 if (!ABJ_404_Solution_RegexAutoPromote::looksLikeUnambiguousRegex($result['url'])) {
261 return $result;
262 }
263
264 $result['statusType'] = ABJ404_STATUS_REGEX;
265 $result['autoPromoted'] = true;
266 $glob = ABJ_404_Solution_RegexAutoPromote::applyGlobFixup($result['url']);
267 $result['url'] = $glob['url'];
268 $result['urlRewritten'] = $glob['changed'];
269
270 return $result;
271 }
272
273 /**
274 * Persist the transient notice the admin sees after a regex auto-promotion
275 * (so they can undo it).
276 *
277 * @param int $redirectId
278 * @param string $originalURL
279 * @param string $newURL
280 * @param bool $urlRewritten
281 * @return void
282 */
283 public function saveRegexAutoPromoteNotice($redirectId, $originalURL, $newURL, $urlRewritten): void {
284 ABJ_404_Solution_RegexAutoPromote::saveNotice($redirectId, $originalURL, $newURL, $urlRewritten);
285 }
286 }
287