Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add multisite rewrite rules #1083

Merged
merged 14 commits into from
Mar 20, 2024
7 changes: 5 additions & 2 deletions packages/php-wasm/universal/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
55 changes: 34 additions & 21 deletions packages/php-wasm/universal/src/lib/php-request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 */
Expand All @@ -32,6 +42,7 @@ export class PHPRequestHandler implements RequestHandler {
#PATHNAME: string;
#ABSOLUTE_URL: string;
#semaphore: Semaphore;
rewriteRules: RewriteRule[];

/**
* The PHP instance
Expand All @@ -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;
Expand All @@ -70,6 +82,7 @@ export class PHPRequestHandler implements RequestHandler {
this.#HOST,
this.#PATHNAME,
].join('');
this.rewriteRules = rewriteRules;
}

/** @inheritDoc */
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
136 changes: 38 additions & 98 deletions packages/playground/remote/service-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Response>;
}
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<Response>;
}

// 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
Expand Down Expand Up @@ -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<string, WPModuleDetails> = {};
async function getScopedWpDetails(scope: string): Promise<WPModuleDetails> {
if (!scopeToWpModule[scope]) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,28 @@
<?php
// Define Playground globals
$playground_scope = get_scope_from_url(get_site_url());
bgrgicak marked this conversation as resolved.
Show resolved Hide resolved

function get_scope_from_url($url)
{
$matches = [];
preg_match('/scope:(\d+\.\d+)/', $url, $matches);
return $matches[1] ?? false;
}

function add_scope_to_url($url, $scope = null)
{
global $playground_scope;
if ($scope === null) {
$scope = $playground_scope;
}
$host = parse_url($url, PHP_URL_HOST);
return str_replace(
$host,
"$host/scope:$scope",
$url
);
}

/**
* This is a temporary workaround to hide the 32bit integer warnings that
* appear when using various time related function, such as strtotime and mktime.
Expand Down
9 changes: 7 additions & 2 deletions packages/playground/remote/src/lib/worker-thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
LatestSupportedWordPressVersion,
SupportedWordPressVersions,
SupportedWordPressVersionsList,
wordPressRewriteRules,
} from '@wp-playground/wordpress';
import {
PHPResponse,
Expand Down Expand Up @@ -120,6 +121,7 @@ if (!wordPressAvailableInOPFS) {
const php = new WebPHP(undefined, {
documentRoot: DOCROOT,
absoluteUrl: scopedSiteUrl,
rewriteRules: wordPressRewriteRules,
});

const recreateRuntime = async () =>
Expand All @@ -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.
Expand Down Expand Up @@ -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 = `<?php
const cliBootstrapScript = `<?php
// Set the argv global.
$GLOBALS['argv'] = array_merge([
"/wordpress/wp-cli.phar",
"--path=/wordpress"
], ${phpVar(args.slice(2))});

// Provide stdin, stdout, stderr streams outside of
// the CLI SAPI.
define('STDIN', fopen('php://stdin', 'rb'));
Expand Down
3 changes: 3 additions & 0 deletions packages/playground/wordpress/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,8 @@
"engines": {
"node": ">=18.18.2",
"npm": ">=8.11.0"
},
"dependencies": {
"@php-wasm/universal": "^0.6.6"
}
}
1 change: 1 addition & 0 deletions packages/playground/wordpress/src/index.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand Down
11 changes: 11 additions & 0 deletions packages/playground/wordpress/src/rewrite-rules.ts
Original file line number Diff line number Diff line change
@@ -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,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this only match a single path segment? Or not? @bgrgicak

Suggested change
match: /^\/(.*?)(\/wp-(content|admin|includes).*)/g,
match: /^\/([^/]+?)(\/wp-(content|admin|includes).*)/g,

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, this should work with single and multiple paths. For example /scope:0.1/subsite/ and /subsite/.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 what about greedy vs non-greedy? E.g. how would this be handled /scope:03938/subsite/wp-content/plugins/myplugin/wp-content/image.jpg?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

E.g. how would this be handled /scope:03938/subsite/wp-content/plugins/myplugin/wp-content/image.jpg?

It would still work as expected and resolve as /wp-content/plugins/myplugin/wp-content/image.jpg.

Screenshot from 2024-03-22 10-57-41

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome, thank you for your patience! :-)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has been a great learning experience for me. I finally feel comfortable with Regex.

replacement: '$2',
},
];
Loading
Loading