This commit is contained in:
Brasdrive 2025-11-08 19:32:32 -04:00
parent 65c7a7365d
commit 388f5112de
14 changed files with 1105 additions and 1250 deletions

View File

@ -1,19 +1,19 @@
{
"name": "tu-nombre/flysystem-offload",
"name": "brasdrive/flysystem-offload",
"description": "Universal storage offloading for WordPress vía Flysystem",
"type": "wordpress-plugin",
"require": {
"php": ">=7.4",
"php": ">=8.1",
"league/flysystem": "^3.24",
"league/flysystem-aws-s3-v3": "^3.24",
"league/flysystem-sftp-v3": "^3.24",
"league/flysystem-azure-blob-storage": "^3.24",
"league/flysystem-google-cloud-storage": "^3.24",
"league/flysystem-webdav": "^3.24",
"league/flysystem-path-prefixing": "^3.24",
"aws/aws-sdk-php": "^3.330",
"google/cloud-storage": "^1.33",
"microsoft/azure-storage-blob": "^1.5",
"azure-oss/storage-blob-flysystem": "^1.3.0",
"sabre/dav": "^4.5"
},
"autoload": {

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
return [
'driver' => 's3',
'visibility' => 'public',
'stream' => [
'protocol' => 'flysystem',
'root_prefix' => '',
'host' => 'uploads',
],
'uploads' => [
'base_url' => getenv('FLYSYSTEM_OFFLOAD_BASE_URL') ?: 'https://your-bucket.s3.amazonaws.com',
'delete_remote' => true,
'prefer_local_for_missing' => false,
],
'admin' => [
'enabled' => false,
],
's3' => [
'key' => getenv('AWS_ACCESS_KEY_ID') ?: null,
'secret' => getenv('AWS_SECRET_ACCESS_KEY') ?: null,
'session_token' => getenv('AWS_SESSION_TOKEN') ?: null,
'region' => getenv('AWS_DEFAULT_REGION') ?: 'us-east-1',
'bucket' => getenv('FLYSYSTEM_OFFLOAD_BUCKET') ?: 'your-bucket-name',
'prefix' => getenv('FLYSYSTEM_OFFLOAD_PREFIX') ?: null,
'endpoint' => getenv('FLYSYSTEM_OFFLOAD_ENDPOINT') ?: null,
'use_path_style_endpoint' => (bool) (getenv('AWS_USE_PATH_STYLE_ENDPOINT') ?: false),
'version' => 'latest',
'options' => [],
],
];

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
return [
'driver' => 's3',
'visibility' => 'public',
'stream' => [
'protocol' => 'flysystem',
'root_prefix' => '',
'host' => 'uploads',
],
'uploads' => [
'base_url' => getenv('FLYSYSTEM_OFFLOAD_BASE_URL') ?: 'https://your-bucket.s3.amazonaws.com',
'delete_remote' => true,
'prefer_local_for_missing' => false,
],
'admin' => [
'enabled' => false,
],
's3' => [
'key' => getenv('AWS_ACCESS_KEY_ID') ?: null,
'secret' => getenv('AWS_SECRET_ACCESS_KEY') ?: null,
'session_token' => getenv('AWS_SESSION_TOKEN') ?: null,
'region' => getenv('AWS_DEFAULT_REGION') ?: 'us-east-1',
'bucket' => getenv('FLYSYSTEM_OFFLOAD_BUCKET') ?: 'your-bucket-name',
'prefix' => getenv('FLYSYSTEM_OFFLOAD_PREFIX') ?: null,
'endpoint' => getenv('FLYSYSTEM_OFFLOAD_ENDPOINT') ?: null,
'use_path_style_endpoint' => (bool) (getenv('AWS_USE_PATH_STYLE_ENDPOINT') ?: false),
'version' => 'latest',
'options' => [],
],
];

View File

@ -1,14 +1,58 @@
<?php
/**
* Plugin Name: Flysystem Offload
* Description: Universal storage offloading para WordPress usando Flysystem.
* Version: 0.1.0
* Author: Tu Nombre
* Plugin URI: https://git.brasdrive.com.br/Brasdrive/flysystem-offload
* Description: Reemplaza el filesystem local de WordPress con almacenamiento remoto transparente usando Flysystem v3.
* Version: 0.3.0
* Author: Brasdrive
* License: GPLv2 or later
* Text Domain: flysystem-offload
*/
defined('ABSPATH') || exit;
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
if (! defined('ABSPATH')) {
exit;
}
FlysystemOffload\Plugin::bootstrap(__FILE__);
if (! defined('FLYSYSTEM_OFFLOAD_PATH')) {
define('FLYSYSTEM_OFFLOAD_PATH', __DIR__);
}
if (! defined('FLYSYSTEM_OFFLOAD_FILE')) {
define('FLYSYSTEM_OFFLOAD_FILE', __FILE__);
}
if (! defined('FLYSYSTEM_OFFLOAD_CONFIG_PATH')) {
define('FLYSYSTEM_OFFLOAD_CONFIG_PATH', FLYSYSTEM_OFFLOAD_PATH . '/config');
}
$autoload = __DIR__ . '/vendor/autoload.php';
if (file_exists($autoload)) {
require_once $autoload;
}
use FlysystemOffload\Plugin;
use Throwable;
add_action('plugins_loaded', static function (): void {
if (! class_exists(Plugin::class)) {
error_log('[Flysystem Offload] No fue posible cargar la clase principal del plugin.');
return;
}
try {
Plugin::bootstrap();
} catch (Throwable $exception) {
error_log('[Flysystem Offload] Error al iniciar el plugin: ' . $exception->getMessage());
add_action('admin_notices', static function () use ($exception): void {
if (! current_user_can('manage_options')) {
return;
}
printf(
'<div class="notice notice-error"><p><strong>Flysystem Offload:</strong> %s</p></div>',
esc_html($exception->getMessage())
);
});
}
}, 0);

View File

@ -1,73 +1,65 @@
<?php
declare(strict_types=1);
namespace FlysystemOffload\Admin;
use FlysystemOffload\Filesystem\FilesystemFactory;
use League\Flysystem\FilesystemException;
use League\Flysystem\Visibility;
use WP_CLI;
use WP_CLI_Command;
use WP_Error;
use FlysystemOffload\Helpers\PathHelper;
use League\Flysystem\FilesystemOperator;
class HealthCheck extends WP_CLI_Command
{
private FilesystemFactory $factory;
final class HealthCheck {
private FilesystemOperator $filesystem;
private array $config;
public function __construct(FilesystemFactory $factory)
{
$this->factory = $factory;
public function __construct(FilesystemOperator $filesystem, array $config) {
$this->filesystem = $filesystem;
$this->config = $config;
}
/**
* Ejecuta un chequeo básico de conectividad e integración.
*
* ## EXAMPLES
*
* wp flysystem-offload health-check
*/
public function __invoke(): void
{
$settings = get_option('flysystem_offload_settings', []);
if (! is_array($settings)) {
WP_CLI::error('No se encontraron ajustes del plugin.');
return;
}
$result = $this->run($settings);
if ($result instanceof WP_Error) {
WP_CLI::error($result->get_error_message());
return;
}
WP_CLI::success('Chequeo completado correctamente.');
public function register(): void {
\add_filter('site_status_tests', [$this, 'registerTest']);
}
/**
* @param array<string, mixed> $settings
*/
public function run(array $settings)
{
try {
$filesystem = $this->factory->build($settings);
} catch (FilesystemException|\Throwable $exception) {
return new WP_Error('flysystem_offload_health_check_failed', $exception->getMessage());
}
public function registerTest(array $tests): array {
$tests['direct']['flysystem_offload'] = [
'label' => __('Flysystem Offload', 'flysystem-offload'),
'test' => [$this, 'runHealthTest'],
];
$testKey = sprintf('health-check/%s.txt', wp_generate_uuid4());
return $tests;
}
public function runHealthTest(): array {
$result = [
'label' => __('Flysystem Offload operativo', 'flysystem-offload'),
'status' => 'good',
'badge' => [
'label' => __('Flysystem', 'flysystem-offload'),
'color' => 'blue',
],
'description' => __('El almacenamiento remoto respondió correctamente a una operación de escritura/lectura.', 'flysystem-offload'),
'actions' => '',
'test' => 'flysystem_offload',
];
$probeKey = PathHelper::join(
$this->config['stream']['root_prefix'] ?? '',
$this->config['stream']['host'] ?? 'uploads',
'.flysystem-offload-site-health'
);
try {
$filesystem->write($testKey, 'ok', ['visibility' => Visibility::PUBLIC]);
$filesystem->delete($testKey);
$this->filesystem->write($probeKey, 'ok', ['visibility' => $this->config['visibility'] ?? 'public']);
$this->filesystem->delete($probeKey);
} catch (\Throwable $exception) {
return new WP_Error('flysystem_offload_health_check_failed', $exception->getMessage());
$result['status'] = 'critical';
$result['label'] = __('No se pudo escribir en el almacenamiento remoto', 'flysystem-offload');
$result['description'] = sprintf(
'<p>%s</p><p><code>%s</code></p>',
esc_html__('Se produjo un error al comunicarse con el backend configurado.', 'flysystem-offload'),
esc_html($exception->getMessage())
);
}
return true;
return $result;
}
}

View File

@ -1,132 +1,58 @@
<?php
declare(strict_types=1);
namespace FlysystemOffload\Config;
class ConfigLoader
{
private string $pluginDir;
use RuntimeException;
use UnexpectedValueException;
public function __construct(string $pluginFile)
{
$this->pluginDir = dirname($pluginFile);
final class ConfigLoader {
private string $configDirectory;
public function __construct(string $configDirectory) {
$this->configDirectory = rtrim($configDirectory, '/\\');
}
/**
* Devuelve la configuración efectiva (defaults + overrides).
*
* @throws \RuntimeException cuando un archivo config no retorna array.
*/
public function load(): array
{
$defaults = $this->defaults();
$files = $this->discoverConfigFiles();
$config = [];
foreach ($files as $file) {
$data = require $file;
if (! is_array($data)) {
throw new \RuntimeException(
sprintf('[Flysystem Offload] El archivo de configuración "%s" debe retornar un array.', $file)
);
}
$config = array_replace_recursive($config, $data);
}
if (empty($config)) {
$config = $defaults;
} else {
$config = array_replace_recursive($defaults, $config);
}
/**
* Permite ajustar/ensanchar la configuración en tiempo de ejecución.
* Ideal para multisite o integraciones externas.
*
* @param array $config
* @param array $files Lista de archivos usados (en orden de carga).
*/
return apply_filters('flysystem_offload_config', $config, $files);
}
/**
* Defaults que garantizan compatibilidad si falta un archivo.
*/
public function defaults(): array
{
return [
'adapter' => 'local',
'base_prefix' => '',
'adapters' => [
's3' => [
'access_key' => '',
'secret_key' => '',
'region' => '',
'bucket' => '',
'prefix' => '',
'endpoint' => '',
'cdn_url' => '',
],
'sftp' => [
'host' => '',
'port' => 22,
'username' => '',
'password' => '',
'root' => '/uploads',
],
'gcs' => [
'project_id' => '',
'bucket' => '',
'key_file_path' => '',
],
'azure' => [
'account_name' => '',
'account_key' => '',
'container' => '',
'prefix' => '',
],
'webdav' => [
'base_uri' => '',
'username' => '',
'password' => '',
'path_prefix'=> '',
],
'googledrive' => [],
'onedrive' => [],
'dropbox' => [],
],
public function load(): array {
$candidateFiles = [
$this->configDirectory . '/flysystem-offload.local.php',
$this->configDirectory . '/flysystem-offload.php',
$this->configDirectory . '/flysystem-offload.example.php',
];
}
/**
* Descubre archivos de configuración en orden de prioridad.
*
* @return string[]
*/
private function discoverConfigFiles(): array
{
$candidates = [];
$configFile = $this->resolveFirstExisting($candidateFiles);
if (defined('FLYSYSTEM_OFFLOAD_CONFIG')) {
$candidates[] = FLYSYSTEM_OFFLOAD_CONFIG;
if ($configFile === null) {
throw new RuntimeException(
sprintf(
'No se pudo localizar un archivo de configuración para Flysystem Offload. Esperado en: %s',
implode(', ', $candidateFiles)
)
);
}
// Opción por defecto en wp-content/.
$candidates[] = WP_CONTENT_DIR . '/flysystem-offload.php';
$config = include $configFile;
// Alias alternativo frecuente.
$candidates[] = WP_CONTENT_DIR . '/flysystem-offload-config.php';
if ($config instanceof \Closure) {
$config = $config();
}
// Fallback incluido dentro del plugin (para entornos sin personalización inicial).
$candidates[] = $this->pluginDir . '/config/flysystem-offload.php';
if (! is_array($config)) {
throw new UnexpectedValueException(
sprintf('El archivo de configuración debe retornar un array. Archivo: %s', $configFile)
);
}
$unique = array_unique(array_filter(
$candidates,
static fn (string $path) => is_readable($path)
));
return $config;
}
return array_values($unique);
private function resolveFirstExisting(array $files): ?string {
foreach ($files as $file) {
if ($file && is_readable($file)) {
return $file;
}
}
return null;
}
}

View File

@ -1,26 +1,10 @@
<?php
declare(strict_types=1);
namespace FlysystemOffload\Filesystem;
use League\Flysystem\FilesystemAdapter;
use WP_Error;
interface AdapterInterface
{
/**
* @param array $settings Configuración específica del adaptador.
* @return FilesystemAdapter|WP_Error
*/
public function create(array $settings);
/**
* Devuelve la URL base pública para el adaptador.
*/
public function publicBaseUrl(array $settings): string;
/**
* Validación previa de opciones. Debe devolver true en caso de éxito.
*
* @return true|WP_Error
*/
public function validate(array $settings);
interface AdapterInterface {
public function createAdapter(array $config): FilesystemAdapter;
}

View File

@ -5,125 +5,93 @@ namespace FlysystemOffload\Filesystem\Adapters;
use Aws\S3\S3Client;
use FlysystemOffload\Filesystem\AdapterInterface;
use FlysystemOffload\Helpers\PathHelper;
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
use League\Flysystem\AwsS3V3\PortableVisibilityConverter;
use League\Flysystem\FilesystemAdapter;
use League\Flysystem\PathPrefixing\PathPrefixedAdapter;
use League\MimeTypeDetection\ExtensionMimeTypeDetector;
use League\MimeTypeDetection\MimeTypeDetector;
use WP_Error;
use League\Flysystem\Visibility;
use RuntimeException;
class S3Adapter implements AdapterInterface
{
private MimeTypeDetector $mimeTypeDetector;
final class S3Adapter implements AdapterInterface {
public function createAdapter(array $config): FilesystemAdapter {
$settings = $config['s3'] ?? [];
public function __construct(?MimeTypeDetector $mimeTypeDetector = null)
{
$this->mimeTypeDetector = $mimeTypeDetector ?? new ExtensionMimeTypeDetector();
}
$bucket = $settings['bucket'] ?? null;
if (! $bucket) {
throw new RuntimeException('Falta la clave "bucket" en la configuración de S3.');
}
public function validate(array $settings)
{
$required = ['access_key', 'secret_key', 'region', 'bucket'];
$root = PathHelper::normalize((string) ($settings['prefix'] ?? ''));
foreach ($required as $field) {
if (empty($settings[$field])) {
return new WP_Error(
'flysystem_offload_invalid_s3',
sprintf(__('Campo S3 faltante: %s', 'flysystem-offload'), $field)
$clientConfig = [
'version' => $settings['version'] ?? 'latest',
'region' => $settings['region'] ?? 'us-east-1',
];
$credentials = [
'key' => $settings['key'] ?? getenv('AWS_ACCESS_KEY_ID'),
'secret' => $settings['secret'] ?? getenv('AWS_SECRET_ACCESS_KEY'),
];
if (! empty($settings['session_token'])) {
$credentials['token'] = $settings['session_token'];
}
if (! empty($credentials['key']) && ! empty($credentials['secret'])) {
$clientConfig['credentials'] = $credentials;
}
if (! empty($settings['endpoint'])) {
$clientConfig['endpoint'] = $settings['endpoint'];
}
if (isset($settings['use_path_style_endpoint'])) {
$clientConfig['use_path_style_endpoint'] = (bool) $settings['use_path_style_endpoint'];
}
if (! empty($settings['options']) && is_array($settings['options'])) {
$clientConfig = array_replace_recursive($clientConfig, $settings['options']);
}
$client = new S3Client($clientConfig);
$visibility = new PortableVisibilityConverter(
$settings['acl_public'] ?? 'public-read',
$settings['acl_private'] ?? 'private',
$config['visibility'] ?? Visibility::PUBLIC
);
$defaultOptions = $settings['default_options'] ?? [];
$uploadsConfig = $config['uploads'] ?? [];
if (! isset($defaultOptions['CacheControl']) && ! empty($uploadsConfig['cache_control'])) {
$defaultOptions['CacheControl'] = $uploadsConfig['cache_control'];
}
if (! isset($defaultOptions['Expires'])) {
if (! empty($uploadsConfig['expires'])) {
$defaultOptions['Expires'] = $uploadsConfig['expires'];
} elseif (! empty($uploadsConfig['expires_ttl'])) {
$defaultOptions['Expires'] = gmdate(
'D, d M Y H:i:s \\G\\M\\T',
time() + (int) $uploadsConfig['expires_ttl']
);
}
}
return true;
}
$defaultOptions = apply_filters(
'flysystem_offload_s3_default_write_options',
$defaultOptions,
$config
);
public function create(array $settings): FilesystemAdapter|WP_Error
{
try {
$clientConfig = [
'credentials' => [
'key' => $settings['access_key'],
'secret' => $settings['secret_key'],
],
'region' => $settings['region'],
'version' => 'latest',
];
if (! empty($settings['endpoint'])) {
$clientConfig['endpoint'] = rtrim($settings['endpoint'], '/');
$clientConfig['use_path_style_endpoint'] = (bool) ($settings['use_path_style_endpoint'] ?? true);
}
if (! empty($settings['http_client'])) {
$clientConfig['http_client'] = $settings['http_client'];
}
$client = new S3Client($clientConfig);
$adapter = new AwsS3V3Adapter(
$client,
$settings['bucket'],
'',
options: [],
mimeTypeDetector: $this->mimeTypeDetector
);
$prefix = trim((string) ($settings['prefix'] ?? ''), '/');
if ($prefix !== '') {
if (class_exists(PathPrefixedAdapter::class)) {
$adapter = new PathPrefixedAdapter($adapter, $prefix);
} else {
$adapter = new PrefixedAdapter($adapter, $prefix);
}
}
return $adapter;
} catch (\Throwable $e) {
return new WP_Error('flysystem_offload_s3_error', $e->getMessage());
}
}
public function publicBaseUrl(array $settings): string
{
$cdn = $settings['cdn_base_url'] ?? null;
if ($cdn) {
return rtrim($cdn, '/');
}
$bucket = $settings['bucket'] ?? '';
$endpoint = $settings['endpoint'] ?? null;
$region = $settings['region'] ?? 'us-east-1';
$usePathStyle = (bool) ($settings['use_path_style_endpoint'] ?? false);
$prefix = trim((string) ($settings['prefix'] ?? ''), '/');
$normalizedUrl = null;
if ($endpoint) {
$endpoint = rtrim($endpoint, '/');
$parts = parse_url($endpoint);
if (! $parts || empty($parts['host'])) {
$normalizedUrl = sprintf('%s/%s', $endpoint, $bucket);
} elseif ($usePathStyle) {
$path = isset($parts['path']) ? rtrim($parts['path'], '/') : '';
$scheme = $parts['scheme'] ?? 'https';
$port = isset($parts['port']) ? ':' . $parts['port'] : '';
$normalizedUrl = sprintf('%s://%s%s%s/%s', $scheme, $parts['host'], $port, $path, $bucket);
} else {
$scheme = $parts['scheme'] ?? 'https';
$port = isset($parts['port']) ? ':' . $parts['port'] : '';
$path = isset($parts['path']) ? rtrim($parts['path'], '/') : '';
$normalizedUrl = sprintf('%s://%s.%s%s%s', $scheme, $bucket, $parts['host'], $port, $path);
}
}
if (! $normalizedUrl) {
$normalizedUrl = sprintf('https://%s.s3.%s.amazonaws.com', $bucket, $region);
}
return $prefix ? $normalizedUrl . '/' . $prefix : $normalizedUrl;
return new AwsS3V3Adapter(
$client,
(string) $bucket,
$root,
$visibility,
null,
$defaultOptions
);
}
}

View File

@ -3,96 +3,25 @@ declare(strict_types=1);
namespace FlysystemOffload\Filesystem;
use FlysystemOffload\Filesystem\Adapters\AzureBlobAdapter;
use FlysystemOffload\Filesystem\Adapters\DropboxAdapter;
use FlysystemOffload\Filesystem\Adapters\GoogleCloudAdapter;
use FlysystemOffload\Filesystem\Adapters\GoogleDriveAdapter;
use FlysystemOffload\Filesystem\Adapters\OneDriveAdapter;
use FlysystemOffload\Filesystem\Adapters\S3Adapter;
use FlysystemOffload\Filesystem\Adapters\SftpAdapter;
use FlysystemOffload\Filesystem\Adapters\WebdavAdapter;
use League\Flysystem\Filesystem;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\Local\LocalFilesystemAdapter;
use WP_Error;
use League\Flysystem\Visibility;
class FilesystemFactory
{
private array $settings;
final class FilesystemFactory {
public function make(array $config): FilesystemOperator {
$driver = $config['driver'] ?? 's3';
public function __construct(array $settings)
{
$this->settings = $settings;
}
public function make(): FilesystemOperator|WP_Error
{
$adapterKey = $this->settings['adapter'] ?? 'local';
$config = $this->settings['adapters'][$adapterKey] ?? [];
$adapter = $this->resolveAdapter($adapterKey);
if ($adapter instanceof WP_Error) {
return $adapter;
}
$validation = $adapter->validate($config);
if ($validation instanceof WP_Error) {
return $validation;
}
$flyAdapter = $adapter->create($config);
if ($flyAdapter instanceof WP_Error) {
return $flyAdapter;
}
return new Filesystem($flyAdapter);
}
public function resolvePublicBaseUrl(string $adapterKey, array $settings): string
{
$adapter = $this->resolveAdapter($adapterKey);
if ($adapter instanceof WP_Error) {
return content_url('/uploads');
}
$baseUrl = $adapter->publicBaseUrl($settings);
return untrailingslashit($baseUrl);
}
private function resolveAdapter(string $adapterKey): AdapterInterface|WP_Error
{
return match ($adapterKey) {
's3' => new S3Adapter(),
'sftp' => new SftpAdapter(),
'gcs' => new GoogleCloudAdapter(),
'azure' => new AzureBlobAdapter(),
'webdav' => new WebdavAdapter(),
'googledrive' => new GoogleDriveAdapter(), // stub (dev)
'onedrive' => new OneDriveAdapter(), // stub (dev)
'dropbox' => new DropboxAdapter(), // stub (dev)
default => new class implements AdapterInterface {
public function create(array $settings)
{
$root = WP_CONTENT_DIR . '/flysystem-uploads';
return new LocalFilesystemAdapter($root);
}
public function publicBaseUrl(array $settings): string
{
return content_url('/flysystem-uploads');
}
public function validate(array $settings)
{
wp_mkdir_p(WP_CONTENT_DIR . '/flysystem-uploads');
return true;
}
},
$adapter = match ($driver) {
's3' => (new S3Adapter())->createAdapter($config),
default => throw new \InvalidArgumentException(sprintf('Driver de Flysystem no soportado: "%s".', $driver)),
};
$filesystemConfig = [
'visibility' => $config['visibility'] ?? Visibility::PUBLIC,
'directory_visibility' => $config['visibility'] ?? Visibility::PUBLIC,
];
return new Filesystem($adapter, $filesystemConfig);
}
}

View File

@ -3,53 +3,46 @@ declare(strict_types=1);
namespace FlysystemOffload\Helpers;
final class PathHelper
{
private function __construct() {}
public static function stripProtocol(string $path): string
{
return ltrim(preg_replace('#^[^:]+://#', '', $path) ?? '', '/');
}
public static function trimLeadingSlash(string $path): string
{
return ltrim($path, '/');
}
public static function trimTrailingSlash(string $path): string
{
return rtrim($path, '/');
}
public static function trimSlashes(string $path): string
{
return trim($path, '/');
}
public static function ensureTrailingSlash(string $path): string
{
return rtrim($path, '/') . '/';
}
public static function normalizeDirectory(string $path): string
{
$path = str_replace('\\', '/', $path);
$path = preg_replace('#/{2,}#', '/', $path) ?? $path;
return self::trimTrailingSlash($path);
}
public static function normalizePrefix(?string $prefix): string
{
if ($prefix === null || $prefix === '') {
final class PathHelper {
public static function join(string ...$segments): string {
if ($segments === []) {
return '';
}
$prefix = self::stripProtocol($prefix);
$prefix = self::normalizeDirectory($prefix);
$prefix = self::trimSlashes($prefix);
$filtered = array_filter(
$segments,
static fn (string $segment): bool => $segment !== ''
);
return $prefix === '' ? '' : self::ensureTrailingSlash($prefix);
if ($filtered === []) {
return '';
}
$normalized = array_map(
static fn (string $segment): string => self::normalize($segment),
$filtered
);
$normalized = array_filter($normalized, static fn (string $segment): bool => $segment !== '');
if ($normalized === []) {
return '';
}
return implode('/', $normalized);
}
public static function normalize(string $path): string {
if ($path === '') {
return '';
}
$path = str_replace('\\', '/', $path);
$normalized = preg_replace('#/+#', '/', $path);
if ($normalized === null) {
$normalized = $path;
}
return trim($normalized, '/');
}
}

View File

@ -5,306 +5,231 @@ namespace FlysystemOffload\Media;
use FlysystemOffload\Helpers\PathHelper;
use League\Flysystem\FilesystemOperator;
use WP_Error;
class MediaHooks
{
private ?FilesystemOperator $filesystem = null;
private string $basePrefix;
final class MediaHooks {
private FilesystemOperator $filesystem;
private array $config;
private string $protocol;
private string $streamHost;
private string $streamRootPrefix;
private string $s3Prefix;
private string $baseUrl;
private string $effectiveBaseUrl;
private string $remoteUrlPathPrefix;
private bool $deleteRemote;
private bool $preferLocal;
/** @var array<int, array{type:'filter'|'action', hook:string, callback:callable, priority:int, accepted_args:int}> */
private array $attachedCallbacks = [];
private bool $registered = false;
private const IMAGE_EDITOR_IMAGICK = 'FlysystemOffload\\Media\\ImageEditorImagick';
private const IMAGE_EDITOR_GD = 'FlysystemOffload\\Media\\ImageEditorGD';
public function __construct(string $basePrefix = '')
{
$this->basePrefix = PathHelper::trimSlashes($basePrefix);
}
public function register(): void
{
if ($this->registered) {
return;
}
$this->attachFilter('upload_dir', [$this, 'filterUploadDir'], 20, 1);
$this->attachFilter('wp_get_attachment_url', [$this, 'filterAttachmentUrl'], 20, 2);
$this->attachFilter('get_attached_file', [$this, 'filterGetAttachedFile'], 20, 2);
$this->attachFilter('update_attached_file', [$this, 'filterUpdateAttachedFile'], 20, 2);
$this->attachFilter('wp_read_image_metadata', [$this, 'ensureLocalPathForMetadata'], 5, 2);
$this->attachFilter('image_editors', [$this, 'filterImageEditors'], 5, 1);
$this->attachAction('delete_attachment', [$this, 'handleDeleteAttachment'], 20, 1);
$this->registered = true;
}
public function unregister(): void
{
if (! $this->registered) {
return;
}
foreach ($this->attachedCallbacks as $hookData) {
if ($hookData['type'] === 'filter') {
remove_filter(
$hookData['hook'],
$hookData['callback'],
$hookData['priority']
);
} else {
remove_action(
$hookData['hook'],
$hookData['callback'],
$hookData['priority']
);
}
}
$this->attachedCallbacks = [];
$this->registered = false;
}
public function setFilesystem(?FilesystemOperator $filesystem): void
{
public function __construct(FilesystemOperator $filesystem, array $config) {
$this->filesystem = $filesystem;
$this->config = $config;
$this->protocol = (string) $config['stream']['protocol'];
if (class_exists(self::IMAGE_EDITOR_IMAGICK)) {
\call_user_func([self::IMAGE_EDITOR_IMAGICK, 'bootWithFilesystem'], $filesystem);
}
if (class_exists(self::IMAGE_EDITOR_GD)) {
\call_user_func([self::IMAGE_EDITOR_GD, 'bootWithFilesystem'], $filesystem);
}
$this->streamRootPrefix = PathHelper::normalize((string) ($config['stream']['root_prefix'] ?? ''));
$this->streamHost = PathHelper::normalize((string) ($config['stream']['host'] ?? 'uploads')) ?: 'uploads';
$this->s3Prefix = PathHelper::normalize((string) ($config['s3']['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->deleteRemote = (bool) ($config['uploads']['delete_remote'] ?? true);
$this->preferLocal = (bool) ($config['uploads']['prefer_local_for_missing'] ?? false);
}
public function filterUploadDir(array $uploadDir): array
{
$subdir = trim($uploadDir['subdir'] ?? '', '/');
$remoteBase = $this->basePrefix !== '' ? $this->basePrefix . '/' : '';
$remoteBase .= $subdir !== '' ? $subdir : '';
$remoteBase = trim($remoteBase, '/');
$uploadDir['path'] = $remoteBase !== '' ? 'fly://' . $remoteBase : 'fly://';
$uploadDir['basedir'] = $uploadDir['path'];
$uploadDir['subdir'] = $subdir !== '' ? '/' . $subdir : '';
$uploadDir['url'] = $uploadDir['baseurl'] = $uploadDir['url']; // baseurl se sobrescribe en Plugin::filterUploadDir
return $uploadDir;
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']);
}
public function filterAttachmentUrl(string $url, int $attachmentId): string
{
public function filterUploadDir(array $uploads): array {
$subdir = $uploads['subdir'] ?? '';
$normalizedSubdir = $subdir !== '' ? PathHelper::normalize($subdir) : '';
$streamSubdir = $normalizedSubdir !== '' ? '/' . $normalizedSubdir : '';
$streamBase = sprintf('%s://%s', $this->protocol, $this->streamHost);
$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;
return $uploads;
}
public function rewriteAttachmentUrl(string $url, int $attachmentId): string {
$file = get_post_meta($attachmentId, '_wp_attached_file', true);
if (empty($file)) {
if (! $file) {
return $url;
}
$relative = PathHelper::trimLeadingSlash($file);
return trailingslashit($this->getBaseUrl()) . $relative;
}
public function filterGetAttachedFile(string $file, int $attachmentId): string
{
$meta = wp_get_attachment_metadata($attachmentId);
$relative = $meta['file'] ?? get_post_meta($attachmentId, '_wp_attached_file', true);
if (empty($relative)) {
return $file;
$relativePath = PathHelper::normalize($file);
if ($relativePath === '') {
return $url;
}
return 'fly://' . PathHelper::trimLeadingSlash($relative);
}
public function filterUpdateAttachedFile(string $file, int $attachmentId): string
{
if (str_starts_with($file, 'fly://')) {
update_post_meta($attachmentId, '_wp_attached_file', PathHelper::stripProtocol($file));
} else {
update_post_meta($attachmentId, '_wp_attached_file', PathHelper::trimLeadingSlash($file));
}
return $file;
}
public function ensureLocalPathForMetadata($metadata, string $file)
{
if (! str_starts_with($file, 'fly://') || ! $this->filesystem) {
return $metadata;
}
// Fuerza a WP a usar una copia temporal local durante la lectura de EXIF/IPTC
$remotePath = PathHelper::stripProtocol($file);
$temp = $this->downloadToTemp($remotePath);
if (! is_wp_error($temp)) {
$metadata = wp_read_image_metadata($temp);
@unlink($temp);
}
return $metadata;
}
public function filterImageEditors(array $editors): array
{
if (class_exists(self::IMAGE_EDITOR_IMAGICK)) {
$editors = array_filter(
$editors,
static fn (string $editor) => $editor !== \WP_Image_Editor_Imagick::class
);
array_unshift($editors, self::IMAGE_EDITOR_IMAGICK);
}
if (class_exists(self::IMAGE_EDITOR_GD)) {
$editors = array_filter(
$editors,
static fn (string $editor) => $editor !== \WP_Image_Editor_GD::class
);
array_unshift($editors, self::IMAGE_EDITOR_GD);
}
return array_values(array_unique($editors));
}
public function handleDeleteAttachment(int $attachmentId): void
{
if (! $this->filesystem) {
return;
}
$meta = wp_get_attachment_metadata($attachmentId);
$relative = $meta['file'] ?? get_post_meta($attachmentId, '_wp_attached_file', true);
if (empty($relative)) {
return;
}
$base = PathHelper::trimLeadingSlash($relative);
$directory = trim(dirname($base), './');
$targets = [$base];
if (! empty($meta['sizes'])) {
foreach ($meta['sizes'] as $size) {
if (! empty($size['file'])) {
$targets[] = ltrim(($directory ? $directory . '/' : '') . $size['file'], '/');
}
if ($this->remoteUrlPathPrefix !== '') {
$prefixWithSlash = $this->remoteUrlPathPrefix . '/';
if (str_starts_with($relativePath, $prefixWithSlash)) {
$relativePath = substr($relativePath, strlen($prefixWithSlash));
} elseif ($relativePath === $this->remoteUrlPathPrefix) {
$relativePath = '';
}
}
foreach ($targets as $target) {
try {
if ($this->filesystem->fileExists($target)) {
$this->filesystem->delete($target);
}
} catch (\Throwable $e) {
error_log('[Flysystem Offload] Error eliminando ' . $target . ': ' . $e->getMessage());
}
}
}
$remoteUrl = $this->buildPublicUrl($relativePath);
private function attachFilter(
string $hook,
callable $callback,
int $priority = 10,
int $acceptedArgs = 1
): void {
add_filter($hook, $callback, $priority, $acceptedArgs);
$this->attachedCallbacks[] = [
'type' => 'filter',
'hook' => $hook,
'callback' => $callback,
'priority' => $priority,
'accepted_args' => $acceptedArgs,
];
}
private function attachAction(
string $hook,
callable $callback,
int $priority = 10,
int $acceptedArgs = 1
): void {
add_action($hook, $callback, $priority, $acceptedArgs);
$this->attachedCallbacks[] = [
'type' => 'action',
'hook' => $hook,
'callback' => $callback,
'priority' => $priority,
'accepted_args' => $acceptedArgs,
];
}
private function downloadToTemp(string $remotePath)
{
if (! $this->filesystem) {
return new WP_Error(
'flysystem_offload_no_fs',
__('No hay filesystem remoto configurado.', 'flysystem-offload')
);
}
if (! function_exists('wp_tempnam')) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}
$temp = wp_tempnam(basename($remotePath));
if (! $temp) {
return new WP_Error(
'flysystem_offload_temp_fail',
__('No se pudo crear un archivo temporal.', 'flysystem-offload')
);
if (! $this->preferLocal) {
return $remoteUrl;
}
try {
$stream = $this->filesystem->readStream($remotePath);
if (! is_resource($stream)) {
throw new \RuntimeException('No se pudo abrir el stream remoto.');
if ($this->filesystem->fileExists($this->toRemotePath($relativePath))) {
return $remoteUrl;
}
$target = fopen($temp, 'wb');
if (! $target) {
throw new \RuntimeException('No se pudo abrir el archivo temporal.');
}
stream_copy_to_stream($stream, $target);
fclose($stream);
fclose($target);
} catch (\Throwable $e) {
if (isset($stream) && is_resource($stream)) {
fclose($stream);
}
if (isset($target) && is_resource($target)) {
fclose($target);
}
@unlink($temp);
return new WP_Error(
'flysystem_offload_download_fail',
sprintf(
__('Fallo al descargar "%s" del almacenamiento remoto: %s', 'flysystem-offload'),
$remotePath,
$e->getMessage()
)
);
} catch (\Throwable $exception) {
error_log(sprintf(
'[Flysystem Offload] No se pudo verificar la existencia remota de "%s": %s',
$relativePath,
$exception->getMessage()
));
}
return $temp;
return $url;
}
private function getBaseUrl(): string
{
$uploadDir = wp_get_upload_dir();
public function filterImageDownsize(bool|array $out, int $attachmentId, array|string $size): bool|array {
return false;
}
return $uploadDir['baseurl'] ?? content_url('/uploads');
public function deleteRemoteFiles(int $attachmentId): void {
if (! $this->deleteRemote) {
return;
}
$files = $this->gatherAttachmentFiles($attachmentId);
foreach ($files as $file) {
$key = $this->toRemotePath($file);
try {
if ($this->filesystem->fileExists($key)) {
$this->filesystem->delete($key);
}
} catch (\Throwable $exception) {
error_log(sprintf(
'[Flysystem Offload] No se pudo eliminar el archivo remoto "%s": %s',
$key,
$exception->getMessage()
));
}
}
}
/**
* @return list<string>
*/
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'];
}
}
}
}
$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;
}
private function toRemotePath(string $file): string {
$segments = [];
if ($this->streamRootPrefix !== '') {
$segments[] = $this->streamRootPrefix;
}
if ($this->streamHost !== '') {
$segments[] = $this->streamHost;
}
$segments[] = $file;
return PathHelper::join(...$segments);
}
private function normaliseBaseUrl(string $baseUrl): string {
$baseUrl = trim($baseUrl);
if ($baseUrl === '') {
$baseUrl = content_url('uploads');
}
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 . '/' . $pathPrefix;
}
private function buildPublicUrl(string $relativePath): string {
$base = rtrim($this->effectiveBaseUrl, '/');
if ($relativePath === '') {
return $base;
}
return $base . '/' . PathHelper::normalize($relativePath);
}
}

View File

@ -1,142 +1,165 @@
<?php
declare(strict_types=1);
namespace FlysystemOffload;
use FlysystemOffload\Admin\HealthCheck;
use FlysystemOffload\Config\ConfigLoader;
use FlysystemOffload\Filesystem\FilesystemFactory;
use FlysystemOffload\Media\MediaHooks;
use FlysystemOffload\Settings\SettingsPage;
use FlysystemOffload\StreamWrapper\FlysystemStreamWrapper;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\Visibility;
use RuntimeException;
use WP_Error;
final class Plugin
{
private FilesystemFactory $filesystemFactory;
final class Plugin {
private static bool $bootstrapped = false;
private static ?Plugin $instance = null;
private array $config;
private FilesystemOperator $filesystem;
private MediaHooks $mediaHooks;
private SettingsPage $settingsPage;
private array $config = [];
private bool $isInitialized = false;
private ?SettingsPage $settingsPage;
private HealthCheck $healthCheck;
public function __construct(
FilesystemFactory $filesystemFactory,
private function __construct(
array $config,
FilesystemOperator $filesystem,
MediaHooks $mediaHooks,
SettingsPage $settingsPage
?SettingsPage $settingsPage,
HealthCheck $healthCheck
) {
$this->filesystemFactory = $filesystemFactory;
$this->config = $config;
$this->filesystem = $filesystem;
$this->mediaHooks = $mediaHooks;
$this->settingsPage = $settingsPage;
$this->healthCheck = $healthCheck;
}
public function register(): void
{
add_action('plugins_loaded', [$this, 'bootstrap'], 5);
add_action('init', [$this, 'loadTextDomain']);
add_filter('plugin_action_links_' . plugin_basename(FlysystemOffload::PLUGIN_FILE), [$this, 'pluginLinks']);
}
public function loadTextDomain(): void
{
load_plugin_textdomain(
'flysystem-offload',
false,
dirname(plugin_basename(FlysystemOffload::PLUGIN_FILE)) . '/languages'
);
}
public function bootstrap(): void
{
if ($this->isInitialized) {
public static function bootstrap(): void {
if (self::$bootstrapped) {
return;
}
$this->config = $this->settingsPage->getSettings();
$configDirectory = \defined('FLYSYSTEM_OFFLOAD_CONFIG_PATH')
? FLYSYSTEM_OFFLOAD_CONFIG_PATH
: \dirname(__DIR__) . '/config';
if (! is_array($this->config)) {
$this->config = [];
$configLoader = new ConfigLoader($configDirectory);
$config = self::normaliseConfig($configLoader->load());
$filesystemFactory = new FilesystemFactory();
$filesystem = $filesystemFactory->make($config);
FlysystemStreamWrapper::register(
$filesystem,
$config['stream']['protocol'],
$config['stream']['root_prefix'],
$config['visibility']
);
$mediaHooks = new MediaHooks($filesystem, $config);
$mediaHooks->register();
$settingsPage = null;
if (! empty($config['admin']['enabled']) && \is_admin()) {
$settingsPage = new SettingsPage($filesystem, $config);
$settingsPage->register();
}
$this->registerStreamWrapper();
$this->mediaHooks->register($this);
$this->settingsPage->register($this);
$healthCheck = new HealthCheck($filesystem, $config);
$healthCheck->register();
if (defined('WP_CLI') && WP_CLI) {
if (class_exists(HealthCheck::class)) {
\WP_CLI::add_command('flysystem-offload health-check', new HealthCheck($this->filesystemFactory));
}
}
$this->isInitialized = true;
self::$instance = new self($config, $filesystem, $mediaHooks, $settingsPage, $healthCheck);
self::$bootstrapped = true;
}
public function reloadConfig(): void
{
$this->mediaHooks->unregister();
$this->config = $this->settingsPage->getSettings();
if (! is_array($this->config)) {
$this->config = [];
}
$this->registerStreamWrapper();
$this->mediaHooks->register($this);
public static function instance(): ?self {
return self::$instance;
}
public function registerStreamWrapper(): void
{
try {
$filesystem = $this->filesystemFactory->build($this->config);
$protocol = $this->config['protocol'] ?? 'fly';
$prefix = $this->config['root_prefix'] ?? '';
FlysystemStreamWrapper::register(
$filesystem,
$protocol,
$prefix,
[
'visibility' => $this->config['visibility'] ?? Visibility::PUBLIC,
]
);
} catch (\Throwable $exception) {
error_log('[Flysystem Offload] No se pudo registrar el stream wrapper: ' . $exception->getMessage());
}
}
public function pluginLinks(array $links): array
{
$settingsUrl = admin_url('options-general.php?page=flysystem-offload');
$links[] = '<a href="' . esc_url($settingsUrl) . '">' . esc_html__('Ajustes', 'flysystem-offload') . '</a>';
return $links;
}
public function getConfig(): array
{
public function config(): array {
return $this->config;
}
public function getFilesystemFactory(): FilesystemFactory
{
return $this->filesystemFactory;
public function filesystem(): FilesystemOperator {
return $this->filesystem;
}
public function getRemoteUrlBase(): string
{
$driver = $this->config['driver'] ?? null;
private static function normaliseConfig(array $config): array {
$defaults = [
'driver' => 's3',
'visibility' => Visibility::PUBLIC,
'cache_ttl' => 900,
'stream' => [
'protocol' => 'flysystem',
'root_prefix' => '',
'host' => 'uploads',
],
'uploads' => [
'base_url' => '',
'delete_remote' => true,
'prefer_local_for_missing' => false,
'cache_control' => 'public, max-age=31536000, immutable',
'expires' => null,
'expires_ttl' => 31536000,
],
'admin' => [
'enabled' => false,
],
's3' => [
'acl_public' => 'public-read',
'acl_private' => 'private',
'default_options' => [],
],
];
if (! $driver) {
return '';
$config = array_replace_recursive($defaults, $config);
$config['visibility'] = self::normaliseVisibility((string) ($config['visibility'] ?? Visibility::PUBLIC));
$config['stream']['protocol'] = self::sanitizeProtocol((string) $config['stream']['protocol']);
$config['stream']['root_prefix'] = self::normalizePathSegment((string) $config['stream']['root_prefix']);
$config['stream']['host'] = self::normalizePathSegment((string) $config['stream']['host']) ?: 'uploads';
if (empty($config['uploads']['base_url'])) {
$config['uploads']['base_url'] = rtrim(content_url('uploads'), '/');
} else {
$config['uploads']['base_url'] = rtrim((string) $config['uploads']['base_url'], '/');
}
$result = $this->filesystemFactory->resolvePublicBaseUrl($driver, $this->config);
$config['uploads']['delete_remote'] = (bool) $config['uploads']['delete_remote'];
$config['uploads']['prefer_local_for_missing'] = (bool) $config['uploads']['prefer_local_for_missing'];
$config['uploads']['cache_control'] = trim((string) $config['uploads']['cache_control']);
$config['uploads']['expires'] = $config['uploads']['expires']
? trim((string) $config['uploads']['expires'])
: null;
$config['uploads']['expires_ttl'] = max(0, (int) ($config['uploads']['expires_ttl'] ?? 0));
if ($result instanceof WP_Error) {
throw new RuntimeException($result->get_error_message());
}
$config['s3']['acl_public'] = (string) ($config['s3']['acl_public'] ?? 'public-read');
$config['s3']['acl_private'] = (string) ($config['s3']['acl_private'] ?? 'private');
$config['s3']['default_options'] = is_array($config['s3']['default_options'])
? $config['s3']['default_options']
: [];
return $result;
return $config;
}
private static function normaliseVisibility(string $visibility): string {
$visibility = \strtolower($visibility);
return $visibility === Visibility::PRIVATE
? Visibility::PRIVATE
: Visibility::PUBLIC;
}
private static function sanitizeProtocol(string $protocol): string {
$protocol = \preg_replace('/[^A-Za-z0-9_\-]/', '', $protocol) ?? 'flysystem';
$protocol = \strtolower($protocol);
return $protocol !== '' ? $protocol : 'flysystem';
}
private static function normalizePathSegment(string $segment): string {
return \trim($segment, " \t\n\r\0\x0B/");
}
}

View File

@ -3,98 +3,109 @@ declare(strict_types=1);
namespace FlysystemOffload\Settings;
use FlysystemOffload\Plugin;
use FlysystemOffload\Helpers\PathHelper;
use League\Flysystem\FilesystemOperator;
class SettingsPage
{
private const OPTION_KEY = 'flysystem_offload_settings';
final class SettingsPage {
private FilesystemOperator $filesystem;
private array $config;
/**
* @return array<string, mixed>
*/
public function getSettings(): array
{
$settings = get_option(self::OPTION_KEY, []);
return is_array($settings) ? $settings : [];
public function __construct(FilesystemOperator $filesystem, array $config) {
$this->filesystem = $filesystem;
$this->config = $config;
}
public function register(Plugin $plugin): void
{
add_action('admin_menu', function () {
add_options_page(
__('Flysystem Offload', 'flysystem-offload'),
__('Flysystem Offload', 'flysystem-offload'),
'manage_options',
'flysystem-offload',
[$this, 'renderPage']
);
});
add_action('admin_init', [$this, 'registerSettings']);
public function register(): void {
\add_action('admin_menu', [$this, 'registerMenu']);
}
public function renderPage(): void
{
if (! current_user_can('manage_options')) {
wp_die(__('No tienes permisos para acceder a esta página.', 'flysystem-offload'));
public function registerMenu(): void {
\add_options_page(
__('Flysystem Offload', 'flysystem-offload'),
__('Flysystem Offload', 'flysystem-offload'),
'manage_options',
'flysystem-offload',
[$this, 'renderPage']
);
}
public function renderPage(): void {
if (! \current_user_can('manage_options')) {
return;
}
$settings = $this->getSettings();
$status = $this->probeFilesystem();
?>
<div class="wrap">
<h1><?php esc_html_e('Flysystem Offload', 'flysystem-offload'); ?></h1>
<p><?php esc_html_e('Configura el almacenamiento remoto para WordPress.', 'flysystem-offload'); ?></p>
<p class="description">
<?php esc_html_e('Configuración de solo lectura del almacenamiento remoto.', 'flysystem-offload'); ?>
</p>
<form action="options.php" method="post">
<table class="widefat striped">
<tbody>
<tr>
<th scope="row"><?php esc_html_e('Driver', 'flysystem-offload'); ?></th>
<td><?php echo esc_html($this->config['driver'] ?? ''); ?></td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('Protocolo del Stream Wrapper', 'flysystem-offload'); ?></th>
<td><?php echo esc_html($this->config['stream']['protocol'] ?? ''); ?></td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('Prefijo remoto', 'flysystem-offload'); ?></th>
<td><?php echo esc_html($this->config['stream']['root_prefix'] ?? ''); ?></td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('Host del stream', 'flysystem-offload'); ?></th>
<td><?php echo esc_html($this->config['stream']['host'] ?? ''); ?></td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('URL base pública', 'flysystem-offload'); ?></th>
<td><code><?php echo esc_html($this->config['uploads']['base_url'] ?? ''); ?></code></td>
</tr>
</tbody>
</table>
<h2><?php esc_html_e('Estado del almacenamiento remoto', 'flysystem-offload'); ?></h2>
<p>
<?php
settings_fields('flysystem_offload');
do_settings_sections('flysystem-offload');
submit_button();
if ($status) {
printf(
'<span class="dashicons dashicons-yes" style="color:green;"></span> %s',
esc_html__('Conexión verificada correctamente.', 'flysystem-offload')
);
} else {
printf(
'<span class="dashicons dashicons-warning" style="color:#d63638;"></span> %s',
esc_html__('No fue posible escribir en el almacenamiento remoto.', 'flysystem-offload')
);
}
?>
</form>
</p>
</div>
<?php
}
public function registerSettings(): void
{
register_setting('flysystem_offload', self::OPTION_KEY);
add_settings_section(
'flysystem_offload_general',
__('Configuración General', 'flysystem-offload'),
function () {
echo '<p>' . esc_html__(
'Introduce las credenciales del proveedor que deseas utilizar.',
'flysystem-offload'
) . '</p>';
},
'flysystem-offload'
private function probeFilesystem(): bool {
$probeKey = PathHelper::join(
$this->config['stream']['root_prefix'] ?? '',
$this->config['stream']['host'] ?? 'uploads',
'.flysystem-offload-probe'
);
add_settings_field(
'flysystem_offload_driver',
__('Driver', 'flysystem-offload'),
[$this, 'renderDriverField'],
'flysystem-offload',
'flysystem_offload_general'
);
}
try {
if ($this->filesystem->fileExists($probeKey)) {
return true;
}
public function renderDriverField(): void
{
$settings = $this->getSettings();
$driver = $settings['driver'] ?? '';
?>
<select name="<?php echo esc_attr(self::OPTION_KEY . '[driver]'); ?>">
<option value=""><?php esc_html_e('Selecciona un driver', 'flysystem-offload'); ?></option>
<option value="s3" <?php selected($driver, 's3'); ?>><?php esc_html_e('Amazon S3 / Compatible', 'flysystem-offload'); ?></option>
<option value="gcs" <?php selected($driver, 'gcs'); ?>><?php esc_html_e('Google Cloud Storage', 'flysystem-offload'); ?></option>
<option value="azure" <?php selected($driver, 'azure'); ?>><?php esc_html_e('Azure Blob Storage', 'flysystem-offload'); ?></option>
<option value="sftp" <?php selected($driver, 'sftp'); ?>><?php esc_html_e('SFTP', 'flysystem-offload'); ?></option>
<option value="webdav" <?php selected($driver, 'webdav'); ?>><?php esc_html_e('WebDAV', 'flysystem-offload'); ?></option>
</select>
<?php
$this->filesystem->write($probeKey, 'ok', ['visibility' => $this->config['visibility'] ?? 'public']);
$this->filesystem->delete($probeKey);
return true;
} catch (\Throwable $exception) {
\error_log(sprintf('[Flysystem Offload] Health probe falló: %s', $exception->getMessage()));
return false;
}
}
}

View File

@ -1,505 +1,493 @@
<?php
declare(strict_types=1);
namespace FlysystemOffload\StreamWrapper;
use FlysystemOffload\Helpers\PathHelper;
use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\StorageAttributes;
use League\Flysystem\UnableToDeleteDirectory;
use League\Flysystem\UnableToDeleteFile;
use League\Flysystem\UnableToMoveFile;
use League\Flysystem\UnableToReadFile;
use League\Flysystem\UnableToWriteFile;
use League\Flysystem\Visibility;
use RuntimeException;
use Throwable;
final class FlysystemStreamWrapper
{
/** @var array<string, FilesystemOperator> */
private static array $filesystems = [];
/** @var array<string, string> */
private static array $prefixes = [];
/** @var array<string, array<string, mixed>> */
private static array $writeOptions = [];
final class FlysystemStreamWrapper {
private static ?FilesystemOperator $filesystem = null;
private static string $protocol = 'flysystem';
private static string $rootPrefix = '';
private static string $defaultVisibility = Visibility::PUBLIC;
/** @var resource|null */
private $stream = null;
private string $protocol = '';
private $resource = null;
private string $mode = 'r';
private string $path = '';
private string $mode = '';
private string $flyPath = '';
private array $dirEntries = [];
private int $dirPosition = 0;
private string $uri = '';
private bool $dirty = false;
/** @var resource|array|string|null */
public $context = null;
/** @var list<string> */
private array $directoryListing = [];
private int $directoryPosition = 0;
public static function register(
FilesystemOperator $filesystem,
string $protocol,
string $prefix = '',
array $writeOptions = []
string $protocol = 'flysystem',
string $rootPrefix = '',
string $defaultVisibility = Visibility::PUBLIC,
bool $force = true
): void {
if (in_array($protocol, stream_get_wrappers(), true)) {
self::$filesystem = $filesystem;
self::$protocol = $protocol;
self::$rootPrefix = PathHelper::normalize($rootPrefix);
self::$defaultVisibility = $defaultVisibility;
$wrappers = stream_get_wrappers();
if (in_array($protocol, $wrappers, true)) {
if (! $force) {
return;
}
stream_wrapper_unregister($protocol);
}
self::$filesystems[$protocol] = $filesystem;
self::$prefixes[$protocol] = trim($prefix, '/');
self::$writeOptions[$protocol] = $writeOptions + [
'visibility' => Visibility::PUBLIC,
];
stream_wrapper_register($protocol, static::class, STREAM_IS_URL);
if (! stream_wrapper_register($protocol, static::class, STREAM_IS_URL)) {
throw new \RuntimeException(sprintf('Unable to register stream wrapper for protocol "%s".', $protocol));
}
}
public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool
{
unset($openedPath);
$this->protocol = $this->extractProtocol($path);
$this->path = $path;
public static function unregister(): void {
if (self::$protocol !== '' && in_array(self::$protocol, stream_get_wrappers(), true)) {
stream_wrapper_unregister(self::$protocol);
}
self::$filesystem = null;
}
public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool {
$this->ensureFilesystem();
$this->uri = $path;
$this->path = self::uriToKey($path);
$this->mode = $mode;
$this->flyPath = $this->resolveFlyPath($path);
$this->resource = fopen('php://temp', 'w+b');
$this->dirty = false;
$filesystem = $this->filesystem($this->protocol);
$binary = str_contains($mode, 'b') ? 'b' : '';
if ($this->resource === false) {
return false;
}
if (strpbrk($mode, 'waxc') !== false) {
$this->stream = fopen('php://temp', 'w+' . $binary);
if ($this->requiresExistingFile($mode) && ! $this->fileExists($this->path)) {
if ($options & STREAM_REPORT_ERRORS) {
trigger_error(sprintf('Flysystem path "%s" does not exist.', $this->path), E_USER_WARNING);
}
return false;
}
if ($this->stream === false) {
if ($this->isExclusiveCreateMode($mode) && $this->fileExists($this->path)) {
return false;
}
if (($this->requiresExistingFile($mode) || $this->isAppendMode($mode)) && $this->fileExists($this->path)) {
try {
$contents = self::$filesystem->read($this->path);
fwrite($this->resource, $contents);
unset($contents);
if ($this->isAppendMode($mode)) {
fseek($this->resource, 0, SEEK_END);
} else {
rewind($this->resource);
}
} catch (UnableToReadFile|FilesystemException $exception) {
if ($options & STREAM_REPORT_ERRORS) {
trigger_error($exception->getMessage(), E_USER_WARNING);
}
return false;
}
}
if (str_contains($mode, 'a') && $filesystem->fileExists($this->flyPath)) {
return true;
}
public function stream_read(int $count): string|false {
if (! $this->resource) {
return false;
}
return fread($this->resource, $count);
}
public function stream_write(string $data): int|false {
if (! $this->resource) {
return false;
}
$written = fwrite($this->resource, $data);
if ($written !== false) {
$this->dirty = true;
}
return $written;
}
public function stream_tell(): int|false {
if (! $this->resource) {
return false;
}
return ftell($this->resource);
}
public function stream_eof(): bool {
if (! $this->resource) {
return true;
}
return feof($this->resource);
}
public function stream_seek(int $offset, int $whence = SEEK_SET): bool {
if (! $this->resource) {
return false;
}
return fseek($this->resource, $offset, $whence) === 0;
}
public function stream_flush(): bool {
if (! $this->resource || ! $this->dirty || ! $this->isWriteMode($this->mode)) {
return true;
}
$position = ftell($this->resource);
rewind($this->resource);
$contents = stream_get_contents($this->resource);
fseek($this->resource, $position);
try {
if ($this->fileExists($this->path)) {
try {
$remote = $filesystem->readStream($this->flyPath);
if (is_resource($remote)) {
stream_copy_to_stream($remote, $this->stream);
fclose($remote);
}
} catch (UnableToReadFile $exception) {
error_log('[Flysystem Offload] Unable to open stream for append: ' . $exception->getMessage());
return false;
self::$filesystem->delete($this->path);
} catch (UnableToDeleteFile|FilesystemException) {
// Intentamos sobrescribir igualmente.
}
}
rewind($this->stream);
return true;
}
try {
$remote = $filesystem->readStream($this->flyPath);
if (! is_resource($remote)) {
return false;
}
$local = fopen('php://temp', 'w+' . $binary);
if ($local === false) {
fclose($remote);
return false;
}
stream_copy_to_stream($remote, $local);
fclose($remote);
rewind($local);
$this->stream = $local;
return true;
} catch (UnableToReadFile $exception) {
error_log('[Flysystem Offload] Unable to open stream: ' . $exception->getMessage());
return false;
}
}
public function stream_read(int $count): string|false
{
if (! is_resource($this->stream)) {
return false;
}
return fread($this->stream, $count);
}
public function stream_write(string $data): int|false
{
if (! is_resource($this->stream)) {
return false;
}
return fwrite($this->stream, $data);
}
public function stream_flush(): bool
{
if (! is_resource($this->stream) || strpbrk($this->mode, 'waxc') === false) {
return true;
}
$filesystem = $this->filesystem($this->protocol);
try {
$meta = stream_get_meta_data($this->stream);
$seekable = (bool) ($meta['seekable'] ?? false);
if ($seekable) {
rewind($this->stream);
}
$filesystem->writeStream(
$this->flyPath,
$this->stream,
$this->writeOptionsForProtocol($this->protocol)
self::$filesystem->write(
$this->path,
(string) $contents,
['visibility' => self::$defaultVisibility]
);
if ($seekable) {
rewind($this->stream);
}
$this->dirty = false;
return true;
} catch (UnableToWriteFile|Throwable $exception) {
error_log('[Flysystem Offload] Unable to flush stream: ' . $exception->getMessage());
} catch (UnableToWriteFile|FilesystemException $exception) {
trigger_error($exception->getMessage(), E_USER_WARNING);
return false;
}
}
public function stream_close(): void
{
public function stream_close(): void {
$this->stream_flush();
if (is_resource($this->stream)) {
fclose($this->stream);
if ($this->resource) {
fclose($this->resource);
}
$this->stream = null;
$this->resource = null;
$this->mode = 'r';
$this->path = '';
$this->mode = '';
$this->flyPath = '';
$this->uri = '';
$this->dirty = false;
}
public function stream_tell(): int|false
{
if (! is_resource($this->stream)) {
public function stream_truncate(int $new_size): bool {
if (! $this->resource) {
return false;
}
return ftell($this->stream);
$result = ftruncate($this->resource, $new_size);
if ($result) {
$this->dirty = true;
}
return $result;
}
public function stream_seek(int $offset, int $whence = SEEK_SET): bool
{
if (! is_resource($this->stream)) {
public function stream_stat(): array|false {
if ($this->path === '') {
return false;
}
return fseek($this->stream, $offset, $whence) === 0;
return $this->statKey($this->path);
}
public function stream_eof(): bool
{
if (! is_resource($this->stream)) {
return true;
public function url_stat(string $path, int $flags): array|false {
$key = self::uriToKey($path);
$stat = $this->statKey($key);
if ($stat === false && ($flags & STREAM_URL_STAT_QUIET) === 0) {
trigger_error(sprintf('Flysystem path "%s" not found.', $key), E_USER_WARNING);
}
return feof($this->stream);
return $stat;
}
public function stream_metadata(string $path, int $option, mixed $value): bool
{
unset($path, $option, $value);
public function unlink(string $path): bool {
$this->ensureFilesystem();
return true;
}
public function stream_cast(int $castAs)
{
if (! is_resource($this->stream)) {
return false;
}
if (in_array($castAs, [STREAM_CAST_FOR_SELECT, STREAM_CAST_AS_STREAM], true)) {
$meta = stream_get_meta_data($this->stream);
$seekable = (bool) ($meta['seekable'] ?? false);
if ($seekable) {
rewind($this->stream);
}
return $this->stream;
}
return false;
}
public function stream_set_option(int $option, int $arg1, int $arg2): bool
{
if (! is_resource($this->stream)) {
return false;
}
if ($option === STREAM_OPTION_READ_TIMEOUT) {
return stream_set_timeout($this->stream, $arg1, $arg2);
}
return false;
}
public function stream_stat(): array|false
{
if (is_resource($this->stream)) {
return fstat($this->stream);
}
return $this->url_stat($this->path !== '' ? $this->path : $this->protocol . '://', 0);
}
public function url_stat(string $path, int $flags): array|false
{
$protocol = $this->extractProtocol($path);
$filesystem = $this->filesystem($protocol);
$flyPath = $this->resolveFlyPath($path);
$key = self::uriToKey($path);
try {
$isDirectory = $filesystem->directoryExists($flyPath);
$exists = $isDirectory || $filesystem->fileExists($flyPath);
if (! $exists) {
if ($flags & STREAM_URL_STAT_QUIET) {
return false;
}
trigger_error("File or directory not found: {$path}", E_USER_WARNING);
return false;
if ($this->fileExists($key)) {
self::$filesystem->delete($key);
}
$size = $isDirectory ? 0 : $filesystem->fileSize($flyPath);
$mtime = $filesystem->lastModified($flyPath);
$mode = $isDirectory ? 0040777 : 0100777;
return true;
} catch (UnableToDeleteFile|FilesystemException $exception) {
trigger_error($exception->getMessage(), E_USER_WARNING);
return false;
}
}
return [
0 => 0,
'dev' => 0,
1 => 0,
'ino' => 0,
2 => $mode,
'mode' => $mode,
3 => 0,
'nlink' => 0,
4 => 0,
'uid' => 0,
5 => 0,
'gid' => 0,
6 => 0,
'rdev' => 0,
7 => $size,
'size' => $size,
8 => $mtime,
'atime' => $mtime,
9 => $mtime,
'mtime' => $mtime,
10 => $mtime,
'ctime' => $mtime,
11 => -1,
'blksize' => -1,
12 => -1,
'blocks' => -1,
];
public function rename(string $path_from, string $path_to): bool {
$this->ensureFilesystem();
$from = self::uriToKey($path_from);
$to = self::uriToKey($path_to);
try {
self::$filesystem->move($from, $to);
return true;
} catch (UnableToMoveFile|FilesystemException $exception) {
trigger_error($exception->getMessage(), E_USER_WARNING);
return false;
}
}
public function mkdir(string $path, int $mode, int $options): bool {
$this->ensureFilesystem();
$key = self::uriToKey($path);
try {
self::$filesystem->createDirectory($key, ['visibility' => self::$defaultVisibility]);
return true;
} catch (FilesystemException $exception) {
if (! ($flags & STREAM_URL_STAT_QUIET)) {
if ($options & STREAM_REPORT_ERRORS) {
trigger_error($exception->getMessage(), E_USER_WARNING);
}
return false;
}
}
public function unlink(string $path): bool
{
$protocol = $this->extractProtocol($path);
$filesystem = $this->filesystem($protocol);
$flyPath = $this->resolveFlyPath($path);
public function rmdir(string $path, int $options): bool {
$this->ensureFilesystem();
$key = self::uriToKey($path);
try {
$filesystem->delete($flyPath);
self::$filesystem->deleteDirectory($key);
return true;
} catch (UnableToDeleteFile $exception) {
error_log('[Flysystem Offload] Unable to delete file: ' . $exception->getMessage());
} catch (UnableToDeleteDirectory|FilesystemException $exception) {
if ($options & STREAM_REPORT_ERRORS) {
trigger_error($exception->getMessage(), E_USER_WARNING);
}
return false;
}
}
public function mkdir(string $path, int $mode, int $options): bool
{
unset($mode, $options);
public function dir_opendir(string $path, int $options): bool {
$this->ensureFilesystem();
$protocol = $this->extractProtocol($path);
$filesystem = $this->filesystem($protocol);
$flyPath = $this->resolveFlyPath($path);
$key = self::uriToKey($path);
$this->directoryListing = ['.', '..'];
try {
$filesystem->createDirectory($flyPath);
return true;
foreach (self::$filesystem->listContents($key, false) as $attributes) {
if ($attributes instanceof StorageAttributes) {
$this->directoryListing[] = basename($attributes->path());
}
}
} catch (FilesystemException $exception) {
error_log('[Flysystem Offload] Unable to create directory: ' . $exception->getMessage());
if ($options & STREAM_REPORT_ERRORS) {
trigger_error($exception->getMessage(), E_USER_WARNING);
}
return false;
}
$this->directoryPosition = 0;
return true;
}
public function rmdir(string $path, int $options): bool
{
unset($options);
$protocol = $this->extractProtocol($path);
$filesystem = $this->filesystem($protocol);
$flyPath = $this->resolveFlyPath($path);
try {
$filesystem->deleteDirectory($flyPath);
return true;
} catch (FilesystemException $exception) {
error_log('[Flysystem Offload] Unable to delete directory: ' . $exception->getMessage());
public function dir_readdir(): string|false {
if (! isset($this->directoryListing[$this->directoryPosition])) {
return false;
}
return $this->directoryListing[$this->directoryPosition++];
}
public function rename(string $oldPath, string $newPath): bool
{
$oldProtocol = $this->extractProtocol($oldPath);
$newProtocol = $this->extractProtocol($newPath);
if ($oldProtocol !== $newProtocol) {
return false;
}
$filesystem = $this->filesystem($oldProtocol);
$from = $this->resolveFlyPath($oldPath);
$to = $this->resolveFlyPath($newPath);
try {
$filesystem->move($from, $to);
return true;
} catch (FilesystemException $exception) {
error_log('[Flysystem Offload] Unable to move file: ' . $exception->getMessage());
return false;
}
public function dir_rewinddir(): bool {
$this->directoryPosition = 0;
return true;
}
public function dir_opendir(string $path, int $options): bool
{
unset($options);
public function dir_closedir(): bool {
$this->directoryListing = [];
$this->directoryPosition = 0;
return true;
}
$this->protocol = $this->extractProtocol($path);
$this->flyPath = $this->resolveFlyPath($path);
public function stream_metadata(string $path, int $option, mixed $value): bool {
$this->ensureFilesystem();
$filesystem = $this->filesystem($this->protocol);
if ($option === STREAM_META_TOUCH) {
$key = self::uriToKey($path);
$entries = ['.', '..'];
try {
if (! $this->fileExists($key)) {
self::$filesystem->write($key, '', ['visibility' => self::$defaultVisibility]);
}
foreach ($filesystem->listContents($this->flyPath, false) as $item) {
if ($item instanceof StorageAttributes) {
$entries[] = basename($item->path());
} elseif (is_array($item) && isset($item['path'])) {
$entries[] = basename((string) $item['path']);
return true;
} catch (FilesystemException $exception) {
trigger_error($exception->getMessage(), E_USER_WARNING);
return false;
}
}
$this->dirEntries = $entries;
$this->dirPosition = 0;
return false;
}
public function stream_set_option(int $option, int $arg1, int $arg2): bool {
return false;
}
public function stream_lock(int $operation): bool {
return true;
}
public function dir_readdir(): string|false
{
if ($this->dirPosition >= count($this->dirEntries)) {
return false;
}
return $this->dirEntries[$this->dirPosition++];
private function requiresExistingFile(string $mode): bool {
return str_starts_with($mode, 'r');
}
public function dir_rewinddir(): bool
{
$this->dirPosition = 0;
return true;
private function isExclusiveCreateMode(string $mode): bool {
return str_starts_with($mode, 'x');
}
public function dir_closedir(): bool
{
$this->dirEntries = [];
$this->dirPosition = 0;
return true;
private function isAppendMode(string $mode): bool {
return str_contains($mode, 'a');
}
private function filesystem(string $protocol): FilesystemOperator
{
if (! isset(self::$filesystems[$protocol])) {
throw new RuntimeException('No filesystem registered for protocol: ' . $protocol);
}
return self::$filesystems[$protocol];
private function isWriteMode(string $mode): bool {
return strpbrk($mode, 'waxc+') !== false;
}
private function resolveFlyPath(string $path): string
{
$raw = preg_replace('#^[^:]+://#', '', $path) ?? '';
$relative = ltrim($raw, '/');
private static function uriToKey(string $uri): string {
$components = parse_url($uri);
$prefix = self::$prefixes[$this->protocol] ?? '';
$host = $components['host'] ?? '';
$path = $components['path'] ?? '';
if ($prefix !== '') {
$prefixWithSlash = $prefix . '/';
$relative = PathHelper::join($host, $path);
if (str_starts_with($relative, $prefixWithSlash)) {
$relative = substr($relative, strlen($prefixWithSlash));
} elseif ($relative === $prefix) {
$relative = '';
}
if (self::$rootPrefix !== '') {
$relative = PathHelper::join(self::$rootPrefix, $relative);
}
return $relative;
}
private function extractProtocol(string $path): string
{
$pos = strpos($path, '://');
private function fileExists(string $key): bool {
try {
return self::$filesystem->fileExists($key);
} catch (FilesystemException) {
return false;
}
}
if ($pos === false) {
return $this->protocol !== '' ? $this->protocol : 'fly';
private function statKey(string $key): array|false {
$this->ensureFilesystem();
try {
if (self::$filesystem->fileExists($key)) {
$size = 0;
$mtime = time();
try {
$size = self::$filesystem->fileSize($key);
} catch (FilesystemException) {
$size = 0;
}
try {
$mtime = self::$filesystem->lastModified($key);
} catch (FilesystemException) {
$mtime = time();
}
return $this->formatStat(0100644, $size, $mtime, 1);
}
if (self::$filesystem->directoryExists($key)) {
$mtime = time();
try {
$mtime = self::$filesystem->lastModified($key);
} catch (FilesystemException) {
$mtime = time();
}
return $this->formatStat(0040755, 0, $mtime, 2);
}
} catch (FilesystemException) {
return false;
}
return substr($path, 0, $pos);
return false;
}
/**
* @return array<string, mixed>
* @return array{
* 0:int,1:int,2:int,3:int,4:int,5:int,6:int,7:int,8:int,9:int,10:int,11:int,12:int,
* dev:int,ino:int,mode:int,nlink:int,uid:int,gid:int,rdev:int,size:int,atime:int,mtime:int,ctime:int,blksize:int,blocks:int
* }
*/
private function writeOptionsForProtocol(string $protocol): array
{
return self::$writeOptions[$protocol] ?? ['visibility' => Visibility::PUBLIC];
private function formatStat(int $mode, int $size, int $timestamp, int $nlink = 1): array {
return [
0 => 0,
'dev' => 0,
1 => 0,
'ino' => 0,
2 => $mode,
'mode' => $mode,
3 => $nlink,
'nlink' => $nlink,
4 => 0,
'uid' => 0,
5 => 0,
'gid' => 0,
6 => 0,
'rdev' => 0,
7 => $size,
'size' => $size,
8 => $timestamp,
'atime' => $timestamp,
9 => $timestamp,
'mtime' => $timestamp,
10 => $timestamp,
'ctime' => $timestamp,
11 => -1,
'blksize' => -1,
12 => -1,
'blocks' => -1,
];
}
private function ensureFilesystem(): void {
if (! self::$filesystem) {
throw new \RuntimeException('Flysystem filesystem has not been registered.');
}
}
}