PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.0
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.0
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / session.php

session.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.0, at includes/session.php

517 lines 17.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — Session Persistence.
4 *
5 * Persists each user's open desktop windows — URLs, positions, sizes,
6 * states, and which window was focused — to user meta so a session can
7 * be restored across page loads and, via the `/desktop-mode` portal,
8 * across devices. Cross-device viewport adaptation (a window that sat
9 * in the far-right corner of a 3440px ultrawide landing sanely on a
10 * 1280px laptop) happens client-side on restore.
11 *
12 * @package WPDesktopMode
13 */
14
15 defined( 'ABSPATH' ) || exit;
16
17 /** User meta key holding the serialized desktop session. */
18 const DESKTOP_MODE_SESSION_META_KEY = 'desktop_mode_session';
19
20 /** Hard cap on persisted windows — guards against runaway meta size. */
21 const DESKTOP_MODE_SESSION_MAX_WINDOWS = 32;
22
23 /** Hard cap on persisted desktops ("Spaces"). Generous — power-users
24 * with 8+ desktops are vanishingly rare, and we'd rather drop tail
25 * desktops than balloon user meta. */
26 const DESKTOP_MODE_SESSION_MAX_DESKTOPS = 16;
27
28 /** Allowed values for a window's state field. */
29 const DESKTOP_MODE_SESSION_STATES = array( 'normal', 'minimized', 'maximized', 'fullscreen' );
30
31 /** Default desktop entry seeded into empty / corrupt sessions. */
32 function desktop_mode_default_desktop() {
33 return array(
34 'id' => 'desktop-1',
35 'label' => 'Desktop 1',
36 );
37 }
38
39 /**
40 * Returns the default empty session shape.
41 *
42 * Includes a default desktop ("Desktop 1") so the client can always
43 * assume at least one desktop exists at boot — the shell can't
44 * function with zero desktops.
45 *
46 * @since 0.4.0
47 *
48 * @return array{windows: array, desktops: array, activeDesktop: string, focused: string, updated: int}
49 */
50 function desktop_mode_empty_session() {
51 return array(
52 'windows' => array(),
53 'desktops' => array( desktop_mode_default_desktop() ),
54 'activeDesktop' => 'desktop-1',
55 'focused' => '',
56 'updated' => 0,
57 );
58 }
59
60 /**
61 * Retrieves the saved desktop session for a user.
62 *
63 * Always returns a well-shaped array so callers don't have to defend
64 * against corrupt or partial meta.
65 *
66 * @since 0.4.0
67 *
68 * @param int $user_id The user ID.
69 * @return array{windows: array, focused: string, updated: int}
70 */
71 function desktop_mode_get_session( $user_id ) {
72 $user_id = (int) $user_id;
73 if ( $user_id <= 0 ) {
74 return desktop_mode_empty_session();
75 }
76
77 $raw = get_user_meta( $user_id, DESKTOP_MODE_SESSION_META_KEY, true );
78 if ( ! is_array( $raw ) ) {
79 return desktop_mode_empty_session();
80 }
81
82 // Desktops + activeDesktop are post-0.4.0 additions. Sessions
83 // saved before they existed don't carry either field — fall back
84 // to the single default desktop so older sessions degrade
85 // gracefully rather than booting into a zero-desktop limbo.
86 $desktops = isset( $raw['desktops'] ) && is_array( $raw['desktops'] )
87 ? array_values( $raw['desktops'] )
88 : array( desktop_mode_default_desktop() );
89 $active_desktop = isset( $raw['activeDesktop'] ) ? (string) $raw['activeDesktop'] : 'desktop-1';
90
91 return array(
92 'windows' => isset( $raw['windows'] ) && is_array( $raw['windows'] ) ? array_values( $raw['windows'] ) : array(),
93 'desktops' => $desktops,
94 'activeDesktop' => $active_desktop,
95 'focused' => isset( $raw['focused'] ) ? (string) $raw['focused'] : '',
96 'updated' => isset( $raw['updated'] ) ? (int) $raw['updated'] : 0,
97 );
98 }
99
100 /**
101 * Persists a sanitized desktop session to user meta.
102 *
103 * Rejects writes whose `updated` timestamp is older than what's
104 * already on file — a simple last-write-wins guard that prevents two
105 * tabs open on the same user from clobbering each other. The client
106 * stamps `updated` with `Math.floor(Date.now() / 1000)` at snapshot
107 * time (see `WindowManager.snapshot`), so this comparison lines up
108 * with real wall-clock ordering on same-machine multi-tab setups.
109 *
110 * Equal timestamps (two writes in the same second) are accepted —
111 * that's a tie and whichever the server processes first wins, which
112 * matches the pre-0.8 behavior for simultaneous saves.
113 *
114 * @since 0.4.0
115 *
116 * @param int $user_id The user ID.
117 * @param array $session Raw session payload (will be sanitized).
118 * @return bool True on success, false when stale / invalid / failed.
119 */
120 function desktop_mode_save_session( $user_id, $session ) {
121 $user_id = (int) $user_id;
122 if ( $user_id <= 0 ) {
123 return false;
124 }
125
126 if ( is_array( $session ) && isset( $session['updated'] ) ) {
127 $incoming = (int) $session['updated'];
128 if ( $incoming > 0 ) {
129 $existing = desktop_mode_get_session( $user_id );
130 $stored = isset( $existing['updated'] ) ? (int) $existing['updated'] : 0;
131 if ( $incoming < $stored ) {
132 // Stale write — another tab saved a newer snapshot
133 // after this one was taken. Bail so the user's latest
134 // work isn't overwritten by a slow-to-arrive payload.
135 return false;
136 }
137 }
138 }
139
140 $clean = desktop_mode_sanitize_session( $session );
141
142 return false !== update_user_meta( $user_id, DESKTOP_MODE_SESSION_META_KEY, $clean );
143 }
144
145 /**
146 * Clears a user's saved desktop session.
147 *
148 * @since 0.4.0
149 *
150 * @param int $user_id The user ID.
151 * @return bool True on success.
152 */
153 function desktop_mode_clear_session( $user_id ) {
154 $user_id = (int) $user_id;
155 if ( $user_id <= 0 ) {
156 return false;
157 }
158 return (bool) delete_user_meta( $user_id, DESKTOP_MODE_SESSION_META_KEY );
159 }
160
161 /**
162 * Sanitizes a session payload before persistence.
163 *
164 * Rejects windows whose `url` isn't a same-origin admin URL, clamps
165 * geometry to sane integer ranges, and normalizes the state enum.
166 * Windows beyond {@see DESKTOP_MODE_SESSION_MAX_WINDOWS} are dropped.
167 *
168 * @since 0.4.0
169 *
170 * @param mixed $session Raw session data from the client.
171 * @return array{windows: array, focused: string, updated: int}
172 */
173 function desktop_mode_sanitize_session( $session ) {
174 $clean = desktop_mode_empty_session();
175
176 if ( ! is_array( $session ) ) {
177 $clean['updated'] = time();
178 return $clean;
179 }
180
181 // Preserve the client's `updated` timestamp so the stale-write guard
182 // in desktop_mode_save_session compares client-to-client (not client-to-server
183 // wallclock) — two saves landing in the same second must tie, not lose.
184 $incoming_updated = isset( $session['updated'] ) ? (int) $session['updated'] : 0;
185 $clean['updated'] = $incoming_updated > 0 ? $incoming_updated : time();
186
187 if ( isset( $session['focused'] ) && is_string( $session['focused'] ) ) {
188 $clean['focused'] = sanitize_key( $session['focused'] );
189 }
190
191 // --- Desktops list -------------------------------------------
192 // Build a sanitized desktops array first so we can validate
193 // per-window desktopId against it below — windows assigned to
194 // non-existent desktops are quietly remapped to the active
195 // desktop on restore client-side, but we want server-side
196 // integrity too.
197 $desktop_ids = array();
198 if ( isset( $session['desktops'] ) && is_array( $session['desktops'] ) ) {
199 $clean_desktops = array();
200 foreach ( $session['desktops'] as $d ) {
201 if ( ! is_array( $d ) ) {
202 continue;
203 }
204 $d_id = isset( $d['id'] ) ? sanitize_key( (string) $d['id'] ) : '';
205 if ( '' === $d_id ) {
206 continue;
207 }
208 $d_label = isset( $d['label'] ) ? wp_strip_all_tags( (string) $d['label'] ) : '';
209 if ( '' === $d_label ) {
210 $d_label = $d_id;
211 }
212 // 64-char cap on labels — generous for any sensible
213 // human-typed desktop name, hard ceiling on meta size.
214 if ( strlen( $d_label ) > 64 ) {
215 $d_label = substr( $d_label, 0, 64 );
216 }
217 $clean_desktops[] = array(
218 'id' => $d_id,
219 'label' => $d_label,
220 );
221 $desktop_ids[] = $d_id;
222 if ( count( $clean_desktops ) >= DESKTOP_MODE_SESSION_MAX_DESKTOPS ) {
223 break;
224 }
225 }
226 if ( ! empty( $clean_desktops ) ) {
227 $clean['desktops'] = $clean_desktops;
228 }
229 }
230 // Always at least one desktop in the persisted shape — guards
231 // against a client clearing every desktop and saving an empty
232 // list, or omitting the key entirely.
233 if ( empty( $clean['desktops'] ) ) {
234 $clean['desktops'] = array( desktop_mode_default_desktop() );
235 }
236 if ( empty( $desktop_ids ) ) {
237 // Rebuild ids from the authoritative desktops list so the
238 // per-window desktopId validation below has something to
239 // compare against — otherwise a client that omits `desktops`
240 // but sends windows would hit `$desktop_ids[0]` on an empty
241 // array.
242 $desktop_ids = array_map(
243 static function ( $d ) {
244 return isset( $d['id'] ) ? (string) $d['id'] : '';
245 },
246 $clean['desktops']
247 );
248 $desktop_ids = array_values( array_filter( $desktop_ids ) );
249 if ( empty( $desktop_ids ) ) {
250 $desktop_ids = array( 'desktop-1' );
251 }
252 }
253
254 // --- Active desktop ------------------------------------------
255 if ( isset( $session['activeDesktop'] ) && is_string( $session['activeDesktop'] ) ) {
256 $candidate = sanitize_key( $session['activeDesktop'] );
257 if ( in_array( $candidate, $desktop_ids, true ) ) {
258 $clean['activeDesktop'] = $candidate;
259 }
260 }
261 // Fallback: first valid desktop. Already true via desktop_mode_empty_session
262 // when the client passed nothing, but guards the case where
263 // activeDesktop named a desktop that didn't survive sanitization.
264 if ( ! in_array( $clean['activeDesktop'], $desktop_ids, true ) ) {
265 $clean['activeDesktop'] = $desktop_ids[ 0 ];
266 }
267
268 if ( isset( $session['windows'] ) && is_array( $session['windows'] ) ) {
269 foreach ( $session['windows'] as $win ) {
270 if ( ! is_array( $win ) ) {
271 continue;
272 }
273
274 $id = isset( $win['id'] ) ? sanitize_key( (string) $win['id'] ) : '';
275 if ( '' === $id ) {
276 continue;
277 }
278
279 // `baseId` groups multi-instance windows of the same admin page
280 // (e.g. `edit-php`, `edit-php-2`, `edit-php-3` all share baseId
281 // `edit-php`). Optional — older sessions predate the field and
282 // the client falls back to `id` when missing.
283 $base_id = isset( $win['baseId'] ) ? sanitize_key( (string) $win['baseId'] ) : '';
284 if ( '' === $base_id ) {
285 $base_id = $id;
286 }
287
288 $url = isset( $win['url'] ) ? esc_url_raw( (string) $win['url'] ) : '';
289 // Only allow URLs that land inside our own wp-admin — both
290 // a safety net against storing arbitrary origins in user meta
291 // and a guarantee the restore path won't try to iframe a
292 // cross-origin page. Host+path parsing rejects tricks like
293 // `//evil.com/wp-admin/…` that a raw prefix check would miss.
294 if ( '' === $url || ! desktop_mode_url_is_same_admin( $url ) ) {
295 continue;
296 }
297 // Strip transient/routing flags before storage. The chromeless
298 // `desktop_mode_chromeless` flag is an iframe-only concern and must never
299 // end up in a top-level URL (e.g., the portal's entry URL);
300 // the portal and classic flags only live on a single request.
301 $url = remove_query_arg(
302 array( 'desktop_mode_chromeless', DESKTOP_MODE_PORTAL_FLAG, DESKTOP_MODE_CLASSIC_FLAG ),
303 $url
304 );
305
306 $state = isset( $win['state'] ) ? (string) $win['state'] : 'normal';
307 if ( ! in_array( $state, DESKTOP_MODE_SESSION_STATES, true ) ) {
308 $state = 'normal';
309 }
310
311 // Map the window to a known desktop. A client that sends a
312 // desktopId pointing at a non-existent desktop (race with
313 // a desktop close, or a malicious payload) is silently
314 // remapped to the active desktop so the window remains
315 // visible — losing it on restore would be the worse UX.
316 $win_desktop = isset( $win['desktopId'] ) ? sanitize_key( (string) $win['desktopId'] ) : '';
317 if ( '' === $win_desktop || ! in_array( $win_desktop, $desktop_ids, true ) ) {
318 $win_desktop = $clean['activeDesktop'];
319 }
320
321 $entry = array(
322 'id' => $id,
323 'baseId' => $base_id,
324 'desktopId' => $win_desktop,
325 'url' => $url,
326 'title' => isset( $win['title'] ) ? wp_strip_all_tags( (string) $win['title'] ) : '',
327 'icon' => isset( $win['icon'] ) ? sanitize_html_class( (string) $win['icon'] ) : 'dashicons-admin-generic',
328 'state' => $state,
329 'x' => desktop_mode_sanitize_session_dimension( $win['x'] ?? 0, -10000, 10000 ),
330 'y' => desktop_mode_sanitize_session_dimension( $win['y'] ?? 0, -10000, 10000 ),
331 'width' => desktop_mode_sanitize_session_dimension( $win['width'] ?? 800, 0, 20000 ),
332 'height' => desktop_mode_sanitize_session_dimension( $win['height'] ?? 600, 0, 20000 ),
333 );
334
335 // Sanitize external sub-tabs. Each entry carries a URL
336 // (any http/https — external tabs are explicitly for links
337 // OUT of wp-admin, so we don't restrict to same-origin
338 // here) and a label. Capped at a reasonable per-window
339 // limit so a runaway client can't balloon user meta.
340 if ( isset( $win['externalTabs'] ) && is_array( $win['externalTabs'] ) ) {
341 $tabs = array();
342 foreach ( $win['externalTabs'] as $tab ) {
343 if ( ! is_array( $tab ) ) {
344 continue;
345 }
346 $tab_url = isset( $tab['url'] ) ? esc_url_raw( (string) $tab['url'], array( 'http', 'https' ) ) : '';
347 if ( '' === $tab_url ) {
348 continue;
349 }
350 // Hard cap on URL length — a runaway client (or a
351 // malicious payload) could otherwise push many
352 // megabytes of URL into user meta. 2048 is the
353 // de-facto IE-legacy URL length limit and covers
354 // every real URL the shell restores.
355 if ( strlen( $tab_url ) > 2048 ) {
356 continue;
357 }
358 $label = isset( $tab['label'] ) ? wp_strip_all_tags( (string) $tab['label'] ) : '';
359 // Trim long labels server-side too, mirroring the
360 // client-side 80-char slice in the chromeless
361 // bridge. Keeps meta size predictable.
362 if ( strlen( $label ) > 80 ) {
363 $label = substr( $label, 0, 80 );
364 }
365 $tabs[] = array(
366 'url' => $tab_url,
367 'label' => $label,
368 );
369 if ( count( $tabs ) >= 16 ) {
370 break;
371 }
372 }
373 if ( ! empty( $tabs ) ) {
374 $entry['externalTabs'] = $tabs;
375 }
376 }
377
378 $clean['windows'][] = $entry;
379
380 if ( count( $clean['windows'] ) >= DESKTOP_MODE_SESSION_MAX_WINDOWS ) {
381 break;
382 }
383 }
384 }
385
386 return $clean;
387 }
388
389 /**
390 * Clamps a numeric dimension into a sane range.
391 *
392 * Geometry coming from the client is untrusted. A malicious or buggy
393 * payload could try to stash multi-million-pixel values in meta,
394 * negative values that break the shell, or non-numeric garbage
395 * (strings, arrays, objects). This enforces numeric type and min/max
396 * bounds, falling back to `$min` for anything non-numeric so the
397 * window restores to a sane geometry rather than colliding with 0.
398 *
399 * `INF`, `NAN`, and array/object input are rejected by `is_numeric()`
400 * before the `(int)` cast, eliminating any overflow or type-juggling
401 * surprise.
402 *
403 * @since 0.4.0
404 * @since 0.11.0 Rejects non-numeric input explicitly instead of
405 * relying on PHP's permissive `(int)` cast.
406 *
407 * @param mixed $value The raw value.
408 * @param int $min Minimum allowed value.
409 * @param int $max Maximum allowed value.
410 * @return int The clamped integer.
411 */
412 function desktop_mode_sanitize_session_dimension( $value, $min, $max ) {
413 if ( is_string( $value ) ) {
414 $value = trim( $value );
415 }
416 if ( ! is_numeric( $value ) ) {
417 return (int) $min;
418 }
419 $value = (int) $value;
420 if ( $value < $min ) {
421 return (int) $min;
422 }
423 if ( $value > $max ) {
424 return (int) $max;
425 }
426 return $value;
427 }
428
429 /**
430 * Registers the REST routes used by the desktop shell to load and save
431 * the current user's session.
432 *
433 * @since 0.4.0
434 */
435 function desktop_mode_register_session_rest_routes() {
436 register_rest_route(
437 'desktop-mode/v1',
438 '/session',
439 array(
440 array(
441 'methods' => WP_REST_Server::READABLE,
442 'callback' => 'desktop_mode_rest_get_session',
443 'permission_callback' => 'desktop_mode_rest_session_permission',
444 ),
445 array(
446 'methods' => WP_REST_Server::CREATABLE,
447 'callback' => 'desktop_mode_rest_save_session',
448 'permission_callback' => 'desktop_mode_rest_session_permission',
449 'args' => array(
450 'session' => array(
451 'required' => true,
452 'type' => 'object',
453 ),
454 ),
455 ),
456 array(
457 'methods' => WP_REST_Server::DELETABLE,
458 'callback' => 'desktop_mode_rest_clear_session',
459 'permission_callback' => 'desktop_mode_rest_session_permission',
460 ),
461 )
462 );
463 }
464 add_action( 'rest_api_init', 'desktop_mode_register_session_rest_routes' );
465
466 /**
467 * Permission gate for the session REST routes: logged-in users who have
468 * desktop mode enabled. See {@see desktop_mode_rest_require_enabled()}
469 * for why `read` alone is insufficient.
470 *
471 * @since 0.4.0
472 * @since 0.8.10 Hardened to require desktop mode enabled (was `read`).
473 *
474 * @return true|WP_Error
475 */
476 function desktop_mode_rest_session_permission() {
477 return desktop_mode_rest_require_enabled();
478 }
479
480 /**
481 * GET /desktop-mode/v1/session — returns the caller's session.
482 *
483 * @since 0.4.0
484 *
485 * @return WP_REST_Response
486 */
487 function desktop_mode_rest_get_session() {
488 return rest_ensure_response( desktop_mode_get_session( get_current_user_id() ) );
489 }
490
491 /**
492 * POST /desktop-mode/v1/session — replaces the caller's session.
493 *
494 * @since 0.4.0
495 *
496 * @param WP_REST_Request $request The REST request.
497 * @return WP_REST_Response The stored session (after sanitization).
498 */
499 function desktop_mode_rest_save_session( WP_REST_Request $request ) {
500 $user_id = get_current_user_id();
501 $payload = $request->get_param( 'session' );
502 desktop_mode_save_session( $user_id, $payload );
503 return rest_ensure_response( desktop_mode_get_session( $user_id ) );
504 }
505
506 /**
507 * DELETE /desktop-mode/v1/session — clears the caller's session.
508 *
509 * @since 0.4.0
510 *
511 * @return WP_REST_Response
512 */
513 function desktop_mode_rest_clear_session() {
514 desktop_mode_clear_session( get_current_user_id() );
515 return rest_ensure_response( desktop_mode_empty_session() );
516 }
517