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 / services / RedirectEditRequest.php

RedirectEditRequest.php in 404 Solution trunk, at includes/services/RedirectEditRequest.php

188 lines 8.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 * 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