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 +253 -57 1.0.1 → 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,33 +269,74 @@
271 269 );
272 270 }
273 271
274 272 /**
275 - * Forwards plain `/wp-admin/...` requests to the `/openstation/` portal
276 - * when the current user has OpenStation enabled.
273 + * Sends plain `/wp-admin/...` requests into the desktop.
277 274 *
278 - * Why: when OpenStation is on, `/openstation/` is meant to be the one
279 - * canonical address. A user who bookmarks `/wp-admin/plugins.php` or
280 - * follows an old admin link should still land in the shell, not in
281 - * vanilla admin with the shell glued over the top. Running through the
282 - * portal unifies the address bar and honors the saved session's focused
283 - * 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:
284 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 + *
285 301 * Narrowly scoped to bail on every automated or sub-request entry point
286 - * — AJAX, REST, cron, admin-post.php, non-GET methods — so the hook
287 - * 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.
288 308 *
289 309 * Disable via the `openstation_admin_redirect_to_portal` filter (return
290 - * false). Passthrough kicks in automatically when the current request
291 - * 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.
292 313 */
293 314 function openstation_redirect_plain_admin_to_portal() {
294 315 if ( ! openstation_is_enabled() ) {
295 316 return;
296 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 + }
297 324 if ( openstation_is_chromeless_request() ) {
298 325 return;
299 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 + }
300 339 if ( wp_doing_ajax() || wp_doing_cron() ) {
301 340 return;
302 341 }
303 342 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
@@ -305,19 +344,21 @@
305 344 }
306 345 if ( ! empty( $_SERVER['REQUEST_METHOD'] ) && 'GET' !== strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) ) {
307 346 return;
308 347 }
309 -
310 - // The portal handler adds this flag after it forwards into admin.
311 - // Bailing here keeps us out of an infinite redirect loop.
312 - 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() ) {
313 354 return;
314 355 }
315 356
316 357 // The "Detach to new tab" button tags its URL with this flag so the
317 358 // user can view one admin page classically without disabling desktop
318 - // mode account-wide. Only affects the single request — subsequent
319 - // 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).
320 361 if ( ! empty( $_GET[ OPENSTATION_CLASSIC_FLAG ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
321 362 return;
322 363 }
323 364
@@ -327,10 +368,28 @@
327 368 if ( in_array( $pagenow, array( 'admin-post.php', 'admin-ajax.php' ), true ) ) {
328 369 return;
329 370 }
330 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 +
331 390 /**
332 - * Filters whether plain admin URLs should redirect to the portal
391 + * Filters whether plain admin URLs should redirect into the desktop
333 392 * when OpenStation is active.
334 393 *
335 394 * @param bool $redirect Whether to redirect. Default true.
336 395 * @param int $user_id The current user's ID.
@@ -339,22 +398,44 @@
339 398 if ( ! $redirect ) {
340 399 return;
341 400 }
342 401
343 - // Preserve the original target on the portal redirect. Without this,
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 + }
408 +
409 + // Route 1: straight to the shell screen.
410 + if ( openstation_portal_forward_is_redundant( $target ) ) {
411 + /**
412 + * Filters whether to skip the portal hop for a URL the portal
413 + * would only hand straight back.
414 + *
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.
420 + *
421 + * @param bool $skip Whether to skip the portal hop.
422 + * @param string $request_uri The current request URI.
423 + */
424 + if ( apply_filters( 'openstation_skip_redundant_portal_forward', true, $target ) ) {
425 + wp_safe_redirect( openstation_shell_url( openstation_sanitize_portal_target( $target ), true ) );
426 + exit;
427 + }
428 + }
429 +
430 + // Route 2: through the portal, target preserved. Without it,
344 431 // navigating to a specific admin page (profile.php, plugins.php, any
345 432 // deep link) loses the user's intent — the portal would forward them
346 433 // to whichever window was last focused instead of the page they asked
347 434 // for. The portal handler reads `target`, validates it's same-origin
348 - // wp-admin, and uses it as the entry URL.
435 + // wp-admin, and passes it on to the shell screen.
349 436 $portal_url = openstation_portal_url();
350 - // `esc_url_raw` instead of `sanitize_text_field`: the latter strips
351 - // every `%XX` percent-encoded sequence, which corrupts URIs whose
352 - // query string legitimately carries an encoded slash — e.g. WP's
353 - // own `plugins.php?action=activate&plugin=dir%2Ffile.php` activate
354 - // link. The portal handler will validate this target downstream.
355 - $target = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
356 - if ( is_string( $target ) && '' !== $target ) {
437 + if ( '' !== $target ) {
357 438 $portal_url = add_query_arg( 'target', rawurlencode( $target ), $portal_url );
358 439 }
359 440
360 441 wp_safe_redirect( $portal_url );
@@ -362,8 +443,87 @@
362 443 }
363 444 add_action( 'admin_init', 'openstation_redirect_plain_admin_to_portal' );
364 445
365 446 /**
447 + * Whether forwarding this request through `/openstation/` would only
448 + * hand the URL already being served back as the shell's target.
449 + *
450 + * Answers locally, and without the HTTP round trip, the same question
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.
455 + *
456 + * Deliberately conservative: every "don't know" answers false, so the
457 + * forward survives wherever the portal might genuinely choose a
458 + * different destination.
459 + *
460 + * 1. The path must resolve through the same wp-admin allowlist the
461 + * portal validates `?target=` against. Anything that list rejects
462 + * — a `network/` or `user/` sub-path on multisite, a filename that
463 + * isn't canonical wp-admin — makes the portal fall back to the
464 + * session's focused window, which is a real change of destination.
465 + * 2. The resolved filename must be the file this request is actually
466 + * serving. If `$pagenow` disagrees with the URL path then a
467 + * rewrite is in play and we can't claim to know what renders here.
468 + * 3. The query must survive intact. The portal drops
469 + * `openstation_chromeless`, both portal flags and `target` from
470 + * the URL it rebuilds, so a request carrying any of them comes
471 + * back as a different URL.
472 + *
473 + * @param string $request_uri The current request URI, unslashed.
474 + * @return bool True when the portal would resolve this URL to itself.
475 + */
476 +function openstation_portal_forward_is_redundant( $request_uri ) {
477 + global $pagenow;
478 +
479 + if ( ! is_string( $request_uri ) || '' === $request_uri ) {
480 + return false;
481 + }
482 +
483 + $path = wp_parse_url( $request_uri, PHP_URL_PATH );
484 + if ( ! is_string( $path ) || '' === $path ) {
485 + return false;
486 + }
487 +
488 + $admin_path = wp_parse_url( admin_url(), PHP_URL_PATH );
489 + $admin_path = is_string( $admin_path ) ? $admin_path : '/wp-admin/';
490 + if ( 0 !== strpos( $path, $admin_path ) ) {
491 + return false;
492 + }
493 +
494 + $file = ltrim( (string) substr( $path, strlen( $admin_path ) ), '/' );
495 + if ( '' === $file ) {
496 + $file = 'index.php';
497 + }
498 +
499 + // 1. The portal's allowlist has to accept it.
500 + if ( is_wp_error( openstation_resolve_admin_target( $file ) ) ) {
501 + return false;
502 + }
503 +
504 + // 2. …and it has to be the page we are actually serving.
505 + if ( ! is_string( $pagenow ) || strtolower( $file ) !== strtolower( $pagenow ) ) {
506 + return false;
507 + }
508 +
509 + // 3. …carrying a query the portal would hand back unchanged.
510 + $rewritten = array(
511 + 'openstation_chromeless',
512 + OPENSTATION_PORTAL_FLAG,
513 + OPENSTATION_PORTAL_INTENT_FLAG,
514 + 'target',
515 + );
516 + foreach ( $rewritten as $key ) {
517 + if ( isset( $_GET[ $key ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
518 + return false;
519 + }
520 + }
521 +
522 + return true;
523 +}
524 +
525 +/**
366 526 * Resolves the admin URL the portal should forward to for a given user.
367 527 *
368 528 * Looks up the user's session and returns the URL of the window flagged
369 529 * as `focused`. If the session is empty, has no focused window, or the
@@ -411,8 +571,14 @@
411 571 }
412 572 if ( ! openstation_url_is_same_admin( $win['url'] ) ) {
413 573 return $fallback;
414 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 + }
415 581 return remove_query_arg( array( 'openstation_chromeless', OPENSTATION_PORTAL_FLAG ), $win['url'] );
416 582 }
417 583
418 584 return $fallback;
@@ -463,8 +629,18 @@
463 629 }
464 630
465 631 $file = substr( $path, strlen( $admin_path ) );
466 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 +
467 643 if ( '' === $file ) {
468 644 $file = 'index.php';
469 645 }
470 646
@@ -473,9 +649,9 @@
473 649 // regex alone would accept a plausible-looking filename that
474 650 // isn't a real core admin page (e.g. `custom_admin_page.php`)
475 651 // and effectively become an open redirect to a 404 page served
476 652 // under the admin path; the explicit allowlist closes that.
477 - $target = openstation_resolve_admin_target( $file );
653 + $target = openstation_resolve_admin_target( $file, $network );
478 654 if ( is_wp_error( $target ) ) {
479 655 return '';
480 656 }
481 657
@@ -484,8 +660,28 @@
484 660 unset( $args['openstation_chromeless'], $args[ OPENSTATION_PORTAL_FLAG ], $args[ OPENSTATION_PORTAL_INTENT_FLAG ], $args['target'] );
485 661 if ( ! empty( $args ) ) {
486 662 $target = add_query_arg( $args, $target );
487 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 '';
488 684 }
489 685
490 686 return $target;
491 687 }