From 1b548491f5d937bc24268041301b7dceb7c266c5 Mon Sep 17 00:00:00 2001 From: DavidCamejo Date: Wed, 5 Nov 2025 22:35:00 -0400 Subject: [PATCH] 2.0.0 --- .dockerignore | 7 + .gitignore | 5 + README.md | 116 ++++++++++ assets/admin-settings.js | 12 -- config/flysystem-offload.example.php | 0 offload | 8 - src/Admin/SettingsPage.php | 291 -------------------------- src/Config/ConfigLoader.php | 132 ++++++++++++ src/Filesystem/Adapters/S3Adapter.php | 27 ++- src/Media/ImageEditorImagick.php | 137 ++++++++++++ src/Media/MediaHooks.php | 287 +++++++++++++++++++++++++ src/Plugin.php | 162 +++++++------- 12 files changed, 783 insertions(+), 401 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitignore delete mode 100644 assets/admin-settings.js create mode 100644 config/flysystem-offload.example.php delete mode 100644 offload delete mode 100644 src/Admin/SettingsPage.php create mode 100644 src/Config/ConfigLoader.php create mode 100644 src/Media/ImageEditorImagick.php create mode 100644 src/Media/MediaHooks.php diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3536d55 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +flysystem-offload/vendor/ +flysystem-offload/node_modules/ +flysystem-offload/.git/ +flysystem-offload/.idea/ +flysystem-offload/*.log +flysystem-offload/*.txt +flysystem-offload/*.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a795cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/node_modules/ +.idea/ +*.log +*.txt +*.lock diff --git a/README.md b/README.md index e69de29..26f2171 100644 --- a/README.md +++ 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/assets/admin-settings.js b/assets/admin-settings.js deleted file mode 100644 index db13f1a..0000000 --- a/assets/admin-settings.js +++ /dev/null @@ -1,12 +0,0 @@ -(function ($) { - function toggleSections() { - const adapter = $('#flysystem-offload-adapter').val(); - $('.flysystem-offload-adapter-section').hide(); - $(`.flysystem-offload-adapter-section[data-adapter="${adapter}"]`).show(); - } - - $(document).ready(function () { - toggleSections(); - $('#flysystem-offload-adapter').on('change', toggleSections); - }); -})(jQuery); diff --git a/config/flysystem-offload.example.php b/config/flysystem-offload.example.php new file mode 100644 index 0000000..e69de29 diff --git a/offload b/offload deleted file mode 100644 index 5dbfd29..0000000 --- a/offload +++ /dev/null @@ -1,8 +0,0 @@ -flysystem-offload.php -src/Plugin.php -src/Helpers/PathHelper.php -src/StreamWrapper/FlysystemStreamWrapper.php -src/Filesystem/AdapterInterface.php -src/Filesystem/FilesystemFactory.php -src/Filesystem/Adapters/S3Adapter.php -src/Admin/SettingsPage.php diff --git a/src/Admin/SettingsPage.php b/src/Admin/SettingsPage.php deleted file mode 100644 index feae5c3..0000000 --- a/src/Admin/SettingsPage.php +++ /dev/null @@ -1,291 +0,0 @@ -pluginFile = $pluginFile; - } - - public function boot(): void - { - add_action('admin_menu', [$this, 'registerPage']); - add_action('admin_init', [$this, 'registerSettings']); - add_action('admin_enqueue_scripts', [$this, 'enqueueAssets']); - } - - public function enqueueAssets(): void - { - $screen = get_current_screen(); - if (! $screen || $screen->id !== 'settings_page_flysystem-offload') { - return; - } - - wp_enqueue_script( - 'flysystem-offload-settings', - plugins_url('assets/admin-settings.js', $this->pluginFile), - ['jquery'], - '0.1.0', - true - ); - } - - public function registerPage(): void - { - add_options_page( - __('Flysystem Offload', 'flysystem-offload'), - __('Flysystem Offload', 'flysystem-offload'), - 'manage_options', - 'flysystem-offload', - [$this, 'render'] - ); - } - - public function registerSettings(): void - { - register_setting( - 'flysystem_offload', - 'flysystem_offload_settings', - [ - 'sanitize_callback' => [$this, 'sanitizeSettings'], - ] - ); - - add_settings_section( - 'flysystem_offload_general', - __('Configuración general', 'flysystem-offload'), - '__return_false', - 'flysystem-offload' - ); - - add_settings_field( - 'adapter', - __('Adaptador activo', 'flysystem-offload'), - [$this, 'renderAdapterField'], - 'flysystem-offload', - 'flysystem_offload_general' - ); - - add_settings_field( - 'base_prefix', - __('Prefijo de almacenamiento', 'flysystem-offload'), - [$this, 'renderBasePrefixField'], - 'flysystem-offload', - 'flysystem_offload_general' - ); - - add_settings_section( - 'flysystem_offload_s3', - __('Amazon S3 / Compatible', 'flysystem-offload'), - function () { - echo '

' . esc_html__('Proporciona credenciales de un bucket S3 o compatible (MinIO, DigitalOcean Spaces, etc.).', 'flysystem-offload') . '

'; - }, - 'flysystem-offload' - ); - - add_settings_field( - 's3_access_key', - __('Access Key', 'flysystem-offload'), - [$this, 'renderS3Field'], - 'flysystem-offload', - 'flysystem_offload_s3', - ['key' => 'access_key', 'type' => 'text'] - ); - - add_settings_field( - 's3_secret_key', - __('Secret Key', 'flysystem-offload'), - [$this, 'renderS3Field'], - 'flysystem-offload', - 'flysystem_offload_s3', - ['key' => 'secret_key', 'type' => 'password'] - ); - - add_settings_field( - 's3_region', - __('Región', 'flysystem-offload'), - [$this, 'renderS3Field'], - 'flysystem-offload', - 'flysystem_offload_s3', - ['key' => 'region', 'type' => 'text', 'placeholder' => 'us-east-1'] - ); - - add_settings_field( - 's3_bucket', - __('Bucket', 'flysystem-offload'), - [$this, 'renderS3Field'], - 'flysystem-offload', - 'flysystem_offload_s3', - ['key' => 'bucket', 'type' => 'text'] - ); - - add_settings_field( - 's3_prefix', - __('Prefijo (opcional)', 'flysystem-offload'), - [$this, 'renderS3Field'], - 'flysystem-offload', - 'flysystem_offload_s3', - ['key' => 'prefix', 'type' => 'text', 'placeholder' => 'uploads'] - ); - - add_settings_field( - 's3_endpoint', - __('Endpoint personalizado', 'flysystem-offload'), - [$this, 'renderS3Field'], - 'flysystem-offload', - 'flysystem_offload_s3', - ['key' => 'endpoint', 'type' => 'text', 'placeholder' => 'https://nyc3.digitaloceanspaces.com'] - ); - - add_settings_field( - 's3_cdn_url', - __('URL CDN (opcional)', 'flysystem-offload'), - [$this, 'renderS3Field'], - 'flysystem-offload', - 'flysystem_offload_s3', - ['key' => 'cdn_url', 'type' => 'text', 'placeholder' => 'https://cdn.midominio.com'] - ); - } - - public function sanitizeSettings(array $input): array - { - $current = get_option('flysystem_offload_settings', []); - - $adapter = sanitize_key($input['adapter'] ?? $current['adapter'] ?? 'local'); - $basePrefix = trim($input['base_prefix'] ?? ''); - - $s3 = $current['adapters']['s3'] ?? []; - $inputS3 = $input['adapters']['s3'] ?? []; - - $secretRaw = $inputS3['secret_key'] ?? ''; - $secret = $secretRaw === '' ? ($s3['secret_key'] ?? '') : $secretRaw; - - $sanitizedS3 = [ - 'access_key' => sanitize_text_field($inputS3['access_key'] ?? $s3['access_key'] ?? ''), - 'secret_key' => sanitize_text_field($inputS3['secret_key'] ?? $s3['secret_key'] ?? ''), - 'region' => sanitize_text_field($inputS3['region'] ?? $s3['region'] ?? ''), - 'bucket' => sanitize_text_field($inputS3['bucket'] ?? $s3['bucket'] ?? ''), - 'prefix' => trim($inputS3['prefix'] ?? $s3['prefix'] ?? ''), - 'endpoint' => esc_url_raw($inputS3['endpoint'] ?? $s3['endpoint'] ?? ''), - 'cdn_url' => esc_url_raw($inputS3['cdn_url'] ?? $s3['cdn_url'] ?? ''), - ]; - - $current['adapter'] = $adapter; - $current['base_prefix'] = $basePrefix; - $current['adapters']['s3'] = $sanitizedS3; - - return $current; - } - - public function render(): void - { - ?> -
-

-
- generateSectionsMarkup(); - echo $sectionsHtml; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - - submit_button(__('Guardar cambios', 'flysystem-offload')); - ?> -
-
- ]*>\s*' . $label . '\s*<\/h2>\s*]*>.*?<\/table>)/is'; - - return preg_replace( - $pattern, - '
$1
', - $html - ); - } - - public function renderAdapterField(): void - { - $settings = get_option('flysystem_offload_settings', []); - $adapter = $settings['adapter'] ?? 'local'; - - $options = [ - 'local' => __('Local (fallback)', 'flysystem-offload'), - 's3' => 'Amazon S3 / Compatible', - 'sftp' => 'SFTP', - 'webdav' => 'WebDAV', - 'gcs' => 'Google Cloud Storage', - 'azure' => 'Azure Blob Storage', - 'googledrive' => 'Google Drive (beta)', - 'onedrive' => 'OneDrive (beta)', - 'dropbox' => 'Dropbox (beta)', - ]; - ?> - - - -

- -

- - - -

- -

- 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/Adapters/S3Adapter.php b/src/Filesystem/Adapters/S3Adapter.php index 6a47e00..12f5bf6 100644 --- a/src/Filesystem/Adapters/S3Adapter.php +++ b/src/Filesystem/Adapters/S3Adapter.php @@ -49,17 +49,32 @@ class S3Adapter implements AdapterInterface public function publicBaseUrl(array $settings): string { - if (!empty($settings['cdn_url'])) { + if (! empty($settings['cdn_url'])) { return rtrim($settings['cdn_url'], '/'); } - $bucket = $settings['bucket']; - $region = $settings['region']; + $bucket = $settings['bucket'] ?? ''; + $prefix = isset($settings['prefix']) ? trim($settings['prefix'], '/') : ''; + $prefix = $prefix === '' ? '' : '/' . $prefix; - if ($region === 'us-east-1') { - return "https://{$bucket}.s3.amazonaws.com"; + if (! empty($settings['endpoint'])) { + $endpoint = trim($settings['endpoint']); + if (! preg_match('#^https?://#i', $endpoint)) { + $endpoint = 'https://' . $endpoint; + } + + $endpoint = rtrim($endpoint, '/'); + + // Cuando se usa endpoint propio forzamos path-style (+ bucket en la ruta) + return $endpoint . '/' . $bucket . $prefix; } - return "https://{$bucket}.s3.{$region}.amazonaws.com"; + $region = $settings['region'] ?? 'us-east-1'; + + if ($region === 'us-east-1') { + return "https://{$bucket}.s3.amazonaws.com{$prefix}"; + } + + return "https://{$bucket}.s3.{$region}.amazonaws.com{$prefix}"; } } diff --git a/src/Media/ImageEditorImagick.php b/src/Media/ImageEditorImagick.php new file mode 100644 index 0000000..b5c849b --- /dev/null +++ b/src/Media/ImageEditorImagick.php @@ -0,0 +1,137 @@ +image instanceof \Imagick) { + return true; + } + + if (empty($this->file)) { + return new WP_Error( + 'flysystem_offload_missing_file', + __('Archivo no definido.', 'flysystem-offload') + ); + } + + if (! $this->isFlysystemPath($this->file)) { + return parent::load(); + } + + $localPath = $this->mirrorToLocal($this->file); + + if (is_wp_error($localPath)) { + return $localPath; + } + + $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) + { + [$filename, $extension, $mime_type] = $this->get_output_format($filename, $mime_type); + + if (! $filename) { + $filename = $this->generate_filename(null, null, $extension); + } + + $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); + } + + return $result; + } + + 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') + ); + } + + $result['path'] = $filename; + $result['file'] = wp_basename($filename); + } + + return $result; + } + + public function __destruct() + { + foreach ($this->tempFiles as $temp) { + @unlink($temp); + } + + parent::__destruct(); + } + + protected function mirrorToLocal(string $remotePath) + { + $tempFile = $this->createTempFile($remotePath); + + if (! $tempFile) { + return new WP_Error( + 'flysystem_offload_temp_missing', + __('No se pudo crear un archivo temporal para la imagen remota.', 'flysystem-offload') + ); + } + + if (! copy($remotePath, $tempFile)) { + @unlink($tempFile); + + return new WP_Error( + 'flysystem_offload_remote_copy_failed', + __('No se pudo copiar la imagen desde el almacenamiento remoto.', 'flysystem-offload') + ); + } + + $this->tempFiles[] = $tempFile; + + return $tempFile; + } + + protected function createTempFile(string $context) + { + if (! function_exists('wp_tempnam')) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + } + + $tempFile = wp_tempnam(wp_basename($context)); + + return $tempFile ?: false; + } + + protected function isFlysystemPath(string $path): bool + { + return strpos($path, 'fly://') === 0; + } +} diff --git a/src/Media/MediaHooks.php b/src/Media/MediaHooks.php new file mode 100644 index 0000000..d10e2be --- /dev/null +++ b/src/Media/MediaHooks.php @@ -0,0 +1,287 @@ +filesystem = $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 + { + $imagickIndex = array_search(\WP_Image_Editor_Imagick::class, $editors, true); + + if ($imagickIndex !== false) { + unset($editors[$imagickIndex]); + } + + array_unshift($editors, ImageEditorImagick::class); + + 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) { + 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 = 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') + ); + } + + try { + $this->filesystem->writeStream($relativePath, $resource); + } catch (FilesystemException $e) { + if (is_resource($resource)) { + fclose($resource); + } + + 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 (is_resource($resource)) { + fclose($resource); + } + + @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 (! 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)) { + @unlink($temp); + + return new WP_Error( + 'flysystem_offload_remote_copy_fail', + __('No se pudo copiar la imagen desde el almacenamiento remoto.', 'flysystem-offload') + ); + } + + return $temp; + } + + protected function isFlyPath(string $path): bool + { + return strpos($path, 'fly://') === 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 index 512b78d..e78eab7 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -1,20 +1,28 @@ mediaHooks = new MediaHooks(); + } public static function bootstrap(string $pluginFile): void { @@ -35,53 +43,20 @@ class Plugin public static function activate(): void { - $defaults = [ - 'adapter' => 'local', - 'base_prefix' => '', - 'adapters' => [ - 's3' => [ - 'access_key' => '', - 'secret_key' => '', - 'region' => '', - 'bucket' => '', - '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' => [] - ] - ]; + wp_mkdir_p(WP_CONTENT_DIR . '/flysystem-uploads'); - add_option('flysystem_offload_settings', $defaults); + 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'); } @@ -89,50 +64,54 @@ class Plugin public function init(): void { - $this->settings = get_option('flysystem_offload_settings', []); + $this->configLoader = new ConfigLoader(self::$pluginFile); - add_action( - 'update_option_flysystem_offload_settings', - function ($oldValue, $newValue) { - $this->settings = $newValue; - $this->filesystem = null; - $this->streamRegistered = false; - $this->registerStreamWrapper(); - }, - 10, - 2 - ); - - $this->registerStreamWrapper(); + $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('switch_blog', function () { - $this->filesystem = null; - $this->streamRegistered = false; - $this->settings = get_option('flysystem_offload_settings', []); - $this->registerStreamWrapper(); - }); - - if (is_admin()) { - (new SettingsPage(self::$pluginFile))->boot(); - } + 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->settings); - $result = $factory->make(); + if (! $this->filesystem) { + $factory = new FilesystemFactory($this->config); + $result = $factory->make(); if (is_wp_error($result)) { throw new \RuntimeException($result->get_error_message()); @@ -151,22 +130,36 @@ class Plugin } try { - FlysystemStreamWrapper::register($this->getFilesystem(), 'fly', PathHelper::normalizePrefix($this->settings['base_prefix'] ?? '')); + $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->settings['base_prefix'] ?? ''); - $subdir = $dirs['subdir'] ?? ''; + $prefix = PathHelper::normalizePrefix($this->config['base_prefix'] ?? ''); + $subdir = $dirs['subdir'] ?? ''; - $dirs['path'] = "fly://{$prefix}{$subdir}"; + $dirs['path'] = "fly://{$prefix}{$subdir}"; $dirs['basedir'] = "fly://{$prefix}"; - $dirs['url'] = trailingslashit($remoteBase) . ltrim($subdir, '/'); + $dirs['url'] = trailingslashit($remoteBase) . ltrim($subdir, '/'); $dirs['baseurl'] = $remoteBase; return $dirs; @@ -174,7 +167,7 @@ class Plugin public function filterAttachmentUrl(string $url, int $postId): string { - $localBase = trailingslashit(wp_get_upload_dir()['baseurl']); + $localBase = trailingslashit(wp_get_upload_dir()['baseurl']); $remoteBase = trailingslashit($this->getRemoteUrlBase()); return str_replace($localBase, $remoteBase, $url); @@ -182,16 +175,17 @@ class Plugin public function filterAttachmentMetadata(array $metadata): array { - if (!empty($metadata['file'])) { + if (! empty($metadata['file'])) { $metadata['file'] = PathHelper::stripProtocol($metadata['file']); } - if (!empty($metadata['sizes'])) { + if (! empty($metadata['sizes'])) { foreach ($metadata['sizes'] as &$size) { - if (!empty($size['file'])) { + if (! empty($size['file'])) { $size['file'] = ltrim($size['file'], '/'); } } + unset($size); } return $metadata; @@ -230,9 +224,9 @@ class Plugin private function getRemoteUrlBase(): string { - $adapterKey = $this->settings['adapter'] ?? 'local'; - $config = $this->settings['adapters'][$adapterKey] ?? []; + $adapterKey = $this->config['adapter'] ?? 'local'; + $settings = $this->config['adapters'][$adapterKey] ?? []; - return (new FilesystemFactory($this->settings))->resolvePublicBaseUrl($adapterKey, $config); + return (new FilesystemFactory($this->config))->resolvePublicBaseUrl($adapterKey, $settings); } }