This commit is contained in:
Brasdrive 2025-11-10 01:58:54 -04:00
parent 04784ee337
commit f8c7736735
6 changed files with 608 additions and 347 deletions

View File

@ -7,7 +7,7 @@ return [
'visibility' => getenv('FLYSYSTEM_OFFLOAD_DEFAULT_VISIBILITY') ?: 'public', 'visibility' => getenv('FLYSYSTEM_OFFLOAD_DEFAULT_VISIBILITY') ?: 'public',
'stream' => [ 'stream' => [
'protocol' => getenv('FLYSYSTEM_OFFLOAD_STREAM_PROTOCOL') ?: 'flysystem', 'protocol' => getenv('FLYSYSTEM_OFFLOAD_STREAM_PROTOCOL') ?: 'webdav',
'root_prefix' => getenv('FLYSYSTEM_OFFLOAD_STREAM_ROOT_PREFIX') ?: '', 'root_prefix' => getenv('FLYSYSTEM_OFFLOAD_STREAM_ROOT_PREFIX') ?: '',
'host' => getenv('FLYSYSTEM_OFFLOAD_STREAM_HOST') ?: '', 'host' => getenv('FLYSYSTEM_OFFLOAD_STREAM_HOST') ?: '',
], ],

View File

@ -456,16 +456,28 @@ class ConfigLoader
$config['prefix'] = trim($config['prefix'], '/'); $config['prefix'] = trim($config['prefix'], '/');
} }
// ✅ Asegurar valores por defecto para 'stream' y 'uploads' // Asegurar 'stream' existe (no sobrescribir si viene del archivo o BD)
if (!isset($config['stream'])) { if (!isset($config['stream']) || !is_array($config['stream'])) {
$config['stream'] = [ $config['stream'] = [];
'protocol' => 'flysystem',
'root_prefix' => '',
'host' => 'uploads',
];
} }
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'] = [ $config['uploads'] = [
'base_url' => content_url('uploads'), 'base_url' => content_url('uploads'),
'delete_remote' => true, '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)); error_log('[Flysystem Offload] Final normalized config: ' . print_r($config, true));
return $config; return $config;

View File

@ -52,21 +52,24 @@ class WebdavAdapter implements FilesystemAdapter
error_log('[WebdavAdapter] Ensuring base directory exists...'); 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 = ''; $path = '';
foreach ($parts as $part) { foreach ($parts as $part) {
$path .= '/' . $part; // Construir path RELATIVO (sin slash inicial)
$path .= ($path === '' ? '' : '/') . $part;
try { try {
// Intentar verificar si existe // Intentar verificar si existe (sin slash inicial = relativo al base_uri)
$this->client->propFind($path, ['{DAV:}resourcetype'], 0); $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) { } catch (\Exception $e) {
// No existe, crear // 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 { try {
// IMPORTANTE: Sin slash inicial = relativo al base_uri
$response = $this->client->request('MKCOL', $path); $response = $this->client->request('MKCOL', $path);
// Verificar el código de estado // Verificar el código de estado
@ -74,10 +77,10 @@ class WebdavAdapter implements FilesystemAdapter
if ($statusCode >= 200 && $statusCode < 300) { if ($statusCode >= 200 && $statusCode < 300) {
// Éxito (201 Created) // É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) { } elseif ($statusCode === 405) {
// 405 Method Not Allowed = ya existe // 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 { } else {
// Error // Error
$errorMsg = sprintf('Failed to create directory "%s", status: %d', $path, $statusCode); $errorMsg = sprintf('Failed to create directory "%s", status: %d', $path, $statusCode);
@ -87,10 +90,10 @@ class WebdavAdapter implements FilesystemAdapter
} catch (\Exception $e2) { } catch (\Exception $e2) {
// Verificar si el error es porque ya existe (405) // Verificar si el error es porque ya existe (405)
if (strpos($e2->getMessage(), '405') !== false) { 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 { } else {
error_log(sprintf( error_log(sprintf(
'[WebdavAdapter] Failed to create base directory: "%s", error: %s', '[WebdavAdapter] Failed to create directory: "%s", error: %s',
$path, $path,
$e2->getMessage() $e2->getMessage()
)); ));
@ -104,21 +107,25 @@ class WebdavAdapter implements FilesystemAdapter
error_log('[WebdavAdapter] Base directory ensured successfully'); 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 private function prefixPath(string $path): string
{ {
$path = trim($path, '/');
if ($this->prefix === '') { if ($this->prefix === '') {
$prefixed = '/' . ltrim($path, '/'); $result = $path;
} else { } else {
$prefixed = '/' . $this->prefix . '/' . ltrim($path, '/'); $result = trim($this->prefix, '/') . ($path === '' ? '' : '/' . $path);
} }
error_log(sprintf( error_log(sprintf('[WebdavAdapter] prefixPath - input: "%s", output: "%s"', $path, $result));
'[WebdavAdapter] prefixPath - input: "%s", output: "%s"',
$path,
$prefixed
));
return $prefixed; return $result;
} }
public function fileExists(string $path): bool public function fileExists(string $path): bool

View File

@ -1,247 +1,247 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
namespace FlysystemOffload\Media; namespace FlysystemOffload\Media;
use FlysystemOffload\Helpers\PathHelper; use FlysystemOffload\Plugin;
use League\Flysystem\FilesystemOperator;
final class MediaHooks { /**
private FilesystemOperator $filesystem; * Hooks para integración con el sistema de medios de WordPress
private array $config; * Intercepta uploads, generación de URLs, eliminación de archivos, etc.
*/
class MediaHooks
{
private string $provider;
private string $protocol; private string $protocol;
private string $streamHost; private string $host;
private string $streamRootPrefix; private string $rootPrefix;
private string $providerPrefix; private string $providerPrefix;
private string $baseUrl; private string $baseUrl;
private string $effectiveBaseUrl;
private bool $deleteRemote;
private bool $preferLocal;
public function __construct(FilesystemOperator $filesystem, array $config) { public function __construct(array $config)
$this->filesystem = $filesystem; {
$this->config = $config; $this->provider = $config['provider'] ?? 'webdav';
$this->protocol = (string) ($config['stream']['protocol'] ?? 'flysystem'); $this->protocol = $config['stream']['protocol'] ?? $this->provider;
$this->host = $config['stream']['host'] ?? '';
$this->streamRootPrefix = PathHelper::normalize((string) ($config['stream']['root_prefix'] ?? '')); $this->rootPrefix = $config['stream']['root_prefix'] ?? '';
$this->streamHost = PathHelper::normalize((string) ($config['stream']['host'] ?? '')); $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( error_log(sprintf(
'[MediaHooks] Initialized - provider: %s, protocol: %s, host: %s, root_prefix: %s, provider_prefix: %s, base_url: %s', '[MediaHooks] Initialized - provider: %s, protocol: %s, host: %s, root_prefix: %s, provider_prefix: %s, base_url: %s',
$provider, $this->provider,
$this->protocol, $this->protocol,
$this->streamHost, $this->host,
$this->streamRootPrefix, $this->rootPrefix,
$this->providerPrefix, $this->providerPrefix,
$this->baseUrl $this->baseUrl
)); ));
} }
public function register(): void { /**
add_filter('upload_dir', [$this, 'filterUploadDir'], 20); * Registra los hooks de WordPress
add_filter('pre_option_upload_path', '__return_false'); */
add_filter('pre_option_upload_url_path', '__return_false'); public function registerHooks(): void
add_filter('wp_get_attachment_url', [$this, 'rewriteAttachmentUrl'], 9, 2); {
add_filter('image_downsize', [$this, 'filterImageDownsize'], 10, 3); // Filtros para upload dir
add_action('delete_attachment', [$this, 'deleteRemoteFiles']); 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'] ?? ''; * Filtra el directorio de uploads para usar nuestro sistema de archivos
$normalizedSubdir = $subdir !== '' ? PathHelper::normalize($subdir) : ''; */
public function filterUploadDir(array $uploads): array
$streamSubdir = $normalizedSubdir !== '' ? '/' . $normalizedSubdir : ''; {
// Construir el path con el protocolo correcto
// 🚫 No forzar 'default' si no hay streamHost definido $path = $this->protocol . '://';
$streamBase = $this->protocol . '://'; if ($this->host) {
$path .= $this->host . '/';
if ($this->streamHost !== '') { }
$streamBase .= $this->streamHost; if ($this->rootPrefix) {
$path .= ltrim($this->rootPrefix, '/') . '/';
} }
$uploads['path'] = $streamBase . $streamSubdir; // Actualizar uploads array
$uploads['basedir'] = $streamBase; $uploads['path'] = $path;
$uploads['baseurl'] = rtrim($this->effectiveBaseUrl, '/'); $uploads['url'] = rtrim($this->baseUrl, '/') . '/';
$uploads['url'] = $this->buildPublicUrl($normalizedSubdir); $uploads['subdir'] = '';
$uploads['subdir'] = $normalizedSubdir !== '' ? '/' . $normalizedSubdir : ''; $uploads['basedir'] = $path;
$uploads['error'] = false;
$uploads['flysystem_protocol'] = $this->protocol; error_log('[MediaHooks] Upload dir filtered - path: ' . $path . ', url: ' . $uploads['url'] . ', subdir: ' . $uploads['subdir']);
$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']
));
return $uploads; 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); $file = get_post_meta($attachmentId, '_wp_attached_file', true);
if (! $file) { if (!$file) {
return $url; return $url;
} }
$relativePath = PathHelper::normalize($file); // Construir URL remota
if ($relativePath === '') { $remoteUrl = rtrim($this->baseUrl, '/') . '/' . ltrim($file, '/');
return $url; error_log('[MediaHooks] Attachment URL filtered - local: ' . $url . ', remote: ' . $remoteUrl);
return $remoteUrl;
}
/**
* 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); // Obtener el path base del archivo
$file = get_post_meta($attachmentId, '_wp_attached_file', true);
if (! $this->preferLocal) { if (!$file) {
return $remoteUrl; return $sources;
} }
try { // Calcular el directorio base
if ($this->filesystem->fileExists($this->toRemotePath($relativePath))) { $basePath = dirname($file);
return $remoteUrl; 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; return;
} }
$files = $this->gatherAttachmentFiles($attachmentId); // Construir el path remoto
$remotePath = $this->protocol . ':///' . $filename;
foreach ($files as $file) { // Copiar el archivo al sistema remoto
$key = $this->toRemotePath($file); if (copy($localPath, $remotePath)) {
error_log('[MediaHooks] Size uploaded successfully: ' . $filename);
} else {
error_log('[MediaHooks] Failed to upload size: ' . $filename);
}
}
try { /**
if ($this->filesystem->fileExists($key)) { * Maneja la eliminación de attachments
$this->filesystem->delete($key); */
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<string> * Elimina un archivo remoto
*/ */
private function gatherAttachmentFiles(int $attachmentId): array { private function deleteRemoteFile(string $filename): void
$files = []; {
$remotePath = $this->protocol . ':///' . ltrim($filename, '/');
$attachedFile = get_post_meta($attachmentId, '_wp_attached_file', true); if (file_exists($remotePath)) {
if ($attachedFile) { if (unlink($remotePath)) {
$files[] = $attachedFile; error_log('[MediaHooks] Remote file deleted: ' . $filename);
} } else {
error_log('[MediaHooks] Failed to delete remote file: ' . $filename);
$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'];
}
}
} }
} 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 * Filtra el path del archivo adjunto
*
* Para WebDAV con prefix, solo usa el archivo relativo
* Para S3 u otros, puede incluir streamRootPrefix y streamHost
*/ */
private function toRemotePath(string $file): string { public function filterAttachedFile(string $file, int $attachmentId): string
$segments = []; {
// Devolver el path con el protocolo correcto
// ✅ Si hay providerPrefix (WebDAV, S3, etc.), NO agregar streamRootPrefix ni streamHost $filename = basename($file);
// El prefix del provider ya incluye la ruta base completa return $this->protocol . ':///' . $filename;
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);
} }
} }

View File

@ -57,9 +57,9 @@ class Plugin
error_log('[Flysystem Offload] Stream wrappers: ' . implode(', ', stream_get_wrappers())); error_log('[Flysystem Offload] Stream wrappers: ' . implode(', ', stream_get_wrappers()));
// Registrar hooks de medios // Registrar hooks de medios - pasar primero $config, luego $filesystem
self::$mediaHooks = new MediaHooks(self::$filesystem, self::$config); self::$mediaHooks = new MediaHooks(self::$config, self::$filesystem);
self::$mediaHooks->register(); self::$mediaHooks->registerHooks(); // <-- CORRECCIÓN: llamar al método que existe
// Registrar página de ajustes // Registrar página de ajustes
if (is_admin()) { if (is_admin()) {

View File

@ -9,12 +9,12 @@ use League\Flysystem\UnableToWriteFile;
/** /**
* Stream wrapper para Flysystem * Stream wrapper para Flysystem
* Soporta protocolo configurable (por defecto 'flysystem') * Soporta protocolo configurable (por defecto 'fly')
*/ */
class FlysystemStreamWrapper class FlysystemStreamWrapper
{ {
private static ?FilesystemOperator $filesystem = null; private static ?FilesystemOperator $filesystem = null;
private static string $protocol = 'flysystem'; private static string $protocol = 'fly';
/** @var resource|null */ /** @var resource|null */
private $stream; private $stream;
@ -22,12 +22,6 @@ class FlysystemStreamWrapper
/** @var string Ruta remota normalizada (sin protocolo ni host) */ /** @var string Ruta remota normalizada (sin protocolo ni host) */
private string $path = ''; 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 */ /** @var string Modo de apertura */
private string $mode = ''; private string $mode = '';
@ -37,7 +31,7 @@ class FlysystemStreamWrapper
/** /**
* Registra el stream wrapper * 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::$filesystem = $filesystem;
self::$protocol = $protocol; self::$protocol = $protocol;
@ -57,24 +51,33 @@ class FlysystemStreamWrapper
/** /**
* Normaliza ruta removiendo protocolo y host * 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 private static function normalizePath(string $path): string
{ {
// Remover protocolo // Remover protocolo
$p = preg_replace('#^' . preg_quote(self::$protocol, '#') . '://#', '', $path) ?? ''; $p = preg_replace('#^' . preg_quote(self::$protocol, '#') . '://#', '', $path) ?? '';
// Remover host si existe (primer segmento después de //) // Remover cualquier host si existe (primer segmento después de //)
// Ejemplo: flysystem://uploads/2025/11/file.jpg -> 2025/11/file.jpg if (strpos($p, '/') !== false) {
$parts = explode('/', ltrim($p, '/'), 2); $parts = explode('/', $p, 2);
if (count($parts) === 2) { if (count($parts) === 2) {
// Si hay host, devolver solo la parte después del host return $parts[1];
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 public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool
{ {
if (!self::$filesystem) { if (!self::$filesystem) {
@ -84,137 +87,325 @@ class FlysystemStreamWrapper
$this->path = self::normalizePath($path); $this->path = self::normalizePath($path);
$this->mode = $mode; $this->mode = $mode;
$this->buffer = '';
$this->position = 0;
error_log(sprintf('[FlysystemStreamWrapper] stream_open path="%s" normalized="%s" mode="%s"', $path, $this->path, $mode)); error_log(sprintf('[FlysystemStreamWrapper] stream_open path="%s" normalized="%s" mode="%s"', $path, $this->path, $mode));
// Para lectura, intentar cargar contenido existente try {
if (str_contains($mode, 'r') || str_contains($mode, '+')) { // Modo lectura
try { if (strpos($mode, 'r') !== false) {
$this->buffer = self::$filesystem->read($this->path); // Verificar si el archivo existe
error_log('[FlysystemStreamWrapper] Loaded existing file (' . strlen($this->buffer) . ' bytes)'); if (!self::$filesystem->fileExists($this->path)) {
} catch (\Throwable $e) { error_log(sprintf('[FlysystemStreamWrapper] stream_open error: file does not exist "%s"', $this->path));
// Si no existe y es modo 'r' puro, fallar
if ($mode === 'r') {
error_log('[FlysystemStreamWrapper] stream_open read error: ' . $e->getMessage());
return false; 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 // Modo escritura
if (str_starts_with($mode, 'a')) { if (strpos($mode, 'w') !== false || strpos($mode, 'a') !== false || strpos($mode, 'x') !== false || strpos($mode, 'c') !== false) {
$this->position = strlen($this->buffer); // Crear un stream temporal en memoria para escritura
} $this->stream = fopen('php://temp', 'r+b');
return true; if ($this->stream === false) {
} error_log(sprintf('[FlysystemStreamWrapper] stream_open error: failed to create temp stream for "%s"', $this->path));
return false;
}
public function stream_write(string $data): int // En modo append, posicionar al final
{ if (strpos($mode, 'a') !== false) {
$len = strlen($data); // 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);
}
// Insertar data en la posición actual error_log(sprintf('[FlysystemStreamWrapper] stream_open write mode for "%s"', $this->path));
$before = substr($this->buffer, 0, $this->position); return true;
$after = substr($this->buffer, $this->position); }
$this->buffer = $before . $data . $after; error_log(sprintf('[FlysystemStreamWrapper] stream_open error: unsupported mode "%s" for "%s"', $mode, $this->path));
$this->position += $len; return false;
} catch (\Exception $e) {
error_log(sprintf('[FlysystemStreamWrapper] stream_write %d bytes (buffer now %d bytes)', $len, strlen($this->buffer))); error_log(sprintf('[FlysystemStreamWrapper] stream_open exception: %s', $e->getMessage()));
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; 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->stream) {
if ($this->buffer === '') { error_log('[FlysystemStreamWrapper] stream_read error: no stream resource');
return true; return false;
} }
try { try {
error_log('[FlysystemStreamWrapper] stream_flush -> write to "' . $this->path . '" (' . strlen($this->buffer) . ' bytes)'); $data = fread($this->stream, $count);
self::$filesystem->write($this->path, $this->buffer);
return true; if ($data === false) {
} catch (UnableToWriteFile $e) { error_log(sprintf('[FlysystemStreamWrapper] stream_read error for "%s"', $this->path));
error_log('[FlysystemStreamWrapper] stream_flush UnableToWriteFile: ' . $e->getMessage()); return false;
return false; }
} catch (\Throwable $e) {
error_log('[FlysystemStreamWrapper] stream_flush error: ' . $e->getMessage()); 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; return false;
} }
} }
/**
* Write to stream
*
* @param string $data
* @return int|false
*/
public function stream_write(string $data)
{
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;
}
return feof($this->stream);
}
/**
* Retrieve the current position of a stream
*
* @return int
*/
public function stream_tell(): int
{
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 public function stream_close(): void
{ {
// Flush final en close if ($this->stream) {
if ($this->buffer !== '' && str_contains($this->mode, 'w') || str_contains($this->mode, 'a') || str_contains($this->mode, '+')) {
try { try {
error_log('[FlysystemStreamWrapper] stream_close -> persisting "' . $this->path . '"'); // Obtener la posición actual antes de rebobinar
self::$filesystem->write($this->path, $this->buffer); $pos = ftell($this->stream);
} catch (\Throwable $e) {
error_log('[FlysystemStreamWrapper] stream_close error: ' . $e->getMessage()); // 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;
} }
$this->buffer = '';
$this->position = 0;
}
public function stream_stat(): array
{
return $this->getStatArray();
} }
/**
* Retrieve information about a file
*
* @param string $path
* @param int $flags
* @return array|false
*/
public function url_stat(string $path, int $flags) public function url_stat(string $path, int $flags)
{ {
$p = self::normalizePath($path); $p = self::normalizePath($path);
try { try {
$size = self::$filesystem->fileSize($p); // Verificar si es un directorio
return $this->getStatArray($size); if (self::$filesystem->directoryExists($p)) {
} catch (\Throwable $e) { 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) { if ($flags & STREAM_URL_STAT_QUIET) {
return false; return false;
} }
@ -222,73 +413,114 @@ class FlysystemStreamWrapper
} }
} }
/**
* Delete a file
*
* @param string $path
* @return bool
*/
public function unlink(string $path): bool public function unlink(string $path): bool
{ {
$p = self::normalizePath($path); $p = self::normalizePath($path);
try { try {
error_log('[FlysystemStreamWrapper] unlink "' . $p . '"'); error_log('[FlysystemStreamWrapper] unlink "' . $p . '"');
self::$filesystem->delete($p); self::$filesystem->delete($p);
return true; return true;
} catch (\Throwable $e) { } catch (\Exception $e) {
error_log('[FlysystemStreamWrapper] unlink error: ' . $e->getMessage()); error_log('[FlysystemStreamWrapper] unlink error: ' . $e->getMessage());
return false; 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 public function mkdir(string $path, int $mode, int $options): bool
{ {
$p = self::normalizePath($path); $p = self::normalizePath($path);
try { try {
error_log('[FlysystemStreamWrapper] mkdir "' . $p . '"'); error_log('[FlysystemStreamWrapper] mkdir "' . $p . '"');
self::$filesystem->createDirectory($p); self::$filesystem->createDirectory($p);
return true; return true;
} catch (\Throwable $e) { } catch (\Exception $e) {
error_log('[FlysystemStreamWrapper] mkdir error: ' . $e->getMessage()); error_log('[FlysystemStreamWrapper] mkdir error: ' . $e->getMessage());
return false; return false;
} }
} }
/**
* Remove a directory
*
* @param string $path
* @param int $options
* @return bool
*/
public function rmdir(string $path, int $options): bool public function rmdir(string $path, int $options): bool
{ {
$p = self::normalizePath($path); $p = self::normalizePath($path);
try { try {
error_log('[FlysystemStreamWrapper] rmdir "' . $p . '"'); error_log('[FlysystemStreamWrapper] rmdir "' . $p . '"');
self::$filesystem->deleteDirectory($p); self::$filesystem->deleteDirectory($p);
return true; return true;
} catch (\Throwable $e) { } catch (\Exception $e) {
error_log('[FlysystemStreamWrapper] rmdir error: ' . $e->getMessage()); error_log('[FlysystemStreamWrapper] rmdir error: ' . $e->getMessage());
return false; 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 public function rename(string $path_from, string $path_to): bool
{ {
$from = self::normalizePath($path_from); $from = self::normalizePath($path_from);
$to = self::normalizePath($path_to); $to = self::normalizePath($path_to);
try { try {
error_log('[FlysystemStreamWrapper] rename "' . $from . '" -> "' . $to . '"'); error_log('[FlysystemStreamWrapper] rename "' . $from . '" -> "' . $to . '"');
self::$filesystem->move($from, $to); self::$filesystem->move($from, $to);
return true; return true;
} catch (\Throwable $e) { } catch (\Exception $e) {
error_log('[FlysystemStreamWrapper] rename error: ' . $e->getMessage()); error_log('[FlysystemStreamWrapper] rename error: ' . $e->getMessage());
return false; 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 [ return [
0 => 0, 'dev' => 0, 0 => 0, 'dev' => 0,
1 => 0, 'ino' => 0, 1 => 0, 'ino' => 0,
2 => 0100666, 'mode' => 0100666, 2 => $mode, 'mode' => $mode,
3 => 0, 'nlink' => 0, 3 => 0, 'nlink' => 0,
4 => 0, 'uid' => 0, 4 => 0, 'uid' => 0,
5 => 0, 'gid' => 0, 5 => 0, 'gid' => 0,
6 => -1, 'rdev' => -1, 6 => -1, 'rdev' => -1,
7 => $size, 'size' => $size, 7 => $size, 'size' => $size,
8 => 0, 'atime' => 0, 8 => time(), 'atime' => time(),
9 => 0, 'mtime' => 0, 9 => time(), 'mtime' => time(),
10 => 0, 'ctime' => 0, 10 => time(), 'ctime' => time(),
11 => -1, 'blksize' => -1, 11 => -1, 'blksize' => -1,
12 => -1, 'blocks' => -1, 12 => -1, 'blocks' => -1,
]; ];