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 <?php
declare(strict_types=1);
namespace FlysystemOffload; namespace FlysystemOffload;
use FlysystemOffload\Admin\HealthCheck;
use FlysystemOffload\Config\ConfigLoader; use FlysystemOffload\Config\ConfigLoader;
use FlysystemOffload\Filesystem\FilesystemFactory; use FlysystemOffload\Filesystem\FilesystemFactory;
use FlysystemOffload\Helpers\PathHelper; use FlysystemOffload\Helpers\PathHelper;
use FlysystemOffload\Media\MediaHooks; use FlysystemOffload\Media\MediaHooks;
use FlysystemOffload\StreamWrapper\FlysystemStreamWrapper; use FlysystemOffload\StreamWrapper\FlysystemStreamWrapper;
use League\Flysystem\FilesystemOperator; 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 static string $pluginFile;
private ?FilesystemOperator $filesystem = null; private ?FilesystemOperator $filesystem = null;
private bool $streamRegistered = false; private bool $streamRegistered = false;
private array $config = []; private array $config = [];
private ConfigLoader $configLoader; private ConfigLoader $configLoader;
private MediaHooks $mediaHooks; private MediaHooks $mediaHooks;
@ -31,7 +37,7 @@ class Plugin
register_activation_hook($pluginFile, [self::class, 'activate']); register_activation_hook($pluginFile, [self::class, 'activate']);
register_deactivation_hook($pluginFile, [self::class, 'deactivate']); register_deactivation_hook($pluginFile, [self::class, 'deactivate']);
add_action('plugins_loaded', static function () { add_action('plugins_loaded', static function (): void {
self::instance()->init(); self::instance()->init();
}); });
} }
@ -65,22 +71,21 @@ class Plugin
public function init(): void public function init(): void
{ {
$this->configLoader = new ConfigLoader(self::$pluginFile); $this->configLoader = new ConfigLoader(self::$pluginFile);
$this->reloadConfig(); $this->reloadConfig();
add_filter('upload_dir', [$this, 'filterUploadDir'], 20); add_filter('upload_dir', [$this, 'filterUploadDir'], 20);
add_filter('wp_get_attachment_url', [$this, 'filterAttachmentUrl'], 20, 2); add_filter('wp_get_attachment_url', [$this, 'filterAttachmentUrl'], 20, 2);
add_filter('wp_get_attachment_metadata', [$this, 'filterAttachmentMetadata'], 20); add_filter('wp_get_attachment_metadata', [$this, 'filterAttachmentMetadata'], 20);
add_filter('wp_get_original_image_path', [$this, 'filterOriginalImagePath'], 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('delete_attachment', [$this, 'handleDeleteAttachment'], 20);
add_action('switch_blog', [$this, 'handleSwitchBlog']); add_action('switch_blog', [$this, 'handleSwitchBlog']);
add_action('flysystem_offload_reload_config', [$this, 'reloadConfig']); add_action('flysystem_offload_reload_config', [$this, 'reloadConfig']);
if (defined('WP_CLI') && WP_CLI) { if (defined('WP_CLI') && WP_CLI && class_exists(HealthCheck::class)) {
\WP_CLI::add_command('flysystem-offload health-check', [Admin\HealthCheck::class, 'run']); \WP_CLI::add_command('flysystem-offload health-check', [HealthCheck::class, 'run']);
} }
} }
@ -88,8 +93,8 @@ class Plugin
{ {
try { try {
$this->config = $this->configLoader->load(); $this->config = $this->configLoader->load();
} catch (\Throwable $e) { } catch (Throwable $exception) {
error_log('[Flysystem Offload] Error cargando configuración: ' . $e->getMessage()); error_log('[Flysystem Offload] Error cargando configuración: ' . $exception->getMessage());
$this->config = $this->configLoader->defaults(); $this->config = $this->configLoader->defaults();
} }
@ -107,11 +112,14 @@ class Plugin
$this->reloadConfig(); $this->reloadConfig();
} }
/**
* @throws Throwable
*/
public function getFilesystem(): FilesystemOperator public function getFilesystem(): FilesystemOperator
{ {
if (! $this->filesystem) { if ($this->filesystem === null) {
$factory = new FilesystemFactory($this->config); $factory = new FilesystemFactory($this->config);
$result = $factory->make(); $result = $factory->make();
if (is_wp_error($result)) { if (is_wp_error($result)) {
throw new \RuntimeException($result->get_error_message()); throw new \RuntimeException($result->get_error_message());
@ -123,43 +131,15 @@ class Plugin
return $this->filesystem; 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 public function filterUploadDir(array $dirs): array
{ {
$remoteBase = $this->getRemoteUrlBase(); $remoteBase = $this->getRemoteUrlBase();
$prefix = PathHelper::normalizePrefix($this->config['base_prefix'] ?? ''); $prefix = PathHelper::normalizePrefix($this->config['base_prefix'] ?? '');
$subdir = $dirs['subdir'] ?? '';
$dirs['path'] = "fly://{$prefix}{$subdir}"; $subdir = $dirs['subdir'] ?? '';
$dirs['path'] = "fly://{$prefix}" . ltrim($subdir, '/');
$dirs['basedir'] = "fly://{$prefix}"; $dirs['basedir'] = "fly://{$prefix}";
$dirs['url'] = trailingslashit($remoteBase) . ltrim($subdir, '/'); $dirs['url'] = trailingslashit($remoteBase) . ltrim($subdir, '/');
$dirs['baseurl'] = $remoteBase; $dirs['baseurl'] = $remoteBase;
return $dirs; return $dirs;
@ -167,7 +147,10 @@ class Plugin
public function filterAttachmentUrl(string $url, int $postId): string 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()); $remoteBase = trailingslashit($this->getRemoteUrlBase());
return str_replace($localBase, $remoteBase, $url); return str_replace($localBase, $remoteBase, $url);
@ -182,9 +165,10 @@ class Plugin
if (! empty($metadata['sizes'])) { if (! empty($metadata['sizes'])) {
foreach ($metadata['sizes'] as &$size) { foreach ($metadata['sizes'] as &$size) {
if (! empty($size['file'])) { if (! empty($size['file'])) {
$size['file'] = ltrim($size['file'], '/'); $size['file'] = ltrim(PathHelper::stripProtocol($size['file']), '/');
} }
} }
unset($size); unset($size);
} }
@ -196,14 +180,25 @@ class Plugin
return PathHelper::ensureFlyProtocol($path); 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 public function handleDeleteFile(string $file): string|false
{ {
$flyPath = PathHelper::stripProtocol($file); $flyPath = PathHelper::stripProtocol($file);
try { try {
$this->getFilesystem()->delete($flyPath); $this->getFilesystem()->delete($flyPath);
} catch (\Throwable $e) { } catch (Throwable $exception) {
error_log('[Flysystem Offload] Error al borrar archivo: ' . $flyPath . ' - ' . $e->getMessage()); error_log('[Flysystem Offload] Error al borrar archivo: ' . $flyPath . ' - ' . $exception->getMessage());
} }
return false; return false;
@ -216,17 +211,51 @@ class Plugin
foreach ($files as $relativePath) { foreach ($files as $relativePath) {
try { try {
$this->getFilesystem()->delete($relativePath); $this->getFilesystem()->delete($relativePath);
} catch (\Throwable $e) { } catch (Throwable $exception) {
error_log('[Flysystem Offload] Error al borrar attachment: ' . $relativePath . ' - ' . $e->getMessage()); 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 private function getRemoteUrlBase(): string
{ {
$adapterKey = $this->config['adapter'] ?? 'local'; $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\FilesystemException;
use League\Flysystem\FilesystemOperator; use League\Flysystem\FilesystemOperator;
use League\Flysystem\PortableVisibility;
use League\Flysystem\StorageAttributes; use League\Flysystem\StorageAttributes;
use League\Flysystem\UnableToDeleteFile; use League\Flysystem\UnableToDeleteFile;
use League\Flysystem\UnableToReadFile; use League\Flysystem\UnableToReadFile;
use League\Flysystem\UnableToWriteFile; use League\Flysystem\UnableToWriteFile;
use RuntimeException;
use Throwable;
class FlysystemStreamWrapper final class FlysystemStreamWrapper
{ {
/** @var array<string, FilesystemOperator> */ /** @var array<string, FilesystemOperator> */
private static array $filesystems = []; private static array $filesystems = [];
@ -18,6 +21,9 @@ class FlysystemStreamWrapper
/** @var array<string, string> */ /** @var array<string, string> */
private static array $prefixes = []; private static array $prefixes = [];
/** @var array<string, array<string, mixed>> */
private static array $writeOptions = [];
/** @var resource|null */ /** @var resource|null */
private $stream = null; private $stream = null;
@ -31,41 +37,54 @@ class FlysystemStreamWrapper
/** @var resource|array|string|null */ /** @var resource|array|string|null */
public $context = 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)) { if (in_array($protocol, stream_get_wrappers(), true)) {
stream_wrapper_unregister($protocol); stream_wrapper_unregister($protocol);
} }
self::$filesystems[$protocol] = $filesystem; 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); 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); unset($openedPath);
$this->protocol = $protocol; $this->protocol = $this->extractProtocol($path);
$this->path = $path; $this->path = $path;
$this->mode = $mode; $this->mode = $mode;
$this->flyPath = $this->resolveFlyPath($path); $this->flyPath = $this->resolveFlyPath($path);
$filesystem = $this->filesystem($protocol);
$filesystem = $this->filesystem($this->protocol);
$binary = str_contains($mode, 'b') ? 'b' : ''; $binary = str_contains($mode, 'b') ? 'b' : '';
if (strpbrk($mode, 'waxc')) { if (strpbrk($mode, 'waxc') !== false) {
$this->stream = fopen('php://temp', 'w+' . $binary); $this->stream = fopen('php://temp', 'w+' . $binary);
if ($this->stream === false) {
return false;
}
if (str_contains($mode, 'a') && $filesystem->fileExists($this->flyPath)) { if (str_contains($mode, 'a') && $filesystem->fileExists($this->flyPath)) {
try { try {
$remote = $filesystem->readStream($this->flyPath); $remote = $filesystem->readStream($this->flyPath);
if (is_resource($remote)) { if (is_resource($remote)) {
stream_copy_to_stream($remote, $this->stream); stream_copy_to_stream($remote, $this->stream);
fclose($remote); fclose($remote);
} }
} catch (UnableToReadFile $e) { } catch (UnableToReadFile $exception) {
error_log('[Flysystem Offload] Unable to open stream for append: ' . $exception->getMessage());
return false; return false;
} }
} }
@ -77,37 +96,54 @@ class FlysystemStreamWrapper
try { try {
$remote = $filesystem->readStream($this->flyPath); $remote = $filesystem->readStream($this->flyPath);
if (! is_resource($remote)) { if (! is_resource($remote)) {
return false; return false;
} }
$local = fopen('php://temp', 'w+' . $binary); $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); fclose($remote);
rewind($local); rewind($local);
$this->stream = $local; $this->stream = $local;
return true; return true;
} catch (UnableToReadFile $e) { } catch (UnableToReadFile $exception) {
error_log('[Flysystem Offload] Unable to open stream: ' . $exception->getMessage());
return false; return false;
} }
} }
public function stream_read(int $count): string|false public function stream_read(int $count): string|false
{ {
if (! is_resource($this->stream)) {
return false;
}
return fread($this->stream, $count); return fread($this->stream, $count);
} }
public function stream_write(string $data): int|false public function stream_write(string $data): int|false
{ {
if (! is_resource($this->stream)) {
return false;
}
return fwrite($this->stream, $data); return fwrite($this->stream, $data);
} }
public function stream_flush(): bool public function stream_flush(): bool
{ {
if (! strpbrk($this->mode, 'waxc')) { if (! is_resource($this->stream) || strpbrk($this->mode, 'waxc') === false) {
return true; return true;
} }
@ -115,19 +151,25 @@ class FlysystemStreamWrapper
try { try {
$meta = stream_get_meta_data($this->stream); $meta = stream_get_meta_data($this->stream);
if ($meta['seekable'] ?? false) { $seekable = (bool) ($meta['seekable'] ?? false);
if ($seekable) {
rewind($this->stream); 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); rewind($this->stream);
} }
return true; return true;
} catch (UnableToWriteFile $e) { } catch (UnableToWriteFile|Throwable $exception) {
error_log('[Flysystem Offload] Unable to flush stream: ' . $e->getMessage()); error_log('[Flysystem Offload] Unable to flush stream: ' . $exception->getMessage());
return false; return false;
} }
@ -142,11 +184,18 @@ class FlysystemStreamWrapper
} }
$this->stream = null; $this->stream = null;
$this->path = '';
$this->mode = '';
$this->flyPath = '';
} }
public function stream_tell(): int|false 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 public function stream_seek(int $offset, int $whence = SEEK_SET): bool
@ -160,27 +209,31 @@ class FlysystemStreamWrapper
public function stream_eof(): bool 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 public function stream_metadata(string $path, int $option, mixed $value): bool
{ {
// WordPress suele invocar chmod/chown/chgrp incluso sobre streams remotos. unset($path, $option, $value);
// Los tratamos como no-ops y devolvemos true para evitar warnings.
return true; 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)) { if (! is_resource($this->stream)) {
return false; return false;
} }
// Aseguramos que el puntero esté al inicio cuando se castea como stream. if (in_array($castAs, [STREAM_CAST_FOR_SELECT, STREAM_CAST_AS_STREAM], true)) {
if (in_array($cast_as, [STREAM_CAST_FOR_SELECT, STREAM_CAST_AS_STREAM], true)) {
$meta = stream_get_meta_data($this->stream); $meta = stream_get_meta_data($this->stream);
if ($meta['seekable'] ?? false) { $seekable = (bool) ($meta['seekable'] ?? false);
if ($seekable) {
rewind($this->stream); rewind($this->stream);
} }
@ -192,23 +245,35 @@ class FlysystemStreamWrapper
public function stream_set_option(int $option, int $arg1, int $arg2): bool 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; return false;
} }
public function stream_stat(): array|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 public function url_stat(string $path, int $flags): array|false
{ {
$protocol = $this->extractProtocol($path); $protocol = $this->extractProtocol($path);
$filesystem = $this->filesystem($protocol); $filesystem = $this->filesystem($protocol);
$flyPath = $this->resolveFlyPath($path); $flyPath = $this->resolveFlyPath($path);
try { try {
$exists = $filesystem->fileExists($flyPath) || $filesystem->directoryExists($flyPath); $isDirectory = $filesystem->directoryExists($flyPath);
$exists = $isDirectory || $filesystem->fileExists($flyPath);
if (! $exists) { if (! $exists) {
if ($flags & STREAM_URL_STAT_QUIET) { if ($flags & STREAM_URL_STAT_QUIET) {
@ -220,17 +285,18 @@ class FlysystemStreamWrapper
return false; return false;
} }
$isDir = $filesystem->directoryExists($flyPath); $size = $isDirectory ? 0 : $filesystem->fileSize($flyPath);
$size = $isDir ? 0 : $filesystem->fileSize($flyPath);
$mtime = $filesystem->lastModified($flyPath); $mtime = $filesystem->lastModified($flyPath);
$mode = $isDirectory ? 0040777 : 0100777;
return [ return [
0 => 0, 0 => 0,
'dev' => 0, 'dev' => 0,
1 => 0, 1 => 0,
'ino' => 0, 'ino' => 0,
2 => $isDir ? 0040777 : 0100777, 2 => $mode,
'mode' => $isDir ? 0040777 : 0100777, 'mode' => $mode,
3 => 0, 3 => 0,
'nlink' => 0, 'nlink' => 0,
4 => 0, 4 => 0,
@ -252,9 +318,9 @@ class FlysystemStreamWrapper
12 => -1, 12 => -1,
'blocks' => -1, 'blocks' => -1,
]; ];
} catch (FilesystemException $e) { } catch (FilesystemException $exception) {
if (! ($flags & STREAM_URL_STAT_QUIET)) { if (! ($flags & STREAM_URL_STAT_QUIET)) {
trigger_error($e->getMessage(), E_USER_WARNING); trigger_error($exception->getMessage(), E_USER_WARNING);
} }
return false; return false;
@ -263,45 +329,55 @@ class FlysystemStreamWrapper
public function unlink(string $path): bool public function unlink(string $path): bool
{ {
$protocol = $this->extractProtocol($path); $protocol = $this->extractProtocol($path);
$filesystem = $this->filesystem($protocol); $filesystem = $this->filesystem($protocol);
$flyPath = $this->resolveFlyPath($path); $flyPath = $this->resolveFlyPath($path);
try { try {
$filesystem->delete($flyPath); $filesystem->delete($flyPath);
return true; return true;
} catch (UnableToDeleteFile $e) { } catch (UnableToDeleteFile $exception) {
error_log('[Flysystem Offload] Unable to delete file: ' . $exception->getMessage());
return false; return false;
} }
} }
public function mkdir(string $path, int $mode, int $options): bool 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); $filesystem = $this->filesystem($protocol);
$flyPath = $this->resolveFlyPath($path); $flyPath = $this->resolveFlyPath($path);
try { try {
$filesystem->createDirectory($flyPath); $filesystem->createDirectory($flyPath);
return true; return true;
} catch (FilesystemException $e) { } catch (FilesystemException $exception) {
error_log('[Flysystem Offload] Unable to create directory: ' . $exception->getMessage());
return false; return false;
} }
} }
public function rmdir(string $path, int $options): bool public function rmdir(string $path, int $options): bool
{ {
$protocol = $this->extractProtocol($path); unset($options);
$protocol = $this->extractProtocol($path);
$filesystem = $this->filesystem($protocol); $filesystem = $this->filesystem($protocol);
$flyPath = $this->resolveFlyPath($path); $flyPath = $this->resolveFlyPath($path);
try { try {
$filesystem->deleteDirectory($flyPath); $filesystem->deleteDirectory($flyPath);
return true; return true;
} catch (FilesystemException $e) { } catch (FilesystemException $exception) {
error_log('[Flysystem Offload] Unable to delete directory: ' . $exception->getMessage());
return false; return false;
} }
} }
@ -316,34 +392,40 @@ class FlysystemStreamWrapper
} }
$filesystem = $this->filesystem($oldProtocol); $filesystem = $this->filesystem($oldProtocol);
$from = $this->resolveFlyPath($oldPath); $from = $this->resolveFlyPath($oldPath);
$to = $this->resolveFlyPath($newPath); $to = $this->resolveFlyPath($newPath);
try { try {
$filesystem->move($from, $to); $filesystem->move($from, $to);
return true; return true;
} catch (FilesystemException $e) { } catch (FilesystemException $exception) {
error_log('[Flysystem Offload] Unable to move file: ' . $exception->getMessage());
return false; return false;
} }
} }
public function dir_opendir(string $path, int $options): bool public function dir_opendir(string $path, int $options): bool
{ {
$protocol = $this->extractProtocol($path); unset($options);
$this->protocol = $protocol;
$this->flyPath = $this->resolveFlyPath($path); $this->protocol = $this->extractProtocol($path);
$filesystem = $this->filesystem($protocol); $this->flyPath = $this->resolveFlyPath($path);
$this->dirEntries = ['.', '..'];
$filesystem = $this->filesystem($this->protocol);
$entries = ['.', '..'];
foreach ($filesystem->listContents($this->flyPath, false) as $item) { foreach ($filesystem->listContents($this->flyPath, false) as $item) {
if ($item instanceof StorageAttributes) { if ($item instanceof StorageAttributes) {
$this->dirEntries[] = basename($item->path()); $entries[] = basename($item->path());
} elseif (is_array($item) && isset($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; $this->dirPosition = 0;
return true; return true;
@ -367,7 +449,7 @@ class FlysystemStreamWrapper
public function dir_closedir(): bool public function dir_closedir(): bool
{ {
$this->dirEntries = []; $this->dirEntries = [];
$this->dirPosition = 0; $this->dirPosition = 0;
return true; return true;
@ -376,7 +458,7 @@ class FlysystemStreamWrapper
private function filesystem(string $protocol): FilesystemOperator private function filesystem(string $protocol): FilesystemOperator
{ {
if (! isset(self::$filesystems[$protocol])) { 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]; return self::$filesystems[$protocol];
@ -385,14 +467,39 @@ class FlysystemStreamWrapper
private function resolveFlyPath(string $path): string private function resolveFlyPath(string $path): string
{ {
$raw = preg_replace('#^[^:]+://#', '', $path) ?? ''; $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 private function extractProtocol(string $path): string
{ {
$pos = strpos($path, '://'); $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];
} }
} }