'array', 'sanitize_callback' => array( __CLASS__, 'sanitize_settings' ), ) ); register_setting( 'r2clouder', Options::CREDENTIALS, array( 'type' => 'array', 'sanitize_callback' => array( __CLASS__, 'sanitize_credentials' ), ) ); // --- Section 1: Credentials ------------------------------------------- add_settings_section( 'r2clouder_credentials_section', __( 'Storage', 'devsyed-media-offloader' ), array( __CLASS__, 'render_credentials_section' ), 'r2clouder' ); $cred_fields = array( 'provider' => array( 'label' => __( 'Provider', 'devsyed-media-offloader' ), 'type' => 'select', 'options' => 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' ), ), ), 'account_id' => array( 'label' => __( 'Account ID', 'devsyed-media-offloader' ), 'type' => 'text', 'class' => 'r2clouder-only-r2', ), 'region' => array( 'label' => __( 'Region', 'devsyed-media-offloader' ), 'type' => 'text', 'class' => 'r2clouder-only-s3', 'help' => __( 'AWS bucket region (for example us-east-1) or Spaces datacenter (for example nyc3).', 'devsyed-media-offloader' ), ), 'access_key' => array( 'label' => __( 'Access Key', 'devsyed-media-offloader' ), 'type' => 'text', ), 'secret_key' => array( 'label' => __( 'Secret Key', 'devsyed-media-offloader' ), 'type' => 'password', ), 'bucket' => array( 'label' => __( 'Bucket Name', 'devsyed-media-offloader' ), 'type' => 'text', ), ); foreach ( $cred_fields as $key => $args ) { add_settings_field( 'r2clouder_cred_' . $key, $args['label'], array( __CLASS__, 'render_credential_field' ), 'r2clouder', 'r2clouder_credentials_section', array( 'key' => $key, 'type' => $args['type'], 'options' => isset( $args['options'] ) ? $args['options'] : array(), 'class' => isset( $args['class'] ) ? $args['class'] : '', 'help' => isset( $args['help'] ) ? $args['help'] : '', ) ); } // --- Section 2: Delivery Domain --------------------------------------- add_settings_section( 'r2clouder_delivery_section', __( 'Delivery Domain', 'devsyed-media-offloader' ), array( __CLASS__, 'render_delivery_section' ), 'r2clouder' ); add_settings_field( 'r2clouder_delivery_mode', __( 'Media access', 'devsyed-media-offloader' ), array( __CLASS__, 'render_private_delivery' ), 'r2clouder', 'r2clouder_delivery_section' ); add_settings_field( 'r2clouder_signed_url_lifetime', __( 'Signed URL lifetime', 'devsyed-media-offloader' ), array( __CLASS__, 'render_setting_field' ), 'r2clouder', 'r2clouder_delivery_section', array( 'key' => 'signed_url_lifetime', 'type' => 'number', 'min' => 1, 'max' => 604800, 'help' => __( 'Seconds before a signed URL expires. Default: 3600 (one hour). Maximum: seven days.', 'devsyed-media-offloader' ), ) ); add_settings_field( 'r2clouder_delivery_provider', __( 'CDN preset', 'devsyed-media-offloader' ), array( __CLASS__, 'render_delivery_preset' ), 'r2clouder', 'r2clouder_delivery_section' ); add_settings_field( 'r2clouder_delivery_public_url', __( 'Delivery URL', 'devsyed-media-offloader' ), array( __CLASS__, 'render_credential_field' ), 'r2clouder', 'r2clouder_delivery_section', array( 'key' => 'public_url', 'type' => 'url', 'help' => __( 'Enter your complete HTTPS delivery URL, including any origin path prefix. Save it and test the connection before enabling offloading.', 'devsyed-media-offloader' ), ) ); add_settings_field( 'r2clouder_cred_cf_domain_api_token', __( 'Domain API Token', 'devsyed-media-offloader' ), array( __CLASS__, 'render_credential_field' ), 'r2clouder', 'r2clouder_delivery_section', array( 'key' => 'cf_domain_api_token', 'class' => 'r2clouder-only-r2 r2clouder-direct-delivery', 'type' => 'password', 'help' => __( 'Used only to connect or inspect the delivery domain. Leave empty to use the provisioning token.', 'devsyed-media-offloader' ), 'fallback' => 'cf_provisioning_token', 'cf_link' => 'https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22workers_r2%22%2C%22type%22%3A%22edit%22%7D%5D&name=Devsyed%20Media%20Offloader%20Domain', ) ); add_settings_field( 'r2clouder_delivery_zone_id', __( 'Cloudflare Zone ID', 'devsyed-media-offloader' ), array( __CLASS__, 'render_setting_field' ), 'r2clouder', 'r2clouder_delivery_section', array( 'key' => 'delivery_zone_id', 'class' => 'r2clouder-only-r2 r2clouder-direct-delivery', 'type' => 'text', 'help' => __( 'The Zone ID for the domain that owns your delivery subdomain. OffloadKit never changes DNS records outside this zone.', 'devsyed-media-offloader' ), ) ); add_settings_field( 'r2clouder_delivery_connect', '', array( __CLASS__, 'render_delivery_connection' ), 'r2clouder', 'r2clouder_delivery_section', array( 'class' => 'r2clouder-only-r2 r2clouder-direct-delivery' ) ); add_settings_field( 'r2clouder_cache_control', __( 'Cache-Control', 'devsyed-media-offloader' ), array( __CLASS__, 'render_setting_field' ), 'r2clouder', 'r2clouder_delivery_section', array( 'key' => 'cache_control', 'type' => 'text', 'help' => __( 'Cache policy for future uploads, for example public, max-age=86400. Leave empty to omit the header. Existing objects keep their policy until re-uploaded. Your CDN may override this policy.', 'devsyed-media-offloader' ), ) ); add_settings_field( 'r2clouder_connection_test', '', array( __CLASS__, 'render_connection_test' ), 'r2clouder', 'r2clouder_delivery_section' ); // --- Section 3: Offload Settings -------------------------------------- add_settings_section( 'r2clouder_offload_section', __( 'Offload Settings', 'devsyed-media-offloader' ), '__return_null', 'r2clouder' ); $offload_fields = array( 'enabled' => array( 'label' => __( 'Enable offloading', 'devsyed-media-offloader' ), 'type' => 'checkbox', ), 'offload_new_uploads' => array( 'label' => __( 'Auto-offload new uploads', 'devsyed-media-offloader' ), 'type' => 'checkbox', ), 'delete_local' => array( 'label' => __( 'Delete local files after offload', 'devsyed-media-offloader' ), 'type' => 'checkbox', 'warning' => __( 'Local copies will be permanently deleted after successful offload. Make sure your R2 bucket is properly backed up.', 'devsyed-media-offloader' ), ), 'offload_original_image' => array( 'label' => __( 'Offload original (pre-scale) images', 'devsyed-media-offloader' ), 'type' => 'checkbox', ), 'offload_non_images' => array( 'label' => __( 'Offload non-image files', 'devsyed-media-offloader' ), 'type' => 'checkbox', ), 'extension_filter_mode' => array( 'label' => __( 'File type filter', 'devsyed-media-offloader' ), 'callback' => 'render_extension_filter', ), 'key_template' => array( 'label' => __( 'Key template', 'devsyed-media-offloader' ), 'type' => 'text', 'help' => __( 'Available tokens: {year}, {month}, {day}, {filename}, {hash}, {id}', 'devsyed-media-offloader' ), ), 'object_versioning' => array( 'label' => __( 'Enable object versioning', 'devsyed-media-offloader' ), 'type' => 'checkbox', 'help' => __( 'Changed files receive new URLs to avoid stale CDN caches. Previous objects are retained until the attachment is deleted and continue to use storage.', 'devsyed-media-offloader' ), ), 'key_prefix' => array( 'label' => __( 'Key prefix', 'devsyed-media-offloader' ), 'type' => 'text', ), ); foreach ( $offload_fields as $key => $args ) { add_settings_field( 'r2clouder_' . $key, $args['label'], array( __CLASS__, isset( $args['callback'] ) ? $args['callback'] : 'render_setting_field' ), 'r2clouder', 'r2clouder_offload_section', array_merge( $args, array( 'key' => $key ) ) ); } // --- Section 4: Image Conversion -------------------------------------- add_settings_section( 'r2clouder_image_section', __( 'Image Conversion', 'devsyed-media-offloader' ), '__return_null', 'r2clouder' ); $image_fields = array( 'webp_enabled' => array( 'label' => __( 'Enable WebP conversion', 'devsyed-media-offloader' ), 'type' => 'checkbox', 'format' => 'webp', ), 'webp_quality' => array( 'label' => __( 'WebP quality', 'devsyed-media-offloader' ), 'type' => 'number', 'min' => 1, 'max' => 100, ), 'avif_enabled' => array( 'label' => __( 'Enable AVIF conversion', 'devsyed-media-offloader' ), 'type' => 'checkbox', 'format' => 'avif', ), 'avif_quality' => array( 'label' => __( 'AVIF quality', 'devsyed-media-offloader' ), 'type' => 'number', 'min' => 1, 'max' => 100, ), 'picture_tag' => array( 'label' => __( 'Enable tag output', 'devsyed-media-offloader' ), 'type' => 'checkbox', ), 'legacy_rewrite' => array( 'label' => __( 'Legacy page-builder rewrite', 'devsyed-media-offloader' ), 'type' => 'checkbox', ), ); foreach ( $image_fields as $key => $args ) { add_settings_field( 'r2clouder_' . $key, $args['label'], array( __CLASS__, 'render_setting_field' ), 'r2clouder', 'r2clouder_image_section', array_merge( $args, array( 'key' => $key ) ) ); } // --- Section 5: Cloudflare Cache Purge -------------------------------- add_settings_section( 'r2clouder_purge_section', __( 'Cloudflare Cache Purge', 'devsyed-media-offloader' ), '__return_null', 'r2clouder' ); add_settings_field( 'r2clouder_purge_enabled', __( 'Enable cache purge', 'devsyed-media-offloader' ), array( __CLASS__, 'render_setting_field' ), 'r2clouder', 'r2clouder_purge_section', array( 'key' => 'purge_enabled', 'type' => 'checkbox', 'label' => __( 'Enable cache purge', 'devsyed-media-offloader' ), ) ); add_settings_field( 'r2clouder_cred_cf_api_token', __( 'Cloudflare API Token', 'devsyed-media-offloader' ), array( __CLASS__, 'render_credential_field' ), 'r2clouder', 'r2clouder_purge_section', array( 'key' => 'cf_api_token', 'type' => 'password', 'help' => __( 'Leave empty to use the provisioning token.', 'devsyed-media-offloader' ), 'fallback' => 'cf_provisioning_token', 'cf_link' => 'https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22cache_purge%22%2C%22type%22%3A%22edit%22%7D%5D&zoneId=all&name=Devsyed%20Media%20Offloader%20Cache%20Purge', ) ); add_settings_field( 'r2clouder_purge_zone_id', __( 'Zone ID', 'devsyed-media-offloader' ), array( __CLASS__, 'render_setting_field' ), 'r2clouder', 'r2clouder_purge_section', array( 'key' => 'purge_zone_id', 'type' => 'text', 'help' => __( 'Leave empty for auto-detection from your public URL.', 'devsyed-media-offloader' ), 'label' => __( 'Zone ID', 'devsyed-media-offloader' ), ) ); // --- Section 6: Advanced ---------------------------------------------- add_settings_section( 'r2clouder_advanced_section', __( 'Advanced', 'devsyed-media-offloader' ), '__return_null', 'r2clouder' ); $advanced_fields = array( 'sync_mode' => array( 'label' => __( 'Sync mode', 'devsyed-media-offloader' ), 'type' => 'select', 'options' => array( 'mixed' => __( 'Mixed (Ajax + Cron)', 'devsyed-media-offloader' ), 'ajax' => __( 'Ajax only', 'devsyed-media-offloader' ), 'cron' => __( 'Cron only', 'devsyed-media-offloader' ), ), 'help' => __( 'Mixed runs batches while this admin page is open and continues through WordPress cron after you leave. Ajax only requires this page to remain open.', 'devsyed-media-offloader' ), ), 'queue_batch_size' => array( 'label' => __( 'Queue batch size', 'devsyed-media-offloader' ), 'type' => 'number', 'min' => 1, 'max' => 200, ), 'queue_time_limit' => array( 'label' => __( 'Queue time limit (seconds)', 'devsyed-media-offloader' ), 'type' => 'number', 'min' => 5, 'max' => 300, ), 'request_timeout' => array( 'label' => __( 'Request timeout (seconds)', 'devsyed-media-offloader' ), 'type' => 'number', 'min' => 5, 'max' => 300, ), 'log_level' => array( 'label' => __( 'Log level', 'devsyed-media-offloader' ), 'type' => 'select', 'options' => array( 'error' => __( 'Error', 'devsyed-media-offloader' ), 'warning' => __( 'Warning', 'devsyed-media-offloader' ), 'info' => __( 'Info', 'devsyed-media-offloader' ), 'debug' => __( 'Debug', 'devsyed-media-offloader' ), ), ), 'remove_data_on_uninstall' => array( 'label' => __( 'Remove all data on uninstall', 'devsyed-media-offloader' ), 'type' => 'checkbox', 'warning' => __( 'All plugin settings, credentials and queue data will be permanently deleted when the plugin is uninstalled.', 'devsyed-media-offloader' ), 'class' => 'r2clouder-warning-red', ), ); foreach ( $advanced_fields as $key => $args ) { add_settings_field( 'r2clouder_' . $key, $args['label'], array( __CLASS__, 'render_setting_field' ), 'r2clouder', 'r2clouder_advanced_section', array_merge( $args, array( 'key' => $key ) ) ); } } /** * Returns translated explanations shared by settings and setup fields. * * @return array */ private static function field_help(): array { return array( 'account_id' => __( 'Identifies your Cloudflare account. Copy the Account ID from the R2 overview in your Cloudflare dashboard; this is different from a Zone ID.', 'devsyed-media-offloader' ), 'region' => __( 'Choose the region where your existing AWS bucket or Spaces bucket is stored. For a new bucket, this chooses where it will be created.', 'devsyed-media-offloader' ), 'access_key' => __( 'The access key ID issued by your storage provider. OffloadKit uses it with the secret key to authenticate uploads and other bucket operations.', 'devsyed-media-offloader' ), 'secret_key' => __( 'The private key paired with your access key. Treat it like a password. Leave a saved secret blank to keep its current value.', 'devsyed-media-offloader' ), 'bucket' => __( 'The exact name of the cloud storage bucket that will hold your media. Use an existing bucket, or choose a name when creating a new one.', 'devsyed-media-offloader' ), 'cf_provisioning_token' => __( 'A Cloudflare API token with Workers R2 Storage Edit permission for your account. OffloadKit uses it to connect or create the bucket and derive storage credentials. Treat it like a password.', 'devsyed-media-offloader' ), 'public_url' => __( 'The HTTPS base URL visitors use to load your cloud media, such as https://media.example.com. Include any origin path prefix. Save and test the connection before enabling offloading.', 'devsyed-media-offloader' ), 'delivery_mode' => __( 'Public media can be loaded by anyone with its URL. Private media uses temporary signed links; also disable public bucket access and public domains in your provider dashboard.', 'devsyed-media-offloader' ), 'signed_url_lifetime' => __( 'How long a private media link remains usable, in seconds. Default: 3600 (one hour); maximum: 604800 (seven days). Avoid caching pages beyond the link lifetime.', 'devsyed-media-offloader' ), 'delivery_provider' => __( 'Choose the CDN setup you use to deliver media. Presets provide setup guidance; configure the CDN in its own dashboard first. This selection does not provision a CDN or enable cache purging.', 'devsyed-media-offloader' ), 'cf_domain_api_token' => __( 'Used to inspect or connect your Cloudflare delivery domain. Leave blank to use the saved provisioning token.', 'devsyed-media-offloader' ), 'delivery_zone_id' => __( 'The Cloudflare Zone ID for the domain that owns your delivery subdomain. OffloadKit limits DNS changes to this zone.', 'devsyed-media-offloader' ), 'cache_control' => __( 'Sets the Cache-Control header on future public uploads, for example public, max-age=86400. Leave blank to omit it. Private uploads and signed downloads always use private, no-store, max-age=0. Existing objects keep their stored policy until re-uploaded; your CDN may override it.', 'devsyed-media-offloader' ), 'enabled' => __( 'Turns on OffloadKit media offloading and cloud delivery. Connect storage and test delivery first.', 'devsyed-media-offloader' ), 'offload_new_uploads' => __( 'Automatically queues new Media Library uploads for cloud storage. Use library migration to offload files uploaded before this was enabled.', 'devsyed-media-offloader' ), 'delete_local' => __( 'Permanently removes local copies after a successful cloud upload to free server space. Keep a backup of your cloud media before enabling this.', 'devsyed-media-offloader' ), 'offload_original_image' => __( 'Also uploads the original full-resolution image that WordPress keeps before creating its scaled version.', 'devsyed-media-offloader' ), 'offload_non_images' => __( 'Allows documents, videos, audio and other non-image Media Library files to be offloaded. The file type filter still applies.', 'devsyed-media-offloader' ), 'extension_filter_mode' => __( 'Choose whether to offload all file types, only listed extensions, or everything except listed extensions. This controls which files are eligible for offloading.', 'devsyed-media-offloader' ), 'extension_filter_list' => __( 'Enter file extensions separated by commas or spaces, such as jpg, png, pdf. These are included or excluded according to the file type filter above.', 'devsyed-media-offloader' ), 'key_template' => __( 'Controls the object path and filename used for uploads. Tokens: {year}, {month}, {day}, {filename}, {hash}, {id}. Changing this does not rename existing cloud objects.', 'devsyed-media-offloader' ), 'object_versioning' => __( 'Gives changed files new URLs to avoid stale CDN caches. Previous objects remain until the attachment is deleted and continue to use storage.', 'devsyed-media-offloader' ), 'key_prefix' => __( 'Adds a folder prefix before generated object keys, for example media. Changing this affects future uploads; existing objects are not moved.', 'devsyed-media-offloader' ), 'webp_enabled' => __( 'Creates WebP versions of supported images for smaller downloads. Requires WebP support on your server.', 'devsyed-media-offloader' ), 'webp_quality' => __( 'WebP encoding quality from 1 to 100. Higher values preserve more detail but usually produce larger files. Applies when images are converted.', 'devsyed-media-offloader' ), 'avif_enabled' => __( 'Creates AVIF versions of supported images for smaller downloads. Requires AVIF support on your server.', 'devsyed-media-offloader' ), 'avif_quality' => __( 'AVIF encoding quality from 1 to 100. Higher values preserve more detail but usually produce larger files. Applies when images are converted.', 'devsyed-media-offloader' ), 'picture_tag' => __( 'Outputs picture elements for supported WordPress images so browsers can choose an available modern format with a fallback image.', 'devsyed-media-offloader' ), 'legacy_rewrite' => __( 'Rewrites image URLs in rendered page HTML for older page builders that bypass WordPress image hooks. WordPress 6.9 and later can rewrite the full template. WordPress 6.8 rewrites post content.', 'devsyed-media-offloader' ), 'purge_enabled' => __( 'Allows OffloadKit to request Cloudflare cache purges when media changes. Requires a token with cache purge permission for the delivery zone.', 'devsyed-media-offloader' ), 'cf_api_token' => __( 'A Cloudflare API token with cache purge permission for your delivery zone. Leave blank to use the saved provisioning token if it has that permission.', 'devsyed-media-offloader' ), 'purge_zone_id' => __( 'The Cloudflare zone whose cached media should be purged. Leave blank to detect it from the public delivery URL.', 'devsyed-media-offloader' ), 'sync_mode' => __( 'Mixed processes batches while this page is open and continues through WordPress cron after you leave. Ajax only needs the page open. Cron only relies on scheduled WordPress jobs.', 'devsyed-media-offloader' ), 'queue_batch_size' => __( 'Maximum number of jobs processed in one queue batch. Smaller batches reduce server load; larger batches can process more media per run.', 'devsyed-media-offloader' ), 'queue_time_limit' => __( 'Maximum time in seconds spent processing a queue batch. Keep it below your server execution limit so batches can finish cleanly.', 'devsyed-media-offloader' ), 'request_timeout' => __( 'Maximum time in seconds to wait for an individual storage HTTP request. Increase it if uploads time out on a slow connection.', 'devsyed-media-offloader' ), 'log_level' => __( 'Controls how much diagnostic detail OffloadKit records. Debug is the most detailed; Error records only failures.', 'devsyed-media-offloader' ), 'remove_data_on_uninstall' => __( 'Permanently deletes plugin settings, credentials and queue data when OffloadKit is uninstalled. Disable this to preserve configuration for a later reinstall.', 'devsyed-media-offloader' ), 'r2_public_read' => __( 'Enables public R2.dev delivery if no public domain exists. Anyone with a file URL can read the media. R2.dev is rate-limited; use a custom domain for production.', 'devsyed-media-offloader' ), 'aws_public_read' => __( 'Allows anyone with a media URL to read the bucket. Uploads and deletion still require authentication. Leave unchecked to keep current permissions; account restrictions may prevent public access.', 'devsyed-media-offloader' ), 'spaces_public_read' => __( 'Allows website visitors to load media by URL. Leave unchecked for private storage. Configuring public delivery requires a Full Access Spaces key.', 'devsyed-media-offloader' ), 'aws_bucket_action' => __( 'Connect uses an existing bucket. Create makes a new bucket in the selected AWS region; its name must be available.', 'devsyed-media-offloader' ), 'spaces_bucket_action' => __( 'Connect uses an existing Spaces bucket. Create makes a new Standard Storage bucket in the selected datacenter and requires a Full Access key.', 'devsyed-media-offloader' ), 'location' => __( 'Suggests a location for a new R2 bucket. Automatic lets Cloudflare choose. This does not move an existing bucket.', 'devsyed-media-offloader' ), 'delivery' => __( 'Detect uses an existing public domain. Custom domain connects your own delivery hostname. R2.dev enables a public development URL.', 'devsyed-media-offloader' ), 'domain' => __( 'The hostname to connect to your R2 bucket, such as media.example.com. Enter the hostname without https:// or a path.', 'devsyed-media-offloader' ), 'zone_id' => __( 'The Cloudflare Zone ID for the domain that owns this hostname. You may leave it blank for automatic lookup when your token has Zone Read permission.', 'devsyed-media-offloader' ), 'migration_secret_key' => __( 'The private key paired with the target bucket access key. Enter the target provider secret so the migration can write to that bucket. Treat it like a password.', 'devsyed-media-offloader' ), 'cf_token' => __( 'A Cloudflare API token with R2 write access used by Super Slurper to copy objects into the target bucket.', 'devsyed-media-offloader' ), 'role_arn' => __( 'The ARN of an IAM role AWS DataSync can assume to write to the target bucket. The target access keys also need DataSync permissions.', 'devsyed-media-offloader' ), 'cloud-prefix' => __( 'Shows objects whose keys begin with this prefix, for example media/2026/. Cloud folders are prefixes within object names.', 'devsyed-media-offloader' ), 'cloud-destination' => __( 'The full destination object key, including folder and filename. Select one file. Managed moves update delivery keys and retain the old object for existing URLs.', 'devsyed-media-offloader' ), 'cloud-folder' => __( 'The destination bucket folder for an entire managed attachment. Select one current managed file; all its image sizes and formats move together without re-uploading.', 'devsyed-media-offloader' ), ); } /** * Enqueues admin JS and CSS on the plugin page only. * * @param string $hook_suffix The current admin page hook suffix. * @return void */ public static function enqueue_assets( $hook_suffix ): void { if ( 'upload.php' === $hook_suffix ) { self::enqueue_sync_button(); return; } if ( ! in_array( $hook_suffix, self::$hook_suffixes, true ) && 'media_page_r2clouder-files' !== $hook_suffix ) { return; } wp_enqueue_style( 'sna-admin-core', R2CLOUDER_URL . 'admin/css/sna-admin-core.css', array(), R2CLOUDER_VERSION ); wp_enqueue_style( 'r2clouder-admin', R2CLOUDER_URL . 'admin/css/r2clouder-admin.css', array( 'sna-admin-core' ), R2CLOUDER_VERSION ); wp_enqueue_script( 'r2clouder-field-help', R2CLOUDER_URL . 'admin/js/r2clouder-field-help.js', array( 'jquery' ), R2CLOUDER_VERSION, true ); wp_localize_script( 'r2clouder-field-help', 'r2clouderFieldHelp', array( 'fields' => self::field_help(), /* translators: %s: field label. */ 'label' => __( 'Help for %s', 'devsyed-media-offloader' ), ) ); wp_enqueue_script( 'r2clouder-admin', R2CLOUDER_URL . 'admin/js/r2clouder-admin.js', array( 'jquery' ), R2CLOUDER_VERSION, true ); wp_localize_script( 'r2clouder-admin', 'r2clouder', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'storageProvider' => Options::get_credential( 'provider' ), 'testNonce' => wp_create_nonce( 'r2clouder_test_connection' ), 'provisionNonce' => wp_create_nonce( 'r2clouder_provision' ), 'domainNonce' => wp_create_nonce( 'r2clouder_delivery_domain' ), 'migrationNonce' => wp_create_nonce( 'r2clouder_migration' ), 'syncMode' => Options::get( 'sync_mode' ), 'queueTickNonce' => wp_create_nonce( \R2Clouder\Queue\Worker::AJAX_NONCE ), 'queueNonce' => wp_create_nonce( 'r2clouder_queue_status' ), 'healthNonce' => wp_create_nonce( 'r2clouder_health_check' ), 'deleteCredentialsConfirm' => __( 'Delete all database-stored cloud credentials and disable offloading? Your media and other plugin settings will not be deleted. Credentials defined in wp-config.php are not affected.', 'devsyed-media-offloader' ), 'testingText' => __( 'Testing connection...', 'devsyed-media-offloader' ), 'provisioningText' => __( 'Connecting Cloudflare...', 'devsyed-media-offloader' ), 'provisionButtonText' => __( 'Connect Cloudflare', 'devsyed-media-offloader' ), 'testButtonText' => __( 'Test connection', 'devsyed-media-offloader' ), 'connectionSuccessText' => __( 'Connection successful — view details', 'devsyed-media-offloader' ), 'connectionFailureText' => __( 'Connection failed — view details', 'devsyed-media-offloader' ), 'connectingDomainText' => __( 'Connecting domain...', 'devsyed-media-offloader' ), 'connectDomainText' => __( 'Connect Domain', 'devsyed-media-offloader' ), 'checkingDomainText' => __( 'Checking domain...', 'devsyed-media-offloader' ), 'checkDomainText' => __( 'Check Status', 'devsyed-media-offloader' ), 'migrationStartingText' => __( 'Adding media to the queue...', 'devsyed-media-offloader' ), 'migrationStartText' => __( 'Start Migration', 'devsyed-media-offloader' ), /* translators: %d: number of attachments skipped by the file type filter. */ 'migrationFilteredText' => __( '%d skipped by the file type filter.', 'devsyed-media-offloader' ), 'savedText' => __( 'All changes saved', 'devsyed-media-offloader' ), 'unsavedText' => __( 'Unsaved changes', 'devsyed-media-offloader' ), /* translators: %d: number of items being synced */ 'syncingText' => __( 'Syncing %d items...', 'devsyed-media-offloader' ), ) ); } /** * Renders the settings page. * * @return void */ public static function render_page(): void { if ( ! current_user_can( self::capability( 'settings' ) ) ) { return; } $show_sidebar = ! Onboarding::needed(); $tabs = array( 'setup' => array( 'label' => __( 'Setup', 'devsyed-media-offloader' ), 'description' => __( 'Connect your storage and delivery, step by step', 'devsyed-media-offloader' ), 'icon' => 'dashicons-admin-tools', ), 'overview' => array( 'label' => __( 'Overview', 'devsyed-media-offloader' ), 'description' => __( 'Setup progress and shortcuts', 'devsyed-media-offloader' ), 'icon' => 'dashicons-chart-area', ), 'storage' => array( 'label' => __( 'Cloud Providers', 'devsyed-media-offloader' ), 'description' => __( 'Choose and connect storage', 'devsyed-media-offloader' ), 'icon' => 'dashicons-cloud', ), 'delivery' => array( 'label' => __( 'Delivery', 'devsyed-media-offloader' ), 'description' => __( 'Domain, URL and cache', 'devsyed-media-offloader' ), 'icon' => 'dashicons-admin-site-alt3', ), 'media' => array( 'label' => __( 'Media', 'devsyed-media-offloader' ), 'description' => __( 'Offload and image formats', 'devsyed-media-offloader' ), 'icon' => 'dashicons-format-image', ), 'advanced' => array( 'label' => __( 'Advanced', 'devsyed-media-offloader' ), 'description' => __( 'Queue, requests and data', 'devsyed-media-offloader' ), 'icon' => 'dashicons-admin-generic', ), 'status' => array( 'label' => __( 'Status', 'devsyed-media-offloader' ), 'description' => __( 'Health and background jobs', 'devsyed-media-offloader' ), 'icon' => 'dashicons-heart', ), ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only tab state. $active_tab = isset( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : ( Onboarding::needed() ? 'setup' : 'overview' ); // Keep bookmarks from the previous navigation useful. $legacy_tabs = array( 'credentials' => 'storage', 'offload' => 'media', 'images' => 'media', 'purge' => 'delivery', ); if ( isset( $legacy_tabs[ $active_tab ] ) ) { $active_tab = $legacy_tabs[ $active_tab ]; } if ( ! isset( $tabs[ $active_tab ] ) ) { $active_tab = 'overview'; } ?>

%s', esc_attr( $class ), esc_html( $label ) ); } /** * Deletes database-stored provider credentials for an administrator. * * Media records, queue data and non-secret plugin settings are preserved. * Credentials defined in wp-config.php cannot be removed from WordPress. * * @return void */ public static function handle_delete_credentials(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to delete cloud credentials.', 'devsyed-media-offloader' ) ); } check_admin_referer( 'r2clouder_delete_credentials' ); if ( class_exists( \R2Clouder\Migration\Migration::class ) && \R2Clouder\Migration\Migration::is_frozen() ) { wp_die( esc_html__( 'Wait for the migration to finish before deleting credentials.', 'devsyed-media-offloader' ) ); } delete_option( Options::CREDENTIALS ); delete_option( \R2Clouder\Connections::OPTION ); delete_option( \R2Clouder\Migration\Migration::OPTION ); Options::flush_cache(); delete_option( Onboarding::PROGRESS ); delete_option( Onboarding::COMPLETE ); Options::update_settings( array( 'enabled' => false ) ); do_action( 'r2clouder_credentials_deleted' ); wp_safe_redirect( add_query_arg( array( 'page' => 'r2clouder', 'tab' => 'storage', 'credentials_deleted' => '1', ), admin_url( 'options-general.php' ) ) ); exit; } /** * Renders the at-a-glance landing page. * * @return void */ private static function render_overview(): void { $credentials = Options::get_credentials(); $location = 'r2' === $credentials['provider'] ? 'account_id' : 'region'; $has_storage = ( 'gcs' === $credentials['provider'] || '' !== $credentials[ $location ] ) && '' !== $credentials['access_key'] && '' !== $credentials['secret_key'] && '' !== $credentials['bucket']; $has_delivery = 'private' === Options::get( 'delivery_mode' ) || '' !== $credentials['public_url']; $is_enabled = (bool) Options::get( 'enabled' ); $next_tab = $has_storage ? ( $has_delivery ? 'media' : 'delivery' ) : 'storage'; $next_label = $has_storage ? ( $has_delivery ? __( 'Manage offloading', 'devsyed-media-offloader' ) : __( 'Configure delivery', 'devsyed-media-offloader' ) ) : __( 'Choose a provider', 'devsyed-media-offloader' ); $local_bytes = self::get_reclaimable_bytes(); ?>

· ·

0 ) : ?>

/3
  1. ·

0 ) : ?>

●
●
'attachment', 'post_status' => 'inherit', 'fields' => 'ids', 'posts_per_page' => $batch, 'paged' => $page, 'orderby' => 'ID', 'order' => 'ASC', 'no_found_rows' => true, 'update_post_term_cache' => false, ) ); foreach ( $attachment_ids as $attachment_id ) { if ( Offload_Meta::is_offloaded( $attachment_id ) ) { continue; } $mime = (string) get_post_mime_type( $attachment_id ); if ( ! Offloader::is_mime_allowed( $mime, $settings ) || ! Offloader::is_extension_allowed( $attachment_id, $settings ) ) { continue; } $file = get_attached_file( $attachment_id, true ); $metadata = wp_get_attachment_metadata( $attachment_id ); if ( ! is_string( $file ) || '' === $file ) { continue; } $sources = $offloader->collect_sources( $attachment_id, $file, is_array( $metadata ) ? $metadata : array(), $mime ); foreach ( $sources as $source ) { $path = isset( $source['path'] ) ? wp_normalize_path( (string) $source['path'] ) : ''; if ( '' === $path || isset( $seen[ $path ] ) || ! is_file( $path ) ) { continue; } $seen[ $path ] = true; $size = wp_filesize( $path ); $bytes += max( 0, (int) $size ); } } ++$page; $attachment_count = count( $attachment_ids ); } while ( $attachment_count === $batch ); set_transient( 'r2clouder_reclaimable_bytes', $bytes, HOUR_IN_SECONDS ); return $bytes; } /** * Loads the cloud sync button on the Media Library screen. */ private static function enqueue_sync_button(): void { if ( ! current_user_can( self::capability( 'migration' ) ) ) { return; } $handle = 'r2clouder-sync-button'; $counts = Queue::counts(); wp_enqueue_style( $handle, R2CLOUDER_URL . 'admin/css/r2clouder-sync-button.css', array(), R2CLOUDER_VERSION ); wp_enqueue_script( $handle, R2CLOUDER_URL . 'admin/js/r2clouder-sync-button.js', array(), R2CLOUDER_VERSION, true ); wp_add_inline_script( $handle, 'window.r2clouderSyncButton = ' . wp_json_encode( array( 'url' => admin_url( 'upload.php?page=r2clouder-migration' ), 'label' => __( 'Sync to Cloud', 'devsyed-media-offloader' ), 'active' => ( $counts['pending'] + $counts['claimed'] ) > 0, 'syncing' => __( 'Syncing...', 'devsyed-media-offloader' ), 'nonce' => wp_create_nonce( 'r2clouder_queue_status' ), 'ajaxUrl' => admin_url( 'admin-ajax.php' ), ), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT ) . ';', 'before' ); } /** * Renders the existing-library migration page under Media. * * @return void */ public static function render_migration_page(): void { if ( ! current_user_can( self::capability( 'migration' ) ) ) { return; } $counts = Queue::counts(); ?>

'r2clouder_cred_cf_provisioning_token', 'title' => __( 'Cloudflare API Token', 'devsyed-media-offloader' ), 'callback' => array( __CLASS__, 'render_credential_field' ), 'args' => array( 'key' => 'cf_provisioning_token', 'type' => 'password', 'help' => __( 'Paste the API token value. OffloadKit derives the storage keys automatically. The token needs Workers R2 Storage Edit permission for this account. An existing bucket is used, or a new bucket is created with this name.', 'devsyed-media-offloader' ), ), ); } } self::do_settings_section( 'r2clouder', $section, true, $fields ); if ( 'r2clouder_credentials_section' === $section && 'r2' === Options::get_credential( 'provider' ) ) { ?>

array( 'r2clouder_credentials_section' ), 'delivery' => array( 'r2clouder_delivery_section', 'r2clouder_purge_section' ), 'media' => array( 'r2clouder_offload_section', 'r2clouder_image_section' ), 'advanced' => array( 'r2clouder_advanced_section' ), ); if ( ! isset( $section_map[ $tab ] ) ) { return; } echo '
'; foreach ( $section_map[ $tab ] as $section ) { echo '
'; self::do_settings_section( 'r2clouder', $section ); echo '
'; } echo '
'; } /** * Renders a single settings section with its fields as a table. * * Mirrors do_settings_sections() but for one section only. * * @param string $page Settings page slug. * @param string $section Section ID to render. * @param bool $onboarding Whether to render only credentials for setup. * @param array|null $fields_override Optional fields for this render only. * @return void */ private static function do_settings_section( $page, $section, bool $onboarding = false, ?array $fields_override = null ): void { global $wp_settings_sections, $wp_settings_fields; if ( ! isset( $wp_settings_sections[ $page ][ $section ] ) ) { return; } $sec = $wp_settings_sections[ $page ][ $section ]; if ( $sec['title'] ) { echo '

' . esc_html( $sec['title'] ) . '

'; } if ( $onboarding && 'r2clouder_credentials_section' === $section ) { echo '
'; self::render_provider_logo(); self::render_provider_credential_help( Options::get_credential( 'provider' ) ); } elseif ( $sec['callback'] && '__return_null' !== $sec['callback'] ) { call_user_func( $sec['callback'], $sec ); } $fields = null !== $fields_override ? $fields_override : ( $wp_settings_fields[ $page ][ $section ] ?? null ); if ( null === $fields ) { if ( 'r2clouder_credentials_section' === $section ) { echo '
'; } return; } echo ''; foreach ( $fields as $field ) { $class = ''; if ( ! empty( $field['args']['class'] ) ) { $class = ' class="' . esc_attr( $field['args']['class'] ) . '"'; } echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $class is escaped above. if ( ! empty( $field['title'] ) ) { $help_key = isset( $field['args']['key'] ) ? $field['args']['key'] : preg_replace( '/^r2clouder_(?:cred_)?/', '', $field['id'] ); $label_for = ''; if ( array( __CLASS__, 'render_credential_field' ) === $field['callback'] ) { $label_for = 'r2clouder-cred-' . $field['args']['key']; } elseif ( array( __CLASS__, 'render_setting_field' ) === $field['callback'] && 'checkbox' !== ( $field['args']['type'] ?? '' ) ) { $label_for = 'r2clouder-' . $field['args']['key']; } echo ''; echo ''; } echo ''; if ( $onboarding && 'r2clouder_credentials_section' === $section && 's3' === Options::get_credential( 'provider' ) ) { ?>

'; } } /** * Renders the inline SVG logo. * * @return void */ private static function render_logo_svg(): void { ?> 'cloud-transfer.svg', 's3' => 'object-storage.svg', 'spaces' => 'storage-spaces.svg', 'gcs' => 'connected-storage.svg', ); if ( isset( $logos[ $provider ] ) ) { printf( '', esc_url( R2CLOUDER_URL . 'admin/images/' . $logos[ $provider ] ) ); return; } echo ''; } /** * Renders credential shortcuts and provider-specific setup steps. * * @param string $provider Storage provider identifier. * @return void */ private static function render_provider_credential_help( string $provider ): void { $guides = array( 'r2' => array( 'url' => 'https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22workers_r2%22%2C%22type%22%3A%22edit%22%7D%5D&accountId=*&zoneId=all&name=Devsyed%20Media%20Offloader%20WordPress', 'link' => __( 'Create Cloudflare token →', 'devsyed-media-offloader' ), 'docs' => 'https://developers.cloudflare.com/r2/api/tokens/', 'steps' => array( __( 'The link prefills Workers R2 Storage Write. Review the account scope and create the token.', 'devsyed-media-offloader' ), __( 'Copy your Account ID from R2 Object Storage. During onboarding, enter it with your API token and bucket name.', 'devsyed-media-offloader' ), __( 'OffloadKit creates or selects your bucket and derives its storage keys from the API token. Configure delivery in the next onboarding step.', 'devsyed-media-offloader' ), __( 'For manual setup, use Manage R2 API Tokens in R2 Object Storage to create an Object Read & Write token scoped to your bucket. Paste its Access Key ID and Secret Access Key into the manual fields.', 'devsyed-media-offloader' ), ), ), 's3' => array( 'url' => 'https://console.aws.amazon.com/iam/home#/users', 'link' => __( 'Open AWS IAM users →', 'devsyed-media-offloader' ), 'docs' => 'https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html', 'steps' => array( __( 'Create or select a dedicated IAM user. Grant s3:ListBucket and s3:GetBucketLocation on your bucket, and s3:GetObject, s3:PutObject, s3:DeleteObject and s3:AbortMultipartUpload on its objects.', 'devsyed-media-offloader' ), __( 'Open the user’s Security credentials tab, choose Create access key, and select Application running outside AWS. Create the key and copy the Access key ID and Secret access key.', 'devsyed-media-offloader' ), __( 'Paste the keys into the fields and select the bucket’s AWS region. During setup, connect an existing bucket or choose Create new bucket with a globally unique name. Creation also requires s3:CreateBucket permission.', 'devsyed-media-offloader' ), __( 'New buckets are private by default. During setup, select Enable public media delivery to let OffloadKit configure public reads, or choose private delivery for signed URLs. You can also use an existing CloudFront domain.', 'devsyed-media-offloader' ), ), ), 'spaces' => array( 'url' => 'https://cloud.digitalocean.com/spaces/access_keys', 'link' => __( 'Create a DigitalOcean Spaces access key →', 'devsyed-media-offloader' ), 'docs' => 'https://docs.digitalocean.com/products/spaces/how-to/manage-access/', 'steps' => array( __( 'Choose a datacenter region, such as nyc3. During setup, connect an existing bucket or let OffloadKit create a new Standard Storage bucket.', 'devsyed-media-offloader' ), __( 'Open the Access Keys tab and click Create Access Key. For a new bucket, choose Full access to allow bucket creation. For an existing bucket without a bucket policy, choose Limited access and Read/Write/Delete on that bucket.', 'devsyed-media-offloader' ), __( 'Paste the Spaces access key and secret key, bucket name and datacenter region. A DigitalOcean API token is not used. Public bucket policies require a Full access key.', 'devsyed-media-offloader' ), __( 'Set your public bucket URL, Spaces CDN URL or custom domain on the Delivery page, then run Test Connection.', 'devsyed-media-offloader' ), ), ), 'gcs' => array( 'url' => 'https://console.cloud.google.com/storage/settings', 'link' => __( 'Open Google Cloud Storage settings →', 'devsyed-media-offloader' ), 'docs' => 'https://docs.cloud.google.com/storage/docs/authentication/managing-hmackeys', 'steps' => array( __( 'Select the project containing your bucket. Create or select a service account with Storage Object Admin access on that bucket.', 'devsyed-media-offloader' ), __( 'In Cloud Storage settings, select Interoperability, then Create a key for a service account. Select the service account and create the HMAC key. Your administrator may need to grant you Storage HMAC Key Admin to create keys.', 'devsyed-media-offloader' ), __( 'Paste the HMAC Access ID as Access Key and the HMAC secret as Secret Key. Enter your bucket name. A service-account JSON file or API key will not work here.', 'devsyed-media-offloader' ), __( 'Configure public delivery or a CDN on the Delivery page, then run Test Connection. The Google Cloud region field is not required.', 'devsyed-media-offloader' ), ), ), ); if ( ! isset( $guides[ $provider ] ) ) { return; } $guide = $guides[ $provider ]; $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' ), ); $dialog_id = 'r2clouder-credential-guide-' . $provider; /* translators: %s: storage provider name. */ $title = sprintf( __( '%s credentials', 'devsyed-media-offloader' ), $labels[ $provider ] ); ?>

array( __( 'Cloudflare R2', 'devsyed-media-offloader' ), __( 'Object storage with Cloudflare delivery and custom domains.', 'devsyed-media-offloader' ) ), 's3' => array( __( 'AWS S3', 'devsyed-media-offloader' ), __( 'Amazon object storage with optional CloudFront delivery.', 'devsyed-media-offloader' ) ), 'spaces' => array( __( 'DigitalOcean Spaces', 'devsyed-media-offloader' ), __( 'S3-compatible storage with optional Spaces CDN delivery.', 'devsyed-media-offloader' ) ), 'gcs' => array( __( 'Google Cloud Storage', 'devsyed-media-offloader' ), __( 'Google object storage using HMAC access keys.', 'devsyed-media-offloader' ) ), ); foreach ( $providers as $provider => $details ) : ?>

'; echo '
'; self::render_provisioning_form(); echo '

'; esc_html_e( 'Advanced: configure storage credentials manually', 'devsyed-media-offloader' ); echo '

'; esc_html_e( 'Use the fields below for an existing bucket, bucket-scoped keys, or wp-config.php constants.', 'devsyed-media-offloader' ); echo '

'; } /** * Renders the primary one-token provisioning form. * * @return void */ private static function render_provisioning_form(): void { $account_id = Options::get_credential( 'account_id' ); $bucket = Options::get_credential( 'bucket' ); $host = wp_parse_url( home_url(), PHP_URL_HOST ); $domain = is_string( $host ) && '' !== $host ? 'media.' . preg_replace( '/^www\./', '', $host ) : ''; ?>


'; foreach ( array( 'public' => __( 'Public CDN URLs', 'devsyed-media-offloader' ), 'private' => __( 'Private storage with expiring signed URLs', 'devsyed-media-offloader' ), ) as $mode => $label ) { echo ''; } echo '

' . esc_html__( 'Private mode uses the storage API endpoint; a public delivery URL is optional. Disable public bucket access and public domains in your provider dashboard. Remove local copies after successful offloading to prevent direct local access. Anyone holding a signed link can use it until it expires; your site controls who can see pages containing those links. Exclude those pages from page/CDN caches to prevent expired links being served.', 'devsyed-media-offloader' ) . '

'; } /** * Renders delivery presets and provider-specific setup guidance. * * @return void */ public static function render_delivery_preset(): void { $presets = Options::delivery_presets(); $current = (string) Options::get( 'delivery_provider', 'custom' ); $current = isset( $presets[ $current ] ) ? $current : 'custom'; printf( ''; printf( '

%s

', esc_html( $presets[ $current ]['help'] ) ); printf( '

%s

', esc_url( $presets[ $current ]['docs'] ), '' === $presets[ $current ]['docs'] ? 'hidden' : '', esc_html__( 'Setup guide', 'devsyed-media-offloader' ) ); echo '

' . esc_html__( 'Configure the CDN in its dashboard first. Presets guide delivery setup; CDN provisioning and cache purging are not configured by this selection.', 'devsyed-media-offloader' ) . '

'; } /** * Renders delivery-domain actions. * * @return void */ public static function render_delivery_connection(): void { ?>

1

2

3

4

  • →
  • →

5

define( \'R2CLOUDER_ACCESS_KEY\', \'your-key\' );' ); ?>

'US East (N. Virginia)', 'us-east-2' => 'US East (Ohio)', 'us-west-1' => 'US West (N. California)', 'us-west-2' => 'US West (Oregon)', 'af-south-1' => 'Africa (Cape Town)', 'ap-east-1' => 'Asia Pacific (Hong Kong)', 'ap-east-2' => 'Asia Pacific (Taipei)', 'ap-south-1' => 'Asia Pacific (Mumbai)', 'ap-south-2' => 'Asia Pacific (Hyderabad)', 'ap-northeast-1' => 'Asia Pacific (Tokyo)', 'ap-northeast-2' => 'Asia Pacific (Seoul)', 'ap-northeast-3' => 'Asia Pacific (Osaka)', 'ap-southeast-1' => 'Asia Pacific (Singapore)', 'ap-southeast-2' => 'Asia Pacific (Sydney)', 'ap-southeast-3' => 'Asia Pacific (Jakarta)', 'ap-southeast-4' => 'Asia Pacific (Melbourne)', 'ap-southeast-5' => 'Asia Pacific (Malaysia)', 'ap-southeast-6' => 'Asia Pacific (New Zealand)', 'ap-southeast-7' => 'Asia Pacific (Thailand)', 'ca-central-1' => 'Canada (Central)', 'ca-west-1' => 'Canada West (Calgary)', 'eu-central-1' => 'Europe (Frankfurt)', 'eu-central-2' => 'Europe (Zurich)', 'eu-west-1' => 'Europe (Ireland)', 'eu-west-2' => 'Europe (London)', 'eu-west-3' => 'Europe (Paris)', 'eu-south-1' => 'Europe (Milan)', 'eu-south-2' => 'Europe (Spain)', 'eu-north-1' => 'Europe (Stockholm)', 'il-central-1' => 'Israel (Tel Aviv)', 'mx-central-1' => 'Mexico (Central)', 'me-south-1' => 'Middle East (Bahrain)', 'me-central-1' => 'Middle East (UAE)', 'sa-east-1' => 'South America (São Paulo)', 'us-gov-east-1' => 'AWS GovCloud (US-East)', 'us-gov-west-1' => 'AWS GovCloud (US-West)', ); echo ''; if ( '' !== $current && ! isset( $regions[ $current ] ) ) { $regions[ $current ] = $current; } foreach ( $regions as $region => $label ) { printf( '', esc_attr( $region ), selected( $current, $region, false ), esc_html( $label === $region ? $region : $label . ' (' . $region . ')' ) ); } } /** * Renders a credential field, with constant override handling. * * @param array $args Field arguments. * @return void */ public static function render_credential_field( $args ): void { $key = $args['key']; $type = isset( $args['type'] ) ? $args['type'] : 'text'; $help = isset( $args['help'] ) ? $args['help'] : ''; $source = Options::credential_source( $key ); $constants = Options::credential_constants(); $constant = isset( $constants[ $key ] ) ? $constants[ $key ] : ''; if ( 'constant' === $source ) { if ( 'provider' === $key ) { printf( '', esc_attr( Options::get_credential( 'provider' ) ) ); } printf( '

%s

', esc_html( sprintf( /* translators: %s: constant name. */ __( 'Defined in wp-config.php as %s', 'devsyed-media-offloader' ), $constant ) ) ); if ( '' !== $help ) { printf( '

%s

', esc_html( $help ) ); } return; } $is_secret = in_array( $type, array( 'password' ), true ); $stored = Options::get_credential( $key ); $prefilled = false; if ( 'public_url' === $key && '' === $stored && 's3' === Options::get_credential( 'provider' ) && 'custom' === Options::get( 'delivery_provider', 'custom' ) ) { $client = Storage::create( Options::get_credentials() ); if ( ! is_wp_error( $client ) ) { $stored = $client->get_base_url(); $prefilled = true; } } $has_value = '' !== $stored; if ( 'region' === $key ) { $regions = array( 'nyc3' => 'New York (NYC3)', 'ams3' => 'Amsterdam (AMS3)', 'sfo2' => 'San Francisco (SFO2)', 'sfo3' => 'San Francisco (SFO3)', 'sgp1' => 'Singapore (SGP1)', 'lon1' => 'London (LON1)', 'fra1' => 'Frankfurt (FRA1)', 'tor1' => 'Toronto (TOR1)', 'blr1' => 'Bangalore (BLR1)', 'syd1' => 'Sydney (SYD1)', 'atl1' => 'Atlanta (ATL1)', 'ric1' => 'Richmond (RIC1)', 'mkc1' => 'Kansas City (MKC1)', ); $spaces = 'spaces' === Options::get_credential( 'provider' ); $name = Options::CREDENTIALS . '[region]'; printf( ''; printf( ''; return; } if ( 'select' === $type ) { printf( ''; if ( '' !== $help ) { printf( '

%s

', esc_html( $help ) ); } return; } $name = esc_attr( Options::CREDENTIALS . '[' . $key . ']' ); $id = esc_attr( 'r2clouder-cred-' . $key ); $placeholder = $is_secret && $has_value ? esc_attr( str_repeat( "\xE2\x80\xA2", 8 ) ) : ''; $value = $is_secret ? '' : esc_attr( $stored ); $input_type = esc_attr( $type ); $css_class = 'regular-text'; // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- All variables are pre-escaped via esc_attr() above. printf( '', $input_type, $id, $name, $value, $placeholder, esc_attr( $css_class ) ); // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped if ( $prefilled ) { printf( '

%s

', esc_html__( 'Prefilled from your S3 bucket and region. You can replace this with a custom domain or CloudFront URL. Public delivery requires public object access; private delivery uses signed URLs and does not require this field.', 'devsyed-media-offloader' ) ); } $fallback_key = isset( $args['fallback'] ) ? $args['fallback'] : ''; if ( '' !== $fallback_key && ! $has_value ) { $fallback_value = Options::get_credential( $fallback_key ); if ( '' !== $fallback_value ) { printf( '

%s

', esc_html__( 'Using provisioning token.', 'devsyed-media-offloader' ) ); } } if ( '' !== $help ) { printf( '

%s

', esc_html( $help ) ); } $cf_link = isset( $args['cf_link'] ) ? $args['cf_link'] : ''; if ( '' !== $cf_link ) { printf( '

%s

', esc_url( $cf_link ), esc_html__( 'Create a token with the right permissions on Cloudflare →', 'devsyed-media-offloader' ) ); } } /** * Renders a non-secret settings field. * * @param array $args Field arguments. * @return void */ public static function render_setting_field( $args ): void { $key = $args['key']; $type = isset( $args['type'] ) ? $args['type'] : 'text'; $help = isset( $args['help'] ) ? $args['help'] : ''; $warning = isset( $args['warning'] ) ? $args['warning'] : ''; $css = isset( $args['class'] ) ? $args['class'] : ''; $value = Options::get( $key ); $name = Options::SETTINGS . '[' . $key . ']'; $id = 'r2clouder-' . $key; switch ( $type ) { case 'checkbox': $disabled = false; $reason = ''; if ( isset( $args['format'] ) ) { $format = $args['format']; if ( ! Image_Support::supports( $format ) ) { $disabled = true; $reason = Image_Support::unsupported_reason( $format ); } } printf( '', esc_attr( $name ), esc_attr( $id ), esc_attr( $name ), checked( (bool) $value, true, false ), $disabled ? 'disabled="disabled"' : '', isset( $args['label'] ) ? esc_html( $args['label'] ) : '' ); if ( $disabled && '' !== $reason ) { printf( '

%s

', esc_html( $reason ) ); } break; case 'number': $min = isset( $args['min'] ) ? (int) $args['min'] : 0; $max = isset( $args['max'] ) ? (int) $args['max'] : 99999; // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- $min and $max are cast to int above; %d is safe. printf( '', esc_attr( $id ), esc_attr( $name ), esc_attr( (string) $value ), $min, $max ); // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped break; case 'select': $options = isset( $args['options'] ) ? (array) $args['options'] : array(); printf( ''; break; default: // text, url. printf( '', esc_attr( $type ), esc_attr( $id ), esc_attr( $name ), esc_attr( (string) $value ) ); break; } if ( '' !== $help ) { printf( '

%s

', esc_html( $help ) ); } if ( '' !== $warning ) { printf( '

%s

', esc_attr( $css ), esc_html( $warning ) ); } } /** * Renders the file type filter: mode radios, extension list and presets. * * @return void */ public static function render_extension_filter(): void { $mode = (string) Options::get( 'extension_filter_mode' ); $list = (array) Options::get( 'extension_filter_list' ); $name = Options::SETTINGS; $modes = array( 'all' => __( 'All file types', 'devsyed-media-offloader' ), 'include' => __( 'Only these types', 'devsyed-media-offloader' ), 'exclude' => __( 'All except these types', 'devsyed-media-offloader' ), ); printf( '
%s', esc_html__( 'File type filter', 'devsyed-media-offloader' ) ); foreach ( $modes as $value => $label ) { printf( '
', esc_attr( $name ), esc_attr( $value ), checked( $mode, $value, false ), esc_html( $label ) ); } printf( '

%s

', 'all' === $mode ? ' hidden' : '', esc_html__( 'File extensions', 'devsyed-media-offloader' ), esc_attr( $name ), esc_textarea( implode( ', ', $list ) ), esc_html__( 'Separate with commas or spaces, for example: jpg, png, pdf.', 'devsyed-media-offloader' ) ); foreach ( self::extension_presets() as $label => $extensions ) { printf( ' ', esc_attr( implode( ' ', $extensions ) ), esc_html( '+ ' . $label ) ); } printf( '

', esc_html__( 'Nothing will be uploaded. Add at least one file type.', 'devsyed-media-offloader' ), esc_html__( 'Some listed types are not images. They will stay local while "Offload non-image files" is off.', 'devsyed-media-offloader' ) ); } /** * Quick-add extension groups for the file type filter. The first group is images. * * @return array */ private static function extension_presets(): array { return array( __( 'Images', 'devsyed-media-offloader' ) => array( 'jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg' ), __( 'Video', 'devsyed-media-offloader' ) => array( 'mp4', 'm4v', 'mov', 'webm', 'ogv', 'avi', 'wmv', 'mkv' ), __( 'Audio', 'devsyed-media-offloader' ) => array( 'mp3', 'm4a', 'wav', 'ogg', 'oga', 'flac', 'aac' ), __( 'Documents', 'devsyed-media-offloader' ) => array( 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'txt', 'csv', 'rtf' ), __( 'Archives', 'devsyed-media-offloader' ) => array( 'zip', 'gz', 'tgz', 'rar', '7z', 'tar' ), ); } /** * Normalizes a raw extension list: lowercase, alphanumeric only, unique, capped. * * @param mixed $raw Comma/space separated string or array. * @return string[] */ private static function sanitize_extension_list( $raw ): array { $items = is_array( $raw ) ? $raw : ( is_string( $raw ) ? preg_split( '/[\s,]+/', $raw ) : array() ); $extensions = array(); foreach ( (array) $items as $item ) { if ( ! is_string( $item ) ) { continue; } $extension = (string) preg_replace( '/[^a-z0-9]/', '', strtolower( (string) $item ) ); if ( '' !== $extension ) { $extensions[ $extension ] = $extension; } } return array_slice( array_values( $extensions ), 0, 200 ); } /** * Renders the connection test button. * * @return void */ public static function render_connection_test(): void { ?>
*/ public static function sanitize_settings( $input ): array { if ( ! is_array( $input ) ) { $input = array(); } $defaults = Options::defaults(); $stored = Options::get_settings(); $sanitized = array(); // Booleans. $booleans = array( 'enabled', 'offload_new_uploads', 'object_versioning', 'delete_local', 'offload_original_image', 'offload_non_images', 'webp_enabled', 'avif_enabled', 'picture_tag', 'legacy_rewrite', 'purge_enabled', 'remove_data_on_uninstall', ); foreach ( $booleans as $key ) { $sanitized[ $key ] = array_key_exists( $key, $input ) ? ! empty( $input[ $key ] ) : (bool) $stored[ $key ]; } // Strings. $strings = array( 'key_template', 'key_prefix', 'purge_zone_id', 'delivery_zone_id', 'cache_control' ); foreach ( $strings as $key ) { $sanitized[ $key ] = isset( $input[ $key ] ) ? sanitize_text_field( $input[ $key ] ) : $stored[ $key ]; } $delivery_provider = isset( $input['delivery_provider'] ) && is_string( $input['delivery_provider'] ) ? sanitize_key( $input['delivery_provider'] ) : $stored['delivery_provider']; $sanitized['delivery_provider'] = isset( Options::delivery_presets()[ $delivery_provider ] ) ? $delivery_provider : 'custom'; $mode = isset( $input['delivery_mode'] ) ? $input['delivery_mode'] : $stored['delivery_mode']; $sanitized['delivery_mode'] = in_array( $mode, array( 'public', 'private' ), true ) ? $mode : 'public'; $sync_mode = isset( $input['sync_mode'] ) ? $input['sync_mode'] : $stored['sync_mode']; $sanitized['sync_mode'] = in_array( $sync_mode, array( 'mixed', 'ajax', 'cron' ), true ) ? $sync_mode : 'mixed'; // Quality values (1-100). foreach ( array( 'webp_quality', 'avif_quality' ) as $key ) { $val = isset( $input[ $key ] ) ? absint( $input[ $key ] ) : $stored[ $key ]; $sanitized[ $key ] = max( 1, min( 100, $val ) ); } // Integers with min/max. $int_fields = array( 'signed_url_lifetime' => array( 1, 604800 ), 'queue_batch_size' => array( 1, 200 ), 'queue_time_limit' => array( 5, 300 ), 'request_timeout' => array( 5, 300 ), ); foreach ( $int_fields as $key => $range ) { $val = isset( $input[ $key ] ) ? absint( $input[ $key ] ) : $stored[ $key ]; $sanitized[ $key ] = max( $range[0], min( $range[1], $val ) ); } // Allowlisted selects. $valid_log_levels = array( 'error', 'warning', 'info', 'debug' ); $sanitized['log_level'] = isset( $input['log_level'] ) && in_array( $input['log_level'], $valid_log_levels, true ) ? $input['log_level'] : $stored['log_level']; // Non-image disposition. $sanitized['non_image_disposition'] = isset( $input['non_image_disposition'] ) && in_array( $input['non_image_disposition'], array( 'inline', 'attachment' ), true ) ? $input['non_image_disposition'] : $stored['non_image_disposition']; // MIME allowlist: keep the stored value since the admin UI does not expose it. $sanitized['mime_allowlist'] = isset( $stored['mime_allowlist'] ) && is_array( $stored['mime_allowlist'] ) ? array_map( 'sanitize_text_field', $stored['mime_allowlist'] ) : $defaults['mime_allowlist']; // File type filter. $sanitized['extension_filter_mode'] = isset( $input['extension_filter_mode'] ) ? ( in_array( $input['extension_filter_mode'], array( 'all', 'include', 'exclude' ), true ) ? $input['extension_filter_mode'] : 'all' ) : $stored['extension_filter_mode']; $sanitized['extension_filter_list'] = self::sanitize_extension_list( isset( $input['extension_filter_list'] ) ? $input['extension_filter_list'] : $stored['extension_filter_list'] ); // Log max rows: keep the stored value. $sanitized['log_max_rows'] = isset( $stored['log_max_rows'] ) ? absint( $stored['log_max_rows'] ) : $defaults['log_max_rows']; return $sanitized; } /** * Sanitizes the credentials option. * * @param mixed $input Raw form input. * @return array */ public static function sanitize_credentials( $input ): array { if ( ! is_array( $input ) ) { $input = array(); } $stored = get_option( Options::CREDENTIALS, array() ); if ( class_exists( \R2Clouder\Migration\Migration::class ) && \R2Clouder\Migration\Migration::is_frozen() ) { return is_array( $stored ) ? $stored : array(); } if ( isset( $input['provider'], $stored['provider'] ) && $input['provider'] !== $stored['provider'] ) { $stored = array(); } $constants = Options::credential_constants(); if ( ! is_array( $stored ) ) { $stored = array(); } $sanitized = array(); foreach ( $constants as $key => $constant ) { // Skip fields defined as constants. if ( 'constant' === Options::credential_source( $key ) ) { continue; } // Each admin tab submits only the fields it renders. Preserve values // omitted by the current tab rather than clearing credentials. if ( ! isset( $input[ $key ] ) ) { $sanitized[ $key ] = isset( $stored[ $key ] ) && is_string( $stored[ $key ] ) ? $stored[ $key ] : ''; continue; } $submitted = sanitize_text_field( $input[ $key ] ); // For secret fields, empty submission means keep existing. if ( '' === $submitted && in_array( $key, array( 'secret_key', 'cf_provisioning_token', 'cf_api_token', 'cf_domain_api_token' ), true ) ) { $sanitized[ $key ] = isset( $stored[ $key ] ) ? $stored[ $key ] : ''; } else { $sanitized[ $key ] = $submitted; } } // URL field: run through esc_url_raw. if ( isset( $sanitized['public_url'] ) ) { $sanitized['public_url'] = esc_url_raw( $sanitized['public_url'] ); } return $sanitized; } /** * AJAX handler: provisions Cloudflare from one API token. * * @return void */ public static function ajax_provision(): void { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Nonce is sanitized before verification. $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'r2clouder_provision' ) ) { wp_send_json_error( array( 'message' => __( 'Invalid security token. Please reload the page and try again.', 'devsyed-media-offloader' ) ), 403 ); } if ( ! current_user_can( self::capability( 'provision' ) ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to provision Cloudflare.', 'devsyed-media-offloader' ) ), 403 ); } $stored = Options::get_credentials(); // phpcs:disable WordPress.Security.ValidatedSanitizedInput -- Values are sanitized inline after nonce verification. $account_id = isset( $_POST['account_id'] ) ? sanitize_text_field( wp_unslash( $_POST['account_id'] ) ) : $stored['account_id']; $token_raw = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : ''; $bucket = isset( $_POST['bucket'] ) ? sanitize_text_field( wp_unslash( $_POST['bucket'] ) ) : $stored['bucket']; $location = isset( $_POST['location'] ) ? sanitize_key( wp_unslash( $_POST['location'] ) ) : ''; $delivery_mode = isset( $_POST['delivery_mode'] ) ? sanitize_key( wp_unslash( $_POST['delivery_mode'] ) ) : 'auto'; $domain = isset( $_POST['domain'] ) ? sanitize_text_field( wp_unslash( $_POST['domain'] ) ) : ''; $zone_id = isset( $_POST['zone_id'] ) ? sanitize_text_field( wp_unslash( $_POST['zone_id'] ) ) : ''; // phpcs:enable WordPress.Security.ValidatedSanitizedInput $token = '' !== $token_raw ? $token_raw : $stored['cf_provisioning_token']; $provisioner = Cloudflare_Provisioner::create( $account_id, $token, (int) Options::get( 'request_timeout', 30 ) ); if ( is_wp_error( $provisioner ) ) { wp_send_json_error( array( 'message' => $provisioner->get_error_message() ) ); } $result = $provisioner->provision( array( 'bucket' => $bucket, 'location' => $location, 'delivery_mode' => $delivery_mode, 'domain' => $domain, 'zone_id' => $zone_id, ) ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ) ); } Options::update_credentials( array( 'account_id' => $result['account_id'], 'access_key' => $result['access_key'], 'secret_key' => $result['secret_key'], 'provider' => 'r2', 'bucket' => $result['bucket'], 'public_url' => $result['public_url'], 'cf_provisioning_token' => $token, ) ); if ( ! empty( $result['delivery']['zone_id'] ) ) { Options::update_settings( array( 'delivery_zone_id' => $result['delivery']['zone_id'] ) ); } $client = S3_Client::for_r2( $result['account_id'], $result['access_key'], $result['secret_key'], $result['bucket'], array( 'timeout' => (int) Options::get( 'request_timeout', 30 ) ) ); $test = is_wp_error( $client ) ? $client : $client->test_connection( $result['public_url'] ); $ok = ! is_wp_error( $test ) && ! empty( $test['ok'] ); if ( $ok && ! Onboarding::needed() && empty( $_POST['onboarding'] ) ) { Options::update_settings( array( 'enabled' => true ) ); } $context = array( 'account_id' => $result['account_id'], 'bucket' => $result['bucket'], 'public_url' => $result['public_url'], 'delivery_mode' => $delivery_mode, 'connection_ok' => $ok, ); /** * Fires after one-token Cloudflare provisioning stores its configuration. * Secrets are deliberately excluded. * * @param array $context Provisioning result context. */ do_action( 'r2clouder_cloudflare_provisioned', $context ); $message = $ok ? __( 'Cloudflare storage and delivery passed the connection test.', 'devsyed-media-offloader' ) : __( 'The bucket and delivery configuration were saved, but public delivery is not ready yet. Check the domain status, then run Test Connection.', 'devsyed-media-offloader' ); wp_send_json_success( array_merge( $context, array( 'ok' => $ok, 'message' => $message, 'migration_url' => Onboarding::needed() ? Onboarding::url() : admin_url( 'upload.php?page=r2clouder-migration' ), 'delivery_url' => admin_url( 'options-general.php?page=r2clouder&tab=delivery' ), 'setup_url' => Onboarding::url( 1 ), ) ) ); } /** * AJAX handler: test connection. * * Builds a client from the submitted credentials (not stored ones) so the * user can test before saving. * * @return void */ public static function ajax_test_connection(): void { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Nonce is sanitized before check. $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'r2clouder_test_connection' ) ) { wp_send_json_error( array( 'message' => __( 'Invalid security token. Please reload the page and try again.', 'devsyed-media-offloader' ) ), 403 ); } if ( ! current_user_can( self::capability( 'test_connection' ) ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to perform this action.', 'devsyed-media-offloader' ) ), 403 ); } // Saved-profile tests use only that profile, without changing the active connection. if ( isset( $_POST['connection_id'] ) ) { $id = is_string( $_POST['connection_id'] ) ? sanitize_key( wp_unslash( $_POST['connection_id'] ) ) : ''; $connections = \R2Clouder\Connections::all(); $current = \R2Clouder\Connections::normalize( Options::get_credentials() ); if ( Options::has_credentials() ) { $connections[ \R2Clouder\Connections::id( $current ) ] = $current; } if ( ! isset( $connections[ $id ] ) ) { wp_send_json_error( array( 'message' => __( 'This connection no longer exists. Reload the page and try again.', 'devsyed-media-offloader' ) ), 404 ); } $client = Storage::create( $connections[ $id ] ); $result = is_wp_error( $client ) ? $client : $client->test_connection( $connections[ $id ]['public_url'] ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ) ); } if ( $result['ok'] ) { wp_send_json_success( $result ); } wp_send_json_error( $result ); } // Use submitted values, falling back to stored credentials for fields left empty. $credentials = Options::get_credentials(); // phpcs:disable WordPress.Security.ValidatedSanitizedInput -- Each value is sanitized inline. $provider = isset( $_POST['provider'] ) ? sanitize_key( wp_unslash( $_POST['provider'] ) ) : $credentials['provider']; $account_id = isset( $_POST['account_id'] ) ? sanitize_text_field( wp_unslash( $_POST['account_id'] ) ) : $credentials['account_id']; $region = isset( $_POST['region'] ) ? sanitize_text_field( wp_unslash( $_POST['region'] ) ) : $credentials['region']; $access_key = isset( $_POST['access_key'] ) ? sanitize_text_field( wp_unslash( $_POST['access_key'] ) ) : $credentials['access_key']; $secret_key_raw = isset( $_POST['secret_key'] ) ? sanitize_text_field( wp_unslash( $_POST['secret_key'] ) ) : ''; $secret_key = '' !== $secret_key_raw ? $secret_key_raw : $credentials['secret_key']; $bucket = isset( $_POST['bucket'] ) ? sanitize_text_field( wp_unslash( $_POST['bucket'] ) ) : $credentials['bucket']; $public_url = isset( $_POST['public_url'] ) ? esc_url_raw( wp_unslash( $_POST['public_url'] ) ) : $credentials['public_url']; // phpcs:enable WordPress.Security.ValidatedSanitizedInput $client = Storage::create( array( 'provider' => $provider, 'account_id' => $account_id, 'region' => $region, 'access_key' => $access_key, 'secret_key' => $secret_key, 'bucket' => $bucket, ) ); if ( is_wp_error( $client ) ) { wp_send_json_error( array( 'message' => $client->get_error_message() ) ); } $result = $client->test_connection( $public_url ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ) ); } if ( $result['ok'] ) { wp_send_json_success( $result ); } else { wp_send_json_error( $result ); } } /** * AJAX handler: attach the configured delivery URL to the R2 bucket. * * The write happens only after a deliberate click on the Delivery Domain tab. * The domain URL and token are read from the form so they can be tested before * a settings-page save; account and bucket must already be configured. * * @return void */ public static function ajax_connect_delivery_domain(): void { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Nonce is sanitized before check. $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'r2clouder_delivery_domain' ) ) { wp_send_json_error( array( 'message' => __( 'Invalid security token. Please reload the page and try again.', 'devsyed-media-offloader' ) ), 403 ); } if ( ! current_user_can( self::capability( 'connect_domain' ) ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to connect a delivery domain.', 'devsyed-media-offloader' ) ), 403 ); } $credentials = Options::get_credentials(); // phpcs:disable WordPress.Security.ValidatedSanitizedInput -- Each value is sanitized inline. $public_url = isset( $_POST['public_url'] ) ? esc_url_raw( wp_unslash( $_POST['public_url'] ) ) : $credentials['public_url']; $token_raw = isset( $_POST['cf_domain_api_token'] ) ? sanitize_text_field( wp_unslash( $_POST['cf_domain_api_token'] ) ) : ''; $zone_id = isset( $_POST['delivery_zone_id'] ) ? sanitize_text_field( wp_unslash( $_POST['delivery_zone_id'] ) ) : (string) Options::get( 'delivery_zone_id', '' ); // phpcs:enable WordPress.Security.ValidatedSanitizedInput $token = '' !== $token_raw ? $token_raw : $credentials['cf_domain_api_token']; $token = '' !== $token ? $token : $credentials['cf_provisioning_token']; $domain = Cloudflare_API::domain_from_url( $public_url ); if ( is_wp_error( $domain ) ) { wp_send_json_error( array( 'message' => $domain->get_error_message() ) ); } $client = Cloudflare_API::create( $credentials['account_id'], $credentials['bucket'], $token, (int) Options::get( 'request_timeout', 30 ) ); if ( is_wp_error( $client ) ) { wp_send_json_error( array( 'message' => $client->get_error_message() ) ); } $result = $client->attach_domain( $domain, $zone_id ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ) ); } wp_send_json_success( self::delivery_domain_response( $domain, $result, true ) ); } /** * AJAX handler: reads the ownership and SSL state of a delivery domain. * * @return void */ public static function ajax_delivery_domain_status(): void { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Nonce is sanitized before check. $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'r2clouder_delivery_domain' ) ) { wp_send_json_error( array( 'message' => __( 'Invalid security token. Please reload the page and try again.', 'devsyed-media-offloader' ) ), 403 ); } if ( ! current_user_can( self::capability( 'connect_domain' ) ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to inspect a delivery domain.', 'devsyed-media-offloader' ) ), 403 ); } $credentials = Options::get_credentials(); // phpcs:disable WordPress.Security.ValidatedSanitizedInput -- Each value is sanitized inline. $public_url = isset( $_POST['public_url'] ) ? esc_url_raw( wp_unslash( $_POST['public_url'] ) ) : $credentials['public_url']; $token_raw = isset( $_POST['cf_domain_api_token'] ) ? sanitize_text_field( wp_unslash( $_POST['cf_domain_api_token'] ) ) : ''; // phpcs:enable WordPress.Security.ValidatedSanitizedInput $domain = Cloudflare_API::domain_from_url( $public_url ); if ( is_wp_error( $domain ) ) { wp_send_json_error( array( 'message' => $domain->get_error_message() ) ); } $domain_token = '' !== $token_raw ? $token_raw : $credentials['cf_domain_api_token']; $domain_token = '' !== $domain_token ? $domain_token : $credentials['cf_provisioning_token']; $client = Cloudflare_API::create( $credentials['account_id'], $credentials['bucket'], $domain_token, (int) Options::get( 'request_timeout', 30 ) ); if ( is_wp_error( $client ) ) { wp_send_json_error( array( 'message' => $client->get_error_message() ) ); } $result = $client->get_domain( $domain ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ) ); } wp_send_json_success( self::delivery_domain_response( $domain, $result, false ) ); } /** * Formats Cloudflare domain status for the admin UI. * * @param string $domain Domain requested. * @param array $result Cloudflare API result. * @param bool $connected Whether an attach was just requested. * @return array */ private static function delivery_domain_response( $domain, array $result, $connected ): array { $status = isset( $result['status'] ) && is_array( $result['status'] ) ? $result['status'] : array(); $ownership = isset( $status['ownership'] ) ? sanitize_key( $status['ownership'] ) : 'pending'; $ssl = isset( $status['ssl'] ) ? sanitize_key( $status['ssl'] ) : 'pending'; $active = 'active' === $ownership && 'active' === $ssl; if ( $active ) { /* translators: %s: custom delivery domain. */ $message = sprintf( __( '%s is connected and its SSL certificate is active. Use Test Connection to verify media delivery.', 'devsyed-media-offloader' ), $domain ); } elseif ( $connected ) { /* translators: %s: custom delivery domain. */ $message = sprintf( __( '%s was added to the bucket. Cloudflare is still preparing it; check the status again shortly.', 'devsyed-media-offloader' ), $domain ); } else { /* translators: 1: custom delivery domain, 2: ownership status, 3: SSL status. */ $message = sprintf( __( '%1$s status — ownership: %2$s; SSL: %3$s.', 'devsyed-media-offloader' ), $domain, $ownership, $ssl ); } return array( 'ok' => $active, 'message' => $message, 'ownership' => $ownership, 'ssl' => $ssl, ); } /** * AJAX handler: queue one cursor-based batch of existing attachments. * * @return void */ public static function ajax_enqueue_migration_batch(): void { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Nonce is sanitized before check. $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'r2clouder_migration' ) ) { wp_send_json_error( array( 'message' => __( 'Invalid security token. Please reload the page and try again.', 'devsyed-media-offloader' ) ), 403 ); } if ( ! current_user_can( self::capability( 'migration' ) ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to start a migration.', 'devsyed-media-offloader' ) ), 403 ); } if ( ! Options::is_active() ) { wp_send_json_error( array( 'message' => __( 'Connect a cloud provider and enable OffloadKit before starting a migration.', 'devsyed-media-offloader' ) ) ); } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Value is cast to an integer. $cursor = isset( $_POST['cursor'] ) ? max( 0, (int) $_POST['cursor'] ) : 0; $limit = 100; if ( 0 === $cursor ) { do_action( 'r2clouder_migration_started' ); } global $wpdb; $posts = $wpdb->posts; $postmeta = $wpdb->postmeta; $meta_key = Offload_Meta::META_KEY; // A monotonically increasing attachment ID is a stable cursor. This avoids // offset scans becoming slower as a large library is migrated. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Static table names from $wpdb and values prepared below. $sql = $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Static table names from $wpdb; values use placeholders. "SELECT p.ID FROM {$posts} p LEFT JOIN {$postmeta} pm ON p.ID = pm.post_id AND pm.meta_key = %s WHERE p.post_type = 'attachment' AND p.post_status != 'trash' AND p.ID > %d AND pm.post_id IS NULL ORDER BY p.ID ASC LIMIT %d", $meta_key, $cursor, $limit ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Prepared above and migration progress must be live. $ids = $wpdb->get_col( $sql ); $ids = is_array( $ids ) ? array_map( 'intval', $ids ) : array(); $enqueued = 0; $skipped = 0; $filtered = 0; foreach ( $ids as $attachment_id ) { $result = Uploads::maybe_enqueue( $attachment_id, true ); if ( false === $result ) { ++$skipped; if ( ! Offloader::is_extension_allowed( $attachment_id ) ) { ++$filtered; } } else { ++$enqueued; } } $next_cursor = empty( $ids ) ? $cursor : max( $ids ); $has_more = count( $ids ) === $limit; if ( ! $has_more ) { do_action( 'r2clouder_migration_queued', Queue::counts() ); } wp_send_json_success( array( 'enqueued' => $enqueued, 'skipped' => $skipped, 'filtered' => $filtered, 'next_cursor' => $next_cursor, 'has_more' => $has_more, 'counts' => Queue::counts(), ) ); } /** * AJAX handler: queue status. * * @return void */ public static function ajax_queue_status(): void { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Nonce is sanitized before check. $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'r2clouder_queue_status' ) ) { wp_send_json_error( array( 'message' => __( 'Invalid security token.', 'devsyed-media-offloader' ) ), 403 ); } if ( ! current_user_can( self::capability( 'queue' ) ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to perform this action.', 'devsyed-media-offloader' ) ), 403 ); } wp_send_json_success( Queue::counts() ); } /** * AJAX handler: run service health checks. * * @return void */ public static function ajax_health_check(): void { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Nonce is sanitized before check. $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'r2clouder_health_check' ) ) { wp_send_json_error( array( 'message' => __( 'Invalid security token.', 'devsyed-media-offloader' ) ), 403 ); } if ( ! current_user_can( self::capability( 'test_connection' ) ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to perform this action.', 'devsyed-media-offloader' ) ), 403 ); } $credentials = Options::get_credentials(); $results = array(); $client = Storage::create( $credentials ); if ( is_wp_error( $client ) ) { $results['r2'] = array( 'ok' => false, 'message' => $client->get_error_message(), ); } else { $list = $client->list_objects( array( 'max_keys' => 1 ) ); $results['r2'] = array( 'ok' => ! is_wp_error( $list ), 'message' => is_wp_error( $list ) ? $list->get_error_message() : __( 'Bucket is reachable.', 'devsyed-media-offloader' ), ); } $public_url = $credentials['public_url']; if ( 'private' === Options::get( 'delivery_mode' ) ) { $results['delivery'] = array( 'ok' => ! is_wp_error( $client ), 'message' => __( 'Signed delivery selected. Use Test Connection to verify an expiring GET URL.', 'devsyed-media-offloader' ), ); } elseif ( '' === $public_url ) { $results['delivery'] = array( 'ok' => false, 'message' => __( 'No delivery URL configured.', 'devsyed-media-offloader' ), ); } else { $response = wp_remote_head( $public_url, array( 'timeout' => 10 ) ); if ( is_wp_error( $response ) ) { $results['delivery'] = array( 'ok' => false, 'message' => $response->get_error_message(), ); } else { $code = wp_remote_retrieve_response_code( $response ); $results['delivery'] = array( 'ok' => $code >= 200 && $code < 500, 'message' => $code >= 200 && $code < 500 ? sprintf( /* translators: %d: HTTP status code */ __( 'Responding (HTTP %d).', 'devsyed-media-offloader' ), $code ) : sprintf( /* translators: %d: HTTP status code */ __( 'Returned HTTP %d.', 'devsyed-media-offloader' ), $code ), ); } } wp_send_json_success( $results ); } }