From 995e1dfd80cfcf1fa3a67bbcc7052bd345097f6d Mon Sep 17 00:00:00 2001 From: Brasdrive Date: Thu, 6 Nov 2025 19:15:34 -0400 Subject: [PATCH] 2.0.0 --- .gitignore | 5 + README.md | 116 ++++++ composer.json | 23 ++ config/flysystem-offload.example.php | 0 flysystem-offload.php | 14 + src/Admin/HealthCheck.php | 0 src/Config/ConfigLoader.php | 132 +++++++ src/Filesystem/AdapterInterface.php | 26 ++ src/Filesystem/Adapters/AzureBlobAdapter.php | 0 src/Filesystem/Adapters/DropboxAdapter.php | 0 .../Adapters/GoogleCloudAdapter.php | 0 .../Adapters/GoogleDriveAdapter.php | 0 src/Filesystem/Adapters/OneDriveAdapter.php | 0 src/Filesystem/Adapters/S3Adapter.php | 92 +++++ src/Filesystem/Adapters/SftpAdapter.php | 0 src/Filesystem/Adapters/WebdavAdapter.php | 0 src/Filesystem/FilesystemFactory.php | 99 +++++ .../Traits/ValidatesOptionsTrait.php | 0 src/Helpers/PathHelper.php | 60 +++ src/Media/ImageEditorGD.php | 223 +++++++++++ src/Media/ImageEditorImagick.php | 229 +++++++++++ src/Media/MediaHooks.php | 356 ++++++++++++++++++ src/Plugin.php | 232 ++++++++++++ src/StreamWrapper/FlysystemStreamWrapper.php | 344 +++++++++++++++++ 24 files changed, 1951 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/flysystem-offload.example.php create mode 100644 flysystem-offload.php create mode 100644 src/Admin/HealthCheck.php create mode 100644 src/Config/ConfigLoader.php create mode 100644 src/Filesystem/AdapterInterface.php create mode 100644 src/Filesystem/Adapters/AzureBlobAdapter.php create mode 100644 src/Filesystem/Adapters/DropboxAdapter.php create mode 100644 src/Filesystem/Adapters/GoogleCloudAdapter.php create mode 100644 src/Filesystem/Adapters/GoogleDriveAdapter.php create mode 100644 src/Filesystem/Adapters/OneDriveAdapter.php create mode 100644 src/Filesystem/Adapters/S3Adapter.php create mode 100644 src/Filesystem/Adapters/SftpAdapter.php create mode 100644 src/Filesystem/Adapters/WebdavAdapter.php create mode 100644 src/Filesystem/FilesystemFactory.php create mode 100644 src/Filesystem/Traits/ValidatesOptionsTrait.php create mode 100644 src/Helpers/PathHelper.php create mode 100644 src/Media/ImageEditorGD.php create mode 100644 src/Media/ImageEditorImagick.php create mode 100644 src/Media/MediaHooks.php create mode 100644 src/Plugin.php create mode 100644 src/StreamWrapper/FlysystemStreamWrapper.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bc4ed38 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/vendor/ +*.log +*.txt +*.lock +.dockerignore diff --git a/README.md b/README.md new file mode 100644 index 0000000..26f2171 --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# Flysystem Offload — Almacenamiento universal para WordPress + +Flysystem Offload sustituye el sistema de archivos local de WordPress por un backend remoto operado con Flysystem v3. Los medios se suben, sirven y eliminan directamente desde el proveedor seleccionado (S3 y compatibles en la primera versión) sin modificar el flujo editorial. + +## Características + +- **Proveedor seleccionable:** Amazon S3 y endpoints compatibles (MinIO, DigitalOcean Spaces, Wasabi, etc.). +- **Integración transparente:** hooks de `upload_dir`, `wp_get_attachment_url`, stream wrapper `fly://` y borrado sincronizado. +- **Arquitectura modular:** preparada para añadir SFTP, WebDAV y otros adaptadores en iteraciones futuras. +- **Panel de ajustes:** selector de proveedor y credenciales gestionadas desde la administración. + + ## Requisitos + +- PHP 8.0+ +- WordPress 6.0+ +- Extensiones PHP: `curl`, `mbstring`, `xml` +- Acceso a Composer durante la construcción del paquete (o usar la imagen Docker proporcionada) +- Credenciales válidas de S3 o servicio compatible + + ## Instalación + + ### Opción A · Proyecto existente de WordPress + + +1. Clona este repositorio dentro del árbol de tu sitio: + `git clone https://git.brasdrive.com.br/Brasdrive/flysystem-offload.git` +2. Entra en la carpeta del plugin y ejecuta Composer para traer las dependencias: + + ```bash + cd flysystem-offload + composer install --no-dev --optimize-autoloader + ``` + +3. Empaqueta el plugin con la carpeta `vendor/` incluida y súbelo a `/wp-content/plugins/` del sitio que corresponda (vía SCP, rsync o panel de hosting). +4. Activa **Flysystem Offload** desde **Plugins > Plugins instalados** en el escritorio de WordPress. + + ### Opción B · Imagen Docker (multi-stage) + + El repositorio incluye un Dockerfile que construye una imagen basada en `wordpress:6.8.3-php8.4-apache` y prepara el plugin en tiempo de build: + + ```Dockerfile + # Etapa 1: composer:2.8.12 instala las dependencias en /app/wp-content/plugins/flysystem-offload + # Etapa 2: copia el plugin con vendor/ dentro de /usr/src/wordpress y /var/www/html + # Instala redis, WP-CLI y algunas utilidades + # Habilita módulos de Apache y carga configuraciones personalizadas de PHP/Apache + ``` + + Pasos para usarla: + + ```bash + # Desde la raíz del repositorio + docker build -t flysystem-offload-wp . + # Arranca el contenedor exponiendo el puerto 80 (puedes convertirlo en un stack Compose si lo prefieres) + docker run --rm -p 8080:80 flysystem-offload-wp + ``` + + La imagen resultante ya contiene: + +- El plugin con todas sus dependencias PHP en `/usr/src/wordpress/wp-content/plugins/flysystem-offload`. +- Copia pre-sincronizada en `/var/www/html/wp-content/plugins` para que esté disponible desde el primer arranque. +- Extensión Redis habilitada, WP-CLI disponible y módulos `rewrite`, `headers`, `expires`, `deflate` activos. + + ### Nota sobre Composer + + En entornos que no usan Docker, asegúrate de ejecutar `/composer install` antes de empaquetar o desplegar. WordPress no ejecuta Composer automáticamente durante la activación de un plugin. + + ## Configuración inicial (S3 / compatible) + + +1. En el escritorio de WordPress abre **Ajustes > Flysystem Offload**. +2. Selecciona **Amazon S3 / Compatible**. +3. Completa los campos: + - Access Key + - Secret Key (permanece oculta tras guardarla) + - Región (`us-east-1`, `eu-west-1`, etc.) + - Bucket + - Prefijo opcional (subcarpeta dentro del bucket) + - Endpoint personalizado (solo para servicios compatibles con S3) + - URL CDN opcional (sustituye la URL pública del bucket por tu dominio CDN) +4. Guarda los cambios. El plugin reconstruye automáticamente el filesystem y el stream wrapper. + **Prefijo base:** Puedes definir un prefijo global (`wordpress/uploads/`) que se añadirá a todas las rutas remotas antes de delegar en el adaptador. + + ## Flujo de funcionamiento + + +- WordPress sigue usando `wp_handle_upload()`. +- Los filtros de `upload_dir` cambian `basedir` a `fly://...`. +- El stream wrapper reenvía lecturas/escrituras a Flysystem y este al cliente S3. +- `wp_get_attachment_url` reescribe la URL base con el dominio del bucket o el CDN configurado. +- Al eliminar un adjunto, se borran el archivo principal y sus derivadas desde el almacenamiento remoto. + + ## Roadmap inmediato + +- Campos y validaciones para SFTP y WebDAV. +- Health check vía WP-CLI. +- Herramientas de migración para copiar la biblioteca existente al proveedor remoto. +- Adaptadores adicionales (GCS, Azure Blob) y conectores OAuth (Drive, OneDrive, Dropbox). + + ## Contribuir + + +1. Haz fork y crea una rama (`feature/tu-feature`). +2. Sigue los [WordPress Coding Standards](https://developer.wordpress.org/coding-standards/). +3. Ejecuta tests si están disponibles y actualiza la documentación si corresponde. +4. Abre un Pull Request describiendo el cambio. + +- Documentación: Este archivo. +- Issues/Contacto: jdavidcamejo@gmail.com + + ## Licencia + + GPL v2. Consulta [LICENSE](LICENSE) para más detalles. + +--- + +Desarrollado por [Brasdrive](https://brasdrive.com.br). \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..d45704c --- /dev/null +++ b/composer.json @@ -0,0 +1,23 @@ +{ + "name": "tu-nombre/flysystem-offload", + "description": "Universal storage offloading for WordPress vía Flysystem", + "type": "wordpress-plugin", + "require": { + "php": ">=7.4", + "league/flysystem": "^3.24", + "league/flysystem-aws-s3-v3": "^3.24", + "league/flysystem-sftp-v3": "^3.24", + "league/flysystem-azure-blob-storage": "^3.24", + "league/flysystem-google-cloud-storage": "^3.24", + "league/flysystem-webdav": "^3.24", + "aws/aws-sdk-php": "^3.330", + "google/cloud-storage": "^1.33", + "microsoft/azure-storage-blob": "^1.5", + "sabre/dav": "^4.5" + }, + "autoload": { + "psr-4": { + "FlysystemOffload\\": "src/" + } + } +} diff --git a/config/flysystem-offload.example.php b/config/flysystem-offload.example.php new file mode 100644 index 0000000..e69de29 diff --git a/flysystem-offload.php b/flysystem-offload.php new file mode 100644 index 0000000..6da53c8 --- /dev/null +++ b/flysystem-offload.php @@ -0,0 +1,14 @@ +pluginDir = dirname($pluginFile); + } + + /** + * Devuelve la configuración efectiva (defaults + overrides). + * + * @throws \RuntimeException cuando un archivo config no retorna array. + */ + public function load(): array + { + $defaults = $this->defaults(); + $files = $this->discoverConfigFiles(); + + $config = []; + + foreach ($files as $file) { + $data = require $file; + + if (! is_array($data)) { + throw new \RuntimeException( + sprintf('[Flysystem Offload] El archivo de configuración "%s" debe retornar un array.', $file) + ); + } + + $config = array_replace_recursive($config, $data); + } + + if (empty($config)) { + $config = $defaults; + } else { + $config = array_replace_recursive($defaults, $config); + } + + /** + * Permite ajustar/ensanchar la configuración en tiempo de ejecución. + * Ideal para multisite o integraciones externas. + * + * @param array $config + * @param array $files Lista de archivos usados (en orden de carga). + */ + return apply_filters('flysystem_offload_config', $config, $files); + } + + /** + * Defaults que garantizan compatibilidad si falta un archivo. + */ + public function defaults(): array + { + return [ + 'adapter' => 'local', + 'base_prefix' => '', + 'adapters' => [ + 's3' => [ + 'access_key' => '', + 'secret_key' => '', + 'region' => '', + 'bucket' => '', + 'prefix' => '', + 'endpoint' => '', + 'cdn_url' => '', + ], + 'sftp' => [ + 'host' => '', + 'port' => 22, + 'username' => '', + 'password' => '', + 'root' => '/uploads', + ], + 'gcs' => [ + 'project_id' => '', + 'bucket' => '', + 'key_file_path' => '', + ], + 'azure' => [ + 'account_name' => '', + 'account_key' => '', + 'container' => '', + 'prefix' => '', + ], + 'webdav' => [ + 'base_uri' => '', + 'username' => '', + 'password' => '', + 'path_prefix'=> '', + ], + 'googledrive' => [], + 'onedrive' => [], + 'dropbox' => [], + ], + ]; + } + + /** + * Descubre archivos de configuración en orden de prioridad. + * + * @return string[] + */ + private function discoverConfigFiles(): array + { + $candidates = []; + + if (defined('FLYSYSTEM_OFFLOAD_CONFIG')) { + $candidates[] = FLYSYSTEM_OFFLOAD_CONFIG; + } + + // Opción por defecto en wp-content/. + $candidates[] = WP_CONTENT_DIR . '/flysystem-offload.php'; + + // Alias alternativo frecuente. + $candidates[] = WP_CONTENT_DIR . '/flysystem-offload-config.php'; + + // Fallback incluido dentro del plugin (para entornos sin personalización inicial). + $candidates[] = $this->pluginDir . '/config/flysystem-offload.php'; + + $unique = array_unique(array_filter( + $candidates, + static fn (string $path) => is_readable($path) + )); + + return array_values($unique); + } +} diff --git a/src/Filesystem/AdapterInterface.php b/src/Filesystem/AdapterInterface.php new file mode 100644 index 0000000..c90822d --- /dev/null +++ b/src/Filesystem/AdapterInterface.php @@ -0,0 +1,26 @@ + [ + 'key' => $settings['access_key'], + 'secret' => $settings['secret_key'] + ], + 'region' => $settings['region'], + 'version' => 'latest' + ]; + + if (!empty($settings['endpoint'])) { + $clientConfig['endpoint'] = $settings['endpoint']; + $clientConfig['use_path_style_endpoint'] = true; + } + + $client = new S3Client($clientConfig); + + return new AwsS3V3Adapter($client, $settings['bucket'], $settings['prefix'] ?? ''); + } catch (\Throwable $e) { + return new WP_Error('flysystem_offload_s3_error', $e->getMessage()); + } + } + + public function publicBaseUrl(array $settings): string + { + $cdn = $settings['cdn_base_url'] ?? null; + if ($cdn) { + return rtrim($cdn, '/'); + } + + $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'] ?? '', '/'); + + $normalizedUrl = null; + + if ($endpoint) { + $endpoint = rtrim($endpoint, '/'); + $parts = parse_url($endpoint); + + 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); + } + } + + if (! $normalizedUrl) { + $normalizedUrl = sprintf('https://%s.s3.%s.amazonaws.com', $bucket, $region); + } + + return $prefix ? $normalizedUrl . '/' . $prefix : $normalizedUrl; + } +} diff --git a/src/Filesystem/Adapters/SftpAdapter.php b/src/Filesystem/Adapters/SftpAdapter.php new file mode 100644 index 0000000..e69de29 diff --git a/src/Filesystem/Adapters/WebdavAdapter.php b/src/Filesystem/Adapters/WebdavAdapter.php new file mode 100644 index 0000000..e69de29 diff --git a/src/Filesystem/FilesystemFactory.php b/src/Filesystem/FilesystemFactory.php new file mode 100644 index 0000000..f010f44 --- /dev/null +++ b/src/Filesystem/FilesystemFactory.php @@ -0,0 +1,99 @@ +settings = $settings; + } + + public function make(): FilesystemOperator|WP_Error + { + $adapterKey = $this->settings['adapter'] ?? 'local'; + $config = $this->settings['adapters'][$adapterKey] ?? []; + + $adapter = $this->resolveAdapter($adapterKey); + + if ($adapter instanceof WP_Error) { + return $adapter; + } + + $validation = $adapter->validate($config); + if ($validation instanceof WP_Error) { + return $validation; + } + + $flyAdapter = $adapter->create($config); + if ($flyAdapter instanceof WP_Error) { + return $flyAdapter; + } + + return new Filesystem($flyAdapter); + } + + public function resolvePublicBaseUrl(string $adapterKey, array $settings): string + { + $adapter = $this->resolveAdapter($adapterKey); + + if ($adapter instanceof WP_Error) { + return content_url('/uploads'); + } + + $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(), + 'googledrive' => new GoogleDriveAdapter(), // stub (dev) + 'onedrive' => new OneDriveAdapter(), // stub (dev) + 'dropbox' => new DropboxAdapter(), // stub (dev) + default => new class implements AdapterInterface { + public function create(array $settings) + { + $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/Filesystem/Traits/ValidatesOptionsTrait.php b/src/Filesystem/Traits/ValidatesOptionsTrait.php new file mode 100644 index 0000000..e69de29 diff --git a/src/Helpers/PathHelper.php b/src/Helpers/PathHelper.php new file mode 100644 index 0000000..afce594 --- /dev/null +++ b/src/Helpers/PathHelper.php @@ -0,0 +1,60 @@ +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; + } + + 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; + } + + $remote = $this->determineRemotePath($result['path']); + $sync = $this->pushToRemote($result['path'], $remote); + + if (is_wp_error($sync)) { + return $sync; + } + + $result['path'] = 'fly://' . $remote; + if (isset($result['file'])) { + $result['file'] = basename($remote); + } + + return $result; + } + + public function multi_resize($sizes) + { + $results = parent::multi_resize($sizes); + + if (! $this->remotePath || ! self::$filesystem) { + return $results; + } + + foreach ($results as &$result) { + if (empty($result['path'])) { + continue; + } + + $remote = $this->determineRemotePath($result['path']); + $sync = $this->pushToRemote($result['path'], $remote); + + if (is_wp_error($sync)) { + $result['error'] = $sync->get_error_message(); + continue; + } + + $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 parent::stream($mime_type); + } + + public function __destruct() + { + if ($this->localPath && file_exists($this->localPath)) { + @unlink($this->localPath); + } + + parent::__destruct(); + } + + protected function pushToRemote(string $localFile, string $remotePath) + { + $stream = @fopen($localFile, 'rb'); + + if (! $stream) { + return new WP_Error( + 'flysystem_offload_upload_fail', + __('No se pudo leer el archivo procesado para subirlo al almacenamiento remoto.', 'flysystem-offload') + ); + } + + try { + self::$filesystem->writeStream($remotePath, $stream); + } catch (\Throwable $e) { + if (is_resource($stream)) { + fclose($stream); + } + + return new WP_Error( + 'flysystem_offload_upload_fail', + sprintf( + __('Fallo al subir %s al almacenamiento remoto: %s', 'flysystem-offload'), + $remotePath, + $e->getMessage() + ) + ); + } + + if (is_resource($stream)) { + fclose($stream); + } + + @unlink($localFile); + + return true; + } + + protected function downloadToTemp(string $remotePath) + { + if (! function_exists('wp_tempnam')) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + } + + $temp = wp_tempnam(basename($remotePath)); + + 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 determineRemotePath(string $localSavedPath): string + { + $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/ImageEditorImagick.php b/src/Media/ImageEditorImagick.php new file mode 100644 index 0000000..fa77552 --- /dev/null +++ b/src/Media/ImageEditorImagick.php @@ -0,0 +1,229 @@ +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; + } + + 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; + } + + $remote = $this->determineRemotePath($result['path']); + $sync = $this->pushToRemote($result['path'], $remote); + + if (is_wp_error($sync)) { + return $sync; + } + + $result['path'] = 'fly://' . $remote; + if (isset($result['file'])) { + $result['file'] = basename($remote); + } + + return $result; + } + + public function multi_resize($sizes) + { + $results = parent::multi_resize($sizes); + + if (! $this->remotePath || ! self::$filesystem) { + return $results; + } + + foreach ($results as &$result) { + if (empty($result['path'])) { + continue; + } + + $remote = $this->determineRemotePath($result['path']); + $sync = $this->pushToRemote($result['path'], $remote); + + if (is_wp_error($sync)) { + $result['error'] = $sync->get_error_message(); + continue; + } + + $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 parent::stream($mime_type); + } + + public function __destruct() + { + if ($this->localPath && file_exists($this->localPath)) { + @unlink($this->localPath); + } + + parent::__destruct(); + } + + protected function pushToRemote(string $localFile, string $remotePath) + { + $stream = @fopen($localFile, 'rb'); + + if (! $stream) { + return new WP_Error( + 'flysystem_offload_upload_fail', + __('No se pudo leer el archivo procesado para subirlo al almacenamiento remoto.', 'flysystem-offload') + ); + } + + try { + self::$filesystem->writeStream($remotePath, $stream); + } catch (\Throwable $e) { + if (is_resource($stream)) { + fclose($stream); + } + + return new WP_Error( + 'flysystem_offload_upload_fail', + sprintf( + __('Fallo al subir %s al almacenamiento remoto: %s', 'flysystem-offload'), + $remotePath, + $e->getMessage() + ) + ); + } + + if (is_resource($stream)) { + fclose($stream); + } + + @unlink($localFile); + + return true; + } + + protected function downloadToTemp(string $remotePath) + { + if (! function_exists('wp_tempnam')) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + } + + $temp = wp_tempnam(basename($remotePath)); + + 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 determineRemotePath(string $localSavedPath): string + { + $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 new file mode 100644 index 0000000..168557d --- /dev/null +++ b/src/Media/MediaHooks.php @@ -0,0 +1,356 @@ +filesystem = $filesystem; + + if (class_exists(self::CUSTOM_IMAGE_EDITOR)) { + \call_user_func([self::CUSTOM_IMAGE_EDITOR, 'bootWithFilesystem'], $filesystem); + } + } + + public function register(): void + { + if ($this->registered) { + return; + } + + add_filter('wp_image_editors', [$this, 'filterImageEditors'], 5); + add_filter('pre_move_uploaded_file', [$this, 'handlePreMoveUploadedFile'], 10, 3); + add_filter('wp_read_image_metadata', [$this, 'filterReadImageMetadata'], 10, 2); + add_filter('wp_generate_attachment_metadata', [$this, 'filterGenerateAttachmentMetadata'], 10, 2); + add_filter('pre_wp_unique_filename_file_list', [$this, 'filterUniqueFilenameFileList'], 10, 3); + add_action('delete_attachment', [$this, 'handleDeleteAttachment'], 10); + + $this->registered = true; + } + + public function unregister(): void + { + if (! $this->registered) { + return; + } + + remove_filter('wp_image_editors', [$this, 'filterImageEditors'], 5); + remove_filter('pre_move_uploaded_file', [$this, 'handlePreMoveUploadedFile'], 10); + remove_filter('wp_read_image_metadata', [$this, 'filterReadImageMetadata'], 10); + remove_filter('wp_generate_attachment_metadata', [$this, 'filterGenerateAttachmentMetadata'], 10); + remove_filter('pre_wp_unique_filename_file_list', [$this, 'filterUniqueFilenameFileList'], 10); + remove_action('delete_attachment', [$this, 'handleDeleteAttachment']); + + $this->registered = false; + } + + 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, self::CUSTOM_IMAGE_EDITOR); + + return array_values(array_unique($editors)); + } + + public function handlePreMoveUploadedFile($override, array $file, string $destination) + { + if ($override !== null) { + return $override; + } + + if (! $this->isFlyPath($destination)) { + return $override; + } + + if (! $this->filesystem) { + return new WP_Error( + 'flysystem_offload_missing_filesystem', + __('No se pudo acceder al filesystem remoto.', 'flysystem-offload') + ); + } + + $relativePath = $this->relativeFlyPath($destination); + + if ($relativePath === null) { + return new WP_Error( + 'flysystem_offload_invalid_destination', + __('Ruta de destino inválida para el stream fly://.', 'flysystem-offload') + ); + } + + $directory = trim(dirname($relativePath), '.'); + + try { + 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( + 'flysystem_offload_write_fail', + sprintf( + __('No se pudo guardar el archivo en el almacenamiento remoto: %s', 'flysystem-offload'), + $e->getMessage() + ) + ); + } + + if (isset($stream) && is_resource($stream)) { + fclose($stream); + } + + @unlink($file['tmp_name']); + + return true; + } + + public function filterReadImageMetadata($metadata, string $file) + { + if ($this->metadataMirrorInProgress || ! $this->isFlyPath($file)) { + return $metadata; + } + + $this->metadataMirrorInProgress = true; + + $temp = $this->mirrorToLocal($file); + + if (! is_wp_error($temp)) { + $metadata = wp_read_image_metadata($temp); + @unlink($temp); + } + + $this->metadataMirrorInProgress = false; + + return $metadata; + } + + public function filterGenerateAttachmentMetadata(array $metadata, int $attachmentId): array + { + if (isset($metadata['filesize'])) { + return $metadata; + } + + $file = get_attached_file($attachmentId); + + if ($file && file_exists($file)) { + $metadata['filesize'] = filesize($file); + } + + return $metadata; + } + + public function filterUniqueFilenameFileList($files, string $dir, string $filename) + { + if (! $this->isFlyPath($dir) || ! $this->filesystem) { + return $files; + } + + $relativeDir = $this->relativeFlyPath($dir); + + if ($relativeDir === null) { + return $files; + } + + $existing = []; + + foreach ($this->filesystem->listContents($relativeDir, false) as $item) { + /** @var StorageAttributes $item */ + if ($item->isDir()) { + continue; + } + + $existing[] = basename($item->path()); + } + + return $existing; + } + + public function handleDeleteAttachment(int $attachmentId): void + { + $file = get_attached_file($attachmentId); + + if (! $file || ! $this->isFlyPath($file)) { + return; + } + + $meta = wp_get_attachment_metadata($attachmentId); + + if (! empty($meta['sizes'])) { + foreach ($meta['sizes'] as $sizeInfo) { + if (empty($sizeInfo['file'])) { + continue; + } + + wp_delete_file(str_replace(basename($file), $sizeInfo['file'], $file)); + } + } + + $original = get_post_meta($attachmentId, 'original_image', true); + if ($original) { + wp_delete_file(str_replace(basename($file), $original, $file)); + } + + $backup = get_post_meta($attachmentId, '_wp_attachment_backup_sizes', true); + if (is_array($backup)) { + foreach ($backup as $sizeInfo) { + if (empty($sizeInfo['file'])) { + continue; + } + + wp_delete_file(str_replace(basename($file), $sizeInfo['file'], $file)); + } + } + + wp_delete_file($file); + } + + 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'; + } + + $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') + ); + } + + $relative = $this->relativeFlyPath($remotePath); + + if ($relative === null) { + @unlink($temp); + + return new WP_Error( + 'flysystem_offload_remote_copy_fail', + __('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 strncmp($path, 'fly://', 6) === 0; + } + + protected function relativeFlyPath(string $path): ?string + { + if (! $this->isFlyPath($path)) { + return null; + } + + return ltrim(substr($path, 6), '/'); + } +} diff --git a/src/Plugin.php b/src/Plugin.php new file mode 100644 index 0000000..e78eab7 --- /dev/null +++ b/src/Plugin.php @@ -0,0 +1,232 @@ +mediaHooks = new MediaHooks(); + } + + public static function bootstrap(string $pluginFile): void + { + self::$pluginFile = $pluginFile; + + register_activation_hook($pluginFile, [self::class, 'activate']); + register_deactivation_hook($pluginFile, [self::class, 'deactivate']); + + add_action('plugins_loaded', static function () { + self::instance()->init(); + }); + } + + public static function instance(): self + { + return self::$instance ??= new self(); + } + + public static function activate(): void + { + wp_mkdir_p(WP_CONTENT_DIR . '/flysystem-uploads'); + + if (! defined('FLYSYSTEM_OFFLOAD_CONFIG') && ! file_exists(WP_CONTENT_DIR . '/flysystem-offload.php')) { + error_log('[Flysystem Offload] No se encontró un archivo de configuración. Copia config/flysystem-offload.example.php a wp-content/flysystem-offload.php y ajústalo.'); + } + } + + public static function deactivate(): void + { + if ($instance = self::$instance) { + $instance->mediaHooks->unregister(); + $instance->mediaHooks->setFilesystem(null); + } + + if (in_array('fly', stream_get_wrappers(), true)) { + stream_wrapper_unregister('fly'); + } + } + + public function init(): void + { + $this->configLoader = new ConfigLoader(self::$pluginFile); + + $this->reloadConfig(); + + add_filter('upload_dir', [$this, 'filterUploadDir'], 20); + add_filter('wp_get_attachment_url', [$this, 'filterAttachmentUrl'], 20, 2); + add_filter('wp_get_attachment_metadata', [$this, 'filterAttachmentMetadata'], 20); + add_filter('wp_get_original_image_path', [$this, 'filterOriginalImagePath'], 20); + add_filter('wp_delete_file', [$this, 'handleDeleteFile'], 20); + + add_action('delete_attachment', [$this, 'handleDeleteAttachment'], 20); + add_action('switch_blog', [$this, 'handleSwitchBlog']); + + add_action('flysystem_offload_reload_config', [$this, 'reloadConfig']); + + if (defined('WP_CLI') && WP_CLI) { + \WP_CLI::add_command('flysystem-offload health-check', [Admin\HealthCheck::class, 'run']); + } + } + + public function reloadConfig(): void + { + try { + $this->config = $this->configLoader->load(); + } catch (\Throwable $e) { + error_log('[Flysystem Offload] Error cargando configuración: ' . $e->getMessage()); + $this->config = $this->configLoader->defaults(); + } + + $this->filesystem = null; + $this->streamRegistered = false; + + $this->mediaHooks->unregister(); + $this->mediaHooks->setFilesystem(null); + + $this->registerStreamWrapper(); + } + + public function handleSwitchBlog(): void + { + $this->reloadConfig(); + } + + public function getFilesystem(): FilesystemOperator + { + if (! $this->filesystem) { + $factory = new FilesystemFactory($this->config); + $result = $factory->make(); + + if (is_wp_error($result)) { + throw new \RuntimeException($result->get_error_message()); + } + + $this->filesystem = $result; + } + + return $this->filesystem; + } + + private function registerStreamWrapper(): void + { + if ($this->streamRegistered) { + return; + } + + try { + $filesystem = $this->getFilesystem(); + } catch (\Throwable $e) { + error_log('[Flysystem Offload] No se pudo obtener el filesystem: ' . $e->getMessage()); + return; + } + + try { + FlysystemStreamWrapper::register( + $filesystem, + 'fly', + PathHelper::normalizePrefix($this->config['base_prefix'] ?? '') + ); + $this->streamRegistered = true; + } catch (\Throwable $e) { + error_log('[Flysystem Offload] No se pudo registrar el stream wrapper: ' . $e->getMessage()); + } + + $this->mediaHooks->setFilesystem($filesystem); + $this->mediaHooks->register(); + } + + public function filterUploadDir(array $dirs): array + { + $remoteBase = $this->getRemoteUrlBase(); + $prefix = PathHelper::normalizePrefix($this->config['base_prefix'] ?? ''); + $subdir = $dirs['subdir'] ?? ''; + + $dirs['path'] = "fly://{$prefix}{$subdir}"; + $dirs['basedir'] = "fly://{$prefix}"; + $dirs['url'] = trailingslashit($remoteBase) . ltrim($subdir, '/'); + $dirs['baseurl'] = $remoteBase; + + return $dirs; + } + + public function filterAttachmentUrl(string $url, int $postId): string + { + $localBase = trailingslashit(wp_get_upload_dir()['baseurl']); + $remoteBase = trailingslashit($this->getRemoteUrlBase()); + + return str_replace($localBase, $remoteBase, $url); + } + + public function filterAttachmentMetadata(array $metadata): array + { + if (! empty($metadata['file'])) { + $metadata['file'] = PathHelper::stripProtocol($metadata['file']); + } + + if (! empty($metadata['sizes'])) { + foreach ($metadata['sizes'] as &$size) { + if (! empty($size['file'])) { + $size['file'] = ltrim($size['file'], '/'); + } + } + unset($size); + } + + return $metadata; + } + + public function filterOriginalImagePath(string $path): string + { + return PathHelper::ensureFlyProtocol($path); + } + + public function handleDeleteFile(string $file): string|false + { + $flyPath = PathHelper::stripProtocol($file); + + try { + $this->getFilesystem()->delete($flyPath); + } catch (\Throwable $e) { + error_log('[Flysystem Offload] Error al borrar archivo: ' . $flyPath . ' - ' . $e->getMessage()); + } + + return false; + } + + public function handleDeleteAttachment(int $postId): void + { + $files = PathHelper::collectFilesFromAttachment($postId); + + foreach ($files as $relativePath) { + try { + $this->getFilesystem()->delete($relativePath); + } catch (\Throwable $e) { + error_log('[Flysystem Offload] Error al borrar attachment: ' . $relativePath . ' - ' . $e->getMessage()); + } + } + } + + private function getRemoteUrlBase(): string + { + $adapterKey = $this->config['adapter'] ?? 'local'; + $settings = $this->config['adapters'][$adapterKey] ?? []; + + return (new FilesystemFactory($this->config))->resolvePublicBaseUrl($adapterKey, $settings); + } +} diff --git a/src/StreamWrapper/FlysystemStreamWrapper.php b/src/StreamWrapper/FlysystemStreamWrapper.php new file mode 100644 index 0000000..2d25037 --- /dev/null +++ b/src/StreamWrapper/FlysystemStreamWrapper.php @@ -0,0 +1,344 @@ + + */ + private static array $filesystems = []; + + /** + * @var array + */ + private static array $prefixes = []; + + /** @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 + { + if (in_array($protocol, stream_get_wrappers(), true)) { + stream_wrapper_unregister($protocol); + } + + self::$filesystems[$protocol] = $filesystem; + 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 + { + $protocol = $this->extractProtocol($path); + $this->protocol = $protocol; + $this->path = $path; + $this->mode = $mode; + $this->flyPath = $this->resolveFlyPath($path, $protocol); + + $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)) { + 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 true; + } catch (UnableToReadFile $e) { + return false; + } + } + + public function stream_read(int $count): string|false + { + return fread($this->stream, $count); + } + + public function stream_write(string $data): int|false + { + return fwrite($this->stream, $data); + } + + public function stream_flush(): bool + { + if (! strpbrk($this->mode, 'waxc')) { + return true; + } + + $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; + } + } + + public function stream_close(): void + { + $this->stream_flush(); + + if (is_resource($this->stream)) { + fclose($this->stream); + } + + $this->stream = null; + } + + public function stream_stat(): array|false + { + return $this->url_stat($this->path, 0); + } + + public function url_stat(string $path, int $flags): array|false + { + $protocol = $this->extractProtocol($path); + $filesystem = $this->filesystem($protocol); + $flyPath = $this->resolveFlyPath($path, $protocol); + + try { + 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); + $mtime = $filesystem->lastModified($flyPath); + + return [ + 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, + 12 => -1, + 'blocks' => -1, + ]; + } catch (FilesystemException $e) { + if (! ($flags & STREAM_URL_STAT_QUIET)) { + trigger_error($e->getMessage(), E_USER_WARNING); + } + + return false; + } + } + + public function unlink(string $path): bool + { + $protocol = $this->extractProtocol($path); + $filesystem = $this->filesystem($protocol); + $flyPath = $this->resolveFlyPath($path, $protocol); + + try { + $filesystem->delete($flyPath); + + return true; + } catch (UnableToDeleteFile $e) { + return false; + } + } + + public function mkdir(string $path, int $mode, int $options): bool + { + $protocol = $this->extractProtocol($path); + $filesystem = $this->filesystem($protocol); + $flyPath = $this->resolveFlyPath($path, $protocol); + + try { + $filesystem->createDirectory($flyPath); + + return true; + } catch (FilesystemException $e) { + return false; + } + } + + public function rmdir(string $path, int $options): bool + { + $protocol = $this->extractProtocol($path); + $filesystem = $this->filesystem($protocol); + $flyPath = $this->resolveFlyPath($path, $protocol); + + try { + $filesystem->deleteDirectory($flyPath); + + return true; + } catch (FilesystemException $e) { + return false; + } + } + + public function dir_opendir(string $path, int $options): bool + { + $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->dirPosition >= count($this->dirEntries)) { + return false; + } + + return $this->dirEntries[$this->dirPosition++]; + } + + public function dir_rewinddir(): bool + { + $this->dirPosition = 0; + + return true; + } + + public function dir_closedir(): bool + { + $this->dirEntries = []; + $this->dirPosition = 0; + + return true; + } + + public function rename(string $oldPath, string $newPath): bool + { + $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(string $protocol): FilesystemOperator + { + if (! isset(self::$filesystems[$protocol])) { + throw new \RuntimeException('No filesystem registered for protocol: ' . $protocol); + } + + return self::$filesystems[$protocol]; + } + + private function resolveFlyPath(string $path, ?string $protocol = null): string + { + $protocol ??= $this->extractProtocol($path); + $prefix = self::$prefixes[$protocol] ?? ''; + $raw = preg_replace('#^[^:]+://#', '', $path) ?? ''; + + $normalized = ltrim($raw, '/'); + + if ($prefix !== '' && str_starts_with($normalized, $prefix . '/')) { + return $normalized; + } + + if ($prefix === '') { + return $normalized; + } + + return $prefix . '/' . $normalized; + } + + private function extractProtocol(string $path): string + { + $pos = strpos($path, '://'); + + return $pos === false ? $this->protocol : substr($path, 0, $pos); + } +}