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 / view / TableViewOptionsResolver.php

TableViewOptionsResolver.php in 404 Solution 4.3.0, at includes/view/TableViewOptionsResolver.php

420 lines 16.1 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 * Resolves the per-page options array that drives every admin list-table
9 * view (Redirects, Captured, Logs): filter, filterText, orderby/order,
10 * paging, perpage, score range, and the optional logsid focus row.
11 *
12 * Source of input is the current request ($_POST / $_GET / REQUEST_URI).
13 * The resolved array is fed to view classes and pagination AJAX endpoints.
14 *
15 * Two responsibilities the audit (M201, CQS finding at PluginLogicSettingsUpdate:131)
16 * called out are now visible as discrete steps inside this class:
17 *
18 * 1. Pure resolution of tableOptions from the request.
19 * 2. The "remember my chosen sort" side effect that persists the
20 * user-supplied orderby/order onto the saved options. The persist call
21 * runs through {@see rememberSortPreference()}, named for what it is,
22 * so the side effect is no longer hidden inside a get*() method.
23 *
24 * Extracted from PluginLogicSettingsUpdate.php during the M201 decomposition.
25 */
26 class ABJ_404_Solution_TableViewOptionsResolver {
27
28 /** @var ABJ_404_Solution_Functions */
29 private $f;
30
31 /** @var callable|null */
32 private $sanitizer;
33
34 /** Allowed column names for the orderby request parameter.
35 * @var array<int, string> */
36 private static $allowedOrderbyColumns = [
37 'url',
38 'status',
39 'type',
40 'dest',
41 'final_dest',
42 'code',
43 'score',
44 'timestamp',
45 'created',
46 'lastused',
47 'last_used',
48 'logshits',
49 'remote_host',
50 'referrer',
51 'action',
52 'username'
53 ];
54
55 /** Allowed values for the order request parameter.
56 * @var array<int, string> */
57 private static $allowedOrderValues = ['ASC', 'DESC'];
58
59 /**
60 * @param ABJ_404_Solution_Functions $f
61 * @param callable|null $sanitizer Optional fn(array): array used to sanitize
62 * the resolved tableOptions. If null, falls
63 * back to the canonical sanitizer obtained
64 * via service lookup on first use.
65 */
66 function __construct($f, $sanitizer = null) {
67 $this->f = $f;
68 $this->sanitizer = is_callable($sanitizer) ? $sanitizer : null;
69 }
70
71 /**
72 * Resolve the table options array for a single admin list-table view.
73 *
74 * Side effects: when the request carries a new orderby or order
75 * parameter, the user's preference is persisted to options via
76 * {@see rememberSortPreference()}.
77 *
78 * @param string $pageBeingViewed One of abj404_redirects, abj404_captured, abj404_logs.
79 * @return array<string, mixed>
80 */
81 function resolve(string $pageBeingViewed): array {
82 $tableOptions = array();
83 $options = abj_service('options_repository')->getOptions(true);
84
85 $tableOptions['translations'] = $this->translationTokens();
86
87 $tableOptions['filter'] = $this->resolveFilter();
88 $tableOptions['filterText'] = $this->resolveFilterText();
89
90 $orderbyInput = $this->f->getPostOrGetSanitize('orderby', '');
91 $tableOptions['orderby'] = $this->resolveOrderby($orderbyInput, $pageBeingViewed, $options);
92
93 $orderInput = strtoupper($this->f->getPostOrGetSanitize('order', ''));
94 $tableOptions['order'] = $this->resolveOrder($orderInput, $tableOptions['orderby'], $pageBeingViewed, $options);
95
96 $this->rememberSortPreference($orderbyInput, $orderInput, $pageBeingViewed, $options);
97
98 $tableOptions['paged'] = $this->resolvePaged();
99 $tableOptions['perpage'] = $this->resolvePerPage($options);
100
101 $tableOptions['logsid'] = $this->resolveLogsId();
102 $tableOptions['score_range'] = $this->resolveScoreRange();
103
104 $forceRebuild = $this->resolveForceViewRebuild();
105 if ($forceRebuild !== null) {
106 $tableOptions['_abj404_force_view_rebuild'] = $forceRebuild;
107 }
108
109 return $this->normalizeResolvedTypes($this->sanitize($tableOptions));
110 }
111
112 /**
113 * Persist the user's chosen sort preference for the redirects / captured
114 * tables. Called from {@see resolve()} when a new orderby/order is in
115 * the request. Public so the side effect can be tested independently
116 * of the read path.
117 *
118 * @param string $orderbyInput Raw orderby from the request.
119 * @param string $orderInput Raw order from the request (already uppercased).
120 * @param string $pageBeingViewed Admin page slug.
121 * @param array<string, mixed> $options Current options snapshot to mutate / save.
122 * @return void
123 */
124 public function rememberSortPreference(string $orderbyInput, string $orderInput, string $pageBeingViewed, array $options): void {
125 $changed = false;
126
127 if ($orderbyInput !== '' && in_array($orderbyInput, self::$allowedOrderbyColumns, true)) {
128 if ($pageBeingViewed === 'abj404_redirects') {
129 $options['page_redirects_order_by'] = $orderbyInput;
130 $changed = true;
131 } else if ($pageBeingViewed === 'abj404_captured') {
132 $options['captured_order_by'] = $orderbyInput;
133 $changed = true;
134 }
135 }
136
137 if ($orderInput !== '' && in_array($orderInput, self::$allowedOrderValues, true)) {
138 if ($pageBeingViewed === 'abj404_redirects') {
139 $options['page_redirects_order'] = $orderInput;
140 $changed = true;
141 } else if ($pageBeingViewed === 'abj404_captured') {
142 $options['captured_order'] = $orderInput;
143 $changed = true;
144 }
145 }
146
147 if ($changed) {
148 abj_service('options_repository')->updateOptions($options);
149 }
150 }
151
152 /**
153 * Localised label tokens injected into table cells. Centralised here so
154 * the catalog stays POT-extractable (literal strings inside __() calls).
155 *
156 * @return array<string, string>
157 */
158 private function translationTokens(): array {
159 return array(
160 '{ABJ404_STATUS_MANUAL_text}' => __('Man', '404-solution'),
161 '{ABJ404_STATUS_AUTO_text}' => __('Auto', '404-solution'),
162 '{ABJ404_STATUS_REGEX_text}' => __('RegEx', '404-solution'),
163 '{ABJ404_TYPE_EXTERNAL_text}' => __('External', '404-solution'),
164 '{ABJ404_TYPE_CAT_text}' => __('Category', '404-solution'),
165 '{ABJ404_TYPE_TAG_text}' => __('Tag', '404-solution'),
166 '{ABJ404_TYPE_HOME_text}' => __('Home Page', '404-solution'),
167 '{ABJ404_TYPE_404_DISPLAYED_text}' => __('(Default 404 Page)', '404-solution'),
168 '{ABJ404_TYPE_SPECIAL_text}' => __('(Special)', '404-solution'),
169 );
170 }
171
172 /** @return int */
173 private function resolveFilter(): int {
174 $rawFilter = $this->f->getPostOrGetSanitize('filter', '');
175 if ($rawFilter === '') {
176 if ($this->f->getPostOrGetSanitize('subpage') == 'abj404_captured') {
177 return ABJ404_STATUS_CAPTURED;
178 }
179 return 0;
180 }
181 return intval($rawFilter);
182 }
183
184 /** @return string */
185 private function resolveFilterText(): string {
186 $filterText = trim($this->f->getPostOrGetSanitize('filterText', ''));
187 return $this->f->str_replace(array('*', '/', '$'), '', $filterText);
188 }
189
190 /**
191 * @param string $orderbyInput
192 * @param string $pageBeingViewed
193 * @param array<string, mixed> $options
194 * @return string
195 */
196 private function resolveOrderby(string $orderbyInput, string $pageBeingViewed, array $options): string {
197 if ($orderbyInput !== '' && in_array($orderbyInput, self::$allowedOrderbyColumns, true)) {
198 return $orderbyInput;
199 }
200 if ($pageBeingViewed === 'abj404_logs') {
201 return 'timestamp';
202 }
203 if ($pageBeingViewed === 'abj404_redirects') {
204 $saved = isset($options['page_redirects_order_by']) && is_scalar($options['page_redirects_order_by'])
205 ? (string)$options['page_redirects_order_by'] : 'url';
206 return in_array($saved, self::$allowedOrderbyColumns, true) ? $saved : 'url';
207 }
208 if ($pageBeingViewed === 'abj404_captured') {
209 $saved = isset($options['captured_order_by']) && is_scalar($options['captured_order_by'])
210 ? (string)$options['captured_order_by'] : 'timestamp';
211 return in_array($saved, self::$allowedOrderbyColumns, true) ? $saved : 'timestamp';
212 }
213 return 'url';
214 }
215
216 /**
217 * @param string $orderInput
218 * @param string $resolvedOrderby
219 * @param string $pageBeingViewed
220 * @param array<string, mixed> $options
221 * @return string
222 */
223 private function resolveOrder(string $orderInput, string $resolvedOrderby, string $pageBeingViewed, array $options): string {
224 if ($orderInput !== '' && in_array($orderInput, self::$allowedOrderValues, true)) {
225 return $orderInput;
226 }
227 if ($resolvedOrderby === 'created' || $resolvedOrderby === 'lastused' || $resolvedOrderby === 'timestamp') {
228 return 'DESC';
229 }
230 if ($pageBeingViewed === 'abj404_redirects') {
231 $saved = isset($options['page_redirects_order']) && is_scalar($options['page_redirects_order'])
232 ? strtoupper((string)$options['page_redirects_order']) : 'ASC';
233 return in_array($saved, self::$allowedOrderValues, true) ? $saved : 'ASC';
234 }
235 if ($pageBeingViewed === 'abj404_captured') {
236 $saved = isset($options['captured_order']) && is_scalar($options['captured_order'])
237 ? strtoupper((string)$options['captured_order']) : 'DESC';
238 return in_array($saved, self::$allowedOrderValues, true) ? $saved : 'DESC';
239 }
240 return 'ASC';
241 }
242
243 /** @return int */
244 private function resolvePaged(): int {
245 $paged = $this->f->getPostOrGetSanitize('paged', '');
246 if ($paged === '') {
247 $paged = $this->readScalarFromRequestUriQuery('paged');
248 }
249 return $this->positiveIntOrDefault($paged, 1);
250 }
251
252 /**
253 * @param array<string, mixed> $options
254 * @return int
255 */
256 private function resolvePerPage(array $options): int {
257 $perPageOption = ABJ404_OPTION_DEFAULT_PERPAGE;
258 if (isset($options['perpage'])) {
259 $perPageOption = max(absint(is_scalar($options['perpage']) ? $options['perpage'] : 0), ABJ404_OPTION_MIN_PERPAGE);
260 }
261 $rawPerPage = $this->f->getPostOrGetSanitize('perpage', '');
262 if ($rawPerPage === '') {
263 return $perPageOption;
264 }
265 return max($this->positiveIntOrDefault($rawPerPage, $perPageOption), ABJ404_OPTION_MIN_PERPAGE);
266 }
267
268 /** @return int */
269 private function resolveLogsId(): int {
270 if ($this->f->getPostOrGetSanitize('subpage') != 'abj404_logs') {
271 return 0;
272 }
273 $logId = (string)$this->f->getPostOrGetSanitize('id', '');
274 if (preg_match('/^\d+$/', $logId) === 1) {
275 return absint($logId);
276 }
277 $redirectToDataFieldId = (string)$this->f->getPostOrGetSanitize('redirect_to_data_field_id', '');
278 if (preg_match('/^\d+$/', $redirectToDataFieldId) === 1) {
279 return absint($redirectToDataFieldId);
280 }
281 return 0;
282 }
283
284 /** @return string */
285 private function resolveScoreRange(): string {
286 $raw = (string)$this->f->getPostOrGetSanitize('score_range', 'all');
287 $allowed = array('all', 'high', 'medium', 'low', 'manual');
288 return in_array($raw, $allowed, true) ? $raw : 'all';
289 }
290
291 /** @return string|null */
292 private function resolveForceViewRebuild() {
293 $val = (string)$this->f->getPostOrGetSanitize('forceViewRebuild', '');
294 if ($val === '') {
295 $val = (string)$this->f->getPostOrGetSanitize('abj404_force_view_rebuild', '');
296 }
297 return $val === '1' ? '1' : null;
298 }
299
300 /**
301 * Read a scalar query parameter directly from REQUEST_URI, bypassing the
302 * $_GET superglobal. Used as a fallback for paged numbers that may have
303 * been pre-stripped from $_GET in some hosting setups.
304 *
305 * @param string $name
306 * @return string
307 */
308 private function readScalarFromRequestUriQuery(string $name): string {
309 if ($name === '') {
310 return '';
311 }
312 $requestUri = isset($_SERVER['REQUEST_URI']) && is_string($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '';
313 if ($requestUri === '') {
314 return '';
315 }
316 $queryString = parse_url($requestUri, PHP_URL_QUERY);
317 if (!is_string($queryString) || $queryString === '') {
318 return '';
319 }
320 $query = array();
321 parse_str($queryString, $query);
322 if (!array_key_exists($name, $query) || !is_scalar($query[$name])) {
323 return '';
324 }
325 return sanitize_text_field((string)$query[$name]);
326 }
327
328 /**
329 * Sanitize the assembled table options array using the injected
330 * sanitizer, or a service-resolved canonical sanitizer as fallback.
331 *
332 * @param array<string, mixed> $tableOptions
333 * @return array<string, mixed>
334 */
335 private function sanitize(array $tableOptions): array {
336 if ($this->sanitizer !== null) {
337 return ($this->sanitizer)($tableOptions);
338 }
339 $pluginLogic = abj_service('plugin_logic');
340 if ($pluginLogic !== null && method_exists($pluginLogic, 'settingsUpdate')) {
341 $settingsUpdate = $pluginLogic->settingsUpdate();
342 if ($settingsUpdate !== null && method_exists($settingsUpdate, 'sanitizePostData')) {
343 return $settingsUpdate->sanitizePostData($tableOptions);
344 }
345 }
346 return $tableOptions;
347 }
348
349 /**
350 * @param mixed $raw
351 */
352 private function positiveIntOrDefault($raw, int $default): int {
353 if (!is_scalar($raw)) {
354 return $default;
355 }
356 $raw = trim((string)$raw);
357 if ($raw === '' || preg_match('/^\d+$/', $raw) !== 1) {
358 return $default;
359 }
360 $value = intval($raw);
361 return $value > 0 ? $value : $default;
362 }
363
364 /**
365 * The legacy sanitizer returns scalar values as strings. Re-assert the
366 * table-options contract at this boundary so downstream readers receive
367 * typed numeric values.
368 *
369 * @param array<string, mixed> $tableOptions
370 * @return array<string, mixed>
371 */
372 private function normalizeResolvedTypes(array $tableOptions): array {
373 $tableOptions['filter'] = $this->normalizeFilter($tableOptions['filter'] ?? 0);
374 $tableOptions['paged'] = $this->positiveIntOrDefault($tableOptions['paged'] ?? 1, 1);
375 $tableOptions['perpage'] = $this->positiveIntOrDefault(
376 $tableOptions['perpage'] ?? ABJ404_OPTION_DEFAULT_PERPAGE,
377 ABJ404_OPTION_DEFAULT_PERPAGE
378 );
379 $tableOptions['logsid'] = $this->positiveIntOrZero($tableOptions['logsid'] ?? 0);
380 return $tableOptions;
381 }
382
383 /**
384 * Normalize the status-filter value. Unlike paged/perpage/logsid, the
385 * filter legitimately carries negative sentinels alongside non-negative
386 * status/type codes: ABJ404_TRASH_FILTER (-1, the Trash tab) and
387 * ABJ404_HANDLED_FILTER (-2, the Captured "Handled" view). It must NOT pass
388 * through the positive-only sanitizer, which rejects the leading minus via
389 * its /^\d+$/ guard and silently coerces the sentinel to 0 (All) -- the
390 * defect that broke every Trash/Handled tab (incident 2026-06-20). Any
391 * other negative or non-numeric value still fails closed to 0.
392 *
393 * @param mixed $raw
394 */
395 private function normalizeFilter($raw): int {
396 if (!is_scalar($raw)) {
397 return 0;
398 }
399 $value = intval(trim((string)$raw));
400 if ($value === ABJ404_TRASH_FILTER || $value === ABJ404_HANDLED_FILTER) {
401 return $value;
402 }
403 return $value > 0 ? $value : 0;
404 }
405
406 /**
407 * @param mixed $raw
408 */
409 private function positiveIntOrZero($raw): int {
410 if (!is_scalar($raw)) {
411 return 0;
412 }
413 $raw = trim((string)$raw);
414 if ($raw === '' || preg_match('/^\d+$/', $raw) !== 1) {
415 return 0;
416 }
417 return intval($raw);
418 }
419 }
420