PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.10
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.10
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
← All changes | includes/core/routing.php +194 -2 1.0.01.1.10 View file →
@@ -15,12 +15,14 @@
15 15 * always present by the time WordPress wants them.
16 16 *
17 17 * Functions in this file:
18 18 * - {@see openstation_url_is_same_admin()} — same-origin admin URL predicate
19 + * - {@see openstation_url_is_page_less_admin_php()} — "renders nothing" predicate
19 20 * - {@see openstation_resolve_admin_target()} — admin filename → URL resolver
20 21 * - {@see openstation_admin_target_allowlist()} — wp-admin filename allowlist
21 22 * - {@see openstation_is_chromeless_request()} — chromeless request detection
22 23 * - {@see openstation_is_classic_request()} — classic-override request detection
24 + * - {@see openstation_is_subresource_request()} — sub-resource fetch detection
23 25 * - {@see openstation_chromeless_hide_admin_bar()} — `show_admin_bar` filter
24 26 * - {@see openstation_chromeless_suppress_admin_bar()} — `admin_init` action
25 27 * - {@see openstation_chromeless_preserve_redirect()} — `wp_redirect` filter
26 28 * - {@see openstation_classic_preserve_redirect()} — `wp_redirect` filter
@@ -74,8 +76,52 @@
74 76 return 0 === strpos( $url_path, $admin_path );
75 77 }
76 78
77 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 +/**
78 124 * Resolves an admin-page filename (e.g. `edit.php`) to its
79 125 * absolute admin URL, allowlisted against the canonical set of
80 126 * wp-admin top-level filenames.
81 127 *
@@ -84,12 +130,13 @@
84 130 * exist in the static allowlist. A regex-only check would accept
85 131 * `custom_admin_page.php` if a plugin named something that way;
86 132 * the explicit allowlist closes that.
87 133 *
88 - * @param string $file Bare admin filename (no path, no query string).
134 + * @param string $file Bare admin filename (no path, no query string).
135 + * @param bool $network Resolve against the network admin's own screens.
89 136 * @return string|WP_Error Absolute admin URL on success, `WP_Error` otherwise.
90 137 */
91 -function openstation_resolve_admin_target( $file ) {
138 +function openstation_resolve_admin_target( $file, $network = false ) {
92 139 $file = is_string( $file ) ? trim( $file ) : '';
93 140 if ( '' === $file ) {
94 141 return new WP_Error(
95 142 'openstation_empty_target',
@@ -114,8 +161,17 @@
114 161 __( 'Admin target must be a plain .php filename.', 'desktop-mode' )
115 162 );
116 163 }
117 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 +
118 174 if ( ! in_array( strtolower( $file ), openstation_admin_target_allowlist(), true ) ) {
119 175 return new WP_Error(
120 176 'openstation_unknown_target',
121 177 __( 'Admin target does not exist.', 'desktop-mode' )
@@ -125,8 +181,45 @@
125 181 return admin_url( $file );
126 182 }
127 183
128 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 +/**
129 222 * Returns the allowlist of canonical wp-admin top-level
130 223 * filenames that {@see openstation_resolve_admin_target()}
131 224 * accepts.
132 225 *
@@ -331,8 +424,39 @@
331 424 return '1' === sanitize_text_field( wp_unslash( $_GET[ OPENSTATION_CLASSIC_FLAG ] ) );
332 425 }
333 426
334 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 +/**
335 459 * Disables the admin bar on chromeless (iframe) requests.
336 460 *
337 461 * Hooked on the `show_admin_bar` filter so the front-end bar path
338 462 * also sees a false return. In admin, `is_admin_bar_showing()`
@@ -367,8 +491,76 @@
367 491 remove_action( 'wp_body_open', 'wp_admin_bar_render', 0 );
368 492 }
369 493 }
370 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' );
371 563
372 564 /**
373 565 * Detaches core's update / maintenance nags inside chromeless iframes so
374 566 * they don't repeat in every window — the shell surfaces the update once