diff --git a/src/php-wasm/__tests__/php-server.ts b/src/php-wasm/__tests__/php-server.ts index 72b7fd09e7..4230c74b42 100644 --- a/src/php-wasm/__tests__/php-server.ts +++ b/src/php-wasm/__tests__/php-server.ts @@ -1,4 +1,4 @@ -import * as phpLoaderModule from '../../../build/php.node.js'; +import * as phpLoaderModule from '../../../build/php-5.6.node.js'; import { startPHP } from '../php'; import { PHPServer } from '../php-server'; @@ -6,52 +6,6 @@ const { TextEncoder, TextDecoder } = require('util'); global.TextEncoder = TextEncoder; global.TextDecoder = TextDecoder; -describe('PHP Server – boot', () => { - beforeAll(() => { - // Shim the user agent for the server - (global as any).navigator = { userAgent: '' }; - }); - - it('should boot', async () => { - const php = await startPHP(phpLoaderModule, 'NODE'); - php.mkdirTree('/tests'); - const server = new PHPServer(php, { - documentRoot: '/tests', - absoluteUrl: 'http://localhost/', - isStaticFilePath: (path) => path.startsWith('/uploads'), - }); - - server.php.writeFile( - '/tests/upload.php', - ` $_FILES, - 'is_uploaded' => is_uploaded_file($_FILES['file_txt']['tmp_name']) - ]);` - ); - const response = await server.request({ - path: `/upload.php`, - method: 'POST', - _POST: {}, - files: { - file_txt: new File(['Hello world'], 'file.txt'), - }, - }); - const bodyText = new TextDecoder().decode(response.body); - expect(JSON.parse(bodyText)).toEqual({ - files: { - file_txt: { - name: 'file.txt', - type: 'text/plain', - tmp_name: expect.any(String), - error: '0', - size: '1', - }, - }, - is_uploaded: true, - }); - }); -}); - describe('PHP Server – requests', () => { beforeAll(() => { // Shim the user agent for the server @@ -96,77 +50,4 @@ describe('PHP Server – requests', () => { }, }); }); - - it('should parse FILES arrays in a PHP way', async () => { - server.php.writeFile( - '/tests/upload.php', - ` $_FILES, - 'is_uploaded' => is_uploaded_file($_FILES['file_txt']['first']['tmp_name']) - ]);` - ); - const response = await server.request({ - path: `/upload.php`, - method: 'POST', - _POST: {}, - files: { - 'file_txt[first]': new File(['Hello world'], 'file.txt'), - }, - }); - const bodyText = new TextDecoder().decode(response.body); - expect(JSON.parse(bodyText)).toEqual({ - files: { - file_txt: { - first: { - name: 'file.txt', - type: 'text/plain', - tmp_name: expect.any(String), - error: '0', - size: '1', - }, - }, - }, - is_uploaded: true, - }); - }); }); - -// Shim the browser's file class -class File { - data; - name; - - constructor(data, name) { - this.data = data; - this.name = name; - } - - get size() { - return this.data.length; - } - - get type() { - return 'text/plain'; - } - - arrayBuffer() { - return dataToArrayBuffer(this.data); - } -} - -function dataToArrayBuffer(data) { - if (typeof data === 'string') { - return new TextEncoder().encode(data).buffer; - } else if (data instanceof ArrayBuffer) { - data = new Uint8Array(data); - } else if (Array.isArray(data)) { - if (data[0] instanceof Number) { - return new Uint8Array(data); - } - return dataToArrayBuffer(data[0]); - } else if (data instanceof Uint8Array) { - return data.buffer; - } else { - throw new Error('Unsupported data type'); - } -} diff --git a/src/php-wasm/__tests__/php.ts b/src/php-wasm/__tests__/php.ts index c1063669ec..5441887ca0 100644 --- a/src/php-wasm/__tests__/php.ts +++ b/src/php-wasm/__tests__/php.ts @@ -1,4 +1,4 @@ -import * as phpLoaderModule from '../../../build/php.node.js'; +import * as phpLoaderModule from '../../../build/php-5.6.node.js'; import { startPHP } from '../php'; const { TextEncoder, TextDecoder } = require('util'); @@ -151,3 +151,101 @@ describe('PHP – stdio', () => { }); }); }); + +describe('PHP Server – requests', () => { + beforeAll(() => { + // Shim the user agent for the server + (global as any).navigator = { userAgent: '' }; + }); + + let php, server; + beforeEach(async () => { + php = await startPHP(phpLoaderModule, 'NODE'); + php.mkdirTree('/tests'); + }); + + it('should parse FILES arrays in a PHP way', async () => { + const response = php.run( + ` $_FILES, + 'is_uploaded' => is_uploaded_file($_FILES['file_txt']['first']['tmp_name']) + ]);`, + { + method: 'POST', + uploadedFiles: await php.uploadFiles({ + 'file_txt[first]': new File(['Hello world'], 'file.txt'), + }), + } + ); + const bodyText = new TextDecoder().decode(response.stdout); + expect(JSON.parse(bodyText)).toEqual({ + files: { + file_txt: { + first: { + name: 'file.txt', + type: 'text/plain', + tmp_name: expect.any(String), + error: '0', + size: '1', + }, + }, + }, + is_uploaded: true, + }); + }); + + it('Should have access to raw POST data', async () => { + const response = await php.run( + ` { - const _FILES = await this.#prepare_FILES(request.files); - - try { - const output = await this.php.run(`queryString, 1), $_GET); - parse_str($request->_POST, $_POST); - parse_str($request->_FILES, $_FILES); + parse_str(substr($request->queryString, 1), $_GET); + parse_str($request->postQueryString, $_POST); - if ( !is_null($request->_COOKIE) ) { - foreach ($request->_COOKIE as $key => $value) { - fwrite($stdErr, 'Setting Cookie: ' . $key . " => " . $value . "\n"); - $_COOKIE[$key] = urldecode($value); - } + if ( !is_null($request->_COOKIE) ) { + foreach ($request->_COOKIE as $key => $value) { + fwrite($stdErr, 'Setting Cookie: ' . $key . " => " . $value . "\n"); + $_COOKIE[$key] = urldecode($value); } + } + + $_SESSION = $request->_SESSION; - $_SESSION = $request->_SESSION; + foreach( $request->headers as $name => $value ) { + $server_key = 'HTTP_' . strtoupper(str_replace('-', '_', $name)); + $_SERVER[$server_key] = $value; + } - foreach( $request->headers as $name => $value ) { - $server_key = 'HTTP_' . strtoupper(str_replace('-', '_', $name)); - $_SERVER[$server_key] = $value; + fwrite($stdErr, json_encode(['session' => $_SESSION]) . "\n"); + + $script = ltrim($request->path, '/'); + + $path = $request->path; + $path = preg_replace('/^\\/php-wasm/', '', $path); + + $_SERVER['PATH'] = '/'; + $_SERVER['REQUEST_URI'] = $path . ($request->queryString ?: ''); + $_SERVER['REQUEST_METHOD'] = $request->method; + $_SERVER['REMOTE_ADDR'] = ${JSON.stringify(this.#HOSTNAME)}; + $_SERVER['SERVER_NAME'] = ${JSON.stringify(this.#ABSOLUTE_URL)}; + $_SERVER['SERVER_PORT'] = ${JSON.stringify(this.#PORT)}; + $_SERVER['HTTPS'] = ${JSON.stringify( + this.#ABSOLUTE_URL.startsWith('https://') ? 'on' : '' + )}; + $_SERVER['HTTP_HOST'] = ${JSON.stringify(this.#HOST)}; + $_SERVER['HTTP_USER_AGENT'] = ${JSON.stringify(navigator.userAgent)}; + $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; + $_SERVER['DOCUMENT_ROOT'] = '/'; + $docroot = ${JSON.stringify(this.#DOCROOT)}; + $_SERVER['SCRIPT_FILENAME'] = $docroot . '/' . $script; + $_SERVER['SCRIPT_NAME'] = $docroot . '/' . $script; + $_SERVER['PHP_SELF'] = $docroot . '/' . $script; + chdir($docroot); + + require_once ${JSON.stringify(this.#resolvePHPFilePath(request.path))}; + `, + { + requestBody: isPostJson + ? JSON.stringify(request._POST) || '' + : new URLSearchParams(request._POST || {}).toString(), + uploadedFiles: request.files + ? await this.php.uploadFiles(request.files) + : undefined, } + ); - fwrite($stdErr, json_encode(['session' => $_SESSION]) . "\n"); - - $script = ltrim($request->path, '/'); - - $path = $request->path; - $path = preg_replace('/^\\/php-wasm/', '', $path); - - $_SERVER['PATH'] = '/'; - $_SERVER['REQUEST_URI'] = $path . ($request->queryString ?: ''); - $_SERVER['REQUEST_METHOD'] = $request->method; - $_SERVER['REMOTE_ADDR'] = ${JSON.stringify(this.#HOSTNAME)}; - $_SERVER['SERVER_NAME'] = ${JSON.stringify(this.#ABSOLUTE_URL)}; - $_SERVER['SERVER_PORT'] = ${JSON.stringify(this.#PORT)}; - $_SERVER['HTTPS'] = ${JSON.stringify( - this.#ABSOLUTE_URL.startsWith('https://') ? 'on' : '' - )}; - $_SERVER['HTTP_HOST'] = ${JSON.stringify(this.#HOST)}; - $_SERVER['HTTP_USER_AGENT'] = ${JSON.stringify(navigator.userAgent)}; - $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; - $_SERVER['DOCUMENT_ROOT'] = '/'; - $docroot = ${JSON.stringify(this.#DOCROOT)}; - $_SERVER['SCRIPT_FILENAME'] = $docroot . '/' . $script; - $_SERVER['SCRIPT_NAME'] = $docroot . '/' . $script; - $_SERVER['PHP_SELF'] = $docroot . '/' . $script; - chdir($docroot); - - require_once ${JSON.stringify(this.#resolvePHPFilePath(request.path))}; - `); - - return parseResponse(output); - } finally { - this.#cleanup_FILES(_FILES); - } + return parseResponse(output); } /** @@ -308,74 +315,6 @@ REQUEST } return requestedPath.substr(this.#PATHNAME.length); } - - /** - * Prepares an object like { file1_name: File, ... } for - * being processed as $_FILES in PHP. - * - * In particular: - * * Creates the files in the filesystem - * * Allocates a global PHP rfc1867_uploaded_files HashTable - * * Registers the files in PHP's rfc1867_uploaded_files - * * Converts the JavaScript files object to the $_FILES data format like below - * - * Array( - * [file1_name] => Array ( - * [name] => file_name.jpg - * [type] => text/plain - * [tmp_name] => /tmp/php/php1h4j1o (some path in the filesystem where the tmp file is kept for processing) - * [error] => UPLOAD_ERR_OK (= 0) - * [size] => 123 (the size in bytes) - * ) - * // ... - * ) - * - * @param files - JavaScript files keyed by their HTTP upload name. - * @returns $_FILES-compatible object. - */ - async #prepare_FILES(files: Record = {}): Promise<_FILES> { - if (Object.keys(files).length) { - this.php.initUploadedFilesHash(); - } - - const _FILES: _FILES = {}; - for (const [key, value] of Object.entries(files)) { - const tmpName = Math.random().toFixed(20); - const tmpPath = `/tmp/${tmpName}`; - // Need to read the blob and store it in the filesystem - this.php.writeFile( - tmpPath, - new Uint8Array(await value.arrayBuffer()) - ); - _FILES[key] = { - name: value.name, - type: value.type, - tmp_name: tmpPath, - error: 0, - size: value.size, - }; - this.php.registerUploadedFile(tmpPath); - } - return _FILES; - } - - /** - * Cleans up after #prepare_FILES: - * * Frees the PHP's rfc1867_uploaded_files HashTable - * * Removes the temporary files from the filesystem - * - * @param _FILES - $_FILES-compatible object. - */ - #cleanup_FILES(_FILES: _FILES = {}) { - if (Object.keys(_FILES).length) { - this.php.destroyUploadedFilesHash(); - } - for (const value of Object.values(_FILES)) { - if (this.php.fileExists(value.tmp_name)) { - this.php.unlink(value.tmp_name); - } - } - } } /** @@ -506,41 +445,6 @@ function inferMimeType(path: string): string { } } -/** - * Convert a dictionary to a string in the format - * that PHP's `parse_str` function expects. - * - * @example - * ```js - * dictToParseStrFormat({ foo: 'bar', baz: 123 }) - * // foo=bar&baz=123 - * - * dictToParseStrFormat({ foo: { bar: 'baz' } }) - * // foo[bar]=baz - * - * dictToParseStrFormat({ 'foo[bar]': { baz: 123 } }) - * // foo[bar][baz]=123 - * ``` - * - * @param dict - The dictionary to convert. - * @returns The string in the format that PHP's `parse_str` function expects. - */ -function dictToParseStrFormat( - dict: Record -): string { - const serializableDict: Record = {}; - for (const key in dict) { - if (typeof dict[key] === 'object') { - for (const subKey in dict[key] as _FILE) { - serializableDict[`${key}[${subKey}]`] = dict[key][subKey]; - } - } else { - serializableDict[key] = dict[key] as string; - } - } - return new URLSearchParams(serializableDict).toString(); -} - export interface PHPServerConfigation { /** * The directory in the PHP filesystem where the server will look @@ -614,14 +518,4 @@ export interface PHPResponse { rawError: string[]; } -type _FILES = Record; - -interface _FILE { - name: string; - type: string; - tmp_name: string; - error: number; - size: number; -} - export default PHPServer; diff --git a/src/php-wasm/php.ts b/src/php-wasm/php.ts index b761135e14..6677296ed3 100644 --- a/src/php-wasm/php.ts +++ b/src/php-wasm/php.ts @@ -203,7 +203,6 @@ display_startup_errors = On session.save_path=/home/web_user ` ); - this.#Runtime.ccall('phpwasm_init_context', NUM, [STR], []); } /** @@ -230,17 +229,60 @@ session.save_path=/home/web_user * console.log(output.stdout); // "Hello world!" * ``` * - * @param code - The PHP code to run. + * @param code - The PHP code to run. + * @param options - The options object. * @returns The PHP process output. */ - run(code: string): PHPOutput { + run(code: string, options: PHPRequestOptions = {}): PHPOutput { + options = { + requestBody: '', + ...options, + }; + + const hasUploadedFiles = !!options.uploadedFiles?.filesQueryString; + const additionalCode: string[] = []; + if (hasUploadedFiles) { + additionalCode.push( + `_FILES, $_FILES); + ?>` + ); + } + const exitCode = this.#Runtime.ccall( 'phpwasm_run', NUM, - [STR], - [`?>${code}`] + [STR, STR, STR], + [ + // char *php_code, + `?>` + + `${additionalCode.join('')}${code}`, + + // char *request_body, + options.requestBody || '', + + // char *uploaded_files_paths + hasUploadedFiles + ? options.uploadedFiles!.tmpPaths.join('\n') + : '/tmp/test', + ] ); - this.#refresh(); + + // Remove the uploaded files + if (hasUploadedFiles) { + for (const uploadedFilePath of options.uploadedFiles!.tmpPaths) { + if (this.fileExists(uploadedFilePath)) { + this.unlink(uploadedFilePath); + } + } + } + return { exitCode, stdout: this.readFileAsBuffer('/tmp/stdout'), @@ -249,16 +291,48 @@ session.save_path=/home/web_user } /** - * Destroys the current PHP context and creates a new one. - * Any variables, functions, classes, etc. defined in the previous - * context will be lost. This methods needs to always be called after - * running PHP code, or else the next call to `run` will be contaminated - * with the previous context. + * Prepares an object like { file1_name: File, ... } for + * being processed as $_FILES in PHP. * - * @internal + * In particular: + * * Creates the files in the filesystem + * * Allocates a global PHP rfc1867_uploaded_files HashTable + * * Registers the files in PHP's rfc1867_uploaded_files + * * Converts the JavaScript files object to the $_FILES data format like below + * + * Array( + * [file1_name] => Array ( + * [name] => file_name.jpg + * [type] => text/plain + * [tmp_name] => /tmp/php/php1h4j1o (some path in the filesystem where the tmp file is kept for processing) + * [error] => UPLOAD_ERR_OK (= 0) + * [size] => 123 (the size in bytes) + * ) + * // ... + * ) + * + * @param files - JavaScript files keyed by their HTTP upload name. + * @returns $_FILES-compatible object. */ - #refresh() { - this.#Runtime.ccall('phpwasm_refresh', NUM, [], []); + async uploadFiles(files: Record): Promise { + const tmpPaths: string[] = []; + const _FILES: Record = {}; + for (const [key, value] of Object.entries(files)) { + const tmpName = Math.random().toFixed(20); + const tmpPath = `/tmp/${tmpName}`; + // Need to read the blob and store it in the filesystem + this.writeFile(tmpPath, new Uint8Array(await value.arrayBuffer())); + _FILES[`${key}[name]`] = value.name; + _FILES[`${key}[type]`] = value.type; + _FILES[`${key}[tmp_name]`] = tmpPath; + _FILES[`${key}[error]`] = '0'; + _FILES[`${key}[size]`] = value.size.toString(); + tmpPaths.push(tmpPath); + } + return { + tmpPaths, + filesQueryString: new URLSearchParams(_FILES).toString(), + }; } /** @@ -338,7 +412,7 @@ session.save_path=/home/web_user /** * Checks if a directory exists in the PHP filesystem. * - * @param path – The path to check. + * @param path – The path to check. * @returns True if the path is a directory, false otherwise. */ isDir(path: string): boolean { @@ -364,102 +438,16 @@ session.save_path=/home/web_user return false; } } +} - /** - * Allocates an internal HashTable to keep track of the legitimate uploads. - * - * Supporting file uploads via WebAssembly is a bit tricky. - * Functions like `is_uploaded_file` or `move_uploaded_file` fail to work - * with those $_FILES entries that are not in an internal hash table. This - * is a security feature, see this exceprt from the `is_uploaded_file` documentation: - * - * > 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 PHP.wasm implementation doesn't run any PHP request machinery, so PHP never has - * a chance to note which files were actually uploaded. In practice, `is_uploaded_file()` - * always returns false. - * - * `initUploadedFilesHash()`, `registerUploadedFile()`, and `destroyUploadedFilesHash()` - * are a workaround for this problem. They allow you to manually register uploaded - * files in the internal hash table, so that PHP functions like `move_uploaded_file()` - * can recognize them. - * - * Usage: - * - * ```js - * // Create an uploaded file in the PHP filesystem. - * php.writeFile( - * '/tmp/test.txt', - * 'I am an uploaded file!' - * ); - * - * // Allocate the internal hash table. - * php.initUploadedFilesHash(); - * - * // Register the uploaded file. - * php.registerUploadedFile('/tmp/test.txt'); - * - * // Run PHP code that uses the uploaded file. - * php.run(` count_bytes) { + len = count_bytes; + } + memcpy(buffer, global_request_body, len); + free(global_request_body); + return len; +} + +/* + * Function: phpwasm_request + * ---------------------------- + */ +int EMSCRIPTEN_KEEPALIVE phpwasm_run( + char *php_code, + char *request_body, + char *uploaded_files_paths +) { + int retVal = 255; // Unknown error. + + // Write to files instead of stdout and stderr because Emscripten truncates null + // bytes from stdout and stderr, and null bytes are a valid output when streaming + // binary data. + int stdout_replacement = redirect_stream_to_file(stdout, "/tmp/stdout", O_TRUNC); + int stderr_replacement = redirect_stream_to_file(stderr, "/tmp/stderr", O_TRUNC); + if (stdout_replacement == -1 || stderr_replacement == -1) + { + return retVal; + } + + putenv("USE_ZEND_ALLOC=0"); + + SG(request_info).content_length = strlen(request_body); + global_request_body = request_body; + php_embed_module.read_post = *php_wasm_read_post_body; + + if (php_embed_init(0, NULL) != -1) + { + phpwasm_init_uploaded_files_hash(); + + if(strlen(uploaded_files_paths) > 0) + { + // Split the string by newlines + char delim[] = "\n"; + char *ptr = strtok(uploaded_files_paths, delim); + + // Register each uploaded file + while(ptr != NULL) + { + phpwasm_register_uploaded_file(ptr); + ptr = strtok(NULL, delim); + } + } + + zend_try + { + retVal = zend_eval_string(php_code, NULL, "php-wasm run script"); + + if (EG(exception)) + { + zend_exception_error(EG(exception), E_ERROR); + retVal = 2; + } + } + zend_catch + { + retVal = 1; // Code died. + } + + zend_end_try(); + destroy_uploaded_files_hash(); + php_embed_shutdown(); + } + + fflush(stdout); + fflush(stderr); + + restore_stream_handler(stdout, stdout_replacement); + restore_stream_handler(stderr, stderr_replacement); + + return retVal; +}