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

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

369 lines 15.3 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 public function __construct(
32 ABJ_404_Solution_PluginLogicAdminActions $parent,
33 ABJ_404_Solution_RedirectFormResolver $resolver
34 ) {
35 $this->parent = $parent;
36 $this->resolver = $resolver;
37 }
38
39 /**
40 * Process the editRedirect POST. Returns a human-readable message and
41 * rewrites $sub/$action by reference on success (defense-in-depth route
42 * when wp_safe_redirect() can no longer fire).
43 *
44 * @param string $sub admin subpage tab key (by ref)
45 * @param string $action admin action verb (by ref)
46 * @return string
47 */
48 public function handle(&$sub, &$action): string {
49 $message = "";
50
51 if (!array_key_exists('action', $_POST) || $_POST['action'] != "editRedirect") {
52 return $message;
53 }
54
55 $f = $this->parent->getFunctions();
56 $id = $f->getPostOrGetSanitize('id');
57 $ids = $f->getPostOrGetSanitize('ids_multiple');
58 if ($id === '' && $ids === '') {
59 return $message;
60 }
61 if (!$f->regexMatch('[0-9]+', '' . $id) && !$f->regexMatch('[0-9]+', '' . $ids)) {
62 return $message;
63 }
64 if (!is_admin() || !$this->parent->verifyLinkNonce('abj404editRedirect')) {
65 return $message;
66 }
67
68 $message = $this->updateRedirectData();
69 if ($message != "") {
70 return $message . __('Error: Unable to update redirect data.', '404-solution');
71 }
72
73 $redirect = $this->buildPostEditRedirect();
74
75 if (!headers_sent()) {
76 wp_safe_redirect(admin_url($this->getMenuParentScript() . $redirect['redirect_url']));
77 }
78
79 $sub = $redirect['source_page'];
80 $action = '';
81 return __('Redirect Information Updated Successfully!', '404-solution');
82 }
83
84 /**
85 * Parse the Edit Redirect POST form, validate, and write through to
86 * redirectsRepo. Returns the error message (or '' on success).
87 *
88 * Public so the legacy PluginLogicAdminActions::updateRedirectData()
89 * shim and existing tests can call it directly.
90 *
91 * @return string
92 */
93 public function updateRedirectData(): string {
94 $target = $this->resolveUpdateTarget();
95 $message = $target['message'];
96 $logger = $this->parent->getLogger();
97
98 $typeAndDest = $this->resolver->getRedirectTypeAndDest();
99 $typeAndDestMessage = is_string($typeAndDest['message']) ? $typeAndDest['message'] : '';
100 if ($typeAndDestMessage != "") {
101 return $typeAndDestMessage;
102 }
103
104 $context = $this->buildUpdateContext($typeAndDest);
105 if (!$this->contextHasDestination($context)) {
106 $message .= __('Error: Data not formatted properly.', '404-solution') . "<BR/>";
107 $logger->errorMessage("Update redirect data issue. Type: " . esc_html((string)$context['tdType']) .
108 ", dest: " . esc_html($context['tdDest']));
109 return $message;
110 }
111
112 if ($target['fromURL'] != "") {
113 return $message . $this->updateSingleRedirect($target['fromURL'], $context);
114 }
115
116 if (!empty($target['ids_multiple'])) {
117 return $message . $this->updateMultipleRedirects($target['ids_multiple'], $context);
118 }
119
120 $logger->errorMessage("Issue determining which redirect(s) to update. " .
121 "fromURL: " . $target['fromURL'] . ", ids_multiple: " . implode(',', $target['ids_multiple']));
122 return $message;
123 }
124
125 /**
126 * @return array{fromURL: string, ids_multiple: array<int, int>, message: string}
127 */
128 private function resolveUpdateTarget(): array {
129 $message = "";
130 $fromURL = "";
131 $idsMultiple = array();
132 $f = $this->parent->getFunctions();
133
134 if (
135 (!array_key_exists('url', $_POST) || $_POST['url'] == "") &&
136 (array_key_exists('ids_multiple', $_POST) && $_POST['ids_multiple'] != "")) {
137 $idsMultiple = array_map('absint', explode(',', (string)$_POST['ids_multiple']));
138
139 } else if (array_key_exists('url', $_POST) && $_POST['url'] != "" &&
140 (!array_key_exists('ids_multiple', $_POST) || $_POST['ids_multiple'] == "")) {
141
142 $fromURL = stripslashes((string)$_POST['url']);
143 } else {
144 $message .= __('Error: URL is a required field.', '404-solution') . "<BR/>";
145 }
146
147 if ($fromURL != "" && $f->substr(isset($_POST['url']) && is_string($_POST['url']) ? $_POST['url'] : '', 0, 1) != "/") {
148 $message .= __('Error: URL must start with /', '404-solution') . "<BR/>";
149 }
150
151 return array('fromURL' => $fromURL, 'ids_multiple' => $idsMultiple, 'message' => $message);
152 }
153
154 /**
155 * @param array<string, mixed> $typeAndDest
156 * @return array{tdTypeRaw: string, tdType: int, tdDest: string, code: string, statusType: int, startTs: int|null, endTs: int|null}
157 */
158 private function buildUpdateContext(array $typeAndDest): array {
159 $tdTypeRaw = is_scalar($typeAndDest['type']) ? (string)$typeAndDest['type'] : '';
160 $tdType = ($tdTypeRaw !== '') ? (int)$tdTypeRaw : -1;
161 $tdDest = is_scalar($typeAndDest['dest']) ? (string)$typeAndDest['dest'] : '';
162 $code = isset($_POST['code']) && is_string($_POST['code']) ? $_POST['code'] : '';
163 $statusType = ABJ404_STATUS_MANUAL;
164 if (isset($_POST['is_regex_url']) && $_POST['is_regex_url'] != '0') {
165 $statusType = ABJ404_STATUS_REGEX;
166 }
167
168 $startDateRaw = isset($_POST['redirect_start_date']) && is_string($_POST['redirect_start_date']) ? trim($_POST['redirect_start_date']) : '';
169 $endDateRaw = isset($_POST['redirect_end_date']) && is_string($_POST['redirect_end_date']) ? trim($_POST['redirect_end_date']) : '';
170 $startTs = ($startDateRaw !== '') ? strtotime($startDateRaw . ' 00:00:00') : null;
171 $endTs = ($endDateRaw !== '') ? strtotime($endDateRaw . ' 23:59:59') : null;
172 if ($startTs === false) { $startTs = null; }
173 if ($endTs === false) { $endTs = null; }
174
175 return array(
176 'tdTypeRaw' => $tdTypeRaw,
177 'tdType' => $tdType,
178 'tdDest' => $tdDest,
179 'code' => $code,
180 'statusType' => $statusType,
181 'startTs' => $startTs,
182 'endTs' => $endTs,
183 );
184 }
185
186 /**
187 * @param array{tdTypeRaw: string, tdType: int, tdDest: string, code: string, statusType: int, startTs: int|null, endTs: int|null} $context
188 */
189 private function contextHasDestination(array $context): bool {
190 $isGoneCode = $context['code'] === '410' || $context['code'] === '451';
191 return $context['tdTypeRaw'] !== '' && ($context['tdDest'] !== "" || $isGoneCode);
192 }
193
194 /**
195 * @param array{tdTypeRaw: string, tdType: int, tdDest: string, code: string, statusType: int, startTs: int|null, endTs: int|null} $context
196 */
197 private function updateSingleRedirect(string $fromURL, array $context): string {
198 $redirectsRepo = $this->parent->getRedirectsRepo();
199 $id = isset($_POST['id']) && is_scalar($_POST['id']) ? (int)$_POST['id'] : 0;
200 $originalFromURL = $fromURL;
201 $autoPromote = $this->resolver->maybeAutoPromoteRegex($context['statusType'], $fromURL);
202 $statusType = $autoPromote['statusType'];
203 $fromURL = $autoPromote['url'];
204 $updateError = $redirectsRepo->updateRedirect(ABJ_404_Solution_RedirectUpdate::fromArray(array(
205 'id' => $id,
206 'type' => $context['tdType'],
207 'fromUrl' => (string)$fromURL,
208 'destination' => $context['tdDest'],
209 'code' => $context['code'],
210 'statusType' => (string)$statusType,
211 'startTs' => $context['startTs'],
212 'endTs' => $context['endTs'],
213 )));
214 $errorCode = is_scalar($updateError) ? (string)$updateError : '';
215 if ($errorCode !== '') {
216 return $this->formatUpdateRedirectError($errorCode) . "<BR/>";
217 }
218 if ($autoPromote['autoPromoted']) {
219 $this->resolver->saveRegexAutoPromoteNotice($id, $originalFromURL, $fromURL, $autoPromote['urlRewritten']);
220 }
221
222 if ($id > 0) {
223 $redirectsRepo->saveRedirectConditions($id, $this->sanitizeRedirectConditions());
224 }
225 return '';
226 }
227
228 /**
229 * @param array<int, int> $idsMultiple
230 * @param array{tdTypeRaw: string, tdType: int, tdDest: string, code: string, statusType: int, startTs: int|null, endTs: int|null} $context
231 */
232 private function updateMultipleRedirects(array $idsMultiple, array $context): string {
233 $message = "";
234 $redirectsRepo = $this->parent->getRedirectsRepo();
235 $redirectsMultiple = $redirectsRepo->getRedirectsByIDs($idsMultiple);
236 foreach ($redirectsMultiple as $redirect) {
237 $redirectUrl = is_string($redirect['url']) ? $redirect['url'] : '';
238 $redirectId = is_scalar($redirect['id']) ? (int)$redirect['id'] : 0;
239 $updateError = $redirectsRepo->updateRedirect(ABJ_404_Solution_RedirectUpdate::fromArray(array(
240 'id' => $redirectId,
241 'type' => $context['tdType'],
242 'fromUrl' => (string)$redirectUrl,
243 'destination' => $context['tdDest'],
244 'code' => $context['code'],
245 'statusType' => (string)$context['statusType'],
246 )));
247 $errorCode = is_scalar($updateError) ? (string)$updateError : '';
248 if ($errorCode !== '') {
249 $message .= $this->formatUpdateRedirectError($errorCode) . "<BR/>";
250 continue;
251 }
252 }
253 return $message;
254 }
255
256 private function formatUpdateRedirectError(string $errorCode): string {
257 if ($errorCode === 'bad_update_request') {
258 return __('Error: Bad data passed for update redirect request.', '404-solution');
259 }
260
261 return sprintf(
262 __('Error: Unable to update redirect data. Repository result: %s', '404-solution'),
263 esc_html($errorCode)
264 );
265 }
266
267 /**
268 * Build the post-edit PRG redirect querystring + the source page that
269 * the in-request render should target.
270 *
271 * @return array{source_page: string, redirect_url: string}
272 */
273 private function buildPostEditRedirect(): array {
274 $f = $this->parent->getFunctions();
275 $valid_tabs = array('abj404_redirects', 'abj404_captured', 'abj404_logs',
276 'abj404_stats', 'abj404_tools', 'abj404_options');
277 $source_page = $f->getPostOrGetSanitize('source_page');
278 if ($source_page === '' || !in_array($source_page, $valid_tabs)) {
279 $source_page = 'abj404_redirects';
280 }
281
282 $redirect_url = "?page=" . ABJ404_PP . "&subpage=" . $source_page . "&updated=1";
283
284 $source_filter = $f->getPostOrGetSanitize('source_filter', '');
285 if ($source_filter !== '' && $source_filter !== '0') {
286 $redirect_url .= "&filter=" . urlencode($source_filter);
287 }
288
289 $source_orderby = $f->getPostOrGetSanitize('source_orderby', '');
290 $source_order = $f->getPostOrGetSanitize('source_order', '');
291 if ($source_orderby !== '' && $source_order !== ''
292 && !($source_orderby === "url" && $source_order === "ASC")) {
293 $redirect_url .= "&orderby=" . urlencode($source_orderby);
294 $redirect_url .= "&order=" . urlencode($source_order);
295 }
296
297 $source_paged = $f->getPostOrGetSanitize('source_paged', '');
298 if ($source_paged !== '' && (int)$source_paged > 1) {
299 $redirect_url .= "&paged=" . urlencode($source_paged);
300 }
301
302 return array('source_page' => $source_page, 'redirect_url' => $redirect_url);
303 }
304
305 /**
306 * Resolve the admin parent script the plugin's menu page is registered
307 * under. Used to build correct admin_url() after a successful edit.
308 *
309 * @return string
310 */
311 private function getMenuParentScript(): string {
312 $options = abj_service('options_repository')->getOptions(true);
313 $menuLocation = 'underSettings';
314 if (is_array($options) && isset($options['menuLocation']) && is_string($options['menuLocation'])) {
315 $menuLocation = $options['menuLocation'];
316 }
317 return $menuLocation === 'settingsLevel' ? 'admin.php' : 'options-general.php';
318 }
319
320 /**
321 * Sanitize the conditions[] POST payload into the shape redirectsRepo
322 * accepts. Whitelists condition types and operators; coerces logic to
323 * AND/OR.
324 *
325 * @return array<int, array<string, mixed>>
326 */
327 private function sanitizeRedirectConditions(): array {
328 $rawConditions = (isset($_POST['conditions']) && is_array($_POST['conditions']))
329 ? $_POST['conditions'] : [];
330 $sanitizedConditions = [];
331 $allowedConditionTypes = [
332 'login_status', 'user_role', 'referrer',
333 'user_agent', 'ip_range', 'http_header',
334 ];
335 $allowedOperators = [
336 'equals', 'not_equals', 'contains',
337 'not_contains', 'regex', 'cidr',
338 ];
339 foreach ($rawConditions as $rawCond) {
340 if (!is_array($rawCond)) {
341 continue;
342 }
343 $condType = isset($rawCond['condition_type']) && is_string($rawCond['condition_type'])
344 ? sanitize_text_field($rawCond['condition_type']) : '';
345 if (!in_array($condType, $allowedConditionTypes, true)) {
346 continue;
347 }
348 $condLogic = (isset($rawCond['logic']) && strtoupper((string)$rawCond['logic']) === 'OR') ? 'OR' : 'AND';
349 $condOperator = isset($rawCond['operator']) && is_string($rawCond['operator'])
350 ? sanitize_text_field($rawCond['operator']) : 'equals';
351 if (!in_array($condOperator, $allowedOperators, true)) {
352 $condOperator = 'equals';
353 }
354 $condValue = isset($rawCond['value']) && is_string($rawCond['value'])
355 ? sanitize_text_field(wp_unslash($rawCond['value'])) : '';
356 $condSortOrder = isset($rawCond['sort_order']) ? absint($rawCond['sort_order']) : 0;
357
358 $sanitizedConditions[] = [
359 'logic' => $condLogic,
360 'condition_type' => $condType,
361 'operator' => $condOperator,
362 'value' => $condValue,
363 'sort_order' => $condSortOrder,
364 ];
365 }
366 return $sanitizedConditions;
367 }
368 }
369