| @@ -1,10 +1,10 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | /** |
| 3 | - * Desktop Mode — request routing helpers. | |
| 3 | + * OpenStation — request routing helpers. | |
| 4 | 4 | * |
| 5 | 5 | * Chromeless / classic admin-bar suppression and the |
| 6 | - * `wp_redirect` filter pair that re-stamps the desktop-mode | |
| 6 | + * `wp_redirect` filter pair that re-stamps the openstation | |
| 7 | 7 | * flags onto server-built redirects. Extracted from the |
| 8 | 8 | * 1,609-LOC `helpers.php` during the architecture-0.8.1 PHP |
| 9 | 9 | * slicing (phase 6). |
| 10 | 10 | * |
| @@ -14,21 +14,22 @@ | ||
| 14 | 14 | * file before `helpers.php`, so the function definitions are |
| 15 | 15 | * always present by the time WordPress wants them. |
| 16 | 16 | * |
| 17 | 17 | * Functions in this file: |
| 18 | - * - {@see desktop_mode_chromeless_hide_admin_bar()} — `show_admin_bar` filter | |
| 19 | - * - {@see desktop_mode_chromeless_suppress_admin_bar()} — `admin_init` action | |
| 20 | - * - {@see desktop_mode_chromeless_preserve_redirect()} — `wp_redirect` filter | |
| 21 | - * - {@see desktop_mode_classic_preserve_redirect()} — `wp_redirect` filter | |
| 22 | - * - {@see desktop_mode_is_admin_redirect_target()} — internal predicate | |
| 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 | |
| 23 | 30 | * |
| 24 | - * The chromeless / classic *request-detection* helpers | |
| 25 | - * (`desktop_mode_is_chromeless_request()`, | |
| 26 | - * `desktop_mode_is_classic_request()`) still live in | |
| 27 | - * `helpers.php` for now — moving them is the next phase-6 cut. | |
| 28 | - * | |
| 29 | - * @package Desktop_Mode | |
| 30 | - * @since 0.8.1 | |
| 31 | + * @package OpenStation | |
| 31 | 32 | */ |
| 32 | 33 | |
| 33 | 34 | defined( 'ABSPATH' ) || exit; |
| 34 | 35 | |
| @@ -42,14 +43,12 @@ | ||
| 42 | 43 | * |
| 43 | 44 | * An empty string returns false — a missing URL is never |
| 44 | 45 | * "same-origin admin" for the purposes of any caller. |
| 45 | 46 | * |
| 46 | - * @since 0.11.0 | |
| 47 | - * | |
| 48 | 47 | * @param string $url URL to test. |
| 49 | 48 | * @return bool |
| 50 | 49 | */ |
| 51 | -function desktop_mode_url_is_same_admin( $url ) { | |
| 50 | +function openstation_url_is_same_admin( $url ) { | |
| 52 | 51 | if ( ! is_string( $url ) || '' === $url ) { |
| 53 | 52 | return false; |
| 54 | 53 | } |
| 55 | 54 | |
| @@ -77,8 +76,52 @@ | ||
| 77 | 76 | return 0 === strpos( $url_path, $admin_path ); |
| 78 | 77 | } |
| 79 | 78 | |
| 80 | 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 | +/** | |
| 81 | 124 | * Resolves an admin-page filename (e.g. `edit.php`) to its |
| 82 | 125 | * absolute admin URL, allowlisted against the canonical set of |
| 83 | 126 | * wp-admin top-level filenames. |
| 84 | 127 | * |
| @@ -87,18 +130,17 @@ | ||
| 87 | 130 | * exist in the static allowlist. A regex-only check would accept |
| 88 | 131 | * `custom_admin_page.php` if a plugin named something that way; |
| 89 | 132 | * the explicit allowlist closes that. |
| 90 | 133 | * |
| 91 | - * @since 0.11.0 | |
| 92 | - * | |
| 93 | - * @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. | |
| 94 | 136 | * @return string|WP_Error Absolute admin URL on success, `WP_Error` otherwise. |
| 95 | 137 | */ |
| 96 | -function desktop_mode_resolve_admin_target( $file ) { | |
| 138 | +function openstation_resolve_admin_target( $file, $network = false ) { | |
| 97 | 139 | $file = is_string( $file ) ? trim( $file ) : ''; |
| 98 | 140 | if ( '' === $file ) { |
| 99 | 141 | return new WP_Error( |
| 100 | - 'desktop_mode_empty_target', | |
| 142 | + 'openstation_empty_target', | |
| 101 | 143 | __( 'Admin target cannot be empty.', 'desktop-mode' ) |
| 102 | 144 | ); |
| 103 | 145 | } |
| 104 | 146 | |
| @@ -103,9 +145,9 @@ | ||
| 103 | 145 | } |
| 104 | 146 | |
| 105 | 147 | if ( false !== strpos( $file, '..' ) || false !== strpos( $file, '/' ) || false !== strpos( $file, '\\' ) ) { |
| 106 | 148 | return new WP_Error( |
| 107 | - 'desktop_mode_invalid_target', | |
| 149 | + 'openstation_invalid_target', | |
| 108 | 150 | __( 'Admin target contains invalid path characters.', 'desktop-mode' ) |
| 109 | 151 | ); |
| 110 | 152 | } |
| 111 | 153 | |
| @@ -114,16 +156,25 @@ | ||
| 114 | 156 | // below is the final arbiter; this regex just pre-filters |
| 115 | 157 | // clearly bad inputs cheaply. |
| 116 | 158 | if ( ! preg_match( '/^[a-z0-9_-]+\.php$/i', $file ) ) { |
| 117 | 159 | return new WP_Error( |
| 118 | - 'desktop_mode_invalid_target', | |
| 160 | + 'openstation_invalid_target', | |
| 119 | 161 | __( 'Admin target must be a plain .php filename.', 'desktop-mode' ) |
| 120 | 162 | ); |
| 121 | 163 | } |
| 122 | 164 | |
| 123 | - if ( ! in_array( strtolower( $file ), desktop_mode_admin_target_allowlist(), true ) ) { | |
| 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 ) ) { | |
| 124 | 175 | return new WP_Error( |
| 125 | - 'desktop_mode_unknown_target', | |
| 176 | + 'openstation_unknown_target', | |
| 126 | 177 | __( 'Admin target does not exist.', 'desktop-mode' ) |
| 127 | 178 | ); |
| 128 | 179 | } |
| 129 | 180 | |
| @@ -130,10 +181,47 @@ | ||
| 130 | 181 | return admin_url( $file ); |
| 131 | 182 | } |
| 132 | 183 | |
| 133 | 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 | +/** | |
| 134 | 222 | * Returns the allowlist of canonical wp-admin top-level |
| 135 | - * filenames that {@see desktop_mode_resolve_admin_target()} | |
| 223 | + * filenames that {@see openstation_resolve_admin_target()} | |
| 136 | 224 | * accepts. |
| 137 | 225 | * |
| 138 | 226 | * Hardcoded rather than read from disk so the plugin doesn't |
| 139 | 227 | * depend on a particular WordPress install layout (and doesn't |
| @@ -140,13 +228,11 @@ | ||
| 140 | 228 | * reference `ABSPATH` to probe core files). Plugins that ship |
| 141 | 229 | * their own top-level admin pages (rare) can extend the list |
| 142 | 230 | * via the filter. |
| 143 | 231 | * |
| 144 | - * @since 0.6.2 | |
| 145 | - * | |
| 146 | 232 | * @return string[] Lowercased filenames including extension. |
| 147 | 233 | */ |
| 148 | -function desktop_mode_admin_target_allowlist() { | |
| 234 | +function openstation_admin_target_allowlist() { | |
| 149 | 235 | $files = array( |
| 150 | 236 | 'about.php', |
| 151 | 237 | 'admin-ajax.php', |
| 152 | 238 | 'admin-footer.php', |
| @@ -239,13 +325,11 @@ | ||
| 239 | 325 | /** |
| 240 | 326 | * Filters the wp-admin filename allowlist used when resolving |
| 241 | 327 | * portal `target=` query args. |
| 242 | 328 | * |
| 243 | - * @since 0.6.2 | |
| 244 | - * | |
| 245 | 329 | * @param string[] $files Default allowlist. |
| 246 | 330 | */ |
| 247 | - $files = (array) apply_filters( 'desktop_mode_admin_target_allowlist', $files ); | |
| 331 | + $files = (array) apply_filters( 'openstation_admin_target_allowlist', $files ); | |
| 248 | 332 | |
| 249 | 333 | return array_values( array_unique( array_map( 'strtolower', array_filter( $files, 'is_string' ) ) ) ); |
| 250 | 334 | } |
| 251 | 335 | |
| @@ -251,21 +335,19 @@ | ||
| 251 | 335 | |
| 252 | 336 | /** |
| 253 | 337 | * Checks whether the current request is a chromeless request. |
| 254 | 338 | * |
| 255 | - * Chromeless requests are admin pages loaded inside desktop-mode | |
| 339 | + * Chromeless requests are admin pages loaded inside openstation | |
| 256 | 340 | * windows (iframes). They render only the page content without |
| 257 | 341 | * the admin shell (sidebar, admin bar, footer). |
| 258 | 342 | * |
| 259 | - * @since 0.1.0 | |
| 260 | - * | |
| 261 | 343 | * @return bool True if this is a chromeless (iframe) request. |
| 262 | 344 | */ |
| 263 | -function desktop_mode_is_chromeless_request() { | |
| 264 | - if ( ! desktop_mode_is_enabled() ) { | |
| 345 | +function openstation_is_chromeless_request() { | |
| 346 | + if ( ! openstation_is_enabled() ) { | |
| 265 | 347 | // Only allow chromeless mode if the user actually has |
| 266 | - // desktop mode enabled. Prevents stripping admin chrome via | |
| 267 | - // a bare `?desktop_mode_chromeless=1` parameter from a | |
| 348 | + // OpenStation enabled. Prevents stripping admin chrome via | |
| 349 | + // a bare `?openstation_chromeless=1` parameter from a | |
| 268 | 350 | // logged-out URL. |
| 269 | 351 | return false; |
| 270 | 352 | } |
| 271 | 353 | |
| @@ -271,9 +353,9 @@ | ||
| 271 | 353 | |
| 272 | 354 | // Primary signal — the explicit query flag the parent shell |
| 273 | 355 | // adds when opening windows. |
| 274 | 356 | // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only request flag, no state change. |
| 275 | - if ( ! empty( $_GET['desktop_mode_chromeless'] ) && '1' === sanitize_text_field( wp_unslash( $_GET['desktop_mode_chromeless'] ) ) ) { | |
| 357 | + if ( ! empty( $_GET['openstation_chromeless'] ) && '1' === sanitize_text_field( wp_unslash( $_GET['openstation_chromeless'] ) ) ) { | |
| 276 | 358 | return true; |
| 277 | 359 | } |
| 278 | 360 | |
| 279 | 361 | // Fallback signal — the request is a same-origin iframe load. |
| @@ -281,12 +363,12 @@ | ||
| 281 | 363 | // the `Sec-Fetch-*` headers reliably, and they are immune to |
| 282 | 364 | // JavaScript spoofing (the browser sets them itself). |
| 283 | 365 | // |
| 284 | 366 | // This catches the failure mode where an internal admin |
| 285 | - // navigation drops the `?desktop_mode_chromeless=1` query flag — | |
| 367 | + // navigation drops the `?openstation_chromeless=1` query flag — | |
| 286 | 368 | // Gutenberg's `window.location` assignments, meta-refresh |
| 287 | 369 | // redirects, or any link the inline rewriter missed. The user |
| 288 | - // is in an iframe on the same origin, has desktop mode enabled, | |
| 370 | + // is in an iframe on the same origin, has OpenStation enabled, | |
| 289 | 371 | // so render as chromeless. |
| 290 | 372 | // |
| 291 | 373 | // `Sec-Fetch-Site: same-origin` is the cross-origin guard so a |
| 292 | 374 | // foreign site that iframes the wp-admin page can't trick us |
| @@ -300,18 +382,15 @@ | ||
| 300 | 382 | : ''; |
| 301 | 383 | if ( 'iframe' === $fetch_dest && 'same-origin' === $fetch_site ) { |
| 302 | 384 | /** |
| 303 | 385 | * Filter the Sec-Fetch fallback. Return false to require an |
| 304 | - * explicit `?desktop_mode_chromeless=1` flag (matches | |
| 305 | - * pre-0.18 behaviour); useful for environments where a | |
| 386 | + * explicit `?openstation_chromeless=1` flag; useful for environments where a | |
| 306 | 387 | * reverse proxy strips the `Sec-Fetch-*` headers and they |
| 307 | 388 | * can't be trusted. |
| 308 | 389 | * |
| 309 | - * @since 0.18.0 | |
| 310 | - * | |
| 311 | 390 | * @param bool $allow Default true. |
| 312 | 391 | */ |
| 313 | - return (bool) apply_filters( 'desktop_mode_chromeless_sec_fetch_fallback', true ); | |
| 392 | + return (bool) apply_filters( 'openstation_chromeless_sec_fetch_fallback', true ); | |
| 314 | 393 | } |
| 315 | 394 | |
| 316 | 395 | return false; |
| 317 | 396 | } |
| @@ -322,33 +401,62 @@ | ||
| 322 | 401 | * |
| 323 | 402 | * The window-chrome "Detach" action opens an admin page in a new |
| 324 | 403 | * browser tab with `?desktop_mode_classic=1` so the user can view |
| 325 | 404 | * that one page outside the desktop shell without disabling |
| 326 | - * desktop mode account-wide. The flag is a per-request override: | |
| 327 | - * `desktop_mode_is_enabled()` still returns true (the user's | |
| 405 | + * OpenStation account-wide. The flag is a per-request override: | |
| 406 | + * `openstation_is_enabled()` still returns true (the user's | |
| 328 | 407 | * preference hasn't changed), but the shell, shell assets, and |
| 329 | 408 | * body class are skipped for this request so the classic admin |
| 330 | 409 | * renders normally. |
| 331 | 410 | * |
| 332 | - * Keep this separate from `desktop_mode_is_enabled()` so the | |
| 411 | + * Keep this separate from `openstation_is_enabled()` so the | |
| 333 | 412 | * admin-bar toggle in the detached tab correctly reflects the |
| 334 | - * account state — letting the user disable desktop mode entirely | |
| 413 | + * account state — letting the user disable OpenStation entirely | |
| 335 | 414 | * from the tab if they want to. |
| 336 | 415 | * |
| 337 | - * @since 0.4.0 | |
| 338 | - * | |
| 339 | 416 | * @return bool True if the request carries `?desktop_mode_classic=1`. |
| 340 | 417 | */ |
| 341 | -function desktop_mode_is_classic_request() { | |
| 418 | +function openstation_is_classic_request() { | |
| 342 | 419 | // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only request flag. |
| 343 | - if ( empty( $_GET[ DESKTOP_MODE_CLASSIC_FLAG ] ) ) { | |
| 420 | + if ( empty( $_GET[ OPENSTATION_CLASSIC_FLAG ] ) ) { | |
| 344 | 421 | return false; |
| 345 | 422 | } |
| 346 | 423 | // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only request flag. |
| 347 | - return '1' === sanitize_text_field( wp_unslash( $_GET[ DESKTOP_MODE_CLASSIC_FLAG ] ) ); | |
| 424 | + return '1' === sanitize_text_field( wp_unslash( $_GET[ OPENSTATION_CLASSIC_FLAG ] ) ); | |
| 348 | 425 | } |
| 349 | 426 | |
| 350 | 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 | +/** | |
| 351 | 459 | * Disables the admin bar on chromeless (iframe) requests. |
| 352 | 460 | * |
| 353 | 461 | * Hooked on the `show_admin_bar` filter so the front-end bar path |
| 354 | 462 | * also sees a false return. In admin, `is_admin_bar_showing()` |
| @@ -353,23 +461,21 @@ | ||
| 353 | 461 | * Hooked on the `show_admin_bar` filter so the front-end bar path |
| 354 | 462 | * also sees a false return. In admin, `is_admin_bar_showing()` |
| 355 | 463 | * short-circuits to true for any `is_admin()` request regardless |
| 356 | 464 | * of this filter, so the actual render is stopped by |
| 357 | - * {@see desktop_mode_chromeless_suppress_admin_bar()} below; this | |
| 465 | + * {@see openstation_chromeless_suppress_admin_bar()} below; this | |
| 358 | 466 | * filter is kept for completeness + tests. |
| 359 | 467 | * |
| 360 | - * @since 0.1.0 | |
| 361 | - * | |
| 362 | 468 | * @param bool $show Whether the admin bar should be shown. |
| 363 | 469 | * @return bool |
| 364 | 470 | */ |
| 365 | -function desktop_mode_chromeless_hide_admin_bar( $show ) { | |
| 366 | - if ( desktop_mode_is_chromeless_request() ) { | |
| 471 | +function openstation_chromeless_hide_admin_bar( $show ) { | |
| 472 | + if ( openstation_is_chromeless_request() ) { | |
| 367 | 473 | return false; |
| 368 | 474 | } |
| 369 | 475 | return $show; |
| 370 | 476 | } |
| 371 | -add_filter( 'show_admin_bar', 'desktop_mode_chromeless_hide_admin_bar' ); | |
| 477 | +add_filter( 'show_admin_bar', 'openstation_chromeless_hide_admin_bar' ); | |
| 372 | 478 | |
| 373 | 479 | /** |
| 374 | 480 | * Suppresses the admin bar render inside chromeless iframes. |
| 375 | 481 | * |
| @@ -377,21 +483,151 @@ | ||
| 377 | 483 | * context, so the `show_admin_bar` filter alone can't stop |
| 378 | 484 | * `wp_admin_bar_render()` from firing on `in_admin_header`. We |
| 379 | 485 | * detach the render action instead and let chromeless.css hide |
| 380 | 486 | * the `wp-toolbar` padding on `<html>`. |
| 381 | - * | |
| 382 | - * @since 0.1.0 | |
| 383 | 487 | */ |
| 384 | -function desktop_mode_chromeless_suppress_admin_bar() { | |
| 385 | - if ( desktop_mode_is_chromeless_request() ) { | |
| 488 | +function openstation_chromeless_suppress_admin_bar() { | |
| 489 | + if ( openstation_is_chromeless_request() ) { | |
| 386 | 490 | remove_action( 'in_admin_header', 'wp_admin_bar_render', 0 ); |
| 387 | 491 | remove_action( 'wp_body_open', 'wp_admin_bar_render', 0 ); |
| 388 | 492 | } |
| 389 | 493 | } |
| 390 | -add_action( 'admin_init', 'desktop_mode_chromeless_suppress_admin_bar' ); | |
| 494 | +add_action( 'admin_init', 'openstation_chromeless_suppress_admin_bar' ); | |
| 391 | 495 | |
| 392 | 496 | /** |
| 393 | - * Preserves the `desktop_mode_chromeless` flag through admin | |
| 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 | |
| 394 | 630 | * redirects. |
| 395 | 631 | * |
| 396 | 632 | * A chromeless iframe can be navigated away from chromeless mode |
| 397 | 633 | * by any redirect that drops the query string — |
| @@ -405,30 +641,28 @@ | ||
| 405 | 641 | * Scope is intentionally narrow: only same-site admin URLs are |
| 406 | 642 | * touched, and only when the current request is itself |
| 407 | 643 | * chromeless. Anything else passes through unchanged. |
| 408 | 644 | * |
| 409 | - * @since 0.1.0 | |
| 410 | - * | |
| 411 | 645 | * @param string $location The redirect URL. |
| 412 | - * @return string The redirect URL, with `desktop_mode_chromeless=1` appended when applicable. | |
| 646 | + * @return string The redirect URL, with `openstation_chromeless=1` appended when applicable. | |
| 413 | 647 | */ |
| 414 | -function desktop_mode_chromeless_preserve_redirect( $location ) { | |
| 415 | - if ( empty( $location ) || ! desktop_mode_is_chromeless_request() ) { | |
| 648 | +function openstation_chromeless_preserve_redirect( $location ) { | |
| 649 | + if ( empty( $location ) || ! openstation_is_chromeless_request() ) { | |
| 416 | 650 | return $location; |
| 417 | 651 | } |
| 418 | 652 | |
| 419 | - if ( ! desktop_mode_is_admin_redirect_target( $location ) ) { | |
| 653 | + if ( ! openstation_is_admin_redirect_target( $location ) ) { | |
| 420 | 654 | return $location; |
| 421 | 655 | } |
| 422 | 656 | |
| 423 | 657 | // Don't double-append if the URL already carries the flag. |
| 424 | - if ( false !== strpos( $location, 'desktop_mode_chromeless=' ) ) { | |
| 658 | + if ( false !== strpos( $location, 'openstation_chromeless=' ) ) { | |
| 425 | 659 | return $location; |
| 426 | 660 | } |
| 427 | 661 | |
| 428 | - return add_query_arg( 'desktop_mode_chromeless', '1', $location ); | |
| 662 | + return add_query_arg( 'openstation_chromeless', '1', $location ); | |
| 429 | 663 | } |
| 430 | -add_filter( 'wp_redirect', 'desktop_mode_chromeless_preserve_redirect', 999 ); | |
| 664 | +add_filter( 'wp_redirect', 'openstation_chromeless_preserve_redirect', 999 ); | |
| 431 | 665 | |
| 432 | 666 | /** |
| 433 | 667 | * Preserves the `desktop_mode_classic` flag through admin |
| 434 | 668 | * redirects. |
| @@ -444,29 +678,27 @@ | ||
| 444 | 678 | * wp-admin targets, only when the current request is itself a |
| 445 | 679 | * classic-override request, and the flag is never appended |
| 446 | 680 | * twice. |
| 447 | 681 | * |
| 448 | - * @since 0.4.0 | |
| 449 | - * | |
| 450 | 682 | * @param string $location The redirect URL. |
| 451 | 683 | * @return string The redirect URL, with `desktop_mode_classic=1` appended when applicable. |
| 452 | 684 | */ |
| 453 | -function desktop_mode_classic_preserve_redirect( $location ) { | |
| 454 | - if ( empty( $location ) || ! desktop_mode_is_classic_request() ) { | |
| 685 | +function openstation_classic_preserve_redirect( $location ) { | |
| 686 | + if ( empty( $location ) || ! openstation_is_classic_request() ) { | |
| 455 | 687 | return $location; |
| 456 | 688 | } |
| 457 | 689 | |
| 458 | - if ( ! desktop_mode_is_admin_redirect_target( $location ) ) { | |
| 690 | + if ( ! openstation_is_admin_redirect_target( $location ) ) { | |
| 459 | 691 | return $location; |
| 460 | 692 | } |
| 461 | 693 | |
| 462 | - if ( false !== strpos( $location, DESKTOP_MODE_CLASSIC_FLAG . '=' ) ) { | |
| 694 | + if ( false !== strpos( $location, OPENSTATION_CLASSIC_FLAG . '=' ) ) { | |
| 463 | 695 | return $location; |
| 464 | 696 | } |
| 465 | 697 | |
| 466 | - return add_query_arg( DESKTOP_MODE_CLASSIC_FLAG, '1', $location ); | |
| 698 | + return add_query_arg( OPENSTATION_CLASSIC_FLAG, '1', $location ); | |
| 467 | 699 | } |
| 468 | -add_filter( 'wp_redirect', 'desktop_mode_classic_preserve_redirect', 999 ); | |
| 700 | +add_filter( 'wp_redirect', 'openstation_classic_preserve_redirect', 999 ); | |
| 469 | 701 | |
| 470 | 702 | /** |
| 471 | 703 | * Whether `$location` is a redirect target that lands inside |
| 472 | 704 | * wp-admin on the current site. Handles all four shapes WP core |
| @@ -482,16 +714,14 @@ | ||
| 482 | 714 | * Off-site redirects (login → external SSO, e.g.) and frontend |
| 483 | 715 | * redirects (`/`, `/?p=42`) return false so we never paint our |
| 484 | 716 | * query flag on URLs that don't run our admin code. |
| 485 | 717 | * |
| 486 | - * @since 0.8.0 | |
| 487 | - * | |
| 488 | 718 | * @internal |
| 489 | 719 | * |
| 490 | 720 | * @param string $location Raw redirect URL handed to `wp_redirect`. |
| 491 | 721 | * @return bool |
| 492 | 722 | */ |
| 493 | -function desktop_mode_is_admin_redirect_target( $location ) { | |
| 723 | +function openstation_is_admin_redirect_target( $location ) { | |
| 494 | 724 | $location = (string) $location; |
| 495 | 725 | if ( '' === $location ) { |
| 496 | 726 | return false; |
| 497 | 727 | } |
| @@ -518,9 +748,9 @@ | ||
| 518 | 748 | return true; |
| 519 | 749 | } |
| 520 | 750 | // Absolute path NOT into wp-admin (e.g. `/`, `/wp-login.php`, |
| 521 | 751 | // `/wp-json/...`). Frontend or login flow — leave alone. |
| 522 | - if ( '/' === $path[ 0 ] ) { | |
| 752 | + if ( '/' === $path[0] ) { | |
| 523 | 753 | return false; |
| 524 | 754 | } |
| 525 | 755 | } |
| 526 | 756 | |