Compare commits

...

2 Commits
3.0.0 ... main

Author SHA1 Message Date
Brasdrive 6a4949f181 2.1.0 2025-11-08 20:11:42 -04:00
Brasdrive 388f5112de 2.1.0 2025-11-08 19:32:32 -04:00
15 changed files with 1318 additions and 1354 deletions

269
README.md
View File

@ -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.
- 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`.
## Requisitos
### 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
- 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).
## Instalación
### Instalación
### Opción A · Proyecto existente de WordPress
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`.
### Configuración básica (`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:
```php
<?php
return [
'driver' => 's3',
'visibility' => 'public',
```bash
cd flysystem-offload
composer install --no-dev --optimize-autoloader
```
'stream' => [
'protocol' => 'flysystem',
'root_prefix' => '',
'host' => 'uploads',
],
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.
'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,
],
### Opción B · Imagen Docker (multi-stage)
'admin' => [
'enabled' => false,
],
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:
'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',
],
];
```
```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
```
### Normalización de configuración
Pasos para usarla:
`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.
```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
```
### Funcionamiento del stream wrapper
La imagen resultante ya contiene:
- 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`).
- 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.
### Cabeceras de caché
### Nota sobre Composer
El adaptador S3 añade `Cache-Control` y `Expires` a través de las opciones por defecto de Flysystem:
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.
```php
$defaultOptions['CacheControl'] = 'public, max-age=31536000, immutable';
$defaultOptions['Expires'] = gmdate('D, d M Y H:i:s \G\M\T', time() + 31536000);
```
## Configuración inicial (S3 / compatible)
Puede personalizarse mediante el filtro `flysystem_offload_s3_default_write_options`.
### Escenario bucket público (probado con IDrive e2)
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.
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`).
## Flujo de funcionamiento
```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.
- 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.
```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
}
]
```
## Roadmap inmediato
### Escenario recomendado: bucket privado + Cloudflare
- 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).
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`):
## Contribuir
```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**
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.
- CNAME `offload.brasdrive.com.br` → endpoint IDrive (ej.`u8k6.va11.idrivee2-9.com`).
- Proxy naranja activado; SSL universal.
- Reglas WAF o Rate Limiting opcionales.
- Documentación: Este archivo.
- Issues/Contacto: jdavidcamejo@gmail.com
3. **WordPress**
## Licencia
- `uploads.base_url = 'https://offload.brasdrive.com.br'`.
- Mantener `prefer_local_for_missing = false`.
GPL v2. Consulta [LICENSE](LICENSE) para más detalles.
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`).
Desarrollado por [Brasdrive](https://brasdrive.com.br).
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`.

View File

@ -1,19 +1,19 @@
{
"name": "tu-nombre/flysystem-offload",
"name": "brasdrive/flysystem-offload",
"description": "Universal storage offloading for WordPress vía Flysystem",
"type": "wordpress-plugin",
"require": {
"php": ">=7.4",
"php": ">=8.1",
"league/flysystem": "^3.24",
"league/flysystem-aws-s3-v3": "^3.24",
"league/flysystem-sftp-v3": "^3.24",
"league/flysystem-azure-blob-storage": "^3.24",
"league/flysystem-google-cloud-storage": "^3.24",
"league/flysystem-webdav": "^3.24",
"league/flysystem-path-prefixing": "^3.24",
"aws/aws-sdk-php": "^3.330",
"google/cloud-storage": "^1.33",
"microsoft/azure-storage-blob": "^1.5",
"azure-oss/storage-blob-flysystem": "^1.3.0",
"sabre/dav": "^4.5"
},
"autoload": {

View File

@ -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' => [],
],
];

View File

@ -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' => [],
],
];

View File

@ -1,14 +1,58 @@
<?php
/**
* Plugin Name: Flysystem Offload
* Description: Universal storage offloading para WordPress usando Flysystem.
* Version: 0.1.0
* Author: Tu Nombre
* 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
*/
defined('ABSPATH') || exit;
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
if (! defined('ABSPATH')) {
exit;
}
FlysystemOffload\Plugin::bootstrap(__FILE__);
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');
}
$autoload = __DIR__ . '/vendor/autoload.php';
if (file_exists($autoload)) {
require_once $autoload;
}
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;
}
try {
Plugin::bootstrap();
} catch (Throwable $exception) {
error_log('[Flysystem Offload] Error al iniciar el plugin: ' . $exception->getMessage());
add_action('admin_notices', static function () use ($exception): void {
if (! current_user_can('manage_options')) {
return;
}
printf(
'<div class="notice notice-error"><p><strong>Flysystem Offload:</strong> %s</p></div>',
esc_html($exception->getMessage())
);
});
}
}, 0);

View File

@ -1,73 +1,65 @@
<?php
declare(strict_types=1);
namespace FlysystemOffload\Admin;
use FlysystemOffload\Filesystem\FilesystemFactory;
use League\Flysystem\FilesystemException;
use League\Flysystem\Visibility;
use WP_CLI;
use WP_CLI_Command;
use WP_Error;
use FlysystemOffload\Helpers\PathHelper;
use League\Flysystem\FilesystemOperator;
class HealthCheck extends WP_CLI_Command
{
private FilesystemFactory $factory;
final class HealthCheck {
private FilesystemOperator $filesystem;
private array $config;
public function __construct(FilesystemFactory $factory)
{
$this->factory = $factory;
public function __construct(FilesystemOperator $filesystem, array $config) {
$this->filesystem = $filesystem;
$this->config = $config;
}
/**
* Ejecuta un chequeo básico de conectividad e integración.
*
* ## EXAMPLES
*
* wp flysystem-offload health-check
*/
public function __invoke(): void
{
$settings = get_option('flysystem_offload_settings', []);
if (! is_array($settings)) {
WP_CLI::error('No se encontraron ajustes del plugin.');
return;
public function register(): void {
\add_filter('site_status_tests', [$this, 'registerTest']);
}
$result = $this->run($settings);
public function registerTest(array $tests): array {
$tests['direct']['flysystem_offload'] = [
'label' => __('Flysystem Offload', 'flysystem-offload'),
'test' => [$this, 'runHealthTest'],
];
if ($result instanceof WP_Error) {
WP_CLI::error($result->get_error_message());
return;
return $tests;
}
WP_CLI::success('Chequeo completado correctamente.');
}
public function runHealthTest(): array {
$result = [
'label' => __('Flysystem Offload operativo', 'flysystem-offload'),
'status' => 'good',
'badge' => [
'label' => __('Flysystem', 'flysystem-offload'),
'color' => 'blue',
],
'description' => __('El almacenamiento remoto respondió correctamente a una operación de escritura/lectura.', 'flysystem-offload'),
'actions' => '',
'test' => 'flysystem_offload',
];
/**
* @param array<string, mixed> $settings
*/
public function run(array $settings)
{
try {
$filesystem = $this->factory->build($settings);
} catch (FilesystemException|\Throwable $exception) {
return new WP_Error('flysystem_offload_health_check_failed', $exception->getMessage());
}
$testKey = sprintf('health-check/%s.txt', wp_generate_uuid4());
$probeKey = PathHelper::join(
$this->config['stream']['root_prefix'] ?? '',
$this->config['stream']['host'] ?? 'uploads',
'.flysystem-offload-site-health'
);
try {
$filesystem->write($testKey, 'ok', ['visibility' => Visibility::PUBLIC]);
$filesystem->delete($testKey);
$this->filesystem->write($probeKey, 'ok', ['visibility' => $this->config['visibility'] ?? 'public']);
$this->filesystem->delete($probeKey);
} catch (\Throwable $exception) {
return new WP_Error('flysystem_offload_health_check_failed', $exception->getMessage());
$result['status'] = 'critical';
$result['label'] = __('No se pudo escribir en el almacenamiento remoto', 'flysystem-offload');
$result['description'] = sprintf(
'<p>%s</p><p><code>%s</code></p>',
esc_html__('Se produjo un error al comunicarse con el backend configurado.', 'flysystem-offload'),
esc_html($exception->getMessage())
);
}
return true;
return $result;
}
}

View File

@ -1,132 +1,58 @@
<?php
declare(strict_types=1);
namespace FlysystemOffload\Config;
class ConfigLoader
{
private string $pluginDir;
use RuntimeException;
use UnexpectedValueException;
public function __construct(string $pluginFile)
{
$this->pluginDir = dirname($pluginFile);
final class ConfigLoader {
private string $configDirectory;
public function __construct(string $configDirectory) {
$this->configDirectory = rtrim($configDirectory, '/\\');
}
/**
* Devuelve la configuración efectiva (defaults + overrides).
*
* @throws \RuntimeException cuando un archivo config no retorna array.
*/
public function load(): array
{
$defaults = $this->defaults();
$files = $this->discoverConfigFiles();
public function load(): array {
$candidateFiles = [
$this->configDirectory . '/flysystem-offload.local.php',
$this->configDirectory . '/flysystem-offload.php',
$this->configDirectory . '/flysystem-offload.example.php',
];
$config = [];
$configFile = $this->resolveFirstExisting($candidateFiles);
foreach ($files as $file) {
$data = require $file;
if (! is_array($data)) {
throw new \RuntimeException(
sprintf('[Flysystem Offload] El archivo de configuración "%s" debe retornar un array.', $file)
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 = array_replace_recursive($config, $data);
$config = include $configFile;
if ($config instanceof \Closure) {
$config = $config();
}
if (empty($config)) {
$config = $defaults;
} else {
$config = array_replace_recursive($defaults, $config);
if (! is_array($config)) {
throw new UnexpectedValueException(
sprintf('El archivo de configuración debe retornar un array. Archivo: %s', $configFile)
);
}
/**
* Permite ajustar/ensanchar la configuración en tiempo de ejecución.
* Ideal para multisite o integraciones externas.
*
* @param array $config
* @param array $files Lista de archivos usados (en orden de carga).
*/
return apply_filters('flysystem_offload_config', $config, $files);
return $config;
}
/**
* Defaults que garantizan compatibilidad si falta un archivo.
*/
public function defaults(): array
{
return [
'adapter' => 'local',
'base_prefix' => '',
'adapters' => [
's3' => [
'access_key' => '',
'secret_key' => '',
'region' => '',
'bucket' => '',
'prefix' => '',
'endpoint' => '',
'cdn_url' => '',
],
'sftp' => [
'host' => '',
'port' => 22,
'username' => '',
'password' => '',
'root' => '/uploads',
],
'gcs' => [
'project_id' => '',
'bucket' => '',
'key_file_path' => '',
],
'azure' => [
'account_name' => '',
'account_key' => '',
'container' => '',
'prefix' => '',
],
'webdav' => [
'base_uri' => '',
'username' => '',
'password' => '',
'path_prefix'=> '',
],
'googledrive' => [],
'onedrive' => [],
'dropbox' => [],
],
];
private function resolveFirstExisting(array $files): ?string {
foreach ($files as $file) {
if ($file && is_readable($file)) {
return $file;
}
}
/**
* Descubre archivos de configuración en orden de prioridad.
*
* @return string[]
*/
private function discoverConfigFiles(): array
{
$candidates = [];
if (defined('FLYSYSTEM_OFFLOAD_CONFIG')) {
$candidates[] = FLYSYSTEM_OFFLOAD_CONFIG;
}
// Opción por defecto en wp-content/.
$candidates[] = WP_CONTENT_DIR . '/flysystem-offload.php';
// Alias alternativo frecuente.
$candidates[] = WP_CONTENT_DIR . '/flysystem-offload-config.php';
// Fallback incluido dentro del plugin (para entornos sin personalización inicial).
$candidates[] = $this->pluginDir . '/config/flysystem-offload.php';
$unique = array_unique(array_filter(
$candidates,
static fn (string $path) => is_readable($path)
));
return array_values($unique);
return null;
}
}

View File

@ -1,26 +1,10 @@
<?php
declare(strict_types=1);
namespace FlysystemOffload\Filesystem;
use League\Flysystem\FilesystemAdapter;
use WP_Error;
interface AdapterInterface
{
/**
* @param array $settings Configuración específica del adaptador.
* @return FilesystemAdapter|WP_Error
*/
public function create(array $settings);
/**
* Devuelve la URL base pública para el adaptador.
*/
public function publicBaseUrl(array $settings): string;
/**
* Validación previa de opciones. Debe devolver true en caso de éxito.
*
* @return true|WP_Error
*/
public function validate(array $settings);
interface AdapterInterface {
public function createAdapter(array $config): FilesystemAdapter;
}

View File

@ -5,125 +5,93 @@ namespace FlysystemOffload\Filesystem\Adapters;
use Aws\S3\S3Client;
use FlysystemOffload\Filesystem\AdapterInterface;
use FlysystemOffload\Helpers\PathHelper;
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
use League\Flysystem\AwsS3V3\PortableVisibilityConverter;
use League\Flysystem\FilesystemAdapter;
use League\Flysystem\PathPrefixing\PathPrefixedAdapter;
use League\MimeTypeDetection\ExtensionMimeTypeDetector;
use League\MimeTypeDetection\MimeTypeDetector;
use WP_Error;
use League\Flysystem\Visibility;
use RuntimeException;
class S3Adapter implements AdapterInterface
{
private MimeTypeDetector $mimeTypeDetector;
final class S3Adapter implements AdapterInterface {
public function createAdapter(array $config): FilesystemAdapter {
$settings = $config['s3'] ?? [];
public function __construct(?MimeTypeDetector $mimeTypeDetector = null)
{
$this->mimeTypeDetector = $mimeTypeDetector ?? new ExtensionMimeTypeDetector();
$bucket = $settings['bucket'] ?? null;
if (! $bucket) {
throw new RuntimeException('Falta la clave "bucket" en la configuración de S3.');
}
public function validate(array $settings)
{
$required = ['access_key', 'secret_key', 'region', 'bucket'];
$root = PathHelper::normalize((string) ($settings['prefix'] ?? ''));
foreach ($required as $field) {
if (empty($settings[$field])) {
return new WP_Error(
'flysystem_offload_invalid_s3',
sprintf(__('Campo S3 faltante: %s', 'flysystem-offload'), $field)
);
}
}
return true;
}
public function create(array $settings): FilesystemAdapter|WP_Error
{
try {
$clientConfig = [
'credentials' => [
'key' => $settings['access_key'],
'secret' => $settings['secret_key'],
],
'region' => $settings['region'],
'version' => 'latest',
'version' => $settings['version'] ?? 'latest',
'region' => $settings['region'] ?? 'us-east-1',
];
if (! empty($settings['endpoint'])) {
$clientConfig['endpoint'] = rtrim($settings['endpoint'], '/');
$clientConfig['use_path_style_endpoint'] = (bool) ($settings['use_path_style_endpoint'] ?? true);
$credentials = [
'key' => $settings['key'] ?? getenv('AWS_ACCESS_KEY_ID'),
'secret' => $settings['secret'] ?? getenv('AWS_SECRET_ACCESS_KEY'),
];
if (! empty($settings['session_token'])) {
$credentials['token'] = $settings['session_token'];
}
if (! empty($settings['http_client'])) {
$clientConfig['http_client'] = $settings['http_client'];
if (! empty($credentials['key']) && ! empty($credentials['secret'])) {
$clientConfig['credentials'] = $credentials;
}
if (! empty($settings['endpoint'])) {
$clientConfig['endpoint'] = $settings['endpoint'];
}
if (isset($settings['use_path_style_endpoint'])) {
$clientConfig['use_path_style_endpoint'] = (bool) $settings['use_path_style_endpoint'];
}
if (! empty($settings['options']) && is_array($settings['options'])) {
$clientConfig = array_replace_recursive($clientConfig, $settings['options']);
}
$client = new S3Client($clientConfig);
$adapter = new AwsS3V3Adapter(
$client,
$settings['bucket'],
'',
options: [],
mimeTypeDetector: $this->mimeTypeDetector
$visibility = new PortableVisibilityConverter(
$settings['acl_public'] ?? 'public-read',
$settings['acl_private'] ?? 'private',
$config['visibility'] ?? Visibility::PUBLIC
);
$prefix = trim((string) ($settings['prefix'] ?? ''), '/');
$defaultOptions = $settings['default_options'] ?? [];
$uploadsConfig = $config['uploads'] ?? [];
if ($prefix !== '') {
if (class_exists(PathPrefixedAdapter::class)) {
$adapter = new PathPrefixedAdapter($adapter, $prefix);
} else {
$adapter = new PrefixedAdapter($adapter, $prefix);
if (! isset($defaultOptions['CacheControl']) && ! empty($uploadsConfig['cache_control'])) {
$defaultOptions['CacheControl'] = $uploadsConfig['cache_control'];
}
if (! isset($defaultOptions['Expires'])) {
if (! empty($uploadsConfig['expires'])) {
$defaultOptions['Expires'] = $uploadsConfig['expires'];
} elseif (! empty($uploadsConfig['expires_ttl'])) {
$defaultOptions['Expires'] = gmdate(
'D, d M Y H:i:s \\G\\M\\T',
time() + (int) $uploadsConfig['expires_ttl']
);
}
}
return $adapter;
} catch (\Throwable $e) {
return new WP_Error('flysystem_offload_s3_error', $e->getMessage());
}
}
$defaultOptions = apply_filters(
'flysystem_offload_s3_default_write_options',
$defaultOptions,
$config
);
public function publicBaseUrl(array $settings): string
{
$cdn = $settings['cdn_base_url'] ?? null;
if ($cdn) {
return rtrim($cdn, '/');
}
$bucket = $settings['bucket'] ?? '';
$endpoint = $settings['endpoint'] ?? null;
$region = $settings['region'] ?? 'us-east-1';
$usePathStyle = (bool) ($settings['use_path_style_endpoint'] ?? false);
$prefix = trim((string) ($settings['prefix'] ?? ''), '/');
$normalizedUrl = null;
if ($endpoint) {
$endpoint = rtrim($endpoint, '/');
$parts = parse_url($endpoint);
if (! $parts || empty($parts['host'])) {
$normalizedUrl = sprintf('%s/%s', $endpoint, $bucket);
} elseif ($usePathStyle) {
$path = isset($parts['path']) ? rtrim($parts['path'], '/') : '';
$scheme = $parts['scheme'] ?? 'https';
$port = isset($parts['port']) ? ':' . $parts['port'] : '';
$normalizedUrl = sprintf('%s://%s%s%s/%s', $scheme, $parts['host'], $port, $path, $bucket);
} else {
$scheme = $parts['scheme'] ?? 'https';
$port = isset($parts['port']) ? ':' . $parts['port'] : '';
$path = isset($parts['path']) ? rtrim($parts['path'], '/') : '';
$normalizedUrl = sprintf('%s://%s.%s%s%s', $scheme, $bucket, $parts['host'], $port, $path);
}
}
if (! $normalizedUrl) {
$normalizedUrl = sprintf('https://%s.s3.%s.amazonaws.com', $bucket, $region);
}
return $prefix ? $normalizedUrl . '/' . $prefix : $normalizedUrl;
return new AwsS3V3Adapter(
$client,
(string) $bucket,
$root,
$visibility,
null,
$defaultOptions
);
}
}

View File

@ -3,96 +3,25 @@ declare(strict_types=1);
namespace FlysystemOffload\Filesystem;
use FlysystemOffload\Filesystem\Adapters\AzureBlobAdapter;
use FlysystemOffload\Filesystem\Adapters\DropboxAdapter;
use FlysystemOffload\Filesystem\Adapters\GoogleCloudAdapter;
use FlysystemOffload\Filesystem\Adapters\GoogleDriveAdapter;
use FlysystemOffload\Filesystem\Adapters\OneDriveAdapter;
use FlysystemOffload\Filesystem\Adapters\S3Adapter;
use FlysystemOffload\Filesystem\Adapters\SftpAdapter;
use FlysystemOffload\Filesystem\Adapters\WebdavAdapter;
use League\Flysystem\Filesystem;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\Local\LocalFilesystemAdapter;
use WP_Error;
use League\Flysystem\Visibility;
class FilesystemFactory
{
private array $settings;
final class FilesystemFactory {
public function make(array $config): FilesystemOperator {
$driver = $config['driver'] ?? 's3';
public function __construct(array $settings)
{
$this->settings = $settings;
}
public function make(): FilesystemOperator|WP_Error
{
$adapterKey = $this->settings['adapter'] ?? 'local';
$config = $this->settings['adapters'][$adapterKey] ?? [];
$adapter = $this->resolveAdapter($adapterKey);
if ($adapter instanceof WP_Error) {
return $adapter;
}
$validation = $adapter->validate($config);
if ($validation instanceof WP_Error) {
return $validation;
}
$flyAdapter = $adapter->create($config);
if ($flyAdapter instanceof WP_Error) {
return $flyAdapter;
}
return new Filesystem($flyAdapter);
}
public function resolvePublicBaseUrl(string $adapterKey, array $settings): string
{
$adapter = $this->resolveAdapter($adapterKey);
if ($adapter instanceof WP_Error) {
return content_url('/uploads');
}
$baseUrl = $adapter->publicBaseUrl($settings);
return untrailingslashit($baseUrl);
}
private function resolveAdapter(string $adapterKey): AdapterInterface|WP_Error
{
return match ($adapterKey) {
's3' => new S3Adapter(),
'sftp' => new SftpAdapter(),
'gcs' => new GoogleCloudAdapter(),
'azure' => new AzureBlobAdapter(),
'webdav' => new WebdavAdapter(),
'googledrive' => new GoogleDriveAdapter(), // stub (dev)
'onedrive' => new OneDriveAdapter(), // stub (dev)
'dropbox' => new DropboxAdapter(), // stub (dev)
default => new class implements AdapterInterface {
public function create(array $settings)
{
$root = WP_CONTENT_DIR . '/flysystem-uploads';
return new LocalFilesystemAdapter($root);
}
public function publicBaseUrl(array $settings): string
{
return content_url('/flysystem-uploads');
}
public function validate(array $settings)
{
wp_mkdir_p(WP_CONTENT_DIR . '/flysystem-uploads');
return true;
}
},
$adapter = match ($driver) {
's3' => (new S3Adapter())->createAdapter($config),
default => throw new \InvalidArgumentException(sprintf('Driver de Flysystem no soportado: "%s".', $driver)),
};
$filesystemConfig = [
'visibility' => $config['visibility'] ?? Visibility::PUBLIC,
'directory_visibility' => $config['visibility'] ?? Visibility::PUBLIC,
];
return new Filesystem($adapter, $filesystemConfig);
}
}

View File

@ -3,53 +3,46 @@ declare(strict_types=1);
namespace FlysystemOffload\Helpers;
final class PathHelper
{
private function __construct() {}
public static function stripProtocol(string $path): string
{
return ltrim(preg_replace('#^[^:]+://#', '', $path) ?? '', '/');
}
public static function trimLeadingSlash(string $path): string
{
return ltrim($path, '/');
}
public static function trimTrailingSlash(string $path): string
{
return rtrim($path, '/');
}
public static function trimSlashes(string $path): string
{
return trim($path, '/');
}
public static function ensureTrailingSlash(string $path): string
{
return rtrim($path, '/') . '/';
}
public static function normalizeDirectory(string $path): string
{
$path = str_replace('\\', '/', $path);
$path = preg_replace('#/{2,}#', '/', $path) ?? $path;
return self::trimTrailingSlash($path);
}
public static function normalizePrefix(?string $prefix): string
{
if ($prefix === null || $prefix === '') {
final class PathHelper {
public static function join(string ...$segments): string {
if ($segments === []) {
return '';
}
$prefix = self::stripProtocol($prefix);
$prefix = self::normalizeDirectory($prefix);
$prefix = self::trimSlashes($prefix);
$filtered = array_filter(
$segments,
static fn (string $segment): bool => $segment !== ''
);
return $prefix === '' ? '' : self::ensureTrailingSlash($prefix);
if ($filtered === []) {
return '';
}
$normalized = array_map(
static fn (string $segment): string => self::normalize($segment),
$filtered
);
$normalized = array_filter($normalized, static fn (string $segment): bool => $segment !== '');
if ($normalized === []) {
return '';
}
return implode('/', $normalized);
}
public static function normalize(string $path): string {
if ($path === '') {
return '';
}
$path = str_replace('\\', '/', $path);
$normalized = preg_replace('#/+#', '/', $path);
if ($normalized === null) {
$normalized = $path;
}
return trim($normalized, '/');
}
}

View File

@ -5,306 +5,231 @@ namespace FlysystemOffload\Media;
use FlysystemOffload\Helpers\PathHelper;
use League\Flysystem\FilesystemOperator;
use WP_Error;
class MediaHooks
{
private ?FilesystemOperator $filesystem = null;
private string $basePrefix;
final class MediaHooks {
private FilesystemOperator $filesystem;
private array $config;
private string $protocol;
private string $streamHost;
private string $streamRootPrefix;
private string $s3Prefix;
private string $baseUrl;
private string $effectiveBaseUrl;
private string $remoteUrlPathPrefix;
private bool $deleteRemote;
private bool $preferLocal;
/** @var array<int, array{type:'filter'|'action', hook:string, callback:callable, priority:int, accepted_args:int}> */
private array $attachedCallbacks = [];
private bool $registered = false;
private const IMAGE_EDITOR_IMAGICK = 'FlysystemOffload\\Media\\ImageEditorImagick';
private const IMAGE_EDITOR_GD = 'FlysystemOffload\\Media\\ImageEditorGD';
public function __construct(string $basePrefix = '')
{
$this->basePrefix = PathHelper::trimSlashes($basePrefix);
}
public function register(): void
{
if ($this->registered) {
return;
}
$this->attachFilter('upload_dir', [$this, 'filterUploadDir'], 20, 1);
$this->attachFilter('wp_get_attachment_url', [$this, 'filterAttachmentUrl'], 20, 2);
$this->attachFilter('get_attached_file', [$this, 'filterGetAttachedFile'], 20, 2);
$this->attachFilter('update_attached_file', [$this, 'filterUpdateAttachedFile'], 20, 2);
$this->attachFilter('wp_read_image_metadata', [$this, 'ensureLocalPathForMetadata'], 5, 2);
$this->attachFilter('image_editors', [$this, 'filterImageEditors'], 5, 1);
$this->attachAction('delete_attachment', [$this, 'handleDeleteAttachment'], 20, 1);
$this->registered = true;
}
public function unregister(): void
{
if (! $this->registered) {
return;
}
foreach ($this->attachedCallbacks as $hookData) {
if ($hookData['type'] === 'filter') {
remove_filter(
$hookData['hook'],
$hookData['callback'],
$hookData['priority']
);
} else {
remove_action(
$hookData['hook'],
$hookData['callback'],
$hookData['priority']
);
}
}
$this->attachedCallbacks = [];
$this->registered = false;
}
public function setFilesystem(?FilesystemOperator $filesystem): void
{
public function __construct(FilesystemOperator $filesystem, array $config) {
$this->filesystem = $filesystem;
$this->config = $config;
$this->protocol = (string) $config['stream']['protocol'];
if (class_exists(self::IMAGE_EDITOR_IMAGICK)) {
\call_user_func([self::IMAGE_EDITOR_IMAGICK, 'bootWithFilesystem'], $filesystem);
}
if (class_exists(self::IMAGE_EDITOR_GD)) {
\call_user_func([self::IMAGE_EDITOR_GD, 'bootWithFilesystem'], $filesystem);
}
$this->streamRootPrefix = PathHelper::normalize((string) ($config['stream']['root_prefix'] ?? ''));
$this->streamHost = PathHelper::normalize((string) ($config['stream']['host'] ?? 'uploads')) ?: 'uploads';
$this->s3Prefix = PathHelper::normalize((string) ($config['s3']['prefix'] ?? ''));
$this->baseUrl = $this->normaliseBaseUrl((string) ($config['uploads']['base_url'] ?? content_url('uploads')));
$this->remoteUrlPathPrefix = $this->buildRemoteUrlPathPrefix();
$this->effectiveBaseUrl = $this->calculateEffectiveBaseUrl($this->baseUrl, $this->remoteUrlPathPrefix);
$this->deleteRemote = (bool) ($config['uploads']['delete_remote'] ?? true);
$this->preferLocal = (bool) ($config['uploads']['prefer_local_for_missing'] ?? false);
}
public function filterUploadDir(array $uploadDir): array
{
$subdir = trim($uploadDir['subdir'] ?? '', '/');
$remoteBase = $this->basePrefix !== '' ? $this->basePrefix . '/' : '';
$remoteBase .= $subdir !== '' ? $subdir : '';
$remoteBase = trim($remoteBase, '/');
$uploadDir['path'] = $remoteBase !== '' ? 'fly://' . $remoteBase : 'fly://';
$uploadDir['basedir'] = $uploadDir['path'];
$uploadDir['subdir'] = $subdir !== '' ? '/' . $subdir : '';
$uploadDir['url'] = $uploadDir['baseurl'] = $uploadDir['url']; // baseurl se sobrescribe en Plugin::filterUploadDir
return $uploadDir;
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']);
}
public function filterAttachmentUrl(string $url, int $attachmentId): string
{
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;
}
public function rewriteAttachmentUrl(string $url, int $attachmentId): string {
$file = get_post_meta($attachmentId, '_wp_attached_file', true);
if (empty($file)) {
if (! $file) {
return $url;
}
$relative = PathHelper::trimLeadingSlash($file);
return trailingslashit($this->getBaseUrl()) . $relative;
$relativePath = PathHelper::normalize($file);
if ($relativePath === '') {
return $url;
}
public function filterGetAttachedFile(string $file, int $attachmentId): string
{
$meta = wp_get_attachment_metadata($attachmentId);
$relative = $meta['file'] ?? get_post_meta($attachmentId, '_wp_attached_file', true);
if (empty($relative)) {
return $file;
}
return 'fly://' . PathHelper::trimLeadingSlash($relative);
}
public function filterUpdateAttachedFile(string $file, int $attachmentId): string
{
if (str_starts_with($file, 'fly://')) {
update_post_meta($attachmentId, '_wp_attached_file', PathHelper::stripProtocol($file));
} else {
update_post_meta($attachmentId, '_wp_attached_file', PathHelper::trimLeadingSlash($file));
}
return $file;
}
public function ensureLocalPathForMetadata($metadata, string $file)
{
if (! str_starts_with($file, 'fly://') || ! $this->filesystem) {
return $metadata;
}
// Fuerza a WP a usar una copia temporal local durante la lectura de EXIF/IPTC
$remotePath = PathHelper::stripProtocol($file);
$temp = $this->downloadToTemp($remotePath);
if (! is_wp_error($temp)) {
$metadata = wp_read_image_metadata($temp);
@unlink($temp);
}
return $metadata;
}
public function filterImageEditors(array $editors): array
{
if (class_exists(self::IMAGE_EDITOR_IMAGICK)) {
$editors = array_filter(
$editors,
static fn (string $editor) => $editor !== \WP_Image_Editor_Imagick::class
);
array_unshift($editors, self::IMAGE_EDITOR_IMAGICK);
}
if (class_exists(self::IMAGE_EDITOR_GD)) {
$editors = array_filter(
$editors,
static fn (string $editor) => $editor !== \WP_Image_Editor_GD::class
);
array_unshift($editors, self::IMAGE_EDITOR_GD);
}
return array_values(array_unique($editors));
}
public function handleDeleteAttachment(int $attachmentId): void
{
if (! $this->filesystem) {
return;
}
$meta = wp_get_attachment_metadata($attachmentId);
$relative = $meta['file'] ?? get_post_meta($attachmentId, '_wp_attached_file', true);
if (empty($relative)) {
return;
}
$base = PathHelper::trimLeadingSlash($relative);
$directory = trim(dirname($base), './');
$targets = [$base];
if (! empty($meta['sizes'])) {
foreach ($meta['sizes'] as $size) {
if (! empty($size['file'])) {
$targets[] = ltrim(($directory ? $directory . '/' : '') . $size['file'], '/');
}
if ($this->remoteUrlPathPrefix !== '') {
$prefixWithSlash = $this->remoteUrlPathPrefix . '/';
if (str_starts_with($relativePath, $prefixWithSlash)) {
$relativePath = substr($relativePath, strlen($prefixWithSlash));
} elseif ($relativePath === $this->remoteUrlPathPrefix) {
$relativePath = '';
}
}
foreach ($targets as $target) {
try {
if ($this->filesystem->fileExists($target)) {
$this->filesystem->delete($target);
}
} catch (\Throwable $e) {
error_log('[Flysystem Offload] Error eliminando ' . $target . ': ' . $e->getMessage());
}
}
}
$remoteUrl = $this->buildPublicUrl($relativePath);
private function attachFilter(
string $hook,
callable $callback,
int $priority = 10,
int $acceptedArgs = 1
): void {
add_filter($hook, $callback, $priority, $acceptedArgs);
$this->attachedCallbacks[] = [
'type' => 'filter',
'hook' => $hook,
'callback' => $callback,
'priority' => $priority,
'accepted_args' => $acceptedArgs,
];
}
private function attachAction(
string $hook,
callable $callback,
int $priority = 10,
int $acceptedArgs = 1
): void {
add_action($hook, $callback, $priority, $acceptedArgs);
$this->attachedCallbacks[] = [
'type' => 'action',
'hook' => $hook,
'callback' => $callback,
'priority' => $priority,
'accepted_args' => $acceptedArgs,
];
}
private function downloadToTemp(string $remotePath)
{
if (! $this->filesystem) {
return new WP_Error(
'flysystem_offload_no_fs',
__('No hay filesystem remoto configurado.', 'flysystem-offload')
);
}
if (! function_exists('wp_tempnam')) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
$temp = wp_tempnam(basename($remotePath));
if (! $temp) {
return new WP_Error(
'flysystem_offload_temp_fail',
__('No se pudo crear un archivo temporal.', 'flysystem-offload')
);
if (! $this->preferLocal) {
return $remoteUrl;
}
try {
$stream = $this->filesystem->readStream($remotePath);
if (! is_resource($stream)) {
throw new \RuntimeException('No se pudo abrir el stream remoto.');
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()
));
}
$target = fopen($temp, 'wb');
if (! $target) {
throw new \RuntimeException('No se pudo abrir el archivo temporal.');
return $url;
}
stream_copy_to_stream($stream, $target);
fclose($stream);
fclose($target);
} catch (\Throwable $e) {
if (isset($stream) && is_resource($stream)) {
fclose($stream);
public function filterImageDownsize(bool|array $out, int $attachmentId, array|string $size): bool|array {
return false;
}
if (isset($target) && is_resource($target)) {
fclose($target);
}
@unlink($temp);
return new WP_Error(
'flysystem_offload_download_fail',
sprintf(
__('Fallo al descargar "%s" del almacenamiento remoto: %s', 'flysystem-offload'),
$remotePath,
$e->getMessage()
)
public function deleteRemoteFiles(int $attachmentId): void {
if (! $this->deleteRemote) {
return;
}
$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()
));
}
}
}
/**
* @return list<string>
*/
private function gatherAttachmentFiles(int $attachmentId): array {
$files = [];
$attachedFile = get_post_meta($attachmentId, '_wp_attached_file', true);
if ($attachedFile) {
$files[] = $attachedFile;
}
$meta = wp_get_attachment_metadata($attachmentId);
if (is_array($meta)) {
if (! empty($meta['file'])) {
$files[] = $meta['file'];
}
if (! empty($meta['sizes']) && is_array($meta['sizes'])) {
$baseDir = $this->dirName($meta['file'] ?? '');
foreach ($meta['sizes'] as $sizeMeta) {
if (! empty($sizeMeta['file'])) {
$files[] = ($baseDir !== '' ? $baseDir . '/' : '') . $sizeMeta['file'];
}
}
}
}
$files = array_filter($files, static fn ($file) => is_string($file) && $file !== '');
return array_values(array_unique($files, SORT_STRING));
}
private function dirName(string $path): string {
$directory = dirname($path);
return $directory === '.' ? '' : $directory;
}
private function toRemotePath(string $file): string {
$segments = [];
if ($this->streamRootPrefix !== '') {
$segments[] = $this->streamRootPrefix;
}
if ($this->streamHost !== '') {
$segments[] = $this->streamHost;
}
$segments[] = $file;
return PathHelper::join(...$segments);
}
private function normaliseBaseUrl(string $baseUrl): string {
$baseUrl = trim($baseUrl);
if ($baseUrl === '') {
$baseUrl = content_url('uploads');
}
return rtrim($baseUrl, '/');
}
private function buildRemoteUrlPathPrefix(): string {
$segments = array_filter(
[$this->s3Prefix, $this->streamRootPrefix, $this->streamHost],
static fn (string $segment): bool => $segment !== ''
);
return PathHelper::join(...$segments);
}
return $temp;
private function calculateEffectiveBaseUrl(string $baseUrl, string $pathPrefix): string {
$baseUrl = rtrim($baseUrl, '/');
if ($pathPrefix === '') {
return $baseUrl;
}
private function getBaseUrl(): string
{
$uploadDir = wp_get_upload_dir();
$basePath = trim((string) parse_url($baseUrl, PHP_URL_PATH), '/');
return $uploadDir['baseurl'] ?? content_url('/uploads');
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);
}
}

View File

@ -1,142 +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\FilesystemOperator;
use League\Flysystem\Visibility;
use RuntimeException;
use WP_Error;
final class Plugin
{
private FilesystemFactory $filesystemFactory;
final class Plugin {
private static bool $bootstrapped = false;
private static ?Plugin $instance = null;
private array $config;
private FilesystemOperator $filesystem;
private MediaHooks $mediaHooks;
private SettingsPage $settingsPage;
private array $config = [];
private bool $isInitialized = false;
private ?SettingsPage $settingsPage;
private HealthCheck $healthCheck;
public function __construct(
FilesystemFactory $filesystemFactory,
private function __construct(
array $config,
FilesystemOperator $filesystem,
MediaHooks $mediaHooks,
SettingsPage $settingsPage
?SettingsPage $settingsPage,
HealthCheck $healthCheck
) {
$this->filesystemFactory = $filesystemFactory;
$this->config = $config;
$this->filesystem = $filesystem;
$this->mediaHooks = $mediaHooks;
$this->settingsPage = $settingsPage;
$this->healthCheck = $healthCheck;
}
public function register(): void
{
add_action('plugins_loaded', [$this, 'bootstrap'], 5);
add_action('init', [$this, 'loadTextDomain']);
add_filter('plugin_action_links_' . plugin_basename(FlysystemOffload::PLUGIN_FILE), [$this, 'pluginLinks']);
}
public function loadTextDomain(): void
{
load_plugin_textdomain(
'flysystem-offload',
false,
dirname(plugin_basename(FlysystemOffload::PLUGIN_FILE)) . '/languages'
);
}
public function bootstrap(): void
{
if ($this->isInitialized) {
public static function bootstrap(): void {
if (self::$bootstrapped) {
return;
}
$this->config = $this->settingsPage->getSettings();
$configDirectory = \defined('FLYSYSTEM_OFFLOAD_CONFIG_PATH')
? FLYSYSTEM_OFFLOAD_CONFIG_PATH
: \dirname(__DIR__) . '/config';
if (! is_array($this->config)) {
$this->config = [];
}
$configLoader = new ConfigLoader($configDirectory);
$config = self::normaliseConfig($configLoader->load());
$this->registerStreamWrapper();
$this->mediaHooks->register($this);
$this->settingsPage->register($this);
if (defined('WP_CLI') && WP_CLI) {
if (class_exists(HealthCheck::class)) {
\WP_CLI::add_command('flysystem-offload health-check', new HealthCheck($this->filesystemFactory));
}
}
$this->isInitialized = true;
}
public function reloadConfig(): void
{
$this->mediaHooks->unregister();
$this->config = $this->settingsPage->getSettings();
if (! is_array($this->config)) {
$this->config = [];
}
$this->registerStreamWrapper();
$this->mediaHooks->register($this);
}
public function registerStreamWrapper(): void
{
try {
$filesystem = $this->filesystemFactory->build($this->config);
$protocol = $this->config['protocol'] ?? 'fly';
$prefix = $this->config['root_prefix'] ?? '';
$filesystemFactory = new FilesystemFactory();
$filesystem = $filesystemFactory->make($config);
FlysystemStreamWrapper::register(
$filesystem,
$protocol,
$prefix,
[
'visibility' => $this->config['visibility'] ?? Visibility::PUBLIC,
]
$config['stream']['protocol'],
$config['stream']['root_prefix'],
$config['visibility']
);
} catch (\Throwable $exception) {
error_log('[Flysystem Offload] No se pudo registrar el stream wrapper: ' . $exception->getMessage());
}
$mediaHooks = new MediaHooks($filesystem, $config);
$mediaHooks->register();
$settingsPage = null;
if (! empty($config['admin']['enabled']) && \is_admin()) {
$settingsPage = new SettingsPage($filesystem, $config);
$settingsPage->register();
}
public function pluginLinks(array $links): array
{
$settingsUrl = admin_url('options-general.php?page=flysystem-offload');
$healthCheck = new HealthCheck($filesystem, $config);
$healthCheck->register();
$links[] = '<a href="' . esc_url($settingsUrl) . '">' . esc_html__('Ajustes', 'flysystem-offload') . '</a>';
return $links;
self::$instance = new self($config, $filesystem, $mediaHooks, $settingsPage, $healthCheck);
self::$bootstrapped = true;
}
public function getConfig(): array
{
public static function instance(): ?self {
return self::$instance;
}
public function config(): array {
return $this->config;
}
public function getFilesystemFactory(): FilesystemFactory
{
return $this->filesystemFactory;
public function filesystem(): FilesystemOperator {
return $this->filesystem;
}
public function getRemoteUrlBase(): string
{
$driver = $this->config['driver'] ?? null;
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' => [],
],
];
if (! $driver) {
return '';
$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'], '/');
}
$result = $this->filesystemFactory->resolvePublicBaseUrl($driver, $this->config);
$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));
if ($result instanceof WP_Error) {
throw new RuntimeException($result->get_error_message());
$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;
}
return $result;
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/");
}
}

View File

@ -3,98 +3,109 @@ declare(strict_types=1);
namespace FlysystemOffload\Settings;
use FlysystemOffload\Plugin;
use FlysystemOffload\Helpers\PathHelper;
use League\Flysystem\FilesystemOperator;
class SettingsPage
{
private const OPTION_KEY = 'flysystem_offload_settings';
final class SettingsPage {
private FilesystemOperator $filesystem;
private array $config;
/**
* @return array<string, mixed>
*/
public function getSettings(): array
{
$settings = get_option(self::OPTION_KEY, []);
return is_array($settings) ? $settings : [];
public function __construct(FilesystemOperator $filesystem, array $config) {
$this->filesystem = $filesystem;
$this->config = $config;
}
public function register(Plugin $plugin): void
{
add_action('admin_menu', function () {
add_options_page(
public function register(): void {
\add_action('admin_menu', [$this, 'registerMenu']);
}
public function registerMenu(): void {
\add_options_page(
__('Flysystem Offload', 'flysystem-offload'),
__('Flysystem Offload', 'flysystem-offload'),
'manage_options',
'flysystem-offload',
[$this, 'renderPage']
);
});
add_action('admin_init', [$this, 'registerSettings']);
}
public function renderPage(): void
{
if (! current_user_can('manage_options')) {
wp_die(__('No tienes permisos para acceder a esta página.', 'flysystem-offload'));
public function renderPage(): void {
if (! \current_user_can('manage_options')) {
return;
}
$settings = $this->getSettings();
$status = $this->probeFilesystem();
?>
<div class="wrap">
<h1><?php esc_html_e('Flysystem Offload', 'flysystem-offload'); ?></h1>
<p><?php esc_html_e('Configura el almacenamiento remoto para WordPress.', 'flysystem-offload'); ?></p>
<p class="description">
<?php esc_html_e('Configuración de solo lectura del almacenamiento remoto.', 'flysystem-offload'); ?>
</p>
<form action="options.php" method="post">
<table class="widefat striped">
<tbody>
<tr>
<th scope="row"><?php esc_html_e('Driver', 'flysystem-offload'); ?></th>
<td><?php echo esc_html($this->config['driver'] ?? ''); ?></td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('Protocolo del Stream Wrapper', 'flysystem-offload'); ?></th>
<td><?php echo esc_html($this->config['stream']['protocol'] ?? ''); ?></td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('Prefijo remoto', 'flysystem-offload'); ?></th>
<td><?php echo esc_html($this->config['stream']['root_prefix'] ?? ''); ?></td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('Host del stream', 'flysystem-offload'); ?></th>
<td><?php echo esc_html($this->config['stream']['host'] ?? ''); ?></td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('URL base pública', 'flysystem-offload'); ?></th>
<td><code><?php echo esc_html($this->config['uploads']['base_url'] ?? ''); ?></code></td>
</tr>
</tbody>
</table>
<h2><?php esc_html_e('Estado del almacenamiento remoto', 'flysystem-offload'); ?></h2>
<p>
<?php
settings_fields('flysystem_offload');
do_settings_sections('flysystem-offload');
submit_button();
if ($status) {
printf(
'<span class="dashicons dashicons-yes" style="color:green;"></span> %s',
esc_html__('Conexión verificada correctamente.', 'flysystem-offload')
);
} else {
printf(
'<span class="dashicons dashicons-warning" style="color:#d63638;"></span> %s',
esc_html__('No fue posible escribir en el almacenamiento remoto.', 'flysystem-offload')
);
}
?>
</form>
</p>
</div>
<?php
}
public function registerSettings(): void
{
register_setting('flysystem_offload', self::OPTION_KEY);
add_settings_section(
'flysystem_offload_general',
__('Configuración General', 'flysystem-offload'),
function () {
echo '<p>' . esc_html__(
'Introduce las credenciales del proveedor que deseas utilizar.',
'flysystem-offload'
) . '</p>';
},
'flysystem-offload'
private function probeFilesystem(): bool {
$probeKey = PathHelper::join(
$this->config['stream']['root_prefix'] ?? '',
$this->config['stream']['host'] ?? 'uploads',
'.flysystem-offload-probe'
);
add_settings_field(
'flysystem_offload_driver',
__('Driver', 'flysystem-offload'),
[$this, 'renderDriverField'],
'flysystem-offload',
'flysystem_offload_general'
);
try {
if ($this->filesystem->fileExists($probeKey)) {
return true;
}
public function renderDriverField(): void
{
$settings = $this->getSettings();
$driver = $settings['driver'] ?? '';
?>
<select name="<?php echo esc_attr(self::OPTION_KEY . '[driver]'); ?>">
<option value=""><?php esc_html_e('Selecciona un driver', 'flysystem-offload'); ?></option>
<option value="s3" <?php selected($driver, 's3'); ?>><?php esc_html_e('Amazon S3 / Compatible', 'flysystem-offload'); ?></option>
<option value="gcs" <?php selected($driver, 'gcs'); ?>><?php esc_html_e('Google Cloud Storage', 'flysystem-offload'); ?></option>
<option value="azure" <?php selected($driver, 'azure'); ?>><?php esc_html_e('Azure Blob Storage', 'flysystem-offload'); ?></option>
<option value="sftp" <?php selected($driver, 'sftp'); ?>><?php esc_html_e('SFTP', 'flysystem-offload'); ?></option>
<option value="webdav" <?php selected($driver, 'webdav'); ?>><?php esc_html_e('WebDAV', 'flysystem-offload'); ?></option>
</select>
<?php
$this->filesystem->write($probeKey, 'ok', ['visibility' => $this->config['visibility'] ?? 'public']);
$this->filesystem->delete($probeKey);
return true;
} catch (\Throwable $exception) {
\error_log(sprintf('[Flysystem Offload] Health probe falló: %s', $exception->getMessage()));
return false;
}
}
}

View File

@ -1,295 +1,460 @@
<?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;
use RuntimeException;
use Throwable;
final class FlysystemStreamWrapper
{
/** @var array<string, FilesystemOperator> */
private static array $filesystems = [];
/** @var array<string, string> */
private static array $prefixes = [];
/** @var array<string, array<string, mixed>> */
private static array $writeOptions = [];
final class FlysystemStreamWrapper {
private static ?FilesystemOperator $filesystem = null;
private static string $protocol = 'flysystem';
private static string $rootPrefix = '';
private static string $defaultVisibility = Visibility::PUBLIC;
/** @var resource|null */
private $stream = null;
private string $protocol = '';
private $resource = null;
private string $mode = 'r';
private string $path = '';
private string $mode = '';
private string $flyPath = '';
private array $dirEntries = [];
private int $dirPosition = 0;
private string $uri = '';
private bool $dirty = false;
/** @var resource|array|string|null */
public $context = null;
/** @var list<string> */
private array $directoryListing = [];
private int $directoryPosition = 0;
public static function register(
FilesystemOperator $filesystem,
string $protocol,
string $prefix = '',
array $writeOptions = []
string $protocol = 'flysystem',
string $rootPrefix = '',
string $defaultVisibility = Visibility::PUBLIC,
bool $force = true
): void {
if (in_array($protocol, stream_get_wrappers(), true)) {
self::$filesystem = $filesystem;
self::$protocol = $protocol;
self::$rootPrefix = PathHelper::normalize($rootPrefix);
self::$defaultVisibility = $defaultVisibility;
$wrappers = stream_get_wrappers();
if (in_array($protocol, $wrappers, true)) {
if (! $force) {
return;
}
stream_wrapper_unregister($protocol);
}
self::$filesystems[$protocol] = $filesystem;
self::$prefixes[$protocol] = trim($prefix, '/');
self::$writeOptions[$protocol] = $writeOptions + [
'visibility' => Visibility::PUBLIC,
];
stream_wrapper_register($protocol, static::class, STREAM_IS_URL);
if (! stream_wrapper_register($protocol, static::class, STREAM_IS_URL)) {
throw new \RuntimeException(sprintf('Unable to register stream wrapper for protocol "%s".', $protocol));
}
}
public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool
{
unset($openedPath);
$this->protocol = $this->extractProtocol($path);
$this->path = $path;
public static function unregister(): void {
if (self::$protocol !== '' && in_array(self::$protocol, stream_get_wrappers(), true)) {
stream_wrapper_unregister(self::$protocol);
}
self::$filesystem = null;
}
public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool {
$this->ensureFilesystem();
$this->uri = $path;
$this->path = self::uriToKey($path);
$this->mode = $mode;
$this->flyPath = $this->resolveFlyPath($path);
$this->resource = fopen('php://temp', 'w+b');
$this->dirty = false;
$filesystem = $this->filesystem($this->protocol);
$binary = str_contains($mode, 'b') ? 'b' : '';
if (strpbrk($mode, 'waxc') !== false) {
$this->stream = fopen('php://temp', 'w+' . $binary);
if ($this->stream === false) {
if ($this->resource === false) {
return false;
}
if (str_contains($mode, 'a') && $filesystem->fileExists($this->flyPath)) {
if ($this->requiresExistingFile($mode) && ! $this->fileExists($this->path)) {
if ($options & STREAM_REPORT_ERRORS) {
trigger_error(sprintf('Flysystem path "%s" does not exist.', $this->path), E_USER_WARNING);
}
return false;
}
if ($this->isExclusiveCreateMode($mode) && $this->fileExists($this->path)) {
return false;
}
if (($this->requiresExistingFile($mode) || $this->isAppendMode($mode)) && $this->fileExists($this->path)) {
try {
$remote = $filesystem->readStream($this->flyPath);
$contents = self::$filesystem->read($this->path);
fwrite($this->resource, $contents);
unset($contents);
if (is_resource($remote)) {
stream_copy_to_stream($remote, $this->stream);
fclose($remote);
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 (UnableToReadFile $exception) {
error_log('[Flysystem Offload] Unable to open stream for append: ' . $exception->getMessage());
return false;
}
}
rewind($this->stream);
return true;
}
try {
$remote = $filesystem->readStream($this->flyPath);
if (! is_resource($remote)) {
public function stream_read(int $count): string|false {
if (! $this->resource) {
return false;
}
$local = fopen('php://temp', 'w+' . $binary);
if ($local === false) {
fclose($remote);
return fread($this->resource, $count);
}
public function stream_write(string $data): int|false {
if (! $this->resource) {
return false;
}
stream_copy_to_stream($remote, $local);
fclose($remote);
rewind($local);
$this->stream = $local;
return true;
} catch (UnableToReadFile $exception) {
error_log('[Flysystem Offload] Unable to open stream: ' . $exception->getMessage());
return false;
}
$written = fwrite($this->resource, $data);
if ($written !== false) {
$this->dirty = true;
}
public function stream_read(int $count): string|false
{
if (! is_resource($this->stream)) {
return $written;
}
public function stream_tell(): int|false {
if (! $this->resource) {
return false;
}
return fread($this->stream, $count);
return ftell($this->resource);
}
public function stream_write(string $data): int|false
{
if (! is_resource($this->stream)) {
return false;
}
return fwrite($this->stream, $data);
}
public function stream_flush(): bool
{
if (! is_resource($this->stream) || strpbrk($this->mode, 'waxc') === false) {
public function stream_eof(): bool {
if (! $this->resource) {
return true;
}
$filesystem = $this->filesystem($this->protocol);
try {
$meta = stream_get_meta_data($this->stream);
$seekable = (bool) ($meta['seekable'] ?? false);
if ($seekable) {
rewind($this->stream);
return feof($this->resource);
}
$filesystem->writeStream(
$this->flyPath,
$this->stream,
$this->writeOptionsForProtocol($this->protocol)
public function stream_seek(int $offset, int $whence = SEEK_SET): bool {
if (! $this->resource) {
return false;
}
return fseek($this->resource, $offset, $whence) === 0;
}
public function stream_flush(): bool {
if (! $this->resource || ! $this->dirty || ! $this->isWriteMode($this->mode)) {
return true;
}
$position = ftell($this->resource);
rewind($this->resource);
$contents = stream_get_contents($this->resource);
fseek($this->resource, $position);
try {
if ($this->fileExists($this->path)) {
try {
self::$filesystem->delete($this->path);
} catch (UnableToDeleteFile|FilesystemException) {
// Intentamos sobrescribir igualmente.
}
}
self::$filesystem->write(
$this->path,
(string) $contents,
['visibility' => self::$defaultVisibility]
);
if ($seekable) {
rewind($this->stream);
}
$this->dirty = false;
return true;
} catch (UnableToWriteFile|Throwable $exception) {
error_log('[Flysystem Offload] Unable to flush stream: ' . $exception->getMessage());
} catch (UnableToWriteFile|FilesystemException $exception) {
trigger_error($exception->getMessage(), E_USER_WARNING);
return false;
}
}
public function stream_close(): void
{
public function stream_close(): void {
$this->stream_flush();
if (is_resource($this->stream)) {
fclose($this->stream);
if ($this->resource) {
fclose($this->resource);
}
$this->stream = null;
$this->resource = null;
$this->mode = 'r';
$this->path = '';
$this->mode = '';
$this->flyPath = '';
$this->uri = '';
$this->dirty = false;
}
public function stream_tell(): int|false
{
if (! is_resource($this->stream)) {
public function stream_truncate(int $new_size): bool {
if (! $this->resource) {
return false;
}
return ftell($this->stream);
$result = ftruncate($this->resource, $new_size);
if ($result) {
$this->dirty = true;
}
public function stream_seek(int $offset, int $whence = SEEK_SET): bool
{
if (! is_resource($this->stream)) {
return $result;
}
public function stream_stat(): array|false {
if ($this->path === '') {
return false;
}
return fseek($this->stream, $offset, $whence) === 0;
return $this->statKey($this->path);
}
public function stream_eof(): bool
{
if (! is_resource($this->stream)) {
return true;
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 feof($this->stream);
return $stat;
}
public function stream_metadata(string $path, int $option, mixed $value): bool
{
unset($path, $option, $value);
public function unlink(string $path): bool {
$this->ensureFilesystem();
return true;
}
public function stream_cast(int $castAs)
{
if (! is_resource($this->stream)) {
return false;
}
if (in_array($castAs, [STREAM_CAST_FOR_SELECT, STREAM_CAST_AS_STREAM], true)) {
$meta = stream_get_meta_data($this->stream);
$seekable = (bool) ($meta['seekable'] ?? false);
if ($seekable) {
rewind($this->stream);
}
return $this->stream;
}
return false;
}
public function stream_set_option(int $option, int $arg1, int $arg2): bool
{
if (! is_resource($this->stream)) {
return false;
}
if ($option === STREAM_OPTION_READ_TIMEOUT) {
return stream_set_timeout($this->stream, $arg1, $arg2);
}
return false;
}
public function stream_stat(): array|false
{
if (is_resource($this->stream)) {
return fstat($this->stream);
}
return $this->url_stat($this->path !== '' ? $this->path : $this->protocol . '://', 0);
}
public function url_stat(string $path, int $flags): array|false
{
$protocol = $this->extractProtocol($path);
$filesystem = $this->filesystem($protocol);
$flyPath = $this->resolveFlyPath($path);
$key = self::uriToKey($path);
try {
$isDirectory = $filesystem->directoryExists($flyPath);
$exists = $isDirectory || $filesystem->fileExists($flyPath);
if ($this->fileExists($key)) {
self::$filesystem->delete($key);
}
if (! $exists) {
if ($flags & STREAM_URL_STAT_QUIET) {
return true;
} catch (UnableToDeleteFile|FilesystemException $exception) {
trigger_error($exception->getMessage(), E_USER_WARNING);
return false;
}
}
public function rename(string $path_from, string $path_to): bool {
$this->ensureFilesystem();
$from = self::uriToKey($path_from);
$to = self::uriToKey($path_to);
try {
self::$filesystem->move($from, $to);
return true;
} catch (UnableToMoveFile|FilesystemException $exception) {
trigger_error($exception->getMessage(), E_USER_WARNING);
return false;
}
}
public function mkdir(string $path, int $mode, int $options): bool {
$this->ensureFilesystem();
$key = self::uriToKey($path);
try {
self::$filesystem->createDirectory($key, ['visibility' => self::$defaultVisibility]);
return true;
} catch (FilesystemException $exception) {
if ($options & STREAM_REPORT_ERRORS) {
trigger_error($exception->getMessage(), E_USER_WARNING);
}
return false;
}
}
public function rmdir(string $path, int $options): bool {
$this->ensureFilesystem();
$key = self::uriToKey($path);
try {
self::$filesystem->deleteDirectory($key);
return true;
} catch (UnableToDeleteDirectory|FilesystemException $exception) {
if ($options & STREAM_REPORT_ERRORS) {
trigger_error($exception->getMessage(), E_USER_WARNING);
}
return false;
}
}
public function dir_opendir(string $path, int $options): bool {
$this->ensureFilesystem();
$key = self::uriToKey($path);
$this->directoryListing = ['.', '..'];
try {
foreach (self::$filesystem->listContents($key, false) as $attributes) {
if ($attributes instanceof StorageAttributes) {
$this->directoryListing[] = basename($attributes->path());
}
}
} catch (FilesystemException $exception) {
if ($options & STREAM_REPORT_ERRORS) {
trigger_error($exception->getMessage(), E_USER_WARNING);
}
return false;
}
trigger_error("File or directory not found: {$path}", E_USER_WARNING);
$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;
}
$size = $isDirectory ? 0 : $filesystem->fileSize($flyPath);
$mtime = $filesystem->lastModified($flyPath);
$mode = $isDirectory ? 0040777 : 0100777;
public function stream_set_option(int $option, int $arg1, int $arg2): bool {
return false;
}
public function stream_lock(int $operation): bool {
return true;
}
private function requiresExistingFile(string $mode): bool {
return str_starts_with($mode, 'r');
}
private function isExclusiveCreateMode(string $mode): bool {
return str_starts_with($mode, 'x');
}
private function isAppendMode(string $mode): bool {
return str_contains($mode, 'a');
}
private function isWriteMode(string $mode): bool {
return strpbrk($mode, 'waxc+') !== false;
}
private static function uriToKey(string $uri): string {
$components = parse_url($uri);
$host = $components['host'] ?? '';
$path = $components['path'] ?? '';
$relative = PathHelper::join($host, $path);
if (self::$rootPrefix !== '') {
$relative = PathHelper::join(self::$rootPrefix, $relative);
}
return $relative;
}
private function fileExists(string $key): bool {
try {
return self::$filesystem->fileExists($key);
} catch (FilesystemException) {
return false;
}
}
private function statKey(string $key): array|false {
$this->ensureFilesystem();
try {
if (self::$filesystem->fileExists($key)) {
$size = 0;
$mtime = time();
try {
$size = self::$filesystem->fileSize($key);
} catch (FilesystemException) {
$size = 0;
}
try {
$mtime = self::$filesystem->lastModified($key);
} catch (FilesystemException) {
$mtime = time();
}
return $this->formatStat(0100644, $size, $mtime, 1);
}
if (self::$filesystem->directoryExists($key)) {
$mtime = time();
try {
$mtime = self::$filesystem->lastModified($key);
} catch (FilesystemException) {
$mtime = time();
}
return $this->formatStat(0040755, 0, $mtime, 2);
}
} catch (FilesystemException) {
return false;
}
return false;
}
/**
* @return array{
* 0:int,1:int,2:int,3:int,4:int,5:int,6:int,7:int,8:int,9:int,10:int,11:int,12:int,
* dev:int,ino:int,mode:int,nlink:int,uid:int,gid:int,rdev:int,size:int,atime:int,mtime:int,ctime:int,blksize:int,blocks:int
* }
*/
private function formatStat(int $mode, int $size, int $timestamp, int $nlink = 1): array {
return [
0 => 0,
'dev' => 0,
@ -297,8 +462,8 @@ final class FlysystemStreamWrapper
'ino' => 0,
2 => $mode,
'mode' => $mode,
3 => 0,
'nlink' => 0,
3 => $nlink,
'nlink' => $nlink,
4 => 0,
'uid' => 0,
5 => 0,
@ -307,199 +472,22 @@ final class FlysystemStreamWrapper
'rdev' => 0,
7 => $size,
'size' => $size,
8 => $mtime,
'atime' => $mtime,
9 => $mtime,
'mtime' => $mtime,
10 => $mtime,
'ctime' => $mtime,
8 => $timestamp,
'atime' => $timestamp,
9 => $timestamp,
'mtime' => $timestamp,
10 => $timestamp,
'ctime' => $timestamp,
11 => -1,
'blksize' => -1,
12 => -1,
'blocks' => -1,
];
} catch (FilesystemException $exception) {
if (! ($flags & STREAM_URL_STAT_QUIET)) {
trigger_error($exception->getMessage(), E_USER_WARNING);
}
return false;
private function ensureFilesystem(): void {
if (! self::$filesystem) {
throw new \RuntimeException('Flysystem filesystem has not been registered.');
}
}
public function unlink(string $path): bool
{
$protocol = $this->extractProtocol($path);
$filesystem = $this->filesystem($protocol);
$flyPath = $this->resolveFlyPath($path);
try {
$filesystem->delete($flyPath);
return true;
} catch (UnableToDeleteFile $exception) {
error_log('[Flysystem Offload] Unable to delete file: ' . $exception->getMessage());
return false;
}
}
public function mkdir(string $path, int $mode, int $options): bool
{
unset($mode, $options);
$protocol = $this->extractProtocol($path);
$filesystem = $this->filesystem($protocol);
$flyPath = $this->resolveFlyPath($path);
try {
$filesystem->createDirectory($flyPath);
return true;
} catch (FilesystemException $exception) {
error_log('[Flysystem Offload] Unable to create directory: ' . $exception->getMessage());
return false;
}
}
public function rmdir(string $path, int $options): bool
{
unset($options);
$protocol = $this->extractProtocol($path);
$filesystem = $this->filesystem($protocol);
$flyPath = $this->resolveFlyPath($path);
try {
$filesystem->deleteDirectory($flyPath);
return true;
} catch (FilesystemException $exception) {
error_log('[Flysystem Offload] Unable to delete directory: ' . $exception->getMessage());
return false;
}
}
public function rename(string $oldPath, string $newPath): bool
{
$oldProtocol = $this->extractProtocol($oldPath);
$newProtocol = $this->extractProtocol($newPath);
if ($oldProtocol !== $newProtocol) {
return false;
}
$filesystem = $this->filesystem($oldProtocol);
$from = $this->resolveFlyPath($oldPath);
$to = $this->resolveFlyPath($newPath);
try {
$filesystem->move($from, $to);
return true;
} catch (FilesystemException $exception) {
error_log('[Flysystem Offload] Unable to move file: ' . $exception->getMessage());
return false;
}
}
public function dir_opendir(string $path, int $options): bool
{
unset($options);
$this->protocol = $this->extractProtocol($path);
$this->flyPath = $this->resolveFlyPath($path);
$filesystem = $this->filesystem($this->protocol);
$entries = ['.', '..'];
foreach ($filesystem->listContents($this->flyPath, false) as $item) {
if ($item instanceof StorageAttributes) {
$entries[] = basename($item->path());
} elseif (is_array($item) && isset($item['path'])) {
$entries[] = basename((string) $item['path']);
}
}
$this->dirEntries = $entries;
$this->dirPosition = 0;
return true;
}
public function dir_readdir(): string|false
{
if ($this->dirPosition >= count($this->dirEntries)) {
return false;
}
return $this->dirEntries[$this->dirPosition++];
}
public function dir_rewinddir(): bool
{
$this->dirPosition = 0;
return true;
}
public function dir_closedir(): bool
{
$this->dirEntries = [];
$this->dirPosition = 0;
return true;
}
private function filesystem(string $protocol): FilesystemOperator
{
if (! isset(self::$filesystems[$protocol])) {
throw new RuntimeException('No filesystem registered for protocol: ' . $protocol);
}
return self::$filesystems[$protocol];
}
private function resolveFlyPath(string $path): string
{
$raw = preg_replace('#^[^:]+://#', '', $path) ?? '';
$relative = ltrim($raw, '/');
$prefix = self::$prefixes[$this->protocol] ?? '';
if ($prefix !== '') {
$prefixWithSlash = $prefix . '/';
if (str_starts_with($relative, $prefixWithSlash)) {
$relative = substr($relative, strlen($prefixWithSlash));
} elseif ($relative === $prefix) {
$relative = '';
}
}
return $relative;
}
private function extractProtocol(string $path): string
{
$pos = strpos($path, '://');
if ($pos === false) {
return $this->protocol !== '' ? $this->protocol : 'fly';
}
return substr($path, 0, $pos);
}
/**
* @return array<string, mixed>
*/
private function writeOptionsForProtocol(string $protocol): array
{
return self::$writeOptions[$protocol] ?? ['visibility' => Visibility::PUBLIC];
}
}