This commit is contained in:
Brasdrive 2025-11-06 20:38:47 -04:00
parent 995e1dfd80
commit 3a316ce2cf
7 changed files with 317 additions and 370 deletions

View File

@ -1,21 +1,36 @@
<?php
declare(strict_types=1);
namespace FlysystemOffload\Filesystem\Adapters;
use Aws\S3\S3Client;
use FlysystemOffload\Filesystem\AdapterInterface;
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
use League\Flysystem\FilesystemAdapter;
use League\Flysystem\PathPrefixing\PathPrefixedAdapter;
use League\MimeTypeDetection\ExtensionMimeTypeDetector;
use League\MimeTypeDetection\MimeTypeDetector;
use WP_Error;
class S3Adapter implements AdapterInterface
{
private MimeTypeDetector $mimeTypeDetector;
public function __construct(?MimeTypeDetector $mimeTypeDetector = null)
{
$this->mimeTypeDetector = $mimeTypeDetector ?? new ExtensionMimeTypeDetector();
}
public function validate(array $settings)
{
$required = ['access_key', 'secret_key', 'region', 'bucket'];
foreach ($required as $field) {
if (empty($settings[$field])) {
return new WP_Error('flysystem_offload_invalid_s3', sprintf(__('Campo S3 faltante: %s', 'flysystem-offload'), $field));
return new WP_Error(
'flysystem_offload_invalid_s3',
sprintf(__('Campo S3 faltante: %s', 'flysystem-offload'), $field)
);
}
}
@ -27,21 +42,40 @@ class S3Adapter implements AdapterInterface
try {
$clientConfig = [
'credentials' => [
'key' => $settings['access_key'],
'secret' => $settings['secret_key']
'key' => $settings['access_key'],
'secret' => $settings['secret_key'],
],
'region' => $settings['region'],
'version' => 'latest'
'region' => $settings['region'],
'version' => 'latest',
];
if (!empty($settings['endpoint'])) {
$clientConfig['endpoint'] = $settings['endpoint'];
$clientConfig['use_path_style_endpoint'] = true;
if (! empty($settings['endpoint'])) {
$clientConfig['endpoint'] = rtrim($settings['endpoint'], '/');
$clientConfig['use_path_style_endpoint'] = (bool) ($settings['use_path_style_endpoint'] ?? true);
}
if (! empty($settings['http_client'])) {
$clientConfig['http_client'] = $settings['http_client'];
}
$client = new S3Client($clientConfig);
return new AwsS3V3Adapter($client, $settings['bucket'], $settings['prefix'] ?? '');
$adapter = new AwsS3V3Adapter(
$client,
$settings['bucket'],
'',
options: [],
mimeTypeDetector: $this->mimeTypeDetector
);
if (! empty($settings['prefix'])) {
$adapter = new PathPrefixedAdapter(
$adapter,
trim($settings['prefix'], '/')
);
}
return $adapter;
} catch (\Throwable $e) {
return new WP_Error('flysystem_offload_s3_error', $e->getMessage());
}

View File

@ -11,7 +11,6 @@ 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;

View File

@ -1,60 +1,42 @@
<?php
declare(strict_types=1);
namespace FlysystemOffload\Helpers;
class PathHelper
final class PathHelper
{
public static function normalizePrefix(string $prefix): string
{
$prefix = trim($prefix);
$prefix = trim($prefix, '/');
return $prefix ? $prefix . '/' : '';
}
private function __construct() {}
public static function stripProtocol(string $path): string
{
$path = preg_replace('#^(fly://|https?://[^/]+/uploads/?)#', '', $path);
return ltrim(preg_replace('#^[^:]+://#', '', $path) ?? '', '/');
}
public static function trimLeadingSlash(string $path): string
{
return ltrim($path, '/');
}
public static function ensureFlyProtocol(string $path): string
public static function trimTrailingSlash(string $path): string
{
if (str_starts_with($path, 'fly://')) {
return $path;
}
$relative = self::stripProtocol($path);
return 'fly://' . $relative;
return rtrim($path, '/');
}
public static function collectFilesFromAttachment(int $postId): array
public static function trimSlashes(string $path): string
{
$files = [];
return trim($path, '/');
}
if ($mainFile = get_attached_file($postId, true)) {
$files[] = self::stripProtocol($mainFile);
}
public static function ensureTrailingSlash(string $path): string
{
return rtrim($path, '/') . '/';
}
$metadata = wp_get_attachment_metadata($postId) ?: [];
$dir = isset($metadata['file']) ? dirname(self::stripProtocol($metadata['file'])) : '';
public static function normalizeDirectory(string $path): string
{
$path = str_replace('\\', '/', $path);
$path = preg_replace('#/{2,}#', '/', $path) ?? $path;
if (!empty($metadata['sizes'])) {
foreach ($metadata['sizes'] as $size) {
if (!empty($size['file'])) {
$files[] = trailingslashit($dir) . ltrim($size['file'], '/');
}
}
}
$backupSizes = get_post_meta($postId, '_wp_attachment_backup_sizes', true) ?: [];
foreach ($backupSizes as $size) {
if (!empty($size['file'])) {
$files[] = trailingslashit($dir) . ltrim($size['file'], '/');
}
}
return array_unique(array_filter($files));
return self::trimTrailingSlash($path);
}
}

View File

@ -131,7 +131,7 @@ class ImageEditorGD extends \WP_Image_Editor_GD
}
try {
self::$filesystem->writeStream($remotePath, $stream);
self::$filesystem?->writeStream($remotePath, $stream);
} catch (\Throwable $e) {
if (is_resource($stream)) {
fclose($stream);
@ -172,7 +172,7 @@ class ImageEditorGD extends \WP_Image_Editor_GD
}
try {
$source = self::$filesystem->readStream($remotePath);
$source = self::$filesystem?->readStream($remotePath);
if (! is_resource($source)) {
throw new \RuntimeException('No se pudo abrir el stream remoto.');
}

View File

@ -11,17 +11,11 @@ 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;
}
@ -29,7 +23,7 @@ class ImageEditorImagick extends \WP_Image_Editor_Imagick
{
protected static ?FilesystemOperator $filesystem = null;
protected ?string $remotePath = null;
protected ?string $localPath = null;
protected ?string $localPath = null;
public static function bootWithFilesystem(?FilesystemOperator $filesystem): void
{
@ -40,7 +34,7 @@ class ImageEditorImagick extends \WP_Image_Editor_Imagick
{
if ($this->isFlyPath($this->file) && self::$filesystem) {
$this->remotePath = PathHelper::stripProtocol($this->file);
$temp = $this->downloadToTemp($this->remotePath);
$temp = $this->downloadToTemp($this->remotePath);
if (is_wp_error($temp)) {
return $temp;
@ -137,7 +131,7 @@ class ImageEditorImagick extends \WP_Image_Editor_Imagick
}
try {
self::$filesystem->writeStream($remotePath, $stream);
self::$filesystem?->writeStream($remotePath, $stream);
} catch (\Throwable $e) {
if (is_resource($stream)) {
fclose($stream);
@ -178,7 +172,7 @@ class ImageEditorImagick extends \WP_Image_Editor_Imagick
}
try {
$source = self::$filesystem->readStream($remotePath);
$source = self::$filesystem?->readStream($remotePath);
if (! is_resource($source)) {
throw new \RuntimeException('No se pudo abrir el stream remoto.');
}

View File

@ -3,354 +3,238 @@ declare(strict_types=1);
namespace FlysystemOffload\Media;
use League\Flysystem\FilesystemException;
use FlysystemOffload\Helpers\PathHelper;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\StorageAttributes;
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;
private string $basePrefix;
private const IMAGE_EDITOR_IMAGICK = 'FlysystemOffload\\Media\\ImageEditorImagick';
private const IMAGE_EDITOR_GD = 'FlysystemOffload\\Media\\ImageEditorGD';
public function __construct(string $basePrefix = '')
{
$this->basePrefix = PathHelper::trimSlashes($basePrefix);
}
public function register(): void
{
add_filter('upload_dir', [$this, 'filterUploadDir'], 20);
add_filter('wp_get_attachment_url', [$this, 'filterAttachmentUrl'], 20, 2);
add_filter('get_attached_file', [$this, 'filterGetAttachedFile'], 20, 2);
add_filter('update_attached_file', [$this, 'filterUpdateAttachedFile'], 20, 2);
add_filter('wp_read_image_metadata', [$this, 'ensureLocalPathForMetadata'], 5, 2);
add_filter('image_editors', [$this, 'filterImageEditors'], 5);
add_action('delete_attachment', [$this, 'handleDeleteAttachment'], 20);
}
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);
if (class_exists(self::IMAGE_EDITOR_IMAGICK)) {
\call_user_func([self::IMAGE_EDITOR_IMAGICK, 'bootWithFilesystem'], $filesystem);
}
if (class_exists(self::IMAGE_EDITOR_GD)) {
\call_user_func([self::IMAGE_EDITOR_GD, 'bootWithFilesystem'], $filesystem);
}
}
public function register(): void
public function filterUploadDir(array $uploadDir): array
{
if ($this->registered) {
return;
}
$subdir = trim($uploadDir['subdir'] ?? '', '/');
add_filter('wp_image_editors', [$this, 'filterImageEditors'], 5);
add_filter('pre_move_uploaded_file', [$this, 'handlePreMoveUploadedFile'], 10, 3);
add_filter('wp_read_image_metadata', [$this, 'filterReadImageMetadata'], 10, 2);
add_filter('wp_generate_attachment_metadata', [$this, 'filterGenerateAttachmentMetadata'], 10, 2);
add_filter('pre_wp_unique_filename_file_list', [$this, 'filterUniqueFilenameFileList'], 10, 3);
add_action('delete_attachment', [$this, 'handleDeleteAttachment'], 10);
$remoteBase = $this->basePrefix !== '' ? $this->basePrefix . '/' : '';
$remoteBase .= $subdir !== '' ? $subdir : '';
$this->registered = true;
$remoteBase = trim($remoteBase, '/');
$uploadDir['path'] = $remoteBase !== '' ? 'fly://' . $remoteBase : 'fly://';
$uploadDir['basedir'] = $uploadDir['path'];
$uploadDir['subdir'] = $subdir !== '' ? '/' . $subdir : '';
$uploadDir['url'] = $uploadDir['baseurl'] = $uploadDir['url']; // baseurl se sobrescribe en Plugin::filterUploadDir
return $uploadDir;
}
public function unregister(): void
public function filterAttachmentUrl(string $url, int $attachmentId): string
{
if (! $this->registered) {
return;
$file = get_post_meta($attachmentId, '_wp_attached_file', true);
if (empty($file)) {
return $url;
}
remove_filter('wp_image_editors', [$this, 'filterImageEditors'], 5);
remove_filter('pre_move_uploaded_file', [$this, 'handlePreMoveUploadedFile'], 10);
remove_filter('wp_read_image_metadata', [$this, 'filterReadImageMetadata'], 10);
remove_filter('wp_generate_attachment_metadata', [$this, 'filterGenerateAttachmentMetadata'], 10);
remove_filter('pre_wp_unique_filename_file_list', [$this, 'filterUniqueFilenameFileList'], 10);
remove_action('delete_attachment', [$this, 'handleDeleteAttachment']);
$relative = PathHelper::trimLeadingSlash($file);
$this->registered = false;
return trailingslashit($this->getBaseUrl()) . $relative;
}
public function filterImageEditors(array $editors): array
public function filterGetAttachedFile(string $file, int $attachmentId): string
{
if (! class_exists(self::CUSTOM_IMAGE_EDITOR)) {
return $editors;
$meta = wp_get_attachment_metadata($attachmentId);
$relative = $meta['file'] ?? get_post_meta($attachmentId, '_wp_attached_file', true);
if (empty($relative)) {
return $file;
}
$imagickIndex = array_search(\WP_Image_Editor_Imagick::class, $editors, true);
if ($imagickIndex !== false) {
unset($editors[$imagickIndex]);
}
array_unshift($editors, self::CUSTOM_IMAGE_EDITOR);
return array_values(array_unique($editors));
return 'fly://' . PathHelper::trimLeadingSlash($relative);
}
public function handlePreMoveUploadedFile($override, array $file, string $destination)
public function filterUpdateAttachedFile(string $file, int $attachmentId): string
{
if ($override !== null) {
return $override;
if (str_starts_with($file, 'fly://')) {
update_post_meta($attachmentId, '_wp_attached_file', PathHelper::stripProtocol($file));
} else {
update_post_meta($attachmentId, '_wp_attached_file', PathHelper::trimLeadingSlash($file));
}
if (! $this->isFlyPath($destination)) {
return $override;
}
if (! $this->filesystem) {
return new WP_Error(
'flysystem_offload_missing_filesystem',
__('No se pudo acceder al filesystem remoto.', 'flysystem-offload')
);
}
$relativePath = $this->relativeFlyPath($destination);
if ($relativePath === null) {
return new WP_Error(
'flysystem_offload_invalid_destination',
__('Ruta de destino inválida para el stream fly://.', 'flysystem-offload')
);
}
$directory = trim(dirname($relativePath), '.');
try {
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(
'flysystem_offload_write_fail',
sprintf(
__('No se pudo guardar el archivo en el almacenamiento remoto: %s', 'flysystem-offload'),
$e->getMessage()
)
);
}
if (isset($stream) && is_resource($stream)) {
fclose($stream);
}
@unlink($file['tmp_name']);
return true;
return $file;
}
public function filterReadImageMetadata($metadata, string $file)
public function ensureLocalPathForMetadata($metadata, string $file)
{
if ($this->metadataMirrorInProgress || ! $this->isFlyPath($file)) {
if (! str_starts_with($file, 'fly://') || ! $this->filesystem) {
return $metadata;
}
$this->metadataMirrorInProgress = true;
$temp = $this->mirrorToLocal($file);
// Fuerza a WP a usar una copia temporal local durante la lectura de EXIF/IPTC
$remotePath = PathHelper::stripProtocol($file);
$temp = $this->downloadToTemp($remotePath);
if (! is_wp_error($temp)) {
$metadata = wp_read_image_metadata($temp);
@unlink($temp);
}
$this->metadataMirrorInProgress = false;
return $metadata;
}
public function filterGenerateAttachmentMetadata(array $metadata, int $attachmentId): array
public function filterImageEditors(array $editors): array
{
if (isset($metadata['filesize'])) {
return $metadata;
if (class_exists(self::IMAGE_EDITOR_IMAGICK)) {
$editors = array_filter(
$editors,
static fn (string $editor) => $editor !== \WP_Image_Editor_Imagick::class
);
array_unshift($editors, self::IMAGE_EDITOR_IMAGICK);
}
$file = get_attached_file($attachmentId);
if ($file && file_exists($file)) {
$metadata['filesize'] = filesize($file);
if (class_exists(self::IMAGE_EDITOR_GD)) {
$editors = array_filter(
$editors,
static fn (string $editor) => $editor !== \WP_Image_Editor_GD::class
);
array_unshift($editors, self::IMAGE_EDITOR_GD);
}
return $metadata;
}
public function filterUniqueFilenameFileList($files, string $dir, string $filename)
{
if (! $this->isFlyPath($dir) || ! $this->filesystem) {
return $files;
}
$relativeDir = $this->relativeFlyPath($dir);
if ($relativeDir === null) {
return $files;
}
$existing = [];
foreach ($this->filesystem->listContents($relativeDir, false) as $item) {
/** @var StorageAttributes $item */
if ($item->isDir()) {
continue;
}
$existing[] = basename($item->path());
}
return $existing;
return array_values(array_unique($editors));
}
public function handleDeleteAttachment(int $attachmentId): void
{
$file = get_attached_file($attachmentId);
if (! $file || ! $this->isFlyPath($file)) {
if (! $this->filesystem) {
return;
}
$meta = wp_get_attachment_metadata($attachmentId);
$meta = wp_get_attachment_metadata($attachmentId);
$relative = $meta['file'] ?? get_post_meta($attachmentId, '_wp_attached_file', true);
if (empty($relative)) {
return;
}
$base = PathHelper::trimLeadingSlash($relative);
$directory = trim(dirname($base), './');
$targets = [$base];
if (! empty($meta['sizes'])) {
foreach ($meta['sizes'] as $sizeInfo) {
if (empty($sizeInfo['file'])) {
continue;
foreach ($meta['sizes'] as $size) {
if (! empty($size['file'])) {
$targets[] = ltrim(($directory ? $directory . '/' : '') . $size['file'], '/');
}
wp_delete_file(str_replace(basename($file), $sizeInfo['file'], $file));
}
}
$original = get_post_meta($attachmentId, 'original_image', true);
if ($original) {
wp_delete_file(str_replace(basename($file), $original, $file));
}
$backup = get_post_meta($attachmentId, '_wp_attachment_backup_sizes', true);
if (is_array($backup)) {
foreach ($backup as $sizeInfo) {
if (empty($sizeInfo['file'])) {
continue;
foreach ($targets as $target) {
try {
if ($this->filesystem->fileExists($target)) {
$this->filesystem->delete($target);
}
wp_delete_file(str_replace(basename($file), $sizeInfo['file'], $file));
} catch (\Throwable $e) {
error_log('[Flysystem Offload] Error eliminando ' . $target . ': ' . $e->getMessage());
}
}
wp_delete_file($file);
}
protected function mirrorToLocal(string $remotePath)
private function downloadToTemp(string $remotePath)
{
if (! $this->filesystem || ! $this->isFlyPath($remotePath)) {
return $this->mirrorViaNativeCopy($remotePath);
if (! $this->filesystem) {
return new WP_Error(
'flysystem_offload_no_fs',
__('No hay filesystem remoto configurado.', 'flysystem-offload')
);
}
if (! function_exists('wp_tempnam')) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
$temp = wp_tempnam(wp_basename($remotePath));
$temp = wp_tempnam(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')
);
}
$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')
__('No se pudo crear un archivo temporal.', 'flysystem-offload')
);
}
try {
$source = $this->filesystem->readStream($relative);
if (! is_resource($source)) {
$stream = $this->filesystem->readStream($remotePath);
if (! is_resource($stream)) {
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.');
throw new \RuntimeException('No se pudo abrir el archivo temporal.');
}
stream_copy_to_stream($source, $target);
stream_copy_to_stream($stream, $target);
fclose($stream);
fclose($target);
} catch (\Throwable $e) {
if (isset($source) && is_resource($source)) {
fclose($source);
if (isset($stream) && is_resource($stream)) {
fclose($stream);
}
if (isset($target) && is_resource($target)) {
fclose($target);
}
@unlink($temp);
return new WP_Error(
'flysystem_offload_remote_copy_fail',
'flysystem_offload_download_fail',
sprintf(
__('No se pudo copiar la imagen desde el almacenamiento remoto: %s', 'flysystem-offload'),
__('Fallo al descargar "%s" del almacenamiento remoto: %s', 'flysystem-offload'),
$remotePath,
$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)
private function getBaseUrl(): string
{
if (! function_exists('wp_tempnam')) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
$uploadDir = wp_get_upload_dir();
$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 strncmp($path, 'fly://', 6) === 0;
}
protected function relativeFlyPath(string $path): ?string
{
if (! $this->isFlyPath($path)) {
return null;
}
return ltrim(substr($path, 6), '/');
return $uploadDir['baseurl'] ?? content_url('/uploads');
}
}

View File

@ -12,18 +12,15 @@ use League\Flysystem\UnableToWriteFile;
class FlysystemStreamWrapper
{
/**
* @var array<string, FilesystemOperator>
*/
/** @var array<string, FilesystemOperator> */
private static array $filesystems = [];
/**
* @var array<string, string>
*/
/** @var array<string, string> */
private static array $prefixes = [];
/** @var resource|null */
private $stream = null;
private string $protocol = '';
private string $path = '';
private string $mode = '';
@ -31,10 +28,10 @@ class FlysystemStreamWrapper
private array $dirEntries = [];
private int $dirPosition = 0;
/** @var resource|null */
/** @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 = ''): void
{
if (in_array($protocol, stream_get_wrappers(), true)) {
stream_wrapper_unregister($protocol);
@ -48,23 +45,25 @@ class FlysystemStreamWrapper
public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool
{
$protocol = $this->extractProtocol($path);
$protocol = $this->extractProtocol($path);
$this->protocol = $protocol;
$this->path = $path;
$this->mode = $mode;
$this->flyPath = $this->resolveFlyPath($path, $protocol);
$this->flyPath = $this->resolveFlyPath($path);
$filesystem = $this->filesystem($protocol);
$binary = str_contains($mode, 'b') ? 'b' : '';
if (strpbrk($mode, 'waxc')) {
$this->stream = fopen('php://temp', str_contains($mode, 'b') ? 'w+b' : 'w+');
$this->stream = fopen('php://temp', 'w+' . $binary);
if (str_contains($mode, 'a') && $filesystem->fileExists($this->flyPath)) {
try {
$contents = $filesystem->readStream($this->flyPath);
if (is_resource($contents)) {
stream_copy_to_stream($contents, $this->stream);
fclose($contents);
$remote = $filesystem->readStream($this->flyPath);
if (is_resource($remote)) {
stream_copy_to_stream($remote, $this->stream);
fclose($remote);
}
} catch (UnableToReadFile $e) {
return false;
@ -77,12 +76,18 @@ class FlysystemStreamWrapper
}
try {
$resource = $filesystem->readStream($this->flyPath);
if (! is_resource($resource)) {
$remote = $filesystem->readStream($this->flyPath);
if (! is_resource($remote)) {
return false;
}
$this->stream = $resource;
$local = fopen('php://temp', 'w+' . $binary);
stream_copy_to_stream($remote, $local);
fclose($remote);
rewind($local);
$this->stream = $local;
return true;
} catch (UnableToReadFile $e) {
@ -109,9 +114,16 @@ class FlysystemStreamWrapper
$filesystem = $this->filesystem($this->protocol);
try {
rewind($this->stream);
$meta = stream_get_meta_data($this->stream);
if ($meta['seekable'] ?? false) {
rewind($this->stream);
}
$filesystem->writeStream($this->flyPath, $this->stream);
rewind($this->stream);
if ($meta['seekable'] ?? false) {
rewind($this->stream);
}
return true;
} catch (UnableToWriteFile $e) {
@ -132,6 +144,58 @@ class FlysystemStreamWrapper
$this->stream = null;
}
public function stream_tell(): int|false
{
return is_resource($this->stream) ? ftell($this->stream) : false;
}
public function stream_seek(int $offset, int $whence = SEEK_SET): bool
{
if (! is_resource($this->stream)) {
return false;
}
return fseek($this->stream, $offset, $whence) === 0;
}
public function stream_eof(): bool
{
return is_resource($this->stream) ? feof($this->stream) : true;
}
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.
return true;
}
public function stream_cast(int $cast_as)
{
// 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)) {
$meta = stream_get_meta_data($this->stream);
if ($meta['seekable'] ?? false) {
rewind($this->stream);
}
return $this->stream;
}
return false;
}
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ó.
return false;
}
public function stream_stat(): array|false
{
return $this->url_stat($this->path, 0);
@ -139,12 +203,14 @@ class FlysystemStreamWrapper
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, $protocol);
$flyPath = $this->resolveFlyPath($path);
try {
if (! $filesystem->fileExists($flyPath) && ! $filesystem->directoryExists($flyPath)) {
$exists = $filesystem->fileExists($flyPath) || $filesystem->directoryExists($flyPath);
if (! $exists) {
if ($flags & STREAM_URL_STAT_QUIET) {
return false;
}
@ -197,9 +263,9 @@ class FlysystemStreamWrapper
public function unlink(string $path): bool
{
$protocol = $this->extractProtocol($path);
$protocol = $this->extractProtocol($path);
$filesystem = $this->filesystem($protocol);
$flyPath = $this->resolveFlyPath($path, $protocol);
$flyPath = $this->resolveFlyPath($path);
try {
$filesystem->delete($flyPath);
@ -212,9 +278,9 @@ class FlysystemStreamWrapper
public function mkdir(string $path, int $mode, int $options): bool
{
$protocol = $this->extractProtocol($path);
$protocol = $this->extractProtocol($path);
$filesystem = $this->filesystem($protocol);
$flyPath = $this->resolveFlyPath($path, $protocol);
$flyPath = $this->resolveFlyPath($path);
try {
$filesystem->createDirectory($flyPath);
@ -227,9 +293,9 @@ class FlysystemStreamWrapper
public function rmdir(string $path, int $options): bool
{
$protocol = $this->extractProtocol($path);
$protocol = $this->extractProtocol($path);
$filesystem = $this->filesystem($protocol);
$flyPath = $this->resolveFlyPath($path, $protocol);
$flyPath = $this->resolveFlyPath($path);
try {
$filesystem->deleteDirectory($flyPath);
@ -240,13 +306,35 @@ class FlysystemStreamWrapper
}
}
public function rename(string $oldPath, string $newPath): bool
{
$oldProtocol = $this->extractProtocol($oldPath);
$newProtocol = $this->extractProtocol($newPath);
if ($oldProtocol !== $newProtocol) {
return false;
}
$filesystem = $this->filesystem($oldProtocol);
$from = $this->resolveFlyPath($oldPath);
$to = $this->resolveFlyPath($newPath);
try {
$filesystem->move($from, $to);
return true;
} catch (FilesystemException $e) {
return false;
}
}
public function dir_opendir(string $path, int $options): bool
{
$protocol = $this->extractProtocol($path);
$this->protocol = $protocol;
$this->flyPath = $this->resolveFlyPath($path, $protocol);
$this->flyPath = $this->resolveFlyPath($path);
$filesystem = $this->filesystem($protocol);
$this->dirEntries = [];
$this->dirEntries = ['.', '..'];
foreach ($filesystem->listContents($this->flyPath, false) as $item) {
if ($item instanceof StorageAttributes) {
@ -285,28 +373,6 @@ class FlysystemStreamWrapper
return true;
}
public function rename(string $oldPath, string $newPath): bool
{
$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(string $protocol): FilesystemOperator
{
if (! isset(self::$filesystems[$protocol])) {
@ -316,24 +382,12 @@ class FlysystemStreamWrapper
return self::$filesystems[$protocol];
}
private function resolveFlyPath(string $path, ?string $protocol = null): string
{
$protocol ??= $this->extractProtocol($path);
$prefix = self::$prefixes[$protocol] ?? '';
$raw = preg_replace('#^[^:]+://#', '', $path) ?? '';
$normalized = ltrim($raw, '/');
if ($prefix !== '' && str_starts_with($normalized, $prefix . '/')) {
return $normalized;
}
if ($prefix === '') {
return $normalized;
}
return $prefix . '/' . $normalized;
}
private function resolveFlyPath(string $path): string
{
$raw = preg_replace('#^[^:]+://#', '', $path) ?? '';
return ltrim($raw, '/');
}
private function extractProtocol(string $path): string
{