PluginProbe
Booking Calendar / 11.9
Booking Calendar v11.9
11.9 11.8.4 11.8.3 11.8.2 11.8.1 11.8 11.7 11.6.1 11.6 11.5 11.4.3 11.4.2 11.4.1 11.4 11.3 11.2.1 11.2 11.1 11.0 10.15.7 10.15.6 10.1.3 10.10 10.10.1 10.10.2 All 205 releases
booking / includes / _shared-ui-catalog / class-wpbc-ui-catalog-preferences.php

class-wpbc-ui-catalog-preferences.php in Booking Calendar 11.9, at includes/_shared-ui-catalog/class-wpbc-ui-catalog-preferences.php

294 lines 9.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Shared catalog preference namespace boundary.
4 *
5 * @package Booking Calendar
6 * @since 11.6.0
7 */
8
9 if ( ! defined( 'ABSPATH' ) ) {
10 exit;
11 }
12
13 /**
14 * Store validated per-user catalog preferences in a site-local namespace.
15 */
16 final class WPBC_UI_Catalog_Preferences {
17
18 /**
19 * Stored payload schema version.
20 *
21 * @var int
22 */
23 const STORAGE_VERSION = 1;
24
25 /**
26 * Prefix shared by all independent catalog preference namespaces.
27 *
28 * @var string
29 */
30 const NAMESPACE_PREFIX = 'wpbc_ui_catalog_';
31
32 /**
33 * Return the stable preference namespace for one catalog.
34 *
35 * @param string $catalog_id Catalog identifier.
36 *
37 * @return string Sanitized preference namespace, or an empty string.
38 */
39 public static function get_namespace( $catalog_id ) {
40 $catalog_id = is_scalar( $catalog_id ) ? sanitize_key( (string) $catalog_id ) : '';
41
42 return '' === $catalog_id ? '' : self::NAMESPACE_PREFIX . $catalog_id;
43 }
44
45 /**
46 * Return shared request keys that may later be stored as preferences.
47 *
48 * Search text and page number are intentionally request-local.
49 *
50 * @return array<int,string> Allow-listed preference keys.
51 */
52 public static function get_request_keys() {
53 return array(
54 'items_per_page',
55 'sort_by',
56 'sort_order',
57 'visible_columns',
58 'column_order',
59 'template_pack',
60 );
61 }
62
63 /**
64 * Extract only shared request values from a stored preference payload.
65 *
66 * Values remain untrusted and are revalidated by WPBC_UI_Catalog_Request.
67 *
68 * @param mixed $stored_preferences Raw stored preference payload.
69 *
70 * @return array<string,mixed> Allow-listed untrusted preference values.
71 */
72 public static function extract_request_values( $stored_preferences ) {
73 $preference_values = array();
74
75 if ( ! is_array( $stored_preferences ) ) {
76 return $preference_values;
77 }
78
79 foreach ( self::get_request_keys() as $request_key ) {
80 if ( array_key_exists( $request_key, $stored_preferences ) ) {
81 $preference_values[ $request_key ] = $stored_preferences[ $request_key ];
82 }
83 }
84
85 return $preference_values;
86 }
87
88 /**
89 * Load one user's site-local catalog preferences.
90 *
91 * WordPress prefixes non-global user options with the current site's table
92 * prefix. This keeps the same user's preferences isolated across multisite
93 * sites while the catalog namespace keeps independent catalogs isolated.
94 *
95 * @param string $catalog_id Catalog identifier.
96 * @param int $user_id WordPress user ID, or zero for the current user.
97 *
98 * @return array<string,mixed> Stored untrusted values, or an empty array.
99 */
100 public static function load( $catalog_id, $user_id = 0 ) {
101 $namespace = self::get_namespace( $catalog_id );
102 $user_id = $user_id ? absint( $user_id ) : get_current_user_id();
103 if ( '' === $namespace || ! $user_id ) {
104 return array();
105 }
106
107 $stored_payload = get_user_option( $namespace, $user_id );
108 if (
109 ! is_array( $stored_payload )
110 || self::STORAGE_VERSION !== ( isset( $stored_payload['version'] ) ? absint( $stored_payload['version'] ) : 0 )
111 || ! isset( $stored_payload['values'] )
112 || ! is_array( $stored_payload['values'] )
113 ) {
114 return array();
115 }
116
117 return $stored_payload['values'];
118 }
119
120 /**
121 * Save normalized shared and domain-owned preference values.
122 *
123 * Shared request values are extracted from the already validated request.
124 * Optional domain values must already be validated by the catalog provider;
125 * this method accepts only scalar values or scalar lists before storage.
126 *
127 * @param string $catalog_id Catalog identifier.
128 * @param WPBC_UI_Catalog_Request $request Validated shared request.
129 * @param array $additional_preferences Validated domain-owned values.
130 * @param int $user_id WordPress user ID, or zero for the current user.
131 * @param int|string $preference_revision Monotonic browser revision for stale-write protection.
132 *
133 * @return true|WP_Error True when stored, or a safe validation error.
134 */
135 public static function save( $catalog_id, $request, $additional_preferences = array(), $user_id = 0, $preference_revision = 0 ) {
136 $namespace = self::get_namespace( $catalog_id );
137 $user_id = $user_id ? absint( $user_id ) : get_current_user_id();
138 if ( '' === $namespace || ! $user_id || ! $request instanceof WPBC_UI_Catalog_Request || $catalog_id !== $request->get_catalog_id() ) {
139 return self::get_error( 'invalid_context', __( 'The catalog preferences could not be saved.', 'booking' ) );
140 }
141 if ( ! is_array( $additional_preferences ) ) {
142 return self::get_error( 'invalid_values', __( 'The catalog preferences are malformed.', 'booking' ) );
143 }
144
145 $preference_values = array();
146 foreach ( $additional_preferences as $preference_key => $preference_value ) {
147 $preference_key = is_scalar( $preference_key ) ? sanitize_key( (string) $preference_key ) : '';
148 $preference_value = self::normalize_storage_value( $preference_value );
149 if ( '' === $preference_key || is_wp_error( $preference_value ) ) {
150 return self::get_error( 'invalid_values', __( 'The catalog preferences are malformed.', 'booking' ) );
151 }
152 $preference_values[ $preference_key ] = $preference_value;
153 }
154
155 $request_values = self::extract_request_values( $request->to_array() );
156 $preference_revision = self::normalize_revision( $preference_revision );
157 $current_payload = get_user_option( $namespace, $user_id );
158 if ( self::revision_is_current( $current_payload, $preference_revision ) ) {
159 return true;
160 }
161
162 $payload = array(
163 'version' => self::STORAGE_VERSION,
164 'revision' => $preference_revision,
165 'values' => array_merge( $preference_values, $request_values ),
166 );
167 $was_updated = update_user_option( $user_id, $namespace, $payload, false );
168 if ( false === $was_updated && $payload !== get_user_option( $namespace, $user_id ) ) {
169 return self::get_error( 'write_failed', __( 'The catalog preferences could not be saved.', 'booking' ) );
170 }
171
172 return true;
173 }
174
175 /**
176 * Reset one user's site-local catalog preferences.
177 *
178 * @param string $catalog_id Catalog identifier.
179 * @param int $user_id WordPress user ID, or zero for the current user.
180 * @param int|string $preference_revision Monotonic browser revision for stale-write protection.
181 *
182 * @return bool Whether the preference is absent after the reset.
183 */
184 public static function reset( $catalog_id, $user_id = 0, $preference_revision = 0 ) {
185 $namespace = self::get_namespace( $catalog_id );
186 $user_id = $user_id ? absint( $user_id ) : get_current_user_id();
187 if ( '' === $namespace || ! $user_id ) {
188 return false;
189 }
190
191 $current_payload = get_user_option( $namespace, $user_id );
192 $preference_revision = self::normalize_revision( $preference_revision );
193 if ( self::revision_is_current( $current_payload, $preference_revision ) ) {
194 return true;
195 }
196 $reset_payload = array(
197 'version' => self::STORAGE_VERSION,
198 'revision' => $preference_revision,
199 'values' => array(),
200 );
201 $was_updated = update_user_option(
202 $user_id,
203 $namespace,
204 $reset_payload,
205 false
206 );
207 if ( false === $was_updated && $reset_payload !== get_user_option( $namespace, $user_id ) ) {
208 return false;
209 }
210
211 return array() === self::load( $catalog_id, $user_id );
212 }
213
214 /**
215 * Normalize a browser revision without depending on the PHP integer size.
216 *
217 * JavaScript millisecond timestamps exceed 32-bit integers. Keeping the
218 * revision as a canonical decimal string preserves ordering on every
219 * supported PHP architecture.
220 *
221 * @param mixed $preference_revision Candidate non-negative revision.
222 *
223 * @return string Canonical decimal revision.
224 */
225 private static function normalize_revision( $preference_revision ) {
226 if ( ! is_scalar( $preference_revision ) || ! preg_match( '/^\d+$/', (string) $preference_revision ) ) {
227 return '0';
228 }
229
230 $preference_revision = ltrim( (string) $preference_revision, '0' );
231
232 return '' === $preference_revision ? '0' : $preference_revision;
233 }
234
235 /**
236 * Determine whether an existing payload is at least as new as a request.
237 *
238 * @param mixed $current_payload Existing user-option payload.
239 * @param string $preference_revision Canonical incoming decimal revision.
240 *
241 * @return bool True when the incoming write must be ignored.
242 */
243 private static function revision_is_current( $current_payload, $preference_revision ) {
244 if ( ! is_array( $current_payload ) || ! isset( $current_payload['revision'] ) ) {
245 return false;
246 }
247
248 $current_revision = self::normalize_revision( $current_payload['revision'] );
249 if ( strlen( $current_revision ) !== strlen( $preference_revision ) ) {
250 return strlen( $current_revision ) > strlen( $preference_revision );
251 }
252
253 return 0 <= strcmp( $current_revision, $preference_revision );
254 }
255
256 /**
257 * Normalize one catalog-owned value for safe user-option storage.
258 *
259 * @param mixed $preference_value Validated domain preference candidate.
260 *
261 * @return scalar|array|WP_Error Storage-safe value or error.
262 */
263 private static function normalize_storage_value( $preference_value ) {
264 if ( is_scalar( $preference_value ) || null === $preference_value ) {
265 return $preference_value;
266 }
267 if ( ! is_array( $preference_value ) ) {
268 return self::get_error( 'invalid_value', __( 'A catalog preference is malformed.', 'booking' ) );
269 }
270
271 $normalized_values = array();
272 foreach ( $preference_value as $list_value ) {
273 if ( ! is_scalar( $list_value ) && null !== $list_value ) {
274 return self::get_error( 'invalid_value', __( 'A catalog preference is malformed.', 'booking' ) );
275 }
276 $normalized_values[] = $list_value;
277 }
278
279 return $normalized_values;
280 }
281
282 /**
283 * Create a namespaced preference error.
284 *
285 * @param string $error_code Short error code.
286 * @param string $error_message Safe localized message.
287 *
288 * @return WP_Error Preference error.
289 */
290 private static function get_error( $error_code, $error_message ) {
291 return new WP_Error( 'wpbc_ui_catalog_preferences_' . sanitize_key( $error_code ), $error_message );
292 }
293 }
294