From 04784ee3370bb9811cc44190705a2c698439360f Mon Sep 17 00:00:00 2001 From: Brasdrive Date: Mon, 10 Nov 2025 01:06:19 -0400 Subject: [PATCH] 3.0.0 --- src/Config/ConfigLoader.php | 22 +- src/Filesystem/Adapters/WebdavAdapter.php | 117 ++- src/Filesystem/FilesystemFactory.php | 28 +- src/Plugin.php | 62 +- src/StreamWrapper/FlysystemStreamWrapper.php | 763 +++++++------------ 5 files changed, 412 insertions(+), 580 deletions(-) diff --git a/src/Config/ConfigLoader.php b/src/Config/ConfigLoader.php index 1a6cb4e..a25b477 100644 --- a/src/Config/ConfigLoader.php +++ b/src/Config/ConfigLoader.php @@ -26,7 +26,7 @@ class ConfigLoader // Merge con prioridad a la base de datos sobre el archivo // Si hay configuración en BD, usar esa; si no, usar archivo - $config = !empty($dbConfig['provider']) + $config = !empty($dbConfig['provider']) ? array_merge($fileConfig, $dbConfig) : $fileConfig; @@ -114,7 +114,7 @@ class ConfigLoader $config['bucket'] = $s3Config['bucket'] ?? ''; $config['endpoint'] = $s3Config['endpoint'] ?? ''; $config['use_path_style_endpoint'] = $s3Config['use_path_style_endpoint'] ?? false; - + // Usar prefix específico de S3 si existe, sino el global if (isset($s3Config['prefix'])) { $config['prefix'] = $s3Config['prefix']; @@ -133,10 +133,10 @@ class ConfigLoader case 'webdav': if (isset($rawConfig['webdav'])) { $webdavConfig = $rawConfig['webdav']; - + // Determinar base_uri desde endpoint o base_url - $config['base_uri'] = $webdavConfig['endpoint'] - ?? $webdavConfig['base_url'] + $config['base_uri'] = $webdavConfig['endpoint'] + ?? $webdavConfig['base_url'] ?? ''; // Credenciales @@ -157,10 +157,10 @@ class ConfigLoader } // Permisos (usar valores por defecto si no están definidos) - $config['file_public'] = '0644'; - $config['file_private'] = '0600'; - $config['dir_public'] = '0755'; - $config['dir_private'] = '0700'; + $config['file_public'] = $webdavConfig['file_public'] ?? '0644'; + $config['file_private'] = $webdavConfig['file_private'] ?? '0600'; + $config['dir_public'] = $webdavConfig['dir_public'] ?? '0755'; + $config['dir_private'] = $webdavConfig['dir_private'] ?? '0700'; // Preservar configuración completa de WebDAV $config['webdav'] = $webdavConfig; @@ -417,12 +417,12 @@ class ConfigLoader if (is_string($permission)) { $permission = trim($permission); - + // Si ya tiene el formato correcto, retornar if (preg_match('/^0[0-7]{3}$/', $permission)) { return $permission; } - + // Si es solo dígitos sin el 0 inicial, añadirlo if (preg_match('/^[0-7]{3}$/', $permission)) { return '0' . $permission; diff --git a/src/Filesystem/Adapters/WebdavAdapter.php b/src/Filesystem/Adapters/WebdavAdapter.php index 6610c59..60ccc81 100644 --- a/src/Filesystem/Adapters/WebdavAdapter.php +++ b/src/Filesystem/Adapters/WebdavAdapter.php @@ -23,6 +23,7 @@ class WebdavAdapter implements FilesystemAdapter private Client $client; private string $prefix; private PortableVisibilityConverter $visibilityConverter; + private bool $baseDirectoryEnsured = false; public function __construct( Client $client, @@ -39,6 +40,70 @@ class WebdavAdapter implements FilesystemAdapter )); } + /** + * Asegurar que el directorio base (prefix) existe + * Se ejecuta lazy (solo cuando se necesita) + */ + private function ensureBaseDirectoryExists(): void + { + if ($this->baseDirectoryEnsured || $this->prefix === '') { + return; + } + + error_log('[WebdavAdapter] Ensuring base directory exists...'); + + $parts = array_filter(explode('/', $this->prefix)); + $path = ''; + + foreach ($parts as $part) { + $path .= '/' . $part; + + try { + // Intentar verificar si existe + $this->client->propFind($path, ['{DAV:}resourcetype'], 0); + error_log(sprintf('[WebdavAdapter] Base directory exists: "%s"', $path)); + } catch (\Exception $e) { + // No existe, crear + error_log(sprintf('[WebdavAdapter] Base directory does not exist, creating: "%s"', $path)); + + try { + $response = $this->client->request('MKCOL', $path); + + // Verificar el código de estado + $statusCode = $response['statusCode'] ?? 0; + + if ($statusCode >= 200 && $statusCode < 300) { + // Éxito (201 Created) + error_log(sprintf('[WebdavAdapter] Created base 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)); + } else { + // Error + $errorMsg = sprintf('Failed to create directory "%s", status: %d', $path, $statusCode); + error_log(sprintf('[WebdavAdapter] %s', $errorMsg)); + throw new \RuntimeException($errorMsg); + } + } 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)); + } else { + error_log(sprintf( + '[WebdavAdapter] Failed to create base directory: "%s", error: %s', + $path, + $e2->getMessage() + )); + throw $e2; + } + } + } + } + + $this->baseDirectoryEnsured = true; + error_log('[WebdavAdapter] Base directory ensured successfully'); + } + private function prefixPath(string $path): string { if ($this->prefix === '') { @@ -86,6 +151,9 @@ class WebdavAdapter implements FilesystemAdapter public function write(string $path, string $contents, Config $config): void { + // ✅ CRÍTICO: Asegurar que el directorio base existe ANTES de escribir + $this->ensureBaseDirectoryExists(); + $prefixedPath = $this->prefixPath($path); error_log(sprintf( @@ -95,7 +163,11 @@ class WebdavAdapter implements FilesystemAdapter strlen($contents) )); - $this->ensureDirectoryExists(dirname($path), $config); + // Asegurar que el directorio padre del archivo existe + $dirname = dirname($path); + if ($dirname !== '.' && $dirname !== '') { + $this->ensureDirectoryExists($dirname, $config); + } try { $response = $this->client->request('PUT', $prefixedPath, $contents); @@ -124,7 +196,11 @@ class WebdavAdapter implements FilesystemAdapter public function writeStream(string $path, $contents, Config $config): void { - $this->write($path, stream_get_contents($contents), $config); + $streamContents = stream_get_contents($contents); + if ($streamContents === false) { + throw UnableToWriteFile::atLocation($path, 'Unable to read from stream'); + } + $this->write($path, $streamContents, $config); } public function read(string $path): string @@ -191,6 +267,9 @@ class WebdavAdapter implements FilesystemAdapter public function createDirectory(string $path, Config $config): void { + // ✅ Asegurar que el directorio base existe + $this->ensureBaseDirectoryExists(); + $prefixedPath = $this->prefixPath($path); error_log(sprintf( @@ -202,6 +281,7 @@ class WebdavAdapter implements FilesystemAdapter try { $response = $this->client->request('MKCOL', $prefixedPath); + // 405 significa que el directorio ya existe if ($response['statusCode'] >= 400 && $response['statusCode'] !== 405) { throw UnableToCreateDirectory::atLocation( $path, @@ -221,8 +301,11 @@ class WebdavAdapter implements FilesystemAdapter $e->getMessage() )); - if ($e->getCode() !== 405) { + // 405 significa que ya existe, no es un error + if (strpos($e->getMessage(), '405') === false) { throw UnableToCreateDirectory::atLocation($path, $e->getMessage(), $e); + } else { + error_log(sprintf('[WebdavAdapter] Directory already exists: "%s"', $path)); } } } @@ -233,18 +316,29 @@ class WebdavAdapter implements FilesystemAdapter return; } - $parts = explode('/', trim($dirname, '/')); + error_log(sprintf('[WebdavAdapter] ensureDirectoryExists - dirname: "%s"', $dirname)); + + $parts = array_filter(explode('/', trim($dirname, '/'))); $path = ''; foreach ($parts as $part) { - if ($part === '') { - continue; - } - $path .= ($path !== '' ? '/' : '') . $part; - if (!$this->directoryExists($path)) { + error_log(sprintf('[WebdavAdapter] Checking/creating directory: "%s"', $path)); + + // Intentar crear directamente + try { $this->createDirectory($path, $config); + } catch (UnableToCreateDirectory $e) { + // Si falla y no es porque ya existe, propagar el error + if (strpos($e->getMessage(), '405') === false && !$this->directoryExists($path)) { + error_log(sprintf( + '[WebdavAdapter] Failed to ensure directory exists: "%s", error: %s', + $path, + $e->getMessage() + )); + throw $e; + } } } } @@ -252,7 +346,6 @@ class WebdavAdapter implements FilesystemAdapter public function setVisibility(string $path, string $visibility): void { // WebDAV no soporta visibilidad de forma nativa - // Se podría implementar con propiedades personalizadas si es necesario } public function visibility(string $path): FileAttributes @@ -383,6 +476,8 @@ class WebdavAdapter implements FilesystemAdapter public function move(string $source, string $destination, Config $config): void { + $this->ensureBaseDirectoryExists(); + try { $this->client->request( 'MOVE', @@ -397,6 +492,8 @@ class WebdavAdapter implements FilesystemAdapter public function copy(string $source, string $destination, Config $config): void { + $this->ensureBaseDirectoryExists(); + try { $this->client->request( 'COPY', diff --git a/src/Filesystem/FilesystemFactory.php b/src/Filesystem/FilesystemFactory.php index 95a4586..3883d6a 100644 --- a/src/Filesystem/FilesystemFactory.php +++ b/src/Filesystem/FilesystemFactory.php @@ -136,14 +136,21 @@ class FilesystemFactory // Configurar cliente Sabre $settings = [ - 'baseUri' => $baseUri, + 'baseUri' => rtrim($baseUri, '/') . '/', ]; // Agregar autenticación si está configurada if (!empty($username)) { $settings['userName'] = $username; $settings['password'] = $password; - $settings['authType'] = constant('Sabre\DAV\Client::AUTH_' . strtoupper($authType)); + + // Mapear auth_type a constante de Sabre + $authTypeConstant = match(strtolower($authType)) { + 'digest' => \Sabre\DAV\Client::AUTH_DIGEST, + 'ntlm' => \Sabre\DAV\Client::AUTH_NTLM, + default => \Sabre\DAV\Client::AUTH_BASIC, + }; + $settings['authType'] = $authTypeConstant; } $client = new SabreClient($settings); @@ -155,21 +162,20 @@ class FilesystemFactory $dirPrivate = self::normalizePermission($config['dir_private'] ?? 0700); error_log(sprintf( - '[Flysystem Offload] WebDAV permissions - file_public: %o (%d), file_private: %o (%d), dir_public: %o (%d), dir_private: %o (%d)', - $filePublic, $filePublic, - $filePrivate, $filePrivate, - $dirPublic, $dirPublic, - $dirPrivate, $dirPrivate + '[Flysystem Offload] WebDAV permissions - file_public: %o, file_private: %o, dir_public: %o, dir_private: %o', + $filePublic, + $filePrivate, + $dirPublic, + $dirPrivate )); // Crear converter de visibilidad - // Los primeros 4 parámetros son int, el 5to es string (visibility: 'public' o 'private') $visibilityConverter = new PortableVisibilityConverter( filePublic: $filePublic, filePrivate: $filePrivate, directoryPublic: $dirPublic, directoryPrivate: $dirPrivate, - defaultForDirectories: 'public' // String: 'public' o 'private' + defaultForDirectories: 'public' ); error_log(sprintf( @@ -262,12 +268,12 @@ class FilesystemFactory // Si es string, convertir if (is_string($permission)) { $permission = trim($permission); - + // Si tiene el formato 0xxx, convertir desde octal if (preg_match('/^0[0-7]{3}$/', $permission)) { return intval($permission, 8); } - + // Si es solo dígitos sin el 0 inicial, añadirlo y convertir if (preg_match('/^[0-7]{3}$/', $permission)) { return intval('0' . $permission, 8); diff --git a/src/Plugin.php b/src/Plugin.php index 6399b5d..668a58b 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -17,29 +17,10 @@ use Throwable; */ class Plugin { - /** - * Instancia del Filesystem - */ private static ?Filesystem $filesystem = null; - - /** - * Configuración del plugin - */ private static array $config = []; - - /** - * Indica si el plugin está inicializado - */ private static bool $initialized = false; - - /** - * Instancia de MediaHooks - */ private static ?MediaHooks $mediaHooks = null; - - /** - * Instancia de SettingsPage - */ private static ?SettingsPage $settingsPage = null; /** @@ -70,14 +51,17 @@ class Plugin // Crear filesystem self::$filesystem = FilesystemFactory::create(self::$config); - // Registrar stream wrapper - FlysystemStreamWrapper::register(self::$filesystem); + // Registrar stream wrapper con protocolo desde config + $protocol = (string)(self::$config['stream']['protocol'] ?? 'flysystem'); + FlysystemStreamWrapper::register(self::$filesystem, $protocol); - // Registrar hooks de medios (instanciar la clase) + 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 página de ajustes (instanciar la clase) + // Registrar página de ajustes if (is_admin()) { self::$settingsPage = new SettingsPage(self::$filesystem, self::$config); self::$settingsPage->register(); @@ -99,42 +83,21 @@ class Plugin } } - /** - * Obtiene la instancia del Filesystem - * - * @return Filesystem|null - */ public static function getFilesystem(): ?Filesystem { return self::$filesystem; } - /** - * Obtiene la configuración del plugin - * - * @return array - */ public static function getConfig(): array { return self::$config; } - /** - * Verifica si el plugin está inicializado - * - * @return bool - */ public static function isInitialized(): bool { return self::$initialized; } - /** - * Registra un aviso de administración - * - * @param string $message Mensaje a mostrar - * @param string $type Tipo de aviso (error, warning, info, success) - */ private static function registerAdminNotice(string $message, string $type = 'error'): void { add_action('admin_notices', static function () use ($message, $type): void { @@ -150,22 +113,19 @@ class Plugin }); } - /** - * Reconstruye el filesystem (útil después de cambiar configuración) - * - * @throws Throwable - */ public static function rebuild(): void { self::$initialized = false; self::$filesystem = null; self::$mediaHooks = null; self::$settingsPage = null; + + $protocol = (string)(self::$config['stream']['protocol'] ?? 'flysystem'); self::$config = []; // Desregistrar stream wrapper si existe - if (in_array('fly', stream_get_wrappers(), true)) { - stream_wrapper_unregister('fly'); + if (in_array($protocol, stream_get_wrappers(), true)) { + @stream_wrapper_unregister($protocol); } self::bootstrap(); diff --git a/src/StreamWrapper/FlysystemStreamWrapper.php b/src/StreamWrapper/FlysystemStreamWrapper.php index 1a376d3..b774f86 100644 --- a/src/StreamWrapper/FlysystemStreamWrapper.php +++ b/src/StreamWrapper/FlysystemStreamWrapper.php @@ -1,527 +1,296 @@ */ - private array $directoryListing = []; - private int $directoryPosition = 0; + /** @var string Buffer en memoria para modo write */ + private string $buffer = ''; - public static function register( - FilesystemOperator $filesystem, - string $protocol = 'flysystem', - string $rootPrefix = '', - string $defaultVisibility = Visibility::PUBLIC, - bool $force = true - ): void { + /** @var int Posición del puntero */ + private int $position = 0; + + /** @var string Modo de apertura */ + private string $mode = ''; + + /** @var array Opciones de contexto */ + public $context; + + /** + * Registra el stream wrapper + */ + public static function register(FilesystemOperator $filesystem, string $protocol = 'flysystem'): void + { self::$filesystem = $filesystem; self::$protocol = $protocol; - self::$rootPrefix = PathHelper::normalize($rootPrefix); - self::$defaultVisibility = $defaultVisibility; - $wrappers = stream_get_wrappers(); - if (in_array($protocol, $wrappers, true)) { - if (! $force) { - return; - } - stream_wrapper_unregister($protocol); + // Desregistrar si ya existe + if (in_array(self::$protocol, stream_get_wrappers(), true)) { + @stream_wrapper_unregister(self::$protocol); } - if (! stream_wrapper_register($protocol, static::class, STREAM_IS_URL)) { - throw new \RuntimeException(sprintf('Unable to register stream wrapper for protocol "%s".', $protocol)); + $ok = @stream_wrapper_register(self::$protocol, self::class); + if ($ok) { + error_log('[FlysystemStreamWrapper] Registered protocol: "' . self::$protocol . '"'); + } else { + error_log('[FlysystemStreamWrapper] ERROR registering protocol "' . self::$protocol . '"'); } } - public static function unregister(): void { - if (self::$protocol !== '' && in_array(self::$protocol, stream_get_wrappers(), true)) { - stream_wrapper_unregister(self::$protocol); - } - self::$filesystem = null; - } - - public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool { - $this->ensureFilesystem(); - - $this->uri = $path; - $this->path = self::uriToKey($path); - $this->mode = $mode; - $this->resource = fopen('php://temp', 'w+b'); - $this->dirty = false; - - error_log(sprintf( - '[FlysystemStreamWrapper] stream_open - uri: "%s", path: "%s", mode: "%s"', - $this->uri, - $this->path, - $this->mode - )); - - if ($this->resource === false) { - return false; - } - - if ($this->requiresExistingFile($mode) && ! $this->fileExists($this->path)) { - if ($options & STREAM_REPORT_ERRORS) { - trigger_error(sprintf('Flysystem path "%s" does not exist.', $this->path), E_USER_WARNING); - } - return false; - } - - if ($this->isExclusiveCreateMode($mode) && $this->fileExists($this->path)) { - return false; - } - - if (($this->requiresExistingFile($mode) || $this->isAppendMode($mode)) && $this->fileExists($this->path)) { - try { - $contents = self::$filesystem->read($this->path); - fwrite($this->resource, $contents); - unset($contents); - - if ($this->isAppendMode($mode)) { - fseek($this->resource, 0, SEEK_END); - } else { - rewind($this->resource); - } - } catch (UnableToReadFile|FilesystemException $exception) { - if ($options & STREAM_REPORT_ERRORS) { - trigger_error($exception->getMessage(), E_USER_WARNING); - } - return false; - } - } - - return true; - } - - public function stream_read(int $count): string|false { - if (! $this->resource) { - return false; - } - - return fread($this->resource, $count); - } - - public function stream_write(string $data): int|false { - if (! $this->resource) { - return false; - } - - $written = fwrite($this->resource, $data); - if ($written !== false) { - $this->dirty = true; - } - - error_log(sprintf( - '[FlysystemStreamWrapper] stream_write - path: "%s", bytes: %d, dirty: %s', - $this->path, - $written !== false ? $written : 0, - $this->dirty ? 'true' : 'false' - )); - - return $written; - } - - - public function stream_tell(): int|false { - if (! $this->resource) { - return false; - } - - return ftell($this->resource); - } - - public function stream_eof(): bool { - if (! $this->resource) { - return true; - } - - return feof($this->resource); - } - - public function stream_seek(int $offset, int $whence = SEEK_SET): bool { - if (! $this->resource) { - return false; - } - - return fseek($this->resource, $offset, $whence) === 0; - } - - public function stream_flush(): bool { - error_log(sprintf( - '[FlysystemStreamWrapper] stream_flush - path: "%s", dirty: %s, write_mode: %s', - $this->path, - $this->dirty ? 'true' : 'false', - $this->isWriteMode($this->mode) ? 'true' : 'false' - )); - - if (! $this->resource || ! $this->dirty || ! $this->isWriteMode($this->mode)) { - return true; - } - - $position = ftell($this->resource); - rewind($this->resource); - $contents = stream_get_contents($this->resource); - fseek($this->resource, $position); - - try { - if ($this->fileExists($this->path)) { - try { - self::$filesystem->delete($this->path); - } catch (UnableToDeleteFile|FilesystemException) { - // Intentamos sobrescribir igualmente. - } - } - - self::$filesystem->write( - $this->path, - (string) $contents, - ['visibility' => self::$defaultVisibility] - ); - - error_log(sprintf( - '[FlysystemStreamWrapper] stream_flush SUCCESS - path: "%s", size: %d bytes', - $this->path, - strlen((string) $contents) - )); - - $this->dirty = false; - - return true; - } catch (UnableToWriteFile|FilesystemException $exception) { - error_log(sprintf( - '[FlysystemStreamWrapper] stream_flush ERROR - path: "%s", error: %s', - $this->path, - $exception->getMessage() - )); - trigger_error($exception->getMessage(), E_USER_WARNING); - return false; - } - } - - public function stream_close(): void { - $this->stream_flush(); - - if ($this->resource) { - fclose($this->resource); - } - - $this->resource = null; - $this->mode = 'r'; - $this->path = ''; - $this->uri = ''; - $this->dirty = false; - } - - public function stream_truncate(int $new_size): bool { - if (! $this->resource) { - return false; - } - - $result = ftruncate($this->resource, $new_size); - if ($result) { - $this->dirty = true; - } - - return $result; - } - - public function stream_stat(): array|false { - if ($this->path === '') { - return false; - } - - return $this->statKey($this->path); - } - - public function url_stat(string $path, int $flags): array|false { - $key = self::uriToKey($path); - $stat = $this->statKey($key); - - if ($stat === false && ($flags & STREAM_URL_STAT_QUIET) === 0) { - trigger_error(sprintf('Flysystem path "%s" not found.', $key), E_USER_WARNING); - } - - return $stat; - } - - public function unlink(string $path): bool { - $this->ensureFilesystem(); - - $key = self::uriToKey($path); - - try { - if ($this->fileExists($key)) { - self::$filesystem->delete($key); - } - - return true; - } catch (UnableToDeleteFile|FilesystemException $exception) { - trigger_error($exception->getMessage(), E_USER_WARNING); - return false; - } - } - - public function rename(string $path_from, string $path_to): bool { - $this->ensureFilesystem(); - - $from = self::uriToKey($path_from); - $to = self::uriToKey($path_to); - - try { - self::$filesystem->move($from, $to); - return true; - } catch (UnableToMoveFile|FilesystemException $exception) { - trigger_error($exception->getMessage(), E_USER_WARNING); - return false; - } - } - - public function mkdir(string $path, int $mode, int $options): bool { - $this->ensureFilesystem(); - - $key = self::uriToKey($path); - - try { - self::$filesystem->createDirectory($key, ['visibility' => self::$defaultVisibility]); - return true; - } catch (FilesystemException $exception) { - if ($options & STREAM_REPORT_ERRORS) { - trigger_error($exception->getMessage(), E_USER_WARNING); - } - return false; - } - } - - public function rmdir(string $path, int $options): bool { - $this->ensureFilesystem(); - - $key = self::uriToKey($path); - - try { - self::$filesystem->deleteDirectory($key); - return true; - } catch (UnableToDeleteDirectory|FilesystemException $exception) { - if ($options & STREAM_REPORT_ERRORS) { - trigger_error($exception->getMessage(), E_USER_WARNING); - } - return false; - } - } - - public function dir_opendir(string $path, int $options): bool { - $this->ensureFilesystem(); - - $key = self::uriToKey($path); - $this->directoryListing = ['.', '..']; - - try { - foreach (self::$filesystem->listContents($key, false) as $attributes) { - if ($attributes instanceof StorageAttributes) { - $this->directoryListing[] = basename($attributes->path()); - } - } - } catch (FilesystemException $exception) { - if ($options & STREAM_REPORT_ERRORS) { - trigger_error($exception->getMessage(), E_USER_WARNING); - } - return false; - } - - $this->directoryPosition = 0; - - return true; - } - - public function dir_readdir(): string|false { - if (! isset($this->directoryListing[$this->directoryPosition])) { - return false; - } - - return $this->directoryListing[$this->directoryPosition++]; - } - - public function dir_rewinddir(): bool { - $this->directoryPosition = 0; - return true; - } - - public function dir_closedir(): bool { - $this->directoryListing = []; - $this->directoryPosition = 0; - return true; - } - - public function stream_metadata(string $path, int $option, mixed $value): bool { - $this->ensureFilesystem(); - - if ($option === STREAM_META_TOUCH) { - $key = self::uriToKey($path); - - try { - if (! $this->fileExists($key)) { - self::$filesystem->write($key, '', ['visibility' => self::$defaultVisibility]); - } - - return true; - } catch (FilesystemException $exception) { - trigger_error($exception->getMessage(), E_USER_WARNING); - return false; - } - } - - return false; - } - - public function stream_set_option(int $option, int $arg1, int $arg2): bool { - return false; - } - - public function stream_lock(int $operation): bool { - return true; - } - - private function requiresExistingFile(string $mode): bool { - return str_starts_with($mode, 'r'); - } - - private function isExclusiveCreateMode(string $mode): bool { - return str_starts_with($mode, 'x'); - } - - private function isAppendMode(string $mode): bool { - return str_contains($mode, 'a'); - } - - private function isWriteMode(string $mode): bool { - return strpbrk($mode, 'waxc+') !== false; - } - - private static function uriToKey(string $uri): string { - $components = parse_url($uri); - - $host = $components['host'] ?? ''; - $path = $components['path'] ?? ''; - - $relative = PathHelper::join($host, $path); - - if (self::$rootPrefix !== '') { - $relative = PathHelper::join(self::$rootPrefix, $relative); - } - - return $relative; - } - - private function fileExists(string $key): bool { - try { - return self::$filesystem->fileExists($key); - } catch (FilesystemException) { - return false; - } - } - - private function statKey(string $key): array|false { - $this->ensureFilesystem(); - - try { - if (self::$filesystem->fileExists($key)) { - $size = 0; - $mtime = time(); - - try { - $size = self::$filesystem->fileSize($key); - } catch (FilesystemException) { - $size = 0; - } - - try { - $mtime = self::$filesystem->lastModified($key); - } catch (FilesystemException) { - $mtime = time(); - } - - return $this->formatStat(0100644, $size, $mtime, 1); - } - - if (self::$filesystem->directoryExists($key)) { - $mtime = time(); - try { - $mtime = self::$filesystem->lastModified($key); - } catch (FilesystemException) { - $mtime = time(); - } - - return $this->formatStat(0040755, 0, $mtime, 2); - } - } catch (FilesystemException) { - return false; - } - - return false; - } - /** - * @return array{ - * 0:int,1:int,2:int,3:int,4:int,5:int,6:int,7:int,8:int,9:int,10:int,11:int,12:int, - * dev:int,ino:int,mode:int,nlink:int,uid:int,gid:int,rdev:int,size:int,atime:int,mtime:int,ctime:int,blksize:int,blocks:int - * } + * Normaliza ruta removiendo protocolo y host + * Ejemplo: flysystem://uploads/2025/11/file.jpg -> 2025/11/file.jpg */ - private function formatStat(int $mode, int $size, int $timestamp, int $nlink = 1): array { - return [ - 0 => 0, - 'dev' => 0, - 1 => 0, - 'ino' => 0, - 2 => $mode, - 'mode' => $mode, - 3 => $nlink, - 'nlink' => $nlink, - 4 => 0, - 'uid' => 0, - 5 => 0, - 'gid' => 0, - 6 => 0, - 'rdev' => 0, - 7 => $size, - 'size' => $size, - 8 => $timestamp, - 'atime' => $timestamp, - 9 => $timestamp, - 'mtime' => $timestamp, - 10 => $timestamp, - 'ctime' => $timestamp, - 11 => -1, - 'blksize' => -1, - 12 => -1, - 'blocks' => -1, - ]; + 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]; + } + + return ltrim($p, '/'); } - private function ensureFilesystem(): void { - if (! self::$filesystem) { - throw new \RuntimeException('Flysystem filesystem has not been registered.'); + public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool + { + if (!self::$filesystem) { + error_log('[FlysystemStreamWrapper] stream_open failed: filesystem not set'); + return false; + } + + $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()); + return false; + } + // Para otros modos (w, a, etc.) continuar con buffer vacío + } + } + + // En modo append, posicionar al final + if (str_starts_with($mode, 'a')) { + $this->position = strlen($this->buffer); + } + + 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) { + return false; + } + $this->position = $newPos; + return true; + } + + public function stream_flush(): bool + { + // Persistir buffer en remoto + if ($this->buffer === '') { + return true; + } + + 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()); + return false; } } + + public function stream_close(): void + { + // 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()); + } + } + + $this->buffer = ''; + $this->position = 0; + } + + public function stream_stat(): array + { + return $this->getStatArray(); + } + + 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) { + if ($flags & STREAM_URL_STAT_QUIET) { + return false; + } + return false; + } + } + + 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) { + error_log('[FlysystemStreamWrapper] unlink error: ' . $e->getMessage()); + return false; + } + } + + 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) { + error_log('[FlysystemStreamWrapper] mkdir error: ' . $e->getMessage()); + return false; + } + } + + 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) { + error_log('[FlysystemStreamWrapper] rmdir error: ' . $e->getMessage()); + return false; + } + } + + 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) { + error_log('[FlysystemStreamWrapper] rename error: ' . $e->getMessage()); + return false; + } + } + + private function getStatArray(int $size = 0): array + { + return [ + 0 => 0, 'dev' => 0, + 1 => 0, 'ino' => 0, + 2 => 0100666, 'mode' => 0100666, + 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, + 11 => -1, 'blksize' => -1, + 12 => -1, 'blocks' => -1, + ]; + } }