PluginProbe
404 Solution / 4.3.3
404 Solution v4.3.3
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.3, at includes/view/TableViewOptionsResolver.php

489 lines 20.0 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 $preludeTracer = ABJ_404_Solution_TableRendererPreludeTracer::begin();
83 try {
84 if ($preludeTracer !== null) {
85 $preludeTracer->prepareTranslationDomain();
86 }
87 $tableOptions = array();
88 $tableOptions['translations'] = $this->tracePrelude(
89 $preludeTracer, 'translation_tokens', fn() => $this->translationTokens());
90 $tableOptions['filter'] = $this->tracePrelude(
91 $preludeTracer, 'filter_resolution', fn() => $this->resolveFilter());
92 $tableOptions['filterText'] = $this->tracePrelude(
93 $preludeTracer, 'filter_text_resolution', fn() => $this->resolveFilterText());
94
95 $orderbyInput = ABJ_404_Solution_RequestInputNormalizer::getPostOrGetSanitize('orderby', '');
96 $orderInput = strtoupper(ABJ_404_Solution_RequestInputNormalizer::getPostOrGetSanitize('order', ''));
97 $sortPreferenceRequested = in_array($pageBeingViewed, array('abj404_redirects', 'abj404_captured'), true)
98 && (
99 ($orderbyInput !== '' && in_array($orderbyInput, self::$allowedOrderbyColumns, true))
100 || ($orderInput !== '' && in_array($orderInput, self::$allowedOrderValues, true))
101 );
102 $optionTracer = $sortPreferenceRequested
103 ? ABJ_404_Solution_OptionPersistenceTracer::begin()
104 : null;
105 try {
106 $optionsRead = static function (): array {
107 return abj_service('options_repository')->getOptions(true);
108 };
109 $options = $this->tracePrelude(
110 $preludeTracer,
111 'options_read',
112 static fn() => $optionTracer === null
113 ? $optionsRead()
114 : $optionTracer->traceOperation('sort_preference_options_read', $optionsRead)
115 );
116
117 $tableOptions['orderby'] = $this->tracePrelude(
118 $preludeTracer,
119 'orderby_resolution',
120 fn() => $this->resolveOrderby($orderbyInput, $pageBeingViewed, $options)
121 );
122 $tableOptions['order'] = $this->tracePrelude(
123 $preludeTracer,
124 'order_resolution',
125 fn() => $this->resolveOrder(
126 $orderInput, $tableOptions['orderby'], $pageBeingViewed, $options)
127 );
128 $sortPreferenceWrite = function () use (
129 $orderbyInput, $orderInput, $pageBeingViewed, $options
130 ): void {
131 $this->rememberSortPreference($orderbyInput, $orderInput, $pageBeingViewed, $options);
132 };
133 $this->tracePrelude(
134 $preludeTracer,
135 'sort_preference_write',
136 static fn() => $optionTracer === null
137 ? $sortPreferenceWrite()
138 : $optionTracer->traceOperation('sort_preference_write', $sortPreferenceWrite)
139 );
140 } finally {
141 if ($optionTracer !== null) {
142 $optionTracer->finish();
143 }
144 }
145
146 $tableOptions['paged'] = $this->tracePrelude(
147 $preludeTracer, 'paged_resolution', fn() => $this->resolvePaged());
148 $tableOptions['perpage'] = $this->tracePrelude(
149 $preludeTracer, 'perpage_resolution', fn() => $this->resolvePerPage($options));
150 $tableOptions['logsid'] = $this->tracePrelude(
151 $preludeTracer, 'logsid_resolution', fn() => $this->resolveLogsId());
152 $tableOptions['score_range'] = $this->tracePrelude(
153 $preludeTracer, 'score_range_resolution', fn() => $this->resolveScoreRange());
154
155 $forceRebuild = $this->tracePrelude(
156 $preludeTracer, 'force_view_rebuild_resolution', fn() => $this->resolveForceViewRebuild());
157 if ($forceRebuild !== null) {
158 $tableOptions['_abj404_force_view_rebuild'] = $forceRebuild;
159 }
160 $sanitized = $this->tracePrelude(
161 $preludeTracer, 'sanitize', fn() => $this->sanitize($tableOptions));
162 return $this->tracePrelude(
163 $preludeTracer, 'normalize_types', fn() => $this->normalizeResolvedTypes($sanitized));
164 } finally {
165 if ($preludeTracer !== null) {
166 $preludeTracer->finish();
167 }
168 }
169 }
170
171 /**
172 * @template T
173 * @param ABJ_404_Solution_TableRendererPreludeTracer|null $tracer
174 * @param callable():T $work
175 * @return T
176 */
177 private function tracePrelude($tracer, string $operation, callable $work) {
178 return $tracer === null ? $work() : $tracer->traceOperation($operation, $work);
179 }
180
181 /**
182 * Persist the user's chosen sort preference for the redirects / captured
183 * tables. Called from {@see resolve()} when a new orderby/order is in
184 * the request. Public so the side effect can be tested independently
185 * of the read path.
186 *
187 * @param string $orderbyInput Raw orderby from the request.
188 * @param string $orderInput Raw order from the request (already uppercased).
189 * @param string $pageBeingViewed Admin page slug.
190 * @param array<string, mixed> $options Current options snapshot to mutate / save.
191 * @return void
192 */
193 public function rememberSortPreference(string $orderbyInput, string $orderInput, string $pageBeingViewed, array $options): void {
194 $changed = false;
195
196 if ($orderbyInput !== '' && in_array($orderbyInput, self::$allowedOrderbyColumns, true)) {
197 if ($pageBeingViewed === 'abj404_redirects') {
198 $options['page_redirects_order_by'] = $orderbyInput;
199 $changed = true;
200 } else if ($pageBeingViewed === 'abj404_captured') {
201 $options['captured_order_by'] = $orderbyInput;
202 $changed = true;
203 }
204 }
205
206 if ($orderInput !== '' && in_array($orderInput, self::$allowedOrderValues, true)) {
207 if ($pageBeingViewed === 'abj404_redirects') {
208 $options['page_redirects_order'] = $orderInput;
209 $changed = true;
210 } else if ($pageBeingViewed === 'abj404_captured') {
211 $options['captured_order'] = $orderInput;
212 $changed = true;
213 }
214 }
215
216 if ($changed) {
217 abj_service('options_repository')->updateOptions($options);
218 }
219 }
220
221 /**
222 * Localised label tokens injected into table cells. Centralised here so
223 * the catalog stays POT-extractable (literal strings inside __() calls).
224 *
225 * @return array<string, string>
226 */
227 private function translationTokens(): array {
228 return array(
229 '{ABJ404_STATUS_MANUAL_text}' => __('Man', '404-solution'),
230 '{ABJ404_STATUS_AUTO_text}' => __('Auto', '404-solution'),
231 '{ABJ404_STATUS_REGEX_text}' => __('RegEx', '404-solution'),
232 '{ABJ404_TYPE_EXTERNAL_text}' => __('External', '404-solution'),
233 '{ABJ404_TYPE_CAT_text}' => __('Category', '404-solution'),
234 '{ABJ404_TYPE_TAG_text}' => __('Tag', '404-solution'),
235 '{ABJ404_TYPE_HOME_text}' => __('Home Page', '404-solution'),
236 '{ABJ404_TYPE_404_DISPLAYED_text}' => __('(Default 404 Page)', '404-solution'),
237 '{ABJ404_TYPE_SPECIAL_text}' => __('(Special)', '404-solution'),
238 );
239 }
240
241 /** @return int */
242 private function resolveFilter(): int {
243 $rawFilter = ABJ_404_Solution_RequestInputNormalizer::getPostOrGetSanitize('filter', '');
244 if ($rawFilter === '') {
245 if (ABJ_404_Solution_RequestInputNormalizer::getPostOrGetSanitize('subpage') == 'abj404_captured') {
246 return ABJ404_STATUS_CAPTURED;
247 }
248 return 0;
249 }
250 return intval($rawFilter);
251 }
252
253 /** @return string */
254 private function resolveFilterText(): string {
255 $filterText = trim(ABJ_404_Solution_RequestInputNormalizer::getPostOrGetSanitize('filterText', ''));
256 return $this->f->str_replace(array('*', '/', '$'), '', $filterText);
257 }
258
259 /**
260 * @param string $orderbyInput
261 * @param string $pageBeingViewed
262 * @param array<string, mixed> $options
263 * @return string
264 */
265 private function resolveOrderby(string $orderbyInput, string $pageBeingViewed, array $options): string {
266 if ($orderbyInput !== '' && in_array($orderbyInput, self::$allowedOrderbyColumns, true)) {
267 return $orderbyInput;
268 }
269 if ($pageBeingViewed === 'abj404_logs') {
270 return 'timestamp';
271 }
272 if ($pageBeingViewed === 'abj404_redirects') {
273 $saved = isset($options['page_redirects_order_by']) && is_scalar($options['page_redirects_order_by'])
274 ? (string)$options['page_redirects_order_by'] : 'url';
275 return in_array($saved, self::$allowedOrderbyColumns, true) ? $saved : 'url';
276 }
277 if ($pageBeingViewed === 'abj404_captured') {
278 $saved = isset($options['captured_order_by']) && is_scalar($options['captured_order_by'])
279 ? (string)$options['captured_order_by'] : 'timestamp';
280 return in_array($saved, self::$allowedOrderbyColumns, true) ? $saved : 'timestamp';
281 }
282 return 'url';
283 }
284
285 /**
286 * @param string $orderInput
287 * @param string $resolvedOrderby
288 * @param string $pageBeingViewed
289 * @param array<string, mixed> $options
290 * @return string
291 */
292 private function resolveOrder(string $orderInput, string $resolvedOrderby, string $pageBeingViewed, array $options): string {
293 if ($orderInput !== '' && in_array($orderInput, self::$allowedOrderValues, true)) {
294 return $orderInput;
295 }
296 if ($resolvedOrderby === 'created' || $resolvedOrderby === 'lastused' || $resolvedOrderby === 'timestamp') {
297 return 'DESC';
298 }
299 if ($pageBeingViewed === 'abj404_redirects') {
300 $saved = isset($options['page_redirects_order']) && is_scalar($options['page_redirects_order'])
301 ? strtoupper((string)$options['page_redirects_order']) : 'ASC';
302 return in_array($saved, self::$allowedOrderValues, true) ? $saved : 'ASC';
303 }
304 if ($pageBeingViewed === 'abj404_captured') {
305 $saved = isset($options['captured_order']) && is_scalar($options['captured_order'])
306 ? strtoupper((string)$options['captured_order']) : 'DESC';
307 return in_array($saved, self::$allowedOrderValues, true) ? $saved : 'DESC';
308 }
309 return 'ASC';
310 }
311
312 /** @return int */
313 private function resolvePaged(): int {
314 $paged = ABJ_404_Solution_RequestInputNormalizer::getPostOrGetSanitize('paged', '');
315 if ($paged === '') {
316 $paged = $this->readScalarFromRequestUriQuery('paged');
317 }
318 return $this->positiveIntOrDefault($paged, 1);
319 }
320
321 /**
322 * @param array<string, mixed> $options
323 * @return int
324 */
325 private function resolvePerPage(array $options): int {
326 $perPageOption = ABJ404_OPTION_DEFAULT_PERPAGE;
327 if (isset($options['perpage'])) {
328 $perPageOption = max(absint(is_scalar($options['perpage']) ? $options['perpage'] : 0), ABJ404_OPTION_MIN_PERPAGE);
329 }
330 $rawPerPage = ABJ_404_Solution_RequestInputNormalizer::getPostOrGetSanitize('perpage', '');
331 if ($rawPerPage === '') {
332 return $perPageOption;
333 }
334 return max($this->positiveIntOrDefault($rawPerPage, $perPageOption), ABJ404_OPTION_MIN_PERPAGE);
335 }
336
337 /** @return int */
338 private function resolveLogsId(): int {
339 if (ABJ_404_Solution_RequestInputNormalizer::getPostOrGetSanitize('subpage') != 'abj404_logs') {
340 return 0;
341 }
342 $logId = (string)ABJ_404_Solution_RequestInputNormalizer::getPostOrGetSanitize('id', '');
343 if (preg_match('/^\d+$/', $logId) === 1) {
344 return absint($logId);
345 }
346 $redirectToDataFieldId = (string)ABJ_404_Solution_RequestInputNormalizer::getPostOrGetSanitize('redirect_to_data_field_id', '');
347 if (preg_match('/^\d+$/', $redirectToDataFieldId) === 1) {
348 return absint($redirectToDataFieldId);
349 }
350 return 0;
351 }
352
353 /** @return string */
354 private function resolveScoreRange(): string {
355 $raw = (string)ABJ_404_Solution_RequestInputNormalizer::getPostOrGetSanitize('score_range', 'all');
356 $allowed = array('all', 'high', 'medium', 'low', 'manual');
357 return in_array($raw, $allowed, true) ? $raw : 'all';
358 }
359
360 /** @return string|null */
361 private function resolveForceViewRebuild() {
362 $val = (string)ABJ_404_Solution_RequestInputNormalizer::getPostOrGetSanitize('forceViewRebuild', '');
363 if ($val === '') {
364 $val = (string)ABJ_404_Solution_RequestInputNormalizer::getPostOrGetSanitize('abj404_force_view_rebuild', '');
365 }
366 return $val === '1' ? '1' : null;
367 }
368
369 /**
370 * Read a scalar query parameter directly from REQUEST_URI, bypassing the
371 * $_GET superglobal. Used as a fallback for paged numbers that may have
372 * been pre-stripped from $_GET in some hosting setups.
373 *
374 * @param string $name
375 * @return string
376 */
377 private function readScalarFromRequestUriQuery(string $name): string {
378 if ($name === '') {
379 return '';
380 }
381 $requestUri = isset($_SERVER['REQUEST_URI']) && is_string($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '';
382 if ($requestUri === '') {
383 return '';
384 }
385 $queryString = parse_url($requestUri, PHP_URL_QUERY);
386 if (!is_string($queryString) || $queryString === '') {
387 return '';
388 }
389 $query = array();
390 parse_str($queryString, $query);
391 if (!array_key_exists($name, $query) || !is_scalar($query[$name])) {
392 return '';
393 }
394 return sanitize_text_field((string)$query[$name]);
395 }
396
397 /**
398 * Sanitize the assembled table options array using the injected
399 * sanitizer, or a service-resolved canonical sanitizer as fallback.
400 *
401 * @param array<string, mixed> $tableOptions
402 * @return array<string, mixed>
403 */
404 private function sanitize(array $tableOptions): array {
405 if ($this->sanitizer !== null) {
406 return ($this->sanitizer)($tableOptions);
407 }
408 $pluginLogic = abj_service('plugin_logic');
409 if ($pluginLogic !== null && method_exists($pluginLogic, 'settingsUpdate')) {
410 $settingsUpdate = $pluginLogic->settingsUpdate();
411 if ($settingsUpdate !== null && method_exists($settingsUpdate, 'sanitizePostData')) {
412 return $settingsUpdate->sanitizePostData($tableOptions);
413 }
414 }
415 return $tableOptions;
416 }
417
418 /**
419 * @param mixed $raw
420 */
421 private function positiveIntOrDefault($raw, int $default): int {
422 if (!is_scalar($raw)) {
423 return $default;
424 }
425 $raw = trim((string)$raw);
426 if ($raw === '' || preg_match('/^\d+$/', $raw) !== 1) {
427 return $default;
428 }
429 $value = intval($raw);
430 return $value > 0 ? $value : $default;
431 }
432
433 /**
434 * The legacy sanitizer returns scalar values as strings. Re-assert the
435 * table-options contract at this boundary so downstream readers receive
436 * typed numeric values.
437 *
438 * @param array<string, mixed> $tableOptions
439 * @return array<string, mixed>
440 */
441 private function normalizeResolvedTypes(array $tableOptions): array {
442 $tableOptions['filter'] = $this->normalizeFilter($tableOptions['filter'] ?? 0);
443 $tableOptions['paged'] = $this->positiveIntOrDefault($tableOptions['paged'] ?? 1, 1);
444 $tableOptions['perpage'] = $this->positiveIntOrDefault(
445 $tableOptions['perpage'] ?? ABJ404_OPTION_DEFAULT_PERPAGE,
446 ABJ404_OPTION_DEFAULT_PERPAGE
447 );
448 $tableOptions['logsid'] = $this->positiveIntOrZero($tableOptions['logsid'] ?? 0);
449 return $tableOptions;
450 }
451
452 /**
453 * Normalize the status-filter value. Unlike paged/perpage/logsid, the
454 * filter legitimately carries negative sentinels alongside non-negative
455 * status/type codes: ABJ404_TRASH_FILTER (-1, the Trash tab) and
456 * ABJ404_HANDLED_FILTER (-2, the Captured "Handled" view). It must NOT pass
457 * through the positive-only sanitizer, which rejects the leading minus via
458 * its /^\d+$/ guard and silently coerces the sentinel to 0 (All) -- the
459 * defect that broke every Trash/Handled tab (incident 2026-06-20). Any
460 * other negative or non-numeric value still fails closed to 0.
461 *
462 * @param mixed $raw
463 */
464 private function normalizeFilter($raw): int {
465 if (!is_scalar($raw)) {
466 return 0;
467 }
468 $value = intval(trim((string)$raw));
469 if ($value === ABJ404_TRASH_FILTER || $value === ABJ404_HANDLED_FILTER) {
470 return $value;
471 }
472 return $value > 0 ? $value : 0;
473 }
474
475 /**
476 * @param mixed $raw
477 */
478 private function positiveIntOrZero($raw): int {
479 if (!is_scalar($raw)) {
480 return 0;
481 }
482 $raw = trim((string)$raw);
483 if ($raw === '' || preg_match('/^\d+$/', $raw) !== 1) {
484 return 0;
485 }
486 return intval($raw);
487 }
488 }
489