403 )
);
}
$user_id = get_current_user_id();
/**
* Filters whether visiting the `/desktop-mode` portal should auto-enable
* desktop mode for the current user.
*
* Default: true — the portal is an explicit opt-in action, so flipping
* the user meta mirrors the intent of visiting the URL.
*
* @since 0.4.0
*
* @param bool $auto_enable Whether to auto-enable desktop mode.
* @param int $user_id The current user's ID.
*/
$auto_enable = apply_filters( 'desktop_mode_portal_auto_enable', true, $user_id );
// CSRF guard: only flip user-meta when the request is a same-origin
// top-level navigation. The portal is a GET URL by design (users
// follow shared `/wp-desktop/` links), so we can't require a nonce
// — but we can require that the navigation originated from the
// same site (or a typed/bookmarked URL with no Referer/Sec-Fetch-
// Site). Off-origin hits still redirect into admin so shared
// links keep working; they just don't silently mutate user-meta.
if ( $auto_enable && desktop_mode_portal_is_same_origin_navigation() && '1' !== get_user_meta( $user_id, 'desktop_mode_mode', true ) ) {
update_user_meta( $user_id, 'desktop_mode_mode', '1' );
}
// Pick the landing page. Priority:
// 1. Explicit `target` query arg, if same-origin wp-admin URL.
// This is how `desktop_mode_redirect_plain_admin_to_portal` preserves
// the user's navigation intent when they follow a link to a
// specific admin page (e.g. profile.php).
// 2. Last-focused window from the saved session.
// 3. Dashboard fallback.
$target = '';
$has_intent = false;
if ( ! empty( $_GET['target'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
// `esc_url_raw`, NOT `sanitize_text_field`: the latter strips
// every `%XX` percent-encoded sequence from its input as an XSS
// safeguard, which mangles request URIs that legitimately carry
// encoded slashes (e.g. `plugin=dir%2Ffile.php`). The downstream
// `desktop_mode_sanitize_portal_target` validates the URL
// rigorously (whitelist against the actual wp-admin directory,
// scheme rejection, file_exists gate) so we don't lose any
// real safety by skipping `sanitize_text_field` here.
$target = desktop_mode_sanitize_portal_target( esc_url_raw( wp_unslash( $_GET['target'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( '' !== $target ) {
$has_intent = true;
}
}
if ( '' === $target ) {
$target = desktop_mode_portal_entry_url( $user_id );
}
// Flag the forward so the shell can stamp the address bar back to
// /desktop-mode/ via history.replaceState once it has loaded.
$target = add_query_arg( DESKTOP_MODE_PORTAL_FLAG, '1', $target );
// Second flag: the redirect resolved from an explicit `target`, so
// the shell should treat the resulting `currentPage` as user
// intent and auto-open it on top of the restored session. Without
// this, a bare `/desktop-mode/` visit and a portal-redirected
// admin-bar click would be indistinguishable downstream.
if ( $has_intent ) {
$target = add_query_arg( DESKTOP_MODE_PORTAL_INTENT_FLAG, '1', $target );
}
wp_safe_redirect( $target );
exit;
}
add_action( 'parse_request', 'desktop_mode_handle_portal_request' );
/**
* Decides whether the current request to the portal can mutate
* user-meta safely (same-origin) or should only redirect (cross-
* origin, possibly CSRF).
*
* Logic mirrors the `Sec-Fetch-Site` heuristic browsers use:
*
* - `Sec-Fetch-Site: same-origin | same-site | none` → trusted
* (the request originated from this site, or from a typed URL
* / bookmark with no referrer info).
* - `Sec-Fetch-Site: cross-site` → untrusted (a third-party page
* pointed the user at the portal — could be an `
` tag).
* - Header missing (older browsers): fall back to `Referer` —
* same host or empty referrer is trusted, anything else isn't.
*
* @since 0.6.2
*
* @return bool
*/
function desktop_mode_portal_is_same_origin_navigation() {
if ( ! empty( $_SERVER['HTTP_SEC_FETCH_SITE'] ) ) {
$site = strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_SEC_FETCH_SITE'] ) ) );
return in_array( $site, array( 'same-origin', 'same-site', 'none' ), true );
}
if ( empty( $_SERVER['HTTP_REFERER'] ) ) {
return true;
}
$referer_host = wp_parse_url( esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ), PHP_URL_HOST );
$home_host = wp_parse_url( home_url(), PHP_URL_HOST );
if ( ! is_string( $referer_host ) || '' === $referer_host ) {
return true;
}
return is_string( $home_host ) && strtolower( $referer_host ) === strtolower( $home_host );
}
/**
* Detects whether the current request is for the portal URL.
*
* Strips any query string and trailing slash and compares against
* `/desktop-mode` relative to the site's home path.
*
* @since 0.4.0
*
* @return bool
*/
function desktop_mode_is_portal_request() {
if ( empty( $_SERVER['REQUEST_URI'] ) ) {
return false;
}
// `esc_url_raw` instead of `sanitize_text_field` so percent-encoded
// chars in the URI (notably `%2F` from query-arg slashes) survive
// long enough for `wp_parse_url` to split path / query correctly.
$uri = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
$path = wp_parse_url( $uri, PHP_URL_PATH );
if ( ! is_string( $path ) ) {
return false;
}
$home_path = wp_parse_url( home_url( '/' ), PHP_URL_PATH );
$home_path = is_string( $home_path ) ? rtrim( $home_path, '/' ) : '';
$expected = $home_path . '/' . DESKTOP_MODE_PORTAL_PATH;
$path = '/' . ltrim( rtrim( $path, '/' ), '/' );
return $path === $expected;
}
/**
* Forwards plain `/wp-admin/...` requests to the `/desktop-mode/` portal
* when the current user has desktop mode enabled.
*
* Why: when desktop mode is on, `/desktop-mode/` is meant to be the one
* canonical address. A user who bookmarks `/wp-admin/plugins.php` or
* follows an old admin link should still land in the shell, not in
* vanilla admin with the shell glued over the top. Running through the
* portal unifies the address bar and honors the saved session's focused
* window.
*
* Narrowly scoped to bail on every automated or sub-request entry point
* — AJAX, REST, cron, admin-post.php, non-GET methods — so the hook
* can't corrupt a form submission or break an API call.
*
* Disable via the `desktop_mode_admin_redirect_to_portal` filter (return
* false). Passthrough kicks in automatically when the current request
* is chromeless or already carries the portal flag.
*
* @since 0.4.0
*/
function desktop_mode_redirect_plain_admin_to_portal() {
if ( ! desktop_mode_is_enabled() ) {
return;
}
if ( desktop_mode_is_chromeless_request() ) {
return;
}
if ( wp_doing_ajax() || wp_doing_cron() ) {
return;
}
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
return;
}
if ( ! empty( $_SERVER['REQUEST_METHOD'] ) && 'GET' !== strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) ) {
return;
}
// The portal handler adds this flag after it forwards into admin.
// Bailing here keeps us out of an infinite redirect loop.
if ( ! empty( $_GET[ DESKTOP_MODE_PORTAL_FLAG ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return;
}
// The "Detach to new tab" button tags its URL with this flag so the
// user can view one admin page classically without disabling desktop
// mode account-wide. Only affects the single request — subsequent
// navigations inside the tab lose the flag and follow normal rules.
if ( ! empty( $_GET[ DESKTOP_MODE_CLASSIC_FLAG ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return;
}
// admin-post.php and admin-ajax.php handle form submissions and JSON
// endpoints; redirecting them would break the call.
global $pagenow;
if ( in_array( $pagenow, array( 'admin-post.php', 'admin-ajax.php' ), true ) ) {
return;
}
/**
* Filters whether plain admin URLs should redirect to the portal
* when desktop mode is active.
*
* @since 0.4.0
*
* @param bool $redirect Whether to redirect. Default true.
* @param int $user_id The current user's ID.
*/
$redirect = apply_filters( 'desktop_mode_admin_redirect_to_portal', true, get_current_user_id() );
if ( ! $redirect ) {
return;
}
// Preserve the original target on the portal redirect. Without this,
// navigating to a specific admin page (profile.php, plugins.php, any
// deep link) loses the user's intent — the portal would forward them
// to whichever window was last focused instead of the page they asked
// for. The portal handler reads `target`, validates it's same-origin
// wp-admin, and uses it as the entry URL.
$portal_url = desktop_mode_portal_url();
// `esc_url_raw` instead of `sanitize_text_field`: the latter strips
// every `%XX` percent-encoded sequence, which corrupts URIs whose
// query string legitimately carries an encoded slash — e.g. WP's
// own `plugins.php?action=activate&plugin=dir%2Ffile.php` activate
// link. The portal handler will validate this target downstream.
$target = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
if ( is_string( $target ) && '' !== $target ) {
$portal_url = add_query_arg( 'target', rawurlencode( $target ), $portal_url );
}
wp_safe_redirect( $portal_url );
exit;
}
add_action( 'admin_init', 'desktop_mode_redirect_plain_admin_to_portal' );
/**
* Resolves the admin URL the portal should forward to for a given user.
*
* Looks up the user's session and returns the URL of the window flagged
* as `focused`. If the session is empty, has no focused window, or the
* focused window's URL isn't same-origin admin, falls back to the
* dashboard.
*
* The portal navigates the TOP window, not an iframe, so any chromeless
* `desktop_mode_chromeless=1` flag baked into the stored URL is stripped — a leftover
* flag would land the user in a standalone chromeless page (no admin
* bar, no toggle, no way out) instead of the shell.
*
* @since 0.4.0
*
* @param int $user_id The user whose session to consult.
* @return string The admin URL to redirect to.
*/
function desktop_mode_portal_entry_url( $user_id ) {
$session = desktop_mode_get_session( $user_id );
// User's configured default-window preference. When disabled, we
// still have to forward SOMEWHERE (the portal is an HTTP redirect),
// so we land on the Dashboard URL — but the shell detects the
// `enabled=false` state via the config and skips the auto-open,
// leaving the user with an empty desktop as they chose.
$default_window = desktop_mode_get_default_window( $user_id );
$fallback = $default_window['url'];
// Native marker (e.g. "native:desktop-mode-os-settings") is not a
// redirectable URL. The portal MUST forward somewhere — the
// redirect happens at HTTP level — so we land on the admin home
// and let the shell pick up `defaultWindow.url` from the config
// after init and call nativeWindows.openById( ).
if ( is_string( $fallback ) && 0 === strpos( $fallback, 'native:' ) ) {
$fallback = admin_url();
}
if ( empty( $session['focused'] ) || empty( $session['windows'] ) ) {
return $fallback;
}
foreach ( $session['windows'] as $win ) {
if ( ! isset( $win['id'], $win['url'] ) ) {
continue;
}
if ( $win['id'] !== $session['focused'] ) {
continue;
}
if ( ! desktop_mode_url_is_same_admin( $win['url'] ) ) {
return $fallback;
}
return remove_query_arg( array( 'desktop_mode_chromeless', DESKTOP_MODE_PORTAL_FLAG ), $win['url'] );
}
return $fallback;
}
/**
* Validates and normalizes a `target` query arg on the portal URL.
*
* Accepts a raw request-URI-shaped string (path + optional query, e.g.
* `/wp-admin/profile.php?foo=bar`) and returns a fully-qualified admin
* URL if — and only if — it resolves to a same-origin `wp-admin/` path.
* Everything else returns an empty string so the caller falls back to
* the saved-session entry URL.
*
* Strips `desktop_mode_chromeless` and the portal flag from the query so the target
* doesn't chain us into a chromeless standalone load or an infinite
* redirect loop.
*
* @since 0.6.0
*
* @param string $raw Raw value from `$_GET['target']` (already unslashed).
* @return string A safe absolute admin URL, or '' if the input is invalid.
*/
function desktop_mode_sanitize_portal_target( $raw ) {
if ( ! is_string( $raw ) || '' === $raw ) {
return '';
}
// Reject URIs with a scheme or protocol-relative prefix — we only
// accept relative paths so there's no way to redirect off-site.
if ( preg_match( '#^([a-z][a-z0-9+.-]*:|//)#i', $raw ) ) {
return '';
}
// Must be an absolute path starting with /.
if ( '/' !== $raw[0] ) {
return '';
}
$path = wp_parse_url( $raw, PHP_URL_PATH );
$query = wp_parse_url( $raw, PHP_URL_QUERY );
if ( ! is_string( $path ) || '' === $path ) {
return '';
}
$admin_path = wp_parse_url( admin_url(), PHP_URL_PATH );
$admin_path = is_string( $admin_path ) ? $admin_path : '/wp-admin/';
if ( 0 !== strpos( $path, $admin_path ) ) {
return '';
}
$file = substr( $path, strlen( $admin_path ) );
$file = ltrim( (string) $file, '/' );
if ( '' === $file ) {
$file = 'index.php';
}
// Resolve + whitelist against the actual wp-admin directory. A
// regex alone would accept a plausible-looking filename that
// doesn't exist (e.g. `custom_admin_page.php`) and effectively
// become an open redirect to a 404 page served under the admin
// path; the file_exists gate closes that.
$target = desktop_mode_resolve_admin_target( $file );
if ( is_wp_error( $target ) ) {
return '';
}
if ( is_string( $query ) && '' !== $query ) {
parse_str( $query, $args );
unset( $args['desktop_mode_chromeless'], $args[ DESKTOP_MODE_PORTAL_FLAG ], $args[ DESKTOP_MODE_PORTAL_INTENT_FLAG ], $args['target'] );
if ( ! empty( $args ) ) {
$target = add_query_arg( $args, $target );
}
}
return $target;
}