First commit
This commit is contained in:
parent
6ab8d38d0c
commit
31347b30cb
|
|
@ -0,0 +1,20 @@
|
|||
// assets/admin-settings.js
|
||||
(function ($) {
|
||||
const toggleSections = () => {
|
||||
const adapter = $('#flysystem-offload-adapter').val();
|
||||
$('.flysystem-offload-adapter-section').hide();
|
||||
$(`.flysystem-offload-adapter-section[data-adapter="${adapter}"]`).show();
|
||||
};
|
||||
|
||||
$(document).ready(function () {
|
||||
const $heading = $('h2:contains("Amazon S3 / Compatible")');
|
||||
const $table = $heading.next('table');
|
||||
|
||||
if ($heading.length && $table.length) {
|
||||
$heading.add($table).wrapAll('<div class="flysystem-offload-adapter-section" data-adapter="s3"></div>');
|
||||
}
|
||||
|
||||
toggleSections();
|
||||
$('#flysystem-offload-adapter').on('change', toggleSections);
|
||||
});
|
||||
})(jQuery);
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "tu-nombre/flysystem-offload",
|
||||
"description": "Universal storage offloading for WordPress vía Flysystem",
|
||||
"type": "wordpress-plugin",
|
||||
"require": {
|
||||
"php": ">=7.4",
|
||||
"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",
|
||||
"aws/aws-sdk-php": "^3.330",
|
||||
"google/cloud-storage": "^1.33",
|
||||
"microsoft/azure-storage-blob": "^1.5",
|
||||
"sabre/dav": "^4.5"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"FlysystemOffload\\": "src/"
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
/**
|
||||
* Plugin Name: Flysystem Offload
|
||||
* Description: Universal storage offloading para WordPress usando Flysystem.
|
||||
* Version: 0.1.0
|
||||
* Author: Tu Nombre
|
||||
* Text Domain: flysystem-offload
|
||||
*/
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
require __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
FlysystemOffload\Plugin::bootstrap(__FILE__);
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
flysystem-offload.php
|
||||
src/Plugin.php
|
||||
src/Helpers/PathHelper.php
|
||||
src/StreamWrapper/FlysystemStreamWrapper.php
|
||||
src/Filesystem/AdapterInterface.php
|
||||
src/Filesystem/FilesystemFactory.php
|
||||
src/Filesystem/Adapters/S3Adapter.php
|
||||
src/Admin/SettingsPage.php
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
<?php
|
||||
namespace FlysystemOffload\Admin;
|
||||
|
||||
class SettingsPage
|
||||
{
|
||||
private string $pluginFile;
|
||||
|
||||
public function __construct(string $pluginFile)
|
||||
{
|
||||
$this->pluginFile = $pluginFile;
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
add_action('admin_menu', [$this, 'registerPage']);
|
||||
add_action('admin_init', [$this, 'registerSettings']);
|
||||
add_action('admin_enqueue_scripts', [$this, 'enqueueAssets']);
|
||||
}
|
||||
|
||||
public function enqueueAssets(): void
|
||||
{
|
||||
$screen = get_current_screen();
|
||||
if (! $screen || $screen->id !== 'settings_page_flysystem-offload') {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_enqueue_script(
|
||||
'flysystem-offload-settings',
|
||||
plugins_url('assets/admin-settings.js', $this->pluginFile),
|
||||
['jquery'],
|
||||
'0.1.0',
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
public function registerPage(): void
|
||||
{
|
||||
add_options_page(
|
||||
__('Flysystem Offload', 'flysystem-offload'),
|
||||
__('Flysystem Offload', 'flysystem-offload'),
|
||||
'manage_options',
|
||||
'flysystem-offload',
|
||||
[$this, 'render']
|
||||
);
|
||||
}
|
||||
|
||||
public function registerSettings(): void
|
||||
{
|
||||
register_setting(
|
||||
'flysystem_offload',
|
||||
'flysystem_offload_settings',
|
||||
[
|
||||
'sanitize_callback' => [$this, 'sanitizeSettings'],
|
||||
]
|
||||
);
|
||||
|
||||
add_settings_section(
|
||||
'flysystem_offload_general',
|
||||
__('Configuración general', 'flysystem-offload'),
|
||||
'__return_false',
|
||||
'flysystem-offload'
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
'adapter',
|
||||
__('Adaptador activo', 'flysystem-offload'),
|
||||
[$this, 'renderAdapterField'],
|
||||
'flysystem-offload',
|
||||
'flysystem_offload_general'
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
'base_prefix',
|
||||
__('Prefijo de almacenamiento', 'flysystem-offload'),
|
||||
[$this, 'renderBasePrefixField'],
|
||||
'flysystem-offload',
|
||||
'flysystem_offload_general'
|
||||
);
|
||||
|
||||
add_settings_section(
|
||||
'flysystem_offload_s3',
|
||||
__('Amazon S3 / Compatible', 'flysystem-offload'),
|
||||
function () {
|
||||
echo '<p>' . esc_html__('Proporciona credenciales de un bucket S3 o compatible (MinIO, DigitalOcean Spaces, etc.).', 'flysystem-offload') . '</p>';
|
||||
},
|
||||
'flysystem-offload'
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
's3_access_key',
|
||||
__('Access Key', 'flysystem-offload'),
|
||||
[$this, 'renderS3Field'],
|
||||
'flysystem-offload',
|
||||
'flysystem_offload_s3',
|
||||
['key' => 'access_key', 'type' => 'text']
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
's3_secret_key',
|
||||
__('Secret Key', 'flysystem-offload'),
|
||||
[$this, 'renderS3Field'],
|
||||
'flysystem-offload',
|
||||
'flysystem_offload_s3',
|
||||
['key' => 'secret_key', 'type' => 'password']
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
's3_region',
|
||||
__('Región', 'flysystem-offload'),
|
||||
[$this, 'renderS3Field'],
|
||||
'flysystem-offload',
|
||||
'flysystem_offload_s3',
|
||||
['key' => 'region', 'type' => 'text', 'placeholder' => 'us-east-1']
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
's3_bucket',
|
||||
__('Bucket', 'flysystem-offload'),
|
||||
[$this, 'renderS3Field'],
|
||||
'flysystem-offload',
|
||||
'flysystem_offload_s3',
|
||||
['key' => 'bucket', 'type' => 'text']
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
's3_prefix',
|
||||
__('Prefijo (opcional)', 'flysystem-offload'),
|
||||
[$this, 'renderS3Field'],
|
||||
'flysystem-offload',
|
||||
'flysystem_offload_s3',
|
||||
['key' => 'prefix', 'type' => 'text', 'placeholder' => 'uploads']
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
's3_endpoint',
|
||||
__('Endpoint personalizado', 'flysystem-offload'),
|
||||
[$this, 'renderS3Field'],
|
||||
'flysystem-offload',
|
||||
'flysystem_offload_s3',
|
||||
['key' => 'endpoint', 'type' => 'text', 'placeholder' => 'https://nyc3.digitaloceanspaces.com']
|
||||
);
|
||||
|
||||
add_settings_field(
|
||||
's3_cdn_url',
|
||||
__('URL CDN (opcional)', 'flysystem-offload'),
|
||||
[$this, 'renderS3Field'],
|
||||
'flysystem-offload',
|
||||
'flysystem_offload_s3',
|
||||
['key' => 'cdn_url', 'type' => 'text', 'placeholder' => 'https://cdn.midominio.com']
|
||||
);
|
||||
}
|
||||
|
||||
public function sanitizeSettings(array $input): array
|
||||
{
|
||||
$current = get_option('flysystem_offload_settings', []);
|
||||
|
||||
$adapter = sanitize_key($input['adapter'] ?? $current['adapter'] ?? 'local');
|
||||
$basePrefix = trim($input['base_prefix'] ?? '');
|
||||
|
||||
$s3 = $current['adapters']['s3'] ?? [];
|
||||
$inputS3 = $input['adapters']['s3'] ?? [];
|
||||
|
||||
$secretRaw = $inputS3['secret_key'] ?? '';
|
||||
$secret = $secretRaw === '' ? ($s3['secret_key'] ?? '') : $secretRaw;
|
||||
|
||||
$sanitizedS3 = [
|
||||
'access_key' => sanitize_text_field($inputS3['access_key'] ?? $s3['access_key'] ?? ''),
|
||||
'secret_key' => sanitize_text_field($inputS3['secret_key'] ?? $s3['secret_key'] ?? ''),
|
||||
'region' => sanitize_text_field($inputS3['region'] ?? $s3['region'] ?? ''),
|
||||
'bucket' => sanitize_text_field($inputS3['bucket'] ?? $s3['bucket'] ?? ''),
|
||||
'prefix' => trim($inputS3['prefix'] ?? $s3['prefix'] ?? ''),
|
||||
'endpoint' => esc_url_raw($inputS3['endpoint'] ?? $s3['endpoint'] ?? ''),
|
||||
'cdn_url' => esc_url_raw($inputS3['cdn_url'] ?? $s3['cdn_url'] ?? ''),
|
||||
];
|
||||
|
||||
$current['adapter'] = $adapter;
|
||||
$current['base_prefix'] = $basePrefix;
|
||||
$current['adapters']['s3'] = $sanitizedS3;
|
||||
|
||||
return $current;
|
||||
}
|
||||
|
||||
public function render(): void
|
||||
{
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e('Flysystem Offload', 'flysystem-offload'); ?></h1>
|
||||
<form method="post" action="options.php">
|
||||
<?php
|
||||
settings_fields('flysystem_offload');
|
||||
do_settings_sections('flysystem-offload');
|
||||
submit_button(__('Guardar cambios', 'flysystem-offload'));
|
||||
?>
|
||||
</form>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
public function renderAdapterField(): void
|
||||
{
|
||||
$settings = get_option('flysystem_offload_settings', []);
|
||||
$adapter = $settings['adapter'] ?? 'local';
|
||||
|
||||
$options = [
|
||||
'local' => __('Local (fallback)', 'flysystem-offload'),
|
||||
's3' => 'Amazon S3 / Compatible',
|
||||
'sftp' => 'SFTP',
|
||||
'webdav' => 'WebDAV',
|
||||
'gcs' => 'Google Cloud Storage',
|
||||
'azure' => 'Azure Blob Storage',
|
||||
'googledrive' => 'Google Drive (beta)',
|
||||
'onedrive' => 'OneDrive (beta)',
|
||||
'dropbox' => 'Dropbox (beta)',
|
||||
];
|
||||
?>
|
||||
<select name="flysystem_offload_settings[adapter]" id="flysystem-offload-adapter">
|
||||
<?php foreach ($options as $value => $label): ?>
|
||||
<option value="<?php echo esc_attr($value); ?>" <?php selected($adapter, $value); ?>>
|
||||
<?php echo esc_html($label); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php
|
||||
}
|
||||
|
||||
public function renderBasePrefixField(): void
|
||||
{
|
||||
$settings = get_option('flysystem_offload_settings', []);
|
||||
$prefix = $settings['base_prefix'] ?? '';
|
||||
?>
|
||||
<input
|
||||
type="text"
|
||||
name="flysystem_offload_settings[base_prefix]"
|
||||
value="<?php echo esc_attr($prefix); ?>"
|
||||
placeholder="media/"
|
||||
class="regular-text"
|
||||
/>
|
||||
<p class="description">
|
||||
<?php esc_html_e('Opcional. Se antepone a todas las rutas remotas (ej. "wordpress/uploads").', 'flysystem-offload'); ?>
|
||||
</p>
|
||||
<?php
|
||||
}
|
||||
|
||||
public function renderS3Field(array $args): void
|
||||
{
|
||||
$settings = get_option('flysystem_offload_settings', []);
|
||||
$s3 = $settings['adapters']['s3'] ?? [];
|
||||
$key = $args['key'];
|
||||
$type = $args['type'] ?? 'text';
|
||||
$placeholder = $args['placeholder'] ?? '';
|
||||
$value = $s3[$key] ?? '';
|
||||
|
||||
if ($type === 'password') {
|
||||
$value = '';
|
||||
}
|
||||
?>
|
||||
<input
|
||||
type="<?php echo esc_attr($type); ?>"
|
||||
name="flysystem_offload_settings[adapters][s3][<?php echo esc_attr($key); ?>]"
|
||||
value="<?php echo esc_attr($value); ?>"
|
||||
class="regular-text"
|
||||
placeholder="<?php echo esc_attr($placeholder); ?>"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<?php if ('secret_key' === $key): ?>
|
||||
<p class="description">
|
||||
<?php esc_html_e('La clave no se mostrará después de guardarla.', 'flysystem-offload'); ?>
|
||||
</p>
|
||||
<?php endif;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
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);
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
namespace FlysystemOffload\Filesystem\Adapters;
|
||||
|
||||
use Aws\S3\S3Client;
|
||||
use FlysystemOffload\Filesystem\AdapterInterface;
|
||||
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
|
||||
use League\Flysystem\FilesystemAdapter;
|
||||
use WP_Error;
|
||||
|
||||
class S3Adapter implements AdapterInterface
|
||||
{
|
||||
public function validate(array $settings)
|
||||
{
|
||||
$required = ['access_key', 'secret_key', 'region', 'bucket'];
|
||||
|
||||
foreach ($required as $field) {
|
||||
if (empty($settings[$field])) {
|
||||
return new WP_Error('flysystem_offload_invalid_s3', sprintf(__('Campo S3 faltante: %s', 'flysystem-offload'), $field));
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
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'] = $settings['endpoint'];
|
||||
$clientConfig['use_path_style_endpoint'] = true;
|
||||
}
|
||||
|
||||
$client = new S3Client($clientConfig);
|
||||
|
||||
return new AwsS3V3Adapter($client, $settings['bucket'], $settings['prefix'] ?? '');
|
||||
} catch (\Throwable $e) {
|
||||
return new WP_Error('flysystem_offload_s3_error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function publicBaseUrl(array $settings): string
|
||||
{
|
||||
if (!empty($settings['cdn_url'])) {
|
||||
return rtrim($settings['cdn_url'], '/');
|
||||
}
|
||||
|
||||
$bucket = $settings['bucket'];
|
||||
$region = $settings['region'];
|
||||
|
||||
if ($region === 'us-east-1') {
|
||||
return "https://{$bucket}.s3.amazonaws.com";
|
||||
}
|
||||
|
||||
return "https://{$bucket}.s3.{$region}.amazonaws.com";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
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 WP_Error;
|
||||
|
||||
class FilesystemFactory
|
||||
{
|
||||
private array $settings;
|
||||
|
||||
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 (is_wp_error($adapter)) {
|
||||
return $adapter;
|
||||
}
|
||||
|
||||
$validation = $adapter->validate($config);
|
||||
if (is_wp_error($validation)) {
|
||||
return $validation;
|
||||
}
|
||||
|
||||
$flyAdapter = $adapter->create($config);
|
||||
if (is_wp_error($flyAdapter)) {
|
||||
return $flyAdapter;
|
||||
}
|
||||
|
||||
return new Filesystem($flyAdapter);
|
||||
}
|
||||
|
||||
public function resolvePublicBaseUrl(string $adapterKey, array $settings): string
|
||||
{
|
||||
$adapter = $this->resolveAdapter($adapterKey);
|
||||
|
||||
if (is_wp_error($adapter)) {
|
||||
return content_url('/uploads');
|
||||
}
|
||||
|
||||
return $adapter->publicBaseUrl($settings);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
return new \League\Flysystem\Local\LocalFilesystemAdapter(WP_CONTENT_DIR . '/flysystem-uploads');
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
namespace FlysystemOffload\Helpers;
|
||||
|
||||
class PathHelper
|
||||
{
|
||||
public static function normalizePrefix(string $prefix): string
|
||||
{
|
||||
$prefix = trim($prefix);
|
||||
$prefix = trim($prefix, '/');
|
||||
|
||||
return $prefix ? $prefix . '/' : '';
|
||||
}
|
||||
|
||||
public static function stripProtocol(string $path): string
|
||||
{
|
||||
$path = preg_replace('#^(fly://|https?://[^/]+/uploads/?)#', '', $path);
|
||||
|
||||
return ltrim($path, '/');
|
||||
}
|
||||
|
||||
public static function ensureFlyProtocol(string $path): string
|
||||
{
|
||||
if (str_starts_with($path, 'fly://')) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
$relative = self::stripProtocol($path);
|
||||
|
||||
return 'fly://' . $relative;
|
||||
}
|
||||
|
||||
public static function collectFilesFromAttachment(int $postId): array
|
||||
{
|
||||
$files = [];
|
||||
|
||||
if ($mainFile = get_attached_file($postId, true)) {
|
||||
$files[] = self::stripProtocol($mainFile);
|
||||
}
|
||||
|
||||
$metadata = wp_get_attachment_metadata($postId) ?: [];
|
||||
$dir = isset($metadata['file']) ? dirname(self::stripProtocol($metadata['file'])) : '';
|
||||
|
||||
if (!empty($metadata['sizes'])) {
|
||||
foreach ($metadata['sizes'] as $size) {
|
||||
if (!empty($size['file'])) {
|
||||
$files[] = trailingslashit($dir) . ltrim($size['file'], '/');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$backupSizes = get_post_meta($postId, '_wp_attachment_backup_sizes', true) ?: [];
|
||||
foreach ($backupSizes as $size) {
|
||||
if (!empty($size['file'])) {
|
||||
$files[] = trailingslashit($dir) . ltrim($size['file'], '/');
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique(array_filter($files));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
<?php
|
||||
namespace FlysystemOffload;
|
||||
|
||||
use FlysystemOffload\Admin\SettingsPage;
|
||||
use FlysystemOffload\Filesystem\FilesystemFactory;
|
||||
use FlysystemOffload\Helpers\PathHelper;
|
||||
use FlysystemOffload\StreamWrapper\FlysystemStreamWrapper;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
use WP_Error;
|
||||
|
||||
class Plugin
|
||||
{
|
||||
private static $instance;
|
||||
private static string $pluginFile;
|
||||
private ?FilesystemOperator $filesystem = null;
|
||||
private bool $streamRegistered = false;
|
||||
private array $settings = [];
|
||||
|
||||
public static function bootstrap(string $pluginFile): void
|
||||
{
|
||||
self::$pluginFile = $pluginFile;
|
||||
|
||||
register_activation_hook($pluginFile, [self::class, 'activate']);
|
||||
register_deactivation_hook($pluginFile, [self::class, 'deactivate']);
|
||||
|
||||
add_action('plugins_loaded', static function () {
|
||||
self::instance()->init();
|
||||
});
|
||||
}
|
||||
|
||||
public static function instance(): self
|
||||
{
|
||||
return self::$instance ??= new self();
|
||||
}
|
||||
|
||||
public static function activate(): void
|
||||
{
|
||||
$defaults = [
|
||||
'adapter' => 'local',
|
||||
'base_prefix' => '',
|
||||
'adapters' => [
|
||||
's3' => [
|
||||
'access_key' => '',
|
||||
'secret_key' => '',
|
||||
'region' => '',
|
||||
'bucket' => '',
|
||||
'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' => []
|
||||
]
|
||||
];
|
||||
|
||||
add_option('flysystem_offload_settings', $defaults);
|
||||
}
|
||||
|
||||
public static function deactivate(): void
|
||||
{
|
||||
if (in_array('fly', stream_get_wrappers(), true)) {
|
||||
stream_wrapper_unregister('fly');
|
||||
}
|
||||
}
|
||||
|
||||
public function init(): void
|
||||
{
|
||||
$this->settings = get_option('flysystem_offload_settings', []);
|
||||
|
||||
add_action(
|
||||
'update_option_flysystem_offload_settings',
|
||||
function ($oldValue, $newValue) {
|
||||
$this->settings = $newValue;
|
||||
$this->filesystem = null;
|
||||
$this->streamRegistered = false;
|
||||
$this->registerStreamWrapper();
|
||||
},
|
||||
10,
|
||||
2
|
||||
);
|
||||
|
||||
$this->registerStreamWrapper();
|
||||
|
||||
add_filter('upload_dir', [$this, 'filterUploadDir'], 20);
|
||||
add_filter('wp_get_attachment_url', [$this, 'filterAttachmentUrl'], 20, 2);
|
||||
add_filter('wp_get_attachment_metadata', [$this, 'filterAttachmentMetadata'], 20);
|
||||
add_filter('wp_get_original_image_path', [$this, 'filterOriginalImagePath'], 20);
|
||||
add_filter('wp_delete_file', [$this, 'handleDeleteFile'], 20);
|
||||
add_action('delete_attachment', [$this, 'handleDeleteAttachment'], 20);
|
||||
|
||||
add_action('switch_blog', function () {
|
||||
$this->filesystem = null;
|
||||
$this->streamRegistered = false;
|
||||
$this->settings = get_option('flysystem_offload_settings', []);
|
||||
$this->registerStreamWrapper();
|
||||
});
|
||||
|
||||
if (is_admin()) {
|
||||
(new SettingsPage(self::$pluginFile))->boot();
|
||||
}
|
||||
|
||||
if (defined('WP_CLI') && WP_CLI) {
|
||||
\WP_CLI::add_command('flysystem-offload health-check', [Admin\HealthCheck::class, 'run']);
|
||||
}
|
||||
}
|
||||
|
||||
public function getFilesystem(): FilesystemOperator
|
||||
{
|
||||
if (!$this->filesystem) {
|
||||
$factory = new FilesystemFactory($this->settings);
|
||||
$result = $factory->make();
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
throw new \RuntimeException($result->get_error_message());
|
||||
}
|
||||
|
||||
$this->filesystem = $result;
|
||||
}
|
||||
|
||||
return $this->filesystem;
|
||||
}
|
||||
|
||||
private function registerStreamWrapper(): void
|
||||
{
|
||||
if ($this->streamRegistered) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
FlysystemStreamWrapper::register($this->getFilesystem(), 'fly', PathHelper::normalizePrefix($this->settings['base_prefix'] ?? ''));
|
||||
$this->streamRegistered = true;
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[Flysystem Offload] No se pudo registrar el stream wrapper: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function filterUploadDir(array $dirs): array
|
||||
{
|
||||
$remoteBase = $this->getRemoteUrlBase();
|
||||
$prefix = PathHelper::normalizePrefix($this->settings['base_prefix'] ?? '');
|
||||
$subdir = $dirs['subdir'] ?? '';
|
||||
|
||||
$dirs['path'] = "fly://{$prefix}{$subdir}";
|
||||
$dirs['basedir'] = "fly://{$prefix}";
|
||||
$dirs['url'] = trailingslashit($remoteBase) . ltrim($subdir, '/');
|
||||
$dirs['baseurl'] = $remoteBase;
|
||||
|
||||
return $dirs;
|
||||
}
|
||||
|
||||
public function filterAttachmentUrl(string $url, int $postId): string
|
||||
{
|
||||
$localBase = trailingslashit(wp_get_upload_dir()['baseurl']);
|
||||
$remoteBase = trailingslashit($this->getRemoteUrlBase());
|
||||
|
||||
return str_replace($localBase, $remoteBase, $url);
|
||||
}
|
||||
|
||||
public function filterAttachmentMetadata(array $metadata): array
|
||||
{
|
||||
if (!empty($metadata['file'])) {
|
||||
$metadata['file'] = PathHelper::stripProtocol($metadata['file']);
|
||||
}
|
||||
|
||||
if (!empty($metadata['sizes'])) {
|
||||
foreach ($metadata['sizes'] as &$size) {
|
||||
if (!empty($size['file'])) {
|
||||
$size['file'] = ltrim($size['file'], '/');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $metadata;
|
||||
}
|
||||
|
||||
public function filterOriginalImagePath(string $path): string
|
||||
{
|
||||
return PathHelper::ensureFlyProtocol($path);
|
||||
}
|
||||
|
||||
public function handleDeleteFile(string $file): string|false
|
||||
{
|
||||
$flyPath = PathHelper::stripProtocol($file);
|
||||
|
||||
try {
|
||||
$this->getFilesystem()->delete($flyPath);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[Flysystem Offload] Error al borrar archivo: ' . $flyPath . ' - ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function handleDeleteAttachment(int $postId): void
|
||||
{
|
||||
$files = PathHelper::collectFilesFromAttachment($postId);
|
||||
|
||||
foreach ($files as $relativePath) {
|
||||
try {
|
||||
$this->getFilesystem()->delete($relativePath);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[Flysystem Offload] Error al borrar attachment: ' . $relativePath . ' - ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function getRemoteUrlBase(): string
|
||||
{
|
||||
$adapterKey = $this->settings['adapter'] ?? 'local';
|
||||
$config = $this->settings['adapters'][$adapterKey] ?? [];
|
||||
|
||||
return (new FilesystemFactory($this->settings))->resolvePublicBaseUrl($adapterKey, $config);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
<?php
|
||||
namespace FlysystemOffload\StreamWrapper;
|
||||
|
||||
use League\Flysystem\FilesystemException;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
use League\Flysystem\UnableToDeleteFile;
|
||||
use League\Flysystem\UnableToReadFile;
|
||||
use League\Flysystem\UnableToWriteFile;
|
||||
|
||||
class FlysystemStreamWrapper
|
||||
{
|
||||
private static array $filesystems = [];
|
||||
private static array $prefixes = [];
|
||||
|
||||
private $stream;
|
||||
private string $protocol;
|
||||
private string $path;
|
||||
private string $mode;
|
||||
private string $flyPath;
|
||||
|
||||
public static function register(FilesystemOperator $filesystem, string $protocol, string $prefix): void
|
||||
{
|
||||
if (in_array($protocol, stream_get_wrappers(), true)) {
|
||||
stream_wrapper_unregister($protocol);
|
||||
}
|
||||
|
||||
self::$filesystems[$protocol] = $filesystem;
|
||||
self::$prefixes[$protocol] = $prefix;
|
||||
|
||||
stream_wrapper_register($protocol, static::class, STREAM_IS_URL);
|
||||
}
|
||||
|
||||
public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool
|
||||
{
|
||||
$this->protocol = strtok($path, ':');
|
||||
$this->path = $path;
|
||||
$this->mode = $mode;
|
||||
$this->flyPath = $this->resolveFlyPath($path);
|
||||
|
||||
$filesystem = $this->filesystem();
|
||||
|
||||
if (strpbrk($mode, 'waxc')) {
|
||||
$this->stream = fopen('php://temp', str_contains($mode, 'b') ? 'w+b' : 'w+');
|
||||
|
||||
if (str_contains($mode, 'a') && $filesystem->fileExists($this->flyPath)) {
|
||||
$contents = $filesystem->readStream($this->flyPath);
|
||||
stream_copy_to_stream($contents, $this->stream);
|
||||
fclose($contents);
|
||||
}
|
||||
|
||||
rewind($this->stream);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$resource = $filesystem->readStream($this->flyPath);
|
||||
$this->stream = $resource;
|
||||
return is_resource($resource);
|
||||
} catch (UnableToReadFile $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function stream_read(int $count): string|false
|
||||
{
|
||||
return fread($this->stream, $count);
|
||||
}
|
||||
|
||||
public function stream_write(string $data): int|false
|
||||
{
|
||||
return fwrite($this->stream, $data);
|
||||
}
|
||||
|
||||
public function stream_flush(): bool
|
||||
{
|
||||
if (!strpbrk($this->mode, 'waxc')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$filesystem = $this->filesystem();
|
||||
|
||||
try {
|
||||
rewind($this->stream);
|
||||
$filesystem->writeStream($this->flyPath, $this->stream);
|
||||
return true;
|
||||
} catch (UnableToWriteFile $e) {
|
||||
error_log('[Flysystem Offload] Unable to flush stream: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function stream_close(): void
|
||||
{
|
||||
$this->stream_flush();
|
||||
|
||||
if (is_resource($this->stream)) {
|
||||
fclose($this->stream);
|
||||
}
|
||||
}
|
||||
|
||||
public function stream_stat(): array|false
|
||||
{
|
||||
return $this->url_stat($this->path, 0);
|
||||
}
|
||||
|
||||
public function url_stat(string $path, int $flags): array|false
|
||||
{
|
||||
$filesystem = $this->filesystem();
|
||||
$flyPath = $this->resolveFlyPath($path);
|
||||
|
||||
try {
|
||||
if (!$filesystem->fileExists($flyPath) && !$filesystem->directoryExists($flyPath)) {
|
||||
if ($flags & STREAM_URL_STAT_QUIET) {
|
||||
return false;
|
||||
}
|
||||
trigger_error("File or directory not found: {$path}", E_USER_WARNING);
|
||||
return false;
|
||||
}
|
||||
|
||||
$isDir = $filesystem->directoryExists($flyPath);
|
||||
$size = $isDir ? 0 : $filesystem->fileSize($flyPath);
|
||||
$mtime = $filesystem->lastModified($flyPath);
|
||||
|
||||
return [
|
||||
'dev' => 0,
|
||||
'ino' => 0,
|
||||
'mode' => $isDir ? 0040777 : 0100777,
|
||||
'nlink' => 0,
|
||||
'uid' => 0,
|
||||
'gid' => 0,
|
||||
'rdev' => 0,
|
||||
'size' => $size,
|
||||
'atime' => $mtime,
|
||||
'mtime' => $mtime,
|
||||
'ctime' => $mtime,
|
||||
'blksize' => -1,
|
||||
'blocks' => -1
|
||||
];
|
||||
} catch (FilesystemException $e) {
|
||||
if (!($flags & STREAM_URL_STAT_QUIET)) {
|
||||
trigger_error($e->getMessage(), E_USER_WARNING);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function unlink(string $path): bool
|
||||
{
|
||||
$filesystem = $this->filesystem();
|
||||
$flyPath = $this->resolveFlyPath($path);
|
||||
|
||||
try {
|
||||
$filesystem->delete($flyPath);
|
||||
return true;
|
||||
} catch (UnableToDeleteFile $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function mkdir(string $path, int $mode, int $options): bool
|
||||
{
|
||||
$filesystem = $this->filesystem();
|
||||
$flyPath = $this->resolveFlyPath($path);
|
||||
|
||||
try {
|
||||
$filesystem->createDirectory($flyPath);
|
||||
return true;
|
||||
} catch (FilesystemException $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function rmdir(string $path, int $options): bool
|
||||
{
|
||||
$filesystem = $this->filesystem();
|
||||
$flyPath = $this->resolveFlyPath($path);
|
||||
|
||||
try {
|
||||
$filesystem->deleteDirectory($flyPath);
|
||||
return true;
|
||||
} catch (FilesystemException $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function dir_opendir(string $path, int $options): bool
|
||||
{
|
||||
$this->protocol = strtok($path, ':');
|
||||
$this->flyPath = $this->resolveFlyPath($path);
|
||||
$this->stream = $this->filesystem()->listContents($this->flyPath, false)->getIterator();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function dir_readdir(): string|false
|
||||
{
|
||||
if ($this->stream instanceof \Iterator) {
|
||||
if ($this->stream->valid()) {
|
||||
$current = $this->stream->current();
|
||||
$this->stream->next();
|
||||
return $current['basename'] ?? $current['path'] ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function dir_rewinddir(): bool
|
||||
{
|
||||
if ($this->stream instanceof \Iterator) {
|
||||
$this->stream->rewind();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function dir_closedir(): bool
|
||||
{
|
||||
$this->stream = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rename(string $oldPath, string $newPath): bool
|
||||
{
|
||||
$filesystem = $this->filesystem();
|
||||
$from = $this->resolveFlyPath($oldPath);
|
||||
$to = $this->resolveFlyPath($newPath);
|
||||
|
||||
try {
|
||||
$filesystem->move($from, $to);
|
||||
return true;
|
||||
} catch (FilesystemException $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function filesystem(): FilesystemOperator
|
||||
{
|
||||
if (!isset(self::$filesystems[$this->protocol])) {
|
||||
throw new \RuntimeException('No filesystem registered for protocol: ' . $this->protocol);
|
||||
}
|
||||
|
||||
return self::$filesystems[$this->protocol];
|
||||
}
|
||||
|
||||
private function resolveFlyPath(string $path): string
|
||||
{
|
||||
$protocol = strtok($path, ':');
|
||||
$prefix = self::$prefixes[$protocol] ?? '';
|
||||
$raw = preg_replace('#^[^:]+://#', '', $path);
|
||||
|
||||
return ltrim($prefix . ltrim($raw, '/'), '/');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue