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 +495 -128 0.9.51.1.10 View file →
@@ -1,36 +1,137 @@
1 1 <?php
2 2 /**
3 - * Desktop Mode — Session Persistence.
3 + * OpenStation — Session Persistence.
4 4 *
5 5 * Persists each user's open desktop windows — URLs, positions, sizes,
6 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,
7 + * be restored across page loads and, via the `/openstation` portal,
8 8 * across devices. Cross-device viewport adaptation (a window that sat
9 9 * in the far-right corner of a 3440px ultrawide landing sanely on a
10 10 * 1280px laptop) happens client-side on restore.
11 11 *
12 - * @package WPDesktopMode
12 + * @package OpenStation
13 13 */
14 14
15 15 defined( 'ABSPATH' ) || exit;
16 16
17 -/** User meta key holding the serialized desktop session. */
18 -const DESKTOP_MODE_SESSION_META_KEY = 'desktop_mode_session';
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';
19 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 +
20 99 /** Hard cap on persisted windows — guards against runaway meta size. */
21 -const DESKTOP_MODE_SESSION_MAX_WINDOWS = 32;
100 +const OPENSTATION_SESSION_MAX_WINDOWS = 32;
22 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 +
23 110 /** Hard cap on persisted desktops ("Spaces"). Generous — power-users
24 111 * with 8+ desktops are vanishingly rare, and we'd rather drop tail
25 112 * desktops than balloon user meta. */
26 -const DESKTOP_MODE_SESSION_MAX_DESKTOPS = 16;
113 +const OPENSTATION_SESSION_MAX_DESKTOPS = 16;
27 114
28 115 /** Allowed values for a window's state field. */
29 -const DESKTOP_MODE_SESSION_STATES = array( 'normal', 'minimized', 'maximized', 'fullscreen' );
116 +const OPENSTATION_SESSION_STATES = array( 'normal', 'minimized', 'maximized', 'fullscreen' );
30 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 +
31 132 /** Default desktop entry seeded into empty / corrupt sessions. */
32 -function desktop_mode_default_desktop() {
133 +function openstation_default_desktop() {
33 134 return array(
34 135 'id' => 'desktop-1',
35 136 'label' => 'Desktop 1',
36 137 );
@@ -42,16 +143,14 @@
42 143 * Includes a default desktop ("Desktop 1") so the client can always
43 144 * assume at least one desktop exists at boot — the shell can't
44 145 * function with zero desktops.
45 146 *
46 - * @since 0.4.0
47 - *
48 147 * @return array{windows: array, desktops: array, activeDesktop: string, focused: string, updated: int}
49 148 */
50 -function desktop_mode_empty_session() {
149 +function openstation_empty_session() {
51 150 return array(
52 151 'windows' => array(),
53 - 'desktops' => array( desktop_mode_default_desktop() ),
152 + 'desktops' => array( openstation_default_desktop() ),
54 153 'activeDesktop' => 'desktop-1',
55 154 'focused' => '',
56 155 'updated' => 0,
57 156 );
@@ -62,22 +161,24 @@
62 161 *
63 162 * Always returns a well-shaped array so callers don't have to defend
64 163 * against corrupt or partial meta.
65 164 *
66 - * @since 0.4.0
67 - *
68 - * @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()}.
69 167 * @return array{windows: array, desktops: array, activeDesktop: string, focused: string, updated: int}
70 168 */
71 -function desktop_mode_get_session( $user_id ) {
169 +function openstation_get_session( $user_id, $network = null ) {
72 170 $user_id = (int) $user_id;
73 171 if ( $user_id <= 0 ) {
74 - return desktop_mode_empty_session();
172 + return openstation_empty_session();
75 173 }
174 + if ( null === $network ) {
175 + $network = is_multisite() && is_network_admin();
176 + }
76 177
77 - $raw = get_user_meta( $user_id, DESKTOP_MODE_SESSION_META_KEY, true );
178 + $raw = get_user_meta( $user_id, openstation_session_meta_key( $network ), true );
78 179 if ( ! is_array( $raw ) ) {
79 - return desktop_mode_empty_session();
180 + return openstation_empty_session();
80 181 }
81 182
82 183 // Desktops + activeDesktop are post-0.4.0 additions. Sessions
83 184 // saved before they existed don't carry either field — fall back
@@ -82,15 +183,59 @@
82 183 // Desktops + activeDesktop are post-0.4.0 additions. Sessions
83 184 // saved before they existed don't carry either field — fall back
84 185 // to the single default desktop so older sessions degrade
85 186 // gracefully rather than booting into a zero-desktop limbo.
86 - $desktops = isset( $raw['desktops'] ) && is_array( $raw['desktops'] )
187 + $desktops = isset( $raw['desktops'] ) && is_array( $raw['desktops'] )
87 188 ? array_values( $raw['desktops'] )
88 - : array( desktop_mode_default_desktop() );
189 + : array( openstation_default_desktop() );
89 190 $active_desktop = isset( $raw['activeDesktop'] ) ? (string) $raw['activeDesktop'] : 'desktop-1';
90 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 +
91 236 return array(
92 - 'windows' => isset( $raw['windows'] ) && is_array( $raw['windows'] ) ? array_values( $raw['windows'] ) : array(),
237 + 'windows' => $windows,
93 238 'desktops' => $desktops,
94 239 'activeDesktop' => $active_desktop,
95 240 'focused' => isset( $raw['focused'] ) ? (string) $raw['focused'] : '',
96 241 'updated' => isset( $raw['updated'] ) ? (int) $raw['updated'] : 0,
@@ -102,32 +247,46 @@
102 247 *
103 248 * Rejects writes whose `updated` timestamp is older than what's
104 249 * already on file — a simple last-write-wins guard that prevents two
105 250 * 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.
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.
109 255 *
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.
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.
113 262 *
114 - * @since 0.4.0
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.
115 267 *
116 - * @param int $user_id The user ID.
117 - * @param array $session Raw session payload (will be sanitized).
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()}.
118 274 * @return bool True on success, false when stale / invalid / failed.
119 275 */
120 -function desktop_mode_save_session( $user_id, $session ) {
276 +function openstation_save_session( $user_id, $session, $network = null ) {
121 277 $user_id = (int) $user_id;
122 278 if ( $user_id <= 0 ) {
123 279 return false;
124 280 }
281 + if ( null === $network ) {
282 + $network = is_multisite() && is_network_admin();
283 + }
125 284
126 285 if ( is_array( $session ) && isset( $session['updated'] ) ) {
127 286 $incoming = (int) $session['updated'];
128 287 if ( $incoming > 0 ) {
129 - $existing = desktop_mode_get_session( $user_id );
288 + $existing = openstation_get_session( $user_id, $network );
130 289 $stored = isset( $existing['updated'] ) ? (int) $existing['updated'] : 0;
131 290 if ( $incoming < $stored ) {
132 291 // Stale write — another tab saved a newer snapshot
133 292 // after this one was taken. Bail so the user's latest
@@ -136,54 +295,59 @@
136 295 }
137 296 }
138 297 }
139 298
140 - $clean = desktop_mode_sanitize_session( $session );
299 + $clean = openstation_sanitize_session( $session, $network );
141 300
142 - return false !== update_user_meta( $user_id, DESKTOP_MODE_SESSION_META_KEY, $clean );
301 + return false !== update_user_meta( $user_id, openstation_session_meta_key( $network ), $clean );
143 302 }
144 303
145 304 /**
146 305 * Clears a user's saved desktop session.
147 306 *
148 - * @since 0.4.0
149 - *
150 - * @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()}.
151 309 * @return bool True on success.
152 310 */
153 -function desktop_mode_clear_session( $user_id ) {
311 +function openstation_clear_session( $user_id, $network = null ) {
154 312 $user_id = (int) $user_id;
155 313 if ( $user_id <= 0 ) {
156 314 return false;
157 315 }
158 - return (bool) delete_user_meta( $user_id, DESKTOP_MODE_SESSION_META_KEY );
316 + return (bool) delete_user_meta( $user_id, openstation_session_meta_key( $network ) );
159 317 }
160 318
161 319 /**
162 320 * Sanitizes a session payload before persistence.
163 321 *
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.
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.
167 326 *
168 - * @since 0.4.0
169 - *
170 - * @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()}.
171 329 * @return array{windows: array, desktops: array, activeDesktop: string, focused: string, updated: int}
172 330 */
173 -function desktop_mode_sanitize_session( $session ) {
174 - $clean = desktop_mode_empty_session();
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();
175 336
176 337 if ( ! is_array( $session ) ) {
177 - $clean['updated'] = time();
338 + $clean['updated'] = openstation_session_now_ms();
178 339 return $clean;
179 340 }
180 341
181 342 // 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.
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.
184 348 $incoming_updated = isset( $session['updated'] ) ? (int) $session['updated'] : 0;
185 - $clean['updated'] = $incoming_updated > 0 ? $incoming_updated : time();
349 + $clean['updated'] = $incoming_updated > 0 ? $incoming_updated : openstation_session_now_ms();
186 350
187 351 if ( isset( $session['focused'] ) && is_string( $session['focused'] ) ) {
188 352 $clean['focused'] = sanitize_key( $session['focused'] );
189 353 }
@@ -213,14 +377,23 @@
213 377 // human-typed desktop name, hard ceiling on meta size.
214 378 if ( strlen( $d_label ) > 64 ) {
215 379 $d_label = substr( $d_label, 0, 64 );
216 380 }
217 - $clean_desktops[] = array(
381 + $entry = array(
218 382 'id' => $d_id,
219 383 'label' => $d_label,
220 384 );
221 - $desktop_ids[] = $d_id;
222 - if ( count( $clean_desktops ) >= DESKTOP_MODE_SESSION_MAX_DESKTOPS ) {
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 ) {
223 396 break;
224 397 }
225 398 }
226 399 if ( ! empty( $clean_desktops ) ) {
@@ -230,9 +403,9 @@
230 403 // Always at least one desktop in the persisted shape — guards
231 404 // against a client clearing every desktop and saving an empty
232 405 // list, or omitting the key entirely.
233 406 if ( empty( $clean['desktops'] ) ) {
234 - $clean['desktops'] = array( desktop_mode_default_desktop() );
407 + $clean['desktops'] = array( openstation_default_desktop() );
235 408 }
236 409 if ( empty( $desktop_ids ) ) {
237 410 // Rebuild ids from the authoritative desktops list so the
238 411 // per-window desktopId validation below has something to
@@ -257,13 +430,13 @@
257 430 if ( in_array( $candidate, $desktop_ids, true ) ) {
258 431 $clean['activeDesktop'] = $candidate;
259 432 }
260 433 }
261 - // Fallback: first valid desktop. Already true via desktop_mode_empty_session
434 + // Fallback: first valid desktop. Already true via openstation_empty_session
262 435 // when the client passed nothing, but guards the case where
263 436 // activeDesktop named a desktop that didn't survive sanitization.
264 437 if ( ! in_array( $clean['activeDesktop'], $desktop_ids, true ) ) {
265 - $clean['activeDesktop'] = $desktop_ids[ 0 ];
438 + $clean['activeDesktop'] = $desktop_ids[0];
266 439 }
267 440
268 441 if ( isset( $session['windows'] ) && is_array( $session['windows'] ) ) {
269 442 foreach ( $session['windows'] as $win ) {
@@ -284,34 +457,11 @@
284 457 if ( '' === $base_id ) {
285 458 $base_id = $id;
286 459 }
287 460
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 461 // 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
462 + // desktopId pointing at a non-existent desktop (race with a
463 + // desktop close, or a malicious payload) is silently
314 464 // remapped to the active desktop so the window remains
315 465 // visible — losing it on restore would be the worse UX.
316 466 $win_desktop = isset( $win['desktopId'] ) ? sanitize_key( (string) $win['desktopId'] ) : '';
317 467 if ( '' === $win_desktop || ! in_array( $win_desktop, $desktop_ids, true ) ) {
@@ -317,8 +467,46 @@
317 467 if ( '' === $win_desktop || ! in_array( $win_desktop, $desktop_ids, true ) ) {
318 468 $win_desktop = $clean['activeDesktop'];
319 469 }
320 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 +
321 509 $entry = array(
322 510 'id' => $id,
323 511 'baseId' => $base_id,
324 512 'desktopId' => $win_desktop,
@@ -325,14 +513,59 @@
325 513 'url' => $url,
326 514 'title' => isset( $win['title'] ) ? wp_strip_all_tags( (string) $win['title'] ) : '',
327 515 'icon' => isset( $win['icon'] ) ? sanitize_html_class( (string) $win['icon'] ) : 'dashicons-admin-generic',
328 516 '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 ),
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 ),
333 521 );
334 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 +
335 568 // Sanitize external sub-tabs. Each entry carries a URL
336 569 // (any http/https — external tabs are explicitly for links
337 570 // OUT of wp-admin, so we don't restrict to same-origin
338 571 // here) and a label. Capped at a reasonable per-window
@@ -376,9 +609,9 @@
376 609 }
377 610
378 611 $clean['windows'][] = $entry;
379 612
380 - if ( count( $clean['windows'] ) >= DESKTOP_MODE_SESSION_MAX_WINDOWS ) {
613 + if ( count( $clean['windows'] ) >= OPENSTATION_SESSION_MAX_WINDOWS ) {
381 614 break;
382 615 }
383 616 }
384 617 }
@@ -400,18 +633,14 @@
400 633 * `is_numeric()`; float `INF`/`NAN` pass that gate but cast to 0 and
401 634 * are then clamped into `[min, max]`, so no out-of-range value
402 635 * survives.
403 636 *
404 - * @since 0.4.0
405 - * @since 0.5.0 Rejects non-numeric input explicitly instead of
406 - * relying on PHP's permissive `(int)` cast.
407 - *
408 637 * @param mixed $value The raw value.
409 638 * @param int $min Minimum allowed value.
410 639 * @param int $max Maximum allowed value.
411 640 * @return int The clamped integer.
412 641 */
413 -function desktop_mode_sanitize_session_dimension( $value, $min, $max ) {
642 +function openstation_sanitize_session_dimension( $value, $min, $max ) {
414 643 if ( is_string( $value ) ) {
415 644 $value = trim( $value );
416 645 }
417 646 if ( ! is_numeric( $value ) ) {
@@ -427,14 +656,133 @@
427 656 return $value;
428 657 }
429 658
430 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 +/**
431 771 * Registers the REST routes used by the desktop shell to load and save
432 772 * the current user's session.
433 - *
434 - * @since 0.4.0
435 773 */
436 -function desktop_mode_register_session_rest_routes() {
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 + );
437 785 register_rest_route(
438 786 'desktop-mode/v1',
439 787 '/session',
440 788 array(
@@ -439,79 +787,98 @@
439 787 '/session',
440 788 array(
441 789 array(
442 790 'methods' => WP_REST_Server::READABLE,
443 - 'callback' => 'desktop_mode_rest_get_session',
444 - 'permission_callback' => 'desktop_mode_rest_session_permission',
791 + 'callback' => 'openstation_rest_get_session',
792 + 'permission_callback' => 'openstation_rest_session_permission',
793 + 'args' => $network_arg,
445 794 ),
446 795 array(
447 796 'methods' => WP_REST_Server::CREATABLE,
448 - 'callback' => 'desktop_mode_rest_save_session',
449 - 'permission_callback' => 'desktop_mode_rest_session_permission',
450 - 'args' => array(
451 - 'session' => array(
452 - 'required' => true,
453 - 'type' => 'object',
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 + ),
454 805 ),
806 + $network_arg
455 807 ),
456 808 ),
457 809 array(
458 810 'methods' => WP_REST_Server::DELETABLE,
459 - 'callback' => 'desktop_mode_rest_clear_session',
460 - 'permission_callback' => 'desktop_mode_rest_session_permission',
811 + 'callback' => 'openstation_rest_clear_session',
812 + 'permission_callback' => 'openstation_rest_session_permission',
813 + 'args' => $network_arg,
461 814 ),
462 815 )
463 816 );
464 817 }
465 -add_action( 'rest_api_init', 'desktop_mode_register_session_rest_routes' );
818 +add_action( 'rest_api_init', 'openstation_register_session_rest_routes' );
466 819
467 820 /**
468 821 * Permission gate for the session REST routes: logged-in users who have
469 - * desktop mode enabled. See {@see desktop_mode_rest_require_enabled()}
822 + * OpenStation enabled. See {@see openstation_rest_require_enabled()}
470 823 * for why `read` alone is insufficient.
471 824 *
472 - * @since 0.4.0
473 - * @since 0.8.10 Hardened to require desktop mode enabled (was `read`).
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.
474 833 *
475 - * @return true|WP_Error
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.
476 842 */
477 -function desktop_mode_rest_session_permission() {
478 - return desktop_mode_rest_require_enabled();
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' );
479 847 }
480 848
481 849 /**
482 850 * GET /desktop-mode/v1/session — returns the caller's session.
483 851 *
484 - * @since 0.4.0
485 - *
852 + * @param WP_REST_Request $request The REST request.
486 853 * @return WP_REST_Response
487 854 */
488 -function desktop_mode_rest_get_session() {
489 - return rest_ensure_response( desktop_mode_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 + );
490 859 }
491 860
492 861 /**
493 862 * POST /desktop-mode/v1/session — replaces the caller's session.
494 863 *
495 - * @since 0.4.0
496 - *
497 864 * @param WP_REST_Request $request The REST request.
498 865 * @return WP_REST_Response The stored session (after sanitization).
499 866 */
500 -function desktop_mode_rest_save_session( WP_REST_Request $request ) {
867 +function openstation_rest_save_session( WP_REST_Request $request ) {
501 868 $user_id = get_current_user_id();
502 869 $payload = $request->get_param( 'session' );
503 - desktop_mode_save_session( $user_id, $payload );
504 - return rest_ensure_response( desktop_mode_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 ) );
505 873 }
506 874
507 875 /**
508 876 * DELETE /desktop-mode/v1/session — clears the caller's session.
509 877 *
510 - * @since 0.4.0
511 - *
878 + * @param WP_REST_Request $request The REST request.
512 879 * @return WP_REST_Response
513 880 */
514 -function desktop_mode_rest_clear_session() {
515 - desktop_mode_clear_session( get_current_user_id() );
516 - return rest_ensure_response( desktop_mode_empty_session() );
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() );
517 884 }