PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.8
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
desktop-mode / includes / portal.php

portal.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.8, at includes/portal.php

447 lines 16.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — `/desktop-mode` Portal Entry Point.
4 *
5 * Registers `/desktop-mode` as a shareable URL that behaves like the
6 * front door of the desktop UI:
7 * 1. Logged-out users are bounced through `wp-login.php` with a
8 * redirect back to `/desktop-mode/`.
9 * 2. Logged-in users with basic admin-read capability have the
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).
14 *
15 * The URL is served virtually (no rewrite rules, no `.htaccess`
16 * surgery) by intercepting `parse_request` before WordPress routes the
17 * URL to 404. This keeps the plugin drop-in.
18 *
19 * @package WPDesktopMode
20 */
21
22 defined( 'ABSPATH' ) || exit;
23
24 /** The URL path that triggers the portal handler. */
25 const DESKTOP_MODE_PORTAL_PATH = 'desktop-mode';
26
27 /** Query var the admin shell reads to know it was entered via the portal. */
28 const DESKTOP_MODE_PORTAL_FLAG = 'desktop_mode_portal';
29
30 /**
31 * Query var set on portal redirects whose landing page came from an
32 * explicit `?target=…` URL the user (or a redirect chain originating
33 * from a click) provided — as opposed to the portal picking the
34 * session's focused window or the default-window fallback.
35 *
36 * The shell uses this to distinguish "user expressed navigation intent
37 * toward this URL" (open it) from "portal had to forward somewhere"
38 * (don't disturb the restored session).
39 */
40 const DESKTOP_MODE_PORTAL_INTENT_FLAG = 'desktop_mode_portal_intent';
41
42 /**
43 * Query var set by the window-title-bar "Detach" action. Tells the
44 * admin_init redirect to skip portal forwarding for this request so the
45 * user can view the page as classic wp-admin in a new tab even when
46 * desktop mode is globally enabled for their account.
47 */
48 const DESKTOP_MODE_CLASSIC_FLAG = 'desktop_mode_classic';
49
50 /**
51 * Returns the canonical portal URL, e.g. `https://example.com/desktop-mode/`.
52 *
53 * @return string
54 */
55 function desktop_mode_portal_url() {
56 return home_url( '/' . DESKTOP_MODE_PORTAL_PATH . '/' );
57 }
58
59 /**
60 * Intercepts requests to `/desktop-mode` and forwards them into the admin.
61 *
62 * Hooks on `parse_request` — early enough to pre-empt 404 handling but
63 * late enough that `is_user_logged_in()` is reliable.
64 *
65 * @param WP $wp Current WordPress environment instance.
66 */
67 function desktop_mode_handle_portal_request( $wp ) {
68 unset( $wp );
69
70 if ( ! desktop_mode_is_portal_request() ) {
71 return;
72 }
73
74 // Logged-out: bounce through login, returning to the portal URL.
75 if ( ! is_user_logged_in() ) {
76 wp_safe_redirect( wp_login_url( desktop_mode_portal_url() ) );
77 exit;
78 }
79
80 // Require basic admin-read capability so subscribers of sites that
81 // blocked `read` from admin don't land in a broken window.
82 if ( ! current_user_can( 'read' ) ) {
83 wp_die(
84 esc_html__( 'Sorry, you are not allowed to access the WordPress desktop.', 'desktop-mode' ),
85 '',
86 array( 'response' => 403 )
87 );
88 }
89
90 $user_id = get_current_user_id();
91
92 /**
93 * Filters whether visiting the `/desktop-mode` portal should auto-enable
94 * desktop mode for the current user.
95 *
96 * Default: true — the portal is an explicit opt-in action, so flipping
97 * the user meta mirrors the intent of visiting the URL.
98 *
99 * @param bool $auto_enable Whether to auto-enable desktop mode.
100 * @param int $user_id The current user's ID.
101 */
102 $auto_enable = apply_filters( 'desktop_mode_portal_auto_enable', true, $user_id );
103
104 // CSRF guard: only flip user-meta when the request is a same-origin
105 // top-level navigation. The portal is a GET URL by design (users
106 // follow shared `/desktop-mode/` links), so we can't require a nonce
107 // — but we can require that the navigation originated from the
108 // same site (or a typed/bookmarked URL with no Referer/Sec-Fetch-
109 // Site). Off-origin hits still redirect into admin so shared
110 // links keep working; they just don't silently mutate user-meta.
111 if ( $auto_enable && desktop_mode_portal_is_same_origin_navigation() && '1' !== get_user_meta( $user_id, 'desktop_mode_mode', true ) ) {
112 update_user_meta( $user_id, 'desktop_mode_mode', '1' );
113 }
114
115 // Pick the landing page. Priority:
116 // 1. Explicit `target` query arg, if same-origin wp-admin URL.
117 // This is how `desktop_mode_redirect_plain_admin_to_portal` preserves
118 // the user's navigation intent when they follow a link to a
119 // specific admin page (e.g. profile.php).
120 // 2. Last-focused window from the saved session.
121 // 3. Dashboard fallback.
122 $target = '';
123 $has_intent = false;
124 if ( ! empty( $_GET['target'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
125 // `esc_url_raw`, NOT `sanitize_text_field`: the latter strips
126 // every `%XX` percent-encoded sequence from its input as an XSS
127 // safeguard, which mangles request URIs that legitimately carry
128 // encoded slashes (e.g. `plugin=dir%2Ffile.php`). The downstream
129 // `desktop_mode_sanitize_portal_target` validates the URL
130 // rigorously (scheme rejection, traversal rejection, and a
131 // hardcoded allowlist of canonical wp-admin filenames — see
132 // `desktop_mode_admin_target_allowlist()`) so we don't lose
133 // any real safety by skipping `sanitize_text_field` here.
134 $target = desktop_mode_sanitize_portal_target( esc_url_raw( wp_unslash( $_GET['target'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
135 if ( '' !== $target ) {
136 $has_intent = true;
137 }
138 }
139 if ( '' === $target ) {
140 $target = desktop_mode_portal_entry_url( $user_id );
141 }
142
143 // Flag the forward so the shell can stamp the address bar back to
144 // /desktop-mode/ via history.replaceState once it has loaded.
145 $target = add_query_arg( DESKTOP_MODE_PORTAL_FLAG, '1', $target );
146
147 // Second flag: the redirect resolved from an explicit `target`, so
148 // the shell should treat the resulting `currentPage` as user
149 // intent and auto-open it on top of the restored session. Without
150 // this, a bare `/desktop-mode/` visit and a portal-redirected
151 // admin-bar click would be indistinguishable downstream.
152 if ( $has_intent ) {
153 $target = add_query_arg( DESKTOP_MODE_PORTAL_INTENT_FLAG, '1', $target );
154 }
155
156 wp_safe_redirect( $target );
157 exit;
158 }
159 add_action( 'parse_request', 'desktop_mode_handle_portal_request' );
160
161 /**
162 * Decides whether the current request to the portal can mutate
163 * user-meta safely (same-origin) or should only redirect (cross-
164 * origin, possibly CSRF).
165 *
166 * Logic mirrors the `Sec-Fetch-Site` heuristic browsers use:
167 *
168 * - `Sec-Fetch-Site: same-origin | same-site | none` → trusted
169 * (the request originated from this site, or from a typed URL
170 * / bookmark with no referrer info).
171 * - `Sec-Fetch-Site: cross-site` → untrusted (a third-party page
172 * pointed the user at the portal — could be an `<img>` tag).
173 * - Header missing (older browsers): fall back to `Referer` —
174 * same host or empty referrer is trusted, anything else isn't.
175 *
176 * @return bool
177 */
178 function desktop_mode_portal_is_same_origin_navigation() {
179 if ( ! empty( $_SERVER['HTTP_SEC_FETCH_SITE'] ) ) {
180 $site = strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_SEC_FETCH_SITE'] ) ) );
181 return in_array( $site, array( 'same-origin', 'same-site', 'none' ), true );
182 }
183
184 if ( empty( $_SERVER['HTTP_REFERER'] ) ) {
185 return true;
186 }
187
188 $referer_host = wp_parse_url( esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ), PHP_URL_HOST );
189 $home_host = wp_parse_url( home_url(), PHP_URL_HOST );
190
191 if ( ! is_string( $referer_host ) || '' === $referer_host ) {
192 return true;
193 }
194
195 return is_string( $home_host ) && strtolower( $referer_host ) === strtolower( $home_host );
196 }
197
198 /**
199 * Detects whether the current request is for the portal URL.
200 *
201 * Strips any query string and trailing slash and compares against
202 * `/desktop-mode` relative to the site's home path.
203 *
204 * @return bool
205 */
206 function desktop_mode_is_portal_request() {
207 if ( empty( $_SERVER['REQUEST_URI'] ) ) {
208 return false;
209 }
210
211 // `esc_url_raw` instead of `sanitize_text_field` so percent-encoded
212 // chars in the URI (notably `%2F` from query-arg slashes) survive
213 // long enough for `wp_parse_url` to split path / query correctly.
214 $uri = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
215 $path = wp_parse_url( $uri, PHP_URL_PATH );
216 if ( ! is_string( $path ) ) {
217 return false;
218 }
219
220 $home_path = wp_parse_url( home_url( '/' ), PHP_URL_PATH );
221 $home_path = is_string( $home_path ) ? rtrim( $home_path, '/' ) : '';
222
223 $expected = $home_path . '/' . DESKTOP_MODE_PORTAL_PATH;
224 $path = '/' . ltrim( rtrim( $path, '/' ), '/' );
225
226 return $path === $expected;
227 }
228
229 /**
230 * Forwards plain `/wp-admin/...` requests to the `/desktop-mode/` portal
231 * when the current user has desktop mode enabled.
232 *
233 * Why: when desktop mode is on, `/desktop-mode/` is meant to be the one
234 * canonical address. A user who bookmarks `/wp-admin/plugins.php` or
235 * follows an old admin link should still land in the shell, not in
236 * vanilla admin with the shell glued over the top. Running through the
237 * portal unifies the address bar and honors the saved session's focused
238 * window.
239 *
240 * Narrowly scoped to bail on every automated or sub-request entry point
241 * — AJAX, REST, cron, admin-post.php, non-GET methods — so the hook
242 * can't corrupt a form submission or break an API call.
243 *
244 * Disable via the `desktop_mode_admin_redirect_to_portal` filter (return
245 * false). Passthrough kicks in automatically when the current request
246 * is chromeless or already carries the portal flag.
247 */
248 function desktop_mode_redirect_plain_admin_to_portal() {
249 if ( ! desktop_mode_is_enabled() ) {
250 return;
251 }
252 if ( desktop_mode_is_chromeless_request() ) {
253 return;
254 }
255 if ( wp_doing_ajax() || wp_doing_cron() ) {
256 return;
257 }
258 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
259 return;
260 }
261 if ( ! empty( $_SERVER['REQUEST_METHOD'] ) && 'GET' !== strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) ) {
262 return;
263 }
264
265 // The portal handler adds this flag after it forwards into admin.
266 // Bailing here keeps us out of an infinite redirect loop.
267 if ( ! empty( $_GET[ DESKTOP_MODE_PORTAL_FLAG ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
268 return;
269 }
270
271 // The "Detach to new tab" button tags its URL with this flag so the
272 // user can view one admin page classically without disabling desktop
273 // mode account-wide. Only affects the single request — subsequent
274 // navigations inside the tab lose the flag and follow normal rules.
275 if ( ! empty( $_GET[ DESKTOP_MODE_CLASSIC_FLAG ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
276 return;
277 }
278
279 // admin-post.php and admin-ajax.php handle form submissions and JSON
280 // endpoints; redirecting them would break the call.
281 global $pagenow;
282 if ( in_array( $pagenow, array( 'admin-post.php', 'admin-ajax.php' ), true ) ) {
283 return;
284 }
285
286 /**
287 * Filters whether plain admin URLs should redirect to the portal
288 * when desktop mode is active.
289 *
290 * @param bool $redirect Whether to redirect. Default true.
291 * @param int $user_id The current user's ID.
292 */
293 $redirect = apply_filters( 'desktop_mode_admin_redirect_to_portal', true, get_current_user_id() );
294 if ( ! $redirect ) {
295 return;
296 }
297
298 // Preserve the original target on the portal redirect. Without this,
299 // navigating to a specific admin page (profile.php, plugins.php, any
300 // deep link) loses the user's intent — the portal would forward them
301 // to whichever window was last focused instead of the page they asked
302 // for. The portal handler reads `target`, validates it's same-origin
303 // wp-admin, and uses it as the entry URL.
304 $portal_url = desktop_mode_portal_url();
305 // `esc_url_raw` instead of `sanitize_text_field`: the latter strips
306 // every `%XX` percent-encoded sequence, which corrupts URIs whose
307 // query string legitimately carries an encoded slash — e.g. WP's
308 // own `plugins.php?action=activate&plugin=dir%2Ffile.php` activate
309 // link. The portal handler will validate this target downstream.
310 $target = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
311 if ( is_string( $target ) && '' !== $target ) {
312 $portal_url = add_query_arg( 'target', rawurlencode( $target ), $portal_url );
313 }
314
315 wp_safe_redirect( $portal_url );
316 exit;
317 }
318 add_action( 'admin_init', 'desktop_mode_redirect_plain_admin_to_portal' );
319
320 /**
321 * Resolves the admin URL the portal should forward to for a given user.
322 *
323 * Looks up the user's session and returns the URL of the window flagged
324 * as `focused`. If the session is empty, has no focused window, or the
325 * focused window's URL isn't same-origin admin, falls back to the
326 * dashboard.
327 *
328 * The portal navigates the TOP window, not an iframe, so any chromeless
329 * `desktop_mode_chromeless=1` flag baked into the stored URL is stripped — a leftover
330 * flag would land the user in a standalone chromeless page (no admin
331 * bar, no toggle, no way out) instead of the shell.
332 *
333 * @param int $user_id The user whose session to consult.
334 * @return string The admin URL to redirect to.
335 */
336 function desktop_mode_portal_entry_url( $user_id ) {
337 $session = desktop_mode_get_session( $user_id );
338
339 // User's configured default-window preference. When disabled, we
340 // still have to forward SOMEWHERE (the portal is an HTTP redirect),
341 // so we land on the Dashboard URL — but the shell detects the
342 // `enabled=false` state via the config and skips the auto-open,
343 // leaving the user with an empty desktop as they chose.
344 $default_window = desktop_mode_get_default_window( $user_id );
345 $fallback = $default_window['url'];
346
347 // Native marker (e.g. "native:desktop-mode-os-settings") is not a
348 // redirectable URL. The portal MUST forward somewhere — the
349 // redirect happens at HTTP level — so we land on the admin home
350 // and let the shell pick up `defaultWindow.url` from the config
351 // after init and call nativeWindows.openById( <slug> ).
352 if ( is_string( $fallback ) && 0 === strpos( $fallback, 'native:' ) ) {
353 $fallback = admin_url();
354 }
355
356 if ( empty( $session['focused'] ) || empty( $session['windows'] ) ) {
357 return $fallback;
358 }
359
360 foreach ( $session['windows'] as $win ) {
361 if ( ! isset( $win['id'], $win['url'] ) ) {
362 continue;
363 }
364 if ( $win['id'] !== $session['focused'] ) {
365 continue;
366 }
367 if ( ! desktop_mode_url_is_same_admin( $win['url'] ) ) {
368 return $fallback;
369 }
370 return remove_query_arg( array( 'desktop_mode_chromeless', DESKTOP_MODE_PORTAL_FLAG ), $win['url'] );
371 }
372
373 return $fallback;
374 }
375
376 /**
377 * Validates and normalizes a `target` query arg on the portal URL.
378 *
379 * Accepts a raw request-URI-shaped string (path + optional query, e.g.
380 * `/wp-admin/profile.php?foo=bar`) and returns a fully-qualified admin
381 * URL if — and only if — it resolves to a same-origin `wp-admin/` path.
382 * Everything else returns an empty string so the caller falls back to
383 * the saved-session entry URL.
384 *
385 * Strips `desktop_mode_chromeless` and the portal flag from the query so the target
386 * doesn't chain us into a chromeless standalone load or an infinite
387 * redirect loop.
388 *
389 * @param string $raw Raw value from `$_GET['target']` (already unslashed).
390 * @return string A safe absolute admin URL, or '' if the input is invalid.
391 */
392 function desktop_mode_sanitize_portal_target( $raw ) {
393 if ( ! is_string( $raw ) || '' === $raw ) {
394 return '';
395 }
396
397 // Reject URIs with a scheme or protocol-relative prefix — we only
398 // accept relative paths so there's no way to redirect off-site.
399 if ( preg_match( '#^([a-z][a-z0-9+.-]*:|//)#i', $raw ) ) {
400 return '';
401 }
402
403 // Must be an absolute path starting with /.
404 if ( '/' !== $raw[0] ) {
405 return '';
406 }
407
408 $path = wp_parse_url( $raw, PHP_URL_PATH );
409 $query = wp_parse_url( $raw, PHP_URL_QUERY );
410 if ( ! is_string( $path ) || '' === $path ) {
411 return '';
412 }
413
414 $admin_path = wp_parse_url( admin_url(), PHP_URL_PATH );
415 $admin_path = is_string( $admin_path ) ? $admin_path : '/wp-admin/';
416 if ( 0 !== strpos( $path, $admin_path ) ) {
417 return '';
418 }
419
420 $file = substr( $path, strlen( $admin_path ) );
421 $file = ltrim( (string) $file, '/' );
422 if ( '' === $file ) {
423 $file = 'index.php';
424 }
425
426 // Resolve against the hardcoded allowlist of canonical wp-admin
427 // filenames (see `desktop_mode_admin_target_allowlist()`). A
428 // regex alone would accept a plausible-looking filename that
429 // isn't a real core admin page (e.g. `custom_admin_page.php`)
430 // and effectively become an open redirect to a 404 page served
431 // under the admin path; the explicit allowlist closes that.
432 $target = desktop_mode_resolve_admin_target( $file );
433 if ( is_wp_error( $target ) ) {
434 return '';
435 }
436
437 if ( is_string( $query ) && '' !== $query ) {
438 parse_str( $query, $args );
439 unset( $args['desktop_mode_chromeless'], $args[ DESKTOP_MODE_PORTAL_FLAG ], $args[ DESKTOP_MODE_PORTAL_INTENT_FLAG ], $args['target'] );
440 if ( ! empty( $args ) ) {
441 $target = add_query_arg( $args, $target );
442 }
443 }
444
445 return $target;
446 }
447