From 5840909e4cc0a144914f59c2e406ac2e5d891f49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 1 Jan 2025 21:54:29 +0100 Subject: [PATCH] Filesystem: Consistent method names, Write streams, WP_Uploaded_Directory_Tree_Filesystem --- .../Filesystem/WP_Abstract_Filesystem.php | 101 +++++++++- .../Filesystem/WP_Filesystem_Chroot.php | 89 +++++++++ .../Filesystem/WP_In_Memory_Filesystem.php | 57 +++++- .../Filesystem/WP_Local_Filesystem.php | 36 +++- .../Filesystem/WP_SQLite_Filesystem.php | 60 ++---- .../WP_Uploaded_Directory_Tree_Filesystem.php | 187 ++++++++++++++++++ src/WordPress/Zip/WP_Zip_Filesystem.php | 6 +- 7 files changed, 470 insertions(+), 66 deletions(-) create mode 100644 src/WordPress/Filesystem/WP_Filesystem_Chroot.php create mode 100644 src/WordPress/Filesystem/WP_Uploaded_Directory_Tree_Filesystem.php diff --git a/src/WordPress/Filesystem/WP_Abstract_Filesystem.php b/src/WordPress/Filesystem/WP_Abstract_Filesystem.php index 8fdc99f7..0dd5ed76 100644 --- a/src/WordPress/Filesystem/WP_Abstract_Filesystem.php +++ b/src/WordPress/Filesystem/WP_Abstract_Filesystem.php @@ -2,6 +2,8 @@ namespace WordPress\Filesystem; +use WordPress\ByteReader\WP_Byte_Reader; + /** * Abstract class for filesystem implementations. * @@ -40,16 +42,16 @@ abstract public function is_file($path); * * @example * - * $fs->open_file_stream($path); + * $fs->open_read_stream($path); * while($fs->next_file_chunk()) { * $chunk = $fs->get_file_chunk(); * // process $chunk * } - * $fs->close_file_stream(); + * $fs->close_read_stream(); * * @param string $path The path to the file. */ - abstract public function open_file_stream($path); + abstract public function open_read_stream($path); /** * Get the next chunk of a file. @@ -77,21 +79,102 @@ abstract public function get_streamed_file_length(); * * @return string|false The error message or false if no error occurred. */ - abstract public function get_error_message(); + abstract public function get_last_error(); /** * Close the file reader. */ - abstract public function close_file_stream(); + abstract public function close_read_stream(); // @TODO: Support for write methods, perhaps in a separate interface? - // abstract public function append_to($path, $data); - // abstract public function overwrite($path, $data); + // abstract public function open_write_stream($path); + // abstract public function append_bytes($data); + // abstract public function close_write_stream(); // abstract public function rename($old_path, $new_path); // abstract public function mkdir($path); // abstract public function rm($path); // abstract public function rmdir($path, $options = []); + public function put_contents($path, $data, $options = []) { + if(!$this->open_write_stream($path)) { + return false; + } + if(is_string($data)) { + if(!$this->append_bytes($data)) { + return false; + } + } else if(is_object($data) && $data instanceof WP_Byte_Reader) { + while($data->next_chunk()) { + if(!$this->append_bytes($data->get_chunk())) { + return false; + } + } + } else { + _doing_it_wrong(__METHOD__, 'Invalid $data argument provided. Expected a string or a WP_Byte_Reader instance. Received: ' . gettype($data), '1.0.0'); + return false; + } + if(!$this->close_write_stream()) { + return false; + } + return true; + } + + public function copy($from_path, $to_path, $options = []) { + $to_fs = $options['to_fs'] ?? $this; + $recursive = $options['recursive'] ?? false; + if($this->is_dir($from_path) && !$recursive) { + _doing_it_wrong( __METHOD__, 'Cannot copy a directory without recursive => true option', '1.0.0' ); + return false; + } + + $stack = [[$from_path, $to_path]]; + while(!empty($stack)) { + [$from_path, $to_path] = array_shift($stack); + if($this->is_dir($from_path)) { + if(!$to_fs->is_dir($to_path)) { + $to_fs->mkdir($to_path); + } + foreach($this->ls($from_path) as $child) { + $stack[] = [ + wp_join_paths($from_path, $child), + wp_join_paths($to_path, $child) + ]; + } + } else { + if(false === $this->open_read_stream($from_path)) { + throw new \Exception('Failed to open read stream for ' . $from_path); + return false; + } + if(false === $to_fs->open_write_stream($to_path)) { + throw new \Exception('Failed to open write stream for ' . $to_path); + return false; + } + $chunks_written = 0; + while($this->next_file_chunk()) { + if(false === $to_fs->append_bytes($this->get_file_chunk())) { + throw new \Exception('Failed to append bytes to ' . $to_path); + return false; + } + $chunks_written++; + } + if($chunks_written === 0) { + // Make sure the file receives at least one chunk + // so we can be sure it gets created in case the + // destination filesystem is lazy. + $to_fs->append_bytes(''); + } + if(false === $this->close_read_stream()) { + throw new \Exception('Failed to close read stream for ' . $from_path); + return false; + } + if(false === $to_fs->close_write_stream()) { + throw new \Exception('Failed to close write stream for ' . $to_path); + return false; + } + } + } + return true; + } /** * Buffers the entire contents of a file into a string @@ -101,7 +184,7 @@ abstract public function close_file_stream(); * @return string|false The contents of the file or false if the file does not exist. */ public function get_contents($path) { - $this->open_file_stream($path); + $this->open_read_stream($path); $body = ''; while($this->next_file_chunk()) { $chunk = $this->get_file_chunk(); @@ -110,7 +193,7 @@ public function get_contents($path) { } $body .= $chunk; } - $this->close_file_stream(); + $this->close_read_stream(); return $body; } diff --git a/src/WordPress/Filesystem/WP_Filesystem_Chroot.php b/src/WordPress/Filesystem/WP_Filesystem_Chroot.php new file mode 100644 index 00000000..1ed8656c --- /dev/null +++ b/src/WordPress/Filesystem/WP_Filesystem_Chroot.php @@ -0,0 +1,89 @@ +fs = $fs; + $this->root = rtrim($root, '/'); + } + + public function exists($path) { + return $this->fs->exists($this->to_chrooted_path($path)); + } + + public function is_file($path) { + return $this->fs->is_file($this->to_chrooted_path($path)); + } + + public function is_dir($path) { + return $this->fs->is_dir($this->to_chrooted_path($path)); + } + + public function mkdir($path, $options = []) { + return $this->fs->mkdir($this->to_chrooted_path($path), $options); + } + + public function rmdir($path, $options = []) { + return $this->fs->rmdir($this->to_chrooted_path($path), $options); + } + + public function ls($path = '/') { + return $this->fs->ls($this->to_chrooted_path($path)); + } + + public function open_read_stream($path) { + return $this->fs->open_read_stream($this->to_chrooted_path($path)); + } + + public function next_file_chunk() { + return $this->fs->next_file_chunk(); + } + + public function get_file_chunk() { + return $this->fs->get_file_chunk(); + } + + public function get_streamed_file_length() { + return $this->fs->get_streamed_file_length(); + } + + public function get_last_error() { + return $this->fs->get_last_error(); + } + + public function close_read_stream() { + return $this->fs->close_read_stream(); + } + + public function get_contents($path) { + return $this->fs->get_contents($this->to_chrooted_path($path)); + } + + public function put_contents($path, $contents, $options = []) { + return $this->fs->put_contents($this->to_chrooted_path($path), $contents, $options); + } + + private function to_chrooted_path($path) { + return wp_join_paths($this->root, $path); + } + +} \ No newline at end of file diff --git a/src/WordPress/Filesystem/WP_In_Memory_Filesystem.php b/src/WordPress/Filesystem/WP_In_Memory_Filesystem.php index 9e60e1b3..000cc202 100644 --- a/src/WordPress/Filesystem/WP_In_Memory_Filesystem.php +++ b/src/WordPress/Filesystem/WP_In_Memory_Filesystem.php @@ -9,6 +9,7 @@ class WP_In_Memory_Filesystem extends WP_Abstract_Filesystem { private $files = []; private $last_file_reader = null; + private $write_stream = null; public function __construct() { $this->files['/'] = [ @@ -18,7 +19,7 @@ public function __construct() { } public function ls($parent = '/') { - $parent = rtrim($parent, '/'); + $parent = wp_canonicalize_path($parent); if (!isset($this->files[$parent]) || $this->files[$parent]['type'] !== 'dir') { return false; } @@ -26,19 +27,22 @@ public function ls($parent = '/') { } public function is_dir($path) { + $path = wp_canonicalize_path($path); return isset($this->files[$path]) && $this->files[$path]['type'] === 'dir'; } public function is_file($path) { + $path = wp_canonicalize_path($path); return isset($this->files[$path]) && $this->files[$path]['type'] === 'file'; } public function exists($path) { + $path = wp_canonicalize_path($path); return isset($this->files[$path]); } private function get_parent_dir($path) { - $path = rtrim($path, '/'); + $path = wp_canonicalize_path($path); $parent = dirname($path); if($parent === '.') { return '/'; @@ -46,7 +50,8 @@ private function get_parent_dir($path) { return $parent; } - public function open_file_stream($path) { + public function open_read_stream($path) { + $path = wp_canonicalize_path($path); if($this->last_file_reader) { $this->last_file_reader->close(); } @@ -78,14 +83,14 @@ public function get_streamed_file_length() { return $this->last_file_reader->length(); } - public function get_error_message() { + public function get_last_error() { if(!$this->last_file_reader) { return false; } return $this->last_file_reader->get_last_error(); } - public function close_file_stream() { + public function close_read_stream() { if(!$this->last_file_reader) { return false; } @@ -95,6 +100,8 @@ public function close_file_stream() { } public function rename($old_path, $new_path) { + $old_path = wp_canonicalize_path($old_path); + $new_path = wp_canonicalize_path($new_path); if (!$this->exists($old_path)) { return false; } @@ -110,6 +117,7 @@ public function rename($old_path, $new_path) { } public function mkdir($path) { + $path = wp_canonicalize_path($path); if ($this->exists($path)) { return false; } @@ -128,6 +136,7 @@ public function mkdir($path) { } public function rm($path) { + $path = wp_canonicalize_path($path); if (!$this->is_file($path)) { return false; } @@ -139,6 +148,7 @@ public function rm($path) { } public function rmdir($path, $options = []) { + $path = wp_canonicalize_path($path); $recursive = $options['recursive'] ?? false; if (!$this->is_dir($path)) { return false; @@ -161,7 +171,8 @@ public function rmdir($path, $options = []) { return true; } - public function put_contents($path, $data) { + public function put_contents($path, $data, $options = []) { + $path = wp_canonicalize_path($path); $parent = $this->get_parent_dir($path); if (!$this->is_dir($parent)) { return false; @@ -175,4 +186,38 @@ public function put_contents($path, $data) { return true; } + public function open_write_stream($path) { + if($this->write_stream) { + _doing_it_wrong(__METHOD__, 'Cannot open a new write stream while another write stream is open.', '1.0.0'); + return false; + } + $this->write_stream = [ + 'path' => $path, + 'contents' => '', + ]; + return true; + } + + public function append_bytes($data) { + if(!$this->write_stream) { + _doing_it_wrong(__METHOD__, 'Cannot append bytes to a write stream that is not open.', '1.0.0'); + return false; + } + $path = $this->write_stream['path']; + if(!isset($this->files[$path])) { + $this->put_contents($path, ''); + } + $this->files[$path]['contents'] .= $data; + return true; + } + + public function close_write_stream() { + if(!$this->write_stream) { + _doing_it_wrong(__METHOD__, 'Cannot close a write stream that is not open.', '1.0.0'); + return false; + } + $this->write_stream = null; + return true; + } + } diff --git a/src/WordPress/Filesystem/WP_Local_Filesystem.php b/src/WordPress/Filesystem/WP_Local_Filesystem.php index 4294647b..3b0c936e 100644 --- a/src/WordPress/Filesystem/WP_Local_Filesystem.php +++ b/src/WordPress/Filesystem/WP_Local_Filesystem.php @@ -8,6 +8,7 @@ class WP_Local_Filesystem extends WP_Abstract_Filesystem { private $root = '/'; + private $write_stream = null; public function __construct( $root = '/' ) { $this->root = rtrim($root, '/'); @@ -56,7 +57,7 @@ public function exists($path) { // but that could suggest that the reader is a separate object // and that we can have multiple readers open at the same time. private $last_file_reader = null; - public function open_file_stream($path) { + public function open_read_stream($path) { if($this->last_file_reader) { $this->last_file_reader->close(); } @@ -77,11 +78,11 @@ public function get_streamed_file_length() { return $this->last_file_reader->length(); } - public function get_error_message() { + public function get_last_error() { return $this->last_file_reader->get_last_error(); } - public function close_file_stream() { + public function close_read_stream() { if($this->last_file_reader) { $this->last_file_reader->close(); $this->last_file_reader = null; @@ -123,13 +124,40 @@ public function rmdir($path, $options = []) { ); } - public function put_contents($path, $data) { + public function put_contents($path, $data, $options = []) { return false !== file_put_contents( $this->get_full_path($path), $data ); } + public function open_write_stream($path) { + if($this->write_stream) { + _doing_it_wrong(__METHOD__, 'Cannot open a new write stream while another write stream is open.', '1.0.0'); + return false; + } + $this->write_stream = fopen($this->get_full_path($path), 'wb'); + return true; + } + + public function append_bytes($data) { + if(!$this->write_stream) { + _doing_it_wrong(__METHOD__, 'Cannot append bytes to a write stream that is not open.', '1.0.0'); + return false; + } + return fwrite($this->write_stream, $data); + } + + public function close_write_stream() { + if(!$this->write_stream) { + _doing_it_wrong(__METHOD__, 'Cannot close a write stream that is not open.', '1.0.0'); + return false; + } + fclose($this->write_stream); + $this->write_stream = null; + return true; + } + private function get_full_path($relative_path) { return $this->root . '/' . ltrim($relative_path, '/'); } diff --git a/src/WordPress/Filesystem/WP_SQLite_Filesystem.php b/src/WordPress/Filesystem/WP_SQLite_Filesystem.php index 4ec7ff6c..425d5449 100644 --- a/src/WordPress/Filesystem/WP_SQLite_Filesystem.php +++ b/src/WordPress/Filesystem/WP_SQLite_Filesystem.php @@ -33,7 +33,7 @@ public function __construct($db_path = ':memory:') { } public function ls($parent = '/') { - $parent = $this->normalize_path($parent); + $parent = wp_canonicalize_path($parent); $parent = rtrim($parent, '/'); $stmt = $this->db->prepare(' SELECT name FROM directory_entries @@ -50,7 +50,7 @@ public function ls($parent = '/') { } public function is_dir($path) { - $path = $this->normalize_path($path); + $path = wp_canonicalize_path($path); $stmt = $this->db->prepare(' SELECT type FROM files WHERE path = ? AND type = ? @@ -62,7 +62,7 @@ public function is_dir($path) { } public function is_file($path) { - $path = $this->normalize_path($path); + $path = wp_canonicalize_path($path); $stmt = $this->db->prepare(' SELECT type FROM files WHERE path = ? AND type = ? @@ -74,7 +74,7 @@ public function is_file($path) { } public function exists($path) { - $path = $this->normalize_path($path); + $path = wp_canonicalize_path($path); $stmt = $this->db->prepare(' SELECT 1 FROM files WHERE path = ? @@ -84,36 +84,8 @@ public function exists($path) { return $result->fetchArray() !== false; } - private function normalize_path($path) { - // Convert to absolute path - if (!str_starts_with($path, '/')) { - $path = '/' . $path; - } - - // Resolve . and .. - $parts = explode('/', $path); - $normalized = []; - foreach ($parts as $part) { - if ($part === '.' || $part === '') { - continue; - } - if ($part === '..') { - array_pop($normalized); - continue; - } - $normalized[] = $part; - } - - // Reconstruct path - $result = '/' . implode('/', $normalized); - if($result === '/.') { - $result = '/'; - } - return $result === '' ? '/' : $result; - } - private function get_parent_dir($path) { - $path = $this->normalize_path($path); + $path = wp_canonicalize_path($path); $path = rtrim($path, '/'); $parent = dirname($path); if($parent === '.') { @@ -122,8 +94,8 @@ private function get_parent_dir($path) { return $parent; } - public function open_file_stream($path) { - $path = $this->normalize_path($path); + public function open_read_stream($path) { + $path = wp_canonicalize_path($path); if($this->last_file_reader) { $this->last_file_reader->close(); } @@ -159,14 +131,14 @@ public function get_streamed_file_length() { return $this->last_file_reader->length(); } - public function get_error_message() { + public function get_last_error() { if(!$this->last_file_reader) { return false; } return $this->last_file_reader->get_last_error(); } - public function close_file_stream() { + public function close_read_stream() { if(!$this->last_file_reader) { return false; } @@ -176,8 +148,8 @@ public function close_file_stream() { } public function rename($old_path, $new_path) { - $old_path = $this->normalize_path($old_path); - $new_path = $this->normalize_path($new_path); + $old_path = wp_canonicalize_path($old_path); + $new_path = wp_canonicalize_path($new_path); if (!$this->exists($old_path)) { return false; } @@ -222,7 +194,7 @@ public function rename($old_path, $new_path) { } public function mkdir($path) { - $path = $this->normalize_path($path); + $path = wp_canonicalize_path($path); if ($this->exists($path)) { return false; } @@ -259,7 +231,7 @@ public function mkdir($path) { } public function rm($path) { - $path = $this->normalize_path($path); + $path = wp_canonicalize_path($path); if (!$this->is_file($path)) { return false; } @@ -289,7 +261,7 @@ public function rm($path) { } public function rmdir($path, $options = []) { - $path = $this->normalize_path($path); + $path = wp_canonicalize_path($path); $recursive = $options['recursive'] ?? false; if (!$this->is_dir($path)) { return false; @@ -330,8 +302,8 @@ public function rmdir($path, $options = []) { } } - public function put_contents($path, $data) { - $path = $this->normalize_path($path); + public function put_contents($path, $data, $options = []) { + $path = wp_canonicalize_path($path); $parent = $this->get_parent_dir($path); if (!$this->is_dir($parent)) { return false; diff --git a/src/WordPress/Filesystem/WP_Uploaded_Directory_Tree_Filesystem.php b/src/WordPress/Filesystem/WP_Uploaded_Directory_Tree_Filesystem.php new file mode 100644 index 00000000..55c54a31 --- /dev/null +++ b/src/WordPress/Filesystem/WP_Uploaded_Directory_Tree_Filesystem.php @@ -0,0 +1,187 @@ +get_param($tree_parameter_name); + if (!$tree_json) { + return new WP_Error('invalid_tree', 'Invalid file tree structure'); + } + + $tree = json_decode($tree_json, true); + if (!$tree) { + return new WP_Error('invalid_json', 'Invalid JSON structure'); + } + + if($tree['type'] !== 'folder' || $tree['name'] !== '') { + $tree = [ + 'type' => 'folder', + 'name' => '', + 'children' => $tree + ]; + } + + return new self($request, $tree); + } + + /** + * @param array $tree The directory tree structure + * @param WP_REST_Request $request The request object containing uploaded files + */ + private function __construct($request, $tree) { + $this->request = $request; + $this->tree = $tree; + } + + public function ls($parent = '/') { + $parent = wp_canonicalize_path($parent); + $node = $this->find_node($parent); + if (!$node || $node['type'] !== 'folder') { + return []; + } + return array_map( + function($child) { return $child['name']; }, + $node['children'] ?? [] + ); + } + + public function is_dir($path) { + $path = wp_canonicalize_path($path); + $node = $this->find_node($path); + return $node && $node['type'] === 'folder'; + } + + public function is_file($path) { + $path = wp_canonicalize_path($path); + $node = $this->find_node($path); + return $node && $node['type'] === 'file'; + } + + public function open_read_stream($path) { + $path = wp_canonicalize_path($path); + $node = $this->find_node($path); + if (!$node || $node['type'] !== 'file') { + return false; + } + + // Handle file content from request + if (!isset($node['content']) || !is_string($node['content'])) { + $node['content'] = ''; + } + + if(strpos($node['content'], '@file:') === 0) { + $file_key = substr($node['content'], 6); + $uploaded_file = $this->request->get_file_params()[$file_key] ?? null; + + if (! $uploaded_file || $uploaded_file['error'] !== UPLOAD_ERR_OK) { + return false; + } + + $this->file_reader = WP_File_Reader::create($uploaded_file['tmp_name']); + return true; + } + + // Handle inline content + $this->file_reader = WP_String_Reader::create($node['content']); + return true; + } + + public function next_file_chunk() { + if ($this->file_reader === null) { + return false; + } + + return $this->file_reader->next_bytes(); + } + + public function get_file_chunk() { + if ($this->file_reader === null) { + return false; + } + + return $this->file_reader->get_bytes(); + } + + public function get_streamed_file_length() { + if ($this->file_reader === null) { + return false; + } + + if ($this->file_reader instanceof WP_File_Reader) { + return $this->file_reader->length(); + } + + return strlen($this->file_reader); + } + + public function get_last_error() { + if ($this->file_reader instanceof WP_File_Reader) { + return $this->file_reader->get_last_error(); + } + return false; + } + + public function close_read_stream() { + if ($this->file_reader instanceof WP_File_Reader) { + $this->file_reader->close(); + } + $this->file_reader = null; + } + + /** + * Find a node in the tree by its path + * + * @param string $path The path to find + * @return array|null The node if found, null otherwise + */ + private function find_node($path) { + $path = trim($path, '/'); + if($path === '') { + return $this->tree; + } + + $parts = explode('/', $path); + $current = $this->tree; + foreach ($parts as $part) { + $found = false; + foreach ($current['children'] as $node) { + if ($node['name'] === $part) { + $found = true; + $current = $node; + break; + } + } + if (!$found) { + return null; + } + } + + return $current; + } +} diff --git a/src/WordPress/Zip/WP_Zip_Filesystem.php b/src/WordPress/Zip/WP_Zip_Filesystem.php index 36a226f6..6fb0e2b1 100644 --- a/src/WordPress/Zip/WP_Zip_Filesystem.php +++ b/src/WordPress/Zip/WP_Zip_Filesystem.php @@ -105,7 +105,7 @@ public function is_file($path) { return isset($this->central_directory[$path]) && self::TYPE_FILE === $this->central_directory[$path]['type']; } - public function open_file_stream($path) { + public function open_read_stream($path) { $this->opened_file_finished = false; $this->file_chunk = null; if($this->state === self::STATE_ERROR) { @@ -164,7 +164,7 @@ public function get_file_chunk(): string { return $this->file_chunk ?? ''; } - public function get_error_message() { + public function get_last_error() { return $this->error_message; } @@ -270,7 +270,7 @@ private function collect_central_directory_end_header() { return true; } - public function close_file_stream() { + public function close_read_stream() { return true; }