PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.9
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.9
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 0.8.6 All 33 releases
desktop-mode / includes / portal.php

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

678 lines 26.8 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 to the shell screen
12 * (`admin.php?page=openstation`, see `includes/shell-screen.php`).
13 * An explicit `?target=` travels along as the page the shell opens
14 * first; without one the screen resolves the entry itself — the
15 * last-focused window of the saved session, else the default
16 * window, else the Dashboard.
17 *
18 * The URL is served virtually (no rewrite rules, no `.htaccess`
19 * surgery) by intercepting `parse_request` before WordPress routes the
20 * URL to 404. This keeps the plugin drop-in.
21 *
22 * @package OpenStation
23 */
24
25 defined( 'ABSPATH' ) || exit;
26
27 /** The URL path that triggers the portal handler. */
28 const OPENSTATION_PORTAL_PATH = 'openstation';
29
30 /**
31 * The pre-rebrand portal path, still accepted.
32 *
33 * The portal was reachable at `/desktop-mode/` before the rename, and
34 * that address is the kind of thing people bookmark or pin. It is not
35 * canonical: {@see openstation_portal_url()} always emits the current
36 * path, and a visit here forwards into wp-admin exactly as the canonical
37 * path does, so the address bar self-corrects on the next hop.
38 *
39 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
40 * persisted or externally-visible identifier, so renaming it would
41 * orphan data already written by live installs (or break a live
42 * URL). The mismatch between this constant's name and its value is
43 * deliberate — it is NOT a half-finished rename.
44 */
45 const OPENSTATION_PORTAL_PATH_LEGACY = 'desktop-mode';
46
47 /**
48 * Query var the admin shell reads to know it was entered via the portal.
49 *
50 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
51 * persisted or externally-visible identifier, so renaming it would
52 * orphan data already written by live installs (or break a live
53 * URL). The mismatch between this constant's name and its value is
54 * deliberate — it is NOT a half-finished rename.
55 */
56 const OPENSTATION_PORTAL_FLAG = 'desktop_mode_portal';
57
58 /**
59 * Query var set on portal redirects whose landing page came from an
60 * explicit `?target=…` URL the user (or a redirect chain originating
61 * from a click) provided — as opposed to the portal picking the
62 * session's focused window or the default-window fallback.
63 *
64 * The shell uses this to distinguish "user expressed navigation intent
65 * toward this URL" (open it) from "portal had to forward somewhere"
66 * (don't disturb the restored session).
67 *
68 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
69 * persisted or externally-visible identifier, so renaming it would
70 * orphan data already written by live installs (or break a live
71 * URL). The mismatch between this constant's name and its value is
72 * deliberate — it is NOT a half-finished rename.
73 */
74 const OPENSTATION_PORTAL_INTENT_FLAG = 'desktop_mode_portal_intent';
75
76 /**
77 * Query var set by the window-title-bar "Detach" action. Tells the
78 * admin_init redirect to skip portal forwarding for this request so the
79 * user can view the page as classic wp-admin in a new tab even when
80 * OpenStation is globally enabled for their account.
81 *
82 * The VALUE keeps its pre-rebrand spelling on purpose: it is a
83 * persisted or externally-visible identifier, so renaming it would
84 * orphan data already written by live installs (or break a live
85 * URL). The mismatch between this constant's name and its value is
86 * deliberate — it is NOT a half-finished rename.
87 */
88 const OPENSTATION_CLASSIC_FLAG = 'desktop_mode_classic';
89
90 /**
91 * Returns the canonical portal URL, e.g. `https://example.com/openstation/`.
92 *
93 * @return string
94 */
95 function openstation_portal_url() {
96 return home_url( '/' . OPENSTATION_PORTAL_PATH . '/' );
97 }
98
99 /**
100 * Intercepts requests to `/openstation` and forwards them into the admin.
101 *
102 * Hooks on `parse_request` — early enough to pre-empt 404 handling but
103 * late enough that `is_user_logged_in()` is reliable.
104 *
105 * @param WP $wp Current WordPress environment instance.
106 */
107 function openstation_handle_portal_request( $wp ) {
108 unset( $wp );
109
110 if ( ! openstation_is_portal_request() ) {
111 return;
112 }
113
114 // Logged-out: bounce through login, returning to the portal URL.
115 if ( ! is_user_logged_in() ) {
116 wp_safe_redirect( wp_login_url( openstation_portal_url() ) );
117 exit;
118 }
119
120 // Require basic admin-read capability so subscribers of sites that
121 // blocked `read` from admin don't land in a broken window.
122 if ( ! current_user_can( 'read' ) ) {
123 wp_die(
124 esc_html__( 'Sorry, you are not allowed to access the WordPress desktop.', 'desktop-mode' ),
125 '',
126 array( 'response' => 403 )
127 );
128 }
129
130 $user_id = get_current_user_id();
131
132 /**
133 * Filters whether visiting the `/openstation` portal should auto-enable
134 * OpenStation for the current user.
135 *
136 * Default: true — the portal is an explicit opt-in action, so flipping
137 * the user meta mirrors the intent of visiting the URL.
138 *
139 * @param bool $auto_enable Whether to auto-enable OpenStation.
140 * @param int $user_id The current user's ID.
141 */
142 $auto_enable = apply_filters( 'openstation_portal_auto_enable', true, $user_id );
143
144 // CSRF guard: only flip user-meta when the request is a same-origin
145 // top-level navigation. The portal is a GET URL by design (users
146 // follow shared `/openstation/` links), so we can't require a nonce
147 // — but we can require that the navigation originated from the
148 // same site (or a typed/bookmarked URL with no Referer/Sec-Fetch-
149 // Site). Off-origin hits still redirect into admin so shared
150 // links keep working; they just don't silently mutate user-meta.
151 if ( $auto_enable && openstation_portal_is_same_origin_navigation() && '1' !== get_user_meta( $user_id, 'desktop_mode_mode', true ) ) {
152 update_user_meta( $user_id, 'desktop_mode_mode', '1' );
153 }
154
155 // Pick the page the shell opens first. An explicit `target` query
156 // arg — a same-origin wp-admin URL — is how
157 // `openstation_redirect_plain_admin_to_portal` preserves the user's
158 // navigation intent when they follow a link to a specific admin
159 // page (e.g. profile.php). Without one the shell screen resolves
160 // the entry itself: the last-focused window from the saved
161 // session, else the default window, else the Dashboard — see
162 // `openstation_shell_boot_target()`. The bare screen URL is the
163 // canonical address, and a reload of it re-resolves against the
164 // live session rather than against the window that was focused
165 // when the redirect happened.
166 $target = '';
167 $has_intent = false;
168 if ( ! empty( $_GET['target'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
169 // `esc_url_raw`, NOT `sanitize_text_field`: the latter strips
170 // every `%XX` percent-encoded sequence from its input as an XSS
171 // safeguard, which mangles request URIs that legitimately carry
172 // encoded slashes (e.g. `plugin=dir%2Ffile.php`). The downstream
173 // `openstation_sanitize_portal_target` validates the URL
174 // rigorously (scheme rejection, traversal rejection, and a
175 // hardcoded allowlist of canonical wp-admin filenames — see
176 // `openstation_admin_target_allowlist()`) so we don't lose
177 // any real safety by skipping `sanitize_text_field` here.
178 $target = openstation_sanitize_portal_target( esc_url_raw( wp_unslash( $_GET['target'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
179 if ( '' !== $target ) {
180 $has_intent = true;
181 }
182 }
183 // `intent=1` rides along with an explicit target so the shell treats
184 // the resulting `currentPage` as user intent and opens it on top of
185 // the restored session. Without it, a bare `/openstation/` visit and
186 // a portal-redirected admin-bar click would be indistinguishable
187 // downstream.
188 wp_safe_redirect( openstation_shell_url( $target, $has_intent ) );
189 exit;
190 }
191 add_action( 'parse_request', 'openstation_handle_portal_request' );
192
193 /**
194 * Decides whether the current request to the portal can mutate
195 * user-meta safely (same-origin) or should only redirect (cross-
196 * origin, possibly CSRF).
197 *
198 * Logic mirrors the `Sec-Fetch-Site` heuristic browsers use:
199 *
200 * - `Sec-Fetch-Site: same-origin | same-site | none` → trusted
201 * (the request originated from this site, or from a typed URL
202 * / bookmark with no referrer info).
203 * - `Sec-Fetch-Site: cross-site` → untrusted (a third-party page
204 * pointed the user at the portal — could be an `<img>` tag).
205 * - Header missing (older browsers): fall back to `Referer` —
206 * same host or empty referrer is trusted, anything else isn't.
207 *
208 * @return bool
209 */
210 function openstation_portal_is_same_origin_navigation() {
211 if ( ! empty( $_SERVER['HTTP_SEC_FETCH_SITE'] ) ) {
212 $site = strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_SEC_FETCH_SITE'] ) ) );
213 return in_array( $site, array( 'same-origin', 'same-site', 'none' ), true );
214 }
215
216 if ( empty( $_SERVER['HTTP_REFERER'] ) ) {
217 return true;
218 }
219
220 $referer_host = wp_parse_url( esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ), PHP_URL_HOST );
221 $home_host = wp_parse_url( home_url(), PHP_URL_HOST );
222
223 if ( ! is_string( $referer_host ) || '' === $referer_host ) {
224 return true;
225 }
226
227 return is_string( $home_host ) && strtolower( $referer_host ) === strtolower( $home_host );
228 }
229
230 /**
231 * Detects whether the current request is for the portal URL.
232 *
233 * Strips any query string and trailing slash and compares against
234 * `/openstation` relative to the site's home path. The pre-rebrand
235 * `/desktop-mode` path is accepted too, so bookmarks made before the
236 * rename still land in the shell.
237 *
238 * @return bool
239 */
240 function openstation_is_portal_request() {
241 if ( empty( $_SERVER['REQUEST_URI'] ) ) {
242 return false;
243 }
244
245 // `esc_url_raw` instead of `sanitize_text_field` so percent-encoded
246 // chars in the URI (notably `%2F` from query-arg slashes) survive
247 // long enough for `wp_parse_url` to split path / query correctly.
248 $uri = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
249 $path = wp_parse_url( $uri, PHP_URL_PATH );
250 if ( ! is_string( $path ) ) {
251 return false;
252 }
253
254 $home_path = wp_parse_url( home_url( '/' ), PHP_URL_PATH );
255 $home_path = is_string( $home_path ) ? rtrim( $home_path, '/' ) : '';
256
257 $path = '/' . ltrim( rtrim( $path, '/' ), '/' );
258
259 return in_array(
260 $path,
261 array(
262 $home_path . '/' . OPENSTATION_PORTAL_PATH,
263 $home_path . '/' . OPENSTATION_PORTAL_PATH_LEGACY,
264 ),
265 true
266 );
267 }
268
269 /**
270 * Sends plain `/wp-admin/...` requests into the desktop.
271 *
272 * The shell is served by its own screen (`includes/shell-screen.php`),
273 * so a plain admin page is never where the desktop renders: a user who
274 * typed or bookmarked `/wp-admin/edit.php` is forwarded to the shell
275 * screen with that URL as the page it opens first. Three routes out of
276 * here, cheapest first:
277 *
278 * 1. **Straight to the shell screen** when the portal would only hand
279 * this URL back — an allowlisted wp-admin file that is also the
280 * page being served, carrying no query arg the portal would strip
281 * ({@see openstation_portal_forward_is_redundant()}). One
282 * redirect; the portal hop would have cost a WordPress bootstrap
283 * to learn what is already known. `openstation_skip_redundant_portal_forward`
284 * (return false) forces the hop back on for a plugin that hooks
285 * the portal handler for side effects.
286 * 2. **Through `/openstation/?target=…`** otherwise — a network-admin
287 * URL, a path outside the wp-admin allowlist — so the portal can
288 * fall back to the saved session's focused window, which is a real
289 * change of destination the shell can't make from here.
290 * 3. **The frozen-flag alias.** A URL carrying `desktop_mode_portal=1`
291 * is the desktop's pre-screen address: the portal used to forward
292 * to a real admin page tagged with it, and bookmarks, the PWA start
293 * URL and plugin-built links still say so. It goes to the shell
294 * screen with that URL as the target, and `intent=1` when the
295 * intent flag was present. The flags stay frozen (see AGENTS.md);
296 * only what they resolve to moved.
297 *
298 * Narrowly scoped to bail on every automated or sub-request entry point
299 * — AJAX, REST, cron, admin-post.php, non-GET methods, and sub-resource
300 * fetches (an `<img>`, a script or an XHR whose URL is an admin page,
301 * see {@see openstation_is_subresource_request()}) — so the hook can't
302 * corrupt a form submission, break an API call or hand an image tag an
303 * HTML document. The shell screen itself, chromeless loads, solo boots
304 * and classic-flagged requests pass through.
305 *
306 * Disable via the `openstation_admin_redirect_to_portal` filter (return
307 * false); plain admin pages then render as classic admin and the
308 * desktop lives at `/openstation/` only. The alias route runs before
309 * the filter: a URL that names the desktop is not a plain admin page.
310 */
311 function openstation_redirect_plain_admin_to_portal() {
312 if ( ! openstation_is_enabled() ) {
313 return;
314 }
315 // The screen the redirects land on. First in the chain: every other
316 // branch below ends in a redirect here, and the screen is a plain
317 // admin GET like any other.
318 if ( openstation_is_shell_screen_request() ) {
319 return;
320 }
321 if ( openstation_is_chromeless_request() ) {
322 return;
323 }
324 // A solo boot renders one window in place, wherever it landed.
325 if ( function_exists( 'openstation_is_solo_request' ) && openstation_is_solo_request() ) {
326 return;
327 }
328 // The user admin (`wp-admin/user/`, multisite's dashboard for users
329 // with no site role) renders classic. It has no shell screen of its
330 // own, and its URLs never survive the target allowlist — before
331 // this pass-through the redirect claimed the request anyway and
332 // silently forwarded the user to the site desktop's default entry.
333 if ( is_multisite() && is_user_admin() ) {
334 return;
335 }
336 if ( wp_doing_ajax() || wp_doing_cron() ) {
337 return;
338 }
339 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
340 return;
341 }
342 if ( ! empty( $_SERVER['REQUEST_METHOD'] ) && 'GET' !== strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) ) {
343 return;
344 }
345 // The browser says what it is fetching for. An <img>, a script or
346 // an XHR aimed at an admin URL (Jetpack's admin-bar sparkline is
347 // admin.php?page=stats&noheader&proxy&chart=…) is not a user
348 // landing on a plain admin page, and forwarding it into the desktop
349 // only swaps the bytes it asked for with the shell's HTML.
350 if ( openstation_is_subresource_request() ) {
351 return;
352 }
353
354 // The "Detach to new tab" button tags its URL with this flag so the
355 // user can view one admin page classically without disabling desktop
356 // mode account-wide. Only affects the single request — subsequent
357 // navigations inside the tab lose the flag and follow normal rules.
358 if ( ! empty( $_GET[ OPENSTATION_CLASSIC_FLAG ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
359 return;
360 }
361
362 // admin-post.php and admin-ajax.php handle form submissions and JSON
363 // endpoints; redirecting them would break the call.
364 global $pagenow;
365 if ( in_array( $pagenow, array( 'admin-post.php', 'admin-ajax.php' ), true ) ) {
366 return;
367 }
368
369 // `esc_url_raw` instead of `sanitize_text_field`: the latter strips
370 // every `%XX` percent-encoded sequence, which corrupts URIs whose
371 // query string legitimately carries an encoded slash — e.g. WP's
372 // own `plugins.php?action=activate&plugin=dir%2Ffile.php` activate
373 // link. The shell screen validates the target on read.
374 $target = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
375 $target = is_string( $target ) ? $target : '';
376
377 // Route 3: the frozen-flag alias. The sanitiser strips both flags
378 // from the target; an unresolvable one leaves the screen to pick
379 // the entry, exactly as the portal did for an invalid `target`.
380 if ( ! empty( $_GET[ OPENSTATION_PORTAL_FLAG ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
381 $clean = openstation_sanitize_portal_target( $target );
382 $intent = '' !== $clean && ! empty( $_GET[ OPENSTATION_PORTAL_INTENT_FLAG ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
383 wp_safe_redirect( openstation_shell_url( $clean, $intent ) );
384 exit;
385 }
386
387 /**
388 * Filters whether plain admin URLs should redirect into the desktop
389 * when OpenStation is active.
390 *
391 * @param bool $redirect Whether to redirect. Default true.
392 * @param int $user_id The current user's ID.
393 */
394 $redirect = apply_filters( 'openstation_admin_redirect_to_portal', true, get_current_user_id() );
395 if ( ! $redirect ) {
396 return;
397 }
398
399 // Route 1: straight to the shell screen.
400 if ( openstation_portal_forward_is_redundant( $target ) ) {
401 /**
402 * Filters whether to skip the portal hop for a URL the portal
403 * would only hand straight back.
404 *
405 * Default: true — the request goes straight to the shell screen
406 * with this URL as its target. Return false to route through
407 * `/openstation/` anyway, e.g. for a plugin that hooks
408 * `openstation_handle_portal_request` for its own side effects
409 * and needs it to run on every admin entry.
410 *
411 * @param bool $skip Whether to skip the portal hop.
412 * @param string $request_uri The current request URI.
413 */
414 if ( apply_filters( 'openstation_skip_redundant_portal_forward', true, $target ) ) {
415 wp_safe_redirect( openstation_shell_url( openstation_sanitize_portal_target( $target ), true ) );
416 exit;
417 }
418 }
419
420 // Route 2: through the portal, target preserved. Without it,
421 // navigating to a specific admin page (profile.php, plugins.php, any
422 // deep link) loses the user's intent — the portal would forward them
423 // to whichever window was last focused instead of the page they asked
424 // for. The portal handler reads `target`, validates it's same-origin
425 // wp-admin, and passes it on to the shell screen.
426 $portal_url = openstation_portal_url();
427 if ( '' !== $target ) {
428 $portal_url = add_query_arg( 'target', rawurlencode( $target ), $portal_url );
429 }
430
431 wp_safe_redirect( $portal_url );
432 exit;
433 }
434 add_action( 'admin_init', 'openstation_redirect_plain_admin_to_portal' );
435
436 /**
437 * Whether forwarding this request through `/openstation/` would only
438 * hand the URL already being served back as the shell's target.
439 *
440 * Answers locally, and without the HTTP round trip, the same question
441 * {@see openstation_handle_portal_request()} answers after another
442 * WordPress bootstrap. True means the hop is pure overhead and the
443 * caller can send the user straight to the shell screen with this URL
444 * as its target.
445 *
446 * Deliberately conservative: every "don't know" answers false, so the
447 * forward survives wherever the portal might genuinely choose a
448 * different destination.
449 *
450 * 1. The path must resolve through the same wp-admin allowlist the
451 * portal validates `?target=` against. Anything that list rejects
452 * — a `network/` or `user/` sub-path on multisite, a filename that
453 * isn't canonical wp-admin — makes the portal fall back to the
454 * session's focused window, which is a real change of destination.
455 * 2. The resolved filename must be the file this request is actually
456 * serving. If `$pagenow` disagrees with the URL path then a
457 * rewrite is in play and we can't claim to know what renders here.
458 * 3. The query must survive intact. The portal drops
459 * `openstation_chromeless`, both portal flags and `target` from
460 * the URL it rebuilds, so a request carrying any of them comes
461 * back as a different URL.
462 *
463 * @param string $request_uri The current request URI, unslashed.
464 * @return bool True when the portal would resolve this URL to itself.
465 */
466 function openstation_portal_forward_is_redundant( $request_uri ) {
467 global $pagenow;
468
469 if ( ! is_string( $request_uri ) || '' === $request_uri ) {
470 return false;
471 }
472
473 $path = wp_parse_url( $request_uri, PHP_URL_PATH );
474 if ( ! is_string( $path ) || '' === $path ) {
475 return false;
476 }
477
478 $admin_path = wp_parse_url( admin_url(), PHP_URL_PATH );
479 $admin_path = is_string( $admin_path ) ? $admin_path : '/wp-admin/';
480 if ( 0 !== strpos( $path, $admin_path ) ) {
481 return false;
482 }
483
484 $file = ltrim( (string) substr( $path, strlen( $admin_path ) ), '/' );
485 if ( '' === $file ) {
486 $file = 'index.php';
487 }
488
489 // 1. The portal's allowlist has to accept it.
490 if ( is_wp_error( openstation_resolve_admin_target( $file ) ) ) {
491 return false;
492 }
493
494 // 2. …and it has to be the page we are actually serving.
495 if ( ! is_string( $pagenow ) || strtolower( $file ) !== strtolower( $pagenow ) ) {
496 return false;
497 }
498
499 // 3. …carrying a query the portal would hand back unchanged.
500 $rewritten = array(
501 'openstation_chromeless',
502 OPENSTATION_PORTAL_FLAG,
503 OPENSTATION_PORTAL_INTENT_FLAG,
504 'target',
505 );
506 foreach ( $rewritten as $key ) {
507 if ( isset( $_GET[ $key ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
508 return false;
509 }
510 }
511
512 return true;
513 }
514
515 /**
516 * Resolves the admin URL the portal should forward to for a given user.
517 *
518 * Looks up the user's session and returns the URL of the window flagged
519 * as `focused`. If the session is empty, has no focused window, or the
520 * focused window's URL isn't same-origin admin, falls back to the
521 * dashboard.
522 *
523 * The portal navigates the TOP window, not an iframe, so any chromeless
524 * `openstation_chromeless=1` flag baked into the stored URL is stripped — a leftover
525 * flag would land the user in a standalone chromeless page (no admin
526 * bar, no toggle, no way out) instead of the shell.
527 *
528 * @param int $user_id The user whose session to consult.
529 * @return string The admin URL to redirect to.
530 */
531 function openstation_portal_entry_url( $user_id ) {
532 $session = openstation_get_session( $user_id );
533
534 // User's configured default-window preference. When disabled, we
535 // still have to forward SOMEWHERE (the portal is an HTTP redirect),
536 // so we land on the Dashboard URL — but the shell detects the
537 // `enabled=false` state via the config and skips the auto-open,
538 // leaving the user with an empty desktop as they chose.
539 $default_window = openstation_get_default_window( $user_id );
540 $fallback = $default_window['url'];
541
542 // Native marker (e.g. "native:os-settings") is not a
543 // redirectable URL. The portal MUST forward somewhere — the
544 // redirect happens at HTTP level — so we land on the admin home
545 // and let the shell pick up `defaultWindow.url` from the config
546 // after init and call nativeWindows.openById( <slug> ).
547 if ( is_string( $fallback ) && 0 === strpos( $fallback, 'native:' ) ) {
548 $fallback = admin_url();
549 }
550
551 if ( empty( $session['focused'] ) || empty( $session['windows'] ) ) {
552 return $fallback;
553 }
554
555 foreach ( $session['windows'] as $win ) {
556 if ( ! isset( $win['id'], $win['url'] ) ) {
557 continue;
558 }
559 if ( $win['id'] !== $session['focused'] ) {
560 continue;
561 }
562 if ( ! openstation_url_is_same_admin( $win['url'] ) ) {
563 return $fallback;
564 }
565 // The shell must never open itself. A saved window pointing at
566 // the shell screen cannot be produced by the shell, but a
567 // hand-edited session could say so; treat it as nothing focused.
568 if ( openstation_url_is_shell_screen( $win['url'] ) ) {
569 return $fallback;
570 }
571 return remove_query_arg( array( 'openstation_chromeless', OPENSTATION_PORTAL_FLAG ), $win['url'] );
572 }
573
574 return $fallback;
575 }
576
577 /**
578 * Validates and normalizes a `target` query arg on the portal URL.
579 *
580 * Accepts a raw request-URI-shaped string (path + optional query, e.g.
581 * `/wp-admin/profile.php?foo=bar`) and returns a fully-qualified admin
582 * URL if — and only if — it resolves to a same-origin `wp-admin/` path.
583 * Everything else returns an empty string so the caller falls back to
584 * the saved-session entry URL.
585 *
586 * Strips `openstation_chromeless` and the portal flag from the query so the target
587 * doesn't chain us into a chromeless standalone load or an infinite
588 * redirect loop.
589 *
590 * @param string $raw Raw value from `$_GET['target']` (already unslashed).
591 * @return string A safe absolute admin URL, or '' if the input is invalid.
592 */
593 function openstation_sanitize_portal_target( $raw ) {
594 if ( ! is_string( $raw ) || '' === $raw ) {
595 return '';
596 }
597
598 // Reject URIs with a scheme or protocol-relative prefix — we only
599 // accept relative paths so there's no way to redirect off-site.
600 if ( preg_match( '#^([a-z][a-z0-9+.-]*:|//)#i', $raw ) ) {
601 return '';
602 }
603
604 // Must be an absolute path starting with /.
605 if ( '/' !== $raw[0] ) {
606 return '';
607 }
608
609 $path = wp_parse_url( $raw, PHP_URL_PATH );
610 $query = wp_parse_url( $raw, PHP_URL_QUERY );
611 if ( ! is_string( $path ) || '' === $path ) {
612 return '';
613 }
614
615 $admin_path = wp_parse_url( admin_url(), PHP_URL_PATH );
616 $admin_path = is_string( $admin_path ) ? $admin_path : '/wp-admin/';
617 if ( 0 !== strpos( $path, $admin_path ) ) {
618 return '';
619 }
620
621 $file = substr( $path, strlen( $admin_path ) );
622 $file = ltrim( (string) $file, '/' );
623
624 // The network admin's own screens live one directory down and are
625 // resolved against their own list. Without this a network URL came
626 // back empty and the user was quietly forwarded to the site
627 // dashboard, which is a different admin.
628 $network = 0 === strpos( $file, 'network/' );
629 if ( $network ) {
630 $file = substr( $file, strlen( 'network/' ) );
631 }
632
633 if ( '' === $file ) {
634 $file = 'index.php';
635 }
636
637 // Resolve against the hardcoded allowlist of canonical wp-admin
638 // filenames (see `openstation_admin_target_allowlist()`). A
639 // regex alone would accept a plausible-looking filename that
640 // isn't a real core admin page (e.g. `custom_admin_page.php`)
641 // and effectively become an open redirect to a 404 page served
642 // under the admin path; the explicit allowlist closes that.
643 $target = openstation_resolve_admin_target( $file, $network );
644 if ( is_wp_error( $target ) ) {
645 return '';
646 }
647
648 if ( is_string( $query ) && '' !== $query ) {
649 parse_str( $query, $args );
650 unset( $args['openstation_chromeless'], $args[ OPENSTATION_PORTAL_FLAG ], $args[ OPENSTATION_PORTAL_INTENT_FLAG ], $args['target'] );
651 if ( ! empty( $args ) ) {
652 $target = add_query_arg( $args, $target );
653 }
654 }
655
656 // The shell screen is where a target is opened, never a target: the
657 // shell would open itself in a window, and a redirect chain built
658 // from it would loop. Fall back to the entry resolver instead.
659 if ( openstation_url_is_shell_screen( $target ) ) {
660 return '';
661 }
662
663 // `admin.php` is a bootstrap, not a page. Without a `page` arg core
664 // falls through the last `else` in `wp-admin/admin.php`, never
665 // requires `admin-header.php`, and answers 200 with an empty body —
666 // so the URL becomes a window showing nothing. The allowlist above
667 // matches filenames and cannot see the query, which is why the
668 // check belongs here, beside the shell-screen one: both are URLs
669 // that resolve but must not become a target. Returning '' hands the
670 // caller back to the entry resolver (session's focused window, else
671 // the default window, else the Dashboard).
672 if ( openstation_url_is_page_less_admin_php( $target ) ) {
673 return '';
674 }
675
676 return $target;
677 }
678