From 720546ff4b3858031fc6bdaee29aa782ca95af83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 8 Feb 2024 15:50:34 +0100 Subject: [PATCH 01/10] PHP: Pass request body as Uint8Array Removes the custom file upload handler and rely on PHP body parsing to populate the $_FILES array. Instead of encoding the body bytes as a string, parsing that string, and re-encoding it as bytes, we keep the body in a binary form and pass it directly to PHP HEAP memory. Closes https://github.com/WordPress/wordpress-playground/issues/997 Closes https://github.com/WordPress/wordpress-playground/issues/1006 Closes https://github.com/WordPress/wordpress-playground/issues/914 ## Testing instructions Confirm the CI checks pass (it will take a few iterations to get them right I'm sure :D) --- packages/php-wasm/compile/php/php_wasm.c | 194 ------------------ packages/php-wasm/node/src/test/php.spec.ts | 97 --------- .../php-wasm/universal/src/lib/base-php.ts | 109 +++------- packages/php-wasm/universal/src/lib/index.ts | 1 - .../universal/src/lib/php-request-handler.ts | 86 +------- .../universal/src/lib/universal-php.ts | 21 +- .../src/initialize-service-worker.ts | 55 +---- packages/playground/client/src/index.ts | 1 - 8 files changed, 39 insertions(+), 525 deletions(-) diff --git a/packages/php-wasm/compile/php/php_wasm.c b/packages/php-wasm/compile/php/php_wasm.c index 22de8cf52a..ecd52d92e9 100644 --- a/packages/php-wasm/compile/php/php_wasm.c +++ b/packages/php-wasm/compile/php/php_wasm.c @@ -555,7 +555,6 @@ typedef struct *php_code; struct wasm_array_entry *server_array_entries; - struct wasm_uploaded_file *uploaded_files; int content_length, request_port, @@ -662,7 +661,6 @@ void wasm_init_server_context() wasm_server_context->execution_mode = MODE_EXECUTE_SCRIPT; wasm_server_context->skip_shebang = 0; wasm_server_context->server_array_entries = NULL; - wasm_server_context->uploaded_files = NULL; } void wasm_destroy_server_context() @@ -714,19 +712,6 @@ void wasm_destroy_server_context() free(current_entry); current_entry = next_entry; } - - // Free wasm_server_context->uploaded_files - wasm_uploaded_file_t *current_file = wasm_server_context->uploaded_files; - while (current_file != NULL) - { - wasm_uploaded_file_t *next_file = current_file->next; - free(current_file->key); - free(current_file->name); - free(current_file->type); - free(current_file->tmp_name); - free(current_file); - current_file = next_file; - } } /** @@ -755,37 +740,6 @@ void wasm_add_SERVER_entry(char *key, char *value) } } -/** - * Function: wasm_add_uploaded_file - * ---------------------------- - * Adds a new entry to the $_FILES array. - * - * key: the key of the $_FILES entry, e.g. my_file for $_FILES['my_file'] - * name: the name of the file, e.g. notes.txt - * type: the type of the file, e.g. text/plain - * tmp_name: the path where the uploaded file is stored, e.g. /tmp/php1234 - * error: the error code associated with this file upload - * size: the size of the file in bytes - */ -void wasm_add_uploaded_file( - char *key, - char *name, - char *type, - char *tmp_name, - int error, - int size) -{ - wasm_uploaded_file_t *entry = (wasm_uploaded_file_t *)malloc(sizeof(wasm_uploaded_file_t)); - entry->key = strdup(key); - entry->name = strdup(name); - entry->type = strdup(type); - entry->tmp_name = strdup(tmp_name); - entry->error = error; - entry->size = size; - entry->next = wasm_server_context->uploaded_files; - wasm_server_context->uploaded_files = entry; -} - /** * Function: wasm_set_query_string * ---------------------------- @@ -1020,92 +974,6 @@ static size_t wasm_sapi_read_post_body(char *buffer, size_t count_bytes) // === FILE UPLOADS SUPPORT === -/* - * Function: free_filename - * ---------------------------- - * Frees the memory after a zval allocated to store the uploaded - * variable name. - */ -static void free_filename(zval *el) -{ - // Uncommenting this code causes a runtime error in the browser: - // @TODO evaluate whether keeping it commented leads to a memory leak - // and how to fix it if it does. - // zend_string *filename = Z_STR_P(el); - // zend_string_release_ex(filename, 0); -} - -/* - * Function: phpwasm_init_uploaded_files_hash - * ---------------------------- - * Allocates an internal HashTable to keep track of the legitimate uploads. - * - * Functions like `is_uploaded_file` or `move_uploaded_file` don't work with - * $_FILES entries that are not in an internal hash table – it's a security feature: - * - * > is_uploaded_file - * > - * > Returns true if the file named by filename was uploaded via HTTP POST. This is - * > useful to help ensure that a malicious user hasn't tried to trick the script into - * > working on files upon which it should not be working--for instance, /etc/passwd. - * > - * > This sort of check is especially important if there is any chance that anything - * > done with uploaded files could reveal their contents to the user, or even to other - * > users on the same system. - * > - * > For proper working, the function is_uploaded_file() needs an argument like - * > $_FILES['userfile']['tmp_name'], - the name of the uploaded file on the client's - * > machine $_FILES['userfile']['name'] does not work. - * - * This function allocates that internal hash table. - * It must not be called when the Content-type header is - * set to multipart/form-data, because then the hash table - * will be also initialized by the rfc1867_post_handler. If - * both code paths are triggered, PHP will crash with the - * following error: - * - * Trace: RuntimeError: unreachable - * at zend_mm_panic (wasm://wasm/029f4afa:wasm-function[1027]:0x9c52c) - * at _efree (wasm://wasm/029f4afa:wasm-function[102]:0xa53a) - * at str_dtor (wasm://wasm/029f4afa:wasm-function[9113]:0x533be1) - * at byn$fpcast-emu$str_dtor (wasm://wasm/029f4afa:wasm-function[5714]:0x3ff0fe) - * at zend_hash_str_del (wasm://wasm/029f4afa:wasm-function[410]:0x304a9) - * at zif_move_uploaded_file (wasm://wasm/029f4afa:wasm-function[7700]:0x4bd986) - */ -void EMSCRIPTEN_KEEPALIVE phpwasm_init_uploaded_files_hash() -{ - HashTable *uploaded_files = NULL; - ALLOC_HASHTABLE(uploaded_files); - zend_hash_init(uploaded_files, 8, NULL, free_filename, 0); - SG(rfc1867_uploaded_files) = uploaded_files; -} - -/* - * Function: phpwasm_register_uploaded_file - * ---------------------------- - * Registers an uploaded file in the internal hash table. - * - * @see phpwasm_init_uploaded_files_hash - */ -void EMSCRIPTEN_KEEPALIVE phpwasm_register_uploaded_file(char *tmp_path_char) -{ - zend_string *tmp_path = zend_string_init(tmp_path_char, strlen(tmp_path_char), 1); - zend_hash_add_ptr(SG(rfc1867_uploaded_files), tmp_path, tmp_path); -} - -/* - * Function: phpwasm_destroy_uploaded_files_hash - * ---------------------------- - * Destroys the internal hash table to free the memory and - * removes the temporary files from the filesystem. - * - * @see phpwasm_init_uploaded_files_hash - */ -void EMSCRIPTEN_KEEPALIVE phpwasm_destroy_uploaded_files_hash() -{ - destroy_uploaded_files_hash(); -} - /** * Function: wasm_sapi_module_startup * ---------------------------- @@ -1292,64 +1160,6 @@ int wasm_sapi_request_init() php_register_variable("PHP_SELF", "-", NULL TSRMLS_CC); - // Set $_FILES in case any were passed via the wasm_server_context->uploaded_files - // linked list - wasm_uploaded_file_t *entry = wasm_server_context->uploaded_files; - if (entry != NULL) - { - if (SG(rfc1867_uploaded_files) == NULL) - { - phpwasm_init_uploaded_files_hash(); - } - - zval *files = &PG(http_globals)[TRACK_VARS_FILES]; - int max_param_size = strlen(entry->key) + 11 /*[tmp_name]\0*/; - char *param; - char *value_buf; - while (entry != NULL) - { - phpwasm_register_uploaded_file(estrdup(entry->tmp_name)); - - // Set $_FILES['key']['name'] - param = malloc(max_param_size); - snprintf(param, max_param_size, "%s[name]", entry->key); - php_register_variable_safe(param, entry->name, strlen(entry->name), files); - free(param); - - // Set $_FILES['key']['tmp_name'] - param = malloc(max_param_size); - snprintf(param, max_param_size, "%s[tmp_name]", entry->key); - php_register_variable_safe(param, entry->tmp_name, strlen(entry->tmp_name), files); - free(param); - - // Set $_FILES['key']['type'] - param = malloc(max_param_size); - snprintf(param, max_param_size, "%s[type]", entry->key); - php_register_variable_safe(param, entry->type, strlen(entry->type), files); - free(param); - - // Set $_FILES['key']['error'] - param = malloc(max_param_size); - snprintf(param, max_param_size, "%s[error]", entry->key); - value_buf = malloc(4); - snprintf(value_buf, 4, "%d", entry->error); - php_register_variable_safe(param, value_buf, strlen(value_buf), files); - free(value_buf); - free(param); - - // Set $_FILES['key']['size'] - param = malloc(max_param_size); - snprintf(param, max_param_size, "%s[size]", entry->key); - value_buf = malloc(16); - snprintf(value_buf, 16, "%d", entry->size); - php_register_variable_safe(param, value_buf, strlen(value_buf), files); - free(value_buf); - free(param); - - entry = entry->next; - } - } - return SUCCESS; } @@ -1362,10 +1172,6 @@ int wasm_sapi_request_init() void wasm_sapi_request_shutdown() { TSRMLS_FETCH(); - if (SG(rfc1867_uploaded_files) != NULL) - { - phpwasm_destroy_uploaded_files_hash(); - } // Destroy the old server context and shutdown the request wasm_destroy_server_context(); php_request_shutdown(NULL); diff --git a/packages/php-wasm/node/src/test/php.spec.ts b/packages/php-wasm/node/src/test/php.spec.ts index 75067d32ff..205e3cb5b6 100644 --- a/packages/php-wasm/node/src/test/php.spec.ts +++ b/packages/php-wasm/node/src/test/php.spec.ts @@ -1118,95 +1118,6 @@ bar expect(JSON.parse(bodyText)).toEqual(expectedResult); }); - it('Should expose uploaded files in $_FILES', async () => { - const response = await php.run({ - code: ` $_FILES, - "is_uploaded" => is_uploaded_file($_FILES["myFile"]["tmp_name"]) - ));`, - method: 'POST', - fileInfos: [ - { - name: 'text.txt', - key: 'myFile', - data: new TextEncoder().encode('bar'), - type: 'text/plain', - }, - ], - headers: { - 'Content-Type': 'multipart/form-data; boundary=boundary', - }, - }); - const bodyText = new TextDecoder().decode(response.bytes); - expect(JSON.parse(bodyText)).toEqual({ - files: { - myFile: { - name: 'text.txt', - type: 'text/plain', - tmp_name: expect.any(String), - error: '0', - size: '3', - }, - }, - is_uploaded: true, - }); - }); - - it('Should expose both the multipart/form-data request body AND uploaded files in $_FILES', async () => { - const response = await php.run({ - code: ` $_FILES, - "is_uploaded1" => is_uploaded_file($_FILES["myFile1"]["tmp_name"]), - "is_uploaded2" => is_uploaded_file($_FILES["myFile2"]["tmp_name"]) - ));`, - relativeUri: '/', - method: 'POST', - body: `--boundary -Content-Disposition: form-data; name="myFile1"; filename="from_body.txt" -Content-Type: text/plain - -bar1 ---boundary--`, - fileInfos: [ - { - name: 'from_files.txt', - key: 'myFile2', - data: new TextEncoder().encode('bar2'), - type: 'application/json', - }, - ], - headers: { - 'Content-Type': 'multipart/form-data; boundary=boundary', - }, - }); - const bodyText = new TextDecoder().decode(response.bytes); - const expectedResult = { - files: { - myFile1: { - name: 'from_body.txt', - type: 'text/plain', - tmp_name: expect.any(String), - error: 0, - size: 4, - }, - myFile2: { - name: 'from_files.txt', - type: 'application/json', - tmp_name: expect.any(String), - error: '0', - size: '4', - }, - }, - is_uploaded1: true, - is_uploaded2: true, - }; - if (Number(phpVersion) > 8) { - (expectedResult.files.myFile1 as any).full_path = - 'from_body.txt'; - } - expect(JSON.parse(bodyText)).toEqual(expectedResult); - }); - it('Should provide the correct $_SERVER information', async () => { php.writeFile( testScriptPath, @@ -1222,14 +1133,6 @@ Content-Type: text/plain bar1 --boundary--`, - fileInfos: [ - { - name: 'from_files.txt', - key: 'myFile2', - data: new TextEncoder().encode('bar2'), - type: 'application/json', - }, - ], headers: { 'Content-Type': 'multipart/form-data; boundary=boundary', Host: 'https://example.com:1235', diff --git a/packages/php-wasm/universal/src/lib/base-php.ts b/packages/php-wasm/universal/src/lib/base-php.ts index a9ea3ba0af..c45b910800 100644 --- a/packages/php-wasm/universal/src/lib/base-php.ts +++ b/packages/php-wasm/universal/src/lib/base-php.ts @@ -11,7 +11,6 @@ import { import { getLoadedRuntime } from './load-php-runtime'; import type { PHPRuntimeId } from './load-php-runtime'; import { - FileInfo, IsomorphicLocalPHP, MessageListener, PHPRequest, @@ -261,11 +260,6 @@ export abstract class BasePHP implements IsomorphicLocalPHP { if (request.body) { heapBodyPointer = this.#setRequestBody(request.body); } - if (request.fileInfos) { - for (const file of request.fileInfos) { - this.#addUploadedFile(file); - } - } if (typeof request.code === 'string') { this.#setPHPCode(' ?>' + request.code); } @@ -471,62 +465,35 @@ export abstract class BasePHP implements IsomorphicLocalPHP { } } - #setRequestBody(body: string) { - /** - * Encoding the body using stringToUTF8 is a lossy, wrong way - * of passing the request body to WASM. The setRequestBody - * method should accept a `body: string | Uint8Array` argument - * instead. - * - * For now, though, we stick to the `stringToUTF8` method as this is what - * a plain ccall() with a string argument would do. Faulty as it might be, - * this is just the way of encode a JavaScript string as bytes to write it - * into the WASM memory. - * - * To show a glimpse of the rabbit hole of what might go wrong here: - * - * Internally, JavaScript strings are stored as something that's like UTF-16, - * but actually it isn't totally because it allows creating strings that cannot - * be represented in Unicode. They allow an arbitrary stream of bytes to be a string, - * which mostly works in UTF-16 and does work in UCS-2, but in order to support extended - * character ranges in Unicode, UTF-16 said that the code points referenced by each - * surrogate half are invalid and cannot be represented. So U+D83C U+DC00 is an - * invalid sequence of code points, but the UTF-16 sequence of bytes 0xd83cdc00 - * is valid and converts to U+1F000. This is why they cannot be split, because - * you cannot represent U+D83C in UTF-8 or even abstractly in Unicode. - * - * If provided an invalid string this conversion will be lossy. - * Invalid code points will be converted to U+FFFD and - * `Module.lengthBytesUTF8` will report the number of bytes after - * converting those code points. E.g. `a\ud83cb` turns into - * the string `a�b` and the length is 1 + 3 + 1 = 5. - * - * Also, consider a string split inside a surrogate pair boundary. - * - * `'I feel 😊.'.slice(0, 8)` - * - * We might expect this to occupy 8 bytes because we split - * the string at 8 characters, or to occupy 11 bytes because - * we expected to get the emoji as the 8th character, and it - * requires four bytes in UTF8, but instead we invalidated - * the string and receive `I feel �`, which takes a total of - * 10 bytes in UTF8: 7 to encode `I feel ` and then 3 to - * encode the replacement character U+FFFD. - * - * There's a lot more, for sure. The ultimate fix is to implement - * the UInt8Array approach. - */ - const size = this[__private__dont__use].lengthBytesUTF8(body); - const heapBodyPointer = this[__private__dont__use].malloc(size + 1); + #setRequestBody(body: string | Uint8Array) { + let size, contentLength; + if (typeof body === 'string') { + console.warn( + 'Passing a string as the request body is deprecated. Please use a Uint8Array instead. See ' + + 'https://github.com/WordPress/wordpress-playground/issues/997 for more details' + ); + contentLength = this[__private__dont__use].lengthBytesUTF8(body); + size = contentLength + 1; + } else { + contentLength = body.byteLength; + size = body.byteLength; + } + + const heapBodyPointer = this[__private__dont__use].malloc(size); if (!heapBodyPointer) { throw new Error('Could not allocate memory for the request body.'); } + // Write the string to the WASM memory - this[__private__dont__use].stringToUTF8( - body, - heapBodyPointer, - size + 1 - ); + if (typeof body === 'string') { + this[__private__dont__use].stringToUTF8( + body, + heapBodyPointer, + size + 1 + ); + } else { + this[__private__dont__use].HEAPU8.set(body, heapBodyPointer); + } this[__private__dont__use].ccall( 'wasm_set_request_body', @@ -538,7 +505,7 @@ export abstract class BasePHP implements IsomorphicLocalPHP { 'wasm_set_content_length', null, [NUMBER], - [new TextEncoder().encode(body).length] + [contentLength] ); return heapBodyPointer; } @@ -587,30 +554,6 @@ export abstract class BasePHP implements IsomorphicLocalPHP { ); } - /** - * Adds file information to $_FILES superglobal in PHP. - * - * In particular: - * * Creates the file data in the filesystem - * * Registers the file details in PHP - * - * @param fileInfo - File details - */ - #addUploadedFile(fileInfo: FileInfo) { - const { key, name, type, data } = fileInfo; - - const tmpPath = `/tmp/${Math.random().toFixed(20)}`; - this.writeFile(tmpPath, data); - - const error = 0; - this[__private__dont__use].ccall( - 'wasm_add_uploaded_file', - null, - [STRING, STRING, STRING, STRING, NUMBER, NUMBER], - [key, name, type, tmpPath, error, data.byteLength] - ); - } - #setPHPCode(code: string) { this[__private__dont__use].ccall( 'wasm_set_php_code', diff --git a/packages/php-wasm/universal/src/lib/index.ts b/packages/php-wasm/universal/src/lib/index.ts index c88a05501f..cc51f72e34 100644 --- a/packages/php-wasm/universal/src/lib/index.ts +++ b/packages/php-wasm/universal/src/lib/index.ts @@ -1,5 +1,4 @@ export type { - FileInfo, IsomorphicLocalPHP, IsomorphicRemotePHP, MessageListener, 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 0a9c27bc33..64c79a25b6 100644 --- a/packages/php-wasm/universal/src/lib/php-request-handler.ts +++ b/packages/php-wasm/universal/src/lib/php-request-handler.ts @@ -7,12 +7,7 @@ import { } from './urls'; import { BasePHP, normalizeHeaders } from './base-php'; import { PHPResponse } from './php-response'; -import { - FileInfo, - PHPRequest, - PHPRunOptions, - RequestHandler, -} from './universal-php'; +import { PHPRequest, PHPRunOptions, RequestHandler } from './universal-php'; export interface PHPRequestHandlerConfiguration { /** @@ -207,36 +202,6 @@ export class PHPRequestHandler implements RequestHandler { host: this.#HOST, ...normalizeHeaders(request.headers || {}), }; - const fileInfos: FileInfo[] = []; - if (request.files && Object.keys(request.files).length) { - preferredMethod = 'POST'; - for (const key in request.files) { - const file: File = request.files[key]; - fileInfos.push({ - key, - name: file.name, - type: file.type, - data: new Uint8Array(await file.arrayBuffer()), - }); - } - - /** - * When the files are present, we can't use the multipart/form-data - * Content-type header. Instead, we rewrite the request body - * to application/x-www-form-urlencoded. - * See the phpwasm_init_uploaded_files_hash() docstring for more details. - */ - if ( - headers['content-type']?.startsWith('multipart/form-data') - ) { - request.formData = parseMultipartFormDataString( - request.body || '' - ); - headers['content-type'] = - 'application/x-www-form-urlencoded'; - delete request.body; - } - } let body; if (request.formData !== undefined) { @@ -244,9 +209,11 @@ export class PHPRequestHandler implements RequestHandler { headers['content-type'] = headers['content-type'] || 'application/x-www-form-urlencoded'; - body = new URLSearchParams( - request.formData as Record - ).toString(); + body = new TextEncoder().encode( + new URLSearchParams( + request.formData as Record + ).toString() + ); } else { body = request.body; } @@ -287,7 +254,6 @@ export class PHPRequestHandler implements RequestHandler { protocol: this.#PROTOCOL, method: request.method || preferredMethod, body, - fileInfos, scriptPath, headers, }); @@ -329,46 +295,6 @@ export class PHPRequestHandler implements RequestHandler { } } -/** - * Parses a multipart/form-data string into a key-value object. - * - * @param multipartString - * @returns - */ -function parseMultipartFormDataString(multipartString: string) { - const parsedData: Record = {}; - - // Extract the boundary from the string - const boundaryMatch = multipartString.match(/--(.*)\r\n/); - if (!boundaryMatch) { - return parsedData; - } - - const boundary = boundaryMatch[1]; - - // Split the string into parts - const parts = multipartString.split(`--${boundary}`); - - // Remove the first and the last part, which are just boundary markers - parts.shift(); - parts.pop(); - - // Process each part - parts.forEach((part: string) => { - const headerBodySplit = part.indexOf('\r\n\r\n'); - const headers = part.substring(0, headerBodySplit).trim(); - const body = part.substring(headerBodySplit + 4).trim(); - - const nameMatch = headers.match(/name="([^"]+)"/); - if (nameMatch) { - const name = nameMatch[1]; - parsedData[name] = body; - } - }); - - return parsedData; -} - /** * Naively infer a file mime type from its path. * diff --git a/packages/php-wasm/universal/src/lib/universal-php.ts b/packages/php-wasm/universal/src/lib/universal-php.ts index 299878dd97..133183ab4d 100644 --- a/packages/php-wasm/universal/src/lib/universal-php.ts +++ b/packages/php-wasm/universal/src/lib/universal-php.ts @@ -485,15 +485,10 @@ export interface PHPRequest { */ headers?: PHPRequestHeaders; - /** - * Uploaded files - */ - files?: Record; - /** * Request body without the files. */ - body?: string; + body?: string | Uint8Array; /** * Form data. If set, the request body will be ignored and @@ -531,12 +526,7 @@ export interface PHPRunOptions { /** * Request body without the files. */ - body?: string; - - /** - * Uploaded files. - */ - fileInfos?: FileInfo[]; + body?: string | Uint8Array; /** * The code snippet to eval instead of a php file. @@ -564,13 +554,6 @@ export interface PHPOutput { stderr: string[]; } -export interface FileInfo { - key: string; - name: string; - type: string; - data: Uint8Array; -} - export interface RmDirOptions { /** * If true, recursively removes the directory and all its contents. diff --git a/packages/php-wasm/web-service-worker/src/initialize-service-worker.ts b/packages/php-wasm/web-service-worker/src/initialize-service-worker.ts index 5c8e9b334b..8033ce67fa 100644 --- a/packages/php-wasm/web-service-worker/src/initialize-service-worker.ts +++ b/packages/php-wasm/web-service-worker/src/initialize-service-worker.ts @@ -86,7 +86,11 @@ export async function convertFetchEventToPHPRequest(event: FetchEvent) { } } - const { body, files, contentType } = await rewritePost(event.request); + const contentType = event.request.headers.get('content-type')!; + const body = + event.request.method === 'POST' + ? new Uint8Array(await event.request.clone().arrayBuffer()) + : undefined; const requestHeaders: Record = {}; for (const pair of (event.request.headers as any).entries()) { requestHeaders[pair[0]] = pair[1]; @@ -99,7 +103,6 @@ export async function convertFetchEventToPHPRequest(event: FetchEvent) { args: [ { body, - files, url: url.toString(), method: event.request.method, headers: { @@ -194,54 +197,6 @@ interface ServiceWorkerConfiguration { handleRequest?: (event: FetchEvent) => Promise | undefined; } -async function rewritePost(request: Request) { - const contentType = request.headers.get('content-type')!; - if (request.method !== 'POST') { - return { - contentType, - body: undefined, - files: undefined, - }; - } - - // If the request contains multipart form data, rewrite it - // to a regular form data and handle files separately. - const isMultipart = contentType - .toLowerCase() - .startsWith('multipart/form-data'); - if (isMultipart) { - try { - const formData = (await request.clone().formData()) as any; - const post: Record = {}; - const files: Record = {}; - - for (const key of formData.keys()) { - const value = formData.get(key); - if (value instanceof File) { - files[key] = value; - } else { - post[key] = value; - } - } - - return { - contentType: 'application/x-www-form-urlencoded', - body: new URLSearchParams(post).toString(), - files, - }; - } catch (e) { - // ignore - } - } - - // Otherwise, grab body as literal text - return { - contentType, - body: await request.clone().text(), - files: {}, - }; -} - /** * Copy a request with custom overrides. * diff --git a/packages/playground/client/src/index.ts b/packages/playground/client/src/index.ts index 8d5301484b..deb097b8e3 100644 --- a/packages/playground/client/src/index.ts +++ b/packages/playground/client/src/index.ts @@ -16,7 +16,6 @@ export type { PHPRequestHeaders, PHPBrowserConfiguration, SupportedPHPVersion, - FileInfo, RmDirOptions, RequestHandler, RuntimeType, From b3e508d37a44f08c42a288c46b80d3be76cb1037 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 8 Feb 2024 15:54:47 +0100 Subject: [PATCH 02/10] Adjust the comments --- packages/php-wasm/universal/src/lib/universal-php.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/php-wasm/universal/src/lib/universal-php.ts b/packages/php-wasm/universal/src/lib/universal-php.ts index 133183ab4d..d2d5032959 100644 --- a/packages/php-wasm/universal/src/lib/universal-php.ts +++ b/packages/php-wasm/universal/src/lib/universal-php.ts @@ -486,7 +486,7 @@ export interface PHPRequest { headers?: PHPRequestHeaders; /** - * Request body without the files. + * Request body. */ body?: string | Uint8Array; @@ -524,7 +524,7 @@ export interface PHPRunOptions { headers?: PHPRequestHeaders; /** - * Request body without the files. + * Request body. */ body?: string | Uint8Array; From 2bb44889c2b14143d5192c7b4e3586803cabd9b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 8 Feb 2024 16:02:34 +0100 Subject: [PATCH 03/10] Adjust unit tests --- packages/php-wasm/compile/php/Dockerfile | 4 --- packages/php-wasm/node/src/test/php.spec.ts | 36 ++++++++++++--------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/php-wasm/compile/php/Dockerfile b/packages/php-wasm/compile/php/Dockerfile index b9995a9125..dcb2344506 100644 --- a/packages/php-wasm/compile/php/Dockerfile +++ b/packages/php-wasm/compile/php/Dockerfile @@ -778,16 +778,12 @@ RUN set -euxo pipefail; \ "FS", \n\ "_wasm_set_sapi_name", \n\ "_php_wasm_init", \n\ -"_phpwasm_destroy_uploaded_files_hash", \n\ -"_phpwasm_init_uploaded_files_hash", \n\ -"_phpwasm_register_uploaded_file", \n\ "_emscripten_sleep", \n\ "_wasm_sleep", \n\ "_wasm_set_phpini_path", \n\ "_wasm_set_phpini_entries", \n\ "_wasm_add_SERVER_entry", \n\ "_wasm_read", \n\ -"_wasm_add_uploaded_file", \n\ "_wasm_sapi_handle_request", \n\ "_wasm_set_content_length", \n\ "_wasm_set_content_type", \n\ diff --git a/packages/php-wasm/node/src/test/php.spec.ts b/packages/php-wasm/node/src/test/php.spec.ts index 205e3cb5b6..9eeaf2c4a6 100644 --- a/packages/php-wasm/node/src/test/php.spec.ts +++ b/packages/php-wasm/node/src/test/php.spec.ts @@ -857,12 +857,12 @@ describe.each(SupportedPHPVersions)('PHP %s', (phpVersion) => { */ it('Should work with long POST body', () => { php.writeFile(testScriptPath, ''); - const body = + const body = new Uint8Array( readFileSync( new URL('./test-data/long-post-body.txt', import.meta.url) - .pathname, - 'utf8' - ) + ''; + .pathname + ) + ); // 0x4000 is SAPI_POST_BLOCK_SIZE expect(body.length).toBeGreaterThan(0x4000); expect(async () => { @@ -900,7 +900,7 @@ describe.each(SupportedPHPVersions)('PHP %s', (phpVersion) => { it('Should have access to raw request data via the php://input stream', async () => { const response = await php.run({ method: 'POST', - body: '{"foo": "bar"}', + body: new TextEncoder().encode('{"foo": "bar"}'), code: ` { php.writeFile('/php/index.php', ` { php.writeFile('/php/index.php', ` { const response = await php.run({ code: ` { const response = await php.run({ code: ` { const response = await php.run({ code: ` { const response = await php.run({ code: ` is_uploaded_file($_FILES["myFile"]["tmp_name"]) ));`, method: 'POST', - body: `--boundary + body: new TextEncoder().encode(`--boundary Content-Disposition: form-data; name="myFile"; filename="text.txt" Content-Type: text/plain bar ---boundary--`, +--boundary--`), headers: { 'Content-Type': 'multipart/form-data; boundary=boundary', }, @@ -1127,12 +1131,12 @@ bar scriptPath: testScriptPath, relativeUri: '/test.php?a=b', method: 'POST', - body: `--boundary + body: new TextEncoder().encode(`--boundary Content-Disposition: form-data; name="myFile1"; filename="from_body.txt" Content-Type: text/plain bar1 ---boundary--`, +--boundary--`), headers: { 'Content-Type': 'multipart/form-data; boundary=boundary', Host: 'https://example.com:1235', From ae4d685aebeef65fc6e50c7387db26f9b11366de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 8 Feb 2024 16:24:31 +0100 Subject: [PATCH 04/10] Support PHPRequest.files --- .../node/src/test/php-request-handler.spec.ts | 10 +- .../universal/src/lib/php-request-handler.ts | 98 ++++++++++++++++--- .../universal/src/lib/universal-php.ts | 8 +- .../blueprints/src/lib/steps/import-file.ts | 2 +- 4 files changed, 98 insertions(+), 20 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 c9083a43d8..944de53a23 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 @@ -149,11 +149,10 @@ describe.each(SupportedPHPVersions)( const response = await handler.request({ url: '/index.php', method: 'POST', - files: { - myFile: new File(['Hello World'], 'text.txt', { - type: 'text/plain', - }), - }, + body: new TextEncoder().encode(`--boundary +Content-Disposition: form-data; name="foo" + +bar`), headers: { 'Content-Type': 'multipart/form-data; boundary=boundary', }, @@ -170,7 +169,6 @@ describe.each(SupportedPHPVersions)( const response = await handler.request({ url: '/index.php', method: 'POST', - files: {}, body: 'foo=bar', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 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 64c79a25b6..9ed7d81c84 100644 --- a/packages/php-wasm/universal/src/lib/php-request-handler.ts +++ b/packages/php-wasm/universal/src/lib/php-request-handler.ts @@ -203,19 +203,13 @@ export class PHPRequestHandler implements RequestHandler { ...normalizeHeaders(request.headers || {}), }; - let body; - if (request.formData !== undefined) { + let body = request.body; + if (request.formData || request.files) { preferredMethod = 'POST'; - headers['content-type'] = - headers['content-type'] || - 'application/x-www-form-urlencoded'; - body = new TextEncoder().encode( - new URLSearchParams( - request.formData as Record - ).toString() - ); - } else { - body = request.body; + body = await new MultipartEncoder().encode({ + ...request.formData, + ...request.files, + }); } let scriptPath; @@ -375,3 +369,83 @@ function seemsLikeADirectoryRoot(path: string) { const lastSegment = path.split('/').pop(); return !lastSegment!.includes('.'); } + +class MultipartEncoder { + constructor( + private boundary: string = `----${Math.random().toString(36).slice(2)}` + ) {} + + /** + * Encodes a multipart/form-data request body. + * + * @param data - The form data to encode. + * @returns The encoded body. + */ + encode(data: Record): Promise { + const parts = Object.entries(data).map(([name, value]) => { + if (value instanceof File) { + return this.#encodeFile(name, value); + } + return this.#encodeField(name, value); + }); + const body = parts.reduce((acc, part) => { + if (acc.length > 0) { + acc.push(new TextEncoder().encode('\r\n')); + } + acc.push(part); + return acc; + }, [] as Uint8Array[]); + const boundary = `--${this.boundary}--`; + body.push(new TextEncoder().encode(`\r\n${boundary}\r\n`)); + return Promise.resolve( + body.reduce((acc, part) => { + const newAcc = new Uint8Array(acc.length + part.length); + newAcc.set(acc, 0); + newAcc.set(part, acc.length); + return newAcc; + }, new Uint8Array(0)) + ); + } + + #encodeField(name: string, value: string) { + const encodedName = new TextEncoder().encode(name); + const encodedValue = new TextEncoder().encode(value); + const part = new Uint8Array( + encodedName.length + encodedValue.length + 2 + ); + part.set(encodedName, 0); + part.set(new TextEncoder().encode('\r\n\r\n'), encodedName.length); + part.set(encodedValue, encodedName.length + 4); + return part; + } + + #encodeFile(name: string, file: File) { + const encodedName = new TextEncoder().encode(name); + const encodedFilename = new TextEncoder().encode(file.name); + const part = new Uint8Array( + encodedName.length + + encodedFilename.length + + `filename="${file.name}"`.length + + 'Content-Type: application/octet-stream'.length + + '\r\n\r\n'.length + + file.size + + 2 + ); + part.set(encodedName, 0); + part.set( + new TextEncoder().encode(`; filename="${file.name}"`), + encodedName.length + ); + part.set( + new TextEncoder().encode( + '\r\nContent-Type: application/octet-stream\r\n\r\n' + ), + encodedName.length + encodedFilename.length + ); + part.set( + new Uint8Array([0, 0]), + encodedName.length + encodedFilename.length + 58 + ); + return part; + } +} diff --git a/packages/php-wasm/universal/src/lib/universal-php.ts b/packages/php-wasm/universal/src/lib/universal-php.ts index d2d5032959..c6cfe1038f 100644 --- a/packages/php-wasm/universal/src/lib/universal-php.ts +++ b/packages/php-wasm/universal/src/lib/universal-php.ts @@ -494,7 +494,13 @@ export interface PHPRequest { * Form data. If set, the request body will be ignored and * the content-type header will be set to `application/x-www-form-urlencoded`. */ - formData?: Record; + formData?: Record; + + /** + * Uploaded files data. If set, the request body will be ignored and + * the content-type header will be set to `multipart/form-data`. + */ + files?: Record; } export interface PHPRunOptions { diff --git a/packages/playground/blueprints/src/lib/steps/import-file.ts b/packages/playground/blueprints/src/lib/steps/import-file.ts index 6b5814b463..ee154947d4 100644 --- a/packages/playground/blueprints/src/lib/steps/import-file.ts +++ b/packages/playground/blueprints/src/lib/steps/import-file.ts @@ -79,6 +79,6 @@ function DOM(response: PHPResponse) { return new DOMParser().parseFromString(response.text, 'text/html'); } -function getFormData(form: HTMLFormElement): Record { +function getFormData(form: HTMLFormElement): Record { return Object.fromEntries((new FormData(form) as any).entries()); } From 3074ed224de8135853811b0dc8b91616b22ab3a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 8 Feb 2024 16:46:32 +0100 Subject: [PATCH 05/10] Adjust the multipart header when using formData --- .../php-wasm/universal/src/lib/php-request-handler.ts | 8 ++++++-- 1 file changed, 6 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 9ed7d81c84..dc3a7c97ed 100644 --- a/packages/php-wasm/universal/src/lib/php-request-handler.ts +++ b/packages/php-wasm/universal/src/lib/php-request-handler.ts @@ -206,10 +206,14 @@ export class PHPRequestHandler implements RequestHandler { let body = request.body; if (request.formData || request.files) { preferredMethod = 'POST'; - body = await new MultipartEncoder().encode({ + const encoder = new MultipartEncoder(); + body = await encoder.encode({ ...request.formData, ...request.files, }); + headers[ + 'content-type' + ] = `multipart/form-data; boundary=${encoder.boundary}`; } let scriptPath; @@ -372,7 +376,7 @@ function seemsLikeADirectoryRoot(path: string) { class MultipartEncoder { constructor( - private boundary: string = `----${Math.random().toString(36).slice(2)}` + public boundary: string = `----${Math.random().toString(36).slice(2)}` ) {} /** From 672accdb44ac52423da52333d9bc89abb8e628a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 8 Feb 2024 22:24:09 +0100 Subject: [PATCH 06/10] Multipart encoder --- .../node/src/test/php-request-handler.spec.ts | 12 +-- .../universal/src/lib/encode-as-multipart.ts | 54 +++++++++++ .../universal/src/lib/php-request-handler.ts | 89 +------------------ 3 files changed, 62 insertions(+), 93 deletions(-) create mode 100644 packages/php-wasm/universal/src/lib/encode-as-multipart.ts 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 944de53a23..4f9977cd3c 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 @@ -5,7 +5,7 @@ import { SupportedPHPVersions, } from '@php-wasm/universal'; -describe.each(SupportedPHPVersions)( +describe.each([SupportedPHPVersions])( '[PHP %s] PHPRequestHandler – request', (phpVersion) => { let php: NodePHP; @@ -140,7 +140,7 @@ describe.each(SupportedPHPVersions)( * the Content-type header is set to multipart/form-data. See the * phpwasm_init_uploaded_files_hash() docstring for more info. */ - await php.writeFile( + php.writeFile( '/index.php', `) { + const boundary = `----${Math.random().toString(36).slice(2)}`; + const contentType = `multipart/form-data; boundary=${boundary}`; + + const textEncoder = new TextEncoder(); + const parts: (string | Uint8Array)[] = []; + for (const [name, value] of Object.entries(data)) { + parts.push(`--${boundary}\r\n`); + parts.push(`Content-Disposition: form-data; name="${name}"`); + if (value instanceof File) { + parts.push(`; filename="${value.name}"`); + } + parts.push(`\r\n`); + if (value instanceof File) { + parts.push(`Content-Type: application/octet-stream`); + } + parts.push(`\r\n\r\n`); + if (typeof value === 'string') { + parts.push(value); + } else { + parts.push(await fileToUint8Array(value)); + } + parts.push(`\r\n`); + } + parts.push(`--${boundary}--\r\n`); + + const length = parts.reduce((acc, part) => acc + part.length, 0); + const body = new Uint8Array(length); + let offset = 0; + for (const part of parts) { + body.set( + typeof part === 'string' ? textEncoder.encode(part) : part, + offset + ); + offset += part.length; + } + return { body, contentType }; +} + +function fileToUint8Array(file: File): Promise { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => { + resolve(new Uint8Array(reader.result as ArrayBuffer)); + }; + reader.readAsArrayBuffer(file); + }); +} 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 dc3a7c97ed..e1342192ab 100644 --- a/packages/php-wasm/universal/src/lib/php-request-handler.ts +++ b/packages/php-wasm/universal/src/lib/php-request-handler.ts @@ -8,6 +8,7 @@ import { import { BasePHP, normalizeHeaders } from './base-php'; import { PHPResponse } from './php-response'; import { PHPRequest, PHPRunOptions, RequestHandler } from './universal-php'; +import { encodeAsMultipart } from './encode-as-multipart'; export interface PHPRequestHandlerConfiguration { /** @@ -206,14 +207,12 @@ export class PHPRequestHandler implements RequestHandler { let body = request.body; if (request.formData || request.files) { preferredMethod = 'POST'; - const encoder = new MultipartEncoder(); - body = await encoder.encode({ + const multipart = await encodeAsMultipart({ ...request.formData, ...request.files, }); - headers[ - 'content-type' - ] = `multipart/form-data; boundary=${encoder.boundary}`; + body = multipart.body; + headers['content-type'] = multipart.contentType; } let scriptPath; @@ -373,83 +372,3 @@ function seemsLikeADirectoryRoot(path: string) { const lastSegment = path.split('/').pop(); return !lastSegment!.includes('.'); } - -class MultipartEncoder { - constructor( - public boundary: string = `----${Math.random().toString(36).slice(2)}` - ) {} - - /** - * Encodes a multipart/form-data request body. - * - * @param data - The form data to encode. - * @returns The encoded body. - */ - encode(data: Record): Promise { - const parts = Object.entries(data).map(([name, value]) => { - if (value instanceof File) { - return this.#encodeFile(name, value); - } - return this.#encodeField(name, value); - }); - const body = parts.reduce((acc, part) => { - if (acc.length > 0) { - acc.push(new TextEncoder().encode('\r\n')); - } - acc.push(part); - return acc; - }, [] as Uint8Array[]); - const boundary = `--${this.boundary}--`; - body.push(new TextEncoder().encode(`\r\n${boundary}\r\n`)); - return Promise.resolve( - body.reduce((acc, part) => { - const newAcc = new Uint8Array(acc.length + part.length); - newAcc.set(acc, 0); - newAcc.set(part, acc.length); - return newAcc; - }, new Uint8Array(0)) - ); - } - - #encodeField(name: string, value: string) { - const encodedName = new TextEncoder().encode(name); - const encodedValue = new TextEncoder().encode(value); - const part = new Uint8Array( - encodedName.length + encodedValue.length + 2 - ); - part.set(encodedName, 0); - part.set(new TextEncoder().encode('\r\n\r\n'), encodedName.length); - part.set(encodedValue, encodedName.length + 4); - return part; - } - - #encodeFile(name: string, file: File) { - const encodedName = new TextEncoder().encode(name); - const encodedFilename = new TextEncoder().encode(file.name); - const part = new Uint8Array( - encodedName.length + - encodedFilename.length + - `filename="${file.name}"`.length + - 'Content-Type: application/octet-stream'.length + - '\r\n\r\n'.length + - file.size + - 2 - ); - part.set(encodedName, 0); - part.set( - new TextEncoder().encode(`; filename="${file.name}"`), - encodedName.length - ); - part.set( - new TextEncoder().encode( - '\r\nContent-Type: application/octet-stream\r\n\r\n' - ), - encodedName.length + encodedFilename.length - ); - part.set( - new Uint8Array([0, 0]), - encodedName.length + encodedFilename.length + 58 - ); - return part; - } -} From c36906e3adc7d58a5c565df0a7139e1c2745cb39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 8 Feb 2024 22:27:44 +0100 Subject: [PATCH 07/10] Consolidate files and formData into just formData in the PHP request handler --- .../php-wasm/node/src/test/php-request-handler.spec.ts | 2 +- .../php-wasm/universal/src/lib/encode-as-multipart.ts | 10 ++++++---- .../php-wasm/universal/src/lib/php-request-handler.ts | 3 +-- packages/php-wasm/universal/src/lib/universal-php.ts | 10 ++-------- .../playground/blueprints/src/lib/steps/import-file.ts | 2 +- 5 files changed, 11 insertions(+), 16 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 4f9977cd3c..c23cb317b9 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 @@ -149,7 +149,7 @@ describe.each([SupportedPHPVersions])( const response = await handler.request({ url: '/index.php', method: 'POST', - files: { + formData: { myFile: new File(['bar'], 'bar.txt'), }, }); diff --git a/packages/php-wasm/universal/src/lib/encode-as-multipart.ts b/packages/php-wasm/universal/src/lib/encode-as-multipart.ts index 937d2c5bbc..90093735d1 100644 --- a/packages/php-wasm/universal/src/lib/encode-as-multipart.ts +++ b/packages/php-wasm/universal/src/lib/encode-as-multipart.ts @@ -4,7 +4,9 @@ * @param data - The form data to encode. * @returns The encoded body and a correctly formatted content type header. */ -export async function encodeAsMultipart(data: Record) { +export async function encodeAsMultipart( + data: Record +) { const boundary = `----${Math.random().toString(36).slice(2)}`; const contentType = `multipart/form-data; boundary=${boundary}`; @@ -21,10 +23,10 @@ export async function encodeAsMultipart(data: Record) { parts.push(`Content-Type: application/octet-stream`); } parts.push(`\r\n\r\n`); - if (typeof value === 'string') { - parts.push(value); - } else { + if (value instanceof File) { parts.push(await fileToUint8Array(value)); + } else { + parts.push(value); } parts.push(`\r\n`); } 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 e1342192ab..bdc4bd3e0a 100644 --- a/packages/php-wasm/universal/src/lib/php-request-handler.ts +++ b/packages/php-wasm/universal/src/lib/php-request-handler.ts @@ -205,11 +205,10 @@ export class PHPRequestHandler implements RequestHandler { }; let body = request.body; - if (request.formData || request.files) { + if (request.formData) { preferredMethod = 'POST'; const multipart = await encodeAsMultipart({ ...request.formData, - ...request.files, }); body = multipart.body; headers['content-type'] = multipart.contentType; diff --git a/packages/php-wasm/universal/src/lib/universal-php.ts b/packages/php-wasm/universal/src/lib/universal-php.ts index c6cfe1038f..76354fdbb1 100644 --- a/packages/php-wasm/universal/src/lib/universal-php.ts +++ b/packages/php-wasm/universal/src/lib/universal-php.ts @@ -492,15 +492,9 @@ export interface PHPRequest { /** * Form data. If set, the request body will be ignored and - * the content-type header will be set to `application/x-www-form-urlencoded`. + * the content-type header will be set to `multipart/form-data`.. */ - formData?: Record; - - /** - * Uploaded files data. If set, the request body will be ignored and - * the content-type header will be set to `multipart/form-data`. - */ - files?: Record; + formData?: Record; } export interface PHPRunOptions { diff --git a/packages/playground/blueprints/src/lib/steps/import-file.ts b/packages/playground/blueprints/src/lib/steps/import-file.ts index ee154947d4..0a74bc2117 100644 --- a/packages/playground/blueprints/src/lib/steps/import-file.ts +++ b/packages/playground/blueprints/src/lib/steps/import-file.ts @@ -44,7 +44,7 @@ export const importFile: StepHandler> = async ( const stepOneResponse = await playground.request({ url: `/wp-admin/${firstUrlAction}`, method: 'POST', - files: { import: file }, + formData: { import: file }, }); // Map authors of imported posts to existing users From 54d55d7fed2ba60aa2cb35d3219583bb18c43d01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 8 Feb 2024 22:32:18 +0100 Subject: [PATCH 08/10] Consolidate formData and body into just body --- .../php-wasm/node/src/test/php-request-handler.spec.ts | 2 +- .../php-wasm/universal/src/lib/encode-as-multipart.ts | 6 +++--- .../php-wasm/universal/src/lib/php-request-handler.ts | 10 ++++------ packages/php-wasm/universal/src/lib/universal-php.ts | 10 +++------- .../playground/blueprints/src/lib/steps/import-file.ts | 4 ++-- 5 files changed, 13 insertions(+), 19 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 c23cb317b9..a5ef1e693c 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 @@ -149,7 +149,7 @@ describe.each([SupportedPHPVersions])( const response = await handler.request({ url: '/index.php', method: 'POST', - formData: { + body: { myFile: new File(['bar'], 'bar.txt'), }, }); diff --git a/packages/php-wasm/universal/src/lib/encode-as-multipart.ts b/packages/php-wasm/universal/src/lib/encode-as-multipart.ts index 90093735d1..e525ecf23d 100644 --- a/packages/php-wasm/universal/src/lib/encode-as-multipart.ts +++ b/packages/php-wasm/universal/src/lib/encode-as-multipart.ts @@ -33,16 +33,16 @@ export async function encodeAsMultipart( parts.push(`--${boundary}--\r\n`); const length = parts.reduce((acc, part) => acc + part.length, 0); - const body = new Uint8Array(length); + const bytes = new Uint8Array(length); let offset = 0; for (const part of parts) { - body.set( + bytes.set( typeof part === 'string' ? textEncoder.encode(part) : part, offset ); offset += part.length; } - return { body, contentType }; + return { bytes, contentType }; } function fileToUint8Array(file: File): Promise { 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 bdc4bd3e0a..871e07e029 100644 --- a/packages/php-wasm/universal/src/lib/php-request-handler.ts +++ b/packages/php-wasm/universal/src/lib/php-request-handler.ts @@ -205,13 +205,11 @@ export class PHPRequestHandler implements RequestHandler { }; let body = request.body; - if (request.formData) { + if (typeof body === 'object' && !(body instanceof Uint8Array)) { preferredMethod = 'POST'; - const multipart = await encodeAsMultipart({ - ...request.formData, - }); - body = multipart.body; - headers['content-type'] = multipart.contentType; + const { bytes, contentType } = await encodeAsMultipart(body); + body = bytes; + headers['content-type'] = contentType; } let scriptPath; diff --git a/packages/php-wasm/universal/src/lib/universal-php.ts b/packages/php-wasm/universal/src/lib/universal-php.ts index 76354fdbb1..e0462346a9 100644 --- a/packages/php-wasm/universal/src/lib/universal-php.ts +++ b/packages/php-wasm/universal/src/lib/universal-php.ts @@ -487,14 +487,10 @@ export interface PHPRequest { /** * Request body. + * If an object is given, the request will be encoded as multipart + * and sent with a `multipart/form-data` header. */ - body?: string | Uint8Array; - - /** - * Form data. If set, the request body will be ignored and - * the content-type header will be set to `multipart/form-data`.. - */ - formData?: Record; + body?: string | Uint8Array | Record; } export interface PHPRunOptions { diff --git a/packages/playground/blueprints/src/lib/steps/import-file.ts b/packages/playground/blueprints/src/lib/steps/import-file.ts index 0a74bc2117..a7e88ad6e4 100644 --- a/packages/playground/blueprints/src/lib/steps/import-file.ts +++ b/packages/playground/blueprints/src/lib/steps/import-file.ts @@ -44,7 +44,7 @@ export const importFile: StepHandler> = async ( const stepOneResponse = await playground.request({ url: `/wp-admin/${firstUrlAction}`, method: 'POST', - formData: { import: file }, + body: { import: file }, }); // Map authors of imported posts to existing users @@ -71,7 +71,7 @@ export const importFile: StepHandler> = async ( await playground.request({ url: importForm.action, method: 'POST', - formData: data, + body: data, }); }; From 527db96f0c7a4c917975a5137183d7c71d1a9917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 8 Feb 2024 22:53:48 +0100 Subject: [PATCH 09/10] Replace formData with body --- packages/php-wasm/universal/src/lib/universal-php.ts | 2 +- packages/playground/blueprints/src/lib/steps/login.ts | 2 +- .../blueprints/src/lib/steps/run-wp-installation-wizard.ts | 2 +- packages/playground/client/README.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/php-wasm/universal/src/lib/universal-php.ts b/packages/php-wasm/universal/src/lib/universal-php.ts index e0462346a9..f75d734f25 100644 --- a/packages/php-wasm/universal/src/lib/universal-php.ts +++ b/packages/php-wasm/universal/src/lib/universal-php.ts @@ -131,7 +131,7 @@ export interface RequestHandler { * headers: { * 'X-foo': 'bar', * }, - * formData: { + * body: { * foo: 'bar', * }, * }); diff --git a/packages/playground/blueprints/src/lib/steps/login.ts b/packages/playground/blueprints/src/lib/steps/login.ts index 07cad2d773..77ac4eb6ee 100644 --- a/packages/playground/blueprints/src/lib/steps/login.ts +++ b/packages/playground/blueprints/src/lib/steps/login.ts @@ -44,7 +44,7 @@ export const login: StepHandler = async ( const response = await playground.request({ url: '/wp-login.php', method: 'POST', - formData: { + body: { log: username, pwd: password, rememberme: 'forever', diff --git a/packages/playground/blueprints/src/lib/steps/run-wp-installation-wizard.ts b/packages/playground/blueprints/src/lib/steps/run-wp-installation-wizard.ts index fd803b50db..873c910af6 100644 --- a/packages/playground/blueprints/src/lib/steps/run-wp-installation-wizard.ts +++ b/packages/playground/blueprints/src/lib/steps/run-wp-installation-wizard.ts @@ -25,7 +25,7 @@ export const runWpInstallationWizard: StepHandler< await playground.request({ url: '/wp-admin/install.php?step=2', method: 'POST', - formData: { + body: { language: 'en', prefix: 'wp_', weblog_title: 'My WordPress Website', diff --git a/packages/playground/client/README.md b/packages/playground/client/README.md index d706aa87b5..e29415096d 100644 --- a/packages/playground/client/README.md +++ b/packages/playground/client/README.md @@ -33,7 +33,7 @@ console.log(await client.readFileAsText('/index.php')); await client.request({ url: '/index.php', method: 'POST', - formData: { + body: { foo: 'bar', }, }); From 35c374920af1d3e14d51f6ca13835aafb03d5050 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 8 Feb 2024 23:34:04 +0100 Subject: [PATCH 10/10] Remove square rbaces from around [SupportedPHPVersions] in the PHPRequestHandler tests --- packages/php-wasm/node/src/test/php-request-handler.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a5ef1e693c..a4f62c3ee3 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 @@ -5,7 +5,7 @@ import { SupportedPHPVersions, } from '@php-wasm/universal'; -describe.each([SupportedPHPVersions])( +describe.each(SupportedPHPVersions)( '[PHP %s] PHPRequestHandler – request', (phpVersion) => { let php: NodePHP;