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 / core / routing.php

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

764 lines 26.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — request routing helpers.
4 *
5 * Chromeless / classic admin-bar suppression and the
6 * `wp_redirect` filter pair that re-stamps the openstation
7 * flags onto server-built redirects. Extracted from the
8 * 1,609-LOC `helpers.php` during the architecture-0.8.1 PHP
9 * slicing (phase 6).
10 *
11 * Behaviour is unchanged. Plugins that registered against any of
12 * these filters keep working: PHP looks function references up by
13 * name at hook-fire time, and `desktop-mode.php` requires this
14 * file before `helpers.php`, so the function definitions are
15 * always present by the time WordPress wants them.
16 *
17 * Functions in this file:
18 * - {@see openstation_url_is_same_admin()} — same-origin admin URL predicate
19 * - {@see openstation_url_is_page_less_admin_php()} — "renders nothing" predicate
20 * - {@see openstation_resolve_admin_target()} — admin filename → URL resolver
21 * - {@see openstation_admin_target_allowlist()} — wp-admin filename allowlist
22 * - {@see openstation_is_chromeless_request()} — chromeless request detection
23 * - {@see openstation_is_classic_request()} — classic-override request detection
24 * - {@see openstation_is_subresource_request()} — sub-resource fetch detection
25 * - {@see openstation_chromeless_hide_admin_bar()} — `show_admin_bar` filter
26 * - {@see openstation_chromeless_suppress_admin_bar()} — `admin_init` action
27 * - {@see openstation_chromeless_preserve_redirect()} — `wp_redirect` filter
28 * - {@see openstation_classic_preserve_redirect()} — `wp_redirect` filter
29 * - {@see openstation_is_admin_redirect_target()} — internal predicate
30 *
31 * @package OpenStation
32 */
33
34 defined( 'ABSPATH' ) || exit;
35
36 /**
37 * Returns true when `$url` is a same-origin admin URL.
38 *
39 * Uses parsed-URL host + path comparison rather than a prefix
40 * `strpos` check so `//evil.com/wp-admin/…` or a URL whose
41 * normalisation happens to share the admin-URL prefix can't
42 * sneak through.
43 *
44 * An empty string returns false — a missing URL is never
45 * "same-origin admin" for the purposes of any caller.
46 *
47 * @param string $url URL to test.
48 * @return bool
49 */
50 function openstation_url_is_same_admin( $url ) {
51 if ( ! is_string( $url ) || '' === $url ) {
52 return false;
53 }
54
55 $parts = wp_parse_url( $url );
56 $admin_parts = wp_parse_url( admin_url() );
57 if ( ! is_array( $parts ) || ! is_array( $admin_parts ) ) {
58 return false;
59 }
60
61 // Host comparison is case-insensitive per RFC 3986. Missing
62 // host on the tested URL (relative or scheme-only) is a
63 // reject — callers should only be handing us fully-qualified
64 // URLs.
65 $url_host = isset( $parts['host'] ) ? strtolower( $parts['host'] ) : '';
66 $admin_host = isset( $admin_parts['host'] ) ? strtolower( $admin_parts['host'] ) : '';
67 if ( '' === $url_host || $url_host !== $admin_host ) {
68 return false;
69 }
70
71 // Path comparison is case-sensitive. The admin path always
72 // ends in `/` (e.g. `/wp-admin/`), so a prefix test is
73 // accurate — nothing at `/wp-administrator/…` can match.
74 $url_path = isset( $parts['path'] ) ? $parts['path'] : '';
75 $admin_path = isset( $admin_parts['path'] ) ? $admin_parts['path'] : '/wp-admin/';
76 return 0 === strpos( $url_path, $admin_path );
77 }
78
79 /**
80 * Whether `$url` addresses `wp-admin/admin.php` with no `page` arg.
81 *
82 * `admin.php` is core's plugin-screen bootstrap, and the allowlist in
83 * {@see openstation_admin_target_allowlist()} accepts it for exactly
84 * that reason — every plugin screen in the admin lives there. Without
85 * a `page` arg, though, there is no screen to dispatch to: core falls
86 * through the last `else` in `wp-admin/admin.php`, fires a couple of
87 * back-compat `load-*` hooks, and returns 200 with an empty body,
88 * having required neither `admin-header.php` nor `admin-footer.php`.
89 *
90 * So the URL resolves, passes every same-origin and allowlist check,
91 * and renders nothing. Callers that are about to turn a URL into a
92 * window or a redirect target use this to refuse it and fall back.
93 *
94 * Accepts absolute URLs and request-URI-shaped paths, mirroring
95 * {@see openstation_url_is_shell_screen()}, whose guard this sits
96 * beside at every call site.
97 *
98 * @param string $url URL or path to test.
99 * @return bool
100 */
101 function openstation_url_is_page_less_admin_php( $url ) {
102 if ( ! is_string( $url ) || '' === $url ) {
103 return false;
104 }
105
106 $path = wp_parse_url( $url, PHP_URL_PATH );
107 if ( ! is_string( $path ) || 'admin.php' !== basename( $path ) ) {
108 return false;
109 }
110
111 $query = wp_parse_url( $url, PHP_URL_QUERY );
112 if ( ! is_string( $query ) || '' === $query ) {
113 return true;
114 }
115
116 // `page=` present but empty is the same nothing: core only sets
117 // `$plugin_page` from a non-empty `?page=`. An array (`page[]=x`)
118 // is not a slug either.
119 parse_str( $query, $args );
120 return ! isset( $args['page'] ) || ! is_string( $args['page'] ) || '' === $args['page'];
121 }
122
123 /**
124 * Resolves an admin-page filename (e.g. `edit.php`) to its
125 * absolute admin URL, allowlisted against the canonical set of
126 * wp-admin top-level filenames.
127 *
128 * Returns a `WP_Error` when the input contains path traversal,
129 * isn't a bare `.php` filename, or points at a file that doesn't
130 * exist in the static allowlist. A regex-only check would accept
131 * `custom_admin_page.php` if a plugin named something that way;
132 * the explicit allowlist closes that.
133 *
134 * @param string $file Bare admin filename (no path, no query string).
135 * @param bool $network Resolve against the network admin's own screens.
136 * @return string|WP_Error Absolute admin URL on success, `WP_Error` otherwise.
137 */
138 function openstation_resolve_admin_target( $file, $network = false ) {
139 $file = is_string( $file ) ? trim( $file ) : '';
140 if ( '' === $file ) {
141 return new WP_Error(
142 'openstation_empty_target',
143 __( 'Admin target cannot be empty.', 'desktop-mode' )
144 );
145 }
146
147 if ( false !== strpos( $file, '..' ) || false !== strpos( $file, '/' ) || false !== strpos( $file, '\\' ) ) {
148 return new WP_Error(
149 'openstation_invalid_target',
150 __( 'Admin target contains invalid path characters.', 'desktop-mode' )
151 );
152 }
153
154 // Lowercase match mirrors WP's filesystem assumptions on
155 // case-insensitive volumes (macOS, Windows). The allowlist
156 // below is the final arbiter; this regex just pre-filters
157 // clearly bad inputs cheaply.
158 if ( ! preg_match( '/^[a-z0-9_-]+\.php$/i', $file ) ) {
159 return new WP_Error(
160 'openstation_invalid_target',
161 __( 'Admin target must be a plain .php filename.', 'desktop-mode' )
162 );
163 }
164
165 if ( $network ) {
166 return in_array( strtolower( $file ), openstation_network_admin_target_allowlist(), true )
167 ? network_admin_url( $file )
168 : new WP_Error(
169 'openstation_unknown_target',
170 __( 'Admin target does not exist.', 'desktop-mode' )
171 );
172 }
173
174 if ( ! in_array( strtolower( $file ), openstation_admin_target_allowlist(), true ) ) {
175 return new WP_Error(
176 'openstation_unknown_target',
177 __( 'Admin target does not exist.', 'desktop-mode' )
178 );
179 }
180
181 return admin_url( $file );
182 }
183
184 /**
185 * Canonical `wp-admin/network/` filenames a target may resolve to.
186 *
187 * The network admin's own screens, and only those: the site allowlist
188 * cannot stand in for it, since the two directories share filenames
189 * that mean different things (`users.php` is everyone on the network
190 * here, one site's users there).
191 *
192 * @return string[]
193 */
194 function openstation_network_admin_target_allowlist() {
195 return array(
196 'index.php',
197 'sites.php',
198 'site-new.php',
199 'site-info.php',
200 'site-users.php',
201 'site-themes.php',
202 'site-settings.php',
203 'users.php',
204 'user-new.php',
205 'themes.php',
206 'theme-install.php',
207 'plugins.php',
208 'plugin-install.php',
209 'plugin-editor.php',
210 'settings.php',
211 'setup.php',
212 'upgrade.php',
213 'update-core.php',
214 'about.php',
215 'credits.php',
216 'freedoms.php',
217 'privacy.php',
218 );
219 }
220
221 /**
222 * Returns the allowlist of canonical wp-admin top-level
223 * filenames that {@see openstation_resolve_admin_target()}
224 * accepts.
225 *
226 * Hardcoded rather than read from disk so the plugin doesn't
227 * depend on a particular WordPress install layout (and doesn't
228 * reference `ABSPATH` to probe core files). Plugins that ship
229 * their own top-level admin pages (rare) can extend the list
230 * via the filter.
231 *
232 * @return string[] Lowercased filenames including extension.
233 */
234 function openstation_admin_target_allowlist() {
235 $files = array(
236 'about.php',
237 'admin-ajax.php',
238 'admin-footer.php',
239 'admin-header.php',
240 'admin-post.php',
241 'admin.php',
242 'async-upload.php',
243 'authorize-application.php',
244 'comment.php',
245 'credits.php',
246 'custom-background.php',
247 'custom-header.php',
248 'customize.php',
249 'edit-comments.php',
250 'edit-form-advanced.php',
251 'edit-form-blocks.php',
252 'edit-form-comment.php',
253 'edit-link-form.php',
254 'edit-tag-form.php',
255 'edit-tags.php',
256 'edit.php',
257 'erase-personal-data.php',
258 'export-personal-data.php',
259 'export.php',
260 'freedoms.php',
261 'import.php',
262 'index.php',
263 'install.php',
264 'link-add.php',
265 'link-manager.php',
266 'link.php',
267 'load-scripts.php',
268 'load-styles.php',
269 'media-new.php',
270 'media-upload.php',
271 'media.php',
272 'menu-header.php',
273 'menu.php',
274 'moderation.php',
275 'ms-admin.php',
276 'ms-delete-site.php',
277 'ms-edit.php',
278 'ms-options.php',
279 'ms-sites.php',
280 'ms-themes.php',
281 'ms-upgrade-network.php',
282 'ms-users.php',
283 'my-sites.php',
284 'nav-menus.php',
285 'network.php',
286 'options-discussion.php',
287 'options-general.php',
288 'options-head.php',
289 'options-media.php',
290 'options-permalink.php',
291 'options-privacy.php',
292 'options-reading.php',
293 'options-writing.php',
294 'options.php',
295 'plugin-editor.php',
296 'plugin-install.php',
297 'plugins.php',
298 'post-new.php',
299 'post.php',
300 'press-this.php',
301 'privacy-policy-guide.php',
302 'privacy.php',
303 'profile.php',
304 'revision.php',
305 'setup-config.php',
306 'site-editor.php',
307 'site-health-info.php',
308 'site-health.php',
309 'sidebar.php',
310 'term.php',
311 'theme-editor.php',
312 'theme-install.php',
313 'themes.php',
314 'tools.php',
315 'update-core.php',
316 'update.php',
317 'upgrade.php',
318 'upload.php',
319 'user-edit.php',
320 'user-new.php',
321 'users.php',
322 'widgets.php',
323 );
324
325 /**
326 * Filters the wp-admin filename allowlist used when resolving
327 * portal `target=` query args.
328 *
329 * @param string[] $files Default allowlist.
330 */
331 $files = (array) apply_filters( 'openstation_admin_target_allowlist', $files );
332
333 return array_values( array_unique( array_map( 'strtolower', array_filter( $files, 'is_string' ) ) ) );
334 }
335
336 /**
337 * Checks whether the current request is a chromeless request.
338 *
339 * Chromeless requests are admin pages loaded inside openstation
340 * windows (iframes). They render only the page content without
341 * the admin shell (sidebar, admin bar, footer).
342 *
343 * @return bool True if this is a chromeless (iframe) request.
344 */
345 function openstation_is_chromeless_request() {
346 if ( ! openstation_is_enabled() ) {
347 // Only allow chromeless mode if the user actually has
348 // OpenStation enabled. Prevents stripping admin chrome via
349 // a bare `?openstation_chromeless=1` parameter from a
350 // logged-out URL.
351 return false;
352 }
353
354 // Primary signal — the explicit query flag the parent shell
355 // adds when opening windows.
356 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only request flag, no state change.
357 if ( ! empty( $_GET['openstation_chromeless'] ) && '1' === sanitize_text_field( wp_unslash( $_GET['openstation_chromeless'] ) ) ) {
358 return true;
359 }
360
361 // Fallback signal — the request is a same-origin iframe load.
362 // Modern browsers (Chrome 80+, Firefox 90+, Safari 16.4+) send
363 // the `Sec-Fetch-*` headers reliably, and they are immune to
364 // JavaScript spoofing (the browser sets them itself).
365 //
366 // This catches the failure mode where an internal admin
367 // navigation drops the `?openstation_chromeless=1` query flag —
368 // Gutenberg's `window.location` assignments, meta-refresh
369 // redirects, or any link the inline rewriter missed. The user
370 // is in an iframe on the same origin, has OpenStation enabled,
371 // so render as chromeless.
372 //
373 // `Sec-Fetch-Site: same-origin` is the cross-origin guard so a
374 // foreign site that iframes the wp-admin page can't trick us
375 // into stripping the chrome — the user agent reports the
376 // embedding context honestly.
377 $fetch_dest = isset( $_SERVER['HTTP_SEC_FETCH_DEST'] )
378 ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_SEC_FETCH_DEST'] ) )
379 : '';
380 $fetch_site = isset( $_SERVER['HTTP_SEC_FETCH_SITE'] )
381 ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_SEC_FETCH_SITE'] ) )
382 : '';
383 if ( 'iframe' === $fetch_dest && 'same-origin' === $fetch_site ) {
384 /**
385 * Filter the Sec-Fetch fallback. Return false to require an
386 * explicit `?openstation_chromeless=1` flag; useful for environments where a
387 * reverse proxy strips the `Sec-Fetch-*` headers and they
388 * can't be trusted.
389 *
390 * @param bool $allow Default true.
391 */
392 return (bool) apply_filters( 'openstation_chromeless_sec_fetch_fallback', true );
393 }
394
395 return false;
396 }
397
398 /**
399 * Checks whether the current request carries the "classic
400 * override" flag.
401 *
402 * The window-chrome "Detach" action opens an admin page in a new
403 * browser tab with `?desktop_mode_classic=1` so the user can view
404 * that one page outside the desktop shell without disabling
405 * OpenStation account-wide. The flag is a per-request override:
406 * `openstation_is_enabled()` still returns true (the user's
407 * preference hasn't changed), but the shell, shell assets, and
408 * body class are skipped for this request so the classic admin
409 * renders normally.
410 *
411 * Keep this separate from `openstation_is_enabled()` so the
412 * admin-bar toggle in the detached tab correctly reflects the
413 * account state — letting the user disable OpenStation entirely
414 * from the tab if they want to.
415 *
416 * @return bool True if the request carries `?desktop_mode_classic=1`.
417 */
418 function openstation_is_classic_request() {
419 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only request flag.
420 if ( empty( $_GET[ OPENSTATION_CLASSIC_FLAG ] ) ) {
421 return false;
422 }
423 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only request flag.
424 return '1' === sanitize_text_field( wp_unslash( $_GET[ OPENSTATION_CLASSIC_FLAG ] ) );
425 }
426
427 /**
428 * Checks whether the browser is fetching this request as a
429 * sub-resource of some page rather than navigating to it.
430 *
431 * Admin URLs serve more than pages. Jetpack's admin-bar sparkline is
432 * an `<img>` whose src is `admin.php?page=stats&noheader&proxy&chart=…`:
433 * core's `admin.php` skips the header on `noheader` and the page hook
434 * echoes PNG bytes. The Jetpack Stats screen loads its report body the
435 * same way, over XHR. Treating such a request as "a user landing on a
436 * plain admin page" and forwarding it into the desktop hands the
437 * consumer an HTML document instead: the admin bar then draws a broken
438 * image with the alt text where the chart should be.
439 *
440 * `Sec-Fetch-Mode` is the browser's own answer, set by the user agent
441 * and immune to script. `navigate` is a document or frame load, the
442 * only kind of request worth forwarding into the desktop; `cors`,
443 * `no-cors`, `same-origin` and `websocket` are sub-resource fetches.
444 * A missing header (an old browser, a proxy that strips it) answers
445 * false: not known to be a sub-resource, so callers keep behaving as
446 * they always did.
447 *
448 * @return bool True when the request is a sub-resource fetch.
449 */
450 function openstation_is_subresource_request() {
451 if ( empty( $_SERVER['HTTP_SEC_FETCH_MODE'] ) ) {
452 return false;
453 }
454 $mode = strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_SEC_FETCH_MODE'] ) ) );
455 return '' !== $mode && 'navigate' !== $mode;
456 }
457
458 /**
459 * Disables the admin bar on chromeless (iframe) requests.
460 *
461 * Hooked on the `show_admin_bar` filter so the front-end bar path
462 * also sees a false return. In admin, `is_admin_bar_showing()`
463 * short-circuits to true for any `is_admin()` request regardless
464 * of this filter, so the actual render is stopped by
465 * {@see openstation_chromeless_suppress_admin_bar()} below; this
466 * filter is kept for completeness + tests.
467 *
468 * @param bool $show Whether the admin bar should be shown.
469 * @return bool
470 */
471 function openstation_chromeless_hide_admin_bar( $show ) {
472 if ( openstation_is_chromeless_request() ) {
473 return false;
474 }
475 return $show;
476 }
477 add_filter( 'show_admin_bar', 'openstation_chromeless_hide_admin_bar' );
478
479 /**
480 * Suppresses the admin bar render inside chromeless iframes.
481 *
482 * `is_admin_bar_showing()` unconditionally returns true in admin
483 * context, so the `show_admin_bar` filter alone can't stop
484 * `wp_admin_bar_render()` from firing on `in_admin_header`. We
485 * detach the render action instead and let chromeless.css hide
486 * the `wp-toolbar` padding on `<html>`.
487 */
488 function openstation_chromeless_suppress_admin_bar() {
489 if ( openstation_is_chromeless_request() ) {
490 remove_action( 'in_admin_header', 'wp_admin_bar_render', 0 );
491 remove_action( 'wp_body_open', 'wp_admin_bar_render', 0 );
492 }
493 }
494 add_action( 'admin_init', 'openstation_chromeless_suppress_admin_bar' );
495
496 /**
497 * Stops a window from BUILDING the admin bar it never draws.
498 *
499 * Removing the render above stops the markup. It does not stop the
500 * work: `_wp_admin_bar_init()` is hooked on `admin_init`,
501 * `is_admin_bar_showing()` short-circuits to true for any admin
502 * request, and so every window still instantiates `WP_Admin_Bar`,
503 * calls `initialize()`, and — the expensive part — calls
504 * `add_menus()`, which fires `admin_bar_menu` and runs **every**
505 * registered callback. Core's twenty-odd nodes, WooCommerce's,
506 * Jetpack's, a host masterbar's: each one resolving links, counting
507 * things, checking capabilities. The finished object is then dropped
508 * on the floor, because nothing renders it.
509 *
510 * The shell draws a real admin bar, once. A window drawing none
511 * should pay for none — this is the same asymmetry the asset trims
512 * exploit, on the server side.
513 *
514 * **Swapping the class rather than unhooking the init** is the
515 * careful way to do it. `remove_action( 'admin_init',
516 * '_wp_admin_bar_init' )` would leave `$wp_admin_bar` null, and a
517 * plugin that touches the global outside the `admin_bar_menu` hook —
518 * bad practice, entirely real — would fatal on it. Core exposes
519 * `wp_admin_bar_class` precisely for this, so a window gets a real
520 * `WP_Admin_Bar` subclass that is fully functional in every respect
521 * except that it never solicits nodes. `add_node()` still works,
522 * `get_nodes()` still answers, the global is still an object; the
523 * hook simply never fires.
524 *
525 * `initialize()` is deliberately left alone — it sets up the object's
526 * own state and costs nothing worth reclaiming.
527 *
528 * @param string $class_name Admin bar class WordPress intends to instantiate.
529 * @return string The silent subclass inside a window; `$class_name` untouched
530 * everywhere else, and whenever the parent class is unavailable.
531 */
532 function openstation_chromeless_silence_admin_bar( $class_name ) {
533 if ( ! openstation_is_chromeless_request() ) {
534 return $class_name;
535 }
536
537 /**
538 * Filters whether a window skips building the admin bar.
539 *
540 * Return false to let a window construct the bar as WordPress
541 * normally would — for a plugin that (unusually) relies on
542 * `admin_bar_menu` firing for a side effect rather than for the
543 * node it adds.
544 *
545 * @param bool $silence Defaults to true inside windows.
546 */
547 if ( ! apply_filters( 'openstation_chromeless_silence_admin_bar', true ) ) {
548 return $class_name;
549 }
550
551 // `_wp_admin_bar_init()` requires `class-wp-admin-bar.php` before
552 // it applies this filter, so the parent is guaranteed loaded here
553 // — and only here, which is why the subclass is required lazily
554 // rather than at bootstrap.
555 if ( ! class_exists( 'WP_Admin_Bar' ) ) {
556 return $class_name;
557 }
558 require_once __DIR__ . '/class-openstation-silent-admin-bar.php';
559
560 return 'OpenStation_Silent_Admin_Bar';
561 }
562 add_filter( 'wp_admin_bar_class', 'openstation_chromeless_silence_admin_bar' );
563
564 /**
565 * Detaches core's update / maintenance nags inside chromeless iframes so
566 * they don't repeat in every window — the shell surfaces the update once
567 * instead.
568 */
569 function openstation_chromeless_suppress_update_nags() {
570 if ( ! openstation_is_chromeless_request() ) {
571 return;
572 }
573 remove_action( 'admin_notices', 'update_nag', 3 );
574 remove_action( 'network_admin_notices', 'update_nag', 3 );
575 remove_action( 'admin_notices', 'maintenance_nag', 10 );
576 remove_action( 'network_admin_notices', 'maintenance_nag', 10 );
577 }
578 add_action( 'admin_init', 'openstation_chromeless_suppress_update_nags' );
579
580 /**
581 * Detaches the remaining global core admin notices inside chromeless iframes
582 * so they don't repeat in every window — the shell re-derives and surfaces
583 * each once (see `openstation_get_core_notices()`). The update / maintenance
584 * nags are handled by `openstation_chromeless_suppress_update_nags()`.
585 */
586 function openstation_chromeless_suppress_core_notices() {
587 if ( ! openstation_is_chromeless_request() ) {
588 return;
589 }
590 remove_action( 'admin_notices', 'wp_recovery_mode_nag', 1 );
591 remove_action( 'admin_notices', 'default_password_nag' );
592 remove_action( 'admin_notices', 'deactivated_plugins_notice', 5 );
593 remove_action( 'admin_notices', 'paused_plugins_notice', 5 );
594 remove_action( 'admin_notices', 'paused_themes_notice', 5 );
595 }
596 add_action( 'admin_init', 'openstation_chromeless_suppress_core_notices' );
597
598 /**
599 * Keeps core's session-expired login modal (`wp-auth-check`) out of
600 * chromeless iframes so the parent shell owns the single prompt.
601 *
602 * Every chromeless iframe runs its own Heartbeat, and by default
603 * each one loads `wp-auth-check.js` + the `#wp-auth-check-wrap`
604 * markup. When the session expires, N open windows meant N stacked
605 * login modals — all asking for the same credentials. Returning
606 * false from `wp_auth_check_load` here stops the modal assets from
607 * ever loading inside iframes; the parent shell (a normal admin
608 * page) keeps its copy and surfaces the one prompt over the whole
609 * desktop.
610 *
611 * Detection is unaffected: the `wp-auth-check` heartbeat response
612 * field is attached server-side (core hooks `wp_auth_check()` on
613 * `heartbeat_send` / `heartbeat_nopriv_send`), so the bridge's
614 * stale-nonce recovery in `chromeless-bridge.php` still sees the
615 * logged-out → logged-in flip without the modal JS.
616 *
617 * @param bool $show Whether to load the authentication check.
618 * @return bool
619 */
620 function openstation_chromeless_suppress_auth_check( $show ) {
621 if ( openstation_is_chromeless_request() ) {
622 return false;
623 }
624 return $show;
625 }
626 add_filter( 'wp_auth_check_load', 'openstation_chromeless_suppress_auth_check' );
627
628 /**
629 * Preserves the `openstation_chromeless` flag through admin
630 * redirects.
631 *
632 * A chromeless iframe can be navigated away from chromeless mode
633 * by any redirect that drops the query string —
634 * `wp_redirect( admin_url( 'edit.php' ) )` after saving a
635 * classic-editor post is the canonical example. The client-side
636 * form interceptor handles the outgoing request, but the
637 * server-built redirect URL is what the browser follows.
638 * Re-append the flag here so the landing page stays chromeless
639 * and the window doesn't "break out" into a nested admin.
640 *
641 * Scope is intentionally narrow: only same-site admin URLs are
642 * touched, and only when the current request is itself
643 * chromeless. Anything else passes through unchanged.
644 *
645 * @param string $location The redirect URL.
646 * @return string The redirect URL, with `openstation_chromeless=1` appended when applicable.
647 */
648 function openstation_chromeless_preserve_redirect( $location ) {
649 if ( empty( $location ) || ! openstation_is_chromeless_request() ) {
650 return $location;
651 }
652
653 if ( ! openstation_is_admin_redirect_target( $location ) ) {
654 return $location;
655 }
656
657 // Don't double-append if the URL already carries the flag.
658 if ( false !== strpos( $location, 'openstation_chromeless=' ) ) {
659 return $location;
660 }
661
662 return add_query_arg( 'openstation_chromeless', '1', $location );
663 }
664 add_filter( 'wp_redirect', 'openstation_chromeless_preserve_redirect', 999 );
665
666 /**
667 * Preserves the `desktop_mode_classic` flag through admin
668 * redirects.
669 *
670 * The detached-tab workflow depends on the classic flag living
671 * on every same-tab navigation — otherwise a `wp_redirect()`
672 * after saving a post (for instance) would drop it and the very
673 * next page would fall back into the desktop shell. The JS
674 * interceptor stamps the flag onto every outbound link and form,
675 * but it can't touch server-built redirect URLs.
676 *
677 * Scope mirrors the chromeless preserver: only same-site
678 * wp-admin targets, only when the current request is itself a
679 * classic-override request, and the flag is never appended
680 * twice.
681 *
682 * @param string $location The redirect URL.
683 * @return string The redirect URL, with `desktop_mode_classic=1` appended when applicable.
684 */
685 function openstation_classic_preserve_redirect( $location ) {
686 if ( empty( $location ) || ! openstation_is_classic_request() ) {
687 return $location;
688 }
689
690 if ( ! openstation_is_admin_redirect_target( $location ) ) {
691 return $location;
692 }
693
694 if ( false !== strpos( $location, OPENSTATION_CLASSIC_FLAG . '=' ) ) {
695 return $location;
696 }
697
698 return add_query_arg( OPENSTATION_CLASSIC_FLAG, '1', $location );
699 }
700 add_filter( 'wp_redirect', 'openstation_classic_preserve_redirect', 999 );
701
702 /**
703 * Whether `$location` is a redirect target that lands inside
704 * wp-admin on the current site. Handles all four shapes WP core
705 * actually emits:
706 *
707 * - Absolute, same-host: `https://example.com/wp-admin/users.php?...`
708 * - Absolute path: `/wp-admin/users.php?...`
709 * - Relative to wp-admin: `users.php?update=add&id=42` (used by
710 * `user-new.php`, `edit-tags.php`, and
711 * quite a few other core admin scripts)
712 * - Same-host without path: `?paged=2`
713 *
714 * Off-site redirects (login → external SSO, e.g.) and frontend
715 * redirects (`/`, `/?p=42`) return false so we never paint our
716 * query flag on URLs that don't run our admin code.
717 *
718 * @internal
719 *
720 * @param string $location Raw redirect URL handed to `wp_redirect`.
721 * @return bool
722 */
723 function openstation_is_admin_redirect_target( $location ) {
724 $location = (string) $location;
725 if ( '' === $location ) {
726 return false;
727 }
728
729 $parts = wp_parse_url( $location );
730 if ( false === $parts ) {
731 return false;
732 }
733
734 // External host? Bail — we don't own that page.
735 if ( ! empty( $parts['host'] ) ) {
736 $site_host = wp_parse_url( site_url(), PHP_URL_HOST );
737 if ( $site_host && 0 !== strcasecmp( (string) $parts['host'], (string) $site_host ) ) {
738 return false;
739 }
740 }
741
742 $path = isset( $parts['path'] ) ? (string) $parts['path'] : '';
743
744 // Absolute path (URL-with-host or leading-slash variant).
745 if ( '' !== $path ) {
746 // `/wp-admin/foo.php` — definitive admin target.
747 if ( false !== strpos( $path, '/wp-admin/' ) ) {
748 return true;
749 }
750 // Absolute path NOT into wp-admin (e.g. `/`, `/wp-login.php`,
751 // `/wp-json/...`). Frontend or login flow — leave alone.
752 if ( '/' === $path[0] ) {
753 return false;
754 }
755 }
756
757 // Relative URL (or pure query string). Only safe to treat as
758 // an admin target when the redirect was issued from inside
759 // wp-admin — that's where wp_redirect( 'users.php?...' )
760 // actually resolves to /wp-admin/users.php?... at the
761 // browser. is_admin() is the canonical signal.
762 return is_admin();
763 }
764