PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.10
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.10
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
← All changes | includes/session.php +290 -45 1.1.41.1.10 View file →
@@ -24,8 +24,79 @@
24 24 * deliberate — it is NOT a half-finished rename.
25 25 */
26 26 const OPENSTATION_SESSION_META_KEY = 'desktop_mode_session';
27 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 +
28 99 /** Hard cap on persisted windows — guards against runaway meta size. */
29 100 const OPENSTATION_SESSION_MAX_WINDOWS = 32;
30 101
31 102 /**
@@ -90,18 +161,22 @@
90 161 *
91 162 * Always returns a well-shaped array so callers don't have to defend
92 163 * against corrupt or partial meta.
93 164 *
94 - * @param int $user_id The user ID.
165 + * @param int $user_id The user ID.
166 + * @param bool|null $network See {@see openstation_session_meta_key()}.
95 167 * @return array{windows: array, desktops: array, activeDesktop: string, focused: string, updated: int}
96 168 */
97 -function openstation_get_session( $user_id ) {
169 +function openstation_get_session( $user_id, $network = null ) {
98 170 $user_id = (int) $user_id;
99 171 if ( $user_id <= 0 ) {
100 172 return openstation_empty_session();
101 173 }
174 + if ( null === $network ) {
175 + $network = is_multisite() && is_network_admin();
176 + }
102 177
103 - $raw = get_user_meta( $user_id, OPENSTATION_SESSION_META_KEY, true );
178 + $raw = get_user_meta( $user_id, openstation_session_meta_key( $network ), true );
104 179 if ( ! is_array( $raw ) ) {
105 180 return openstation_empty_session();
106 181 }
107 182
@@ -113,10 +188,54 @@
113 188 ? array_values( $raw['desktops'] )
114 189 : array( openstation_default_desktop() );
115 190 $active_desktop = isset( $raw['activeDesktop'] ) ? (string) $raw['activeDesktop'] : 'desktop-1';
116 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 +
117 236 return array(
118 - 'windows' => isset( $raw['windows'] ) && is_array( $raw['windows'] ) ? array_values( $raw['windows'] ) : array(),
237 + 'windows' => $windows,
119 238 'desktops' => $desktops,
120 239 'activeDesktop' => $active_desktop,
121 240 'focused' => isset( $raw['focused'] ) ? (string) $raw['focused'] : '',
122 241 'updated' => isset( $raw['updated'] ) ? (int) $raw['updated'] : 0,
@@ -148,22 +267,26 @@
148 267 *
149 268 * Equal timestamps are still accepted — that's a tie and whichever the
150 269 * server processes first wins.
151 270 *
152 - * @param int $user_id The user ID.
153 - * @param array $session Raw session payload (will be sanitized).
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()}.
154 274 * @return bool True on success, false when stale / invalid / failed.
155 275 */
156 -function openstation_save_session( $user_id, $session ) {
276 +function openstation_save_session( $user_id, $session, $network = null ) {
157 277 $user_id = (int) $user_id;
158 278 if ( $user_id <= 0 ) {
159 279 return false;
160 280 }
281 + if ( null === $network ) {
282 + $network = is_multisite() && is_network_admin();
283 + }
161 284
162 285 if ( is_array( $session ) && isset( $session['updated'] ) ) {
163 286 $incoming = (int) $session['updated'];
164 287 if ( $incoming > 0 ) {
165 - $existing = openstation_get_session( $user_id );
288 + $existing = openstation_get_session( $user_id, $network );
166 289 $stored = isset( $existing['updated'] ) ? (int) $existing['updated'] : 0;
167 290 if ( $incoming < $stored ) {
168 291 // Stale write — another tab saved a newer snapshot
169 292 // after this one was taken. Bail so the user's latest
@@ -172,38 +295,44 @@
172 295 }
173 296 }
174 297 }
175 298
176 - $clean = openstation_sanitize_session( $session );
299 + $clean = openstation_sanitize_session( $session, $network );
177 300
178 - return false !== update_user_meta( $user_id, OPENSTATION_SESSION_META_KEY, $clean );
301 + return false !== update_user_meta( $user_id, openstation_session_meta_key( $network ), $clean );
179 302 }
180 303
181 304 /**
182 305 * Clears a user's saved desktop session.
183 306 *
184 - * @param int $user_id The user ID.
307 + * @param int $user_id The user ID.
308 + * @param bool|null $network See {@see openstation_session_meta_key()}.
185 309 * @return bool True on success.
186 310 */
187 -function openstation_clear_session( $user_id ) {
311 +function openstation_clear_session( $user_id, $network = null ) {
188 312 $user_id = (int) $user_id;
189 313 if ( $user_id <= 0 ) {
190 314 return false;
191 315 }
192 - return (bool) delete_user_meta( $user_id, OPENSTATION_SESSION_META_KEY );
316 + return (bool) delete_user_meta( $user_id, openstation_session_meta_key( $network ) );
193 317 }
194 318
195 319 /**
196 320 * Sanitizes a session payload before persistence.
197 321 *
198 - * Rejects windows whose `url` isn't a same-origin admin URL, clamps
199 - * geometry to sane integer ranges, and normalizes the state enum.
200 - * Windows beyond {@see OPENSTATION_SESSION_MAX_WINDOWS} are dropped.
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.
201 326 *
202 - * @param mixed $session Raw session data from the client.
327 + * @param mixed $session Raw session data from the client.
328 + * @param bool|null $network See {@see openstation_session_meta_key()}.
203 329 * @return array{windows: array, desktops: array, activeDesktop: string, focused: string, updated: int}
204 330 */
205 -function openstation_sanitize_session( $session ) {
331 +function openstation_sanitize_session( $session, $network = null ) {
332 + if ( null === $network ) {
333 + $network = is_multisite() && is_network_admin();
334 + }
206 335 $clean = openstation_empty_session();
207 336
208 337 if ( ! is_array( $session ) ) {
209 338 $clean['updated'] = openstation_session_now_ms();
@@ -248,12 +377,21 @@
248 377 // human-typed desktop name, hard ceiling on meta size.
249 378 if ( strlen( $d_label ) > 64 ) {
250 379 $d_label = substr( $d_label, 0, 64 );
251 380 }
252 - $clean_desktops[] = array(
381 + $entry = array(
253 382 'id' => $d_id,
254 383 'label' => $d_label,
255 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;
256 394 $desktop_ids[] = $d_id;
257 395 if ( count( $clean_desktops ) >= OPENSTATION_SESSION_MAX_DESKTOPS ) {
258 396 break;
259 397 }
@@ -319,8 +457,18 @@
319 457 if ( '' === $base_id ) {
320 458 $base_id = $id;
321 459 }
322 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 +
323 471 // Native windows (OS Settings, Bug Report, anything from
324 472 // `openstation_register_window()`) carry no admin URL —
325 473 // the shell reconstructs them from the registry by id. Their
326 474 // `url` is a `#slug` marker, which would fail the same-admin
@@ -334,14 +482,14 @@
334 482 if ( $is_native ) {
335 483 $url = '#' . $id;
336 484 } else {
337 485 $url = isset( $win['url'] ) ? esc_url_raw( (string) $win['url'] ) : '';
338 - // Only allow URLs that land inside our own wp-admin — both
339 - // a safety net against storing arbitrary origins in user meta
340 - // and a guarantee the restore path won't try to iframe a
341 - // cross-origin page. Host+path parsing rejects tricks like
342 - // `//evil.com/wp-admin/…` that a raw prefix check would miss.
343 - if ( '' === $url || ! openstation_url_is_same_admin( $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 ) ) {
344 492 continue;
345 493 }
346 494 // Strip transient/routing flags before storage. The chromeless
347 495 // `openstation_chromeless` flag is an iframe-only concern and must never
@@ -357,18 +505,8 @@
357 505 if ( ! in_array( $state, OPENSTATION_SESSION_STATES, true ) ) {
358 506 $state = 'normal';
359 507 }
360 508
361 - // Map the window to a known desktop. A client that sends a
362 - // desktopId pointing at a non-existent desktop (race with
363 - // a desktop close, or a malicious payload) is silently
364 - // remapped to the active desktop so the window remains
365 - // visible — losing it on restore would be the worse UX.
366 - $win_desktop = isset( $win['desktopId'] ) ? sanitize_key( (string) $win['desktopId'] ) : '';
367 - if ( '' === $win_desktop || ! in_array( $win_desktop, $desktop_ids, true ) ) {
368 - $win_desktop = $clean['activeDesktop'];
369 - }
370 -
371 509 $entry = array(
372 510 'id' => $id,
373 511 'baseId' => $base_id,
374 512 'desktopId' => $win_desktop,
@@ -381,8 +519,26 @@
381 519 'width' => openstation_sanitize_session_dimension( $win['width'] ?? 800, 0, 20000 ),
382 520 'height' => openstation_sanitize_session_dimension( $win['height'] ?? 600, 0, 20000 ),
383 521 );
384 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 +
385 541 // Marks the entry for the shell's restore path: native
386 542 // windows reopen through the native-window registry, not by
387 543 // pointing an iframe at a URL. Only written when true so
388 544 // sessions of plain admin windows keep their existing shape.
@@ -500,8 +656,59 @@
500 656 return $value;
501 657 }
502 658
503 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 +/**
504 711 * Sanitize a native window's open-time params.
505 712 *
506 713 * These say WHAT a native window is showing (`{ userId: 12 }`,
507 714 * `{ customerId: 7 }`) as opposed to what it is — see
@@ -564,8 +771,18 @@
564 771 * Registers the REST routes used by the desktop shell to load and save
565 772 * the current user's session.
566 773 */
567 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 + );
568 785 register_rest_route(
569 786 'desktop-mode/v1',
570 787 '/session',
571 788 array(
@@ -572,18 +789,22 @@
572 789 array(
573 790 'methods' => WP_REST_Server::READABLE,
574 791 'callback' => 'openstation_rest_get_session',
575 792 'permission_callback' => 'openstation_rest_session_permission',
793 + 'args' => $network_arg,
576 794 ),
577 795 array(
578 796 'methods' => WP_REST_Server::CREATABLE,
579 797 'callback' => 'openstation_rest_save_session',
580 798 'permission_callback' => 'openstation_rest_session_permission',
581 - 'args' => array(
582 - 'session' => array(
583 - 'required' => true,
584 - 'type' => 'object',
799 + 'args' => array_merge(
800 + array(
801 + 'session' => array(
802 + 'required' => true,
803 + 'type' => 'object',
804 + ),
585 805 ),
806 + $network_arg
586 807 ),
587 808 ),
588 809 array(
589 810 'methods' => WP_REST_Server::DELETABLE,
@@ -588,8 +809,9 @@
588 809 array(
589 810 'methods' => WP_REST_Server::DELETABLE,
590 811 'callback' => 'openstation_rest_clear_session',
591 812 'permission_callback' => 'openstation_rest_session_permission',
813 + 'args' => $network_arg,
592 814 ),
593 815 )
594 816 );
595 817 }
@@ -606,14 +828,35 @@
606 828 return openstation_rest_require_enabled();
607 829 }
608 830
609 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 +/**
610 850 * GET /desktop-mode/v1/session — returns the caller's session.
611 851 *
852 + * @param WP_REST_Request $request The REST request.
612 853 * @return WP_REST_Response
613 854 */
614 -function openstation_rest_get_session() {
615 - return rest_ensure_response( openstation_get_session( get_current_user_id() ) );
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 + );
616 859 }
617 860
618 861 /**
619 862 * POST /desktop-mode/v1/session — replaces the caller's session.
@@ -623,17 +866,19 @@
623 866 */
624 867 function openstation_rest_save_session( WP_REST_Request $request ) {
625 868 $user_id = get_current_user_id();
626 869 $payload = $request->get_param( 'session' );
627 - openstation_save_session( $user_id, $payload );
628 - return rest_ensure_response( openstation_get_session( $user_id ) );
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 ) );
629 873 }
630 874
631 875 /**
632 876 * DELETE /desktop-mode/v1/session — clears the caller's session.
633 877 *
878 + * @param WP_REST_Request $request The REST request.
634 879 * @return WP_REST_Response
635 880 */
636 -function openstation_rest_clear_session() {
637 - openstation_clear_session( get_current_user_id() );
881 +function openstation_rest_clear_session( WP_REST_Request $request ) {
882 + openstation_clear_session( get_current_user_id(), openstation_rest_session_network( $request ) );
638 883 return rest_ensure_response( openstation_empty_session() );
639 884 }