diff --git a/composer.json b/composer.json index 7029339..2f76ce4 100644 --- a/composer.json +++ b/composer.json @@ -1,19 +1,19 @@ { - "name": "tu-nombre/flysystem-offload", + "name": "brasdrive/flysystem-offload", "description": "Universal storage offloading for WordPress vía Flysystem", "type": "wordpress-plugin", "require": { - "php": ">=7.4", + "php": ">=8.1", "league/flysystem": "^3.24", "league/flysystem-aws-s3-v3": "^3.24", "league/flysystem-sftp-v3": "^3.24", - "league/flysystem-azure-blob-storage": "^3.24", "league/flysystem-google-cloud-storage": "^3.24", "league/flysystem-webdav": "^3.24", "league/flysystem-path-prefixing": "^3.24", "aws/aws-sdk-php": "^3.330", "google/cloud-storage": "^1.33", "microsoft/azure-storage-blob": "^1.5", + "azure-oss/storage-blob-flysystem": "^1.3.0", "sabre/dav": "^4.5" }, "autoload": { diff --git a/config/flysystem-offload.example.php b/config/flysystem-offload.example.php index e69de29..ed2c132 100644 --- a/config/flysystem-offload.example.php +++ b/config/flysystem-offload.example.php @@ -0,0 +1,36 @@ + 's3', + 'visibility' => 'public', + + 'stream' => [ + 'protocol' => 'flysystem', + 'root_prefix' => '', + 'host' => 'uploads', + ], + + 'uploads' => [ + 'base_url' => getenv('FLYSYSTEM_OFFLOAD_BASE_URL') ?: 'https://your-bucket.s3.amazonaws.com', + 'delete_remote' => true, + 'prefer_local_for_missing' => false, + ], + + 'admin' => [ + 'enabled' => false, + ], + + 's3' => [ + 'key' => getenv('AWS_ACCESS_KEY_ID') ?: null, + 'secret' => getenv('AWS_SECRET_ACCESS_KEY') ?: null, + 'session_token' => getenv('AWS_SESSION_TOKEN') ?: null, + 'region' => getenv('AWS_DEFAULT_REGION') ?: 'us-east-1', + 'bucket' => getenv('FLYSYSTEM_OFFLOAD_BUCKET') ?: 'your-bucket-name', + 'prefix' => getenv('FLYSYSTEM_OFFLOAD_PREFIX') ?: null, + 'endpoint' => getenv('FLYSYSTEM_OFFLOAD_ENDPOINT') ?: null, + 'use_path_style_endpoint' => (bool) (getenv('AWS_USE_PATH_STYLE_ENDPOINT') ?: false), + 'version' => 'latest', + 'options' => [], + ], +]; diff --git a/config/flysystem-offload.php b/config/flysystem-offload.php new file mode 100644 index 0000000..ed2c132 --- /dev/null +++ b/config/flysystem-offload.php @@ -0,0 +1,36 @@ + 's3', + 'visibility' => 'public', + + 'stream' => [ + 'protocol' => 'flysystem', + 'root_prefix' => '', + 'host' => 'uploads', + ], + + 'uploads' => [ + 'base_url' => getenv('FLYSYSTEM_OFFLOAD_BASE_URL') ?: 'https://your-bucket.s3.amazonaws.com', + 'delete_remote' => true, + 'prefer_local_for_missing' => false, + ], + + 'admin' => [ + 'enabled' => false, + ], + + 's3' => [ + 'key' => getenv('AWS_ACCESS_KEY_ID') ?: null, + 'secret' => getenv('AWS_SECRET_ACCESS_KEY') ?: null, + 'session_token' => getenv('AWS_SESSION_TOKEN') ?: null, + 'region' => getenv('AWS_DEFAULT_REGION') ?: 'us-east-1', + 'bucket' => getenv('FLYSYSTEM_OFFLOAD_BUCKET') ?: 'your-bucket-name', + 'prefix' => getenv('FLYSYSTEM_OFFLOAD_PREFIX') ?: null, + 'endpoint' => getenv('FLYSYSTEM_OFFLOAD_ENDPOINT') ?: null, + 'use_path_style_endpoint' => (bool) (getenv('AWS_USE_PATH_STYLE_ENDPOINT') ?: false), + 'version' => 'latest', + 'options' => [], + ], +]; diff --git a/flysystem-offload.php b/flysystem-offload.php index 6da53c8..efe7ab8 100644 --- a/flysystem-offload.php +++ b/flysystem-offload.php @@ -1,14 +1,58 @@ getMessage()); + + add_action('admin_notices', static function () use ($exception): void { + if (! current_user_can('manage_options')) { + return; + } + + printf( + '

Flysystem Offload: %s

', + esc_html($exception->getMessage()) + ); + }); + } +}, 0); diff --git a/src/Admin/HealthCheck.php b/src/Admin/HealthCheck.php index a0503e6..0b2908d 100644 --- a/src/Admin/HealthCheck.php +++ b/src/Admin/HealthCheck.php @@ -1,73 +1,65 @@ 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 $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( + '

%s

%s

', + esc_html__('Se produjo un error al comunicarse con el backend configurado.', 'flysystem-offload'), + esc_html($exception->getMessage()) + ); } - return true; + return $result; } } diff --git a/src/Config/ConfigLoader.php b/src/Config/ConfigLoader.php index 455da55..291aa9f 100644 --- a/src/Config/ConfigLoader.php +++ b/src/Config/ConfigLoader.php @@ -1,132 +1,58 @@ pluginDir = dirname($pluginFile); +final class ConfigLoader { + private string $configDirectory; + + public function __construct(string $configDirectory) { + $this->configDirectory = rtrim($configDirectory, '/\\'); } - /** - * Devuelve la configuración efectiva (defaults + overrides). - * - * @throws \RuntimeException cuando un archivo config no retorna array. - */ - public function load(): array - { - $defaults = $this->defaults(); - $files = $this->discoverConfigFiles(); - - $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' => [], - ], + public function load(): array { + $candidateFiles = [ + $this->configDirectory . '/flysystem-offload.local.php', + $this->configDirectory . '/flysystem-offload.php', + $this->configDirectory . '/flysystem-offload.example.php', ]; - } - /** - * Descubre archivos de configuración en orden de prioridad. - * - * @return string[] - */ - private function discoverConfigFiles(): array - { - $candidates = []; + $configFile = $this->resolveFirstExisting($candidateFiles); - if (defined('FLYSYSTEM_OFFLOAD_CONFIG')) { - $candidates[] = FLYSYSTEM_OFFLOAD_CONFIG; + if ($configFile === null) { + throw new RuntimeException( + sprintf( + 'No se pudo localizar un archivo de configuración para Flysystem Offload. Esperado en: %s', + implode(', ', $candidateFiles) + ) + ); } - // Opción por defecto en wp-content/. - $candidates[] = WP_CONTENT_DIR . '/flysystem-offload.php'; + $config = include $configFile; - // Alias alternativo frecuente. - $candidates[] = WP_CONTENT_DIR . '/flysystem-offload-config.php'; + if ($config instanceof \Closure) { + $config = $config(); + } - // Fallback incluido dentro del plugin (para entornos sin personalización inicial). - $candidates[] = $this->pluginDir . '/config/flysystem-offload.php'; + if (! is_array($config)) { + throw new UnexpectedValueException( + sprintf('El archivo de configuración debe retornar un array. Archivo: %s', $configFile) + ); + } - $unique = array_unique(array_filter( - $candidates, - static fn (string $path) => is_readable($path) - )); + return $config; + } - return array_values($unique); + private function resolveFirstExisting(array $files): ?string { + foreach ($files as $file) { + if ($file && is_readable($file)) { + return $file; + } + } + + return null; } } diff --git a/src/Filesystem/AdapterInterface.php b/src/Filesystem/AdapterInterface.php index c90822d..2501cf7 100644 --- a/src/Filesystem/AdapterInterface.php +++ b/src/Filesystem/AdapterInterface.php @@ -1,26 +1,10 @@ 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 + ); } } diff --git a/src/Filesystem/FilesystemFactory.php b/src/Filesystem/FilesystemFactory.php index 8570d7c..2a628ab 100644 --- a/src/Filesystem/FilesystemFactory.php +++ b/src/Filesystem/FilesystemFactory.php @@ -3,96 +3,25 @@ declare(strict_types=1); namespace FlysystemOffload\Filesystem; -use FlysystemOffload\Filesystem\Adapters\AzureBlobAdapter; -use FlysystemOffload\Filesystem\Adapters\DropboxAdapter; -use FlysystemOffload\Filesystem\Adapters\GoogleCloudAdapter; -use FlysystemOffload\Filesystem\Adapters\GoogleDriveAdapter; -use FlysystemOffload\Filesystem\Adapters\OneDriveAdapter; use FlysystemOffload\Filesystem\Adapters\S3Adapter; -use FlysystemOffload\Filesystem\Adapters\SftpAdapter; -use FlysystemOffload\Filesystem\Adapters\WebdavAdapter; use League\Flysystem\Filesystem; use League\Flysystem\FilesystemOperator; -use League\Flysystem\Local\LocalFilesystemAdapter; -use WP_Error; +use League\Flysystem\Visibility; -class FilesystemFactory -{ - private array $settings; +final class FilesystemFactory { + public function make(array $config): FilesystemOperator { + $driver = $config['driver'] ?? 's3'; - public function __construct(array $settings) - { - $this->settings = $settings; - } - - public function make(): FilesystemOperator|WP_Error - { - $adapterKey = $this->settings['adapter'] ?? 'local'; - $config = $this->settings['adapters'][$adapterKey] ?? []; - - $adapter = $this->resolveAdapter($adapterKey); - - if ($adapter instanceof WP_Error) { - return $adapter; - } - - $validation = $adapter->validate($config); - if ($validation instanceof WP_Error) { - return $validation; - } - - $flyAdapter = $adapter->create($config); - if ($flyAdapter instanceof WP_Error) { - return $flyAdapter; - } - - return new Filesystem($flyAdapter); - } - - public function resolvePublicBaseUrl(string $adapterKey, array $settings): string - { - $adapter = $this->resolveAdapter($adapterKey); - - if ($adapter instanceof WP_Error) { - return content_url('/uploads'); - } - - $baseUrl = $adapter->publicBaseUrl($settings); - - return untrailingslashit($baseUrl); - } - - private function resolveAdapter(string $adapterKey): AdapterInterface|WP_Error - { - return match ($adapterKey) { - 's3' => new S3Adapter(), - 'sftp' => new SftpAdapter(), - 'gcs' => new GoogleCloudAdapter(), - 'azure' => new AzureBlobAdapter(), - 'webdav' => new WebdavAdapter(), - 'googledrive' => new GoogleDriveAdapter(), // stub (dev) - 'onedrive' => new OneDriveAdapter(), // stub (dev) - 'dropbox' => new DropboxAdapter(), // stub (dev) - default => new class implements AdapterInterface { - public function create(array $settings) - { - $root = WP_CONTENT_DIR . '/flysystem-uploads'; - - return new LocalFilesystemAdapter($root); - } - - public function publicBaseUrl(array $settings): string - { - return content_url('/flysystem-uploads'); - } - - public function validate(array $settings) - { - wp_mkdir_p(WP_CONTENT_DIR . '/flysystem-uploads'); - - return true; - } - }, + $adapter = match ($driver) { + 's3' => (new S3Adapter())->createAdapter($config), + default => throw new \InvalidArgumentException(sprintf('Driver de Flysystem no soportado: "%s".', $driver)), }; + + $filesystemConfig = [ + 'visibility' => $config['visibility'] ?? Visibility::PUBLIC, + 'directory_visibility' => $config['visibility'] ?? Visibility::PUBLIC, + ]; + + return new Filesystem($adapter, $filesystemConfig); } } diff --git a/src/Helpers/PathHelper.php b/src/Helpers/PathHelper.php index 1eb452b..b8f8b92 100644 --- a/src/Helpers/PathHelper.php +++ b/src/Helpers/PathHelper.php @@ -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, '/'); } } diff --git a/src/Media/MediaHooks.php b/src/Media/MediaHooks.php index 7255796..9c97a31 100644 --- a/src/Media/MediaHooks.php +++ b/src/Media/MediaHooks.php @@ -5,306 +5,231 @@ namespace FlysystemOffload\Media; use FlysystemOffload\Helpers\PathHelper; use League\Flysystem\FilesystemOperator; -use WP_Error; -class MediaHooks -{ - private ?FilesystemOperator $filesystem = null; - private string $basePrefix; +final class MediaHooks { + private FilesystemOperator $filesystem; + private array $config; + private string $protocol; + private string $streamHost; + private string $streamRootPrefix; + private string $s3Prefix; + private string $baseUrl; + private string $effectiveBaseUrl; + private string $remoteUrlPathPrefix; + private bool $deleteRemote; + private bool $preferLocal; - /** @var array */ - private array $attachedCallbacks = []; - private bool $registered = false; - - private const IMAGE_EDITOR_IMAGICK = 'FlysystemOffload\\Media\\ImageEditorImagick'; - private const IMAGE_EDITOR_GD = 'FlysystemOffload\\Media\\ImageEditorGD'; - - public function __construct(string $basePrefix = '') - { - $this->basePrefix = PathHelper::trimSlashes($basePrefix); - } - - public function register(): void - { - if ($this->registered) { - return; - } - - $this->attachFilter('upload_dir', [$this, 'filterUploadDir'], 20, 1); - $this->attachFilter('wp_get_attachment_url', [$this, 'filterAttachmentUrl'], 20, 2); - $this->attachFilter('get_attached_file', [$this, 'filterGetAttachedFile'], 20, 2); - $this->attachFilter('update_attached_file', [$this, 'filterUpdateAttachedFile'], 20, 2); - $this->attachFilter('wp_read_image_metadata', [$this, 'ensureLocalPathForMetadata'], 5, 2); - $this->attachFilter('image_editors', [$this, 'filterImageEditors'], 5, 1); - $this->attachAction('delete_attachment', [$this, 'handleDeleteAttachment'], 20, 1); - - $this->registered = true; - } - - public function unregister(): void - { - if (! $this->registered) { - return; - } - - foreach ($this->attachedCallbacks as $hookData) { - if ($hookData['type'] === 'filter') { - remove_filter( - $hookData['hook'], - $hookData['callback'], - $hookData['priority'] - ); - } else { - remove_action( - $hookData['hook'], - $hookData['callback'], - $hookData['priority'] - ); - } - } - - $this->attachedCallbacks = []; - $this->registered = false; - } - - public function setFilesystem(?FilesystemOperator $filesystem): void - { + public function __construct(FilesystemOperator $filesystem, array $config) { $this->filesystem = $filesystem; + $this->config = $config; + $this->protocol = (string) $config['stream']['protocol']; - if (class_exists(self::IMAGE_EDITOR_IMAGICK)) { - \call_user_func([self::IMAGE_EDITOR_IMAGICK, 'bootWithFilesystem'], $filesystem); - } - if (class_exists(self::IMAGE_EDITOR_GD)) { - \call_user_func([self::IMAGE_EDITOR_GD, 'bootWithFilesystem'], $filesystem); - } + $this->streamRootPrefix = PathHelper::normalize((string) ($config['stream']['root_prefix'] ?? '')); + $this->streamHost = PathHelper::normalize((string) ($config['stream']['host'] ?? 'uploads')) ?: 'uploads'; + $this->s3Prefix = PathHelper::normalize((string) ($config['s3']['prefix'] ?? '')); + + $this->baseUrl = $this->normaliseBaseUrl((string) ($config['uploads']['base_url'] ?? content_url('uploads'))); + $this->remoteUrlPathPrefix = $this->buildRemoteUrlPathPrefix(); + $this->effectiveBaseUrl = $this->calculateEffectiveBaseUrl($this->baseUrl, $this->remoteUrlPathPrefix); + + $this->deleteRemote = (bool) ($config['uploads']['delete_remote'] ?? true); + $this->preferLocal = (bool) ($config['uploads']['prefer_local_for_missing'] ?? false); } - public function filterUploadDir(array $uploadDir): array - { - $subdir = trim($uploadDir['subdir'] ?? '', '/'); - - $remoteBase = $this->basePrefix !== '' ? $this->basePrefix . '/' : ''; - $remoteBase .= $subdir !== '' ? $subdir : ''; - - $remoteBase = trim($remoteBase, '/'); - - $uploadDir['path'] = $remoteBase !== '' ? 'fly://' . $remoteBase : 'fly://'; - $uploadDir['basedir'] = $uploadDir['path']; - $uploadDir['subdir'] = $subdir !== '' ? '/' . $subdir : ''; - $uploadDir['url'] = $uploadDir['baseurl'] = $uploadDir['url']; // baseurl se sobrescribe en Plugin::filterUploadDir - - return $uploadDir; + public function register(): void { + add_filter('upload_dir', [$this, 'filterUploadDir'], 20); + add_filter('pre_option_upload_path', '__return_false'); + add_filter('pre_option_upload_url_path', '__return_false'); + add_filter('wp_get_attachment_url', [$this, 'rewriteAttachmentUrl'], 9, 2); + add_filter('image_downsize', [$this, 'filterImageDownsize'], 10, 3); + add_action('delete_attachment', [$this, 'deleteRemoteFiles']); } - public function filterAttachmentUrl(string $url, int $attachmentId): string - { + public function filterUploadDir(array $uploads): array { + $subdir = $uploads['subdir'] ?? ''; + $normalizedSubdir = $subdir !== '' ? PathHelper::normalize($subdir) : ''; + + $streamSubdir = $normalizedSubdir !== '' ? '/' . $normalizedSubdir : ''; + $streamBase = sprintf('%s://%s', $this->protocol, $this->streamHost); + + $uploads['path'] = $streamBase . $streamSubdir; + $uploads['basedir'] = $streamBase; + $uploads['baseurl'] = rtrim($this->effectiveBaseUrl, '/'); + $uploads['url'] = $this->buildPublicUrl($normalizedSubdir); + $uploads['subdir'] = $normalizedSubdir !== '' ? '/' . $normalizedSubdir : ''; + $uploads['error'] = false; + + $uploads['flysystem_protocol'] = $this->protocol; + $uploads['flysystem_host'] = $this->streamHost; + $uploads['flysystem_root_prefix'] = $this->streamRootPrefix; + + return $uploads; + } + + public function rewriteAttachmentUrl(string $url, int $attachmentId): string { $file = get_post_meta($attachmentId, '_wp_attached_file', true); - - if (empty($file)) { + if (! $file) { return $url; } - $relative = PathHelper::trimLeadingSlash($file); - - return trailingslashit($this->getBaseUrl()) . $relative; - } - - public function filterGetAttachedFile(string $file, int $attachmentId): string - { - $meta = wp_get_attachment_metadata($attachmentId); - $relative = $meta['file'] ?? get_post_meta($attachmentId, '_wp_attached_file', true); - - if (empty($relative)) { - return $file; + $relativePath = PathHelper::normalize($file); + if ($relativePath === '') { + return $url; } - return 'fly://' . PathHelper::trimLeadingSlash($relative); - } - - public function filterUpdateAttachedFile(string $file, int $attachmentId): string - { - if (str_starts_with($file, 'fly://')) { - update_post_meta($attachmentId, '_wp_attached_file', PathHelper::stripProtocol($file)); - } else { - update_post_meta($attachmentId, '_wp_attached_file', PathHelper::trimLeadingSlash($file)); - } - - return $file; - } - - public function ensureLocalPathForMetadata($metadata, string $file) - { - if (! str_starts_with($file, 'fly://') || ! $this->filesystem) { - return $metadata; - } - - // Fuerza a WP a usar una copia temporal local durante la lectura de EXIF/IPTC - $remotePath = PathHelper::stripProtocol($file); - $temp = $this->downloadToTemp($remotePath); - - if (! is_wp_error($temp)) { - $metadata = wp_read_image_metadata($temp); - @unlink($temp); - } - - return $metadata; - } - - public function filterImageEditors(array $editors): array - { - if (class_exists(self::IMAGE_EDITOR_IMAGICK)) { - $editors = array_filter( - $editors, - static fn (string $editor) => $editor !== \WP_Image_Editor_Imagick::class - ); - array_unshift($editors, self::IMAGE_EDITOR_IMAGICK); - } - - if (class_exists(self::IMAGE_EDITOR_GD)) { - $editors = array_filter( - $editors, - static fn (string $editor) => $editor !== \WP_Image_Editor_GD::class - ); - array_unshift($editors, self::IMAGE_EDITOR_GD); - } - - return array_values(array_unique($editors)); - } - - public function handleDeleteAttachment(int $attachmentId): void - { - if (! $this->filesystem) { - return; - } - - $meta = wp_get_attachment_metadata($attachmentId); - $relative = $meta['file'] ?? get_post_meta($attachmentId, '_wp_attached_file', true); - - if (empty($relative)) { - return; - } - - $base = PathHelper::trimLeadingSlash($relative); - $directory = trim(dirname($base), './'); - - $targets = [$base]; - - if (! empty($meta['sizes'])) { - foreach ($meta['sizes'] as $size) { - if (! empty($size['file'])) { - $targets[] = ltrim(($directory ? $directory . '/' : '') . $size['file'], '/'); - } + if ($this->remoteUrlPathPrefix !== '') { + $prefixWithSlash = $this->remoteUrlPathPrefix . '/'; + if (str_starts_with($relativePath, $prefixWithSlash)) { + $relativePath = substr($relativePath, strlen($prefixWithSlash)); + } elseif ($relativePath === $this->remoteUrlPathPrefix) { + $relativePath = ''; } } - foreach ($targets as $target) { - try { - if ($this->filesystem->fileExists($target)) { - $this->filesystem->delete($target); - } - } catch (\Throwable $e) { - error_log('[Flysystem Offload] Error eliminando ' . $target . ': ' . $e->getMessage()); - } - } - } + $remoteUrl = $this->buildPublicUrl($relativePath); - private function attachFilter( - string $hook, - callable $callback, - int $priority = 10, - int $acceptedArgs = 1 - ): void { - add_filter($hook, $callback, $priority, $acceptedArgs); - - $this->attachedCallbacks[] = [ - 'type' => 'filter', - 'hook' => $hook, - 'callback' => $callback, - 'priority' => $priority, - 'accepted_args' => $acceptedArgs, - ]; - } - - private function attachAction( - string $hook, - callable $callback, - int $priority = 10, - int $acceptedArgs = 1 - ): void { - add_action($hook, $callback, $priority, $acceptedArgs); - - $this->attachedCallbacks[] = [ - 'type' => 'action', - 'hook' => $hook, - 'callback' => $callback, - 'priority' => $priority, - 'accepted_args' => $acceptedArgs, - ]; - } - - private function downloadToTemp(string $remotePath) - { - if (! $this->filesystem) { - return new WP_Error( - 'flysystem_offload_no_fs', - __('No hay filesystem remoto configurado.', 'flysystem-offload') - ); - } - - if (! function_exists('wp_tempnam')) { - require_once ABSPATH . 'wp-admin/includes/file.php'; - } - - $temp = wp_tempnam(basename($remotePath)); - - if (! $temp) { - return new WP_Error( - 'flysystem_offload_temp_fail', - __('No se pudo crear un archivo temporal.', 'flysystem-offload') - ); + if (! $this->preferLocal) { + return $remoteUrl; } try { - $stream = $this->filesystem->readStream($remotePath); - if (! is_resource($stream)) { - throw new \RuntimeException('No se pudo abrir el stream remoto.'); + if ($this->filesystem->fileExists($this->toRemotePath($relativePath))) { + return $remoteUrl; } - - $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() - ) - ); + } catch (\Throwable $exception) { + error_log(sprintf( + '[Flysystem Offload] No se pudo verificar la existencia remota de "%s": %s', + $relativePath, + $exception->getMessage() + )); } - return $temp; + return $url; } - private function getBaseUrl(): string - { - $uploadDir = wp_get_upload_dir(); + public function filterImageDownsize(bool|array $out, int $attachmentId, array|string $size): bool|array { + return false; + } - return $uploadDir['baseurl'] ?? content_url('/uploads'); + public function deleteRemoteFiles(int $attachmentId): void { + if (! $this->deleteRemote) { + return; + } + + $files = $this->gatherAttachmentFiles($attachmentId); + + foreach ($files as $file) { + $key = $this->toRemotePath($file); + + try { + if ($this->filesystem->fileExists($key)) { + $this->filesystem->delete($key); + } + } catch (\Throwable $exception) { + error_log(sprintf( + '[Flysystem Offload] No se pudo eliminar el archivo remoto "%s": %s', + $key, + $exception->getMessage() + )); + } + } + } + + /** + * @return list + */ + private function gatherAttachmentFiles(int $attachmentId): array { + $files = []; + + $attachedFile = get_post_meta($attachmentId, '_wp_attached_file', true); + if ($attachedFile) { + $files[] = $attachedFile; + } + + $meta = wp_get_attachment_metadata($attachmentId); + + if (is_array($meta)) { + if (! empty($meta['file'])) { + $files[] = $meta['file']; + } + + if (! empty($meta['sizes']) && is_array($meta['sizes'])) { + $baseDir = $this->dirName($meta['file'] ?? ''); + foreach ($meta['sizes'] as $sizeMeta) { + if (! empty($sizeMeta['file'])) { + $files[] = ($baseDir !== '' ? $baseDir . '/' : '') . $sizeMeta['file']; + } + } + } + } + + $files = array_filter($files, static fn ($file) => is_string($file) && $file !== ''); + + return array_values(array_unique($files, SORT_STRING)); + } + + private function dirName(string $path): string { + $directory = dirname($path); + return $directory === '.' ? '' : $directory; + } + + private function toRemotePath(string $file): string { + $segments = []; + + if ($this->streamRootPrefix !== '') { + $segments[] = $this->streamRootPrefix; + } + + if ($this->streamHost !== '') { + $segments[] = $this->streamHost; + } + + $segments[] = $file; + + return PathHelper::join(...$segments); + } + + private function normaliseBaseUrl(string $baseUrl): string { + $baseUrl = trim($baseUrl); + if ($baseUrl === '') { + $baseUrl = content_url('uploads'); + } + + return rtrim($baseUrl, '/'); + } + + private function buildRemoteUrlPathPrefix(): string { + $segments = array_filter( + [$this->s3Prefix, $this->streamRootPrefix, $this->streamHost], + static fn (string $segment): bool => $segment !== '' + ); + + return PathHelper::join(...$segments); + } + + private function calculateEffectiveBaseUrl(string $baseUrl, string $pathPrefix): string { + $baseUrl = rtrim($baseUrl, '/'); + + if ($pathPrefix === '') { + return $baseUrl; + } + + $basePath = trim((string) parse_url($baseUrl, PHP_URL_PATH), '/'); + + if ($basePath !== '' && str_ends_with($basePath, $pathPrefix)) { + return $baseUrl; + } + + return $baseUrl . '/' . $pathPrefix; + } + + private function buildPublicUrl(string $relativePath): string { + $base = rtrim($this->effectiveBaseUrl, '/'); + + if ($relativePath === '') { + return $base; + } + + return $base . '/' . PathHelper::normalize($relativePath); } } diff --git a/src/Plugin.php b/src/Plugin.php index 1ce998e..5b84576 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -1,142 +1,165 @@ filesystemFactory = $filesystemFactory; + $this->config = $config; + $this->filesystem = $filesystem; $this->mediaHooks = $mediaHooks; $this->settingsPage = $settingsPage; + $this->healthCheck = $healthCheck; } - public function register(): void - { - add_action('plugins_loaded', [$this, 'bootstrap'], 5); - add_action('init', [$this, 'loadTextDomain']); - add_filter('plugin_action_links_' . plugin_basename(FlysystemOffload::PLUGIN_FILE), [$this, 'pluginLinks']); - } - - public function loadTextDomain(): void - { - load_plugin_textdomain( - 'flysystem-offload', - false, - dirname(plugin_basename(FlysystemOffload::PLUGIN_FILE)) . '/languages' - ); - } - - public function bootstrap(): void - { - if ($this->isInitialized) { + public static function bootstrap(): void { + if (self::$bootstrapped) { return; } - $this->config = $this->settingsPage->getSettings(); + $configDirectory = \defined('FLYSYSTEM_OFFLOAD_CONFIG_PATH') + ? FLYSYSTEM_OFFLOAD_CONFIG_PATH + : \dirname(__DIR__) . '/config'; - if (! is_array($this->config)) { - $this->config = []; + $configLoader = new ConfigLoader($configDirectory); + $config = self::normaliseConfig($configLoader->load()); + + $filesystemFactory = new FilesystemFactory(); + $filesystem = $filesystemFactory->make($config); + + FlysystemStreamWrapper::register( + $filesystem, + $config['stream']['protocol'], + $config['stream']['root_prefix'], + $config['visibility'] + ); + + $mediaHooks = new MediaHooks($filesystem, $config); + $mediaHooks->register(); + + $settingsPage = null; + if (! empty($config['admin']['enabled']) && \is_admin()) { + $settingsPage = new SettingsPage($filesystem, $config); + $settingsPage->register(); } - $this->registerStreamWrapper(); - $this->mediaHooks->register($this); - $this->settingsPage->register($this); + $healthCheck = new HealthCheck($filesystem, $config); + $healthCheck->register(); - 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; + self::$instance = new self($config, $filesystem, $mediaHooks, $settingsPage, $healthCheck); + self::$bootstrapped = 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 static function instance(): ?self { + return self::$instance; } - public function registerStreamWrapper(): void - { - try { - $filesystem = $this->filesystemFactory->build($this->config); - $protocol = $this->config['protocol'] ?? 'fly'; - $prefix = $this->config['root_prefix'] ?? ''; - - FlysystemStreamWrapper::register( - $filesystem, - $protocol, - $prefix, - [ - 'visibility' => $this->config['visibility'] ?? Visibility::PUBLIC, - ] - ); - } catch (\Throwable $exception) { - error_log('[Flysystem Offload] No se pudo registrar el stream wrapper: ' . $exception->getMessage()); - } - } - - public function pluginLinks(array $links): array - { - $settingsUrl = admin_url('options-general.php?page=flysystem-offload'); - - $links[] = '' . esc_html__('Ajustes', 'flysystem-offload') . ''; - - return $links; - } - - public function getConfig(): array - { + public function config(): array { return $this->config; } - public function getFilesystemFactory(): FilesystemFactory - { - return $this->filesystemFactory; + public function filesystem(): FilesystemOperator { + return $this->filesystem; } - public function getRemoteUrlBase(): string - { - $driver = $this->config['driver'] ?? null; + private static function normaliseConfig(array $config): array { + $defaults = [ + 'driver' => 's3', + 'visibility' => Visibility::PUBLIC, + 'cache_ttl' => 900, + 'stream' => [ + 'protocol' => 'flysystem', + 'root_prefix' => '', + 'host' => 'uploads', + ], + 'uploads' => [ + 'base_url' => '', + 'delete_remote' => true, + 'prefer_local_for_missing' => false, + 'cache_control' => 'public, max-age=31536000, immutable', + 'expires' => null, + 'expires_ttl' => 31536000, + ], + 'admin' => [ + 'enabled' => false, + ], + 's3' => [ + 'acl_public' => 'public-read', + 'acl_private' => 'private', + 'default_options' => [], + ], + ]; - if (! $driver) { - return ''; + $config = array_replace_recursive($defaults, $config); + + $config['visibility'] = self::normaliseVisibility((string) ($config['visibility'] ?? Visibility::PUBLIC)); + $config['stream']['protocol'] = self::sanitizeProtocol((string) $config['stream']['protocol']); + $config['stream']['root_prefix'] = self::normalizePathSegment((string) $config['stream']['root_prefix']); + $config['stream']['host'] = self::normalizePathSegment((string) $config['stream']['host']) ?: 'uploads'; + + if (empty($config['uploads']['base_url'])) { + $config['uploads']['base_url'] = rtrim(content_url('uploads'), '/'); + } else { + $config['uploads']['base_url'] = rtrim((string) $config['uploads']['base_url'], '/'); } - $result = $this->filesystemFactory->resolvePublicBaseUrl($driver, $this->config); + $config['uploads']['delete_remote'] = (bool) $config['uploads']['delete_remote']; + $config['uploads']['prefer_local_for_missing'] = (bool) $config['uploads']['prefer_local_for_missing']; + $config['uploads']['cache_control'] = trim((string) $config['uploads']['cache_control']); + $config['uploads']['expires'] = $config['uploads']['expires'] + ? trim((string) $config['uploads']['expires']) + : null; + $config['uploads']['expires_ttl'] = max(0, (int) ($config['uploads']['expires_ttl'] ?? 0)); - if ($result instanceof WP_Error) { - throw new RuntimeException($result->get_error_message()); - } + $config['s3']['acl_public'] = (string) ($config['s3']['acl_public'] ?? 'public-read'); + $config['s3']['acl_private'] = (string) ($config['s3']['acl_private'] ?? 'private'); + $config['s3']['default_options'] = is_array($config['s3']['default_options']) + ? $config['s3']['default_options'] + : []; - return $result; + return $config; + } + + private static function normaliseVisibility(string $visibility): string { + $visibility = \strtolower($visibility); + + return $visibility === Visibility::PRIVATE + ? Visibility::PRIVATE + : Visibility::PUBLIC; + } + + private static function sanitizeProtocol(string $protocol): string { + $protocol = \preg_replace('/[^A-Za-z0-9_\-]/', '', $protocol) ?? 'flysystem'; + $protocol = \strtolower($protocol); + + return $protocol !== '' ? $protocol : 'flysystem'; + } + + private static function normalizePathSegment(string $segment): string { + return \trim($segment, " \t\n\r\0\x0B/"); } } diff --git a/src/Settings/SettingsPage.php b/src/Settings/SettingsPage.php index d26a5c4..07eb4c5 100644 --- a/src/Settings/SettingsPage.php +++ b/src/Settings/SettingsPage.php @@ -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 - */ - 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(); ?>

-

+

+ +

-
+ + + + + + + + + + + + + + + + + + + + + + + +
config['driver'] ?? ''); ?>
config['stream']['protocol'] ?? ''); ?>
config['stream']['root_prefix'] ?? ''); ?>
config['stream']['host'] ?? ''); ?>
config['uploads']['base_url'] ?? ''); ?>
+ +

+

%s', + esc_html__('Conexión verificada correctamente.', 'flysystem-offload') + ); + } else { + printf( + ' %s', + esc_html__('No fue posible escribir en el almacenamiento remoto.', 'flysystem-offload') + ); + } ?> -

+

' . esc_html__( - 'Introduce las credenciales del proveedor que deseas utilizar.', - 'flysystem-offload' - ) . '

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