From c988f0607058b84b73206037e87ebda271b2dbf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?berislav=20grgi=C4=8Dak?= Date: Wed, 20 Mar 2024 12:36:42 +0100 Subject: [PATCH] Add multisite rewrite rules (#1083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What is this PR doing? This PR adds support for multisite URL rewrites. ## What problem is it solving? It ensures that all types of WordPress URLs work on Playground. ## How is the problem addressed? By adding support for rewrite rules in PHP WASM and adding a rule to resolve WordPress multisite URLs. ## Testing Instructions - Checkout this branch - [Start a new multisite](https://playground.test/website-server/#{%20%22landingPage%22:%20%22/test/%22,%20%22phpExtensionBundles%22:%20[%22kitchen-sink%22],%20%22features%22:%20{%20%22networking%22:%20true%20},%20%22steps%22:%20[%20{%20%22step%22:%20%22enableMultisite%22%20},%20{%20%22step%22:%20%22login%22%20},%20{%20%22step%22:%20%22runPHP%22,%20%22code%22:%20%22%3C?php%20require_once%20'wordpress/wp-load.php';%20global%20$playground_scope;%20wp_insert_site(array('path'=%3E%20'/scope:'.$playground_scope.'/test/',%20'domain'=%3E%20parse_url(%20get_site_url(),%20PHP_URL_HOST%20),%20'user_id'=%3E1));%20?%3E%22%20}%20]%20}) (ensure playground.test proxy is running) - Confirm that the subsite loaded - Open WP-admin of that site and confirm that all assets loaded correctly --------- Co-authored-by: Adam ZieliƄski --- packages/php-wasm/universal/src/lib/index.ts | 7 +- .../universal/src/lib/php-request-handler.ts | 55 ++++--- packages/playground/remote/service-worker.ts | 136 +++++------------- .../remote/src/lib/worker-thread.ts | 9 +- packages/playground/wordpress/package.json | 3 + packages/playground/wordpress/src/index.ts | 1 + .../playground/wordpress/src/rewrite-rules.ts | 11 ++ .../wordpress/src/test/rewrite-rules.spec.ts | 50 +++++++ 8 files changed, 149 insertions(+), 123 deletions(-) create mode 100644 packages/playground/wordpress/src/rewrite-rules.ts create mode 100644 packages/playground/wordpress/src/test/rewrite-rules.spec.ts diff --git a/packages/php-wasm/universal/src/lib/index.ts b/packages/php-wasm/universal/src/lib/index.ts index cc51f72e34..ad3dbcbc7e 100644 --- a/packages/php-wasm/universal/src/lib/index.ts +++ b/packages/php-wasm/universal/src/lib/index.ts @@ -54,8 +54,11 @@ export { rethrowFileSystemError } from './rethrow-file-system-error'; export { isLocalPHP } from './is-local-php'; export { isRemotePHP } from './is-remote-php'; -export type { PHPRequestHandlerConfiguration } from './php-request-handler'; -export { PHPRequestHandler } from './php-request-handler'; +export type { + PHPRequestHandlerConfiguration, + RewriteRule, +} from './php-request-handler'; +export { PHPRequestHandler, applyRewriteRules } from './php-request-handler'; export type { PHPBrowserConfiguration } from './php-browser'; export { PHPBrowser } from './php-browser'; export { rotatePHPRuntime } from './rotate-php-runtime'; diff --git a/packages/php-wasm/universal/src/lib/php-request-handler.ts b/packages/php-wasm/universal/src/lib/php-request-handler.ts index c2e86007d3..2d74a73626 100644 --- a/packages/php-wasm/universal/src/lib/php-request-handler.ts +++ b/packages/php-wasm/universal/src/lib/php-request-handler.ts @@ -10,6 +10,11 @@ import { PHPResponse } from './php-response'; import { PHPRequest, PHPRunOptions, RequestHandler } from './universal-php'; import { encodeAsMultipart } from './encode-as-multipart'; +export type RewriteRule = { + match: RegExp; + replacement: string; +}; + export interface PHPRequestHandlerConfiguration { /** * The directory in the PHP filesystem where the server will look @@ -20,6 +25,11 @@ export interface PHPRequestHandlerConfiguration { * Request Handler URL. Used to populate $_SERVER details like HTTP_HOST. */ absoluteUrl?: string; + + /** + * Rewrite rules + */ + rewriteRules?: RewriteRule[]; } /** @inheritDoc */ @@ -32,6 +42,7 @@ export class PHPRequestHandler implements RequestHandler { #PATHNAME: string; #ABSOLUTE_URL: string; #semaphore: Semaphore; + rewriteRules: RewriteRule[]; /** * The PHP instance @@ -47,6 +58,7 @@ export class PHPRequestHandler implements RequestHandler { const { documentRoot = '/www/', absoluteUrl = typeof location === 'object' ? location?.href : '', + rewriteRules = [], } = config; this.php = php; this.#DOCROOT = documentRoot; @@ -70,6 +82,7 @@ export class PHPRequestHandler implements RequestHandler { this.#HOST, this.#PATHNAME, ].join(''); + this.rewriteRules = rewriteRules; } /** @inheritDoc */ @@ -110,9 +123,9 @@ export class PHPRequestHandler implements RequestHandler { isAbsolute ? undefined : DEFAULT_BASE_URL ); - const normalizedRequestedPath = removePathPrefix( - requestedUrl.pathname, - this.#PATHNAME + const normalizedRequestedPath = applyRewriteRules( + removePathPrefix(requestedUrl.pathname, this.#PATHNAME), + this.rewriteRules ); const fsPath = `${this.#DOCROOT}${normalizedRequestedPath}`; if (seemsLikeAPHPRequestHandlerPath(fsPath)) { @@ -214,24 +227,7 @@ export class PHPRequestHandler implements RequestHandler { let scriptPath; try { - /** - * Support .htaccess-like URL rewriting. - * If the request was rewritten by a service worker, - * the pathname requested by the user will be in - * the `requestedUrl.pathname` property, while the - * rewritten target URL will be in `request.headers['x-rewrite-url']`. - */ - let requestedPath = requestedUrl.pathname; - if (request.headers?.['x-rewrite-url']) { - try { - requestedPath = new URL( - request.headers['x-rewrite-url'] - ).pathname; - } catch (error) { - // Ignore - } - } - scriptPath = this.#resolvePHPFilePath(requestedPath); + scriptPath = this.#resolvePHPFilePath(requestedUrl.pathname); } catch (error) { return new PHPResponse( 404, @@ -267,6 +263,7 @@ export class PHPRequestHandler implements RequestHandler { */ #resolvePHPFilePath(requestedPath: string): string { let filePath = removePathPrefix(requestedPath, this.#PATHNAME); + filePath = applyRewriteRules(filePath, this.rewriteRules); if (filePath.includes('.php')) { // If the path mentions a .php extension, that's our file's path. @@ -370,3 +367,19 @@ function seemsLikeADirectoryRoot(path: string) { const lastSegment = path.split('/').pop(); return !lastSegment!.includes('.'); } + +/** + * Applies the given rewrite rules to the given path. + * + * @param path The path to apply the rules to. + * @param rules The rules to apply. + * @returns The path with the rules applied. + */ +export function applyRewriteRules(path: string, rules: RewriteRule[]): string { + for (const rule of rules) { + if (new RegExp(rule.match).test(path)) { + return path.replace(rule.match, rule.replacement); + } + } + return path; +} diff --git a/packages/playground/remote/service-worker.ts b/packages/playground/remote/service-worker.ts index 67b10c4995..62bb607870 100644 --- a/packages/playground/remote/service-worker.ts +++ b/packages/playground/remote/service-worker.ts @@ -2,15 +2,16 @@ declare const self: ServiceWorkerGlobalScope; -import { getURLScope, removeURLScope, setURLScope } from '@php-wasm/scopes'; +import { getURLScope, removeURLScope } from '@php-wasm/scopes'; +import { applyRewriteRules } from '@php-wasm/universal'; import { awaitReply, convertFetchEventToPHPRequest, initializeServiceWorker, cloneRequest, broadcastMessageExpectReply, - getRequestHeaders, } from '@php-wasm/web-service-worker'; +import { wordPressRewriteRules } from '@wp-playground/wordpress'; if (!(self as any).document) { // Workaround: vite translates import.meta.url @@ -47,66 +48,44 @@ initializeServiceWorker({ const { staticAssetsDirectory } = await getScopedWpDetails(scope!); - let workerResponse = await convertFetchEventToPHPRequest(event); - // If we get a 404, try to apply the WordPress URL rewrite rules. - let rewrittenUrlString: string | undefined = undefined; - if (workerResponse.status === 404) { - for (const url of rewriteWordPressUrl(unscopedUrl, scope!)) { - rewrittenUrlString = url.toString(); - workerResponse = await convertFetchEventToPHPRequest( - await cloneFetchEvent(event, rewrittenUrlString) - ); - if ( - workerResponse.status !== 404 || - workerResponse.headers.get('x-file-type') === 'static' - ) { - break; - } + const workerResponse = await convertFetchEventToPHPRequest(event); + if ( + workerResponse.status === 404 && + workerResponse.headers.get('x-file-type') === 'static' + ) { + // If we get a 404 for a static file, try to fetch it from + // the from the static assets directory at the remote server. + const requestedUrl = new URL(event.request.url); + const resolvedUrl = removeURLScope(requestedUrl); + resolvedUrl.pathname = applyRewriteRules( + resolvedUrl.pathname, + wordPressRewriteRules + ); + if ( + // Vite dev server requests + !resolvedUrl.pathname.startsWith('/@fs') && + !resolvedUrl.pathname.startsWith('/assets') + ) { + resolvedUrl.pathname = `/${staticAssetsDirectory}${resolvedUrl.pathname}`; } - } - - if (workerResponse.status === 404) { - if (workerResponse.headers.get('x-file-type') === 'static') { - // If we get a 404 for a static file, try to fetch it from - // the from the static assets directory at the remote server. - const requestedUrl = new URL( - rewrittenUrlString || event.request.url - ); - const resolvedUrl = removeURLScope(requestedUrl); - if ( - // Vite dev server requests - !resolvedUrl.pathname.startsWith('/@fs') && - !resolvedUrl.pathname.startsWith('/assets') - ) { - resolvedUrl.pathname = `/${staticAssetsDirectory}${resolvedUrl.pathname}`; + const request = await cloneRequest(event.request, { + url: resolvedUrl, + }); + return fetch(request).catch((e) => { + if (e?.name === 'TypeError') { + // This could be an ERR_HTTP2_PROTOCOL_ERROR that sometimes + // happen on playground.wordpress.net. Let's add a randomized + // delay and retry once + return new Promise((resolve) => { + setTimeout(() => { + resolve(fetch(request)); + }, Math.random() * 1500); + }) as Promise; } - const request = await cloneRequest(event.request, { - url: resolvedUrl, - }); - return fetch(request).catch((e) => { - if (e?.name === 'TypeError') { - // This could be an ERR_HTTP2_PROTOCOL_ERROR that sometimes - // happen on playground.wordpress.net. Let's add a randomized - // delay and retry once - return new Promise((resolve) => { - setTimeout(() => { - resolve(fetch(request)); - }, Math.random() * 1500); - }) as Promise; - } - // Otherwise let's just re-throw the error - throw e; - }); - } else { - const indexPhp = setURLScope( - new URL('/index.php', unscopedUrl), - scope! - ); - workerResponse = await convertFetchEventToPHPRequest( - await cloneFetchEvent(event, indexPhp.toString()) - ); - } + // Otherwise let's just re-throw the error + throw e; + }); } // Path the block-editor.js file to ensure the site editor's iframe @@ -240,49 +219,10 @@ function emptyHtml() { ); } -async function cloneFetchEvent(event: FetchEvent, rewriteUrl: string) { - return new FetchEvent(event.type, { - ...event, - request: await cloneRequest(event.request, { - headers: { - ...getRequestHeaders(event.request), - 'x-rewrite-url': rewriteUrl, - }, - }), - }); -} - type WPModuleDetails = { staticAssetsDirectory: string; }; -/** - * Rewrite the URL according to WordPress .htaccess rules. - */ -function* rewriteWordPressUrl(unscopedUrl: URL, scope: string) { - // RewriteRule ^([_0-9a-zA-Z-]+/)?(wp-(content|admin|includes).*) wordpress/$2 [L] - const rewrittenUrl = unscopedUrl.pathname - .toString() - .replace( - /^\/([_0-9a-zA-Z-]+\/)?(wp-(content|admin|includes).*)/, - '/$2' - ); - if (rewrittenUrl !== unscopedUrl.pathname) { - // Something changed, let's try the rewritten URL - const url = new URL(rewrittenUrl, unscopedUrl); - yield setURLScope(url, scope); - } - - // RewriteRule ^([_0-9a-zA-Z-]+/)?(.*\.php)$ wordpress/$2 [L] - if (unscopedUrl.pathname.endsWith('.php')) { - // The URL ends with .php, let's try to rewrite it to - // a .php file in the WordPress root directory - const filename = unscopedUrl.pathname.split('/').pop(); - const url = new URL('/' + filename, unscopedUrl); - yield setURLScope(url, scope); - } -} - const scopeToWpModule: Record = {}; async function getScopedWpDetails(scope: string): Promise { if (!scopeToWpModule[scope]) { diff --git a/packages/playground/remote/src/lib/worker-thread.ts b/packages/playground/remote/src/lib/worker-thread.ts index cba1108ba7..cfc77fea23 100644 --- a/packages/playground/remote/src/lib/worker-thread.ts +++ b/packages/playground/remote/src/lib/worker-thread.ts @@ -7,6 +7,7 @@ import { LatestSupportedWordPressVersion, SupportedWordPressVersions, SupportedWordPressVersionsList, + wordPressRewriteRules, } from '@wp-playground/wordpress'; import { PHPResponse, @@ -120,6 +121,7 @@ if (!wordPressAvailableInOPFS) { const php = new WebPHP(undefined, { documentRoot: DOCROOT, absoluteUrl: scopedSiteUrl, + rewriteRules: wordPressRewriteRules, }); const recreateRuntime = async () => @@ -128,6 +130,9 @@ const recreateRuntime = async () => // We don't yet support loading specific PHP extensions one-by-one. // Let's just indicate whether we want to load all of them. loadAllExtensions: phpExtensions?.length > 0, + requestHandler: { + rewriteRules: wordPressRewriteRules, + }, }); // Rotate the PHP runtime periodically to avoid memory leak-related crashes. @@ -380,13 +385,13 @@ try { // @TODO: Run the actual PHP CLI SAPI instead of // interpreting the arguments and emulating // the CLI constants and globals. - const cliBootstrapScript = `=18.18.2", "npm": ">=8.11.0" + }, + "dependencies": { + "@php-wasm/universal": "^0.6.6" } } diff --git a/packages/playground/wordpress/src/index.ts b/packages/playground/wordpress/src/index.ts index a2ab8fc6ac..556265d523 100644 --- a/packages/playground/wordpress/src/index.ts +++ b/packages/playground/wordpress/src/index.ts @@ -1,5 +1,6 @@ export { getWordPressModuleDetails } from './wordpress/get-wordpress-module-details'; export { getWordPressModule } from './wordpress/get-wordpress-module'; +export * from './rewrite-rules'; import SupportedWordPressVersions from './wordpress/wp-versions.json'; export { SupportedWordPressVersions }; diff --git a/packages/playground/wordpress/src/rewrite-rules.ts b/packages/playground/wordpress/src/rewrite-rules.ts new file mode 100644 index 0000000000..6a64c37262 --- /dev/null +++ b/packages/playground/wordpress/src/rewrite-rules.ts @@ -0,0 +1,11 @@ +import type { RewriteRule } from '@php-wasm/universal'; + +/** + * The default rewrite rules for WordPress. + */ +export const wordPressRewriteRules: RewriteRule[] = [ + { + match: /^\/(.*?)(\/wp-(content|admin|includes).*)/g, + replacement: '$2', + }, +]; diff --git a/packages/playground/wordpress/src/test/rewrite-rules.spec.ts b/packages/playground/wordpress/src/test/rewrite-rules.spec.ts new file mode 100644 index 0000000000..ab7de08e7d --- /dev/null +++ b/packages/playground/wordpress/src/test/rewrite-rules.spec.ts @@ -0,0 +1,50 @@ +import { applyRewriteRules } from '@php-wasm/universal'; +import { wordPressRewriteRules } from '../rewrite-rules'; + +describe('Test WordPress rewrites', () => { + it('Should return root folder PHP file', async () => { + expect(applyRewriteRules('/index.php', wordPressRewriteRules)).toBe( + '/index.php' + ); + }); + + it('Should keep query string', async () => { + expect( + applyRewriteRules('/index.php?test=1', wordPressRewriteRules) + ).toBe('/index.php?test=1'); + }); + + it('Should return subfolder PHP file', async () => { + expect( + applyRewriteRules('/wp-admin/index.php', wordPressRewriteRules) + ).toBe('/wp-admin/index.php'); + }); + + it('Should strip multisite prefix from path', async () => { + expect( + applyRewriteRules('/test/wp-admin/index.php', wordPressRewriteRules) + ).toBe('/wp-admin/index.php'); + }); + + it('Should strip multisite prefix from asset path', async () => { + expect( + applyRewriteRules( + '/test/wp-content/themes/twentytwentyfour/assets/images/windows.webp', + wordPressRewriteRules + ) + ).toBe( + '/wp-content/themes/twentytwentyfour/assets/images/windows.webp' + ); + }); + + it('Should strip multisite prefix and scope', async () => { + expect( + applyRewriteRules( + '/scope:0.1/test/wp-content/themes/twentytwentyfour/assets/images/windows.webp', + wordPressRewriteRules + ) + ).toBe( + '/wp-content/themes/twentytwentyfour/assets/images/windows.webp' + ); + }); +});