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 +159 -83 1.1.41.1.10 View file →
@@ -7,11 +7,14 @@
7 7 * 1. Logged-out users are bounced through `wp-login.php` with a
8 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.
@@ -146,17 +149,24 @@
146 149 // Site). Off-origin hits still redirect into admin so shared
147 150 // links keep working; they just don't silently mutate user-meta.
148 151 if ( $auto_enable && openstation_portal_is_same_origin_navigation() && '1' !== get_user_meta( $user_id, 'desktop_mode_mode', true ) ) {
149 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 );
150 156 }
151 157
152 - // Pick the landing page. Priority:
153 - // 1. Explicit `target` query arg, if same-origin wp-admin URL.
154 - // This is how `openstation_redirect_plain_admin_to_portal` preserves
155 - // the user's navigation intent when they follow a link to a
156 - // specific admin page (e.g. profile.php).
157 - // 2. Last-focused window from the saved session.
158 - // 3. Dashboard fallback.
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.
159 169 $target = '';
160 170 $has_intent = false;
161 171 if ( ! empty( $_GET['target'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
162 172 // `esc_url_raw`, NOT `sanitize_text_field`: the latter strips
@@ -172,26 +182,14 @@
172 182 if ( '' !== $target ) {
173 183 $has_intent = true;
174 184 }
175 185 }
176 - if ( '' === $target ) {
177 - $target = openstation_portal_entry_url( $user_id );
178 - }
179 -
180 - // Flag the forward so the shell can stamp the address bar back to
181 - // /openstation/ via history.replaceState once it has loaded.
182 - $target = add_query_arg( OPENSTATION_PORTAL_FLAG, '1', $target );
183 -
184 - // Second flag: the redirect resolved from an explicit `target`, so
185 - // the shell should treat the resulting `currentPage` as user
186 - // intent and auto-open it on top of the restored session. Without
187 - // this, a bare `/openstation/` visit and a portal-redirected
188 - // admin-bar click would be indistinguishable downstream.
189 - if ( $has_intent ) {
190 - $target = add_query_arg( OPENSTATION_PORTAL_INTENT_FLAG, '1', $target );
191 - }
192 -
193 - 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 ) );
194 192 exit;
195 193 }
196 194 add_action( 'parse_request', 'openstation_handle_portal_request' );
197 195
@@ -271,46 +269,74 @@
271 269 );
272 270 }
273 271
274 272 /**
275 - * Forwards plain `/wp-admin/...` requests to the `/openstation/` portal
276 - * when the portal would land the user somewhere other than here.
273 + * Sends plain `/wp-admin/...` requests into the desktop.
277 274 *
278 - * Why: the portal honors the saved session's focused window, so a user
279 - * who follows a link the portal can't resolve — a network-admin URL, a
280 - * path outside the wp-admin allowlist — is better off on their restored
281 - * desktop than on a page the shell can't place.
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:
282 280 *
283 - * Why NOT unconditionally: for the ordinary case the forward is a round
284 - * trip to nowhere. The portal resolves `?target=` straight back to the
285 - * URL we are already serving and redirects here with
286 - * `desktop_mode_portal=1&desktop_mode_portal_intent=1` — a flag pair
287 - * every consumer reads as `fromPortal && ! fromPortalIntent`, i.e. as
288 - * indistinguishable from no flags at all. Two full WordPress bootstraps
289 - * bought nothing. The shell does not need the portal to reach it: it
290 - * enqueues on any admin page where {@see openstation_is_enabled()}, and
291 - * the address bar is deliberately no longer normalized to
292 - * `/openstation/` (see the `history.replaceState` note in
293 - * `src/desktop.ts` — the round trip is exactly what made reloads flash).
294 - * So {@see openstation_portal_forward_is_redundant()} answers the
295 - * portal's question locally and we render in place when the answer is
296 - * "right here."
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.
297 300 *
298 301 * Narrowly scoped to bail on every automated or sub-request entry point
299 - * — AJAX, REST, cron, admin-post.php, non-GET methods — so the hook
300 - * 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.
301 308 *
302 309 * Disable via the `openstation_admin_redirect_to_portal` filter (return
303 - * false). Passthrough kicks in automatically when the current request
304 - * is chromeless or already carries the portal flag.
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.
305 313 */
306 314 function openstation_redirect_plain_admin_to_portal() {
307 315 if ( ! openstation_is_enabled() ) {
308 316 return;
309 317 }
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() ) {
322 + return;
323 + }
310 324 if ( openstation_is_chromeless_request() ) {
311 325 return;
312 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 + }
313 339 if ( wp_doing_ajax() || wp_doing_cron() ) {
314 340 return;
315 341 }
316 342 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
@@ -318,12 +344,14 @@
318 344 }
319 345 if ( ! empty( $_SERVER['REQUEST_METHOD'] ) && 'GET' !== strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) ) {
320 346 return;
321 347 }
322 -
323 - // The portal handler adds this flag after it forwards into admin.
324 - // Bailing here keeps us out of an infinite redirect loop.
325 - if ( ! empty( $_GET[ OPENSTATION_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() ) {
326 354 return;
327 355 }
328 356
329 357 // The "Detach to new tab" button tags its URL with this flag so the
@@ -340,10 +368,28 @@
340 368 if ( in_array( $pagenow, array( 'admin-post.php', 'admin-ajax.php' ), true ) ) {
341 369 return;
342 370 }
343 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 +
344 390 /**
345 - * Filters whether plain admin URLs should redirect to the portal
391 + * Filters whether plain admin URLs should redirect into the desktop
346 392 * when OpenStation is active.
347 393 *
348 394 * @param bool $redirect Whether to redirect. Default true.
349 395 * @param int $user_id The current user's ID.
@@ -352,42 +398,35 @@
352 398 if ( ! $redirect ) {
353 399 return;
354 400 }
355 401
356 - // `esc_url_raw` instead of `sanitize_text_field`: the latter strips
357 - // every `%XX` percent-encoded sequence, which corrupts URIs whose
358 - // query string legitimately carries an encoded slash — e.g. WP's
359 - // own `plugins.php?action=activate&plugin=dir%2Ffile.php` activate
360 - // link. The portal handler will validate this target downstream.
361 - $target = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
362 - $target = is_string( $target ) ? $target : '';
363 -
402 + // Route 1: straight to the shell screen.
364 403 if ( openstation_portal_forward_is_redundant( $target ) ) {
365 404 /**
366 - * Filters whether to skip a portal forward that would resolve
367 - * back to the URL already being served.
405 + * Filters whether to skip the portal hop for a URL the portal
406 + * would only hand straight back.
368 407 *
369 - * Default: true — the forward costs two extra WordPress
370 - * bootstraps and lands on the same page with flags the shell
371 - * reads as a no-op. Return false to force the round trip, e.g.
372 - * for a plugin that hooks `openstation_handle_portal_request`
373 - * for its own side effects and needs it to run on every admin
374 - * entry.
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.
375 413 *
376 - * @param bool $skip Whether to skip the redundant forward.
414 + * @param bool $skip Whether to skip the portal hop.
377 415 * @param string $request_uri The current request URI.
378 416 */
379 417 if ( apply_filters( 'openstation_skip_redundant_portal_forward', true, $target ) ) {
380 - return;
418 + wp_safe_redirect( openstation_shell_url( openstation_sanitize_portal_target( $target ), true ) );
419 + exit;
381 420 }
382 421 }
383 422
384 - // Preserve the original target on the portal redirect. Without this,
423 + // Route 2: through the portal, target preserved. Without it,
385 424 // navigating to a specific admin page (profile.php, plugins.php, any
386 425 // deep link) loses the user's intent — the portal would forward them
387 426 // to whichever window was last focused instead of the page they asked
388 427 // for. The portal handler reads `target`, validates it's same-origin
389 - // wp-admin, and uses it as the entry URL.
428 + // wp-admin, and passes it on to the shell screen.
390 429 $portal_url = openstation_portal_url();
391 430 if ( '' !== $target ) {
392 431 $portal_url = add_query_arg( 'target', rawurlencode( $target ), $portal_url );
393 432 }
@@ -397,15 +436,16 @@
397 436 }
398 437 add_action( 'admin_init', 'openstation_redirect_plain_admin_to_portal' );
399 438
400 439 /**
401 - * Whether forwarding this request through `/openstation/` would land
402 - * the user straight back on the URL already being served.
440 + * Whether forwarding this request through `/openstation/` would only
441 + * hand the URL already being served back as the shell's target.
403 442 *
404 443 * Answers locally, and without the HTTP round trip, the same question
405 - * {@see openstation_handle_portal_request()} answers after two full
406 - * WordPress bootstraps. True means the forward is pure overhead and the
407 - * caller should render in place instead.
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.
408 448 *
409 449 * Deliberately conservative: every "don't know" answers false, so the
410 450 * forward survives wherever the portal might genuinely choose a
411 451 * different destination.
@@ -524,8 +564,14 @@
524 564 }
525 565 if ( ! openstation_url_is_same_admin( $win['url'] ) ) {
526 566 return $fallback;
527 567 }
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 + }
528 574 return remove_query_arg( array( 'openstation_chromeless', OPENSTATION_PORTAL_FLAG ), $win['url'] );
529 575 }
530 576
531 577 return $fallback;
@@ -576,8 +622,18 @@
576 622 }
577 623
578 624 $file = substr( $path, strlen( $admin_path ) );
579 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 +
580 636 if ( '' === $file ) {
581 637 $file = 'index.php';
582 638 }
583 639
@@ -586,9 +642,9 @@
586 642 // regex alone would accept a plausible-looking filename that
587 643 // isn't a real core admin page (e.g. `custom_admin_page.php`)
588 644 // and effectively become an open redirect to a 404 page served
589 645 // under the admin path; the explicit allowlist closes that.
590 - $target = openstation_resolve_admin_target( $file );
646 + $target = openstation_resolve_admin_target( $file, $network );
591 647 if ( is_wp_error( $target ) ) {
592 648 return '';
593 649 }
594 650
@@ -597,8 +653,28 @@
597 653 unset( $args['openstation_chromeless'], $args[ OPENSTATION_PORTAL_FLAG ], $args[ OPENSTATION_PORTAL_INTENT_FLAG ], $args['target'] );
598 654 if ( ! empty( $args ) ) {
599 655 $target = add_query_arg( $args, $target );
600 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 '';
601 677 }
602 678
603 679 return $target;
604 680 }