PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.4
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.4
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 / core / routing.php

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

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