Files
WPS3Media/ui/components/BucketSettingsSubPage.svelte
Malin 3248cbb029 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
2026-03-03 12:30:18 +01:00

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}&nbsp;<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}&nbsp;<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}&nbsp;<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>