Compare commits
2 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
6a4949f181 | |
|
|
388f5112de |
317
README.md
317
README.md
|
|
@ -1,116 +1,225 @@
|
|||
# Flysystem Offload — Almacenamiento universal para WordPress
|
||||
### Flysystem Offload
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Características
|
||||
### Características
|
||||
|
||||
- **Proveedor seleccionable:** Amazon S3 y endpoints compatibles (MinIO, DigitalOcean Spaces, Wasabi, etc.).
|
||||
- **Integración transparente:** hooks de `upload_dir`, `wp_get_attachment_url`, stream wrapper `fly://` y borrado sincronizado.
|
||||
- **Arquitectura modular:** preparada para añadir SFTP, WebDAV y otros adaptadores en iteraciones futuras.
|
||||
- **Panel de ajustes:** selector de proveedor y credenciales gestionadas desde la administración.
|
||||
|
||||
## Requisitos
|
||||
|
||||
- PHP 8.0+
|
||||
- WordPress 6.0+
|
||||
- Extensiones PHP: `curl`, `mbstring`, `xml`
|
||||
- Acceso a Composer durante la construcción del paquete (o usar la imagen Docker proporcionada)
|
||||
- Credenciales válidas de S3 o servicio compatible
|
||||
|
||||
## Instalación
|
||||
|
||||
### Opción A · Proyecto existente de WordPress
|
||||
|
||||
- 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`.
|
||||
|
||||
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:
|
||||
### Requisitos
|
||||
|
||||
- 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)
|
||||
|
||||
- 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).
|
||||
|
||||
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
|
||||
|
||||
### Instalación
|
||||
|
||||
- WordPress sigue usando `wp_handle_upload()`.
|
||||
- Los filtros de `upload_dir` cambian `basedir` a `fly://...`.
|
||||
- El stream wrapper reenvía lecturas/escrituras a Flysystem y este al cliente S3.
|
||||
- `wp_get_attachment_url` reescribe la URL base con el dominio del bucket o el CDN configurado.
|
||||
- Al eliminar un adjunto, se borran el archivo principal y sus derivadas desde el almacenamiento remoto.
|
||||
|
||||
## Roadmap inmediato
|
||||
|
||||
- Campos y validaciones para SFTP y WebDAV.
|
||||
- Health check vía WP-CLI.
|
||||
- Herramientas de migración para copiar la biblioteca existente al proveedor remoto.
|
||||
- Adaptadores adicionales (GCS, Azure Blob) y conectores OAuth (Drive, OneDrive, Dropbox).
|
||||
|
||||
## Contribuir
|
||||
|
||||
1. 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`.
|
||||
|
||||
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.
|
||||
### Configuración básica (`config/flysystem-offload.php`)
|
||||
|
||||
- Documentación: Este archivo.
|
||||
- Issues/Contacto: jdavidcamejo@gmail.com
|
||||
```php
|
||||
<?php
|
||||
return [
|
||||
'driver' => 's3',
|
||||
'visibility' => 'public',
|
||||
|
||||
## Licencia
|
||||
|
||||
GPL v2. Consulta [LICENSE](LICENSE) para más detalles.
|
||||
'stream' => [
|
||||
'protocol' => 'flysystem',
|
||||
'root_prefix' => '',
|
||||
'host' => 'uploads',
|
||||
],
|
||||
|
||||
---
|
||||
'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,
|
||||
],
|
||||
|
||||
Desarrollado por [Brasdrive](https://brasdrive.com.br).
|
||||
'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`.
|
||||
|
|
|
|||
|
|
@ -1,14 +1,7 @@
|
|||
{
|
||||
"name": "brasdrive/flysystem-offload",
|
||||
"description": "Universal storage offloading for WordPress via Flysystem",
|
||||
"description": "Universal storage offloading for WordPress vía 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",
|
||||
|
|
@ -23,36 +16,9 @@
|
|||
"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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
<?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' => [],
|
||||
],
|
||||
];
|
||||
|
|
@ -1,37 +1,24 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'provider' => getenv('FLYSYSTEM_OFFLOAD_PROVIDER') ?: 'webdav',
|
||||
'visibility' => getenv('FLYSYSTEM_OFFLOAD_DEFAULT_VISIBILITY') ?: 'public',
|
||||
'driver' => 's3',
|
||||
'visibility' => 'public',
|
||||
|
||||
'stream' => [
|
||||
'protocol' => getenv('FLYSYSTEM_OFFLOAD_STREAM_PROTOCOL') ?: 'webdav',
|
||||
'root_prefix' => getenv('FLYSYSTEM_OFFLOAD_STREAM_ROOT_PREFIX') ?: '',
|
||||
'host' => getenv('FLYSYSTEM_OFFLOAD_STREAM_HOST') ?: '',
|
||||
'protocol' => 'flysystem',
|
||||
'root_prefix' => '',
|
||||
'host' => 'uploads',
|
||||
],
|
||||
|
||||
'uploads' => [
|
||||
'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
|
||||
),
|
||||
'base_url' => getenv('FLYSYSTEM_OFFLOAD_BASE_URL') ?: 'https://your-bucket.s3.amazonaws.com',
|
||||
'delete_remote' => true,
|
||||
'prefer_local_for_missing' => false,
|
||||
],
|
||||
|
||||
'admin' => [
|
||||
'enabled' => filter_var(
|
||||
getenv('FLYSYSTEM_OFFLOAD_ADMIN_ENABLED') ?: 'true',
|
||||
FILTER_VALIDATE_BOOLEAN
|
||||
),
|
||||
'enabled' => false,
|
||||
],
|
||||
|
||||
's3' => [
|
||||
|
|
@ -42,39 +29,8 @@ 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' => filter_var(
|
||||
getenv('AWS_USE_PATH_STYLE_ENDPOINT') ?: 'false',
|
||||
FILTER_VALIDATE_BOOLEAN
|
||||
),
|
||||
'use_path_style_endpoint' => (bool) (getenv('AWS_USE_PATH_STYLE_ENDPOINT') ?: false),
|
||||
'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',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,101 +1,58 @@
|
|||
<?php
|
||||
/**
|
||||
* Plugin Name: Flysystem Offload
|
||||
* 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
|
||||
* 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
|
||||
* Text Domain: flysystem-offload
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Evitar acceso directo
|
||||
if (!defined('ABSPATH')) {
|
||||
if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// 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');
|
||||
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');
|
||||
}
|
||||
|
||||
// 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;
|
||||
$autoload = __DIR__ . '/vendor/autoload.php';
|
||||
if (file_exists($autoload)) {
|
||||
require_once $autoload;
|
||||
}
|
||||
|
||||
require_once $autoloader;
|
||||
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;
|
||||
}
|
||||
|
||||
// Inicializar el plugin cuando WordPress esté listo
|
||||
add_action('plugins_loaded', function (): void {
|
||||
try {
|
||||
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())
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 10);
|
||||
Plugin::bootstrap();
|
||||
} catch (Throwable $exception) {
|
||||
error_log('[Flysystem Offload] Error al iniciar el plugin: ' . $exception->getMessage());
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
add_action('admin_notices', static function () use ($exception): void {
|
||||
if (! current_user_can('manage_options')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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');
|
||||
printf(
|
||||
'<div class="notice notice-error"><p><strong>Flysystem Offload:</strong> %s</p></div>',
|
||||
esc_html($exception->getMessage())
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}, 0);
|
||||
|
|
|
|||
|
|
@ -1,596 +1,58 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FlysystemOffload\Config;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use UnexpectedValueException;
|
||||
|
||||
/**
|
||||
* 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();
|
||||
final class ConfigLoader {
|
||||
private string $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 __construct(string $configDirectory) {
|
||||
$this->configDirectory = rtrim($configDirectory, '/\\');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'
|
||||
: '';
|
||||
|
||||
if (empty($configFile) || !file_exists($configFile)) {
|
||||
error_log('[Flysystem Offload] Config file not found: ' . $configFile);
|
||||
return [];
|
||||
}
|
||||
|
||||
$rawConfig = require $configFile;
|
||||
|
||||
if (!is_array($rawConfig)) {
|
||||
error_log('[Flysystem Offload] Config file must return an array');
|
||||
return [];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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', ''),
|
||||
public function load(): array {
|
||||
$candidateFiles = [
|
||||
$this->configDirectory . '/flysystem-offload.local.php',
|
||||
$this->configDirectory . '/flysystem-offload.php',
|
||||
$this->configDirectory . '/flysystem-offload.example.php',
|
||||
];
|
||||
|
||||
// Cargar configuración específica del proveedor
|
||||
switch ($provider) {
|
||||
case 's3':
|
||||
$config = array_merge($config, self::loadS3Config());
|
||||
break;
|
||||
$configFile = $this->resolveFirstExisting($candidateFiles);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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'
|
||||
if ($configFile === null) {
|
||||
throw new RuntimeException(
|
||||
sprintf(
|
||||
'No se pudo localizar un archivo de configuración para Flysystem Offload. Esperado en: %s',
|
||||
implode(', ', $candidateFiles)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$config = include $configFile;
|
||||
|
||||
if ($config instanceof \Closure) {
|
||||
$config = $config();
|
||||
}
|
||||
|
||||
if (! is_array($config)) {
|
||||
throw new UnexpectedValueException(
|
||||
sprintf('El archivo de configuración debe retornar un array. Archivo: %s', $configFile)
|
||||
);
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
private function resolveFirstExisting(array $files): ?string {
|
||||
foreach ($files as $file) {
|
||||
if ($file && is_readable($file)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,39 +1,10 @@
|
|||
<?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;
|
||||
}
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FlysystemOffload\Filesystem;
|
||||
|
||||
use League\Flysystem\FilesystemAdapter;
|
||||
|
||||
interface AdapterInterface {
|
||||
public function createAdapter(array $config): FilesystemAdapter;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,515 +0,0 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,287 +1,27 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FlysystemOffload\Filesystem;
|
||||
|
||||
use FlysystemOffload\Filesystem\Adapters\WebdavAdapter;
|
||||
use FlysystemOffload\Filesystem\Adapters\S3Adapter;
|
||||
use League\Flysystem\Filesystem;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
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;
|
||||
use League\Flysystem\Visibility;
|
||||
|
||||
/**
|
||||
* 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'] ?? '';
|
||||
final class FilesystemFactory {
|
||||
public function make(array $config): FilesystemOperator {
|
||||
$driver = $config['driver'] ?? 's3';
|
||||
|
||||
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}"),
|
||||
$adapter = match ($driver) {
|
||||
's3' => (new S3Adapter())->createAdapter($config),
|
||||
default => throw new \InvalidArgumentException(sprintf('Driver de Flysystem no soportado: "%s".', $driver)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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',
|
||||
$filesystemConfig = [
|
||||
'visibility' => $config['visibility'] ?? Visibility::PUBLIC,
|
||||
'directory_visibility' => $config['visibility'] ?? Visibility::PUBLIC,
|
||||
];
|
||||
|
||||
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;
|
||||
return new Filesystem($adapter, $filesystemConfig);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,247 +1,235 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FlysystemOffload\Media;
|
||||
|
||||
use FlysystemOffload\Plugin;
|
||||
use FlysystemOffload\Helpers\PathHelper;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
final class MediaHooks {
|
||||
private FilesystemOperator $filesystem;
|
||||
private array $config;
|
||||
private string $protocol;
|
||||
private string $host;
|
||||
private string $rootPrefix;
|
||||
private string $providerPrefix;
|
||||
private string $streamHost;
|
||||
private string $streamRootPrefix;
|
||||
private string $s3Prefix;
|
||||
private string $baseUrl;
|
||||
private string $effectiveBaseUrl;
|
||||
private string $remoteUrlPathPrefix;
|
||||
private bool $deleteRemote;
|
||||
private bool $preferLocal;
|
||||
|
||||
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 __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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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']);
|
||||
|
||||
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;
|
||||
|
||||
return $uploads;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
public function rewriteAttachmentUrl(string $url, int $attachmentId): string {
|
||||
$file = get_post_meta($attachmentId, '_wp_attached_file', true);
|
||||
if (! $file) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
// Obtener el path del archivo
|
||||
$file = get_post_meta($attachmentId, '_wp_attached_file', true);
|
||||
if (!$file) {
|
||||
|
||||
$relativePath = PathHelper::normalize($file);
|
||||
if ($relativePath === '') {
|
||||
return $url;
|
||||
}
|
||||
|
||||
// Construir URL remota
|
||||
$remoteUrl = rtrim($this->baseUrl, '/') . '/' . ltrim($file, '/');
|
||||
error_log('[MediaHooks] Attachment URL filtered - local: ' . $url . ', remote: ' . $remoteUrl);
|
||||
|
||||
return $remoteUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, '/');
|
||||
if ($this->remoteUrlPathPrefix !== '') {
|
||||
$prefixWithSlash = $this->remoteUrlPathPrefix . '/';
|
||||
if (str_starts_with($relativePath, $prefixWithSlash)) {
|
||||
$relativePath = substr($relativePath, strlen($prefixWithSlash));
|
||||
} elseif ($relativePath === $this->remoteUrlPathPrefix) {
|
||||
$relativePath = '';
|
||||
}
|
||||
}
|
||||
|
||||
return $sources;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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']);
|
||||
}
|
||||
}
|
||||
$remoteUrl = $this->buildPublicUrl($relativePath);
|
||||
|
||||
if (! $this->preferLocal) {
|
||||
return $remoteUrl;
|
||||
}
|
||||
|
||||
return $metadata;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
public function filterImageDownsize(bool|array $out, int $attachmentId, array|string $size): bool|array {
|
||||
return false;
|
||||
}
|
||||
|
||||
public function deleteRemoteFiles(int $attachmentId): void {
|
||||
if (! $this->deleteRemote) {
|
||||
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);
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
error_log(sprintf(
|
||||
'[Flysystem Offload] No se pudo eliminar el archivo remoto "%s": %s',
|
||||
$key,
|
||||
$exception->getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maneja la eliminación de attachments
|
||||
* @return list<string>
|
||||
*/
|
||||
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);
|
||||
private function gatherAttachmentFiles(int $attachmentId): array {
|
||||
$files = [];
|
||||
|
||||
$attachedFile = get_post_meta($attachmentId, '_wp_attached_file', true);
|
||||
if ($attachedFile) {
|
||||
$files[] = $attachedFile;
|
||||
}
|
||||
|
||||
// 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']);
|
||||
|
||||
$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'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$files = array_filter($files, static fn ($file) => is_string($file) && $file !== '');
|
||||
|
||||
return array_values(array_unique($files, SORT_STRING));
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina un archivo remoto
|
||||
*/
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
250
src/Plugin.php
250
src/Plugin.php
|
|
@ -1,133 +1,165 @@
|
|||
<?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\Filesystem;
|
||||
use Throwable;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
use League\Flysystem\Visibility;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
final class Plugin {
|
||||
private static bool $bootstrapped = false;
|
||||
private static ?Plugin $instance = null;
|
||||
|
||||
/**
|
||||
* Bootstrap del plugin
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public static function bootstrap(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Cargar configuración
|
||||
self::$config = ConfigLoader::load();
|
||||
$configDirectory = \defined('FLYSYSTEM_OFFLOAD_CONFIG_PATH')
|
||||
? FLYSYSTEM_OFFLOAD_CONFIG_PATH
|
||||
: \dirname(__DIR__) . '/config';
|
||||
|
||||
// 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;
|
||||
}
|
||||
$configLoader = new ConfigLoader($configDirectory);
|
||||
$config = self::normaliseConfig($configLoader->load());
|
||||
|
||||
// Validar configuración
|
||||
ConfigLoader::validate(self::$config);
|
||||
$filesystemFactory = new FilesystemFactory();
|
||||
$filesystem = $filesystemFactory->make($config);
|
||||
|
||||
// Crear filesystem
|
||||
self::$filesystem = FilesystemFactory::create(self::$config);
|
||||
FlysystemStreamWrapper::register(
|
||||
$filesystem,
|
||||
$config['stream']['protocol'],
|
||||
$config['stream']['root_prefix'],
|
||||
$config['visibility']
|
||||
);
|
||||
|
||||
// Registrar stream wrapper con protocolo desde config
|
||||
$protocol = (string)(self::$config['stream']['protocol'] ?? 'flysystem');
|
||||
FlysystemStreamWrapper::register(self::$filesystem, $protocol);
|
||||
$mediaHooks = new MediaHooks($filesystem, $config);
|
||||
$mediaHooks->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);
|
||||
$settingsPage = null;
|
||||
if (! empty($config['admin']['enabled']) && \is_admin()) {
|
||||
$settingsPage = new SettingsPage($filesystem, $config);
|
||||
$settingsPage->register();
|
||||
}
|
||||
|
||||
self::bootstrap();
|
||||
$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/");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,8 +45,8 @@ final class SettingsPage {
|
|||
<table class="widefat striped">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Provider', 'flysystem-offload'); ?></th>
|
||||
<td><?php echo esc_html($this->config['provider'] ?? ''); ?></td>
|
||||
<th scope="row"><?php esc_html_e('Driver', 'flysystem-offload'); ?></th>
|
||||
<td><?php echo esc_html($this->config['driver'] ?? ''); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php esc_html_e('Protocolo del Stream Wrapper', 'flysystem-offload'); ?></th>
|
||||
|
|
|
|||
|
|
@ -1,528 +1,493 @@
|
|||
<?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;
|
||||
|
||||
/**
|
||||
* Stream wrapper para Flysystem
|
||||
* Soporta protocolo configurable (por defecto 'fly')
|
||||
*/
|
||||
class FlysystemStreamWrapper
|
||||
{
|
||||
final class FlysystemStreamWrapper {
|
||||
private static ?FilesystemOperator $filesystem = null;
|
||||
private static string $protocol = 'fly';
|
||||
private static string $protocol = 'flysystem';
|
||||
private static string $rootPrefix = '';
|
||||
private static string $defaultVisibility = Visibility::PUBLIC;
|
||||
|
||||
/** @var resource|null */
|
||||
private $stream;
|
||||
|
||||
/** @var string Ruta remota normalizada (sin protocolo ni host) */
|
||||
private $resource = null;
|
||||
private string $mode = 'r';
|
||||
private string $path = '';
|
||||
private string $uri = '';
|
||||
private bool $dirty = false;
|
||||
|
||||
/** @var string Modo de apertura */
|
||||
private string $mode = '';
|
||||
/** @var list<string> */
|
||||
private array $directoryListing = [];
|
||||
private int $directoryPosition = 0;
|
||||
|
||||
/** @var array Opciones de contexto */
|
||||
public $context;
|
||||
|
||||
/**
|
||||
* Registra el stream wrapper
|
||||
*/
|
||||
public static function register(FilesystemOperator $filesystem, string $protocol = 'fly'): void
|
||||
{
|
||||
public static function register(
|
||||
FilesystemOperator $filesystem,
|
||||
string $protocol = 'flysystem',
|
||||
string $rootPrefix = '',
|
||||
string $defaultVisibility = Visibility::PUBLIC,
|
||||
bool $force = true
|
||||
): void {
|
||||
self::$filesystem = $filesystem;
|
||||
self::$protocol = $protocol;
|
||||
self::$rootPrefix = PathHelper::normalize($rootPrefix);
|
||||
self::$defaultVisibility = $defaultVisibility;
|
||||
|
||||
// Desregistrar si ya existe
|
||||
if (in_array(self::$protocol, stream_get_wrappers(), true)) {
|
||||
@stream_wrapper_unregister(self::$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 . '"');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normaliza ruta removiendo protocolo y host
|
||||
* Ejemplo: fly://uploads/2025/11/file.jpg -> uploads/2025/11/file.jpg
|
||||
*/
|
||||
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];
|
||||
$wrappers = stream_get_wrappers();
|
||||
if (in_array($protocol, $wrappers, true)) {
|
||||
if (! $force) {
|
||||
return;
|
||||
}
|
||||
stream_wrapper_unregister($protocol);
|
||||
}
|
||||
|
||||
if (! stream_wrapper_register($protocol, static::class, STREAM_IS_URL)) {
|
||||
throw new \RuntimeException(sprintf('Unable to register stream wrapper for protocol "%s".', $protocol));
|
||||
}
|
||||
|
||||
return $p;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
public static function unregister(): void {
|
||||
if (self::$protocol !== '' && in_array(self::$protocol, stream_get_wrappers(), true)) {
|
||||
stream_wrapper_unregister(self::$protocol);
|
||||
}
|
||||
self::$filesystem = null;
|
||||
}
|
||||
|
||||
$this->path = self::normalizePath($path);
|
||||
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;
|
||||
|
||||
error_log(sprintf('[FlysystemStreamWrapper] stream_open path="%s" normalized="%s" mode="%s"', $path, $this->path, $mode));
|
||||
if ($this->resource === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
// 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');
|
||||
if ($this->isExclusiveCreateMode($mode) && $this->fileExists($this->path)) {
|
||||
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) {
|
||||
if (($this->requiresExistingFile($mode) || $this->isAppendMode($mode)) && $this->fileExists($this->path)) {
|
||||
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));
|
||||
$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);
|
||||
}
|
||||
} 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 true;
|
||||
}
|
||||
|
||||
public function stream_read(int $count): string|false {
|
||||
if (! $this->resource) {
|
||||
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 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.
|
||||
}
|
||||
}
|
||||
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);
|
||||
self::$filesystem->write(
|
||||
$this->path,
|
||||
(string) $contents,
|
||||
['visibility' => self::$defaultVisibility]
|
||||
);
|
||||
$this->dirty = false;
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
error_log('[FlysystemStreamWrapper] unlink error: ' . $e->getMessage());
|
||||
} catch (UnableToWriteFile|FilesystemException $exception) {
|
||||
trigger_error($exception->getMessage(), E_USER_WARNING);
|
||||
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);
|
||||
|
||||
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 {
|
||||
error_log('[FlysystemStreamWrapper] mkdir "' . $p . '"');
|
||||
self::$filesystem->createDirectory($p);
|
||||
if ($this->fileExists($key)) {
|
||||
self::$filesystem->delete($key);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
error_log('[FlysystemStreamWrapper] mkdir error: ' . $e->getMessage());
|
||||
} catch (UnableToDeleteFile|FilesystemException $exception) {
|
||||
trigger_error($exception->getMessage(), E_USER_WARNING);
|
||||
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;
|
||||
}
|
||||
}
|
||||
public function rename(string $path_from, string $path_to): bool {
|
||||
$this->ensureFilesystem();
|
||||
|
||||
$from = self::uriToKey($path_from);
|
||||
$to = self::uriToKey($path_to);
|
||||
|
||||
/**
|
||||
* 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());
|
||||
} 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera array stat para archivos y directorios
|
||||
*
|
||||
* @param int $size Tamaño del archivo
|
||||
* @param bool $isDir Si es directorio
|
||||
* @return array
|
||||
* @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
|
||||
* }
|
||||
*/
|
||||
private function getStatArray(int $size = 0, bool $isDir = false): array
|
||||
{
|
||||
$mode = $isDir ? 0040777 : 0100666; // Directorio o archivo regular
|
||||
|
||||
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 => 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,
|
||||
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 function ensureFilesystem(): void {
|
||||
if (! self::$filesystem) {
|
||||
throw new \RuntimeException('Flysystem filesystem has not been registered.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue