From 51d7437164bdf9851dc3efe7ec30e81caea7bea1 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 17 Sep 2025 10:40:04 -0400 Subject: [PATCH 01/31] feat: Authorize AJAX with application passwords Include authorization header in AJAX requets, as we do not have cookies to send in the mobile app environment. --- src/utils/ajax.js | 77 ++++++++++++++++++++ src/utils/editor-environment.js | 4 +- src/utils/videopress-bridge.js | 124 -------------------------------- 3 files changed, 79 insertions(+), 126 deletions(-) create mode 100644 src/utils/ajax.js delete mode 100644 src/utils/videopress-bridge.js diff --git a/src/utils/ajax.js b/src/utils/ajax.js new file mode 100644 index 000000000..8d9bd76db --- /dev/null +++ b/src/utils/ajax.js @@ -0,0 +1,77 @@ +/** + * Internal dependencies + */ +import { getGBKit } from './bridge'; +import { warn, debug } from './logger'; + +/** + * GutenbergKit lacks authentication cookies required for AJAX requests. + * This configures a root URL and authentication header for AJAX requests. + * + * @return {void} + */ +export function initializeAjax() { + window.wp = window.wp || {}; + window.wp.ajax = window.wp.ajax || {}; + window.wp.ajax.settings = window.wp.ajax.settings || {}; + + const { siteURL, authHeader } = getGBKit(); + configureAjaxUrl( siteURL ); + configureAjaxAuth( authHeader ); +} + +function configureAjaxUrl( siteURL ) { + if ( ! siteURL ) { + warn( 'Unable to configure AJAX URL without siteURL' ); + return; + } + + window.wp.ajax.settings.url = `${ siteURL }/wp-admin/admin-ajax.php`; + + debug( 'AJAX URL configured' ); +} + +function configureAjaxAuth( authHeader ) { + if ( ! authHeader ) { + warn( 'Unable to configure AJAX auth without authHeader' ); + return; + } + + window.jQuery?.ajaxSetup( { + headers: { + Authorization: authHeader, + }, + } ); + + const originalSend = window.wp.ajax.send; + window.wp.ajax.send = function ( options ) { + const originalBeforeSend = options.beforeSend; + + options.beforeSend = function ( xhr ) { + xhr.setRequestHeader( 'Authorization', authHeader ); + + if ( typeof originalBeforeSend === 'function' ) { + originalBeforeSend( xhr ); + } + }; + + return originalSend.call( this, options ); + }; + + const originalPost = window.wp.ajax.post; + window.wp.ajax.post = function ( options ) { + const originalBeforeSend = options.beforeSend; + + options.beforeSend = function ( xhr ) { + xhr.setRequestHeader( 'Authorization', authHeader ); + + if ( typeof originalBeforeSend === 'function' ) { + originalBeforeSend( xhr ); + } + }; + + return originalPost.call( this, options ); + }; + + debug( 'AJAX auth configured' ); +} diff --git a/src/utils/editor-environment.js b/src/utils/editor-environment.js index 4470638a6..3115f7c25 100644 --- a/src/utils/editor-environment.js +++ b/src/utils/editor-environment.js @@ -9,7 +9,7 @@ import { } from './bridge'; import { configureLocale } from './localization'; import { loadEditorAssets } from './editor-loader'; -import { initializeVideoPressAjaxBridge } from './videopress-bridge'; +import { initializeAjax } from './ajax'; import { initializeFetchInterceptor } from './fetch-interceptor'; import EditorLoadError from '../components/editor-load-error'; import { setLogLevel, error } from './logger'; @@ -33,7 +33,6 @@ export async function setUpEditorEnvironment() { await configureLocale(); await initializeWordPressGlobals(); await configureApiFetch(); - initializeVideoPressAjaxBridge(); const pluginLoadResult = await loadPluginsIfEnabled(); await initializeEditor( pluginLoadResult ); } catch ( err ) { @@ -138,6 +137,7 @@ async function loadPluginsIfEnabled() { * @return {Promise} Promise that resolves when the editor is initialized */ async function initializeEditor( pluginLoadResult = {} ) { + initializeAjax(); const { initializeEditor: _initializeEditor } = await import( './editor' ); _initializeEditor( { allowedBlockTypes: pluginLoadResult.allowedBlockTypes, diff --git a/src/utils/videopress-bridge.js b/src/utils/videopress-bridge.js deleted file mode 100644 index 7bed2ee2b..000000000 --- a/src/utils/videopress-bridge.js +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Internal dependencies - */ -import { getGBKit } from './bridge'; -import { warn, debug, error } from './logger'; - -/** - * VideoPress AJAX to REST API bridge. - * - * GutenbergKit lacks authentication cookies required for AJAX requests. - * This module overrides wp.media.ajax to bridge specific VideoPress AJAX - * requests to their corresponding REST API endpoints. - */ - -/** - * Initializes the VideoPress AJAX bridge. - * - * This function overrides wp.media.ajax to intercept VideoPress-specific - * AJAX requests and redirect them to the appropriate REST API endpoints. - * - * @return {void} - */ -export function initializeVideoPressAjaxBridge() { - // Ensure necessary globals are available - if ( ! window.wp || ! window.wp.apiFetch ) { - warn( 'VideoPress bridge: wp.apiFetch not available' ); - return; - } - - // Initialize wp.ajax if not already present - window.wp.ajax = window.wp.ajax || {}; - window.wp.ajax.settings = window.wp.ajax.settings || {}; - - // Set up AJAX settings with site URL - const { siteURL } = getGBKit(); - if ( siteURL ) { - window.wp.ajax.settings.url = `${ siteURL }/wp-admin/admin-ajax.php`; - } - - // Store original wp.media.ajax function if it exists - const originalMediaAjax = window.wp.media?.ajax; - - // Override wp.media.ajax - window.wp.media = window.wp.media || {}; - window.wp.media.ajax = ( ...args ) => { - const [ action ] = args; - - // Handle VideoPress upload JWT request - if ( action === 'videopress-get-upload-jwt' ) { - return handleVideoPressUploadJWT(); - } - - // Fall back to original function or default behavior - if ( originalMediaAjax ) { - return originalMediaAjax( ...args ); - } - - // If no original function exists, return a rejected promise - const deferred = - window.jQuery?.Deferred?.() || createFallbackDeferred(); - deferred.reject( new Error( `Unhandled AJAX action: ${ action }` ) ); - return deferred.promise(); - }; - - debug( 'VideoPress AJAX bridge initialized' ); -} - -/** - * Handles the VideoPress upload JWT request by calling the REST API. - * - * @return {Promise} jQuery Deferred promise that resolves with the JWT response. - */ -function handleVideoPressUploadJWT() { - const deferred = window.jQuery?.Deferred?.() || createFallbackDeferred(); - - window.wp - .apiFetch( { - path: '/wpcom/v2/videopress/upload-jwt', - method: 'POST', - } ) - .then( ( response ) => { - if ( response.error ) { - deferred.reject( response.error ); - } else { - // Transform the response to match expected AJAX format - const processedResponse = { - ...response, - upload_action_url: response.upload_url, - }; - delete processedResponse.upload_url; - - debug( - 'VideoPress JWT obtained successfully', - processedResponse - ); - deferred.resolve( processedResponse ); - } - } ) - .catch( ( err ) => { - error( 'VideoPress JWT request failed', err ); - deferred.reject( err ); - } ); - - return deferred.promise(); -} - -/** - * Creates a fallback deferred object if jQuery is not available. - * - * @return {Object} Deferred-like object with resolve, reject, and promise methods. - */ -function createFallbackDeferred() { - let resolveCallback, rejectCallback; - const promise = new Promise( ( resolve, reject ) => { - resolveCallback = resolve; - rejectCallback = reject; - } ); - - return { - resolve: resolveCallback, - reject: rejectCallback, - promise: () => promise, - }; -} From c687f505adc6f91331686bef765a9536f4846ff5 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 19 Sep 2025 09:15:07 -0400 Subject: [PATCH 02/31] refactor: Rename AJAX and api-fetch configuration utilities --- src/utils/ajax.js | 2 +- src/utils/editor-environment.js | 17 +++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/utils/ajax.js b/src/utils/ajax.js index 8d9bd76db..d608dcd89 100644 --- a/src/utils/ajax.js +++ b/src/utils/ajax.js @@ -10,7 +10,7 @@ import { warn, debug } from './logger'; * * @return {void} */ -export function initializeAjax() { +export function configureAjax() { window.wp = window.wp || {}; window.wp.ajax = window.wp.ajax || {}; window.wp.ajax.settings = window.wp.ajax.settings || {}; diff --git a/src/utils/editor-environment.js b/src/utils/editor-environment.js index 3115f7c25..fbffaede4 100644 --- a/src/utils/editor-environment.js +++ b/src/utils/editor-environment.js @@ -9,7 +9,7 @@ import { } from './bridge'; import { configureLocale } from './localization'; import { loadEditorAssets } from './editor-loader'; -import { initializeAjax } from './ajax'; +import { configureAjax } from './ajax'; import { initializeFetchInterceptor } from './fetch-interceptor'; import EditorLoadError from '../components/editor-load-error'; import { setLogLevel, error } from './logger'; @@ -32,7 +32,7 @@ export async function setUpEditorEnvironment() { initializeFetchInterceptor(); await configureLocale(); await initializeWordPressGlobals(); - await configureApiFetch(); + await configureNetworkUtils(); const pluginLoadResult = await loadPluginsIfEnabled(); await initializeEditor( pluginLoadResult ); } catch ( err ) { @@ -90,12 +90,14 @@ async function initializeWordPressGlobals() { * Configure `api-fetch` middleware and settings. Lazy-loaded to ensure * WordPress globals are available before importing `api-fetch` and * referencing `window.wp.apiFetch`. + * + * Also, configure AJAX URL and token authentication. */ -async function configureApiFetch() { - const { configureApiFetch: _configureApiFetch } = await import( - './api-fetch' - ); - _configureApiFetch(); +async function configureNetworkUtils() { + configureAjax(); + + const { configureApiFetch } = await import( './api-fetch' ); + configureApiFetch(); } /** @@ -137,7 +139,6 @@ async function loadPluginsIfEnabled() { * @return {Promise} Promise that resolves when the editor is initialized */ async function initializeEditor( pluginLoadResult = {} ) { - initializeAjax(); const { initializeEditor: _initializeEditor } = await import( './editor' ); _initializeEditor( { allowedBlockTypes: pluginLoadResult.allowedBlockTypes, From 1f9d1cc72a9a17580f69ce4174856d2a0975e3f4 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 19 Sep 2025 14:25:04 -0400 Subject: [PATCH 03/31] fix: Configure AJAX after the library loads If we configure AJAX before loading the library, the configuration is overridden. --- src/utils/editor-environment.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/utils/editor-environment.js b/src/utils/editor-environment.js index fbffaede4..5b7fcca18 100644 --- a/src/utils/editor-environment.js +++ b/src/utils/editor-environment.js @@ -32,7 +32,7 @@ export async function setUpEditorEnvironment() { initializeFetchInterceptor(); await configureLocale(); await initializeWordPressGlobals(); - await configureNetworkUtils(); + await configureApiFetch(); const pluginLoadResult = await loadPluginsIfEnabled(); await initializeEditor( pluginLoadResult ); } catch ( err ) { @@ -90,14 +90,12 @@ async function initializeWordPressGlobals() { * Configure `api-fetch` middleware and settings. Lazy-loaded to ensure * WordPress globals are available before importing `api-fetch` and * referencing `window.wp.apiFetch`. - * - * Also, configure AJAX URL and token authentication. */ -async function configureNetworkUtils() { - configureAjax(); - - const { configureApiFetch } = await import( './api-fetch' ); - configureApiFetch(); +async function configureApiFetch() { + const { configureApiFetch: _configureApiFetch } = await import( + './api-fetch' + ); + _configureApiFetch(); } /** @@ -140,6 +138,8 @@ async function loadPluginsIfEnabled() { */ async function initializeEditor( pluginLoadResult = {} ) { const { initializeEditor: _initializeEditor } = await import( './editor' ); + configureAjax(); // Configure AJAX URL and token authentication + _initializeEditor( { allowedBlockTypes: pluginLoadResult.allowedBlockTypes, pluginLoadFailed: pluginLoadResult.pluginLoadFailed, From e14bbf5fcf6224826d06364350a5b8cbac374ed7 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 19 Sep 2025 16:08:09 -0400 Subject: [PATCH 04/31] test: Fix test imports and assertions --- src/utils/editor-environment.test.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/utils/editor-environment.test.js b/src/utils/editor-environment.test.js index 834fb13da..c91dfc8a1 100644 --- a/src/utils/editor-environment.test.js +++ b/src/utils/editor-environment.test.js @@ -16,7 +16,7 @@ import { import { loadEditorAssets } from './editor-loader.js'; import EditorLoadError from '../components/editor-load-error/index.jsx'; import { error } from './logger.js'; -import { initializeVideoPressAjaxBridge } from './videopress-bridge.js'; +import { configureAjax } from './ajax.js'; import { initializeWordPressGlobals } from './wordpress-globals.js'; import { configureLocale } from './localization.js'; import { configureApiFetch } from './api-fetch.js'; @@ -27,7 +27,7 @@ vi.mock( './bridge.js' ); vi.mock( './fetch-interceptor.js' ); vi.mock( './logger.js' ); vi.mock( './editor-styles.js' ); -vi.mock( './videopress-bridge.js' ); +vi.mock( './ajax.js' ); vi.mock( './wordpress-globals.js', () => ( { initializeWordPressGlobals: vi.fn(), @@ -63,7 +63,7 @@ describe( 'setUpEditorEnvironment', () => { initializeWordPressGlobals.mockImplementation( () => {} ); configureApiFetch.mockImplementation( () => {} ); initializeFetchInterceptor.mockImplementation( () => {} ); - initializeVideoPressAjaxBridge.mockImplementation( () => {} ); + configureAjax.mockImplementation( () => {} ); initializeEditor.mockImplementation( () => {} ); EditorLoadError.mockReturnValue( '
Error
' ); loadEditorAssets.mockResolvedValue( { @@ -96,8 +96,8 @@ describe( 'setUpEditorEnvironment', () => { callOrder.push( 'configureApiFetch' ); } ); - initializeVideoPressAjaxBridge.mockImplementation( () => { - callOrder.push( 'initializeVideoPress' ); + configureAjax.mockImplementation( () => { + callOrder.push( 'configureAjax' ); } ); initializeEditor.mockImplementation( () => { @@ -112,7 +112,7 @@ describe( 'setUpEditorEnvironment', () => { 'configureLocale', 'loadRemainingGlobals', 'configureApiFetch', - 'initializeVideoPress', + 'configureAjax', 'initializeEditor', ] ); } ); From 5997c2efe915f231020ebbb9aab5ab05bcbfa858 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 22 Sep 2025 10:22:09 -0400 Subject: [PATCH 05/31] fix: Set the global WordPress admin AJAX URL This global is often used by WordPress Admin page scripts. --- src/utils/ajax.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/utils/ajax.js b/src/utils/ajax.js index d608dcd89..e009c881e 100644 --- a/src/utils/ajax.js +++ b/src/utils/ajax.js @@ -26,6 +26,9 @@ function configureAjaxUrl( siteURL ) { return; } + // Global used within WordPress admin pages + window.ajaxurl = `${ siteURL }/wp-admin/admin-ajax.php`; + // Global used by WordPress' JavaScript API window.wp.ajax.settings.url = `${ siteURL }/wp-admin/admin-ajax.php`; debug( 'AJAX URL configured' ); From f12aa24b5aef81dbe850bbaffb68dfc0e004b013 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 22 Sep 2025 10:44:00 -0400 Subject: [PATCH 06/31] test: Assert AJAX configuration --- src/utils/ajax.test.js | 491 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 491 insertions(+) create mode 100644 src/utils/ajax.test.js diff --git a/src/utils/ajax.test.js b/src/utils/ajax.test.js new file mode 100644 index 000000000..a9f988d58 --- /dev/null +++ b/src/utils/ajax.test.js @@ -0,0 +1,491 @@ +/** + * External dependencies + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +/** + * Internal dependencies + */ +import { configureAjax } from './ajax'; +import * as bridge from './bridge'; +import * as logger from './logger'; + +vi.mock( './bridge' ); +vi.mock( './logger' ); + +describe( 'configureAjax', () => { + let originalWindow; + let mockJQueryAjaxSetup; + let originalWpAjaxSend; + let originalWpAjaxPost; + + beforeEach( () => { + vi.clearAllMocks(); + + // Store original window state + originalWindow = { + wp: global.window.wp, + ajaxurl: global.window.ajaxurl, + jQuery: global.window.jQuery, + }; + + // Reset window.wp + global.window.wp = undefined; + global.window.ajaxurl = undefined; + + // Mock jQuery + mockJQueryAjaxSetup = vi.fn(); + global.window.jQuery = { + ajaxSetup: mockJQueryAjaxSetup, + }; + + // Create mock functions for wp.ajax methods + originalWpAjaxSend = vi.fn( ( options ) => { + // Simulate calling beforeSend if it exists + if ( options?.beforeSend ) { + const mockXhr = { setRequestHeader: vi.fn() }; + options.beforeSend( mockXhr ); + } + return Promise.resolve(); + } ); + + originalWpAjaxPost = vi.fn( ( options ) => { + // Simulate calling beforeSend if it exists + if ( options?.beforeSend ) { + const mockXhr = { setRequestHeader: vi.fn() }; + options.beforeSend( mockXhr ); + } + return Promise.resolve(); + } ); + } ); + + afterEach( () => { + // Restore original window state + global.window.wp = originalWindow.wp; + global.window.ajaxurl = originalWindow.ajaxurl; + global.window.jQuery = originalWindow.jQuery; + } ); + + describe( 'URL configuration', () => { + it( 'should configure ajax URLs when siteURL is provided', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + configureAjax(); + + expect( global.window.ajaxurl ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + expect( global.window.wp.ajax.settings.url ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + expect( logger.debug ).toHaveBeenCalledWith( + 'AJAX URL configured' + ); + } ); + + it( 'should log warning when siteURL is missing', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: null, + authHeader: 'Bearer token', + } ); + + configureAjax(); + + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX URL without siteURL' + ); + expect( global.window.ajaxurl ).toBeUndefined(); + } ); + + it( 'should handle undefined siteURL', () => { + bridge.getGBKit.mockReturnValue( { + authHeader: 'Bearer token', + } ); + + configureAjax(); + + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX URL without siteURL' + ); + expect( global.window.ajaxurl ).toBeUndefined(); + } ); + + it( 'should properly initialize window.wp.ajax hierarchy', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + // Ensure window.wp doesn't exist initially + expect( global.window.wp ).toBeUndefined(); + + configureAjax(); + + expect( global.window.wp ).toBeDefined(); + expect( global.window.wp.ajax ).toBeDefined(); + expect( global.window.wp.ajax.settings ).toBeDefined(); + } ); + } ); + + describe( 'Auth configuration', () => { + beforeEach( () => { + // Setup wp.ajax with original methods + global.window.wp = { + ajax: { + send: originalWpAjaxSend, + post: originalWpAjaxPost, + settings: {}, + }, + }; + } ); + + it( 'should configure jQuery ajax with auth header', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: null, + authHeader: 'Bearer test-token', + } ); + + configureAjax(); + + expect( mockJQueryAjaxSetup ).toHaveBeenCalledWith( { + headers: { + Authorization: 'Bearer test-token', + }, + } ); + expect( logger.debug ).toHaveBeenCalledWith( + 'AJAX auth configured' + ); + } ); + + it( 'should wrap wp.ajax.send with auth header', async () => { + bridge.getGBKit.mockReturnValue( { + siteURL: null, + authHeader: 'Bearer send-token', + } ); + + configureAjax(); + + // Call the wrapped send method + const options = { data: 'test' }; + await global.window.wp.ajax.send( options ); + + // Verify the original was called + expect( originalWpAjaxSend ).toHaveBeenCalled(); + + // Verify beforeSend was added + const calledOptions = originalWpAjaxSend.mock.calls[ 0 ][ 0 ]; + expect( calledOptions.beforeSend ).toBeDefined(); + + // Verify auth header is set + const mockXhr = { setRequestHeader: vi.fn() }; + calledOptions.beforeSend( mockXhr ); + expect( mockXhr.setRequestHeader ).toHaveBeenCalledWith( + 'Authorization', + 'Bearer send-token' + ); + } ); + + it( 'should wrap wp.ajax.post with auth header', async () => { + bridge.getGBKit.mockReturnValue( { + siteURL: null, + authHeader: 'Bearer post-token', + } ); + + configureAjax(); + + // Call the wrapped post method + const options = { action: 'test_action' }; + await global.window.wp.ajax.post( options ); + + // Verify the original was called + expect( originalWpAjaxPost ).toHaveBeenCalled(); + + // Verify beforeSend was added + const calledOptions = originalWpAjaxPost.mock.calls[ 0 ][ 0 ]; + expect( calledOptions.beforeSend ).toBeDefined(); + + // Verify auth header is set + const mockXhr = { setRequestHeader: vi.fn() }; + calledOptions.beforeSend( mockXhr ); + expect( mockXhr.setRequestHeader ).toHaveBeenCalledWith( + 'Authorization', + 'Bearer post-token' + ); + } ); + + it( 'should preserve original beforeSend in wp.ajax.send', async () => { + bridge.getGBKit.mockReturnValue( { + siteURL: null, + authHeader: 'Bearer preserve-token', + } ); + + configureAjax(); + + // Call with existing beforeSend + const originalBeforeSend = vi.fn(); + const options = { beforeSend: originalBeforeSend }; + await global.window.wp.ajax.send( options ); + + // Get the wrapped beforeSend + const calledOptions = originalWpAjaxSend.mock.calls[ 0 ][ 0 ]; + const mockXhr = { setRequestHeader: vi.fn() }; + calledOptions.beforeSend( mockXhr ); + + // Verify both auth header and original beforeSend were called + expect( mockXhr.setRequestHeader ).toHaveBeenCalledWith( + 'Authorization', + 'Bearer preserve-token' + ); + expect( originalBeforeSend ).toHaveBeenCalledWith( mockXhr ); + } ); + + it( 'should preserve original beforeSend in wp.ajax.post', async () => { + bridge.getGBKit.mockReturnValue( { + siteURL: null, + authHeader: 'Bearer preserve-post-token', + } ); + + configureAjax(); + + // Call with existing beforeSend + const originalBeforeSend = vi.fn(); + const options = { beforeSend: originalBeforeSend }; + await global.window.wp.ajax.post( options ); + + // Get the wrapped beforeSend + const calledOptions = originalWpAjaxPost.mock.calls[ 0 ][ 0 ]; + const mockXhr = { setRequestHeader: vi.fn() }; + calledOptions.beforeSend( mockXhr ); + + // Verify both auth header and original beforeSend were called + expect( mockXhr.setRequestHeader ).toHaveBeenCalledWith( + 'Authorization', + 'Bearer preserve-post-token' + ); + expect( originalBeforeSend ).toHaveBeenCalledWith( mockXhr ); + } ); + + it( 'should log warning when authHeader is missing', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + configureAjax(); + + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX auth without authHeader' + ); + expect( mockJQueryAjaxSetup ).not.toHaveBeenCalled(); + } ); + + it( 'should handle undefined authHeader', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + } ); + + configureAjax(); + + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX auth without authHeader' + ); + expect( mockJQueryAjaxSetup ).not.toHaveBeenCalled(); + } ); + } ); + + describe( 'Integration tests', () => { + it( 'should configure both URL and auth when both are provided', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer full-token', + } ); + + // Setup wp.ajax with methods + global.window.wp = { + ajax: { + send: originalWpAjaxSend, + post: originalWpAjaxPost, + settings: {}, + }, + }; + + configureAjax(); + + // Check URL configuration + expect( global.window.ajaxurl ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + expect( global.window.wp.ajax.settings.url ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + + // Check auth configuration + expect( mockJQueryAjaxSetup ).toHaveBeenCalledWith( { + headers: { + Authorization: 'Bearer full-token', + }, + } ); + + // Check debug logs + expect( logger.debug ).toHaveBeenCalledWith( + 'AJAX URL configured' + ); + expect( logger.debug ).toHaveBeenCalledWith( + 'AJAX auth configured' + ); + } ); + + it( 'should handle empty configuration object', () => { + bridge.getGBKit.mockReturnValue( {} ); + + configureAjax(); + + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX URL without siteURL' + ); + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX auth without authHeader' + ); + } ); + } ); + + describe( 'Edge cases', () => { + it( 'should handle missing jQuery gracefully', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer no-jquery', + } ); + + delete global.window.jQuery; + + expect( () => configureAjax() ).not.toThrow(); + expect( logger.debug ).toHaveBeenCalledWith( + 'AJAX URL configured' + ); + expect( logger.debug ).toHaveBeenCalledWith( + 'AJAX auth configured' + ); + } ); + + it( 'should handle undefined jQuery', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer undefined-jquery', + } ); + + global.window.jQuery = undefined; + + expect( () => configureAjax() ).not.toThrow(); + expect( logger.debug ).toHaveBeenCalledWith( + 'AJAX URL configured' + ); + expect( logger.debug ).toHaveBeenCalledWith( + 'AJAX auth configured' + ); + } ); + + it( 'should handle missing wp.ajax.send method', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer no-send', + } ); + + global.window.wp = { + ajax: { + post: originalWpAjaxPost, + settings: {}, + }, + }; + + expect( () => configureAjax() ).not.toThrow(); + + // Should still wrap post + expect( global.window.wp.ajax.post ).not.toBe( originalWpAjaxPost ); + } ); + + it( 'should handle missing wp.ajax.post method', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer no-post', + } ); + + global.window.wp = { + ajax: { + send: originalWpAjaxSend, + settings: {}, + }, + }; + + expect( () => configureAjax() ).not.toThrow(); + + // Should still wrap send + expect( global.window.wp.ajax.send ).not.toBe( originalWpAjaxSend ); + } ); + + it( 'should handle missing wp.ajax entirely', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer no-ajax', + } ); + + global.window.wp = {}; + + expect( () => configureAjax() ).not.toThrow(); + + // Should create ajax object + expect( global.window.wp.ajax ).toBeDefined(); + expect( global.window.wp.ajax.settings ).toBeDefined(); + } ); + + it( 'should work with window.wp already partially initialized', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + // Pre-existing wp object with other properties + global.window.wp = { + data: { someData: 'test' }, + }; + + configureAjax(); + + // Should preserve existing properties + expect( global.window.wp.data ).toEqual( { someData: 'test' } ); + + // Should add ajax properties + expect( global.window.wp.ajax ).toBeDefined(); + expect( global.window.wp.ajax.settings.url ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + } ); + + it( 'should work when wp.ajax is partially initialized', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + // Pre-existing wp.ajax object without settings + global.window.wp = { + ajax: { + someMethod: vi.fn(), + }, + }; + + configureAjax(); + + // Should preserve existing methods + expect( global.window.wp.ajax.someMethod ).toBeDefined(); + + // Should add settings + expect( global.window.wp.ajax.settings ).toBeDefined(); + expect( global.window.wp.ajax.settings.url ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + } ); + } ); +} ); From 054742e3c5e0e2338a536c964203a4edf06c3bd1 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 2 Oct 2025 14:54:19 -0400 Subject: [PATCH 07/31] feat: Allow configuring the Android asset loader domain Useful when needing to allow CORS for specific domains. --- .../org/wordpress/gutenberg/GutenbergView.kt | 18 ++++++++++++------ .../gutenberg/model/EditorConfiguration.kt | 9 ++++++++- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 88bd94fb4..c7789812d 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -47,8 +47,8 @@ import org.wordpress.gutenberg.services.EditorService import java.util.Collections import java.util.Locale -private const val ASSET_URL = "https://appassets.androidplatform.net/assets/index.html" -private const val ASSET_URL_HTTP = "http://appassets.androidplatform.net/assets/index.html" +const val DEFAULT_ASSET_DOMAIN = "appassets.androidplatform.net" +const val ASSET_PATH_INDEX = "/assets/index.html" /** * A WebView-based Gutenberg block editor for Android. @@ -92,6 +92,7 @@ class GutenbergView : WebView { private var isEditorLoaded = false private var didFireEditorLoaded = false private lateinit var assetLoader: WebViewAssetLoader + private lateinit var assetDomain: String private val configuration: EditorConfiguration private lateinit var dependencies: EditorDependencies @@ -243,7 +244,7 @@ class GutenbergView : WebView { ): WebResourceResponse? { if (request.url == null) { return super.shouldInterceptRequest(view, request) - } else if (request.url.host?.contains("appassets.androidplatform.net") == true) { + } else if (request.url.host == assetDomain) { return assetLoader.shouldInterceptRequest(request.url) } else if (requestInterceptor.canIntercept(request)) { return requestInterceptor.handleRequest(request) @@ -276,7 +277,7 @@ class GutenbergView : WebView { } // Allow asset URLs - if (url.host == "appassets.androidplatform.net") { + if (url.host == assetDomain) { return false } @@ -394,6 +395,9 @@ class GutenbergView : WebView { private fun loadEditor(dependencies: EditorDependencies) { this.dependencies = dependencies + // Set up asset loader domain + assetDomain = configuration.assetLoaderDomain ?: DEFAULT_ASSET_DOMAIN + // Set up asset caching requestInterceptor = CachedAssetRequestInterceptor( dependencies.assetBundle, @@ -407,6 +411,7 @@ class GutenbergView : WebView { val siteUri = Uri.parse(configuration.siteURL) val isLocalHttpSite = siteUri.scheme == "http" && siteUri.host in LOCAL_HOSTS assetLoader = WebViewAssetLoader.Builder() + .setDomain(assetDomain) .setHttpAllowed(isLocalHttpSite) .addPathHandler("/assets/", AssetsPathHandler(this.context)) .build() @@ -416,7 +421,8 @@ class GutenbergView : WebView { initializeWebView() - val assetUrl = if (isLocalHttpSite) ASSET_URL_HTTP else ASSET_URL + val scheme = if (isLocalHttpSite) "http" else "https" + val assetUrl = "$scheme://$assetDomain$ASSET_PATH_INDEX" val editorUrl = BuildConfig.GUTENBERG_EDITOR_URL.ifEmpty { assetUrl } @@ -424,7 +430,7 @@ class GutenbergView : WebView { WebStorage.getInstance().deleteAllData() this.clearCache(true) // All cookies are third-party cookies because the root of this document - // lives under `appassets.androidplatform.net` + // lives under the configured asset domain (e.g., `https://appassets.androidplatform.net`) CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) // Erase all local cookies before loading the URL – we don't want to persist diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt index 2e44cbaef..d36dbb171 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt @@ -29,6 +29,7 @@ data class EditorConfiguration( val editorAssetsEndpoint: String? = null, val enableNetworkLogging: Boolean = false, var enableOfflineMode: Boolean = false, + val assetLoaderDomain: String? = null ): Parcelable { /** @@ -73,6 +74,7 @@ data class EditorConfiguration( private var editorAssetsEndpoint: String? = null private var enableNetworkLogging: Boolean = false private var enableOfflineMode: Boolean = false + private var assetLoaderDomain: String? = null fun setTitle(title: String) = apply { this.title = title } fun setContent(content: String) = apply { this.content = content } @@ -95,6 +97,7 @@ data class EditorConfiguration( fun setEditorAssetsEndpoint(editorAssetsEndpoint: String?) = apply { this.editorAssetsEndpoint = editorAssetsEndpoint } fun setEnableNetworkLogging(enableNetworkLogging: Boolean) = apply { this.enableNetworkLogging = enableNetworkLogging } fun setEnableOfflineMode(enableOfflineMode: Boolean) = apply { this.enableOfflineMode = enableOfflineMode } + fun setAssetLoaderDomain(assetLoaderDomain: String?) = apply { this.assetLoaderDomain = assetLoaderDomain } fun build(): EditorConfiguration = EditorConfiguration( title = title, @@ -117,7 +120,8 @@ data class EditorConfiguration( cachedAssetHosts = cachedAssetHosts, editorAssetsEndpoint = editorAssetsEndpoint, enableNetworkLogging = enableNetworkLogging, - enableOfflineMode = enableOfflineMode + enableOfflineMode = enableOfflineMode, + assetLoaderDomain = assetLoaderDomain ) } @@ -145,6 +149,7 @@ data class EditorConfiguration( .setEditorAssetsEndpoint(editorAssetsEndpoint) .setEnableNetworkLogging(enableNetworkLogging) .setEnableOfflineMode(enableOfflineMode) + .setAssetLoaderDomain(assetLoaderDomain) override fun equals(other: Any?): Boolean { if (this === other) return true @@ -173,6 +178,7 @@ data class EditorConfiguration( if (editorAssetsEndpoint != other.editorAssetsEndpoint) return false if (enableNetworkLogging != other.enableNetworkLogging) return false if (enableOfflineMode != other.enableOfflineMode) return false + if (assetLoaderDomain != other.assetLoaderDomain) return false if (siteId != other.siteId) return false return true @@ -200,6 +206,7 @@ data class EditorConfiguration( result = 31 * result + (editorAssetsEndpoint?.hashCode() ?: 0) result = 31 * result + enableNetworkLogging.hashCode() result = 31 * result + enableOfflineMode.hashCode() + result = 31 * result + (assetLoaderDomain?.hashCode() ?: 0) result = 31 * result + siteId.hashCode() return result } From 87811462fffcad7d2ecdfa7965f04d4d5123f88e Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 13 Jan 2026 14:30:47 -0500 Subject: [PATCH 08/31] docs: Note AJAX support requirements --- docs/integration.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/docs/integration.md b/docs/integration.md index bd606add6..973c39c73 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -291,3 +291,43 @@ val configuration = EditorConfiguration.builder() .setEditorSettings(editorSettingsJSON) .build() ``` + +### AJAX Support + +Some Gutenberg blocks and features use WordPress AJAX (`admin-ajax.php`) for functionality like form submissions. GutenbergKit supports AJAX requests when properly configured. + +**Requirements:** + +1. **Production bundle required**: AJAX requests fail with CORS errors when using the development server because the editor runs on `localhost` while AJAX requests target your WordPress site. You must use a production bundle built with `make build`. + +2. **Configure `siteURL`**: The `siteURL` configuration option must be set to your WordPress site URL. This is used to construct the AJAX endpoint (`{siteURL}/wp-admin/admin-ajax.php`). + +3. **Set authentication header**: The `authHeader` configuration must be set. GutenbergKit injects this header into all AJAX requests since the WebView lacks WordPress authentication cookies. + +4. **Android: Configure `assetLoaderDomain`**: On Android, you must set the `assetLoaderDomain` to a domain that your WordPress site/plugin allows. This is because Android's WebViewAssetLoader serves the editor from a configurable domain, and AJAX requests must pass CORS validation on your server. + + For example, the Jetpack mobile plugin allows requests from `android-app-assets.jetpack.com`: + +```swift +// iOS - siteURL and authHeader are required +let configuration = EditorConfigurationBuilder( + postType: "post", + siteURL: URL(string: "https://example.com")!, + siteApiRoot: URL(string: "https://example.com/wp-json")! +) + .setAuthHeader("Bearer your-token") + .build() +``` + +```kotlin +// Android - assetLoaderDomain is also required for AJAX +val configuration = EditorConfiguration.builder() + .setPostType("post") + .setSiteURL("https://example.com") + .setSiteApiRoot("https://example.com/wp-json") + .setAuthHeader("Bearer your-token") + .setAssetLoaderDomain("android-app-assets.jetpack.com") // Must be allowed by your WordPress site + .build() +``` + +**Server-side CORS configuration**: Your WordPress site must include the `assetLoaderDomain` in its CORS allowed origins. This is typically handled by your WordPress plugin (e.g., Jetpack) that integrates with the mobile app. From 34932e47ff9845af42fd50771a26253a8c714552 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 13 Jan 2026 14:34:44 -0500 Subject: [PATCH 09/31] docs: Note AJAX CORS errors in troubleshooting documentation --- docs/code/troubleshooting.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/code/troubleshooting.md b/docs/code/troubleshooting.md index 300ac055f..cf0acafe3 100644 --- a/docs/code/troubleshooting.md +++ b/docs/code/troubleshooting.md @@ -28,3 +28,13 @@ The file does not exist at "[path]" which is in the optimize deps directory. The - Deleting the `node_modules/.vite` directory (or `node_modules` entirely) and restarting the development server via `make dev-server`. You may also need to clear your browser cache to ensure no stale files are used. + +## AJAX requests fail with CORS errors + +**Error:** `Access to XMLHttpRequest at 'https://example.com/wp-admin/admin-ajax.php' from origin 'http://localhost:5173' has been blocked by CORS policy` + +This error occurs when the editor makes AJAX requests (e.g., from blocks that use `admin-ajax.php`) while running on the development server. The browser blocks these cross-origin requests because the editor runs on `localhost` while AJAX targets your WordPress site. + +**Solution:** AJAX functionality requires a production bundle. Build the editor assets with `make build` and test AJAX features using the demo apps without using the `GUTENBERG_EDITOR_URL` environment variable. + +For Android, you must also configure `assetLoaderDomain` to a domain allowed by your WordPress site's CORS policy. See the [AJAX Support section](../integration.md#ajax-support) in the Integration Guide for complete configuration details. From 0e45e3d5dd27c4f4f85a1da8acfd967c6406aeaf Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 14 Jan 2026 15:03:51 -0500 Subject: [PATCH 10/31] fix: Add type checks before wrapping wp.ajax methods (#282) Address PR feedback about potential race condition. The code now checks if `window.wp.ajax.send` and `window.wp.ajax.post` are functions before wrapping them. This prevents TypeError when calling the wrapped function if the original method was undefined during configuration. Update tests to verify that missing methods remain undefined rather than being wrapped with an undefined reference. Co-authored-by: Claude --- src/utils/ajax.js | 46 +++++++++++++++++++++++------------------- src/utils/ajax.test.js | 6 ++++++ 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/src/utils/ajax.js b/src/utils/ajax.js index e009c881e..ed4df197b 100644 --- a/src/utils/ajax.js +++ b/src/utils/ajax.js @@ -46,35 +46,39 @@ function configureAjaxAuth( authHeader ) { }, } ); - const originalSend = window.wp.ajax.send; - window.wp.ajax.send = function ( options ) { - const originalBeforeSend = options.beforeSend; + if ( typeof window.wp.ajax.send === 'function' ) { + const originalSend = window.wp.ajax.send; + window.wp.ajax.send = function ( options ) { + const originalBeforeSend = options.beforeSend; - options.beforeSend = function ( xhr ) { - xhr.setRequestHeader( 'Authorization', authHeader ); + options.beforeSend = function ( xhr ) { + xhr.setRequestHeader( 'Authorization', authHeader ); - if ( typeof originalBeforeSend === 'function' ) { - originalBeforeSend( xhr ); - } + if ( typeof originalBeforeSend === 'function' ) { + originalBeforeSend( xhr ); + } + }; + + return originalSend.call( this, options ); }; + } - return originalSend.call( this, options ); - }; + if ( typeof window.wp.ajax.post === 'function' ) { + const originalPost = window.wp.ajax.post; + window.wp.ajax.post = function ( options ) { + const originalBeforeSend = options.beforeSend; - const originalPost = window.wp.ajax.post; - window.wp.ajax.post = function ( options ) { - const originalBeforeSend = options.beforeSend; + options.beforeSend = function ( xhr ) { + xhr.setRequestHeader( 'Authorization', authHeader ); - options.beforeSend = function ( xhr ) { - xhr.setRequestHeader( 'Authorization', authHeader ); + if ( typeof originalBeforeSend === 'function' ) { + originalBeforeSend( xhr ); + } + }; - if ( typeof originalBeforeSend === 'function' ) { - originalBeforeSend( xhr ); - } + return originalPost.call( this, options ); }; - - return originalPost.call( this, options ); - }; + } debug( 'AJAX auth configured' ); } diff --git a/src/utils/ajax.test.js b/src/utils/ajax.test.js index a9f988d58..28cf84cd4 100644 --- a/src/utils/ajax.test.js +++ b/src/utils/ajax.test.js @@ -402,6 +402,9 @@ describe( 'configureAjax', () => { expect( () => configureAjax() ).not.toThrow(); + // Should not wrap send (it doesn't exist) + expect( global.window.wp.ajax.send ).toBeUndefined(); + // Should still wrap post expect( global.window.wp.ajax.post ).not.toBe( originalWpAjaxPost ); } ); @@ -421,6 +424,9 @@ describe( 'configureAjax', () => { expect( () => configureAjax() ).not.toThrow(); + // Should not wrap post (it doesn't exist) + expect( global.window.wp.ajax.post ).toBeUndefined(); + // Should still wrap send expect( global.window.wp.ajax.send ).not.toBe( originalWpAjaxSend ); } ); From 61f2628f102f2b2dcf58ef628c9dfbf9a36efcbc Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 24 Mar 2026 20:02:43 -0400 Subject: [PATCH 11/31] feat: conditionally reinstate VideoPress bridge When `videopress/video` is not in `allowed_block_types`, initialize the VideoPress AJAX bridge to handle `core/video` blocks extended to rely upon VideoPress upload services. AJAX auth is always initialized. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/utils/editor-environment.js | 16 +++- src/utils/editor-environment.test.js | 41 +++++++++ src/utils/videopress-bridge.js | 124 +++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 src/utils/videopress-bridge.js diff --git a/src/utils/editor-environment.js b/src/utils/editor-environment.js index 5b7fcca18..dfcd02fa6 100644 --- a/src/utils/editor-environment.js +++ b/src/utils/editor-environment.js @@ -10,6 +10,7 @@ import { import { configureLocale } from './localization'; import { loadEditorAssets } from './editor-loader'; import { configureAjax } from './ajax'; +import { initializeVideoPressAjaxBridge } from './videopress-bridge'; import { initializeFetchInterceptor } from './fetch-interceptor'; import EditorLoadError from '../components/editor-load-error'; import { setLogLevel, error } from './logger'; @@ -131,17 +132,26 @@ async function loadPluginsIfEnabled() { /** * Initialize the editor module. Lazy-loaded to ensure WordPress globals are - * before importing the editor module and referencing `window.wp` globals. + * available before importing the editor module and referencing `window.wp` + * globals. * * @param {Object} pluginLoadResult - Results from plugin loading * @return {Promise} Promise that resolves when the editor is initialized */ async function initializeEditor( pluginLoadResult = {} ) { const { initializeEditor: _initializeEditor } = await import( './editor' ); - configureAjax(); // Configure AJAX URL and token authentication + const { allowedBlockTypes } = pluginLoadResult; + + configureAjax(); + + if ( ! allowedBlockTypes?.includes( 'videopress/video' ) ) { + // The VideoPress block isn't available, so initialize the bridge to handle + // any `core/video` blocks extended to rely upon VideoPress upload services. + initializeVideoPressAjaxBridge(); + } _initializeEditor( { - allowedBlockTypes: pluginLoadResult.allowedBlockTypes, + allowedBlockTypes, pluginLoadFailed: pluginLoadResult.pluginLoadFailed, } ); } diff --git a/src/utils/editor-environment.test.js b/src/utils/editor-environment.test.js index c91dfc8a1..ea6c77008 100644 --- a/src/utils/editor-environment.test.js +++ b/src/utils/editor-environment.test.js @@ -17,6 +17,7 @@ import { loadEditorAssets } from './editor-loader.js'; import EditorLoadError from '../components/editor-load-error/index.jsx'; import { error } from './logger.js'; import { configureAjax } from './ajax.js'; +import { initializeVideoPressAjaxBridge } from './videopress-bridge.js'; import { initializeWordPressGlobals } from './wordpress-globals.js'; import { configureLocale } from './localization.js'; import { configureApiFetch } from './api-fetch.js'; @@ -28,6 +29,7 @@ vi.mock( './fetch-interceptor.js' ); vi.mock( './logger.js' ); vi.mock( './editor-styles.js' ); vi.mock( './ajax.js' ); +vi.mock( './videopress-bridge.js' ); vi.mock( './wordpress-globals.js', () => ( { initializeWordPressGlobals: vi.fn(), @@ -64,6 +66,7 @@ describe( 'setUpEditorEnvironment', () => { configureApiFetch.mockImplementation( () => {} ); initializeFetchInterceptor.mockImplementation( () => {} ); configureAjax.mockImplementation( () => {} ); + initializeVideoPressAjaxBridge.mockImplementation( () => {} ); initializeEditor.mockImplementation( () => {} ); EditorLoadError.mockReturnValue( '
Error
' ); loadEditorAssets.mockResolvedValue( { @@ -100,6 +103,10 @@ describe( 'setUpEditorEnvironment', () => { callOrder.push( 'configureAjax' ); } ); + initializeVideoPressAjaxBridge.mockImplementation( () => { + callOrder.push( 'initializeVideoPressAjaxBridge' ); + } ); + initializeEditor.mockImplementation( () => { callOrder.push( 'initializeEditor' ); } ); @@ -113,6 +120,7 @@ describe( 'setUpEditorEnvironment', () => { 'loadRemainingGlobals', 'configureApiFetch', 'configureAjax', + 'initializeVideoPressAjaxBridge', 'initializeEditor', ] ); } ); @@ -224,6 +232,39 @@ describe( 'setUpEditorEnvironment', () => { expect( editorLoaded ).toHaveBeenCalledTimes( 1 ); } ); + it( 'initializes VideoPress bridge when videopress/video is not in allowed block types', async () => { + getGBKit.mockReturnValue( { plugins: true } ); + loadEditorAssets.mockResolvedValue( { + allowedBlockTypes: [ 'core/paragraph', 'core/heading' ], + } ); + + await setUpEditorEnvironment(); + + expect( configureAjax ).toHaveBeenCalledTimes( 1 ); + expect( initializeVideoPressAjaxBridge ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'skips VideoPress bridge when videopress/video is in allowed block types', async () => { + getGBKit.mockReturnValue( { plugins: true } ); + loadEditorAssets.mockResolvedValue( { + allowedBlockTypes: [ 'core/paragraph', 'videopress/video' ], + } ); + + await setUpEditorEnvironment(); + + expect( configureAjax ).toHaveBeenCalledTimes( 1 ); + expect( initializeVideoPressAjaxBridge ).not.toHaveBeenCalled(); + } ); + + it( 'initializes VideoPress bridge when allowed block types is undefined', async () => { + getGBKit.mockReturnValue( { plugins: false } ); + + await setUpEditorEnvironment(); + + expect( configureAjax ).toHaveBeenCalledTimes( 1 ); + expect( initializeVideoPressAjaxBridge ).toHaveBeenCalledTimes( 1 ); + } ); + it( 'returns a promise that resolves when initialization completes', async () => { const result = setUpEditorEnvironment(); diff --git a/src/utils/videopress-bridge.js b/src/utils/videopress-bridge.js new file mode 100644 index 000000000..7bed2ee2b --- /dev/null +++ b/src/utils/videopress-bridge.js @@ -0,0 +1,124 @@ +/** + * Internal dependencies + */ +import { getGBKit } from './bridge'; +import { warn, debug, error } from './logger'; + +/** + * VideoPress AJAX to REST API bridge. + * + * GutenbergKit lacks authentication cookies required for AJAX requests. + * This module overrides wp.media.ajax to bridge specific VideoPress AJAX + * requests to their corresponding REST API endpoints. + */ + +/** + * Initializes the VideoPress AJAX bridge. + * + * This function overrides wp.media.ajax to intercept VideoPress-specific + * AJAX requests and redirect them to the appropriate REST API endpoints. + * + * @return {void} + */ +export function initializeVideoPressAjaxBridge() { + // Ensure necessary globals are available + if ( ! window.wp || ! window.wp.apiFetch ) { + warn( 'VideoPress bridge: wp.apiFetch not available' ); + return; + } + + // Initialize wp.ajax if not already present + window.wp.ajax = window.wp.ajax || {}; + window.wp.ajax.settings = window.wp.ajax.settings || {}; + + // Set up AJAX settings with site URL + const { siteURL } = getGBKit(); + if ( siteURL ) { + window.wp.ajax.settings.url = `${ siteURL }/wp-admin/admin-ajax.php`; + } + + // Store original wp.media.ajax function if it exists + const originalMediaAjax = window.wp.media?.ajax; + + // Override wp.media.ajax + window.wp.media = window.wp.media || {}; + window.wp.media.ajax = ( ...args ) => { + const [ action ] = args; + + // Handle VideoPress upload JWT request + if ( action === 'videopress-get-upload-jwt' ) { + return handleVideoPressUploadJWT(); + } + + // Fall back to original function or default behavior + if ( originalMediaAjax ) { + return originalMediaAjax( ...args ); + } + + // If no original function exists, return a rejected promise + const deferred = + window.jQuery?.Deferred?.() || createFallbackDeferred(); + deferred.reject( new Error( `Unhandled AJAX action: ${ action }` ) ); + return deferred.promise(); + }; + + debug( 'VideoPress AJAX bridge initialized' ); +} + +/** + * Handles the VideoPress upload JWT request by calling the REST API. + * + * @return {Promise} jQuery Deferred promise that resolves with the JWT response. + */ +function handleVideoPressUploadJWT() { + const deferred = window.jQuery?.Deferred?.() || createFallbackDeferred(); + + window.wp + .apiFetch( { + path: '/wpcom/v2/videopress/upload-jwt', + method: 'POST', + } ) + .then( ( response ) => { + if ( response.error ) { + deferred.reject( response.error ); + } else { + // Transform the response to match expected AJAX format + const processedResponse = { + ...response, + upload_action_url: response.upload_url, + }; + delete processedResponse.upload_url; + + debug( + 'VideoPress JWT obtained successfully', + processedResponse + ); + deferred.resolve( processedResponse ); + } + } ) + .catch( ( err ) => { + error( 'VideoPress JWT request failed', err ); + deferred.reject( err ); + } ); + + return deferred.promise(); +} + +/** + * Creates a fallback deferred object if jQuery is not available. + * + * @return {Object} Deferred-like object with resolve, reject, and promise methods. + */ +function createFallbackDeferred() { + let resolveCallback, rejectCallback; + const promise = new Promise( ( resolve, reject ) => { + resolveCallback = resolve; + rejectCallback = reject; + } ); + + return { + resolve: resolveCallback, + reject: rejectCallback, + promise: () => promise, + }; +} From a63e447466161d3951cb67094fab855199e2b7f1 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 25 Mar 2026 15:05:08 -0400 Subject: [PATCH 12/31] fix: filter lodash-js-after inline script from editor assets WordPress's `lodash-js-after` inline script calls `_.noConflict()` to restore `window._` to Underscore.js. Since GutenbergKit excludes core WordPress assets from the editor assets endpoint but doesn't load Underscore, this wipes `window._` to `undefined`. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/utils/editor-loader.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/utils/editor-loader.js b/src/utils/editor-loader.js index 3edfb8e17..c04907eaf 100644 --- a/src/utils/editor-loader.js +++ b/src/utils/editor-loader.js @@ -87,6 +87,13 @@ async function loadAssets( html ) { return false; } + // WordPress's lodash-js-after inline script calls _.noConflict() to + // restore window._ to Underscore.js. GutenbergKit doesn't load + // Underscore, so this wipes window._ to undefined. + if ( asset.id === 'lodash-js-after' ) { + return false; + } + return !! asset.id; } ); From d23adecbb48306a62b82b1eb3548cd0ae4c18b15 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 25 Mar 2026 15:05:36 -0400 Subject: [PATCH 13/31] feat: vendor and load wp-util.js GutenbergKit excludes core WordPress assets from the editor assets endpoint, so wp-util.js (which provides wp.ajax and wp.template) must be vendored and loaded directly. Load it via dynamic import at the end of initializeWordPressGlobals() after jQuery and lodash are on window, since its IIFE captures jQuery via closure at execution time. Co-Authored-By: Claude Opus 4.6 (1M context) --- .eslintignore | 1 + .prettierignore | 1 + src/utils/editor-environment.js | 2 +- src/utils/wordpress-globals.js | 5 +- vendor/wp-util.js | 177 ++++++++++++++++++++++++++++++++ 5 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 vendor/wp-util.js diff --git a/.eslintignore b/.eslintignore index 55e50dca1..9cd8ab950 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,3 +2,4 @@ android/ build/ dist/ ios/ +vendor/ diff --git a/.prettierignore b/.prettierignore index 6a48be6f6..f04cf3685 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,4 @@ android ios package-lock.json .github/**/*.md +vendor diff --git a/src/utils/editor-environment.js b/src/utils/editor-environment.js index dfcd02fa6..339f8b6a7 100644 --- a/src/utils/editor-environment.js +++ b/src/utils/editor-environment.js @@ -84,7 +84,7 @@ function setLogLevelFromGBKit() { async function initializeWordPressGlobals() { const { initializeWordPressGlobals: _initializeWordPressGlobals } = await import( './wordpress-globals' ); - _initializeWordPressGlobals(); + await _initializeWordPressGlobals(); } /** diff --git a/src/utils/wordpress-globals.js b/src/utils/wordpress-globals.js index 5bdb8e1d8..87f6e6a52 100644 --- a/src/utils/wordpress-globals.js +++ b/src/utils/wordpress-globals.js @@ -67,7 +67,7 @@ import * as wordcount from '@wordpress/wordcount'; * * @return {void} */ -export function initializeWordPressGlobals() { +export async function initializeWordPressGlobals() { window.jQuery = jquery; // Expose jQuery for plugins // Initialize the wp namespace if it doesn't exist @@ -139,4 +139,7 @@ export function initializeWordPressGlobals() { // React JSX runtime for plugin compatibility window.ReactJSXRuntime = ReactJSXRuntime; + + // Load wp-util after jQuery and lodash are on window + await import( '../../vendor/wp-util.js' ); } diff --git a/vendor/wp-util.js b/vendor/wp-util.js new file mode 100644 index 000000000..06725d67c --- /dev/null +++ b/vendor/wp-util.js @@ -0,0 +1,177 @@ +/** + * @output wp-includes/js/wp-util.js + */ + +/* global _wpUtilSettings */ + +/** @namespace wp */ +window.wp = window.wp || {}; + +( function ( $ ) { + // Check for the utility settings. + var settings = + typeof _wpUtilSettings === 'undefined' ? {} : _wpUtilSettings; + + /** + * wp.template( id ) + * + * Fetch a JavaScript template for an id, and return a templating function for it. + * + * @param {string} id A string that corresponds to a DOM element with an id prefixed with "tmpl-". + * For example, "attachment" maps to "tmpl-attachment". + * @return {function} A function that lazily-compiles the template requested. + */ + wp.template = _.memoize( function ( id ) { + var compiled, + /* + * Underscore's default ERB-style templates are incompatible with PHP + * when asp_tags is enabled, so WordPress uses Mustache-inspired templating syntax. + * + * @see trac ticket #22344. + */ + options = { + evaluate: /<#([\s\S]+?)#>/g, + interpolate: /\{\{\{([\s\S]+?)\}\}\}/g, + escape: /\{\{([^\}]+?)\}\}(?!\})/g, + variable: 'data', + }; + + return function ( data ) { + var el = document.querySelector( 'script#tmpl-' + id ); + if ( ! el ) { + throw new Error( 'Template not found: ' + '#tmpl-' + id ); + } + compiled = compiled || _.template( $( el ).html(), options ); + return compiled( data ); + }; + } ); + + /* + * wp.ajax + * ------ + * + * Tools for sending ajax requests with JSON responses and built in error handling. + * Mirrors and wraps jQuery's ajax APIs. + */ + wp.ajax = { + settings: settings.ajax || {}, + + /** + * wp.ajax.post( [action], [data] ) + * + * Sends a POST request to WordPress. + * + * @param {(string|Object)} action The slug of the action to fire in WordPress or options passed + * to jQuery.ajax. + * @param {Object=} data Optional. The data to populate $_POST with. + * @return {$.promise} A jQuery promise that represents the request, + * decorated with an abort() method. + */ + post: function ( action, data ) { + return wp.ajax.send( { + data: _.isObject( action ) + ? action + : _.extend( data || {}, { action: action } ), + } ); + }, + + /** + * wp.ajax.send( [action], [options] ) + * + * Sends a POST request to WordPress. + * + * @param {(string|Object)} action The slug of the action to fire in WordPress or options passed + * to jQuery.ajax. + * @param {Object=} options Optional. The options passed to jQuery.ajax. + * @return {$.promise} A jQuery promise that represents the request, + * decorated with an abort() method. + */ + send: function ( action, options ) { + var promise, deferred; + if ( _.isObject( action ) ) { + options = action; + } else { + options = options || {}; + options.data = _.extend( options.data || {}, { + action: action, + } ); + } + + options = _.defaults( options || {}, { + type: 'POST', + url: wp.ajax.settings.url, + context: this, + } ); + + deferred = $.Deferred( function ( deferred ) { + // Transfer success/error callbacks. + if ( options.success ) { + deferred.done( options.success ); + } + + if ( options.error ) { + deferred.fail( options.error ); + } + + delete options.success; + delete options.error; + + // Use with PHP's wp_send_json_success() and wp_send_json_error(). + deferred.jqXHR = $.ajax( options ) + .done( function ( response ) { + // Treat a response of 1 as successful for backward compatibility with existing handlers. + if ( response === '1' || response === 1 ) { + response = { success: true }; + } + + if ( + _.isObject( response ) && + ! _.isUndefined( response.success ) + ) { + // When handling a media attachments request, get the total attachments from response headers. + var context = this; + deferred.done( function () { + if ( + action && + action.data && + 'query-attachments' === + action.data.action && + deferred.jqXHR.hasOwnProperty( + 'getResponseHeader' + ) && + deferred.jqXHR.getResponseHeader( + 'X-WP-Total' + ) + ) { + context.totalAttachments = parseInt( + deferred.jqXHR.getResponseHeader( + 'X-WP-Total' + ), + 10 + ); + } else { + context.totalAttachments = 0; + } + } ); + deferred[ + response.success ? 'resolveWith' : 'rejectWith' + ]( this, [ response.data ] ); + } else { + deferred.rejectWith( this, [ response ] ); + } + } ) + .fail( function () { + deferred.rejectWith( this, arguments ); + } ); + } ); + + promise = deferred.promise(); + promise.abort = function () { + deferred.jqXHR.abort(); + return this; + }; + + return promise; + }, + }; +} )( jQuery ); From 576cf0ae11116298e38b4a64c17077c046952eae Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 25 Mar 2026 15:09:04 -0400 Subject: [PATCH 14/31] fix: align wp.ajax wrapper signatures with wp-util The wp.ajax.send and wp.ajax.post wrappers accepted a single options argument, but wp-util's implementation accepts (action, options). Align the wrapper signatures so the action argument is forwarded correctly. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/utils/ajax.js | 8 ++++---- src/utils/ajax.test.js | 18 +++++++++--------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/utils/ajax.js b/src/utils/ajax.js index ed4df197b..871f65594 100644 --- a/src/utils/ajax.js +++ b/src/utils/ajax.js @@ -48,7 +48,7 @@ function configureAjaxAuth( authHeader ) { if ( typeof window.wp.ajax.send === 'function' ) { const originalSend = window.wp.ajax.send; - window.wp.ajax.send = function ( options ) { + window.wp.ajax.send = function ( action, options = {} ) { const originalBeforeSend = options.beforeSend; options.beforeSend = function ( xhr ) { @@ -59,13 +59,13 @@ function configureAjaxAuth( authHeader ) { } }; - return originalSend.call( this, options ); + return originalSend.call( this, action, options ); }; } if ( typeof window.wp.ajax.post === 'function' ) { const originalPost = window.wp.ajax.post; - window.wp.ajax.post = function ( options ) { + window.wp.ajax.post = function ( action, options = {} ) { const originalBeforeSend = options.beforeSend; options.beforeSend = function ( xhr ) { @@ -76,7 +76,7 @@ function configureAjaxAuth( authHeader ) { } }; - return originalPost.call( this, options ); + return originalPost.call( this, action, options ); }; } diff --git a/src/utils/ajax.test.js b/src/utils/ajax.test.js index 28cf84cd4..113d5aca2 100644 --- a/src/utils/ajax.test.js +++ b/src/utils/ajax.test.js @@ -170,13 +170,13 @@ describe( 'configureAjax', () => { // Call the wrapped send method const options = { data: 'test' }; - await global.window.wp.ajax.send( options ); + await global.window.wp.ajax.send( 'test_action', options ); // Verify the original was called expect( originalWpAjaxSend ).toHaveBeenCalled(); // Verify beforeSend was added - const calledOptions = originalWpAjaxSend.mock.calls[ 0 ][ 0 ]; + const calledOptions = originalWpAjaxSend.mock.calls[ 0 ][ 1 ]; expect( calledOptions.beforeSend ).toBeDefined(); // Verify auth header is set @@ -197,14 +197,14 @@ describe( 'configureAjax', () => { configureAjax(); // Call the wrapped post method - const options = { action: 'test_action' }; - await global.window.wp.ajax.post( options ); + const options = {}; + await global.window.wp.ajax.post( 'test_action', options ); // Verify the original was called expect( originalWpAjaxPost ).toHaveBeenCalled(); // Verify beforeSend was added - const calledOptions = originalWpAjaxPost.mock.calls[ 0 ][ 0 ]; + const calledOptions = originalWpAjaxPost.mock.calls[ 0 ][ 1 ]; expect( calledOptions.beforeSend ).toBeDefined(); // Verify auth header is set @@ -227,10 +227,10 @@ describe( 'configureAjax', () => { // Call with existing beforeSend const originalBeforeSend = vi.fn(); const options = { beforeSend: originalBeforeSend }; - await global.window.wp.ajax.send( options ); + await global.window.wp.ajax.send( 'test_action', options ); // Get the wrapped beforeSend - const calledOptions = originalWpAjaxSend.mock.calls[ 0 ][ 0 ]; + const calledOptions = originalWpAjaxSend.mock.calls[ 0 ][ 1 ]; const mockXhr = { setRequestHeader: vi.fn() }; calledOptions.beforeSend( mockXhr ); @@ -253,10 +253,10 @@ describe( 'configureAjax', () => { // Call with existing beforeSend const originalBeforeSend = vi.fn(); const options = { beforeSend: originalBeforeSend }; - await global.window.wp.ajax.post( options ); + await global.window.wp.ajax.post( 'test_action', options ); // Get the wrapped beforeSend - const calledOptions = originalWpAjaxPost.mock.calls[ 0 ][ 0 ]; + const calledOptions = originalWpAjaxPost.mock.calls[ 0 ][ 1 ]; const mockXhr = { setRequestHeader: vi.fn() }; calledOptions.beforeSend( mockXhr ); From 6807e8c60c2dd012e9a88eaf157c2e9808b4c092 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 25 Mar 2026 15:41:22 -0400 Subject: [PATCH 15/31] fix: use home URL for iOS demo app site URL Use `homeUrlString()` instead of `siteUrlString()` from the REST API root response. The `url` field often returns `http://` for WordPress.com sites, while `home` returns the actual public-facing `https://` URL. Co-Authored-By: Claude Opus 4.6 (1M context) --- ios/Demo-iOS/Sources/Views/SitePreparationView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift index b2aab0be4..68d6e0b8a 100644 --- a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift +++ b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift @@ -425,7 +425,7 @@ class SitePreparationViewModel { return EditorConfigurationBuilder( postType: selectedPostTypeDetails, - siteURL: URL(string: apiRoot.siteUrlString())!, + siteURL: URL(string: apiRoot.homeUrlString())!, siteApiRoot: siteApiRoot ) .setShouldUseThemeStyles(canUseEditorStyles) From 4c3442c8941d3c2ca8388d71aa38a7f659f58933 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 25 Mar 2026 15:49:43 -0400 Subject: [PATCH 16/31] feat: alias wp.media.ajax and wp.media.post to wp.ajax WordPress core sets these aliases in media-models.js, which GutenbergKit doesn't load. Alias them after auth wrapping so media uploads use the authenticated AJAX methods. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/utils/ajax.js | 23 +++++++++++++++-- src/utils/ajax.test.js | 57 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/utils/ajax.js b/src/utils/ajax.js index 871f65594..d87004ce5 100644 --- a/src/utils/ajax.js +++ b/src/utils/ajax.js @@ -5,8 +5,13 @@ import { getGBKit } from './bridge'; import { warn, debug } from './logger'; /** - * GutenbergKit lacks authentication cookies required for AJAX requests. - * This configures a root URL and authentication header for AJAX requests. + * Configure AJAX for use without authentication cookies. + * + * GutenbergKit runs in a WebView without WordPress session cookies, + * so AJAX requests need explicit URL and token-based authentication. + * Additionally, WordPress core media globals (`wp.media.ajax`, + * `wp.media.post`) are normally set by wp-includes/js/media-models.js, + * which GutenbergKit doesn't load — so we alias them here. * * @return {void} */ @@ -18,6 +23,7 @@ export function configureAjax() { const { siteURL, authHeader } = getGBKit(); configureAjaxUrl( siteURL ); configureAjaxAuth( authHeader ); + configureMediaAjax(); } function configureAjaxUrl( siteURL ) { @@ -82,3 +88,16 @@ function configureAjaxAuth( authHeader ) { debug( 'AJAX auth configured' ); } + +/** + * Alias `wp.media.ajax` and `wp.media.post` to the (now-authenticated) + * `wp.ajax.send` and `wp.ajax.post`. WordPress core normally sets these + * in `wp-includes/js/media-models.js`, which GutenbergKit doesn't load. + * + * @see https://github.com/WordPress/wordpress-develop/blob/117af7e/src/js/_enqueues/wp/media/models.js#L134 + */ +function configureMediaAjax() { + window.wp.media = window.wp.media || {}; + window.wp.media.ajax = window.wp.ajax.send; + window.wp.media.post = window.wp.ajax.post; +} diff --git a/src/utils/ajax.test.js b/src/utils/ajax.test.js index 113d5aca2..7a330100f 100644 --- a/src/utils/ajax.test.js +++ b/src/utils/ajax.test.js @@ -494,4 +494,61 @@ describe( 'configureAjax', () => { ); } ); } ); + + describe( 'Media AJAX configuration', () => { + it( 'should alias wp.media.ajax to the wrapped wp.ajax.send', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer media-token', + } ); + + global.window.wp = { + ajax: { + send: originalWpAjaxSend, + post: originalWpAjaxPost, + settings: {}, + }, + }; + + configureAjax(); + + expect( global.window.wp.media.ajax ).toBe( + global.window.wp.ajax.send + ); + } ); + + it( 'should alias wp.media.post to the wrapped wp.ajax.post', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer media-token', + } ); + + global.window.wp = { + ajax: { + send: originalWpAjaxSend, + post: originalWpAjaxPost, + settings: {}, + }, + }; + + configureAjax(); + + expect( global.window.wp.media.post ).toBe( + global.window.wp.ajax.post + ); + } ); + + it( 'should initialize wp.media if it does not exist', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + global.window.wp = {}; + + configureAjax(); + + expect( global.window.wp.media ).toBeDefined(); + } ); + } ); } ); From 04154416ec021ebc70f49593a61f98ae78728186 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 25 Mar 2026 16:25:53 -0400 Subject: [PATCH 17/31] build: Use wp-util from production WordPress release Avoid including latest changes from the WordPress/wordpress-develop repository. --- vendor/wp-util.js | 131 +++++++++++++++++++--------------------------- 1 file changed, 55 insertions(+), 76 deletions(-) diff --git a/vendor/wp-util.js b/vendor/wp-util.js index 06725d67c..f28653af7 100644 --- a/vendor/wp-util.js +++ b/vendor/wp-util.js @@ -7,10 +7,9 @@ /** @namespace wp */ window.wp = window.wp || {}; -( function ( $ ) { +(function ($) { // Check for the utility settings. - var settings = - typeof _wpUtilSettings === 'undefined' ? {} : _wpUtilSettings; + var settings = typeof _wpUtilSettings === 'undefined' ? {} : _wpUtilSettings; /** * wp.template( id ) @@ -21,7 +20,7 @@ window.wp = window.wp || {}; * For example, "attachment" maps to "tmpl-attachment". * @return {function} A function that lazily-compiles the template requested. */ - wp.template = _.memoize( function ( id ) { + wp.template = _.memoize(function ( id ) { var compiled, /* * Underscore's default ERB-style templates are incompatible with PHP @@ -30,21 +29,20 @@ window.wp = window.wp || {}; * @see trac ticket #22344. */ options = { - evaluate: /<#([\s\S]+?)#>/g, + evaluate: /<#([\s\S]+?)#>/g, interpolate: /\{\{\{([\s\S]+?)\}\}\}/g, - escape: /\{\{([^\}]+?)\}\}(?!\})/g, - variable: 'data', + escape: /\{\{([^\}]+?)\}\}(?!\})/g, + variable: 'data' }; return function ( data ) { - var el = document.querySelector( 'script#tmpl-' + id ); - if ( ! el ) { + if ( ! document.getElementById( 'tmpl-' + id ) ) { throw new Error( 'Template not found: ' + '#tmpl-' + id ); } - compiled = compiled || _.template( $( el ).html(), options ); + compiled = compiled || _.template( $( '#tmpl-' + id ).html(), options ); return compiled( data ); }; - } ); + }); /* * wp.ajax @@ -67,12 +65,10 @@ window.wp = window.wp || {}; * @return {$.promise} A jQuery promise that represents the request, * decorated with an abort() method. */ - post: function ( action, data ) { - return wp.ajax.send( { - data: _.isObject( action ) - ? action - : _.extend( data || {}, { action: action } ), - } ); + post: function( action, data ) { + return wp.ajax.send({ + data: _.isObject( action ) ? action : _.extend( data || {}, { action: action }) + }); }, /** @@ -86,24 +82,22 @@ window.wp = window.wp || {}; * @return {$.promise} A jQuery promise that represents the request, * decorated with an abort() method. */ - send: function ( action, options ) { + send: function( action, options ) { var promise, deferred; if ( _.isObject( action ) ) { options = action; } else { options = options || {}; - options.data = _.extend( options.data || {}, { - action: action, - } ); + options.data = _.extend( options.data || {}, { action: action }); } options = _.defaults( options || {}, { - type: 'POST', - url: wp.ajax.settings.url, - context: this, - } ); + type: 'POST', + url: wp.ajax.settings.url, + context: this + }); - deferred = $.Deferred( function ( deferred ) { + deferred = $.Deferred( function( deferred ) { // Transfer success/error callbacks. if ( options.success ) { deferred.done( options.success ); @@ -117,61 +111,46 @@ window.wp = window.wp || {}; delete options.error; // Use with PHP's wp_send_json_success() and wp_send_json_error(). - deferred.jqXHR = $.ajax( options ) - .done( function ( response ) { - // Treat a response of 1 as successful for backward compatibility with existing handlers. - if ( response === '1' || response === 1 ) { - response = { success: true }; - } - - if ( - _.isObject( response ) && - ! _.isUndefined( response.success ) - ) { - // When handling a media attachments request, get the total attachments from response headers. - var context = this; - deferred.done( function () { - if ( - action && - action.data && - 'query-attachments' === - action.data.action && - deferred.jqXHR.hasOwnProperty( - 'getResponseHeader' - ) && - deferred.jqXHR.getResponseHeader( - 'X-WP-Total' - ) - ) { - context.totalAttachments = parseInt( - deferred.jqXHR.getResponseHeader( - 'X-WP-Total' - ), - 10 - ); - } else { - context.totalAttachments = 0; - } - } ); - deferred[ - response.success ? 'resolveWith' : 'rejectWith' - ]( this, [ response.data ] ); - } else { - deferred.rejectWith( this, [ response ] ); - } - } ) - .fail( function () { - deferred.rejectWith( this, arguments ); - } ); - } ); + deferred.jqXHR = $.ajax( options ).done( function( response ) { + // Treat a response of 1 as successful for backward compatibility with existing handlers. + if ( response === '1' || response === 1 ) { + response = { success: true }; + } + + if ( _.isObject( response ) && ! _.isUndefined( response.success ) ) { + + // When handling a media attachments request, get the total attachments from response headers. + var context = this; + deferred.done( function() { + if ( + action && + action.data && + 'query-attachments' === action.data.action && + deferred.jqXHR.hasOwnProperty( 'getResponseHeader' ) && + deferred.jqXHR.getResponseHeader( 'X-WP-Total' ) + ) { + context.totalAttachments = parseInt( deferred.jqXHR.getResponseHeader( 'X-WP-Total' ), 10 ); + } else { + context.totalAttachments = 0; + } + } ); + deferred[ response.success ? 'resolveWith' : 'rejectWith' ]( this, [response.data] ); + } else { + deferred.rejectWith( this, [response] ); + } + }).fail( function() { + deferred.rejectWith( this, arguments ); + }); + }); promise = deferred.promise(); - promise.abort = function () { + promise.abort = function() { deferred.jqXHR.abort(); return this; }; return promise; - }, + } }; -} )( jQuery ); + +}(jQuery)); \ No newline at end of file From c5b6964a33335129026eb4d7999331466ca710df Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 25 Mar 2026 16:40:05 -0400 Subject: [PATCH 18/31] docs: Expand inline comments --- src/utils/editor-loader.js | 18 +++++++++++++----- src/utils/videopress-bridge.js | 2 ++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/utils/editor-loader.js b/src/utils/editor-loader.js index c04907eaf..18d4a6daf 100644 --- a/src/utils/editor-loader.js +++ b/src/utils/editor-loader.js @@ -61,6 +61,13 @@ async function processEditorAssets( assets ) { * Load the asset files for a block * * @param {string} html The HTML content to parse for assets. + * + * @todo Remove the core and Gutenberg asset filtering once the relevant Jetpack + * plugin release is available that excludes these assets from the editor assets + * endpoint response. See: https://github.com/Automattic/jetpack/pull/45715 + * + * @todo Replace the client `lodash-js-after` script filtering with more robust + * server-side filtering in the editor assets endpoint. */ async function loadAssets( html ) { const doc = new window.DOMParser().parseFromString( html, 'text/html' ); @@ -69,8 +76,6 @@ async function loadAssets( html ) { doc.querySelectorAll( 'link[rel="stylesheet"],script' ) ).filter( ( asset ) => { /** - * TODO: Remove this once the relevant Jetpack plugin release is available. - * * Exclude WordPress core and Gutenberg assets to avoid loading duplicate * assets, which causes editor loading failures. * @@ -87,9 +92,12 @@ async function loadAssets( html ) { return false; } - // WordPress's lodash-js-after inline script calls _.noConflict() to - // restore window._ to Underscore.js. GutenbergKit doesn't load - // Underscore, so this wipes window._ to undefined. + /** + * WordPress's lodash-js-after inline script calls _.noConflict() to + * restore window._ to Underscore.js. GutenbergKit doesn't load + * Underscore, so this wipes window._ to undefined. Ideally, this filtering + * occurs in the editor assets endpoint. + */ if ( asset.id === 'lodash-js-after' ) { return false; } diff --git a/src/utils/videopress-bridge.js b/src/utils/videopress-bridge.js index 7bed2ee2b..fd9fa54b5 100644 --- a/src/utils/videopress-bridge.js +++ b/src/utils/videopress-bridge.js @@ -18,6 +18,8 @@ import { warn, debug, error } from './logger'; * This function overrides wp.media.ajax to intercept VideoPress-specific * AJAX requests and redirect them to the appropriate REST API endpoints. * + * @todo Remove this bridge once the `videopress/video` block type is allowed and stable. + * * @return {void} */ export function initializeVideoPressAjaxBridge() { From f5df112ca454d9c5581e7130503e3a7005586528 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 25 Mar 2026 17:07:12 -0400 Subject: [PATCH 19/31] refactor: scope AJAX auth to same-site requests via ajaxPrefilter Replace jQuery.ajaxSetup and wp.ajax.send/post wrappers with a single jQuery.ajaxPrefilter that only injects the Authorization header when the request URL starts with the configured siteURL. This prevents leaking credentials to cross-origin requests and avoids argument normalization issues with the previous wp.ajax wrapper approach. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/utils/ajax.js | 54 +++------ src/utils/ajax.test.js | 256 +++++++++++------------------------------ 2 files changed, 82 insertions(+), 228 deletions(-) diff --git a/src/utils/ajax.js b/src/utils/ajax.js index d87004ce5..6abd62b2f 100644 --- a/src/utils/ajax.js +++ b/src/utils/ajax.js @@ -22,7 +22,7 @@ export function configureAjax() { const { siteURL, authHeader } = getGBKit(); configureAjaxUrl( siteURL ); - configureAjaxAuth( authHeader ); + configureAjaxAuth( siteURL, authHeader ); configureMediaAjax(); } @@ -40,51 +40,25 @@ function configureAjaxUrl( siteURL ) { debug( 'AJAX URL configured' ); } -function configureAjaxAuth( authHeader ) { +function configureAjaxAuth( siteURL, authHeader ) { if ( ! authHeader ) { warn( 'Unable to configure AJAX auth without authHeader' ); return; } - window.jQuery?.ajaxSetup( { - headers: { - Authorization: authHeader, - }, - } ); - - if ( typeof window.wp.ajax.send === 'function' ) { - const originalSend = window.wp.ajax.send; - window.wp.ajax.send = function ( action, options = {} ) { - const originalBeforeSend = options.beforeSend; - - options.beforeSend = function ( xhr ) { - xhr.setRequestHeader( 'Authorization', authHeader ); - - if ( typeof originalBeforeSend === 'function' ) { - originalBeforeSend( xhr ); - } - }; - - return originalSend.call( this, action, options ); + window.jQuery?.ajaxPrefilter( function ( options ) { + if ( ! options.url?.startsWith( siteURL ) ) { + return; + } + + const originalBeforeSend = options.beforeSend; + options.beforeSend = function ( xhr ) { + xhr.setRequestHeader( 'Authorization', authHeader ); + if ( typeof originalBeforeSend === 'function' ) { + originalBeforeSend( xhr ); + } }; - } - - if ( typeof window.wp.ajax.post === 'function' ) { - const originalPost = window.wp.ajax.post; - window.wp.ajax.post = function ( action, options = {} ) { - const originalBeforeSend = options.beforeSend; - - options.beforeSend = function ( xhr ) { - xhr.setRequestHeader( 'Authorization', authHeader ); - - if ( typeof originalBeforeSend === 'function' ) { - originalBeforeSend( xhr ); - } - }; - - return originalPost.call( this, action, options ); - }; - } + } ); debug( 'AJAX auth configured' ); } diff --git a/src/utils/ajax.test.js b/src/utils/ajax.test.js index 7a330100f..c5964c0f3 100644 --- a/src/utils/ajax.test.js +++ b/src/utils/ajax.test.js @@ -15,9 +15,7 @@ vi.mock( './logger' ); describe( 'configureAjax', () => { let originalWindow; - let mockJQueryAjaxSetup; - let originalWpAjaxSend; - let originalWpAjaxPost; + let mockJQueryAjaxPrefilter; beforeEach( () => { vi.clearAllMocks(); @@ -34,29 +32,10 @@ describe( 'configureAjax', () => { global.window.ajaxurl = undefined; // Mock jQuery - mockJQueryAjaxSetup = vi.fn(); + mockJQueryAjaxPrefilter = vi.fn(); global.window.jQuery = { - ajaxSetup: mockJQueryAjaxSetup, + ajaxPrefilter: mockJQueryAjaxPrefilter, }; - - // Create mock functions for wp.ajax methods - originalWpAjaxSend = vi.fn( ( options ) => { - // Simulate calling beforeSend if it exists - if ( options?.beforeSend ) { - const mockXhr = { setRequestHeader: vi.fn() }; - options.beforeSend( mockXhr ); - } - return Promise.resolve(); - } ); - - originalWpAjaxPost = vi.fn( ( options ) => { - // Simulate calling beforeSend if it exists - if ( options?.beforeSend ) { - const mockXhr = { setRequestHeader: vi.fn() }; - options.beforeSend( mockXhr ); - } - return Promise.resolve(); - } ); } ); afterEach( () => { @@ -131,139 +110,82 @@ describe( 'configureAjax', () => { } ); describe( 'Auth configuration', () => { - beforeEach( () => { - // Setup wp.ajax with original methods - global.window.wp = { - ajax: { - send: originalWpAjaxSend, - post: originalWpAjaxPost, - settings: {}, - }, - }; - } ); - - it( 'should configure jQuery ajax with auth header', () => { + it( 'should register a jQuery ajaxPrefilter', () => { bridge.getGBKit.mockReturnValue( { - siteURL: null, + siteURL: 'https://example.com', authHeader: 'Bearer test-token', } ); configureAjax(); - expect( mockJQueryAjaxSetup ).toHaveBeenCalledWith( { - headers: { - Authorization: 'Bearer test-token', - }, - } ); + expect( mockJQueryAjaxPrefilter ).toHaveBeenCalledWith( + expect.any( Function ) + ); expect( logger.debug ).toHaveBeenCalledWith( 'AJAX auth configured' ); } ); - it( 'should wrap wp.ajax.send with auth header', async () => { + it( 'should inject auth header for same-site requests', () => { bridge.getGBKit.mockReturnValue( { - siteURL: null, - authHeader: 'Bearer send-token', + siteURL: 'https://example.com', + authHeader: 'Bearer test-token', } ); configureAjax(); - // Call the wrapped send method - const options = { data: 'test' }; - await global.window.wp.ajax.send( 'test_action', options ); - - // Verify the original was called - expect( originalWpAjaxSend ).toHaveBeenCalled(); - - // Verify beforeSend was added - const calledOptions = originalWpAjaxSend.mock.calls[ 0 ][ 1 ]; - expect( calledOptions.beforeSend ).toBeDefined(); + const prefilter = mockJQueryAjaxPrefilter.mock.calls[ 0 ][ 0 ]; + const options = { + url: 'https://example.com/wp-admin/admin-ajax.php', + }; + prefilter( options ); - // Verify auth header is set const mockXhr = { setRequestHeader: vi.fn() }; - calledOptions.beforeSend( mockXhr ); - expect( mockXhr.setRequestHeader ).toHaveBeenCalledWith( - 'Authorization', - 'Bearer send-token' - ); - } ); + options.beforeSend( mockXhr ); - it( 'should wrap wp.ajax.post with auth header', async () => { - bridge.getGBKit.mockReturnValue( { - siteURL: null, - authHeader: 'Bearer post-token', - } ); - - configureAjax(); - - // Call the wrapped post method - const options = {}; - await global.window.wp.ajax.post( 'test_action', options ); - - // Verify the original was called - expect( originalWpAjaxPost ).toHaveBeenCalled(); - - // Verify beforeSend was added - const calledOptions = originalWpAjaxPost.mock.calls[ 0 ][ 1 ]; - expect( calledOptions.beforeSend ).toBeDefined(); - - // Verify auth header is set - const mockXhr = { setRequestHeader: vi.fn() }; - calledOptions.beforeSend( mockXhr ); expect( mockXhr.setRequestHeader ).toHaveBeenCalledWith( 'Authorization', - 'Bearer post-token' + 'Bearer test-token' ); } ); - it( 'should preserve original beforeSend in wp.ajax.send', async () => { + it( 'should not inject auth header for cross-origin requests', () => { bridge.getGBKit.mockReturnValue( { - siteURL: null, - authHeader: 'Bearer preserve-token', + siteURL: 'https://example.com', + authHeader: 'Bearer test-token', } ); configureAjax(); - // Call with existing beforeSend - const originalBeforeSend = vi.fn(); - const options = { beforeSend: originalBeforeSend }; - await global.window.wp.ajax.send( 'test_action', options ); - - // Get the wrapped beforeSend - const calledOptions = originalWpAjaxSend.mock.calls[ 0 ][ 1 ]; - const mockXhr = { setRequestHeader: vi.fn() }; - calledOptions.beforeSend( mockXhr ); + const prefilter = mockJQueryAjaxPrefilter.mock.calls[ 0 ][ 0 ]; + const options = { url: 'https://evil.com/steal' }; + prefilter( options ); - // Verify both auth header and original beforeSend were called - expect( mockXhr.setRequestHeader ).toHaveBeenCalledWith( - 'Authorization', - 'Bearer preserve-token' - ); - expect( originalBeforeSend ).toHaveBeenCalledWith( mockXhr ); + expect( options.beforeSend ).toBeUndefined(); } ); - it( 'should preserve original beforeSend in wp.ajax.post', async () => { + it( 'should preserve original beforeSend', () => { bridge.getGBKit.mockReturnValue( { - siteURL: null, - authHeader: 'Bearer preserve-post-token', + siteURL: 'https://example.com', + authHeader: 'Bearer test-token', } ); configureAjax(); - // Call with existing beforeSend + const prefilter = mockJQueryAjaxPrefilter.mock.calls[ 0 ][ 0 ]; const originalBeforeSend = vi.fn(); - const options = { beforeSend: originalBeforeSend }; - await global.window.wp.ajax.post( 'test_action', options ); + const options = { + url: 'https://example.com/wp-admin/admin-ajax.php', + beforeSend: originalBeforeSend, + }; + prefilter( options ); - // Get the wrapped beforeSend - const calledOptions = originalWpAjaxPost.mock.calls[ 0 ][ 1 ]; const mockXhr = { setRequestHeader: vi.fn() }; - calledOptions.beforeSend( mockXhr ); + options.beforeSend( mockXhr ); - // Verify both auth header and original beforeSend were called expect( mockXhr.setRequestHeader ).toHaveBeenCalledWith( 'Authorization', - 'Bearer preserve-post-token' + 'Bearer test-token' ); expect( originalBeforeSend ).toHaveBeenCalledWith( mockXhr ); } ); @@ -279,7 +201,7 @@ describe( 'configureAjax', () => { expect( logger.warn ).toHaveBeenCalledWith( 'Unable to configure AJAX auth without authHeader' ); - expect( mockJQueryAjaxSetup ).not.toHaveBeenCalled(); + expect( mockJQueryAjaxPrefilter ).not.toHaveBeenCalled(); } ); it( 'should handle undefined authHeader', () => { @@ -292,7 +214,7 @@ describe( 'configureAjax', () => { expect( logger.warn ).toHaveBeenCalledWith( 'Unable to configure AJAX auth without authHeader' ); - expect( mockJQueryAjaxSetup ).not.toHaveBeenCalled(); + expect( mockJQueryAjaxPrefilter ).not.toHaveBeenCalled(); } ); } ); @@ -303,15 +225,6 @@ describe( 'configureAjax', () => { authHeader: 'Bearer full-token', } ); - // Setup wp.ajax with methods - global.window.wp = { - ajax: { - send: originalWpAjaxSend, - post: originalWpAjaxPost, - settings: {}, - }, - }; - configureAjax(); // Check URL configuration @@ -323,11 +236,9 @@ describe( 'configureAjax', () => { ); // Check auth configuration - expect( mockJQueryAjaxSetup ).toHaveBeenCalledWith( { - headers: { - Authorization: 'Bearer full-token', - }, - } ); + expect( mockJQueryAjaxPrefilter ).toHaveBeenCalledWith( + expect.any( Function ) + ); // Check debug logs expect( logger.debug ).toHaveBeenCalledWith( @@ -387,50 +298,6 @@ describe( 'configureAjax', () => { ); } ); - it( 'should handle missing wp.ajax.send method', () => { - bridge.getGBKit.mockReturnValue( { - siteURL: 'https://example.com', - authHeader: 'Bearer no-send', - } ); - - global.window.wp = { - ajax: { - post: originalWpAjaxPost, - settings: {}, - }, - }; - - expect( () => configureAjax() ).not.toThrow(); - - // Should not wrap send (it doesn't exist) - expect( global.window.wp.ajax.send ).toBeUndefined(); - - // Should still wrap post - expect( global.window.wp.ajax.post ).not.toBe( originalWpAjaxPost ); - } ); - - it( 'should handle missing wp.ajax.post method', () => { - bridge.getGBKit.mockReturnValue( { - siteURL: 'https://example.com', - authHeader: 'Bearer no-post', - } ); - - global.window.wp = { - ajax: { - send: originalWpAjaxSend, - settings: {}, - }, - }; - - expect( () => configureAjax() ).not.toThrow(); - - // Should not wrap post (it doesn't exist) - expect( global.window.wp.ajax.post ).toBeUndefined(); - - // Should still wrap send - expect( global.window.wp.ajax.send ).not.toBe( originalWpAjaxSend ); - } ); - it( 'should handle missing wp.ajax entirely', () => { bridge.getGBKit.mockReturnValue( { siteURL: 'https://example.com', @@ -493,49 +360,62 @@ describe( 'configureAjax', () => { 'https://example.com/wp-admin/admin-ajax.php' ); } ); + + it( 'should not modify options when URL is missing', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer test-token', + } ); + + configureAjax(); + + const prefilter = mockJQueryAjaxPrefilter.mock.calls[ 0 ][ 0 ]; + const options = {}; + prefilter( options ); + + expect( options.beforeSend ).toBeUndefined(); + } ); } ); describe( 'Media AJAX configuration', () => { - it( 'should alias wp.media.ajax to the wrapped wp.ajax.send', () => { + it( 'should alias wp.media.ajax to wp.ajax.send', () => { + const mockSend = vi.fn(); bridge.getGBKit.mockReturnValue( { siteURL: 'https://example.com', - authHeader: 'Bearer media-token', + authHeader: null, } ); global.window.wp = { ajax: { - send: originalWpAjaxSend, - post: originalWpAjaxPost, + send: mockSend, + post: vi.fn(), settings: {}, }, }; configureAjax(); - expect( global.window.wp.media.ajax ).toBe( - global.window.wp.ajax.send - ); + expect( global.window.wp.media.ajax ).toBe( mockSend ); } ); - it( 'should alias wp.media.post to the wrapped wp.ajax.post', () => { + it( 'should alias wp.media.post to wp.ajax.post', () => { + const mockPost = vi.fn(); bridge.getGBKit.mockReturnValue( { siteURL: 'https://example.com', - authHeader: 'Bearer media-token', + authHeader: null, } ); global.window.wp = { ajax: { - send: originalWpAjaxSend, - post: originalWpAjaxPost, + send: vi.fn(), + post: mockPost, settings: {}, }, }; configureAjax(); - expect( global.window.wp.media.post ).toBe( - global.window.wp.ajax.post - ); + expect( global.window.wp.media.post ).toBe( mockPost ); } ); it( 'should initialize wp.media if it does not exist', () => { From d58aed2834c7ed224c6853ac7ac1394c1a182e9c Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 25 Mar 2026 17:08:33 -0400 Subject: [PATCH 20/31] fix: strip trailing slash from siteURL before building AJAX URLs Prevents double-slash in constructed URLs (e.g., `https://example.com//wp-admin/admin-ajax.php`) when siteURL is provided with a trailing slash. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/utils/ajax.js | 3 ++- src/utils/ajax.test.js | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/utils/ajax.js b/src/utils/ajax.js index 6abd62b2f..b52f33297 100644 --- a/src/utils/ajax.js +++ b/src/utils/ajax.js @@ -20,7 +20,8 @@ export function configureAjax() { window.wp.ajax = window.wp.ajax || {}; window.wp.ajax.settings = window.wp.ajax.settings || {}; - const { siteURL, authHeader } = getGBKit(); + const { siteURL: rawSiteURL, authHeader } = getGBKit(); + const siteURL = rawSiteURL?.replace( /\/+$/, '' ); configureAjaxUrl( siteURL ); configureAjaxAuth( siteURL, authHeader ); configureMediaAjax(); diff --git a/src/utils/ajax.test.js b/src/utils/ajax.test.js index c5964c0f3..b240f2ea1 100644 --- a/src/utils/ajax.test.js +++ b/src/utils/ajax.test.js @@ -65,6 +65,22 @@ describe( 'configureAjax', () => { ); } ); + it( 'should strip trailing slash from siteURL', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com/', + authHeader: null, + } ); + + configureAjax(); + + expect( global.window.ajaxurl ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + expect( global.window.wp.ajax.settings.url ).toBe( + 'https://example.com/wp-admin/admin-ajax.php' + ); + } ); + it( 'should log warning when siteURL is missing', () => { bridge.getGBKit.mockReturnValue( { siteURL: null, From 83fc12513127bbc2b53365660293fdae6d48ab4a Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Mar 2026 11:00:04 -0400 Subject: [PATCH 21/31] fix: warn instead of logging success when jQuery is unavailable for AJAX auth The ajaxPrefilter silently no-ops via optional chaining when jQuery is missing, but the debug log still claims auth was configured. Guard with an early return and warning so the log accurately reflects what happened. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/utils/ajax.js | 7 ++++++- src/utils/ajax.test.js | 14 ++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/utils/ajax.js b/src/utils/ajax.js index b52f33297..4fa9a5f43 100644 --- a/src/utils/ajax.js +++ b/src/utils/ajax.js @@ -47,7 +47,12 @@ function configureAjaxAuth( siteURL, authHeader ) { return; } - window.jQuery?.ajaxPrefilter( function ( options ) { + if ( ! window.jQuery?.ajaxPrefilter ) { + warn( 'Unable to configure AJAX auth: jQuery not available' ); + return; + } + + window.jQuery.ajaxPrefilter( function ( options ) { if ( ! options.url?.startsWith( siteURL ) ) { return; } diff --git a/src/utils/ajax.test.js b/src/utils/ajax.test.js index b240f2ea1..5d322c42a 100644 --- a/src/utils/ajax.test.js +++ b/src/utils/ajax.test.js @@ -280,7 +280,7 @@ describe( 'configureAjax', () => { } ); describe( 'Edge cases', () => { - it( 'should handle missing jQuery gracefully', () => { + it( 'should warn when jQuery is missing', () => { bridge.getGBKit.mockReturnValue( { siteURL: 'https://example.com', authHeader: 'Bearer no-jquery', @@ -292,12 +292,15 @@ describe( 'configureAjax', () => { expect( logger.debug ).toHaveBeenCalledWith( 'AJAX URL configured' ); - expect( logger.debug ).toHaveBeenCalledWith( + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX auth: jQuery not available' + ); + expect( logger.debug ).not.toHaveBeenCalledWith( 'AJAX auth configured' ); } ); - it( 'should handle undefined jQuery', () => { + it( 'should warn when jQuery is undefined', () => { bridge.getGBKit.mockReturnValue( { siteURL: 'https://example.com', authHeader: 'Bearer undefined-jquery', @@ -309,7 +312,10 @@ describe( 'configureAjax', () => { expect( logger.debug ).toHaveBeenCalledWith( 'AJAX URL configured' ); - expect( logger.debug ).toHaveBeenCalledWith( + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX auth: jQuery not available' + ); + expect( logger.debug ).not.toHaveBeenCalledWith( 'AJAX auth configured' ); } ); From fcb130fc55a4d24bce6e10c48ca433c6f46c52e6 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Mar 2026 11:01:44 -0400 Subject: [PATCH 22/31] fix: use origin-based matching for AJAX auth header injection Replace `startsWith(siteURL)` with `URL.origin` comparison so that scheme, host, and port must all match exactly. This prevents credential leakage to lookalike domains (e.g. `https://example.com.evil.com`). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/utils/ajax.js | 28 +++++++++++++++++++++++++++- src/utils/ajax.test.js | 23 ++++++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/utils/ajax.js b/src/utils/ajax.js index 4fa9a5f43..82584ac76 100644 --- a/src/utils/ajax.js +++ b/src/utils/ajax.js @@ -42,6 +42,11 @@ function configureAjaxUrl( siteURL ) { } function configureAjaxAuth( siteURL, authHeader ) { + if ( ! siteURL ) { + warn( 'Unable to configure AJAX auth without siteURL' ); + return; + } + if ( ! authHeader ) { warn( 'Unable to configure AJAX auth without authHeader' ); return; @@ -52,8 +57,10 @@ function configureAjaxAuth( siteURL, authHeader ) { return; } + const siteOrigin = new URL( siteURL ).origin; + window.jQuery.ajaxPrefilter( function ( options ) { - if ( ! options.url?.startsWith( siteURL ) ) { + if ( ! isSameOrigin( options.url, siteOrigin ) ) { return; } @@ -69,6 +76,25 @@ function configureAjaxAuth( siteURL, authHeader ) { debug( 'AJAX auth configured' ); } +/** + * Check whether a request URL shares the same origin as the site. + * + * Uses `URL.origin` so that scheme, host, and port must all match exactly, + * preventing credential leakage to lookalike domains (e.g. + * `https://example.com.evil.com`). + * + * @param {string} requestUrl The URL of the outgoing request. + * @param {string} siteOrigin The origin derived from `siteURL`. + * @return {boolean} Whether the request targets the same origin. + */ +function isSameOrigin( requestUrl, siteOrigin ) { + try { + return new URL( requestUrl ).origin === siteOrigin; + } catch { + return false; + } +} + /** * Alias `wp.media.ajax` and `wp.media.post` to the (now-authenticated) * `wp.ajax.send` and `wp.ajax.post`. WordPress core normally sets these diff --git a/src/utils/ajax.test.js b/src/utils/ajax.test.js index 5d322c42a..dfb4f3561 100644 --- a/src/utils/ajax.test.js +++ b/src/utils/ajax.test.js @@ -92,6 +92,9 @@ describe( 'configureAjax', () => { expect( logger.warn ).toHaveBeenCalledWith( 'Unable to configure AJAX URL without siteURL' ); + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX auth without siteURL' + ); expect( global.window.ajaxurl ).toBeUndefined(); } ); @@ -105,6 +108,9 @@ describe( 'configureAjax', () => { expect( logger.warn ).toHaveBeenCalledWith( 'Unable to configure AJAX URL without siteURL' ); + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX auth without siteURL' + ); expect( global.window.ajaxurl ).toBeUndefined(); } ); @@ -180,6 +186,21 @@ describe( 'configureAjax', () => { expect( options.beforeSend ).toBeUndefined(); } ); + it( 'should not inject auth header for lookalike subdomain prefixes', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: 'Bearer test-token', + } ); + + configureAjax(); + + const prefilter = mockJQueryAjaxPrefilter.mock.calls[ 0 ][ 0 ]; + const options = { url: 'https://example.com.evil.com/steal' }; + prefilter( options ); + + expect( options.beforeSend ).toBeUndefined(); + } ); + it( 'should preserve original beforeSend', () => { bridge.getGBKit.mockReturnValue( { siteURL: 'https://example.com', @@ -274,7 +295,7 @@ describe( 'configureAjax', () => { 'Unable to configure AJAX URL without siteURL' ); expect( logger.warn ).toHaveBeenCalledWith( - 'Unable to configure AJAX auth without authHeader' + 'Unable to configure AJAX auth without siteURL' ); } ); } ); From 4c9000746e012dd4b8260ba279245243b98468c6 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Mar 2026 11:06:16 -0400 Subject: [PATCH 23/31] docs: separate AJAX config examples from Android requirement Move the iOS and Android code examples out of the Android-specific requirement so they are not visually nested under that bullet point. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/integration.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/integration.md b/docs/integration.md index 973c39c73..400de998b 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -304,9 +304,9 @@ Some Gutenberg blocks and features use WordPress AJAX (`admin-ajax.php`) for fun 3. **Set authentication header**: The `authHeader` configuration must be set. GutenbergKit injects this header into all AJAX requests since the WebView lacks WordPress authentication cookies. -4. **Android: Configure `assetLoaderDomain`**: On Android, you must set the `assetLoaderDomain` to a domain that your WordPress site/plugin allows. This is because Android's WebViewAssetLoader serves the editor from a configurable domain, and AJAX requests must pass CORS validation on your server. +4. **Android: Configure `assetLoaderDomain`**: On Android, you must set the `assetLoaderDomain` to a domain that your WordPress site/plugin allows. This is because Android's WebViewAssetLoader serves the editor from a configurable domain, and AJAX requests must pass CORS validation on your server. For example, the Jetpack mobile plugin allows requests from `android-app-assets.jetpack.com`. - For example, the Jetpack mobile plugin allows requests from `android-app-assets.jetpack.com`: +**Configuration examples:** ```swift // iOS - siteURL and authHeader are required From 50361f0eeb0fae269959ec3a59aad7ff61f4dd59 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Mar 2026 12:28:46 -0400 Subject: [PATCH 24/31] refactor: remove redundant AJAX setup from VideoPress bridge configureAjax() now initializes wp.ajax, wp.ajax.settings, and the AJAX URL before the VideoPress bridge runs, making the duplicate setup in initializeVideoPressAjaxBridge() unnecessary. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/utils/videopress-bridge.js | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/utils/videopress-bridge.js b/src/utils/videopress-bridge.js index fd9fa54b5..23418c8ad 100644 --- a/src/utils/videopress-bridge.js +++ b/src/utils/videopress-bridge.js @@ -1,7 +1,6 @@ /** * Internal dependencies */ -import { getGBKit } from './bridge'; import { warn, debug, error } from './logger'; /** @@ -29,16 +28,6 @@ export function initializeVideoPressAjaxBridge() { return; } - // Initialize wp.ajax if not already present - window.wp.ajax = window.wp.ajax || {}; - window.wp.ajax.settings = window.wp.ajax.settings || {}; - - // Set up AJAX settings with site URL - const { siteURL } = getGBKit(); - if ( siteURL ) { - window.wp.ajax.settings.url = `${ siteURL }/wp-admin/admin-ajax.php`; - } - // Store original wp.media.ajax function if it exists const originalMediaAjax = window.wp.media?.ajax; From f4abf21ba62e33d07a29c34b41ef64f3126aa087 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Mar 2026 12:29:06 -0400 Subject: [PATCH 25/31] fix: add defensive guards to AJAX configuration - Wrap `new URL(siteURL)` in try/catch so a malformed siteURL logs a warning instead of throwing. - Guard `configureMediaAjax` against missing `wp.ajax.send`/`post` (e.g., if wp-util.js failed to load). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/utils/ajax.js | 15 +++++++++++++- src/utils/ajax.test.js | 44 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/utils/ajax.js b/src/utils/ajax.js index 82584ac76..36a9e5019 100644 --- a/src/utils/ajax.js +++ b/src/utils/ajax.js @@ -57,7 +57,13 @@ function configureAjaxAuth( siteURL, authHeader ) { return; } - const siteOrigin = new URL( siteURL ).origin; + let siteOrigin; + try { + siteOrigin = new URL( siteURL ).origin; + } catch { + warn( 'Unable to configure AJAX auth: invalid siteURL' ); + return; + } window.jQuery.ajaxPrefilter( function ( options ) { if ( ! isSameOrigin( options.url, siteOrigin ) ) { @@ -103,6 +109,13 @@ function isSameOrigin( requestUrl, siteOrigin ) { * @see https://github.com/WordPress/wordpress-develop/blob/117af7e/src/js/_enqueues/wp/media/models.js#L134 */ function configureMediaAjax() { + if ( ! window.wp.ajax.send || ! window.wp.ajax.post ) { + warn( + 'Unable to configure media AJAX: wp.ajax.send/post not available' + ); + return; + } + window.wp.media = window.wp.media || {}; window.wp.media.ajax = window.wp.ajax.send; window.wp.media.post = window.wp.ajax.post; diff --git a/src/utils/ajax.test.js b/src/utils/ajax.test.js index dfb4f3561..73d6abc04 100644 --- a/src/utils/ajax.test.js +++ b/src/utils/ajax.test.js @@ -461,7 +461,7 @@ describe( 'configureAjax', () => { expect( global.window.wp.media.post ).toBe( mockPost ); } ); - it( 'should initialize wp.media if it does not exist', () => { + it( 'should not initialize wp.media when wp.ajax.send is unavailable', () => { bridge.getGBKit.mockReturnValue( { siteURL: 'https://example.com', authHeader: null, @@ -471,7 +471,47 @@ describe( 'configureAjax', () => { configureAjax(); - expect( global.window.wp.media ).toBeDefined(); + expect( global.window.wp.media ).toBeUndefined(); + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure media AJAX: wp.ajax.send/post not available' + ); + } ); + + it( 'should warn when wp.ajax.send or wp.ajax.post are not available', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'https://example.com', + authHeader: null, + } ); + + // wp.ajax exists but without send/post (e.g., wp-util.js failed to load) + global.window.wp = { + ajax: { + settings: {}, + }, + }; + + configureAjax(); + + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure media AJAX: wp.ajax.send/post not available' + ); + expect( global.window.wp.media ).toBeUndefined(); + } ); + } ); + + describe( 'Invalid siteURL handling', () => { + it( 'should warn when siteURL is not a valid URL for auth config', () => { + bridge.getGBKit.mockReturnValue( { + siteURL: 'not-a-url', + authHeader: 'Bearer test-token', + } ); + + configureAjax(); + + expect( logger.warn ).toHaveBeenCalledWith( + 'Unable to configure AJAX auth: invalid siteURL' + ); + expect( mockJQueryAjaxPrefilter ).not.toHaveBeenCalled(); } ); } ); } ); From c1af7d327c01ac74c31019be4bcbf9e0ea0f4162 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Mar 2026 12:29:14 -0400 Subject: [PATCH 26/31] fix: validate assetLoaderDomain in Android EditorConfiguration Throw IllegalArgumentException if the value contains a scheme, path, or is blank, so callers get a clear error instead of a malformed asset URL at runtime. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../gutenberg/model/EditorConfiguration.kt | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt index d36dbb171..87bf2d778 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt @@ -97,7 +97,20 @@ data class EditorConfiguration( fun setEditorAssetsEndpoint(editorAssetsEndpoint: String?) = apply { this.editorAssetsEndpoint = editorAssetsEndpoint } fun setEnableNetworkLogging(enableNetworkLogging: Boolean) = apply { this.enableNetworkLogging = enableNetworkLogging } fun setEnableOfflineMode(enableOfflineMode: Boolean) = apply { this.enableOfflineMode = enableOfflineMode } - fun setAssetLoaderDomain(assetLoaderDomain: String?) = apply { this.assetLoaderDomain = assetLoaderDomain } + fun setAssetLoaderDomain(assetLoaderDomain: String?) = apply { + if (assetLoaderDomain != null) { + require(!assetLoaderDomain.contains("://")) { + "assetLoaderDomain must be a bare domain (e.g., \"assets.example.com\"), not a URL" + } + require(!assetLoaderDomain.contains("/")) { + "assetLoaderDomain must be a bare domain without a path" + } + require(assetLoaderDomain.isNotBlank()) { + "assetLoaderDomain must not be blank" + } + } + this.assetLoaderDomain = assetLoaderDomain + } fun build(): EditorConfiguration = EditorConfiguration( title = title, From fb76ac70f44c55a922a019782ace28bc4d2f6a10 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Mar 2026 12:29:21 -0400 Subject: [PATCH 27/31] docs: add vendor README documenting wp-util.js source Record the upstream commit hash and rationale for vendoring so future maintainers know where the file came from and when to update it. Co-Authored-By: Claude Opus 4.6 (1M context) --- vendor/README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 vendor/README.md diff --git a/vendor/README.md b/vendor/README.md new file mode 100644 index 000000000..51b73fd07 --- /dev/null +++ b/vendor/README.md @@ -0,0 +1,12 @@ +# Vendored Files + +This directory contains vendored third-party files that GutenbergKit loads directly, since it does not include the full set of WordPress core assets. + +## wp-util.js + +- **Source**: [`wp-includes/js/wp-util.js`](https://github.com/WordPress/wordpress-develop/blob/117af7e9a37c02ee17ac8f143cb46b6b0f4cde15/src/js/_enqueues/wp/util.js) +- **Commit**: `117af7e9a37c02ee17ac8f143cb46b6b0f4cde15` + +Provides `wp.ajax` (authenticated AJAX utilities) and `wp.template` (JavaScript templating). WordPress normally enqueues this as the `wp-util` script handle. GutenbergKit vendors it because the editor assets endpoint excludes core WordPress scripts, and the IIFE captures jQuery via closure at execution time — so it must be loaded after jQuery is on `window`. + +This file is excluded from ESLint and Prettier (see `.eslintrc.cjs` and `.prettierignore`). From 282c499b4adbc95413d0e37c34def25e46e9ee29 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Mar 2026 13:05:30 -0400 Subject: [PATCH 28/31] fix: add https scheme to WP.com site URL in Android demo app Account.WpCom.username stores just the hostname (e.g., "dcpaid.wordpress.com") since it is extracted via URI.host during OAuth. ConfigurationItem was using this bare hostname as siteUrl, producing invalid AJAX endpoints. Prepend "https://" to match the self-hosted flow, which receives a full URL from the callback. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/main/java/com/example/gutenbergkit/ConfigurationItem.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/ConfigurationItem.kt b/android/app/src/main/java/com/example/gutenbergkit/ConfigurationItem.kt index 2ed2d3c74..86b94b00f 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/ConfigurationItem.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/ConfigurationItem.kt @@ -31,7 +31,7 @@ sealed class ConfigurationItem { is Account.WpCom -> ConfiguredEditor( accountId = account.id, name = account.username, - siteUrl = account.username, + siteUrl = "https://${account.username}", siteApiRoot = account.siteApiRoot, authHeader = "Bearer ${account.token}" ) From 1035b14a84c4dc9da4aa08982696bd93e4ce2878 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Mar 2026 13:11:16 -0400 Subject: [PATCH 29/31] refactor: extract duplicated AJAX URL into local variable Co-Authored-By: Claude Opus 4.6 (1M context) --- src/utils/ajax.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/utils/ajax.js b/src/utils/ajax.js index 36a9e5019..22c669113 100644 --- a/src/utils/ajax.js +++ b/src/utils/ajax.js @@ -33,10 +33,11 @@ function configureAjaxUrl( siteURL ) { return; } + const ajaxUrl = `${ siteURL }/wp-admin/admin-ajax.php`; // Global used within WordPress admin pages - window.ajaxurl = `${ siteURL }/wp-admin/admin-ajax.php`; + window.ajaxurl = ajaxUrl; // Global used by WordPress' JavaScript API - window.wp.ajax.settings.url = `${ siteURL }/wp-admin/admin-ajax.php`; + window.wp.ajax.settings.url = ajaxUrl; debug( 'AJAX URL configured' ); } From 51d58eed3101088441be1cd890d4adb79e431c3e Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 26 Mar 2026 13:14:53 -0400 Subject: [PATCH 30/31] docs: remove lint/format exclusion note from vendor README Co-Authored-By: Claude Opus 4.6 (1M context) --- vendor/README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/vendor/README.md b/vendor/README.md index 51b73fd07..3ca63cd99 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -7,6 +7,4 @@ This directory contains vendored third-party files that GutenbergKit loads direc - **Source**: [`wp-includes/js/wp-util.js`](https://github.com/WordPress/wordpress-develop/blob/117af7e9a37c02ee17ac8f143cb46b6b0f4cde15/src/js/_enqueues/wp/util.js) - **Commit**: `117af7e9a37c02ee17ac8f143cb46b6b0f4cde15` -Provides `wp.ajax` (authenticated AJAX utilities) and `wp.template` (JavaScript templating). WordPress normally enqueues this as the `wp-util` script handle. GutenbergKit vendors it because the editor assets endpoint excludes core WordPress scripts, and the IIFE captures jQuery via closure at execution time — so it must be loaded after jQuery is on `window`. - -This file is excluded from ESLint and Prettier (see `.eslintrc.cjs` and `.prettierignore`). +Provides `wp.ajax` (authenticated AJAX utilities) and `wp.template` (JavaScript templating). WordPress normally enqueues this as the `wp-util` script handle. GutenbergKit vendors it because the editor assets endpoint excludes core WordPress scripts, and the IIFE captures jQuery via closure at execution time — so it must be loaded after jQuery is on `window`. \ No newline at end of file From 78bc47ece365ed012dad2119c630ebe63778654c Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 27 Mar 2026 20:48:05 -0400 Subject: [PATCH 31/31] refactor: derive Android WebView asset domain from site URL Derive the WebViewAssetLoader domain from the configured siteURL instead of defaulting to the synthetic appassets.androidplatform.net domain. This makes REST API and admin-ajax.php requests same-origin, eliminating CORS restrictions without requiring server-side headers. - Restrict shouldOverrideUrlLoading to /assets/ paths on the asset domain so arbitrary site pages don't load inside the WebView. - Reorder shouldInterceptRequest to check the cache interceptor before the asset loader, preventing cached JS/CSS from being short-circuited when both share the site domain. - Remove the now-unnecessary assetLoaderDomain configuration option from EditorConfiguration. - Update AJAX documentation to reflect the simplified setup. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../org/wordpress/gutenberg/GutenbergView.kt | 26 +++++++++---- .../gutenberg/model/EditorConfiguration.kt | 24 +----------- .../wordpress/gutenberg/GutenbergViewTest.kt | 37 +++++++++++++++++++ docs/code/troubleshooting.md | 4 +- docs/integration.md | 18 ++++----- 5 files changed, 66 insertions(+), 43 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index c7789812d..7ecba6bf3 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -244,10 +244,18 @@ class GutenbergView : WebView { ): WebResourceResponse? { if (request.url == null) { return super.shouldInterceptRequest(view, request) - } else if (request.url.host == assetDomain) { + } + + // Check the cache interceptor first — it handles JS/CSS + // assets from any allowed host, which may include the asset + // domain when it matches the site domain. + if (requestInterceptor.canIntercept(request)) { + val response = requestInterceptor.handleRequest(request) + if (response != null) return response + } + + if (request.url.host == assetDomain) { return assetLoader.shouldInterceptRequest(request.url) - } else if (requestInterceptor.canIntercept(request)) { - return requestInterceptor.handleRequest(request) } return super.shouldInterceptRequest(view, request) @@ -276,8 +284,10 @@ class GutenbergView : WebView { return false } - // Allow asset URLs - if (url.host == assetDomain) { + // Allow asset URLs (restrict to the asset path prefix so that + // arbitrary site pages don't load inside the WebView when the + // asset domain matches the site domain) + if (url.host == assetDomain && url.path?.startsWith("/assets/") == true) { return false } @@ -395,8 +405,10 @@ class GutenbergView : WebView { private fun loadEditor(dependencies: EditorDependencies) { this.dependencies = dependencies - // Set up asset loader domain - assetDomain = configuration.assetLoaderDomain ?: DEFAULT_ASSET_DOMAIN + // Derive the asset loader domain from the site URL so that the editor + // document shares the site's origin, making REST API and AJAX requests + // same-origin and eliminating CORS restrictions. + assetDomain = Uri.parse(configuration.siteURL).host ?: DEFAULT_ASSET_DOMAIN // Set up asset caching requestInterceptor = CachedAssetRequestInterceptor( diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt index 87bf2d778..196111bb3 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt @@ -28,8 +28,7 @@ data class EditorConfiguration( val cachedAssetHosts: Set = emptySet(), val editorAssetsEndpoint: String? = null, val enableNetworkLogging: Boolean = false, - var enableOfflineMode: Boolean = false, - val assetLoaderDomain: String? = null + var enableOfflineMode: Boolean = false ): Parcelable { /** @@ -74,7 +73,6 @@ data class EditorConfiguration( private var editorAssetsEndpoint: String? = null private var enableNetworkLogging: Boolean = false private var enableOfflineMode: Boolean = false - private var assetLoaderDomain: String? = null fun setTitle(title: String) = apply { this.title = title } fun setContent(content: String) = apply { this.content = content } @@ -97,20 +95,6 @@ data class EditorConfiguration( fun setEditorAssetsEndpoint(editorAssetsEndpoint: String?) = apply { this.editorAssetsEndpoint = editorAssetsEndpoint } fun setEnableNetworkLogging(enableNetworkLogging: Boolean) = apply { this.enableNetworkLogging = enableNetworkLogging } fun setEnableOfflineMode(enableOfflineMode: Boolean) = apply { this.enableOfflineMode = enableOfflineMode } - fun setAssetLoaderDomain(assetLoaderDomain: String?) = apply { - if (assetLoaderDomain != null) { - require(!assetLoaderDomain.contains("://")) { - "assetLoaderDomain must be a bare domain (e.g., \"assets.example.com\"), not a URL" - } - require(!assetLoaderDomain.contains("/")) { - "assetLoaderDomain must be a bare domain without a path" - } - require(assetLoaderDomain.isNotBlank()) { - "assetLoaderDomain must not be blank" - } - } - this.assetLoaderDomain = assetLoaderDomain - } fun build(): EditorConfiguration = EditorConfiguration( title = title, @@ -133,8 +117,7 @@ data class EditorConfiguration( cachedAssetHosts = cachedAssetHosts, editorAssetsEndpoint = editorAssetsEndpoint, enableNetworkLogging = enableNetworkLogging, - enableOfflineMode = enableOfflineMode, - assetLoaderDomain = assetLoaderDomain + enableOfflineMode = enableOfflineMode ) } @@ -162,7 +145,6 @@ data class EditorConfiguration( .setEditorAssetsEndpoint(editorAssetsEndpoint) .setEnableNetworkLogging(enableNetworkLogging) .setEnableOfflineMode(enableOfflineMode) - .setAssetLoaderDomain(assetLoaderDomain) override fun equals(other: Any?): Boolean { if (this === other) return true @@ -191,7 +173,6 @@ data class EditorConfiguration( if (editorAssetsEndpoint != other.editorAssetsEndpoint) return false if (enableNetworkLogging != other.enableNetworkLogging) return false if (enableOfflineMode != other.enableOfflineMode) return false - if (assetLoaderDomain != other.assetLoaderDomain) return false if (siteId != other.siteId) return false return true @@ -219,7 +200,6 @@ data class EditorConfiguration( result = 31 * result + (editorAssetsEndpoint?.hashCode() ?: 0) result = 31 * result + enableNetworkLogging.hashCode() result = 31 * result + enableOfflineMode.hashCode() - result = 31 * result + (assetLoaderDomain?.hashCode() ?: 0) result = 31 * result + siteId.hashCode() return result } diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt index b4e15c0c4..64a85132d 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt @@ -5,6 +5,7 @@ import android.net.Uri import android.os.Looper import android.webkit.ValueCallback import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest import android.webkit.WebView import kotlinx.coroutines.test.TestScope import org.junit.Before @@ -12,6 +13,7 @@ import org.junit.Test import org.junit.Rule import org.junit.runner.RunWith import org.mockito.Mock +import org.mockito.Mockito.mock import org.mockito.Mockito.`when` import org.mockito.MockitoAnnotations import org.robolectric.RobolectricTestRunner @@ -19,6 +21,7 @@ import org.robolectric.RuntimeEnvironment import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit @@ -171,4 +174,38 @@ class GutenbergViewTest { assertTrue("User agent should contain version number", userAgent.contains("GutenbergKit/${GutenbergKitVersion.VERSION}")) } + + @Test + fun `shouldOverrideUrlLoading allows asset path URLs on site domain`() { + val siteView = GutenbergView( + EditorConfiguration.builder("https://example.com", "https://example.com/wp-json/") + .build(), + EditorDependencies.empty, + testScope, + RuntimeEnvironment.getApplication() + ) + + val request = mock(WebResourceRequest::class.java) + `when`(request.url).thenReturn(Uri.parse("https://example.com/assets/index.html")) + + val result = siteView.webViewClient.shouldOverrideUrlLoading(siteView, request) + assertFalse("Asset path URLs on the site domain should load in the WebView", result) + } + + @Test + fun `shouldOverrideUrlLoading blocks non-asset URLs on site domain`() { + val siteView = GutenbergView( + EditorConfiguration.builder("https://example.com", "https://example.com/wp-json/") + .build(), + EditorDependencies.empty, + testScope, + RuntimeEnvironment.getApplication() + ) + + val request = mock(WebResourceRequest::class.java) + `when`(request.url).thenReturn(Uri.parse("https://example.com/some-page")) + + val result = siteView.webViewClient.shouldOverrideUrlLoading(siteView, request) + assertTrue("Non-asset URLs on the site domain should open externally", result) + } } diff --git a/docs/code/troubleshooting.md b/docs/code/troubleshooting.md index cf0acafe3..3ff4f70dc 100644 --- a/docs/code/troubleshooting.md +++ b/docs/code/troubleshooting.md @@ -35,6 +35,4 @@ You may also need to clear your browser cache to ensure no stale files are used. This error occurs when the editor makes AJAX requests (e.g., from blocks that use `admin-ajax.php`) while running on the development server. The browser blocks these cross-origin requests because the editor runs on `localhost` while AJAX targets your WordPress site. -**Solution:** AJAX functionality requires a production bundle. Build the editor assets with `make build` and test AJAX features using the demo apps without using the `GUTENBERG_EDITOR_URL` environment variable. - -For Android, you must also configure `assetLoaderDomain` to a domain allowed by your WordPress site's CORS policy. See the [AJAX Support section](../integration.md#ajax-support) in the Integration Guide for complete configuration details. +**Solution:** AJAX functionality requires a production bundle. Build the editor assets with `make build` and test AJAX features using the demo apps without using the `GUTENBERG_EDITOR_URL` environment variable. See the [AJAX Support section](../integration.md#ajax-support) in the Integration Guide for complete configuration details. diff --git a/docs/integration.md b/docs/integration.md index 400de998b..f445b704a 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -300,16 +300,14 @@ Some Gutenberg blocks and features use WordPress AJAX (`admin-ajax.php`) for fun 1. **Production bundle required**: AJAX requests fail with CORS errors when using the development server because the editor runs on `localhost` while AJAX requests target your WordPress site. You must use a production bundle built with `make build`. -2. **Configure `siteURL`**: The `siteURL` configuration option must be set to your WordPress site URL. This is used to construct the AJAX endpoint (`{siteURL}/wp-admin/admin-ajax.php`). +2. **Configure `siteURL`**: The `siteURL` configuration option must be set to your WordPress site URL. This is used to construct the AJAX endpoint (`{siteURL}/wp-admin/admin-ajax.php`). On Android, the editor is served from the site's domain so that AJAX requests are same-origin. 3. **Set authentication header**: The `authHeader` configuration must be set. GutenbergKit injects this header into all AJAX requests since the WebView lacks WordPress authentication cookies. -4. **Android: Configure `assetLoaderDomain`**: On Android, you must set the `assetLoaderDomain` to a domain that your WordPress site/plugin allows. This is because Android's WebViewAssetLoader serves the editor from a configurable domain, and AJAX requests must pass CORS validation on your server. For example, the Jetpack mobile plugin allows requests from `android-app-assets.jetpack.com`. - **Configuration examples:** ```swift -// iOS - siteURL and authHeader are required +// iOS let configuration = EditorConfigurationBuilder( postType: "post", siteURL: URL(string: "https://example.com")!, @@ -320,14 +318,12 @@ let configuration = EditorConfigurationBuilder( ``` ```kotlin -// Android - assetLoaderDomain is also required for AJAX -val configuration = EditorConfiguration.builder() +// Android +val configuration = EditorConfiguration.builder( + siteURL = "https://example.com", + siteApiRoot = "https://example.com/wp-json" +) .setPostType("post") - .setSiteURL("https://example.com") - .setSiteApiRoot("https://example.com/wp-json") .setAuthHeader("Bearer your-token") - .setAssetLoaderDomain("android-app-assets.jetpack.com") // Must be allowed by your WordPress site .build() ``` - -**Server-side CORS configuration**: Your WordPress site must include the `assetLoaderDomain` in its CORS allowed origins. This is typically handled by your WordPress plugin (e.g., Jetpack) that integrates with the mobile app.