PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / includes / class-metasync-per-page-helper.php

class-metasync-per-page-helper.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.2, at includes/class-metasync-per-page-helper.php

355 lines 13.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Per-page selector helper for Metasync admin list tables.
4 *
5 * Centralises the "results per page" control shared by the Redirections,
6 * 404 Monitor, Changes Log and Media Library (Media Optimization) lists.
7 *
8 * Responsibilities:
9 * - Define the allow-list of page sizes (10 / 20 / 50 / 100). No
10 * arbitrary user-supplied value ever reaches a query — anything outside
11 * this list is rejected and the per-page default for the page is used.
12 * - Resolve the active page size for a given admin list, preferring an
13 * explicit submission on that list's own request key, then the user's
14 * persisted preference (stored in user_meta), and finally the
15 * page-specific default. A submitted valid value is persisted back to
16 * user_meta so it survives across page loads and sessions — gated on a
17 * per-page nonce so a crafted link cannot change the stored preference.
18 * - Render an inline `<select>` with the allowed options, wired to refresh
19 * the current page with the new page size.
20 *
21 * Meta key scheme: `metasync_per_page_<page_key>` (one preference per page per
22 * user), e.g. `metasync_per_page_redirections`, `metasync_per_page_404_monitor`,
23 * `metasync_per_page_sync_log`, `metasync_per_page_media_library`.
24 *
25 * Request key scheme: `per_page_<page_key>`, one per list. A single shared
26 * `per_page` key cannot work here: the Redirections screen renders BOTH the
27 * Redirections and the 404 Monitor table in the same request (see
28 * Metasync_Redirections_Admin::render_tab_content()), so a shared key made each
29 * tab's selector overwrite the other tab's stored preference.
30 *
31 * @package Metasync
32 * @subpackage Metasync/includes
33 */
34
35 // If this file is called directly, abort.
36 if (!defined('ABSPATH')) {
37 exit;
38 }
39
40 class Metasync_Per_Page_Helper
41 {
42 /**
43 * Allowed results-per-page values. Anything else is rejected.
44 */
45 public const ALLOWED_VALUES = [10, 20, 50, 100];
46
47 /**
48 * Get the allow-list of page sizes.
49 *
50 * @return int[]
51 */
52 public static function allowed_values()
53 {
54 return self::ALLOWED_VALUES;
55 }
56
57 /**
58 * The request key carrying a submitted page size for a given list.
59 *
60 * One key per list, so two tables rendered onto the same screen (the
61 * Redirections page renders Redirections AND 404 Monitor in one request)
62 * cannot clobber each other's persisted preference.
63 *
64 * @param string $page_key Unique list identifier (e.g. 'redirections').
65 * @return string
66 */
67 public static function request_key($page_key)
68 {
69 return 'per_page_' . $page_key;
70 }
71
72 /**
73 * The pagination parameter belonging to a given list.
74 *
75 * Each list paginates on its own parameter, because the Redirections screen
76 * shows two tables at once and a shared `paged` would move both. Changing
77 * one list's page size must reset THAT list to page 1 and leave the other
78 * table's page alone.
79 *
80 * @param string $page_key Unique list identifier.
81 * @return string
82 */
83 public static function paged_param($page_key)
84 {
85 $map = [
86 'redirections' => 'paged_redir',
87 '404_monitor' => 'paged_404',
88 'media_library' => 'paged_media',
89 'sync_log' => 'paged',
90 ];
91
92 return isset($map[$page_key]) ? $map[$page_key] : 'paged';
93 }
94
95 /**
96 * Query parameters that describe the visible state of each list.
97 *
98 * Keeping this allow-list beside the per-page contract ensures browser
99 * navigation never copies bulk actions, selected rows or nonces into URLs.
100 *
101 * @param string $page_key Unique list identifier.
102 * @return string[]
103 */
104 public static function state_params($page_key)
105 {
106 $map = [
107 'redirections' => ['status_filter', 'pattern_filter', 'http_code_filter', 's_redir', 'orderby_redir', 'order_redir'],
108 '404_monitor' => ['date_from', 'date_to', 'min_hits', 's_404', 'orderby_404', 'order_404'],
109 'media_library' => ['status_filter', 's', 'orderby_media', 'order_media'],
110 'sync_log' => ['date_range', 'status'],
111 ];
112
113 return isset($map[$page_key]) ? $map[$page_key] : [];
114 }
115
116 /**
117 * Return sanitized, allow-listed request state for links and redirects.
118 *
119 * @param string $page_key Unique list identifier.
120 * @param array|null $request Request source; defaults to $_REQUEST.
121 * @return array<string,string>
122 */
123 public static function request_state($page_key, $request = null)
124 {
125 $request = is_array($request) ? $request : $_REQUEST; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed list state for links
126 $keys = array_merge(self::state_params($page_key), [self::request_key($page_key)]);
127 $state = [];
128
129 foreach ($keys as $key) {
130 if (!isset($request[$key]) || !is_scalar($request[$key])) {
131 continue;
132 }
133
134 $value = (string) $request[$key];
135 if ($value === '') {
136 continue;
137 }
138
139 if ($key === self::request_key($page_key) && !in_array((int) $value, self::ALLOWED_VALUES, true)) {
140 continue;
141 }
142
143 if (function_exists('wp_unslash')) {
144 $value = wp_unslash($value);
145 }
146 $state[$key] = sanitize_text_field($value);
147 }
148
149 // Older links used WordPress's shared `s` parameter. Canonicalize it
150 // into the list-specific key without allowing the two tables rendered
151 // on the combined screen to overwrite one another on new requests.
152 $search_keys = [
153 'redirections' => 's_redir',
154 '404_monitor' => 's_404',
155 ];
156 if (isset($search_keys[$page_key])) {
157 $search_key = $search_keys[$page_key];
158 if (!isset($state[$search_key]) && isset($request['s']) && is_scalar($request['s'])) {
159 $value = (string) $request['s'];
160 if ($value !== '') {
161 if (function_exists('wp_unslash')) {
162 $value = wp_unslash($value);
163 }
164 $state[$search_key] = sanitize_text_field($value);
165 }
166 }
167 }
168
169 return $state;
170 }
171
172 /**
173 * Nonce action protecting persistence of a page-size preference.
174 *
175 * The selector's navigation is a plain GET (no form submit), so the nonce
176 * travels as a `_wpnonce` query parameter added by metasync-per-page.js
177 * and is verified in resolve() before anything is written to user_meta.
178 *
179 * @param string $page_key Unique list identifier.
180 * @return string
181 */
182 public static function nonce_action($page_key)
183 {
184 return 'metasync_per_page_' . $page_key;
185 }
186
187 /**
188 * Page keys that have a results-per-page selector. Kept beside the
189 * resolution logic so the localized nonce map (page_nonces()) and the
190 * persistence gate in resolve() cannot drift apart.
191 *
192 * @return string[]
193 */
194 public static function page_keys()
195 {
196 return ['redirections', '404_monitor', 'media_library', 'sync_log'];
197 }
198
199 /**
200 * Nonce for each selector page key, for wp_localize_script().
201 *
202 * @return array<string,string>
203 */
204 public static function page_nonces()
205 {
206 $nonces = [];
207 foreach (self::page_keys() as $page_key) {
208 $nonces[$page_key] = wp_create_nonce(self::nonce_action($page_key));
209 }
210 return $nonces;
211 }
212
213 /**
214 * Resolve the active per-page value for a given admin list.
215 *
216 * Resolution order:
217 * 1. `$_REQUEST[$request_key]` when present AND a valid allow-list
218 * integer — applied to this request, and persisted to user_meta
219 * when the request also carries a valid `metasync_per_page_<page_key>`
220 * nonce (added by the selector's own navigation) so only the
221 * selector itself can change the stored preference.
222 * 2. The user's persisted preference (`metasync_per_page_<page_key>`),
223 * when it is a valid allow-list integer.
224 * 3. The supplied `$default`.
225 *
226 * Server-side validation guarantees no arbitrary user-supplied value can
227 * reach a query's LIMIT.
228 *
229 * @param string $page_key Unique list identifier (e.g. 'redirections').
230 * @param int $default Fallback page size for this list.
231 * @param string|null $request_key Query parameter carrying the submitted
232 * value. Defaults to this list's own key.
233 * @return int Validated page size from the allow-list.
234 */
235 public static function resolve($page_key, $default, $request_key = null)
236 {
237 $default = self::clamp_default($default);
238 $request_key = $request_key === null ? self::request_key($page_key) : $request_key;
239 $user_id = function_exists('get_current_user_id') ? get_current_user_id() : 0;
240 $meta_key = 'metasync_per_page_' . $page_key;
241
242 // 1) A freshly submitted value wins and is persisted — but only when
243 // the request carries the per-page nonce. A nonce-less URL (shared,
244 // bookmarked, forged) still applies the value for this one request
245 // without writing the user's stored preference.
246 if (isset($_REQUEST[$request_key])) {
247 $submitted = (int) $_REQUEST[$request_key];
248 if (in_array($submitted, self::ALLOWED_VALUES, true)) {
249 $nonce_ok = false;
250 if (isset($_REQUEST['_wpnonce']) && is_scalar($_REQUEST['_wpnonce'])) {
251 $nonce = sanitize_key(wp_unslash($_REQUEST['_wpnonce']));
252 if ($nonce !== '' && function_exists('wp_verify_nonce')) {
253 $nonce_ok = (bool) wp_verify_nonce($nonce, self::nonce_action($page_key));
254 }
255 }
256 if ($nonce_ok && $user_id > 0 && function_exists('update_user_meta')) {
257 update_user_meta($user_id, $meta_key, $submitted);
258 }
259 return $submitted;
260 }
261 }
262
263 // 2) Fall back to the user's saved preference.
264 if ($user_id > 0 && function_exists('get_user_meta')) {
265 $saved = get_user_meta($user_id, $meta_key, true);
266 if ($saved !== '' && $saved !== null && $saved !== false) {
267 $saved = (int) $saved;
268 if (in_array($saved, self::ALLOWED_VALUES, true)) {
269 return $saved;
270 }
271 }
272 }
273
274 // 3) Finally, the page default.
275 return $default;
276 }
277
278 /**
279 * Render an inline rows-per-page `<select>` for the given list.
280 *
281 * The external admin change handler reloads the current URL with the new
282 * page size, so this works without a surrounding <form> and preserves the
283 * rest of the query string. It resets only THIS list's
284 * pagination parameter: page numbers for this list stop meaning anything
285 * once its page size changes, but the other table on a combined screen has
286 * not resized and must keep its page.
287 *
288 * WP_List_Table renders the table nav twice (top and bottom), so callers
289 * must pass `$instance` (the `$which` they were given) to keep the `id`
290 * unique — a repeated `id` is invalid HTML and makes the `<label for>`
291 * ambiguous.
292 *
293 * @param string $page_key Unique list identifier.
294 * @param int $current Currently active page size (selected option).
295 * @param string $instance Position/instance suffix ('top', 'bottom', ...).
296 * Omit for a list that renders one selector.
297 * @param string|null $request_key Query parameter to set on change.
298 * Defaults to this list's own key.
299 * @return string HTML markup for the selector (not echoed).
300 */
301 public static function render_selector($page_key, $current, $instance = '', $request_key = null)
302 {
303 $current = (int) $current;
304 $request_key = $request_key === null ? self::request_key($page_key) : $request_key;
305 $paged_param = self::paged_param($page_key);
306
307 $id = 'metasync-per-page-' . $page_key;
308 if ($instance !== '') {
309 $id .= '-' . $instance;
310 }
311
312 $options = '';
313 foreach (self::ALLOWED_VALUES as $value) {
314 $options .= sprintf(
315 '<option value="%d"%s>%d</option>',
316 $value,
317 selected($value, $current, false),
318 $value
319 );
320 }
321
322 return sprintf(
323 '<span class="metasync-per-page" style="margin-left:8px;">'
324 . '<label for="%1$s" class="screen-reader-text">%2$s</label>'
325 . '<select id="%1$s" name="%3$s" class="metasync-per-page-select" '
326 . 'data-per-page-key="%3$s" data-paged-param="%4$s" '
327 . 'data-page-key="%5$s" data-state-params="%6$s">'
328 . '%7$s'
329 . '</select>'
330 . '</span>',
331 esc_attr($id),
332 esc_html__('Rows per page', 'metasync'),
333 esc_attr($request_key),
334 esc_attr($paged_param),
335 esc_attr($page_key),
336 esc_attr(wp_json_encode(self::state_params($page_key))),
337 $options
338 );
339 }
340
341 /**
342 * Ensure the supplied default is itself a valid allow-list value,
343 * otherwise coerce to the first allow-list entry. Keeps callers safe
344 * even if a custom default slips through.
345 *
346 * @param int $default
347 * @return int
348 */
349 private static function clamp_default($default)
350 {
351 $default = (int) $default;
352 return in_array($default, self::ALLOWED_VALUES, true) ? $default : self::ALLOWED_VALUES[0];
353 }
354 }
355