diff --git a/src/Filesystem/Adapters/WebdavAdapter.php b/src/Filesystem/Adapters/WebdavAdapter.php index 72be272..6610c59 100644 --- a/src/Filesystem/Adapters/WebdavAdapter.php +++ b/src/Filesystem/Adapters/WebdavAdapter.php @@ -4,110 +4,408 @@ declare(strict_types=1); namespace FlysystemOffload\Filesystem\Adapters; -use FlysystemOffload\Filesystem\AdapterInterface; use League\Flysystem\FilesystemAdapter; -use League\Flysystem\WebDAV\WebDAVAdapter as LeagueWebDAVAdapter; +use League\Flysystem\Config; +use League\Flysystem\FileAttributes; +use League\Flysystem\DirectoryAttributes; +use League\Flysystem\UnableToReadFile; +use League\Flysystem\UnableToWriteFile; +use League\Flysystem\UnableToDeleteFile; +use League\Flysystem\UnableToCreateDirectory; +use League\Flysystem\UnableToSetVisibility; +use League\Flysystem\UnableToRetrieveMetadata; +use League\Flysystem\Visibility; +use League\Flysystem\UnixVisibility\PortableVisibilityConverter; use Sabre\DAV\Client; -use InvalidArgumentException; -/** - * Adaptador WebDAV para Flysystem Offload - */ -class WebdavAdapter implements AdapterInterface +class WebdavAdapter implements FilesystemAdapter { - /** - * {@inheritdoc} - */ - public function createAdapter(array $config): FilesystemAdapter - { - $this->validateConfig($config); + private Client $client; + private string $prefix; + private PortableVisibilityConverter $visibilityConverter; - // Configurar cliente WebDAV - $clientConfig = [ - 'baseUri' => rtrim($config['base_uri'], '/') . '/', - 'userName' => $config['username'] ?? null, - 'password' => $config['password'] ?? null, - ]; - - // Configurar tipo de autenticación - if (!empty($config['auth_type'])) { - $authType = strtolower($config['auth_type']); - $clientConfig['authType'] = match ($authType) { - 'basic' => Client::AUTH_BASIC, - 'digest' => Client::AUTH_DIGEST, - 'ntlm' => Client::AUTH_NTLM, - default => Client::AUTH_BASIC, - }; - } - - // Crear cliente - $client = new Client($clientConfig); - - // El tercer parámetro debe ser un string: 'public' o 'private' - // Por defecto usamos 'public' para compatibilidad - $visibilityHandling = $config['visibility'] ?? 'public'; + public function __construct( + Client $client, + string $prefix = '', + ?PortableVisibilityConverter $visibilityConverter = null + ) { + $this->client = $client; + $this->prefix = trim($prefix, '/'); + $this->visibilityConverter = $visibilityConverter ?? new PortableVisibilityConverter(); error_log(sprintf( - '[WebDAV Adapter] Creating adapter with prefix: %s, visibility: %s', - $config['prefix'] ?? '', - $visibilityHandling + '[WebdavAdapter] Initialized with prefix: "%s"', + $this->prefix + )); + } + + private function prefixPath(string $path): string + { + if ($this->prefix === '') { + $prefixed = '/' . ltrim($path, '/'); + } else { + $prefixed = '/' . $this->prefix . '/' . ltrim($path, '/'); + } + + error_log(sprintf( + '[WebdavAdapter] prefixPath - input: "%s", output: "%s"', + $path, + $prefixed )); - // Crear adaptador - el tercer parámetro DEBE ser string - $adapter = new LeagueWebDAVAdapter( - $client, - $config['prefix'] ?? '', - $visibilityHandling // ← DEBE SER STRING: 'public' o 'private' - ); - - error_log('[WebDAV Adapter] Adapter created successfully'); - - return $adapter; + return $prefixed; } - /** - * {@inheritdoc} - */ - public function getRequiredConfigKeys(): array + public function fileExists(string $path): bool { - return ['base_uri']; + try { + $response = $this->client->propFind($this->prefixPath($path), ['{DAV:}resourcetype'], 0); + $exists = !empty($response); + + error_log(sprintf( + '[WebdavAdapter] fileExists - path: "%s", exists: %s', + $path, + $exists ? 'true' : 'false' + )); + + return $exists; + } catch (\Exception $e) { + error_log(sprintf( + '[WebdavAdapter] fileExists error - path: "%s", error: %s', + $path, + $e->getMessage() + )); + return false; + } } - /** - * {@inheritdoc} - */ - public function getOptionalConfigKeys(): array + public function directoryExists(string $path): bool { - return [ - 'username', - 'password', - 'auth_type', - 'prefix', - 'visibility', // 'public' o 'private' - ]; + return $this->fileExists($path); } - /** - * Valida la configuración - * - * @param array $config - * @throws InvalidArgumentException - */ - private function validateConfig(array $config): void + public function write(string $path, string $contents, Config $config): void { - foreach ($this->getRequiredConfigKeys() as $key) { - if (empty($config[$key])) { - throw new InvalidArgumentException("WebDAV config key '{$key}' is required"); + $prefixedPath = $this->prefixPath($path); + + error_log(sprintf( + '[WebdavAdapter] write - path: "%s", prefixed: "%s", size: %d bytes', + $path, + $prefixedPath, + strlen($contents) + )); + + $this->ensureDirectoryExists(dirname($path), $config); + + try { + $response = $this->client->request('PUT', $prefixedPath, $contents); + + if ($response['statusCode'] >= 400) { + throw UnableToWriteFile::atLocation( + $path, + "WebDAV returned status {$response['statusCode']}" + ); + } + + error_log(sprintf( + '[WebdavAdapter] write success - path: "%s", status: %d', + $path, + $response['statusCode'] + )); + } catch (\Exception $e) { + error_log(sprintf( + '[WebdavAdapter] write error - path: "%s", error: %s', + $path, + $e->getMessage() + )); + throw UnableToWriteFile::atLocation($path, $e->getMessage(), $e); + } + } + + public function writeStream(string $path, $contents, Config $config): void + { + $this->write($path, stream_get_contents($contents), $config); + } + + public function read(string $path): string + { + try { + $response = $this->client->request('GET', $this->prefixPath($path)); + + if ($response['statusCode'] >= 400) { + throw UnableToReadFile::fromLocation( + $path, + "WebDAV returned status {$response['statusCode']}" + ); + } + + return $response['body']; + } catch (\Exception $e) { + throw UnableToReadFile::fromLocation($path, $e->getMessage(), $e); + } + } + + public function readStream(string $path) + { + $resource = fopen('php://temp', 'r+'); + if ($resource === false) { + throw UnableToReadFile::fromLocation($path, 'Unable to create temp stream'); + } + + fwrite($resource, $this->read($path)); + rewind($resource); + + return $resource; + } + + public function delete(string $path): void + { + try { + $response = $this->client->request('DELETE', $this->prefixPath($path)); + + if ($response['statusCode'] >= 400 && $response['statusCode'] !== 404) { + throw UnableToDeleteFile::atLocation( + $path, + "WebDAV returned status {$response['statusCode']}" + ); + } + + error_log(sprintf( + '[WebdavAdapter] delete success - path: "%s"', + $path + )); + } catch (\Exception $e) { + error_log(sprintf( + '[WebdavAdapter] delete error - path: "%s", error: %s', + $path, + $e->getMessage() + )); + throw UnableToDeleteFile::atLocation($path, $e->getMessage(), $e); + } + } + + public function deleteDirectory(string $path): void + { + $this->delete($path); + } + + public function createDirectory(string $path, Config $config): void + { + $prefixedPath = $this->prefixPath($path); + + error_log(sprintf( + '[WebdavAdapter] createDirectory - path: "%s", prefixed: "%s"', + $path, + $prefixedPath + )); + + try { + $response = $this->client->request('MKCOL', $prefixedPath); + + if ($response['statusCode'] >= 400 && $response['statusCode'] !== 405) { + throw UnableToCreateDirectory::atLocation( + $path, + "WebDAV returned status {$response['statusCode']}" + ); + } + + error_log(sprintf( + '[WebdavAdapter] createDirectory success - path: "%s", status: %d', + $path, + $response['statusCode'] + )); + } catch (\Exception $e) { + error_log(sprintf( + '[WebdavAdapter] createDirectory error - path: "%s", error: %s', + $path, + $e->getMessage() + )); + + if ($e->getCode() !== 405) { + throw UnableToCreateDirectory::atLocation($path, $e->getMessage(), $e); + } + } + } + + private function ensureDirectoryExists(string $dirname, Config $config): void + { + if ($dirname === '' || $dirname === '.') { + return; + } + + $parts = explode('/', trim($dirname, '/')); + $path = ''; + + foreach ($parts as $part) { + if ($part === '') { + continue; + } + + $path .= ($path !== '' ? '/' : '') . $part; + + if (!$this->directoryExists($path)) { + $this->createDirectory($path, $config); + } + } + } + + 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 + { + return new FileAttributes($path, null, Visibility::PUBLIC); + } + + public function mimeType(string $path): FileAttributes + { + try { + $response = $this->client->propFind( + $this->prefixPath($path), + ['{DAV:}getcontenttype'], + 0 + ); + + $mimeType = $response['{DAV:}getcontenttype'] ?? null; + + return new FileAttributes($path, null, null, null, $mimeType); + } catch (\Exception $e) { + throw UnableToRetrieveMetadata::mimeType($path, $e->getMessage(), $e); + } + } + + public function lastModified(string $path): FileAttributes + { + try { + $response = $this->client->propFind( + $this->prefixPath($path), + ['{DAV:}getlastmodified'], + 0 + ); + + $lastModified = $response['{DAV:}getlastmodified'] ?? null; + $timestamp = $lastModified ? strtotime($lastModified) : null; + + return new FileAttributes($path, null, null, $timestamp); + } catch (\Exception $e) { + throw UnableToRetrieveMetadata::lastModified($path, $e->getMessage(), $e); + } + } + + public function fileSize(string $path): FileAttributes + { + try { + $response = $this->client->propFind( + $this->prefixPath($path), + ['{DAV:}getcontentlength'], + 0 + ); + + $size = isset($response['{DAV:}getcontentlength']) + ? (int) $response['{DAV:}getcontentlength'] + : null; + + return new FileAttributes($path, $size); + } catch (\Exception $e) { + throw UnableToRetrieveMetadata::fileSize($path, $e->getMessage(), $e); + } + } + + public function listContents(string $path, bool $deep): iterable + { + try { + $response = $this->client->propFind( + $this->prefixPath($path), + [ + '{DAV:}resourcetype', + '{DAV:}getcontentlength', + '{DAV:}getlastmodified', + '{DAV:}getcontenttype', + ], + $deep ? \Sabre\DAV\Client::DEPTH_INFINITY : 1 + ); + + foreach ($response as $itemPath => $properties) { + $relativePath = $this->removePrefix($itemPath); + + if ($relativePath === $path || $relativePath === '') { + continue; + } + + $isDirectory = isset($properties['{DAV:}resourcetype']) + && strpos($properties['{DAV:}resourcetype']->serialize(new \Sabre\Xml\Writer()), 'collection') !== false; + + if ($isDirectory) { + yield new DirectoryAttributes($relativePath); + } else { + $size = isset($properties['{DAV:}getcontentlength']) + ? (int) $properties['{DAV:}getcontentlength'] + : null; + $lastModified = isset($properties['{DAV:}getlastmodified']) + ? strtotime($properties['{DAV:}getlastmodified']) + : null; + $mimeType = $properties['{DAV:}getcontenttype'] ?? null; + + yield new FileAttributes( + $relativePath, + $size, + null, + $lastModified, + $mimeType + ); + } + } + } catch (\Exception $e) { + error_log(sprintf( + '[WebdavAdapter] listContents error - path: "%s", error: %s', + $path, + $e->getMessage() + )); + } + } + + private function removePrefix(string $path): string + { + $path = '/' . trim($path, '/'); + + if ($this->prefix !== '') { + $prefixWithSlash = '/' . $this->prefix . '/'; + if (str_starts_with($path, $prefixWithSlash)) { + return substr($path, strlen($prefixWithSlash)); } } - if (!filter_var($config['base_uri'], FILTER_VALIDATE_URL)) { - throw new InvalidArgumentException('WebDAV base_uri must be a valid URL'); - } + return ltrim($path, '/'); + } - // Validar visibility si está presente - if (isset($config['visibility']) && !in_array($config['visibility'], ['public', 'private'], true)) { - throw new InvalidArgumentException("WebDAV visibility must be 'public' or 'private'"); + public function move(string $source, string $destination, Config $config): void + { + try { + $this->client->request( + 'MOVE', + $this->prefixPath($source), + null, + ['Destination' => $this->prefixPath($destination)] + ); + } catch (\Exception $e) { + throw new \RuntimeException("Unable to move file from {$source} to {$destination}: " . $e->getMessage(), 0, $e); + } + } + + public function copy(string $source, string $destination, Config $config): void + { + try { + $this->client->request( + 'COPY', + $this->prefixPath($source), + null, + ['Destination' => $this->prefixPath($destination)] + ); + } catch (\Exception $e) { + throw new \RuntimeException("Unable to copy file from {$source} to {$destination}: " . $e->getMessage(), 0, $e); } } } diff --git a/src/Media/MediaHooks.php b/src/Media/MediaHooks.php index 9a72c77..c4b4899 100644 --- a/src/Media/MediaHooks.php +++ b/src/Media/MediaHooks.php @@ -12,10 +12,9 @@ final class MediaHooks { private string $protocol; private string $streamHost; private string $streamRootPrefix; - private string $s3Prefix; + private string $providerPrefix; private string $baseUrl; private string $effectiveBaseUrl; - private string $remoteUrlPathPrefix; private bool $deleteRemote; private bool $preferLocal; @@ -26,22 +25,25 @@ final class MediaHooks { $this->streamRootPrefix = PathHelper::normalize((string) ($config['stream']['root_prefix'] ?? '')); $this->streamHost = PathHelper::normalize((string) ($config['stream']['host'] ?? '')); - $this->s3Prefix = PathHelper::normalize((string) ($config['s3']['prefix'] ?? '')); + + // ✅ 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->remoteUrlPathPrefix = $this->buildRemoteUrlPathPrefix(); - $this->effectiveBaseUrl = $this->calculateEffectiveBaseUrl($this->baseUrl, $this->remoteUrlPathPrefix); + $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 - protocol: %s, host: %s, root_prefix: %s, base_url: %s, effective_base_url: %s', + '[MediaHooks] Initialized - provider: %s, protocol: %s, host: %s, root_prefix: %s, provider_prefix: %s, base_url: %s', + $provider, $this->protocol, $this->streamHost, $this->streamRootPrefix, - $this->baseUrl, - $this->effectiveBaseUrl + $this->providerPrefix, + $this->baseUrl )); } @@ -59,7 +61,10 @@ final class MediaHooks { $normalizedSubdir = $subdir !== '' ? PathHelper::normalize($subdir) : ''; $streamSubdir = $normalizedSubdir !== '' ? '/' . $normalizedSubdir : ''; - $streamBase = sprintf('%s://%s', $this->protocol, $this->streamHost ?: 'default'); + + // ✅ Usar 'default' solo si NO hay streamHost configurado + $streamHostPart = $this->streamHost !== '' ? $this->streamHost : 'default'; + $streamBase = sprintf('%s://%s', $this->protocol, $streamHostPart); $uploads['path'] = $streamBase . $streamSubdir; $uploads['basedir'] = $streamBase; @@ -71,11 +76,13 @@ final class MediaHooks { $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', + '[MediaHooks] Upload dir filtered - path: %s, url: %s, subdir: %s', $uploads['path'], - $uploads['url'] + $uploads['url'], + $uploads['subdir'] )); return $uploads; @@ -92,15 +99,6 @@ final class MediaHooks { return $url; } - if ($this->remoteUrlPathPrefix !== '') { - $prefixWithSlash = $this->remoteUrlPathPrefix . '/'; - if (str_starts_with($relativePath, $prefixWithSlash)) { - $relativePath = substr($relativePath, strlen($prefixWithSlash)); - } elseif ($relativePath === $this->remoteUrlPathPrefix) { - $relativePath = ''; - } - } - $remoteUrl = $this->buildPublicUrl($relativePath); if (! $this->preferLocal) { @@ -188,25 +186,37 @@ final class MediaHooks { 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 + */ private function toRemotePath(string $file): string { $segments = []; - // ✅ Solo agregar si NO están vacíos - if ($this->streamRootPrefix !== '') { - $segments[] = $this->streamRootPrefix; - } - - if ($this->streamHost !== '') { - $segments[] = $this->streamHost; + // ✅ 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, remote: %s', + '[MediaHooks] toRemotePath - file: %s, provider_prefix: %s, remote: %s', $file, + $this->providerPrefix, $remotePath )); @@ -222,31 +232,6 @@ final class MediaHooks { return rtrim($baseUrl, '/'); } - private function buildRemoteUrlPathPrefix(): string { - $segments = array_filter( - [$this->s3Prefix, $this->streamRootPrefix, $this->streamHost], - static fn (string $segment): bool => $segment !== '' - ); - - return PathHelper::join(...$segments); - } - - private function calculateEffectiveBaseUrl(string $baseUrl, string $pathPrefix): string { - $baseUrl = rtrim($baseUrl, '/'); - - if ($pathPrefix === '') { - return $baseUrl; - } - - $basePath = trim((string) parse_url($baseUrl, PHP_URL_PATH), '/'); - - if ($basePath !== '' && str_ends_with($basePath, $pathPrefix)) { - return $baseUrl; - } - - return $baseUrl; - } - private function buildPublicUrl(string $relativePath): string { $base = rtrim($this->effectiveBaseUrl, '/');