feat: add S3-compatible storage provider (MinIO, Ceph, R2, etc.)
Adds a new 'S3-Compatible Storage' provider that works with any
S3-API-compatible object storage service, including MinIO, Ceph,
Cloudflare R2, Backblaze B2, and others.
Changes:
- New provider class: classes/providers/storage/s3-compatible-provider.php
- Provider key: s3compatible
- Reads user-configured endpoint URL from settings
- Uses path-style URL access (required by most S3-compatible services)
- Supports credentials via AS3CF_S3COMPAT_ACCESS_KEY_ID /
AS3CF_S3COMPAT_SECRET_ACCESS_KEY wp-config.php constants
- Disables AWS-specific features (Block Public Access, Object Ownership)
- New provider SVG icons (s3compatible.svg, -link.svg, -round.svg)
- Registered provider in main plugin class with endpoint setting support
- Updated StorageProviderSubPage to show endpoint URL input for S3-compatible
- Built pro settings bundle with rollup (Svelte 4.2.19)
- Added package.json and updated rollup.config.mjs for pro-only builds
This commit is contained in:
315
ui/components/StorageProviderSubPage.svelte
Normal file
315
ui/components/StorageProviderSubPage.svelte
Normal file
@@ -0,0 +1,315 @@
|
||||
<script>
|
||||
import {createEventDispatcher, getContext, hasContext} from "svelte";
|
||||
import {writable} from "svelte/store";
|
||||
import {
|
||||
settings,
|
||||
defined_settings,
|
||||
strings,
|
||||
storage_providers,
|
||||
storage_provider,
|
||||
counts,
|
||||
current_settings,
|
||||
needs_refresh,
|
||||
revalidatingSettings,
|
||||
state
|
||||
} from "../js/stores";
|
||||
import {
|
||||
scrollNotificationsIntoView
|
||||
} from "../js/scrollNotificationsIntoView";
|
||||
import {needsRefresh} from "../js/needsRefresh";
|
||||
import SubPage from "./SubPage.svelte";
|
||||
import Panel from "./Panel.svelte";
|
||||
import PanelRow from "./PanelRow.svelte";
|
||||
import TabButton from "./TabButton.svelte";
|
||||
import RadioButton from "./RadioButton.svelte";
|
||||
import AccessKeysDefine from "./AccessKeysDefine.svelte";
|
||||
import BackNextButtonsRow from "./BackNextButtonsRow.svelte";
|
||||
import KeyFileDefine from "./KeyFileDefine.svelte";
|
||||
import UseServerRolesDefine from "./UseServerRolesDefine.svelte";
|
||||
import AccessKeysEntry from "./AccessKeysEntry.svelte";
|
||||
import KeyFileEntry from "./KeyFileEntry.svelte";
|
||||
import Notification from "./Notification.svelte";
|
||||
|
||||
export let params = {}; // Required for regex routes.
|
||||
const _params = params; // Stops compiler warning about unused params export;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// Parent page may want to be locked.
|
||||
let settingsLocked = writable( false );
|
||||
|
||||
if ( hasContext( "settingsLocked" ) ) {
|
||||
settingsLocked = getContext( "settingsLocked" );
|
||||
}
|
||||
|
||||
// Need to be careful about throwing unneeded warnings.
|
||||
let initialSettings = $current_settings;
|
||||
|
||||
if ( hasContext( "initialSettings" ) ) {
|
||||
initialSettings = getContext( "initialSettings" );
|
||||
}
|
||||
|
||||
// As this page does not directly alter the settings store until done,
|
||||
// we need to keep track of any changes made elsewhere and prompt
|
||||
// the user to refresh the page.
|
||||
let saving = false;
|
||||
const previousSettings = { ...$current_settings };
|
||||
const previousDefines = { ...$defined_settings };
|
||||
|
||||
$: {
|
||||
$needs_refresh = $needs_refresh || needsRefresh( saving, previousSettings, $current_settings, previousDefines, $defined_settings );
|
||||
}
|
||||
|
||||
/*
|
||||
* 1. Select Storage Provider
|
||||
*/
|
||||
|
||||
let storageProvider = { ...$storage_provider };
|
||||
|
||||
$: defined = $defined_settings.includes( "provider" );
|
||||
$: disabled = defined || $needs_refresh || $settingsLocked;
|
||||
|
||||
/**
|
||||
* Handles picking different storage provider.
|
||||
*
|
||||
* @param {Object} provider
|
||||
*/
|
||||
function handleChooseProvider( provider ) {
|
||||
if ( disabled ) {
|
||||
return;
|
||||
}
|
||||
|
||||
storageProvider = provider;
|
||||
|
||||
// Now make sure authMethod is valid for chosen storage provider.
|
||||
authMethod = getAuthMethod( storageProvider, authMethod );
|
||||
}
|
||||
|
||||
$: changedWithOffloaded = initialSettings.provider !== storageProvider.provider_key_name && $counts.offloaded > 0;
|
||||
|
||||
/*
|
||||
* 2. Select Authentication method
|
||||
*/
|
||||
|
||||
let endpointUrl = $settings[ "endpoint" ] || "";
|
||||
|
||||
let accessKeyId = $settings[ "access-key-id" ];
|
||||
let secretAccessKey = $settings[ "secret-access-key" ];
|
||||
let keyFile = $settings[ "key-file" ] ? JSON.stringify( $settings[ "key-file" ] ) : "";
|
||||
|
||||
/**
|
||||
* For the given current storage provider, determine the authentication method or fallback to currently selected.
|
||||
* It's possible that the storage provider can be freely changed but the
|
||||
* authentication method is defined (fixed) differently for each, or freely changeable too.
|
||||
* The order of evaluation in this function is important and mirrors the server side evaluation order.
|
||||
*
|
||||
* @param {provider} provider
|
||||
* @param {string} current auth method, one of "define", "server-role" or "database" if set.
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
function getAuthMethod( provider, current = "" ) {
|
||||
if ( provider.use_access_keys_allowed && provider.used_access_keys_constants.length ) {
|
||||
return "define";
|
||||
}
|
||||
|
||||
if ( provider.use_key_file_allowed && provider.used_key_file_path_constants.length ) {
|
||||
return "define";
|
||||
}
|
||||
|
||||
if ( provider.use_server_roles_allowed && provider.used_server_roles_constants.length ) {
|
||||
return "server-role";
|
||||
}
|
||||
|
||||
if ( current === "server-role" && !provider.use_server_roles_allowed ) {
|
||||
return "define";
|
||||
}
|
||||
|
||||
if ( current.length === 0 ) {
|
||||
if ( provider.use_access_keys_allowed && (accessKeyId || secretAccessKey) ) {
|
||||
return "database";
|
||||
}
|
||||
|
||||
if ( provider.use_key_file_allowed && keyFile ) {
|
||||
return "database";
|
||||
}
|
||||
|
||||
if ( provider.use_server_roles_allowed && $settings[ "use-server-roles" ] ) {
|
||||
return "server-role";
|
||||
}
|
||||
|
||||
// Default to most secure option.
|
||||
return "define";
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
let authMethod = getAuthMethod( storageProvider );
|
||||
|
||||
// If auth method is not allowed to be database, then either define or server-role is being forced, likely by a define.
|
||||
$: authDefined = "database" !== getAuthMethod( storageProvider, "database" );
|
||||
$: authDisabled = authDefined || $needs_refresh || $settingsLocked;
|
||||
|
||||
/*
|
||||
* 3. Save Authentication Credentials
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns a title string to be used for the credentials panel as appropriate for the auth method.
|
||||
*
|
||||
* @param {string} method
|
||||
* @return {*}
|
||||
*/
|
||||
function getCredentialsTitle( method ) {
|
||||
return $strings.auth_method_title[ method ];
|
||||
}
|
||||
|
||||
$: saveCredentialsTitle = getCredentialsTitle( authMethod );
|
||||
|
||||
/*
|
||||
* Do Something!
|
||||
*/
|
||||
|
||||
/**
|
||||
* Handles a Next button click.
|
||||
*
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async function handleNext() {
|
||||
saving = true;
|
||||
state.pausePeriodicFetch();
|
||||
|
||||
$settings.provider = storageProvider.provider_key_name;
|
||||
$settings[ "endpoint" ] = storageProvider.provider_key_name === "s3compatible" ? endpointUrl : "";
|
||||
$settings[ "access-key-id" ] = accessKeyId;
|
||||
$settings[ "secret-access-key" ] = secretAccessKey;
|
||||
$settings[ "use-server-roles" ] = authMethod === "server-role";
|
||||
$settings[ "key-file" ] = keyFile;
|
||||
const result = await settings.save();
|
||||
|
||||
// If something went wrong, don't move onto next step.
|
||||
if ( !result.hasOwnProperty( "saved" ) || !result.saved ) {
|
||||
settings.reset();
|
||||
saving = false;
|
||||
await state.resumePeriodicFetch();
|
||||
|
||||
scrollNotificationsIntoView();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$revalidatingSettings = true;
|
||||
const statePromise = state.resumePeriodicFetch();
|
||||
|
||||
dispatch( "routeEvent", { event: "settings.save", data: result } );
|
||||
|
||||
// Just make sure periodic state fetch promise is done with,
|
||||
// even though we don't really care about it.
|
||||
await statePromise;
|
||||
$revalidatingSettings = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<SubPage name="storage-provider-settings" route="/storage/provider">
|
||||
{#if changedWithOffloaded}
|
||||
<Notification inline warning heading={storageProvider.media_already_offloaded_warning.heading}>
|
||||
<p>{@html storageProvider.media_already_offloaded_warning.message}</p>
|
||||
</Notification>
|
||||
{/if}
|
||||
|
||||
<Panel heading={$strings.select_storage_provider_title} defined={defined} multi>
|
||||
<PanelRow class="body flex-row tab-buttons">
|
||||
{#each Object.values( $storage_providers ) as provider}
|
||||
<TabButton
|
||||
active={provider.provider_key_name === storageProvider.provider_key_name}
|
||||
{disabled}
|
||||
icon={provider.icon}
|
||||
iconDesc={provider.icon_desc}
|
||||
text={provider.provider_service_name}
|
||||
on:click={() => handleChooseProvider( provider )}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<Notification class="notice-qsg">
|
||||
<p>{@html storageProvider.get_access_keys_help}</p>
|
||||
</Notification>
|
||||
</PanelRow>
|
||||
</Panel>
|
||||
|
||||
{#if storageProvider.provider_key_name === "s3compatible"}
|
||||
<Panel heading={$strings.s3compat_endpoint_title} multi>
|
||||
<PanelRow class="body flex-column">
|
||||
<label class="input-label" for="s3compat-endpoint">{$strings.s3compat_endpoint_label}</label>
|
||||
<input
|
||||
type="url"
|
||||
id="s3compat-endpoint"
|
||||
name="endpoint"
|
||||
placeholder={$strings.s3compat_endpoint_placeholder}
|
||||
bind:value={endpointUrl}
|
||||
class:disabled
|
||||
{disabled}
|
||||
>
|
||||
<p class="desc">{$strings.s3compat_endpoint_desc}</p>
|
||||
</PanelRow>
|
||||
</Panel>
|
||||
{/if}
|
||||
|
||||
<Panel heading={$strings.select_auth_method_title} defined={authDefined} multi>
|
||||
<PanelRow class="body flex-column">
|
||||
<!-- define -->
|
||||
{#if storageProvider.use_access_keys_allowed}
|
||||
<RadioButton bind:selected={authMethod} disabled={authDisabled} value="define" desc={storageProvider.defined_auth_desc}>
|
||||
{$strings.define_access_keys}
|
||||
</RadioButton>
|
||||
{:else if storageProvider.use_key_file_allowed}
|
||||
<RadioButton bind:selected={authMethod} disabled={authDisabled} value="define" desc={storageProvider.defined_auth_desc}>
|
||||
{$strings.define_key_file_path}
|
||||
</RadioButton>
|
||||
{/if}
|
||||
|
||||
<!-- server-role -->
|
||||
{#if storageProvider.use_server_roles_allowed}
|
||||
<RadioButton bind:selected={authMethod} disabled={authDisabled} value="server-role" desc={storageProvider.defined_auth_desc}>
|
||||
{storageProvider.use_server_roles_title}
|
||||
</RadioButton>
|
||||
{/if}
|
||||
|
||||
<!-- database -->
|
||||
{#if storageProvider.use_access_keys_allowed}
|
||||
<RadioButton bind:selected={authMethod} disabled={authDisabled} value="database">
|
||||
{$strings.store_access_keys_in_db}
|
||||
</RadioButton>
|
||||
{:else if storageProvider.use_key_file_allowed}
|
||||
<RadioButton bind:selected={authMethod} disabled={authDisabled} value="database">
|
||||
{$strings.store_key_file_in_db}
|
||||
</RadioButton>
|
||||
{/if}
|
||||
</PanelRow>
|
||||
</Panel>
|
||||
|
||||
{#if !authDefined}
|
||||
<Panel heading={saveCredentialsTitle} multi>
|
||||
<PanelRow class="body flex-column access-keys">
|
||||
{#if authMethod === "define" && storageProvider.use_access_keys_allowed}
|
||||
<AccessKeysDefine provider={storageProvider}/>
|
||||
{:else if authMethod === "define" && storageProvider.use_key_file_allowed}
|
||||
<KeyFileDefine provider={storageProvider}/>
|
||||
{:else if authMethod === "server-role" && storageProvider.use_server_roles_allowed}
|
||||
<UseServerRolesDefine provider={storageProvider}/>
|
||||
{:else if authMethod === "database" && storageProvider.use_access_keys_allowed}
|
||||
<AccessKeysEntry
|
||||
provider={storageProvider}
|
||||
bind:accessKeyId
|
||||
bind:secretAccessKey
|
||||
disabled={authDisabled}
|
||||
/>
|
||||
{:else if authMethod === "database" && storageProvider.use_key_file_allowed}
|
||||
<KeyFileEntry provider={storageProvider} bind:value={keyFile}/>
|
||||
{/if}
|
||||
</PanelRow>
|
||||
</Panel>
|
||||
{/if}
|
||||
|
||||
<BackNextButtonsRow on:next={handleNext} nextDisabled={$needs_refresh || $settingsLocked} nextText={$strings.save_and_continue}/>
|
||||
</SubPage>
|
||||
Reference in New Issue
Block a user