PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.13
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.13
1.1.13 1.1.12 1.1.11 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 All 37 releases
← All changes | includes/portal.php +167 -84 1.1.4 → 1.1.13 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,19 +344,21 @@
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
330 358 // user can view one admin page classically without disabling desktop
331 - // mode account-wide. Only affects the single request — subsequent
332 - // navigations inside the tab lose the flag and follow normal rules.
359 + // mode account-wide. Navigations inside that tab keep it (see the
360 + // Referer check below).
333 361 if ( ! empty( $_GET[ OPENSTATION_CLASSIC_FLAG ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
334 362 return;
335 363 }
336 364
@@ -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,42 @@
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 : '';
402 + // A navigation out of a classic tab that lost the flag stays classic,
403 + // with the flag back in the URL for the next navigation's Referer.
404 + if ( '' !== $target && openstation_is_classic_referer() ) {
405 + wp_safe_redirect( add_query_arg( OPENSTATION_CLASSIC_FLAG, '1', $target ) );
406 + exit;
407 + }
363 408
409 + // Route 1: straight to the shell screen.
364 410 if ( openstation_portal_forward_is_redundant( $target ) ) {
365 411 /**
366 - * Filters whether to skip a portal forward that would resolve
367 - * back to the URL already being served.
412 + * Filters whether to skip the portal hop for a URL the portal
413 + * would only hand straight back.
368 414 *
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.
415 + * Default: true — the request goes straight to the shell screen
416 + * with this URL as its target. Return false to route through
417 + * `/openstation/` anyway, e.g. for a plugin that hooks
418 + * `openstation_handle_portal_request` for its own side effects
419 + * and needs it to run on every admin entry.
375 420 *
376 - * @param bool $skip Whether to skip the redundant forward.
421 + * @param bool $skip Whether to skip the portal hop.
377 422 * @param string $request_uri The current request URI.
378 423 */
379 424 if ( apply_filters( 'openstation_skip_redundant_portal_forward', true, $target ) ) {
380 - return;
425 + wp_safe_redirect( openstation_shell_url( openstation_sanitize_portal_target( $target ), true ) );
426 + exit;
381 427 }
382 428 }
383 429
384 - // Preserve the original target on the portal redirect. Without this,
430 + // Route 2: through the portal, target preserved. Without it,
385 431 // navigating to a specific admin page (profile.php, plugins.php, any
386 432 // deep link) loses the user's intent — the portal would forward them
387 433 // to whichever window was last focused instead of the page they asked
388 434 // for. The portal handler reads `target`, validates it's same-origin
389 - // wp-admin, and uses it as the entry URL.
435 + // wp-admin, and passes it on to the shell screen.
390 436 $portal_url = openstation_portal_url();
391 437 if ( '' !== $target ) {
392 438 $portal_url = add_query_arg( 'target', rawurlencode( $target ), $portal_url );
393 439 }
@@ -397,15 +443,16 @@
397 443 }
398 444 add_action( 'admin_init', 'openstation_redirect_plain_admin_to_portal' );
399 445
400 446 /**
401 - * Whether forwarding this request through `/openstation/` would land
402 - * the user straight back on the URL already being served.
447 + * Whether forwarding this request through `/openstation/` would only
448 + * hand the URL already being served back as the shell's target.
403 449 *
404 450 * 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.
451 + * {@see openstation_handle_portal_request()} answers after another
452 + * WordPress bootstrap. True means the hop is pure overhead and the
453 + * caller can send the user straight to the shell screen with this URL
454 + * as its target.
408 455 *
409 456 * Deliberately conservative: every "don't know" answers false, so the
410 457 * forward survives wherever the portal might genuinely choose a
411 458 * different destination.
@@ -524,8 +571,14 @@
524 571 }
525 572 if ( ! openstation_url_is_same_admin( $win['url'] ) ) {
526 573 return $fallback;
527 574 }
575 + // The shell must never open itself. A saved window pointing at
576 + // the shell screen cannot be produced by the shell, but a
577 + // hand-edited session could say so; treat it as nothing focused.
578 + if ( openstation_url_is_shell_screen( $win['url'] ) ) {
579 + return $fallback;
580 + }
528 581 return remove_query_arg( array( 'openstation_chromeless', OPENSTATION_PORTAL_FLAG ), $win['url'] );
529 582 }
530 583
531 584 return $fallback;
@@ -576,8 +629,18 @@
576 629 }
577 630
578 631 $file = substr( $path, strlen( $admin_path ) );
579 632 $file = ltrim( (string) $file, '/' );
633 +
634 + // The network admin's own screens live one directory down and are
635 + // resolved against their own list. Without this a network URL came
636 + // back empty and the user was quietly forwarded to the site
637 + // dashboard, which is a different admin.
638 + $network = 0 === strpos( $file, 'network/' );
639 + if ( $network ) {
640 + $file = substr( $file, strlen( 'network/' ) );
641 + }
642 +
580 643 if ( '' === $file ) {
581 644 $file = 'index.php';
582 645 }
583 646
@@ -586,9 +649,9 @@
586 649 // regex alone would accept a plausible-looking filename that
587 650 // isn't a real core admin page (e.g. `custom_admin_page.php`)
588 651 // and effectively become an open redirect to a 404 page served
589 652 // under the admin path; the explicit allowlist closes that.
590 - $target = openstation_resolve_admin_target( $file );
653 + $target = openstation_resolve_admin_target( $file, $network );
591 654 if ( is_wp_error( $target ) ) {
592 655 return '';
593 656 }
594 657
@@ -597,8 +660,28 @@
597 660 unset( $args['openstation_chromeless'], $args[ OPENSTATION_PORTAL_FLAG ], $args[ OPENSTATION_PORTAL_INTENT_FLAG ], $args['target'] );
598 661 if ( ! empty( $args ) ) {
599 662 $target = add_query_arg( $args, $target );
600 663 }
664 + }
665 +
666 + // The shell screen is where a target is opened, never a target: the
667 + // shell would open itself in a window, and a redirect chain built
668 + // from it would loop. Fall back to the entry resolver instead.
669 + if ( openstation_url_is_shell_screen( $target ) ) {
670 + return '';
671 + }
672 +
673 + // `admin.php` is a bootstrap, not a page. Without a `page` arg core
674 + // falls through the last `else` in `wp-admin/admin.php`, never
675 + // requires `admin-header.php`, and answers 200 with an empty body —
676 + // so the URL becomes a window showing nothing. The allowlist above
677 + // matches filenames and cannot see the query, which is why the
678 + // check belongs here, beside the shell-screen one: both are URLs
679 + // that resolve but must not become a target. Returning '' hands the
680 + // caller back to the entry resolver (session's focused window, else
681 + // the default window, else the Dashboard).
682 + if ( openstation_url_is_page_less_admin_php( $target ) ) {
683 + return '';
601 684 }
602 685
603 686 return $target;
604 687 }