This commit is contained in:
Brasdrive 2025-11-09 23:00:19 -04:00
parent b755cb707e
commit e4bff1f14f
2 changed files with 418 additions and 135 deletions

View File

@ -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);
}
}
}

View File

@ -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, '/');