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 <?php
declare(strict_types=1);
namespace FlysystemOffload\Filesystem\Adapters; namespace FlysystemOffload\Filesystem\Adapters;
use Aws\S3\S3Client; use Aws\S3\S3Client;
use FlysystemOffload\Filesystem\AdapterInterface; use FlysystemOffload\Filesystem\AdapterInterface;
use League\Flysystem\AwsS3V3\AwsS3V3Adapter; use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
use League\Flysystem\FilesystemAdapter; use League\Flysystem\FilesystemAdapter;
use League\Flysystem\PathPrefixing\PathPrefixedAdapter;
use League\MimeTypeDetection\ExtensionMimeTypeDetector;
use League\MimeTypeDetection\MimeTypeDetector;
use WP_Error; use WP_Error;
class S3Adapter implements AdapterInterface class S3Adapter implements AdapterInterface
{ {
private MimeTypeDetector $mimeTypeDetector;
public function __construct(?MimeTypeDetector $mimeTypeDetector = null)
{
$this->mimeTypeDetector = $mimeTypeDetector ?? new ExtensionMimeTypeDetector();
}
public function validate(array $settings) public function validate(array $settings)
{ {
$required = ['access_key', 'secret_key', 'region', 'bucket']; $required = ['access_key', 'secret_key', 'region', 'bucket'];
foreach ($required as $field) { foreach ($required as $field) {
if (empty($settings[$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)
);
} }
} }
@ -28,20 +43,39 @@ class S3Adapter implements AdapterInterface
$clientConfig = [ $clientConfig = [
'credentials' => [ 'credentials' => [
'key' => $settings['access_key'], 'key' => $settings['access_key'],
'secret' => $settings['secret_key'] 'secret' => $settings['secret_key'],
], ],
'region' => $settings['region'], 'region' => $settings['region'],
'version' => 'latest' 'version' => 'latest',
]; ];
if (! empty($settings['endpoint'])) { if (! empty($settings['endpoint'])) {
$clientConfig['endpoint'] = $settings['endpoint']; $clientConfig['endpoint'] = rtrim($settings['endpoint'], '/');
$clientConfig['use_path_style_endpoint'] = true; $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); $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) { } catch (\Throwable $e) {
return new WP_Error('flysystem_offload_s3_error', $e->getMessage()); 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\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 League\Flysystem\Local\LocalFilesystemAdapter;

View File

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

View File

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

View File

@ -11,17 +11,11 @@ if (! defined('ABSPATH')) {
exit; 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')) { 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'; require_once ABSPATH . WPINC . '/class-wp-image-editor-imagick.php';
} }
if (! class_exists(\WP_Image_Editor_Imagick::class)) { 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; return;
} }
@ -137,7 +131,7 @@ class ImageEditorImagick extends \WP_Image_Editor_Imagick
} }
try { try {
self::$filesystem->writeStream($remotePath, $stream); self::$filesystem?->writeStream($remotePath, $stream);
} catch (\Throwable $e) { } catch (\Throwable $e) {
if (is_resource($stream)) { if (is_resource($stream)) {
fclose($stream); fclose($stream);
@ -178,7 +172,7 @@ class ImageEditorImagick extends \WP_Image_Editor_Imagick
} }
try { try {
$source = self::$filesystem->readStream($remotePath); $source = self::$filesystem?->readStream($remotePath);
if (! is_resource($source)) { if (! is_resource($source)) {
throw new \RuntimeException('No se pudo abrir el stream remoto.'); throw new \RuntimeException('No se pudo abrir el stream remoto.');
} }

View File

@ -3,354 +3,238 @@ declare(strict_types=1);
namespace FlysystemOffload\Media; namespace FlysystemOffload\Media;
use League\Flysystem\FilesystemException; use FlysystemOffload\Helpers\PathHelper;
use League\Flysystem\FilesystemOperator; use League\Flysystem\FilesystemOperator;
use League\Flysystem\StorageAttributes;
use WP_Error; 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 string $basePrefix;
private bool $metadataMirrorInProgress = false;
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 public function setFilesystem(?FilesystemOperator $filesystem): void
{ {
$this->filesystem = $filesystem; $this->filesystem = $filesystem;
if (class_exists(self::CUSTOM_IMAGE_EDITOR)) { if (class_exists(self::IMAGE_EDITOR_IMAGICK)) {
\call_user_func([self::CUSTOM_IMAGE_EDITOR, 'bootWithFilesystem'], $filesystem); \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) { $subdir = trim($uploadDir['subdir'] ?? '', '/');
return;
$remoteBase = $this->basePrefix !== '' ? $this->basePrefix . '/' : '';
$remoteBase .= $subdir !== '' ? $subdir : '';
$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;
} }
add_filter('wp_image_editors', [$this, 'filterImageEditors'], 5); public function filterAttachmentUrl(string $url, int $attachmentId): string
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);
$this->registered = true;
}
public function unregister(): void
{ {
if (! $this->registered) { $file = get_post_meta($attachmentId, '_wp_attached_file', true);
return;
if (empty($file)) {
return $url;
} }
remove_filter('wp_image_editors', [$this, 'filterImageEditors'], 5); $relative = PathHelper::trimLeadingSlash($file);
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']);
$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)) { $meta = wp_get_attachment_metadata($attachmentId);
return $editors; $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); return 'fly://' . PathHelper::trimLeadingSlash($relative);
if ($imagickIndex !== false) {
unset($editors[$imagickIndex]);
} }
array_unshift($editors, self::CUSTOM_IMAGE_EDITOR); public function filterUpdateAttachedFile(string $file, int $attachmentId): string
return array_values(array_unique($editors));
}
public function handlePreMoveUploadedFile($override, array $file, string $destination)
{ {
if ($override !== null) { if (str_starts_with($file, 'fly://')) {
return $override; 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 $file;
return $override;
} }
if (! $this->filesystem) { public function ensureLocalPathForMetadata($metadata, string $file)
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;
}
public function filterReadImageMetadata($metadata, string $file)
{ {
if ($this->metadataMirrorInProgress || ! $this->isFlyPath($file)) { if (! str_starts_with($file, 'fly://') || ! $this->filesystem) {
return $metadata; return $metadata;
} }
$this->metadataMirrorInProgress = true; // Fuerza a WP a usar una copia temporal local durante la lectura de EXIF/IPTC
$remotePath = PathHelper::stripProtocol($file);
$temp = $this->mirrorToLocal($file); $temp = $this->downloadToTemp($remotePath);
if (! is_wp_error($temp)) { if (! is_wp_error($temp)) {
$metadata = wp_read_image_metadata($temp); $metadata = wp_read_image_metadata($temp);
@unlink($temp); @unlink($temp);
} }
$this->metadataMirrorInProgress = false;
return $metadata; return $metadata;
} }
public function filterGenerateAttachmentMetadata(array $metadata, int $attachmentId): array public function filterImageEditors(array $editors): array
{ {
if (isset($metadata['filesize'])) { if (class_exists(self::IMAGE_EDITOR_IMAGICK)) {
return $metadata; $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 (class_exists(self::IMAGE_EDITOR_GD)) {
$editors = array_filter(
if ($file && file_exists($file)) { $editors,
$metadata['filesize'] = filesize($file); static fn (string $editor) => $editor !== \WP_Image_Editor_GD::class
);
array_unshift($editors, self::IMAGE_EDITOR_GD);
} }
return $metadata; return array_values(array_unique($editors));
}
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;
} }
public function handleDeleteAttachment(int $attachmentId): void public function handleDeleteAttachment(int $attachmentId): void
{ {
$file = get_attached_file($attachmentId); if (! $this->filesystem) {
if (! $file || ! $this->isFlyPath($file)) {
return; 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'])) { if (! empty($meta['sizes'])) {
foreach ($meta['sizes'] as $sizeInfo) { foreach ($meta['sizes'] as $size) {
if (empty($sizeInfo['file'])) { if (! empty($size['file'])) {
continue; $targets[] = ltrim(($directory ? $directory . '/' : '') . $size['file'], '/');
} }
wp_delete_file(str_replace(basename($file), $sizeInfo['file'], $file));
} }
} }
$original = get_post_meta($attachmentId, 'original_image', true); foreach ($targets as $target) {
if ($original) { try {
wp_delete_file(str_replace(basename($file), $original, $file)); if ($this->filesystem->fileExists($target)) {
$this->filesystem->delete($target);
} }
} catch (\Throwable $e) {
$backup = get_post_meta($attachmentId, '_wp_attachment_backup_sizes', true); error_log('[Flysystem Offload] Error eliminando ' . $target . ': ' . $e->getMessage());
if (is_array($backup)) {
foreach ($backup as $sizeInfo) {
if (empty($sizeInfo['file'])) {
continue;
} }
wp_delete_file(str_replace(basename($file), $sizeInfo['file'], $file));
} }
} }
wp_delete_file($file); private function downloadToTemp(string $remotePath)
}
protected function mirrorToLocal(string $remotePath)
{ {
if (! $this->filesystem || ! $this->isFlyPath($remotePath)) { if (! $this->filesystem) {
return $this->mirrorViaNativeCopy($remotePath); return new WP_Error(
'flysystem_offload_no_fs',
__('No hay filesystem remoto configurado.', 'flysystem-offload')
);
} }
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';
} }
$temp = wp_tempnam(wp_basename($remotePath)); $temp = wp_tempnam(basename($remotePath));
if (! $temp) { if (! $temp) {
return new WP_Error( return new WP_Error(
'flysystem_offload_temp_fail', 'flysystem_offload_temp_fail',
__('No se pudo crear un archivo temporal para la lectura de metadatos.', 'flysystem-offload') __('No se pudo crear un archivo temporal.', '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')
); );
} }
try { try {
$source = $this->filesystem->readStream($relative); $stream = $this->filesystem->readStream($remotePath);
if (! is_resource($stream)) {
if (! is_resource($source)) {
throw new \RuntimeException('No se pudo abrir el stream remoto.'); throw new \RuntimeException('No se pudo abrir el stream remoto.');
} }
$target = fopen($temp, 'wb'); $target = fopen($temp, 'wb');
if (! $target) { 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) { } catch (\Throwable $e) {
if (isset($source) && is_resource($source)) { if (isset($stream) && is_resource($stream)) {
fclose($source); fclose($stream);
} }
if (isset($target) && is_resource($target)) { if (isset($target) && is_resource($target)) {
fclose($target); fclose($target);
} }
@unlink($temp); @unlink($temp);
return new WP_Error( return new WP_Error(
'flysystem_offload_remote_copy_fail', 'flysystem_offload_download_fail',
sprintf( 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() $e->getMessage()
) )
); );
} }
if (isset($source) && is_resource($source)) {
fclose($source);
}
if (isset($target) && is_resource($target)) {
fclose($target);
}
return $temp; return $temp;
} }
private function mirrorViaNativeCopy(string $remotePath) private function getBaseUrl(): string
{ {
if (! function_exists('wp_tempnam')) { $uploadDir = wp_get_upload_dir();
require_once ABSPATH . 'wp-admin/includes/file.php';
}
$temp = wp_tempnam(wp_basename($remotePath)); return $uploadDir['baseurl'] ?? content_url('/uploads');
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), '/');
} }
} }

View File

@ -12,18 +12,15 @@ use League\Flysystem\UnableToWriteFile;
class FlysystemStreamWrapper class FlysystemStreamWrapper
{ {
/** /** @var array<string, FilesystemOperator> */
* @var array<string, FilesystemOperator>
*/
private static array $filesystems = []; private static array $filesystems = [];
/** /** @var array<string, string> */
* @var array<string, string>
*/
private static array $prefixes = []; private static array $prefixes = [];
/** @var resource|null */ /** @var resource|null */
private $stream = null; private $stream = null;
private string $protocol = ''; private string $protocol = '';
private string $path = ''; private string $path = '';
private string $mode = ''; private string $mode = '';
@ -31,10 +28,10 @@ class FlysystemStreamWrapper
private array $dirEntries = []; private array $dirEntries = [];
private int $dirPosition = 0; private int $dirPosition = 0;
/** @var resource|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 = ''): 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);
@ -52,19 +49,21 @@ class FlysystemStreamWrapper
$this->protocol = $protocol; $this->protocol = $protocol;
$this->path = $path; $this->path = $path;
$this->mode = $mode; $this->mode = $mode;
$this->flyPath = $this->resolveFlyPath($path, $protocol); $this->flyPath = $this->resolveFlyPath($path);
$filesystem = $this->filesystem($protocol); $filesystem = $this->filesystem($protocol);
$binary = str_contains($mode, 'b') ? 'b' : '';
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', 'w+' . $binary);
if (str_contains($mode, 'a') && $filesystem->fileExists($this->flyPath)) { if (str_contains($mode, 'a') && $filesystem->fileExists($this->flyPath)) {
try { try {
$contents = $filesystem->readStream($this->flyPath); $remote = $filesystem->readStream($this->flyPath);
if (is_resource($contents)) { if (is_resource($remote)) {
stream_copy_to_stream($contents, $this->stream); stream_copy_to_stream($remote, $this->stream);
fclose($contents); fclose($remote);
} }
} catch (UnableToReadFile $e) { } catch (UnableToReadFile $e) {
return false; return false;
@ -77,12 +76,18 @@ class FlysystemStreamWrapper
} }
try { try {
$resource = $filesystem->readStream($this->flyPath); $remote = $filesystem->readStream($this->flyPath);
if (! is_resource($resource)) { if (! is_resource($remote)) {
return false; 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; return true;
} catch (UnableToReadFile $e) { } catch (UnableToReadFile $e) {
@ -109,9 +114,16 @@ class FlysystemStreamWrapper
$filesystem = $this->filesystem($this->protocol); $filesystem = $this->filesystem($this->protocol);
try { try {
$meta = stream_get_meta_data($this->stream);
if ($meta['seekable'] ?? false) {
rewind($this->stream); rewind($this->stream);
}
$filesystem->writeStream($this->flyPath, $this->stream); $filesystem->writeStream($this->flyPath, $this->stream);
if ($meta['seekable'] ?? false) {
rewind($this->stream); rewind($this->stream);
}
return true; return true;
} catch (UnableToWriteFile $e) { } catch (UnableToWriteFile $e) {
@ -132,6 +144,58 @@ class FlysystemStreamWrapper
$this->stream = null; $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 public function stream_stat(): array|false
{ {
return $this->url_stat($this->path, 0); return $this->url_stat($this->path, 0);
@ -141,10 +205,12 @@ class FlysystemStreamWrapper
{ {
$protocol = $this->extractProtocol($path); $protocol = $this->extractProtocol($path);
$filesystem = $this->filesystem($protocol); $filesystem = $this->filesystem($protocol);
$flyPath = $this->resolveFlyPath($path, $protocol); $flyPath = $this->resolveFlyPath($path);
try { try {
if (! $filesystem->fileExists($flyPath) && ! $filesystem->directoryExists($flyPath)) { $exists = $filesystem->fileExists($flyPath) || $filesystem->directoryExists($flyPath);
if (! $exists) {
if ($flags & STREAM_URL_STAT_QUIET) { if ($flags & STREAM_URL_STAT_QUIET) {
return false; return false;
} }
@ -199,7 +265,7 @@ class FlysystemStreamWrapper
{ {
$protocol = $this->extractProtocol($path); $protocol = $this->extractProtocol($path);
$filesystem = $this->filesystem($protocol); $filesystem = $this->filesystem($protocol);
$flyPath = $this->resolveFlyPath($path, $protocol); $flyPath = $this->resolveFlyPath($path);
try { try {
$filesystem->delete($flyPath); $filesystem->delete($flyPath);
@ -214,7 +280,7 @@ class FlysystemStreamWrapper
{ {
$protocol = $this->extractProtocol($path); $protocol = $this->extractProtocol($path);
$filesystem = $this->filesystem($protocol); $filesystem = $this->filesystem($protocol);
$flyPath = $this->resolveFlyPath($path, $protocol); $flyPath = $this->resolveFlyPath($path);
try { try {
$filesystem->createDirectory($flyPath); $filesystem->createDirectory($flyPath);
@ -229,7 +295,7 @@ class FlysystemStreamWrapper
{ {
$protocol = $this->extractProtocol($path); $protocol = $this->extractProtocol($path);
$filesystem = $this->filesystem($protocol); $filesystem = $this->filesystem($protocol);
$flyPath = $this->resolveFlyPath($path, $protocol); $flyPath = $this->resolveFlyPath($path);
try { try {
$filesystem->deleteDirectory($flyPath); $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 public function dir_opendir(string $path, int $options): bool
{ {
$protocol = $this->extractProtocol($path); $protocol = $this->extractProtocol($path);
$this->protocol = $protocol; $this->protocol = $protocol;
$this->flyPath = $this->resolveFlyPath($path, $protocol); $this->flyPath = $this->resolveFlyPath($path);
$filesystem = $this->filesystem($protocol); $filesystem = $this->filesystem($protocol);
$this->dirEntries = []; $this->dirEntries = ['.', '..'];
foreach ($filesystem->listContents($this->flyPath, false) as $item) { foreach ($filesystem->listContents($this->flyPath, false) as $item) {
if ($item instanceof StorageAttributes) { if ($item instanceof StorageAttributes) {
@ -285,28 +373,6 @@ class FlysystemStreamWrapper
return true; 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 private function filesystem(string $protocol): FilesystemOperator
{ {
if (! isset(self::$filesystems[$protocol])) { if (! isset(self::$filesystems[$protocol])) {
@ -316,23 +382,11 @@ class FlysystemStreamWrapper
return self::$filesystems[$protocol]; return self::$filesystems[$protocol];
} }
private function resolveFlyPath(string $path, ?string $protocol = null): string private function resolveFlyPath(string $path): string
{ {
$protocol ??= $this->extractProtocol($path);
$prefix = self::$prefixes[$protocol] ?? '';
$raw = preg_replace('#^[^:]+://#', '', $path) ?? ''; $raw = preg_replace('#^[^:]+://#', '', $path) ?? '';
$normalized = ltrim($raw, '/'); return ltrim($raw, '/');
if ($prefix !== '' && str_starts_with($normalized, $prefix . '/')) {
return $normalized;
}
if ($prefix === '') {
return $normalized;
}
return $prefix . '/' . $normalized;
} }
private function extractProtocol(string $path): string private function extractProtocol(string $path): string