From f8c7736735fd7ae381c343f8ed4fdda35d862fcf Mon Sep 17 00:00:00 2001 From: Brasdrive Date: Mon, 10 Nov 2025 01:58:54 -0400 Subject: [PATCH] 3.0.0 --- config/flysystem-offload.php | 2 +- src/Config/ConfigLoader.php | 38 +- src/Filesystem/Adapters/WebdavAdapter.php | 45 +- src/Media/MediaHooks.php | 384 +++++++-------- src/Plugin.php | 6 +- src/StreamWrapper/FlysystemStreamWrapper.php | 480 ++++++++++++++----- 6 files changed, 608 insertions(+), 347 deletions(-) diff --git a/config/flysystem-offload.php b/config/flysystem-offload.php index 5dae355..07dfea2 100644 --- a/config/flysystem-offload.php +++ b/config/flysystem-offload.php @@ -7,7 +7,7 @@ return [ 'visibility' => getenv('FLYSYSTEM_OFFLOAD_DEFAULT_VISIBILITY') ?: 'public', 'stream' => [ - 'protocol' => getenv('FLYSYSTEM_OFFLOAD_STREAM_PROTOCOL') ?: 'flysystem', + 'protocol' => getenv('FLYSYSTEM_OFFLOAD_STREAM_PROTOCOL') ?: 'webdav', 'root_prefix' => getenv('FLYSYSTEM_OFFLOAD_STREAM_ROOT_PREFIX') ?: '', 'host' => getenv('FLYSYSTEM_OFFLOAD_STREAM_HOST') ?: '', ], diff --git a/src/Config/ConfigLoader.php b/src/Config/ConfigLoader.php index a25b477..1cb2c06 100644 --- a/src/Config/ConfigLoader.php +++ b/src/Config/ConfigLoader.php @@ -456,16 +456,28 @@ class ConfigLoader $config['prefix'] = trim($config['prefix'], '/'); } - // ✅ Asegurar valores por defecto para 'stream' y 'uploads' - if (!isset($config['stream'])) { - $config['stream'] = [ - 'protocol' => 'flysystem', - 'root_prefix' => '', - 'host' => 'uploads', - ]; + // Asegurar 'stream' existe (no sobrescribir si viene del archivo o BD) + if (!isset($config['stream']) || !is_array($config['stream'])) { + $config['stream'] = []; } - if (!isset($config['uploads'])) { + // Si no hay protocolo explícito en 'stream', usar el provider como protocolo por defecto + $config['stream']['protocol'] = $config['stream']['protocol'] ?? + ($config['provider'] ?? 'flysystem'); + + // Si existe una sección específica del provider que define un protocolo (ej. webdav.stream.protocol), respetarla + $provider = $config['provider'] ?? null; + if ($provider && isset($config[$provider]) && is_array($config[$provider])) { + if (isset($config[$provider]['stream']['protocol'])) { + $config['stream']['protocol'] = $config[$provider]['stream']['protocol']; + } + } + + // Asegurar valores por defecto para 'stream' y 'uploads' si no vinieron en la configuración + $config['stream']['root_prefix'] = $config['stream']['root_prefix'] ?? ''; + $config['stream']['host'] = $config['stream']['host'] ?? ''; + + if (!isset($config['uploads']) || !is_array($config['uploads'])) { $config['uploads'] = [ 'base_url' => content_url('uploads'), 'delete_remote' => true, @@ -473,6 +485,16 @@ class ConfigLoader ]; } + // Si el provider es webdav y no tenemos uploads.base_url, intentar construirla desde webdav/base_uri + prefix + if (($provider === 'webdav') && (!isset($config['uploads']['base_url']) || empty($config['uploads']['base_url']))) { + $baseUri = $config['base_uri'] ?? ($config['webdav']['base_url'] ?? ($config['webdav']['endpoint'] ?? '')); + $prefix = $config['prefix'] ?? ($config['webdav']['prefix'] ?? ''); + if (!empty($baseUri)) { + $baseUri = rtrim($baseUri, '/'); + $config['uploads']['base_url'] = $prefix !== '' ? $baseUri . '/' . ltrim($prefix, '/') : $baseUri; + } + } + error_log('[Flysystem Offload] Final normalized config: ' . print_r($config, true)); return $config; diff --git a/src/Filesystem/Adapters/WebdavAdapter.php b/src/Filesystem/Adapters/WebdavAdapter.php index 60ccc81..ea258a5 100644 --- a/src/Filesystem/Adapters/WebdavAdapter.php +++ b/src/Filesystem/Adapters/WebdavAdapter.php @@ -52,21 +52,24 @@ class WebdavAdapter implements FilesystemAdapter error_log('[WebdavAdapter] Ensuring base directory exists...'); - $parts = array_filter(explode('/', $this->prefix)); + // Dividir el prefix en partes (sin slashes iniciales/finales) + $parts = array_filter(explode('/', trim($this->prefix, '/'))); $path = ''; foreach ($parts as $part) { - $path .= '/' . $part; + // Construir path RELATIVO (sin slash inicial) + $path .= ($path === '' ? '' : '/') . $part; try { - // Intentar verificar si existe + // Intentar verificar si existe (sin slash inicial = relativo al base_uri) $this->client->propFind($path, ['{DAV:}resourcetype'], 0); - error_log(sprintf('[WebdavAdapter] Base directory exists: "%s"', $path)); + error_log(sprintf('[WebdavAdapter] Directory exists: "%s"', $path)); } catch (\Exception $e) { // No existe, crear - error_log(sprintf('[WebdavAdapter] Base directory does not exist, creating: "%s"', $path)); + error_log(sprintf('[WebdavAdapter] Directory does not exist, creating: "%s"', $path)); try { + // IMPORTANTE: Sin slash inicial = relativo al base_uri $response = $this->client->request('MKCOL', $path); // Verificar el código de estado @@ -74,10 +77,10 @@ class WebdavAdapter implements FilesystemAdapter if ($statusCode >= 200 && $statusCode < 300) { // Éxito (201 Created) - error_log(sprintf('[WebdavAdapter] Created base directory: "%s", status: %d', $path, $statusCode)); + error_log(sprintf('[WebdavAdapter] Created directory: "%s", status: %d', $path, $statusCode)); } elseif ($statusCode === 405) { // 405 Method Not Allowed = ya existe - error_log(sprintf('[WebdavAdapter] Base directory already exists: "%s"', $path)); + error_log(sprintf('[WebdavAdapter] Directory already exists: "%s"', $path)); } else { // Error $errorMsg = sprintf('Failed to create directory "%s", status: %d', $path, $statusCode); @@ -87,10 +90,10 @@ class WebdavAdapter implements FilesystemAdapter } catch (\Exception $e2) { // Verificar si el error es porque ya existe (405) if (strpos($e2->getMessage(), '405') !== false) { - error_log(sprintf('[WebdavAdapter] Base directory already exists (405): "%s"', $path)); + error_log(sprintf('[WebdavAdapter] Directory already exists (405): "%s"', $path)); } else { error_log(sprintf( - '[WebdavAdapter] Failed to create base directory: "%s", error: %s', + '[WebdavAdapter] Failed to create directory: "%s", error: %s', $path, $e2->getMessage() )); @@ -104,21 +107,25 @@ class WebdavAdapter implements FilesystemAdapter error_log('[WebdavAdapter] Base directory ensured successfully'); } + /** + * Agregar el prefix a la ruta + * + * @param string $path + * @return string Ruta con prefix (RELATIVA, sin slash inicial) + */ private function prefixPath(string $path): string { + $path = trim($path, '/'); + if ($this->prefix === '') { - $prefixed = '/' . ltrim($path, '/'); + $result = $path; } else { - $prefixed = '/' . $this->prefix . '/' . ltrim($path, '/'); + $result = trim($this->prefix, '/') . ($path === '' ? '' : '/' . $path); } - - error_log(sprintf( - '[WebdavAdapter] prefixPath - input: "%s", output: "%s"', - $path, - $prefixed - )); - - return $prefixed; + + error_log(sprintf('[WebdavAdapter] prefixPath - input: "%s", output: "%s"', $path, $result)); + + return $result; } public function fileExists(string $path): bool diff --git a/src/Media/MediaHooks.php b/src/Media/MediaHooks.php index 22728d5..98215ee 100644 --- a/src/Media/MediaHooks.php +++ b/src/Media/MediaHooks.php @@ -1,247 +1,247 @@ filesystem = $filesystem; - $this->config = $config; - $this->protocol = (string) ($config['stream']['protocol'] ?? 'flysystem'); - - $this->streamRootPrefix = PathHelper::normalize((string) ($config['stream']['root_prefix'] ?? '')); - $this->streamHost = PathHelper::normalize((string) ($config['stream']['host'] ?? '')); + public function __construct(array $config) + { + $this->provider = $config['provider'] ?? 'webdav'; + $this->protocol = $config['stream']['protocol'] ?? $this->provider; + $this->host = $config['stream']['host'] ?? ''; + $this->rootPrefix = $config['stream']['root_prefix'] ?? ''; + $this->providerPrefix = $config['prefix'] ?? ''; + $this->baseUrl = $config['uploads']['base_url'] ?? ''; - // ✅ Obtener el prefix del provider actual - $provider = $config['provider'] ?? 's3'; - $this->providerPrefix = PathHelper::normalize((string) ($config[$provider]['prefix'] ?? $config['prefix'] ?? '')); - - $this->baseUrl = $this->normaliseBaseUrl((string) ($config['uploads']['base_url'] ?? content_url('uploads'))); - $this->effectiveBaseUrl = $this->baseUrl; - - $this->deleteRemote = (bool) ($config['uploads']['delete_remote'] ?? true); - $this->preferLocal = (bool) ($config['uploads']['prefer_local_for_missing'] ?? false); - error_log(sprintf( '[MediaHooks] Initialized - provider: %s, protocol: %s, host: %s, root_prefix: %s, provider_prefix: %s, base_url: %s', - $provider, + $this->provider, $this->protocol, - $this->streamHost, - $this->streamRootPrefix, + $this->host, + $this->rootPrefix, $this->providerPrefix, $this->baseUrl )); } - public function register(): void { - add_filter('upload_dir', [$this, 'filterUploadDir'], 20); - add_filter('pre_option_upload_path', '__return_false'); - add_filter('pre_option_upload_url_path', '__return_false'); - add_filter('wp_get_attachment_url', [$this, 'rewriteAttachmentUrl'], 9, 2); - add_filter('image_downsize', [$this, 'filterImageDownsize'], 10, 3); - add_action('delete_attachment', [$this, 'deleteRemoteFiles']); + /** + * Registra los hooks de WordPress + */ + public function registerHooks(): void + { + // Filtros para upload dir + add_filter('upload_dir', [$this, 'filterUploadDir']); + + // Filtros para URLs de medios + add_filter('wp_get_attachment_url', [$this, 'filterAttachmentUrl'], 10, 2); + add_filter('wp_calculate_image_srcset', [$this, 'filterImageSrcset'], 10, 5); + + // Acciones para manejo de archivos + add_action('wp_generate_attachment_metadata', [$this, 'handleAttachmentMetadata'], 10, 2); + add_action('delete_attachment', [$this, 'handleDeleteAttachment']); + + // Filtro para obtener el path correcto + add_filter('get_attached_file', [$this, 'filterAttachedFile'], 10, 2); } - public function filterUploadDir(array $uploads): array { - $subdir = $uploads['subdir'] ?? ''; - $normalizedSubdir = $subdir !== '' ? PathHelper::normalize($subdir) : ''; - - $streamSubdir = $normalizedSubdir !== '' ? '/' . $normalizedSubdir : ''; - - // 🚫 No forzar 'default' si no hay streamHost definido - $streamBase = $this->protocol . '://'; - - if ($this->streamHost !== '') { - $streamBase .= $this->streamHost; + /** + * Filtra el directorio de uploads para usar nuestro sistema de archivos + */ + public function filterUploadDir(array $uploads): array + { + // Construir el path con el protocolo correcto + $path = $this->protocol . '://'; + if ($this->host) { + $path .= $this->host . '/'; } - - $uploads['path'] = $streamBase . $streamSubdir; - $uploads['basedir'] = $streamBase; - $uploads['baseurl'] = rtrim($this->effectiveBaseUrl, '/'); - $uploads['url'] = $this->buildPublicUrl($normalizedSubdir); - $uploads['subdir'] = $normalizedSubdir !== '' ? '/' . $normalizedSubdir : ''; - $uploads['error'] = false; - - $uploads['flysystem_protocol'] = $this->protocol; - $uploads['flysystem_host'] = $this->streamHost; - $uploads['flysystem_root_prefix'] = $this->streamRootPrefix; - $uploads['flysystem_provider_prefix'] = $this->providerPrefix; - - error_log(sprintf( - '[MediaHooks] Upload dir filtered - path: %s, url: %s, subdir: %s', - $uploads['path'], - $uploads['url'], - $uploads['subdir'] - )); - + if ($this->rootPrefix) { + $path .= ltrim($this->rootPrefix, '/') . '/'; + } + + // Actualizar uploads array + $uploads['path'] = $path; + $uploads['url'] = rtrim($this->baseUrl, '/') . '/'; + $uploads['subdir'] = ''; + $uploads['basedir'] = $path; + + error_log('[MediaHooks] Upload dir filtered - path: ' . $path . ', url: ' . $uploads['url'] . ', subdir: ' . $uploads['subdir']); + return $uploads; } - public function rewriteAttachmentUrl(string $url, int $attachmentId): string { + /** + * Filtra la URL de un attachment para usar la URL remota + */ + public function filterAttachmentUrl(string $url, int $attachmentId): string + { + // Obtener metadata del attachment + $metadata = wp_get_attachment_metadata($attachmentId); + if (!$metadata) { + return $url; + } + + // Obtener el path del archivo $file = get_post_meta($attachmentId, '_wp_attached_file', true); - if (! $file) { + if (!$file) { return $url; } + + // Construir URL remota + $remoteUrl = rtrim($this->baseUrl, '/') . '/' . ltrim($file, '/'); + error_log('[MediaHooks] Attachment URL filtered - local: ' . $url . ', remote: ' . $remoteUrl); + + return $remoteUrl; + } - $relativePath = PathHelper::normalize($file); - if ($relativePath === '') { - return $url; + /** + * Filtra el srcset de imágenes para usar URLs remotas + */ + public function filterImageSrcset(array $sources, array $sizeArray, string $imageSrc, array $imageMeta, int $attachmentId): array + { + if (empty($sources)) { + return $sources; } - - $remoteUrl = $this->buildPublicUrl($relativePath); - - if (! $this->preferLocal) { - return $remoteUrl; + + // Obtener el path base del archivo + $file = get_post_meta($attachmentId, '_wp_attached_file', true); + if (!$file) { + return $sources; } - - try { - if ($this->filesystem->fileExists($this->toRemotePath($relativePath))) { - return $remoteUrl; + + // Calcular el directorio base + $basePath = dirname($file); + if ($basePath === '.') { + $basePath = ''; + } + + // Actualizar cada source con URL remota + foreach ($sources as &$source) { + if (isset($source['url'])) { + $filename = basename($source['url']); + if ($basePath) { + $remotePath = $basePath . '/' . $filename; + } else { + $remotePath = $filename; + } + $source['url'] = rtrim($this->baseUrl, '/') . '/' . ltrim($remotePath, '/'); } - } catch (\Throwable $exception) { - error_log(sprintf( - '[Flysystem Offload] No se pudo verificar la existencia remota de "%s": %s', - $relativePath, - $exception->getMessage() - )); } - - return $url; + + return $sources; } - public function filterImageDownsize(bool|array $out, int $attachmentId, array|string $size): bool|array { - return false; + /** + * Maneja la generación de metadata de attachments + */ + public function handleAttachmentMetadata(array $metadata, int $attachmentId): array + { + error_log('[MediaHooks] Handling attachment metadata for ID: ' . $attachmentId); + + // Verificar si se generaron tamaños + if (isset($metadata['sizes']) && is_array($metadata['sizes'])) { + error_log('[MediaHooks] Generated sizes: ' . print_r(array_keys($metadata['sizes']), true)); + + // Para cada tamaño generado, asegurarse de que se suba al sistema remoto + foreach ($metadata['sizes'] as $sizeName => $sizeData) { + if (isset($sizeData['file'])) { + $this->ensureSizeUploaded($attachmentId, $sizeData['file']); + } + } + } + + return $metadata; } - public function deleteRemoteFiles(int $attachmentId): void { - if (! $this->deleteRemote) { + /** + * Asegura que un tamaño específico se haya subido + */ + private function ensureSizeUploaded(int $attachmentId, string $filename): void + { + // Obtener el directorio de uploads local + $uploadDir = wp_upload_dir(); + $localPath = $uploadDir['basedir'] . '/' . $filename; + + // Verificar si el archivo local existe + if (!file_exists($localPath)) { + error_log('[MediaHooks] Local size file not found: ' . $localPath); return; } + + // Construir el path remoto + $remotePath = $this->protocol . ':///' . $filename; + + // Copiar el archivo al sistema remoto + if (copy($localPath, $remotePath)) { + error_log('[MediaHooks] Size uploaded successfully: ' . $filename); + } else { + error_log('[MediaHooks] Failed to upload size: ' . $filename); + } + } - $files = $this->gatherAttachmentFiles($attachmentId); - - foreach ($files as $file) { - $key = $this->toRemotePath($file); - - try { - if ($this->filesystem->fileExists($key)) { - $this->filesystem->delete($key); + /** + * Maneja la eliminación de attachments + */ + public function handleDeleteAttachment(int $attachmentId): void + { + error_log('[MediaHooks] Handling delete attachment: ' . $attachmentId); + + // Obtener el archivo principal + $file = get_post_meta($attachmentId, '_wp_attached_file', true); + if ($file) { + $this->deleteRemoteFile($file); + } + + // Obtener metadata para eliminar tamaños + $metadata = wp_get_attachment_metadata($attachmentId); + if ($metadata && isset($metadata['sizes'])) { + foreach ($metadata['sizes'] as $sizeData) { + if (isset($sizeData['file'])) { + $this->deleteRemoteFile($sizeData['file']); } - } catch (\Throwable $exception) { - error_log(sprintf( - '[Flysystem Offload] No se pudo eliminar el archivo remoto "%s": %s', - $key, - $exception->getMessage() - )); } } } /** - * @return list + * Elimina un archivo remoto */ - private function gatherAttachmentFiles(int $attachmentId): array { - $files = []; - - $attachedFile = get_post_meta($attachmentId, '_wp_attached_file', true); - if ($attachedFile) { - $files[] = $attachedFile; - } - - $meta = wp_get_attachment_metadata($attachmentId); - - if (is_array($meta)) { - if (! empty($meta['file'])) { - $files[] = $meta['file']; - } - - if (! empty($meta['sizes']) && is_array($meta['sizes'])) { - $baseDir = $this->dirName($meta['file'] ?? ''); - foreach ($meta['sizes'] as $sizeMeta) { - if (! empty($sizeMeta['file'])) { - $files[] = ($baseDir !== '' ? $baseDir . '/' : '') . $sizeMeta['file']; - } - } + private function deleteRemoteFile(string $filename): void + { + $remotePath = $this->protocol . ':///' . ltrim($filename, '/'); + + if (file_exists($remotePath)) { + if (unlink($remotePath)) { + error_log('[MediaHooks] Remote file deleted: ' . $filename); + } else { + error_log('[MediaHooks] Failed to delete remote file: ' . $filename); } + } else { + error_log('[MediaHooks] Remote file not found for deletion: ' . $filename); } - - $files = array_filter($files, static fn ($file) => is_string($file) && $file !== ''); - - return array_values(array_unique($files, SORT_STRING)); - } - - private function dirName(string $path): string { - $directory = dirname($path); - return $directory === '.' ? '' : $directory; } /** - * Convierte una ruta relativa de WordPress a la ruta remota en el filesystem - * - * Para WebDAV con prefix, solo usa el archivo relativo - * Para S3 u otros, puede incluir streamRootPrefix y streamHost + * Filtra el path del archivo adjunto */ - private function toRemotePath(string $file): string { - $segments = []; - - // ✅ Si hay providerPrefix (WebDAV, S3, etc.), NO agregar streamRootPrefix ni streamHost - // El prefix del provider ya incluye la ruta base completa - if ($this->providerPrefix === '') { - // Solo para providers sin prefix (local, etc.) - if ($this->streamRootPrefix !== '') { - $segments[] = $this->streamRootPrefix; - } - - if ($this->streamHost !== '') { - $segments[] = $this->streamHost; - } - } - - // Agregar el archivo relativo - $segments[] = $file; - - $remotePath = PathHelper::join(...$segments); - - error_log(sprintf( - '[MediaHooks] toRemotePath - file: %s, provider_prefix: %s, remote: %s', - $file, - $this->providerPrefix, - $remotePath - )); - - return $remotePath; - } - - private function normaliseBaseUrl(string $baseUrl): string { - $baseUrl = trim($baseUrl); - if ($baseUrl === '') { - $baseUrl = content_url('uploads'); - } - - return rtrim($baseUrl, '/'); - } - - private function buildPublicUrl(string $relativePath): string { - $base = rtrim($this->effectiveBaseUrl, '/'); - - if ($relativePath === '') { - return $base; - } - - return $base . '/' . PathHelper::normalize($relativePath); + public function filterAttachedFile(string $file, int $attachmentId): string + { + // Devolver el path con el protocolo correcto + $filename = basename($file); + return $this->protocol . ':///' . $filename; } } diff --git a/src/Plugin.php b/src/Plugin.php index 668a58b..eaffa92 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -57,9 +57,9 @@ class Plugin error_log('[Flysystem Offload] Stream wrappers: ' . implode(', ', stream_get_wrappers())); - // Registrar hooks de medios - self::$mediaHooks = new MediaHooks(self::$filesystem, self::$config); - self::$mediaHooks->register(); + // Registrar hooks de medios - pasar primero $config, luego $filesystem + self::$mediaHooks = new MediaHooks(self::$config, self::$filesystem); + self::$mediaHooks->registerHooks(); // <-- CORRECCIÓN: llamar al método que existe // Registrar página de ajustes if (is_admin()) { diff --git a/src/StreamWrapper/FlysystemStreamWrapper.php b/src/StreamWrapper/FlysystemStreamWrapper.php index b774f86..c3d8bfe 100644 --- a/src/StreamWrapper/FlysystemStreamWrapper.php +++ b/src/StreamWrapper/FlysystemStreamWrapper.php @@ -9,12 +9,12 @@ use League\Flysystem\UnableToWriteFile; /** * Stream wrapper para Flysystem - * Soporta protocolo configurable (por defecto 'flysystem') + * Soporta protocolo configurable (por defecto 'fly') */ class FlysystemStreamWrapper { private static ?FilesystemOperator $filesystem = null; - private static string $protocol = 'flysystem'; + private static string $protocol = 'fly'; /** @var resource|null */ private $stream; @@ -22,12 +22,6 @@ class FlysystemStreamWrapper /** @var string Ruta remota normalizada (sin protocolo ni host) */ private string $path = ''; - /** @var string Buffer en memoria para modo write */ - private string $buffer = ''; - - /** @var int Posición del puntero */ - private int $position = 0; - /** @var string Modo de apertura */ private string $mode = ''; @@ -37,7 +31,7 @@ class FlysystemStreamWrapper /** * Registra el stream wrapper */ - public static function register(FilesystemOperator $filesystem, string $protocol = 'flysystem'): void + public static function register(FilesystemOperator $filesystem, string $protocol = 'fly'): void { self::$filesystem = $filesystem; self::$protocol = $protocol; @@ -57,24 +51,33 @@ class FlysystemStreamWrapper /** * Normaliza ruta removiendo protocolo y host - * Ejemplo: flysystem://uploads/2025/11/file.jpg -> 2025/11/file.jpg + * Ejemplo: fly://uploads/2025/11/file.jpg -> uploads/2025/11/file.jpg */ private static function normalizePath(string $path): string { // Remover protocolo $p = preg_replace('#^' . preg_quote(self::$protocol, '#') . '://#', '', $path) ?? ''; - // Remover host si existe (primer segmento después de //) - // Ejemplo: flysystem://uploads/2025/11/file.jpg -> 2025/11/file.jpg - $parts = explode('/', ltrim($p, '/'), 2); - if (count($parts) === 2) { - // Si hay host, devolver solo la parte después del host - return $parts[1]; + // Remover cualquier host si existe (primer segmento después de //) + if (strpos($p, '/') !== false) { + $parts = explode('/', $p, 2); + if (count($parts) === 2) { + return $parts[1]; + } } - return ltrim($p, '/'); + return $p; } + /** + * Opens file or URL + * + * @param string $path + * @param string $mode + * @param int $options + * @param string|null $opened_path + * @return bool + */ public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool { if (!self::$filesystem) { @@ -84,137 +87,325 @@ class FlysystemStreamWrapper $this->path = self::normalizePath($path); $this->mode = $mode; - $this->buffer = ''; - $this->position = 0; error_log(sprintf('[FlysystemStreamWrapper] stream_open path="%s" normalized="%s" mode="%s"', $path, $this->path, $mode)); - // Para lectura, intentar cargar contenido existente - if (str_contains($mode, 'r') || str_contains($mode, '+')) { - try { - $this->buffer = self::$filesystem->read($this->path); - error_log('[FlysystemStreamWrapper] Loaded existing file (' . strlen($this->buffer) . ' bytes)'); - } catch (\Throwable $e) { - // Si no existe y es modo 'r' puro, fallar - if ($mode === 'r') { - error_log('[FlysystemStreamWrapper] stream_open read error: ' . $e->getMessage()); + try { + // Modo lectura + if (strpos($mode, 'r') !== false) { + // Verificar si el archivo existe + if (!self::$filesystem->fileExists($this->path)) { + error_log(sprintf('[FlysystemStreamWrapper] stream_open error: file does not exist "%s"', $this->path)); return false; } - // Para otros modos (w, a, etc.) continuar con buffer vacío + + // Leer el contenido del archivo desde el filesystem + $contents = self::$filesystem->read($this->path); + + // Crear un stream temporal en memoria + $this->stream = fopen('php://temp', 'r+b'); + + if ($this->stream === false) { + error_log(sprintf('[FlysystemStreamWrapper] stream_open error: failed to create temp stream for "%s"', $this->path)); + return false; + } + + // Escribir el contenido en el stream temporal + fwrite($this->stream, $contents); + + // Volver al inicio del stream + rewind($this->stream); + + error_log(sprintf('[FlysystemStreamWrapper] stream_open read mode: loaded %d bytes for "%s"', strlen($contents), $this->path)); + return true; } - } - // En modo append, posicionar al final - if (str_starts_with($mode, 'a')) { - $this->position = strlen($this->buffer); - } + // Modo escritura + if (strpos($mode, 'w') !== false || strpos($mode, 'a') !== false || strpos($mode, 'x') !== false || strpos($mode, 'c') !== false) { + // Crear un stream temporal en memoria para escritura + $this->stream = fopen('php://temp', 'r+b'); + + if ($this->stream === false) { + error_log(sprintf('[FlysystemStreamWrapper] stream_open error: failed to create temp stream for "%s"', $this->path)); + return false; + } + + // En modo append, posicionar al final + if (strpos($mode, 'a') !== false) { + // Si el archivo existe, cargar su contenido + if (self::$filesystem->fileExists($this->path)) { + $contents = self::$filesystem->read($this->path); + fwrite($this->stream, $contents); + } + fseek($this->stream, 0, SEEK_END); + } + + error_log(sprintf('[FlysystemStreamWrapper] stream_open write mode for "%s"', $this->path)); + return true; + } - return true; - } - - public function stream_write(string $data): int - { - $len = strlen($data); - - // Insertar data en la posición actual - $before = substr($this->buffer, 0, $this->position); - $after = substr($this->buffer, $this->position); - - $this->buffer = $before . $data . $after; - $this->position += $len; - - error_log(sprintf('[FlysystemStreamWrapper] stream_write %d bytes (buffer now %d bytes)', $len, strlen($this->buffer))); - - return $len; - } - - public function stream_read(int $count): string - { - $chunk = substr($this->buffer, $this->position, $count); - $this->position += strlen($chunk); - return $chunk; - } - - public function stream_tell(): int - { - return $this->position; - } - - public function stream_eof(): bool - { - return $this->position >= strlen($this->buffer); - } - - public function stream_seek(int $offset, int $whence = SEEK_SET): bool - { - $newPos = $this->position; - switch ($whence) { - case SEEK_SET: - $newPos = $offset; - break; - case SEEK_CUR: - $newPos += $offset; - break; - case SEEK_END: - $newPos = strlen($this->buffer) + $offset; - break; - } - - if ($newPos < 0) { + error_log(sprintf('[FlysystemStreamWrapper] stream_open error: unsupported mode "%s" for "%s"', $mode, $this->path)); + return false; + } catch (\Exception $e) { + error_log(sprintf('[FlysystemStreamWrapper] stream_open exception: %s', $e->getMessage())); return false; } - $this->position = $newPos; - return true; } - public function stream_flush(): bool + /** + * Read from stream + * + * @param int $count + * @return string|false + */ + public function stream_read(int $count) { - // Persistir buffer en remoto - if ($this->buffer === '') { - return true; + if (!$this->stream) { + error_log('[FlysystemStreamWrapper] stream_read error: no stream resource'); + return false; } try { - error_log('[FlysystemStreamWrapper] stream_flush -> write to "' . $this->path . '" (' . strlen($this->buffer) . ' bytes)'); - self::$filesystem->write($this->path, $this->buffer); - return true; - } catch (UnableToWriteFile $e) { - error_log('[FlysystemStreamWrapper] stream_flush UnableToWriteFile: ' . $e->getMessage()); - return false; - } catch (\Throwable $e) { - error_log('[FlysystemStreamWrapper] stream_flush error: ' . $e->getMessage()); + $data = fread($this->stream, $count); + + if ($data === false) { + error_log(sprintf('[FlysystemStreamWrapper] stream_read error for "%s"', $this->path)); + return false; + } + + error_log(sprintf('[FlysystemStreamWrapper] stream_read %d bytes from "%s"', strlen($data), $this->path)); + return $data; + } catch (\Exception $e) { + error_log(sprintf('[FlysystemStreamWrapper] stream_read exception: %s', $e->getMessage())); return false; } } - public function stream_close(): void + /** + * Write to stream + * + * @param string $data + * @return int|false + */ + public function stream_write(string $data) { - // Flush final en close - if ($this->buffer !== '' && str_contains($this->mode, 'w') || str_contains($this->mode, 'a') || str_contains($this->mode, '+')) { - try { - error_log('[FlysystemStreamWrapper] stream_close -> persisting "' . $this->path . '"'); - self::$filesystem->write($this->path, $this->buffer); - } catch (\Throwable $e) { - error_log('[FlysystemStreamWrapper] stream_close error: ' . $e->getMessage()); + if (!$this->stream) { + error_log('[FlysystemStreamWrapper] stream_write error: no stream resource'); + return false; + } + + try { + $bytesWritten = fwrite($this->stream, $data); + + if ($bytesWritten === false) { + error_log(sprintf('[FlysystemStreamWrapper] stream_write error for "%s"', $this->path)); + return false; } + + error_log(sprintf('[FlysystemStreamWrapper] stream_write %d bytes to "%s"', $bytesWritten, $this->path)); + return $bytesWritten; + } catch (\Exception $e) { + error_log(sprintf('[FlysystemStreamWrapper] stream_write exception: %s', $e->getMessage())); + return false; + } + } + + /** + * Tests for end-of-file on a file pointer + * + * @return bool + */ + public function stream_eof(): bool + { + if (!$this->stream) { + return true; } - $this->buffer = ''; - $this->position = 0; + return feof($this->stream); } - public function stream_stat(): array + /** + * Retrieve the current position of a stream + * + * @return int + */ + public function stream_tell(): int { - return $this->getStatArray(); + if (!$this->stream) { + return 0; + } + + $position = ftell($this->stream); + return $position !== false ? $position : 0; } + /** + * Seeks to specific location in a stream + * + * @param int $offset + * @param int $whence + * @return bool + */ + public function stream_seek(int $offset, int $whence = SEEK_SET): bool + { + if (!$this->stream) { + error_log('[FlysystemStreamWrapper] stream_seek error: no stream resource'); + return false; + } + + try { + $result = fseek($this->stream, $offset, $whence); + + if ($result === 0) { + error_log(sprintf('[FlysystemStreamWrapper] stream_seek to %d (whence: %d) for "%s"', $offset, $whence, $this->path)); + return true; + } + + error_log(sprintf('[FlysystemStreamWrapper] stream_seek failed for "%s"', $this->path)); + return false; + } catch (\Exception $e) { + error_log(sprintf('[FlysystemStreamWrapper] stream_seek exception: %s', $e->getMessage())); + return false; + } + } + + /** + * Retrieve information about a file resource + * + * @return array|false + */ + public function stream_stat() + { + if (!$this->stream) { + error_log('[FlysystemStreamWrapper] stream_stat error: no stream resource'); + return false; + } + + try { + $stat = fstat($this->stream); + + if ($stat === false) { + error_log(sprintf('[FlysystemStreamWrapper] stream_stat failed for "%s"', $this->path)); + return false; + } + + error_log(sprintf('[FlysystemStreamWrapper] stream_stat for "%s": size=%d', $this->path, $stat['size'] ?? 0)); + return $stat; + } catch (\Exception $e) { + error_log(sprintf('[FlysystemStreamWrapper] stream_stat exception: %s', $e->getMessage())); + return false; + } + } + + /** + * Flushes the output + * + * @return bool + */ + public function stream_flush(): bool + { + if (!$this->stream) { + error_log('[FlysystemStreamWrapper] stream_flush error: no stream resource'); + return false; + } + + try { + // Forzar escritura del buffer interno de PHP + fflush($this->stream); + + // Obtener la posición actual antes de rebobinar + $pos = ftell($this->stream); + + // Rebobinar para leer todo el contenido + rewind($this->stream); + $contents = stream_get_contents($this->stream); + + // Restaurar posición original + if ($pos !== false) { + fseek($this->stream, $pos); + } + + if ($contents === false) { + error_log(sprintf('[FlysystemStreamWrapper] stream_flush error reading contents for "%s"', $this->path)); + return false; + } + + // Escribir en el sistema de archivos + self::$filesystem->write($this->path, $contents); + + error_log(sprintf('[FlysystemStreamWrapper] stream_flush wrote %d bytes to "%s"', strlen($contents), $this->path)); + return true; + } catch (\Exception $e) { + error_log(sprintf('[FlysystemStreamWrapper] stream_flush exception: %s', $e->getMessage())); + return false; + } + } + + /** + * Close a resource + */ + public function stream_close(): void + { + if ($this->stream) { + try { + // Obtener la posición actual antes de rebobinar + $pos = ftell($this->stream); + + // Rebobinar para leer todo el contenido + rewind($this->stream); + $contents = stream_get_contents($this->stream); + + // Solo escribir si hay contenido y el modo permite escritura + if ($contents !== false && strlen($contents) > 0 && + (strpos($this->mode, 'w') !== false || + strpos($this->mode, 'a') !== false || + strpos($this->mode, 'x') !== false || + strpos($this->mode, 'c') !== false || + strpos($this->mode, '+') !== false)) { + + // Escribir en el sistema de archivos + self::$filesystem->write($this->path, $contents); + error_log(sprintf('[FlysystemStreamWrapper] stream_close wrote %d bytes to "%s"', strlen($contents), $this->path)); + } + } catch (\Exception $e) { + error_log(sprintf('[FlysystemStreamWrapper] stream_close exception: %s', $e->getMessage())); + } + + fclose($this->stream); + $this->stream = null; + } + } + + /** + * Retrieve information about a file + * + * @param string $path + * @param int $flags + * @return array|false + */ public function url_stat(string $path, int $flags) { $p = self::normalizePath($path); + try { - $size = self::$filesystem->fileSize($p); - return $this->getStatArray($size); - } catch (\Throwable $e) { + // Verificar si es un directorio + if (self::$filesystem->directoryExists($p)) { + return $this->getStatArray(0, true); + } + + // Verificar si es un archivo + if (self::$filesystem->fileExists($p)) { + $size = self::$filesystem->fileSize($p); + return $this->getStatArray($size, false); + } + + // No existe + if ($flags & STREAM_URL_STAT_QUIET) { + return false; + } + return false; + } catch (\Exception $e) { + error_log(sprintf('[FlysystemStreamWrapper] url_stat exception for "%s": %s', $p, $e->getMessage())); if ($flags & STREAM_URL_STAT_QUIET) { return false; } @@ -222,73 +413,114 @@ class FlysystemStreamWrapper } } + /** + * Delete a file + * + * @param string $path + * @return bool + */ public function unlink(string $path): bool { $p = self::normalizePath($path); + try { error_log('[FlysystemStreamWrapper] unlink "' . $p . '"'); self::$filesystem->delete($p); return true; - } catch (\Throwable $e) { + } catch (\Exception $e) { error_log('[FlysystemStreamWrapper] unlink error: ' . $e->getMessage()); return false; } } + /** + * Create a directory + * + * @param string $path + * @param int $mode + * @param int $options + * @return bool + */ public function mkdir(string $path, int $mode, int $options): bool { $p = self::normalizePath($path); + try { error_log('[FlysystemStreamWrapper] mkdir "' . $p . '"'); self::$filesystem->createDirectory($p); return true; - } catch (\Throwable $e) { + } catch (\Exception $e) { error_log('[FlysystemStreamWrapper] mkdir error: ' . $e->getMessage()); return false; } } + /** + * Remove a directory + * + * @param string $path + * @param int $options + * @return bool + */ public function rmdir(string $path, int $options): bool { $p = self::normalizePath($path); + try { error_log('[FlysystemStreamWrapper] rmdir "' . $p . '"'); self::$filesystem->deleteDirectory($p); return true; - } catch (\Throwable $e) { + } catch (\Exception $e) { error_log('[FlysystemStreamWrapper] rmdir error: ' . $e->getMessage()); return false; } } + /** + * Rename a file or directory + * + * @param string $path_from + * @param string $path_to + * @return bool + */ public function rename(string $path_from, string $path_to): bool { $from = self::normalizePath($path_from); $to = self::normalizePath($path_to); + try { error_log('[FlysystemStreamWrapper] rename "' . $from . '" -> "' . $to . '"'); self::$filesystem->move($from, $to); return true; - } catch (\Throwable $e) { + } catch (\Exception $e) { error_log('[FlysystemStreamWrapper] rename error: ' . $e->getMessage()); return false; } } - private function getStatArray(int $size = 0): array + /** + * Genera array stat para archivos y directorios + * + * @param int $size Tamaño del archivo + * @param bool $isDir Si es directorio + * @return array + */ + private function getStatArray(int $size = 0, bool $isDir = false): array { + $mode = $isDir ? 0040777 : 0100666; // Directorio o archivo regular + return [ 0 => 0, 'dev' => 0, 1 => 0, 'ino' => 0, - 2 => 0100666, 'mode' => 0100666, + 2 => $mode, 'mode' => $mode, 3 => 0, 'nlink' => 0, 4 => 0, 'uid' => 0, 5 => 0, 'gid' => 0, 6 => -1, 'rdev' => -1, 7 => $size, 'size' => $size, - 8 => 0, 'atime' => 0, - 9 => 0, 'mtime' => 0, - 10 => 0, 'ctime' => 0, + 8 => time(), 'atime' => time(), + 9 => time(), 'mtime' => time(), + 10 => time(), 'ctime' => time(), 11 => -1, 'blksize' => -1, 12 => -1, 'blocks' => -1, ];