diff --git a/src/js/entries/admin-bar.ts b/src/js/entries/admin-bar.ts index c7b427893..306bfad37 100644 --- a/src/js/entries/admin-bar.ts +++ b/src/js/entries/admin-bar.ts @@ -74,6 +74,12 @@ const buildRequestUrl = (status: PaginationStatus, page: number): string => { url.searchParams.set('network', '1') } + const safeMode = new URLSearchParams(window.location.search).get('snippets-safe-mode') + + if (safeMode) { + url.searchParams.set('snippets-safe-mode', safeMode) + } + for (const excluded of config.excludeTypes) { url.searchParams.append('exclude_types[]', excluded) } diff --git a/src/js/hooks/useRestAPI.tsx b/src/js/hooks/useRestAPI.tsx index 6730bc428..33fb8eb67 100644 --- a/src/js/hooks/useRestAPI.tsx +++ b/src/js/hooks/useRestAPI.tsx @@ -1,7 +1,7 @@ import React, { useMemo } from 'react' import axios from 'axios' import { createContextHook } from '../utils/bootstrap' -import { REST_API_AXIOS_CONFIG, applyMethodOverride, applyRestNonce, listenForNonceRefresh } from '../utils/restAPI' +import { REST_API_AXIOS_CONFIG, applyMethodOverride, applyRestNonce, applySafeMode, listenForNonceRefresh } from '../utils/restAPI' import type { PropsWithChildren } from 'react' import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' @@ -62,6 +62,7 @@ export const WithRestAPIContext: React.FC = ({ children }) => const instance = axios.create(REST_API_AXIOS_CONFIG) instance.interceptors.request.use(applyRestNonce) instance.interceptors.request.use(applyMethodOverride) + instance.interceptors.request.use(applySafeMode) listenForNonceRefresh() return instance }, []) diff --git a/src/js/utils/restAPI.ts b/src/js/utils/restAPI.ts index 4600d679f..f006497ca 100644 --- a/src/js/utils/restAPI.ts +++ b/src/js/utils/restAPI.ts @@ -132,6 +132,24 @@ export const addQueryArg = ({ url, name, value }: QueryArg): string => { return parsed.toString() } +/** + * Carry the per-page safe mode flag on to REST requests. + * + * The server cannot add the flag to the localized REST URLs, as with pretty + * permalinks it would bury the route in the query string. Without it, the + * request that deactivates or deletes a broken snippet would still run that + * snippet, and fail with it. + */ +export const applySafeMode = (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => { + const safeMode = new URLSearchParams(window.location.search).get('snippets-safe-mode') + + if (safeMode && config.url) { + config.url = addQueryArg({ url: config.url, name: 'snippets-safe-mode', value: safeMode }) + } + + return config +} + /** * A WordPress core REST route (`wp/v2/…`) with its query arguments, built so it * works whether or not the site has pretty permalinks: without them the REST diff --git a/src/php/Integration/Evaluate_Functions.php b/src/php/Integration/Evaluate_Functions.php index 9034ab062..b77a10343 100644 --- a/src/php/Integration/Evaluate_Functions.php +++ b/src/php/Integration/Evaluate_Functions.php @@ -42,7 +42,7 @@ public function __construct( DB $db ) { // The capability is checked in the callback instead, which never runs // before URLs are being generated. if ( $this->is_safe_mode_query_var_set() ) { - add_filter( 'home_url', [ $this, 'add_safe_mode_query_var' ] ); + add_filter( 'home_url', [ $this, 'add_safe_mode_query_var_to_home_url' ], 10, 3 ); add_filter( 'admin_url', [ $this, 'add_safe_mode_query_var' ] ); } } @@ -86,6 +86,28 @@ public function add_safe_mode_query_var( $url ): string { $url; } + /** + * Inject the safe mode query var into home URLs, leaving REST API URLs alone. + * + * REST URLs are built on the home URL, so a query string added here would end + * up in front of the route, or of index.php with plain permalinks, and REST + * requests would miss their endpoint. The admin scripts carry safe mode on to + * their REST requests themselves. + * + * @param mixed $url Original URL, from an unknown earlier callback. + * @param mixed $path Path relative to the home URL. + * @param mixed $orig_scheme Scheme requested for the URL, 'rest' when built by rest_url(). + * + * @return string Modified URL. + */ + public function add_safe_mode_query_var_to_home_url( $url, $path = '', $orig_scheme = null ): string { + if ( 'rest' === $orig_scheme ) { + return is_string( $url ) ? $url : ''; + } + + return $this->add_safe_mode_query_var( $url ); + } + /** * Retrieve details about the currently edited snippet, if any. * diff --git a/tests/unit/Integration/Evaluate_Functions_Safe_Mode_Test.php b/tests/unit/Integration/Evaluate_Functions_Safe_Mode_Test.php index 1f86df8ac..211b26ba6 100644 --- a/tests/unit/Integration/Evaluate_Functions_Safe_Mode_Test.php +++ b/tests/unit/Integration/Evaluate_Functions_Safe_Mode_Test.php @@ -66,6 +66,59 @@ public function test_constructing_with_the_query_var_set_is_not_fatal(): void { $this->assertSame( 10, has_filter( 'admin_url', [ $evaluate, 'add_safe_mode_query_var' ] ) ); } + /** + * REST API URLs are left unchanged when safe mode is requested. + * + * The route, or index.php with plain permalinks, is appended to the home URL, + * so a query var added there would end up in front of it and break every request. + * + * @dataProvider data_permalink_structures + * + * @param string $structure Permalink structure. + * + * @return void + */ + public function test_rest_urls_are_unchanged_in_safe_mode( string $structure ): void { + $_REQUEST['snippets-safe-mode'] = '1'; + wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); + $this->set_permalink_structure( $structure ); + $expected = rest_url( 'code-snippets/v1/snippets/5' ); + + new Evaluate_Functions( code_snippets()->db ); + + $this->assertSame( $expected, rest_url( 'code-snippets/v1/snippets/5' ) ); + } + + /** + * Permalink structures, covering each way rest_url() builds a URL. + * + * @return array + */ + public function data_permalink_structures(): array { + return [ + 'plain permalinks' => [ '' ], + 'pretty permalinks' => [ '/%postname%/' ], + 'index permalinks' => [ '/index.php/%postname%/' ], + ]; + } + + /** + * Other site and admin URLs still carry the safe mode query var. + * + * @return void + */ + public function test_site_and_admin_urls_carry_the_safe_mode_query_var(): void { + $_REQUEST['snippets-safe-mode'] = '1'; + wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); + $this->set_permalink_structure( '/%postname%/' ); + + new Evaluate_Functions( code_snippets()->db ); + + $this->assertStringContainsString( 'snippets-safe-mode=1', home_url( '/sample-page/' ) ); + $this->assertStringContainsString( 'snippets-safe-mode=1', home_url( '/guides/' . rest_get_url_prefix() . '/help/' ) ); + $this->assertStringContainsString( 'snippets-safe-mode=1', admin_url( 'admin.php?page=snippets' ) ); + } + /** * The URL callback tolerates a non-string from an earlier callback. * @@ -75,6 +128,7 @@ public function test_url_callback_tolerates_a_null_from_an_earlier_callback(): v $evaluate = new Evaluate_Functions( code_snippets()->db ); $this->assertIsString( $evaluate->add_safe_mode_query_var( null ) ); + $this->assertIsString( $evaluate->add_safe_mode_query_var_to_home_url( null, null, 'rest' ) ); } /**