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
428 lines
12 KiB
Svelte
428 lines
12 KiB
Svelte
<script>
|
|
import {
|
|
createEventDispatcher,
|
|
getContext,
|
|
hasContext,
|
|
onMount
|
|
} from "svelte";
|
|
import {writable} from "svelte/store";
|
|
import {slide} from "svelte/transition";
|
|
import {
|
|
api,
|
|
settings,
|
|
defined_settings,
|
|
strings,
|
|
storage_provider,
|
|
urls,
|
|
current_settings,
|
|
needs_refresh,
|
|
revalidatingSettings,
|
|
state
|
|
} from "../js/stores";
|
|
import {scrollIntoView} from "../js/scrollIntoView";
|
|
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 BackNextButtonsRow from "./BackNextButtonsRow.svelte";
|
|
import RadioButton from "./RadioButton.svelte";
|
|
import Loading from "./Loading.svelte";
|
|
import DefinedInWPConfig from "./DefinedInWPConfig.svelte";
|
|
|
|
const dispatch = createEventDispatcher();
|
|
|
|
// Parent page may want to be locked.
|
|
let settingsLocked = writable( false );
|
|
|
|
if ( hasContext( "settingsLocked" ) ) {
|
|
settingsLocked = getContext( "settingsLocked" );
|
|
}
|
|
|
|
// Keep track of where we were at prior to any changes made here.
|
|
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 );
|
|
}
|
|
|
|
let bucketSource = "existing";
|
|
let enterOrSelectExisting = "enter";
|
|
|
|
// If $defined_settings.bucket set, must use it, and disable change.
|
|
let newBucket = $settings.bucket;
|
|
$: defined = $defined_settings.includes( "bucket" );
|
|
$: disabled = defined || $needs_refresh || $settingsLocked;
|
|
|
|
// If $defined_settings.region set, must use it, and disable change.
|
|
let newRegion = $settings.region;
|
|
$: newRegionDefined = $defined_settings.includes( "region" );
|
|
$: newRegionDisabled = newRegionDefined || $needs_refresh || $settingsLocked;
|
|
|
|
/**
|
|
* Handles clicking the Existing radio button.
|
|
*/
|
|
function handleExisting() {
|
|
if ( disabled ) {
|
|
return;
|
|
}
|
|
|
|
bucketSource = "existing";
|
|
}
|
|
|
|
/**
|
|
* Handles clicking the New radio button.
|
|
*/
|
|
function handleNew() {
|
|
if ( disabled ) {
|
|
return;
|
|
}
|
|
|
|
bucketSource = "new";
|
|
}
|
|
|
|
/**
|
|
* Calls the API to get a list of existing buckets for the currently selected storage provider and region (if applicable).
|
|
*
|
|
* @param {string} region
|
|
*
|
|
* @return {Promise<*[]>}
|
|
*/
|
|
async function getBuckets( region ) {
|
|
let params = {};
|
|
|
|
if ( $storage_provider.region_required ) {
|
|
params = { region: region };
|
|
}
|
|
|
|
let data = await api.get( "buckets", params );
|
|
|
|
if ( data.hasOwnProperty( "buckets" ) ) {
|
|
if ( data.buckets.filter( ( bucket ) => bucket.Name === newBucket ).length === 0 ) {
|
|
newBucket = "";
|
|
}
|
|
|
|
return data.buckets;
|
|
}
|
|
|
|
newBucket = "";
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Calls the API to create a new bucket with the currently entered name and selected region.
|
|
*
|
|
* @return {Promise<boolean>}
|
|
*/
|
|
async function createBucket() {
|
|
let data = await api.post( "buckets", {
|
|
bucket: newBucket,
|
|
region: newRegion
|
|
} )
|
|
|
|
if ( data.hasOwnProperty( "saved" ) ) {
|
|
return data.saved;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Potentially returns a reason that the provided bucket name is invalid.
|
|
*
|
|
* @param {string} bucket
|
|
* @param {string} source Either "existing" or "new".
|
|
* @param {string} existingType Either "enter" or "select".
|
|
*
|
|
* @return {string}
|
|
*/
|
|
function getInvalidBucketNameMessage( bucket, source, existingType ) {
|
|
// If there's an invalid region defined, don't even bother looking at bucket name.
|
|
if ( newRegionDefined && (newRegion.length === 0 || !$storage_provider.regions.hasOwnProperty( newRegion )) ) {
|
|
return $strings.defined_region_invalid;
|
|
}
|
|
|
|
const bucketNamePattern = source === "new" ? /[^a-z0-9.\-]/ : /[^a-zA-Z0-9.\-_]/;
|
|
|
|
let message = "";
|
|
|
|
if ( bucket.trim().length < 1 ) {
|
|
if ( source === "existing" && existingType === "select" ) {
|
|
message = $strings.no_bucket_selected;
|
|
} else {
|
|
message = $strings.create_bucket_name_missing;
|
|
|
|
}
|
|
} else if ( true === bucketNamePattern.test( bucket ) ) {
|
|
message = source === "new" ? $strings.create_bucket_invalid_chars : $strings.select_bucket_invalid_chars;
|
|
} else if ( bucket.length < 3 ) {
|
|
message = $strings.create_bucket_name_short;
|
|
} else if ( bucket.length > 63 ) {
|
|
message = $strings.create_bucket_name_long;
|
|
}
|
|
|
|
return message;
|
|
}
|
|
|
|
$: invalidBucketNameMessage = getInvalidBucketNameMessage( newBucket, bucketSource, enterOrSelectExisting );
|
|
|
|
/**
|
|
* Returns text to be used on Next button.
|
|
*
|
|
* @param {string} source Either "existing" or "new".
|
|
* @param {string} existingType Either "enter" or "select".
|
|
*
|
|
* @return {string}
|
|
*/
|
|
function getNextText( source, existingType ) {
|
|
if ( source === "existing" && existingType === "enter" ) {
|
|
return $strings.save_enter_bucket;
|
|
}
|
|
|
|
if ( source === "existing" && existingType === "select" ) {
|
|
return $strings.save_select_bucket;
|
|
}
|
|
|
|
if ( source === "new" ) {
|
|
return $strings.save_new_bucket;
|
|
}
|
|
|
|
return $strings.next;
|
|
}
|
|
|
|
$: nextText = getNextText( bucketSource, enterOrSelectExisting );
|
|
|
|
/**
|
|
* Handles a Next button click.
|
|
*
|
|
* @return {Promise<void>}
|
|
*/
|
|
async function handleNext() {
|
|
if ( bucketSource === "new" && false === await createBucket() ) {
|
|
scrollNotificationsIntoView();
|
|
return;
|
|
}
|
|
|
|
saving = true;
|
|
state.pausePeriodicFetch();
|
|
|
|
$settings.bucket = newBucket;
|
|
$settings.region = newRegion;
|
|
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();
|
|
|
|
result.bucketSource = bucketSource;
|
|
result.initialSettings = initialSettings;
|
|
|
|
dispatch( "routeEvent", {
|
|
event: "settings.save",
|
|
data: result,
|
|
default: "/"
|
|
} );
|
|
|
|
// Just make sure periodic state fetch promise is done with,
|
|
// even though we don't really care about it.
|
|
await statePromise;
|
|
$revalidatingSettings = false;
|
|
}
|
|
|
|
onMount( () => {
|
|
// Default to first region in storage provider if not defined and not set or not valid.
|
|
if ( !newRegionDefined && (newRegion.length === 0 || !$storage_provider.regions.hasOwnProperty( newRegion )) ) {
|
|
newRegion = Object.keys( $storage_provider.regions )[ 0 ];
|
|
}
|
|
} );
|
|
</script>
|
|
|
|
<SubPage name="bucket-settings" route="/storage/bucket">
|
|
<Panel heading={$strings.bucket_source_title} multi {defined}>
|
|
<PanelRow class="body flex-row tab-buttons">
|
|
<TabButton
|
|
active={bucketSource === "existing"}
|
|
{disabled}
|
|
text={$strings.use_existing_bucket}
|
|
on:click={handleExisting}
|
|
/>
|
|
<TabButton
|
|
active={bucketSource === "new"}
|
|
{disabled}
|
|
text={$strings.create_new_bucket}
|
|
on:click={handleNew}
|
|
/>
|
|
</PanelRow>
|
|
</Panel>
|
|
|
|
{#if bucketSource === "existing"}
|
|
<Panel heading={$strings.existing_bucket_title} storageProvider={$storage_provider} multi {defined}>
|
|
<PanelRow class="body flex-column">
|
|
<div class="flex-row align-center row radio-btns">
|
|
<RadioButton bind:selected={enterOrSelectExisting} value="enter" list {disabled}>{$strings.enter_bucket}</RadioButton>
|
|
<RadioButton bind:selected={enterOrSelectExisting} value="select" list {disabled}>{$strings.select_bucket}</RadioButton>
|
|
</div>
|
|
|
|
{#if enterOrSelectExisting === "enter"}
|
|
<div class="flex-row align-center row">
|
|
<div class="new-bucket-details flex-column">
|
|
<label class="input-label" for="bucket-name">{$strings.bucket_name}</label>
|
|
<input
|
|
type="text"
|
|
id="bucket-name"
|
|
class="bucket-name"
|
|
name="bucket"
|
|
minlength="3"
|
|
placeholder={$strings.enter_bucket_name_placeholder}
|
|
bind:value={newBucket}
|
|
class:disabled
|
|
{disabled}
|
|
>
|
|
</div>
|
|
{#if $storage_provider.region_required}
|
|
<div class="region flex-column">
|
|
<label class="input-label" for="region">
|
|
{$strings.region} <DefinedInWPConfig defined={newRegionDefined}/>
|
|
</label>
|
|
<select name="region" id="region" bind:value={newRegion} disabled={newRegionDisabled} class:disabled={newRegionDisabled}>
|
|
{#each Object.entries( $storage_provider.regions ) as [regionKey, regionName], index}
|
|
<option
|
|
value={regionKey}
|
|
selected={regionKey === newRegion}
|
|
>
|
|
{regionName}
|
|
</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if enterOrSelectExisting === "select"}
|
|
{#if $storage_provider.region_required}
|
|
<label class="input-label" for="list-region">
|
|
{$strings.region} <DefinedInWPConfig defined={newRegionDefined}/>
|
|
</label>
|
|
<select name="region" id="list-region" bind:value={newRegion} disabled={newRegionDisabled} class:disabled={newRegionDisabled}>
|
|
{#each Object.entries( $storage_provider.regions ) as [regionKey, regionName], index}
|
|
<option
|
|
value={regionKey}
|
|
selected={regionKey === newRegion}
|
|
>
|
|
{regionName}
|
|
</option>
|
|
{/each}
|
|
</select>
|
|
{/if}
|
|
{#await getBuckets( newRegion )}
|
|
<Loading/>
|
|
{:then buckets}
|
|
<ul class="bucket-list">
|
|
{#if buckets.length}
|
|
{#each buckets as bucket}
|
|
<!-- TODO: Fix a11y. -->
|
|
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
|
<li
|
|
class="row"
|
|
class:active={newBucket === bucket.Name}
|
|
on:click={() => newBucket = bucket.Name}
|
|
use:scrollIntoView={newBucket === bucket.Name}
|
|
data-bucket-name={bucket.Name}
|
|
>
|
|
<img class="icon bucket" src="{$urls.assets + 'img/icon/bucket.svg'}" alt={$strings.bucket_icon}>
|
|
<p>{bucket.Name}</p>
|
|
{#if newBucket === bucket.Name}
|
|
<img class="icon status" src="{$urls.assets + 'img/icon/licence-checked.svg'}" type="image/svg+xml" alt={$strings.selected_desc}>
|
|
{/if}
|
|
</li>
|
|
{/each}
|
|
{:else}
|
|
<li class="row nothing-found">
|
|
<p>{$strings.nothing_found}</p>
|
|
</li>
|
|
{/if}
|
|
</ul>
|
|
{/await}
|
|
{/if}
|
|
{#if invalidBucketNameMessage}
|
|
<p class="input-error" transition:slide>{invalidBucketNameMessage}</p>
|
|
{/if}
|
|
</PanelRow>
|
|
</Panel>
|
|
{/if}
|
|
|
|
{#if bucketSource === "new"}
|
|
<Panel heading={$strings.new_bucket_title} storageProvider={$storage_provider} multi {defined}>
|
|
<PanelRow class="body flex-column">
|
|
<div class="flex-row align-center row">
|
|
<div class="new-bucket-details flex-column">
|
|
<label class="input-label" for="new-bucket-name">{$strings.bucket_name}</label>
|
|
<input
|
|
type="text"
|
|
id="new-bucket-name"
|
|
class="bucket-name"
|
|
name="bucket"
|
|
minlength="3"
|
|
placeholder={$strings.enter_bucket_name_placeholder}
|
|
bind:value={newBucket}
|
|
class:disabled
|
|
{disabled}
|
|
>
|
|
</div>
|
|
<div class="region flex-column">
|
|
<label class="input-label" for="new-region">
|
|
{$strings.region} <DefinedInWPConfig defined={newRegionDefined}/>
|
|
</label>
|
|
<select name="region" id="new-region" bind:value={newRegion} disabled={newRegionDisabled} class:disabled={newRegionDisabled}>
|
|
{#each Object.entries( $storage_provider.regions ) as [regionKey, regionName], index}
|
|
<option
|
|
value={regionKey}
|
|
selected={regionKey === newRegion}
|
|
>
|
|
{regionName}
|
|
</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
{#if invalidBucketNameMessage}
|
|
<p class="input-error" transition:slide>{invalidBucketNameMessage}</p>
|
|
{/if}
|
|
</PanelRow>
|
|
</Panel>
|
|
{/if}
|
|
|
|
<BackNextButtonsRow
|
|
on:next={handleNext}
|
|
{nextText}
|
|
nextDisabled={invalidBucketNameMessage || $needs_refresh || $settingsLocked}
|
|
nextTitle={invalidBucketNameMessage}
|
|
/>
|
|
</SubPage>
|