updateRedirect()/saveRedirectConditions() via the
* shared RedirectFormResolver. On success attempts a PRG redirect to the
* caller's source page (so the post-update view does not re-render the edit
* form), and rewrites $sub/$action by reference as a defense-in-depth
* in-request route when headers are already sent.
*
* Extracted from PluginLogicAdminActions::handleActionEdit() +
* updateRedirectData() (148 lines) (M201, design-audit-2026-06-02). Called
* from View.php's admin-page render and from PluginLogicAdminActions's
* thin compat shims (used by tests).
*/
class ABJ_404_Solution_EditRedirectHandler {
/** @var ABJ_404_Solution_PluginLogicAdminActions */
private $parent;
/** @var ABJ_404_Solution_RedirectFormResolver */
private $resolver;
/**
* The cardinality cap on the conditions[] payload is enforced by
* SubmittedRedirectConditions; this is the handler's public name for the
* same value (EditRedirectConditionsCapTest reads it here) so the two can
* never disagree.
*/
const MAX_REDIRECT_CONDITIONS = ABJ_404_Solution_SubmittedRedirectConditions::MAX_CONDITIONS;
public function __construct(
ABJ_404_Solution_PluginLogicAdminActions $parent,
ABJ_404_Solution_RedirectFormResolver $resolver
) {
$this->parent = $parent;
$this->resolver = $resolver;
}
/**
* Process the editRedirect POST. Returns a human-readable message and
* rewrites $sub/$action by reference on success (defense-in-depth route
* when wp_safe_redirect() can no longer fire).
*
* @param string $sub admin subpage tab key (by ref)
* @param string $action admin action verb (by ref)
* @return string
*/
public function handle(&$sub, &$action): string {
if (!array_key_exists('action', $_POST) || $_POST['action'] != "editRedirect") {
return '';
}
if (!is_admin() || !$this->parent->verifyLinkNonce('abj404editRedirect')) {
return '';
}
$message = $this->updateRedirectData();
if ($message != "") {
return $message . __('Error: Unable to update redirect data.', '404-solution');
}
$redirect = $this->buildPostEditRedirect();
if (!headers_sent()) {
$destination = admin_url($this->getMenuParentScript() . $redirect['redirect_url']);
if (!is_callable(array('ABJ_404_Solution_RedirectLoopGuard', 'permitsDestination'))
|| ABJ_404_Solution_RedirectLoopGuard::permitsDestination($destination)) {
wp_safe_redirect($destination);
}
}
$sub = $redirect['source_page'];
$action = '';
return __('Redirect Information Updated Successfully!', '404-solution');
}
/**
* Parse the Edit Redirect POST form, validate, and write through to
* redirectsRepo. Returns the error message (or '' on success).
*
* Public so the legacy PluginLogicAdminActions::updateRedirectData()
* shim and existing tests can call it directly.
*
* @return string
*/
public function updateRedirectData(): string {
try {
if (array_key_exists('query_mode', $_POST)) {
ABJ_404_Solution_QueryStringHelper::queryMode(wp_unslash($_POST['query_mode']));
}
} catch (InvalidArgumentException $e) {
return esc_html($e->getMessage());
}
$target = $this->resolveUpdateTarget();
$message = $target['message'];
$logger = $this->parent->getLogger();
if ($message !== '') {
return $message;
}
$statusTypeForValidation = ABJ404_STATUS_MANUAL;
if (isset($_POST['is_regex_url']) && $_POST['is_regex_url'] != '0') {
$statusTypeForValidation = ABJ404_STATUS_REGEX;
}
$sourceResolution = array(
'statusType' => $statusTypeForValidation,
'url' => $target['fromURL'],
'autoPromoted' => false,
'urlRewritten' => false,
);
$originalFromURL = $target['fromURL'];
if ($target['fromURL'] !== '') {
$sourceResolution = $this->resolver->resolveSource(
$statusTypeForValidation,
$target['fromURL']
);
$statusTypeForValidation = $sourceResolution['statusType'];
$target['fromURL'] = $sourceResolution['url'];
}
$typeAndDest = $this->resolver->getRedirectTypeAndDest(array(
'isRegex' => $statusTypeForValidation === ABJ404_STATUS_REGEX,
'sourcePattern' => $target['fromURL'],
));
$typeAndDestMessage = is_string($typeAndDest['message']) ? $typeAndDest['message'] : '';
if ($typeAndDestMessage != "") {
return $typeAndDestMessage;
}
$context = $this->buildUpdateContext($typeAndDest, $statusTypeForValidation);
if (!$this->contextHasDestination($context)) {
$message .= __('Error: Data not formatted properly.', '404-solution') . "
";
$logger->errorMessage("Update redirect data issue. Type: " . esc_html((string)$context['tdType']) .
", dest: " . esc_html($context['tdDest']));
return $message;
}
if ($target['fromURL'] != "") {
return $message . $this->updateSingleRedirect(
$target['fromURL'],
$context,
$sourceResolution,
$originalFromURL
);
}
if (!empty($target['ids_multiple'])) {
return $message . $this->updateMultipleRedirects($target['ids_multiple'], $context);
}
$logger->errorMessage("Issue determining which redirect(s) to update. " .
"fromURL: " . $target['fromURL'] . ", ids_multiple: " . implode(',', $target['ids_multiple']));
return $message;
}
/**
* @return array{fromURL: string, ids_multiple: array, message: string}
*/
private function resolveUpdateTarget(): array {
$message = "";
$fromURL = "";
$idsMultiple = array();
if (
(!array_key_exists('url', $_POST) || $_POST['url'] == "") &&
(array_key_exists('ids_multiple', $_POST) && $_POST['ids_multiple'] != "")) {
$rawIds = $_POST['ids_multiple'];
if (is_string($rawIds)) {
$requestedCount = substr_count($rawIds, ',') + 1;
if ($requestedCount > ABJ_404_Solution_RedirectEditRequest::MAX_SELECTED_IDS) {
$message .= sprintf(
/* translators: 1: number of redirects selected. 2: maximum allowed. */
__('You selected %1$s redirects. At most %2$s can be edited at once, so nothing was changed. Please select fewer and try again.', '404-solution'),
number_format_i18n($requestedCount),
number_format_i18n(ABJ_404_Solution_RedirectEditRequest::MAX_SELECTED_IDS)
) . "
";
} else {
$idsMultiple = array_values(array_filter(array_map(
static fn(string $value): int => ABJ_404_Solution_ExactInteger::readOr($value, 1, 0),
explode(',', $rawIds)
), static fn(int $value): bool => $value > 0));
}
}
if (empty($idsMultiple) && $message === '') {
$message .= __('Error: Data not formatted properly.', '404-solution') . "
";
}
} else if (array_key_exists('url', $_POST) && $_POST['url'] != "" &&
(!array_key_exists('ids_multiple', $_POST) || $_POST['ids_multiple'] == "")) {
$fromURL = stripslashes((string)$_POST['url']);
} else {
$message .= __('Error: URL is a required field.', '404-solution') . "
";
}
return array('fromURL' => $fromURL, 'ids_multiple' => $idsMultiple, 'message' => $message);
}
/**
* @param array $typeAndDest
* @return array{tdTypeRaw: string, tdType: int, tdDest: string, code: string, statusType: int, startTs: int|null, endTs: int|null}
*/
private function buildUpdateContext(array $typeAndDest, int $statusType): array {
$tdTypeRaw = is_scalar($typeAndDest['type']) ? (string)$typeAndDest['type'] : '';
$tdType = ($tdTypeRaw !== '') ? (int)$tdTypeRaw : -1;
$tdDest = is_scalar($typeAndDest['dest']) ? (string)$typeAndDest['dest'] : '';
$code = isset($_POST['code']) && is_string($_POST['code']) ? $_POST['code'] : '';
$startDateRaw = isset($_POST['redirect_start_date']) && is_string($_POST['redirect_start_date']) ? trim($_POST['redirect_start_date']) : '';
$endDateRaw = isset($_POST['redirect_end_date']) && is_string($_POST['redirect_end_date']) ? trim($_POST['redirect_end_date']) : '';
$startTs = ABJ_404_Solution_RedirectScheduleTimezone::toEpoch($startDateRaw, '00:00:00');
$endTs = ABJ_404_Solution_RedirectScheduleTimezone::toEpoch($endDateRaw, '23:59:59');
return array(
'tdTypeRaw' => $tdTypeRaw,
'tdType' => $tdType,
'tdDest' => $tdDest,
'code' => $code,
'statusType' => $statusType,
'startTs' => $startTs,
'endTs' => $endTs,
);
}
/**
* @param array{tdTypeRaw: string, tdType: int, tdDest: string, code: string, statusType: int, startTs: int|null, endTs: int|null} $context
*/
private function contextHasDestination(array $context): bool {
$isGoneCode = $context['code'] === '410' || $context['code'] === '451';
return $context['tdTypeRaw'] !== '' && ($context['tdDest'] !== "" || $isGoneCode);
}
/**
* @param array{tdTypeRaw: string, tdType: int, tdDest: string, code: string, statusType: int, startTs: int|null, endTs: int|null} $context
* @param array{statusType: int, url: string, autoPromoted: bool, urlRewritten: bool} $sourceResolution
*/
private function updateSingleRedirect(
string $fromURL,
array $context,
array $sourceResolution,
string $originalFromURL
): string {
$redirectsRepo = $this->parent->getRedirectsRepo();
$id = ABJ_404_Solution_ExactInteger::readOr($_POST['id'] ?? null, 1, 0);
if ($id === 0) {
return __('Error: Data not formatted properly.', '404-solution') . "
";
}
$updateError = $redirectsRepo->updateRedirect(ABJ_404_Solution_RedirectUpdate::fromArray((array_key_exists('query_mode', $_POST)
? array('query_mode' => ABJ_404_Solution_QueryStringHelper::queryMode(wp_unslash($_POST['query_mode']))) : array()) + array(
'id' => $id,
'type' => $context['tdType'],
'fromUrl' => (string)$fromURL,
'destination' => $context['tdDest'],
'code' => $context['code'],
'statusType' => (string)$context['statusType'],
'startTs' => $context['startTs'],
'endTs' => $context['endTs'],
)));
$errorCode = is_scalar($updateError) ? (string)$updateError : '';
if ($errorCode !== '') {
return $this->formatUpdateRedirectError($errorCode) . "
";
}
if ($sourceResolution['autoPromoted']) {
$this->resolver->saveRegexAutoPromoteNotice(
$id,
$originalFromURL,
$fromURL,
$sourceResolution['urlRewritten']
);
}
if ($id > 0) {
$submitted = ABJ_404_Solution_SubmittedRedirectConditions::fromRawConditions(
(isset($_POST['conditions']) && is_array($_POST['conditions'])) ? $_POST['conditions'] : []
);
$conditionsError = $redirectsRepo->saveRedirectConditions($id, $submitted->conditions());
if ($conditionsError !== '') {
return $this->formatSaveConditionsError($conditionsError) . "
";
}
// After the save, not instead of it: the conditions that fit were
// stored and the rest of the edit stands. This reports what did
// not fit so the admin is not left believing all of it was kept.
if ($submitted->droppedOverMaximum() > 0) {
return $this->formatConditionsCappedNotice($submitted->droppedOverMaximum()) . "
";
}
}
return '';
}
/**
* @param array $idsMultiple
* @param array{tdTypeRaw: string, tdType: int, tdDest: string, code: string, statusType: int, startTs: int|null, endTs: int|null} $context
*/
private function updateMultipleRedirects(array $idsMultiple, array $context): string {
$message = "";
$redirectsRepo = $this->parent->getRedirectsRepo();
$redirectsRead = $redirectsRepo->readRedirectsByIDs($idsMultiple);
// A refused read does not mean the rows are gone: report it, write nothing.
if ($redirectsRead['last_error'] !== '') { return $this->formatUpdateRedirectError($redirectsRead['last_error']) . "
"; }
$redirectsMultiple = $redirectsRead['rows'];
if (empty($redirectsMultiple)) {
// Every selected row is gone: another admin deleted them, or
// deleteOldRedirectsCron did, while the list page holding the
// checkboxes was still open. Without this branch the loop below
// runs zero times and returns "", which handleActionEdit() reads
// as success and answers "Redirect Information Updated
// Successfully!" after writing nothing. Same "the id no longer has
// a row" condition the edit screen reports (production report 349),
// on the write side. Below error level for the same reason: the
// plugin worked correctly, the selection did not survive.
$this->parent->getLogger()->debugMessage("Bulk redirect update: no redirect rows exist " .
"for requested id(s): " . esc_html(implode(', ', array_map('strval', $idsMultiple))));
return sprintf(
/* translators: %s is a comma-separated list of redirect id numbers. */
_n(
'Redirect %s was not found. It may have been deleted since this page was opened.',
'Redirects %s were not found. They may have been deleted since this page was opened.',
count($idsMultiple),
'404-solution'
),
implode(', ', array_map('strval', $idsMultiple))
) . "
";
}
foreach ($redirectsMultiple as $redirect) {
$redirectId = is_scalar($redirect['id']) ? (int)$redirect['id'] : 0;
$updateError = $redirectsRepo->updateRedirectFromStoredRow($redirectId, function (array $row) use ($context, $redirectId) {
return ABJ_404_Solution_RedirectUpdate::fromArray(array(
'id' => $redirectId,
'type' => $context['tdType'],
'fromUrl' => isset($row['url']) && is_string($row['url']) ? $row['url'] : '',
'destination' => $context['tdDest'],
'code' => $context['code'],
'statusType' => (string)$context['statusType'],
));
});
$errorCode = is_scalar($updateError) ? (string)$updateError : '';
if ($errorCode !== '') {
$message .= $this->formatUpdateRedirectError($errorCode) . "
";
continue;
}
}
return $message;
}
private function formatUpdateRedirectError(string $errorCode): string {
if (strpos($errorCode, 'Redirect loop:') === 0) {
return __('Error:', '404-solution') . ' ' . esc_html($errorCode);
}
if ($errorCode === 'bad_update_request') {
return __('Error: Bad data passed for update redirect request.', '404-solution');
}
return sprintf(
__('Error: Unable to update redirect data. Repository result: %s', '404-solution'),
esc_html($errorCode)
);
}
/**
* @param string $errorMessage Underlying DB/transaction error text (per
* the Error visibility philosophy, surfaced rather than genericized).
*/
private function formatSaveConditionsError(string $errorMessage): string {
return sprintf(
__('Error: Unable to save redirect conditions. Repository result: %s', '404-solution'),
esc_html($errorMessage)
);
}
/**
* Build the post-edit PRG redirect querystring + the source page that
* the in-request render should target.
*
* @return array{source_page: string, redirect_url: string}
*/
private function buildPostEditRedirect(): array {
// Through RedirectEditRequest's list, not a second copy of it: the read
// side answers the same question for the back link and the hidden
// inputs, and two lists of "which subpage is valid" drift apart.
$source_page = ABJ_404_Solution_RequestInputNormalizer::getPostOrGetSanitize('source_page');
if (!ABJ_404_Solution_RedirectEditRequest::isListSubpage($source_page)) {
$source_page = ABJ_404_Solution_RedirectEditRequest::DEFAULT_SUBPAGE;
}
$redirect_url = "?page=" . ABJ404_PP . "&subpage=" . $source_page . "&updated=1";
$source_filter = ABJ_404_Solution_RequestInputNormalizer::getPostOrGetSanitize('source_filter', '');
if ($source_filter !== '' && $source_filter !== '0') {
$redirect_url .= "&filter=" . urlencode($source_filter);
}
$source_orderby = ABJ_404_Solution_RequestInputNormalizer::getPostOrGetSanitize('source_orderby', '');
$source_order = ABJ_404_Solution_RequestInputNormalizer::getPostOrGetSanitize('source_order', '');
if ($source_orderby !== '' && $source_order !== ''
&& !($source_orderby === "url" && $source_order === "ASC")) {
$redirect_url .= "&orderby=" . urlencode($source_orderby);
$redirect_url .= "&order=" . urlencode($source_order);
}
$source_paged = ABJ_404_Solution_RequestInputNormalizer::getPostOrGetSanitize('source_paged', '');
if ($source_paged !== '' && (int)$source_paged > 1) {
$redirect_url .= "&paged=" . urlencode($source_paged);
}
return array('source_page' => $source_page, 'redirect_url' => $redirect_url);
}
/**
* Resolve the admin parent script the plugin's menu page is registered
* under. Used to build correct admin_url() after a successful edit.
*
* @return string
*/
private function getMenuParentScript(): string {
$options = abj_service('options_repository')->getOptions(true);
return ABJ_404_Solution_AdminPageUrlBuilder::pageFile(is_array($options) ? $options : array());
}
/**
* @param int $droppedCount How many valid conditions were dropped for
* arriving past MAX_REDIRECT_CONDITIONS.
*/
private function formatConditionsCappedNotice(int $droppedCount): string {
return sprintf(
/* translators: 1: number of conditions that were not saved. 2: the maximum allowed. */
_n(
'Note: %1$s condition was not saved. A redirect may have at most %2$s conditions.',
'Note: %1$s conditions were not saved. A redirect may have at most %2$s conditions.',
$droppedCount,
'404-solution'
),
number_format_i18n($droppedCount),
number_format_i18n(self::MAX_REDIRECT_CONDITIONS)
);
}
}