From 49abc3ba8d6099d8a5c398faf6cf03c92750bfc0 Mon Sep 17 00:00:00 2001 From: DavidCamejo Date: Thu, 6 Nov 2025 01:09:25 -0400 Subject: [PATCH] 2.0.0 --- .gitignore | 3 +- src/Filesystem/Adapters/S3Adapter.php | 46 ++-- src/Filesystem/FilesystemFactory.php | 43 ++-- src/Media/ImageEditorImagick.php | 232 +++++++++++++------ src/Media/MediaHooks.php | 145 +++++++++--- src/StreamWrapper/FlysystemStreamWrapper.php | 217 +++++++++++------ 6 files changed, 475 insertions(+), 211 deletions(-) diff --git a/.gitignore b/.gitignore index 5a795cb..9670c64 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ -/node_modules/ -.idea/ +/vendor/ *.log *.txt *.lock diff --git a/src/Filesystem/Adapters/S3Adapter.php b/src/Filesystem/Adapters/S3Adapter.php index 12f5bf6..16a939a 100644 --- a/src/Filesystem/Adapters/S3Adapter.php +++ b/src/Filesystem/Adapters/S3Adapter.php @@ -49,32 +49,44 @@ class S3Adapter implements AdapterInterface public function publicBaseUrl(array $settings): string { - if (! empty($settings['cdn_url'])) { - return rtrim($settings['cdn_url'], '/'); + $cdn = $settings['cdn_base_url'] ?? null; + if ($cdn) { + return rtrim($cdn, '/'); } - $bucket = $settings['bucket'] ?? ''; - $prefix = isset($settings['prefix']) ? trim($settings['prefix'], '/') : ''; - $prefix = $prefix === '' ? '' : '/' . $prefix; + $bucket = $settings['bucket'] ?? ''; + $endpoint = $settings['endpoint'] ?? null; + $region = $settings['region'] ?? 'us-east-1'; + $usePathStyle = (bool) ($settings['use_path_style_endpoint'] ?? false); + $prefix = trim($settings['prefix'] ?? '', '/'); - if (! empty($settings['endpoint'])) { - $endpoint = trim($settings['endpoint']); - if (! preg_match('#^https?://#i', $endpoint)) { - $endpoint = 'https://' . $endpoint; - } + $normalizedUrl = null; + if ($endpoint) { $endpoint = rtrim($endpoint, '/'); + $parts = parse_url($endpoint); - // Cuando se usa endpoint propio forzamos path-style (+ bucket en la ruta) - return $endpoint . '/' . $bucket . $prefix; + if (! $parts || empty($parts['host'])) { + $normalizedUrl = sprintf('%s/%s', $endpoint, $bucket); + } elseif ($usePathStyle) { + $path = isset($parts['path']) ? rtrim($parts['path'], '/') : ''; + $scheme = $parts['scheme'] ?? 'https'; + $port = isset($parts['port']) ? ':' . $parts['port'] : ''; + + $normalizedUrl = sprintf('%s://%s%s%s/%s', $scheme, $parts['host'], $port, $path, $bucket); + } else { + $scheme = $parts['scheme'] ?? 'https'; + $port = isset($parts['port']) ? ':' . $parts['port'] : ''; + $path = isset($parts['path']) ? rtrim($parts['path'], '/') : ''; + + $normalizedUrl = sprintf('%s://%s.%s%s%s', $scheme, $bucket, $parts['host'], $port, $path); + } } - $region = $settings['region'] ?? 'us-east-1'; - - if ($region === 'us-east-1') { - return "https://{$bucket}.s3.amazonaws.com{$prefix}"; + if (! $normalizedUrl) { + $normalizedUrl = sprintf('https://%s.s3.%s.amazonaws.com', $bucket, $region); } - return "https://{$bucket}.s3.{$region}.amazonaws.com{$prefix}"; + return $prefix ? $normalizedUrl . '/' . $prefix : $normalizedUrl; } } diff --git a/src/Filesystem/FilesystemFactory.php b/src/Filesystem/FilesystemFactory.php index fa9ab47..f010f44 100644 --- a/src/Filesystem/FilesystemFactory.php +++ b/src/Filesystem/FilesystemFactory.php @@ -1,4 +1,6 @@ settings['adapter'] ?? 'local'; - $config = $this->settings['adapters'][$adapterKey] ?? []; + $config = $this->settings['adapters'][$adapterKey] ?? []; $adapter = $this->resolveAdapter($adapterKey); - if (is_wp_error($adapter)) { + if ($adapter instanceof WP_Error) { return $adapter; } $validation = $adapter->validate($config); - if (is_wp_error($validation)) { + if ($validation instanceof WP_Error) { return $validation; } $flyAdapter = $adapter->create($config); - if (is_wp_error($flyAdapter)) { + if ($flyAdapter instanceof WP_Error) { return $flyAdapter; } @@ -50,39 +54,46 @@ class FilesystemFactory { $adapter = $this->resolveAdapter($adapterKey); - if (is_wp_error($adapter)) { + if ($adapter instanceof WP_Error) { return content_url('/uploads'); } - return $adapter->publicBaseUrl($settings); + $baseUrl = $adapter->publicBaseUrl($settings); + + return untrailingslashit($baseUrl); } private function resolveAdapter(string $adapterKey): AdapterInterface|WP_Error { return match ($adapterKey) { - 's3' => new S3Adapter(), - 'sftp' => new SftpAdapter(), - 'gcs' => new GoogleCloudAdapter(), - 'azure' => new AzureBlobAdapter(), - 'webdav' => new WebdavAdapter(), + 's3' => new S3Adapter(), + 'sftp' => new SftpAdapter(), + 'gcs' => new GoogleCloudAdapter(), + 'azure' => new AzureBlobAdapter(), + 'webdav' => new WebdavAdapter(), 'googledrive' => new GoogleDriveAdapter(), // stub (dev) - 'onedrive' => new OneDriveAdapter(), // stub (dev) - 'dropbox' => new DropboxAdapter(), // stub (dev) - default => new class implements AdapterInterface { + 'onedrive' => new OneDriveAdapter(), // stub (dev) + 'dropbox' => new DropboxAdapter(), // stub (dev) + default => new class implements AdapterInterface { public function create(array $settings) { - return new \League\Flysystem\Local\LocalFilesystemAdapter(WP_CONTENT_DIR . '/flysystem-uploads'); + $root = WP_CONTENT_DIR . '/flysystem-uploads'; + + return new LocalFilesystemAdapter($root); } + public function publicBaseUrl(array $settings): string { return content_url('/flysystem-uploads'); } + public function validate(array $settings) { wp_mkdir_p(WP_CONTENT_DIR . '/flysystem-uploads'); + return true; } - } + }, }; } } diff --git a/src/Media/ImageEditorImagick.php b/src/Media/ImageEditorImagick.php index b5c849b..fa77552 100644 --- a/src/Media/ImageEditorImagick.php +++ b/src/Media/ImageEditorImagick.php @@ -3,135 +3,227 @@ declare(strict_types=1); namespace FlysystemOffload\Media; +use FlysystemOffload\Helpers\PathHelper; +use League\Flysystem\FilesystemOperator; use WP_Error; -use WP_Image_Editor_Imagick; -class ImageEditorImagick extends WP_Image_Editor_Imagick +if (! defined('ABSPATH')) { + exit; +} + +if (! class_exists(\WP_Image_Editor::class)) { + require_once ABSPATH . WPINC . '/class-wp-image-editor.php'; +} + +if (! class_exists(\WP_Image_Editor_Imagick::class) && file_exists(ABSPATH . WPINC . '/class-wp-image-editor-imagick.php')) { + require_once ABSPATH . WPINC . '/class-wp-image-editor-imagick.php'; +} + +if (! class_exists(\WP_Image_Editor_Imagick::class)) { + // Si la clase base no está disponible (p. ej. Imagick deshabilitado), + // salimos silenciosamente para permitir que WordPress siga usando GD. + return; +} + +class ImageEditorImagick extends \WP_Image_Editor_Imagick { - protected ?string $remoteFilename = null; - protected array $tempFiles = []; + protected static ?FilesystemOperator $filesystem = null; + protected ?string $remotePath = null; + protected ?string $localPath = null; + + public static function bootWithFilesystem(?FilesystemOperator $filesystem): void + { + self::$filesystem = $filesystem; + } public function load() { - if ($this->image instanceof \Imagick) { - return true; + if ($this->isFlyPath($this->file) && self::$filesystem) { + $this->remotePath = PathHelper::stripProtocol($this->file); + $temp = $this->downloadToTemp($this->remotePath); + + if (is_wp_error($temp)) { + return $temp; + } + + $this->localPath = $temp; + $this->file = $temp; } - if (empty($this->file)) { - return new WP_Error( - 'flysystem_offload_missing_file', - __('Archivo no definido.', 'flysystem-offload') - ); + return parent::load(); + } + + public function save($filename = null, $mime_type = null) + { + $result = parent::save($filename, $mime_type); + + if (is_wp_error($result) || ! $this->remotePath || ! self::$filesystem || empty($result['path'])) { + return $result; } - if (! $this->isFlysystemPath($this->file)) { - return parent::load(); + $remote = $this->determineRemotePath($result['path']); + $sync = $this->pushToRemote($result['path'], $remote); + + if (is_wp_error($sync)) { + return $sync; } - $localPath = $this->mirrorToLocal($this->file); - - if (is_wp_error($localPath)) { - return $localPath; + $result['path'] = 'fly://' . $remote; + if (isset($result['file'])) { + $result['file'] = basename($remote); } - $this->remoteFilename = $this->file; - $this->file = $localPath; - - $result = parent::load(); - - $this->file = $this->remoteFilename; - return $result; } - protected function _save($image, $filename = null, $mime_type = null) + public function multi_resize($sizes) { - [$filename, $extension, $mime_type] = $this->get_output_format($filename, $mime_type); + $results = parent::multi_resize($sizes); - if (! $filename) { - $filename = $this->generate_filename(null, null, $extension); + if (! $this->remotePath || ! self::$filesystem) { + return $results; } - $isRemote = $this->isFlysystemPath($filename); - $tempTarget = $isRemote ? $this->createTempFile($filename) : false; - - $result = parent::_save($image, $tempTarget ?: $filename, $mime_type); - - if (is_wp_error($result)) { - if ($tempTarget) { - @unlink($tempTarget); + foreach ($results as &$result) { + if (empty($result['path'])) { + continue; } - return $result; - } + $remote = $this->determineRemotePath($result['path']); + $sync = $this->pushToRemote($result['path'], $remote); - if ($tempTarget) { - $copy = copy($result['path'], $filename); - - @unlink($result['path']); - @unlink($tempTarget); - - if (! $copy) { - return new WP_Error( - 'flysystem_offload_copy_failed', - __('No se pudo copiar la imagen procesada al almacenamiento remoto.', 'flysystem-offload') - ); + if (is_wp_error($sync)) { + $result['error'] = $sync->get_error_message(); + continue; } - $result['path'] = $filename; - $result['file'] = wp_basename($filename); + $result['path'] = 'fly://' . $remote; + if (isset($result['file'])) { + $result['file'] = basename($remote); + } + } + unset($result); + + return $results; + } + + public function stream($mime_type = null) + { + if ($this->remotePath && $this->localPath) { + $this->file = $this->localPath; } - return $result; + return parent::stream($mime_type); } public function __destruct() { - foreach ($this->tempFiles as $temp) { - @unlink($temp); + if ($this->localPath && file_exists($this->localPath)) { + @unlink($this->localPath); } parent::__destruct(); } - protected function mirrorToLocal(string $remotePath) + protected function pushToRemote(string $localFile, string $remotePath) { - $tempFile = $this->createTempFile($remotePath); + $stream = @fopen($localFile, 'rb'); - if (! $tempFile) { + if (! $stream) { return new WP_Error( - 'flysystem_offload_temp_missing', - __('No se pudo crear un archivo temporal para la imagen remota.', 'flysystem-offload') + 'flysystem_offload_upload_fail', + __('No se pudo leer el archivo procesado para subirlo al almacenamiento remoto.', 'flysystem-offload') ); } - if (! copy($remotePath, $tempFile)) { - @unlink($tempFile); + try { + self::$filesystem->writeStream($remotePath, $stream); + } catch (\Throwable $e) { + if (is_resource($stream)) { + fclose($stream); + } return new WP_Error( - 'flysystem_offload_remote_copy_failed', - __('No se pudo copiar la imagen desde el almacenamiento remoto.', 'flysystem-offload') + 'flysystem_offload_upload_fail', + sprintf( + __('Fallo al subir %s al almacenamiento remoto: %s', 'flysystem-offload'), + $remotePath, + $e->getMessage() + ) ); } - $this->tempFiles[] = $tempFile; + if (is_resource($stream)) { + fclose($stream); + } - return $tempFile; + @unlink($localFile); + + return true; } - protected function createTempFile(string $context) + protected function downloadToTemp(string $remotePath) { if (! function_exists('wp_tempnam')) { require_once ABSPATH . 'wp-admin/includes/file.php'; } - $tempFile = wp_tempnam(wp_basename($context)); + $temp = wp_tempnam(basename($remotePath)); - return $tempFile ?: false; + if (! $temp) { + return new WP_Error( + 'flysystem_offload_temp_fail', + __('No se pudo crear un archivo temporal para procesar la imagen.', 'flysystem-offload') + ); + } + + try { + $source = self::$filesystem->readStream($remotePath); + if (! is_resource($source)) { + throw new \RuntimeException('No se pudo abrir el stream remoto.'); + } + + $target = fopen($temp, 'wb'); + if (! $target) { + throw new \RuntimeException('No se pudo abrir el archivo temporal.'); + } + + stream_copy_to_stream($source, $target); + + fclose($source); + fclose($target); + } catch (\Throwable $e) { + if (isset($source) && is_resource($source)) { + fclose($source); + } + if (isset($target) && is_resource($target)) { + fclose($target); + } + @unlink($temp); + + return new WP_Error( + 'flysystem_offload_download_fail', + sprintf( + __('Fallo al descargar "%s" del almacenamiento remoto: %s', 'flysystem-offload'), + $remotePath, + $e->getMessage() + ) + ); + } + + return $temp; } - protected function isFlysystemPath(string $path): bool + protected function determineRemotePath(string $localSavedPath): string { - return strpos($path, 'fly://') === 0; + $remoteDir = $this->remotePath ? trim(dirname($this->remotePath), './') : ''; + $basename = basename($localSavedPath); + + return ltrim(($remoteDir ? $remoteDir . '/' : '') . $basename, '/'); + } + + protected function isFlyPath(string $path): bool + { + return strncmp($path, 'fly://', 6) === 0; } } diff --git a/src/Media/MediaHooks.php b/src/Media/MediaHooks.php index d10e2be..168557d 100644 --- a/src/Media/MediaHooks.php +++ b/src/Media/MediaHooks.php @@ -10,6 +10,8 @@ use WP_Error; class MediaHooks { + private const CUSTOM_IMAGE_EDITOR = 'FlysystemOffload\\Media\\ImageEditorImagick'; + private ?FilesystemOperator $filesystem = null; private bool $registered = false; private bool $metadataMirrorInProgress = false; @@ -17,6 +19,10 @@ class MediaHooks public function setFilesystem(?FilesystemOperator $filesystem): void { $this->filesystem = $filesystem; + + if (class_exists(self::CUSTOM_IMAGE_EDITOR)) { + \call_user_func([self::CUSTOM_IMAGE_EDITOR, 'bootWithFilesystem'], $filesystem); + } } public function register(): void @@ -53,20 +59,21 @@ class MediaHooks public function filterImageEditors(array $editors): array { + if (! class_exists(self::CUSTOM_IMAGE_EDITOR)) { + return $editors; + } + $imagickIndex = array_search(\WP_Image_Editor_Imagick::class, $editors, true); if ($imagickIndex !== false) { unset($editors[$imagickIndex]); } - array_unshift($editors, ImageEditorImagick::class); + array_unshift($editors, self::CUSTOM_IMAGE_EDITOR); return array_values(array_unique($editors)); } - /** - * Sobreescribe el movimiento final del archivo para subirlo a fly:// mediante Flysystem. - */ public function handlePreMoveUploadedFile($override, array $file, string $destination) { if ($override !== null) { @@ -93,37 +100,26 @@ class MediaHooks ); } - $directory = dirname($relativePath); - - if ($directory !== '' && $directory !== '.') { - try { - $this->filesystem->createDirectory($directory); - } catch (FilesystemException $e) { - return new WP_Error( - 'flysystem_offload_directory_error', - sprintf( - __('No se pudo crear el directorio remoto "%s": %s', 'flysystem-offload'), - esc_html($directory), - $e->getMessage() - ) - ); - } - } - - $resource = @fopen($file['tmp_name'], 'rb'); - - if (! $resource) { - return new WP_Error( - 'flysystem_offload_tmp_read_fail', - __('No se pudo leer el archivo temporal subido.', 'flysystem-offload') - ); - } + $directory = trim(dirname($relativePath), '.'); try { - $this->filesystem->writeStream($relativePath, $resource); - } catch (FilesystemException $e) { - if (is_resource($resource)) { - fclose($resource); + if ($directory !== '') { + $this->filesystem->createDirectory($directory); + } + + $stream = @fopen($file['tmp_name'], 'rb'); + + if (! $stream) { + return new WP_Error( + 'flysystem_offload_tmp_read_fail', + __('No se pudo leer el archivo temporal subido.', 'flysystem-offload') + ); + } + + $this->filesystem->writeStream($relativePath, $stream); + } catch (\Throwable $e) { + if (isset($stream) && is_resource($stream)) { + fclose($stream); } return new WP_Error( @@ -135,8 +131,8 @@ class MediaHooks ); } - if (is_resource($resource)) { - fclose($resource); + if (isset($stream) && is_resource($stream)) { + fclose($stream); } @unlink($file['tmp_name']); @@ -246,6 +242,10 @@ class MediaHooks protected function mirrorToLocal(string $remotePath) { + if (! $this->filesystem || ! $this->isFlyPath($remotePath)) { + return $this->mirrorViaNativeCopy($remotePath); + } + if (! function_exists('wp_tempnam')) { require_once ABSPATH . 'wp-admin/includes/file.php'; } @@ -259,21 +259,90 @@ class MediaHooks ); } - if (! @copy($remotePath, $temp)) { + $relative = $this->relativeFlyPath($remotePath); + + if ($relative === null) { @unlink($temp); return new WP_Error( 'flysystem_offload_remote_copy_fail', - __('No se pudo copiar la imagen desde el almacenamiento remoto.', 'flysystem-offload') + __('No se pudo determinar la ruta remota del archivo.', 'flysystem-offload') ); } + try { + $source = $this->filesystem->readStream($relative); + + if (! is_resource($source)) { + throw new \RuntimeException('No se pudo abrir el stream remoto.'); + } + + $target = fopen($temp, 'wb'); + + if (! $target) { + throw new \RuntimeException('No se pudo abrir el archivo temporal en disco.'); + } + + stream_copy_to_stream($source, $target); + } catch (\Throwable $e) { + if (isset($source) && is_resource($source)) { + fclose($source); + } + if (isset($target) && is_resource($target)) { + fclose($target); + } + + @unlink($temp); + + return new WP_Error( + 'flysystem_offload_remote_copy_fail', + sprintf( + __('No se pudo copiar la imagen desde el almacenamiento remoto: %s', 'flysystem-offload'), + $e->getMessage() + ) + ); + } + + if (isset($source) && is_resource($source)) { + fclose($source); + } + if (isset($target) && is_resource($target)) { + fclose($target); + } + return $temp; } + private function mirrorViaNativeCopy(string $remotePath) + { + if (! function_exists('wp_tempnam')) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + } + + $temp = wp_tempnam(wp_basename($remotePath)); + + if (! $temp) { + return new WP_Error( + 'flysystem_offload_temp_fail', + __('No se pudo crear un archivo temporal para la lectura de metadatos.', 'flysystem-offload') + ); + } + + if (@copy($remotePath, $temp)) { + return $temp; + } + + @unlink($temp); + + return new WP_Error( + 'flysystem_offload_remote_copy_fail', + __('No se pudo copiar la imagen desde el almacenamiento remoto.', 'flysystem-offload') + ); + } + protected function isFlyPath(string $path): bool { - return strpos($path, 'fly://') === 0; + return strncmp($path, 'fly://', 6) === 0; } protected function relativeFlyPath(string $path): ?string diff --git a/src/StreamWrapper/FlysystemStreamWrapper.php b/src/StreamWrapper/FlysystemStreamWrapper.php index 4d15757..0fbf81c 100644 --- a/src/StreamWrapper/FlysystemStreamWrapper.php +++ b/src/StreamWrapper/FlysystemStreamWrapper.php @@ -1,22 +1,38 @@ + */ private static array $filesystems = []; + + /** + * @var array + */ private static array $prefixes = []; - private $stream; - private string $protocol; - private string $path; - private string $mode; - private string $flyPath; + /** @var resource|null */ + private $stream = null; + private string $protocol = ''; + private string $path = ''; + private string $mode = ''; + private string $flyPath = ''; + private array $dirEntries = []; + private int $dirPosition = 0; + + /** @var resource|null */ + public $context = null; public static function register(FilesystemOperator $filesystem, string $protocol, string $prefix): void { @@ -25,37 +41,50 @@ class FlysystemStreamWrapper } self::$filesystems[$protocol] = $filesystem; - self::$prefixes[$protocol] = $prefix; + self::$prefixes[$protocol] = trim($prefix, '/'); stream_wrapper_register($protocol, static::class, STREAM_IS_URL); } public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool { - $this->protocol = strtok($path, ':'); - $this->path = $path; - $this->mode = $mode; - $this->flyPath = $this->resolveFlyPath($path); + $protocol = $this->extractProtocol($path); + $this->protocol = $protocol; + $this->path = $path; + $this->mode = $mode; + $this->flyPath = $this->resolveFlyPath($path, $protocol); - $filesystem = $this->filesystem(); + $filesystem = $this->filesystem($protocol); if (strpbrk($mode, 'waxc')) { $this->stream = fopen('php://temp', str_contains($mode, 'b') ? 'w+b' : 'w+'); if (str_contains($mode, 'a') && $filesystem->fileExists($this->flyPath)) { - $contents = $filesystem->readStream($this->flyPath); - stream_copy_to_stream($contents, $this->stream); - fclose($contents); + try { + $contents = $filesystem->readStream($this->flyPath); + if (is_resource($contents)) { + stream_copy_to_stream($contents, $this->stream); + fclose($contents); + } + } catch (UnableToReadFile $e) { + return false; + } } rewind($this->stream); + return true; } try { $resource = $filesystem->readStream($this->flyPath); + if (! is_resource($resource)) { + return false; + } + $this->stream = $resource; - return is_resource($resource); + + return true; } catch (UnableToReadFile $e) { return false; } @@ -73,18 +102,21 @@ class FlysystemStreamWrapper public function stream_flush(): bool { - if (!strpbrk($this->mode, 'waxc')) { + if (! strpbrk($this->mode, 'waxc')) { return true; } - $filesystem = $this->filesystem(); + $filesystem = $this->filesystem($this->protocol); try { rewind($this->stream); $filesystem->writeStream($this->flyPath, $this->stream); + rewind($this->stream); + return true; } catch (UnableToWriteFile $e) { error_log('[Flysystem Offload] Unable to flush stream: ' . $e->getMessage()); + return false; } } @@ -96,6 +128,8 @@ class FlysystemStreamWrapper if (is_resource($this->stream)) { fclose($this->stream); } + + $this->stream = null; } public function stream_stat(): array|false @@ -105,52 +139,71 @@ class FlysystemStreamWrapper public function url_stat(string $path, int $flags): array|false { - $filesystem = $this->filesystem(); - $flyPath = $this->resolveFlyPath($path); + $protocol = $this->extractProtocol($path); + $filesystem = $this->filesystem($protocol); + $flyPath = $this->resolveFlyPath($path, $protocol); try { - if (!$filesystem->fileExists($flyPath) && !$filesystem->directoryExists($flyPath)) { + if (! $filesystem->fileExists($flyPath) && ! $filesystem->directoryExists($flyPath)) { if ($flags & STREAM_URL_STAT_QUIET) { return false; } + trigger_error("File or directory not found: {$path}", E_USER_WARNING); + return false; } $isDir = $filesystem->directoryExists($flyPath); - $size = $isDir ? 0 : $filesystem->fileSize($flyPath); + $size = $isDir ? 0 : $filesystem->fileSize($flyPath); $mtime = $filesystem->lastModified($flyPath); return [ - 'dev' => 0, - 'ino' => 0, - 'mode' => $isDir ? 0040777 : 0100777, - 'nlink' => 0, - 'uid' => 0, - 'gid' => 0, - 'rdev' => 0, - 'size' => $size, - 'atime' => $mtime, - 'mtime' => $mtime, - 'ctime' => $mtime, + 0 => 0, + 'dev' => 0, + 1 => 0, + 'ino' => 0, + 2 => $isDir ? 0040777 : 0100777, + 'mode' => $isDir ? 0040777 : 0100777, + 3 => 0, + 'nlink' => 0, + 4 => 0, + 'uid' => 0, + 5 => 0, + 'gid' => 0, + 6 => 0, + 'rdev' => 0, + 7 => $size, + 'size' => $size, + 8 => $mtime, + 'atime' => $mtime, + 9 => $mtime, + 'mtime' => $mtime, + 10 => $mtime, + 'ctime' => $mtime, + 11 => -1, 'blksize' => -1, - 'blocks' => -1 + 12 => -1, + 'blocks' => -1, ]; } catch (FilesystemException $e) { - if (!($flags & STREAM_URL_STAT_QUIET)) { + if (! ($flags & STREAM_URL_STAT_QUIET)) { trigger_error($e->getMessage(), E_USER_WARNING); } + return false; } } public function unlink(string $path): bool { - $filesystem = $this->filesystem(); - $flyPath = $this->resolveFlyPath($path); + $protocol = $this->extractProtocol($path); + $filesystem = $this->filesystem($protocol); + $flyPath = $this->resolveFlyPath($path, $protocol); try { $filesystem->delete($flyPath); + return true; } catch (UnableToDeleteFile $e) { return false; @@ -159,11 +212,13 @@ class FlysystemStreamWrapper public function mkdir(string $path, int $mode, int $options): bool { - $filesystem = $this->filesystem(); - $flyPath = $this->resolveFlyPath($path); + $protocol = $this->extractProtocol($path); + $filesystem = $this->filesystem($protocol); + $flyPath = $this->resolveFlyPath($path, $protocol); try { $filesystem->createDirectory($flyPath); + return true; } catch (FilesystemException $e) { return false; @@ -172,11 +227,13 @@ class FlysystemStreamWrapper public function rmdir(string $path, int $options): bool { - $filesystem = $this->filesystem(); - $flyPath = $this->resolveFlyPath($path); + $protocol = $this->extractProtocol($path); + $filesystem = $this->filesystem($protocol); + $flyPath = $this->resolveFlyPath($path, $protocol); try { $filesystem->deleteDirectory($flyPath); + return true; } catch (FilesystemException $e) { return false; @@ -185,71 +242,95 @@ class FlysystemStreamWrapper public function dir_opendir(string $path, int $options): bool { - $this->protocol = strtok($path, ':'); - $this->flyPath = $this->resolveFlyPath($path); - $this->stream = $this->filesystem()->listContents($this->flyPath, false)->getIterator(); + $protocol = $this->extractProtocol($path); + $this->protocol = $protocol; + $this->flyPath = $this->resolveFlyPath($path, $protocol); + $filesystem = $this->filesystem($protocol); + $this->dirEntries = []; + + foreach ($filesystem->listContents($this->flyPath, false) as $item) { + if ($item instanceof StorageAttributes) { + $this->dirEntries[] = basename($item->path()); + } elseif (is_array($item) && isset($item['path'])) { + $this->dirEntries[] = basename($item['path']); + } + } + + $this->dirPosition = 0; return true; } public function dir_readdir(): string|false { - if ($this->stream instanceof \Iterator) { - if ($this->stream->valid()) { - $current = $this->stream->current(); - $this->stream->next(); - return $current['basename'] ?? $current['path'] ?? false; - } + if ($this->dirPosition >= count($this->dirEntries)) { + return false; } - return false; + return $this->dirEntries[$this->dirPosition++]; } public function dir_rewinddir(): bool { - if ($this->stream instanceof \Iterator) { - $this->stream->rewind(); - return true; - } + $this->dirPosition = 0; - return false; + return true; } public function dir_closedir(): bool { - $this->stream = null; + $this->dirEntries = []; + $this->dirPosition = 0; + return true; } public function rename(string $oldPath, string $newPath): bool { - $filesystem = $this->filesystem(); - $from = $this->resolveFlyPath($oldPath); - $to = $this->resolveFlyPath($newPath); + $oldProtocol = $this->extractProtocol($oldPath); + $newProtocol = $this->extractProtocol($newPath); + + if ($oldProtocol !== $newProtocol) { + return false; + } + + $filesystem = $this->filesystem($oldProtocol); + $from = $this->resolveFlyPath($oldPath, $oldProtocol); + $to = $this->resolveFlyPath($newPath, $newProtocol); try { $filesystem->move($from, $to); + return true; } catch (FilesystemException $e) { return false; } } - private function filesystem(): FilesystemOperator + private function filesystem(string $protocol): FilesystemOperator { - if (!isset(self::$filesystems[$this->protocol])) { - throw new \RuntimeException('No filesystem registered for protocol: ' . $this->protocol); + if (! isset(self::$filesystems[$protocol])) { + throw new \RuntimeException('No filesystem registered for protocol: ' . $protocol); } - return self::$filesystems[$this->protocol]; + return self::$filesystems[$protocol]; } - private function resolveFlyPath(string $path): string + private function resolveFlyPath(string $path, ?string $protocol = null): string { - $protocol = strtok($path, ':'); - $prefix = self::$prefixes[$protocol] ?? ''; - $raw = preg_replace('#^[^:]+://#', '', $path); + $protocol ??= $this->extractProtocol($path); + $prefix = self::$prefixes[$protocol] ?? ''; + $raw = preg_replace('#^[^:]+://#', '', $path) ?? ''; - return ltrim($prefix . ltrim($raw, '/'), '/'); + $combined = $prefix !== '' ? $prefix . '/' . ltrim($raw, '/') : ltrim($raw, '/'); + + return ltrim($combined, '/'); + } + + private function extractProtocol(string $path): string + { + $pos = strpos($path, '://'); + + return $pos === false ? $this->protocol : substr($path, 0, $pos); } }