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; }