diff --git a/src/Admin/HealthCheck.php b/src/Admin/HealthCheck.php index e69de29..91d7052 100644 --- a/src/Admin/HealthCheck.php +++ b/src/Admin/HealthCheck.php @@ -0,0 +1,63 @@ + $args + * @param array $assocArgs + */ + public static function run(array $args, array $assocArgs): void + { + unset($args, $assocArgs); + + if (! class_exists('\WP_CLI')) { + return; + } + + try { + $plugin = Plugin::instance(); + $filesystem = $plugin->getFilesystem(); + + if (! in_array('fly', stream_get_wrappers(), true)) { + \WP_CLI::error('El stream wrapper "fly://" no está registrado.'); + } + + $report = []; + + $report[] = 'Adapter: ' . get_class($filesystem); + + $uploadDir = wp_get_upload_dir(); + $report[] = 'Base URL remoto: ' . ($uploadDir['baseurl'] ?? '(desconocido)'); + $report[] = 'Directorio remoto: ' . ($uploadDir['basedir'] ?? '(desconocido)'); + + $testKey = trim(PathHelper::stripProtocol(($uploadDir['path'] ?? '') . '/flysystem-offload-health-check-' . uniqid('', true)), '/'); + + $filesystem->write($testKey, 'ok', ['visibility' => PortableVisibility::PUBLIC]); + $content = $filesystem->read($testKey); + + if ($content !== 'ok') { + \WP_CLI::warning('El contenido leído no coincide con lo escrito.'); + } else { + $report[] = 'Lectura/escritura remota verificada.'; + } + + $filesystem->delete($testKey); + + foreach ($report as $line) { + \WP_CLI::line($line); + } + + \WP_CLI::success('Health-check completado correctamente.'); + } catch (Throwable $exception) { + \WP_CLI::error('Health-check falló: ' . $exception->getMessage()); + } + } +} diff --git a/src/Plugin.php b/src/Plugin.php index e78eab7..3f7c744 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -1,21 +1,27 @@ init(); }); } @@ -65,22 +71,21 @@ class Plugin public function init(): void { $this->configLoader = new ConfigLoader(self::$pluginFile); - $this->reloadConfig(); add_filter('upload_dir', [$this, 'filterUploadDir'], 20); add_filter('wp_get_attachment_url', [$this, 'filterAttachmentUrl'], 20, 2); add_filter('wp_get_attachment_metadata', [$this, 'filterAttachmentMetadata'], 20); add_filter('wp_get_original_image_path', [$this, 'filterOriginalImagePath'], 20); - add_filter('wp_delete_file', [$this, 'handleDeleteFile'], 20); + add_filter('get_attached_file', [$this, 'filterAttachedFile'], 20, 2); + add_filter('wp_delete_file', [$this, 'handleDeleteFile'], 20); add_action('delete_attachment', [$this, 'handleDeleteAttachment'], 20); add_action('switch_blog', [$this, 'handleSwitchBlog']); - add_action('flysystem_offload_reload_config', [$this, 'reloadConfig']); - if (defined('WP_CLI') && WP_CLI) { - \WP_CLI::add_command('flysystem-offload health-check', [Admin\HealthCheck::class, 'run']); + if (defined('WP_CLI') && WP_CLI && class_exists(HealthCheck::class)) { + \WP_CLI::add_command('flysystem-offload health-check', [HealthCheck::class, 'run']); } } @@ -88,8 +93,8 @@ class Plugin { try { $this->config = $this->configLoader->load(); - } catch (\Throwable $e) { - error_log('[Flysystem Offload] Error cargando configuración: ' . $e->getMessage()); + } catch (Throwable $exception) { + error_log('[Flysystem Offload] Error cargando configuración: ' . $exception->getMessage()); $this->config = $this->configLoader->defaults(); } @@ -107,11 +112,14 @@ class Plugin $this->reloadConfig(); } + /** + * @throws Throwable + */ public function getFilesystem(): FilesystemOperator { - if (! $this->filesystem) { + if ($this->filesystem === null) { $factory = new FilesystemFactory($this->config); - $result = $factory->make(); + $result = $factory->make(); if (is_wp_error($result)) { throw new \RuntimeException($result->get_error_message()); @@ -123,43 +131,15 @@ class Plugin return $this->filesystem; } - private function registerStreamWrapper(): void - { - if ($this->streamRegistered) { - return; - } - - try { - $filesystem = $this->getFilesystem(); - } catch (\Throwable $e) { - error_log('[Flysystem Offload] No se pudo obtener el filesystem: ' . $e->getMessage()); - return; - } - - try { - FlysystemStreamWrapper::register( - $filesystem, - 'fly', - PathHelper::normalizePrefix($this->config['base_prefix'] ?? '') - ); - $this->streamRegistered = true; - } catch (\Throwable $e) { - error_log('[Flysystem Offload] No se pudo registrar el stream wrapper: ' . $e->getMessage()); - } - - $this->mediaHooks->setFilesystem($filesystem); - $this->mediaHooks->register(); - } - public function filterUploadDir(array $dirs): array { $remoteBase = $this->getRemoteUrlBase(); - $prefix = PathHelper::normalizePrefix($this->config['base_prefix'] ?? ''); - $subdir = $dirs['subdir'] ?? ''; + $prefix = PathHelper::normalizePrefix($this->config['base_prefix'] ?? ''); - $dirs['path'] = "fly://{$prefix}{$subdir}"; + $subdir = $dirs['subdir'] ?? ''; + $dirs['path'] = "fly://{$prefix}" . ltrim($subdir, '/'); $dirs['basedir'] = "fly://{$prefix}"; - $dirs['url'] = trailingslashit($remoteBase) . ltrim($subdir, '/'); + $dirs['url'] = trailingslashit($remoteBase) . ltrim($subdir, '/'); $dirs['baseurl'] = $remoteBase; return $dirs; @@ -167,7 +147,10 @@ class Plugin public function filterAttachmentUrl(string $url, int $postId): string { - $localBase = trailingslashit(wp_get_upload_dir()['baseurl']); + unset($postId); + + $uploadDir = wp_get_upload_dir(); + $localBase = trailingslashit($uploadDir['baseurl'] ?? ''); $remoteBase = trailingslashit($this->getRemoteUrlBase()); return str_replace($localBase, $remoteBase, $url); @@ -182,9 +165,10 @@ class Plugin if (! empty($metadata['sizes'])) { foreach ($metadata['sizes'] as &$size) { if (! empty($size['file'])) { - $size['file'] = ltrim($size['file'], '/'); + $size['file'] = ltrim(PathHelper::stripProtocol($size['file']), '/'); } } + unset($size); } @@ -196,14 +180,25 @@ class Plugin return PathHelper::ensureFlyProtocol($path); } + public function filterAttachedFile(string $file, int $attachmentId): string + { + unset($attachmentId); + + if (PathHelper::isFlyProtocol($file)) { + return $file; + } + + return PathHelper::ensureFlyProtocol($file); + } + public function handleDeleteFile(string $file): string|false { $flyPath = PathHelper::stripProtocol($file); try { $this->getFilesystem()->delete($flyPath); - } catch (\Throwable $e) { - error_log('[Flysystem Offload] Error al borrar archivo: ' . $flyPath . ' - ' . $e->getMessage()); + } catch (Throwable $exception) { + error_log('[Flysystem Offload] Error al borrar archivo: ' . $flyPath . ' - ' . $exception->getMessage()); } return false; @@ -216,17 +211,51 @@ class Plugin foreach ($files as $relativePath) { try { $this->getFilesystem()->delete($relativePath); - } catch (\Throwable $e) { - error_log('[Flysystem Offload] Error al borrar attachment: ' . $relativePath . ' - ' . $e->getMessage()); + } catch (Throwable $exception) { + error_log('[Flysystem Offload] Error al borrar attachment: ' . $relativePath . ' - ' . $exception->getMessage()); } } } + private function registerStreamWrapper(): void + { + if ($this->streamRegistered) { + return; + } + + try { + $filesystem = $this->getFilesystem(); + } catch (Throwable $exception) { + error_log('[Flysystem Offload] No se pudo obtener el filesystem: ' . $exception->getMessage()); + + return; + } + + try { + $visibility = $this->config['visibility'] ?? PortableVisibility::PUBLIC; + + FlysystemStreamWrapper::register( + $filesystem, + 'fly', + PathHelper::normalizePrefix($this->config['base_prefix'] ?? ''), + ['visibility' => $visibility] + ); + + $this->streamRegistered = true; + } catch (Throwable $exception) { + error_log('[Flysystem Offload] No se pudo registrar el stream wrapper: ' . $exception->getMessage()); + } + + $this->mediaHooks->setFilesystem($filesystem); + $this->mediaHooks->register(); + } + private function getRemoteUrlBase(): string { $adapterKey = $this->config['adapter'] ?? 'local'; - $settings = $this->config['adapters'][$adapterKey] ?? []; + $settings = $this->config['adapters'][$adapterKey] ?? []; - return (new FilesystemFactory($this->config))->resolvePublicBaseUrl($adapterKey, $settings); + return (new FilesystemFactory($this->config)) + ->resolvePublicBaseUrl($adapterKey, $settings); } } diff --git a/src/StreamWrapper/FlysystemStreamWrapper.php b/src/StreamWrapper/FlysystemStreamWrapper.php index 7cc9d16..69e47af 100644 --- a/src/StreamWrapper/FlysystemStreamWrapper.php +++ b/src/StreamWrapper/FlysystemStreamWrapper.php @@ -5,12 +5,15 @@ namespace FlysystemOffload\StreamWrapper; use League\Flysystem\FilesystemException; use League\Flysystem\FilesystemOperator; +use League\Flysystem\PortableVisibility; use League\Flysystem\StorageAttributes; use League\Flysystem\UnableToDeleteFile; use League\Flysystem\UnableToReadFile; use League\Flysystem\UnableToWriteFile; +use RuntimeException; +use Throwable; -class FlysystemStreamWrapper +final class FlysystemStreamWrapper { /** @var array */ private static array $filesystems = []; @@ -18,6 +21,9 @@ class FlysystemStreamWrapper /** @var array */ private static array $prefixes = []; + /** @var array> */ + private static array $writeOptions = []; + /** @var resource|null */ private $stream = null; @@ -31,41 +37,54 @@ class FlysystemStreamWrapper /** @var resource|array|string|null */ public $context = null; - public static function register(FilesystemOperator $filesystem, string $protocol, string $prefix = ''): void - { + public static function register( + FilesystemOperator $filesystem, + string $protocol, + string $prefix = '', + array $writeOptions = [] + ): void { if (in_array($protocol, stream_get_wrappers(), true)) { stream_wrapper_unregister($protocol); } self::$filesystems[$protocol] = $filesystem; - self::$prefixes[$protocol] = trim($prefix, '/'); + self::$prefixes[$protocol] = trim($prefix, '/'); + self::$writeOptions[$protocol] = $writeOptions + [ + 'visibility' => PortableVisibility::PUBLIC, + ]; stream_wrapper_register($protocol, static::class, STREAM_IS_URL); } - public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool + public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool { - $protocol = $this->extractProtocol($path); - $this->protocol = $protocol; - $this->path = $path; - $this->mode = $mode; - $this->flyPath = $this->resolveFlyPath($path); - - $filesystem = $this->filesystem($protocol); + unset($openedPath); + $this->protocol = $this->extractProtocol($path); + $this->path = $path; + $this->mode = $mode; + $this->flyPath = $this->resolveFlyPath($path); + $filesystem = $this->filesystem($this->protocol); $binary = str_contains($mode, 'b') ? 'b' : ''; - if (strpbrk($mode, 'waxc')) { + if (strpbrk($mode, 'waxc') !== false) { $this->stream = fopen('php://temp', 'w+' . $binary); + if ($this->stream === false) { + return false; + } + if (str_contains($mode, 'a') && $filesystem->fileExists($this->flyPath)) { try { $remote = $filesystem->readStream($this->flyPath); + if (is_resource($remote)) { stream_copy_to_stream($remote, $this->stream); fclose($remote); } - } catch (UnableToReadFile $e) { + } catch (UnableToReadFile $exception) { + error_log('[Flysystem Offload] Unable to open stream for append: ' . $exception->getMessage()); + return false; } } @@ -77,37 +96,54 @@ class FlysystemStreamWrapper try { $remote = $filesystem->readStream($this->flyPath); + if (! is_resource($remote)) { return false; } $local = fopen('php://temp', 'w+' . $binary); - stream_copy_to_stream($remote, $local); + if ($local === false) { + fclose($remote); + + return false; + } + + stream_copy_to_stream($remote, $local); fclose($remote); rewind($local); $this->stream = $local; return true; - } catch (UnableToReadFile $e) { + } 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 (! strpbrk($this->mode, 'waxc')) { + if (! is_resource($this->stream) || strpbrk($this->mode, 'waxc') === false) { return true; } @@ -115,19 +151,25 @@ class FlysystemStreamWrapper try { $meta = stream_get_meta_data($this->stream); - if ($meta['seekable'] ?? false) { + $seekable = (bool) ($meta['seekable'] ?? false); + + if ($seekable) { rewind($this->stream); } - $filesystem->writeStream($this->flyPath, $this->stream); + $filesystem->writeStream( + $this->flyPath, + $this->stream, + $this->writeOptionsForProtocol($this->protocol) + ); - if ($meta['seekable'] ?? false) { + if ($seekable) { rewind($this->stream); } return true; - } catch (UnableToWriteFile $e) { - error_log('[Flysystem Offload] Unable to flush stream: ' . $e->getMessage()); + } catch (UnableToWriteFile|Throwable $exception) { + error_log('[Flysystem Offload] Unable to flush stream: ' . $exception->getMessage()); return false; } @@ -142,11 +184,18 @@ class FlysystemStreamWrapper } $this->stream = null; + $this->path = ''; + $this->mode = ''; + $this->flyPath = ''; } public function stream_tell(): int|false { - return is_resource($this->stream) ? ftell($this->stream) : false; + if (! is_resource($this->stream)) { + return false; + } + + return ftell($this->stream); } public function stream_seek(int $offset, int $whence = SEEK_SET): bool @@ -160,27 +209,31 @@ class FlysystemStreamWrapper public function stream_eof(): bool { - return is_resource($this->stream) ? feof($this->stream) : true; + if (! is_resource($this->stream)) { + return true; + } + + return feof($this->stream); } public function stream_metadata(string $path, int $option, mixed $value): bool { - // WordPress suele invocar chmod/chown/chgrp incluso sobre streams remotos. - // Los tratamos como no-ops y devolvemos true para evitar warnings. + unset($path, $option, $value); + return true; } - public function stream_cast(int $cast_as) + public function stream_cast(int $castAs) { - // Permite que funciones como getimagesize() o getID3 obtengan el recurso subyacente. if (! is_resource($this->stream)) { return false; } - // Aseguramos que el puntero esté al inicio cuando se castea como stream. - if (in_array($cast_as, [STREAM_CAST_FOR_SELECT, STREAM_CAST_AS_STREAM], true)) { + if (in_array($castAs, [STREAM_CAST_FOR_SELECT, STREAM_CAST_AS_STREAM], true)) { $meta = stream_get_meta_data($this->stream); - if ($meta['seekable'] ?? false) { + $seekable = (bool) ($meta['seekable'] ?? false); + + if ($seekable) { rewind($this->stream); } @@ -192,23 +245,35 @@ class FlysystemStreamWrapper public function stream_set_option(int $option, int $arg1, int $arg2): bool { - // No se requieren operaciones especiales; devolvemos false para indicar que no se manejó. + 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 { - return $this->url_stat($this->path, 0); + 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); + $protocol = $this->extractProtocol($path); $filesystem = $this->filesystem($protocol); - $flyPath = $this->resolveFlyPath($path); + $flyPath = $this->resolveFlyPath($path); try { - $exists = $filesystem->fileExists($flyPath) || $filesystem->directoryExists($flyPath); + $isDirectory = $filesystem->directoryExists($flyPath); + $exists = $isDirectory || $filesystem->fileExists($flyPath); if (! $exists) { if ($flags & STREAM_URL_STAT_QUIET) { @@ -220,17 +285,18 @@ class FlysystemStreamWrapper return false; } - $isDir = $filesystem->directoryExists($flyPath); - $size = $isDir ? 0 : $filesystem->fileSize($flyPath); + $size = $isDirectory ? 0 : $filesystem->fileSize($flyPath); $mtime = $filesystem->lastModified($flyPath); + $mode = $isDirectory ? 0040777 : 0100777; + return [ 0 => 0, 'dev' => 0, 1 => 0, 'ino' => 0, - 2 => $isDir ? 0040777 : 0100777, - 'mode' => $isDir ? 0040777 : 0100777, + 2 => $mode, + 'mode' => $mode, 3 => 0, 'nlink' => 0, 4 => 0, @@ -252,9 +318,9 @@ class FlysystemStreamWrapper 12 => -1, 'blocks' => -1, ]; - } catch (FilesystemException $e) { + } catch (FilesystemException $exception) { if (! ($flags & STREAM_URL_STAT_QUIET)) { - trigger_error($e->getMessage(), E_USER_WARNING); + trigger_error($exception->getMessage(), E_USER_WARNING); } return false; @@ -263,45 +329,55 @@ class FlysystemStreamWrapper public function unlink(string $path): bool { - $protocol = $this->extractProtocol($path); + $protocol = $this->extractProtocol($path); $filesystem = $this->filesystem($protocol); - $flyPath = $this->resolveFlyPath($path); + $flyPath = $this->resolveFlyPath($path); try { $filesystem->delete($flyPath); return true; - } catch (UnableToDeleteFile $e) { + } catch (UnableToDeleteFile $exception) { + error_log('[Flysystem Offload] Unable to delete file: ' . $exception->getMessage()); + return false; } } public function mkdir(string $path, int $mode, int $options): bool { - $protocol = $this->extractProtocol($path); + unset($mode, $options); + + $protocol = $this->extractProtocol($path); $filesystem = $this->filesystem($protocol); - $flyPath = $this->resolveFlyPath($path); + $flyPath = $this->resolveFlyPath($path); try { $filesystem->createDirectory($flyPath); return true; - } catch (FilesystemException $e) { + } catch (FilesystemException $exception) { + error_log('[Flysystem Offload] Unable to create directory: ' . $exception->getMessage()); + return false; } } public function rmdir(string $path, int $options): bool { - $protocol = $this->extractProtocol($path); + unset($options); + + $protocol = $this->extractProtocol($path); $filesystem = $this->filesystem($protocol); - $flyPath = $this->resolveFlyPath($path); + $flyPath = $this->resolveFlyPath($path); try { $filesystem->deleteDirectory($flyPath); return true; - } catch (FilesystemException $e) { + } catch (FilesystemException $exception) { + error_log('[Flysystem Offload] Unable to delete directory: ' . $exception->getMessage()); + return false; } } @@ -316,34 +392,40 @@ class FlysystemStreamWrapper } $filesystem = $this->filesystem($oldProtocol); - $from = $this->resolveFlyPath($oldPath); - $to = $this->resolveFlyPath($newPath); + $from = $this->resolveFlyPath($oldPath); + $to = $this->resolveFlyPath($newPath); try { $filesystem->move($from, $to); return true; - } catch (FilesystemException $e) { + } catch (FilesystemException $exception) { + error_log('[Flysystem Offload] Unable to move file: ' . $exception->getMessage()); + return false; } } public function dir_opendir(string $path, int $options): bool { - $protocol = $this->extractProtocol($path); - $this->protocol = $protocol; - $this->flyPath = $this->resolveFlyPath($path); - $filesystem = $this->filesystem($protocol); - $this->dirEntries = ['.', '..']; + unset($options); + + $this->protocol = $this->extractProtocol($path); + $this->flyPath = $this->resolveFlyPath($path); + + $filesystem = $this->filesystem($this->protocol); + + $entries = ['.', '..']; foreach ($filesystem->listContents($this->flyPath, false) as $item) { if ($item instanceof StorageAttributes) { - $this->dirEntries[] = basename($item->path()); + $entries[] = basename($item->path()); } elseif (is_array($item) && isset($item['path'])) { - $this->dirEntries[] = basename($item['path']); + $entries[] = basename((string) $item['path']); } } + $this->dirEntries = $entries; $this->dirPosition = 0; return true; @@ -367,7 +449,7 @@ class FlysystemStreamWrapper public function dir_closedir(): bool { - $this->dirEntries = []; + $this->dirEntries = []; $this->dirPosition = 0; return true; @@ -376,7 +458,7 @@ class FlysystemStreamWrapper private function filesystem(string $protocol): FilesystemOperator { if (! isset(self::$filesystems[$protocol])) { - throw new \RuntimeException('No filesystem registered for protocol: ' . $protocol); + throw new RuntimeException('No filesystem registered for protocol: ' . $protocol); } return self::$filesystems[$protocol]; @@ -385,14 +467,39 @@ class FlysystemStreamWrapper private function resolveFlyPath(string $path): string { $raw = preg_replace('#^[^:]+://#', '', $path) ?? ''; + $relative = ltrim($raw, '/'); - return ltrim($raw, '/'); + $prefix = self::$prefixes[$this->protocol] ?? ''; + + if ($prefix !== '') { + $prefixWithSlash = $prefix . '/'; + + if (str_starts_with($relative, $prefixWithSlash)) { + $relative = substr($relative, strlen($prefixWithSlash)); + } elseif ($relative === $prefix) { + $relative = ''; + } + } + + return $relative; } private function extractProtocol(string $path): string { $pos = strpos($path, '://'); - return $pos === false ? $this->protocol : substr($path, 0, $pos); + if ($pos === false) { + return $this->protocol ?: 'fly'; + } + + return substr($path, 0, $pos); + } + + /** + * @return array + */ + private function writeOptionsForProtocol(string $protocol): array + { + return self::$writeOptions[$protocol] ?? ['visibility' => PortableVisibility::PUBLIC]; } }