PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.13
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.13
1.1.13 1.1.12 1.1.11 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 All 37 releases
desktop-mode / includes / core / routing.php

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

784 lines 27.6 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 * Whether the request came from a classic-override page. A navigation
429 * a page starts from script (`location.href = …`, as "Edit with
430 * Elementor" does) carries no flag, but its Referer still does.
431 *
432 * @return bool
433 */
434 function openstation_is_classic_referer() {
435 if ( empty( $_SERVER['HTTP_REFERER'] ) ) {
436 return false;
437 }
438 $referer = esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) );
439 $query = wp_parse_url( $referer, PHP_URL_QUERY );
440 if ( ! openstation_url_is_same_admin( $referer ) || ! is_string( $query ) ) {
441 return false;
442 }
443 parse_str( $query, $args );
444 return isset( $args[ OPENSTATION_CLASSIC_FLAG ] ) && '1' === $args[ OPENSTATION_CLASSIC_FLAG ];
445 }
446
447 /**
448 * Checks whether the browser is fetching this request as a
449 * sub-resource of some page rather than navigating to it.
450 *
451 * Admin URLs serve more than pages. Jetpack's admin-bar sparkline is
452 * an `<img>` whose src is `admin.php?page=stats&noheader&proxy&chart=…`:
453 * core's `admin.php` skips the header on `noheader` and the page hook
454 * echoes PNG bytes. The Jetpack Stats screen loads its report body the
455 * same way, over XHR. Treating such a request as "a user landing on a
456 * plain admin page" and forwarding it into the desktop hands the
457 * consumer an HTML document instead: the admin bar then draws a broken
458 * image with the alt text where the chart should be.
459 *
460 * `Sec-Fetch-Mode` is the browser's own answer, set by the user agent
461 * and immune to script. `navigate` is a document or frame load, the
462 * only kind of request worth forwarding into the desktop; `cors`,
463 * `no-cors`, `same-origin` and `websocket` are sub-resource fetches.
464 * A missing header (an old browser, a proxy that strips it) answers
465 * false: not known to be a sub-resource, so callers keep behaving as
466 * they always did.
467 *
468 * @return bool True when the request is a sub-resource fetch.
469 */
470 function openstation_is_subresource_request() {
471 if ( empty( $_SERVER['HTTP_SEC_FETCH_MODE'] ) ) {
472 return false;
473 }
474 $mode = strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_SEC_FETCH_MODE'] ) ) );
475 return '' !== $mode && 'navigate' !== $mode;
476 }
477
478 /**
479 * Disables the admin bar on chromeless (iframe) requests.
480 *
481 * Hooked on the `show_admin_bar` filter so the front-end bar path
482 * also sees a false return. In admin, `is_admin_bar_showing()`
483 * short-circuits to true for any `is_admin()` request regardless
484 * of this filter, so the actual render is stopped by
485 * {@see openstation_chromeless_suppress_admin_bar()} below; this
486 * filter is kept for completeness + tests.
487 *
488 * @param bool $show Whether the admin bar should be shown.
489 * @return bool
490 */
491 function openstation_chromeless_hide_admin_bar( $show ) {
492 if ( openstation_is_chromeless_request() ) {
493 return false;
494 }
495 return $show;
496 }
497 add_filter( 'show_admin_bar', 'openstation_chromeless_hide_admin_bar' );
498
499 /**
500 * Suppresses the admin bar render inside chromeless iframes.
501 *
502 * `is_admin_bar_showing()` unconditionally returns true in admin
503 * context, so the `show_admin_bar` filter alone can't stop
504 * `wp_admin_bar_render()` from firing on `in_admin_header`. We
505 * detach the render action instead and let chromeless.css hide
506 * the `wp-toolbar` padding on `<html>`.
507 */
508 function openstation_chromeless_suppress_admin_bar() {
509 if ( openstation_is_chromeless_request() ) {
510 remove_action( 'in_admin_header', 'wp_admin_bar_render', 0 );
511 remove_action( 'wp_body_open', 'wp_admin_bar_render', 0 );
512 }
513 }
514 add_action( 'admin_init', 'openstation_chromeless_suppress_admin_bar' );
515
516 /**
517 * Stops a window from BUILDING the admin bar it never draws.
518 *
519 * Removing the render above stops the markup. It does not stop the
520 * work: `_wp_admin_bar_init()` is hooked on `admin_init`,
521 * `is_admin_bar_showing()` short-circuits to true for any admin
522 * request, and so every window still instantiates `WP_Admin_Bar`,
523 * calls `initialize()`, and — the expensive part — calls
524 * `add_menus()`, which fires `admin_bar_menu` and runs **every**
525 * registered callback. Core's twenty-odd nodes, WooCommerce's,
526 * Jetpack's, a host masterbar's: each one resolving links, counting
527 * things, checking capabilities. The finished object is then dropped
528 * on the floor, because nothing renders it.
529 *
530 * The shell draws a real admin bar, once. A window drawing none
531 * should pay for none — this is the same asymmetry the asset trims
532 * exploit, on the server side.
533 *
534 * **Swapping the class rather than unhooking the init** is the
535 * careful way to do it. `remove_action( 'admin_init',
536 * '_wp_admin_bar_init' )` would leave `$wp_admin_bar` null, and a
537 * plugin that touches the global outside the `admin_bar_menu` hook —
538 * bad practice, entirely real — would fatal on it. Core exposes
539 * `wp_admin_bar_class` precisely for this, so a window gets a real
540 * `WP_Admin_Bar` subclass that is fully functional in every respect
541 * except that it never solicits nodes. `add_node()` still works,
542 * `get_nodes()` still answers, the global is still an object; the
543 * hook simply never fires.
544 *
545 * `initialize()` is deliberately left alone — it sets up the object's
546 * own state and costs nothing worth reclaiming.
547 *
548 * @param string $class_name Admin bar class WordPress intends to instantiate.
549 * @return string The silent subclass inside a window; `$class_name` untouched
550 * everywhere else, and whenever the parent class is unavailable.
551 */
552 function openstation_chromeless_silence_admin_bar( $class_name ) {
553 if ( ! openstation_is_chromeless_request() ) {
554 return $class_name;
555 }
556
557 /**
558 * Filters whether a window skips building the admin bar.
559 *
560 * Return false to let a window construct the bar as WordPress
561 * normally would — for a plugin that (unusually) relies on
562 * `admin_bar_menu` firing for a side effect rather than for the
563 * node it adds.
564 *
565 * @param bool $silence Defaults to true inside windows.
566 */
567 if ( ! apply_filters( 'openstation_chromeless_silence_admin_bar', true ) ) {
568 return $class_name;
569 }
570
571 // `_wp_admin_bar_init()` requires `class-wp-admin-bar.php` before
572 // it applies this filter, so the parent is guaranteed loaded here
573 // — and only here, which is why the subclass is required lazily
574 // rather than at bootstrap.
575 if ( ! class_exists( 'WP_Admin_Bar' ) ) {
576 return $class_name;
577 }
578 require_once __DIR__ . '/class-openstation-silent-admin-bar.php';
579
580 return 'OpenStation_Silent_Admin_Bar';
581 }
582 add_filter( 'wp_admin_bar_class', 'openstation_chromeless_silence_admin_bar' );
583
584 /**
585 * Detaches core's update / maintenance nags inside chromeless iframes so
586 * they don't repeat in every window — the shell surfaces the update once
587 * instead.
588 */
589 function openstation_chromeless_suppress_update_nags() {
590 if ( ! openstation_is_chromeless_request() ) {
591 return;
592 }
593 remove_action( 'admin_notices', 'update_nag', 3 );
594 remove_action( 'network_admin_notices', 'update_nag', 3 );
595 remove_action( 'admin_notices', 'maintenance_nag', 10 );
596 remove_action( 'network_admin_notices', 'maintenance_nag', 10 );
597 }
598 add_action( 'admin_init', 'openstation_chromeless_suppress_update_nags' );
599
600 /**
601 * Detaches the remaining global core admin notices inside chromeless iframes
602 * so they don't repeat in every window — the shell re-derives and surfaces
603 * each once (see `openstation_get_core_notices()`). The update / maintenance
604 * nags are handled by `openstation_chromeless_suppress_update_nags()`.
605 */
606 function openstation_chromeless_suppress_core_notices() {
607 if ( ! openstation_is_chromeless_request() ) {
608 return;
609 }
610 remove_action( 'admin_notices', 'wp_recovery_mode_nag', 1 );
611 remove_action( 'admin_notices', 'default_password_nag' );
612 remove_action( 'admin_notices', 'deactivated_plugins_notice', 5 );
613 remove_action( 'admin_notices', 'paused_plugins_notice', 5 );
614 remove_action( 'admin_notices', 'paused_themes_notice', 5 );
615 }
616 add_action( 'admin_init', 'openstation_chromeless_suppress_core_notices' );
617
618 /**
619 * Keeps core's session-expired login modal (`wp-auth-check`) out of
620 * chromeless iframes so the parent shell owns the single prompt.
621 *
622 * Every chromeless iframe runs its own Heartbeat, and by default
623 * each one loads `wp-auth-check.js` + the `#wp-auth-check-wrap`
624 * markup. When the session expires, N open windows meant N stacked
625 * login modals — all asking for the same credentials. Returning
626 * false from `wp_auth_check_load` here stops the modal assets from
627 * ever loading inside iframes; the parent shell (a normal admin
628 * page) keeps its copy and surfaces the one prompt over the whole
629 * desktop.
630 *
631 * Detection is unaffected: the `wp-auth-check` heartbeat response
632 * field is attached server-side (core hooks `wp_auth_check()` on
633 * `heartbeat_send` / `heartbeat_nopriv_send`), so the bridge's
634 * stale-nonce recovery in `chromeless-bridge.php` still sees the
635 * logged-out → logged-in flip without the modal JS.
636 *
637 * @param bool $show Whether to load the authentication check.
638 * @return bool
639 */
640 function openstation_chromeless_suppress_auth_check( $show ) {
641 if ( openstation_is_chromeless_request() ) {
642 return false;
643 }
644 return $show;
645 }
646 add_filter( 'wp_auth_check_load', 'openstation_chromeless_suppress_auth_check' );
647
648 /**
649 * Preserves the `openstation_chromeless` flag through admin
650 * redirects.
651 *
652 * A chromeless iframe can be navigated away from chromeless mode
653 * by any redirect that drops the query string —
654 * `wp_redirect( admin_url( 'edit.php' ) )` after saving a
655 * classic-editor post is the canonical example. The client-side
656 * form interceptor handles the outgoing request, but the
657 * server-built redirect URL is what the browser follows.
658 * Re-append the flag here so the landing page stays chromeless
659 * and the window doesn't "break out" into a nested admin.
660 *
661 * Scope is intentionally narrow: only same-site admin URLs are
662 * touched, and only when the current request is itself
663 * chromeless. Anything else passes through unchanged.
664 *
665 * @param string $location The redirect URL.
666 * @return string The redirect URL, with `openstation_chromeless=1` appended when applicable.
667 */
668 function openstation_chromeless_preserve_redirect( $location ) {
669 if ( empty( $location ) || ! openstation_is_chromeless_request() ) {
670 return $location;
671 }
672
673 if ( ! openstation_is_admin_redirect_target( $location ) ) {
674 return $location;
675 }
676
677 // Don't double-append if the URL already carries the flag.
678 if ( false !== strpos( $location, 'openstation_chromeless=' ) ) {
679 return $location;
680 }
681
682 return add_query_arg( 'openstation_chromeless', '1', $location );
683 }
684 add_filter( 'wp_redirect', 'openstation_chromeless_preserve_redirect', 999 );
685
686 /**
687 * Preserves the `desktop_mode_classic` flag through admin
688 * redirects.
689 *
690 * The detached-tab workflow depends on the classic flag living
691 * on every same-tab navigation — otherwise a `wp_redirect()`
692 * after saving a post (for instance) would drop it and the very
693 * next page would fall back into the desktop shell. The JS
694 * interceptor stamps the flag onto every outbound link and form,
695 * but it can't touch server-built redirect URLs.
696 *
697 * Scope mirrors the chromeless preserver: only same-site
698 * wp-admin targets, only when the current request is itself a
699 * classic-override request, and the flag is never appended
700 * twice.
701 *
702 * @param string $location The redirect URL.
703 * @return string The redirect URL, with `desktop_mode_classic=1` appended when applicable.
704 */
705 function openstation_classic_preserve_redirect( $location ) {
706 if ( empty( $location ) || ! openstation_is_classic_request() ) {
707 return $location;
708 }
709
710 if ( ! openstation_is_admin_redirect_target( $location ) ) {
711 return $location;
712 }
713
714 if ( false !== strpos( $location, OPENSTATION_CLASSIC_FLAG . '=' ) ) {
715 return $location;
716 }
717
718 return add_query_arg( OPENSTATION_CLASSIC_FLAG, '1', $location );
719 }
720 add_filter( 'wp_redirect', 'openstation_classic_preserve_redirect', 999 );
721
722 /**
723 * Whether `$location` is a redirect target that lands inside
724 * wp-admin on the current site. Handles all four shapes WP core
725 * actually emits:
726 *
727 * - Absolute, same-host: `https://example.com/wp-admin/users.php?...`
728 * - Absolute path: `/wp-admin/users.php?...`
729 * - Relative to wp-admin: `users.php?update=add&id=42` (used by
730 * `user-new.php`, `edit-tags.php`, and
731 * quite a few other core admin scripts)
732 * - Same-host without path: `?paged=2`
733 *
734 * Off-site redirects (login → external SSO, e.g.) and frontend
735 * redirects (`/`, `/?p=42`) return false so we never paint our
736 * query flag on URLs that don't run our admin code.
737 *
738 * @internal
739 *
740 * @param string $location Raw redirect URL handed to `wp_redirect`.
741 * @return bool
742 */
743 function openstation_is_admin_redirect_target( $location ) {
744 $location = (string) $location;
745 if ( '' === $location ) {
746 return false;
747 }
748
749 $parts = wp_parse_url( $location );
750 if ( false === $parts ) {
751 return false;
752 }
753
754 // External host? Bail — we don't own that page.
755 if ( ! empty( $parts['host'] ) ) {
756 $site_host = wp_parse_url( site_url(), PHP_URL_HOST );
757 if ( $site_host && 0 !== strcasecmp( (string) $parts['host'], (string) $site_host ) ) {
758 return false;
759 }
760 }
761
762 $path = isset( $parts['path'] ) ? (string) $parts['path'] : '';
763
764 // Absolute path (URL-with-host or leading-slash variant).
765 if ( '' !== $path ) {
766 // `/wp-admin/foo.php` — definitive admin target.
767 if ( false !== strpos( $path, '/wp-admin/' ) ) {
768 return true;
769 }
770 // Absolute path NOT into wp-admin (e.g. `/`, `/wp-login.php`,
771 // `/wp-json/...`). Frontend or login flow — leave alone.
772 if ( '/' === $path[0] ) {
773 return false;
774 }
775 }
776
777 // Relative URL (or pure query string). Only safe to treat as
778 // an admin target when the redirect was issued from inside
779 // wp-admin — that's where wp_redirect( 'users.php?...' )
780 // actually resolves to /wp-admin/users.php?... at the
781 // browser. is_admin() is the canonical signal.
782 return is_admin();
783 }
784