PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / trunk
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin vtrunk
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 0.8.6 0.8.5 0.8.4 All 31 releases
desktop-mode / includes / session.php

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

885 lines 32.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — 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 `/openstation` 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 OpenStation
13 */
14
15 defined( 'ABSPATH' ) || exit;
16
17 /**
18 * User meta key holding the serialized desktop session.
19 *
20 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
21 * persisted or externally-visible identifier, so renaming it would
22 * orphan data already written by live installs (or break a live
23 * URL). The mismatch between this constant's name and its value is
24 * deliberate — it is NOT a half-finished rename.
25 */
26 const OPENSTATION_SESSION_META_KEY = 'desktop_mode_session';
27
28 /**
29 * The session meta key for the admin the request is running against.
30 *
31 * User meta is network-wide, so every site shared one blob, and the
32 * sanitizer drops any window URL outside the current site's
33 * `admin_url()` — so the first save from site B rewrote it and site A's
34 * desktop was gone. The MAIN site keeps the bare key, and so does every
35 * single-site install: those sessions already exist, and a new key would
36 * silently empty every desktop on upgrade.
37 *
38 * The NETWORK admin gets its own key rather than sharing the main
39 * site's, even though it runs in the main site's blog context. The two
40 * desktops derive the same window ids from different admins —
41 * `index-php` is the site dashboard on one and the network dashboard on
42 * the other — so one shared blob meant the network desktop restored the
43 * site's dashboard window (and the reverse), and the dock's Dashboard
44 * tile focused the wrong admin's screen.
45 *
46 * @param bool|null $network The network admin's session (true) or the
47 * current site's (false). Null follows the
48 * request, which is wrong on `admin-ajax.php`
49 * and REST — those callers must pass what the
50 * client reported.
51 * @return string Meta key to read and write.
52 */
53 function openstation_session_meta_key( $network = null ) {
54 if ( null === $network ) {
55 $network = is_multisite() && is_network_admin();
56 }
57 if ( $network ) {
58 return OPENSTATION_SESSION_META_KEY . '_network';
59 }
60 return ! is_multisite() || get_current_blog_id() === get_main_site_id()
61 ? OPENSTATION_SESSION_META_KEY
62 : OPENSTATION_SESSION_META_KEY . '_' . get_current_blog_id();
63 }
64
65 /**
66 * Whether a persisted window URL belongs to the session's admin.
67 *
68 * The scope gate behind the per-admin meta keys: keys separate the
69 * blobs, this separates their CONTENTS, so a blob written before the
70 * keys split (or by an older client posting to the wrong scope) heals
71 * on read instead of restoring one admin's window on the other's
72 * desktop. Runs on read and on sanitize both.
73 *
74 * @param string $url Absolute window URL, already same-admin checked.
75 * @param bool $network Whether the session is the network admin's.
76 * @return bool True when the URL lives in the session's own admin.
77 */
78 function openstation_session_url_in_scope( $url, $network ) {
79 $path = wp_parse_url( $url, PHP_URL_PATH );
80 $in_network = is_string( $path ) && false !== strpos( $path, '/wp-admin/network/' );
81 return $in_network === (bool) $network;
82 }
83
84 /**
85 * Whether a window URL may persist in this session: same-origin, under
86 * this site's admin path, on the right side of the network split. The
87 * rule every window has to satisfy — on a network every site is its
88 * own OpenStation, and one admin's window never persists into
89 * another's session.
90 *
91 * @param string $url Absolute window URL.
92 * @param bool $network Whether the session is the network admin's.
93 * @return bool
94 */
95 function openstation_session_window_url_ok( $url, $network ) {
96 return openstation_url_is_same_admin( $url ) && openstation_session_url_in_scope( $url, $network );
97 }
98
99 /** Hard cap on persisted windows — guards against runaway meta size. */
100 const OPENSTATION_SESSION_MAX_WINDOWS = 32;
101
102 /**
103 * Hard cap on a native window's persisted open-time params. These are
104 * "which user / which customer / which tab" — a handful of scalars,
105 * never a payload. The cap is what stops a careless (or hostile)
106 * client turning the session blob into a data store.
107 */
108 const OPENSTATION_SESSION_MAX_PARAMS = 12;
109
110 /** Hard cap on persisted desktops ("Spaces"). Generous — power-users
111 * with 8+ desktops are vanishingly rare, and we'd rather drop tail
112 * desktops than balloon user meta. */
113 const OPENSTATION_SESSION_MAX_DESKTOPS = 16;
114
115 /** Allowed values for a window's state field. */
116 const OPENSTATION_SESSION_STATES = array( 'normal', 'minimized', 'maximized', 'fullscreen' );
117
118 /**
119 * Current time as epoch milliseconds.
120 *
121 * The session's `updated` field is the ordering key for the
122 * stale-write guard and the client stamps it with `Date.now()`.
123 * Server-side fallbacks have to speak the same unit — see
124 * {@see openstation_save_session()} for why the resolution matters.
125 *
126 * @return int Epoch milliseconds.
127 */
128 function openstation_session_now_ms() {
129 return (int) round( microtime( true ) * 1000 );
130 }
131
132 /** Default desktop entry seeded into empty / corrupt sessions. */
133 function openstation_default_desktop() {
134 return array(
135 'id' => 'desktop-1',
136 'label' => 'Desktop 1',
137 );
138 }
139
140 /**
141 * Returns the default empty session shape.
142 *
143 * Includes a default desktop ("Desktop 1") so the client can always
144 * assume at least one desktop exists at boot — the shell can't
145 * function with zero desktops.
146 *
147 * @return array{windows: array, desktops: array, activeDesktop: string, focused: string, updated: int}
148 */
149 function openstation_empty_session() {
150 return array(
151 'windows' => array(),
152 'desktops' => array( openstation_default_desktop() ),
153 'activeDesktop' => 'desktop-1',
154 'focused' => '',
155 'updated' => 0,
156 );
157 }
158
159 /**
160 * Retrieves the saved desktop session for a user.
161 *
162 * Always returns a well-shaped array so callers don't have to defend
163 * against corrupt or partial meta.
164 *
165 * @param int $user_id The user ID.
166 * @param bool|null $network See {@see openstation_session_meta_key()}.
167 * @return array{windows: array, desktops: array, activeDesktop: string, focused: string, updated: int}
168 */
169 function openstation_get_session( $user_id, $network = null ) {
170 $user_id = (int) $user_id;
171 if ( $user_id <= 0 ) {
172 return openstation_empty_session();
173 }
174 if ( null === $network ) {
175 $network = is_multisite() && is_network_admin();
176 }
177
178 $raw = get_user_meta( $user_id, openstation_session_meta_key( $network ), true );
179 if ( ! is_array( $raw ) ) {
180 return openstation_empty_session();
181 }
182
183 // Desktops + activeDesktop are post-0.4.0 additions. Sessions
184 // saved before they existed don't carry either field — fall back
185 // to the single default desktop so older sessions degrade
186 // gracefully rather than booting into a zero-desktop limbo.
187 $desktops = isset( $raw['desktops'] ) && is_array( $raw['desktops'] )
188 ? array_values( $raw['desktops'] )
189 : array( openstation_default_desktop() );
190 $active_desktop = isset( $raw['activeDesktop'] ) ? (string) $raw['activeDesktop'] : 'desktop-1';
191
192 // A desktop persisted by the site-Spaces model carried a `scope`: a
193 // desk hosting another admin. Every site is its own OpenStation now,
194 // so such a desk has nothing left to host — dropped on read, with
195 // the active desktop moved off it, and the next save writes the
196 // blob without it.
197 $desktops = array_values(
198 array_filter(
199 $desktops,
200 static function ( $d ) {
201 return ! ( is_array( $d ) && isset( $d['scope'] ) );
202 }
203 )
204 );
205 if ( empty( $desktops ) ) {
206 $desktops = array( openstation_default_desktop() );
207 }
208 $desktop_ids = array();
209 foreach ( $desktops as $d ) {
210 if ( is_array( $d ) && isset( $d['id'] ) ) {
211 $desktop_ids[] = (string) $d['id'];
212 }
213 }
214 if ( ! in_array( $active_desktop, $desktop_ids, true ) && ! empty( $desktop_ids ) ) {
215 $active_desktop = $desktop_ids[0];
216 }
217
218 // Scope gate on read: drop windows persisted for the other admin.
219 // Blobs written before the network admin had its own meta key mix
220 // the two, and restoring across the split would open one admin's
221 // window on the other's desktop under a colliding window id.
222 $windows = isset( $raw['windows'] ) && is_array( $raw['windows'] ) ? array_values( $raw['windows'] ) : array();
223 $windows = array_values(
224 array_filter(
225 $windows,
226 static function ( $win ) use ( $network ) {
227 if ( ! is_array( $win ) || ! empty( $win['native'] ) ) {
228 return true;
229 }
230 $url = isset( $win['url'] ) ? (string) $win['url'] : '';
231 return openstation_session_window_url_ok( $url, $network );
232 }
233 )
234 );
235
236 return array(
237 'windows' => $windows,
238 'desktops' => $desktops,
239 'activeDesktop' => $active_desktop,
240 'focused' => isset( $raw['focused'] ) ? (string) $raw['focused'] : '',
241 'updated' => isset( $raw['updated'] ) ? (int) $raw['updated'] : 0,
242 );
243 }
244
245 /**
246 * Persists a sanitized desktop session to user meta.
247 *
248 * Rejects writes whose `updated` timestamp is older than what's
249 * already on file — a simple last-write-wins guard that prevents two
250 * tabs open on the same user from clobbering each other. The client
251 * stamps `updated` with `Date.now()` — epoch MILLISECONDS — at
252 * snapshot time (see `WindowManager.snapshot`), so this comparison
253 * lines up with real wall-clock ordering on same-machine multi-tab
254 * setups.
255 *
256 * Millisecond resolution is load-bearing, not cosmetic. The two
257 * writes that race hardest are a `keepalive` fetch still in flight
258 * and the `pagehide` beacon that supersedes it; at second resolution
259 * they tie, and the tie rule below hands the win to whichever the
260 * server processes last — which can be the stale one, reinstating a
261 * window the user just closed.
262 *
263 * Sessions written before the switch carry a seconds value. Those are
264 * ~1000x smaller than any millisecond stamp, so the first write after
265 * an upgrade always wins — which is the correct outcome for a stamp
266 * that is genuinely older.
267 *
268 * Equal timestamps are still accepted — that's a tie and whichever the
269 * server processes first wins.
270 *
271 * @param int $user_id The user ID.
272 * @param array $session Raw session payload (will be sanitized).
273 * @param bool|null $network See {@see openstation_session_meta_key()}.
274 * @return bool True on success, false when stale / invalid / failed.
275 */
276 function openstation_save_session( $user_id, $session, $network = null ) {
277 $user_id = (int) $user_id;
278 if ( $user_id <= 0 ) {
279 return false;
280 }
281 if ( null === $network ) {
282 $network = is_multisite() && is_network_admin();
283 }
284
285 if ( is_array( $session ) && isset( $session['updated'] ) ) {
286 $incoming = (int) $session['updated'];
287 if ( $incoming > 0 ) {
288 $existing = openstation_get_session( $user_id, $network );
289 $stored = isset( $existing['updated'] ) ? (int) $existing['updated'] : 0;
290 if ( $incoming < $stored ) {
291 // Stale write — another tab saved a newer snapshot
292 // after this one was taken. Bail so the user's latest
293 // work isn't overwritten by a slow-to-arrive payload.
294 return false;
295 }
296 }
297 }
298
299 $clean = openstation_sanitize_session( $session, $network );
300
301 return false !== update_user_meta( $user_id, openstation_session_meta_key( $network ), $clean );
302 }
303
304 /**
305 * Clears a user's saved desktop session.
306 *
307 * @param int $user_id The user ID.
308 * @param bool|null $network See {@see openstation_session_meta_key()}.
309 * @return bool True on success.
310 */
311 function openstation_clear_session( $user_id, $network = null ) {
312 $user_id = (int) $user_id;
313 if ( $user_id <= 0 ) {
314 return false;
315 }
316 return (bool) delete_user_meta( $user_id, openstation_session_meta_key( $network ) );
317 }
318
319 /**
320 * Sanitizes a session payload before persistence.
321 *
322 * Rejects windows whose `url` isn't a same-origin admin URL or lives
323 * in the other admin's scope, clamps geometry to sane integer ranges,
324 * and normalizes the state enum. Windows beyond
325 * {@see OPENSTATION_SESSION_MAX_WINDOWS} are dropped.
326 *
327 * @param mixed $session Raw session data from the client.
328 * @param bool|null $network See {@see openstation_session_meta_key()}.
329 * @return array{windows: array, desktops: array, activeDesktop: string, focused: string, updated: int}
330 */
331 function openstation_sanitize_session( $session, $network = null ) {
332 if ( null === $network ) {
333 $network = is_multisite() && is_network_admin();
334 }
335 $clean = openstation_empty_session();
336
337 if ( ! is_array( $session ) ) {
338 $clean['updated'] = openstation_session_now_ms();
339 return $clean;
340 }
341
342 // Preserve the client's `updated` timestamp so the stale-write guard
343 // in openstation_save_session compares client-to-client (not client-to-server
344 // wallclock) — two saves landing in the same millisecond must tie, not lose.
345 // The fallback matches the client's unit (epoch milliseconds); mixing
346 // units here would store a seconds value that every later comparison
347 // treats as ancient, quietly disabling the guard.
348 $incoming_updated = isset( $session['updated'] ) ? (int) $session['updated'] : 0;
349 $clean['updated'] = $incoming_updated > 0 ? $incoming_updated : openstation_session_now_ms();
350
351 if ( isset( $session['focused'] ) && is_string( $session['focused'] ) ) {
352 $clean['focused'] = sanitize_key( $session['focused'] );
353 }
354
355 // --- Desktops list -------------------------------------------
356 // Build a sanitized desktops array first so we can validate
357 // per-window desktopId against it below — windows assigned to
358 // non-existent desktops are quietly remapped to the active
359 // desktop on restore client-side, but we want server-side
360 // integrity too.
361 $desktop_ids = array();
362 if ( isset( $session['desktops'] ) && is_array( $session['desktops'] ) ) {
363 $clean_desktops = array();
364 foreach ( $session['desktops'] as $d ) {
365 if ( ! is_array( $d ) ) {
366 continue;
367 }
368 $d_id = isset( $d['id'] ) ? sanitize_key( (string) $d['id'] ) : '';
369 if ( '' === $d_id ) {
370 continue;
371 }
372 $d_label = isset( $d['label'] ) ? wp_strip_all_tags( (string) $d['label'] ) : '';
373 if ( '' === $d_label ) {
374 $d_label = $d_id;
375 }
376 // 64-char cap on labels — generous for any sensible
377 // human-typed desktop name, hard ceiling on meta size.
378 if ( strlen( $d_label ) > 64 ) {
379 $d_label = substr( $d_label, 0, 64 );
380 }
381 $entry = array(
382 'id' => $d_id,
383 'label' => $d_label,
384 );
385 // A desktop's workspace profile — which apps it shows, what
386 // it opens with, how they are arranged. Optional, and only
387 // written when there is one, so a plain Space keeps the
388 // shape every session saved before workspaces existed had.
389 $profile = openstation_sanitize_workspace_profile( isset( $d['profile'] ) ? $d['profile'] : null );
390 if ( null !== $profile ) {
391 $entry['profile'] = $profile;
392 }
393 $clean_desktops[] = $entry;
394 $desktop_ids[] = $d_id;
395 if ( count( $clean_desktops ) >= OPENSTATION_SESSION_MAX_DESKTOPS ) {
396 break;
397 }
398 }
399 if ( ! empty( $clean_desktops ) ) {
400 $clean['desktops'] = $clean_desktops;
401 }
402 }
403 // Always at least one desktop in the persisted shape — guards
404 // against a client clearing every desktop and saving an empty
405 // list, or omitting the key entirely.
406 if ( empty( $clean['desktops'] ) ) {
407 $clean['desktops'] = array( openstation_default_desktop() );
408 }
409 if ( empty( $desktop_ids ) ) {
410 // Rebuild ids from the authoritative desktops list so the
411 // per-window desktopId validation below has something to
412 // compare against — otherwise a client that omits `desktops`
413 // but sends windows would hit `$desktop_ids[0]` on an empty
414 // array.
415 $desktop_ids = array_map(
416 static function ( $d ) {
417 return isset( $d['id'] ) ? (string) $d['id'] : '';
418 },
419 $clean['desktops']
420 );
421 $desktop_ids = array_values( array_filter( $desktop_ids ) );
422 if ( empty( $desktop_ids ) ) {
423 $desktop_ids = array( 'desktop-1' );
424 }
425 }
426
427 // --- Active desktop ------------------------------------------
428 if ( isset( $session['activeDesktop'] ) && is_string( $session['activeDesktop'] ) ) {
429 $candidate = sanitize_key( $session['activeDesktop'] );
430 if ( in_array( $candidate, $desktop_ids, true ) ) {
431 $clean['activeDesktop'] = $candidate;
432 }
433 }
434 // Fallback: first valid desktop. Already true via openstation_empty_session
435 // when the client passed nothing, but guards the case where
436 // activeDesktop named a desktop that didn't survive sanitization.
437 if ( ! in_array( $clean['activeDesktop'], $desktop_ids, true ) ) {
438 $clean['activeDesktop'] = $desktop_ids[0];
439 }
440
441 if ( isset( $session['windows'] ) && is_array( $session['windows'] ) ) {
442 foreach ( $session['windows'] as $win ) {
443 if ( ! is_array( $win ) ) {
444 continue;
445 }
446
447 $id = isset( $win['id'] ) ? sanitize_key( (string) $win['id'] ) : '';
448 if ( '' === $id ) {
449 continue;
450 }
451
452 // `baseId` groups multi-instance windows of the same admin page
453 // (e.g. `edit-php`, `edit-php-2`, `edit-php-3` all share baseId
454 // `edit-php`). Optional — older sessions predate the field and
455 // the client falls back to `id` when missing.
456 $base_id = isset( $win['baseId'] ) ? sanitize_key( (string) $win['baseId'] ) : '';
457 if ( '' === $base_id ) {
458 $base_id = $id;
459 }
460
461 // Map the window to a known desktop. A client that sends a
462 // desktopId pointing at a non-existent desktop (race with a
463 // desktop close, or a malicious payload) is silently
464 // remapped to the active desktop so the window remains
465 // visible — losing it on restore would be the worse UX.
466 $win_desktop = isset( $win['desktopId'] ) ? sanitize_key( (string) $win['desktopId'] ) : '';
467 if ( '' === $win_desktop || ! in_array( $win_desktop, $desktop_ids, true ) ) {
468 $win_desktop = $clean['activeDesktop'];
469 }
470
471 // Native windows (OS Settings, Bug Report, anything from
472 // `openstation_register_window()`) carry no admin URL —
473 // the shell reconstructs them from the registry by id. Their
474 // `url` is a `#slug` marker, which would fail the same-admin
475 // check below and drop the window from the session entirely.
476 // Synthesise the marker server-side instead of trusting (or
477 // storing) whatever string the client sent: nothing ever
478 // navigates to it, so there is no reason to round-trip a
479 // client-controlled value through user meta.
480 $is_native = ! empty( $win['native'] );
481
482 if ( $is_native ) {
483 $url = '#' . $id;
484 } else {
485 $url = isset( $win['url'] ) ? esc_url_raw( (string) $win['url'] ) : '';
486 // Only allow URLs this session may hold: same-origin,
487 // inside this admin. Host+path parsing rejects tricks
488 // like `//evil.com/wp-admin/…` that a raw prefix check
489 // would miss, and one admin's window never persists
490 // into another's session.
491 if ( '' === $url || ! openstation_session_window_url_ok( $url, $network ) ) {
492 continue;
493 }
494 // Strip transient/routing flags before storage. The chromeless
495 // `openstation_chromeless` flag is an iframe-only concern and must never
496 // end up in a top-level URL (e.g., the portal's entry URL);
497 // the portal and classic flags only live on a single request.
498 $url = remove_query_arg(
499 array( 'openstation_chromeless', OPENSTATION_PORTAL_FLAG, OPENSTATION_CLASSIC_FLAG ),
500 $url
501 );
502 }
503
504 $state = isset( $win['state'] ) ? (string) $win['state'] : 'normal';
505 if ( ! in_array( $state, OPENSTATION_SESSION_STATES, true ) ) {
506 $state = 'normal';
507 }
508
509 $entry = array(
510 'id' => $id,
511 'baseId' => $base_id,
512 'desktopId' => $win_desktop,
513 'url' => $url,
514 'title' => isset( $win['title'] ) ? wp_strip_all_tags( (string) $win['title'] ) : '',
515 'icon' => isset( $win['icon'] ) ? sanitize_html_class( (string) $win['icon'] ) : 'dashicons-admin-generic',
516 'state' => $state,
517 'x' => openstation_sanitize_session_dimension( $win['x'] ?? 0, -10000, 10000 ),
518 'y' => openstation_sanitize_session_dimension( $win['y'] ?? 0, -10000, 10000 ),
519 'width' => openstation_sanitize_session_dimension( $win['width'] ?? 800, 0, 20000 ),
520 'height' => openstation_sanitize_session_dimension( $win['height'] ?? 600, 0, 20000 ),
521 );
522
523 // A grid-snapped window's cells, next to its pixels. On
524 // restore the cells win — they are a fraction of the desk,
525 // and the pixels are from whatever display the session was
526 // saved on. Only written when valid, so a plain window keeps
527 // the shape it always had.
528 $grid_span = openstation_sanitize_session_grid_span( $win['gridSpan'] ?? null );
529 if ( null !== $grid_span ) {
530 $entry['gridSpan'] = $grid_span;
531 }
532
533 // A window the phone layer opened with no desktop geometry to
534 // keep: its pixels are a phone's defaults, and the shell's
535 // restore path places it afresh instead of trusting them.
536 // Only written when true so plain sessions keep their shape.
537 if ( ! empty( $win['unplaced'] ) ) {
538 $entry['unplaced'] = true;
539 }
540
541 // Marks the entry for the shell's restore path: native
542 // windows reopen through the native-window registry, not by
543 // pointing an iframe at a URL. Only written when true so
544 // sessions of plain admin windows keep their existing shape.
545 if ( $is_native ) {
546 $entry['native'] = true;
547
548 // A native window's open-time arguments: WHAT it is
549 // showing, as opposed to what it is. A native window
550 // is addressed by id, and its id is its identity
551 // (`desktop-mode-user-edit` is "the profile editor",
552 // not "the profile editor for user 12"), so a
553 // singleton that retargets has nowhere else to record
554 // its subject. Drop these and the window restores onto
555 // its default — the profile window comes back showing
556 // whoever is logged in, the customer window comes back
557 // empty.
558 //
559 // Only for native entries: an iframe window's URL
560 // already says what it shows, and it round-trips on
561 // its own.
562 $params = openstation_sanitize_session_params( $win['params'] ?? null );
563 if ( ! empty( $params ) ) {
564 $entry['params'] = $params;
565 }
566 }
567
568 // Sanitize external sub-tabs. Each entry carries a URL
569 // (any http/https — external tabs are explicitly for links
570 // OUT of wp-admin, so we don't restrict to same-origin
571 // here) and a label. Capped at a reasonable per-window
572 // limit so a runaway client can't balloon user meta.
573 if ( isset( $win['externalTabs'] ) && is_array( $win['externalTabs'] ) ) {
574 $tabs = array();
575 foreach ( $win['externalTabs'] as $tab ) {
576 if ( ! is_array( $tab ) ) {
577 continue;
578 }
579 $tab_url = isset( $tab['url'] ) ? esc_url_raw( (string) $tab['url'], array( 'http', 'https' ) ) : '';
580 if ( '' === $tab_url ) {
581 continue;
582 }
583 // Hard cap on URL length — a runaway client (or a
584 // malicious payload) could otherwise push many
585 // megabytes of URL into user meta. 2048 is the
586 // de-facto IE-legacy URL length limit and covers
587 // every real URL the shell restores.
588 if ( strlen( $tab_url ) > 2048 ) {
589 continue;
590 }
591 $label = isset( $tab['label'] ) ? wp_strip_all_tags( (string) $tab['label'] ) : '';
592 // Trim long labels server-side too, mirroring the
593 // client-side 80-char slice in the chromeless
594 // bridge. Keeps meta size predictable.
595 if ( strlen( $label ) > 80 ) {
596 $label = substr( $label, 0, 80 );
597 }
598 $tabs[] = array(
599 'url' => $tab_url,
600 'label' => $label,
601 );
602 if ( count( $tabs ) >= 16 ) {
603 break;
604 }
605 }
606 if ( ! empty( $tabs ) ) {
607 $entry['externalTabs'] = $tabs;
608 }
609 }
610
611 $clean['windows'][] = $entry;
612
613 if ( count( $clean['windows'] ) >= OPENSTATION_SESSION_MAX_WINDOWS ) {
614 break;
615 }
616 }
617 }
618
619 return $clean;
620 }
621
622 /**
623 * Clamps a numeric dimension into a sane range.
624 *
625 * Geometry coming from the client is untrusted. A malicious or buggy
626 * payload could try to stash multi-million-pixel values in meta,
627 * negative values that break the shell, or non-numeric garbage
628 * (strings, arrays, objects). This enforces numeric type and min/max
629 * bounds, falling back to `$min` for anything non-numeric so the
630 * window restores to a sane geometry rather than colliding with 0.
631 *
632 * Array/object input and non-numeric strings are rejected by
633 * `is_numeric()`; float `INF`/`NAN` pass that gate but cast to 0 and
634 * are then clamped into `[min, max]`, so no out-of-range value
635 * survives.
636 *
637 * @param mixed $value The raw value.
638 * @param int $min Minimum allowed value.
639 * @param int $max Maximum allowed value.
640 * @return int The clamped integer.
641 */
642 function openstation_sanitize_session_dimension( $value, $min, $max ) {
643 if ( is_string( $value ) ) {
644 $value = trim( $value );
645 }
646 if ( ! is_numeric( $value ) ) {
647 return (int) $min;
648 }
649 $value = (int) $value;
650 if ( $value < $min ) {
651 return (int) $min;
652 }
653 if ( $value > $max ) {
654 return (int) $max;
655 }
656 return $value;
657 }
658
659 /**
660 * Sanitize a window's grid placement.
661 *
662 * `{ anchor: { col, row }, cursor: { col, row }, cols, rows }`, every
663 * value an integer, the grid between 1×1 and 24×24 (the same ceiling
664 * the client's dimensions filter enforces), every cell inside it.
665 * Anything else is `null` — the window restores on its pixels, which
666 * is what a session written before grid snap does anyway.
667 *
668 * @param mixed $raw Raw span from the payload.
669 * @return array|null Sanitized span, or null.
670 */
671 function openstation_sanitize_session_grid_span( $raw ) {
672 if ( ! is_array( $raw ) || ! isset( $raw['anchor'], $raw['cursor'], $raw['cols'], $raw['rows'] ) ) {
673 return null;
674 }
675 $int = static function ( $value ) {
676 return is_int( $value ) || ( is_numeric( $value ) && (string) (int) $value === (string) $value ) ? (int) $value : null;
677 };
678 $cols = $int( $raw['cols'] );
679 $rows = $int( $raw['rows'] );
680 if ( null === $cols || null === $rows || $cols < 1 || $rows < 1 || $cols > 24 || $rows > 24 ) {
681 return null;
682 }
683 $cell = static function ( $c ) use ( $int, $cols, $rows ) {
684 if ( ! is_array( $c ) || ! isset( $c['col'], $c['row'] ) ) {
685 return null;
686 }
687 $col = $int( $c['col'] );
688 $row = $int( $c['row'] );
689 if ( null === $col || null === $row || $col < 0 || $row < 0 || $col >= $cols || $row >= $rows ) {
690 return null;
691 }
692 return array(
693 'col' => $col,
694 'row' => $row,
695 );
696 };
697 $anchor = $cell( $raw['anchor'] );
698 $cursor = $cell( $raw['cursor'] );
699 if ( null === $anchor || null === $cursor ) {
700 return null;
701 }
702 return array(
703 'anchor' => $anchor,
704 'cursor' => $cursor,
705 'cols' => $cols,
706 'rows' => $rows,
707 );
708 }
709
710 /**
711 * Sanitize a native window's open-time params.
712 *
713 * These say WHAT a native window is showing (`{ userId: 12 }`,
714 * `{ customerId: 7 }`) as opposed to what it is — see
715 * `WindowConfig.params` on the JS side. They come from the client, so
716 * they are untrusted, unbounded, and arbitrarily nested unless this
717 * says otherwise.
718 *
719 * The rules mirror the client's own sanitizer so both ends agree on
720 * what survives: scalar values only (string, finite number, bool),
721 * and hard caps on both the number of keys and the length of a string
722 * value. Anything else is dropped rather than rejected — one careless
723 * value from a plugin must not cost the user every window's geometry.
724 *
725 * Keys are filtered to `[A-Za-z0-9_-]` rather than passed through
726 * `sanitize_key()`, which **lowercases**. Every param name in the
727 * shell is camelCase (`customerId`, `userId`), so lowercasing would
728 * store `customerid` and the client's `params.customerId` would read
729 * `undefined` — a window that restores blank, with the data sitting
730 * right there under a name nobody looks up.
731 *
732 * @param mixed $params Raw params from the payload.
733 * @return array Sanitized params, possibly empty.
734 */
735 function openstation_sanitize_session_params( $params ) {
736 if ( ! is_array( $params ) ) {
737 return array();
738 }
739
740 $clean = array();
741 foreach ( $params as $key => $value ) {
742 if ( count( $clean ) >= OPENSTATION_SESSION_MAX_PARAMS ) {
743 break;
744 }
745 $key = substr( preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $key ), 0, 64 );
746 if ( '' === $key ) {
747 continue;
748 }
749 if ( is_bool( $value ) ) {
750 $clean[ $key ] = $value;
751 continue;
752 }
753 if ( is_int( $value ) || is_float( $value ) ) {
754 if ( is_finite( (float) $value ) ) {
755 $clean[ $key ] = $value + 0;
756 }
757 continue;
758 }
759 if ( is_string( $value ) ) {
760 // A window param is an id, a slug or a short label. The
761 // cap keeps a runaway client from pushing megabytes into
762 // user meta, the same way the external-tab URL cap does.
763 $clean[ $key ] = substr( sanitize_text_field( $value ), 0, 256 );
764 }
765 }
766
767 return $clean;
768 }
769
770 /**
771 * Registers the REST routes used by the desktop shell to load and save
772 * the current user's session.
773 */
774 function openstation_register_session_rest_routes() {
775 // `network` addresses the network admin's own session. The route
776 // runs in the main site's blog context whichever desktop is
777 // saving, so the shell says which one it is: the network screen's
778 // `sessionUrl` carries `network=1`.
779 $network_arg = array(
780 'network' => array(
781 'type' => 'boolean',
782 'default' => false,
783 ),
784 );
785 register_rest_route(
786 'desktop-mode/v1',
787 '/session',
788 array(
789 array(
790 'methods' => WP_REST_Server::READABLE,
791 'callback' => 'openstation_rest_get_session',
792 'permission_callback' => 'openstation_rest_session_permission',
793 'args' => $network_arg,
794 ),
795 array(
796 'methods' => WP_REST_Server::CREATABLE,
797 'callback' => 'openstation_rest_save_session',
798 'permission_callback' => 'openstation_rest_session_permission',
799 'args' => array_merge(
800 array(
801 'session' => array(
802 'required' => true,
803 'type' => 'object',
804 ),
805 ),
806 $network_arg
807 ),
808 ),
809 array(
810 'methods' => WP_REST_Server::DELETABLE,
811 'callback' => 'openstation_rest_clear_session',
812 'permission_callback' => 'openstation_rest_session_permission',
813 'args' => $network_arg,
814 ),
815 )
816 );
817 }
818 add_action( 'rest_api_init', 'openstation_register_session_rest_routes' );
819
820 /**
821 * Permission gate for the session REST routes: logged-in users who have
822 * OpenStation enabled. See {@see openstation_rest_require_enabled()}
823 * for why `read` alone is insufficient.
824 *
825 * @return true|WP_Error
826 */
827 function openstation_rest_session_permission() {
828 return openstation_rest_require_enabled();
829 }
830
831 /**
832 * Which admin's session a REST call addresses.
833 *
834 * `is_network_admin()` is false on every REST request, so the client
835 * reports the scope (`network=1`, stamped onto the network screen's
836 * `sessionUrl`). Honoured only for users who can open the network
837 * desktop at all — anyone else's flag falls back to the site session
838 * rather than minting a blob for a desktop they cannot reach.
839 *
840 * @param WP_REST_Request $request The REST request.
841 * @return bool True when the call addresses the network admin's session.
842 */
843 function openstation_rest_session_network( WP_REST_Request $request ) {
844 return is_multisite()
845 && rest_sanitize_boolean( $request->get_param( 'network' ) )
846 && current_user_can( 'manage_network' );
847 }
848
849 /**
850 * GET /desktop-mode/v1/session — returns the caller's session.
851 *
852 * @param WP_REST_Request $request The REST request.
853 * @return WP_REST_Response
854 */
855 function openstation_rest_get_session( WP_REST_Request $request ) {
856 return rest_ensure_response(
857 openstation_get_session( get_current_user_id(), openstation_rest_session_network( $request ) )
858 );
859 }
860
861 /**
862 * POST /desktop-mode/v1/session — replaces the caller's session.
863 *
864 * @param WP_REST_Request $request The REST request.
865 * @return WP_REST_Response The stored session (after sanitization).
866 */
867 function openstation_rest_save_session( WP_REST_Request $request ) {
868 $user_id = get_current_user_id();
869 $payload = $request->get_param( 'session' );
870 $network = openstation_rest_session_network( $request );
871 openstation_save_session( $user_id, $payload, $network );
872 return rest_ensure_response( openstation_get_session( $user_id, $network ) );
873 }
874
875 /**
876 * DELETE /desktop-mode/v1/session — clears the caller's session.
877 *
878 * @param WP_REST_Request $request The REST request.
879 * @return WP_REST_Response
880 */
881 function openstation_rest_clear_session( WP_REST_Request $request ) {
882 openstation_clear_session( get_current_user_id(), openstation_rest_session_network( $request ) );
883 return rest_ensure_response( openstation_empty_session() );
884 }
885