PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.8
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.8, at includes/session.php

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