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 -128 0.9.51.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,50 +63,58 @@
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 - * @since 0.4.0
54 - *
55 93 * @return string
56 94 */
57 -function desktop_mode_portal_url() {
58 - return home_url( '/' . DESKTOP_MODE_PORTAL_PATH . '/' );
95 +function openstation_portal_url() {
96 + return home_url( '/' . OPENSTATION_PORTAL_PATH . '/' );
59 97 }
60 98
61 99 /**
62 - * Intercepts requests to `/desktop-mode` and forwards them into the admin.
100 + * Intercepts requests to `/openstation` and forwards them into the admin.
63 101 *
64 102 * Hooks on `parse_request` — early enough to pre-empt 404 handling but
65 103 * late enough that `is_user_logged_in()` is reliable.
66 104 *
67 - * @since 0.4.0
68 - *
69 105 * @param WP $wp Current WordPress environment instance.
70 106 */
71 -function desktop_mode_handle_portal_request( $wp ) {
107 +function openstation_handle_portal_request( $wp ) {
72 108 unset( $wp );
73 109
74 - if ( ! desktop_mode_is_portal_request() ) {
110 + if ( ! openstation_is_portal_request() ) {
75 111 return;
76 112 }
77 113
78 114 // Logged-out: bounce through login, returning to the portal URL.
79 115 if ( ! is_user_logged_in() ) {
80 - wp_safe_redirect( wp_login_url( desktop_mode_portal_url() ) );
116 + wp_safe_redirect( wp_login_url( openstation_portal_url() ) );
81 117 exit;
82 118 }
83 119
84 120 // Require basic admin-read capability so subscribers of sites that
@@ -93,77 +129,70 @@
93 129
94 130 $user_id = get_current_user_id();
95 131
96 132 /**
97 - * Filters whether visiting the `/desktop-mode` portal should auto-enable
98 - * desktop mode for the current user.
133 + * Filters whether visiting the `/openstation` portal should auto-enable
134 + * OpenStation for the current user.
99 135 *
100 136 * Default: true — the portal is an explicit opt-in action, so flipping
101 137 * the user meta mirrors the intent of visiting the URL.
102 138 *
103 - * @since 0.4.0
104 - *
105 - * @param bool $auto_enable Whether to auto-enable desktop mode.
139 + * @param bool $auto_enable Whether to auto-enable OpenStation.
106 140 * @param int $user_id The current user's ID.
107 141 */
108 - $auto_enable = apply_filters( 'desktop_mode_portal_auto_enable', true, $user_id );
142 + $auto_enable = apply_filters( 'openstation_portal_auto_enable', true, $user_id );
109 143
110 144 // CSRF guard: only flip user-meta when the request is a same-origin
111 145 // top-level navigation. The portal is a GET URL by design (users
112 - // follow shared `/desktop-mode/` links), so we can't require a nonce
146 + // follow shared `/openstation/` links), so we can't require a nonce
113 147 // — but we can require that the navigation originated from the
114 148 // same site (or a typed/bookmarked URL with no Referer/Sec-Fetch-
115 149 // Site). Off-origin hits still redirect into admin so shared
116 150 // links keep working; they just don't silently mutate user-meta.
117 - 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 ) ) {
118 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 );
119 156 }
120 157
121 - // Pick the landing page. Priority:
122 - // 1. Explicit `target` query arg, if same-origin wp-admin URL.
123 - // This is how `desktop_mode_redirect_plain_admin_to_portal` preserves
124 - // the user's navigation intent when they follow a link to a
125 - // specific admin page (e.g. profile.php).
126 - // 2. Last-focused window from the saved session.
127 - // 3. Dashboard fallback.
128 - $target = '';
129 - $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;
130 171 if ( ! empty( $_GET['target'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
131 172 // `esc_url_raw`, NOT `sanitize_text_field`: the latter strips
132 173 // every `%XX` percent-encoded sequence from its input as an XSS
133 174 // safeguard, which mangles request URIs that legitimately carry
134 175 // encoded slashes (e.g. `plugin=dir%2Ffile.php`). The downstream
135 - // `desktop_mode_sanitize_portal_target` validates the URL
176 + // `openstation_sanitize_portal_target` validates the URL
136 177 // rigorously (scheme rejection, traversal rejection, and a
137 178 // hardcoded allowlist of canonical wp-admin filenames — see
138 - // `desktop_mode_admin_target_allowlist()`) so we don't lose
179 + // `openstation_admin_target_allowlist()`) so we don't lose
139 180 // any real safety by skipping `sanitize_text_field` here.
140 - $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
141 182 if ( '' !== $target ) {
142 183 $has_intent = true;
143 184 }
144 185 }
145 - if ( '' === $target ) {
146 - $target = desktop_mode_portal_entry_url( $user_id );
147 - }
148 -
149 - // Flag the forward so the shell can stamp the address bar back to
150 - // /desktop-mode/ via history.replaceState once it has loaded.
151 - $target = add_query_arg( DESKTOP_MODE_PORTAL_FLAG, '1', $target );
152 -
153 - // Second flag: the redirect resolved from an explicit `target`, so
154 - // the shell should treat the resulting `currentPage` as user
155 - // intent and auto-open it on top of the restored session. Without
156 - // this, a bare `/desktop-mode/` visit and a portal-redirected
157 - // admin-bar click would be indistinguishable downstream.
158 - if ( $has_intent ) {
159 - $target = add_query_arg( DESKTOP_MODE_PORTAL_INTENT_FLAG, '1', $target );
160 - }
161 -
162 - 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 ) );
163 192 exit;
164 193 }
165 -add_action( 'parse_request', 'desktop_mode_handle_portal_request' );
194 +add_action( 'parse_request', 'openstation_handle_portal_request' );
166 195
167 196 /**
168 197 * Decides whether the current request to the portal can mutate
169 198 * user-meta safely (same-origin) or should only redirect (cross-
@@ -178,13 +207,11 @@
178 207 * pointed the user at the portal — could be an `<img>` tag).
179 208 * - Header missing (older browsers): fall back to `Referer` —
180 209 * same host or empty referrer is trusted, anything else isn't.
181 210 *
182 - * @since 0.6.2
183 - *
184 211 * @return bool
185 212 */
186 -function desktop_mode_portal_is_same_origin_navigation() {
213 +function openstation_portal_is_same_origin_navigation() {
187 214 if ( ! empty( $_SERVER['HTTP_SEC_FETCH_SITE'] ) ) {
188 215 $site = strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_SEC_FETCH_SITE'] ) ) );
189 216 return in_array( $site, array( 'same-origin', 'same-site', 'none' ), true );
190 217 }
@@ -206,15 +233,15 @@
206 233 /**
207 234 * Detects whether the current request is for the portal URL.
208 235 *
209 236 * Strips any query string and trailing slash and compares against
210 - * `/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.
211 240 *
212 - * @since 0.4.0
213 - *
214 241 * @return bool
215 242 */
216 -function desktop_mode_is_portal_request() {
243 +function openstation_is_portal_request() {
217 244 if ( empty( $_SERVER['REQUEST_URI'] ) ) {
218 245 return false;
219 246 }
220 247
@@ -229,42 +256,87 @@
229 256
230 257 $home_path = wp_parse_url( home_url( '/' ), PHP_URL_PATH );
231 258 $home_path = is_string( $home_path ) ? rtrim( $home_path, '/' ) : '';
232 259
233 - $expected = $home_path . '/' . DESKTOP_MODE_PORTAL_PATH;
234 - $path = '/' . ltrim( rtrim( $path, '/' ), '/' );
260 + $path = '/' . ltrim( rtrim( $path, '/' ), '/' );
235 261
236 - 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 + );
237 270 }
238 271
239 272 /**
240 - * Forwards plain `/wp-admin/...` requests to the `/desktop-mode/` portal
241 - * when the current user has desktop mode enabled.
273 + * Sends plain `/wp-admin/...` requests into the desktop.
242 274 *
243 - * Why: when desktop mode is on, `/desktop-mode/` is meant to be the one
244 - * canonical address. A user who bookmarks `/wp-admin/plugins.php` or
245 - * follows an old admin link should still land in the shell, not in
246 - * vanilla admin with the shell glued over the top. Running through the
247 - * portal unifies the address bar and honors the saved session's focused
248 - * 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:
249 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 + *
250 301 * Narrowly scoped to bail on every automated or sub-request entry point
251 - * — AJAX, REST, cron, admin-post.php, non-GET methods — so the hook
252 - * 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.
253 308 *
254 - * Disable via the `desktop_mode_admin_redirect_to_portal` filter (return
255 - * false). Passthrough kicks in automatically when the current request
256 - * is chromeless or already carries the portal flag.
257 - *
258 - * @since 0.4.0
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.
259 313 */
260 -function desktop_mode_redirect_plain_admin_to_portal() {
261 - if ( ! desktop_mode_is_enabled() ) {
314 +function openstation_redirect_plain_admin_to_portal() {
315 + if ( ! openstation_is_enabled() ) {
262 316 return;
263 317 }
264 - 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() ) {
265 322 return;
266 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 + }
267 339 if ( wp_doing_ajax() || wp_doing_cron() ) {
268 340 return;
269 341 }
270 342 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
@@ -272,12 +344,14 @@
272 344 }
273 345 if ( ! empty( $_SERVER['REQUEST_METHOD'] ) && 'GET' !== strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) ) {
274 346 return;
275 347 }
276 -
277 - // The portal handler adds this flag after it forwards into admin.
278 - // Bailing here keeps us out of an infinite redirect loop.
279 - 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() ) {
280 354 return;
281 355 }
282 356
283 357 // The "Detach to new tab" button tags its URL with this flag so the
@@ -283,9 +357,9 @@
283 357 // The "Detach to new tab" button tags its URL with this flag so the
284 358 // user can view one admin page classically without disabling desktop
285 359 // mode account-wide. Only affects the single request — subsequent
286 360 // navigations inside the tab lose the flag and follow normal rules.
287 - 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
288 362 return;
289 363 }
290 364
291 365 // admin-post.php and admin-ajax.php handle form submissions and JSON
@@ -294,36 +368,67 @@
294 368 if ( in_array( $pagenow, array( 'admin-post.php', 'admin-ajax.php' ), true ) ) {
295 369 return;
296 370 }
297 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 +
298 390 /**
299 - * Filters whether plain admin URLs should redirect to the portal
300 - * when desktop mode is active.
391 + * Filters whether plain admin URLs should redirect into the desktop
392 + * when OpenStation is active.
301 393 *
302 - * @since 0.4.0
303 - *
304 394 * @param bool $redirect Whether to redirect. Default true.
305 395 * @param int $user_id The current user's ID.
306 396 */
307 - $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() );
308 398 if ( ! $redirect ) {
309 399 return;
310 400 }
311 401
312 - // 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,
313 424 // navigating to a specific admin page (profile.php, plugins.php, any
314 425 // deep link) loses the user's intent — the portal would forward them
315 426 // to whichever window was last focused instead of the page they asked
316 427 // for. The portal handler reads `target`, validates it's same-origin
317 - // wp-admin, and uses it as the entry URL.
318 - $portal_url = desktop_mode_portal_url();
319 - // `esc_url_raw` instead of `sanitize_text_field`: the latter strips
320 - // every `%XX` percent-encoded sequence, which corrupts URIs whose
321 - // query string legitimately carries an encoded slash — e.g. WP's
322 - // own `plugins.php?action=activate&plugin=dir%2Ffile.php` activate
323 - // link. The portal handler will validate this target downstream.
324 - $target = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
325 - 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 ) {
326 431 $portal_url = add_query_arg( 'target', rawurlencode( $target ), $portal_url );
327 432 }
328 433
329 434 wp_safe_redirect( $portal_url );
@@ -328,11 +433,90 @@
328 433
329 434 wp_safe_redirect( $portal_url );
330 435 exit;
331 436 }
332 -add_action( 'admin_init', 'desktop_mode_redirect_plain_admin_to_portal' );
437 +add_action( 'admin_init', 'openstation_redirect_plain_admin_to_portal' );
333 438
334 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 +/**
335 519 * Resolves the admin URL the portal should forward to for a given user.
336 520 *
337 521 * Looks up the user's session and returns the URL of the window flagged
338 522 * as `focused`. If the session is empty, has no focused window, or the
@@ -339,19 +523,17 @@
339 523 * focused window's URL isn't same-origin admin, falls back to the
340 524 * dashboard.
341 525 *
342 526 * The portal navigates the TOP window, not an iframe, so any chromeless
343 - * `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
344 528 * flag would land the user in a standalone chromeless page (no admin
345 529 * bar, no toggle, no way out) instead of the shell.
346 530 *
347 - * @since 0.4.0
348 - *
349 531 * @param int $user_id The user whose session to consult.
350 532 * @return string The admin URL to redirect to.
351 533 */
352 -function desktop_mode_portal_entry_url( $user_id ) {
353 - $session = desktop_mode_get_session( $user_id );
534 +function openstation_portal_entry_url( $user_id ) {
535 + $session = openstation_get_session( $user_id );
354 536
355 537 // User's configured default-window preference. When disabled, we
356 538 // still have to forward SOMEWHERE (the portal is an HTTP redirect),
357 539 // so we land on the Dashboard URL — but the shell detects the
@@ -356,12 +538,12 @@
356 538 // still have to forward SOMEWHERE (the portal is an HTTP redirect),
357 539 // so we land on the Dashboard URL — but the shell detects the
358 540 // `enabled=false` state via the config and skips the auto-open,
359 541 // leaving the user with an empty desktop as they chose.
360 - $default_window = desktop_mode_get_default_window( $user_id );
542 + $default_window = openstation_get_default_window( $user_id );
361 543 $fallback = $default_window['url'];
362 544
363 - // Native marker (e.g. "native:desktop-mode-os-settings") is not a
545 + // Native marker (e.g. "native:os-settings") is not a
364 546 // redirectable URL. The portal MUST forward somewhere — the
365 547 // redirect happens at HTTP level — so we land on the admin home
366 548 // and let the shell pick up `defaultWindow.url` from the config
367 549 // after init and call nativeWindows.openById( <slug> ).
@@ -379,12 +561,18 @@
379 561 }
380 562 if ( $win['id'] !== $session['focused'] ) {
381 563 continue;
382 564 }
383 - if ( ! desktop_mode_url_is_same_admin( $win['url'] ) ) {
565 + if ( ! openstation_url_is_same_admin( $win['url'] ) ) {
384 566 return $fallback;
385 567 }
386 - 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'] );
387 575 }
388 576
389 577 return $fallback;
390 578 }
@@ -397,18 +585,16 @@
397 585 * URL if — and only if — it resolves to a same-origin `wp-admin/` path.
398 586 * Everything else returns an empty string so the caller falls back to
399 587 * the saved-session entry URL.
400 588 *
401 - * 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
402 590 * doesn't chain us into a chromeless standalone load or an infinite
403 591 * redirect loop.
404 592 *
405 - * @since 0.6.0
406 - *
407 593 * @param string $raw Raw value from `$_GET['target']` (already unslashed).
408 594 * @return string A safe absolute admin URL, or '' if the input is invalid.
409 595 */
410 -function desktop_mode_sanitize_portal_target( $raw ) {
596 +function openstation_sanitize_portal_target( $raw ) {
411 597 if ( ! is_string( $raw ) || '' === $raw ) {
412 598 return '';
413 599 }
414 600
@@ -436,19 +622,29 @@
436 622 }
437 623
438 624 $file = substr( $path, strlen( $admin_path ) );
439 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 +
440 636 if ( '' === $file ) {
441 637 $file = 'index.php';
442 638 }
443 639
444 640 // Resolve against the hardcoded allowlist of canonical wp-admin
445 - // filenames (see `desktop_mode_admin_target_allowlist()`). A
641 + // filenames (see `openstation_admin_target_allowlist()`). A
446 642 // regex alone would accept a plausible-looking filename that
447 643 // isn't a real core admin page (e.g. `custom_admin_page.php`)
448 644 // and effectively become an open redirect to a 404 page served
449 645 // under the admin path; the explicit allowlist closes that.
450 - $target = desktop_mode_resolve_admin_target( $file );
646 + $target = openstation_resolve_admin_target( $file, $network );
451 647 if ( is_wp_error( $target ) ) {
452 648 return '';
453 649 }
454 650
@@ -453,12 +649,32 @@
453 649 }
454 650
455 651 if ( is_string( $query ) && '' !== $query ) {
456 652 parse_str( $query, $args );
457 - 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'] );
458 654 if ( ! empty( $args ) ) {
459 655 $target = add_query_arg( $args, $target );
460 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 '';
461 677 }
462 678
463 679 return $target;
464 680 }