PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.7
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.7
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.7, at includes/portal.php

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