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',
'stream' => [
'protocol' => getenv('FLYSYSTEM_OFFLOAD_STREAM_PROTOCOL') ?: 'flysystem',
'protocol' => getenv('FLYSYSTEM_OFFLOAD_STREAM_PROTOCOL') ?: 'webdav',
'root_prefix' => getenv('FLYSYSTEM_OFFLOAD_STREAM_ROOT_PREFIX') ?: '',
'host' => getenv('FLYSYSTEM_OFFLOAD_STREAM_HOST') ?: '',
],

View File

@ -456,16 +456,28 @@ class ConfigLoader
$config['prefix'] = trim($config['prefix'], '/');
}
// ✅ Asegurar valores por defecto para 'stream' y 'uploads'
if (!isset($config['stream'])) {
$config['stream'] = [
'protocol' => 'flysystem',
'root_prefix' => '',
'host' => 'uploads',
];
// Asegurar 'stream' existe (no sobrescribir si viene del archivo o BD)
if (!isset($config['stream']) || !is_array($config['stream'])) {
$config['stream'] = [];
}
if (!isset($config['uploads'])) {
// Si no hay protocolo explícito en 'stream', usar el provider como protocolo por defecto
$config['stream']['protocol'] = $config['stream']['protocol'] ??
($config['provider'] ?? 'flysystem');
// Si existe una sección específica del provider que define un protocolo (ej. webdav.stream.protocol), respetarla
$provider = $config['provider'] ?? null;
if ($provider && isset($config[$provider]) && is_array($config[$provider])) {
if (isset($config[$provider]['stream']['protocol'])) {
$config['stream']['protocol'] = $config[$provider]['stream']['protocol'];
}
}
// Asegurar valores por defecto para 'stream' y 'uploads' si no vinieron en la configuración
$config['stream']['root_prefix'] = $config['stream']['root_prefix'] ?? '';
$config['stream']['host'] = $config['stream']['host'] ?? '';
if (!isset($config['uploads']) || !is_array($config['uploads'])) {
$config['uploads'] = [
'base_url' => content_url('uploads'),
'delete_remote' => true,
@ -473,6 +485,16 @@ class ConfigLoader
];
}
// Si el provider es webdav y no tenemos uploads.base_url, intentar construirla desde webdav/base_uri + prefix
if (($provider === 'webdav') && (!isset($config['uploads']['base_url']) || empty($config['uploads']['base_url']))) {
$baseUri = $config['base_uri'] ?? ($config['webdav']['base_url'] ?? ($config['webdav']['endpoint'] ?? ''));
$prefix = $config['prefix'] ?? ($config['webdav']['prefix'] ?? '');
if (!empty($baseUri)) {
$baseUri = rtrim($baseUri, '/');
$config['uploads']['base_url'] = $prefix !== '' ? $baseUri . '/' . ltrim($prefix, '/') : $baseUri;
}
}
error_log('[Flysystem Offload] Final normalized config: ' . print_r($config, true));
return $config;

View File

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

View File

@ -1,247 +1,247 @@
<?php
declare(strict_types=1);
namespace FlysystemOffload\Media;
use FlysystemOffload\Helpers\PathHelper;
use League\Flysystem\FilesystemOperator;
use FlysystemOffload\Plugin;
final class MediaHooks {
private FilesystemOperator $filesystem;
private array $config;
/**
* Hooks para integración con el sistema de medios de WordPress
* Intercepta uploads, generación de URLs, eliminación de archivos, etc.
*/
class MediaHooks
{
private string $provider;
private string $protocol;
private string $streamHost;
private string $streamRootPrefix;
private string $host;
private string $rootPrefix;
private string $providerPrefix;
private string $baseUrl;
private string $effectiveBaseUrl;
private bool $deleteRemote;
private bool $preferLocal;
public function __construct(FilesystemOperator $filesystem, array $config) {
$this->filesystem = $filesystem;
$this->config = $config;
$this->protocol = (string) ($config['stream']['protocol'] ?? 'flysystem');
$this->streamRootPrefix = PathHelper::normalize((string) ($config['stream']['root_prefix'] ?? ''));
$this->streamHost = PathHelper::normalize((string) ($config['stream']['host'] ?? ''));
public function __construct(array $config)
{
$this->provider = $config['provider'] ?? 'webdav';
$this->protocol = $config['stream']['protocol'] ?? $this->provider;
$this->host = $config['stream']['host'] ?? '';
$this->rootPrefix = $config['stream']['root_prefix'] ?? '';
$this->providerPrefix = $config['prefix'] ?? '';
$this->baseUrl = $config['uploads']['base_url'] ?? '';
// ✅ Obtener el prefix del provider actual
$provider = $config['provider'] ?? 's3';
$this->providerPrefix = PathHelper::normalize((string) ($config[$provider]['prefix'] ?? $config['prefix'] ?? ''));
$this->baseUrl = $this->normaliseBaseUrl((string) ($config['uploads']['base_url'] ?? content_url('uploads')));
$this->effectiveBaseUrl = $this->baseUrl;
$this->deleteRemote = (bool) ($config['uploads']['delete_remote'] ?? true);
$this->preferLocal = (bool) ($config['uploads']['prefer_local_for_missing'] ?? false);
error_log(sprintf(
'[MediaHooks] Initialized - provider: %s, protocol: %s, host: %s, root_prefix: %s, provider_prefix: %s, base_url: %s',
$provider,
$this->provider,
$this->protocol,
$this->streamHost,
$this->streamRootPrefix,
$this->host,
$this->rootPrefix,
$this->providerPrefix,
$this->baseUrl
));
}
public function register(): void {
add_filter('upload_dir', [$this, 'filterUploadDir'], 20);
add_filter('pre_option_upload_path', '__return_false');
add_filter('pre_option_upload_url_path', '__return_false');
add_filter('wp_get_attachment_url', [$this, 'rewriteAttachmentUrl'], 9, 2);
add_filter('image_downsize', [$this, 'filterImageDownsize'], 10, 3);
add_action('delete_attachment', [$this, 'deleteRemoteFiles']);
/**
* Registra los hooks de WordPress
*/
public function registerHooks(): void
{
// Filtros para upload dir
add_filter('upload_dir', [$this, 'filterUploadDir']);
// Filtros para URLs de medios
add_filter('wp_get_attachment_url', [$this, 'filterAttachmentUrl'], 10, 2);
add_filter('wp_calculate_image_srcset', [$this, 'filterImageSrcset'], 10, 5);
// Acciones para manejo de archivos
add_action('wp_generate_attachment_metadata', [$this, 'handleAttachmentMetadata'], 10, 2);
add_action('delete_attachment', [$this, 'handleDeleteAttachment']);
// Filtro para obtener el path correcto
add_filter('get_attached_file', [$this, 'filterAttachedFile'], 10, 2);
}
public function filterUploadDir(array $uploads): array {
$subdir = $uploads['subdir'] ?? '';
$normalizedSubdir = $subdir !== '' ? PathHelper::normalize($subdir) : '';
$streamSubdir = $normalizedSubdir !== '' ? '/' . $normalizedSubdir : '';
// 🚫 No forzar 'default' si no hay streamHost definido
$streamBase = $this->protocol . '://';
if ($this->streamHost !== '') {
$streamBase .= $this->streamHost;
/**
* Filtra el directorio de uploads para usar nuestro sistema de archivos
*/
public function filterUploadDir(array $uploads): array
{
// Construir el path con el protocolo correcto
$path = $this->protocol . '://';
if ($this->host) {
$path .= $this->host . '/';
}
$uploads['path'] = $streamBase . $streamSubdir;
$uploads['basedir'] = $streamBase;
$uploads['baseurl'] = rtrim($this->effectiveBaseUrl, '/');
$uploads['url'] = $this->buildPublicUrl($normalizedSubdir);
$uploads['subdir'] = $normalizedSubdir !== '' ? '/' . $normalizedSubdir : '';
$uploads['error'] = false;
$uploads['flysystem_protocol'] = $this->protocol;
$uploads['flysystem_host'] = $this->streamHost;
$uploads['flysystem_root_prefix'] = $this->streamRootPrefix;
$uploads['flysystem_provider_prefix'] = $this->providerPrefix;
error_log(sprintf(
'[MediaHooks] Upload dir filtered - path: %s, url: %s, subdir: %s',
$uploads['path'],
$uploads['url'],
$uploads['subdir']
));
if ($this->rootPrefix) {
$path .= ltrim($this->rootPrefix, '/') . '/';
}
// Actualizar uploads array
$uploads['path'] = $path;
$uploads['url'] = rtrim($this->baseUrl, '/') . '/';
$uploads['subdir'] = '';
$uploads['basedir'] = $path;
error_log('[MediaHooks] Upload dir filtered - path: ' . $path . ', url: ' . $uploads['url'] . ', subdir: ' . $uploads['subdir']);
return $uploads;
}
public function rewriteAttachmentUrl(string $url, int $attachmentId): string {
/**
* Filtra la URL de un attachment para usar la URL remota
*/
public function filterAttachmentUrl(string $url, int $attachmentId): string
{
// Obtener metadata del attachment
$metadata = wp_get_attachment_metadata($attachmentId);
if (!$metadata) {
return $url;
}
// Obtener el path del archivo
$file = get_post_meta($attachmentId, '_wp_attached_file', true);
if (! $file) {
if (!$file) {
return $url;
}
// Construir URL remota
$remoteUrl = rtrim($this->baseUrl, '/') . '/' . ltrim($file, '/');
error_log('[MediaHooks] Attachment URL filtered - local: ' . $url . ', remote: ' . $remoteUrl);
return $remoteUrl;
}
$relativePath = PathHelper::normalize($file);
if ($relativePath === '') {
return $url;
/**
* Filtra el srcset de imágenes para usar URLs remotas
*/
public function filterImageSrcset(array $sources, array $sizeArray, string $imageSrc, array $imageMeta, int $attachmentId): array
{
if (empty($sources)) {
return $sources;
}
$remoteUrl = $this->buildPublicUrl($relativePath);
if (! $this->preferLocal) {
return $remoteUrl;
// Obtener el path base del archivo
$file = get_post_meta($attachmentId, '_wp_attached_file', true);
if (!$file) {
return $sources;
}
try {
if ($this->filesystem->fileExists($this->toRemotePath($relativePath))) {
return $remoteUrl;
// Calcular el directorio base
$basePath = dirname($file);
if ($basePath === '.') {
$basePath = '';
}
// Actualizar cada source con URL remota
foreach ($sources as &$source) {
if (isset($source['url'])) {
$filename = basename($source['url']);
if ($basePath) {
$remotePath = $basePath . '/' . $filename;
} else {
$remotePath = $filename;
}
$source['url'] = rtrim($this->baseUrl, '/') . '/' . ltrim($remotePath, '/');
}
} catch (\Throwable $exception) {
error_log(sprintf(
'[Flysystem Offload] No se pudo verificar la existencia remota de "%s": %s',
$relativePath,
$exception->getMessage()
));
}
return $url;
return $sources;
}
public function filterImageDownsize(bool|array $out, int $attachmentId, array|string $size): bool|array {
return false;
/**
* Maneja la generación de metadata de attachments
*/
public function handleAttachmentMetadata(array $metadata, int $attachmentId): array
{
error_log('[MediaHooks] Handling attachment metadata for ID: ' . $attachmentId);
// Verificar si se generaron tamaños
if (isset($metadata['sizes']) && is_array($metadata['sizes'])) {
error_log('[MediaHooks] Generated sizes: ' . print_r(array_keys($metadata['sizes']), true));
// Para cada tamaño generado, asegurarse de que se suba al sistema remoto
foreach ($metadata['sizes'] as $sizeName => $sizeData) {
if (isset($sizeData['file'])) {
$this->ensureSizeUploaded($attachmentId, $sizeData['file']);
}
}
}
return $metadata;
}
public function deleteRemoteFiles(int $attachmentId): void {
if (! $this->deleteRemote) {
/**
* Asegura que un tamaño específico se haya subido
*/
private function ensureSizeUploaded(int $attachmentId, string $filename): void
{
// Obtener el directorio de uploads local
$uploadDir = wp_upload_dir();
$localPath = $uploadDir['basedir'] . '/' . $filename;
// Verificar si el archivo local existe
if (!file_exists($localPath)) {
error_log('[MediaHooks] Local size file not found: ' . $localPath);
return;
}
// Construir el path remoto
$remotePath = $this->protocol . ':///' . $filename;
// Copiar el archivo al sistema remoto
if (copy($localPath, $remotePath)) {
error_log('[MediaHooks] Size uploaded successfully: ' . $filename);
} else {
error_log('[MediaHooks] Failed to upload size: ' . $filename);
}
}
$files = $this->gatherAttachmentFiles($attachmentId);
foreach ($files as $file) {
$key = $this->toRemotePath($file);
try {
if ($this->filesystem->fileExists($key)) {
$this->filesystem->delete($key);
/**
* Maneja la eliminación de attachments
*/
public function handleDeleteAttachment(int $attachmentId): void
{
error_log('[MediaHooks] Handling delete attachment: ' . $attachmentId);
// Obtener el archivo principal
$file = get_post_meta($attachmentId, '_wp_attached_file', true);
if ($file) {
$this->deleteRemoteFile($file);
}
// Obtener metadata para eliminar tamaños
$metadata = wp_get_attachment_metadata($attachmentId);
if ($metadata && isset($metadata['sizes'])) {
foreach ($metadata['sizes'] as $sizeData) {
if (isset($sizeData['file'])) {
$this->deleteRemoteFile($sizeData['file']);
}
} catch (\Throwable $exception) {
error_log(sprintf(
'[Flysystem Offload] No se pudo eliminar el archivo remoto "%s": %s',
$key,
$exception->getMessage()
));
}
}
}
/**
* @return list<string>
* Elimina un archivo remoto
*/
private function gatherAttachmentFiles(int $attachmentId): array {
$files = [];
$attachedFile = get_post_meta($attachmentId, '_wp_attached_file', true);
if ($attachedFile) {
$files[] = $attachedFile;
}
$meta = wp_get_attachment_metadata($attachmentId);
if (is_array($meta)) {
if (! empty($meta['file'])) {
$files[] = $meta['file'];
}
if (! empty($meta['sizes']) && is_array($meta['sizes'])) {
$baseDir = $this->dirName($meta['file'] ?? '');
foreach ($meta['sizes'] as $sizeMeta) {
if (! empty($sizeMeta['file'])) {
$files[] = ($baseDir !== '' ? $baseDir . '/' : '') . $sizeMeta['file'];
}
}
private function deleteRemoteFile(string $filename): void
{
$remotePath = $this->protocol . ':///' . ltrim($filename, '/');
if (file_exists($remotePath)) {
if (unlink($remotePath)) {
error_log('[MediaHooks] Remote file deleted: ' . $filename);
} else {
error_log('[MediaHooks] Failed to delete remote file: ' . $filename);
}
} else {
error_log('[MediaHooks] Remote file not found for deletion: ' . $filename);
}
$files = array_filter($files, static fn ($file) => is_string($file) && $file !== '');
return array_values(array_unique($files, SORT_STRING));
}
private function dirName(string $path): string {
$directory = dirname($path);
return $directory === '.' ? '' : $directory;
}
/**
* Convierte una ruta relativa de WordPress a la ruta remota en el filesystem
*
* Para WebDAV con prefix, solo usa el archivo relativo
* Para S3 u otros, puede incluir streamRootPrefix y streamHost
* Filtra el path del archivo adjunto
*/
private function toRemotePath(string $file): string {
$segments = [];
// ✅ Si hay providerPrefix (WebDAV, S3, etc.), NO agregar streamRootPrefix ni streamHost
// El prefix del provider ya incluye la ruta base completa
if ($this->providerPrefix === '') {
// Solo para providers sin prefix (local, etc.)
if ($this->streamRootPrefix !== '') {
$segments[] = $this->streamRootPrefix;
}
if ($this->streamHost !== '') {
$segments[] = $this->streamHost;
}
}
// Agregar el archivo relativo
$segments[] = $file;
$remotePath = PathHelper::join(...$segments);
error_log(sprintf(
'[MediaHooks] toRemotePath - file: %s, provider_prefix: %s, remote: %s',
$file,
$this->providerPrefix,
$remotePath
));
return $remotePath;
}
private function normaliseBaseUrl(string $baseUrl): string {
$baseUrl = trim($baseUrl);
if ($baseUrl === '') {
$baseUrl = content_url('uploads');
}
return rtrim($baseUrl, '/');
}
private function buildPublicUrl(string $relativePath): string {
$base = rtrim($this->effectiveBaseUrl, '/');
if ($relativePath === '') {
return $base;
}
return $base . '/' . PathHelper::normalize($relativePath);
public function filterAttachedFile(string $file, int $attachmentId): string
{
// Devolver el path con el protocolo correcto
$filename = basename($file);
return $this->protocol . ':///' . $filename;
}
}

View File

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

View File

@ -9,12 +9,12 @@ use League\Flysystem\UnableToWriteFile;
/**
* Stream wrapper para Flysystem
* Soporta protocolo configurable (por defecto 'flysystem')
* Soporta protocolo configurable (por defecto 'fly')
*/
class FlysystemStreamWrapper
{
private static ?FilesystemOperator $filesystem = null;
private static string $protocol = 'flysystem';
private static string $protocol = 'fly';
/** @var resource|null */
private $stream;
@ -22,12 +22,6 @@ class FlysystemStreamWrapper
/** @var string Ruta remota normalizada (sin protocolo ni host) */
private string $path = '';
/** @var string Buffer en memoria para modo write */
private string $buffer = '';
/** @var int Posición del puntero */
private int $position = 0;
/** @var string Modo de apertura */
private string $mode = '';
@ -37,7 +31,7 @@ class FlysystemStreamWrapper
/**
* Registra el stream wrapper
*/
public static function register(FilesystemOperator $filesystem, string $protocol = 'flysystem'): void
public static function register(FilesystemOperator $filesystem, string $protocol = 'fly'): void
{
self::$filesystem = $filesystem;
self::$protocol = $protocol;
@ -57,24 +51,33 @@ class FlysystemStreamWrapper
/**
* Normaliza ruta removiendo protocolo y host
* Ejemplo: flysystem://uploads/2025/11/file.jpg -> 2025/11/file.jpg
* Ejemplo: fly://uploads/2025/11/file.jpg -> uploads/2025/11/file.jpg
*/
private static function normalizePath(string $path): string
{
// Remover protocolo
$p = preg_replace('#^' . preg_quote(self::$protocol, '#') . '://#', '', $path) ?? '';
// Remover host si existe (primer segmento después de //)
// Ejemplo: flysystem://uploads/2025/11/file.jpg -> 2025/11/file.jpg
$parts = explode('/', ltrim($p, '/'), 2);
if (count($parts) === 2) {
// Si hay host, devolver solo la parte después del host
return $parts[1];
// Remover cualquier host si existe (primer segmento después de //)
if (strpos($p, '/') !== false) {
$parts = explode('/', $p, 2);
if (count($parts) === 2) {
return $parts[1];
}
}
return ltrim($p, '/');
return $p;
}
/**
* Opens file or URL
*
* @param string $path
* @param string $mode
* @param int $options
* @param string|null $opened_path
* @return bool
*/
public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool
{
if (!self::$filesystem) {
@ -84,137 +87,325 @@ class FlysystemStreamWrapper
$this->path = self::normalizePath($path);
$this->mode = $mode;
$this->buffer = '';
$this->position = 0;
error_log(sprintf('[FlysystemStreamWrapper] stream_open path="%s" normalized="%s" mode="%s"', $path, $this->path, $mode));
// Para lectura, intentar cargar contenido existente
if (str_contains($mode, 'r') || str_contains($mode, '+')) {
try {
$this->buffer = self::$filesystem->read($this->path);
error_log('[FlysystemStreamWrapper] Loaded existing file (' . strlen($this->buffer) . ' bytes)');
} catch (\Throwable $e) {
// Si no existe y es modo 'r' puro, fallar
if ($mode === 'r') {
error_log('[FlysystemStreamWrapper] stream_open read error: ' . $e->getMessage());
try {
// Modo lectura
if (strpos($mode, 'r') !== false) {
// Verificar si el archivo existe
if (!self::$filesystem->fileExists($this->path)) {
error_log(sprintf('[FlysystemStreamWrapper] stream_open error: file does not exist "%s"', $this->path));
return false;
}
// Para otros modos (w, a, etc.) continuar con buffer vacío
// Leer el contenido del archivo desde el filesystem
$contents = self::$filesystem->read($this->path);
// Crear un stream temporal en memoria
$this->stream = fopen('php://temp', 'r+b');
if ($this->stream === false) {
error_log(sprintf('[FlysystemStreamWrapper] stream_open error: failed to create temp stream for "%s"', $this->path));
return false;
}
// Escribir el contenido en el stream temporal
fwrite($this->stream, $contents);
// Volver al inicio del stream
rewind($this->stream);
error_log(sprintf('[FlysystemStreamWrapper] stream_open read mode: loaded %d bytes for "%s"', strlen($contents), $this->path));
return true;
}
}
// En modo append, posicionar al final
if (str_starts_with($mode, 'a')) {
$this->position = strlen($this->buffer);
}
// Modo escritura
if (strpos($mode, 'w') !== false || strpos($mode, 'a') !== false || strpos($mode, 'x') !== false || strpos($mode, 'c') !== false) {
// Crear un stream temporal en memoria para escritura
$this->stream = fopen('php://temp', 'r+b');
if ($this->stream === false) {
error_log(sprintf('[FlysystemStreamWrapper] stream_open error: failed to create temp stream for "%s"', $this->path));
return false;
}
// En modo append, posicionar al final
if (strpos($mode, 'a') !== false) {
// Si el archivo existe, cargar su contenido
if (self::$filesystem->fileExists($this->path)) {
$contents = self::$filesystem->read($this->path);
fwrite($this->stream, $contents);
}
fseek($this->stream, 0, SEEK_END);
}
error_log(sprintf('[FlysystemStreamWrapper] stream_open write mode for "%s"', $this->path));
return true;
}
return true;
}
public function stream_write(string $data): int
{
$len = strlen($data);
// Insertar data en la posición actual
$before = substr($this->buffer, 0, $this->position);
$after = substr($this->buffer, $this->position);
$this->buffer = $before . $data . $after;
$this->position += $len;
error_log(sprintf('[FlysystemStreamWrapper] stream_write %d bytes (buffer now %d bytes)', $len, strlen($this->buffer)));
return $len;
}
public function stream_read(int $count): string
{
$chunk = substr($this->buffer, $this->position, $count);
$this->position += strlen($chunk);
return $chunk;
}
public function stream_tell(): int
{
return $this->position;
}
public function stream_eof(): bool
{
return $this->position >= strlen($this->buffer);
}
public function stream_seek(int $offset, int $whence = SEEK_SET): bool
{
$newPos = $this->position;
switch ($whence) {
case SEEK_SET:
$newPos = $offset;
break;
case SEEK_CUR:
$newPos += $offset;
break;
case SEEK_END:
$newPos = strlen($this->buffer) + $offset;
break;
}
if ($newPos < 0) {
error_log(sprintf('[FlysystemStreamWrapper] stream_open error: unsupported mode "%s" for "%s"', $mode, $this->path));
return false;
} catch (\Exception $e) {
error_log(sprintf('[FlysystemStreamWrapper] stream_open exception: %s', $e->getMessage()));
return false;
}
$this->position = $newPos;
return true;
}
public function stream_flush(): bool
/**
* Read from stream
*
* @param int $count
* @return string|false
*/
public function stream_read(int $count)
{
// Persistir buffer en remoto
if ($this->buffer === '') {
return true;
if (!$this->stream) {
error_log('[FlysystemStreamWrapper] stream_read error: no stream resource');
return false;
}
try {
error_log('[FlysystemStreamWrapper] stream_flush -> write to "' . $this->path . '" (' . strlen($this->buffer) . ' bytes)');
self::$filesystem->write($this->path, $this->buffer);
return true;
} catch (UnableToWriteFile $e) {
error_log('[FlysystemStreamWrapper] stream_flush UnableToWriteFile: ' . $e->getMessage());
return false;
} catch (\Throwable $e) {
error_log('[FlysystemStreamWrapper] stream_flush error: ' . $e->getMessage());
$data = fread($this->stream, $count);
if ($data === false) {
error_log(sprintf('[FlysystemStreamWrapper] stream_read error for "%s"', $this->path));
return false;
}
error_log(sprintf('[FlysystemStreamWrapper] stream_read %d bytes from "%s"', strlen($data), $this->path));
return $data;
} catch (\Exception $e) {
error_log(sprintf('[FlysystemStreamWrapper] stream_read exception: %s', $e->getMessage()));
return false;
}
}
public function stream_close(): void
/**
* Write to stream
*
* @param string $data
* @return int|false
*/
public function stream_write(string $data)
{
// Flush final en close
if ($this->buffer !== '' && str_contains($this->mode, 'w') || str_contains($this->mode, 'a') || str_contains($this->mode, '+')) {
try {
error_log('[FlysystemStreamWrapper] stream_close -> persisting "' . $this->path . '"');
self::$filesystem->write($this->path, $this->buffer);
} catch (\Throwable $e) {
error_log('[FlysystemStreamWrapper] stream_close error: ' . $e->getMessage());
if (!$this->stream) {
error_log('[FlysystemStreamWrapper] stream_write error: no stream resource');
return false;
}
try {
$bytesWritten = fwrite($this->stream, $data);
if ($bytesWritten === false) {
error_log(sprintf('[FlysystemStreamWrapper] stream_write error for "%s"', $this->path));
return false;
}
error_log(sprintf('[FlysystemStreamWrapper] stream_write %d bytes to "%s"', $bytesWritten, $this->path));
return $bytesWritten;
} catch (\Exception $e) {
error_log(sprintf('[FlysystemStreamWrapper] stream_write exception: %s', $e->getMessage()));
return false;
}
}
/**
* Tests for end-of-file on a file pointer
*
* @return bool
*/
public function stream_eof(): bool
{
if (!$this->stream) {
return true;
}
$this->buffer = '';
$this->position = 0;
return feof($this->stream);
}
public function stream_stat(): array
/**
* Retrieve the current position of a stream
*
* @return int
*/
public function stream_tell(): int
{
return $this->getStatArray();
if (!$this->stream) {
return 0;
}
$position = ftell($this->stream);
return $position !== false ? $position : 0;
}
/**
* Seeks to specific location in a stream
*
* @param int $offset
* @param int $whence
* @return bool
*/
public function stream_seek(int $offset, int $whence = SEEK_SET): bool
{
if (!$this->stream) {
error_log('[FlysystemStreamWrapper] stream_seek error: no stream resource');
return false;
}
try {
$result = fseek($this->stream, $offset, $whence);
if ($result === 0) {
error_log(sprintf('[FlysystemStreamWrapper] stream_seek to %d (whence: %d) for "%s"', $offset, $whence, $this->path));
return true;
}
error_log(sprintf('[FlysystemStreamWrapper] stream_seek failed for "%s"', $this->path));
return false;
} catch (\Exception $e) {
error_log(sprintf('[FlysystemStreamWrapper] stream_seek exception: %s', $e->getMessage()));
return false;
}
}
/**
* Retrieve information about a file resource
*
* @return array|false
*/
public function stream_stat()
{
if (!$this->stream) {
error_log('[FlysystemStreamWrapper] stream_stat error: no stream resource');
return false;
}
try {
$stat = fstat($this->stream);
if ($stat === false) {
error_log(sprintf('[FlysystemStreamWrapper] stream_stat failed for "%s"', $this->path));
return false;
}
error_log(sprintf('[FlysystemStreamWrapper] stream_stat for "%s": size=%d', $this->path, $stat['size'] ?? 0));
return $stat;
} catch (\Exception $e) {
error_log(sprintf('[FlysystemStreamWrapper] stream_stat exception: %s', $e->getMessage()));
return false;
}
}
/**
* Flushes the output
*
* @return bool
*/
public function stream_flush(): bool
{
if (!$this->stream) {
error_log('[FlysystemStreamWrapper] stream_flush error: no stream resource');
return false;
}
try {
// Forzar escritura del buffer interno de PHP
fflush($this->stream);
// Obtener la posición actual antes de rebobinar
$pos = ftell($this->stream);
// Rebobinar para leer todo el contenido
rewind($this->stream);
$contents = stream_get_contents($this->stream);
// Restaurar posición original
if ($pos !== false) {
fseek($this->stream, $pos);
}
if ($contents === false) {
error_log(sprintf('[FlysystemStreamWrapper] stream_flush error reading contents for "%s"', $this->path));
return false;
}
// Escribir en el sistema de archivos
self::$filesystem->write($this->path, $contents);
error_log(sprintf('[FlysystemStreamWrapper] stream_flush wrote %d bytes to "%s"', strlen($contents), $this->path));
return true;
} catch (\Exception $e) {
error_log(sprintf('[FlysystemStreamWrapper] stream_flush exception: %s', $e->getMessage()));
return false;
}
}
/**
* Close a resource
*/
public function stream_close(): void
{
if ($this->stream) {
try {
// Obtener la posición actual antes de rebobinar
$pos = ftell($this->stream);
// Rebobinar para leer todo el contenido
rewind($this->stream);
$contents = stream_get_contents($this->stream);
// Solo escribir si hay contenido y el modo permite escritura
if ($contents !== false && strlen($contents) > 0 &&
(strpos($this->mode, 'w') !== false ||
strpos($this->mode, 'a') !== false ||
strpos($this->mode, 'x') !== false ||
strpos($this->mode, 'c') !== false ||
strpos($this->mode, '+') !== false)) {
// Escribir en el sistema de archivos
self::$filesystem->write($this->path, $contents);
error_log(sprintf('[FlysystemStreamWrapper] stream_close wrote %d bytes to "%s"', strlen($contents), $this->path));
}
} catch (\Exception $e) {
error_log(sprintf('[FlysystemStreamWrapper] stream_close exception: %s', $e->getMessage()));
}
fclose($this->stream);
$this->stream = null;
}
}
/**
* Retrieve information about a file
*
* @param string $path
* @param int $flags
* @return array|false
*/
public function url_stat(string $path, int $flags)
{
$p = self::normalizePath($path);
try {
$size = self::$filesystem->fileSize($p);
return $this->getStatArray($size);
} catch (\Throwable $e) {
// Verificar si es un directorio
if (self::$filesystem->directoryExists($p)) {
return $this->getStatArray(0, true);
}
// Verificar si es un archivo
if (self::$filesystem->fileExists($p)) {
$size = self::$filesystem->fileSize($p);
return $this->getStatArray($size, false);
}
// No existe
if ($flags & STREAM_URL_STAT_QUIET) {
return false;
}
return false;
} catch (\Exception $e) {
error_log(sprintf('[FlysystemStreamWrapper] url_stat exception for "%s": %s', $p, $e->getMessage()));
if ($flags & STREAM_URL_STAT_QUIET) {
return false;
}
@ -222,73 +413,114 @@ class FlysystemStreamWrapper
}
}
/**
* Delete a file
*
* @param string $path
* @return bool
*/
public function unlink(string $path): bool
{
$p = self::normalizePath($path);
try {
error_log('[FlysystemStreamWrapper] unlink "' . $p . '"');
self::$filesystem->delete($p);
return true;
} catch (\Throwable $e) {
} catch (\Exception $e) {
error_log('[FlysystemStreamWrapper] unlink error: ' . $e->getMessage());
return false;
}
}
/**
* Create a directory
*
* @param string $path
* @param int $mode
* @param int $options
* @return bool
*/
public function mkdir(string $path, int $mode, int $options): bool
{
$p = self::normalizePath($path);
try {
error_log('[FlysystemStreamWrapper] mkdir "' . $p . '"');
self::$filesystem->createDirectory($p);
return true;
} catch (\Throwable $e) {
} catch (\Exception $e) {
error_log('[FlysystemStreamWrapper] mkdir error: ' . $e->getMessage());
return false;
}
}
/**
* Remove a directory
*
* @param string $path
* @param int $options
* @return bool
*/
public function rmdir(string $path, int $options): bool
{
$p = self::normalizePath($path);
try {
error_log('[FlysystemStreamWrapper] rmdir "' . $p . '"');
self::$filesystem->deleteDirectory($p);
return true;
} catch (\Throwable $e) {
} catch (\Exception $e) {
error_log('[FlysystemStreamWrapper] rmdir error: ' . $e->getMessage());
return false;
}
}
/**
* Rename a file or directory
*
* @param string $path_from
* @param string $path_to
* @return bool
*/
public function rename(string $path_from, string $path_to): bool
{
$from = self::normalizePath($path_from);
$to = self::normalizePath($path_to);
try {
error_log('[FlysystemStreamWrapper] rename "' . $from . '" -> "' . $to . '"');
self::$filesystem->move($from, $to);
return true;
} catch (\Throwable $e) {
} catch (\Exception $e) {
error_log('[FlysystemStreamWrapper] rename error: ' . $e->getMessage());
return false;
}
}
private function getStatArray(int $size = 0): array
/**
* Genera array stat para archivos y directorios
*
* @param int $size Tamaño del archivo
* @param bool $isDir Si es directorio
* @return array
*/
private function getStatArray(int $size = 0, bool $isDir = false): array
{
$mode = $isDir ? 0040777 : 0100666; // Directorio o archivo regular
return [
0 => 0, 'dev' => 0,
1 => 0, 'ino' => 0,
2 => 0100666, 'mode' => 0100666,
2 => $mode, 'mode' => $mode,
3 => 0, 'nlink' => 0,
4 => 0, 'uid' => 0,
5 => 0, 'gid' => 0,
6 => -1, 'rdev' => -1,
7 => $size, 'size' => $size,
8 => 0, 'atime' => 0,
9 => 0, 'mtime' => 0,
10 => 0, 'ctime' => 0,
8 => time(), 'atime' => time(),
9 => time(), 'mtime' => time(),
10 => time(), 'ctime' => time(),
11 => -1, 'blksize' => -1,
12 => -1, 'blocks' => -1,
];