PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
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 / PluginLogicTrait_AdminActions.php

PluginLogicTrait_AdminActions.php in 404 Solution 4.1.19, at includes/PluginLogicTrait_AdminActions.php

1,029 lines 47.8 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 * Admin action handlers: trash, delete, ignore, later, edit, bulk actions, empty trash.
9 * Used by ABJ_404_Solution_PluginLogic via `use`.
10 */
11 trait ABJ_404_Solution_PluginLogicTrait_AdminActions {
12
13 /** Do the passed in action and return the associated message.
14 * @param string $action
15 * @param string $sub
16 * @return string
17 */
18 function handlePluginAction($action, &$sub) {
19 $message = "";
20 $message = array_key_exists('display-this-message', $_POST) ?
21 sanitize_text_field($_POST['display-this-message']) : '';
22
23 if ($action == "updateOptions") {
24 if (wp_verify_nonce($_POST['nonce'], 'abj404UpdateOptions') && is_admin()) {
25 // delete the debug file and lose all changes, or
26 if (array_key_exists('deleteDebugFile', $_POST) && $_POST['deleteDebugFile']) {
27 $filepath = $this->logger->getDebugFilePath();
28 if (!file_exists($filepath)) {
29 $message = sprintf(__("Debug file not found. (%s)", '404-solution'), $filepath);
30 } else if ($this->logger->deleteDebugFile()) {
31 $message = sprintf(__("Debug file(s) deleted. (%s)", '404-solution'), $filepath);
32 } else {
33 $message = sprintf(__("Issue deleting debug file. (%s)", '404-solution'), $filepath);
34 }
35 return $message;
36 }
37
38 // save all changes. saveOptions, saveSettings
39 $sub = "abj404_options";
40 } else {
41 $this->logger->debugMessage("Unexpected result. How did we get here? is_admin: " .
42 is_admin() . ", Action: " . $action . ", Sub: " . $sub);
43 }
44 } else if ($action == "addRedirect") {
45 if (check_admin_referer('abj404addRedirect') && is_admin()) {
46 $message = $this->addAdminRedirect();
47 if ($message == "") {
48 $message = __('New Redirect Added Successfully!', '404-solution');
49 } else {
50 $message .= __('Error: unable to add new redirect.', '404-solution');
51 }
52 } else {
53 $this->logger->debugMessage("Unexpected result. How did we get here? is_admin: " .
54 is_admin() . ", Action: " . $action . ", Sub: " . $sub);
55 }
56 } else if ($action == "emptyRedirectTrash") {
57 if (check_admin_referer('abj404_bulkProcess') && is_admin()) {
58 $this->doEmptyTrash('abj404_redirects');
59 $this->dao->markViewDoneInvalidatedByAdminMutation();
60 $message = __('All trashed URLs have been deleted!', '404-solution');
61 } else {
62 $this->logger->debugMessage("Unexpected result. How did we get here? is_admin: " .
63 is_admin() . ", Action: " . $action . ", Sub: " . $sub);
64 }
65 } else if ($action == "emptyCapturedTrash") {
66 if (check_admin_referer('abj404_bulkProcess') && is_admin()) {
67 $this->doEmptyTrash('abj404_captured');
68 $this->dao->markViewDoneInvalidatedByAdminMutation();
69 $message = __('All trashed URLs have been deleted!', '404-solution');
70 } else {
71 $this->logger->debugMessage("Unexpected result. How did we get here? is_admin: " .
72 is_admin() . ", Action: " . $action . ", Sub: " . $sub);
73 }
74 } else if ($action == "purgeRedirects") {
75 if (check_admin_referer('abj404_purgeRedirects') && is_admin()) {
76 $message = $this->dao->deleteSpecifiedRedirects();
77 $this->dao->markViewDoneInvalidatedByAdminMutation();
78 } else {
79 $this->logger->debugMessage("Unexpected result. How did we get here? is_admin: " .
80 is_admin() . ", Action: " . $action . ", Sub: " . $sub);
81 }
82 } else if ($action == "runMaintenance") {
83 if (check_admin_referer('abj404_runMaintenance') && is_admin()) {
84 $message = $this->dao->deleteOldRedirectsCron();
85 } else {
86 $this->logger->debugMessage("Unexpected result. How did we get here? is_admin: " .
87 is_admin() . ", Action: " . $action . ", Sub: " . $sub);
88 }
89 } else if ($action == "rebuildNgramCache") {
90 if (check_admin_referer('abj404_rebuildNgramCache') && is_admin()) {
91 // Server-side request deduplication to prevent race conditions
92 $userId = get_current_user_id();
93 $transientKey = 'abj404_ngram_rebuild_request_' . $userId;
94 $recentRequest = get_transient($transientKey);
95
96 if ($recentRequest) {
97 // Duplicate request within 10 seconds - likely from rapid button clicks
98 $message = __('N-gram cache rebuild is already scheduled or in progress. Please wait for it to complete.', '404-solution');
99 } else {
100 // Set transient to prevent duplicate requests for 10 seconds
101 set_transient($transientKey, time(), 10);
102
103 $dbUpgrades = abj_service('database_upgrades');
104
105 // Use async rebuild to avoid timeouts on large sites
106 $scheduled = $dbUpgrades->scheduleNGramCacheRebuild();
107
108 if ($scheduled) {
109 $message = __('N-gram cache rebuild has been scheduled and will run in the background. This may take several minutes on large sites. You can continue using the plugin normally.', '404-solution');
110 } else {
111 // Check if already running
112 $nextScheduled = wp_next_scheduled('abj404_rebuild_ngram_cache_hook');
113 if ($nextScheduled) {
114 $message = __('N-gram cache rebuild is already scheduled or in progress. Please wait for it to complete.', '404-solution');
115 } else {
116 $message = __('Failed to schedule N-gram cache rebuild. Please try again or check your WordPress cron configuration.', '404-solution');
117 }
118 }
119 }
120 } else {
121 $this->logger->debugMessage("Unexpected result. How did we get here? is_admin: " .
122 is_admin() . ", Action: " . $action . ", Sub: " . $sub);
123 }
124 } else if ($action == "clearSpellingCache") {
125 if (check_admin_referer('abj404_clearSpellingCache') && is_admin()) {
126 $this->dao->deleteSpellingCache();
127 $message = __('Spelling cache cleared successfully.', '404-solution');
128 } else {
129 $this->logger->debugMessage("Unexpected result. How did we get here? is_admin: " .
130 is_admin() . ", Action: " . $action . ", Sub: " . $sub);
131 }
132 } else if ($action == "saveGscSettings") {
133 if (check_admin_referer('abj404_gsc_save', '_wpnonce_gsc') && is_admin()) {
134 $logger = abj_service('logging');
135 $gsc = new ABJ_404_Solution_GoogleSearchConsole($logger);
136 $error = $gsc->saveSettings($_POST);
137 $message = ($error === '') ? __('Google Search Console credentials saved.', '404-solution') : $error;
138 } else {
139 $this->logger->debugMessage("saveGscSettings security check failed. is_admin: " . is_admin());
140 }
141 } else if ($action == "importFromPlugin") {
142 if (check_admin_referer('abj404_importFromPlugin') && is_admin()) {
143 $message = $this->handleActionImportFromPlugin();
144 $this->dao->markViewDoneInvalidatedByAdminMutation();
145 } else {
146 $this->logger->debugMessage("Unexpected result. How did we get here? is_admin: " .
147 is_admin() . ", Action: " . $action . ", Sub: " . $sub);
148 }
149 } else if ($action == "undoRegexAutoPromote") {
150 if (check_admin_referer('abj404undoRegexAutoPromote') && is_admin()) {
151 $message = $this->handleActionUndoRegexAutoPromote();
152 } else {
153 $this->logger->debugMessage("Unexpected result. How did we get here? is_admin: " .
154 is_admin() . ", Action: " . $action . ", Sub: " . $sub);
155 }
156 } else if ($this->f->substr($action . '', 0, 4) == "bulk") {
157 if (check_admin_referer('abj404_bulkProcess') && is_admin()) {
158 if (!isset($_POST['idnum'])) {
159 $this->logger->debugMessage("No ID(s) specified for bulk action: " . esc_html($action));
160 echo sprintf(__("Error: No ID(s) specified for bulk action. (%s)", '404-solution'),
161 esc_html($action));
162 return '';
163 }
164 $message = $this->doBulkAction($action, array_map('absint', $_POST['idnum']));
165 $this->dao->markViewDoneInvalidatedByAdminMutation();
166 } else {
167 $this->logger->debugMessage("Unexpected result. How did we get here? is_admin: " .
168 is_admin() . ", Action: " . $action . ", Sub: " . $sub);
169 }
170 }
171
172 return $message;
173 }
174
175 /** Move redirects to trash.
176 * @return string
177 */
178 function hanldeTrashAction() {
179
180 $message = "";
181 // Handle Trash Functionality
182 if (isset($_GET['trash'])) {
183 if (is_admin() && $this->verifyLinkNonce('abj404_trashRedirect')) {
184 $trash = "";
185 if ($_GET['trash'] == 0) {
186 $trash = 0;
187 } else if ($_GET['trash'] == 1) {
188 $trash = 1;
189 } else {
190 $this->logger->errorMessage("Unexpected trash operation: " .
191 esc_html($_GET['trash']));
192 $message = __('Error: Bad trash operation specified.', '404-solution');
193 return $message;
194 }
195
196 $id = absint($_GET['id']);
197 $message = $this->dao->moveRedirectsToTrash($id, $trash);
198 if ($message == "") {
199 // Captured URLs: restoring from the Captured->Trash view should return to Captured (not Ignored/Later).
200 $subpage = isset($_GET['subpage']) ? sanitize_text_field(wp_unslash($_GET['subpage'])) : '';
201 $filter = isset($_GET['filter']) ? intval($_GET['filter']) : 0;
202 if ($trash == 0 && $subpage === 'abj404_captured' && $filter === ABJ404_TRASH_FILTER) {
203 $this->dao->updateRedirectTypeStatus($id, (string)ABJ404_STATUS_CAPTURED);
204 }
205 $this->dao->markViewDoneInvalidatedByAdminMutation();
206 if ($trash == 1) {
207 $message = __('Redirect moved to trash successfully!', '404-solution');
208 } else {
209 $message = __('Redirect restored from trash successfully!', '404-solution');
210 }
211 } else {
212 if ($trash == 1) {
213 $message = __('Error: Unable to move redirect to trash.', '404-solution');
214 } else {
215 $message = __('Error: Unable to move redirect from trash.', '404-solution');
216 }
217 }
218
219 }
220 }
221
222 return $message;
223 }
224
225 /** @return void */
226 function handleActionChangeItemsPerRow(): void {
227
228 if ($this->dao->getPostOrGetSanitize('action') == 'changeItemsPerRow' && $this->userIsPluginAdmin()) {
229 check_admin_referer('abj404_changeItemsPerRow'); // verify nonce for CSRF protection
230 $this->updatePerPageOption(absint($this->dao->getPostOrGetSanitize('perpage')));
231 }
232 }
233
234 /** @return void */
235 function handleActionExport(): void {
236
237 if (($this->dao->getPostOrGetSanitize('action') == 'exportRedirects') && $this->userIsPluginAdmin()) {
238 check_admin_referer('abj404_exportRedirects'); // this verifies the nonce
239 $this->doExport();
240 }
241 }
242
243 /** @return string|null */
244 function handleActionImportFile() {
245
246 if (($this->dao->getPostOrGetSanitize('action') == 'importRedirectsFile') && $this->userIsPluginAdmin()) {
247 check_admin_referer('abj404_importRedirectsFile'); // this verifies the nonce (must match View.php form nonce)
248 $result = $this->doImportFile();
249 // Admin-initiated mutation: force a fresh view_done rebuild before
250 // the next AJAX fetch so the newly-imported rows appear on the
251 // redirects table immediately, not on the next cron rebuild.
252 $this->dao->markViewDoneInvalidatedByAdminMutation();
253 return $result;
254 }
255
256 return null;
257 }
258
259 /** @return void */
260 function updatePerPageOption(int $rows): void {
261 $showRows = max($rows, ABJ404_OPTION_MIN_PERPAGE);
262 $showRows = min($showRows, ABJ404_OPTION_MAX_PERPAGE);
263
264 $options = $this->getOptions();
265 $options['perpage'] = $showRows;
266 $this->updateOptions($options);
267 }
268
269 /**
270 *
271 * @global type $abj404dao
272 * @global type $abj404logging
273 * @return string
274 */
275 function handleActionImportRedirects() {
276 $message = "";
277
278
279 if ($this->dao->getPostOrGetSanitize('action') == 'importRedirects') {
280 if ($this->dao->getPostOrGetSanitize('sanity_404redirected') != '1') {
281 $message = __("Error: You didn't check the I understand checkbox. No importing for you!", '404-solution');
282 return $message;
283 }
284
285 check_admin_referer('abj404_importRedirects');
286
287 try {
288 $result = $this->dao->importDataFromPluginRedirectioner();
289 if ($result['last_error'] != '') {
290 $lastErrorJson = json_encode($result['last_error']);
291 $message = sprintf(__("Error: No records were imported. SQL result: %s", '404-solution'),
292 wp_kses_post(is_string($lastErrorJson) ? $lastErrorJson : ''));
293 } else {
294 $rowsAffected = is_scalar($result['rows_affected']) ? (string)$result['rows_affected'] : '0';
295 $message = sprintf(__("Records imported: %s", '404-solution'), esc_html($rowsAffected));
296 // Admin-initiated mutation: force a fresh view_done rebuild
297 // before the next AJAX fetch so the newly-imported rows
298 // appear on the redirects table immediately.
299 $this->dao->markViewDoneInvalidatedByAdminMutation();
300 }
301
302 } catch (Exception $e) {
303 $message = "Error: Importing failed. Message: " . $e->getMessage();
304 $this->logger->errorMessage('Error importing redirects.', $e);
305 }
306 }
307
308 return $message;
309 }
310
311 /** Delete redirects.
312 * @global type $abj404dao
313 * @return string
314 */
315 function handleDeleteAction() {
316 $message = "";
317
318 //Handle Delete Functionality
319 if (array_key_exists('remove', $_GET) && @$_GET['remove'] == 1) {
320 if (is_admin() && $this->verifyLinkNonce('abj404_removeRedirect')) {
321 if ($this->f->regexMatch('[0-9]+', $_GET['id'])) {
322 $this->dao->deleteRedirect(absint($_GET['id']));
323 $this->dao->markViewDoneInvalidatedByAdminMutation();
324 $message = __('Redirect Removed Successfully!', '404-solution');
325 }
326 }
327 }
328
329 return $message;
330 }
331
332 /**
333 * Generic handler for updating redirect status based on URL parameters.
334 * Eliminates duplication between handleIgnoreAction and handleLaterAction.
335 *
336 * @param string $paramName The $_GET parameter name ('ignore' or 'later')
337 * @param string $nonceAction The nonce action name for security verification
338 * @param int $activeStatus The status constant to use when action=1
339 * @param string $errorActionName Action name for error messages ('ignore' or 'organize later')
340 * @param string $successActionName Action name for success messages ('ignored' or 'organize later')
341 * @return string Success/error message or empty string
342 */
343 private function handleStatusUpdate($paramName, $nonceAction, $activeStatus, $errorActionName, $successActionName) {
344 $message = "";
345
346 if (isset($_GET[$paramName])) {
347 if (is_admin() && $this->verifyLinkNonce($nonceAction)) {
348 if ($_GET[$paramName] != 0 && $_GET[$paramName] != 1) {
349 $this->logger->debugMessage("Unexpected {$errorActionName} operation: " .
350 esc_html($_GET[$paramName]));
351 $message = sprintf(__('Error: Bad %s operation specified.', '404-solution'), $errorActionName);
352 return $message;
353 }
354
355 $id = $_GET['id'] ?? '';
356 if ($id !== '' && $this->f->regexMatch('[0-9]+', $id)) {
357 if ($_GET[$paramName] == 1) {
358 $newstatus = $activeStatus;
359 } else {
360 $newstatus = ABJ404_STATUS_CAPTURED;
361 }
362
363 $message = $this->dao->updateRedirectTypeStatus(absint($id), (string)$newstatus);
364 if ($message == "") {
365 $this->dao->markViewDoneInvalidatedByAdminMutation();
366 if ($newstatus == ABJ404_STATUS_CAPTURED) {
367 $message = sprintf(__('Removed 404 URL from %s list successfully!', '404-solution'), $successActionName);
368 } else {
369 $message = sprintf(__('404 URL marked as %s successfully!', '404-solution'), $successActionName);
370 }
371 } else {
372 if ($newstatus == ABJ404_STATUS_CAPTURED) {
373 $message = sprintf(__('Error: unable to remove URL from %s list', '404-solution'), $successActionName);
374 } else {
375 $message = sprintf(__('Error: unable to mark URL as %s', '404-solution'), $successActionName);
376 }
377 }
378 }
379 }
380 }
381
382 return $message;
383 }
384
385 /** Set a redirect as ignored.
386 * @return string
387 */
388 function handleIgnoreAction() {
389 return $this->handleStatusUpdate('ignore', 'abj404_ignore404', ABJ404_STATUS_IGNORED, 'ignore', 'ignored');
390 }
391
392 /** Set a redirect as "organize later".
393 * @return string
394 */
395 function handleLaterAction() {
396 return $this->handleStatusUpdate('later', 'abj404_organizeLater', ABJ404_STATUS_LATER, 'organize later', 'organize later');
397 }
398
399 /** Edit redirect data.
400 * @global type $abj404dao
401 * @param string $sub
402 * @param string $action
403 * @return string
404 */
405 function handleActionEdit(&$sub, &$action) {
406 $message = "";
407
408 //Handle edit posts
409 if (array_key_exists('action', $_POST) && $_POST['action'] == "editRedirect") {
410 $id = $this->dao->getPostOrGetSanitize('id');
411 $ids = $this->dao->getPostOrGetSanitize('ids_multiple');
412 if (!($id === '' && $ids === '') && ($this->f->regexMatch('[0-9]+', '' . $id) || $this->f->regexMatch('[0-9]+', '' . $ids))) {
413 if (is_admin() && $this->verifyLinkNonce('abj404editRedirect')) {
414 $message = $this->updateRedirectData();
415 if ($message == "") {
416 // Return user to the page they came from instead of always going to redirects page
417 $source_page = $this->dao->getPostOrGetSanitize('source_page');
418
419 // Validate source_page is a known tab
420 $valid_tabs = array('abj404_redirects', 'abj404_captured', 'abj404_logs',
421 'abj404_stats', 'abj404_tools', 'abj404_options');
422 if ($source_page === '' || !in_array($source_page, $valid_tabs)) {
423 // Default to redirects page if source_page is missing or invalid
424 $source_page = 'abj404_redirects';
425 }
426
427 // Build redirect URL with source page and preserved table options
428 $redirect_url = "?page=" . ABJ404_PP . "&subpage=" . $source_page;
429 $redirect_url .= "&updated=1"; // Add flag to show success message
430
431 // Preserve table options
432 $source_filter = $this->dao->getPostOrGetSanitize('source_filter', '');
433 if ($source_filter !== '' && $source_filter !== '0') {
434 $redirect_url .= "&filter=" . urlencode($source_filter);
435 }
436
437 $source_orderby = $this->dao->getPostOrGetSanitize('source_orderby', '');
438 $source_order = $this->dao->getPostOrGetSanitize('source_order', '');
439 if ($source_orderby !== '' && $source_order !== '') {
440 if (!($source_orderby === "url" && $source_order === "ASC")) {
441 $redirect_url .= "&orderby=" . urlencode($source_orderby);
442 $redirect_url .= "&order=" . urlencode($source_order);
443 }
444 }
445
446 $source_paged = $this->dao->getPostOrGetSanitize('source_paged', '');
447 if ($source_paged !== '' && (int)$source_paged > 1) {
448 $redirect_url .= "&paged=" . urlencode($source_paged);
449 }
450
451 // Perform redirect using Post/Redirect/Get pattern
452 wp_safe_redirect(admin_url('admin.php' . $redirect_url));
453 // Note: Intentionally not calling exit() to allow for testability
454 // WordPress will handle the redirect on next page load
455 return "";
456 } else {
457 $message .= __('Error: Unable to update redirect data.', '404-solution');
458 }
459 }
460 }
461 }
462
463 return $message;
464 }
465
466 /**
467 * @global type $abj404dao
468 * @param string $action
469 * @param array<int, int> $ids
470 * @return string
471 */
472 function doBulkAction(string $action, array $ids): string {
473 $message = "";
474
475 // nonce already verified.
476
477 $this->logger->debugMessage("In doBulkAction. Action: " .
478 esc_html($action == '' ? '(none)' : $action) . ", ids: " . wp_kses_post((string)json_encode($ids)));
479
480 if ($action == "bulkignore" || $action == "bulkcaptured" || $action == "bulklater" ||
481 $action == "bulk_trash_restore") {
482
483 $status = 0;
484 if ($action == "bulkignore") {
485 $status = ABJ404_STATUS_IGNORED;
486
487 } else if ($action == "bulkcaptured") {
488 $status = ABJ404_STATUS_CAPTURED;
489
490 } else if ($action == "bulklater") {
491 $status = ABJ404_STATUS_LATER;
492 }
493 // else: bulk_trash_restore - don't change the status.
494
495 $count = 0;
496 foreach ($ids as $id) {
497 $s = $this->dao->moveRedirectsToTrash($id, 0);
498 if ($action != "bulk_trash_restore") {
499 $s = $this->dao->updateRedirectTypeStatus($id, (string)$status);
500 }
501 if ($s == "") {
502 $count++;
503 }
504 }
505 if ($action == "bulkignore") {
506 $message = $count . " " . __('URL(s) marked as Ignored.', '404-solution');
507 } else if ($action == "bulkcaptured") {
508 $message = $count . " " . __('URL(s) marked as Captured.', '404-solution');
509 } else if ($action == "bulklater") {
510 $message = $count . " " . __('URL(s) marked as Later.', '404-solution');
511 } else {
512 // bulk_trash_restore
513 $message = $count . " " . __('URL(s) restored.', '404-solution');
514 }
515
516 } else if ($action == "bulk_trash_delete_permanently") {
517 $count = 0;
518 foreach ($ids as $id) {
519 $this->dao->deleteRedirect(absint($id));
520 $count ++;
521 }
522 $message = $count . " " . __('URL(s) deleted', '404-solution');
523
524 } else if ($action == "bulktrash") {
525 $count = 0;
526 foreach ($ids as $id) {
527 $s = $this->dao->moveRedirectsToTrash($id, 1);
528 if ($s == "") {
529 $count ++;
530 }
531 }
532 $message = $count . " " . __('URL(s) moved to trash', '404-solution');
533
534 } else {
535 $this->logger->errorMessage("Unrecognized bulk action: " . esc_html($action));
536 echo sprintf(__("Error: Unrecognized bulk action. (%s)", '404-solution'), esc_html($action));
537 }
538 return $message;
539 }
540
541 /**
542 * This is for both empty trash buttons (page redirects and captured 404 URLs).
543 * @param string $sub
544 * @return void
545 */
546 function doEmptyTrash(string $sub): void {
547 global $wpdb;
548 global $abj404_redirect_types;
549 global $abj404_captured_types;
550
551 $query = "";
552 if ($sub == "abj404_captured") {
553 $query = "delete FROM {wp_abj404_redirects} \n" .
554 "where disabled = 1 \n" .
555 " and status in (" . implode(", ", $abj404_captured_types) . ")";
556
557 } else if ($sub == "abj404_redirects") {
558 $query = "delete FROM {wp_abj404_redirects} \n" .
559 "where disabled = 1 \n" .
560 " and status in (" . implode(", ", $abj404_redirect_types) . ")";
561
562 } else {
563 $this->logger->errorMessage("Unrecognized type in doEmptyTrash(" . $sub . ")");
564 return;
565 }
566
567 $result = $this->dao->queryAndGetResults($query);
568 $this->logger->debugMessage("doEmptyTrash deleted " . $result['rows_affected'] . " rows total. (" . $sub . ")");
569
570 // Invalidate status counts cache after bulk delete
571 $this->dao->invalidateStatusCountsCache();
572
573 $this->dao->queryAndGetResults("optimize table {wp_abj404_redirects}");
574 }
575
576 /**
577 * @global type $abj404dao
578 * @return string
579 */
580 function updateRedirectData() {
581 $message = "";
582 $fromURL = "";
583 $ids_multiple = "";
584
585 if (
586 (!array_key_exists('url', $_POST) || $_POST['url'] == "") &&
587 (array_key_exists('ids_multiple', $_POST) && $_POST['ids_multiple'] != "")) {
588 $ids_multiple = array_map('absint', explode(',', $_POST['ids_multiple']));
589
590 } else if (array_key_exists('url', $_POST) && $_POST['url'] != "" &&
591 (!array_key_exists('ids_multiple', $_POST) || $_POST['ids_multiple'] == "")) {
592
593 $fromURL = stripslashes($_POST['url']);
594 } else {
595 $message .= __('Error: URL is a required field.', '404-solution') . "<BR/>";
596 }
597
598 if ($fromURL != "" && $this->f->substr($_POST['url'], 0, 1) != "/") {
599 $message .= __('Error: URL must start with /', '404-solution') . "<BR/>";
600 }
601
602 $typeAndDest = $this->getRedirectTypeAndDest();
603
604 $typeAndDestMessage = is_string($typeAndDest['message']) ? $typeAndDest['message'] : '';
605 if ($typeAndDestMessage != "") {
606 return $typeAndDestMessage;
607 }
608
609 $tdTypeRaw = is_scalar($typeAndDest['type']) ? (string)$typeAndDest['type'] : '';
610 $tdType = ($tdTypeRaw !== '') ? (int)$tdTypeRaw : -1;
611 $tdDest = is_scalar($typeAndDest['dest']) ? (string)$typeAndDest['dest'] : '';
612 $postedCodeForCheck = isset($_POST['code']) && is_scalar($_POST['code']) ? (string)$_POST['code'] : '';
613 $isCode410 = $postedCodeForCheck === '410' || $postedCodeForCheck === '451';
614 if ($tdTypeRaw !== '' && ($tdDest !== "" || $isCode410)) {
615 $statusType = ABJ404_STATUS_MANUAL;
616 if (isset($_POST['is_regex_url']) &&
617 $_POST['is_regex_url'] != '0') {
618
619 $statusType = ABJ404_STATUS_REGEX;
620 }
621
622 // Parse scheduled redirect dates from POST data
623 $startDateRaw = isset($_POST['redirect_start_date']) && is_string($_POST['redirect_start_date']) ? trim($_POST['redirect_start_date']) : '';
624 $endDateRaw = isset($_POST['redirect_end_date']) && is_string($_POST['redirect_end_date']) ? trim($_POST['redirect_end_date']) : '';
625 $startTs = ($startDateRaw !== '') ? strtotime($startDateRaw . ' 00:00:00') : null;
626 $endTs = ($endDateRaw !== '') ? strtotime($endDateRaw . ' 23:59:59') : null;
627 // Treat strtotime failures as null
628 if ($startTs === false) { $startTs = null; }
629 if ($endTs === false) { $endTs = null; }
630
631 // Sanitize and collect conditions from POST data.
632 $rawConditions = (isset($_POST['conditions']) && is_array($_POST['conditions']))
633 ? $_POST['conditions'] : [];
634 $sanitizedConditions = [];
635 $allowedConditionTypes = [
636 'login_status', 'user_role', 'referrer',
637 'user_agent', 'ip_range', 'http_header',
638 ];
639 $allowedOperators = [
640 'equals', 'not_equals', 'contains',
641 'not_contains', 'regex', 'cidr',
642 ];
643 foreach ($rawConditions as $rawCond) {
644 if (!is_array($rawCond)) {
645 continue;
646 }
647 $condType = isset($rawCond['condition_type']) && is_string($rawCond['condition_type'])
648 ? sanitize_text_field($rawCond['condition_type']) : '';
649 if (!in_array($condType, $allowedConditionTypes, true)) {
650 continue;
651 }
652 $condLogic = (isset($rawCond['logic']) && strtoupper((string)$rawCond['logic']) === 'OR') ? 'OR' : 'AND';
653 $condOperator = isset($rawCond['operator']) && is_string($rawCond['operator'])
654 ? sanitize_text_field($rawCond['operator']) : 'equals';
655 if (!in_array($condOperator, $allowedOperators, true)) {
656 $condOperator = 'equals';
657 }
658 $condValue = isset($rawCond['value']) && is_string($rawCond['value'])
659 ? sanitize_text_field(wp_unslash($rawCond['value'])) : '';
660 $condSortOrder = isset($rawCond['sort_order']) ? absint($rawCond['sort_order']) : 0;
661
662 $sanitizedConditions[] = [
663 'logic' => $condLogic,
664 'condition_type' => $condType,
665 'operator' => $condOperator,
666 'value' => $condValue,
667 'sort_order' => $condSortOrder,
668 ];
669 }
670
671 // decide whether we're updating one or multiple redirects.
672 if ($fromURL != "") {
673 $id = isset($_POST['id']) && is_scalar($_POST['id']) ? (int)$_POST['id'] : 0;
674 $code = isset($_POST['code']) && is_string($_POST['code']) ? $_POST['code'] : '';
675 // Server-side regex auto-promotion. Mirrors the JS detector
676 // at includes/ajax/redirect_to_ajax.js so a paste-and-submit
677 // with JS disabled (or any path the browser does not reach)
678 // still flips MANUAL to REGEX when the from_url contains
679 // unambiguous regex metachars. Also applies the bare-`*`
680 // to `.*` glob fixup so the stored pattern compiles.
681 $originalFromURL = $fromURL;
682 $autoPromote = $this->maybeAutoPromoteRegex($statusType, $fromURL);
683 $statusType = $autoPromote['statusType'];
684 $fromURL = $autoPromote['url'];
685 $this->dao->updateRedirect($tdType, $tdDest,
686 $fromURL, $id, $code, (string)$statusType, $startTs, $endTs);
687 if ($autoPromote['autoPromoted']) {
688 $this->saveRegexAutoPromoteNotice($id, $originalFromURL, $fromURL, $autoPromote['urlRewritten']);
689 }
690
691 // Save conditions only for single-redirect edits (bulk edit has no conditions UI).
692 if ($id > 0) {
693 $this->dao->saveRedirectConditions($id, $sanitizedConditions);
694 }
695 $this->dao->markViewDoneInvalidatedByAdminMutation();
696
697 } else if ($ids_multiple != "") {
698 // get the redirect data for each ID.
699 $redirects_multiple = $this->dao->getRedirectsByIDs($ids_multiple);
700 $code = isset($_POST['code']) && is_string($_POST['code']) ? $_POST['code'] : '';
701 foreach ($redirects_multiple as $redirect) {
702 $redirectUrl = is_string($redirect['url']) ? $redirect['url'] : '';
703 $redirectId = is_scalar($redirect['id']) ? (int)$redirect['id'] : 0;
704 $this->dao->updateRedirect($tdType, $tdDest,
705 $redirectUrl, $redirectId, $code, (string)$statusType);
706 }
707 $this->dao->markViewDoneInvalidatedByAdminMutation();
708
709 } else {
710 $this->logger->errorMessage("Issue determining which redirect(s) to update. " .
711 "fromURL: " . $fromURL . ", ids_multiple: " . $ids_multiple);
712 }
713
714 } else {
715 $message .= __('Error: Data not formatted properly.', '404-solution') . "<BR/>";
716 $this->logger->errorMessage("Update redirect data issue. Type: " . esc_html((string)$tdType) .
717 ", dest: " . esc_html($tdDest));
718 }
719
720 return $message;
721 }
722
723 /**
724 * @return array<string, mixed>
725 */
726 function getRedirectTypeAndDest(): array {
727
728 $response = array();
729 $response['type'] = "";
730 $response['dest'] = "";
731 $response['message'] = "";
732 $userEnteredURL = '';
733
734 // 410 Gone and 451 Unavailable For Legal Reasons have no destination URL — bypass destination validation.
735 $postedCode = isset($_POST['code']) && is_scalar($_POST['code']) ? (string)$_POST['code'] : '';
736 if ($postedCode === '410' || $postedCode === '451') {
737 $response['type'] = (string)ABJ404_TYPE_HOME;
738 $response['dest'] = '';
739 return $response;
740 }
741
742 if (!isset($_POST['redirect_to_data_field_id']) || $_POST['redirect_to_data_field_id'] === '') {
743 $response['message'] = __('Error: Redirect destination is required.', '404-solution') . "<BR/>";
744 return $response;
745 }
746
747 if ($_POST['redirect_to_data_field_id'] == ABJ404_TYPE_EXTERNAL . '|' . ABJ404_TYPE_EXTERNAL) {
748 $rawEnteredURLResult = $this->dao->getPostOrGetSanitizeUrl('redirect_to_user_field');
749 $rawEnteredURL = is_string($rawEnteredURLResult) ? $rawEnteredURLResult : null;
750 $userEnteredURL = $this->normalizeExternalDestinationUrl($rawEnteredURL);
751 $userEnteredURL = esc_url($userEnteredURL, array('http', 'https'));
752 if ($userEnteredURL == "") {
753 $response['message'] = __('Error: You selected external URL but did not enter a URL.', '404-solution') . "<BR/>";
754
755 } else if ($this->f->strlen($userEnteredURL) < 8) {
756 $response['message'] = __('Error: External URL is too short.', '404-solution') . "<BR/>";
757
758 } else if ($this->f->strpos($userEnteredURL, "://") === false) {
759 $response['message'] = __("Error: External URL doesn't contain ://", '404-solution') . "<BR/>";
760
761 } else {
762 // Validate that URL uses safe protocol (http/https only)
763 $parsed_url = parse_url($userEnteredURL);
764 if (!is_array($parsed_url) || !isset($parsed_url['scheme']) || !in_array(strtolower($parsed_url['scheme']), array('http', 'https'))) {
765 $response['message'] = __('Error: External URL must use http:// or https:// protocol only.', '404-solution') . "<BR/>";
766 }
767
768 // Allow filtering of external redirect URLs for additional validation
769 // Usage: add_filter('abj404_validate_external_redirect', function($url) { /* validation */ return $url; });
770 $validated_url = apply_filters('abj404_validate_external_redirect', $userEnteredURL);
771 if ($validated_url === false) {
772 $response['message'] = __('Error: External redirect URL failed validation.', '404-solution') . "<BR/>";
773 } else {
774 $userEnteredURL = $validated_url;
775 }
776 }
777 }
778
779 if ($response['message'] != "") {
780 return $response;
781 }
782 $info = explode("|", sanitize_text_field($_POST['redirect_to_data_field_id']));
783
784 if ($_POST['redirect_to_data_field_id'] == ABJ404_TYPE_EXTERNAL . '|' . ABJ404_TYPE_EXTERNAL) {
785 $response['type'] = ABJ404_TYPE_EXTERNAL;
786 // Use the sanitized $userEnteredURL instead of raw POST
787 $response['dest'] = $userEnteredURL;
788 } else {
789 if (count($info) == 2) {
790 $response['dest'] = absint($info[0]);
791 $response['type'] = $info[1];
792 } else {
793 $infoJson = json_encode($info);
794 $this->logger->errorMessage("Unexpected info while updating redirect: " .
795 wp_kses_post(is_string($infoJson) ? $infoJson : ''));
796 }
797 }
798
799 return $response;
800 }
801
802 /**
803 * @global type $abj404dao
804 * @return string
805 */
806 function addAdminRedirect() {
807 $message = "";
808
809 if (!isset($_POST['manual_redirect_url']) || $_POST['manual_redirect_url'] == "") {
810 $message .= __('Error: URL is a required field.', '404-solution') . "<BR/>";
811 return $message;
812 }
813
814 $manualURL = isset($_POST['manual_redirect_url']) ? wp_unslash($_POST['manual_redirect_url']) : '';
815 $manualURL = $this->normalizeUserProvidedPath($manualURL);
816 if ($this->f->substr($manualURL, 0, 1) != "/") {
817 $message .= __('Error: URL must start with /', '404-solution') . "<BR/>";
818 return $message;
819 }
820
821 $typeAndDest = $this->getRedirectTypeAndDest();
822
823 $tdMsg = is_string($typeAndDest['message']) ? $typeAndDest['message'] : '';
824 if ($tdMsg != "") {
825 return $tdMsg;
826 }
827
828 $tdType2 = is_scalar($typeAndDest['type']) ? (string)$typeAndDest['type'] : '';
829 $tdDest2 = is_scalar($typeAndDest['dest']) ? (string)$typeAndDest['dest'] : '';
830 $postedCodeForCheck2 = isset($_POST['code']) && is_scalar($_POST['code']) ? (string)$_POST['code'] : '';
831 $code410 = $postedCodeForCheck2 === '410' || $postedCodeForCheck2 === '451';
832 if ($tdType2 != "" && ($tdDest2 !== "" || $code410)) {
833 // url match type. regex or normal exact match.
834 $statusType = ABJ404_STATUS_MANUAL;
835 if (isset($_POST['is_regex_url']) &&
836 $_POST['is_regex_url'] != '0') {
837
838 $statusType = ABJ404_STATUS_REGEX;
839 }
840
841 // Note: use !== '' instead of !empty() because empty('0') is true in PHP,
842 // which would incorrectly discard code=0 (Meta Refresh).
843 $code = isset($_POST['code']) && is_scalar($_POST['code']) && (string)$_POST['code'] !== '' ? (string)$_POST['code'] : '301';
844
845 // Server-side regex auto-promotion. Same rationale as in
846 // updateRedirectData(): cover paths the JS detector cannot reach.
847 $originalManualURL = $manualURL;
848 $autoPromoteAdd = $this->maybeAutoPromoteRegex($statusType, $manualURL);
849 $statusType = $autoPromoteAdd['statusType'];
850 $manualURL = $autoPromoteAdd['url'];
851
852 $newRedirectId = $this->dao->setupRedirect($manualURL, (string)$statusType,
853 $tdType2, $tdDest2,
854 sanitize_text_field($code), 0);
855 if ($autoPromoteAdd['autoPromoted']) {
856 $this->saveRegexAutoPromoteNotice((int)$newRedirectId, $originalManualURL, $manualURL, $autoPromoteAdd['urlRewritten']);
857 }
858 // Admin-initiated mutation: bump the watermark so the build
859 // runner notices the new source data at the next stage
860 // boundary, and record the post-increment value against the
861 // admin-visibility gate so the next AJAX fetch waits for a
862 // build whose built_watermark covers it (the stale-serving
863 // contract from fbc270d8 is preserved for cron/maintenance
864 // paths but the admin sees their own change immediately).
865 $this->dao->markViewDoneInvalidatedByAdminMutation();
866
867 } else {
868 $message .= __('Error: Data not formatted properly.', '404-solution') . "<BR/>";
869 $this->logger->errorMessage("Add redirect data issue. Type: " . esc_html($tdType2) . ", dest: " .
870 esc_html($tdDest2));
871 }
872
873 return $message;
874 }
875
876 /**
877 * Server-side regex auto-promotion. When the admin posts a from_url
878 * that contains unambiguous regex metachars but does not check
879 * "Treat as regex", flip the status to REGEX automatically and apply
880 * the glob-to-regex fixup (bare `*` becomes `.*`) so the stored
881 * pattern compiles at runtime.
882 *
883 * This is the server-side counterpart to the JS auto-check in
884 * includes/ajax/redirect_to_ajax.js: same intent, different reach.
885 * The JS handler covers anyone who types into the admin form with
886 * JS enabled. This handler covers the paste-and-submit, JS-disabled,
887 * and programmatic-POST paths the JS detector never sees.
888 *
889 * When the admin explicitly checked the "Treat as regex" box, we
890 * leave the pattern untouched (no glob fixup): they meant exactly
891 * what they wrote, even if it is weird like `(foo)*`.
892 *
893 * @param int $statusTypeIn The status already decided from the POST
894 * checkbox (ABJ404_STATUS_MANUAL or ABJ404_STATUS_REGEX).
895 * @param string $fromURL The raw from_url posted by the admin.
896 * @return array{statusType: int, url: string, autoPromoted: bool, urlRewritten: bool}
897 */
898 private function maybeAutoPromoteRegex($statusTypeIn, $fromURL) {
899 $result = array(
900 'statusType' => (int)$statusTypeIn,
901 'url' => is_string($fromURL) ? $fromURL : '',
902 'autoPromoted' => false,
903 'urlRewritten' => false,
904 );
905
906 if ((int)$statusTypeIn === ABJ404_STATUS_REGEX) {
907 return $result;
908 }
909 if (!ABJ_404_Solution_RegexAutoPromote::looksLikeUnambiguousRegex($result['url'])) {
910 return $result;
911 }
912
913 $result['statusType'] = ABJ404_STATUS_REGEX;
914 $result['autoPromoted'] = true;
915 $glob = ABJ_404_Solution_RegexAutoPromote::applyGlobFixup($result['url']);
916 $result['url'] = $glob['url'];
917 $result['urlRewritten'] = $glob['changed'];
918
919 return $result;
920 }
921
922 /**
923 * Persist a regex auto-promote event for the current user so the
924 * next admin page render can show a notice with [Edit] and [Undo]
925 * links. Thin wrapper around the static helper; kept here so the
926 * call site in updateRedirectData()/addAdminRedirect() reads at the
927 * level of the surrounding code.
928 *
929 * @param int $redirectId The id of the row that was just saved.
930 * @param string $originalURL The from_url the admin posted (pre-rewrite).
931 * @param string $newURL The from_url that was actually stored.
932 * @param bool $urlRewritten True when the glob fixup mutated the URL.
933 * @return void
934 */
935 private function saveRegexAutoPromoteNotice($redirectId, $originalURL, $newURL, $urlRewritten) {
936 ABJ_404_Solution_RegexAutoPromote::saveNotice($redirectId, $originalURL, $newURL, $urlRewritten);
937 }
938
939 /**
940 * Handle the "Undo regex auto-promotion" admin action. Restores the
941 * row's status to MANUAL and its from_url to the original value the
942 * admin posted (before the glob-fixup rewrite).
943 *
944 * Nonce: abj404undoRegexAutoPromote. The nonce ensures the link in
945 * the auto-promote notice is the only way to invoke this action.
946 *
947 * @return string Human-readable result message.
948 */
949 function handleActionUndoRegexAutoPromote() {
950 $notice = ABJ_404_Solution_RegexAutoPromote::readNotice();
951 if ($notice === null || $notice['redirect_id'] <= 0) {
952 return __('Error: No regex auto-promotion to undo.', '404-solution');
953 }
954 $redirectsTable = $this->dao->doTableNameReplacements('{wp_abj404_redirects}');
955 $sql = "UPDATE `" . $redirectsTable . "` SET `url` = %s, `status` = %d WHERE `id` = %d";
956 $this->dao->queryAndGetResults($sql, array('query_params' => array(
957 $notice['original_url'],
958 (int)ABJ404_STATUS_MANUAL,
959 (int)$notice['redirect_id'],
960 )));
961 // The raw UPDATE above bypasses the setupRedirect/updateRedirect
962 // chain that normally bumps the mutation watermark via
963 // invalidateStatusCountsCache. Bump explicitly so markView below
964 // observes a fresh watermark and the next admin read sees the
965 // reverted URL/status (without this, observed == built_watermark
966 // and the gate never fires; view_done keeps serving the
967 // pre-undo /oops/.* row).
968 $this->dao->bumpMutationWatermark();
969 $this->dao->markViewDoneInvalidatedByAdminMutation();
970 ABJ_404_Solution_RegexAutoPromote::clearNotice();
971 return sprintf(
972 /* translators: %s = the original from_url string that was restored */
973 __('Regex auto-promotion undone. Restored "%s" with status Manual.', '404-solution'),
974 $notice['original_url']
975 );
976 }
977
978 /**
979 * Handle the importFromPlugin POST action.
980 * Reads the selected source plugin from $_POST['import_source'] and delegates
981 * to CrossPluginImporter::importFrom().
982 *
983 * @return string Human-readable result message.
984 */
985 private function handleActionImportFromPlugin(): string {
986 $source = isset($_POST['import_source']) && is_string($_POST['import_source'])
987 ? sanitize_text_field($_POST['import_source'])
988 : '';
989
990 if ($source === '') {
991 return __('Error: No source plugin specified.', '404-solution');
992 }
993
994 $allowedSources = array('rankmath', 'yoast', 'aioseo', 'safe-redirect-manager', 'redirection');
995 if (!in_array($source, $allowedSources, true)) {
996 return sprintf(
997 /* translators: %s = unknown source identifier */
998 __('Error: Unknown source plugin "%s".', '404-solution'),
999 esc_html($source)
1000 );
1001 }
1002
1003 $importer = new ABJ_404_Solution_CrossPluginImporter($this->dao, $this->logger);
1004 $count = $importer->importFrom($source);
1005
1006 // Admin-initiated mutation: force a fresh view_done rebuild before the
1007 // next AJAX fetch so the newly-imported rows appear on the redirects
1008 // table immediately, not on the next cron rebuild. Mirrors the CSV
1009 // import path above; without this the rows land in wp_abj404_redirects
1010 // but the admin-visibility gate stays open and the cached view_done
1011 // snapshot keeps serving pre-import rows.
1012 if ($count > 0) {
1013 $this->dao->markViewDoneInvalidatedByAdminMutation();
1014 }
1015
1016 return sprintf(
1017 /* translators: %d = number of redirects imported */
1018 _n(
1019 '%d redirect imported successfully.',
1020 '%d redirects imported successfully.',
1021 $count,
1022 '404-solution'
1023 ),
1024 $count
1025 );
1026 }
1027
1028 }
1029