Compare commits

..

20 Commits
main ... 3.0.0

Author SHA1 Message Date
Brasdrive f8c7736735 3.0.0 2025-11-10 01:58:54 -04:00
Brasdrive 04784ee337 3.0.0 2025-11-10 01:06:19 -04:00
Brasdrive b974fb340a 3.0.0 2025-11-09 23:57:08 -04:00
Brasdrive e4bff1f14f 3.0.0 2025-11-09 23:00:19 -04:00
Brasdrive b755cb707e 3.0.0 2025-11-09 22:40:09 -04:00
Brasdrive fb9f2726b8 3.0.0 2025-11-09 22:22:52 -04:00
Brasdrive e46b8de1cb 3.0.0 2025-11-09 21:57:24 -04:00
Brasdrive 5a11f3e2b9 3.0.0 2025-11-09 21:47:38 -04:00
Brasdrive 510a40a350 3.0.0 2025-11-09 21:33:24 -04:00
Brasdrive 0dcd0dbf00 3.0.0 2025-11-09 21:05:19 -04:00
Brasdrive 0ab69f79c8 3.0.0 2025-11-09 20:51:02 -04:00
Brasdrive cb0316fab6 3.0.0 2025-11-09 20:10:02 -04:00
Brasdrive 799352708b 3.0.0 2025-11-09 19:36:01 -04:00
Brasdrive 79fa886ee1 3.0.0 2025-11-09 19:20:21 -04:00
Brasdrive 56d96b32cb 3.0.0 2025-11-09 18:18:30 -04:00
Brasdrive ac0a351064 3.0.0 2025-11-09 17:55:28 -04:00
Brasdrive 5886686d24 3.0.0 2025-11-09 17:38:39 -04:00
Brasdrive fd1d5a988a 3.0.0 2025-11-09 17:24:27 -04:00
Brasdrive 917c25c99b 3.0.0 2025-11-09 16:28:21 -04:00
Brasdrive 8ccfce7f65 3.0.0 2025-11-09 15:41:57 -04:00
13 changed files with 2492 additions and 1159 deletions

317
README.md
View File

@ -1,225 +1,116 @@
### Flysystem Offload
# Flysystem Offload — Almacenamiento universal para WordPress
Flysystem Offload es un plugin minimalista de WordPress que sustituye el almacenamiento local por un backend remoto usando [Flysystem v3](https://flysystem.thephpleague.com/v3/docs/). Inspirado por el estilo ligero de “S3 Uploads”, se concentra en proporcionar una integración transparente, sin panel de administración, y con compatibilidad total con el flujo estándar de medios de 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
## Características
- Registro temprano del stream wrapper para redirigir `wp-content/uploads` a almacenamiento remoto.
- Generación automática de URLs públicas coherentes con el prefijo de bucket.
- Cabeceras `Cache-Control` y `Expires` aplicadas en las subidas.
- Limpieza remota de adjuntos al eliminarlos desde WordPress.
- Configuración declarativa vía `config/flysystem-offload.php`.
- **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
### Requisitos
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:
- WordPress 6.5 o superior.
- PHP 8.2 o superior (probado en PHP 8.4).
- Extensiones PHP: `json`, `curl`, `mbstring`.
- Credenciales válidas para un servicio S3 compatible.
- Composer para instalar dependencias (AWS SDK).
- 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 `<plugin>/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)
### Instalación
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
1. Clona el repositorio en `wp-content/plugins/flysystem-offload`.
2. Ejecuta `composer install` dentro del plugin si el SDK no está en el proyecto.
3. Copia `config/flysystem-offload.example.php` a `config/flysystem-offload.php`.
4. Ajusta las claves del archivo de configuración.
5. Activa el plugin desde el dashboard de WordPress o vía `wp plugin activate flysystem-offload`.
- 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
### Configuración básica (`config/flysystem-offload.php`)
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.
```php
<?php
return [
'driver' => 's3',
'visibility' => 'public',
- Documentación: Este archivo.
- Issues/Contacto: jdavidcamejo@gmail.com
'stream' => [
'protocol' => 'flysystem',
'root_prefix' => '',
'host' => 'uploads',
],
## Licencia
GPL v2. Consulta [LICENSE](LICENSE) para más detalles.
'uploads' => [
'base_url' => getenv('FLYSYSTEM_OFFLOAD_BASE_URL') ?: 'https://offload.brasdrive.com.br',
'delete_remote' => true,
'prefer_local_for_missing' => false,
'cache_control' => 'public, max-age=31536000, immutable',
'expires_ttl' => 31536000,
'expires' => null,
],
---
'admin' => [
'enabled' => false,
],
's3' => [
'key' => getenv('AWS_ACCESS_KEY_ID') ?: null,
'secret' => getenv('AWS_SECRET_ACCESS_KEY') ?: null,
'session_token' => getenv('AWS_SESSION_TOKEN') ?: null,
'region' => getenv('AWS_DEFAULT_REGION') ?: 'us-east-1',
'bucket' => getenv('FLYSYSTEM_OFFLOAD_BUCKET') ?: 'your-bucket-name',
'prefix' => getenv('FLYSYSTEM_OFFLOAD_PREFIX') ?: 'wordpress',
'endpoint' => getenv('FLYSYSTEM_OFFLOAD_ENDPOINT') ?: null,
'use_path_style_endpoint' => (bool) (getenv('AWS_USE_PATH_STYLE_ENDPOINT') ?: false),
'version' => 'latest',
'options' => [],
'default_options' => [],
'acl_public' => 'public-read',
'acl_private' => 'private',
],
];
```
### Normalización de configuración
`Plugin::normaliseConfig()` establece valores por defecto, garantiza que `uploads.base_url` termine sin `/`, y fusiona `s3.prefix`, `stream.root_prefix` y `stream.host` para producir el prefijo remoto final.
### Funcionamiento del stream wrapper
- El filtro `upload_dir` devuelve rutas como `flysystem://uploads/2025/01`.
- `MediaHooks` reescribe automáticamente `wp_get_attachment_url` para apuntar a la ruta remota (`base_url + prefijo + archivo`).
- El prefijo remoto resultante combina `s3.prefix`, `stream.root_prefix` y `stream.host` para reflejar exactamente la clave almacenada en el bucket (ej.: `wordpress/uploads/2025/01/file.jpg`).
### Cabeceras de caché
El adaptador S3 añade `Cache-Control` y `Expires` a través de las opciones por defecto de Flysystem:
```php
$defaultOptions['CacheControl'] = 'public, max-age=31536000, immutable';
$defaultOptions['Expires'] = gmdate('D, d M Y H:i:s \G\M\T', time() + 31536000);
```
Puede personalizarse mediante el filtro `flysystem_offload_s3_default_write_options`.
### Escenario bucket público (probado con IDrive e2)
1. Marcar bucket como público en la consola de IDrive e2.
2. Aplicar policy simple (la UI sólo acepta una versión sin `Condition`).
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowPublicReadUploads",
"Effect": "Allow",
"Principal": { "AWS": "*" },
"Action": [
"s3:GetObject"
],
"Resource": [
"arn:aws:s3:::flysystem-offload/wordpress/uploads/*"
]
}
]
}
```
3. Configurar CORS si el panel o el frontend cargan las imágenes desde otro origen.
```json
[
{
"AllowedOrigins": [
"https://offload.brasdrive.com.br"
],
"AllowedMethods": [
"GET",
"HEAD"
],
"AllowedHeaders": [
"*"
],
"ExposeHeaders": [
"ETag",
"Last-Modified",
"x-amz-request-id",
"x-amz-id-2"
],
"AllowCredentials": false,
"MaxAgeSeconds": 86400
}
]
```
### Escenario recomendado: bucket privado + Cloudflare
1. **Bucket privado**
- Mantenerlo cerrado; permitir `s3:GetObject` sólo a IPs de Cloudflare.
- Aplicar policy vía CLI (`aws s3api put-bucket-policy`). Ejemplo con `Condition` por IP (mantén la lista actualizada con la API `GET /client/v4/ips`):
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudflareAccess",
"Effect": "Allow",
"Principal": { "AWS": "*" },
"Action": [
"s3:GetObject"
],
"Resource": [
"arn:aws:s3:::flysystem-offload/wordpress/uploads/*"
],
"Condition": {
"IpAddress": {
"aws:SourceIp": [
"173.245.48.0/20",
"103.21.244.0/22"
// ...
]
}
}
}
]
}
```
2. **Cloudflare**
- CNAME `offload.brasdrive.com.br` → endpoint IDrive (ej.`u8k6.va11.idrivee2-9.com`).
- Proxy naranja activado; SSL universal.
- Reglas WAF o Rate Limiting opcionales.
3. **WordPress**
- `uploads.base_url = 'https://offload.brasdrive.com.br'`.
- Mantener `prefer_local_for_missing = false`.
4. **Automatización**
- Script/cron que consulte la API de Cloudflare, regenere la policy con los nuevos rangos IP y la aplique (controlando con `etag`).
5. **Caché y purgas**
- Cloudflare respeta las cabeceras `Cache-Control` y `Expires`.
- Purgar desde la consola de Cloudflare cuando sea necesario (por URL o caché completa).
### Compatibilidad de backends
Actualmente Flysystem Offload proporciona el adaptador **S3**. El código está preparado para admitir otros backends soportados por Flysystem (WebDAV, SFTP, etc.), pero aún deben agregarse adaptadores específicos (`WebdavAdapter`, `SftpAdapter`, etc.) y sus respectivas configuraciones. Esto forma parte del plan inmediato de ampliación: se crearán clases adicionales en `src/Filesystem/Adapters/`, se extenderá la factoría de adaptadores y se actualizará la documentación para cada backend.
### Notas sobre IDrive e2
- Para habilitar un bucket público reciente, es necesario contactar soporte.
- El editor web de políticas no soporta `Condition`; aplicar policies complejas via CLI/API.
- CORS sí puede configurarse desde la UI mientras se respete el formato simple.
- Usa el endpoint regional del bucket como `endpoint` en configuración y CLI.
### Solución de problemas
- URLs sin prefijo: actualizar a la versión reciente de `MediaHooks.php`.
- Confirmar `s3.prefix`, `stream.root_prefix`, `stream.host`.
- Falta de `Cache-Control`/`Expires`: revisar `config` y `s3.default_options`.
- Error `_.filter` en la UI: bug del formulario; usar CLI.
- Archivos inaccesibles con bucket privado: verificar policy, rangos Cloudflare, CNAME activo.
### Contribuciones
Se aceptan PRs. Mantén el enfoque minimalista, sin panel de administración, y prioriza compatibilidad con el flujo nativo de WordPress. Roadmap cercano: añadir adaptadores para otros drivers Flysystem.
### Licencia
MIT. Revisa el archivo `LICENSE`.
Desarrollado por [Brasdrive](https://brasdrive.com.br).

View File

@ -1,7 +1,14 @@
{
"name": "brasdrive/flysystem-offload",
"description": "Universal storage offloading for WordPress vía Flysystem",
"description": "Universal storage offloading for WordPress via Flysystem",
"type": "wordpress-plugin",
"license": "GPL-2.0-or-later",
"authors": [
{
"name": "Brasdrive",
"email": "jdavidcamejo@gmail.com"
}
],
"require": {
"php": ">=8.1",
"league/flysystem": "^3.24",
@ -16,9 +23,36 @@
"azure-oss/storage-blob-flysystem": "^1.3.0",
"sabre/dav": "^4.5"
},
"require-dev": {
"phpunit/phpunit": "^10.0",
"squizlabs/php_codesniffer": "^3.7"
},
"autoload": {
"psr-4": {
"FlysystemOffload\\": "src/"
}
}
},
"autoload-dev": {
"psr-4": {
"FlysystemOffload\\Tests\\": "tests/"
}
},
"config": {
"optimize-autoloader": true,
"sort-packages": true,
"allow-plugins": {
"composer/installers": true
},
"platform": {
"php": "8.1"
}
},
"scripts": {
"test": "phpunit",
"cs": "phpcs --standard=PSR12 src/",
"cbf": "phpcbf --standard=PSR12 src/",
"dump": "composer dump-autoload --optimize"
},
"minimum-stability": "stable",
"prefer-stable": true
}

View File

@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
return [
'driver' => 's3',
'visibility' => 'public',
'stream' => [
'protocol' => 'flysystem',
'root_prefix' => '',
'host' => 'uploads',
],
'uploads' => [
'base_url' => getenv('FLYSYSTEM_OFFLOAD_BASE_URL') ?: 'https://your-bucket.s3.amazonaws.com',
'delete_remote' => true,
'prefer_local_for_missing' => false,
],
'admin' => [
'enabled' => false,
],
's3' => [
'key' => getenv('AWS_ACCESS_KEY_ID') ?: null,
'secret' => getenv('AWS_SECRET_ACCESS_KEY') ?: null,
'session_token' => getenv('AWS_SESSION_TOKEN') ?: null,
'region' => getenv('AWS_DEFAULT_REGION') ?: 'us-east-1',
'bucket' => getenv('FLYSYSTEM_OFFLOAD_BUCKET') ?: 'your-bucket-name',
'prefix' => getenv('FLYSYSTEM_OFFLOAD_PREFIX') ?: null,
'endpoint' => getenv('FLYSYSTEM_OFFLOAD_ENDPOINT') ?: null,
'use_path_style_endpoint' => (bool) (getenv('AWS_USE_PATH_STYLE_ENDPOINT') ?: false),
'version' => 'latest',
'options' => [],
],
];

View File

@ -1,24 +1,37 @@
<?php
declare(strict_types=1);
return [
'driver' => 's3',
'visibility' => 'public',
'provider' => getenv('FLYSYSTEM_OFFLOAD_PROVIDER') ?: 'webdav',
'visibility' => getenv('FLYSYSTEM_OFFLOAD_DEFAULT_VISIBILITY') ?: 'public',
'stream' => [
'protocol' => 'flysystem',
'root_prefix' => '',
'host' => 'uploads',
'protocol' => getenv('FLYSYSTEM_OFFLOAD_STREAM_PROTOCOL') ?: 'webdav',
'root_prefix' => getenv('FLYSYSTEM_OFFLOAD_STREAM_ROOT_PREFIX') ?: '',
'host' => getenv('FLYSYSTEM_OFFLOAD_STREAM_HOST') ?: '',
],
'uploads' => [
'base_url' => getenv('FLYSYSTEM_OFFLOAD_BASE_URL') ?: 'https://your-bucket.s3.amazonaws.com',
'delete_remote' => true,
'prefer_local_for_missing' => false,
'base_url' => getenv('FLYSYSTEM_OFFLOAD_BASE_URL')
?: (getenv('FLYSYSTEM_OFFLOAD_WEBDAV_BASE_URL')
? rtrim(getenv('FLYSYSTEM_OFFLOAD_WEBDAV_BASE_URL'), '/') . '/' . trim(getenv('FLYSYSTEM_OFFLOAD_WEBDAV_PREFIX') ?: 'wordpress/uploads', '/')
: content_url('uploads')),
'delete_remote' => filter_var(
getenv('FLYSYSTEM_OFFLOAD_DELETE_REMOTE') ?: 'true',
FILTER_VALIDATE_BOOLEAN
),
'prefer_local_for_missing' => filter_var(
getenv('FLYSYSTEM_OFFLOAD_PREFER_LOCAL_FOR_MISSING') ?: 'false',
FILTER_VALIDATE_BOOLEAN
),
],
'admin' => [
'enabled' => false,
'enabled' => filter_var(
getenv('FLYSYSTEM_OFFLOAD_ADMIN_ENABLED') ?: 'true',
FILTER_VALIDATE_BOOLEAN
),
],
's3' => [
@ -29,8 +42,39 @@ return [
'bucket' => getenv('FLYSYSTEM_OFFLOAD_BUCKET') ?: 'your-bucket-name',
'prefix' => getenv('FLYSYSTEM_OFFLOAD_PREFIX') ?: null,
'endpoint' => getenv('FLYSYSTEM_OFFLOAD_ENDPOINT') ?: null,
'use_path_style_endpoint' => (bool) (getenv('AWS_USE_PATH_STYLE_ENDPOINT') ?: false),
'use_path_style_endpoint' => filter_var(
getenv('AWS_USE_PATH_STYLE_ENDPOINT') ?: 'false',
FILTER_VALIDATE_BOOLEAN
),
'version' => 'latest',
'options' => [],
],
'webdav' => [
'enabled' => filter_var(
getenv('FLYSYSTEM_OFFLOAD_WEBDAV_ENABLED') ?: 'true',
FILTER_VALIDATE_BOOLEAN
),
'base_url' => getenv('FLYSYSTEM_OFFLOAD_WEBDAV_BASE_URL') ?: 'https://webdav.example.com',
'endpoint' => getenv('FLYSYSTEM_OFFLOAD_WEBDAV_ENDPOINT') ?: getenv('FLYSYSTEM_OFFLOAD_WEBDAV_BASE_URL') ?: '',
'prefix' => getenv('FLYSYSTEM_OFFLOAD_WEBDAV_PREFIX') ?: 'wordpress/uploads',
'credentials' => [
'username' => getenv('FLYSYSTEM_OFFLOAD_WEBDAV_USERNAME') ?: '',
'password' => getenv('FLYSYSTEM_OFFLOAD_WEBDAV_PASSWORD') ?: '',
'auth_type' => getenv('FLYSYSTEM_OFFLOAD_WEBDAV_AUTH_TYPE') ?: 'basic',
],
'stream' => [
'register' => filter_var(
getenv('FLYSYSTEM_OFFLOAD_WEBDAV_STREAM_REGISTER') ?: 'true',
FILTER_VALIDATE_BOOLEAN
),
'protocol' => getenv('FLYSYSTEM_OFFLOAD_WEBDAV_STREAM_PROTOCOL') ?: 'webdav',
],
'default_headers' => [
'Cache-Control' => getenv('FLYSYSTEM_OFFLOAD_WEBDAV_CACHE_CONTROL') ?: 'public, max-age=31536000',
'Expires' => getenv('FLYSYSTEM_OFFLOAD_WEBDAV_EXPIRES')
?: gmdate('D, d M Y H:i:s \G\M\T', strtotime('+1 year')),
],
'default_visibility' => getenv('FLYSYSTEM_OFFLOAD_WEBDAV_VISIBILITY') ?: 'public',
],
];

View File

@ -1,58 +1,101 @@
<?php
/**
* Plugin Name: Flysystem Offload
* Plugin URI: https://git.brasdrive.com.br/Brasdrive/flysystem-offload
* Description: Reemplaza el filesystem local de WordPress con almacenamiento remoto transparente usando Flysystem v3.
* Version: 0.3.0
* Author: Brasdrive
* License: GPLv2 or later
* Plugin URI: https://git.brasdrive.com.br/Brasdrive/flysystem-offload
* Description: Universal storage offloading for WordPress via Flysystem
* Version: 3.0.0
* Requires at least: 6.0
* Requires PHP: 8.1
* Author: Brasdrive
* Author URI: https://brasdrive.com.br
* License: GPL v2 or later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: flysystem-offload
*/
declare(strict_types=1);
if (! defined('ABSPATH')) {
// Evitar acceso directo
if (!defined('ABSPATH')) {
exit;
}
if (! defined('FLYSYSTEM_OFFLOAD_PATH')) {
define('FLYSYSTEM_OFFLOAD_PATH', __DIR__);
}
if (! defined('FLYSYSTEM_OFFLOAD_FILE')) {
define('FLYSYSTEM_OFFLOAD_FILE', __FILE__);
}
if (! defined('FLYSYSTEM_OFFLOAD_CONFIG_PATH')) {
define('FLYSYSTEM_OFFLOAD_CONFIG_PATH', FLYSYSTEM_OFFLOAD_PATH . '/config');
// Definir constantes del plugin
define('FLYSYSTEM_OFFLOAD_VERSION', '3.0.0');
define('FLYSYSTEM_OFFLOAD_PLUGIN_FILE', __FILE__);
define('FLYSYSTEM_OFFLOAD_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('FLYSYSTEM_OFFLOAD_PLUGIN_URL', plugin_dir_url(__FILE__));
// Definir ruta de configuración (buscar en config/ del plugin)
if (!defined('FLYSYSTEM_OFFLOAD_CONFIG_PATH')) {
define('FLYSYSTEM_OFFLOAD_CONFIG_PATH', FLYSYSTEM_OFFLOAD_PLUGIN_DIR . 'config');
}
$autoload = __DIR__ . '/vendor/autoload.php';
if (file_exists($autoload)) {
require_once $autoload;
// Cargar autoloader de Composer
$autoloader = FLYSYSTEM_OFFLOAD_PLUGIN_DIR . 'vendor/autoload.php';
if (!file_exists($autoloader)) {
add_action('admin_notices', function (): void {
printf(
'<div class="notice notice-error"><p><strong>Flysystem Offload:</strong> %s</p></div>',
esc_html__('Composer dependencies not installed. Please run: composer install', 'flysystem-offload')
);
});
return;
}
use FlysystemOffload\Plugin;
use Throwable;
add_action('plugins_loaded', static function (): void {
if (! class_exists(Plugin::class)) {
error_log('[Flysystem Offload] No fue posible cargar la clase principal del plugin.');
return;
}
require_once $autoloader;
// Inicializar el plugin cuando WordPress esté listo
add_action('plugins_loaded', function (): void {
try {
Plugin::bootstrap();
} catch (Throwable $exception) {
error_log('[Flysystem Offload] Error al iniciar el plugin: ' . $exception->getMessage());
add_action('admin_notices', static function () use ($exception): void {
if (! current_user_can('manage_options')) {
return;
}
printf(
'<div class="notice notice-error"><p><strong>Flysystem Offload:</strong> %s</p></div>',
esc_html($exception->getMessage())
);
});
FlysystemOffload\Plugin::bootstrap();
} catch (Throwable $e) {
error_log('[Flysystem Offload] Error al iniciar el plugin: ' . $e->getMessage());
// Mostrar error solo a administradores
if (is_admin() && current_user_can('manage_options')) {
add_action('admin_notices', function () use ($e): void {
printf(
'<div class="notice notice-error"><p><strong>Flysystem Offload:</strong> %s</p></div>',
esc_html($e->getMessage())
);
});
}
}
}, 0);
}, 10);
// Hook de activación
register_activation_hook(__FILE__, function (): void {
// Verificar requisitos
if (version_compare(PHP_VERSION, '8.1', '<')) {
deactivate_plugins(plugin_basename(__FILE__));
wp_die(
esc_html__('Flysystem Offload requires PHP 8.1 or higher.', 'flysystem-offload'),
esc_html__('Plugin Activation Error', 'flysystem-offload'),
['back_link' => true]
);
}
// Crear directorio de configuración si no existe
$configDir = FLYSYSTEM_OFFLOAD_CONFIG_PATH;
if (!file_exists($configDir)) {
wp_mkdir_p($configDir);
}
// Copiar archivo de ejemplo si no existe configuración
$configFile = $configDir . '/flysystem-offload.php';
$exampleFile = $configDir . '/flysystem-offload.example.php';
if (!file_exists($configFile) && file_exists($exampleFile)) {
copy($exampleFile, $configFile);
}
});
// Hook de desactivación
register_deactivation_hook(__FILE__, function (): void {
// Desregistrar stream wrapper si existe
if (in_array('fly', stream_get_wrappers(), true)) {
stream_wrapper_unregister('fly');
}
});

View File

@ -1,58 +1,596 @@
<?php
declare(strict_types=1);
namespace FlysystemOffload\Config;
use RuntimeException;
use UnexpectedValueException;
use InvalidArgumentException;
final class ConfigLoader {
private string $configDirectory;
/**
* Cargador de configuración para Flysystem Offload
*/
class ConfigLoader
{
/**
* Carga la configuración desde archivo o base de datos
*
* @return array
*/
public static function load(): array
{
// Intentar cargar desde archivo de configuración primero
$fileConfig = self::loadFromFile();
public function __construct(string $configDirectory) {
$this->configDirectory = rtrim($configDirectory, '/\\');
// Cargar desde opciones de WordPress
$dbConfig = self::loadFromDatabase();
// Merge con prioridad a la base de datos sobre el archivo
// Si hay configuración en BD, usar esa; si no, usar archivo
$config = !empty($dbConfig['provider'])
? array_merge($fileConfig, $dbConfig)
: $fileConfig;
// Normalizar configuración
return self::normalize($config);
}
public function load(): array {
$candidateFiles = [
$this->configDirectory . '/flysystem-offload.local.php',
$this->configDirectory . '/flysystem-offload.php',
$this->configDirectory . '/flysystem-offload.example.php',
];
/**
* Carga configuración desde archivo PHP
*
* @return array
*/
private static function loadFromFile(): array
{
$configFile = defined('FLYSYSTEM_OFFLOAD_CONFIG_PATH')
? FLYSYSTEM_OFFLOAD_CONFIG_PATH . '/flysystem-offload.php'
: '';
$configFile = $this->resolveFirstExisting($candidateFiles);
if ($configFile === null) {
throw new RuntimeException(
sprintf(
'No se pudo localizar un archivo de configuración para Flysystem Offload. Esperado en: %s',
implode(', ', $candidateFiles)
)
);
if (empty($configFile) || !file_exists($configFile)) {
error_log('[Flysystem Offload] Config file not found: ' . $configFile);
return [];
}
$config = include $configFile;
$rawConfig = require $configFile;
if ($config instanceof \Closure) {
$config = $config();
if (!is_array($rawConfig)) {
error_log('[Flysystem Offload] Config file must return an array');
return [];
}
if (! is_array($config)) {
throw new UnexpectedValueException(
sprintf('El archivo de configuración debe retornar un array. Archivo: %s', $configFile)
);
error_log('[Flysystem Offload] Loaded config from file: ' . print_r($rawConfig, true));
// Convertir estructura del archivo al formato esperado
return self::normalizeFileConfig($rawConfig);
}
/**
* Normaliza la configuración del archivo al formato esperado
*
* @param array $rawConfig Configuración raw del archivo
* @return array Configuración normalizada
*/
private static function normalizeFileConfig(array $rawConfig): array
{
$config = [];
// ✅ PRESERVAR las secciones 'stream' y 'uploads' del archivo base
if (isset($rawConfig['stream'])) {
$config['stream'] = $rawConfig['stream'];
}
if (isset($rawConfig['uploads'])) {
$config['uploads'] = $rawConfig['uploads'];
}
if (isset($rawConfig['admin'])) {
$config['admin'] = $rawConfig['admin'];
}
// Extraer 'provider'
if (isset($rawConfig['provider'])) {
$config['provider'] = $rawConfig['provider'];
}
// Extraer visibility global si existe
if (isset($rawConfig['visibility'])) {
$config['visibility'] = $rawConfig['visibility'];
}
// Extraer prefijo global si existe
if (isset($rawConfig['stream']['root_prefix'])) {
$config['prefix'] = $rawConfig['stream']['root_prefix'];
}
// Normalizar configuración según el driver/provider
$provider = $config['provider'] ?? '';
switch ($provider) {
case 's3':
if (isset($rawConfig['s3'])) {
$s3Config = $rawConfig['s3'];
$config['key'] = $s3Config['key'] ?? '';
$config['secret'] = $s3Config['secret'] ?? '';
$config['region'] = $s3Config['region'] ?? 'us-east-1';
$config['bucket'] = $s3Config['bucket'] ?? '';
$config['endpoint'] = $s3Config['endpoint'] ?? '';
$config['use_path_style_endpoint'] = $s3Config['use_path_style_endpoint'] ?? false;
// Usar prefix específico de S3 si existe, sino el global
if (isset($s3Config['prefix'])) {
$config['prefix'] = $s3Config['prefix'];
}
// CDN URL desde uploads.base_url
if (isset($rawConfig['uploads']['base_url'])) {
$config['cdn_url'] = $rawConfig['uploads']['base_url'];
}
// Preservar configuración completa de S3
$config['s3'] = $s3Config;
}
break;
case 'webdav':
if (isset($rawConfig['webdav'])) {
$webdavConfig = $rawConfig['webdav'];
// Determinar base_uri desde endpoint o base_url
$config['base_uri'] = $webdavConfig['endpoint']
?? $webdavConfig['base_url']
?? '';
// Credenciales
if (isset($webdavConfig['credentials'])) {
$config['username'] = $webdavConfig['credentials']['username'] ?? '';
$config['password'] = $webdavConfig['credentials']['password'] ?? '';
$config['auth_type'] = $webdavConfig['credentials']['auth_type'] ?? 'basic';
}
// Prefix específico de WebDAV
if (isset($webdavConfig['prefix'])) {
$config['prefix'] = $webdavConfig['prefix'];
}
// Visibility específica de WebDAV
if (isset($webdavConfig['default_visibility'])) {
$config['visibility'] = $webdavConfig['default_visibility'];
}
// Permisos (usar valores por defecto si no están definidos)
$config['file_public'] = $webdavConfig['file_public'] ?? '0644';
$config['file_private'] = $webdavConfig['file_private'] ?? '0600';
$config['dir_public'] = $webdavConfig['dir_public'] ?? '0755';
$config['dir_private'] = $webdavConfig['dir_private'] ?? '0700';
// Preservar configuración completa de WebDAV
$config['webdav'] = $webdavConfig;
}
break;
case 'sftp':
if (isset($rawConfig['sftp'])) {
$sftpConfig = $rawConfig['sftp'];
$config['host'] = $sftpConfig['host'] ?? '';
$config['port'] = $sftpConfig['port'] ?? 22;
$config['username'] = $sftpConfig['username'] ?? '';
$config['password'] = $sftpConfig['password'] ?? '';
$config['private_key'] = $sftpConfig['private_key'] ?? '';
$config['passphrase'] = $sftpConfig['passphrase'] ?? '';
$config['root'] = $sftpConfig['root'] ?? '/';
$config['timeout'] = $sftpConfig['timeout'] ?? 10;
// Permisos
$config['file_public'] = $sftpConfig['file_public'] ?? '0644';
$config['file_private'] = $sftpConfig['file_private'] ?? '0600';
$config['dir_public'] = $sftpConfig['dir_public'] ?? '0755';
$config['dir_private'] = $sftpConfig['dir_private'] ?? '0700';
// Preservar configuración completa de SFTP
$config['sftp'] = $sftpConfig;
}
break;
}
error_log('[Flysystem Offload] Normalized file config: ' . print_r($config, true));
return $config;
}
private function resolveFirstExisting(array $files): ?string {
foreach ($files as $file) {
if ($file && is_readable($file)) {
return $file;
/**
* Carga configuración desde la base de datos de WordPress
*
* @return array
*/
private static function loadFromDatabase(): array
{
$provider = get_option('flysystem_offload_provider', '');
if (empty($provider)) {
error_log('[Flysystem Offload] No provider found in database');
return [];
}
error_log('[Flysystem Offload] Loading config from database for provider: ' . $provider);
$config = [
'provider' => $provider,
'prefix' => get_option('flysystem_offload_prefix', ''),
];
// Cargar configuración específica del proveedor
switch ($provider) {
case 's3':
$config = array_merge($config, self::loadS3Config());
break;
case 'webdav':
$config = array_merge($config, self::loadWebdavConfig());
break;
case 'sftp':
$config = array_merge($config, self::loadSftpConfig());
break;
case 'gcs':
$config = array_merge($config, self::loadGcsConfig());
break;
case 'azure':
$config = array_merge($config, self::loadAzureConfig());
break;
case 'dropbox':
$config = array_merge($config, self::loadDropboxConfig());
break;
case 'google-drive':
$config = array_merge($config, self::loadGoogleDriveConfig());
break;
case 'onedrive':
$config = array_merge($config, self::loadOneDriveConfig());
break;
}
error_log('[Flysystem Offload] Database config loaded: ' . print_r($config, true));
return $config;
}
/**
* Carga configuración de S3
*
* @return array
*/
private static function loadS3Config(): array
{
return [
'key' => get_option('flysystem_offload_s3_key', ''),
'secret' => get_option('flysystem_offload_s3_secret', ''),
'region' => get_option('flysystem_offload_s3_region', 'us-east-1'),
'bucket' => get_option('flysystem_offload_s3_bucket', ''),
'endpoint' => get_option('flysystem_offload_s3_endpoint', ''),
'use_path_style_endpoint' => (bool) get_option('flysystem_offload_s3_path_style', false),
'cdn_url' => get_option('flysystem_offload_s3_cdn_url', ''),
];
}
/**
* Carga configuración de WebDAV
*
* @return array
*/
private static function loadWebdavConfig(): array
{
return [
'base_uri' => get_option('flysystem_offload_webdav_base_uri', ''),
'username' => get_option('flysystem_offload_webdav_username', ''),
'password' => get_option('flysystem_offload_webdav_password', ''),
'auth_type' => get_option('flysystem_offload_webdav_auth_type', 'basic'),
'prefix' => get_option('flysystem_offload_webdav_prefix', ''),
'file_public' => self::normalizePermissionFromDb(
get_option('flysystem_offload_webdav_file_public', '0644')
),
'file_private' => self::normalizePermissionFromDb(
get_option('flysystem_offload_webdav_file_private', '0600')
),
'dir_public' => self::normalizePermissionFromDb(
get_option('flysystem_offload_webdav_dir_public', '0755')
),
'dir_private' => self::normalizePermissionFromDb(
get_option('flysystem_offload_webdav_dir_private', '0700')
),
];
}
/**
* Carga configuración de SFTP
*
* @return array
*/
private static function loadSftpConfig(): array
{
return [
'host' => get_option('flysystem_offload_sftp_host', ''),
'port' => (int) get_option('flysystem_offload_sftp_port', 22),
'username' => get_option('flysystem_offload_sftp_username', ''),
'password' => get_option('flysystem_offload_sftp_password', ''),
'private_key' => get_option('flysystem_offload_sftp_private_key', ''),
'passphrase' => get_option('flysystem_offload_sftp_passphrase', ''),
'root' => get_option('flysystem_offload_sftp_root', '/'),
'timeout' => (int) get_option('flysystem_offload_sftp_timeout', 10),
'file_public' => self::normalizePermissionFromDb(
get_option('flysystem_offload_sftp_file_public', '0644')
),
'file_private' => self::normalizePermissionFromDb(
get_option('flysystem_offload_sftp_file_private', '0600')
),
'dir_public' => self::normalizePermissionFromDb(
get_option('flysystem_offload_sftp_dir_public', '0755')
),
'dir_private' => self::normalizePermissionFromDb(
get_option('flysystem_offload_sftp_dir_private', '0700')
),
];
}
/**
* Carga configuración de Google Cloud Storage
*
* @return array
*/
private static function loadGcsConfig(): array
{
return [
'project_id' => get_option('flysystem_offload_gcs_project_id', ''),
'key_file' => get_option('flysystem_offload_gcs_key_file', ''),
'bucket' => get_option('flysystem_offload_gcs_bucket', ''),
];
}
/**
* Carga configuración de Azure Blob Storage
*
* @return array
*/
private static function loadAzureConfig(): array
{
return [
'account_name' => get_option('flysystem_offload_azure_account_name', ''),
'account_key' => get_option('flysystem_offload_azure_account_key', ''),
'container' => get_option('flysystem_offload_azure_container', ''),
];
}
/**
* Carga configuración de Dropbox
*
* @return array
*/
private static function loadDropboxConfig(): array
{
return [
'access_token' => get_option('flysystem_offload_dropbox_access_token', ''),
];
}
/**
* Carga configuración de Google Drive
*
* @return array
*/
private static function loadGoogleDriveConfig(): array
{
return [
'client_id' => get_option('flysystem_offload_gdrive_client_id', ''),
'client_secret' => get_option('flysystem_offload_gdrive_client_secret', ''),
'refresh_token' => get_option('flysystem_offload_gdrive_refresh_token', ''),
];
}
/**
* Carga configuración de OneDrive
*
* @return array
*/
private static function loadOneDriveConfig(): array
{
return [
'client_id' => get_option('flysystem_offload_onedrive_client_id', ''),
'client_secret' => get_option('flysystem_offload_onedrive_client_secret', ''),
'refresh_token' => get_option('flysystem_offload_onedrive_refresh_token', ''),
];
}
/**
* Normaliza un permiso desde la base de datos
* Mantiene como string para que el adaptador lo convierta correctamente
*
* @param mixed $permission Permiso desde la BD
* @return string|int Permiso normalizado
*/
private static function normalizePermissionFromDb($permission)
{
if (is_int($permission)) {
return $permission;
}
if (is_string($permission)) {
$permission = trim($permission);
// Si ya tiene el formato correcto, retornar
if (preg_match('/^0[0-7]{3}$/', $permission)) {
return $permission;
}
// Si es solo dígitos sin el 0 inicial, añadirlo
if (preg_match('/^[0-7]{3}$/', $permission)) {
return '0' . $permission;
}
}
return null;
// Valor por defecto
return '0644';
}
/**
* Normaliza la configuración completa
*
* @param array $config Configuración a normalizar
* @return array Configuración normalizada
*/
private static function normalize(array $config): array
{
// Eliminar valores vacíos excepto 0 y false
$config = array_filter($config, function ($value) {
return $value !== '' && $value !== null && $value !== [];
});
// Normalizar provider
if (isset($config['provider'])) {
$config['provider'] = strtolower(trim($config['provider']));
}
// Normalizar prefix (eliminar barras al inicio y final)
if (isset($config['prefix'])) {
$config['prefix'] = trim($config['prefix'], '/');
}
// Asegurar 'stream' existe (no sobrescribir si viene del archivo o BD)
if (!isset($config['stream']) || !is_array($config['stream'])) {
$config['stream'] = [];
}
// Si no hay protocolo explícito en 'stream', usar el provider como protocolo por defecto
$config['stream']['protocol'] = $config['stream']['protocol'] ??
($config['provider'] ?? 'flysystem');
// Si existe una sección específica del provider que define un protocolo (ej. webdav.stream.protocol), respetarla
$provider = $config['provider'] ?? null;
if ($provider && isset($config[$provider]) && is_array($config[$provider])) {
if (isset($config[$provider]['stream']['protocol'])) {
$config['stream']['protocol'] = $config[$provider]['stream']['protocol'];
}
}
// Asegurar valores por defecto para 'stream' y 'uploads' si no vinieron en la configuración
$config['stream']['root_prefix'] = $config['stream']['root_prefix'] ?? '';
$config['stream']['host'] = $config['stream']['host'] ?? '';
if (!isset($config['uploads']) || !is_array($config['uploads'])) {
$config['uploads'] = [
'base_url' => content_url('uploads'),
'delete_remote' => true,
'prefer_local_for_missing' => false,
];
}
// Si el provider es webdav y no tenemos uploads.base_url, intentar construirla desde webdav/base_uri + prefix
if (($provider === 'webdav') && (!isset($config['uploads']['base_url']) || empty($config['uploads']['base_url']))) {
$baseUri = $config['base_uri'] ?? ($config['webdav']['base_url'] ?? ($config['webdav']['endpoint'] ?? ''));
$prefix = $config['prefix'] ?? ($config['webdav']['prefix'] ?? '');
if (!empty($baseUri)) {
$baseUri = rtrim($baseUri, '/');
$config['uploads']['base_url'] = $prefix !== '' ? $baseUri . '/' . ltrim($prefix, '/') : $baseUri;
}
}
error_log('[Flysystem Offload] Final normalized config: ' . print_r($config, true));
return $config;
}
/**
* Valida que la configuración sea válida
*
* @param array $config Configuración a validar
* @return bool
* @throws InvalidArgumentException Si la configuración es inválida
*/
public static function validate(array $config): bool
{
if (empty($config['provider'])) {
throw new InvalidArgumentException('Provider is required');
}
$provider = $config['provider'];
error_log('[Flysystem Offload] Validating config for provider: ' . $provider);
// Validar configuración específica del proveedor
switch ($provider) {
case 's3':
self::validateS3Config($config);
break;
case 'webdav':
self::validateWebdavConfig($config);
break;
case 'sftp':
self::validateSftpConfig($config);
break;
}
error_log('[Flysystem Offload] Config validation passed');
return true;
}
/**
* Valida configuración de S3
*
* @param array $config
* @throws InvalidArgumentException
*/
private static function validateS3Config(array $config): void
{
$required = ['key', 'secret', 'region', 'bucket'];
foreach ($required as $key) {
if (empty($config[$key])) {
throw new InvalidArgumentException("S3 {$key} is required");
}
}
}
/**
* Valida configuración de WebDAV
*
* @param array $config
* @throws InvalidArgumentException
*/
private static function validateWebdavConfig(array $config): void
{
if (empty($config['base_uri'])) {
throw new InvalidArgumentException('WebDAV base_uri is required');
}
if (!filter_var($config['base_uri'], FILTER_VALIDATE_URL)) {
throw new InvalidArgumentException('WebDAV base_uri must be a valid URL');
}
}
/**
* Valida configuración de SFTP
*
* @param array $config
* @throws InvalidArgumentException
*/
private static function validateSftpConfig(array $config): void
{
if (empty($config['host'])) {
throw new InvalidArgumentException('SFTP host is required');
}
if (empty($config['username'])) {
throw new InvalidArgumentException('SFTP username is required');
}
if (empty($config['password']) && empty($config['private_key'])) {
throw new InvalidArgumentException(
'SFTP password or private_key is required'
);
}
}
}

View File

@ -1,10 +1,39 @@
<?php
declare(strict_types=1);
namespace FlysystemOffload\Filesystem;
use League\Flysystem\FilesystemAdapter;
interface AdapterInterface {
public function createAdapter(array $config): FilesystemAdapter;
}
<?php
declare(strict_types=1);
namespace FlysystemOffload\Filesystem;
use League\Flysystem\FilesystemAdapter;
/**
* Interfaz para adaptadores de Flysystem Offload
*
* Todos los adaptadores deben implementar esta interfaz para garantizar
* consistencia en la creación y configuración.
*/
interface AdapterInterface
{
/**
* Crea y configura el adaptador de Flysystem
*
* @param array $config Configuración del adaptador
* @return FilesystemAdapter Instancia del adaptador configurado
* @throws \InvalidArgumentException Si la configuración es inválida
*/
public function createAdapter(array $config): FilesystemAdapter;
/**
* Obtiene las claves de configuración requeridas
*
* @return array Lista de claves requeridas
*/
public function getRequiredConfigKeys(): array;
/**
* Obtiene las claves de configuración opcionales
*
* @return array Lista de claves opcionales
*/
public function getOptionalConfigKeys(): array;
}

View File

@ -0,0 +1,515 @@
<?php
declare(strict_types=1);
namespace FlysystemOffload\Filesystem\Adapters;
use League\Flysystem\FilesystemAdapter;
use League\Flysystem\Config;
use League\Flysystem\FileAttributes;
use League\Flysystem\DirectoryAttributes;
use League\Flysystem\UnableToReadFile;
use League\Flysystem\UnableToWriteFile;
use League\Flysystem\UnableToDeleteFile;
use League\Flysystem\UnableToCreateDirectory;
use League\Flysystem\UnableToSetVisibility;
use League\Flysystem\UnableToRetrieveMetadata;
use League\Flysystem\Visibility;
use League\Flysystem\UnixVisibility\PortableVisibilityConverter;
use Sabre\DAV\Client;
class WebdavAdapter implements FilesystemAdapter
{
private Client $client;
private string $prefix;
private PortableVisibilityConverter $visibilityConverter;
private bool $baseDirectoryEnsured = false;
public function __construct(
Client $client,
string $prefix = '',
?PortableVisibilityConverter $visibilityConverter = null
) {
$this->client = $client;
$this->prefix = trim($prefix, '/');
$this->visibilityConverter = $visibilityConverter ?? new PortableVisibilityConverter();
error_log(sprintf(
'[WebdavAdapter] Initialized with prefix: "%s"',
$this->prefix
));
}
/**
* Asegurar que el directorio base (prefix) existe
* Se ejecuta lazy (solo cuando se necesita)
*/
private function ensureBaseDirectoryExists(): void
{
if ($this->baseDirectoryEnsured || $this->prefix === '') {
return;
}
error_log('[WebdavAdapter] Ensuring base directory exists...');
// Dividir el prefix en partes (sin slashes iniciales/finales)
$parts = array_filter(explode('/', trim($this->prefix, '/')));
$path = '';
foreach ($parts as $part) {
// Construir path RELATIVO (sin slash inicial)
$path .= ($path === '' ? '' : '/') . $part;
try {
// Intentar verificar si existe (sin slash inicial = relativo al base_uri)
$this->client->propFind($path, ['{DAV:}resourcetype'], 0);
error_log(sprintf('[WebdavAdapter] Directory exists: "%s"', $path));
} catch (\Exception $e) {
// No existe, crear
error_log(sprintf('[WebdavAdapter] Directory does not exist, creating: "%s"', $path));
try {
// IMPORTANTE: Sin slash inicial = relativo al base_uri
$response = $this->client->request('MKCOL', $path);
// Verificar el código de estado
$statusCode = $response['statusCode'] ?? 0;
if ($statusCode >= 200 && $statusCode < 300) {
// Éxito (201 Created)
error_log(sprintf('[WebdavAdapter] Created directory: "%s", status: %d', $path, $statusCode));
} elseif ($statusCode === 405) {
// 405 Method Not Allowed = ya existe
error_log(sprintf('[WebdavAdapter] Directory already exists: "%s"', $path));
} else {
// Error
$errorMsg = sprintf('Failed to create directory "%s", status: %d', $path, $statusCode);
error_log(sprintf('[WebdavAdapter] %s', $errorMsg));
throw new \RuntimeException($errorMsg);
}
} catch (\Exception $e2) {
// Verificar si el error es porque ya existe (405)
if (strpos($e2->getMessage(), '405') !== false) {
error_log(sprintf('[WebdavAdapter] Directory already exists (405): "%s"', $path));
} else {
error_log(sprintf(
'[WebdavAdapter] Failed to create directory: "%s", error: %s',
$path,
$e2->getMessage()
));
throw $e2;
}
}
}
}
$this->baseDirectoryEnsured = true;
error_log('[WebdavAdapter] Base directory ensured successfully');
}
/**
* Agregar el prefix a la ruta
*
* @param string $path
* @return string Ruta con prefix (RELATIVA, sin slash inicial)
*/
private function prefixPath(string $path): string
{
$path = trim($path, '/');
if ($this->prefix === '') {
$result = $path;
} else {
$result = trim($this->prefix, '/') . ($path === '' ? '' : '/' . $path);
}
error_log(sprintf('[WebdavAdapter] prefixPath - input: "%s", output: "%s"', $path, $result));
return $result;
}
public function fileExists(string $path): bool
{
try {
$response = $this->client->propFind($this->prefixPath($path), ['{DAV:}resourcetype'], 0);
$exists = !empty($response);
error_log(sprintf(
'[WebdavAdapter] fileExists - path: "%s", exists: %s',
$path,
$exists ? 'true' : 'false'
));
return $exists;
} catch (\Exception $e) {
error_log(sprintf(
'[WebdavAdapter] fileExists error - path: "%s", error: %s',
$path,
$e->getMessage()
));
return false;
}
}
public function directoryExists(string $path): bool
{
return $this->fileExists($path);
}
public function write(string $path, string $contents, Config $config): void
{
// ✅ CRÍTICO: Asegurar que el directorio base existe ANTES de escribir
$this->ensureBaseDirectoryExists();
$prefixedPath = $this->prefixPath($path);
error_log(sprintf(
'[WebdavAdapter] write - path: "%s", prefixed: "%s", size: %d bytes',
$path,
$prefixedPath,
strlen($contents)
));
// Asegurar que el directorio padre del archivo existe
$dirname = dirname($path);
if ($dirname !== '.' && $dirname !== '') {
$this->ensureDirectoryExists($dirname, $config);
}
try {
$response = $this->client->request('PUT', $prefixedPath, $contents);
if ($response['statusCode'] >= 400) {
throw UnableToWriteFile::atLocation(
$path,
"WebDAV returned status {$response['statusCode']}"
);
}
error_log(sprintf(
'[WebdavAdapter] write success - path: "%s", status: %d',
$path,
$response['statusCode']
));
} catch (\Exception $e) {
error_log(sprintf(
'[WebdavAdapter] write error - path: "%s", error: %s',
$path,
$e->getMessage()
));
throw UnableToWriteFile::atLocation($path, $e->getMessage(), $e);
}
}
public function writeStream(string $path, $contents, Config $config): void
{
$streamContents = stream_get_contents($contents);
if ($streamContents === false) {
throw UnableToWriteFile::atLocation($path, 'Unable to read from stream');
}
$this->write($path, $streamContents, $config);
}
public function read(string $path): string
{
try {
$response = $this->client->request('GET', $this->prefixPath($path));
if ($response['statusCode'] >= 400) {
throw UnableToReadFile::fromLocation(
$path,
"WebDAV returned status {$response['statusCode']}"
);
}
return $response['body'];
} catch (\Exception $e) {
throw UnableToReadFile::fromLocation($path, $e->getMessage(), $e);
}
}
public function readStream(string $path)
{
$resource = fopen('php://temp', 'r+');
if ($resource === false) {
throw UnableToReadFile::fromLocation($path, 'Unable to create temp stream');
}
fwrite($resource, $this->read($path));
rewind($resource);
return $resource;
}
public function delete(string $path): void
{
try {
$response = $this->client->request('DELETE', $this->prefixPath($path));
if ($response['statusCode'] >= 400 && $response['statusCode'] !== 404) {
throw UnableToDeleteFile::atLocation(
$path,
"WebDAV returned status {$response['statusCode']}"
);
}
error_log(sprintf(
'[WebdavAdapter] delete success - path: "%s"',
$path
));
} catch (\Exception $e) {
error_log(sprintf(
'[WebdavAdapter] delete error - path: "%s", error: %s',
$path,
$e->getMessage()
));
throw UnableToDeleteFile::atLocation($path, $e->getMessage(), $e);
}
}
public function deleteDirectory(string $path): void
{
$this->delete($path);
}
public function createDirectory(string $path, Config $config): void
{
// ✅ Asegurar que el directorio base existe
$this->ensureBaseDirectoryExists();
$prefixedPath = $this->prefixPath($path);
error_log(sprintf(
'[WebdavAdapter] createDirectory - path: "%s", prefixed: "%s"',
$path,
$prefixedPath
));
try {
$response = $this->client->request('MKCOL', $prefixedPath);
// 405 significa que el directorio ya existe
if ($response['statusCode'] >= 400 && $response['statusCode'] !== 405) {
throw UnableToCreateDirectory::atLocation(
$path,
"WebDAV returned status {$response['statusCode']}"
);
}
error_log(sprintf(
'[WebdavAdapter] createDirectory success - path: "%s", status: %d',
$path,
$response['statusCode']
));
} catch (\Exception $e) {
error_log(sprintf(
'[WebdavAdapter] createDirectory error - path: "%s", error: %s',
$path,
$e->getMessage()
));
// 405 significa que ya existe, no es un error
if (strpos($e->getMessage(), '405') === false) {
throw UnableToCreateDirectory::atLocation($path, $e->getMessage(), $e);
} else {
error_log(sprintf('[WebdavAdapter] Directory already exists: "%s"', $path));
}
}
}
private function ensureDirectoryExists(string $dirname, Config $config): void
{
if ($dirname === '' || $dirname === '.') {
return;
}
error_log(sprintf('[WebdavAdapter] ensureDirectoryExists - dirname: "%s"', $dirname));
$parts = array_filter(explode('/', trim($dirname, '/')));
$path = '';
foreach ($parts as $part) {
$path .= ($path !== '' ? '/' : '') . $part;
error_log(sprintf('[WebdavAdapter] Checking/creating directory: "%s"', $path));
// Intentar crear directamente
try {
$this->createDirectory($path, $config);
} catch (UnableToCreateDirectory $e) {
// Si falla y no es porque ya existe, propagar el error
if (strpos($e->getMessage(), '405') === false && !$this->directoryExists($path)) {
error_log(sprintf(
'[WebdavAdapter] Failed to ensure directory exists: "%s", error: %s',
$path,
$e->getMessage()
));
throw $e;
}
}
}
}
public function setVisibility(string $path, string $visibility): void
{
// WebDAV no soporta visibilidad de forma nativa
}
public function visibility(string $path): FileAttributes
{
return new FileAttributes($path, null, Visibility::PUBLIC);
}
public function mimeType(string $path): FileAttributes
{
try {
$response = $this->client->propFind(
$this->prefixPath($path),
['{DAV:}getcontenttype'],
0
);
$mimeType = $response['{DAV:}getcontenttype'] ?? null;
return new FileAttributes($path, null, null, null, $mimeType);
} catch (\Exception $e) {
throw UnableToRetrieveMetadata::mimeType($path, $e->getMessage(), $e);
}
}
public function lastModified(string $path): FileAttributes
{
try {
$response = $this->client->propFind(
$this->prefixPath($path),
['{DAV:}getlastmodified'],
0
);
$lastModified = $response['{DAV:}getlastmodified'] ?? null;
$timestamp = $lastModified ? strtotime($lastModified) : null;
return new FileAttributes($path, null, null, $timestamp);
} catch (\Exception $e) {
throw UnableToRetrieveMetadata::lastModified($path, $e->getMessage(), $e);
}
}
public function fileSize(string $path): FileAttributes
{
try {
$response = $this->client->propFind(
$this->prefixPath($path),
['{DAV:}getcontentlength'],
0
);
$size = isset($response['{DAV:}getcontentlength'])
? (int) $response['{DAV:}getcontentlength']
: null;
return new FileAttributes($path, $size);
} catch (\Exception $e) {
throw UnableToRetrieveMetadata::fileSize($path, $e->getMessage(), $e);
}
}
public function listContents(string $path, bool $deep): iterable
{
try {
$response = $this->client->propFind(
$this->prefixPath($path),
[
'{DAV:}resourcetype',
'{DAV:}getcontentlength',
'{DAV:}getlastmodified',
'{DAV:}getcontenttype',
],
$deep ? \Sabre\DAV\Client::DEPTH_INFINITY : 1
);
foreach ($response as $itemPath => $properties) {
$relativePath = $this->removePrefix($itemPath);
if ($relativePath === $path || $relativePath === '') {
continue;
}
$isDirectory = isset($properties['{DAV:}resourcetype'])
&& strpos($properties['{DAV:}resourcetype']->serialize(new \Sabre\Xml\Writer()), 'collection') !== false;
if ($isDirectory) {
yield new DirectoryAttributes($relativePath);
} else {
$size = isset($properties['{DAV:}getcontentlength'])
? (int) $properties['{DAV:}getcontentlength']
: null;
$lastModified = isset($properties['{DAV:}getlastmodified'])
? strtotime($properties['{DAV:}getlastmodified'])
: null;
$mimeType = $properties['{DAV:}getcontenttype'] ?? null;
yield new FileAttributes(
$relativePath,
$size,
null,
$lastModified,
$mimeType
);
}
}
} catch (\Exception $e) {
error_log(sprintf(
'[WebdavAdapter] listContents error - path: "%s", error: %s',
$path,
$e->getMessage()
));
}
}
private function removePrefix(string $path): string
{
$path = '/' . trim($path, '/');
if ($this->prefix !== '') {
$prefixWithSlash = '/' . $this->prefix . '/';
if (str_starts_with($path, $prefixWithSlash)) {
return substr($path, strlen($prefixWithSlash));
}
}
return ltrim($path, '/');
}
public function move(string $source, string $destination, Config $config): void
{
$this->ensureBaseDirectoryExists();
try {
$this->client->request(
'MOVE',
$this->prefixPath($source),
null,
['Destination' => $this->prefixPath($destination)]
);
} catch (\Exception $e) {
throw new \RuntimeException("Unable to move file from {$source} to {$destination}: " . $e->getMessage(), 0, $e);
}
}
public function copy(string $source, string $destination, Config $config): void
{
$this->ensureBaseDirectoryExists();
try {
$this->client->request(
'COPY',
$this->prefixPath($source),
null,
['Destination' => $this->prefixPath($destination)]
);
} catch (\Exception $e) {
throw new \RuntimeException("Unable to copy file from {$source} to {$destination}: " . $e->getMessage(), 0, $e);
}
}
}

View File

@ -1,27 +1,287 @@
<?php
declare(strict_types=1);
namespace FlysystemOffload\Filesystem;
use FlysystemOffload\Filesystem\Adapters\S3Adapter;
use FlysystemOffload\Filesystem\Adapters\WebdavAdapter;
use League\Flysystem\Filesystem;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\Visibility;
use League\Flysystem\FilesystemAdapter;
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
use League\Flysystem\UnixVisibility\PortableVisibilityConverter;
use Aws\S3\S3Client;
use Sabre\DAV\Client as SabreClient;
use InvalidArgumentException;
final class FilesystemFactory {
public function make(array $config): FilesystemOperator {
$driver = $config['driver'] ?? 's3';
/**
* Factory para crear instancias de Filesystem
*/
class FilesystemFactory
{
/**
* Crea una instancia de Filesystem basada en la configuración
*
* @param array $config Configuración del filesystem
* @return FilesystemOperator
* @throws InvalidArgumentException Si el provider no es válido
*/
public static function create(array $config): FilesystemOperator
{
$provider = $config['provider'] ?? '';
$adapter = match ($driver) {
's3' => (new S3Adapter())->createAdapter($config),
default => throw new \InvalidArgumentException(sprintf('Driver de Flysystem no soportado: "%s".', $driver)),
if (empty($provider)) {
throw new InvalidArgumentException('Provider is required in configuration');
}
error_log('[Flysystem Offload] Creating filesystem for provider: ' . $provider);
$adapter = self::createAdapter($provider, $config);
return new Filesystem($adapter);
}
/**
* Crea el adaptador según el provider
*
* @param string $provider Nombre del provider
* @param array $config Configuración completa
* @return FilesystemAdapter
* @throws InvalidArgumentException Si el provider no es soportado
*/
private static function createAdapter(string $provider, array $config): FilesystemAdapter
{
error_log('[Flysystem Offload] Creating adapter for: ' . $provider);
return match ($provider) {
's3' => self::createS3Adapter($config),
'webdav' => self::createWebdavAdapter($config),
'sftp' => self::createSftpAdapter($config),
'gcs' => self::createGcsAdapter($config),
'azure' => self::createAzureAdapter($config),
'dropbox' => self::createDropboxAdapter($config),
'google-drive' => self::createGoogleDriveAdapter($config),
'onedrive' => self::createOneDriveAdapter($config),
default => throw new InvalidArgumentException("Unsupported provider: {$provider}"),
};
}
$filesystemConfig = [
'visibility' => $config['visibility'] ?? Visibility::PUBLIC,
'directory_visibility' => $config['visibility'] ?? Visibility::PUBLIC,
/**
* Crea adaptador S3
*
* @param array $config
* @return AwsS3V3Adapter
*/
private static function createS3Adapter(array $config): AwsS3V3Adapter
{
$clientConfig = [
'credentials' => [
'key' => $config['key'] ?? '',
'secret' => $config['secret'] ?? '',
],
'region' => $config['region'] ?? 'us-east-1',
'version' => 'latest',
];
return new Filesystem($adapter, $filesystemConfig);
if (!empty($config['endpoint'])) {
$clientConfig['endpoint'] = $config['endpoint'];
}
if (!empty($config['use_path_style_endpoint'])) {
$clientConfig['use_path_style_endpoint'] = true;
}
$client = new S3Client($clientConfig);
$bucket = $config['bucket'] ?? '';
$prefix = $config['prefix'] ?? '';
error_log(sprintf(
'[Flysystem Offload] S3 adapter created - bucket: %s, prefix: %s',
$bucket,
$prefix
));
return new AwsS3V3Adapter(
$client,
$bucket,
$prefix
);
}
/**
* Crea adaptador WebDAV
*
* @param array $config
* @return WebdavAdapter
*/
private static function createWebdavAdapter(array $config): WebdavAdapter
{
$baseUri = $config['base_uri'] ?? '';
$username = $config['username'] ?? '';
$password = $config['password'] ?? '';
$authType = $config['auth_type'] ?? 'basic';
$prefix = $config['prefix'] ?? '';
if (empty($baseUri)) {
throw new InvalidArgumentException('WebDAV base_uri is required');
}
error_log(sprintf(
'[Flysystem Offload] Creating WebDAV client - base_uri: %s, username: %s, prefix: %s',
$baseUri,
$username,
$prefix
));
// Configurar cliente Sabre
$settings = [
'baseUri' => rtrim($baseUri, '/') . '/',
];
// Agregar autenticación si está configurada
if (!empty($username)) {
$settings['userName'] = $username;
$settings['password'] = $password;
// Mapear auth_type a constante de Sabre
$authTypeConstant = match(strtolower($authType)) {
'digest' => \Sabre\DAV\Client::AUTH_DIGEST,
'ntlm' => \Sabre\DAV\Client::AUTH_NTLM,
default => \Sabre\DAV\Client::AUTH_BASIC,
};
$settings['authType'] = $authTypeConstant;
}
$client = new SabreClient($settings);
// Normalizar permisos a formato int octal
$filePublic = self::normalizePermission($config['file_public'] ?? 0644);
$filePrivate = self::normalizePermission($config['file_private'] ?? 0600);
$dirPublic = self::normalizePermission($config['dir_public'] ?? 0755);
$dirPrivate = self::normalizePermission($config['dir_private'] ?? 0700);
error_log(sprintf(
'[Flysystem Offload] WebDAV permissions - file_public: %o, file_private: %o, dir_public: %o, dir_private: %o',
$filePublic,
$filePrivate,
$dirPublic,
$dirPrivate
));
// Crear converter de visibilidad
$visibilityConverter = new PortableVisibilityConverter(
filePublic: $filePublic,
filePrivate: $filePrivate,
directoryPublic: $dirPublic,
directoryPrivate: $dirPrivate,
defaultForDirectories: 'public'
);
error_log(sprintf(
'[Flysystem Offload] WebDAV adapter created - prefix: %s',
$prefix
));
return new WebdavAdapter($client, $prefix, $visibilityConverter);
}
/**
* Crea adaptador SFTP
*
* @param array $config
* @return FilesystemAdapter
*/
private static function createSftpAdapter(array $config): FilesystemAdapter
{
throw new InvalidArgumentException('SFTP adapter not yet implemented');
}
/**
* Crea adaptador Google Cloud Storage
*
* @param array $config
* @return FilesystemAdapter
*/
private static function createGcsAdapter(array $config): FilesystemAdapter
{
throw new InvalidArgumentException('GCS adapter not yet implemented');
}
/**
* Crea adaptador Azure Blob Storage
*
* @param array $config
* @return FilesystemAdapter
*/
private static function createAzureAdapter(array $config): FilesystemAdapter
{
throw new InvalidArgumentException('Azure adapter not yet implemented');
}
/**
* Crea adaptador Dropbox
*
* @param array $config
* @return FilesystemAdapter
*/
private static function createDropboxAdapter(array $config): FilesystemAdapter
{
throw new InvalidArgumentException('Dropbox adapter not yet implemented');
}
/**
* Crea adaptador Google Drive
*
* @param array $config
* @return FilesystemAdapter
*/
private static function createGoogleDriveAdapter(array $config): FilesystemAdapter
{
throw new InvalidArgumentException('Google Drive adapter not yet implemented');
}
/**
* Crea adaptador OneDrive
*
* @param array $config
* @return FilesystemAdapter
*/
private static function createOneDriveAdapter(array $config): FilesystemAdapter
{
throw new InvalidArgumentException('OneDrive adapter not yet implemented');
}
/**
* Normaliza un permiso a formato int octal
*
* @param string|int $permission Permiso en formato string u octal
* @return int Permiso en formato int octal (ej: 0644)
*/
private static function normalizePermission($permission): int
{
// Si ya es int, retornar tal cual
if (is_int($permission)) {
return $permission;
}
// Si es string, convertir
if (is_string($permission)) {
$permission = trim($permission);
// Si tiene el formato 0xxx, convertir desde octal
if (preg_match('/^0[0-7]{3}$/', $permission)) {
return intval($permission, 8);
}
// Si es solo dígitos sin el 0 inicial, añadirlo y convertir
if (preg_match('/^[0-7]{3}$/', $permission)) {
return intval('0' . $permission, 8);
}
}
// Valor por defecto (0644 en octal = 420 en decimal)
error_log('[Flysystem Offload] Invalid permission format: ' . print_r($permission, true) . ', using default 0644');
return 0644;
}
}

View File

@ -1,235 +1,247 @@
<?php
declare(strict_types=1);
namespace FlysystemOffload\Media;
use FlysystemOffload\Helpers\PathHelper;
use League\Flysystem\FilesystemOperator;
use FlysystemOffload\Plugin;
final class MediaHooks {
private FilesystemOperator $filesystem;
private array $config;
/**
* Hooks para integración con el sistema de medios de WordPress
* Intercepta uploads, generación de URLs, eliminación de archivos, etc.
*/
class MediaHooks
{
private string $provider;
private string $protocol;
private string $streamHost;
private string $streamRootPrefix;
private string $s3Prefix;
private string $host;
private string $rootPrefix;
private string $providerPrefix;
private string $baseUrl;
private string $effectiveBaseUrl;
private string $remoteUrlPathPrefix;
private bool $deleteRemote;
private bool $preferLocal;
public function __construct(FilesystemOperator $filesystem, array $config) {
$this->filesystem = $filesystem;
$this->config = $config;
$this->protocol = (string) $config['stream']['protocol'];
$this->streamRootPrefix = PathHelper::normalize((string) ($config['stream']['root_prefix'] ?? ''));
$this->streamHost = PathHelper::normalize((string) ($config['stream']['host'] ?? 'uploads')) ?: 'uploads';
$this->s3Prefix = PathHelper::normalize((string) ($config['s3']['prefix'] ?? ''));
$this->baseUrl = $this->normaliseBaseUrl((string) ($config['uploads']['base_url'] ?? content_url('uploads')));
$this->remoteUrlPathPrefix = $this->buildRemoteUrlPathPrefix();
$this->effectiveBaseUrl = $this->calculateEffectiveBaseUrl($this->baseUrl, $this->remoteUrlPathPrefix);
$this->deleteRemote = (bool) ($config['uploads']['delete_remote'] ?? true);
$this->preferLocal = (bool) ($config['uploads']['prefer_local_for_missing'] ?? false);
public function __construct(array $config)
{
$this->provider = $config['provider'] ?? 'webdav';
$this->protocol = $config['stream']['protocol'] ?? $this->provider;
$this->host = $config['stream']['host'] ?? '';
$this->rootPrefix = $config['stream']['root_prefix'] ?? '';
$this->providerPrefix = $config['prefix'] ?? '';
$this->baseUrl = $config['uploads']['base_url'] ?? '';
error_log(sprintf(
'[MediaHooks] Initialized - provider: %s, protocol: %s, host: %s, root_prefix: %s, provider_prefix: %s, base_url: %s',
$this->provider,
$this->protocol,
$this->host,
$this->rootPrefix,
$this->providerPrefix,
$this->baseUrl
));
}
public function register(): void {
add_filter('upload_dir', [$this, 'filterUploadDir'], 20);
add_filter('pre_option_upload_path', '__return_false');
add_filter('pre_option_upload_url_path', '__return_false');
add_filter('wp_get_attachment_url', [$this, 'rewriteAttachmentUrl'], 9, 2);
add_filter('image_downsize', [$this, 'filterImageDownsize'], 10, 3);
add_action('delete_attachment', [$this, 'deleteRemoteFiles']);
/**
* Registra los hooks de WordPress
*/
public function registerHooks(): void
{
// Filtros para upload dir
add_filter('upload_dir', [$this, 'filterUploadDir']);
// Filtros para URLs de medios
add_filter('wp_get_attachment_url', [$this, 'filterAttachmentUrl'], 10, 2);
add_filter('wp_calculate_image_srcset', [$this, 'filterImageSrcset'], 10, 5);
// Acciones para manejo de archivos
add_action('wp_generate_attachment_metadata', [$this, 'handleAttachmentMetadata'], 10, 2);
add_action('delete_attachment', [$this, 'handleDeleteAttachment']);
// Filtro para obtener el path correcto
add_filter('get_attached_file', [$this, 'filterAttachedFile'], 10, 2);
}
public function filterUploadDir(array $uploads): array {
$subdir = $uploads['subdir'] ?? '';
$normalizedSubdir = $subdir !== '' ? PathHelper::normalize($subdir) : '';
$streamSubdir = $normalizedSubdir !== '' ? '/' . $normalizedSubdir : '';
$streamBase = sprintf('%s://%s', $this->protocol, $this->streamHost);
$uploads['path'] = $streamBase . $streamSubdir;
$uploads['basedir'] = $streamBase;
$uploads['baseurl'] = rtrim($this->effectiveBaseUrl, '/');
$uploads['url'] = $this->buildPublicUrl($normalizedSubdir);
$uploads['subdir'] = $normalizedSubdir !== '' ? '/' . $normalizedSubdir : '';
$uploads['error'] = false;
$uploads['flysystem_protocol'] = $this->protocol;
$uploads['flysystem_host'] = $this->streamHost;
$uploads['flysystem_root_prefix'] = $this->streamRootPrefix;
/**
* Filtra el directorio de uploads para usar nuestro sistema de archivos
*/
public function filterUploadDir(array $uploads): array
{
// Construir el path con el protocolo correcto
$path = $this->protocol . '://';
if ($this->host) {
$path .= $this->host . '/';
}
if ($this->rootPrefix) {
$path .= ltrim($this->rootPrefix, '/') . '/';
}
// Actualizar uploads array
$uploads['path'] = $path;
$uploads['url'] = rtrim($this->baseUrl, '/') . '/';
$uploads['subdir'] = '';
$uploads['basedir'] = $path;
error_log('[MediaHooks] Upload dir filtered - path: ' . $path . ', url: ' . $uploads['url'] . ', subdir: ' . $uploads['subdir']);
return $uploads;
}
public function rewriteAttachmentUrl(string $url, int $attachmentId): string {
/**
* Filtra la URL de un attachment para usar la URL remota
*/
public function filterAttachmentUrl(string $url, int $attachmentId): string
{
// Obtener metadata del attachment
$metadata = wp_get_attachment_metadata($attachmentId);
if (!$metadata) {
return $url;
}
// Obtener el path del archivo
$file = get_post_meta($attachmentId, '_wp_attached_file', true);
if (! $file) {
if (!$file) {
return $url;
}
$relativePath = PathHelper::normalize($file);
if ($relativePath === '') {
return $url;
}
if ($this->remoteUrlPathPrefix !== '') {
$prefixWithSlash = $this->remoteUrlPathPrefix . '/';
if (str_starts_with($relativePath, $prefixWithSlash)) {
$relativePath = substr($relativePath, strlen($prefixWithSlash));
} elseif ($relativePath === $this->remoteUrlPathPrefix) {
$relativePath = '';
}
}
$remoteUrl = $this->buildPublicUrl($relativePath);
if (! $this->preferLocal) {
return $remoteUrl;
}
try {
if ($this->filesystem->fileExists($this->toRemotePath($relativePath))) {
return $remoteUrl;
}
} catch (\Throwable $exception) {
error_log(sprintf(
'[Flysystem Offload] No se pudo verificar la existencia remota de "%s": %s',
$relativePath,
$exception->getMessage()
));
}
return $url;
// Construir URL remota
$remoteUrl = rtrim($this->baseUrl, '/') . '/' . ltrim($file, '/');
error_log('[MediaHooks] Attachment URL filtered - local: ' . $url . ', remote: ' . $remoteUrl);
return $remoteUrl;
}
public function filterImageDownsize(bool|array $out, int $attachmentId, array|string $size): bool|array {
return false;
/**
* Filtra el srcset de imágenes para usar URLs remotas
*/
public function filterImageSrcset(array $sources, array $sizeArray, string $imageSrc, array $imageMeta, int $attachmentId): array
{
if (empty($sources)) {
return $sources;
}
// Obtener el path base del archivo
$file = get_post_meta($attachmentId, '_wp_attached_file', true);
if (!$file) {
return $sources;
}
// Calcular el directorio base
$basePath = dirname($file);
if ($basePath === '.') {
$basePath = '';
}
// Actualizar cada source con URL remota
foreach ($sources as &$source) {
if (isset($source['url'])) {
$filename = basename($source['url']);
if ($basePath) {
$remotePath = $basePath . '/' . $filename;
} else {
$remotePath = $filename;
}
$source['url'] = rtrim($this->baseUrl, '/') . '/' . ltrim($remotePath, '/');
}
}
return $sources;
}
public function deleteRemoteFiles(int $attachmentId): void {
if (! $this->deleteRemote) {
/**
* Maneja la generación de metadata de attachments
*/
public function handleAttachmentMetadata(array $metadata, int $attachmentId): array
{
error_log('[MediaHooks] Handling attachment metadata for ID: ' . $attachmentId);
// Verificar si se generaron tamaños
if (isset($metadata['sizes']) && is_array($metadata['sizes'])) {
error_log('[MediaHooks] Generated sizes: ' . print_r(array_keys($metadata['sizes']), true));
// Para cada tamaño generado, asegurarse de que se suba al sistema remoto
foreach ($metadata['sizes'] as $sizeName => $sizeData) {
if (isset($sizeData['file'])) {
$this->ensureSizeUploaded($attachmentId, $sizeData['file']);
}
}
}
return $metadata;
}
/**
* Asegura que un tamaño específico se haya subido
*/
private function ensureSizeUploaded(int $attachmentId, string $filename): void
{
// Obtener el directorio de uploads local
$uploadDir = wp_upload_dir();
$localPath = $uploadDir['basedir'] . '/' . $filename;
// Verificar si el archivo local existe
if (!file_exists($localPath)) {
error_log('[MediaHooks] Local size file not found: ' . $localPath);
return;
}
// Construir el path remoto
$remotePath = $this->protocol . ':///' . $filename;
// Copiar el archivo al sistema remoto
if (copy($localPath, $remotePath)) {
error_log('[MediaHooks] Size uploaded successfully: ' . $filename);
} else {
error_log('[MediaHooks] Failed to upload size: ' . $filename);
}
}
$files = $this->gatherAttachmentFiles($attachmentId);
foreach ($files as $file) {
$key = $this->toRemotePath($file);
try {
if ($this->filesystem->fileExists($key)) {
$this->filesystem->delete($key);
/**
* Maneja la eliminación de attachments
*/
public function handleDeleteAttachment(int $attachmentId): void
{
error_log('[MediaHooks] Handling delete attachment: ' . $attachmentId);
// Obtener el archivo principal
$file = get_post_meta($attachmentId, '_wp_attached_file', true);
if ($file) {
$this->deleteRemoteFile($file);
}
// Obtener metadata para eliminar tamaños
$metadata = wp_get_attachment_metadata($attachmentId);
if ($metadata && isset($metadata['sizes'])) {
foreach ($metadata['sizes'] as $sizeData) {
if (isset($sizeData['file'])) {
$this->deleteRemoteFile($sizeData['file']);
}
} catch (\Throwable $exception) {
error_log(sprintf(
'[Flysystem Offload] No se pudo eliminar el archivo remoto "%s": %s',
$key,
$exception->getMessage()
));
}
}
}
/**
* @return list<string>
* Elimina un archivo remoto
*/
private function gatherAttachmentFiles(int $attachmentId): array {
$files = [];
$attachedFile = get_post_meta($attachmentId, '_wp_attached_file', true);
if ($attachedFile) {
$files[] = $attachedFile;
}
$meta = wp_get_attachment_metadata($attachmentId);
if (is_array($meta)) {
if (! empty($meta['file'])) {
$files[] = $meta['file'];
}
if (! empty($meta['sizes']) && is_array($meta['sizes'])) {
$baseDir = $this->dirName($meta['file'] ?? '');
foreach ($meta['sizes'] as $sizeMeta) {
if (! empty($sizeMeta['file'])) {
$files[] = ($baseDir !== '' ? $baseDir . '/' : '') . $sizeMeta['file'];
}
}
private function deleteRemoteFile(string $filename): void
{
$remotePath = $this->protocol . ':///' . ltrim($filename, '/');
if (file_exists($remotePath)) {
if (unlink($remotePath)) {
error_log('[MediaHooks] Remote file deleted: ' . $filename);
} else {
error_log('[MediaHooks] Failed to delete remote file: ' . $filename);
}
} else {
error_log('[MediaHooks] Remote file not found for deletion: ' . $filename);
}
$files = array_filter($files, static fn ($file) => is_string($file) && $file !== '');
return array_values(array_unique($files, SORT_STRING));
}
private function dirName(string $path): string {
$directory = dirname($path);
return $directory === '.' ? '' : $directory;
}
private function toRemotePath(string $file): string {
$segments = [];
if ($this->streamRootPrefix !== '') {
$segments[] = $this->streamRootPrefix;
}
if ($this->streamHost !== '') {
$segments[] = $this->streamHost;
}
$segments[] = $file;
return PathHelper::join(...$segments);
}
private function normaliseBaseUrl(string $baseUrl): string {
$baseUrl = trim($baseUrl);
if ($baseUrl === '') {
$baseUrl = content_url('uploads');
}
return rtrim($baseUrl, '/');
}
private function buildRemoteUrlPathPrefix(): string {
$segments = array_filter(
[$this->s3Prefix, $this->streamRootPrefix, $this->streamHost],
static fn (string $segment): bool => $segment !== ''
);
return PathHelper::join(...$segments);
}
private function calculateEffectiveBaseUrl(string $baseUrl, string $pathPrefix): string {
$baseUrl = rtrim($baseUrl, '/');
if ($pathPrefix === '') {
return $baseUrl;
}
$basePath = trim((string) parse_url($baseUrl, PHP_URL_PATH), '/');
if ($basePath !== '' && str_ends_with($basePath, $pathPrefix)) {
return $baseUrl;
}
return $baseUrl . '/' . $pathPrefix;
}
private function buildPublicUrl(string $relativePath): string {
$base = rtrim($this->effectiveBaseUrl, '/');
if ($relativePath === '') {
return $base;
}
return $base . '/' . PathHelper::normalize($relativePath);
/**
* Filtra el path del archivo adjunto
*/
public function filterAttachedFile(string $file, int $attachmentId): string
{
// Devolver el path con el protocolo correcto
$filename = basename($file);
return $this->protocol . ':///' . $filename;
}
}

View File

@ -1,165 +1,133 @@
<?php
declare(strict_types=1);
namespace FlysystemOffload;
use FlysystemOffload\Admin\HealthCheck;
use FlysystemOffload\Config\ConfigLoader;
use FlysystemOffload\Filesystem\FilesystemFactory;
use FlysystemOffload\Media\MediaHooks;
use FlysystemOffload\Settings\SettingsPage;
use FlysystemOffload\StreamWrapper\FlysystemStreamWrapper;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\Visibility;
use League\Flysystem\Filesystem;
use Throwable;
final class Plugin {
private static bool $bootstrapped = false;
private static ?Plugin $instance = null;
/**
* Clase principal del plugin Flysystem Offload
*/
class Plugin
{
private static ?Filesystem $filesystem = null;
private static array $config = [];
private static bool $initialized = false;
private static ?MediaHooks $mediaHooks = null;
private static ?SettingsPage $settingsPage = null;
private array $config;
private FilesystemOperator $filesystem;
private MediaHooks $mediaHooks;
private ?SettingsPage $settingsPage;
private HealthCheck $healthCheck;
private function __construct(
array $config,
FilesystemOperator $filesystem,
MediaHooks $mediaHooks,
?SettingsPage $settingsPage,
HealthCheck $healthCheck
) {
$this->config = $config;
$this->filesystem = $filesystem;
$this->mediaHooks = $mediaHooks;
$this->settingsPage = $settingsPage;
$this->healthCheck = $healthCheck;
}
public static function bootstrap(): void {
if (self::$bootstrapped) {
/**
* Bootstrap del plugin
*
* @throws Throwable
*/
public static function bootstrap(): void
{
if (self::$initialized) {
return;
}
$configDirectory = \defined('FLYSYSTEM_OFFLOAD_CONFIG_PATH')
? FLYSYSTEM_OFFLOAD_CONFIG_PATH
: \dirname(__DIR__) . '/config';
try {
// Cargar configuración
self::$config = ConfigLoader::load();
$configLoader = new ConfigLoader($configDirectory);
$config = self::normaliseConfig($configLoader->load());
// Validar que haya un proveedor configurado
if (empty(self::$config['provider'])) {
error_log('[Flysystem Offload] No provider configured. Please configure the plugin in Settings > Flysystem Offload');
self::registerAdminNotice('No storage provider configured. Please configure the plugin.');
return;
}
$filesystemFactory = new FilesystemFactory();
$filesystem = $filesystemFactory->make($config);
// Validar configuración
ConfigLoader::validate(self::$config);
FlysystemStreamWrapper::register(
$filesystem,
$config['stream']['protocol'],
$config['stream']['root_prefix'],
$config['visibility']
);
// Crear filesystem
self::$filesystem = FilesystemFactory::create(self::$config);
$mediaHooks = new MediaHooks($filesystem, $config);
$mediaHooks->register();
// Registrar stream wrapper con protocolo desde config
$protocol = (string)(self::$config['stream']['protocol'] ?? 'flysystem');
FlysystemStreamWrapper::register(self::$filesystem, $protocol);
$settingsPage = null;
if (! empty($config['admin']['enabled']) && \is_admin()) {
$settingsPage = new SettingsPage($filesystem, $config);
$settingsPage->register();
error_log('[Flysystem Offload] Stream wrappers: ' . implode(', ', stream_get_wrappers()));
// Registrar hooks de medios - pasar primero $config, luego $filesystem
self::$mediaHooks = new MediaHooks(self::$config, self::$filesystem);
self::$mediaHooks->registerHooks(); // <-- CORRECCIÓN: llamar al método que existe
// Registrar página de ajustes
if (is_admin()) {
self::$settingsPage = new SettingsPage(self::$filesystem, self::$config);
self::$settingsPage->register();
}
self::$initialized = true;
error_log('[Flysystem Offload] Plugin initialized successfully with provider: ' . self::$config['provider']);
} catch (Throwable $e) {
error_log('[Flysystem Offload] Initialization error: ' . $e->getMessage());
error_log('[Flysystem Offload] Stack trace: ' . $e->getTraceAsString());
self::registerAdminNotice(
'Failed to initialize: ' . $e->getMessage()
);
throw $e;
}
}
public static function getFilesystem(): ?Filesystem
{
return self::$filesystem;
}
public static function getConfig(): array
{
return self::$config;
}
public static function isInitialized(): bool
{
return self::$initialized;
}
private static function registerAdminNotice(string $message, string $type = 'error'): void
{
add_action('admin_notices', static function () use ($message, $type): void {
if (!current_user_can('manage_options')) {
return;
}
printf(
'<div class="notice notice-%s"><p><strong>Flysystem Offload:</strong> %s</p></div>',
esc_attr($type),
esc_html($message)
);
});
}
public static function rebuild(): void
{
self::$initialized = false;
self::$filesystem = null;
self::$mediaHooks = null;
self::$settingsPage = null;
$protocol = (string)(self::$config['stream']['protocol'] ?? 'flysystem');
self::$config = [];
// Desregistrar stream wrapper si existe
if (in_array($protocol, stream_get_wrappers(), true)) {
@stream_wrapper_unregister($protocol);
}
$healthCheck = new HealthCheck($filesystem, $config);
$healthCheck->register();
self::$instance = new self($config, $filesystem, $mediaHooks, $settingsPage, $healthCheck);
self::$bootstrapped = true;
}
public static function instance(): ?self {
return self::$instance;
}
public function config(): array {
return $this->config;
}
public function filesystem(): FilesystemOperator {
return $this->filesystem;
}
private static function normaliseConfig(array $config): array {
$defaults = [
'driver' => 's3',
'visibility' => Visibility::PUBLIC,
'cache_ttl' => 900,
'stream' => [
'protocol' => 'flysystem',
'root_prefix' => '',
'host' => 'uploads',
],
'uploads' => [
'base_url' => '',
'delete_remote' => true,
'prefer_local_for_missing' => false,
'cache_control' => 'public, max-age=31536000, immutable',
'expires' => null,
'expires_ttl' => 31536000,
],
'admin' => [
'enabled' => false,
],
's3' => [
'acl_public' => 'public-read',
'acl_private' => 'private',
'default_options' => [],
],
];
$config = array_replace_recursive($defaults, $config);
$config['visibility'] = self::normaliseVisibility((string) ($config['visibility'] ?? Visibility::PUBLIC));
$config['stream']['protocol'] = self::sanitizeProtocol((string) $config['stream']['protocol']);
$config['stream']['root_prefix'] = self::normalizePathSegment((string) $config['stream']['root_prefix']);
$config['stream']['host'] = self::normalizePathSegment((string) $config['stream']['host']) ?: 'uploads';
if (empty($config['uploads']['base_url'])) {
$config['uploads']['base_url'] = rtrim(content_url('uploads'), '/');
} else {
$config['uploads']['base_url'] = rtrim((string) $config['uploads']['base_url'], '/');
}
$config['uploads']['delete_remote'] = (bool) $config['uploads']['delete_remote'];
$config['uploads']['prefer_local_for_missing'] = (bool) $config['uploads']['prefer_local_for_missing'];
$config['uploads']['cache_control'] = trim((string) $config['uploads']['cache_control']);
$config['uploads']['expires'] = $config['uploads']['expires']
? trim((string) $config['uploads']['expires'])
: null;
$config['uploads']['expires_ttl'] = max(0, (int) ($config['uploads']['expires_ttl'] ?? 0));
$config['s3']['acl_public'] = (string) ($config['s3']['acl_public'] ?? 'public-read');
$config['s3']['acl_private'] = (string) ($config['s3']['acl_private'] ?? 'private');
$config['s3']['default_options'] = is_array($config['s3']['default_options'])
? $config['s3']['default_options']
: [];
return $config;
}
private static function normaliseVisibility(string $visibility): string {
$visibility = \strtolower($visibility);
return $visibility === Visibility::PRIVATE
? Visibility::PRIVATE
: Visibility::PUBLIC;
}
private static function sanitizeProtocol(string $protocol): string {
$protocol = \preg_replace('/[^A-Za-z0-9_\-]/', '', $protocol) ?? 'flysystem';
$protocol = \strtolower($protocol);
return $protocol !== '' ? $protocol : 'flysystem';
}
private static function normalizePathSegment(string $segment): string {
return \trim($segment, " \t\n\r\0\x0B/");
self::bootstrap();
}
}

View File

@ -45,8 +45,8 @@ final class SettingsPage {
<table class="widefat striped">
<tbody>
<tr>
<th scope="row"><?php esc_html_e('Driver', 'flysystem-offload'); ?></th>
<td><?php echo esc_html($this->config['driver'] ?? ''); ?></td>
<th scope="row"><?php esc_html_e('Provider', 'flysystem-offload'); ?></th>
<td><?php echo esc_html($this->config['provider'] ?? ''); ?></td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('Protocolo del Stream Wrapper', 'flysystem-offload'); ?></th>

View File

@ -1,493 +1,528 @@
<?php
declare(strict_types=1);
namespace FlysystemOffload\StreamWrapper;
use FlysystemOffload\Helpers\PathHelper;
use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\StorageAttributes;
use League\Flysystem\UnableToDeleteDirectory;
use League\Flysystem\UnableToDeleteFile;
use League\Flysystem\UnableToMoveFile;
use League\Flysystem\UnableToReadFile;
use League\Flysystem\UnableToWriteFile;
use League\Flysystem\Visibility;
final class FlysystemStreamWrapper {
/**
* Stream wrapper para Flysystem
* Soporta protocolo configurable (por defecto 'fly')
*/
class FlysystemStreamWrapper
{
private static ?FilesystemOperator $filesystem = null;
private static string $protocol = 'flysystem';
private static string $rootPrefix = '';
private static string $defaultVisibility = Visibility::PUBLIC;
private static string $protocol = 'fly';
/** @var resource|null */
private $resource = null;
private string $mode = 'r';
private $stream;
/** @var string Ruta remota normalizada (sin protocolo ni host) */
private string $path = '';
private string $uri = '';
private bool $dirty = false;
/** @var list<string> */
private array $directoryListing = [];
private int $directoryPosition = 0;
/** @var string Modo de apertura */
private string $mode = '';
public static function register(
FilesystemOperator $filesystem,
string $protocol = 'flysystem',
string $rootPrefix = '',
string $defaultVisibility = Visibility::PUBLIC,
bool $force = true
): void {
/** @var array Opciones de contexto */
public $context;
/**
* Registra el stream wrapper
*/
public static function register(FilesystemOperator $filesystem, string $protocol = 'fly'): void
{
self::$filesystem = $filesystem;
self::$protocol = $protocol;
self::$rootPrefix = PathHelper::normalize($rootPrefix);
self::$defaultVisibility = $defaultVisibility;
$wrappers = stream_get_wrappers();
if (in_array($protocol, $wrappers, true)) {
if (! $force) {
return;
}
stream_wrapper_unregister($protocol);
// Desregistrar si ya existe
if (in_array(self::$protocol, stream_get_wrappers(), true)) {
@stream_wrapper_unregister(self::$protocol);
}
if (! stream_wrapper_register($protocol, static::class, STREAM_IS_URL)) {
throw new \RuntimeException(sprintf('Unable to register stream wrapper for protocol "%s".', $protocol));
$ok = @stream_wrapper_register(self::$protocol, self::class);
if ($ok) {
error_log('[FlysystemStreamWrapper] Registered protocol: "' . self::$protocol . '"');
} else {
error_log('[FlysystemStreamWrapper] ERROR registering protocol "' . self::$protocol . '"');
}
}
public static function unregister(): void {
if (self::$protocol !== '' && in_array(self::$protocol, stream_get_wrappers(), true)) {
stream_wrapper_unregister(self::$protocol);
}
self::$filesystem = null;
}
public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool {
$this->ensureFilesystem();
$this->uri = $path;
$this->path = self::uriToKey($path);
$this->mode = $mode;
$this->resource = fopen('php://temp', 'w+b');
$this->dirty = false;
if ($this->resource === false) {
return false;
}
if ($this->requiresExistingFile($mode) && ! $this->fileExists($this->path)) {
if ($options & STREAM_REPORT_ERRORS) {
trigger_error(sprintf('Flysystem path "%s" does not exist.', $this->path), E_USER_WARNING);
}
return false;
}
if ($this->isExclusiveCreateMode($mode) && $this->fileExists($this->path)) {
return false;
}
if (($this->requiresExistingFile($mode) || $this->isAppendMode($mode)) && $this->fileExists($this->path)) {
try {
$contents = self::$filesystem->read($this->path);
fwrite($this->resource, $contents);
unset($contents);
if ($this->isAppendMode($mode)) {
fseek($this->resource, 0, SEEK_END);
} else {
rewind($this->resource);
}
} catch (UnableToReadFile|FilesystemException $exception) {
if ($options & STREAM_REPORT_ERRORS) {
trigger_error($exception->getMessage(), E_USER_WARNING);
}
return false;
}
}
return true;
}
public function stream_read(int $count): string|false {
if (! $this->resource) {
return false;
}
return fread($this->resource, $count);
}
public function stream_write(string $data): int|false {
if (! $this->resource) {
return false;
}
$written = fwrite($this->resource, $data);
if ($written !== false) {
$this->dirty = true;
}
return $written;
}
public function stream_tell(): int|false {
if (! $this->resource) {
return false;
}
return ftell($this->resource);
}
public function stream_eof(): bool {
if (! $this->resource) {
return true;
}
return feof($this->resource);
}
public function stream_seek(int $offset, int $whence = SEEK_SET): bool {
if (! $this->resource) {
return false;
}
return fseek($this->resource, $offset, $whence) === 0;
}
public function stream_flush(): bool {
if (! $this->resource || ! $this->dirty || ! $this->isWriteMode($this->mode)) {
return true;
}
$position = ftell($this->resource);
rewind($this->resource);
$contents = stream_get_contents($this->resource);
fseek($this->resource, $position);
try {
if ($this->fileExists($this->path)) {
try {
self::$filesystem->delete($this->path);
} catch (UnableToDeleteFile|FilesystemException) {
// Intentamos sobrescribir igualmente.
}
}
self::$filesystem->write(
$this->path,
(string) $contents,
['visibility' => self::$defaultVisibility]
);
$this->dirty = false;
return true;
} catch (UnableToWriteFile|FilesystemException $exception) {
trigger_error($exception->getMessage(), E_USER_WARNING);
return false;
}
}
public function stream_close(): void {
$this->stream_flush();
if ($this->resource) {
fclose($this->resource);
}
$this->resource = null;
$this->mode = 'r';
$this->path = '';
$this->uri = '';
$this->dirty = false;
}
public function stream_truncate(int $new_size): bool {
if (! $this->resource) {
return false;
}
$result = ftruncate($this->resource, $new_size);
if ($result) {
$this->dirty = true;
}
return $result;
}
public function stream_stat(): array|false {
if ($this->path === '') {
return false;
}
return $this->statKey($this->path);
}
public function url_stat(string $path, int $flags): array|false {
$key = self::uriToKey($path);
$stat = $this->statKey($key);
if ($stat === false && ($flags & STREAM_URL_STAT_QUIET) === 0) {
trigger_error(sprintf('Flysystem path "%s" not found.', $key), E_USER_WARNING);
}
return $stat;
}
public function unlink(string $path): bool {
$this->ensureFilesystem();
$key = self::uriToKey($path);
try {
if ($this->fileExists($key)) {
self::$filesystem->delete($key);
}
return true;
} catch (UnableToDeleteFile|FilesystemException $exception) {
trigger_error($exception->getMessage(), E_USER_WARNING);
return false;
}
}
public function rename(string $path_from, string $path_to): bool {
$this->ensureFilesystem();
$from = self::uriToKey($path_from);
$to = self::uriToKey($path_to);
try {
self::$filesystem->move($from, $to);
return true;
} catch (UnableToMoveFile|FilesystemException $exception) {
trigger_error($exception->getMessage(), E_USER_WARNING);
return false;
}
}
public function mkdir(string $path, int $mode, int $options): bool {
$this->ensureFilesystem();
$key = self::uriToKey($path);
try {
self::$filesystem->createDirectory($key, ['visibility' => self::$defaultVisibility]);
return true;
} catch (FilesystemException $exception) {
if ($options & STREAM_REPORT_ERRORS) {
trigger_error($exception->getMessage(), E_USER_WARNING);
}
return false;
}
}
public function rmdir(string $path, int $options): bool {
$this->ensureFilesystem();
$key = self::uriToKey($path);
try {
self::$filesystem->deleteDirectory($key);
return true;
} catch (UnableToDeleteDirectory|FilesystemException $exception) {
if ($options & STREAM_REPORT_ERRORS) {
trigger_error($exception->getMessage(), E_USER_WARNING);
}
return false;
}
}
public function dir_opendir(string $path, int $options): bool {
$this->ensureFilesystem();
$key = self::uriToKey($path);
$this->directoryListing = ['.', '..'];
try {
foreach (self::$filesystem->listContents($key, false) as $attributes) {
if ($attributes instanceof StorageAttributes) {
$this->directoryListing[] = basename($attributes->path());
}
}
} catch (FilesystemException $exception) {
if ($options & STREAM_REPORT_ERRORS) {
trigger_error($exception->getMessage(), E_USER_WARNING);
}
return false;
}
$this->directoryPosition = 0;
return true;
}
public function dir_readdir(): string|false {
if (! isset($this->directoryListing[$this->directoryPosition])) {
return false;
}
return $this->directoryListing[$this->directoryPosition++];
}
public function dir_rewinddir(): bool {
$this->directoryPosition = 0;
return true;
}
public function dir_closedir(): bool {
$this->directoryListing = [];
$this->directoryPosition = 0;
return true;
}
public function stream_metadata(string $path, int $option, mixed $value): bool {
$this->ensureFilesystem();
if ($option === STREAM_META_TOUCH) {
$key = self::uriToKey($path);
try {
if (! $this->fileExists($key)) {
self::$filesystem->write($key, '', ['visibility' => self::$defaultVisibility]);
}
return true;
} catch (FilesystemException $exception) {
trigger_error($exception->getMessage(), E_USER_WARNING);
return false;
}
}
return false;
}
public function stream_set_option(int $option, int $arg1, int $arg2): bool {
return false;
}
public function stream_lock(int $operation): bool {
return true;
}
private function requiresExistingFile(string $mode): bool {
return str_starts_with($mode, 'r');
}
private function isExclusiveCreateMode(string $mode): bool {
return str_starts_with($mode, 'x');
}
private function isAppendMode(string $mode): bool {
return str_contains($mode, 'a');
}
private function isWriteMode(string $mode): bool {
return strpbrk($mode, 'waxc+') !== false;
}
private static function uriToKey(string $uri): string {
$components = parse_url($uri);
$host = $components['host'] ?? '';
$path = $components['path'] ?? '';
$relative = PathHelper::join($host, $path);
if (self::$rootPrefix !== '') {
$relative = PathHelper::join(self::$rootPrefix, $relative);
}
return $relative;
}
private function fileExists(string $key): bool {
try {
return self::$filesystem->fileExists($key);
} catch (FilesystemException) {
return false;
}
}
private function statKey(string $key): array|false {
$this->ensureFilesystem();
try {
if (self::$filesystem->fileExists($key)) {
$size = 0;
$mtime = time();
try {
$size = self::$filesystem->fileSize($key);
} catch (FilesystemException) {
$size = 0;
}
try {
$mtime = self::$filesystem->lastModified($key);
} catch (FilesystemException) {
$mtime = time();
}
return $this->formatStat(0100644, $size, $mtime, 1);
}
if (self::$filesystem->directoryExists($key)) {
$mtime = time();
try {
$mtime = self::$filesystem->lastModified($key);
} catch (FilesystemException) {
$mtime = time();
}
return $this->formatStat(0040755, 0, $mtime, 2);
}
} catch (FilesystemException) {
return false;
}
return false;
}
/**
* @return array{
* 0:int,1:int,2:int,3:int,4:int,5:int,6:int,7:int,8:int,9:int,10:int,11:int,12:int,
* dev:int,ino:int,mode:int,nlink:int,uid:int,gid:int,rdev:int,size:int,atime:int,mtime:int,ctime:int,blksize:int,blocks:int
* }
* Normaliza ruta removiendo protocolo y host
* Ejemplo: fly://uploads/2025/11/file.jpg -> uploads/2025/11/file.jpg
*/
private function formatStat(int $mode, int $size, int $timestamp, int $nlink = 1): array {
return [
0 => 0,
'dev' => 0,
1 => 0,
'ino' => 0,
2 => $mode,
'mode' => $mode,
3 => $nlink,
'nlink' => $nlink,
4 => 0,
'uid' => 0,
5 => 0,
'gid' => 0,
6 => 0,
'rdev' => 0,
7 => $size,
'size' => $size,
8 => $timestamp,
'atime' => $timestamp,
9 => $timestamp,
'mtime' => $timestamp,
10 => $timestamp,
'ctime' => $timestamp,
11 => -1,
'blksize' => -1,
12 => -1,
'blocks' => -1,
];
private static function normalizePath(string $path): string
{
// Remover protocolo
$p = preg_replace('#^' . preg_quote(self::$protocol, '#') . '://#', '', $path) ?? '';
// Remover cualquier host si existe (primer segmento después de //)
if (strpos($p, '/') !== false) {
$parts = explode('/', $p, 2);
if (count($parts) === 2) {
return $parts[1];
}
}
return $p;
}
private function ensureFilesystem(): void {
if (! self::$filesystem) {
throw new \RuntimeException('Flysystem filesystem has not been registered.');
/**
* Opens file or URL
*
* @param string $path
* @param string $mode
* @param int $options
* @param string|null $opened_path
* @return bool
*/
public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool
{
if (!self::$filesystem) {
error_log('[FlysystemStreamWrapper] stream_open failed: filesystem not set');
return false;
}
$this->path = self::normalizePath($path);
$this->mode = $mode;
error_log(sprintf('[FlysystemStreamWrapper] stream_open path="%s" normalized="%s" mode="%s"', $path, $this->path, $mode));
try {
// Modo lectura
if (strpos($mode, 'r') !== false) {
// Verificar si el archivo existe
if (!self::$filesystem->fileExists($this->path)) {
error_log(sprintf('[FlysystemStreamWrapper] stream_open error: file does not exist "%s"', $this->path));
return false;
}
// Leer el contenido del archivo desde el filesystem
$contents = self::$filesystem->read($this->path);
// Crear un stream temporal en memoria
$this->stream = fopen('php://temp', 'r+b');
if ($this->stream === false) {
error_log(sprintf('[FlysystemStreamWrapper] stream_open error: failed to create temp stream for "%s"', $this->path));
return false;
}
// Escribir el contenido en el stream temporal
fwrite($this->stream, $contents);
// Volver al inicio del stream
rewind($this->stream);
error_log(sprintf('[FlysystemStreamWrapper] stream_open read mode: loaded %d bytes for "%s"', strlen($contents), $this->path));
return true;
}
// Modo escritura
if (strpos($mode, 'w') !== false || strpos($mode, 'a') !== false || strpos($mode, 'x') !== false || strpos($mode, 'c') !== false) {
// Crear un stream temporal en memoria para escritura
$this->stream = fopen('php://temp', 'r+b');
if ($this->stream === false) {
error_log(sprintf('[FlysystemStreamWrapper] stream_open error: failed to create temp stream for "%s"', $this->path));
return false;
}
// En modo append, posicionar al final
if (strpos($mode, 'a') !== false) {
// Si el archivo existe, cargar su contenido
if (self::$filesystem->fileExists($this->path)) {
$contents = self::$filesystem->read($this->path);
fwrite($this->stream, $contents);
}
fseek($this->stream, 0, SEEK_END);
}
error_log(sprintf('[FlysystemStreamWrapper] stream_open write mode for "%s"', $this->path));
return true;
}
error_log(sprintf('[FlysystemStreamWrapper] stream_open error: unsupported mode "%s" for "%s"', $mode, $this->path));
return false;
} catch (\Exception $e) {
error_log(sprintf('[FlysystemStreamWrapper] stream_open exception: %s', $e->getMessage()));
return false;
}
}
/**
* Read from stream
*
* @param int $count
* @return string|false
*/
public function stream_read(int $count)
{
if (!$this->stream) {
error_log('[FlysystemStreamWrapper] stream_read error: no stream resource');
return false;
}
try {
$data = fread($this->stream, $count);
if ($data === false) {
error_log(sprintf('[FlysystemStreamWrapper] stream_read error for "%s"', $this->path));
return false;
}
error_log(sprintf('[FlysystemStreamWrapper] stream_read %d bytes from "%s"', strlen($data), $this->path));
return $data;
} catch (\Exception $e) {
error_log(sprintf('[FlysystemStreamWrapper] stream_read exception: %s', $e->getMessage()));
return false;
}
}
/**
* Write to stream
*
* @param string $data
* @return int|false
*/
public function stream_write(string $data)
{
if (!$this->stream) {
error_log('[FlysystemStreamWrapper] stream_write error: no stream resource');
return false;
}
try {
$bytesWritten = fwrite($this->stream, $data);
if ($bytesWritten === false) {
error_log(sprintf('[FlysystemStreamWrapper] stream_write error for "%s"', $this->path));
return false;
}
error_log(sprintf('[FlysystemStreamWrapper] stream_write %d bytes to "%s"', $bytesWritten, $this->path));
return $bytesWritten;
} catch (\Exception $e) {
error_log(sprintf('[FlysystemStreamWrapper] stream_write exception: %s', $e->getMessage()));
return false;
}
}
/**
* Tests for end-of-file on a file pointer
*
* @return bool
*/
public function stream_eof(): bool
{
if (!$this->stream) {
return true;
}
return feof($this->stream);
}
/**
* Retrieve the current position of a stream
*
* @return int
*/
public function stream_tell(): int
{
if (!$this->stream) {
return 0;
}
$position = ftell($this->stream);
return $position !== false ? $position : 0;
}
/**
* Seeks to specific location in a stream
*
* @param int $offset
* @param int $whence
* @return bool
*/
public function stream_seek(int $offset, int $whence = SEEK_SET): bool
{
if (!$this->stream) {
error_log('[FlysystemStreamWrapper] stream_seek error: no stream resource');
return false;
}
try {
$result = fseek($this->stream, $offset, $whence);
if ($result === 0) {
error_log(sprintf('[FlysystemStreamWrapper] stream_seek to %d (whence: %d) for "%s"', $offset, $whence, $this->path));
return true;
}
error_log(sprintf('[FlysystemStreamWrapper] stream_seek failed for "%s"', $this->path));
return false;
} catch (\Exception $e) {
error_log(sprintf('[FlysystemStreamWrapper] stream_seek exception: %s', $e->getMessage()));
return false;
}
}
/**
* Retrieve information about a file resource
*
* @return array|false
*/
public function stream_stat()
{
if (!$this->stream) {
error_log('[FlysystemStreamWrapper] stream_stat error: no stream resource');
return false;
}
try {
$stat = fstat($this->stream);
if ($stat === false) {
error_log(sprintf('[FlysystemStreamWrapper] stream_stat failed for "%s"', $this->path));
return false;
}
error_log(sprintf('[FlysystemStreamWrapper] stream_stat for "%s": size=%d', $this->path, $stat['size'] ?? 0));
return $stat;
} catch (\Exception $e) {
error_log(sprintf('[FlysystemStreamWrapper] stream_stat exception: %s', $e->getMessage()));
return false;
}
}
/**
* Flushes the output
*
* @return bool
*/
public function stream_flush(): bool
{
if (!$this->stream) {
error_log('[FlysystemStreamWrapper] stream_flush error: no stream resource');
return false;
}
try {
// Forzar escritura del buffer interno de PHP
fflush($this->stream);
// Obtener la posición actual antes de rebobinar
$pos = ftell($this->stream);
// Rebobinar para leer todo el contenido
rewind($this->stream);
$contents = stream_get_contents($this->stream);
// Restaurar posición original
if ($pos !== false) {
fseek($this->stream, $pos);
}
if ($contents === false) {
error_log(sprintf('[FlysystemStreamWrapper] stream_flush error reading contents for "%s"', $this->path));
return false;
}
// Escribir en el sistema de archivos
self::$filesystem->write($this->path, $contents);
error_log(sprintf('[FlysystemStreamWrapper] stream_flush wrote %d bytes to "%s"', strlen($contents), $this->path));
return true;
} catch (\Exception $e) {
error_log(sprintf('[FlysystemStreamWrapper] stream_flush exception: %s', $e->getMessage()));
return false;
}
}
/**
* Close a resource
*/
public function stream_close(): void
{
if ($this->stream) {
try {
// Obtener la posición actual antes de rebobinar
$pos = ftell($this->stream);
// Rebobinar para leer todo el contenido
rewind($this->stream);
$contents = stream_get_contents($this->stream);
// Solo escribir si hay contenido y el modo permite escritura
if ($contents !== false && strlen($contents) > 0 &&
(strpos($this->mode, 'w') !== false ||
strpos($this->mode, 'a') !== false ||
strpos($this->mode, 'x') !== false ||
strpos($this->mode, 'c') !== false ||
strpos($this->mode, '+') !== false)) {
// Escribir en el sistema de archivos
self::$filesystem->write($this->path, $contents);
error_log(sprintf('[FlysystemStreamWrapper] stream_close wrote %d bytes to "%s"', strlen($contents), $this->path));
}
} catch (\Exception $e) {
error_log(sprintf('[FlysystemStreamWrapper] stream_close exception: %s', $e->getMessage()));
}
fclose($this->stream);
$this->stream = null;
}
}
/**
* Retrieve information about a file
*
* @param string $path
* @param int $flags
* @return array|false
*/
public function url_stat(string $path, int $flags)
{
$p = self::normalizePath($path);
try {
// Verificar si es un directorio
if (self::$filesystem->directoryExists($p)) {
return $this->getStatArray(0, true);
}
// Verificar si es un archivo
if (self::$filesystem->fileExists($p)) {
$size = self::$filesystem->fileSize($p);
return $this->getStatArray($size, false);
}
// No existe
if ($flags & STREAM_URL_STAT_QUIET) {
return false;
}
return false;
} catch (\Exception $e) {
error_log(sprintf('[FlysystemStreamWrapper] url_stat exception for "%s": %s', $p, $e->getMessage()));
if ($flags & STREAM_URL_STAT_QUIET) {
return false;
}
return false;
}
}
/**
* Delete a file
*
* @param string $path
* @return bool
*/
public function unlink(string $path): bool
{
$p = self::normalizePath($path);
try {
error_log('[FlysystemStreamWrapper] unlink "' . $p . '"');
self::$filesystem->delete($p);
return true;
} catch (\Exception $e) {
error_log('[FlysystemStreamWrapper] unlink error: ' . $e->getMessage());
return false;
}
}
/**
* Create a directory
*
* @param string $path
* @param int $mode
* @param int $options
* @return bool
*/
public function mkdir(string $path, int $mode, int $options): bool
{
$p = self::normalizePath($path);
try {
error_log('[FlysystemStreamWrapper] mkdir "' . $p . '"');
self::$filesystem->createDirectory($p);
return true;
} catch (\Exception $e) {
error_log('[FlysystemStreamWrapper] mkdir error: ' . $e->getMessage());
return false;
}
}
/**
* Remove a directory
*
* @param string $path
* @param int $options
* @return bool
*/
public function rmdir(string $path, int $options): bool
{
$p = self::normalizePath($path);
try {
error_log('[FlysystemStreamWrapper] rmdir "' . $p . '"');
self::$filesystem->deleteDirectory($p);
return true;
} catch (\Exception $e) {
error_log('[FlysystemStreamWrapper] rmdir error: ' . $e->getMessage());
return false;
}
}
/**
* Rename a file or directory
*
* @param string $path_from
* @param string $path_to
* @return bool
*/
public function rename(string $path_from, string $path_to): bool
{
$from = self::normalizePath($path_from);
$to = self::normalizePath($path_to);
try {
error_log('[FlysystemStreamWrapper] rename "' . $from . '" -> "' . $to . '"');
self::$filesystem->move($from, $to);
return true;
} catch (\Exception $e) {
error_log('[FlysystemStreamWrapper] rename error: ' . $e->getMessage());
return false;
}
}
/**
* Genera array stat para archivos y directorios
*
* @param int $size Tamaño del archivo
* @param bool $isDir Si es directorio
* @return array
*/
private function getStatArray(int $size = 0, bool $isDir = false): array
{
$mode = $isDir ? 0040777 : 0100666; // Directorio o archivo regular
return [
0 => 0, 'dev' => 0,
1 => 0, 'ino' => 0,
2 => $mode, 'mode' => $mode,
3 => 0, 'nlink' => 0,
4 => 0, 'uid' => 0,
5 => 0, 'gid' => 0,
6 => -1, 'rdev' => -1,
7 => $size, 'size' => $size,
8 => time(), 'atime' => time(),
9 => time(), 'mtime' => time(),
10 => time(), 'ctime' => time(),
11 => -1, 'blksize' => -1,
12 => -1, 'blocks' => -1,
];
}
}