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