diff --git a/.gitignore b/.gitignore index add4fce9..45c6c70d 100644 --- a/.gitignore +++ b/.gitignore @@ -195,6 +195,7 @@ Cargo.lock !.yarn/versions *.node +*.dylib # Added by static-php-cli crates/php/buildroot diff --git a/.npmignore b/.npmignore index a54f1844..36347e4b 100644 --- a/.npmignore +++ b/.npmignore @@ -12,6 +12,8 @@ compile_flags.txt rustfmt.toml pnpm-lock.yaml *.node +*.dylib !npm/**/*.node +!npm/**/*.dylib __test__ renovate.json diff --git a/__test__/handler.spec.mjs b/__test__/handler.spec.mjs index 6d66ea74..60af4679 100644 --- a/__test__/handler.spec.mjs +++ b/__test__/handler.spec.mjs @@ -2,20 +2,26 @@ import test from 'ava' import { Php, Request } from '../index.js' +import { MockRoot } from './util.mjs' + test('Support input/output streams', async (t) => { - const php = new Php({ - argv: process.argv, - file: 'index.php', - code: `` }) + t.teardown(() => mockroot.clean()) + + const php = new Php({ + argv: process.argv, + docroot: mockroot.path + }) const req = new Request({ method: 'GET', - url: 'http://example.com/test.php', + url: 'http://example.com/index.php', body: Buffer.from('Hello, from Node.js!') }) @@ -25,16 +31,21 @@ test('Support input/output streams', async (t) => { }) test('Capture logs', async (t) => { - const php = new Php({ - file: 'index.php', - code: `` }) + t.teardown(() => mockroot.clean()) + + const php = new Php({ + argv: process.argv, + docroot: mockroot.path + }) const req = new Request({ method: 'GET', - url: 'http://example.com/test.php' + url: 'http://example.com/index.php' }) const res = await php.handleRequest(req) @@ -43,16 +54,21 @@ test('Capture logs', async (t) => { }) test('Capture exceptions', async (t) => { - const php = new Php({ - file: 'index.php', - code: `` }) + t.teardown(() => mockroot.clean()) + + const php = new Php({ + argv: process.argv, + docroot: mockroot.path + }) const req = new Request({ method: 'GET', - url: 'http://example.com/test.php' + url: 'http://example.com/index.php' }) const res = await php.handleRequest(req) @@ -62,19 +78,24 @@ test('Capture exceptions', async (t) => { }) test('Support request and response headers', async (t) => { - const php = new Php({ - file: 'index.php', - code: `` }) + t.teardown(() => mockroot.clean()) + + const php = new Php({ + argv: process.argv, + docroot: mockroot.path + }) const req = new Request({ method: 'GET', - url: 'http://example.com/test.php', + url: 'http://example.com/index.php', headers: { 'X-Test': ['Hello, from Node.js!'] } diff --git a/__test__/headers.spec.mjs b/__test__/headers.spec.mjs index 6f2fc2fa..43d7f873 100644 --- a/__test__/headers.spec.mjs +++ b/__test__/headers.spec.mjs @@ -62,12 +62,12 @@ test('includes iterator methods', (t) => { const entries = Array.from(headers.entries()) .sort((a, b) => a[0].localeCompare(b[0])) t.deepEqual(entries, [ - ['accept', ['application/json']], - ['content-type', ['application/json']] + ['Accept', ['application/json']], + ['Content-Type', ['application/json']] ]) const keys = Array.from(headers.keys()).sort() - t.deepEqual(keys, ['accept', 'content-type']) + t.deepEqual(keys, ['Accept', 'Content-Type']) const values = Array.from(headers.values()).sort() t.deepEqual(values, ['application/json', 'application/json']) @@ -77,7 +77,7 @@ test('includes iterator methods', (t) => { seen.push([name, values, map]) }) t.deepEqual(seen.sort((a, b) => a[0].localeCompare(b[0])), [ - ['accept', ['application/json'], headers], - ['content-type', ['application/json'], headers] + ['Accept', ['application/json'], headers], + ['Content-Type', ['application/json'], headers] ]) }) diff --git a/__test__/util.mjs b/__test__/util.mjs new file mode 100644 index 00000000..ce9ef3ce --- /dev/null +++ b/__test__/util.mjs @@ -0,0 +1,62 @@ +import { randomUUID } from 'node:crypto' +import { writeFile, mkdir, rmdir } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const base = tmpdir() + +export class MockRoot { + /** + * Creates a mock docroot using a nested object to represent the directory + * structure. A directory has a + * + * Example: + * + * ```js + * const dir = mockroot({ + * 'hello.txt': 'Hello, World!', + * 'subdir': { + * 'subfile.txt': Buffer.from('hi') + * } + * }) + * ``` + * + * @param {*} files + */ + constructor(name = randomUUID()) { + this.path = join(base, name) + } + + async writeFiles(files, base = this.path) { + await mkdir(base, { recursive: true }) + + for (let [name, contents] of Object.entries(files)) { + if (typeof contents === 'string') { + contents = Buffer.from(contents) + } + + const path = join(base, name) + if (Buffer.isBuffer(contents)) { + await writeFile(path, contents) + } else { + await this.writeFiles(contents, path) + } + } + } + + static async from(files) { + const mockroot = new MockRoot() + await mockroot.writeFiles(files) + return mockroot + } + + /** + * Cleanup the mock docroot + */ + async clean() { + await rmdir(this.path, { + recursive: true, + force: true + }) + } +} diff --git a/crates/lang_handler/src/headers.rs b/crates/lang_handler/src/headers.rs index ea3ea6c2..adef563b 100644 --- a/crates/lang_handler/src/headers.rs +++ b/crates/lang_handler/src/headers.rs @@ -58,7 +58,9 @@ impl Headers { where K: AsRef, { - self.0.contains_key(key.as_ref().to_lowercase().as_str()) + self + .0 + .contains_key(key.as_ref() /*.to_lowercase().as_str()*/) } /// Returns the last single value associated with a header field. @@ -77,7 +79,7 @@ impl Headers { where K: AsRef, { - match self.0.get(key.as_ref().to_lowercase().as_str()) { + match self.0.get(key.as_ref() /*.to_lowercase().as_str()*/) { Some(Header::Single(value)) => Some(value.clone()), Some(Header::Multiple(values)) => values.last().cloned(), None => None, @@ -106,7 +108,7 @@ impl Headers { where K: AsRef, { - match self.0.get(key.as_ref().to_lowercase().as_str()) { + match self.0.get(key.as_ref() /*.to_lowercase().as_str()*/) { Some(Header::Single(value)) => vec![value.clone()], Some(Header::Multiple(values)) => values.clone(), None => Vec::new(), @@ -162,9 +164,10 @@ impl Headers { K: Into, V: Into, { - self - .0 - .insert(key.into().to_lowercase(), Header::Single(value.into())); + self.0.insert( + key.into(), /*.to_lowercase()*/ + Header::Single(value.into()), + ); } /// Add a header with the given value without replacing existing ones. @@ -187,7 +190,7 @@ impl Headers { K: Into, V: Into, { - let key = key.into().to_lowercase(); + let key = key.into()/*.to_lowercase()*/; let value = value.into(); match self.0.entry(key) { @@ -227,7 +230,7 @@ impl Headers { where K: AsRef, { - self.0.remove(key.as_ref().to_lowercase().as_str()); + self.0.remove(key.as_ref() /*.to_lowercase().as_str()*/); } /// Clears all headers. diff --git a/crates/lang_handler/src/response.rs b/crates/lang_handler/src/response.rs index d03efd01..379476f7 100644 --- a/crates/lang_handler/src/response.rs +++ b/crates/lang_handler/src/response.rs @@ -317,7 +317,7 @@ impl ResponseBuilder { K: Into, V: Into, { - self.headers.set(key, value); + self.headers.add(key, value); self } diff --git a/crates/php/src/embed.rs b/crates/php/src/embed.rs index 84b450df..ca86dfad 100644 --- a/crates/php/src/embed.rs +++ b/crates/php/src/embed.rs @@ -3,7 +3,7 @@ use std::{ env::Args, ffi::{c_char, c_int, c_void, CStr, CString, NulError}, ops::Deref, - path::PathBuf, + path::{Path, PathBuf, StripPrefixError}, str::FromStr, sync::{OnceLock, RwLock}, }; @@ -29,56 +29,9 @@ use ext_php_rs::{ }, }; -use lang_handler::{Handler, Request, Response, ResponseBuilder}; +use lang_handler::{Handler, Header, Request, Response, ResponseBuilder}; use libc::free; -struct memory_stream { - ptr: *mut c_char, - len: usize, - available: usize, -} - -#[no_mangle] -unsafe extern "C" fn memory_stream_reader( - handle: *mut c_void, - buf: *mut c_char, - len: usize, -) -> isize { - let stream = handle as *mut memory_stream; - if stream.is_null() { - return 0; - } - - let stream = unsafe { &mut *stream }; - if stream.available == 0 { - return 0; - } - - let read_len = std::cmp::min(len, stream.available); - unsafe { - std::ptr::copy_nonoverlapping(stream.ptr as *const c_char, buf, read_len); - } - stream.available -= read_len; - stream.ptr = unsafe { stream.ptr.add(read_len) }; - read_len as isize -} - -#[no_mangle] -unsafe extern "C" fn memory_stream_fsizer(handle: *mut c_void) -> usize { - let stream = handle as *mut memory_stream; - if stream.is_null() { - return 0; - } - - let stream = unsafe { &mut *stream }; - stream.len - stream.available -} - -#[no_mangle] -unsafe extern "C" fn memory_stream_closer(_handle: *mut c_void) { - // Nothing to do. The memory stream lifetime is managed by Rust -} - // This is a helper to ensure that PHP is initialized and deinitialized at the // appropriate times. struct Sapi(Box); @@ -112,7 +65,9 @@ impl Sapi { .expect("Failed to build SAPI module"); sapi.ini_defaults = Some(sapi_cli_ini_defaults); + sapi.php_ini_path_override = std::ptr::null_mut(); sapi.php_ini_ignore_cwd = 1; + sapi.additional_functions = std::ptr::null(); // sapi.phpinfo_as_text = 1; let exe_loc = argv.get(0).expect("should have exe location"); @@ -191,6 +146,8 @@ pub enum EmbedException { Bailout, ResponseError, IoError(std::io::Error), + RelativizeError(StripPrefixError), + CanonicalizeError(std::io::Error), } impl std::fmt::Display for EmbedException { @@ -206,6 +163,8 @@ impl std::fmt::Display for EmbedException { EmbedException::Bailout => write!(f, "PHP bailout"), EmbedException::ResponseError => write!(f, "Error building response"), EmbedException::IoError(e) => write!(f, "IO error: {}", e), + EmbedException::RelativizeError(e) => write!(f, "Path relativization error: {}", e), + EmbedException::CanonicalizeError(e) => write!(f, "Path canonicalization error: {}", e), } } } @@ -213,8 +172,7 @@ impl std::fmt::Display for EmbedException { /// Embed a PHP script into a Rust application to handle HTTP requests. #[derive(Debug)] pub struct Embed { - code: String, - filename: Option, + docroot: PathBuf, } // An embed instance may be constructed on the main thread and then shared @@ -232,12 +190,8 @@ impl Embed { /// /// let embed = Embed::new("echo 'Hello, world!';", Some("example.php")); /// ``` - pub fn new(code: C, filename: Option) -> Self - where - C: Into, - F: Into, - { - Embed::new_with_argv::(code, filename, vec![]) + pub fn new>(docroot: C) -> Self { + Embed::new_with_argv::(docroot, vec![]) } /// Creates a new `Embed` instance with command-line arguments. @@ -250,12 +204,8 @@ impl Embed { /// let args = std::env::args(); /// let embed = Embed::new_with_args("echo $argv[1];", Some("example.php"), args); /// ``` - pub fn new_with_args(code: C, filename: Option, args: Args) -> Self - where - C: Into, - F: Into, - { - Embed::new_with_argv(code, filename, args.collect()) + pub fn new_with_args>(docroot: C, args: Args) -> Self { + Embed::new_with_argv(docroot, args.collect()) } /// Creates a new `Embed` instance with command-line arguments. @@ -280,18 +230,16 @@ impl Embed { /// # // TODO: Uncomment when argv gets passed through correctly. /// # // assert_eq!(response.body(), "Hello, world!"); /// ``` - pub fn new_with_argv(code: C, filename: Option, argv: Vec) -> Self + pub fn new_with_argv(docroot: C, argv: Vec) -> Self where - C: Into, - F: Into, + C: AsRef, S: AsRef + std::fmt::Debug, { SAPI_INIT.get_or_init(|| RwLock::new(Sapi::new(argv))); - Embed { - code: code.into(), - filename: filename.map(|v| v.into()), - } + let docroot = docroot.as_ref().canonicalize().expect("should exist"); + + Embed { docroot } } } @@ -325,32 +273,19 @@ impl Handler for Embed { // Initialize the SAPI module Sapi::startup().map_err(|_| EmbedException::SapiStartupError)?; + let url = request.url(); + // Get code and filename to execute - let code = cstr(self.code.clone())?; - let cwd = maybe_current_dir()?; - let request_uri = default_cstr( - "", - self - .filename - .clone() - .map(|v| PathBuf::new().join("/").join(v).display().to_string()), - )?; - let path_translated = default_cstr( - "", - self.filename.clone().map(|v| { - cwd - .join(".".to_string() + &v) - .canonicalize() - .unwrap_or(cwd.clone()) - .display() - .to_string() - }), + let request_uri = url.path(); + let path_translated = cstr( + translate_path(&self.docroot, request_uri)? + .display() + .to_string(), )?; + let request_uri = cstr(request_uri)?; // Extract request information let request_method = cstr(request.method())?; - - let url = request.url(); let query_string = cstr(url.query().unwrap_or(""))?; let headers = request.headers(); @@ -365,37 +300,20 @@ impl Handler for Embed { let mut file_handle = unsafe { use ext_php_rs::ffi::{_zend_file_handle__bindgen_ty_1, zend_file_handle, zend_stream}; - let mut mem_stream = memory_stream { - ptr: code, - len: self.code.len(), - available: self.code.len(), - }; - - let stream = zend_stream { - handle: &mut mem_stream as *mut _ as *mut c_void, - isatty: 0, - reader: Some(memory_stream_reader), - fsizer: Some(memory_stream_fsizer), - closer: Some(memory_stream_closer), - }; - let mut file_handle = zend_file_handle { - handle: _zend_file_handle__bindgen_ty_1 { stream }, + handle: _zend_file_handle__bindgen_ty_1 { + fp: std::ptr::null_mut(), + }, filename: std::ptr::null_mut(), opened_path: std::ptr::null_mut(), - type_: 2, // ZEND_HANDLE_STREAM - primary_script: true, + type_: 0, //ZEND_HANDLE_FP + primary_script: false, in_list: false, buf: std::ptr::null_mut(), len: 0, }; zend_stream_init_filename(&mut file_handle, path_translated); - file_handle.handle = _zend_file_handle__bindgen_ty_1 { stream }; - // file_handle.opened_path = file_handle.filename; - file_handle.opened_path = std::ptr::null_mut(); - file_handle.type_ = 2; // ZEND_HANDLE_STREAM - file_handle.primary_script = true; // TODO: Make a scope to do zend_destroy_file_handle at the end. @@ -439,12 +357,6 @@ impl Handler for Embed { // return Err(EmbedException::ExecuteError); } - // if unsafe { - // zend_eval_string_ex(code, std::ptr::null_mut(), filename, false) - // } != ZEND_RESULT_CODE_SUCCESS { - // return Err(EmbedException::ExecuteError); - // } - if let Some(err) = ExecutorGlobals::take_exception() { { let mut globals = SapiGlobals::get_mut(); @@ -465,7 +377,6 @@ impl Handler for Embed { Ok(()) }) .map_or_else(|_err| Err(EmbedException::Bailout), |res| res)?; - // .map_err(|_| EmbedException::Bailout)?; let (mimetype, http_response_code) = { let globals = SapiGlobals::get(); @@ -782,12 +693,25 @@ pub extern "C" fn sapi_module_read_cookies() -> *mut c_char { #[no_mangle] pub extern "C" fn sapi_module_register_server_variables(vars: *mut ext_php_rs::types::Zval) { - // println!("sapi_module_register_server_variables"); unsafe { - if let Some(php_import_environment_variables) = - ext_php_rs::ffi::php_import_environment_variables - { - php_import_environment_variables(vars); + // use ext_php_rs::ffi::php_import_environment_variables; + // if let Some(f) = php_import_environment_variables { + // f(vars); + // } + + let request = RequestContext::current() + .map(|ctx| ctx.request()) + .expect("should have request"); + + let headers = request.headers(); + + for (key, values) in headers.iter() { + let header = match values { + Header::Single(header) => header, + Header::Multiple(headers) => headers.first().expect("should have first header"), + }; + let cgi_key = format!("HTTP_{}", key.to_ascii_uppercase().replace("-", "_")); + php_register_variable(cstr(&cgi_key).unwrap(), cstr(header).unwrap(), vars); } let globals = SapiGlobals::get(); @@ -803,12 +727,38 @@ pub extern "C" fn sapi_module_register_server_variables(vars: *mut ext_php_rs::t c"".as_ptr() }; + // php_register_variable(cstr("PATH").unwrap(), cstr("/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin").unwrap(), vars); + // php_register_variable(cstr("SERVER_SIGNATURE").unwrap(), cstr(" + // Apache/2.4.62 (Debian) Server at localhost Port 8080 + + // ").unwrap(), vars); + php_register_variable( + cstr("REQUEST_SCHEME").unwrap(), + cstr(request.url().scheme()).unwrap(), + vars, + ); + php_register_variable(cstr("CONTEXT_PREFIX").unwrap(), cstr("").unwrap(), vars); + php_register_variable( + cstr("SERVER_ADMIN").unwrap(), + cstr("webmaster@localhost").unwrap(), + vars, + ); + php_register_variable( + cstr("GATEWAY_INTERFACE").unwrap(), + cstr("CGI/1.1").unwrap(), + vars, + ); + php_register_variable(cstr("PHP_SELF").unwrap(), script_name, vars); php_register_variable(cstr("SCRIPT_NAME").unwrap(), script_name, vars); - php_register_variable(cstr("REQUEST_URI").unwrap(), script_name, vars); php_register_variable(cstr("SCRIPT_FILENAME").unwrap(), script_filename, vars); php_register_variable(cstr("PATH_TRANSLATED").unwrap(), script_filename, vars); php_register_variable(cstr("DOCUMENT_ROOT").unwrap(), cwd_cstr, vars); + php_register_variable(cstr("CONTEXT_DOCUMENT_ROOT").unwrap(), cwd_cstr, vars); + + if !req_info.request_uri.is_null() { + php_register_variable(cstr("REQUEST_URI").unwrap(), req_info.request_uri, vars); + } php_register_variable( cstr("SERVER_PROTOCOL").unwrap(), @@ -828,7 +778,10 @@ pub extern "C" fn sapi_module_register_server_variables(vars: *mut ext_php_rs::t // TODO: This should pull from the _real_ headers php_register_variable(cstr("HTTP_HOST").unwrap(), c"localhost:3000".as_ptr(), vars); php_register_variable(cstr("SERVER_NAME").unwrap(), c"localhost".as_ptr(), vars); + php_register_variable(cstr("SERVER_ADDR").unwrap(), c"172.19.0.2".as_ptr(), vars); php_register_variable(cstr("SERVER_PORT").unwrap(), c"3000".as_ptr(), vars); + php_register_variable(cstr("REMOTE_ADDR").unwrap(), c"192.168.65.1".as_ptr(), vars); + php_register_variable(cstr("REMOTE_PORT").unwrap(), c"21845".as_ptr(), vars); if !req_info.request_method.is_null() { php_register_variable( @@ -839,7 +792,7 @@ pub extern "C" fn sapi_module_register_server_variables(vars: *mut ext_php_rs::t } if !req_info.cookie_data.is_null() { - php_register_variable(cstr("COOKIE").unwrap(), req_info.cookie_data, vars); + php_register_variable(cstr("HTTP_COOKIE").unwrap(), req_info.cookie_data, vars); } if !req_info.query_string.is_null() { @@ -930,3 +883,23 @@ fn maybe_current_dir() -> Result { .canonicalize() .map_err(EmbedException::IoError) } + +fn translate_path(docroot: D, request_uri: P) -> Result +where + D: AsRef, + P: AsRef, +{ + let docroot = docroot.as_ref().to_path_buf(); + let request_uri = request_uri.as_ref(); + let relative_uri = request_uri + .strip_prefix("/") + .map_err(EmbedException::RelativizeError)?; + + match docroot.join(relative_uri).join("index.php").canonicalize() { + Ok(path) => Ok(path), + Err(_) => docroot + .join(relative_uri) + .canonicalize() + .map_err(EmbedException::CanonicalizeError), + } +} diff --git a/crates/php/src/main.rs b/crates/php/src/main.rs index a28504ed..99aed0a6 100644 --- a/crates/php/src/main.rs +++ b/crates/php/src/main.rs @@ -1,20 +1,8 @@ use php::{Embed, Handler, Request}; pub fn main() { - let code = " - http_response_code(123); - header('Content-Type: text/plain'); - echo file_get_contents(\"php://input\"); - echo \"\n\"; - - $headers = apache_request_headers(); - - foreach ($headers as $header => $value) { - echo \"$header: $value\n\"; - } - "; - let filename = Some("test.php"); - let embed = Embed::new_with_args(code, filename, std::env::args()); + let docroot = std::env::current_dir().expect("should have current_dir"); + let embed = Embed::new_with_args(docroot, std::env::args()); let request = Request::builder() .method("POST") diff --git a/crates/php_node/src/runtime.rs b/crates/php_node/src/runtime.rs index 79a608af..24783eb1 100644 --- a/crates/php_node/src/runtime.rs +++ b/crates/php_node/src/runtime.rs @@ -13,10 +13,8 @@ use crate::{PhpRequest, PhpResponse}; pub struct PhpOptions { /// The command-line arguments for the PHP instance. pub argv: Option>, - /// The PHP code to embed. - pub code: String, - /// The filename for the PHP code. - pub file: Option, + /// The document root for the PHP instance. + pub docroot: String, } /// A PHP instance. @@ -54,15 +52,12 @@ impl PhpRuntime { /// ``` #[napi(constructor)] pub fn new(options: PhpOptions) -> Self { - let code = options.code.clone(); - let filename = options.file.clone(); + let docroot = options.docroot.clone(); let argv = options.argv.clone(); - // TODO: Need to figure out how to send an Embed across threads - // so we can reuse the same Embed instance for multiple requests. let embed = match argv { - Some(argv) => Embed::new_with_argv(code, filename, argv), - None => Embed::new(code, filename), + Some(argv) => Embed::new_with_argv(docroot, argv), + None => Embed::new(docroot), }; Self { diff --git a/index.d.ts b/index.d.ts index 74ca23a4..4b3a4395 100644 --- a/index.d.ts +++ b/index.d.ts @@ -39,10 +39,8 @@ export interface PhpResponseOptions { export interface PhpOptions { /** The command-line arguments for the PHP instance. */ argv?: Array - /** The PHP code to embed. */ - code: string - /** The filename for the PHP code. */ - file?: string + /** The document root for the PHP instance. */ + docroot: string } export type PhpHeaders = Headers /**