|null */ private static $settings_cache = null; /** * Per-request cache of the merged credentials array. * * @var array|null */ private static $credentials_cache = null; /** * Maps each credential key to the `wp-config.php` constant that overrides it. * * @return array */ public static function credential_constants(): array { return array( 'provider' => 'R2CLOUDER_PROVIDER', 'account_id' => 'R2CLOUDER_ACCOUNT_ID', 'region' => 'R2CLOUDER_REGION', 'access_key' => 'R2CLOUDER_ACCESS_KEY', 'secret_key' => 'R2CLOUDER_SECRET_KEY', 'bucket' => 'R2CLOUDER_BUCKET', 'public_url' => 'R2CLOUDER_PUBLIC_URL', 'cf_provisioning_token' => 'R2CLOUDER_CF_PROVISIONING_TOKEN', 'cf_api_token' => 'R2CLOUDER_CF_API_TOKEN', 'cf_domain_api_token' => 'R2CLOUDER_CF_DOMAIN_API_TOKEN', ); } /** * Returns the selected provider's display name. * * @return string */ public static function provider_label(): string { $labels = array( 'r2' => __( 'Cloudflare R2', 'devsyed-media-offloader' ), 's3' => __( 'AWS S3', 'devsyed-media-offloader' ), 'spaces' => __( 'DigitalOcean Spaces', 'devsyed-media-offloader' ), 'gcs' => __( 'Google Cloud Storage', 'devsyed-media-offloader' ), ); return $labels[ self::get_credential( 'provider' ) ]; } /** * Delivery presets used by the settings screen. * * @return array */ public static function delivery_presets(): array { return array( 'custom' => array( 'label' => __( 'Custom URL / direct bucket', 'devsyed-media-offloader' ), 'example' => 'https://media.example.com', 'help' => __( 'Use your public bucket URL or an existing HTTPS delivery domain. The URL must serve object keys at the same paths as the bucket.', 'devsyed-media-offloader' ), 'docs' => '', ), 'cloudflare' => array( 'label' => 'Cloudflare', 'example' => 'https://media.example.com', 'help' => __( 'For R2, connect a custom delivery domain below. For other storage providers, configure your Cloudflare delivery domain to serve the bucket.', 'devsyed-media-offloader' ), 'docs' => 'https://developers.cloudflare.com/r2/buckets/public-buckets/', ), 'cloudfront' => array( 'label' => 'Amazon CloudFront', 'example' => 'https://media.example.com', 'help' => __( 'Use your distribution domain or HTTPS custom domain. Configure the bucket as the origin; private S3 buckets need origin access configured in CloudFront.', 'devsyed-media-offloader' ), 'docs' => 'https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/LinkFormat.html', ), 'bunny' => array( 'label' => 'Bunny CDN', 'example' => 'https://media.example.com', 'help' => __( 'Create a Pull Zone pointing to the bucket origin. Use its b-cdn.net hostname or an HTTPS custom hostname.', 'devsyed-media-offloader' ), 'docs' => 'https://support.bunny.net/hc/en-us/categories/360003064619-CDN', ), 'fastly' => array( 'label' => 'Fastly', 'example' => 'https://media.example.com', 'help' => __( 'Use a domain configured on your Fastly service with TLS enabled. Set the bucket as the backend and configure the origin Host header and TLS hostname.', 'devsyed-media-offloader' ), 'docs' => 'https://www.fastly.com/documentation/guides/getting-started/hosts/working-with-hosts/', ), 'keycdn' => array( 'label' => 'KeyCDN', 'example' => 'https://media.example.com', 'help' => __( 'Create a Pull Zone with the bucket as its Origin URL. Use the Zone URL or a Zone Alias with HTTPS enabled.', 'devsyed-media-offloader' ), 'docs' => 'https://www.keycdn.com/support/getting-started', ), 'gcore' => array( 'label' => 'Gcore', 'example' => 'https://media.example.com', 'help' => __( 'Configure a CDN resource with the bucket as its origin. Use the resource delivery domain or your custom domain with HTTPS enabled.', 'devsyed-media-offloader' ), 'docs' => 'https://gcore.com/docs/cdn', ), 'azure' => array( 'label' => 'Azure Front Door', 'example' => 'https://media.example.com', 'help' => __( 'Configure a route to your bucket origin. Use the Front Door endpoint hostname from your dashboard or an HTTPS custom domain.', 'devsyed-media-offloader' ), 'docs' => 'https://learn.microsoft.com/en-us/azure/frontdoor/endpoint', ), ); } /** * Default non-secret settings. * * Every infrastructure value is intentionally empty. The plugin must never * ship a real account id, bucket name or delivery domain. * * @return array */ public static function defaults(): array { return array( 'enabled' => false, 'offload_new_uploads' => true, 'delete_local' => false, 'offload_original_image' => true, 'key_prefix' => '', 'object_versioning' => false, 'key_template' => '{year}/{month}/{filename}', 'webp_enabled' => true, 'webp_quality' => 82, 'avif_enabled' => false, 'avif_quality' => 55, 'picture_tag' => false, 'legacy_rewrite' => true, 'offload_non_images' => false, 'mime_allowlist' => array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'video/*', 'audio/*', 'application/pdf', 'application/zip', 'application/x-gzip', ), 'extension_filter_mode' => 'all', 'extension_filter_list' => array(), 'non_image_disposition' => 'inline', 'purge_enabled' => false, 'purge_zone_id' => '', 'delivery_zone_id' => '', 'delivery_provider' => 'custom', 'delivery_mode' => 'public', 'cache_control' => 'public, max-age=31536000, immutable', 'signed_url_lifetime' => 3600, 'sync_mode' => 'mixed', 'queue_batch_size' => 20, 'queue_time_limit' => 20, 'request_timeout' => 30, 'log_level' => 'error', 'log_max_rows' => 500, 'remove_data_on_uninstall' => false, ); } /** * Returns all settings, with defaults filled in for missing keys. * * @return array */ public static function get_settings(): array { if ( null === self::$settings_cache ) { $stored = get_option( self::SETTINGS, array() ); if ( ! is_array( $stored ) ) { $stored = array(); } self::$settings_cache = array_merge( self::defaults(), $stored ); } return self::$settings_cache; } /** * Returns a single setting. * * @param string $key Setting key. * @param mixed $default_value Value returned when the key is unknown. * @return mixed */ public static function get( $key, $default_value = null ) { $settings = self::get_settings(); return array_key_exists( $key, $settings ) ? $settings[ $key ] : $default_value; } /** * Merges the given values into the stored settings. * * @param array $values Settings to write. * @return bool True when the option row was updated. */ public static function update_settings( array $values ): bool { $stored = get_option( self::SETTINGS, array() ); if ( ! is_array( $stored ) ) { $stored = array(); } $merged = array_merge( $stored, $values ); $merged = array_intersect_key( $merged, self::defaults() ); self::$settings_cache = null; $updated = update_option( self::SETTINGS, $merged, false ); do_action( 'r2clouder_settings_updated', array_keys( $values ) ); return $updated; } /** * Returns all credentials, constants taking precedence over the database. * * @return array */ public static function get_credentials(): array { if ( null === self::$credentials_cache ) { $stored = get_option( self::CREDENTIALS, array() ); if ( ! is_array( $stored ) ) { $stored = array(); } $merged = array(); foreach ( self::credential_constants() as $key => $constant ) { if ( defined( $constant ) && is_string( constant( $constant ) ) && '' !== constant( $constant ) ) { $merged[ $key ] = trim( constant( $constant ) ); continue; } $merged[ $key ] = isset( $stored[ $key ] ) && is_string( $stored[ $key ] ) ? trim( $stored[ $key ] ) : ''; } if ( ! in_array( $merged['provider'], array( 'r2', 's3', 'spaces', 'gcs' ), true ) ) { $merged['provider'] = 'r2'; } self::$credentials_cache = $merged; } return self::$credentials_cache; } /** * Returns a single credential. * * @param string $key Credential key. * @return string Empty string when unset. */ public static function get_credential( $key ): string { $credentials = self::get_credentials(); return isset( $credentials[ $key ] ) ? $credentials[ $key ] : ''; } /** * Reports where a credential's value came from. * * Used by the settings screen to render a read-only notice instead of an * editable field when the value is pinned by a constant. * * @param string $key Credential key. * @return string One of `constant`, `option` or `none`. */ public static function credential_source( $key ): string { $constants = self::credential_constants(); if ( ! isset( $constants[ $key ] ) ) { return 'none'; } if ( defined( $constants[ $key ] ) && is_string( constant( $constants[ $key ] ) ) && '' !== constant( $constants[ $key ] ) ) { return 'constant'; } return '' === self::get_credential( $key ) ? 'none' : 'option'; } /** * Merges the given values into the stored credentials. * * Keys mapped to a defined constant are discarded, because writing them * would have no effect and would only duplicate the secret in the database. * * @param array $values Credentials to write. * @return bool True when the option row was updated. */ public static function update_credentials( array $values ): bool { $stored = get_option( self::CREDENTIALS, array() ); if ( ! is_array( $stored ) ) { $stored = array(); } $constants = self::credential_constants(); $merged = isset( $values['provider'], $stored['provider'] ) && $values['provider'] !== $stored['provider'] ? array() : $stored; foreach ( $values as $key => $value ) { if ( ! isset( $constants[ $key ] ) ) { continue; } if ( defined( $constants[ $key ] ) ) { continue; } $merged[ $key ] = is_string( $value ) ? trim( $value ) : ''; } $merged = array_intersect_key( $merged, $constants ); self::$credentials_cache = null; $updated = update_option( self::CREDENTIALS, $merged, false ); // Pass key names only. Credential values must never enter third-party hooks. do_action( 'r2clouder_credentials_updated', array_keys( array_intersect_key( $values, $constants ) ) ); return $updated; } /** * Rewrites stored attachment URLs when the public URL changes. * * Hooked to the option update so the Settings API and update_credentials() * both trigger it. * * @param mixed $old_value Previous option value. * @param mixed $new_value New option value. * @return void */ public static function on_credentials_updated( $old_value, $new_value ): void { self::$credentials_cache = null; $old_url = is_array( $old_value ) && isset( $old_value['public_url'] ) ? (string) $old_value['public_url'] : ''; $new_url = is_array( $new_value ) && isset( $new_value['public_url'] ) ? (string) $new_value['public_url'] : ''; if ( ! Connections::$switching && is_array( $old_value ) && is_array( $new_value ) && Connections::id( $old_value ) === Connections::id( $new_value ) && '' !== $old_url && '' !== $new_url && $old_url !== $new_url ) { \R2Clouder\Media\Offload_Meta::update_base_url( $old_url, $new_url ); } } /** * Whether every credential required to reach the bucket is present. * * Cloudflare management tokens are excluded - cache purging and domain * connection are optional. * * @return bool */ public static function has_credentials(): bool { $provider = self::get_credential( 'provider' ); $location = 'r2' === $provider ? 'account_id' : 'region'; $required = array( $location, 'access_key', 'secret_key', 'bucket' ); if ( 'private' !== self::get( 'delivery_mode' ) ) { $required[] = 'public_url'; } foreach ( $required as $key ) { if ( 'gcs' !== $provider || 'region' !== $key ) { if ( '' === self::get_credential( $key ) ) { return false; } } } return true; } /** * Whether the plugin is configured and switched on. * * Every delivery-side rewrite is gated on this. When it returns false the * site serves local files, so a misconfigured install never points at a CDN * it cannot reach. * * @return bool */ public static function is_active(): bool { $has_credentials = self::has_credentials(); $active = (bool) self::get( 'enabled' ) && $has_credentials; /** * Filters whether R2Clouder is active for the current request. * * This filter may disable the plugin for a request, but cannot make an * incompletely configured installation active. * * @param bool $active Whether settings and credentials enable offloading. */ return $has_credentials && (bool) apply_filters( 'r2clouder_is_active', $active ); } /** * Clears the per-request caches. * * @return void */ public static function flush_cache(): void { self::$settings_cache = null; self::$credentials_cache = null; } }