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

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