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 +244 -55 1.1.21.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,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,12 +344,14 @@
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
@@ -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,37 @@
339 398 if ( ! $redirect ) {
340 399 return;
341 400 }
342 401
343 - // 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,
344 424 // navigating to a specific admin page (profile.php, plugins.php, any
345 425 // deep link) loses the user's intent — the portal would forward them
346 426 // to whichever window was last focused instead of the page they asked
347 427 // for. The portal handler reads `target`, validates it's same-origin
348 - // wp-admin, and uses it as the entry URL.
428 + // wp-admin, and passes it on to the shell screen.
349 429 $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 ) {
430 + if ( '' !== $target ) {
357 431 $portal_url = add_query_arg( 'target', rawurlencode( $target ), $portal_url );
358 432 }
359 433
360 434 wp_safe_redirect( $portal_url );
@@ -362,8 +436,87 @@
362 436 }
363 437 add_action( 'admin_init', 'openstation_redirect_plain_admin_to_portal' );
364 438
365 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 +/**
366 519 * Resolves the admin URL the portal should forward to for a given user.
367 520 *
368 521 * Looks up the user's session and returns the URL of the window flagged
369 522 * as `focused`. If the session is empty, has no focused window, or the
@@ -411,8 +564,14 @@
411 564 }
412 565 if ( ! openstation_url_is_same_admin( $win['url'] ) ) {
413 566 return $fallback;
414 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 + }
415 574 return remove_query_arg( array( 'openstation_chromeless', OPENSTATION_PORTAL_FLAG ), $win['url'] );
416 575 }
417 576
418 577 return $fallback;
@@ -463,8 +622,18 @@
463 622 }
464 623
465 624 $file = substr( $path, strlen( $admin_path ) );
466 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 +
467 636 if ( '' === $file ) {
468 637 $file = 'index.php';
469 638 }
470 639
@@ -473,9 +642,9 @@
473 642 // regex alone would accept a plausible-looking filename that
474 643 // isn't a real core admin page (e.g. `custom_admin_page.php`)
475 644 // and effectively become an open redirect to a 404 page served
476 645 // under the admin path; the explicit allowlist closes that.
477 - $target = openstation_resolve_admin_target( $file );
646 + $target = openstation_resolve_admin_target( $file, $network );
478 647 if ( is_wp_error( $target ) ) {
479 648 return '';
480 649 }
481 650
@@ -484,8 +653,28 @@
484 653 unset( $args['openstation_chromeless'], $args[ OPENSTATION_PORTAL_FLAG ], $args[ OPENSTATION_PORTAL_INTENT_FLAG ], $args['target'] );
485 654 if ( ! empty( $args ) ) {
486 655 $target = add_query_arg( $args, $target );
487 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 '';
488 677 }
489 678
490 679 return $target;
491 680 }