| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* What the Edit Redirect screen was asked to do. |
| 9 |
* |
| 10 |
* Reads the incoming request and answers two questions the edit screen needs |
| 11 |
* before it can render anything: which redirect ids were named, and which list |
| 12 |
* page the admin arrived from. Pure request interpretation -- no HTML, no |
| 13 |
* user-facing copy, no database access, and no logging -- so the answers can be |
| 14 |
* asserted directly instead of by scraping rendered markup, and so a getter |
| 15 |
* here never writes anything (enforced by scripts/lint/lint-hidden-write-getters). |
| 16 |
* |
| 17 |
* The two questions live together because they are read from the same request |
| 18 |
* in the same breath and every consumer needs both: the edit form needs the |
| 19 |
* ids to load and the source page for its "back" link and hidden inputs, and |
| 20 |
* the missing-row notice needs the ids to name and the same source page to |
| 21 |
* send the admin back to. |
| 22 |
* |
| 23 |
* This is also the single answer to "which subpage is a real one". The write |
| 24 |
* side used to keep its own tab list in EditRedirectHandler while this class |
| 25 |
* validated only by excluding the edit tab, so the same question had two |
| 26 |
* answers that could drift apart -- and the weaker of the two fed a link the |
| 27 |
* admin clicks. |
| 28 |
*/ |
| 29 |
class ABJ_404_Solution_RedirectEditRequest { |
| 30 |
|
| 31 |
/** |
| 32 |
* Every subpage the edit screen can legitimately have been opened from, |
| 33 |
* and the only values getSourcePage() will return. |
| 34 |
* |
| 35 |
* Deliberately NOT including 'abj404_edit': the edit screen must not offer |
| 36 |
* to send the admin back to the screen they are already on. |
| 37 |
* |
| 38 |
* @var array<int, string> |
| 39 |
*/ |
| 40 |
const LIST_SUBPAGES = array('abj404_redirects', 'abj404_captured', 'abj404_logs', |
| 41 |
'abj404_stats', 'abj404_tools', 'abj404_options'); |
| 42 |
|
| 43 |
/** Where the edit screen returns to when the request names nothing usable. */ |
| 44 |
const DEFAULT_SUBPAGE = 'abj404_redirects'; |
| 45 |
|
| 46 |
/** Request named a single redirect through the GET id parameter. */ |
| 47 |
const SOURCE_GET_ID = 'get_id'; |
| 48 |
|
| 49 |
/** Request named a single redirect through the POST id parameter. */ |
| 50 |
const SOURCE_POST_ID = 'post_id'; |
| 51 |
|
| 52 |
/** Request named a set of redirects through the idnum parameter. */ |
| 53 |
const SOURCE_IDNUM = 'idnum'; |
| 54 |
|
| 55 |
/** |
| 56 |
* Most redirects one request may name at once. |
| 57 |
* |
| 58 |
* The set arrives as a repeatable form field, so its size is chosen by the |
| 59 |
* client, and every consumer scales with it: one `IN (...)` lookup over the |
| 60 |
* whole set, then a hidden input and a rendered table row per id. Without a |
| 61 |
* ceiling the request decides how much work the server does. |
| 62 |
* |
| 63 |
* Set well above any real bulk selection -- the admin tables page in the |
| 64 |
* dozens, and the REST list endpoint caps a page at 100 -- so an admin who |
| 65 |
* selects everything on a very large page is unaffected, and only a set no |
| 66 |
* screen produces is bounded. |
| 67 |
*/ |
| 68 |
const MAX_SELECTED_IDS = 2000; |
| 69 |
|
| 70 |
/** @var ABJ_404_Solution_Functions */ |
| 71 |
private $f; |
| 72 |
|
| 73 |
/** |
| 74 |
* @param ABJ_404_Solution_Functions $f Provides regexMatch. |
| 75 |
*/ |
| 76 |
public function __construct($f) { |
| 77 |
$this->f = $f; |
| 78 |
} |
| 79 |
|
| 80 |
/** |
| 81 |
* Which list page the edit screen was opened from. |
| 82 |
* |
| 83 |
* Falls back to the Redirects list when the request names nothing usable, |
| 84 |
* which includes the edit screen itself -- so a reload of the edit URL |
| 85 |
* cannot make the screen offer to send the admin back to itself. |
| 86 |
* |
| 87 |
* Returns a member of LIST_SUBPAGES or nothing at all: the answer is a |
| 88 |
* subpage key, so a value that is not one is not an answer to narrow later. |
| 89 |
* Returning the raw sanitized string made this a half-parse, and the raw |
| 90 |
* string went straight into the back link as |
| 91 |
* '?page=...&subpage=' . esc_attr($sourcePage) -- esc_attr escapes HTML, |
| 92 |
* not URL components, so an '&' rode through and appended parameters of |
| 93 |
* the caller's choosing to a link the admin is invited to click. |
| 94 |
* |
| 95 |
* @return string One of self::LIST_SUBPAGES. |
| 96 |
*/ |
| 97 |
public function getSourcePage(): string { |
| 98 |
$sourcePage = $this->sanitizedScalar('source_page'); |
| 99 |
if ($sourcePage === '') { |
| 100 |
$sourcePage = $this->sanitizedScalar('subpage'); |
| 101 |
} |
| 102 |
return self::isListSubpage($sourcePage) ? $sourcePage : self::DEFAULT_SUBPAGE; |
| 103 |
} |
| 104 |
|
| 105 |
/** |
| 106 |
* Whether a value names a real plugin list page. |
| 107 |
* |
| 108 |
* Public so the write side resolves the same question through the same |
| 109 |
* list rather than keeping a second copy of it. |
| 110 |
* |
| 111 |
* @param mixed $subpage |
| 112 |
*/ |
| 113 |
public static function isListSubpage($subpage): bool { |
| 114 |
return is_string($subpage) && in_array($subpage, self::LIST_SUBPAGES, true); |
| 115 |
} |
| 116 |
|
| 117 |
/** |
| 118 |
* Which redirect ids the request named. |
| 119 |
* |
| 120 |
* Returns null when the request named no usable redirect at all: no id |
| 121 |
* parameter, or an idnum parameter that sanitizes down to nothing (empty |
| 122 |
* array, all zeros, non-numeric). Callers treat null as "nothing was |
| 123 |
* selected", which is a different message from "these ids have no row" -- |
| 124 |
* the latter would print an empty id list. |
| 125 |
* |
| 126 |
* Otherwise one of three mutually exclusive states, which is why the answer |
| 127 |
* is an object and not a bag of keys: single, bulk, or refused-as-too-many. |
| 128 |
* See ABJ_404_Solution_RequestedRedirectIds. |
| 129 |
*/ |
| 130 |
public function getRequestedIds(): ?ABJ_404_Solution_RequestedRedirectIds { |
| 131 |
if (isset($_GET['id']) && is_scalar($_GET['id']) && $this->f->regexMatch('^[0-9]+$', (string)$_GET['id'])) { |
| 132 |
return ABJ_404_Solution_RequestedRedirectIds::single( |
| 133 |
self::SOURCE_GET_ID, absint($_GET['id'])); |
| 134 |
} |
| 135 |
|
| 136 |
if (isset($_POST['id']) && is_scalar($_POST['id']) && $this->f->regexMatch('^[0-9]+$', (string)$_POST['id'])) { |
| 137 |
return ABJ_404_Solution_RequestedRedirectIds::single( |
| 138 |
self::SOURCE_POST_ID, absint($_POST['id'])); |
| 139 |
} |
| 140 |
|
| 141 |
// Presence only, read straight from the superglobals. This used to lead |
| 142 |
// with sanitizedScalar('idnum'), which routes through |
| 143 |
// RequestInputNormalizer::getPostOrGetSanitize() -- and that runs |
| 144 |
// wp_unslash plus array_map('sanitize_text_field', ...) over the WHOLE |
| 145 |
// array. So a million-element idnum[] was fully unslashed and sanitized |
| 146 |
// here, one line above the cap that exists to stop exactly that work. |
| 147 |
// The clause was also redundant: with neither superglobal set, that |
| 148 |
// call returns '' by construction. |
| 149 |
if (!isset($_GET['idnum']) && !isset($_POST['idnum'])) { |
| 150 |
return null; |
| 151 |
} |
| 152 |
|
| 153 |
$rawIds = (array)(isset($_GET['idnum']) ? $_GET['idnum'] : $_POST['idnum']); |
| 154 |
$requestedCount = count($rawIds); |
| 155 |
// Refused BEFORE a single id is touched. The ceiling exists to bound |
| 156 |
// the work one request can cause, so a refused request must cost the |
| 157 |
// count and nothing else -- sanitizing a million entries and then |
| 158 |
// declining to use any of them has already done the damage. |
| 159 |
if ($requestedCount > self::MAX_SELECTED_IDS) { |
| 160 |
return ABJ_404_Solution_RequestedRedirectIds::refusedAsTooMany( |
| 161 |
self::SOURCE_IDNUM, $requestedCount); |
| 162 |
} |
| 163 |
$ids = array_values(array_filter(array_map( |
| 164 |
function ($v): int { return is_scalar($v) ? absint($v) : 0; }, |
| 165 |
$rawIds), function (int $v): bool { return $v > 0; })); |
| 166 |
// bulk() answers null for a set that sanitized down to nothing, which |
| 167 |
// is the same "no usable id" this method returns for a missing |
| 168 |
// parameter -- one definition of empty rather than two. |
| 169 |
return ABJ_404_Solution_RequestedRedirectIds::bulk( |
| 170 |
self::SOURCE_IDNUM, $ids, $requestedCount); |
| 171 |
} |
| 172 |
|
| 173 |
/** |
| 174 |
* One sanitized GET/POST scalar as a string. |
| 175 |
* |
| 176 |
* Reads through RequestInputNormalizer directly rather than through |
| 177 |
* View_Shared's wrapper of it: this class answers a question about the |
| 178 |
* request, so it must not depend on the presentation layer to do so. |
| 179 |
* |
| 180 |
* @param string $name |
| 181 |
* @return string '' when absent or non-scalar. |
| 182 |
*/ |
| 183 |
private function sanitizedScalar(string $name): string { |
| 184 |
$result = ABJ_404_Solution_RequestInputNormalizer::getPostOrGetSanitize($name); |
| 185 |
return is_string($result) ? $result : (is_scalar($result) ? (string)$result : ''); |
| 186 |
} |
| 187 |
} |
| 188 |
|