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:
8
ui/js/autofocus.js
Normal file
8
ui/js/autofocus.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* A simple action that focuses the supplied HTML node.
|
||||
*
|
||||
* @param {Object} node
|
||||
*/
|
||||
export function autofocus( node ) {
|
||||
node.focus();
|
||||
}
|
||||
144
ui/js/defaultPages.js
Normal file
144
ui/js/defaultPages.js
Normal file
@@ -0,0 +1,144 @@
|
||||
import {get} from "svelte/store";
|
||||
import {location} from "svelte-spa-router";
|
||||
import {
|
||||
strings,
|
||||
storage_provider,
|
||||
is_plugin_setup_with_credentials,
|
||||
is_plugin_setup,
|
||||
needs_access_keys,
|
||||
delivery_provider
|
||||
} from "./stores";
|
||||
|
||||
// Components used for default pages.
|
||||
import MediaPage from "../components/MediaPage.svelte";
|
||||
import StoragePage from "../components/StoragePage.svelte";
|
||||
import StorageProviderSubPage
|
||||
from "../components/StorageProviderSubPage.svelte";
|
||||
import BucketSettingsSubPage from "../components/BucketSettingsSubPage.svelte";
|
||||
import SecuritySubPage from "../components/SecuritySubPage.svelte";
|
||||
import DeliveryPage from "../components/DeliveryPage.svelte";
|
||||
|
||||
// Default pages, having a title means inclusion in main tabs.
|
||||
// NOTE: get() only resolves after initialization, hence arrow functions for getting titles.
|
||||
export const defaultPages = [
|
||||
{
|
||||
position: 0,
|
||||
name: "media-library",
|
||||
title: () => get( strings ).media_tab_title,
|
||||
nav: true,
|
||||
route: "/",
|
||||
routeMatcher: /^\/(media\/.*)*$/,
|
||||
component: MediaPage,
|
||||
default: true
|
||||
},
|
||||
{
|
||||
position: 200,
|
||||
name: "storage",
|
||||
route: "/storage/*",
|
||||
component: StoragePage
|
||||
},
|
||||
{
|
||||
position: 210,
|
||||
name: "storage-provider",
|
||||
title: () => get( strings ).storage_provider_tab_title,
|
||||
subNav: true,
|
||||
route: "/storage/provider",
|
||||
component: StorageProviderSubPage,
|
||||
default: true,
|
||||
events: {
|
||||
"page.initial.settings": ( data ) => {
|
||||
// We need Storage Provider credentials for some pages to be useful.
|
||||
if ( data.hasOwnProperty( "location" ) && get( needs_access_keys ) && !get( is_plugin_setup ) ) {
|
||||
for ( const prefix of ["/storage", "/media", "/delivery"] ) {
|
||||
if ( data.location.startsWith( prefix ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return data.location === "/";
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
position: 220,
|
||||
name: "bucket",
|
||||
title: () => get( strings ).bucket_tab_title,
|
||||
subNav: true,
|
||||
route: "/storage/bucket",
|
||||
component: BucketSettingsSubPage,
|
||||
enabled: () => {
|
||||
return !get( needs_access_keys );
|
||||
},
|
||||
events: {
|
||||
"page.initial.settings": ( data ) => {
|
||||
// We need a bucket and region to have been verified before some pages are useful.
|
||||
if ( data.hasOwnProperty( "location" ) && !get( needs_access_keys ) && !get( is_plugin_setup ) ) {
|
||||
for ( const prefix of ["/storage", "/media", "/delivery"] ) {
|
||||
if ( data.location.startsWith( prefix ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return data.location === "/";
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
"settings.save": ( data ) => {
|
||||
// If currently in /storage/provider route, bucket is always next, assuming storage provider set up correctly.
|
||||
return get( location ) === "/storage/provider" && !get( needs_access_keys );
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
position: 230,
|
||||
name: "security",
|
||||
title: () => get( strings ).security_tab_title,
|
||||
subNav: true,
|
||||
route: "/storage/security",
|
||||
component: SecuritySubPage,
|
||||
enabled: () => {
|
||||
return get( is_plugin_setup_with_credentials ) && !get( storage_provider ).requires_acls;
|
||||
},
|
||||
events: {
|
||||
"settings.save": ( data ) => {
|
||||
// If currently in /storage/bucket route,
|
||||
// and storage provider does not require ACLs,
|
||||
// and bucket wasn't just created during initial set up
|
||||
// with delivery provider compatible access control,
|
||||
// then security is next.
|
||||
if (
|
||||
get( location ) === "/storage/bucket" &&
|
||||
get( is_plugin_setup_with_credentials ) &&
|
||||
!get( storage_provider ).requires_acls &&
|
||||
(
|
||||
!data.hasOwnProperty( "bucketSource" ) || // unexpected data issue
|
||||
data.bucketSource !== "new" || // bucket not created
|
||||
!data.hasOwnProperty( "initialSettings" ) || // unexpected data issue
|
||||
!data.initialSettings.hasOwnProperty( "bucket" ) || // unexpected data issue
|
||||
data.initialSettings.bucket.length > 0 || // bucket previously set
|
||||
!data.hasOwnProperty( "settings" ) || // unexpected data issue
|
||||
!data.settings.hasOwnProperty( "use-bucket-acls" ) || // unexpected data issue
|
||||
(
|
||||
!data.settings[ "use-bucket-acls" ] && // bucket not using ACLs ...
|
||||
get( delivery_provider ).requires_acls // ... but delivery provider needs ACLs
|
||||
)
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
position: 300,
|
||||
name: "delivery",
|
||||
route: "/delivery/*",
|
||||
component: DeliveryPage
|
||||
},
|
||||
];
|
||||
12
ui/js/delay.js
Normal file
12
ui/js/delay.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Return a promise that resolves after a minimum amount of time has elapsed.
|
||||
*
|
||||
* @param {number} start Timestamp of when the action started.
|
||||
* @param {number} minTime Minimum amount of time to delay in milliseconds.
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
export function delayMin( start, minTime ) {
|
||||
let elapsed = Date.now() - start;
|
||||
return new Promise( ( resolve ) => setTimeout( resolve, minTime - elapsed ) );
|
||||
}
|
||||
8
ui/js/getLocale.js
Normal file
8
ui/js/getLocale.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Get the user's current locale string.
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
export function getLocale() {
|
||||
return (navigator.languages && navigator.languages.length) ? navigator.languages[ 0 ] : navigator.language;
|
||||
}
|
||||
24
ui/js/needsRefresh.js
Normal file
24
ui/js/needsRefresh.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import {objectsDiffer} from "./objectsDiffer";
|
||||
|
||||
/**
|
||||
* Determines whether a page should be refreshed due to changes to settings.
|
||||
*
|
||||
* @param {boolean} saving
|
||||
* @param {object} previousSettings
|
||||
* @param {object} currentSettings
|
||||
* @param {object} previousDefines
|
||||
* @param {object} currentDefines
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function needsRefresh( saving, previousSettings, currentSettings, previousDefines, currentDefines ) {
|
||||
if ( saving ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( objectsDiffer( [previousSettings, currentSettings] ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return objectsDiffer( [previousDefines, currentDefines] );
|
||||
}
|
||||
23
ui/js/numToString.js
Normal file
23
ui/js/numToString.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import {getLocale} from "./getLocale";
|
||||
|
||||
/**
|
||||
* Get number formatted for user's current locale.
|
||||
*
|
||||
* @param {number} num
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
export function numToString( num ) {
|
||||
return Intl.NumberFormat( getLocale() ).format( num );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number formatted with short representation for user's current locale.
|
||||
*
|
||||
* @param {number} num
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
export function numToShortString( num ) {
|
||||
return Intl.NumberFormat( getLocale(), { notation: "compact" } ).format( num );
|
||||
}
|
||||
41
ui/js/objectsDiffer.js
Normal file
41
ui/js/objectsDiffer.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Does the current object have different keys or values compared to the previous version?
|
||||
*
|
||||
* @param {object} previous
|
||||
* @param {object} current
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function objectsDiffer( [previous, current] ) {
|
||||
if ( !previous || !current ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Any difference in keys?
|
||||
const prevKeys = Object.keys( previous );
|
||||
const currKeys = Object.keys( current );
|
||||
|
||||
if ( prevKeys.length !== currKeys.length ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Symmetrical diff to find extra keys in either object.
|
||||
if (
|
||||
prevKeys.filter( x => !currKeys.includes( x ) )
|
||||
.concat(
|
||||
currKeys.filter( x => !prevKeys.includes( x ) )
|
||||
)
|
||||
.length > 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Any difference in values?
|
||||
for ( const key in previous ) {
|
||||
if ( JSON.stringify( current[ key ] ) !== JSON.stringify( previous[ key ] ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
117
ui/js/routes.js
Normal file
117
ui/js/routes.js
Normal file
@@ -0,0 +1,117 @@
|
||||
import {derived, writable, get} from "svelte/store";
|
||||
import {wrap} from "svelte-spa-router/wrap";
|
||||
|
||||
/**
|
||||
* Creates store of default pages.
|
||||
*
|
||||
* Having a title means inclusion in main tabs.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
function createPages() {
|
||||
// NOTE: get() only resolves after initialization, hence arrow functions for getting titles.
|
||||
const { subscribe, set, update } = writable( [] );
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
set,
|
||||
add( page ) {
|
||||
update( $pages => {
|
||||
return [...$pages, page]
|
||||
.sort( ( a, b ) => {
|
||||
return a.position - b.position;
|
||||
} );
|
||||
} );
|
||||
},
|
||||
withPrefix( prefix = null ) {
|
||||
return get( this ).filter( ( page ) => {
|
||||
return (prefix && page.route.startsWith( prefix )) || !prefix;
|
||||
} );
|
||||
},
|
||||
routes( prefix = null ) {
|
||||
let defaultComponent = null;
|
||||
let defaultUserData = null;
|
||||
const routes = new Map();
|
||||
|
||||
// If a page can be enabled/disabled, check whether it is enabled before displaying.
|
||||
const conditions = [
|
||||
( detail ) => {
|
||||
if (
|
||||
detail.hasOwnProperty( "userData" ) &&
|
||||
detail.userData.hasOwnProperty( "page" ) &&
|
||||
detail.userData.page.hasOwnProperty( "enabled" )
|
||||
) {
|
||||
return detail.userData.page.enabled();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
];
|
||||
|
||||
for ( const page of this.withPrefix( prefix ) ) {
|
||||
const userData = { page: page };
|
||||
|
||||
let route = page.route;
|
||||
|
||||
if ( prefix && route !== prefix + "/*" ) {
|
||||
route = route.replace( prefix, "" );
|
||||
}
|
||||
|
||||
routes.set( route, wrap( {
|
||||
component: page.component,
|
||||
userData: userData,
|
||||
conditions: conditions
|
||||
} ) );
|
||||
|
||||
if ( !defaultComponent && page.default ) {
|
||||
defaultComponent = page.component;
|
||||
defaultUserData = userData;
|
||||
}
|
||||
}
|
||||
|
||||
if ( defaultComponent ) {
|
||||
routes.set( "*", wrap( {
|
||||
component: defaultComponent,
|
||||
userData: defaultUserData,
|
||||
conditions: conditions
|
||||
} ) );
|
||||
}
|
||||
|
||||
return routes;
|
||||
},
|
||||
handleRouteEvent( detail ) {
|
||||
if ( detail.hasOwnProperty( "event" ) ) {
|
||||
if ( !detail.hasOwnProperty( "data" ) ) {
|
||||
detail.data = {};
|
||||
}
|
||||
|
||||
// Find the first page that wants to handle the event
|
||||
// , but also let other pages see the event
|
||||
// so they can set any initial state etc.
|
||||
let route = false;
|
||||
for ( const page of get( this ).values() ) {
|
||||
if ( page.events && page.events[ detail.event ] && page.events[ detail.event ]( detail.data ) && !route ) {
|
||||
route = page.route;
|
||||
}
|
||||
}
|
||||
|
||||
if ( route ) {
|
||||
return route;
|
||||
}
|
||||
}
|
||||
|
||||
if ( detail.hasOwnProperty( "default" ) ) {
|
||||
return detail.default;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const pages = createPages();
|
||||
|
||||
// Convenience readable store of all routes.
|
||||
export const routes = derived( pages, () => {
|
||||
return pages.routes();
|
||||
} );
|
||||
11
ui/js/scrollIntoView.js
Normal file
11
ui/js/scrollIntoView.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* A simple action to scroll the element into view if active.
|
||||
*
|
||||
* @param {Object} node
|
||||
* @param {boolean} active
|
||||
*/
|
||||
export function scrollIntoView( node, active ) {
|
||||
if ( active ) {
|
||||
node.scrollIntoView( { behavior: "smooth", block: "center", inline: "nearest" } );
|
||||
}
|
||||
}
|
||||
10
ui/js/scrollNotificationsIntoView.js
Normal file
10
ui/js/scrollNotificationsIntoView.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Scrolls the notifications into view.
|
||||
*/
|
||||
export function scrollNotificationsIntoView() {
|
||||
const element = document.getElementById( "notifications" );
|
||||
|
||||
if ( element ) {
|
||||
element.scrollIntoView( { behavior: "smooth", block: "start" } );
|
||||
}
|
||||
}
|
||||
51
ui/js/settingsNotifications.js
Normal file
51
ui/js/settingsNotifications.js
Normal file
@@ -0,0 +1,51 @@
|
||||
export const settingsNotifications = {
|
||||
/**
|
||||
* Process local and server settings to return a new Map of inline notifications.
|
||||
*
|
||||
* @param {Map} notifications
|
||||
* @param {Object} settings
|
||||
* @param {Object} current_settings
|
||||
* @param {Object} strings
|
||||
*
|
||||
* @return {Map<string, Map<string, Object>>} keyed by setting name, containing map of notification objects keyed by id.
|
||||
*/
|
||||
process: ( notifications, settings, current_settings, strings ) => {
|
||||
// remove-local-file
|
||||
if ( settings.hasOwnProperty( "remove-local-file" ) && settings[ "remove-local-file" ] ) {
|
||||
let entries = notifications.has( "remove-local-file" ) ? notifications.get( "remove-local-file" ) : new Map();
|
||||
|
||||
if ( settings.hasOwnProperty( "serve-from-s3" ) && !settings[ "serve-from-s3" ] ) {
|
||||
if ( !entries.has( "lost-files-notice" ) ) {
|
||||
entries.set( "lost-files-notice", {
|
||||
inline: true,
|
||||
type: "error",
|
||||
heading: strings.lost_files_notice_heading,
|
||||
message: strings.lost_files_notice_message
|
||||
} );
|
||||
}
|
||||
} else {
|
||||
entries.delete( "lost-files-notice" );
|
||||
}
|
||||
|
||||
// Show inline warning about potential compatibility issues
|
||||
// when turning on setting for the first time.
|
||||
if (
|
||||
!entries.has( "remove-local-file-notice" ) &&
|
||||
current_settings.hasOwnProperty( "remove-local-file" ) &&
|
||||
!current_settings[ "remove-local-file" ]
|
||||
) {
|
||||
entries.set( "remove-local-file-notice", {
|
||||
inline: true,
|
||||
type: "warning",
|
||||
message: strings.remove_local_file_message
|
||||
} );
|
||||
}
|
||||
|
||||
notifications.set( "remove-local-file", entries );
|
||||
} else {
|
||||
notifications.delete( "remove-local-file" );
|
||||
}
|
||||
|
||||
return notifications;
|
||||
}
|
||||
};
|
||||
594
ui/js/stores.js
Normal file
594
ui/js/stores.js
Normal file
@@ -0,0 +1,594 @@
|
||||
import {derived, writable, get, readable} from "svelte/store";
|
||||
import {objectsDiffer} from "./objectsDiffer";
|
||||
|
||||
// Initial config store.
|
||||
export const config = writable( {} );
|
||||
|
||||
// Whether settings are locked due to background activity such as upgrade.
|
||||
export const settingsLocked = writable( false );
|
||||
|
||||
// Convenience readable store of server's settings, derived from config.
|
||||
export const current_settings = derived( config, $config => $config.settings );
|
||||
|
||||
// Convenience readable store of defined settings keys, derived from config.
|
||||
export const defined_settings = derived( config, $config => $config.defined_settings );
|
||||
|
||||
// Convenience readable store of translated strings, derived from config.
|
||||
export const strings = derived( config, $config => $config.strings );
|
||||
|
||||
// Convenience readable store for nonce, derived from config.
|
||||
export const nonce = derived( config, $config => $config.nonce );
|
||||
|
||||
// Convenience readable store of urls, derived from config.
|
||||
export const urls = derived( config, $config => $config.urls );
|
||||
|
||||
// Convenience readable store of docs, derived from config.
|
||||
export const docs = derived( config, $config => $config.docs );
|
||||
|
||||
// Convenience readable store of api endpoints, derived from config.
|
||||
export const endpoints = derived( config, $config => $config.endpoints );
|
||||
|
||||
// Convenience readable store of diagnostics, derived from config.
|
||||
export const diagnostics = derived( config, $config => $config.diagnostics );
|
||||
|
||||
// Convenience readable store of counts, derived from config.
|
||||
export const counts = derived( config, $config => $config.counts );
|
||||
|
||||
// Convenience readable store of summary counts, derived from config.
|
||||
export const summaryCounts = derived( config, $config => $config.summary_counts );
|
||||
|
||||
// Convenience readable store of offload remaining upsell, derived from config.
|
||||
export const offloadRemainingUpsell = derived( config, $config => $config.offload_remaining_upsell );
|
||||
|
||||
// Convenience readable store of upgrades, derived from config.
|
||||
export const upgrades = derived( config, $config => $config.upgrades );
|
||||
|
||||
// Convenience readable store of whether plugin is set up, derived from config.
|
||||
export const is_plugin_setup = derived( config, $config => $config.is_plugin_setup );
|
||||
|
||||
// Convenience readable store of whether plugin is set up, including with credentials, derived from config.
|
||||
export const is_plugin_setup_with_credentials = derived( config, $config => $config.is_plugin_setup_with_credentials );
|
||||
|
||||
// Convenience readable store of whether storage provider needs access credentials, derived from config.
|
||||
export const needs_access_keys = derived( config, $config => $config.needs_access_keys );
|
||||
|
||||
// Convenience readable store of whether bucket is writable, derived from config.
|
||||
export const bucket_writable = derived( config, $config => $config.bucket_writable );
|
||||
|
||||
// Convenience readable store of settings validation results, derived from config.
|
||||
export const settings_validation = derived( config, $config => $config.settings_validation );
|
||||
|
||||
// Store of inline errors and warnings to be shown next to settings.
|
||||
// Format is a map using settings key for keys, values are an array of objects that can be used to instantiate a notification.
|
||||
export const settings_notifications = writable( new Map() );
|
||||
|
||||
// Store of validation errors for settings.
|
||||
// Format is a map using settings key for keys, values are strings containing validation error.
|
||||
export const validationErrors = writable( new Map() );
|
||||
|
||||
// Whether settings validations are being run.
|
||||
export const revalidatingSettings = writable( false );
|
||||
|
||||
// Does the app need a page refresh to resolve conflicts?
|
||||
export const needs_refresh = writable( false );
|
||||
|
||||
// Various stores may call the API, and the api object uses some stores.
|
||||
// To avoid cyclic dependencies, we therefore co-locate the api object with the stores.
|
||||
// We also need to add its functions much later so that JSHint does not complain about using the stores too early.
|
||||
export const api = {};
|
||||
|
||||
/**
|
||||
* Creates store of settings.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
function createSettings() {
|
||||
const { subscribe, set, update } = writable( [] );
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
set,
|
||||
async save() {
|
||||
const json = await api.put( "settings", get( this ) );
|
||||
|
||||
if ( json.hasOwnProperty( "saved" ) && true === json.saved ) {
|
||||
// Sync settings with what the server has.
|
||||
this.updateSettings( json );
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
return { 'saved': false };
|
||||
},
|
||||
reset() {
|
||||
set( { ...get( current_settings ) } );
|
||||
},
|
||||
async fetch() {
|
||||
const json = await api.get( "settings", {} );
|
||||
this.updateSettings( json );
|
||||
},
|
||||
updateSettings( json ) {
|
||||
if (
|
||||
json.hasOwnProperty( "defined_settings" ) &&
|
||||
json.hasOwnProperty( "settings" ) &&
|
||||
json.hasOwnProperty( "storage_providers" ) &&
|
||||
json.hasOwnProperty( "delivery_providers" ) &&
|
||||
json.hasOwnProperty( "is_plugin_setup" ) &&
|
||||
json.hasOwnProperty( "is_plugin_setup_with_credentials" ) &&
|
||||
json.hasOwnProperty( "needs_access_keys" ) &&
|
||||
json.hasOwnProperty( "bucket_writable" ) &&
|
||||
json.hasOwnProperty( "urls" )
|
||||
) {
|
||||
// Update our understanding of what the server's settings are.
|
||||
config.update( $config => {
|
||||
return {
|
||||
...$config,
|
||||
defined_settings: json.defined_settings,
|
||||
settings: json.settings,
|
||||
storage_providers: json.storage_providers,
|
||||
delivery_providers: json.delivery_providers,
|
||||
is_plugin_setup: json.is_plugin_setup,
|
||||
is_plugin_setup_with_credentials: json.is_plugin_setup_with_credentials,
|
||||
needs_access_keys: json.needs_access_keys,
|
||||
bucket_writable: json.bucket_writable,
|
||||
urls: json.urls
|
||||
};
|
||||
} );
|
||||
// Update our local working copy of the settings.
|
||||
update( $settings => {
|
||||
return { ...json.settings };
|
||||
} );
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const settings = createSettings();
|
||||
|
||||
// Have the settings been changed from current server side settings?
|
||||
export const settings_changed = derived( [settings, current_settings], objectsDiffer );
|
||||
|
||||
// Convenience readable store of default storage provider, derived from config.
|
||||
export const defaultStorageProvider = derived( config, $config => $config.default_storage_provider );
|
||||
|
||||
// Convenience readable store of available storage providers.
|
||||
export const storage_providers = derived( [config, urls], ( [$config, $urls] ) => {
|
||||
for ( const key in $config.storage_providers ) {
|
||||
$config.storage_providers[ key ].icon = $urls.assets + "img/icon/provider/storage/" + $config.storage_providers[ key ].provider_key_name + ".svg";
|
||||
$config.storage_providers[ key ].link_icon = $urls.assets + "img/icon/provider/storage/" + $config.storage_providers[ key ].provider_key_name + "-link.svg";
|
||||
$config.storage_providers[ key ].round_icon = $urls.assets + "img/icon/provider/storage/" + $config.storage_providers[ key ].provider_key_name + "-round.svg";
|
||||
}
|
||||
|
||||
return $config.storage_providers;
|
||||
} );
|
||||
|
||||
// Convenience readable store of storage provider's details.
|
||||
export const storage_provider = derived( [settings, storage_providers], ( [$settings, $storage_providers] ) => {
|
||||
if ( $settings.hasOwnProperty( "provider" ) && $storage_providers.hasOwnProperty( $settings.provider ) ) {
|
||||
return $storage_providers[ $settings.provider ];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
} );
|
||||
|
||||
// Convenience readable store of default delivery provider, derived from config.
|
||||
export const defaultDeliveryProvider = derived( config, $config => $config.default_delivery_provider );
|
||||
|
||||
// Convenience readable store of available delivery providers.
|
||||
export const delivery_providers = derived( [config, urls, storage_provider], ( [$config, $urls, $storage_provider] ) => {
|
||||
for ( const key in $config.delivery_providers ) {
|
||||
if ( "storage" === key ) {
|
||||
$config.delivery_providers[ key ].icon = $storage_provider.icon;
|
||||
$config.delivery_providers[ key ].round_icon = $storage_provider.round_icon;
|
||||
$config.delivery_providers[ key ].provider_service_quick_start_url = $storage_provider.provider_service_quick_start_url;
|
||||
} else {
|
||||
$config.delivery_providers[ key ].icon = $urls.assets + "img/icon/provider/delivery/" + $config.delivery_providers[ key ].provider_key_name + ".svg";
|
||||
$config.delivery_providers[ key ].round_icon = $urls.assets + "img/icon/provider/delivery/" + $config.delivery_providers[ key ].provider_key_name + "-round.svg";
|
||||
}
|
||||
}
|
||||
|
||||
return $config.delivery_providers;
|
||||
} );
|
||||
|
||||
// Convenience readable store of delivery provider's details.
|
||||
export const delivery_provider = derived( [settings, delivery_providers, urls], ( [$settings, $delivery_providers, $urls] ) => {
|
||||
if ( $settings.hasOwnProperty( "delivery-provider" ) && $delivery_providers.hasOwnProperty( $settings[ "delivery-provider" ] ) ) {
|
||||
return $delivery_providers[ $settings[ "delivery-provider" ] ];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
} );
|
||||
|
||||
// Full name for current region.
|
||||
export const region_name = derived( [settings, storage_provider, strings], ( [$settings, $storage_provider, $strings] ) => {
|
||||
if ( $settings.region && $storage_provider.regions && $storage_provider.regions.hasOwnProperty( $settings.region ) ) {
|
||||
return $storage_provider.regions[ $settings.region ];
|
||||
} else if ( $settings.region && $storage_provider.regions ) {
|
||||
// Region set but not available in list of regions.
|
||||
return $strings.unknown;
|
||||
} else if ( $storage_provider.default_region && $storage_provider.regions && $storage_provider.regions.hasOwnProperty( $storage_provider.default_region ) ) {
|
||||
// Region not set but default available.
|
||||
return $storage_provider.regions[ $storage_provider.default_region ];
|
||||
} else {
|
||||
// Possibly no default region or regions available.
|
||||
return $strings.unknown;
|
||||
}
|
||||
} );
|
||||
|
||||
// Convenience readable store of whether Block All Public Access is enabled.
|
||||
export const bapa = derived( [settings, storage_provider], ( [$settings, $storage_provider] ) => {
|
||||
return $storage_provider.block_public_access_supported && $settings.hasOwnProperty( "block-public-access" ) && $settings[ "block-public-access" ];
|
||||
} );
|
||||
|
||||
// Convenience readable store of whether Object Ownership is enforced.
|
||||
export const ooe = derived( [settings, storage_provider], ( [$settings, $storage_provider] ) => {
|
||||
return $storage_provider.object_ownership_supported && $settings.hasOwnProperty( "object-ownership-enforced" ) && $settings[ "object-ownership-enforced" ];
|
||||
} );
|
||||
|
||||
/**
|
||||
* Creates a store of notifications.
|
||||
*
|
||||
* Example object in the array:
|
||||
* {
|
||||
* id: "error-message",
|
||||
* type: "error", // error | warning | success | primary (default)
|
||||
* dismissible: true,
|
||||
* flash: true, // Optional, means notification is context specific and will not persist on server, defaults to true.
|
||||
* inline: false, // Optional, unlikely to be true, included here for completeness.
|
||||
* only_show_on_tab: "media-library", // Optional, blank/missing means on all tabs.
|
||||
* heading: "Global Error: Something has gone terribly pear shaped.", // Optional.
|
||||
* message: "We're so sorry, but unfortunately we're going to have to delete the year 2020.", // Optional.
|
||||
* icon: "notification-error.svg", // Optional icon file name to be shown in front of heading.
|
||||
* plainHeading: false, // Optional boolean as to whether a <p> tag should be used instead of <h3> for heading content.
|
||||
* extra: "", // Optional extra content to be shown in paragraph below message.
|
||||
* links: [], // Optional list of links to be shown at bottom of notice.
|
||||
* },
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
function createNotifications() {
|
||||
const { subscribe, set, update } = writable( [] );
|
||||
|
||||
return {
|
||||
set,
|
||||
subscribe,
|
||||
add( notification ) {
|
||||
// There's a slight difference between our notification's formatting and what WP uses.
|
||||
if ( notification.hasOwnProperty( "type" ) && notification.type === "updated" ) {
|
||||
notification.type = "success";
|
||||
}
|
||||
if ( notification.hasOwnProperty( "type" ) && notification.type === "notice-warning" ) {
|
||||
notification.type = "warning";
|
||||
}
|
||||
if ( notification.hasOwnProperty( "type" ) && notification.type === "notice-info" ) {
|
||||
notification.type = "info";
|
||||
}
|
||||
if (
|
||||
notification.hasOwnProperty( "message" ) &&
|
||||
(!notification.hasOwnProperty( "heading" ) || notification.heading.trim().length === 0)
|
||||
) {
|
||||
notification.heading = notification.message;
|
||||
notification.plainHeading = true;
|
||||
delete notification.message;
|
||||
}
|
||||
if ( !notification.hasOwnProperty( "flash" ) ) {
|
||||
notification.flash = true;
|
||||
}
|
||||
|
||||
// We need some sort of id for indexing and to ensure rendering is efficient.
|
||||
if ( !notification.hasOwnProperty( "id" ) ) {
|
||||
// Notifications are useless without at least a heading or message, so we can be sure at least one exists.
|
||||
const idHeading = notification.hasOwnProperty( "heading" ) ? notification.heading.trim() : "dynamic-heading";
|
||||
const idMessage = notification.hasOwnProperty( "message" ) ? notification.message.trim() : "dynamic-message";
|
||||
|
||||
notification.id = btoa( idHeading + idMessage );
|
||||
}
|
||||
|
||||
// So that rendering is efficient, but updates displayed notifications that re-use keys,
|
||||
// we create a render_key based on id and created_at as created_at is churned on re-use.
|
||||
const createdAt = notification.hasOwnProperty( "created_at" ) ? notification.created_at : 0;
|
||||
notification.render_key = notification.id + "-" + createdAt;
|
||||
|
||||
update( $notifications => {
|
||||
// Maybe update a notification if id already exists.
|
||||
let index = -1;
|
||||
if ( notification.hasOwnProperty( "id" ) ) {
|
||||
index = $notifications.findIndex( _notification => _notification.id === notification.id );
|
||||
}
|
||||
|
||||
if ( index >= 0 ) {
|
||||
// If the id exists but has been dismissed, add the replacement notification to the end of the array
|
||||
// if given notification is newer, otherwise skip it entirely.
|
||||
if ( $notifications[ index ].hasOwnProperty( "dismissed" ) ) {
|
||||
if ( $notifications[ index ].dismissed < notification.created_at ) {
|
||||
$notifications.push( notification );
|
||||
$notifications.splice( index, 1 );
|
||||
}
|
||||
} else {
|
||||
// Update existing.
|
||||
$notifications.splice( index, 1, notification );
|
||||
}
|
||||
} else {
|
||||
// Add new.
|
||||
$notifications.push( notification );
|
||||
}
|
||||
|
||||
return $notifications.sort( this.sortCompare );
|
||||
} );
|
||||
},
|
||||
sortCompare( a, b ) {
|
||||
// Sort by created_at in case an existing notification was updated.
|
||||
if ( a.created_at < b.created_at ) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if ( a.created_at > b.created_at ) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
},
|
||||
async dismiss( id ) {
|
||||
update( $notifications => {
|
||||
const index = $notifications.findIndex( notification => notification.id === id );
|
||||
|
||||
// If the notification still exists, set a "dismissed" tombstone with the created_at value.
|
||||
// The cleanup will delete any notifications that have been dismissed and no longer exist
|
||||
// in the list of notifications retrieved from the server.
|
||||
// The created_at value ensures that if a notification is retrieved from the server that
|
||||
// has the same id but later created_at, then it can be added, otherwise it is skipped.
|
||||
if ( index >= 0 ) {
|
||||
if ( $notifications[ index ].hasOwnProperty( "created_at" ) ) {
|
||||
$notifications[ index ].dismissed = $notifications[ index ].created_at;
|
||||
} else {
|
||||
// Notification likely did not come from server, maybe a local "flash" notification.
|
||||
$notifications.splice( index, 1 );
|
||||
}
|
||||
}
|
||||
|
||||
return $notifications;
|
||||
} );
|
||||
|
||||
// Tell server to dismiss notification, still ok to try if flash notification, makes sure it is definitely removed.
|
||||
await api.delete( "notifications", { id: id, all_tabs: true } );
|
||||
},
|
||||
/**
|
||||
* Delete removes a notification from the UI without telling the server.
|
||||
*/
|
||||
delete( id ) {
|
||||
update( $notifications => {
|
||||
const index = $notifications.findIndex( notification => notification.id === id );
|
||||
|
||||
if ( index >= 0 ) {
|
||||
$notifications.splice( index, 1 );
|
||||
}
|
||||
|
||||
return $notifications;
|
||||
} );
|
||||
},
|
||||
cleanup( latest ) {
|
||||
update( $notifications => {
|
||||
for ( const [index, notification] of $notifications.entries() ) {
|
||||
// Only clean up dismissed or server created notices that no longer exist.
|
||||
if ( notification.hasOwnProperty( "dismissed" ) || notification.hasOwnProperty( "created_at" ) ) {
|
||||
const latestIndex = latest.findIndex( _notification => _notification.id === notification.id );
|
||||
|
||||
// If server doesn't know about the notification anymore, remove it.
|
||||
if ( latestIndex < 0 ) {
|
||||
$notifications.splice( index, 1 );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $notifications;
|
||||
} );
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const notifications = createNotifications();
|
||||
|
||||
// Controller for periodic fetch of state info.
|
||||
let stateFetchInterval;
|
||||
let stateFetchIntervalStarted = false;
|
||||
let stateFetchIntervalPaused = false;
|
||||
|
||||
// Store of functions to call before an update of state processes the result into config.
|
||||
export const preStateUpdateCallbacks = writable( [] );
|
||||
|
||||
// Store of functions to call after an update of state processes the result into config.
|
||||
export const postStateUpdateCallbacks = writable( [] );
|
||||
|
||||
/**
|
||||
* Store of functions to call when state info is updated, and actual API access methods.
|
||||
*
|
||||
* Functions are called after the returned state info has been used to update the config store.
|
||||
* Therefore, functions should only be added to the store if extra processing is required.
|
||||
* The functions should be asynchronous as they are part of the reactive chain and called with await.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
function createState() {
|
||||
const { subscribe, set, update } = writable( [] );
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
set,
|
||||
update,
|
||||
async fetch() {
|
||||
const json = await api.get( "state", {} );
|
||||
|
||||
// Abort controller is still a bit hit or miss, so we'll go old skool.
|
||||
if ( stateFetchIntervalStarted && !stateFetchIntervalPaused ) {
|
||||
this.updateState( json );
|
||||
}
|
||||
},
|
||||
updateState( json ) {
|
||||
for ( const callable of get( preStateUpdateCallbacks ) ) {
|
||||
callable( json );
|
||||
}
|
||||
|
||||
const dirty = get( settings_changed );
|
||||
const previous_settings = { ...get( current_settings ) }; // cloned
|
||||
|
||||
config.update( $config => {
|
||||
return { ...$config, ...json };
|
||||
} );
|
||||
|
||||
// If the settings weren't changed before, they shouldn't be now.
|
||||
if ( !dirty && get( settings_changed ) ) {
|
||||
settings.reset();
|
||||
}
|
||||
|
||||
// If settings are in middle of being changed when changes come in
|
||||
// from server, reset to server version.
|
||||
if ( dirty && objectsDiffer( [previous_settings, get( current_settings )] ) ) {
|
||||
needs_refresh.update( $needs_refresh => true );
|
||||
settings.reset();
|
||||
}
|
||||
|
||||
for ( const callable of get( postStateUpdateCallbacks ) ) {
|
||||
callable( json );
|
||||
}
|
||||
},
|
||||
async startPeriodicFetch() {
|
||||
stateFetchIntervalStarted = true;
|
||||
stateFetchIntervalPaused = false;
|
||||
|
||||
await this.fetch();
|
||||
|
||||
stateFetchInterval = setInterval( async () => {
|
||||
await this.fetch();
|
||||
}, 5000 );
|
||||
},
|
||||
stopPeriodicFetch() {
|
||||
stateFetchIntervalStarted = false;
|
||||
stateFetchIntervalPaused = false;
|
||||
|
||||
clearInterval( stateFetchInterval );
|
||||
},
|
||||
pausePeriodicFetch() {
|
||||
if ( stateFetchIntervalStarted ) {
|
||||
stateFetchIntervalPaused = true;
|
||||
clearInterval( stateFetchInterval );
|
||||
}
|
||||
},
|
||||
async resumePeriodicFetch() {
|
||||
stateFetchIntervalPaused = false;
|
||||
|
||||
if ( stateFetchIntervalStarted ) {
|
||||
await this.startPeriodicFetch();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const state = createState();
|
||||
|
||||
// API functions added here to avoid JSHint errors.
|
||||
api.headers = () => {
|
||||
return {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': get( nonce )
|
||||
};
|
||||
};
|
||||
|
||||
api.url = ( endpoint ) => {
|
||||
return get( urls ).api + get( endpoints )[ endpoint ];
|
||||
};
|
||||
|
||||
api.get = async ( endpoint, params ) => {
|
||||
let url = new URL( api.url( endpoint ) );
|
||||
|
||||
const searchParams = new URLSearchParams( params );
|
||||
|
||||
searchParams.forEach( function( value, name ) {
|
||||
url.searchParams.set( name, value );
|
||||
} );
|
||||
|
||||
const response = await fetch( url.toString(), {
|
||||
method: 'GET',
|
||||
headers: api.headers()
|
||||
} );
|
||||
return response.json().then( json => {
|
||||
json = api.check_response( json );
|
||||
return json;
|
||||
} );
|
||||
};
|
||||
|
||||
api.post = async ( endpoint, body ) => {
|
||||
const response = await fetch( api.url( endpoint ), {
|
||||
method: 'POST',
|
||||
headers: api.headers(),
|
||||
body: JSON.stringify( body )
|
||||
} );
|
||||
return response.json().then( json => {
|
||||
json = api.check_response( json );
|
||||
return json;
|
||||
} );
|
||||
};
|
||||
|
||||
api.put = async ( endpoint, body ) => {
|
||||
const response = await fetch( api.url( endpoint ), {
|
||||
method: 'PUT',
|
||||
headers: api.headers(),
|
||||
body: JSON.stringify( body )
|
||||
} );
|
||||
return response.json().then( json => {
|
||||
json = api.check_response( json );
|
||||
return json;
|
||||
} );
|
||||
};
|
||||
|
||||
api.delete = async ( endpoint, body ) => {
|
||||
const response = await fetch( api.url( endpoint ), {
|
||||
method: 'DELETE',
|
||||
headers: api.headers(),
|
||||
body: JSON.stringify( body )
|
||||
} );
|
||||
return response.json().then( json => {
|
||||
json = api.check_response( json );
|
||||
return json;
|
||||
} );
|
||||
};
|
||||
|
||||
api.check_errors = ( json ) => {
|
||||
if ( json.code && json.message ) {
|
||||
notifications.add( {
|
||||
id: json.code,
|
||||
type: 'error',
|
||||
dismissible: true,
|
||||
heading: get( strings ).api_error_notice_heading,
|
||||
message: json.message
|
||||
} );
|
||||
|
||||
// Just in case resultant json is expanded into a store.
|
||||
delete json.code;
|
||||
delete json.message;
|
||||
}
|
||||
|
||||
return json;
|
||||
};
|
||||
|
||||
api.check_notifications = ( json ) => {
|
||||
const _notifications = json.hasOwnProperty( "notifications" ) ? json.notifications : [];
|
||||
if ( _notifications ) {
|
||||
for ( const notification of _notifications ) {
|
||||
notifications.add( notification );
|
||||
}
|
||||
}
|
||||
notifications.cleanup( _notifications );
|
||||
|
||||
// Just in case resultant json is expanded into a store.
|
||||
delete json.notifications;
|
||||
|
||||
return json;
|
||||
};
|
||||
|
||||
api.check_response = ( json ) => {
|
||||
json = api.check_notifications( json );
|
||||
json = api.check_errors( json );
|
||||
|
||||
return json;
|
||||
};
|
||||
Reference in New Issue
Block a user