Compare commits

...

20 Commits
main ... 3.0.0

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

View File

@ -1,24 +1,58 @@
{
"name": "tu-nombre/flysystem-offload",
"description": "Universal storage offloading for WordPress vía Flysystem",
"name": "brasdrive/flysystem-offload",
"description": "Universal storage offloading for WordPress via Flysystem",
"type": "wordpress-plugin",
"license": "GPL-2.0-or-later",
"authors": [
{
"name": "Brasdrive",
"email": "jdavidcamejo@gmail.com"
}
],
"require": {
"php": ">=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"
},
"require-dev": {
"phpunit/phpunit": "^10.0",
"squizlabs/php_codesniffer": "^3.7"
},
"autoload": {
"psr-4": {
"FlysystemOffload\\": "src/"
}
}
},
"autoload-dev": {
"psr-4": {
"FlysystemOffload\\Tests\\": "tests/"
}
},
"config": {
"optimize-autoloader": true,
"sort-packages": true,
"allow-plugins": {
"composer/installers": true
},
"platform": {
"php": "8.1"
}
},
"scripts": {
"test": "phpunit",
"cs": "phpcs --standard=PSR12 src/",
"cbf": "phpcbf --standard=PSR12 src/",
"dump": "composer dump-autoload --optimize"
},
"minimum-stability": "stable",
"prefer-stable": true
}

View File

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
return [
'provider' => getenv('FLYSYSTEM_OFFLOAD_PROVIDER') ?: 'webdav',
'visibility' => getenv('FLYSYSTEM_OFFLOAD_DEFAULT_VISIBILITY') ?: 'public',
'stream' => [
'protocol' => getenv('FLYSYSTEM_OFFLOAD_STREAM_PROTOCOL') ?: 'webdav',
'root_prefix' => getenv('FLYSYSTEM_OFFLOAD_STREAM_ROOT_PREFIX') ?: '',
'host' => getenv('FLYSYSTEM_OFFLOAD_STREAM_HOST') ?: '',
],
'uploads' => [
'base_url' => getenv('FLYSYSTEM_OFFLOAD_BASE_URL')
?: (getenv('FLYSYSTEM_OFFLOAD_WEBDAV_BASE_URL')
? rtrim(getenv('FLYSYSTEM_OFFLOAD_WEBDAV_BASE_URL'), '/') . '/' . trim(getenv('FLYSYSTEM_OFFLOAD_WEBDAV_PREFIX') ?: 'wordpress/uploads', '/')
: content_url('uploads')),
'delete_remote' => filter_var(
getenv('FLYSYSTEM_OFFLOAD_DELETE_REMOTE') ?: 'true',
FILTER_VALIDATE_BOOLEAN
),
'prefer_local_for_missing' => filter_var(
getenv('FLYSYSTEM_OFFLOAD_PREFER_LOCAL_FOR_MISSING') ?: 'false',
FILTER_VALIDATE_BOOLEAN
),
],
'admin' => [
'enabled' => filter_var(
getenv('FLYSYSTEM_OFFLOAD_ADMIN_ENABLED') ?: 'true',
FILTER_VALIDATE_BOOLEAN
),
],
'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' => filter_var(
getenv('AWS_USE_PATH_STYLE_ENDPOINT') ?: 'false',
FILTER_VALIDATE_BOOLEAN
),
'version' => 'latest',
'options' => [],
],
'webdav' => [
'enabled' => filter_var(
getenv('FLYSYSTEM_OFFLOAD_WEBDAV_ENABLED') ?: 'true',
FILTER_VALIDATE_BOOLEAN
),
'base_url' => getenv('FLYSYSTEM_OFFLOAD_WEBDAV_BASE_URL') ?: 'https://webdav.example.com',
'endpoint' => getenv('FLYSYSTEM_OFFLOAD_WEBDAV_ENDPOINT') ?: getenv('FLYSYSTEM_OFFLOAD_WEBDAV_BASE_URL') ?: '',
'prefix' => getenv('FLYSYSTEM_OFFLOAD_WEBDAV_PREFIX') ?: 'wordpress/uploads',
'credentials' => [
'username' => getenv('FLYSYSTEM_OFFLOAD_WEBDAV_USERNAME') ?: '',
'password' => getenv('FLYSYSTEM_OFFLOAD_WEBDAV_PASSWORD') ?: '',
'auth_type' => getenv('FLYSYSTEM_OFFLOAD_WEBDAV_AUTH_TYPE') ?: 'basic',
],
'stream' => [
'register' => filter_var(
getenv('FLYSYSTEM_OFFLOAD_WEBDAV_STREAM_REGISTER') ?: 'true',
FILTER_VALIDATE_BOOLEAN
),
'protocol' => getenv('FLYSYSTEM_OFFLOAD_WEBDAV_STREAM_PROTOCOL') ?: 'webdav',
],
'default_headers' => [
'Cache-Control' => getenv('FLYSYSTEM_OFFLOAD_WEBDAV_CACHE_CONTROL') ?: 'public, max-age=31536000',
'Expires' => getenv('FLYSYSTEM_OFFLOAD_WEBDAV_EXPIRES')
?: gmdate('D, d M Y H:i:s \G\M\T', strtotime('+1 year')),
],
'default_visibility' => getenv('FLYSYSTEM_OFFLOAD_WEBDAV_VISIBILITY') ?: 'public',
],
];

View File

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

View File

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

View File

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

View File

@ -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)
$clientConfig = [
'version' => $settings['version'] ?? 'latest',
'region' => $settings['region'] ?? 'us-east-1',
];
$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($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);
$visibility = new PortableVisibilityConverter(
$settings['acl_public'] ?? 'public-read',
$settings['acl_private'] ?? 'private',
$config['visibility'] ?? Visibility::PUBLIC
);
$defaultOptions = $settings['default_options'] ?? [];
$uploadsConfig = $config['uploads'] ?? [];
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 true;
}
$defaultOptions = apply_filters(
'flysystem_offload_s3_default_write_options',
$defaultOptions,
$config
);
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'] = rtrim($settings['endpoint'], '/');
$clientConfig['use_path_style_endpoint'] = (bool) ($settings['use_path_style_endpoint'] ?? true);
}
if (! empty($settings['http_client'])) {
$clientConfig['http_client'] = $settings['http_client'];
}
$client = new S3Client($clientConfig);
$adapter = new AwsS3V3Adapter(
$client,
$settings['bucket'],
'',
options: [],
mimeTypeDetector: $this->mimeTypeDetector
);
$prefix = trim((string) ($settings['prefix'] ?? ''), '/');
if ($prefix !== '') {
if (class_exists(PathPrefixedAdapter::class)) {
$adapter = new PathPrefixedAdapter($adapter, $prefix);
} else {
$adapter = new PrefixedAdapter($adapter, $prefix);
}
}
return $adapter;
} 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((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

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

View File

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

View File

@ -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

@ -1,310 +1,247 @@
<?php
declare(strict_types=1);
namespace FlysystemOffload\Media;
use FlysystemOffload\Helpers\PathHelper;
use League\Flysystem\FilesystemOperator;
use WP_Error;
use FlysystemOffload\Plugin;
/**
* Hooks para integración con el sistema de medios de WordPress
* Intercepta uploads, generación de URLs, eliminación de archivos, etc.
*/
class MediaHooks
{
private ?FilesystemOperator $filesystem = null;
private string $basePrefix;
private string $provider;
private string $protocol;
private string $host;
private string $rootPrefix;
private string $providerPrefix;
private string $baseUrl;
/** @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 = '')
public function __construct(array $config)
{
$this->basePrefix = PathHelper::trimSlashes($basePrefix);
$this->provider = $config['provider'] ?? 'webdav';
$this->protocol = $config['stream']['protocol'] ?? $this->provider;
$this->host = $config['stream']['host'] ?? '';
$this->rootPrefix = $config['stream']['root_prefix'] ?? '';
$this->providerPrefix = $config['prefix'] ?? '';
$this->baseUrl = $config['uploads']['base_url'] ?? '';
error_log(sprintf(
'[MediaHooks] Initialized - provider: %s, protocol: %s, host: %s, root_prefix: %s, provider_prefix: %s, base_url: %s',
$this->provider,
$this->protocol,
$this->host,
$this->rootPrefix,
$this->providerPrefix,
$this->baseUrl
));
}
public function register(): void
/**
* Registra los hooks de WordPress
*/
public function registerHooks(): 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;
// Filtros para upload dir
add_filter('upload_dir', [$this, 'filterUploadDir']);
// Filtros para URLs de medios
add_filter('wp_get_attachment_url', [$this, 'filterAttachmentUrl'], 10, 2);
add_filter('wp_calculate_image_srcset', [$this, 'filterImageSrcset'], 10, 5);
// Acciones para manejo de archivos
add_action('wp_generate_attachment_metadata', [$this, 'handleAttachmentMetadata'], 10, 2);
add_action('delete_attachment', [$this, 'handleDeleteAttachment']);
// Filtro para obtener el path correcto
add_filter('get_attached_file', [$this, 'filterAttachedFile'], 10, 2);
}
public function unregister(): void
/**
* Filtra el directorio de uploads para usar nuestro sistema de archivos
*/
public function filterUploadDir(array $uploads): array
{
if (! $this->registered) {
return;
// Construir el path con el protocolo correcto
$path = $this->protocol . '://';
if ($this->host) {
$path .= $this->host . '/';
}
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']
);
}
if ($this->rootPrefix) {
$path .= ltrim($this->rootPrefix, '/') . '/';
}
$this->attachedCallbacks = [];
$this->registered = false;
}
public function setFilesystem(?FilesystemOperator $filesystem): void
{
$this->filesystem = $filesystem;
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);
}
}
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;
// Actualizar uploads array
$uploads['path'] = $path;
$uploads['url'] = rtrim($this->baseUrl, '/') . '/';
$uploads['subdir'] = '';
$uploads['basedir'] = $path;
error_log('[MediaHooks] Upload dir filtered - path: ' . $path . ', url: ' . $uploads['url'] . ', subdir: ' . $uploads['subdir']);
return $uploads;
}
/**
* Filtra la URL de un attachment para usar la URL remota
*/
public function filterAttachmentUrl(string $url, int $attachmentId): string
{
$file = get_post_meta($attachmentId, '_wp_attached_file', true);
if (empty($file)) {
// Obtener metadata del attachment
$metadata = wp_get_attachment_metadata($attachmentId);
if (!$metadata) {
return $url;
}
$relative = PathHelper::trimLeadingSlash($file);
return trailingslashit($this->getBaseUrl()) . $relative;
// Obtener el path del archivo
$file = get_post_meta($attachmentId, '_wp_attached_file', true);
if (!$file) {
return $url;
}
// Construir URL remota
$remoteUrl = rtrim($this->baseUrl, '/') . '/' . ltrim($file, '/');
error_log('[MediaHooks] Attachment URL filtered - local: ' . $url . ', remote: ' . $remoteUrl);
return $remoteUrl;
}
public function filterGetAttachedFile(string $file, int $attachmentId): string
/**
* Filtra el srcset de imágenes para usar URLs remotas
*/
public function filterImageSrcset(array $sources, array $sizeArray, string $imageSrc, array $imageMeta, int $attachmentId): array
{
$meta = wp_get_attachment_metadata($attachmentId);
$relative = $meta['file'] ?? get_post_meta($attachmentId, '_wp_attached_file', true);
if (empty($relative)) {
return $file;
if (empty($sources)) {
return $sources;
}
return 'fly://' . PathHelper::trimLeadingSlash($relative);
// Obtener el path base del archivo
$file = get_post_meta($attachmentId, '_wp_attached_file', true);
if (!$file) {
return $sources;
}
// Calcular el directorio base
$basePath = dirname($file);
if ($basePath === '.') {
$basePath = '';
}
// Actualizar cada source con URL remota
foreach ($sources as &$source) {
if (isset($source['url'])) {
$filename = basename($source['url']);
if ($basePath) {
$remotePath = $basePath . '/' . $filename;
} else {
$remotePath = $filename;
}
$source['url'] = rtrim($this->baseUrl, '/') . '/' . ltrim($remotePath, '/');
}
}
return $sources;
}
public function filterUpdateAttachedFile(string $file, int $attachmentId): string
/**
* Maneja la generación de metadata de attachments
*/
public function handleAttachmentMetadata(array $metadata, int $attachmentId): array
{
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));
error_log('[MediaHooks] Handling attachment metadata for ID: ' . $attachmentId);
// Verificar si se generaron tamaños
if (isset($metadata['sizes']) && is_array($metadata['sizes'])) {
error_log('[MediaHooks] Generated sizes: ' . print_r(array_keys($metadata['sizes']), true));
// Para cada tamaño generado, asegurarse de que se suba al sistema remoto
foreach ($metadata['sizes'] as $sizeName => $sizeData) {
if (isset($sizeData['file'])) {
$this->ensureSizeUploaded($attachmentId, $sizeData['file']);
}
}
}
return $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
/**
* Asegura que un tamaño específico se haya subido
*/
private function ensureSizeUploaded(int $attachmentId, string $filename): void
{
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);
// Obtener el directorio de uploads local
$uploadDir = wp_upload_dir();
$localPath = $uploadDir['basedir'] . '/' . $filename;
// Verificar si el archivo local existe
if (!file_exists($localPath)) {
error_log('[MediaHooks] Local size file not found: ' . $localPath);
return;
}
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);
// Construir el path remoto
$remotePath = $this->protocol . ':///' . $filename;
// Copiar el archivo al sistema remoto
if (copy($localPath, $remotePath)) {
error_log('[MediaHooks] Size uploaded successfully: ' . $filename);
} else {
error_log('[MediaHooks] Failed to upload size: ' . $filename);
}
return array_values(array_unique($editors));
}
/**
* Maneja la eliminación de attachments
*/
public function handleDeleteAttachment(int $attachmentId): void
{
if (! $this->filesystem) {
return;
error_log('[MediaHooks] Handling delete attachment: ' . $attachmentId);
// Obtener el archivo principal
$file = get_post_meta($attachmentId, '_wp_attached_file', true);
if ($file) {
$this->deleteRemoteFile($file);
}
$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'], '/');
// Obtener metadata para eliminar tamaños
$metadata = wp_get_attachment_metadata($attachmentId);
if ($metadata && isset($metadata['sizes'])) {
foreach ($metadata['sizes'] as $sizeData) {
if (isset($sizeData['file'])) {
$this->deleteRemoteFile($sizeData['file']);
}
}
}
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());
}
}
}
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)
/**
* Elimina un archivo remoto
*/
private function deleteRemoteFile(string $filename): void
{
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')
);
}
try {
$stream = $this->filesystem->readStream($remotePath);
if (! is_resource($stream)) {
throw new \RuntimeException('No se pudo abrir el stream remoto.');
$remotePath = $this->protocol . ':///' . ltrim($filename, '/');
if (file_exists($remotePath)) {
if (unlink($remotePath)) {
error_log('[MediaHooks] Remote file deleted: ' . $filename);
} else {
error_log('[MediaHooks] Failed to delete remote file: ' . $filename);
}
$target = fopen($temp, 'wb');
if (! $target) {
throw new \RuntimeException('No se pudo abrir el archivo temporal.');
}
stream_copy_to_stream($stream, $target);
fclose($stream);
fclose($target);
} catch (\Throwable $e) {
if (isset($stream) && is_resource($stream)) {
fclose($stream);
}
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()
)
);
} else {
error_log('[MediaHooks] Remote file not found for deletion: ' . $filename);
}
return $temp;
}
private function getBaseUrl(): string
/**
* Filtra el path del archivo adjunto
*/
public function filterAttachedFile(string $file, int $attachmentId): string
{
$uploadDir = wp_get_upload_dir();
return $uploadDir['baseurl'] ?? content_url('/uploads');
// Devolver el path con el protocolo correcto
$filename = basename($file);
return $this->protocol . ':///' . $filename;
}
}

View File

@ -4,139 +4,130 @@ 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\Visibility;
use RuntimeException;
use WP_Error;
use League\Flysystem\Filesystem;
use Throwable;
final class Plugin
/**
* Clase principal del plugin Flysystem Offload
*/
class Plugin
{
private FilesystemFactory $filesystemFactory;
private MediaHooks $mediaHooks;
private SettingsPage $settingsPage;
private array $config = [];
private bool $isInitialized = false;
private static ?Filesystem $filesystem = null;
private static array $config = [];
private static bool $initialized = false;
private static ?MediaHooks $mediaHooks = null;
private static ?SettingsPage $settingsPage = null;
public function __construct(
FilesystemFactory $filesystemFactory,
MediaHooks $mediaHooks,
SettingsPage $settingsPage
) {
$this->filesystemFactory = $filesystemFactory;
$this->mediaHooks = $mediaHooks;
$this->settingsPage = $settingsPage;
}
public function register(): void
/**
* Bootstrap del plugin
*
* @throws Throwable
*/
public static function bootstrap(): 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) {
if (self::$initialized) {
return;
}
$this->config = $this->settingsPage->getSettings();
if (! is_array($this->config)) {
$this->config = [];
}
$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'] ?? '';
// Cargar configuración
self::$config = ConfigLoader::load();
FlysystemStreamWrapper::register(
$filesystem,
$protocol,
$prefix,
[
'visibility' => $this->config['visibility'] ?? Visibility::PUBLIC,
]
// Validar que haya un proveedor configurado
if (empty(self::$config['provider'])) {
error_log('[Flysystem Offload] No provider configured. Please configure the plugin in Settings > Flysystem Offload');
self::registerAdminNotice('No storage provider configured. Please configure the plugin.');
return;
}
// Validar configuración
ConfigLoader::validate(self::$config);
// Crear filesystem
self::$filesystem = FilesystemFactory::create(self::$config);
// Registrar stream wrapper con protocolo desde config
$protocol = (string)(self::$config['stream']['protocol'] ?? 'flysystem');
FlysystemStreamWrapper::register(self::$filesystem, $protocol);
error_log('[Flysystem Offload] Stream wrappers: ' . implode(', ', stream_get_wrappers()));
// Registrar hooks de medios - pasar primero $config, luego $filesystem
self::$mediaHooks = new MediaHooks(self::$config, self::$filesystem);
self::$mediaHooks->registerHooks(); // <-- CORRECCIÓN: llamar al método que existe
// Registrar página de ajustes
if (is_admin()) {
self::$settingsPage = new SettingsPage(self::$filesystem, self::$config);
self::$settingsPage->register();
}
self::$initialized = true;
error_log('[Flysystem Offload] Plugin initialized successfully with provider: ' . self::$config['provider']);
} catch (Throwable $e) {
error_log('[Flysystem Offload] Initialization error: ' . $e->getMessage());
error_log('[Flysystem Offload] Stack trace: ' . $e->getTraceAsString());
self::registerAdminNotice(
'Failed to initialize: ' . $e->getMessage()
);
} catch (\Throwable $exception) {
error_log('[Flysystem Offload] No se pudo registrar el stream wrapper: ' . $exception->getMessage());
throw $e;
}
}
public function pluginLinks(array $links): array
public static function getFilesystem(): ?Filesystem
{
$settingsUrl = admin_url('options-general.php?page=flysystem-offload');
$links[] = '<a href="' . esc_url($settingsUrl) . '">' . esc_html__('Ajustes', 'flysystem-offload') . '</a>';
return $links;
return self::$filesystem;
}
public function getConfig(): array
public static function getConfig(): array
{
return $this->config;
return self::$config;
}
public function getFilesystemFactory(): FilesystemFactory
public static function isInitialized(): bool
{
return $this->filesystemFactory;
return self::$initialized;
}
public function getRemoteUrlBase(): string
private static function registerAdminNotice(string $message, string $type = 'error'): void
{
$driver = $this->config['driver'] ?? null;
add_action('admin_notices', static function () use ($message, $type): void {
if (!current_user_can('manage_options')) {
return;
}
if (! $driver) {
return '';
printf(
'<div class="notice notice-%s"><p><strong>Flysystem Offload:</strong> %s</p></div>',
esc_attr($type),
esc_html($message)
);
});
}
public static function rebuild(): void
{
self::$initialized = false;
self::$filesystem = null;
self::$mediaHooks = null;
self::$settingsPage = null;
$protocol = (string)(self::$config['stream']['protocol'] ?? 'flysystem');
self::$config = [];
// Desregistrar stream wrapper si existe
if (in_array($protocol, stream_get_wrappers(), true)) {
@stream_wrapper_unregister($protocol);
}
$result = $this->filesystemFactory->resolvePublicBaseUrl($driver, $this->config);
if ($result instanceof WP_Error) {
throw new RuntimeException($result->get_error_message());
}
return $result;
self::bootstrap();
}
}

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(
__('Flysystem Offload', 'flysystem-offload'),
__('Flysystem Offload', 'flysystem-offload'),
'manage_options',
'flysystem-offload',
[$this, 'renderPage']
);
});
add_action('admin_init', [$this, 'registerSettings']);
public function register(): void {
\add_action('admin_menu', [$this, 'registerMenu']);
}
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 registerMenu(): void {
\add_options_page(
__('Flysystem Offload', 'flysystem-offload'),
__('Flysystem Offload', 'flysystem-offload'),
'manage_options',
'flysystem-offload',
[$this, 'renderPage']
);
}
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('Provider', 'flysystem-offload'); ?></th>
<td><?php echo esc_html($this->config['provider'] ?? ''); ?></td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('Protocolo del Stream Wrapper', 'flysystem-offload'); ?></th>
<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;
}
}
}

File diff suppressed because it is too large Load Diff