This commit is contained in:
DavidCamejo 2025-11-06 01:09:25 -04:00
parent 1b548491f5
commit 49abc3ba8d
6 changed files with 475 additions and 211 deletions

3
.gitignore vendored
View File

@ -1,5 +1,4 @@
/node_modules/ /vendor/
.idea/
*.log *.log
*.txt *.txt
*.lock *.lock

View File

@ -49,32 +49,44 @@ class S3Adapter implements AdapterInterface
public function publicBaseUrl(array $settings): string public function publicBaseUrl(array $settings): string
{ {
if (! empty($settings['cdn_url'])) { $cdn = $settings['cdn_base_url'] ?? null;
return rtrim($settings['cdn_url'], '/'); if ($cdn) {
return rtrim($cdn, '/');
} }
$bucket = $settings['bucket'] ?? ''; $bucket = $settings['bucket'] ?? '';
$prefix = isset($settings['prefix']) ? trim($settings['prefix'], '/') : ''; $endpoint = $settings['endpoint'] ?? null;
$prefix = $prefix === '' ? '' : '/' . $prefix;
if (! empty($settings['endpoint'])) {
$endpoint = trim($settings['endpoint']);
if (! preg_match('#^https?://#i', $endpoint)) {
$endpoint = 'https://' . $endpoint;
}
$endpoint = rtrim($endpoint, '/');
// Cuando se usa endpoint propio forzamos path-style (+ bucket en la ruta)
return $endpoint . '/' . $bucket . $prefix;
}
$region = $settings['region'] ?? 'us-east-1'; $region = $settings['region'] ?? 'us-east-1';
$usePathStyle = (bool) ($settings['use_path_style_endpoint'] ?? false);
$prefix = trim($settings['prefix'] ?? '', '/');
if ($region === 'us-east-1') { $normalizedUrl = null;
return "https://{$bucket}.s3.amazonaws.com{$prefix}";
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);
}
} }
return "https://{$bucket}.s3.{$region}.amazonaws.com{$prefix}"; if (! $normalizedUrl) {
$normalizedUrl = sprintf('https://%s.s3.%s.amazonaws.com', $bucket, $region);
}
return $prefix ? $normalizedUrl . '/' . $prefix : $normalizedUrl;
} }
} }

View File

@ -1,4 +1,6 @@
<?php <?php
declare(strict_types=1);
namespace FlysystemOffload\Filesystem; namespace FlysystemOffload\Filesystem;
use FlysystemOffload\Filesystem\Adapters\AzureBlobAdapter; use FlysystemOffload\Filesystem\Adapters\AzureBlobAdapter;
@ -9,8 +11,10 @@ use FlysystemOffload\Filesystem\Adapters\OneDriveAdapter;
use FlysystemOffload\Filesystem\Adapters\S3Adapter; use FlysystemOffload\Filesystem\Adapters\S3Adapter;
use FlysystemOffload\Filesystem\Adapters\SftpAdapter; use FlysystemOffload\Filesystem\Adapters\SftpAdapter;
use FlysystemOffload\Filesystem\Adapters\WebdavAdapter; use FlysystemOffload\Filesystem\Adapters\WebdavAdapter;
use FlysystemOffload\Helpers\PathHelper;
use League\Flysystem\Filesystem; use League\Flysystem\Filesystem;
use League\Flysystem\FilesystemOperator; use League\Flysystem\FilesystemOperator;
use League\Flysystem\Local\LocalFilesystemAdapter;
use WP_Error; use WP_Error;
class FilesystemFactory class FilesystemFactory
@ -29,17 +33,17 @@ class FilesystemFactory
$adapter = $this->resolveAdapter($adapterKey); $adapter = $this->resolveAdapter($adapterKey);
if (is_wp_error($adapter)) { if ($adapter instanceof WP_Error) {
return $adapter; return $adapter;
} }
$validation = $adapter->validate($config); $validation = $adapter->validate($config);
if (is_wp_error($validation)) { if ($validation instanceof WP_Error) {
return $validation; return $validation;
} }
$flyAdapter = $adapter->create($config); $flyAdapter = $adapter->create($config);
if (is_wp_error($flyAdapter)) { if ($flyAdapter instanceof WP_Error) {
return $flyAdapter; return $flyAdapter;
} }
@ -50,11 +54,13 @@ class FilesystemFactory
{ {
$adapter = $this->resolveAdapter($adapterKey); $adapter = $this->resolveAdapter($adapterKey);
if (is_wp_error($adapter)) { if ($adapter instanceof WP_Error) {
return content_url('/uploads'); return content_url('/uploads');
} }
return $adapter->publicBaseUrl($settings); $baseUrl = $adapter->publicBaseUrl($settings);
return untrailingslashit($baseUrl);
} }
private function resolveAdapter(string $adapterKey): AdapterInterface|WP_Error private function resolveAdapter(string $adapterKey): AdapterInterface|WP_Error
@ -71,18 +77,23 @@ class FilesystemFactory
default => new class implements AdapterInterface { default => new class implements AdapterInterface {
public function create(array $settings) public function create(array $settings)
{ {
return new \League\Flysystem\Local\LocalFilesystemAdapter(WP_CONTENT_DIR . '/flysystem-uploads'); $root = WP_CONTENT_DIR . '/flysystem-uploads';
return new LocalFilesystemAdapter($root);
} }
public function publicBaseUrl(array $settings): string public function publicBaseUrl(array $settings): string
{ {
return content_url('/flysystem-uploads'); return content_url('/flysystem-uploads');
} }
public function validate(array $settings) public function validate(array $settings)
{ {
wp_mkdir_p(WP_CONTENT_DIR . '/flysystem-uploads'); wp_mkdir_p(WP_CONTENT_DIR . '/flysystem-uploads');
return true; return true;
} }
} },
}; };
} }
} }

View File

@ -3,135 +3,227 @@ declare(strict_types=1);
namespace FlysystemOffload\Media; namespace FlysystemOffload\Media;
use FlysystemOffload\Helpers\PathHelper;
use League\Flysystem\FilesystemOperator;
use WP_Error; use WP_Error;
use WP_Image_Editor_Imagick;
class ImageEditorImagick extends WP_Image_Editor_Imagick if (! defined('ABSPATH')) {
exit;
}
if (! class_exists(\WP_Image_Editor::class)) {
require_once ABSPATH . WPINC . '/class-wp-image-editor.php';
}
if (! class_exists(\WP_Image_Editor_Imagick::class) && file_exists(ABSPATH . WPINC . '/class-wp-image-editor-imagick.php')) {
require_once ABSPATH . WPINC . '/class-wp-image-editor-imagick.php';
}
if (! class_exists(\WP_Image_Editor_Imagick::class)) {
// Si la clase base no está disponible (p. ej. Imagick deshabilitado),
// salimos silenciosamente para permitir que WordPress siga usando GD.
return;
}
class ImageEditorImagick extends \WP_Image_Editor_Imagick
{ {
protected ?string $remoteFilename = null; protected static ?FilesystemOperator $filesystem = null;
protected array $tempFiles = []; protected ?string $remotePath = null;
protected ?string $localPath = null;
public static function bootWithFilesystem(?FilesystemOperator $filesystem): void
{
self::$filesystem = $filesystem;
}
public function load() public function load()
{ {
if ($this->image instanceof \Imagick) { if ($this->isFlyPath($this->file) && self::$filesystem) {
return true; $this->remotePath = PathHelper::stripProtocol($this->file);
$temp = $this->downloadToTemp($this->remotePath);
if (is_wp_error($temp)) {
return $temp;
} }
if (empty($this->file)) { $this->localPath = $temp;
return new WP_Error( $this->file = $temp;
'flysystem_offload_missing_file',
__('Archivo no definido.', 'flysystem-offload')
);
} }
if (! $this->isFlysystemPath($this->file)) {
return parent::load(); return parent::load();
} }
$localPath = $this->mirrorToLocal($this->file); public function save($filename = null, $mime_type = null)
if (is_wp_error($localPath)) {
return $localPath;
}
$this->remoteFilename = $this->file;
$this->file = $localPath;
$result = parent::load();
$this->file = $this->remoteFilename;
return $result;
}
protected function _save($image, $filename = null, $mime_type = null)
{ {
[$filename, $extension, $mime_type] = $this->get_output_format($filename, $mime_type); $result = parent::save($filename, $mime_type);
if (! $filename) { if (is_wp_error($result) || ! $this->remotePath || ! self::$filesystem || empty($result['path'])) {
$filename = $this->generate_filename(null, null, $extension); return $result;
} }
$isRemote = $this->isFlysystemPath($filename); $remote = $this->determineRemotePath($result['path']);
$tempTarget = $isRemote ? $this->createTempFile($filename) : false; $sync = $this->pushToRemote($result['path'], $remote);
$result = parent::_save($image, $tempTarget ?: $filename, $mime_type); if (is_wp_error($sync)) {
return $sync;
}
if (is_wp_error($result)) { $result['path'] = 'fly://' . $remote;
if ($tempTarget) { if (isset($result['file'])) {
@unlink($tempTarget); $result['file'] = basename($remote);
} }
return $result; return $result;
} }
if ($tempTarget) { public function multi_resize($sizes)
$copy = copy($result['path'], $filename); {
$results = parent::multi_resize($sizes);
@unlink($result['path']); if (! $this->remotePath || ! self::$filesystem) {
@unlink($tempTarget); return $results;
if (! $copy) {
return new WP_Error(
'flysystem_offload_copy_failed',
__('No se pudo copiar la imagen procesada al almacenamiento remoto.', 'flysystem-offload')
);
} }
$result['path'] = $filename; foreach ($results as &$result) {
$result['file'] = wp_basename($filename); if (empty($result['path'])) {
continue;
} }
return $result; $remote = $this->determineRemotePath($result['path']);
$sync = $this->pushToRemote($result['path'], $remote);
if (is_wp_error($sync)) {
$result['error'] = $sync->get_error_message();
continue;
}
$result['path'] = 'fly://' . $remote;
if (isset($result['file'])) {
$result['file'] = basename($remote);
}
}
unset($result);
return $results;
}
public function stream($mime_type = null)
{
if ($this->remotePath && $this->localPath) {
$this->file = $this->localPath;
}
return parent::stream($mime_type);
} }
public function __destruct() public function __destruct()
{ {
foreach ($this->tempFiles as $temp) { if ($this->localPath && file_exists($this->localPath)) {
@unlink($temp); @unlink($this->localPath);
} }
parent::__destruct(); parent::__destruct();
} }
protected function mirrorToLocal(string $remotePath) protected function pushToRemote(string $localFile, string $remotePath)
{ {
$tempFile = $this->createTempFile($remotePath); $stream = @fopen($localFile, 'rb');
if (! $tempFile) { if (! $stream) {
return new WP_Error( return new WP_Error(
'flysystem_offload_temp_missing', 'flysystem_offload_upload_fail',
__('No se pudo crear un archivo temporal para la imagen remota.', 'flysystem-offload') __('No se pudo leer el archivo procesado para subirlo al almacenamiento remoto.', 'flysystem-offload')
); );
} }
if (! copy($remotePath, $tempFile)) { try {
@unlink($tempFile); self::$filesystem->writeStream($remotePath, $stream);
} catch (\Throwable $e) {
if (is_resource($stream)) {
fclose($stream);
}
return new WP_Error( return new WP_Error(
'flysystem_offload_remote_copy_failed', 'flysystem_offload_upload_fail',
__('No se pudo copiar la imagen desde el almacenamiento remoto.', 'flysystem-offload') sprintf(
__('Fallo al subir %s al almacenamiento remoto: %s', 'flysystem-offload'),
$remotePath,
$e->getMessage()
)
); );
} }
$this->tempFiles[] = $tempFile; if (is_resource($stream)) {
fclose($stream);
return $tempFile;
} }
protected function createTempFile(string $context) @unlink($localFile);
return true;
}
protected function downloadToTemp(string $remotePath)
{ {
if (! function_exists('wp_tempnam')) { if (! function_exists('wp_tempnam')) {
require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/file.php';
} }
$tempFile = wp_tempnam(wp_basename($context)); $temp = wp_tempnam(basename($remotePath));
return $tempFile ?: false; if (! $temp) {
return new WP_Error(
'flysystem_offload_temp_fail',
__('No se pudo crear un archivo temporal para procesar la imagen.', 'flysystem-offload')
);
} }
protected function isFlysystemPath(string $path): bool try {
$source = self::$filesystem->readStream($remotePath);
if (! is_resource($source)) {
throw new \RuntimeException('No se pudo abrir el stream remoto.');
}
$target = fopen($temp, 'wb');
if (! $target) {
throw new \RuntimeException('No se pudo abrir el archivo temporal.');
}
stream_copy_to_stream($source, $target);
fclose($source);
fclose($target);
} catch (\Throwable $e) {
if (isset($source) && is_resource($source)) {
fclose($source);
}
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()
)
);
}
return $temp;
}
protected function determineRemotePath(string $localSavedPath): string
{ {
return strpos($path, 'fly://') === 0; $remoteDir = $this->remotePath ? trim(dirname($this->remotePath), './') : '';
$basename = basename($localSavedPath);
return ltrim(($remoteDir ? $remoteDir . '/' : '') . $basename, '/');
}
protected function isFlyPath(string $path): bool
{
return strncmp($path, 'fly://', 6) === 0;
} }
} }

View File

@ -10,6 +10,8 @@ use WP_Error;
class MediaHooks class MediaHooks
{ {
private const CUSTOM_IMAGE_EDITOR = 'FlysystemOffload\\Media\\ImageEditorImagick';
private ?FilesystemOperator $filesystem = null; private ?FilesystemOperator $filesystem = null;
private bool $registered = false; private bool $registered = false;
private bool $metadataMirrorInProgress = false; private bool $metadataMirrorInProgress = false;
@ -17,6 +19,10 @@ class MediaHooks
public function setFilesystem(?FilesystemOperator $filesystem): void public function setFilesystem(?FilesystemOperator $filesystem): void
{ {
$this->filesystem = $filesystem; $this->filesystem = $filesystem;
if (class_exists(self::CUSTOM_IMAGE_EDITOR)) {
\call_user_func([self::CUSTOM_IMAGE_EDITOR, 'bootWithFilesystem'], $filesystem);
}
} }
public function register(): void public function register(): void
@ -53,20 +59,21 @@ class MediaHooks
public function filterImageEditors(array $editors): array public function filterImageEditors(array $editors): array
{ {
if (! class_exists(self::CUSTOM_IMAGE_EDITOR)) {
return $editors;
}
$imagickIndex = array_search(\WP_Image_Editor_Imagick::class, $editors, true); $imagickIndex = array_search(\WP_Image_Editor_Imagick::class, $editors, true);
if ($imagickIndex !== false) { if ($imagickIndex !== false) {
unset($editors[$imagickIndex]); unset($editors[$imagickIndex]);
} }
array_unshift($editors, ImageEditorImagick::class); array_unshift($editors, self::CUSTOM_IMAGE_EDITOR);
return array_values(array_unique($editors)); return array_values(array_unique($editors));
} }
/**
* Sobreescribe el movimiento final del archivo para subirlo a fly:// mediante Flysystem.
*/
public function handlePreMoveUploadedFile($override, array $file, string $destination) public function handlePreMoveUploadedFile($override, array $file, string $destination)
{ {
if ($override !== null) { if ($override !== null) {
@ -93,37 +100,26 @@ class MediaHooks
); );
} }
$directory = dirname($relativePath); $directory = trim(dirname($relativePath), '.');
if ($directory !== '' && $directory !== '.') {
try { try {
if ($directory !== '') {
$this->filesystem->createDirectory($directory); $this->filesystem->createDirectory($directory);
} catch (FilesystemException $e) {
return new WP_Error(
'flysystem_offload_directory_error',
sprintf(
__('No se pudo crear el directorio remoto "%s": %s', 'flysystem-offload'),
esc_html($directory),
$e->getMessage()
)
);
}
} }
$resource = @fopen($file['tmp_name'], 'rb'); $stream = @fopen($file['tmp_name'], 'rb');
if (! $resource) { if (! $stream) {
return new WP_Error( return new WP_Error(
'flysystem_offload_tmp_read_fail', 'flysystem_offload_tmp_read_fail',
__('No se pudo leer el archivo temporal subido.', 'flysystem-offload') __('No se pudo leer el archivo temporal subido.', 'flysystem-offload')
); );
} }
try { $this->filesystem->writeStream($relativePath, $stream);
$this->filesystem->writeStream($relativePath, $resource); } catch (\Throwable $e) {
} catch (FilesystemException $e) { if (isset($stream) && is_resource($stream)) {
if (is_resource($resource)) { fclose($stream);
fclose($resource);
} }
return new WP_Error( return new WP_Error(
@ -135,8 +131,8 @@ class MediaHooks
); );
} }
if (is_resource($resource)) { if (isset($stream) && is_resource($stream)) {
fclose($resource); fclose($stream);
} }
@unlink($file['tmp_name']); @unlink($file['tmp_name']);
@ -246,6 +242,10 @@ class MediaHooks
protected function mirrorToLocal(string $remotePath) protected function mirrorToLocal(string $remotePath)
{ {
if (! $this->filesystem || ! $this->isFlyPath($remotePath)) {
return $this->mirrorViaNativeCopy($remotePath);
}
if (! function_exists('wp_tempnam')) { if (! function_exists('wp_tempnam')) {
require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/file.php';
} }
@ -259,7 +259,79 @@ class MediaHooks
); );
} }
if (! @copy($remotePath, $temp)) { $relative = $this->relativeFlyPath($remotePath);
if ($relative === null) {
@unlink($temp);
return new WP_Error(
'flysystem_offload_remote_copy_fail',
__('No se pudo determinar la ruta remota del archivo.', 'flysystem-offload')
);
}
try {
$source = $this->filesystem->readStream($relative);
if (! is_resource($source)) {
throw new \RuntimeException('No se pudo abrir el stream remoto.');
}
$target = fopen($temp, 'wb');
if (! $target) {
throw new \RuntimeException('No se pudo abrir el archivo temporal en disco.');
}
stream_copy_to_stream($source, $target);
} catch (\Throwable $e) {
if (isset($source) && is_resource($source)) {
fclose($source);
}
if (isset($target) && is_resource($target)) {
fclose($target);
}
@unlink($temp);
return new WP_Error(
'flysystem_offload_remote_copy_fail',
sprintf(
__('No se pudo copiar la imagen desde el almacenamiento remoto: %s', 'flysystem-offload'),
$e->getMessage()
)
);
}
if (isset($source) && is_resource($source)) {
fclose($source);
}
if (isset($target) && is_resource($target)) {
fclose($target);
}
return $temp;
}
private function mirrorViaNativeCopy(string $remotePath)
{
if (! function_exists('wp_tempnam')) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
$temp = wp_tempnam(wp_basename($remotePath));
if (! $temp) {
return new WP_Error(
'flysystem_offload_temp_fail',
__('No se pudo crear un archivo temporal para la lectura de metadatos.', 'flysystem-offload')
);
}
if (@copy($remotePath, $temp)) {
return $temp;
}
@unlink($temp); @unlink($temp);
return new WP_Error( return new WP_Error(
@ -268,12 +340,9 @@ class MediaHooks
); );
} }
return $temp;
}
protected function isFlyPath(string $path): bool protected function isFlyPath(string $path): bool
{ {
return strpos($path, 'fly://') === 0; return strncmp($path, 'fly://', 6) === 0;
} }
protected function relativeFlyPath(string $path): ?string protected function relativeFlyPath(string $path): ?string

View File

@ -1,22 +1,38 @@
<?php <?php
declare(strict_types=1);
namespace FlysystemOffload\StreamWrapper; namespace FlysystemOffload\StreamWrapper;
use League\Flysystem\FilesystemException; use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator; use League\Flysystem\FilesystemOperator;
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;
class FlysystemStreamWrapper class FlysystemStreamWrapper
{ {
/**
* @var array<string, FilesystemOperator>
*/
private static array $filesystems = []; private static array $filesystems = [];
/**
* @var array<string, string>
*/
private static array $prefixes = []; private static array $prefixes = [];
private $stream; /** @var resource|null */
private string $protocol; private $stream = null;
private string $path; private string $protocol = '';
private string $mode; private string $path = '';
private string $flyPath; private string $mode = '';
private string $flyPath = '';
private array $dirEntries = [];
private int $dirPosition = 0;
/** @var resource|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): void
{ {
@ -25,37 +41,50 @@ class FlysystemStreamWrapper
} }
self::$filesystems[$protocol] = $filesystem; self::$filesystems[$protocol] = $filesystem;
self::$prefixes[$protocol] = $prefix; self::$prefixes[$protocol] = trim($prefix, '/');
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 &$opened_path): bool
{ {
$this->protocol = strtok($path, ':'); $protocol = $this->extractProtocol($path);
$this->protocol = $protocol;
$this->path = $path; $this->path = $path;
$this->mode = $mode; $this->mode = $mode;
$this->flyPath = $this->resolveFlyPath($path); $this->flyPath = $this->resolveFlyPath($path, $protocol);
$filesystem = $this->filesystem(); $filesystem = $this->filesystem($protocol);
if (strpbrk($mode, 'waxc')) { if (strpbrk($mode, 'waxc')) {
$this->stream = fopen('php://temp', str_contains($mode, 'b') ? 'w+b' : 'w+'); $this->stream = fopen('php://temp', str_contains($mode, 'b') ? 'w+b' : 'w+');
if (str_contains($mode, 'a') && $filesystem->fileExists($this->flyPath)) { if (str_contains($mode, 'a') && $filesystem->fileExists($this->flyPath)) {
try {
$contents = $filesystem->readStream($this->flyPath); $contents = $filesystem->readStream($this->flyPath);
if (is_resource($contents)) {
stream_copy_to_stream($contents, $this->stream); stream_copy_to_stream($contents, $this->stream);
fclose($contents); fclose($contents);
} }
} catch (UnableToReadFile $e) {
return false;
}
}
rewind($this->stream); rewind($this->stream);
return true; return true;
} }
try { try {
$resource = $filesystem->readStream($this->flyPath); $resource = $filesystem->readStream($this->flyPath);
if (! is_resource($resource)) {
return false;
}
$this->stream = $resource; $this->stream = $resource;
return is_resource($resource);
return true;
} catch (UnableToReadFile $e) { } catch (UnableToReadFile $e) {
return false; return false;
} }
@ -73,18 +102,21 @@ class FlysystemStreamWrapper
public function stream_flush(): bool public function stream_flush(): bool
{ {
if (!strpbrk($this->mode, 'waxc')) { if (! strpbrk($this->mode, 'waxc')) {
return true; return true;
} }
$filesystem = $this->filesystem(); $filesystem = $this->filesystem($this->protocol);
try { try {
rewind($this->stream); rewind($this->stream);
$filesystem->writeStream($this->flyPath, $this->stream); $filesystem->writeStream($this->flyPath, $this->stream);
rewind($this->stream);
return true; return true;
} catch (UnableToWriteFile $e) { } catch (UnableToWriteFile $e) {
error_log('[Flysystem Offload] Unable to flush stream: ' . $e->getMessage()); error_log('[Flysystem Offload] Unable to flush stream: ' . $e->getMessage());
return false; return false;
} }
} }
@ -96,6 +128,8 @@ class FlysystemStreamWrapper
if (is_resource($this->stream)) { if (is_resource($this->stream)) {
fclose($this->stream); fclose($this->stream);
} }
$this->stream = null;
} }
public function stream_stat(): array|false public function stream_stat(): array|false
@ -105,15 +139,18 @@ class FlysystemStreamWrapper
public function url_stat(string $path, int $flags): array|false public function url_stat(string $path, int $flags): array|false
{ {
$filesystem = $this->filesystem(); $protocol = $this->extractProtocol($path);
$flyPath = $this->resolveFlyPath($path); $filesystem = $this->filesystem($protocol);
$flyPath = $this->resolveFlyPath($path, $protocol);
try { try {
if (!$filesystem->fileExists($flyPath) && !$filesystem->directoryExists($flyPath)) { if (! $filesystem->fileExists($flyPath) && ! $filesystem->directoryExists($flyPath)) {
if ($flags & STREAM_URL_STAT_QUIET) { if ($flags & STREAM_URL_STAT_QUIET) {
return false; return false;
} }
trigger_error("File or directory not found: {$path}", E_USER_WARNING); trigger_error("File or directory not found: {$path}", E_USER_WARNING);
return false; return false;
} }
@ -122,35 +159,51 @@ class FlysystemStreamWrapper
$mtime = $filesystem->lastModified($flyPath); $mtime = $filesystem->lastModified($flyPath);
return [ return [
0 => 0,
'dev' => 0, 'dev' => 0,
1 => 0,
'ino' => 0, 'ino' => 0,
2 => $isDir ? 0040777 : 0100777,
'mode' => $isDir ? 0040777 : 0100777, 'mode' => $isDir ? 0040777 : 0100777,
3 => 0,
'nlink' => 0, 'nlink' => 0,
4 => 0,
'uid' => 0, 'uid' => 0,
5 => 0,
'gid' => 0, 'gid' => 0,
6 => 0,
'rdev' => 0, 'rdev' => 0,
7 => $size,
'size' => $size, 'size' => $size,
8 => $mtime,
'atime' => $mtime, 'atime' => $mtime,
9 => $mtime,
'mtime' => $mtime, 'mtime' => $mtime,
10 => $mtime,
'ctime' => $mtime, 'ctime' => $mtime,
11 => -1,
'blksize' => -1, 'blksize' => -1,
'blocks' => -1 12 => -1,
'blocks' => -1,
]; ];
} catch (FilesystemException $e) { } catch (FilesystemException $e) {
if (!($flags & STREAM_URL_STAT_QUIET)) { if (! ($flags & STREAM_URL_STAT_QUIET)) {
trigger_error($e->getMessage(), E_USER_WARNING); trigger_error($e->getMessage(), E_USER_WARNING);
} }
return false; return false;
} }
} }
public function unlink(string $path): bool public function unlink(string $path): bool
{ {
$filesystem = $this->filesystem(); $protocol = $this->extractProtocol($path);
$flyPath = $this->resolveFlyPath($path); $filesystem = $this->filesystem($protocol);
$flyPath = $this->resolveFlyPath($path, $protocol);
try { try {
$filesystem->delete($flyPath); $filesystem->delete($flyPath);
return true; return true;
} catch (UnableToDeleteFile $e) { } catch (UnableToDeleteFile $e) {
return false; return false;
@ -159,11 +212,13 @@ class FlysystemStreamWrapper
public function mkdir(string $path, int $mode, int $options): bool public function mkdir(string $path, int $mode, int $options): bool
{ {
$filesystem = $this->filesystem(); $protocol = $this->extractProtocol($path);
$flyPath = $this->resolveFlyPath($path); $filesystem = $this->filesystem($protocol);
$flyPath = $this->resolveFlyPath($path, $protocol);
try { try {
$filesystem->createDirectory($flyPath); $filesystem->createDirectory($flyPath);
return true; return true;
} catch (FilesystemException $e) { } catch (FilesystemException $e) {
return false; return false;
@ -172,11 +227,13 @@ class FlysystemStreamWrapper
public function rmdir(string $path, int $options): bool public function rmdir(string $path, int $options): bool
{ {
$filesystem = $this->filesystem(); $protocol = $this->extractProtocol($path);
$flyPath = $this->resolveFlyPath($path); $filesystem = $this->filesystem($protocol);
$flyPath = $this->resolveFlyPath($path, $protocol);
try { try {
$filesystem->deleteDirectory($flyPath); $filesystem->deleteDirectory($flyPath);
return true; return true;
} catch (FilesystemException $e) { } catch (FilesystemException $e) {
return false; return false;
@ -185,71 +242,95 @@ class FlysystemStreamWrapper
public function dir_opendir(string $path, int $options): bool public function dir_opendir(string $path, int $options): bool
{ {
$this->protocol = strtok($path, ':'); $protocol = $this->extractProtocol($path);
$this->flyPath = $this->resolveFlyPath($path); $this->protocol = $protocol;
$this->stream = $this->filesystem()->listContents($this->flyPath, false)->getIterator(); $this->flyPath = $this->resolveFlyPath($path, $protocol);
$filesystem = $this->filesystem($protocol);
$this->dirEntries = [];
foreach ($filesystem->listContents($this->flyPath, false) as $item) {
if ($item instanceof StorageAttributes) {
$this->dirEntries[] = basename($item->path());
} elseif (is_array($item) && isset($item['path'])) {
$this->dirEntries[] = basename($item['path']);
}
}
$this->dirPosition = 0;
return true; return true;
} }
public function dir_readdir(): string|false public function dir_readdir(): string|false
{ {
if ($this->stream instanceof \Iterator) { if ($this->dirPosition >= count($this->dirEntries)) {
if ($this->stream->valid()) { return false;
$current = $this->stream->current();
$this->stream->next();
return $current['basename'] ?? $current['path'] ?? false;
}
} }
return false; return $this->dirEntries[$this->dirPosition++];
} }
public function dir_rewinddir(): bool public function dir_rewinddir(): bool
{ {
if ($this->stream instanceof \Iterator) { $this->dirPosition = 0;
$this->stream->rewind();
return true;
}
return false; return true;
} }
public function dir_closedir(): bool public function dir_closedir(): bool
{ {
$this->stream = null; $this->dirEntries = [];
$this->dirPosition = 0;
return true; return true;
} }
public function rename(string $oldPath, string $newPath): bool public function rename(string $oldPath, string $newPath): bool
{ {
$filesystem = $this->filesystem(); $oldProtocol = $this->extractProtocol($oldPath);
$from = $this->resolveFlyPath($oldPath); $newProtocol = $this->extractProtocol($newPath);
$to = $this->resolveFlyPath($newPath);
if ($oldProtocol !== $newProtocol) {
return false;
}
$filesystem = $this->filesystem($oldProtocol);
$from = $this->resolveFlyPath($oldPath, $oldProtocol);
$to = $this->resolveFlyPath($newPath, $newProtocol);
try { try {
$filesystem->move($from, $to); $filesystem->move($from, $to);
return true; return true;
} catch (FilesystemException $e) { } catch (FilesystemException $e) {
return false; return false;
} }
} }
private function filesystem(): FilesystemOperator private function filesystem(string $protocol): FilesystemOperator
{ {
if (!isset(self::$filesystems[$this->protocol])) { if (! isset(self::$filesystems[$protocol])) {
throw new \RuntimeException('No filesystem registered for protocol: ' . $this->protocol); throw new \RuntimeException('No filesystem registered for protocol: ' . $protocol);
} }
return self::$filesystems[$this->protocol]; return self::$filesystems[$protocol];
} }
private function resolveFlyPath(string $path): string private function resolveFlyPath(string $path, ?string $protocol = null): string
{ {
$protocol = strtok($path, ':'); $protocol ??= $this->extractProtocol($path);
$prefix = self::$prefixes[$protocol] ?? ''; $prefix = self::$prefixes[$protocol] ?? '';
$raw = preg_replace('#^[^:]+://#', '', $path); $raw = preg_replace('#^[^:]+://#', '', $path) ?? '';
return ltrim($prefix . ltrim($raw, '/'), '/'); $combined = $prefix !== '' ? $prefix . '/' . ltrim($raw, '/') : ltrim($raw, '/');
return ltrim($combined, '/');
}
private function extractProtocol(string $path): string
{
$pos = strpos($path, '://');
return $pos === false ? $this->protocol : substr($path, 0, $pos);
} }
} }