From 21820a093e3db6cbe574d037c30bb247d055ed59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 4 Nov 2025 22:13:45 +0100 Subject: [PATCH 01/10] Update the default rewrite rule --- packages/playground/wordpress/src/rewrite-rules.ts | 6 +++++- .../wordpress/src/test/rewrite-rules.spec.ts | 11 +++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/playground/wordpress/src/rewrite-rules.ts b/packages/playground/wordpress/src/rewrite-rules.ts index e56adfc08b..1a3738ed6b 100644 --- a/packages/playground/wordpress/src/rewrite-rules.ts +++ b/packages/playground/wordpress/src/rewrite-rules.ts @@ -4,8 +4,12 @@ import type { RewriteRule } from '@php-wasm/universal'; * The default rewrite rules for WordPress. */ export const wordPressRewriteRules: RewriteRule[] = [ + /** + * Substitutes this .htaccess rule: + * RewriteRule ^([_0-9a-zA-Z-]+/)?(wp-(content|admin|includes).*) $2 [L] + */ { - match: /^\/(.*?)(\/wp-(content|admin|includes)\/.*)/g, + 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 index ab7de08e7d..5a956c2950 100644 --- a/packages/playground/wordpress/src/test/rewrite-rules.spec.ts +++ b/packages/playground/wordpress/src/test/rewrite-rules.spec.ts @@ -47,4 +47,15 @@ describe('Test WordPress rewrites', () => { '/wp-content/themes/twentytwentyfour/assets/images/windows.webp' ); }); + + it('Should only target the initial wp-admin|wp-content|wp-includes path', async () => { + expect( + applyRewriteRules( + '/wp-content/themes/Newspaper/includes/wp-booster/wp-admin/images/plugins/tagdiv-small.png', + wordPressRewriteRules + ) + ).toBe( + '/wp-content/themes/Newspaper/includes/wp-booster/wp-admin/images/plugins/tagdiv-small.png' + ); + }); }); From 32746ab343891e4a197eecfb08cc51b88e911423 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 5 Nov 2025 14:26:43 +0100 Subject: [PATCH 02/10] Remove rewrite rules entirely to see how CI reacts --- .../playground/wordpress/src/rewrite-rules.ts | 14 +----------- .../wordpress/src/test/rewrite-rules.spec.ts | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/packages/playground/wordpress/src/rewrite-rules.ts b/packages/playground/wordpress/src/rewrite-rules.ts index 1a3738ed6b..e4d912964c 100644 --- a/packages/playground/wordpress/src/rewrite-rules.ts +++ b/packages/playground/wordpress/src/rewrite-rules.ts @@ -1,15 +1,3 @@ import type { RewriteRule } from '@php-wasm/universal'; -/** - * The default rewrite rules for WordPress. - */ -export const wordPressRewriteRules: RewriteRule[] = [ - /** - * Substitutes this .htaccess rule: - * RewriteRule ^([_0-9a-zA-Z-]+/)?(wp-(content|admin|includes).*) $2 [L] - */ - { - match: /^(.*?)(\/wp-(content|admin|includes)\/.*)/g, - replacement: '$2', - }, -]; +export const wordPressRewriteRules: RewriteRule[] = []; diff --git a/packages/playground/wordpress/src/test/rewrite-rules.spec.ts b/packages/playground/wordpress/src/test/rewrite-rules.spec.ts index 5a956c2950..080058c423 100644 --- a/packages/playground/wordpress/src/test/rewrite-rules.spec.ts +++ b/packages/playground/wordpress/src/test/rewrite-rules.spec.ts @@ -58,4 +58,26 @@ describe('Test WordPress rewrites', () => { '/wp-content/themes/Newspaper/includes/wp-booster/wp-admin/images/plugins/tagdiv-small.png' ); }); + + it('Should only target the initial wp-admin|wp-content|wp-includes path (2)', async () => { + expect( + applyRewriteRules( + '/wp-content/themes/Newspaper/includes/wp-booster/wp-content/images/plugins/tagdiv-small.png', + wordPressRewriteRules + ) + ).toBe( + '/wp-content/themes/Newspaper/includes/wp-booster/wp-content/images/plugins/tagdiv-small.png' + ); + }); + + it('Should not strip wp-content prefix from a path', async () => { + expect( + applyRewriteRules( + '/wp-content/themes/twentytwentyfour/assets/images/windows.webp', + wordPressRewriteRules + ) + ).toBe( + '/wp-content/themes/twentytwentyfour/assets/images/windows.webp' + ); + }); }); From 3f898669e01506cbf18c0b1e7ea1cbafdf47ca99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 5 Nov 2025 16:43:44 +0100 Subject: [PATCH 03/10] Distinguish between REQUEST_URI (before rewriting) and PHP_SELF (after rewriting) --- packages/php-wasm/compile/php/php_wasm.c | 12 +- .../universal/src/lib/php-request-handler.ts | 26 +-- packages/php-wasm/universal/src/lib/php.ts | 6 +- .../universal/src/lib/universal-php.ts | 17 +- .../src/lib/steps/enable-multisite.ts | 2 +- packages/playground/remote/vite.config.ts | 2 +- .../playground/wordpress/src/rewrite-rules.ts | 166 +++++++++++++++++- .../wordpress/src/test/rewrite-rules.spec.ts | 2 +- 8 files changed, 216 insertions(+), 17 deletions(-) diff --git a/packages/php-wasm/compile/php/php_wasm.c b/packages/php-wasm/compile/php/php_wasm.c index 5a977fa779..424fad7f15 100644 --- a/packages/php-wasm/compile/php/php_wasm.c +++ b/packages/php-wasm/compile/php/php_wasm.c @@ -1451,6 +1451,11 @@ static void wasm_sapi_register_server_variables(zval *track_vars_array TSRMLS_DC value = SG(request_info).request_uri; if (value != NULL) { + /** + * REQUEST_URI represents the requested path relative to the site root. + * This is **before** any URL rewriting rules (e.g. apache .htaccess) have been + * applied. + */ php_register_variable("REQUEST_URI", value, track_vars_array TSRMLS_CC); } @@ -1459,7 +1464,8 @@ static void wasm_sapi_register_server_variables(zval *track_vars_array TSRMLS_DC { // Confirm path translated starts with the document root /** - * PHP_SELF is the script path relative to the document root. + * PHP_SELF represents the requested script path after any URL rewriting rules + * (e.g. apache .htaccess) have been applied. * * For example: * @@ -1478,6 +1484,10 @@ static void wasm_sapi_register_server_variables(zval *track_vars_array TSRMLS_DC char *script_name = wasm_server_context->path_translated + strlen(wasm_server_context->document_root); char *script_filename = wasm_server_context->path_translated; char *php_self = wasm_server_context->path_translated + strlen(wasm_server_context->document_root); + /** + * SCRIPT_NAME represents the path to the PHP script being executed after + * any URL rewriting rules (e.g. apache .htaccess) have been applied. + */ php_register_variable("SCRIPT_NAME", estrdup(script_name), track_vars_array TSRMLS_CC); php_register_variable("SCRIPT_FILENAME", estrdup(script_filename), track_vars_array TSRMLS_CC); php_register_variable("PHP_SELF", estrdup(php_self), track_vars_array TSRMLS_CC); 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 e0e380e5fa..7dab3725be 100644 --- a/packages/php-wasm/universal/src/lib/php-request-handler.ts +++ b/packages/php-wasm/universal/src/lib/php-request-handler.ts @@ -367,17 +367,23 @@ export class PHPRequestHandler implements AsyncDisposable { isAbsolute ? undefined : DEFAULT_BASE_URL ); - const normalizedRequestedPath = applyRewriteRules( - removePathPrefix( - decodeURIComponent(requestedUrl.pathname), - this.#PATHNAME - ), + const siteRelativePath = removePathPrefix( + decodeURIComponent(requestedUrl.pathname), + this.#PATHNAME + ); + const rewrittenRequestPath = applyRewriteRules( + siteRelativePath, this.rewriteRules ); + const rewrittenRequestUrl = new URL(requestedUrl.toString()); + rewrittenRequestUrl.pathname = joinPaths( + this.#PATHNAME, + rewrittenRequestPath + ); const primaryPhp = await this.getPrimaryPhp(); - let fsPath = joinPaths(this.#DOCROOT, normalizedRequestedPath); + let fsPath = joinPaths(this.#DOCROOT, rewrittenRequestPath); if (primaryPhp.isDir(fsPath)) { // Ensure directory URIs have a trailing slash. Otherwise, @@ -421,9 +427,8 @@ export class PHPRequestHandler implements AsyncDisposable { } if (!primaryPhp.isFile(fsPath)) { - const fileNotFoundAction = this.getFileNotFoundAction( - normalizedRequestedPath - ); + const fileNotFoundAction = + this.getFileNotFoundAction(rewrittenRequestPath); switch (fileNotFoundAction.type) { case 'response': return fileNotFoundAction.response; @@ -450,7 +455,8 @@ export class PHPRequestHandler implements AsyncDisposable { const effectiveRequest: PHPRequest = { ...request, // Pass along URL with the #fragment filtered out - url: requestedUrl.toString(), + url: rewrittenRequestUrl.toString(), + urlBeforeRewriting: requestedUrl.toString(), }; const response = await this.#spawnPHPAndDispatchRequest( effectiveRequest, diff --git a/packages/php-wasm/universal/src/lib/php.ts b/packages/php-wasm/universal/src/lib/php.ts index 501ba0e5c0..a4dde0562d 100644 --- a/packages/php-wasm/universal/src/lib/php.ts +++ b/packages/php-wasm/universal/src/lib/php.ts @@ -649,7 +649,11 @@ export class PHP implements Disposable { `The script path "${request.scriptPath}" does not exist.` ); } - this.#setRelativeRequestUri(request.relativeUri || ''); + this.#setRelativeRequestUri( + request.relativeUriBeforeRewriting || + request.relativeUri || + '' + ); this.#setRequestMethod(request.method || 'GET'); const requestHeaders = normalizeHeaders(request.headers || {}); const host = requestHeaders['host'] || 'example.com:443'; diff --git a/packages/php-wasm/universal/src/lib/universal-php.ts b/packages/php-wasm/universal/src/lib/universal-php.ts index c886d9c976..e2426c462a 100644 --- a/packages/php-wasm/universal/src/lib/universal-php.ts +++ b/packages/php-wasm/universal/src/lib/universal-php.ts @@ -100,6 +100,12 @@ export interface PHPRequest { */ url: string; + /** + * Request URL before any URL rewriting rules (e.g. apache .htaccess) + * have been applied. + */ + urlBeforeRewriting?: string; + /** * Request headers. */ @@ -115,10 +121,19 @@ export interface PHPRequest { export interface PHPRunOptions { /** - * Request path following the domain:port part. + * Request path following the domain:port part – + * after any URL rewriting rules (e.g. apache .htaccess) + * have been applied. */ relativeUri?: string; + /** + * Request path following the domain:port part – + * before any URL rewriting rules (e.g. apache .htaccess) + * have been applied. + */ + relativeUriBeforeRewriting?: string; + /** * Path of the .php file to execute. */ diff --git a/packages/playground/blueprints/src/lib/steps/enable-multisite.ts b/packages/playground/blueprints/src/lib/steps/enable-multisite.ts index 0ceca20800..38c72d6f36 100644 --- a/packages/playground/blueprints/src/lib/steps/enable-multisite.ts +++ b/packages/playground/blueprints/src/lib/steps/enable-multisite.ts @@ -58,6 +58,6 @@ export const enableMultisite: StepHandler = async ( }); await wpCLI(playground, { - command: 'wp core multisite-convert', + command: 'wp core multisite-convert --base=' + sitePath, }); }; diff --git a/packages/playground/remote/vite.config.ts b/packages/playground/remote/vite.config.ts index 3ac33d023d..c02170abe2 100644 --- a/packages/playground/remote/vite.config.ts +++ b/packages/playground/remote/vite.config.ts @@ -85,7 +85,7 @@ export default defineConfig(({ mode }) => { server: { port: remoteDevServerPort, host: remoteDevServerHost, - allowedHosts: ['playground.test'], + allowedHosts: ['playground.test', 'playground-preview.test'], fs: { // Allow serving files from the 'packages' directory allow: ['../../'], diff --git a/packages/playground/wordpress/src/rewrite-rules.ts b/packages/playground/wordpress/src/rewrite-rules.ts index e4d912964c..9e80f6118f 100644 --- a/packages/playground/wordpress/src/rewrite-rules.ts +++ b/packages/playground/wordpress/src/rewrite-rules.ts @@ -1,3 +1,167 @@ import type { RewriteRule } from '@php-wasm/universal'; -export const wordPressRewriteRules: RewriteRule[] = []; +/** + * WordPress rewrite rules adapted for Playground. + * + * These rules are matched against the requested path without the site path prefix. + * + * For example: + * + * * The site URL is `https://playground.wordpress.net/scope:ambitious-chic-country/`. + * * The site path prefix is `/scope:ambitious-chic-country/`. + * * The requested URL is `https://playground.wordpress.net/scope:ambitious-chic-country/wp-admin/index.php`, + * * The requested path without the site path prefix is `/wp-admin/index.php`. + * + * And so, the rewrite rules are matched against `/wp-admin/index.php`. + * This is similar to setting the `RewriteBase` to `/scope:ambitious-chic-country`. + * + * ## Rationale + * + * WordPress does not use a single, static set of rewrite rules. Rather, it generates + * its own .htaccess file based on the current configuration using the save_mod_rewrite_rules() + * function: + * + * https://developer.wordpress.org/reference/functions/save_mod_rewrite_rules/ + * + * Here's a few examples of what that .htaccess might look like for different + * WordPress configurations: + * + * ### Vanilla WordPress single-site installation + * + * ```apache + * RewriteBase / + * RewriteRule ^index\.php$ - [L] + * RewriteCond %{REQUEST_FILENAME} !-f + * RewriteCond %{REQUEST_FILENAME} !-d + * RewriteRule . /index.php [L] + * ``` + * + * ### Single-site installation living at a /subdirectory/ + * + * ```apache + * # https://developer.wordpress.org/advanced-administration/server/wordpress-in-directory/: + * RewriteCond %{REQUEST_URI} !^/subdirectory/ + * RewriteCond %{REQUEST_FILENAME} !-f + * RewriteCond %{REQUEST_FILENAME} !-d + * RewriteRule ^(.*)$ /subdirectory/$1 + * RewriteRule ^(/)?$ subdirectory/index.php [L] + * ``` + * + * Some sources also set the RewriteBase to `/subdirectory/`. + * + * ### Multisite installation using subfolder network type + * + * ```apache + * # https://wordpress.org/documentation/article/htaccess/#multisite + * + * RewriteBase / + * RewriteRule ^index\.php$ - [L] + * + * // add a trailing slash to /wp-admin + * RewriteRule ^([_0-9a-zA-Z-]+/)?wp-admin$ $1wp-admin/ [R=301,L] + * + * RewriteCond %{REQUEST_FILENAME} -f [OR] + * RewriteCond %{REQUEST_FILENAME} -d + * RewriteRule ^ - [L] + * RewriteRule ^([_0-9a-zA-Z-]+/)?(wp-(content|admin|includes).*) $2 [L] + * RewriteRule ^([_0-9a-zA-Z-]+/)?(.*\.php)$ $2 [L] + * RewriteRule . index.php [L] + * ``` + * + * # Multisite living at /scope:ambitious-chic-country/ + * + * ```apache + * RewriteBase /scope:ambitious-chic-country/ + * RewriteRule ^index\.php$ - [L] + * + * // Add a trailing slash to /wp-admin + * RewriteRule ^([_0-9a-zA-Z-]+/)?wp-admin$ $1wp-admin/ [R=301,L] + * + * RewriteCond %{REQUEST_FILENAME} -f [OR] + * RewriteCond %{REQUEST_FILENAME} -d + * RewriteRule ^ - [L] + * + * // The `wordpress/` prefix matches the document root, but seeing + * // it here is unexpected. @TODO: Why is it being added by WordPress? + * RewriteRule ^([_0-9a-zA-Z-]+/)?(wp-(content|admin|includes).*) wordpress/$2 [L] + * RewriteRule ^([_0-9a-zA-Z-]+/)?(.*\.php)$ wordpress/$2 [L] + * RewriteRule . index.php [L] + * ``` + * + * ## .htaccess syntax + * + * Here's an excerpt/summary from the .htaccess documentation [^1][^2] for + * convenience: + * + * The mod_rewrite module uses a rule-based rewriting engine, based + * on a PCRE regular-expression parser, to rewrite requested URLs on + * the fly. By default, mod_rewrite maps a URL to a filesystem path. + * However, it can also be used to redirect one URL to another URL, + * or to invoke an internal proxy fetch. + * + * ## RewriteBase Directive + * + * The RewriteBase directive specifies the URL prefix to be used for + * per-directory (htaccess) RewriteRule directives that substitute a + * relative path. + * + * Syntax: + * RewriteBase URL-path + * + * (Setting RewriteBase to "/" makes it possible to use RewriteRule + * patterns that **do not** start with a slash.) + * + * ## RewriteRule Directive + * + * Defines rules for the rewriting engine. + * + * Syntax: + * RewriteRule Pattern Substitution [flags] + * + * ## Flags + * + * - L|Last + * Stop processing the rule set. In most contexts, this means + * that if the rule matches, no further rules will be processed + * + * - NC|No Case + * Ignore case when matching. + * + * - R|Redirect + * Causes a HTTP redirect to be issued to the browser. + * + * ## Differences with .htaccess + * + * [1] https://httpd.apache.org/docs/current/rewrite/intro.html + * [2] https://httpd.apache.org/docs/current/rewrite/flags.html + */ +export const wordPressRewriteRules: RewriteRule[] = [ + /** + * Substitutes the multisite WordPress rewrite rule: + * + * RewriteBase / + * RewriteRule ^([_0-9a-zA-Z-]+/)?(wp-(content|admin|includes).*) $2 [L] + */ + { + match: new RegExp( + `^(` + + /* The .htaccess rule does not have an explicit initial slash, + but it's still implied by `RewriteBase /` */ + `/` + + `[_0-9a-zA-Z-/]+` + + `)?` + + /** + * Avoid discarding the initial slash of the rewritten URL. + * .htaccess places the trailing slash in the first group. It + * relies on the implicit `RewriteBase /` again – the final, + * rewritten URL still has the `/` at the beginning. This rule + * does not have an implied RewriteBase, so the only way to preserve + * the `/` at the beginning is to avoid replacing it. + */ + '(/' + + // The rest of the pattern is the same: + `wp-(content|admin|includes)/.*)` + ), + replacement: '$2', + }, +]; diff --git a/packages/playground/wordpress/src/test/rewrite-rules.spec.ts b/packages/playground/wordpress/src/test/rewrite-rules.spec.ts index 080058c423..8775ba5b16 100644 --- a/packages/playground/wordpress/src/test/rewrite-rules.spec.ts +++ b/packages/playground/wordpress/src/test/rewrite-rules.spec.ts @@ -48,7 +48,7 @@ describe('Test WordPress rewrites', () => { ); }); - it('Should only target the initial wp-admin|wp-content|wp-includes path', async () => { + it('Should only target the initial wp-admin|wp-content|wp-includes path (1)', async () => { expect( applyRewriteRules( '/wp-content/themes/Newspaper/includes/wp-booster/wp-admin/images/plugins/tagdiv-small.png', From 79d11a5726f35baf6265c8c44bf6dff9550a8661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 5 Nov 2025 16:48:48 +0100 Subject: [PATCH 04/10] Carefully handle original and rewritten url in the php request handler --- .../php-wasm/universal/src/lib/php-request-handler.ts | 11 ++++++----- packages/php-wasm/universal/src/lib/php.ts | 5 ++--- 2 files changed, 8 insertions(+), 8 deletions(-) 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 7dab3725be..3bf8cd8b30 100644 --- a/packages/php-wasm/universal/src/lib/php-request-handler.ts +++ b/packages/php-wasm/universal/src/lib/php-request-handler.ts @@ -361,21 +361,22 @@ export class PHPRequestHandler implements AsyncDisposable { */ async request(request: PHPRequest): Promise { const isAbsolute = URL.canParse(request.url); - const requestedUrl = new URL( + const originalRequestUrl = new URL( // Remove the hash part of the URL as it's not meant for the server. request.url.split('#')[0], isAbsolute ? undefined : DEFAULT_BASE_URL ); + // Apply the rewrite rules to the original request URL: const siteRelativePath = removePathPrefix( - decodeURIComponent(requestedUrl.pathname), + decodeURIComponent(originalRequestUrl.pathname), this.#PATHNAME ); const rewrittenRequestPath = applyRewriteRules( siteRelativePath, this.rewriteRules ); - const rewrittenRequestUrl = new URL(requestedUrl.toString()); + const rewrittenRequestUrl = new URL(originalRequestUrl.toString()); rewrittenRequestUrl.pathname = joinPaths( this.#PATHNAME, rewrittenRequestPath @@ -410,7 +411,7 @@ export class PHPRequestHandler implements AsyncDisposable { if (!fsPath.endsWith('/')) { return new PHPResponse( 301, - { Location: [`${requestedUrl.pathname}/`] }, + { Location: [`${rewrittenRequestUrl.pathname}/`] }, new Uint8Array(0) ); } @@ -456,7 +457,7 @@ export class PHPRequestHandler implements AsyncDisposable { ...request, // Pass along URL with the #fragment filtered out url: rewrittenRequestUrl.toString(), - urlBeforeRewriting: requestedUrl.toString(), + urlBeforeRewriting: originalRequestUrl.toString(), }; const response = await this.#spawnPHPAndDispatchRequest( effectiveRequest, diff --git a/packages/php-wasm/universal/src/lib/php.ts b/packages/php-wasm/universal/src/lib/php.ts index a4dde0562d..b9ff55cf8e 100644 --- a/packages/php-wasm/universal/src/lib/php.ts +++ b/packages/php-wasm/universal/src/lib/php.ts @@ -650,9 +650,8 @@ export class PHP implements Disposable { ); } this.#setRelativeRequestUri( - request.relativeUriBeforeRewriting || - request.relativeUri || - '' + // request.relativeUriBeforeRewriting || + request.relativeUri || '' ); this.#setRequestMethod(request.method || 'GET'); const requestHeaders = normalizeHeaders(request.headers || {}); From 6afaaf5ff9dd421b36abf3722ea08d8bb28e0e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 5 Nov 2025 17:54:40 +0100 Subject: [PATCH 05/10] Thoroughly set $_SERVER variables in the request handler --- packages/php-wasm/compile/php/php_wasm.c | 5 +- .../universal/src/lib/php-request-handler.ts | 173 +++++++++++++++++- packages/php-wasm/universal/src/lib/php.ts | 10 +- .../universal/src/lib/universal-php.ts | 13 -- 4 files changed, 174 insertions(+), 27 deletions(-) diff --git a/packages/php-wasm/compile/php/php_wasm.c b/packages/php-wasm/compile/php/php_wasm.c index 424fad7f15..7d5d3313a1 100644 --- a/packages/php-wasm/compile/php/php_wasm.c +++ b/packages/php-wasm/compile/php/php_wasm.c @@ -1464,8 +1464,9 @@ static void wasm_sapi_register_server_variables(zval *track_vars_array TSRMLS_DC { // Confirm path translated starts with the document root /** - * PHP_SELF represents the requested script path after any URL rewriting rules - * (e.g. apache .htaccess) have been applied. + * PHP_SELF represents the requested script path resolved to a filesystem path relative to the document + * root. This is after any URL rewriting rules (e.g. apache .htaccess) + * have been applied. * * For example: * 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 3bf8cd8b30..20fe98ffe0 100644 --- a/packages/php-wasm/universal/src/lib/php-request-handler.ts +++ b/packages/php-wasm/universal/src/lib/php-request-handler.ts @@ -84,6 +84,12 @@ export type PHPRequestHandlerFactoryArgs = PHPFactoryOptions & { requestHandler: PHPRequestHandler; }; +interface PHPRequestWrapper { + request: PHPRequest; + originalRequestUrl: URL; + rewrittenRequestUrl: URL; +} + export type PHPRequestHandlerConfiguration = BaseConfiguration & ( | { @@ -453,11 +459,10 @@ export class PHPRequestHandler implements AsyncDisposable { // file-not-found fallback actions may redirect to non-existent files. if (primaryPhp.isFile(fsPath)) { if (fsPath.endsWith('.php')) { - const effectiveRequest: PHPRequest = { - ...request, - // Pass along URL with the #fragment filtered out - url: rewrittenRequestUrl.toString(), - urlBeforeRewriting: originalRequestUrl.toString(), + const effectiveRequest: PHPRequestWrapper = { + request, + originalRequestUrl, + rewrittenRequestUrl, }; const response = await this.#spawnPHPAndDispatchRequest( effectiveRequest, @@ -514,7 +519,7 @@ export class PHPRequestHandler implements AsyncDisposable { * Spawns a new PHP instance and dispatches a request to it. */ async #spawnPHPAndDispatchRequest( - request: PHPRequest, + request: PHPRequestWrapper, scriptPath: string ): Promise { let spawnedPHP: SpawnedPHP | undefined = undefined; @@ -549,9 +554,10 @@ export class PHPRequestHandler implements AsyncDisposable { */ async #dispatchToPHP( php: PHP, - request: PHPRequest, + requestWrapper: PHPRequestWrapper, scriptPath: string ): Promise { + const { request, rewrittenRequestUrl } = requestWrapper; let preferredMethod: PHPRunOptions['method'] = 'GET'; const headers: Record = { @@ -573,7 +579,7 @@ export class PHPRequestHandler implements AsyncDisposable { try { const response = await php.run({ relativeUri: ensurePathPrefix( - toRelativeUrl(new URL(request.url)), + toRelativeUrl(new URL(rewrittenRequestUrl.toString())), this.#PATHNAME ), protocol: this.#PROTOCOL, @@ -584,6 +590,7 @@ export class PHPRequestHandler implements AsyncDisposable { HTTPS: this.#ABSOLUTE_URL.startsWith('https://') ? 'on' : '', + ...this.prepare$_SERVER(requestWrapper, scriptPath), }, body, scriptPath, @@ -605,6 +612,156 @@ export class PHPRequestHandler implements AsyncDisposable { } } + /** + * Computes the essential $_SERVER entries for a request. + * + * php_wasm.c sets some defaults, assuming it runs as a CLI script. + * This function overrides them with the values correct in the request + * context. + * + * @TODO: Consolidate the $_SERVER setting logic into a single place instead + * of splitting it between the C SAPI and the TypeScript code. The PHP + * class has a `.cli()` method that could take care of the CLI-specific + * $_SERVER values. + * + * Path and URL-related $_SERVER entries are theoretically documented + * at https://www.php.net/manual/en/reserved.variables.server.php, + * but that page is not very helpful in practice. Here are tables derived + * by interacting with PHP servers: + * + * ## PHP Dev Server + * + * Setup: + * – `/home/adam/subdir/script.php` file contains `` + * – `php -S 127.0.0.1:8041` running in `/home/adam` directory + * – A request is sent to `http://127.0.0.1:8041/subdir/script.php/b.php/c.php` + * + * Results: + * + * $_SERVER['REQUEST_URI'] | `/subdir/script.php/b.php/c.php` + * $_SERVER['SCRIPT_NAME'] | `/subdir/script.php` + * $_SERVER['SCRIPT_FILENAME']| `/home/adam/subdir/script.php` + * $_SERVER['PATH_INFO'] | `/b.php/c.php` + * $_SERVER['PHP_SELF'] | `/subdir/script.php/b.php/c.php` + * + * ## Apache – rewriting rules + * + * Setup: + * – `/var/www/html/subdir/script.php` file contains `` + * – Apache is listening on port 8041 + * – The document root is `/var/www/html` + * – A request is sent to `http://127.0.0.1:8041/api/v1/user/123` + * + * .htaccess file: + * + * ```apache + * RewriteEngine On + * RewriteRule ^api/v1/user/([0-9]+)$ /subdir/script.php?endpoint=user&id=$1 [L,QSA] + * ``` + * + * Results: + * + * ``` + * $_SERVER['REQUEST_URI'] | /api/v1/user/123 + * $_SERVER['SCRIPT_NAME'] | /subdir/script.php + * $_SERVER['SCRIPT_FILENAME'] | /var/www/html/subdir/script.php + * $_SERVER['PATH_INFO'] | (not set) + * $_SERVER['PHP_SELF'] | /subdir/script.php + * $_SERVER['QUERY_STRING'] | endpoint=user&id=123 + * $_SERVER['REDIRECT_STATUS'] | 200 + * $_SERVER['REDIRECT_URL'] | /api/v1/user/123 + * $_SERVER['REDIRECT_QUERY_STRING'] | endpoint=user&id=123 + * === $_GET Variables === + * $_GET['endpoint'] | user + * $_GET['id'] | 123 + * ``` + * + * ## Apache – vanilla request + * + * Setup: + * – The same as above. + * – A request sent http://localhost:8041/subdir/script.php?param=value + * + * Results: + * + * ``` + * $_SERVER['REQUEST_URI'] | /subdir/script.php?param=value + * $_SERVER['SCRIPT_NAME'] | /subdir/script.php + * $_SERVER['SCRIPT_FILENAME'] | /var/www/html/subdir/script.php + * $_SERVER['PATH_INFO'] | (not set) + * $_SERVER['PHP_SELF'] | /subdir/script.php + * $_SERVER['REDIRECT_URL'] | (not set) + * $_SERVER['REDIRECT_STATUS'] | (not set) + * $_SERVER['QUERY_STRING'] | param=value + * $_SERVER['REQUEST_METHOD'] | GET + * $_SERVER['DOCUMENT_ROOT'] | /var/www/html + * + * === $_GET Variables === + * $_GET['param'] | value + * ``` + */ + private prepare$_SERVER( + { + // request, + originalRequestUrl, + rewrittenRequestUrl, + }: PHPRequestWrapper, + resolvedScriptPath: string + ): Record { + const $_SERVER: Record = {}; + /** + * REQUEST_URI + * + * Path + query string extracted from the + * requested URL **after** applying URL rewriting. + */ + $_SERVER['REQUEST_URI'] = + rewrittenRequestUrl.pathname + rewrittenRequestUrl.search; + + /** + * SCRIPT_NAME + * + * Filesystem path of the script relative to the document root. + * Note this is a filesystem path so URL rewriting is not applicable here. + */ + if (resolvedScriptPath.startsWith(this.#DOCROOT)) { + $_SERVER['SCRIPT_NAME'] = resolvedScriptPath.substring( + this.#DOCROOT.length + ); + } + + /** + * PHP_SELF + * + * Path extracted from the requested URL up to + * the resolved script path **before** applying URL + * rewriting. See the php dev server example above. + * + * Requesting `/subdir/script.php/b.php/c.php` will set + * PHP_SELF to `/subdir/script.php/b.php/c.php`. + */ + $_SERVER['PHP_SELF'] = originalRequestUrl.pathname; + + /** + * QUERY_STRING + * + * Query string extracted from the requested URL + * **after** applying URL rewriting. + */ + $_SERVER['QUERY_STRING'] = rewrittenRequestUrl.search; + + /** + * There's a few relevant entries we are NOT setting here: + * + * – SCRIPT_FILENAME: Absolute path to the script file. It is set by + * php_wasm.c. + * – REDIRECT_STATUS: Apache sets it, but it's optional so we skip it. + * – REDIRECT_URL: Apache sets it, but it's optional so we skip it. + * – REDIRECT_QUERY_STRING: Apache sets it, but it's optional so we skip it. + */ + return $_SERVER; + } + async [Symbol.asyncDispose]() { await this.processManager[Symbol.asyncDispose](); } diff --git a/packages/php-wasm/universal/src/lib/php.ts b/packages/php-wasm/universal/src/lib/php.ts index b9ff55cf8e..ac7463e58f 100644 --- a/packages/php-wasm/universal/src/lib/php.ts +++ b/packages/php-wasm/universal/src/lib/php.ts @@ -649,10 +649,7 @@ export class PHP implements Disposable { `The script path "${request.scriptPath}" does not exist.` ); } - this.#setRelativeRequestUri( - // request.relativeUriBeforeRewriting || - request.relativeUri || '' - ); + this.#setRelativeRequestUri(request.relativeUri || ''); this.#setRequestMethod(request.method || 'GET'); const requestHeaders = normalizeHeaders(request.headers || {}); const host = requestHeaders['host'] || 'example.com:443'; @@ -687,6 +684,10 @@ export class PHP implements Disposable { for (const key in $_SERVER) { this.#setServerGlobalEntry(key, $_SERVER[key]); } + this.#setServerGlobalEntry( + 'PHP_SELF', + request.relativeUri || '' + ); const env = request.env || {}; for (const key in env) { @@ -773,6 +774,7 @@ export class PHP implements Disposable { $_SERVER[`${HTTP_prefix}${name.toUpperCase().replace(/-/g, '_')}`] = headers[name]; } + return $_SERVER; } diff --git a/packages/php-wasm/universal/src/lib/universal-php.ts b/packages/php-wasm/universal/src/lib/universal-php.ts index e2426c462a..6e884401ee 100644 --- a/packages/php-wasm/universal/src/lib/universal-php.ts +++ b/packages/php-wasm/universal/src/lib/universal-php.ts @@ -100,12 +100,6 @@ export interface PHPRequest { */ url: string; - /** - * Request URL before any URL rewriting rules (e.g. apache .htaccess) - * have been applied. - */ - urlBeforeRewriting?: string; - /** * Request headers. */ @@ -127,13 +121,6 @@ export interface PHPRunOptions { */ relativeUri?: string; - /** - * Request path following the domain:port part – - * before any URL rewriting rules (e.g. apache .htaccess) - * have been applied. - */ - relativeUriBeforeRewriting?: string; - /** * Path of the .php file to execute. */ From 4b49765410beedd9cad71e24b8e95809e721d64d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 5 Nov 2025 19:20:16 +0100 Subject: [PATCH 06/10] Scrutinize all the $_SERVER variables and test nuanced scenarios against real outcomes from other servers --- .../node/src/test/php-request-handler.spec.ts | 250 +++++++++++++++++- .../universal/src/lib/php-request-handler.ts | 236 +++++++++++++---- .../playground/wordpress/src/rewrite-rules.ts | 9 +- .../wordpress/src/test/rewrite-rules.spec.ts | 11 - 4 files changed, 429 insertions(+), 77 deletions(-) diff --git a/packages/php-wasm/node/src/test/php-request-handler.spec.ts b/packages/php-wasm/node/src/test/php-request-handler.spec.ts index 1cf3b8da38..cf3b10bda1 100644 --- a/packages/php-wasm/node/src/test/php-request-handler.spec.ts +++ b/packages/php-wasm/node/src/test/php-request-handler.spec.ts @@ -41,8 +41,8 @@ interface ConfigForRequestTests { absoluteUrl: string | undefined; } -const configsForRequestTests: ConfigForRequestTests[] = - SupportedPHPVersions.map((phpVersion) => { +let configsForRequestTests: ConfigForRequestTests[] = SupportedPHPVersions.map( + (phpVersion) => { const documentRoots = [ '/', // TODO: Re-enable when we can avoid GH workflow cancelation. @@ -64,7 +64,14 @@ const configsForRequestTests: ConfigForRequestTests[] = absoluteUrl, })); }); - }).flat(2); + } +).flat(2); + +if ('PHP' in process.env) { + configsForRequestTests = configsForRequestTests.filter( + (config) => config.phpVersion === process.env['PHP'] + ); +} describe.each(configsForRequestTests)( '[PHP $phpVersion, DocRoot $docRoot, AbsUrl $absoluteUrl] PHPRequestHandler – request', @@ -156,6 +163,7 @@ describe.each(configsForRequestTests)( }); it('should serve a static file with urlencoded entities in the path', async () => { + console.log({ absoluteUrl, docRoot }); php.writeFile( joinPaths(docRoot, 'Screenshot 2024-04-05 at 7.13.36 AM.html'), `Hello World` @@ -670,8 +678,12 @@ describe.each(configsForRequestTests)( } ); -describe.each(SupportedPHPVersions)( - '[PHP %s] PHPRequestHandler – PHP_SELF', +let phpVersions = SupportedPHPVersions; +if ('PHP' in process.env) { + phpVersions = [process.env['PHP']] as any; +} +describe.each(phpVersions)( + '[PHP %s] PHPRequestHandler – $_SERVER entries', (phpVersion) => { let handler: PHPRequestHandler; beforeEach(async () => { @@ -732,6 +744,234 @@ describe.each(SupportedPHPVersions)( expect(response.text).toEqual('/var/www'); }); + + describe('PHP Dev Server scenario (with PATH_INFO)', () => { + it('should set $_SERVER variables correctly for script with PATH_INFO', async () => { + const php = await handler.getPrimaryPhp(); + php.mkdirTree('/var/www/subdir'); + php.writeFile( + '/var/www/subdir/script.php', + ` $_SERVER['REQUEST_URI'], + 'SCRIPT_NAME' => $_SERVER['SCRIPT_NAME'], + 'SCRIPT_FILENAME' => $_SERVER['SCRIPT_FILENAME'], + 'PATH_INFO' => $_SERVER['PATH_INFO'] ?? '(not set)', + 'PHP_SELF' => $_SERVER['PHP_SELF'], + ]); + ` + ); + + const response = await handler.request({ + url: '/subdir/script.php/b.php/c.php', + }); + + const result = response.json; + expect(result['REQUEST_URI']).toEqual( + '/subdir/script.php/b.php/c.php' + ); + expect(result['SCRIPT_NAME']).toEqual('/subdir/script.php'); + expect(result['SCRIPT_FILENAME']).toEqual( + '/var/www/subdir/script.php' + ); + expect(result['PATH_INFO']).toEqual('/b.php/c.php'); + expect(result['PHP_SELF']).toEqual( + '/subdir/script.php/b.php/c.php' + ); + }); + }); + + describe('Apache vanilla request scenario', () => { + it('should set $_SERVER variables correctly for vanilla request with query string', async () => { + const php = await handler.getPrimaryPhp(); + php.mkdirTree('/var/www/subdir'); + php.writeFile( + '/var/www/subdir/script.php', + ` $_SERVER['REQUEST_URI'], + 'SCRIPT_NAME' => $_SERVER['SCRIPT_NAME'], + 'SCRIPT_FILENAME' => $_SERVER['SCRIPT_FILENAME'], + 'PATH_INFO' => $_SERVER['PATH_INFO'] ?? '(not set)', + 'PHP_SELF' => $_SERVER['PHP_SELF'], + 'QUERY_STRING' => $_SERVER['QUERY_STRING'] ?? '', + 'REQUEST_METHOD' => $_SERVER['REQUEST_METHOD'], + 'DOCUMENT_ROOT' => $_SERVER['DOCUMENT_ROOT'], + 'GET_param' => $_GET['param'] ?? '(not set)', + ]); + ` + ); + + const response = await handler.request({ + url: '/subdir/script.php?param=value', + }); + + const result = response.json; + expect(result['REQUEST_URI']).toEqual( + '/subdir/script.php?param=value' + ); + expect(result['SCRIPT_NAME']).toEqual('/subdir/script.php'); + expect(result['SCRIPT_FILENAME']).toEqual( + '/var/www/subdir/script.php' + ); + expect(result['PATH_INFO']).toEqual(''); + // This should actually be a missing key, not an empty string. + // @TODO: Adjust this inconsistency. + // expect(result['PATH_INFO']).toEqual('(not set)'); + expect(result['PHP_SELF']).toEqual('/subdir/script.php'); + expect(result['QUERY_STRING']).toEqual('param=value'); + expect(result['REQUEST_METHOD']).toEqual('GET'); + expect(result['DOCUMENT_ROOT']).toEqual('/var/www'); + expect(result['GET_param']).toEqual('value'); + }); + }); + + describe('Apache rewriting rules scenario', () => { + it('should set $_SERVER variables correctly when rewrite rules are applied', async () => { + const handlerWithRewrite = new PHPRequestHandler({ + phpFactory: async () => + new PHP(await loadNodeRuntime(phpVersion)), + documentRoot: '/var/www', + maxPhpInstances: 1, + rewriteRules: [ + { + match: /^\/api\/v1\/user\/([0-9]+)$/, + replacement: + '/subdir/script.php?endpoint=user&id=$1', + }, + ], + }); + const php = await handlerWithRewrite.getPrimaryPhp(); + php.mkdirTree('/var/www/subdir'); + php.writeFile( + '/var/www/subdir/script.php', + ` $_SERVER['REQUEST_URI'], + 'SCRIPT_NAME' => $_SERVER['SCRIPT_NAME'], + 'SCRIPT_FILENAME' => $_SERVER['SCRIPT_FILENAME'], + 'PATH_INFO' => $_SERVER['PATH_INFO'] ?? '(not set)', + 'PHP_SELF' => $_SERVER['PHP_SELF'], + 'QUERY_STRING' => $_SERVER['QUERY_STRING'] ?? '', + 'GET_endpoint' => $_GET['endpoint'] ?? '(not set)', + 'GET_id' => $_GET['id'] ?? '(not set)', + ]); + ` + ); + + const response = await handlerWithRewrite.request({ + url: '/api/v1/user/123', + }); + + const result = response.json; + // REQUEST_URI should be the original URL (before rewriting) per Apache behavior + expect(result['REQUEST_URI']).toEqual('/api/v1/user/123'); + // SCRIPT_NAME is the path to the script relative to document root + expect(result['SCRIPT_NAME']).toEqual('/subdir/script.php'); + // SCRIPT_FILENAME is the absolute path to the script file + expect(result['SCRIPT_FILENAME']).toEqual( + '/var/www/subdir/script.php' + ); + // PATH_INFO is not set for this type of rewrite + expect(result['PATH_INFO']).toEqual('(not set)'); + // PHP_SELF should be the script path per Apache behavior + expect(result['PHP_SELF']).toEqual('/subdir/script.php'); + // QUERY_STRING should contain the rewritten query parameters + expect(result['QUERY_STRING']).toEqual('endpoint=user&id=123'); + // $_GET should have the parsed query parameters + expect(result['GET_endpoint']).toEqual('user'); + expect(result['GET_id']).toEqual('123'); + + php.exit(); + }); + + it('should preserve original REQUEST_URI while rewriting to a different script', async () => { + const handlerWithRewrite = new PHPRequestHandler({ + phpFactory: async () => + new PHP(await loadNodeRuntime(phpVersion)), + documentRoot: '/var/www', + maxPhpInstances: 1, + rewriteRules: [ + { + match: /^\/pretty\/url/, + replacement: '/index.php?page=pretty', + }, + ], + }); + const php = await handlerWithRewrite.getPrimaryPhp(); + php.writeFile( + '/var/www/index.php', + ` $_SERVER['REQUEST_URI'], + 'PHP_SELF' => $_SERVER['PHP_SELF'], + 'SCRIPT_NAME' => $_SERVER['SCRIPT_NAME'], + ]); + ` + ); + + const response = await handlerWithRewrite.request({ + url: '/pretty/url', + }); + + const result = response.json; + // REQUEST_URI should be the original URL per Apache behavior + expect(result['REQUEST_URI']).toEqual('/pretty/url'); + // PHP_SELF should be the script path per Apache behavior + expect(result['PHP_SELF']).toEqual('/index.php'); + // SCRIPT_NAME is the script path + expect(result['SCRIPT_NAME']).toEqual('/index.php'); + + php.exit(); + }); + + it('should preserve the original query params through URL rewriting', async () => { + const handlerWithRewrite = new PHPRequestHandler({ + phpFactory: async () => + new PHP(await loadNodeRuntime(phpVersion)), + documentRoot: '/var/www', + maxPhpInstances: 1, + rewriteRules: [ + { + match: /^\/pretty\/url/, + replacement: '/index.php?page=pretty', + }, + ], + }); + const php = await handlerWithRewrite.getPrimaryPhp(); + php.writeFile( + '/var/www/index.php', + ` $_SERVER['REQUEST_URI'], + 'PHP_SELF' => $_SERVER['PHP_SELF'], + 'SCRIPT_NAME' => $_SERVER['SCRIPT_NAME'], + 'QUERY_STRING' => $_SERVER['QUERY_STRING'], + ]); + ` + ); + + const response = await handlerWithRewrite.request({ + url: '/pretty/url?foo=bar&page=different-value', + }); + + const result = response.json; + // REQUEST_URI should be the original URL per Apache behavior + expect(result['REQUEST_URI']).toEqual( + '/pretty/url?foo=bar&page=different-value' + ); + // QUERY_STRING should contain all the query parameters: original + rewritten + expect(result['QUERY_STRING']).toEqual( + 'page=pretty&foo=bar&page=different-value' + ); + // PHP_SELF should be the script path per Apache behavior + expect(result['PHP_SELF']).toEqual('/index.php'); + // SCRIPT_NAME is the script path + expect(result['SCRIPT_NAME']).toEqual('/index.php'); + + php.exit(); + }); + }); } ); 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 20fe98ffe0..109c4896e5 100644 --- a/packages/php-wasm/universal/src/lib/php-request-handler.ts +++ b/packages/php-wasm/universal/src/lib/php-request-handler.ts @@ -1,4 +1,4 @@ -import { joinPaths } from '@php-wasm/util'; +import { dirname, joinPaths } from '@php-wasm/util'; import { ensurePathPrefix, toRelativeUrl, @@ -373,25 +373,16 @@ export class PHPRequestHandler implements AsyncDisposable { isAbsolute ? undefined : DEFAULT_BASE_URL ); - // Apply the rewrite rules to the original request URL: - const siteRelativePath = removePathPrefix( - decodeURIComponent(originalRequestUrl.pathname), - this.#PATHNAME - ); - const rewrittenRequestPath = applyRewriteRules( - siteRelativePath, - this.rewriteRules - ); - const rewrittenRequestUrl = new URL(originalRequestUrl.toString()); - rewrittenRequestUrl.pathname = joinPaths( - this.#PATHNAME, - rewrittenRequestPath - ); - + const rewrittenRequestUrl = this.#applyRewriteRules(originalRequestUrl); const primaryPhp = await this.getPrimaryPhp(); - - let fsPath = joinPaths(this.#DOCROOT, rewrittenRequestPath); - + let fsPath = joinPaths( + this.#DOCROOT, + /** + * URL.pathname returns a URL-encoded path. We need to decode it + * before using it as a filesystem path. + */ + decodeURIComponent(rewrittenRequestUrl.pathname) + ); if (primaryPhp.isDir(fsPath)) { // Ensure directory URIs have a trailing slash. Otherwise, // relative URIs in index.php or index.html files are relative @@ -428,14 +419,47 @@ export class PHPRequestHandler implements AsyncDisposable { const possibleIndexPath = joinPaths(fsPath, possibleIndexFile); if (primaryPhp.isFile(possibleIndexPath)) { fsPath = possibleIndexPath; + + // Include the resolved index file in the final rewritten request URL. + rewrittenRequestUrl.pathname = joinPaths( + rewrittenRequestUrl.pathname, + possibleIndexFile + ); break; } } } if (!primaryPhp.isFile(fsPath)) { - const fileNotFoundAction = - this.getFileNotFoundAction(rewrittenRequestPath); + /** + * Try resolving a partial path. + * + * Example: + * + * – Request URL: /file.php/index.php + * – Document Root: /var/www + * + * If /var/www/file.php/index.php does not exist, but /var/www/file.php does, + * use /var/www/file.php. This is also what Apache and PHP Dev Server do. + */ + let pathToTry = rewrittenRequestUrl.pathname; + while (true) { + pathToTry = dirname(pathToTry); + if (pathToTry === '/' || !pathToTry.includes('/')) { + // We've tried all segments for a partial path. + break; + } + if (primaryPhp.isFile(joinPaths(this.#DOCROOT, pathToTry))) { + fsPath = joinPaths(this.#DOCROOT, pathToTry); + break; + } + } + } + + if (!primaryPhp.isFile(fsPath)) { + const fileNotFoundAction = this.getFileNotFoundAction( + rewrittenRequestUrl.pathname + ); switch (fileNotFoundAction.type) { case 'response': return fileNotFoundAction.response; @@ -492,6 +516,32 @@ export class PHPRequestHandler implements AsyncDisposable { } } + /** + * Apply the rewrite rules to the original request URL. + * + * @param originalRequestUrl - The original request URL. + * @returns The rewritten request URL. + */ + #applyRewriteRules(originalRequestUrl: URL): URL { + const siteRelativePath = removePathPrefix( + decodeURIComponent(originalRequestUrl.pathname), + this.#PATHNAME + ); + const rewrittenRequestPath = applyRewriteRules( + siteRelativePath, + this.rewriteRules + ); + const rewrittenRequestUrl = new URL( + joinPaths(this.#PATHNAME, rewrittenRequestPath), + originalRequestUrl.toString() + ); + // Merge the query string parameters from the original request URL. + for (const [key, value] of originalRequestUrl.searchParams.entries()) { + rewrittenRequestUrl.searchParams.append(key, value); + } + return rewrittenRequestUrl; + } + /** * Serves a static file from the PHP filesystem. * @@ -590,7 +640,11 @@ export class PHPRequestHandler implements AsyncDisposable { HTTPS: this.#ABSOLUTE_URL.startsWith('https://') ? 'on' : '', - ...this.prepare$_SERVER(requestWrapper, scriptPath), + ...this.prepare$_SERVER( + requestWrapper.originalRequestUrl, + requestWrapper.rewrittenRequestUrl, + scriptPath + ), }, body, scriptPath, @@ -665,7 +719,7 @@ export class PHPRequestHandler implements AsyncDisposable { * $_SERVER['REQUEST_URI'] | /api/v1/user/123 * $_SERVER['SCRIPT_NAME'] | /subdir/script.php * $_SERVER['SCRIPT_FILENAME'] | /var/www/html/subdir/script.php - * $_SERVER['PATH_INFO'] | (not set) + * $_SERVER['PATH_INFO'] | (key not set) * $_SERVER['PHP_SELF'] | /subdir/script.php * $_SERVER['QUERY_STRING'] | endpoint=user&id=123 * $_SERVER['REDIRECT_STATUS'] | 200 @@ -688,10 +742,10 @@ export class PHPRequestHandler implements AsyncDisposable { * $_SERVER['REQUEST_URI'] | /subdir/script.php?param=value * $_SERVER['SCRIPT_NAME'] | /subdir/script.php * $_SERVER['SCRIPT_FILENAME'] | /var/www/html/subdir/script.php - * $_SERVER['PATH_INFO'] | (not set) + * $_SERVER['PATH_INFO'] | (key not set) * $_SERVER['PHP_SELF'] | /subdir/script.php - * $_SERVER['REDIRECT_URL'] | (not set) - * $_SERVER['REDIRECT_STATUS'] | (not set) + * $_SERVER['REDIRECT_URL'] | (key not set) + * $_SERVER['REDIRECT_STATUS'] | (key not set) * $_SERVER['QUERY_STRING'] | param=value * $_SERVER['REQUEST_METHOD'] | GET * $_SERVER['DOCUMENT_ROOT'] | /var/www/html @@ -701,54 +755,125 @@ export class PHPRequestHandler implements AsyncDisposable { * ``` */ private prepare$_SERVER( - { - // request, - originalRequestUrl, - rewrittenRequestUrl, - }: PHPRequestWrapper, + originalRequestUrl: URL, + rewrittenRequestUrl: URL, resolvedScriptPath: string ): Record { const $_SERVER: Record = {}; /** * REQUEST_URI * - * Path + query string extracted from the - * requested URL **after** applying URL rewriting. + * The original path + query string extracted from the requested URL + * **before** applying any URL rewriting. */ $_SERVER['REQUEST_URI'] = - rewrittenRequestUrl.pathname + rewrittenRequestUrl.search; + originalRequestUrl.pathname + originalRequestUrl.search; - /** - * SCRIPT_NAME - * - * Filesystem path of the script relative to the document root. - * Note this is a filesystem path so URL rewriting is not applicable here. - */ if (resolvedScriptPath.startsWith(this.#DOCROOT)) { + /** + * SCRIPT_NAME + * + * > Contains the current script's path. This is useful for pages + * > which need to point to themselves. + * + * Filesystem path of the script relative to the document root. + * Note this is a filesystem path so URL rewriting is not applicable here. + */ $_SERVER['SCRIPT_NAME'] = resolvedScriptPath.substring( this.#DOCROOT.length ); + + /** + * PHP_SELF – the path sourced from the final **request URL** after the + * rewrite rules have been applied. + * + * php.net documentation is very misleading on this one: + * + * > The filename of the currently executing script, relative + * > to the document root. For instance, $_SERVER['PHP_SELF'] + * > in a script at the address http://example.com/foo/bar.php + * > would be /foo/bar.php. + * + * @see https://www.php.net/manual/en/reserved.variables.server.php#:~:text=PHP_SELF + * + * This is not what Apache, nor what the PHP dev server do: + * + * – Document Root: /var/www + * – Script file: /var/www/subdir/script.php + * – Requesting /subdir/script.php/b.php/c.php + * + * $_SERVER['PHP_SELF'] = "/subdir/script.php/b.php/c.php" + * + * So, in that regard, it is a URL path, not a filesystem path. + * + * When URL rewriting is involved, it's the same. + * + * Consider this Apache example from above: + * + * – Document Root: /var/www/html + * – Script file: /var/www/html/subdir/script.php + * – Rewrite rule: ^api/v1/user/([0-9]+)$ /subdir/script.php?endpoint=user&id=$1 [L,QSA] + * – Requesting /api/v1/user/123 + * + * $_SERVER['PHP_SELF'] = "/subdir/script.php" + * + * So, on the face value, this is a filesystem path. However, see + * what happens if we slightly modify that rewrite rule to: + * + * – Rewrite rule: ^api/v1/user/([0-9]+)$ /subdir/script.php/next.php + * ^^^^^^^^^ + * – Requesting /api/v1/user/123 + * + * $_SERVER['PHP_SELF'] = "/subdir/script.php/next.php" + * + * So: + * * PHP_SELF is not sourced from the filesystem path. + * * PHP_SELF is sourced from the final request URL after the + * rewrite rules have been applied. + */ + $_SERVER['PHP_SELF'] = rewrittenRequestUrl.pathname; + + /** + * PATH_INFO + * + * > Contains any client-provided pathname information trailing the actual + * > script filename but preceding the query string, if available. For instance, + * > if the current script was accessed via the URI http://www.example.com/php/path_info.php/some/stuff?foo=bar, + * > then $_SERVER['PATH_INFO'] would contain /some/stuff. + * + * This **does not** include the query string. + * + * @see https://www.php.net/manual/en/reserved.variables.server.php#:~:text=PATH_INFO + */ + if ($_SERVER['REQUEST_URI'].startsWith($_SERVER['SCRIPT_NAME'])) { + $_SERVER['PATH_INFO'] = $_SERVER['REQUEST_URI'].substring( + $_SERVER['SCRIPT_NAME'].length + ); + // Remove the query string if present. + if ($_SERVER['PATH_INFO'].includes('?')) { + $_SERVER['PATH_INFO'] = $_SERVER['PATH_INFO'].substring( + 0, + $_SERVER['PATH_INFO'].indexOf('?') + ); + } + } } /** - * PHP_SELF + * QUERY_STRING * - * Path extracted from the requested URL up to - * the resolved script path **before** applying URL - * rewriting. See the php dev server example above. + * The query string from the original and rewritten request URLs. + * Does not include the leading question mark. * - * Requesting `/subdir/script.php/b.php/c.php` will set - * PHP_SELF to `/subdir/script.php/b.php/c.php`. - */ - $_SERVER['PHP_SELF'] = originalRequestUrl.pathname; - - /** - * QUERY_STRING + * Note it contains all the query parameters from the original + * URL merged with the new parameters from the rewritten request URLs. * - * Query string extracted from the requested URL - * **after** applying URL rewriting. + * Example: + * – Original request URL: /pretty/url?foo=bar&page=different-value + * – Rewritten request URL: /pretty/url?page=pretty + * – QUERY_STRING: page=pretty&foo=bar&page=different-value */ - $_SERVER['QUERY_STRING'] = rewrittenRequestUrl.search; + $_SERVER['QUERY_STRING'] = rewrittenRequestUrl.search.substring(1); /** * There's a few relevant entries we are NOT setting here: @@ -793,7 +918,8 @@ export function inferMimeType(path: string): string { 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); + path = path.replace(rule.match, rule.replacement); + break; } } return path; diff --git a/packages/playground/wordpress/src/rewrite-rules.ts b/packages/playground/wordpress/src/rewrite-rules.ts index 9e80f6118f..1c1b4cbdda 100644 --- a/packages/playground/wordpress/src/rewrite-rules.ts +++ b/packages/playground/wordpress/src/rewrite-rules.ts @@ -144,12 +144,9 @@ export const wordPressRewriteRules: RewriteRule[] = [ */ { match: new RegExp( - `^(` + - /* The .htaccess rule does not have an explicit initial slash, - but it's still implied by `RewriteBase /` */ - `/` + - `[_0-9a-zA-Z-/]+` + - `)?` + + /* The .htaccess rule does not have an explicit initial slash, + but it's still implied by `RewriteBase /` */ + `^(/[_0-9a-zA-Z-]+)?` + /** * Avoid discarding the initial slash of the rewritten URL. * .htaccess places the trailing slash in the first group. It diff --git a/packages/playground/wordpress/src/test/rewrite-rules.spec.ts b/packages/playground/wordpress/src/test/rewrite-rules.spec.ts index 8775ba5b16..6d4feb99db 100644 --- a/packages/playground/wordpress/src/test/rewrite-rules.spec.ts +++ b/packages/playground/wordpress/src/test/rewrite-rules.spec.ts @@ -37,17 +37,6 @@ describe('Test WordPress rewrites', () => { ); }); - 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' - ); - }); - it('Should only target the initial wp-admin|wp-content|wp-includes path (1)', async () => { expect( applyRewriteRules( From 1a470e01e35c46ea7089be99944d62b08e2e4dc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 5 Nov 2025 19:21:07 +0100 Subject: [PATCH 07/10] Rename request to requestWrapper --- packages/php-wasm/universal/src/lib/php-request-handler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 109c4896e5..6e0a0b5178 100644 --- a/packages/php-wasm/universal/src/lib/php-request-handler.ts +++ b/packages/php-wasm/universal/src/lib/php-request-handler.ts @@ -569,7 +569,7 @@ export class PHPRequestHandler implements AsyncDisposable { * Spawns a new PHP instance and dispatches a request to it. */ async #spawnPHPAndDispatchRequest( - request: PHPRequestWrapper, + requestWrapper: PHPRequestWrapper, scriptPath: string ): Promise { let spawnedPHP: SpawnedPHP | undefined = undefined; @@ -587,7 +587,7 @@ export class PHPRequestHandler implements AsyncDisposable { try { return await this.#dispatchToPHP( spawnedPHP.php, - request, + requestWrapper, scriptPath ); } finally { From 8af49227814960c98392c203f70aae63153365b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 5 Nov 2025 23:46:39 +0100 Subject: [PATCH 08/10] Call removePathPrefix() to compute fsPath --- .../universal/src/lib/php-request-handler.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) 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 6e0a0b5178..03dd3a277f 100644 --- a/packages/php-wasm/universal/src/lib/php-request-handler.ts +++ b/packages/php-wasm/universal/src/lib/php-request-handler.ts @@ -378,10 +378,17 @@ export class PHPRequestHandler implements AsyncDisposable { let fsPath = joinPaths( this.#DOCROOT, /** - * URL.pathname returns a URL-encoded path. We need to decode it - * before using it as a filesystem path. + * Turn a URL such as `https://playground/scope:my-site/wp-admin/index.php` + * into a site-relative path, such as `/wp-admin/index.php`. */ - decodeURIComponent(rewrittenRequestUrl.pathname) + removePathPrefix( + /** + * URL.pathname returns a URL-encoded path. We need to decode it + * before using it as a filesystem path. + */ + decodeURIComponent(rewrittenRequestUrl.pathname), + this.#PATHNAME + ) ); if (primaryPhp.isDir(fsPath)) { // Ensure directory URIs have a trailing slash. Otherwise, From 52d45821ac1197c49b839e8ef44f4f4c00582fd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 5 Nov 2025 23:49:08 +0100 Subject: [PATCH 09/10] Remove PHPRequestWrapper type --- .../universal/src/lib/php-request-handler.ts | 48 ++++++++----------- 1 file changed, 21 insertions(+), 27 deletions(-) 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 03dd3a277f..19f3e3201f 100644 --- a/packages/php-wasm/universal/src/lib/php-request-handler.ts +++ b/packages/php-wasm/universal/src/lib/php-request-handler.ts @@ -84,12 +84,6 @@ export type PHPRequestHandlerFactoryArgs = PHPFactoryOptions & { requestHandler: PHPRequestHandler; }; -interface PHPRequestWrapper { - request: PHPRequest; - originalRequestUrl: URL; - rewrittenRequestUrl: URL; -} - export type PHPRequestHandlerConfiguration = BaseConfiguration & ( | { @@ -490,13 +484,10 @@ export class PHPRequestHandler implements AsyncDisposable { // file-not-found fallback actions may redirect to non-existent files. if (primaryPhp.isFile(fsPath)) { if (fsPath.endsWith('.php')) { - const effectiveRequest: PHPRequestWrapper = { + const response = await this.#spawnPHPAndDispatchRequest( request, originalRequestUrl, rewrittenRequestUrl, - }; - const response = await this.#spawnPHPAndDispatchRequest( - effectiveRequest, fsPath ); @@ -576,7 +567,9 @@ export class PHPRequestHandler implements AsyncDisposable { * Spawns a new PHP instance and dispatches a request to it. */ async #spawnPHPAndDispatchRequest( - requestWrapper: PHPRequestWrapper, + request: PHPRequest, + originalRequestUrl: URL, + rewrittenRequestUrl: URL, scriptPath: string ): Promise { let spawnedPHP: SpawnedPHP | undefined = undefined; @@ -594,7 +587,9 @@ export class PHPRequestHandler implements AsyncDisposable { try { return await this.#dispatchToPHP( spawnedPHP.php, - requestWrapper, + request, + originalRequestUrl, + rewrittenRequestUrl, scriptPath ); } finally { @@ -611,10 +606,11 @@ export class PHPRequestHandler implements AsyncDisposable { */ async #dispatchToPHP( php: PHP, - requestWrapper: PHPRequestWrapper, + request: PHPRequest, + originalRequestUrl: URL, + rewrittenRequestUrl: URL, scriptPath: string ): Promise { - const { request, rewrittenRequestUrl } = requestWrapper; let preferredMethod: PHPRunOptions['method'] = 'GET'; const headers: Record = { @@ -641,18 +637,11 @@ export class PHPRequestHandler implements AsyncDisposable { ), protocol: this.#PROTOCOL, method: request.method || preferredMethod, - $_SERVER: { - REMOTE_ADDR: '127.0.0.1', - DOCUMENT_ROOT: this.#DOCROOT, - HTTPS: this.#ABSOLUTE_URL.startsWith('https://') - ? 'on' - : '', - ...this.prepare$_SERVER( - requestWrapper.originalRequestUrl, - requestWrapper.rewrittenRequestUrl, - scriptPath - ), - }, + $_SERVER: this.prepare$_SERVER( + originalRequestUrl, + rewrittenRequestUrl, + scriptPath + ), body, scriptPath, headers, @@ -766,7 +755,12 @@ export class PHPRequestHandler implements AsyncDisposable { rewrittenRequestUrl: URL, resolvedScriptPath: string ): Record { - const $_SERVER: Record = {}; + const $_SERVER: Record = { + REMOTE_ADDR: '127.0.0.1', + DOCUMENT_ROOT: this.#DOCROOT, + HTTPS: this.#ABSOLUTE_URL.startsWith('https://') ? 'on' : '', + }; + /** * REQUEST_URI * From 74de530704e3be95c6dcf049ecde5b1db3dbe752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 6 Nov 2025 01:27:51 +0100 Subject: [PATCH 10/10] Resolve multisite test failure --- packages/php-wasm/universal/src/lib/php.ts | 7 +++---- .../blueprints/src/lib/steps/enable-multisite.spec.ts | 2 +- .../blueprints/src/lib/steps/enable-multisite.ts | 2 +- .../playground/wordpress/src/test/version-detect.spec.ts | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/php-wasm/universal/src/lib/php.ts b/packages/php-wasm/universal/src/lib/php.ts index ac7463e58f..6ff23fdd3f 100644 --- a/packages/php-wasm/universal/src/lib/php.ts +++ b/packages/php-wasm/universal/src/lib/php.ts @@ -681,13 +681,12 @@ export class PHP implements Disposable { requestHeaders, port ); + if (request.relativeUri) { + this.#setServerGlobalEntry('PHP_SELF', request.relativeUri); + } for (const key in $_SERVER) { this.#setServerGlobalEntry(key, $_SERVER[key]); } - this.#setServerGlobalEntry( - 'PHP_SELF', - request.relativeUri || '' - ); const env = request.env || {}; for (const key in env) { diff --git a/packages/playground/blueprints/src/lib/steps/enable-multisite.spec.ts b/packages/playground/blueprints/src/lib/steps/enable-multisite.spec.ts index ecd06776d1..94db5fa932 100644 --- a/packages/playground/blueprints/src/lib/steps/enable-multisite.spec.ts +++ b/packages/playground/blueprints/src/lib/steps/enable-multisite.spec.ts @@ -82,7 +82,7 @@ describe('Blueprint step enableMultisite', () => { */ await login(php, {}); const response = await requestFollowRedirects({ - url: '/', + url: absoluteUrl, }); expect(response.httpStatusCode).toEqual(200); expect(response.text).toContain('My Sites'); diff --git a/packages/playground/blueprints/src/lib/steps/enable-multisite.ts b/packages/playground/blueprints/src/lib/steps/enable-multisite.ts index 38c72d6f36..85223d0b7d 100644 --- a/packages/playground/blueprints/src/lib/steps/enable-multisite.ts +++ b/packages/playground/blueprints/src/lib/steps/enable-multisite.ts @@ -58,6 +58,6 @@ export const enableMultisite: StepHandler = async ( }); await wpCLI(playground, { - command: 'wp core multisite-convert --base=' + sitePath, + command: `wp core multisite-convert --base="${sitePath}"`, }); }; diff --git a/packages/playground/wordpress/src/test/version-detect.spec.ts b/packages/playground/wordpress/src/test/version-detect.spec.ts index 9c1ac5d1a8..7d18a8661e 100644 --- a/packages/playground/wordpress/src/test/version-detect.spec.ts +++ b/packages/playground/wordpress/src/test/version-detect.spec.ts @@ -93,6 +93,6 @@ describe('Test WP version detection', async () => { it(`maps '${input}' to '${expected}'`, () => { const result = versionStringToLoadedWordPressVersion(input); expect(result).to.equal(expected); - }); + }, 30_000); } });