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

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