This commit is contained in:
Brasdrive 2025-11-06 21:52:26 -04:00
parent 3a316ce2cf
commit f0f1ee8c00
3 changed files with 316 additions and 117 deletions

View File

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace FlysystemOffload\Admin;
use FlysystemOffload\Plugin;
use FlysystemOffload\Helpers\PathHelper;
use League\Flysystem\PortableVisibility;
use Throwable;
final class HealthCheck
{
/**
* @param array<int, string> $args
* @param array<string, mixed> $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());
}
}
}

View File

@ -1,21 +1,27 @@
<?php
declare(strict_types=1);
namespace FlysystemOffload;
use FlysystemOffload\Admin\HealthCheck;
use FlysystemOffload\Config\ConfigLoader;
use FlysystemOffload\Filesystem\FilesystemFactory;
use FlysystemOffload\Helpers\PathHelper;
use FlysystemOffload\Media\MediaHooks;
use FlysystemOffload\StreamWrapper\FlysystemStreamWrapper;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\PortableVisibility;
use Throwable;
class Plugin
final class Plugin
{
private static $instance;
private static ?self $instance = null;
private static string $pluginFile;
private ?FilesystemOperator $filesystem = null;
private bool $streamRegistered = false;
private array $config = [];
private ConfigLoader $configLoader;
private MediaHooks $mediaHooks;
@ -31,7 +37,7 @@ class Plugin
register_activation_hook($pluginFile, [self::class, 'activate']);
register_deactivation_hook($pluginFile, [self::class, 'deactivate']);
add_action('plugins_loaded', static function () {
add_action('plugins_loaded', static function (): void {
self::instance()->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);
}
}

View File

@ -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<string, FilesystemOperator> */
private static array $filesystems = [];
@ -18,6 +21,9 @@ class FlysystemStreamWrapper
/** @var array<string, string> */
private static array $prefixes = [];
/** @var array<string, array<string, mixed>> */
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<string, mixed>
*/
private function writeOptionsForProtocol(string $protocol): array
{
return self::$writeOptions[$protocol] ?? ['visibility' => PortableVisibility::PUBLIC];
}
}