diff --git a/composer.json b/composer.json
index 7029339..2f76ce4 100644
--- a/composer.json
+++ b/composer.json
@@ -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": {
diff --git a/config/flysystem-offload.example.php b/config/flysystem-offload.example.php
index e69de29..ed2c132 100644
--- a/config/flysystem-offload.example.php
+++ b/config/flysystem-offload.example.php
@@ -0,0 +1,36 @@
+ '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' => [],
+ ],
+];
diff --git a/config/flysystem-offload.php b/config/flysystem-offload.php
new file mode 100644
index 0000000..ed2c132
--- /dev/null
+++ b/config/flysystem-offload.php
@@ -0,0 +1,36 @@
+ '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' => [],
+ ],
+];
diff --git a/flysystem-offload.php b/flysystem-offload.php
index 6da53c8..efe7ab8 100644
--- a/flysystem-offload.php
+++ b/flysystem-offload.php
@@ -1,14 +1,58 @@
getMessage());
+
+ add_action('admin_notices', static function () use ($exception): void {
+ if (! current_user_can('manage_options')) {
+ return;
+ }
+
+ printf(
+ '
',
+ esc_html($exception->getMessage())
+ );
+ });
+ }
+}, 0);
diff --git a/src/Admin/HealthCheck.php b/src/Admin/HealthCheck.php
index a0503e6..0b2908d 100644
--- a/src/Admin/HealthCheck.php
+++ b/src/Admin/HealthCheck.php
@@ -1,73 +1,65 @@
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 $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(
+ '%s
%s
',
+ esc_html__('Se produjo un error al comunicarse con el backend configurado.', 'flysystem-offload'),
+ esc_html($exception->getMessage())
+ );
}
- return true;
+ return $result;
}
}
diff --git a/src/Config/ConfigLoader.php b/src/Config/ConfigLoader.php
index 455da55..291aa9f 100644
--- a/src/Config/ConfigLoader.php
+++ b/src/Config/ConfigLoader.php
@@ -1,132 +1,58 @@
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;
}
}
diff --git a/src/Filesystem/AdapterInterface.php b/src/Filesystem/AdapterInterface.php
index c90822d..2501cf7 100644
--- a/src/Filesystem/AdapterInterface.php
+++ b/src/Filesystem/AdapterInterface.php
@@ -1,26 +1,10 @@
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
+ );
}
}
diff --git a/src/Filesystem/FilesystemFactory.php b/src/Filesystem/FilesystemFactory.php
index 8570d7c..2a628ab 100644
--- a/src/Filesystem/FilesystemFactory.php
+++ b/src/Filesystem/FilesystemFactory.php
@@ -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);
}
}
diff --git a/src/Helpers/PathHelper.php b/src/Helpers/PathHelper.php
index 1eb452b..b8f8b92 100644
--- a/src/Helpers/PathHelper.php
+++ b/src/Helpers/PathHelper.php
@@ -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, '/');
}
}
diff --git a/src/Media/MediaHooks.php b/src/Media/MediaHooks.php
index 7255796..9c97a31 100644
--- a/src/Media/MediaHooks.php
+++ b/src/Media/MediaHooks.php
@@ -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 */
- 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
+ */
+ 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);
}
}
diff --git a/src/Plugin.php b/src/Plugin.php
index 1ce998e..5b84576 100644
--- a/src/Plugin.php
+++ b/src/Plugin.php
@@ -1,142 +1,165 @@
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[] = '' . esc_html__('Ajustes', 'flysystem-offload') . '';
-
- 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/");
}
}
diff --git a/src/Settings/SettingsPage.php b/src/Settings/SettingsPage.php
index d26a5c4..07eb4c5 100644
--- a/src/Settings/SettingsPage.php
+++ b/src/Settings/SettingsPage.php
@@ -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
- */
- 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();
?>
' . esc_html__(
- 'Introduce las credenciales del proveedor que deseas utilizar.',
- 'flysystem-offload'
- ) . '';
- },
- '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'] ?? '';
- ?>
-
- 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;
+ }
}
}
diff --git a/src/StreamWrapper/FlysystemStreamWrapper.php b/src/StreamWrapper/FlysystemStreamWrapper.php
index 69b5218..a564a4a 100644
--- a/src/StreamWrapper/FlysystemStreamWrapper.php
+++ b/src/StreamWrapper/FlysystemStreamWrapper.php
@@ -1,505 +1,493 @@
*/
- private static array $filesystems = [];
-
- /** @var array */
- private static array $prefixes = [];
-
- /** @var array> */
- 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 */
+ 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
+ * @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.');
+ }
}
}