diff --git a/packages/docs/site/docs/08-query-api/01-index.md b/packages/docs/site/docs/08-query-api/01-index.md index 53daab14d0..91a9b4cf6c 100644 --- a/packages/docs/site/docs/08-query-api/01-index.md +++ b/packages/docs/site/docs/08-query-api/01-index.md @@ -24,8 +24,9 @@ You can go ahead and try it out. The Playground will automatically install the t | Option | Default Value | Description | | ---------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `php` | `8.0` | Loads the specified PHP version. Supported values: `5.6`, `7.0`, `7.1`, `7.2`, `7.3`, `7.4`, `8.0`, `8.1`, `8.2`, `latest` | -| `wp` | `latest` | Loads the specified WordPress version. Supported values: `5.9`, `6.0`, `6.1`, `6.2`, `6.3`, `latest`, `nightly`, `beta` | +| `wp` | `latest` | Loads the specified WordPress version. Supported values: `5.9`, `6.0`, `6.1`, `6.2`, `6.3`, `latest`, `nightly`, `beta` | | `php-extension-bundle` | | Loads a bundle of PHP extensions. Supported bundles: `kitchen-sink` (for gd, mbstring, iconv, libxml, xml, dom, simplexml, xmlreader, xmlwriter) | +| `networking` | `yes` or `no` | Enables or disables the networking support for Playground. Defaults to `yes` | | `plugin` | | Installs the specified plugin. Use the plugin name from the plugins directory URL, e.g. for a URL like `https://wordpress.org/plugins/wp-lazy-loading/`, the plugin name would be `wp-lazy-loading`. You can pre-install multiple plugins by saying `plugin=coblocks&plugin=wp-lazy-loading&…`. Installing a plugin automatically logs the user in as an admin | | `theme` | | Installs the specified theme. Use the theme name from the themes directory URL, e.g. for a URL like `https://wordpress.org/themes/disco/`, the theme name would be `disco`. Installing a theme automatically logs the user in as an admin | | `url` | `/wp-admin/` | Load the specified initial page displaying WordPress | diff --git a/packages/docs/site/docs/09-blueprints-api/03-data-format.md b/packages/docs/site/docs/09-blueprints-api/03-data-format.md index a90a814dd6..eb8e22509a 100644 --- a/packages/docs/site/docs/09-blueprints-api/03-data-format.md +++ b/packages/docs/site/docs/09-blueprints-api/03-data-format.md @@ -22,6 +22,9 @@ import BlueprintExample from '@site/src/components/Blueprints/BlueprintExample.m "wp": "5.9" }, "phpExtensionBundles": ["kitchen-sink"], + "features": { + "networking": true + }, "steps": [ { "step": "login", @@ -55,3 +58,9 @@ The `preferredVersions` property, unsurprisingly, declares the preferred of PHP The `phpExtensionBundles` property is an array of PHP extension bundles to load. The following bundles are supported: - `kitchen-sink`: Installs `gd`, `mbstring`, `iconv`, `libxml`, `xml`, `dom`, `simplexml`, `xmlreader`, `xmlwriter` + +## Features + +The `features` property is used to enable or disable certain features of the Playground. It can contain the following properties: + +- `networking`: Defaults to `true`. Enables or disables the networking support for Playground. If enabled, `wp_safe_remote_get` and similar WordPress functions will actually use `fetch()` to make HTTP requests. If disabled, they will immediately fail instead. diff --git a/packages/php-wasm/universal/src/lib/universal-php.ts b/packages/php-wasm/universal/src/lib/universal-php.ts index 2ffa629900..42348d51e5 100644 --- a/packages/php-wasm/universal/src/lib/universal-php.ts +++ b/packages/php-wasm/universal/src/lib/universal-php.ts @@ -416,7 +416,7 @@ export interface IsomorphicLocalPHP extends RequestHandler { export type MessageListener = ( data: string -) => Promise | Promise | Promise | string | void; +) => Promise | string | void; interface EventEmitter { on(event: string, listener: (...args: any[]) => void): this; emit(event: string, ...args: any[]): boolean; diff --git a/packages/playground/blueprints/public/blueprint-schema.json b/packages/playground/blueprints/public/blueprint-schema.json index cb0433b122..562d8fbdf5 100644 --- a/packages/playground/blueprints/public/blueprint-schema.json +++ b/packages/playground/blueprints/public/blueprint-schema.json @@ -1,1322 +1,1205 @@ { - "$schema": "http://json-schema.org/schema", - "$ref": "#/definitions/Blueprint", - "definitions": { - "Blueprint": { - "type": "object", - "properties": { - "landingPage": { - "type": "string", - "description": "The URL to navigate to after the blueprint has been run." - }, - "preferredVersions": { - "type": "object", - "properties": { - "php": { - "anyOf": [ - { - "$ref": "#/definitions/SupportedPHPVersion" - }, - { - "type": "string", - "const": "latest" - } - ], - "description": "The preferred PHP version to use. If not specified, the latest supported version will be used" - }, - "wp": { - "type": "string", - "description": "The preferred WordPress version to use. If not specified, the latest supported version will be used" - } - }, - "required": [ - "php", - "wp" - ], - "additionalProperties": false, - "description": "The preferred PHP and WordPress versions to use." - }, - "phpExtensionBundles": { - "type": "array", - "items": { - "$ref": "#/definitions/SupportedPHPExtensionBundle" - }, - "description": "The PHP extensions to use." - }, - "steps": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/definitions/StepDefinition" - }, - { - "type": "string" - }, - { - "not": {} - }, - { - "type": "boolean", - "const": false - }, - { - "type": "null" - } - ] - }, - "description": "The steps to run." - }, - "$schema": { - "type": "string" - } - }, - "additionalProperties": false - }, - "SupportedPHPVersion": { - "type": "string", - "enum": [ - "8.2", - "8.1", - "8.0", - "7.4", - "7.3", - "7.2", - "7.1", - "7.0" - ] - }, - "SupportedPHPExtensionBundle": { - "type": "string", - "const": "kitchen-sink" - }, - "StepDefinition": { - "type": "object", - "discriminator": { - "propertyName": "step" - }, - "required": [ - "step" - ], - "oneOf": [ - { - "type": "object", - "additionalProperties": false, - "properties": { - "progress": { - "type": "object", - "properties": { - "weight": { - "type": "number" - }, - "caption": { - "type": "string" - } - }, - "additionalProperties": false - }, - "step": { - "type": "string", - "const": "activatePlugin" - }, - "pluginPath": { - "type": "string", - "description": "Path to the plugin directory as absolute path (/wordpress/wp-content/plugins/plugin-name); or the plugin entry file relative to the plugins directory (plugin-name/plugin-name.php)." - }, - "pluginName": { - "type": "string", - "description": "Optional. Plugin name to display in the progress bar." - } - }, - "required": [ - "pluginPath", - "step" - ] - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "progress": { - "type": "object", - "properties": { - "weight": { - "type": "number" - }, - "caption": { - "type": "string" - } - }, - "additionalProperties": false - }, - "step": { - "type": "string", - "const": "activateTheme" - }, - "themeFolderName": { - "type": "string", - "description": "The name of the theme folder inside wp-content/themes/" - } - }, - "required": [ - "step", - "themeFolderName" - ] - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "progress": { - "type": "object", - "properties": { - "weight": { - "type": "number" - }, - "caption": { - "type": "string" - } - }, - "additionalProperties": false - }, - "step": { - "type": "string", - "const": "applyWordPressPatches" - }, - "siteUrl": { - "type": "string" - }, - "wordpressPath": { - "type": "string" - }, - "addPhpInfo": { - "type": "boolean" - }, - "patchSecrets": { - "type": "boolean" - }, - "disableSiteHealth": { - "type": "boolean" - }, - "disableWpNewBlogNotification": { - "type": "boolean" - }, - "makeEditorFrameControlled": { - "type": "boolean" - }, - "prepareForRunningInsideWebBrowser": { - "type": "boolean" - } - }, - "required": [ - "step" - ] - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "progress": { - "type": "object", - "properties": { - "weight": { - "type": "number" - }, - "caption": { - "type": "string" - } - }, - "additionalProperties": false - }, - "step": { - "type": "string", - "const": "cp" - }, - "fromPath": { - "type": "string", - "description": "Source path" - }, - "toPath": { - "type": "string", - "description": "Target path" - } - }, - "required": [ - "fromPath", - "step", - "toPath" - ] - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "progress": { - "type": "object", - "properties": { - "weight": { - "type": "number" - }, - "caption": { - "type": "string" - } - }, - "additionalProperties": false - }, - "step": { - "type": "string", - "const": "defineWpConfigConsts" - }, - "consts": { - "type": "object", - "additionalProperties": {}, - "description": "The constants to define" - }, - "virtualize": { - "type": "boolean", - "description": "Enables the virtualization of wp-config.php and playground-consts.json files, leaving the local system files untouched. The variables defined in the /vfs-blueprints/playground-consts.json file are loaded via the auto_prepend_file directive in the php.ini file.", - "default": false - } - }, - "required": [ - "consts", - "step" - ] - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "progress": { - "type": "object", - "properties": { - "weight": { - "type": "number" - }, - "caption": { - "type": "string" - } - }, - "additionalProperties": false - }, - "step": { - "type": "string", - "const": "defineSiteUrl" - }, - "siteUrl": { - "type": "string", - "description": "The URL" - } - }, - "required": [ - "siteUrl", - "step" - ] - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "progress": { - "type": "object", - "properties": { - "weight": { - "type": "number" - }, - "caption": { - "type": "string" - } - }, - "additionalProperties": false - }, - "step": { - "type": "string", - "const": "importFile" - }, - "file": { - "$ref": "#/definitions/FileReference", - "description": "The file to import" - } - }, - "required": [ - "file", - "step" - ] - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "progress": { - "type": "object", - "properties": { - "weight": { - "type": "number" - }, - "caption": { - "type": "string" - } - }, - "additionalProperties": false - }, - "step": { - "type": "string", - "const": "installPlugin", - "description": "The step identifier." - }, - "pluginZipFile": { - "$ref": "#/definitions/FileReference", - "description": "The plugin zip file to install." - }, - "options": { - "$ref": "#/definitions/InstallPluginOptions", - "description": "Optional installation options." - } - }, - "required": [ - "pluginZipFile", - "step" - ] - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "progress": { - "type": "object", - "properties": { - "weight": { - "type": "number" - }, - "caption": { - "type": "string" - } - }, - "additionalProperties": false - }, - "step": { - "type": "string", - "const": "installTheme", - "description": "The step identifier." - }, - "themeZipFile": { - "$ref": "#/definitions/FileReference", - "description": "The theme zip file to install." - }, - "options": { - "type": "object", - "properties": { - "activate": { - "type": "boolean", - "description": "Whether to activate the theme after installing it." - } - }, - "additionalProperties": false, - "description": "Optional installation options." - } - }, - "required": [ - "step", - "themeZipFile" - ] - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "progress": { - "type": "object", - "properties": { - "weight": { - "type": "number" - }, - "caption": { - "type": "string" - } - }, - "additionalProperties": false - }, - "step": { - "type": "string", - "const": "login" - }, - "username": { - "type": "string", - "description": "The user to log in as. Defaults to 'admin'." - }, - "password": { - "type": "string", - "description": "The password to log in with. Defaults to 'password'." - } - }, - "required": [ - "step" - ] - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "progress": { - "type": "object", - "properties": { - "weight": { - "type": "number" - }, - "caption": { - "type": "string" - } - }, - "additionalProperties": false - }, - "step": { - "type": "string", - "const": "mkdir" - }, - "path": { - "type": "string", - "description": "The path of the directory you want to create" - } - }, - "required": [ - "path", - "step" - ] - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "progress": { - "type": "object", - "properties": { - "weight": { - "type": "number" - }, - "caption": { - "type": "string" - } - }, - "additionalProperties": false - }, - "step": { - "type": "string", - "const": "mv" - }, - "fromPath": { - "type": "string", - "description": "Source path" - }, - "toPath": { - "type": "string", - "description": "Target path" - } - }, - "required": [ - "fromPath", - "step", - "toPath" - ] - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "progress": { - "type": "object", - "properties": { - "weight": { - "type": "number" - }, - "caption": { - "type": "string" - } - }, - "additionalProperties": false - }, - "step": { - "type": "string", - "const": "request" - }, - "request": { - "$ref": "#/definitions/PHPRequest", - "description": "Request details (See /wordpress-playground/api/universal/interface/PHPRequest)" - } - }, - "required": [ - "request", - "step" - ] - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "progress": { - "type": "object", - "properties": { - "weight": { - "type": "number" - }, - "caption": { - "type": "string" - } - }, - "additionalProperties": false - }, - "step": { - "type": "string", - "const": "replaceSite" - }, - "fullSiteZip": { - "$ref": "#/definitions/FileReference", - "description": "The zip file containing the new WordPress site" - } - }, - "required": [ - "fullSiteZip", - "step" - ] - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "progress": { - "type": "object", - "properties": { - "weight": { - "type": "number" - }, - "caption": { - "type": "string" - } - }, - "additionalProperties": false - }, - "step": { - "type": "string", - "const": "rm" - }, - "path": { - "type": "string", - "description": "The path to remove" - } - }, - "required": [ - "path", - "step" - ] - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "progress": { - "type": "object", - "properties": { - "weight": { - "type": "number" - }, - "caption": { - "type": "string" - } - }, - "additionalProperties": false - }, - "step": { - "type": "string", - "const": "rmdir" - }, - "path": { - "type": "string", - "description": "The path to remove" - } - }, - "required": [ - "path", - "step" - ] - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "progress": { - "type": "object", - "properties": { - "weight": { - "type": "number" - }, - "caption": { - "type": "string" - } - }, - "additionalProperties": false - }, - "step": { - "type": "string", - "const": "runPHP", - "description": "The step identifier." - }, - "code": { - "type": "string", - "description": "The PHP code to run." - } - }, - "required": [ - "code", - "step" - ] - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "progress": { - "type": "object", - "properties": { - "weight": { - "type": "number" - }, - "caption": { - "type": "string" - } - }, - "additionalProperties": false - }, - "step": { - "type": "string", - "const": "runPHPWithOptions" - }, - "options": { - "$ref": "#/definitions/PHPRunOptions", - "description": "Run options (See /wordpress-playground/api/universal/interface/PHPRunOptions)" - } - }, - "required": [ - "options", - "step" - ] - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "progress": { - "type": "object", - "properties": { - "weight": { - "type": "number" - }, - "caption": { - "type": "string" - } - }, - "additionalProperties": false - }, - "step": { - "type": "string", - "const": "runWpInstallationWizard" - }, - "options": { - "$ref": "#/definitions/WordPressInstallationOptions" - } - }, - "required": [ - "options", - "step" - ] - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "progress": { - "type": "object", - "properties": { - "weight": { - "type": "number" - }, - "caption": { - "type": "string" - } - }, - "additionalProperties": false - }, - "step": { - "type": "string", - "const": "setPhpIniEntry" - }, - "key": { - "type": "string", - "description": "Entry name e.g. \"display_errors\"" - }, - "value": { - "type": "string", - "description": "Entry value as a string e.g. \"1\"" - } - }, - "required": [ - "key", - "step", - "value" - ] - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "progress": { - "type": "object", - "properties": { - "weight": { - "type": "number" - }, - "caption": { - "type": "string" - } - }, - "additionalProperties": false - }, - "step": { - "type": "string", - "const": "setSiteOptions", - "description": "The name of the step. Must be \"setSiteOptions\"." - }, - "options": { - "type": "object", - "additionalProperties": {}, - "description": "The options to set on the site." - } - }, - "required": [ - "options", - "step" - ] - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "progress": { - "type": "object", - "properties": { - "weight": { - "type": "number" - }, - "caption": { - "type": "string" - } - }, - "additionalProperties": false - }, - "step": { - "type": "string", - "const": "unzip" - }, - "zipPath": { - "type": "string", - "description": "The zip file to extract" - }, - "extractToPath": { - "type": "string", - "description": "The path to extract the zip file to" - } - }, - "required": [ - "extractToPath", - "step", - "zipPath" - ] - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "progress": { - "type": "object", - "properties": { - "weight": { - "type": "number" - }, - "caption": { - "type": "string" - } - }, - "additionalProperties": false - }, - "step": { - "type": "string", - "const": "updateUserMeta" - }, - "meta": { - "type": "object", - "additionalProperties": {}, - "description": "An object of user meta values to set, e.g. { \"first_name\": \"John\" }" - }, - "userId": { - "type": "number", - "description": "User ID" - } - }, - "required": [ - "meta", - "step", - "userId" - ] - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "progress": { - "type": "object", - "properties": { - "weight": { - "type": "number" - }, - "caption": { - "type": "string" - } - }, - "additionalProperties": false - }, - "step": { - "type": "string", - "const": "writeFile" - }, - "path": { - "type": "string", - "description": "The path of the file to write to" - }, - "data": { - "anyOf": [ - { - "$ref": "#/definitions/FileReference" - }, - { - "type": "string" - }, - { - "type": "object", - "properties": { - "BYTES_PER_ELEMENT": { - "type": "number" - }, - "buffer": { - "type": "object", - "properties": { - "byteLength": { - "type": "number" - } - }, - "required": [ - "byteLength" - ], - "additionalProperties": false - }, - "byteLength": { - "type": "number" - }, - "byteOffset": { - "type": "number" - }, - "length": { - "type": "number" - } - }, - "required": [ - "BYTES_PER_ELEMENT", - "buffer", - "byteLength", - "byteOffset", - "length" - ], - "additionalProperties": { - "type": "number" - } - } - ], - "description": "The data to write" - } - }, - "required": [ - "data", - "path", - "step" - ] - } - ] - }, - "FileReference": { - "anyOf": [ - { - "$ref": "#/definitions/VFSReference" - }, - { - "$ref": "#/definitions/LiteralReference" - }, - { - "$ref": "#/definitions/CoreThemeReference" - }, - { - "$ref": "#/definitions/CorePluginReference" - }, - { - "$ref": "#/definitions/UrlReference" - } - ] - }, - "VFSReference": { - "type": "object", - "properties": { - "resource": { - "type": "string", - "const": "vfs", - "description": "Identifies the file resource as Virtual File System (VFS)" - }, - "path": { - "type": "string", - "description": "The path to the file in the VFS" - } - }, - "required": [ - "resource", - "path" - ], - "additionalProperties": false - }, - "LiteralReference": { - "type": "object", - "properties": { - "resource": { - "type": "string", - "const": "literal", - "description": "Identifies the file resource as a literal file" - }, - "name": { - "type": "string", - "description": "The name of the file" - }, - "contents": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "BYTES_PER_ELEMENT": { - "type": "number" - }, - "buffer": { - "type": "object", - "properties": { - "byteLength": { - "type": "number" - } - }, - "required": [ - "byteLength" - ], - "additionalProperties": false - }, - "byteLength": { - "type": "number" - }, - "byteOffset": { - "type": "number" - }, - "length": { - "type": "number" - } - }, - "required": [ - "BYTES_PER_ELEMENT", - "buffer", - "byteLength", - "byteOffset", - "length" - ], - "additionalProperties": { - "type": "number" - } - } - ], - "description": "The contents of the file" - } - }, - "required": [ - "resource", - "name", - "contents" - ], - "additionalProperties": false - }, - "CoreThemeReference": { - "type": "object", - "properties": { - "resource": { - "type": "string", - "const": "wordpress.org/themes", - "description": "Identifies the file resource as a WordPress Core theme" - }, - "slug": { - "type": "string", - "description": "The slug of the WordPress Core theme" - } - }, - "required": [ - "resource", - "slug" - ], - "additionalProperties": false - }, - "CorePluginReference": { - "type": "object", - "properties": { - "resource": { - "type": "string", - "const": "wordpress.org/plugins", - "description": "Identifies the file resource as a WordPress Core plugin" - }, - "slug": { - "type": "string", - "description": "The slug of the WordPress Core plugin" - } - }, - "required": [ - "resource", - "slug" - ], - "additionalProperties": false - }, - "UrlReference": { - "type": "object", - "properties": { - "resource": { - "type": "string", - "const": "url", - "description": "Identifies the file resource as a URL" - }, - "url": { - "type": "string", - "description": "The URL of the file" - }, - "caption": { - "type": "string", - "description": "Optional caption for displaying a progress message" - } - }, - "required": [ - "resource", - "url" - ], - "additionalProperties": false - }, - "InstallPluginOptions": { - "type": "object", - "properties": { - "activate": { - "type": "boolean", - "description": "Whether to activate the plugin after installing it." - } - }, - "additionalProperties": false - }, - "PHPRequest": { - "type": "object", - "properties": { - "method": { - "$ref": "#/definitions/HTTPMethod", - "description": "Request method. Default: `GET`." - }, - "url": { - "type": "string", - "description": "Request path or absolute URL." - }, - "headers": { - "$ref": "#/definitions/PHPRequestHeaders", - "description": "Request headers." - }, - "files": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "size": { - "type": "number" - }, - "type": { - "type": "string" - }, - "lastModified": { - "type": "number" - }, - "name": { - "type": "string" - }, - "webkitRelativePath": { - "type": "string" - } - }, - "required": [ - "lastModified", - "name", - "size", - "type", - "webkitRelativePath" - ], - "additionalProperties": false - }, - "description": "Uploaded files" - }, - "body": { - "type": "string", - "description": "Request body without the files." - }, - "formData": { - "type": "object", - "additionalProperties": {}, - "description": "Form data. If set, the request body will be ignored and the content-type header will be set to `application/x-www-form-urlencoded`." - } - }, - "required": [ - "url" - ], - "additionalProperties": false - }, - "HTTPMethod": { - "type": "string", - "enum": [ - "GET", - "POST", - "HEAD", - "OPTIONS", - "PATCH", - "PUT", - "DELETE" - ] - }, - "PHPRequestHeaders": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "PHPRunOptions": { - "type": "object", - "properties": { - "relativeUri": { - "type": "string", - "description": "Request path following the domain:port part." - }, - "scriptPath": { - "type": "string", - "description": "Path of the .php file to execute." - }, - "protocol": { - "type": "string", - "description": "Request protocol." - }, - "method": { - "$ref": "#/definitions/HTTPMethod", - "description": "Request method. Default: `GET`." - }, - "headers": { - "$ref": "#/definitions/PHPRequestHeaders", - "description": "Request headers." - }, - "body": { - "type": "string", - "description": "Request body without the files." - }, - "fileInfos": { - "type": "array", - "items": { - "$ref": "#/definitions/FileInfo" - }, - "description": "Uploaded files." - }, - "code": { - "type": "string", - "description": "The code snippet to eval instead of a php file." - } - }, - "additionalProperties": false - }, - "FileInfo": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "name": { - "type": "string" - }, - "type": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "BYTES_PER_ELEMENT": { - "type": "number" - }, - "buffer": { - "type": "object", - "properties": { - "byteLength": { - "type": "number" - } - }, - "required": [ - "byteLength" - ], - "additionalProperties": false - }, - "byteLength": { - "type": "number" - }, - "byteOffset": { - "type": "number" - }, - "length": { - "type": "number" - } - }, - "required": [ - "BYTES_PER_ELEMENT", - "buffer", - "byteLength", - "byteOffset", - "length" - ], - "additionalProperties": { - "type": "number" - } - } - }, - "required": [ - "key", - "name", - "type", - "data" - ], - "additionalProperties": false - }, - "WordPressInstallationOptions": { - "type": "object", - "properties": { - "adminUsername": { - "type": "string" - }, - "adminPassword": { - "type": "string" - } - }, - "additionalProperties": false - } - } -} \ No newline at end of file + "$schema": "http://json-schema.org/schema", + "$ref": "#/definitions/Blueprint", + "definitions": { + "Blueprint": { + "type": "object", + "properties": { + "landingPage": { + "type": "string", + "description": "The URL to navigate to after the blueprint has been run." + }, + "preferredVersions": { + "type": "object", + "properties": { + "php": { + "anyOf": [ + { + "$ref": "#/definitions/SupportedPHPVersion" + }, + { + "type": "string", + "const": "latest" + } + ], + "description": "The preferred PHP version to use. If not specified, the latest supported version will be used" + }, + "wp": { + "type": "string", + "description": "The preferred WordPress version to use. If not specified, the latest supported version will be used" + } + }, + "required": ["php", "wp"], + "additionalProperties": false, + "description": "The preferred PHP and WordPress versions to use." + }, + "features": { + "type": "object", + "properties": { + "networking": { + "type": "boolean", + "description": "Should boot with support for network request via wp_safe_remote_get?" + } + }, + "additionalProperties": false + }, + "phpExtensionBundles": { + "type": "array", + "items": { + "$ref": "#/definitions/SupportedPHPExtensionBundle" + }, + "description": "The PHP extensions to use." + }, + "steps": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/StepDefinition" + }, + { + "type": "string" + }, + { + "not": {} + }, + { + "type": "boolean", + "const": false + }, + { + "type": "null" + } + ] + }, + "description": "The steps to run." + }, + "$schema": { + "type": "string" + } + }, + "additionalProperties": false + }, + "SupportedPHPVersion": { + "type": "string", + "enum": ["8.2", "8.1", "8.0", "7.4", "7.3", "7.2", "7.1", "7.0"] + }, + "SupportedPHPExtensionBundle": { + "type": "string", + "const": "kitchen-sink" + }, + "StepDefinition": { + "type": "object", + "discriminator": { + "propertyName": "step" + }, + "required": ["step"], + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "progress": { + "type": "object", + "properties": { + "weight": { + "type": "number" + }, + "caption": { + "type": "string" + } + }, + "additionalProperties": false + }, + "step": { + "type": "string", + "const": "activatePlugin" + }, + "pluginPath": { + "type": "string", + "description": "Path to the plugin directory as absolute path (/wordpress/wp-content/plugins/plugin-name); or the plugin entry file relative to the plugins directory (plugin-name/plugin-name.php)." + }, + "pluginName": { + "type": "string", + "description": "Optional. Plugin name to display in the progress bar." + } + }, + "required": ["pluginPath", "step"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "progress": { + "type": "object", + "properties": { + "weight": { + "type": "number" + }, + "caption": { + "type": "string" + } + }, + "additionalProperties": false + }, + "step": { + "type": "string", + "const": "activateTheme" + }, + "themeFolderName": { + "type": "string", + "description": "The name of the theme folder inside wp-content/themes/" + } + }, + "required": ["step", "themeFolderName"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "progress": { + "type": "object", + "properties": { + "weight": { + "type": "number" + }, + "caption": { + "type": "string" + } + }, + "additionalProperties": false + }, + "step": { + "type": "string", + "const": "applyWordPressPatches" + }, + "siteUrl": { + "type": "string" + }, + "wordpressPath": { + "type": "string" + }, + "addPhpInfo": { + "type": "boolean" + }, + "patchSecrets": { + "type": "boolean" + }, + "disableSiteHealth": { + "type": "boolean" + }, + "disableWpNewBlogNotification": { + "type": "boolean" + }, + "makeEditorFrameControlled": { + "type": "boolean" + }, + "prepareForRunningInsideWebBrowser": { + "type": "boolean" + } + }, + "required": ["step"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "progress": { + "type": "object", + "properties": { + "weight": { + "type": "number" + }, + "caption": { + "type": "string" + } + }, + "additionalProperties": false + }, + "step": { + "type": "string", + "const": "cp" + }, + "fromPath": { + "type": "string", + "description": "Source path" + }, + "toPath": { + "type": "string", + "description": "Target path" + } + }, + "required": ["fromPath", "step", "toPath"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "progress": { + "type": "object", + "properties": { + "weight": { + "type": "number" + }, + "caption": { + "type": "string" + } + }, + "additionalProperties": false + }, + "step": { + "type": "string", + "const": "defineWpConfigConsts" + }, + "consts": { + "type": "object", + "additionalProperties": {}, + "description": "The constants to define" + }, + "virtualize": { + "type": "boolean", + "description": "Enables the virtualization of wp-config.php and playground-consts.json files, leaving the local system files untouched. The variables defined in the /vfs-blueprints/playground-consts.json file are loaded via the auto_prepend_file directive in the php.ini file.", + "default": false + } + }, + "required": ["consts", "step"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "progress": { + "type": "object", + "properties": { + "weight": { + "type": "number" + }, + "caption": { + "type": "string" + } + }, + "additionalProperties": false + }, + "step": { + "type": "string", + "const": "defineSiteUrl" + }, + "siteUrl": { + "type": "string", + "description": "The URL" + } + }, + "required": ["siteUrl", "step"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "progress": { + "type": "object", + "properties": { + "weight": { + "type": "number" + }, + "caption": { + "type": "string" + } + }, + "additionalProperties": false + }, + "step": { + "type": "string", + "const": "importFile" + }, + "file": { + "$ref": "#/definitions/FileReference", + "description": "The file to import" + } + }, + "required": ["file", "step"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "progress": { + "type": "object", + "properties": { + "weight": { + "type": "number" + }, + "caption": { + "type": "string" + } + }, + "additionalProperties": false + }, + "step": { + "type": "string", + "const": "installPlugin", + "description": "The step identifier." + }, + "pluginZipFile": { + "$ref": "#/definitions/FileReference", + "description": "The plugin zip file to install." + }, + "options": { + "$ref": "#/definitions/InstallPluginOptions", + "description": "Optional installation options." + } + }, + "required": ["pluginZipFile", "step"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "progress": { + "type": "object", + "properties": { + "weight": { + "type": "number" + }, + "caption": { + "type": "string" + } + }, + "additionalProperties": false + }, + "step": { + "type": "string", + "const": "installTheme", + "description": "The step identifier." + }, + "themeZipFile": { + "$ref": "#/definitions/FileReference", + "description": "The theme zip file to install." + }, + "options": { + "type": "object", + "properties": { + "activate": { + "type": "boolean", + "description": "Whether to activate the theme after installing it." + } + }, + "additionalProperties": false, + "description": "Optional installation options." + } + }, + "required": ["step", "themeZipFile"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "progress": { + "type": "object", + "properties": { + "weight": { + "type": "number" + }, + "caption": { + "type": "string" + } + }, + "additionalProperties": false + }, + "step": { + "type": "string", + "const": "login" + }, + "username": { + "type": "string", + "description": "The user to log in as. Defaults to 'admin'." + }, + "password": { + "type": "string", + "description": "The password to log in with. Defaults to 'password'." + } + }, + "required": ["step"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "progress": { + "type": "object", + "properties": { + "weight": { + "type": "number" + }, + "caption": { + "type": "string" + } + }, + "additionalProperties": false + }, + "step": { + "type": "string", + "const": "mkdir" + }, + "path": { + "type": "string", + "description": "The path of the directory you want to create" + } + }, + "required": ["path", "step"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "progress": { + "type": "object", + "properties": { + "weight": { + "type": "number" + }, + "caption": { + "type": "string" + } + }, + "additionalProperties": false + }, + "step": { + "type": "string", + "const": "mv" + }, + "fromPath": { + "type": "string", + "description": "Source path" + }, + "toPath": { + "type": "string", + "description": "Target path" + } + }, + "required": ["fromPath", "step", "toPath"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "progress": { + "type": "object", + "properties": { + "weight": { + "type": "number" + }, + "caption": { + "type": "string" + } + }, + "additionalProperties": false + }, + "step": { + "type": "string", + "const": "request" + }, + "request": { + "$ref": "#/definitions/PHPRequest", + "description": "Request details (See /wordpress-playground/api/universal/interface/PHPRequest)" + } + }, + "required": ["request", "step"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "progress": { + "type": "object", + "properties": { + "weight": { + "type": "number" + }, + "caption": { + "type": "string" + } + }, + "additionalProperties": false + }, + "step": { + "type": "string", + "const": "replaceSite" + }, + "fullSiteZip": { + "$ref": "#/definitions/FileReference", + "description": "The zip file containing the new WordPress site" + } + }, + "required": ["fullSiteZip", "step"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "progress": { + "type": "object", + "properties": { + "weight": { + "type": "number" + }, + "caption": { + "type": "string" + } + }, + "additionalProperties": false + }, + "step": { + "type": "string", + "const": "rm" + }, + "path": { + "type": "string", + "description": "The path to remove" + } + }, + "required": ["path", "step"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "progress": { + "type": "object", + "properties": { + "weight": { + "type": "number" + }, + "caption": { + "type": "string" + } + }, + "additionalProperties": false + }, + "step": { + "type": "string", + "const": "rmdir" + }, + "path": { + "type": "string", + "description": "The path to remove" + } + }, + "required": ["path", "step"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "progress": { + "type": "object", + "properties": { + "weight": { + "type": "number" + }, + "caption": { + "type": "string" + } + }, + "additionalProperties": false + }, + "step": { + "type": "string", + "const": "runPHP", + "description": "The step identifier." + }, + "code": { + "type": "string", + "description": "The PHP code to run." + } + }, + "required": ["code", "step"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "progress": { + "type": "object", + "properties": { + "weight": { + "type": "number" + }, + "caption": { + "type": "string" + } + }, + "additionalProperties": false + }, + "step": { + "type": "string", + "const": "runPHPWithOptions" + }, + "options": { + "$ref": "#/definitions/PHPRunOptions", + "description": "Run options (See /wordpress-playground/api/universal/interface/PHPRunOptions)" + } + }, + "required": ["options", "step"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "progress": { + "type": "object", + "properties": { + "weight": { + "type": "number" + }, + "caption": { + "type": "string" + } + }, + "additionalProperties": false + }, + "step": { + "type": "string", + "const": "runWpInstallationWizard" + }, + "options": { + "$ref": "#/definitions/WordPressInstallationOptions" + } + }, + "required": ["options", "step"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "progress": { + "type": "object", + "properties": { + "weight": { + "type": "number" + }, + "caption": { + "type": "string" + } + }, + "additionalProperties": false + }, + "step": { + "type": "string", + "const": "setPhpIniEntry" + }, + "key": { + "type": "string", + "description": "Entry name e.g. \"display_errors\"" + }, + "value": { + "type": "string", + "description": "Entry value as a string e.g. \"1\"" + } + }, + "required": ["key", "step", "value"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "progress": { + "type": "object", + "properties": { + "weight": { + "type": "number" + }, + "caption": { + "type": "string" + } + }, + "additionalProperties": false + }, + "step": { + "type": "string", + "const": "setSiteOptions", + "description": "The name of the step. Must be \"setSiteOptions\"." + }, + "options": { + "type": "object", + "additionalProperties": {}, + "description": "The options to set on the site." + } + }, + "required": ["options", "step"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "progress": { + "type": "object", + "properties": { + "weight": { + "type": "number" + }, + "caption": { + "type": "string" + } + }, + "additionalProperties": false + }, + "step": { + "type": "string", + "const": "unzip" + }, + "zipPath": { + "type": "string", + "description": "The zip file to extract" + }, + "extractToPath": { + "type": "string", + "description": "The path to extract the zip file to" + } + }, + "required": ["extractToPath", "step", "zipPath"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "progress": { + "type": "object", + "properties": { + "weight": { + "type": "number" + }, + "caption": { + "type": "string" + } + }, + "additionalProperties": false + }, + "step": { + "type": "string", + "const": "updateUserMeta" + }, + "meta": { + "type": "object", + "additionalProperties": {}, + "description": "An object of user meta values to set, e.g. { \"first_name\": \"John\" }" + }, + "userId": { + "type": "number", + "description": "User ID" + } + }, + "required": ["meta", "step", "userId"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "progress": { + "type": "object", + "properties": { + "weight": { + "type": "number" + }, + "caption": { + "type": "string" + } + }, + "additionalProperties": false + }, + "step": { + "type": "string", + "const": "writeFile" + }, + "path": { + "type": "string", + "description": "The path of the file to write to" + }, + "data": { + "anyOf": [ + { + "$ref": "#/definitions/FileReference" + }, + { + "type": "string" + }, + { + "type": "object", + "properties": { + "BYTES_PER_ELEMENT": { + "type": "number" + }, + "buffer": { + "type": "object", + "properties": { + "byteLength": { + "type": "number" + } + }, + "required": ["byteLength"], + "additionalProperties": false + }, + "byteLength": { + "type": "number" + }, + "byteOffset": { + "type": "number" + }, + "length": { + "type": "number" + } + }, + "required": [ + "BYTES_PER_ELEMENT", + "buffer", + "byteLength", + "byteOffset", + "length" + ], + "additionalProperties": { + "type": "number" + } + } + ], + "description": "The data to write" + } + }, + "required": ["data", "path", "step"] + } + ] + }, + "FileReference": { + "anyOf": [ + { + "$ref": "#/definitions/VFSReference" + }, + { + "$ref": "#/definitions/LiteralReference" + }, + { + "$ref": "#/definitions/CoreThemeReference" + }, + { + "$ref": "#/definitions/CorePluginReference" + }, + { + "$ref": "#/definitions/UrlReference" + } + ] + }, + "VFSReference": { + "type": "object", + "properties": { + "resource": { + "type": "string", + "const": "vfs", + "description": "Identifies the file resource as Virtual File System (VFS)" + }, + "path": { + "type": "string", + "description": "The path to the file in the VFS" + } + }, + "required": ["resource", "path"], + "additionalProperties": false + }, + "LiteralReference": { + "type": "object", + "properties": { + "resource": { + "type": "string", + "const": "literal", + "description": "Identifies the file resource as a literal file" + }, + "name": { + "type": "string", + "description": "The name of the file" + }, + "contents": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "BYTES_PER_ELEMENT": { + "type": "number" + }, + "buffer": { + "type": "object", + "properties": { + "byteLength": { + "type": "number" + } + }, + "required": ["byteLength"], + "additionalProperties": false + }, + "byteLength": { + "type": "number" + }, + "byteOffset": { + "type": "number" + }, + "length": { + "type": "number" + } + }, + "required": [ + "BYTES_PER_ELEMENT", + "buffer", + "byteLength", + "byteOffset", + "length" + ], + "additionalProperties": { + "type": "number" + } + } + ], + "description": "The contents of the file" + } + }, + "required": ["resource", "name", "contents"], + "additionalProperties": false + }, + "CoreThemeReference": { + "type": "object", + "properties": { + "resource": { + "type": "string", + "const": "wordpress.org/themes", + "description": "Identifies the file resource as a WordPress Core theme" + }, + "slug": { + "type": "string", + "description": "The slug of the WordPress Core theme" + } + }, + "required": ["resource", "slug"], + "additionalProperties": false + }, + "CorePluginReference": { + "type": "object", + "properties": { + "resource": { + "type": "string", + "const": "wordpress.org/plugins", + "description": "Identifies the file resource as a WordPress Core plugin" + }, + "slug": { + "type": "string", + "description": "The slug of the WordPress Core plugin" + } + }, + "required": ["resource", "slug"], + "additionalProperties": false + }, + "UrlReference": { + "type": "object", + "properties": { + "resource": { + "type": "string", + "const": "url", + "description": "Identifies the file resource as a URL" + }, + "url": { + "type": "string", + "description": "The URL of the file" + }, + "caption": { + "type": "string", + "description": "Optional caption for displaying a progress message" + } + }, + "required": ["resource", "url"], + "additionalProperties": false + }, + "InstallPluginOptions": { + "type": "object", + "properties": { + "activate": { + "type": "boolean", + "description": "Whether to activate the plugin after installing it." + } + }, + "additionalProperties": false + }, + "PHPRequest": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/HTTPMethod", + "description": "Request method. Default: `GET`." + }, + "url": { + "type": "string", + "description": "Request path or absolute URL." + }, + "headers": { + "$ref": "#/definitions/PHPRequestHeaders", + "description": "Request headers." + }, + "files": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "size": { + "type": "number" + }, + "type": { + "type": "string" + }, + "lastModified": { + "type": "number" + }, + "name": { + "type": "string" + }, + "webkitRelativePath": { + "type": "string" + } + }, + "required": [ + "lastModified", + "name", + "size", + "type", + "webkitRelativePath" + ], + "additionalProperties": false + }, + "description": "Uploaded files" + }, + "body": { + "type": "string", + "description": "Request body without the files." + }, + "formData": { + "type": "object", + "additionalProperties": {}, + "description": "Form data. If set, the request body will be ignored and the content-type header will be set to `application/x-www-form-urlencoded`." + } + }, + "required": ["url"], + "additionalProperties": false + }, + "HTTPMethod": { + "type": "string", + "enum": ["GET", "POST", "HEAD", "OPTIONS", "PATCH", "PUT", "DELETE"] + }, + "PHPRequestHeaders": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "PHPRunOptions": { + "type": "object", + "properties": { + "relativeUri": { + "type": "string", + "description": "Request path following the domain:port part." + }, + "scriptPath": { + "type": "string", + "description": "Path of the .php file to execute." + }, + "protocol": { + "type": "string", + "description": "Request protocol." + }, + "method": { + "$ref": "#/definitions/HTTPMethod", + "description": "Request method. Default: `GET`." + }, + "headers": { + "$ref": "#/definitions/PHPRequestHeaders", + "description": "Request headers." + }, + "body": { + "type": "string", + "description": "Request body without the files." + }, + "fileInfos": { + "type": "array", + "items": { + "$ref": "#/definitions/FileInfo" + }, + "description": "Uploaded files." + }, + "code": { + "type": "string", + "description": "The code snippet to eval instead of a php file." + } + }, + "additionalProperties": false + }, + "FileInfo": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "BYTES_PER_ELEMENT": { + "type": "number" + }, + "buffer": { + "type": "object", + "properties": { + "byteLength": { + "type": "number" + } + }, + "required": ["byteLength"], + "additionalProperties": false + }, + "byteLength": { + "type": "number" + }, + "byteOffset": { + "type": "number" + }, + "length": { + "type": "number" + } + }, + "required": [ + "BYTES_PER_ELEMENT", + "buffer", + "byteLength", + "byteOffset", + "length" + ], + "additionalProperties": { + "type": "number" + } + } + }, + "required": ["key", "name", "type", "data"], + "additionalProperties": false + }, + "WordPressInstallationOptions": { + "type": "object", + "properties": { + "adminUsername": { + "type": "string" + }, + "adminPassword": { + "type": "string" + } + }, + "additionalProperties": false + } + } +} diff --git a/packages/playground/blueprints/src/lib/blueprint.ts b/packages/playground/blueprints/src/lib/blueprint.ts index 2479a349c5..2cb2e2eea8 100644 --- a/packages/playground/blueprints/src/lib/blueprint.ts +++ b/packages/playground/blueprints/src/lib/blueprint.ts @@ -24,6 +24,10 @@ export interface Blueprint { */ wp: string | 'latest'; }; + features?: { + /** Should boot with support for network request via wp_safe_remote_get? */ + networking?: boolean; + }; /** * The PHP extensions to use. */ diff --git a/packages/playground/blueprints/src/lib/compile.ts b/packages/playground/blueprints/src/lib/compile.ts index 29df7b9e61..253d4ce45f 100644 --- a/packages/playground/blueprints/src/lib/compile.ts +++ b/packages/playground/blueprints/src/lib/compile.ts @@ -40,6 +40,10 @@ export interface CompiledBlueprint { }; /** The requested PHP extensions to load */ phpExtensions: SupportedPHPExtension[]; + features: { + /** Should boot with support for network request via wp_safe_remote_get? */ + networking: boolean; + }; /** The compiled steps for the blueprint */ run: (playground: UniversalPHP) => Promise; } @@ -113,6 +117,10 @@ export function compileBlueprint( [], blueprint.phpExtensionBundles || [] ), + features: { + // Enable networking by default + networking: blueprint.features?.networking ?? true, + }, run: async (playground: UniversalPHP) => { try { // Start resolving resources early diff --git a/packages/playground/blueprints/src/lib/steps/apply-wordpress-patches/index.ts b/packages/playground/blueprints/src/lib/steps/apply-wordpress-patches/index.ts index 11e7030e37..d17104e512 100644 --- a/packages/playground/blueprints/src/lib/steps/apply-wordpress-patches/index.ts +++ b/packages/playground/blueprints/src/lib/steps/apply-wordpress-patches/index.ts @@ -31,6 +31,7 @@ export interface ApplyWordPressPatchesStep { disableWpNewBlogNotification?: boolean; makeEditorFrameControlled?: boolean; prepareForRunningInsideWebBrowser?: boolean; + addFetchNetworkTransport?: boolean; } export const applyWordPressPatches: StepHandler< @@ -66,6 +67,9 @@ export const applyWordPressPatches: StepHandler< if (options.prepareForRunningInsideWebBrowser === true) { await patch.prepareForRunningInsideWebBrowser(); } + if (options.addFetchNetworkTransport === true) { + await patch.addFetchNetworkTransport(); + } }; class WordPressPatcher { @@ -147,9 +151,31 @@ class WordPressPatcher { } async prepareForRunningInsideWebBrowser() { + // Various tweaks + await this.php.writeFile( + `${this.wordpressPath}/wp-content/mu-plugins/1-show-admin-credentials-on-wp-login.php`, + showAdminCredentialsOnWpLogin + ); + await this.php.writeFile( + `${this.wordpressPath}/wp-content/mu-plugins/2-nice-error-messages-for-plugins-and-themes-directories.php`, + niceErrorMessagesForPluginsAndThemesDirectories + ); + await this.php.writeFile( + `${this.wordpressPath}/wp-content/mu-plugins/3-links-targeting-top-frame-should-target-playground-iframe.php`, + linksTargetingTopFrameShouldTargetPlaygroundIframe + ); + + // Activate URL rewriting. + await this.php.writeFile( + `${this.wordpressPath}/wp-content/mu-plugins/4-enable-url-rewrite.php`, + enableUrlRewrite + ); + } + + async addFetchNetworkTransport() { await defineWpConfigConsts(this.php, { consts: { - USE_FETCH_FOR_REQUESTS: false, + USE_FETCH_FOR_REQUESTS: true, }, }); @@ -157,6 +183,8 @@ class WordPressPatcher { const transports = [ `${this.wordpressPath}/wp-includes/Requests/Transport/fsockopen.php`, `${this.wordpressPath}/wp-includes/Requests/Transport/cURL.php`, + `${this.wordpressPath}/wp-includes/Requests/src/Transport/Fsockopen.php`, + `${this.wordpressPath}/wp-includes/Requests/src/Transport/Curl.php`, ]; for (const transport of transports) { // One of the transports might not exist in the latest WordPress version. @@ -188,25 +216,7 @@ class WordPressPatcher { addRequests ); - // Various tweaks - await this.php.writeFile( - `${this.wordpressPath}/wp-content/mu-plugins/1-show-admin-credentials-on-wp-login.php`, - showAdminCredentialsOnWpLogin - ); - await this.php.writeFile( - `${this.wordpressPath}/wp-content/mu-plugins/2-nice-error-messages-for-plugins-and-themes-directories.php`, - niceErrorMessagesForPluginsAndThemesDirectories - ); - await this.php.writeFile( - `${this.wordpressPath}/wp-content/mu-plugins/3-links-targeting-top-frame-should-target-playground-iframe.php`, - linksTargetingTopFrameShouldTargetPlaygroundIframe - ); - - // Activate URL rewriting. - await this.php.writeFile( - `${this.wordpressPath}/wp-content/mu-plugins/4-enable-url-rewrite.php`, - enableUrlRewrite - ); + await this.php.mkdir(`${this.wordpressPath}/wp-content/fonts`); } } diff --git a/packages/playground/blueprints/src/lib/steps/apply-wordpress-patches/wp-content/mu-plugins/add_requests_transport.php b/packages/playground/blueprints/src/lib/steps/apply-wordpress-patches/wp-content/mu-plugins/add_requests_transport.php index 604f8fb203..6d43fa52b6 100644 --- a/packages/playground/blueprints/src/lib/steps/apply-wordpress-patches/wp-content/mu-plugins/add_requests_transport.php +++ b/packages/playground/blueprints/src/lib/steps/apply-wordpress-patches/wp-content/mu-plugins/add_requests_transport.php @@ -13,12 +13,25 @@ * the Requests class happy. */ if (defined('USE_FETCH_FOR_REQUESTS') && USE_FETCH_FOR_REQUESTS) { - require(__DIR__ . '/includes/requests_transport_fetch.php'); + require(__DIR__ . '/includes/requests_transport_fetch.php'); Requests::add_transport('Requests_Transport_Fetch'); + /** + * Disable signature verification as it doesn't seem to work with + * fetch requests: + * + * https://downloads.wordpress.org/plugin/classic-editor.zip returns no signature header. + * https://downloads.wordpress.org/plugin/classic-editor.zip.sig returns 404. + * + * @TODO Investigate why. + */ + add_filter('wp_signature_hosts', function ($hosts) { + return []; + }); + add_filter('http_request_host_is_external', function ($arg) { return true; }); } else { - require(__DIR__ . '/includes/requests_transport_dummy.php'); - Requests::add_transport('Requests_Transport_Dummy'); + require(__DIR__ . '/includes/requests_transport_dummy.php'); + Requests::add_transport('Requests_Transport_Dummy'); } diff --git a/packages/playground/blueprints/src/lib/steps/apply-wordpress-patches/wp-content/mu-plugins/includes/requests_transport_fetch.php b/packages/playground/blueprints/src/lib/steps/apply-wordpress-patches/wp-content/mu-plugins/includes/requests_transport_fetch.php index e4843fc7f3..5bad07eefe 100644 --- a/packages/playground/blueprints/src/lib/steps/apply-wordpress-patches/wp-content/mu-plugins/includes/requests_transport_fetch.php +++ b/packages/playground/blueprints/src/lib/steps/apply-wordpress-patches/wp-content/mu-plugins/includes/requests_transport_fetch.php @@ -6,11 +6,7 @@ * This file isn't actually used. It's just here for reference and development. The actual * PHP code used in WordPress is hardcoded copy residing in wordpress.mjs in the _patchWordPressCode * function. - * - * @TODO Make the build pipeline use this exact file instead of creating it - * from within the JavaScript runtime. */ - class Requests_Transport_Fetch implements Requests_Transport { public $headers = ''; @@ -43,7 +39,6 @@ public function request($url, $headers = array(), $data = array(), $options = ar return false; } - $headers = Requests::flatten($headers); if (!empty($data)) { $data_format = $options['data_format']; if ($data_format === 'query') { @@ -54,36 +49,25 @@ public function request($url, $headers = array(), $data = array(), $options = ar } } - $request = json_encode(json_encode(array( - 'headers' => $headers, - 'data' => $data, - 'url' => $url, - 'method' => $options['type'], - ))); - - $js = <<headers = vrzno_eval($js); + $request = json_encode(array( + 'type' => 'request', + 'data' => [ + 'headers' => $headers, + 'data' => $data, + 'url' => $url, + 'method' => $options['type'], + ] + )); + + $this->headers = post_message_to_js($request); + + // Store a file if the request specifies it. + // Are we sure that `$this->headers` includes the body of the response? + $before_response_body = strpos( $this->headers, "\r\n\r\n" ); + if ( isset( $options['filename'] ) && $options['filename'] && false !== $before_response_body ) { + $response_body = substr( $this->headers, $before_response_body + 4 ); + file_put_contents($options['filename'], $response_body); + } return $this->headers; } @@ -132,11 +116,7 @@ protected static function format_get($url, $data) public static function test($capabilities = array()) { - if (!function_exists('vrzno_eval')) { - return false; - } - - if (vrzno_eval("typeof XMLHttpRequest;") !== 'function') { + if (!function_exists('post_message_to_js')) { return false; } diff --git a/packages/playground/client/src/index.ts b/packages/playground/client/src/index.ts index 5c38e8d8d3..885ad5461a 100644 --- a/packages/playground/client/src/index.ts +++ b/packages/playground/client/src/index.ts @@ -83,6 +83,7 @@ export async function startPlaygroundWeb({ php: compiled.versions.php, wp: compiled.versions.wp, ['php-extension']: compiled.phpExtensions, + ['networking']: compiled.features.networking ? 'yes' : 'no', }), progressTracker ); diff --git a/packages/playground/remote/src/lib/boot-playground-remote.ts b/packages/playground/remote/src/lib/boot-playground-remote.ts index 003a08a53d..15a8ab4725 100644 --- a/packages/playground/remote/src/lib/boot-playground-remote.ts +++ b/packages/playground/remote/src/lib/boot-playground-remote.ts @@ -29,6 +29,7 @@ import serviceWorkerPath from '../../service-worker.ts?worker&url'; import { LatestSupportedWordPressVersion } from '../wordpress/get-wordpress-module'; import type { SyncProgressCallback } from './opfs/bind-opfs'; import { FilesystemOperation } from '@php-wasm/fs-journal'; +import { setupFetchNetworkTransport } from './setup-fetch-network-transport'; export const serviceWorkerUrl = new URL(serviceWorkerPath, origin); // Prevent Vite from hot-reloading this file – it would @@ -66,11 +67,13 @@ export async function bootPlaygroundRemote() { query.getAll('php-extension'), SupportedPHPExtensionsList ); + const withNetworking = query.get('networking') === 'yes'; const workerApi = consumeAPI( await spawnPHPWorkerThread(workerUrl, { wpVersion, phpVersion, ['php-extension']: phpExtensions, + networking: withNetworking ? 'yes' : 'no', storage: query.get('storage') || '', }) ); @@ -187,6 +190,9 @@ export async function bootPlaygroundRemote() { serviceWorkerUrl + '' ); setupPostMessageRelay(wpFrame, getOrigin(await playground.absoluteUrl)); + if (withNetworking) { + setupFetchNetworkTransport(workerApi); + } setAPIReady(); } catch (e) { diff --git a/packages/playground/remote/src/lib/setup-fetch-network-transport.ts b/packages/playground/remote/src/lib/setup-fetch-network-transport.ts new file mode 100644 index 0000000000..5a34c7b746 --- /dev/null +++ b/packages/playground/remote/src/lib/setup-fetch-network-transport.ts @@ -0,0 +1,93 @@ +import { UniversalPHP } from '@php-wasm/universal'; +import { applyWordPressPatches } from '@wp-playground/blueprints'; + +export interface RequestData { + url: string; + method?: string; + headers?: Record; + data?: string; +} + +export interface RequestMessage { + type: 'request'; + data: RequestData; +} + +/** + * Allow WordPress to make network requests via the fetch API. + * On the WordPress side, this is handled by Requests_Transport_Fetch + * + * @param playground the Playground instance to set up with network support. + */ +export async function setupFetchNetworkTransport(playground: UniversalPHP) { + await applyWordPressPatches(playground, { + addFetchNetworkTransport: true, + }); + + await playground.onMessage(async (message: string) => { + const envelope: RequestMessage = JSON.parse(message); + const { type, data } = envelope; + if (type !== 'request') { + return ''; + } + + return handleRequest(data); + }); +} + +export async function handleRequest(data: RequestData, fetchFn = fetch) { + const hostname = new URL(data.url).hostname; + const fetchUrl = ['api.wordpress.org', 'w.org', 's.w.org'].includes( + hostname + ) + ? `/plugin-proxy.php?url=${encodeURIComponent(data.url)}` + : data.url; + + let response; + try { + response = await fetchFn(fetchUrl, { + method: data.method || 'GET', + headers: data.headers, + body: data.data, + credentials: 'omit', + }); + } catch (e) { + // console.error(e); + return new TextEncoder().encode( + `HTTP/1.1 400 Invalid Request\r\ncontent-type: text/plain\r\n\r\nPlayground could not serve the request.` + ); + } + const responseHeaders: string[] = []; + response.headers.forEach((value, key) => { + responseHeaders.push(key + ': ' + value); + }); + + /* + * Technically we should only send ASCII here and ensure we don't send control + * characters or newlines. We ought to be very careful with HTTP headers since + * some attacks rely on assumed processing of them to let things slip in that + * would end the headers section before its done. e.g. we don't want to allow + * emoji in a header and we don't want to allow \r\n\r\n in a header. + * + * That being said, the browser takes care of it for us. + * response.headers is an instance of the Headers class, and you just can't + * construct the Headers instance if the values are malformed: + * + * > new Headers({'Content-type': 'text/html\r\n\r\nBreakout!'}) + * Failed to construct 'Headers': Invalid value + */ + const headersText = + [ + 'HTTP/1.1 ' + response.status + ' ' + response.statusText, + ...responseHeaders, + ].join('\r\n') + `\r\n\r\n`; + const headersBuffer = new TextEncoder().encode(headersText); + const bodyBuffer = new Uint8Array(await response.arrayBuffer()); + const jointBuffer = new Uint8Array( + headersBuffer.byteLength + bodyBuffer.byteLength + ); + jointBuffer.set(headersBuffer); + jointBuffer.set(bodyBuffer, headersBuffer.byteLength); + + return jointBuffer; +} diff --git a/packages/playground/remote/src/test/setup-fetch-network-transport.spec.ts b/packages/playground/remote/src/test/setup-fetch-network-transport.spec.ts new file mode 100644 index 0000000000..c86a123052 --- /dev/null +++ b/packages/playground/remote/src/test/setup-fetch-network-transport.spec.ts @@ -0,0 +1,52 @@ +import { handleRequest } from '../lib/setup-fetch-network-transport'; + +describe('handleRequest', () => { + it('Should return a correct response to a basic request', async () => { + const fetchMock = vitest.fn(async () => { + return { + status: 200, + statusText: 'OK', + headers: new Headers({ + 'Content-type': 'text/html', + }), + arrayBuffer: async () => { + return new TextEncoder().encode('Hello, world!'); + }, + }; + }); + const response = await handleRequest( + { + url: 'https://playground.wordpress.net/', + headers: { 'Content-type': 'text/html' }, + }, + fetchMock as any + ); + expect(new TextDecoder().decode(response)).toBe( + `HTTP/1.1 200 OK\r\ncontent-type: text/html\r\n\r\nHello, world!` + ); + }); + it('Should reject responses with malicious headers trying to terminate the headers section early', async () => { + const fetchMock = vitest.fn(async () => { + return { + status: 200, + statusText: 'OK', + headers: new Headers({ + 'Content-type': 'text/html✅', + }), + arrayBuffer: async () => { + return new TextEncoder().encode('Hello, world!'); + }, + }; + }); + const response = await handleRequest( + { + url: 'https://playground.wordpress.net/', + headers: { 'Content-type': 'text/html' }, + }, + fetchMock as any + ); + expect(new TextDecoder().decode(response)).toBe( + `HTTP/1.1 400 Invalid Request\r\ncontent-type: text/plain\r\n\r\nPlayground could not serve the request.` + ); + }); +}); diff --git a/packages/playground/website/public/plugin-proxy.php b/packages/playground/website/public/plugin-proxy.php index f3fb0c3763..4bc720f605 100644 --- a/packages/playground/website/public/plugin-proxy.php +++ b/packages/playground/website/public/plugin-proxy.php @@ -23,7 +23,7 @@ public function streamFromDirectory($name, $directory) $name = preg_replace('#[^a-zA-Z0-9\.\-_]#', '', $name); $zipUrl = "https://downloads.wordpress.org/$directory/$name"; try { - $this->streamHttpResponse($zipUrl, [ + $info = streamHttpResponse($zipUrl, 'GET', [], NULL, [ 'content-length', 'x-frame-options', 'last-modified', @@ -32,7 +32,13 @@ public function streamFromDirectory($name, $directory) 'age', 'vary', 'cache-Control' + ], [ + 'Content-Type: application/zip', + 'Content-Disposition: attachment; filename="plugin.zip"', ]); + if ($info['http_code'] > 299 || $info['http_code'] < 200) { + throw new ApiException('Request failed'); + } } catch (ApiException $e) { throw new ApiException("Plugin or theme '$name' not found"); } @@ -126,7 +132,7 @@ public function streamFromGithubReleases($repo, $name) { $zipUrl = "https://github.com/$repo/releases/latest/download/$name"; try { - $this->streamHttpResponse($zipUrl, [ + $info = streamHttpResponse($zipUrl, 'GET', [], NULL, [ 'content-length', 'x-frame-options', 'last-modified', @@ -135,7 +141,13 @@ public function streamFromGithubReleases($repo, $name) 'age', 'vary', 'cache-Control' + ], [ + 'Content-Type: application/zip', + 'Content-Disposition: attachment; filename="plugin.zip"', ]); + if ($info['http_code'] > 299 || $info['http_code'] < 200) { + throw new ApiException('Request failed'); + } } catch (ApiException $e) { throw new ApiException("Plugin or theme '$name' not found"); } @@ -168,63 +180,74 @@ protected function gitHubRequest($url, $decode = true) ]; } - private function streamHttpResponse($url, $allowed_headers = [], $default_headers = []) - { - $default_headers = array_merge([ - 'Content-Type: application/zip', - 'Content-Disposition: attachment; filename="plugin.zip"', - ], $default_headers); - $ch = curl_init($url); - curl_setopt_array( - $ch, - [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_CONNECTTIMEOUT => 30, - CURLOPT_FAILONERROR => true, - CURLOPT_FOLLOWLOCATION => true, - ] - ); +} +function streamHttpResponse($url, $request_method = 'GET', $request_headers = [], $request_body = null, $allowed_response_headers = [], $default_response_headers = []) +{ + $ch = curl_init($url); + curl_setopt_array( + $ch, + [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 30, + CURLOPT_FAILONERROR => true, + CURLOPT_FOLLOWLOCATION => true, + ] + ); - $seen_headers = []; - curl_setopt( - $ch, - CURLOPT_HEADERFUNCTION, - function ($curl, $header_line) use ($seen_headers, $allowed_headers) { - $header_name = strtolower(substr($header_line, 0, strpos($header_line, ':'))); - $seen_headers[$header_name] = true; - if (in_array($header_name, $allowed_headers)) { - header($header_line); - } + if ($request_method === 'POST') { + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $request_body); + } else if ($request_method === 'HEAD') { + curl_setopt($ch, CURLOPT_NOBODY, true); + } + + if (count($request_headers)) { + curl_setopt($ch, CURLOPT_HTTPHEADER, $request_headers); + } + + $seen_headers = []; + curl_setopt( + $ch, + CURLOPT_HEADERFUNCTION, + function ($curl, $header_line) use ($seen_headers, $allowed_response_headers) { + if (strpos($header_line, ':') === false) { return strlen($header_line); } - ); - $extra_headers_sent = false; - curl_setopt( - $ch, - CURLOPT_WRITEFUNCTION, - function ($curl, $body) use (&$extra_headers_sent, $default_headers) { - if (!$extra_headers_sent) { - foreach ($default_headers as $header_line) { - $header_name = strtolower(substr($header_line, 0, strpos($header_line, ':'))); - if (!isset($seen_headers[strtolower($header_name)])) { - header($header_line); - } + $header_name = strtolower(substr($header_line, 0, strpos($header_line, ':'))); + $seen_headers[$header_name] = true; + $illegal_headers = ['transfer-encoding']; + $header_allowed = ( + NULL === $allowed_response_headers || in_array($header_name, $allowed_response_headers) + ) && !in_array($header_name, $illegal_headers); + if ($header_allowed) { + header($header_line); + } + return strlen($header_line); + } + ); + $extra_headers_sent = false; + curl_setopt( + $ch, + CURLOPT_WRITEFUNCTION, + function ($curl, $body) use (&$extra_headers_sent, $default_response_headers) { + if (!$extra_headers_sent) { + foreach ($default_response_headers as $header_line) { + $header_name = strtolower(substr($header_line, 0, strpos($header_line, ':'))); + if (!isset($seen_headers[$header_name])) { + header($header_line); } - $extra_headers_sent = true; } - echo $body; - flush(); - return strlen($body); + $extra_headers_sent = true; } - ); - curl_exec($ch); - $info = curl_getinfo($ch); - curl_close($ch); - if ($info['http_code'] > 299 || $info['http_code'] < 200) { - throw new ApiException('Request failed'); + echo $body; + flush(); + return strlen($body); } - } - + ); + curl_exec($ch); + $info = curl_getinfo($ch); + curl_close($ch); + return $info; } $downloader = new PluginDownloader( @@ -232,7 +255,9 @@ function ($curl, $body) use (&$extra_headers_sent, $default_headers) { ); // Serve the request: -header('Access-Control-Allow-Origin: *'); +if (!array_key_exists('url', $_GET)) { + header('Access-Control-Allow-Origin: *'); +} $pluginResponse; try { /** @deprecated Plugins and themes downloads are no longer needed now that WordPress.org serves @@ -287,14 +312,66 @@ function ($curl, $body) use (&$extra_headers_sent, $default_headers) { $_GET['artifact'] ); } else if (isset($_GET['repo']) && isset($_GET['name'])) { - - // Only allow downloads from the block-interactivity-experiments repo for now. + // Only allow downloads from the block-interactivity-experiments repo for now. if ($_GET['repo'] !== 'WordPress/block-interactivity-experiments') { throw new ApiException('Invalid repo. Only "WordPress/block-interactivity-experiments" is allowed.'); } $downloader->streamFromGithubReleases($_GET['repo'], $_GET['name']); + } else if (isset($_GET['url'])) { + // Proxy the current request to $_GET['url'] and return the response, + // but only if the URL is allowlisted. + $url = $_GET['url']; + $allowed_domains = ['api.wordpress.org', 'w.org', 's.w.org']; + $parsed_url = parse_url($url); + if (!in_array($parsed_url['host'], $allowed_domains)) { + http_response_code(403); + echo "Error: The specified URL is not allowed."; + exit; + } + + /** + * Pass through the request headers we got from WordPress via fetch(), + * then filter out: + * + * * The browser-specific headers + * * Headers related to security to avoid leaking any auth information + * + * ...and pass the rest to the proxied request. + * + * @return array + */ + function get_request_headers() + { + $headers = []; + foreach ($_SERVER as $name => $value) { + if (substr($name, 0, 5) !== 'HTTP_') { + continue; + } + $name = str_replace(' ', '-', ucwords(str_replace('_', ' ', strtolower(substr($name, 5))))); + $lcname = strtolower($name); + if ( + $lcname === 'authorization' + || $lcname === 'cookie' + || $lcname === 'host' + || $lcname === 'origin' + || $lcname === 'referer' + || 0 === strpos($lcname, 'sec-') + ) { + continue; + } + $headers[$name] = $value; + } + return $headers; + } + streamHttpResponse( + $url, + $_SERVER['REQUEST_METHOD'], + get_request_headers(), + file_get_contents('php://input'), + null + ); } else { throw new ApiException('Invalid query parameters'); } diff --git a/packages/playground/website/src/components/playground-configuration-group/index.tsx b/packages/playground/website/src/components/playground-configuration-group/index.tsx index 4de5206455..c1fb4da0c8 100644 --- a/packages/playground/website/src/components/playground-configuration-group/index.tsx +++ b/packages/playground/website/src/components/playground-configuration-group/index.tsx @@ -63,6 +63,7 @@ export default function PlaygroundConfigurationGroup({ const [wpVersionChoices, setWPVersionChoices] = useState< Record >({}); + useEffect(() => { playground?.getSupportedWordPressVersions().then(({ all, latest }) => { const formOptions: Record = {}; diff --git a/packages/playground/website/src/lib/make-blueprint.tsx b/packages/playground/website/src/lib/make-blueprint.tsx index 6078c87ab7..09e627f7da 100644 --- a/packages/playground/website/src/lib/make-blueprint.tsx +++ b/packages/playground/website/src/lib/make-blueprint.tsx @@ -5,6 +5,7 @@ interface MakeBlueprintOptions { wp?: string; phpExtensionBundles?: string[]; landingPage?: string; + features?: Blueprint['features']; theme?: string; plugins?: string[]; } @@ -17,6 +18,7 @@ export function makeBlueprint(options: MakeBlueprintOptions): Blueprint { wp: options.wp as any, }, phpExtensionBundles: options.phpExtensionBundles as any, + features: options.features, steps: [ { step: 'login', diff --git a/packages/playground/website/src/main.tsx b/packages/playground/website/src/main.tsx index 0111d14b21..cbabcca27d 100644 --- a/packages/playground/website/src/main.tsx +++ b/packages/playground/website/src/main.tsx @@ -35,10 +35,17 @@ try { query.get('wp') || blueprint.preferredVersions!.wp || 'latest'; } } catch (e) { + const features: Blueprint['features'] = {}; + // Networking is enabled by default, so we only need to disable it + // if the query param is explicitly set to "no". + if (query.get('networking') === 'no') { + features['networking'] = false; + } blueprint = makeBlueprint({ php: query.get('php') || '8.0', wp: query.get('wp') || 'latest', theme: query.get('theme') || undefined, + features, plugins: query.getAll('plugin'), landingPage: query.get('url') || undefined, phpExtensionBundles: query.getAll('php-extension-bundle') || [], diff --git a/packages/playground/website/vite.config.ts b/packages/playground/website/vite.config.ts index bb4c382fa9..572d0dd6ee 100644 --- a/packages/playground/website/vite.config.ts +++ b/packages/playground/website/vite.config.ts @@ -17,25 +17,11 @@ import virtualModule from '../vite-virtual-module'; import { fileURLToPath } from 'node:url'; const proxy = { - '^/plugin-proxy.*&artifact=.*': { + '^/plugin-proxy': { target: 'https://playground.wordpress.net', changeOrigin: true, secure: true, }, - '/plugin-proxy': { - target: 'https://downloads.wordpress.org', - changeOrigin: true, - secure: true, - rewrite: (path: string) => { - const url = new URL(path, 'http://example.com'); - if (url.searchParams.has('plugin')) { - return `/plugin/${url.searchParams.get('plugin')}`; - } else if (url.searchParams.has('theme')) { - return `/theme/${url.searchParams.get('theme')}`; - } - throw new Error('Invalid request'); - }, - }, }; let buildVersion: string;