This commit is contained in:
Brasdrive 2025-11-06 01:06:49 -04:00
parent bf16330bc7
commit 3ef11020d7
6 changed files with 474 additions and 211 deletions

2
.gitignore vendored
View File

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

View File

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

View File

@ -1,4 +1,6 @@
<?php
declare(strict_types=1);
namespace FlysystemOffload\Filesystem;
use FlysystemOffload\Filesystem\Adapters\AzureBlobAdapter;
@ -9,8 +11,10 @@ use FlysystemOffload\Filesystem\Adapters\OneDriveAdapter;
use FlysystemOffload\Filesystem\Adapters\S3Adapter;
use FlysystemOffload\Filesystem\Adapters\SftpAdapter;
use FlysystemOffload\Filesystem\Adapters\WebdavAdapter;
use FlysystemOffload\Helpers\PathHelper;
use League\Flysystem\Filesystem;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\Local\LocalFilesystemAdapter;
use WP_Error;
class FilesystemFactory
@ -25,21 +29,21 @@ class FilesystemFactory
public function make(): FilesystemOperator|WP_Error
{
$adapterKey = $this->settings['adapter'] ?? 'local';
$config = $this->settings['adapters'][$adapterKey] ?? [];
$config = $this->settings['adapters'][$adapterKey] ?? [];
$adapter = $this->resolveAdapter($adapterKey);
if (is_wp_error($adapter)) {
if ($adapter instanceof WP_Error) {
return $adapter;
}
$validation = $adapter->validate($config);
if (is_wp_error($validation)) {
if ($validation instanceof WP_Error) {
return $validation;
}
$flyAdapter = $adapter->create($config);
if (is_wp_error($flyAdapter)) {
if ($flyAdapter instanceof WP_Error) {
return $flyAdapter;
}
@ -50,39 +54,46 @@ class FilesystemFactory
{
$adapter = $this->resolveAdapter($adapterKey);
if (is_wp_error($adapter)) {
if ($adapter instanceof WP_Error) {
return content_url('/uploads');
}
return $adapter->publicBaseUrl($settings);
$baseUrl = $adapter->publicBaseUrl($settings);
return untrailingslashit($baseUrl);
}
private function resolveAdapter(string $adapterKey): AdapterInterface|WP_Error
{
return match ($adapterKey) {
's3' => new S3Adapter(),
'sftp' => new SftpAdapter(),
'gcs' => new GoogleCloudAdapter(),
'azure' => new AzureBlobAdapter(),
'webdav' => new WebdavAdapter(),
's3' => new S3Adapter(),
'sftp' => new SftpAdapter(),
'gcs' => new GoogleCloudAdapter(),
'azure' => new AzureBlobAdapter(),
'webdav' => new WebdavAdapter(),
'googledrive' => new GoogleDriveAdapter(), // stub (dev)
'onedrive' => new OneDriveAdapter(), // stub (dev)
'dropbox' => new DropboxAdapter(), // stub (dev)
default => new class implements AdapterInterface {
'onedrive' => new OneDriveAdapter(), // stub (dev)
'dropbox' => new DropboxAdapter(), // stub (dev)
default => new class implements AdapterInterface {
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
{
return content_url('/flysystem-uploads');
}
public function validate(array $settings)
{
wp_mkdir_p(WP_CONTENT_DIR . '/flysystem-uploads');
return true;
}
}
},
};
}
}

View File

@ -3,135 +3,227 @@ declare(strict_types=1);
namespace FlysystemOffload\Media;
use FlysystemOffload\Helpers\PathHelper;
use League\Flysystem\FilesystemOperator;
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 array $tempFiles = [];
protected static ?FilesystemOperator $filesystem = null;
protected ?string $remotePath = null;
protected ?string $localPath = null;
public static function bootWithFilesystem(?FilesystemOperator $filesystem): void
{
self::$filesystem = $filesystem;
}
public function load()
{
if ($this->image instanceof \Imagick) {
return true;
if ($this->isFlyPath($this->file) && self::$filesystem) {
$this->remotePath = PathHelper::stripProtocol($this->file);
$temp = $this->downloadToTemp($this->remotePath);
if (is_wp_error($temp)) {
return $temp;
}
$this->localPath = $temp;
$this->file = $temp;
}
if (empty($this->file)) {
return new WP_Error(
'flysystem_offload_missing_file',
__('Archivo no definido.', 'flysystem-offload')
);
return parent::load();
}
public function save($filename = null, $mime_type = null)
{
$result = parent::save($filename, $mime_type);
if (is_wp_error($result) || ! $this->remotePath || ! self::$filesystem || empty($result['path'])) {
return $result;
}
if (! $this->isFlysystemPath($this->file)) {
return parent::load();
$remote = $this->determineRemotePath($result['path']);
$sync = $this->pushToRemote($result['path'], $remote);
if (is_wp_error($sync)) {
return $sync;
}
$localPath = $this->mirrorToLocal($this->file);
if (is_wp_error($localPath)) {
return $localPath;
$result['path'] = 'fly://' . $remote;
if (isset($result['file'])) {
$result['file'] = basename($remote);
}
$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)
public function multi_resize($sizes)
{
[$filename, $extension, $mime_type] = $this->get_output_format($filename, $mime_type);
$results = parent::multi_resize($sizes);
if (! $filename) {
$filename = $this->generate_filename(null, null, $extension);
if (! $this->remotePath || ! self::$filesystem) {
return $results;
}
$isRemote = $this->isFlysystemPath($filename);
$tempTarget = $isRemote ? $this->createTempFile($filename) : false;
$result = parent::_save($image, $tempTarget ?: $filename, $mime_type);
if (is_wp_error($result)) {
if ($tempTarget) {
@unlink($tempTarget);
foreach ($results as &$result) {
if (empty($result['path'])) {
continue;
}
return $result;
}
$remote = $this->determineRemotePath($result['path']);
$sync = $this->pushToRemote($result['path'], $remote);
if ($tempTarget) {
$copy = copy($result['path'], $filename);
@unlink($result['path']);
@unlink($tempTarget);
if (! $copy) {
return new WP_Error(
'flysystem_offload_copy_failed',
__('No se pudo copiar la imagen procesada al almacenamiento remoto.', 'flysystem-offload')
);
if (is_wp_error($sync)) {
$result['error'] = $sync->get_error_message();
continue;
}
$result['path'] = $filename;
$result['file'] = wp_basename($filename);
$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 $result;
return parent::stream($mime_type);
}
public function __destruct()
{
foreach ($this->tempFiles as $temp) {
@unlink($temp);
if ($this->localPath && file_exists($this->localPath)) {
@unlink($this->localPath);
}
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(
'flysystem_offload_temp_missing',
__('No se pudo crear un archivo temporal para la imagen remota.', 'flysystem-offload')
'flysystem_offload_upload_fail',
__('No se pudo leer el archivo procesado para subirlo al almacenamiento remoto.', 'flysystem-offload')
);
}
if (! copy($remotePath, $tempFile)) {
@unlink($tempFile);
try {
self::$filesystem->writeStream($remotePath, $stream);
} catch (\Throwable $e) {
if (is_resource($stream)) {
fclose($stream);
}
return new WP_Error(
'flysystem_offload_remote_copy_failed',
__('No se pudo copiar la imagen desde el almacenamiento remoto.', 'flysystem-offload')
'flysystem_offload_upload_fail',
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;
@unlink($localFile);
return true;
}
protected function createTempFile(string $context)
protected function downloadToTemp(string $remotePath)
{
if (! function_exists('wp_tempnam')) {
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')
);
}
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 isFlysystemPath(string $path): bool
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
{
private const CUSTOM_IMAGE_EDITOR = 'FlysystemOffload\\Media\\ImageEditorImagick';
private ?FilesystemOperator $filesystem = null;
private bool $registered = false;
private bool $metadataMirrorInProgress = false;
@ -17,6 +19,10 @@ class MediaHooks
public function setFilesystem(?FilesystemOperator $filesystem): void
{
$this->filesystem = $filesystem;
if (class_exists(self::CUSTOM_IMAGE_EDITOR)) {
\call_user_func([self::CUSTOM_IMAGE_EDITOR, 'bootWithFilesystem'], $filesystem);
}
}
public function register(): void
@ -53,20 +59,21 @@ class MediaHooks
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);
if ($imagickIndex !== false) {
unset($editors[$imagickIndex]);
}
array_unshift($editors, ImageEditorImagick::class);
array_unshift($editors, self::CUSTOM_IMAGE_EDITOR);
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)
{
if ($override !== null) {
@ -93,37 +100,26 @@ class MediaHooks
);
}
$directory = dirname($relativePath);
if ($directory !== '' && $directory !== '.') {
try {
$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');
if (! $resource) {
return new WP_Error(
'flysystem_offload_tmp_read_fail',
__('No se pudo leer el archivo temporal subido.', 'flysystem-offload')
);
}
$directory = trim(dirname($relativePath), '.');
try {
$this->filesystem->writeStream($relativePath, $resource);
} catch (FilesystemException $e) {
if (is_resource($resource)) {
fclose($resource);
if ($directory !== '') {
$this->filesystem->createDirectory($directory);
}
$stream = @fopen($file['tmp_name'], 'rb');
if (! $stream) {
return new WP_Error(
'flysystem_offload_tmp_read_fail',
__('No se pudo leer el archivo temporal subido.', 'flysystem-offload')
);
}
$this->filesystem->writeStream($relativePath, $stream);
} catch (\Throwable $e) {
if (isset($stream) && is_resource($stream)) {
fclose($stream);
}
return new WP_Error(
@ -135,8 +131,8 @@ class MediaHooks
);
}
if (is_resource($resource)) {
fclose($resource);
if (isset($stream) && is_resource($stream)) {
fclose($stream);
}
@unlink($file['tmp_name']);
@ -246,6 +242,10 @@ class MediaHooks
protected function mirrorToLocal(string $remotePath)
{
if (! $this->filesystem || ! $this->isFlyPath($remotePath)) {
return $this->mirrorViaNativeCopy($remotePath);
}
if (! function_exists('wp_tempnam')) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
@ -259,21 +259,90 @@ 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 copiar la imagen desde el almacenamiento remoto.', 'flysystem-offload')
__('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);
return new WP_Error(
'flysystem_offload_remote_copy_fail',
__('No se pudo copiar la imagen desde el almacenamiento remoto.', 'flysystem-offload')
);
}
protected function isFlyPath(string $path): bool
{
return strpos($path, 'fly://') === 0;
return strncmp($path, 'fly://', 6) === 0;
}
protected function relativeFlyPath(string $path): ?string

View File

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