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/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-request-handler.spec.ts b/packages/php-wasm/node/src/test/php-request-handler.spec.ts index c9083a43d8..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 @@ -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', ` { */ 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', }, @@ -1118,95 +1122,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, @@ -1216,20 +1131,12 @@ bar1 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--`, - fileInfos: [ - { - name: 'from_files.txt', - key: 'myFile2', - data: new TextEncoder().encode('bar2'), - type: 'application/json', - }, - ], +--boundary--`), 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/encode-as-multipart.ts b/packages/php-wasm/universal/src/lib/encode-as-multipart.ts new file mode 100644 index 0000000000..e525ecf23d --- /dev/null +++ b/packages/php-wasm/universal/src/lib/encode-as-multipart.ts @@ -0,0 +1,56 @@ +/** + * Encodes a multipart/form-data request body. + * + * @param data - The form data to encode. + * @returns The encoded body and a correctly formatted content type header. + */ +export async function encodeAsMultipart( + data: Record +) { + 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 (value instanceof File) { + parts.push(await fileToUint8Array(value)); + } else { + parts.push(value); + } + parts.push(`\r\n`); + } + parts.push(`--${boundary}--\r\n`); + + const length = parts.reduce((acc, part) => acc + part.length, 0); + const bytes = new Uint8Array(length); + let offset = 0; + for (const part of parts) { + bytes.set( + typeof part === 'string' ? textEncoder.encode(part) : part, + offset + ); + offset += part.length; + } + return { bytes, 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/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..871e07e029 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,8 @@ 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'; +import { encodeAsMultipart } from './encode-as-multipart'; export interface PHPRequestHandlerConfiguration { /** @@ -207,48 +203,13 @@ 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) { + let body = request.body; + if (typeof body === 'object' && !(body instanceof Uint8Array)) { preferredMethod = 'POST'; - headers['content-type'] = - headers['content-type'] || - 'application/x-www-form-urlencoded'; - body = new URLSearchParams( - request.formData as Record - ).toString(); - } else { - body = request.body; + const { bytes, contentType } = await encodeAsMultipart(body); + body = bytes; + headers['content-type'] = contentType; } let scriptPath; @@ -287,7 +248,6 @@ export class PHPRequestHandler implements RequestHandler { protocol: this.#PROTOCOL, method: request.method || preferredMethod, body, - fileInfos, scriptPath, headers, }); @@ -329,46 +289,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..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', * }, * }); @@ -486,20 +486,11 @@ export interface PHPRequest { headers?: PHPRequestHeaders; /** - * Uploaded files + * Request body. + * If an object is given, the request will be encoded as multipart + * and sent with a `multipart/form-data` header. */ - files?: Record; - - /** - * Request body without the files. - */ - body?: string; - - /** - * 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; + body?: string | Uint8Array | Record; } export interface PHPRunOptions { @@ -529,14 +520,9 @@ export interface PHPRunOptions { headers?: PHPRequestHeaders; /** - * Request body without the files. + * Request body. */ - body?: string; - - /** - * Uploaded files. - */ - fileInfos?: FileInfo[]; + body?: string | Uint8Array; /** * The code snippet to eval instead of a php file. @@ -564,13 +550,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/blueprints/src/lib/steps/import-file.ts b/packages/playground/blueprints/src/lib/steps/import-file.ts index 6b5814b463..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', - files: { 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, }); }; @@ -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()); } 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', }, }); 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,