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/portal.php +344 -110 0.9.81.1.10 View file →
@@ -1,34 +1,62 @@
1 1 <?php
2 2 /**
3 - * Desktop Mode — `/desktop-mode` Portal Entry Point.
3 + * OpenStation — `/openstation` Portal Entry Point.
4 4 *
5 - * Registers `/desktop-mode` as a shareable URL that behaves like the
5 + * Registers `/openstation` as a shareable URL that behaves like the
6 6 * front door of the desktop UI:
7 7 * 1. Logged-out users are bounced through `wp-login.php` with a
8 - * redirect back to `/desktop-mode/`.
8 + * redirect back to `/openstation/`.
9 9 * 2. Logged-in users with basic admin-read capability have the
10 10 * `desktop_mode_mode` user-meta toggle auto-enabled on first visit,
11 - * then are forwarded into `wp-admin` at whichever window was
12 - * last focused in their saved session (or the dashboard as
13 - * fallback).
11 + * then are forwarded to the shell screen
12 + * (`admin.php?page=openstation`, see `includes/shell-screen.php`).
13 + * An explicit `?target=` travels along as the page the shell opens
14 + * first; without one the screen resolves the entry itself — the
15 + * last-focused window of the saved session, else the default
16 + * window, else the Dashboard.
14 17 *
15 18 * The URL is served virtually (no rewrite rules, no `.htaccess`
16 19 * surgery) by intercepting `parse_request` before WordPress routes the
17 20 * URL to 404. This keeps the plugin drop-in.
18 21 *
19 - * @package WPDesktopMode
22 + * @package OpenStation
20 23 */
21 24
22 25 defined( 'ABSPATH' ) || exit;
23 26
24 27 /** The URL path that triggers the portal handler. */
25 -const DESKTOP_MODE_PORTAL_PATH = 'desktop-mode';
28 +const OPENSTATION_PORTAL_PATH = 'openstation';
26 29
27 -/** Query var the admin shell reads to know it was entered via the portal. */
28 -const DESKTOP_MODE_PORTAL_FLAG = 'desktop_mode_portal';
30 +/**
31 + * The pre-rebrand portal path, still accepted.
32 + *
33 + * The portal was reachable at `/desktop-mode/` before the rename, and
34 + * that address is the kind of thing people bookmark or pin. It is not
35 + * canonical: {@see openstation_portal_url()} always emits the current
36 + * path, and a visit here forwards into wp-admin exactly as the canonical
37 + * path does, so the address bar self-corrects on the next hop.
38 + *
39 + * The VALUE keeps its pre-rebrand spelling on purpose: it is a
40 + * persisted or externally-visible identifier, so renaming it would
41 + * orphan data already written by live installs (or break a live
42 + * URL). The mismatch between this constant's name and its value is
43 + * deliberate — it is NOT a half-finished rename.
44 + */
45 +const OPENSTATION_PORTAL_PATH_LEGACY = 'desktop-mode';
29 46
30 47 /**
48 + * Query var the admin shell reads to know it was entered via the portal.
49 + *
50 + * The VALUE keeps its pre-rebrand spelling on purpose: it is a
51 + * persisted or externally-visible identifier, so renaming it would
52 + * orphan data already written by live installs (or break a live
53 + * URL). The mismatch between this constant's name and its value is
54 + * deliberate — it is NOT a half-finished rename.
55 + */
56 +const OPENSTATION_PORTAL_FLAG = 'desktop_mode_portal';
57 +
58 +/**
31 59 * Query var set on portal redirects whose landing page came from an
32 60 * explicit `?target=…` URL the user (or a redirect chain originating
33 61 * from a click) provided — as opposed to the portal picking the
34 62 * session's focused window or the default-window fallback.
@@ -35,30 +63,42 @@
35 63 *
36 64 * The shell uses this to distinguish "user expressed navigation intent
37 65 * toward this URL" (open it) from "portal had to forward somewhere"
38 66 * (don't disturb the restored session).
67 + *
68 + * The VALUE keeps its pre-rebrand spelling on purpose: it is a
69 + * persisted or externally-visible identifier, so renaming it would
70 + * orphan data already written by live installs (or break a live
71 + * URL). The mismatch between this constant's name and its value is
72 + * deliberate — it is NOT a half-finished rename.
39 73 */
40 -const DESKTOP_MODE_PORTAL_INTENT_FLAG = 'desktop_mode_portal_intent';
74 +const OPENSTATION_PORTAL_INTENT_FLAG = 'desktop_mode_portal_intent';
41 75
42 76 /**
43 77 * Query var set by the window-title-bar "Detach" action. Tells the
44 78 * admin_init redirect to skip portal forwarding for this request so the
45 79 * user can view the page as classic wp-admin in a new tab even when
46 - * desktop mode is globally enabled for their account.
80 + * OpenStation is globally enabled for their account.
81 + *
82 + * The VALUE keeps its pre-rebrand spelling on purpose: it is a
83 + * persisted or externally-visible identifier, so renaming it would
84 + * orphan data already written by live installs (or break a live
85 + * URL). The mismatch between this constant's name and its value is
86 + * deliberate — it is NOT a half-finished rename.
47 87 */
48 -const DESKTOP_MODE_CLASSIC_FLAG = 'desktop_mode_classic';
88 +const OPENSTATION_CLASSIC_FLAG = 'desktop_mode_classic';
49 89
50 90 /**
51 - * Returns the canonical portal URL, e.g. `https://example.com/desktop-mode/`.
91 + * Returns the canonical portal URL, e.g. `https://example.com/openstation/`.
52 92 *
53 93 * @return string
54 94 */
55 -function desktop_mode_portal_url() {
56 - return home_url( '/' . DESKTOP_MODE_PORTAL_PATH . '/' );
95 +function openstation_portal_url() {
96 + return home_url( '/' . OPENSTATION_PORTAL_PATH . '/' );
57 97 }
58 98
59 99 /**
60 - * Intercepts requests to `/desktop-mode` and forwards them into the admin.
100 + * Intercepts requests to `/openstation` and forwards them into the admin.
61 101 *
62 102 * Hooks on `parse_request` — early enough to pre-empt 404 handling but
63 103 * late enough that `is_user_logged_in()` is reliable.
64 104 *
@@ -63,18 +103,18 @@
63 103 * late enough that `is_user_logged_in()` is reliable.
64 104 *
65 105 * @param WP $wp Current WordPress environment instance.
66 106 */
67 -function desktop_mode_handle_portal_request( $wp ) {
107 +function openstation_handle_portal_request( $wp ) {
68 108 unset( $wp );
69 109
70 - if ( ! desktop_mode_is_portal_request() ) {
110 + if ( ! openstation_is_portal_request() ) {
71 111 return;
72 112 }
73 113
74 114 // Logged-out: bounce through login, returning to the portal URL.
75 115 if ( ! is_user_logged_in() ) {
76 - wp_safe_redirect( wp_login_url( desktop_mode_portal_url() ) );
116 + wp_safe_redirect( wp_login_url( openstation_portal_url() ) );
77 117 exit;
78 118 }
79 119
80 120 // Require basic admin-read capability so subscribers of sites that
@@ -89,75 +129,70 @@
89 129
90 130 $user_id = get_current_user_id();
91 131
92 132 /**
93 - * Filters whether visiting the `/desktop-mode` portal should auto-enable
94 - * desktop mode for the current user.
133 + * Filters whether visiting the `/openstation` portal should auto-enable
134 + * OpenStation for the current user.
95 135 *
96 136 * Default: true — the portal is an explicit opt-in action, so flipping
97 137 * the user meta mirrors the intent of visiting the URL.
98 138 *
99 - * @param bool $auto_enable Whether to auto-enable desktop mode.
139 + * @param bool $auto_enable Whether to auto-enable OpenStation.
100 140 * @param int $user_id The current user's ID.
101 141 */
102 - $auto_enable = apply_filters( 'desktop_mode_portal_auto_enable', true, $user_id );
142 + $auto_enable = apply_filters( 'openstation_portal_auto_enable', true, $user_id );
103 143
104 144 // CSRF guard: only flip user-meta when the request is a same-origin
105 145 // top-level navigation. The portal is a GET URL by design (users
106 - // follow shared `/desktop-mode/` links), so we can't require a nonce
146 + // follow shared `/openstation/` links), so we can't require a nonce
107 147 // — but we can require that the navigation originated from the
108 148 // same site (or a typed/bookmarked URL with no Referer/Sec-Fetch-
109 149 // Site). Off-origin hits still redirect into admin so shared
110 150 // links keep working; they just don't silently mutate user-meta.
111 - if ( $auto_enable && desktop_mode_portal_is_same_origin_navigation() && '1' !== get_user_meta( $user_id, 'desktop_mode_mode', true ) ) {
151 + if ( $auto_enable && openstation_portal_is_same_origin_navigation() && '1' !== get_user_meta( $user_id, 'desktop_mode_mode', true ) ) {
112 152 update_user_meta( $user_id, 'desktop_mode_mode', '1' );
153 + // Same stamps + action as the AJAX toggle; the portal is the
154 + // second of the two paths that turn a user on.
155 + openstation_record_user_enabled( $user_id );
113 156 }
114 157
115 - // Pick the landing page. Priority:
116 - // 1. Explicit `target` query arg, if same-origin wp-admin URL.
117 - // This is how `desktop_mode_redirect_plain_admin_to_portal` preserves
118 - // the user's navigation intent when they follow a link to a
119 - // specific admin page (e.g. profile.php).
120 - // 2. Last-focused window from the saved session.
121 - // 3. Dashboard fallback.
122 - $target = '';
123 - $has_intent = false;
158 + // Pick the page the shell opens first. An explicit `target` query
159 + // arg — a same-origin wp-admin URL — is how
160 + // `openstation_redirect_plain_admin_to_portal` preserves the user's
161 + // navigation intent when they follow a link to a specific admin
162 + // page (e.g. profile.php). Without one the shell screen resolves
163 + // the entry itself: the last-focused window from the saved
164 + // session, else the default window, else the Dashboard — see
165 + // `openstation_shell_boot_target()`. The bare screen URL is the
166 + // canonical address, and a reload of it re-resolves against the
167 + // live session rather than against the window that was focused
168 + // when the redirect happened.
169 + $target = '';
170 + $has_intent = false;
124 171 if ( ! empty( $_GET['target'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
125 172 // `esc_url_raw`, NOT `sanitize_text_field`: the latter strips
126 173 // every `%XX` percent-encoded sequence from its input as an XSS
127 174 // safeguard, which mangles request URIs that legitimately carry
128 175 // encoded slashes (e.g. `plugin=dir%2Ffile.php`). The downstream
129 - // `desktop_mode_sanitize_portal_target` validates the URL
176 + // `openstation_sanitize_portal_target` validates the URL
130 177 // rigorously (scheme rejection, traversal rejection, and a
131 178 // hardcoded allowlist of canonical wp-admin filenames — see
132 - // `desktop_mode_admin_target_allowlist()`) so we don't lose
179 + // `openstation_admin_target_allowlist()`) so we don't lose
133 180 // any real safety by skipping `sanitize_text_field` here.
134 - $target = desktop_mode_sanitize_portal_target( esc_url_raw( wp_unslash( $_GET['target'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
181 + $target = openstation_sanitize_portal_target( esc_url_raw( wp_unslash( $_GET['target'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
135 182 if ( '' !== $target ) {
136 183 $has_intent = true;
137 184 }
138 185 }
139 - if ( '' === $target ) {
140 - $target = desktop_mode_portal_entry_url( $user_id );
141 - }
142 -
143 - // Flag the forward so the shell can stamp the address bar back to
144 - // /desktop-mode/ via history.replaceState once it has loaded.
145 - $target = add_query_arg( DESKTOP_MODE_PORTAL_FLAG, '1', $target );
146 -
147 - // Second flag: the redirect resolved from an explicit `target`, so
148 - // the shell should treat the resulting `currentPage` as user
149 - // intent and auto-open it on top of the restored session. Without
150 - // this, a bare `/desktop-mode/` visit and a portal-redirected
151 - // admin-bar click would be indistinguishable downstream.
152 - if ( $has_intent ) {
153 - $target = add_query_arg( DESKTOP_MODE_PORTAL_INTENT_FLAG, '1', $target );
154 - }
155 -
156 - wp_safe_redirect( $target );
186 + // `intent=1` rides along with an explicit target so the shell treats
187 + // the resulting `currentPage` as user intent and opens it on top of
188 + // the restored session. Without it, a bare `/openstation/` visit and
189 + // a portal-redirected admin-bar click would be indistinguishable
190 + // downstream.
191 + wp_safe_redirect( openstation_shell_url( $target, $has_intent ) );
157 192 exit;
158 193 }
159 -add_action( 'parse_request', 'desktop_mode_handle_portal_request' );
194 +add_action( 'parse_request', 'openstation_handle_portal_request' );
160 195
161 196 /**
162 197 * Decides whether the current request to the portal can mutate
163 198 * user-meta safely (same-origin) or should only redirect (cross-
@@ -174,9 +209,9 @@
174 209 * same host or empty referrer is trusted, anything else isn't.
175 210 *
176 211 * @return bool
177 212 */
178 -function desktop_mode_portal_is_same_origin_navigation() {
213 +function openstation_portal_is_same_origin_navigation() {
179 214 if ( ! empty( $_SERVER['HTTP_SEC_FETCH_SITE'] ) ) {
180 215 $site = strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_SEC_FETCH_SITE'] ) ) );
181 216 return in_array( $site, array( 'same-origin', 'same-site', 'none' ), true );
182 217 }
@@ -198,13 +233,15 @@
198 233 /**
199 234 * Detects whether the current request is for the portal URL.
200 235 *
201 236 * Strips any query string and trailing slash and compares against
202 - * `/desktop-mode` relative to the site's home path.
237 + * `/openstation` relative to the site's home path. The pre-rebrand
238 + * `/desktop-mode` path is accepted too, so bookmarks made before the
239 + * rename still land in the shell.
203 240 *
204 241 * @return bool
205 242 */
206 -function desktop_mode_is_portal_request() {
243 +function openstation_is_portal_request() {
207 244 if ( empty( $_SERVER['REQUEST_URI'] ) ) {
208 245 return false;
209 246 }
210 247
@@ -219,40 +256,87 @@
219 256
220 257 $home_path = wp_parse_url( home_url( '/' ), PHP_URL_PATH );
221 258 $home_path = is_string( $home_path ) ? rtrim( $home_path, '/' ) : '';
222 259
223 - $expected = $home_path . '/' . DESKTOP_MODE_PORTAL_PATH;
224 - $path = '/' . ltrim( rtrim( $path, '/' ), '/' );
260 + $path = '/' . ltrim( rtrim( $path, '/' ), '/' );
225 261
226 - return $path === $expected;
262 + return in_array(
263 + $path,
264 + array(
265 + $home_path . '/' . OPENSTATION_PORTAL_PATH,
266 + $home_path . '/' . OPENSTATION_PORTAL_PATH_LEGACY,
267 + ),
268 + true
269 + );
227 270 }
228 271
229 272 /**
230 - * Forwards plain `/wp-admin/...` requests to the `/desktop-mode/` portal
231 - * when the current user has desktop mode enabled.
273 + * Sends plain `/wp-admin/...` requests into the desktop.
232 274 *
233 - * Why: when desktop mode is on, `/desktop-mode/` is meant to be the one
234 - * canonical address. A user who bookmarks `/wp-admin/plugins.php` or
235 - * follows an old admin link should still land in the shell, not in
236 - * vanilla admin with the shell glued over the top. Running through the
237 - * portal unifies the address bar and honors the saved session's focused
238 - * window.
275 + * The shell is served by its own screen (`includes/shell-screen.php`),
276 + * so a plain admin page is never where the desktop renders: a user who
277 + * typed or bookmarked `/wp-admin/edit.php` is forwarded to the shell
278 + * screen with that URL as the page it opens first. Three routes out of
279 + * here, cheapest first:
239 280 *
281 + * 1. **Straight to the shell screen** when the portal would only hand
282 + * this URL back — an allowlisted wp-admin file that is also the
283 + * page being served, carrying no query arg the portal would strip
284 + * ({@see openstation_portal_forward_is_redundant()}). One
285 + * redirect; the portal hop would have cost a WordPress bootstrap
286 + * to learn what is already known. `openstation_skip_redundant_portal_forward`
287 + * (return false) forces the hop back on for a plugin that hooks
288 + * the portal handler for side effects.
289 + * 2. **Through `/openstation/?target=…`** otherwise — a network-admin
290 + * URL, a path outside the wp-admin allowlist — so the portal can
291 + * fall back to the saved session's focused window, which is a real
292 + * change of destination the shell can't make from here.
293 + * 3. **The frozen-flag alias.** A URL carrying `desktop_mode_portal=1`
294 + * is the desktop's pre-screen address: the portal used to forward
295 + * to a real admin page tagged with it, and bookmarks, the PWA start
296 + * URL and plugin-built links still say so. It goes to the shell
297 + * screen with that URL as the target, and `intent=1` when the
298 + * intent flag was present. The flags stay frozen (see AGENTS.md);
299 + * only what they resolve to moved.
300 + *
240 301 * Narrowly scoped to bail on every automated or sub-request entry point
241 - * — AJAX, REST, cron, admin-post.php, non-GET methods — so the hook
242 - * can't corrupt a form submission or break an API call.
302 + * — AJAX, REST, cron, admin-post.php, non-GET methods, and sub-resource
303 + * fetches (an `<img>`, a script or an XHR whose URL is an admin page,
304 + * see {@see openstation_is_subresource_request()}) — so the hook can't
305 + * corrupt a form submission, break an API call or hand an image tag an
306 + * HTML document. The shell screen itself, chromeless loads, solo boots
307 + * and classic-flagged requests pass through.
243 308 *
244 - * Disable via the `desktop_mode_admin_redirect_to_portal` filter (return
245 - * false). Passthrough kicks in automatically when the current request
246 - * is chromeless or already carries the portal flag.
309 + * Disable via the `openstation_admin_redirect_to_portal` filter (return
310 + * false); plain admin pages then render as classic admin and the
311 + * desktop lives at `/openstation/` only. The alias route runs before
312 + * the filter: a URL that names the desktop is not a plain admin page.
247 313 */
248 -function desktop_mode_redirect_plain_admin_to_portal() {
249 - if ( ! desktop_mode_is_enabled() ) {
314 +function openstation_redirect_plain_admin_to_portal() {
315 + if ( ! openstation_is_enabled() ) {
250 316 return;
251 317 }
252 - if ( desktop_mode_is_chromeless_request() ) {
318 + // The screen the redirects land on. First in the chain: every other
319 + // branch below ends in a redirect here, and the screen is a plain
320 + // admin GET like any other.
321 + if ( openstation_is_shell_screen_request() ) {
253 322 return;
254 323 }
324 + if ( openstation_is_chromeless_request() ) {
325 + return;
326 + }
327 + // A solo boot renders one window in place, wherever it landed.
328 + if ( function_exists( 'openstation_is_solo_request' ) && openstation_is_solo_request() ) {
329 + return;
330 + }
331 + // The user admin (`wp-admin/user/`, multisite's dashboard for users
332 + // with no site role) renders classic. It has no shell screen of its
333 + // own, and its URLs never survive the target allowlist — before
334 + // this pass-through the redirect claimed the request anyway and
335 + // silently forwarded the user to the site desktop's default entry.
336 + if ( is_multisite() && is_user_admin() ) {
337 + return;
338 + }
255 339 if ( wp_doing_ajax() || wp_doing_cron() ) {
256 340 return;
257 341 }
258 342 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
@@ -260,12 +344,14 @@
260 344 }
261 345 if ( ! empty( $_SERVER['REQUEST_METHOD'] ) && 'GET' !== strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) ) {
262 346 return;
263 347 }
264 -
265 - // The portal handler adds this flag after it forwards into admin.
266 - // Bailing here keeps us out of an infinite redirect loop.
267 - if ( ! empty( $_GET[ DESKTOP_MODE_PORTAL_FLAG ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
348 + // The browser says what it is fetching for. An <img>, a script or
349 + // an XHR aimed at an admin URL (Jetpack's admin-bar sparkline is
350 + // admin.php?page=stats&noheader&proxy&chart=…) is not a user
351 + // landing on a plain admin page, and forwarding it into the desktop
352 + // only swaps the bytes it asked for with the shell's HTML.
353 + if ( openstation_is_subresource_request() ) {
268 354 return;
269 355 }
270 356
271 357 // The "Detach to new tab" button tags its URL with this flag so the
@@ -271,9 +357,9 @@
271 357 // The "Detach to new tab" button tags its URL with this flag so the
272 358 // user can view one admin page classically without disabling desktop
273 359 // mode account-wide. Only affects the single request — subsequent
274 360 // navigations inside the tab lose the flag and follow normal rules.
275 - if ( ! empty( $_GET[ DESKTOP_MODE_CLASSIC_FLAG ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
361 + if ( ! empty( $_GET[ OPENSTATION_CLASSIC_FLAG ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
276 362 return;
277 363 }
278 364
279 365 // admin-post.php and admin-ajax.php handle form submissions and JSON
@@ -282,34 +368,67 @@
282 368 if ( in_array( $pagenow, array( 'admin-post.php', 'admin-ajax.php' ), true ) ) {
283 369 return;
284 370 }
285 371
372 + // `esc_url_raw` instead of `sanitize_text_field`: the latter strips
373 + // every `%XX` percent-encoded sequence, which corrupts URIs whose
374 + // query string legitimately carries an encoded slash — e.g. WP's
375 + // own `plugins.php?action=activate&plugin=dir%2Ffile.php` activate
376 + // link. The shell screen validates the target on read.
377 + $target = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
378 + $target = is_string( $target ) ? $target : '';
379 +
380 + // Route 3: the frozen-flag alias. The sanitiser strips both flags
381 + // from the target; an unresolvable one leaves the screen to pick
382 + // the entry, exactly as the portal did for an invalid `target`.
383 + if ( ! empty( $_GET[ OPENSTATION_PORTAL_FLAG ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
384 + $clean = openstation_sanitize_portal_target( $target );
385 + $intent = '' !== $clean && ! empty( $_GET[ OPENSTATION_PORTAL_INTENT_FLAG ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
386 + wp_safe_redirect( openstation_shell_url( $clean, $intent ) );
387 + exit;
388 + }
389 +
286 390 /**
287 - * Filters whether plain admin URLs should redirect to the portal
288 - * when desktop mode is active.
391 + * Filters whether plain admin URLs should redirect into the desktop
392 + * when OpenStation is active.
289 393 *
290 394 * @param bool $redirect Whether to redirect. Default true.
291 395 * @param int $user_id The current user's ID.
292 396 */
293 - $redirect = apply_filters( 'desktop_mode_admin_redirect_to_portal', true, get_current_user_id() );
397 + $redirect = apply_filters( 'openstation_admin_redirect_to_portal', true, get_current_user_id() );
294 398 if ( ! $redirect ) {
295 399 return;
296 400 }
297 401
298 - // Preserve the original target on the portal redirect. Without this,
402 + // Route 1: straight to the shell screen.
403 + if ( openstation_portal_forward_is_redundant( $target ) ) {
404 + /**
405 + * Filters whether to skip the portal hop for a URL the portal
406 + * would only hand straight back.
407 + *
408 + * Default: true — the request goes straight to the shell screen
409 + * with this URL as its target. Return false to route through
410 + * `/openstation/` anyway, e.g. for a plugin that hooks
411 + * `openstation_handle_portal_request` for its own side effects
412 + * and needs it to run on every admin entry.
413 + *
414 + * @param bool $skip Whether to skip the portal hop.
415 + * @param string $request_uri The current request URI.
416 + */
417 + if ( apply_filters( 'openstation_skip_redundant_portal_forward', true, $target ) ) {
418 + wp_safe_redirect( openstation_shell_url( openstation_sanitize_portal_target( $target ), true ) );
419 + exit;
420 + }
421 + }
422 +
423 + // Route 2: through the portal, target preserved. Without it,
299 424 // navigating to a specific admin page (profile.php, plugins.php, any
300 425 // deep link) loses the user's intent — the portal would forward them
301 426 // to whichever window was last focused instead of the page they asked
302 427 // for. The portal handler reads `target`, validates it's same-origin
303 - // wp-admin, and uses it as the entry URL.
304 - $portal_url = desktop_mode_portal_url();
305 - // `esc_url_raw` instead of `sanitize_text_field`: the latter strips
306 - // every `%XX` percent-encoded sequence, which corrupts URIs whose
307 - // query string legitimately carries an encoded slash — e.g. WP's
308 - // own `plugins.php?action=activate&plugin=dir%2Ffile.php` activate
309 - // link. The portal handler will validate this target downstream.
310 - $target = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
311 - if ( is_string( $target ) && '' !== $target ) {
428 + // wp-admin, and passes it on to the shell screen.
429 + $portal_url = openstation_portal_url();
430 + if ( '' !== $target ) {
312 431 $portal_url = add_query_arg( 'target', rawurlencode( $target ), $portal_url );
313 432 }
314 433
315 434 wp_safe_redirect( $portal_url );
@@ -314,11 +433,90 @@
314 433
315 434 wp_safe_redirect( $portal_url );
316 435 exit;
317 436 }
318 -add_action( 'admin_init', 'desktop_mode_redirect_plain_admin_to_portal' );
437 +add_action( 'admin_init', 'openstation_redirect_plain_admin_to_portal' );
319 438
320 439 /**
440 + * Whether forwarding this request through `/openstation/` would only
441 + * hand the URL already being served back as the shell's target.
442 + *
443 + * Answers locally, and without the HTTP round trip, the same question
444 + * {@see openstation_handle_portal_request()} answers after another
445 + * WordPress bootstrap. True means the hop is pure overhead and the
446 + * caller can send the user straight to the shell screen with this URL
447 + * as its target.
448 + *
449 + * Deliberately conservative: every "don't know" answers false, so the
450 + * forward survives wherever the portal might genuinely choose a
451 + * different destination.
452 + *
453 + * 1. The path must resolve through the same wp-admin allowlist the
454 + * portal validates `?target=` against. Anything that list rejects
455 + * — a `network/` or `user/` sub-path on multisite, a filename that
456 + * isn't canonical wp-admin — makes the portal fall back to the
457 + * session's focused window, which is a real change of destination.
458 + * 2. The resolved filename must be the file this request is actually
459 + * serving. If `$pagenow` disagrees with the URL path then a
460 + * rewrite is in play and we can't claim to know what renders here.
461 + * 3. The query must survive intact. The portal drops
462 + * `openstation_chromeless`, both portal flags and `target` from
463 + * the URL it rebuilds, so a request carrying any of them comes
464 + * back as a different URL.
465 + *
466 + * @param string $request_uri The current request URI, unslashed.
467 + * @return bool True when the portal would resolve this URL to itself.
468 + */
469 +function openstation_portal_forward_is_redundant( $request_uri ) {
470 + global $pagenow;
471 +
472 + if ( ! is_string( $request_uri ) || '' === $request_uri ) {
473 + return false;
474 + }
475 +
476 + $path = wp_parse_url( $request_uri, PHP_URL_PATH );
477 + if ( ! is_string( $path ) || '' === $path ) {
478 + return false;
479 + }
480 +
481 + $admin_path = wp_parse_url( admin_url(), PHP_URL_PATH );
482 + $admin_path = is_string( $admin_path ) ? $admin_path : '/wp-admin/';
483 + if ( 0 !== strpos( $path, $admin_path ) ) {
484 + return false;
485 + }
486 +
487 + $file = ltrim( (string) substr( $path, strlen( $admin_path ) ), '/' );
488 + if ( '' === $file ) {
489 + $file = 'index.php';
490 + }
491 +
492 + // 1. The portal's allowlist has to accept it.
493 + if ( is_wp_error( openstation_resolve_admin_target( $file ) ) ) {
494 + return false;
495 + }
496 +
497 + // 2. …and it has to be the page we are actually serving.
498 + if ( ! is_string( $pagenow ) || strtolower( $file ) !== strtolower( $pagenow ) ) {
499 + return false;
500 + }
501 +
502 + // 3. …carrying a query the portal would hand back unchanged.
503 + $rewritten = array(
504 + 'openstation_chromeless',
505 + OPENSTATION_PORTAL_FLAG,
506 + OPENSTATION_PORTAL_INTENT_FLAG,
507 + 'target',
508 + );
509 + foreach ( $rewritten as $key ) {
510 + if ( isset( $_GET[ $key ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
511 + return false;
512 + }
513 + }
514 +
515 + return true;
516 +}
517 +
518 +/**
321 519 * Resolves the admin URL the portal should forward to for a given user.
322 520 *
323 521 * Looks up the user's session and returns the URL of the window flagged
324 522 * as `focused`. If the session is empty, has no focused window, or the
@@ -325,9 +523,9 @@
325 523 * focused window's URL isn't same-origin admin, falls back to the
326 524 * dashboard.
327 525 *
328 526 * The portal navigates the TOP window, not an iframe, so any chromeless
329 - * `desktop_mode_chromeless=1` flag baked into the stored URL is stripped — a leftover
527 + * `openstation_chromeless=1` flag baked into the stored URL is stripped — a leftover
330 528 * flag would land the user in a standalone chromeless page (no admin
331 529 * bar, no toggle, no way out) instead of the shell.
332 530 *
333 531 * @param int $user_id The user whose session to consult.
@@ -332,10 +530,10 @@
332 530 *
333 531 * @param int $user_id The user whose session to consult.
334 532 * @return string The admin URL to redirect to.
335 533 */
336 -function desktop_mode_portal_entry_url( $user_id ) {
337 - $session = desktop_mode_get_session( $user_id );
534 +function openstation_portal_entry_url( $user_id ) {
535 + $session = openstation_get_session( $user_id );
338 536
339 537 // User's configured default-window preference. When disabled, we
340 538 // still have to forward SOMEWHERE (the portal is an HTTP redirect),
341 539 // so we land on the Dashboard URL — but the shell detects the
@@ -340,12 +538,12 @@
340 538 // still have to forward SOMEWHERE (the portal is an HTTP redirect),
341 539 // so we land on the Dashboard URL — but the shell detects the
342 540 // `enabled=false` state via the config and skips the auto-open,
343 541 // leaving the user with an empty desktop as they chose.
344 - $default_window = desktop_mode_get_default_window( $user_id );
542 + $default_window = openstation_get_default_window( $user_id );
345 543 $fallback = $default_window['url'];
346 544
347 - // Native marker (e.g. "native:desktop-mode-os-settings") is not a
545 + // Native marker (e.g. "native:os-settings") is not a
348 546 // redirectable URL. The portal MUST forward somewhere — the
349 547 // redirect happens at HTTP level — so we land on the admin home
350 548 // and let the shell pick up `defaultWindow.url` from the config
351 549 // after init and call nativeWindows.openById( <slug> ).
@@ -363,12 +561,18 @@
363 561 }
364 562 if ( $win['id'] !== $session['focused'] ) {
365 563 continue;
366 564 }
367 - if ( ! desktop_mode_url_is_same_admin( $win['url'] ) ) {
565 + if ( ! openstation_url_is_same_admin( $win['url'] ) ) {
368 566 return $fallback;
369 567 }
370 - return remove_query_arg( array( 'desktop_mode_chromeless', DESKTOP_MODE_PORTAL_FLAG ), $win['url'] );
568 + // The shell must never open itself. A saved window pointing at
569 + // the shell screen cannot be produced by the shell, but a
570 + // hand-edited session could say so; treat it as nothing focused.
571 + if ( openstation_url_is_shell_screen( $win['url'] ) ) {
572 + return $fallback;
573 + }
574 + return remove_query_arg( array( 'openstation_chromeless', OPENSTATION_PORTAL_FLAG ), $win['url'] );
371 575 }
372 576
373 577 return $fallback;
374 578 }
@@ -381,9 +585,9 @@
381 585 * URL if — and only if — it resolves to a same-origin `wp-admin/` path.
382 586 * Everything else returns an empty string so the caller falls back to
383 587 * the saved-session entry URL.
384 588 *
385 - * Strips `desktop_mode_chromeless` and the portal flag from the query so the target
589 + * Strips `openstation_chromeless` and the portal flag from the query so the target
386 590 * doesn't chain us into a chromeless standalone load or an infinite
387 591 * redirect loop.
388 592 *
389 593 * @param string $raw Raw value from `$_GET['target']` (already unslashed).
@@ -388,9 +592,9 @@
388 592 *
389 593 * @param string $raw Raw value from `$_GET['target']` (already unslashed).
390 594 * @return string A safe absolute admin URL, or '' if the input is invalid.
391 595 */
392 -function desktop_mode_sanitize_portal_target( $raw ) {
596 +function openstation_sanitize_portal_target( $raw ) {
393 597 if ( ! is_string( $raw ) || '' === $raw ) {
394 598 return '';
395 599 }
396 600
@@ -418,19 +622,29 @@
418 622 }
419 623
420 624 $file = substr( $path, strlen( $admin_path ) );
421 625 $file = ltrim( (string) $file, '/' );
626 +
627 + // The network admin's own screens live one directory down and are
628 + // resolved against their own list. Without this a network URL came
629 + // back empty and the user was quietly forwarded to the site
630 + // dashboard, which is a different admin.
631 + $network = 0 === strpos( $file, 'network/' );
632 + if ( $network ) {
633 + $file = substr( $file, strlen( 'network/' ) );
634 + }
635 +
422 636 if ( '' === $file ) {
423 637 $file = 'index.php';
424 638 }
425 639
426 640 // Resolve against the hardcoded allowlist of canonical wp-admin
427 - // filenames (see `desktop_mode_admin_target_allowlist()`). A
641 + // filenames (see `openstation_admin_target_allowlist()`). A
428 642 // regex alone would accept a plausible-looking filename that
429 643 // isn't a real core admin page (e.g. `custom_admin_page.php`)
430 644 // and effectively become an open redirect to a 404 page served
431 645 // under the admin path; the explicit allowlist closes that.
432 - $target = desktop_mode_resolve_admin_target( $file );
646 + $target = openstation_resolve_admin_target( $file, $network );
433 647 if ( is_wp_error( $target ) ) {
434 648 return '';
435 649 }
436 650
@@ -435,12 +649,32 @@
435 649 }
436 650
437 651 if ( is_string( $query ) && '' !== $query ) {
438 652 parse_str( $query, $args );
439 - unset( $args['desktop_mode_chromeless'], $args[ DESKTOP_MODE_PORTAL_FLAG ], $args[ DESKTOP_MODE_PORTAL_INTENT_FLAG ], $args['target'] );
653 + unset( $args['openstation_chromeless'], $args[ OPENSTATION_PORTAL_FLAG ], $args[ OPENSTATION_PORTAL_INTENT_FLAG ], $args['target'] );
440 654 if ( ! empty( $args ) ) {
441 655 $target = add_query_arg( $args, $target );
442 656 }
657 + }
658 +
659 + // The shell screen is where a target is opened, never a target: the
660 + // shell would open itself in a window, and a redirect chain built
661 + // from it would loop. Fall back to the entry resolver instead.
662 + if ( openstation_url_is_shell_screen( $target ) ) {
663 + return '';
664 + }
665 +
666 + // `admin.php` is a bootstrap, not a page. Without a `page` arg core
667 + // falls through the last `else` in `wp-admin/admin.php`, never
668 + // requires `admin-header.php`, and answers 200 with an empty body —
669 + // so the URL becomes a window showing nothing. The allowlist above
670 + // matches filenames and cannot see the query, which is why the
671 + // check belongs here, beside the shell-screen one: both are URLs
672 + // that resolve but must not become a target. Returning '' hands the
673 + // caller back to the entry resolver (session's focused window, else
674 + // the default window, else the Dashboard).
675 + if ( openstation_url_is_page_less_admin_php( $target ) ) {
676 + return '';
443 677 }
444 678
445 679 return $target;
446 680 }