'none', 'schedule' => 'manual', 'profile' => 'essential', 'scope' => 'all', 'scope_id' => 0, 'retention' => 3, 'keep_local' => true, 'schedule_encrypt' => false, 'schedule_password' => '', 'google' => array( 'client_id' => '', 'client_secret' => '', 'refresh_token' => '', 'folder_id' => '', 'connected_at' => 0, ), // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- Default endpoint for an explicitly configured customer-owned backup destination; this URL is not an offloaded script, style or public site image. 's3' => array( // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- Default endpoint for an explicitly configured customer-owned backup destination; this URL is not an offloaded script, style or public site image. 'endpoint' => 'https://s3.amazonaws.com', 'region' => 'us-east-1', 'bucket' => '', 'prefix' => 'affstackdev-image-manager/', 'access_key' => '', 'secret_key' => '', 'path_style' => true, 'verified_at' => 0, 'verified_signature' => '', ), ); } /** * The saved remote backup settings over the defaults, each kept to its allowed values (retention 1 to 30). * * @return array The settings. */ public static function settings(): array { $raw = get_option( self::SETTINGS_OPTION, array() ); if ( ! is_array( $raw ) ) { $raw = array(); } $out = array_replace_recursive( self::defaults(), $raw ); $out['provider'] = in_array( $out['provider'], array( 'none', 'google', 's3' ), true ) ? $out['provider'] : 'none'; $out['schedule'] = in_array( $out['schedule'], array( 'manual', 'daily', 'weekly' ), true ) ? $out['schedule'] : 'manual'; $out['profile'] = in_array( $out['profile'], array( 'essential', 'published' ), true ) ? $out['profile'] : 'essential'; $out['scope'] = in_array( $out['scope'], array( 'all', 'folder', 'collection' ), true ) ? $out['scope'] : 'all'; $out['scope_id'] = absint( $out['scope_id'] ); $out['retention'] = max( 1, min( 30, absint( $out['retention'] ) ) ); $out['keep_local'] = ! empty( $out['keep_local'] ); $out['schedule_encrypt'] = ! empty( $out['schedule_encrypt'] ); return $out; } /** * Save the remote backup settings as given. * * @param array $settings The settings. */ private static function save_settings_raw( array $settings ): void { if ( false === get_option( self::SETTINGS_OPTION, false ) ) { Reviewed_Store::add_option( self::SETTINGS_OPTION, $settings, '', false ); } else { Reviewed_Store::update_option( self::SETTINGS_OPTION, $settings, false ); } } /** * The key that seals saved secrets: SHA-256 of the site's secret keys and salt. * * @return string The key (32 bytes). * @throws \RuntimeException With remote_secret_storage_unavailable when the site has no secret keys. */ private static function key(): string { $parts = array(); foreach ( array( 'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY' ) as $c ) { if ( defined( $c ) ) { $parts[] = (string) constant( $c ); } } if ( function_exists( 'wp_salt' ) ) { $parts[] = (string) wp_salt( 'auth' ); } if ( ! $parts ) { throw new \RuntimeException( 'remote_secret_storage_unavailable' ); } return hash( 'sha256', implode( '|', $parts ), true ); } /** * Encrypt a secret for saving (AES-256-GCM with the site's key). * * @param string $value The secret ('' stays ''). * @return string v1: and the encrypted secret in base64. * @throws \RuntimeException With remote_secret_storage_unavailable when OpenSSL cannot encrypt it. */ private static function seal( string $value ): string { if ( '' === $value ) { return ''; } if ( ! function_exists( 'openssl_encrypt' ) ) { throw new \RuntimeException( 'remote_secret_storage_unavailable' ); } $iv = random_bytes( 12 ); $tag = ''; $cipher = openssl_encrypt( $value, 'aes-256-gcm', self::key(), OPENSSL_RAW_DATA, $iv, $tag, 'afsd-im-remote-v1' ); if ( false === $cipher || 16 !== strlen( $tag ) ) { throw new \RuntimeException( 'remote_secret_storage_unavailable' ); } return 'v1:' . base64_encode( $iv . $tag . $cipher ); } /** * A saved secret, decrypted. * * @param string $wrapped The sealed secret. * @return string The secret, or '' when it cannot be decrypted. */ private static function unseal( string $wrapped ): string { if ( '' === $wrapped ) { return ''; } if ( str_starts_with( $wrapped, 'plain:' ) ) { return substr( $wrapped, 6 ); } // test fixture compatibility only; never created by runtime. if ( ! str_starts_with( $wrapped, 'v1:' ) || ! function_exists( 'openssl_decrypt' ) ) { return ''; } $raw = base64_decode( substr( $wrapped, 3 ), true ); if ( false === $raw || strlen( $raw ) < 29 ) { return ''; } $iv = substr( $raw, 0, 12 ); $tag = substr( $raw, 12, 16 ); $cipher = substr( $raw, 28 ); $plain = openssl_decrypt( $cipher, 'aes-256-gcm', self::key(), OPENSSL_RAW_DATA, $iv, $tag, 'afsd-im-remote-v1' ); return is_string( $plain ) ? $plain : ''; } /** * A secret from its constant in wp-config.php when that is set, or else the saved one. * * @param string $constant The constant's name. * @param string $wrapped The sealed saved secret. * @return string The secret. */ private static function secret( string $constant, string $wrapped ): string { if ( defined( $constant ) && is_string( constant( $constant ) ) && '' !== trim( constant( $constant ) ) ) { return trim( constant( $constant ) ); } return self::unseal( $wrapped ); } /** * Save the remote backup settings from the form: provider, schedule, profile, scope, retention, keeping the local * copy, the scheduled backups' password (sealed), the Google Drive app (a new app or secret ends the connection) and * the S3 settings and keys (sealed; a change needs a new connection test); then reschedule. * * @param array $input The form's fields. * @return array The connection's state. * @throws \RuntimeException With a remote_ or backup_ reason code for a value that is not valid. */ public static function save_settings( array $input ): array { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { $current = self::settings(); $provider = sanitize_key( (string) ( $input['remote_provider'] ?? $current['provider'] ) ); if ( ! in_array( $provider, array( 'none', 'google', 's3' ), true ) ) { throw new \RuntimeException( 'remote_provider_invalid' ); } $schedule = sanitize_key( (string) ( $input['remote_schedule'] ?? $current['schedule'] ) ); if ( ! in_array( $schedule, array( 'manual', 'daily', 'weekly' ), true ) ) { throw new \RuntimeException( 'remote_schedule_invalid' ); } $profile = sanitize_key( (string) ( $input['remote_profile'] ?? $current['profile'] ) ); if ( ! in_array( $profile, array( 'essential', 'published' ), true ) ) { throw new \RuntimeException( 'backup_invalid_profile' ); } $scope = sanitize_key( (string) ( $input['remote_scope'] ?? $current['scope'] ) ); if ( ! in_array( $scope, array( 'all', 'folder', 'collection' ), true ) ) { throw new \RuntimeException( 'backup_invalid_scope' ); } $scope_id = absint( $input['remote_scope_id'] ?? $current['scope_id'] ); if ( 'all' !== $scope ) { $tax = 'folder' === $scope ? Library::FOLDER : Library::COLLECTION; $term = get_term( $scope_id, $tax ); if ( ! $term || is_wp_error( $term ) ) { throw new \RuntimeException( 'backup_scope_missing' ); } } $current['provider'] = $provider; $current['schedule'] = $schedule; $current['profile'] = $profile; $current['scope'] = $scope; $current['scope_id'] = $scope_id; $current['retention'] = max( 1, min( 30, absint( $input['remote_retention'] ?? $current['retention'] ) ) ); $current['keep_local'] = ! empty( $input['remote_keep_local'] ); $want_encrypt = ! empty( $input['remote_schedule_encrypt'] ); if ( $want_encrypt ) { $password = is_string( $input['remote_schedule_password'] ?? null ) ? (string) $input['remote_schedule_password'] : ''; $confirm = is_string( $input['remote_schedule_password_confirm'] ?? null ) ? (string) $input['remote_schedule_password_confirm'] : ''; if ( '' !== $password ) { if ( strlen( $password ) < 8 || ! hash_equals( $password, $confirm ) ) { throw new \RuntimeException( 'backup_password_invalid' ); } $current['schedule_password'] = self::seal( $password ); } elseif ( empty( $current['schedule_password'] ) ) { throw new \RuntimeException( 'remote_schedule_password_required' ); } } else { $current['schedule_password'] = ''; } $current['schedule_encrypt'] = $want_encrypt; $g = $current['google']; $new_client = isset( $input['google_client_id'] ) ? trim( sanitize_text_field( (string) $input['google_client_id'] ) ) : (string) $g['client_id']; if ( $new_client !== (string) $g['client_id'] && '' !== (string) $g['refresh_token'] ) { $g['refresh_token'] = ''; $g['folder_id'] = ''; $g['connected_at'] = 0; } $g['client_id'] = substr( $new_client, 0, 255 ); $secret = is_string( $input['google_client_secret'] ?? null ) ? trim( (string) $input['google_client_secret'] ) : ''; if ( '' !== $secret ) { $old_secret = self::secret( 'AFSD_IM_GOOGLE_CLIENT_SECRET', (string) $g['client_secret'] ); if ( '' !== (string) $g['refresh_token'] && ! hash_equals( $old_secret, $secret ) ) { $g['refresh_token'] = ''; $g['folder_id'] = ''; $g['connected_at'] = 0; } $g['client_secret'] = self::seal( $secret ); } $current['google'] = $g; $s3 = $current['s3']; foreach ( array( 'endpoint', 'region', 'bucket', 'prefix' ) as $field ) { if ( isset( $input[ 's3_' . $field ] ) ) { $s3[ $field ] = sanitize_text_field( (string) $input[ 's3_' . $field ] ); } } $s3['endpoint'] = self::validate_endpoint( $s3['endpoint'] ); $s3['region'] = preg_match( '/^[a-z0-9-]{2,32}$/D', $s3['region'] ) ? $s3['region'] : 'us-east-1'; if ( '' !== $s3['bucket'] && ! preg_match( '/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/D', $s3['bucket'] ) ) { throw new \RuntimeException( 'remote_s3_bucket_invalid' ); } $s3['prefix'] = self::clean_prefix( $s3['prefix'] ); $s3['path_style'] = ! empty( $input['s3_path_style'] ); $ak = is_string( $input['s3_access_key'] ?? null ) ? trim( (string) $input['s3_access_key'] ) : ''; $sk = is_string( $input['s3_secret_key'] ?? null ) ? trim( (string) $input['s3_secret_key'] ) : ''; if ( '' !== $ak ) { $s3['access_key'] = self::seal( $ak ); } if ( '' !== $sk ) { $s3['secret_key'] = self::seal( $sk ); } $sig = self::s3_signature( $s3 ); if ( ! hash_equals( (string) ( $s3['verified_signature'] ?? '' ), $sig ) ) { $s3['verified_at'] = 0; $s3['verified_signature'] = ''; } $current['s3'] = $s3; self::save_settings_raw( $current ); self::reschedule(); return self::connection(); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * An S3 prefix made safe: at most 12 parts of 80 characters of letters, digits, dot, dash and underscore, ending in a * slash (affstackdev-image-manager/ when empty). * * @param string $prefix The prefix. * @return string The prefix. */ private static function clean_prefix( string $prefix ): string { $prefix = trim( str_replace( '\\', '/', $prefix ), '/' ); if ( '' === $prefix ) { return 'affstackdev-image-manager/'; } $parts = array(); foreach ( explode( '/', $prefix ) as $p ) { $p = preg_replace( '/[^A-Za-z0-9._-]/', '-', $p ); $p = trim( (string) $p, '.-' ); if ( '' !== $p ) { $parts[] = substr( $p, 0, 80 ); } if ( count( $parts ) >= 12 ) { break; } } return ( $parts ? implode( '/', $parts ) : 'affstackdev-image-manager' ) . '/'; } /** * An S3 endpoint kept to an HTTPS host with an optional port: no user, path, query or fragment, and not localhost or * an IP address (Amazon S3 when empty). * * @param string $url The endpoint. * @return string The endpoint. * @throws \RuntimeException With remote_endpoint_invalid for anything else. */ private static function validate_endpoint( string $url ): string { // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- Default endpoint for an explicitly configured customer-owned backup destination; this URL is not an offloaded script, style or public site image. $url = trim( $url ); if ( '' === $url ) { // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- Default endpoint for an explicitly configured customer-owned backup destination; this URL is not an offloaded script, style or public site image. return 'https://s3.amazonaws.com'; } $p = wp_parse_url( $url ); if ( ! is_array( $p ) || 'https' !== strtolower( (string) ( $p['scheme'] ?? '' ) ) || empty( $p['host'] ) || ! empty( $p['user'] ) || ! empty( $p['pass'] ) || ! empty( $p['query'] ) || ! empty( $p['fragment'] ) || ( isset( $p['path'] ) && ! in_array( $p['path'], array( '', '/' ), true ) ) ) { throw new \RuntimeException( 'remote_endpoint_invalid' ); } $host = strtolower( (string) $p['host'] ); if ( 'localhost' === $host || filter_var( $host, FILTER_VALIDATE_IP ) ) { throw new \RuntimeException( 'remote_endpoint_invalid' ); } return 'https://' . $host . ( isset( $p['port'] ) ? ':' . (int) $p['port'] : '' ); } /** * The Google Drive settings in use, the client ID and secret from their constants when set. * * @param array|null $settings The settings (the saved ones when null). * @return array client_id, client_secret, refresh_token and folder_id. */ private static function google_config( ?array $settings = null ): array { $settings = $settings ?: self::settings(); $g = $settings['google']; return array( 'client_id' => defined( 'AFSD_IM_GOOGLE_CLIENT_ID' ) ? trim( (string) AFSD_IM_GOOGLE_CLIENT_ID ) : trim( (string) $g['client_id'] ), 'client_secret' => self::secret( 'AFSD_IM_GOOGLE_CLIENT_SECRET', (string) $g['client_secret'] ), 'refresh_token' => self::unseal( (string) $g['refresh_token'] ), 'folder_id' => (string) $g['folder_id'], ); } /** * The S3 settings in use, with the keys from their constants when set. * * @param array|null $settings The settings (the saved ones when null). * @return array The S3 settings, with access_key_plain and secret_key_plain. */ private static function s3_config( ?array $settings = null ): array { $settings = $settings ?: self::settings(); $s = $settings['s3']; $s['access_key_plain'] = self::secret( 'AFSD_IM_S3_ACCESS_KEY', (string) $s['access_key'] ); $s['secret_key_plain'] = self::secret( 'AFSD_IM_S3_SECRET_KEY', (string) $s['secret_key'] ); return $s; } /** * A fingerprint of the S3 settings a connection test proves (endpoint, region, bucket, prefix, path style and the * keys). * * @param array $s3 The S3 settings. * @return string SHA-256. */ private static function s3_signature( array $s3 ): string { $ak = self::secret( 'AFSD_IM_S3_ACCESS_KEY', (string) ( $s3['access_key'] ?? '' ) ); $sk = self::secret( 'AFSD_IM_S3_SECRET_KEY', (string) ( $s3['secret_key'] ?? '' ) ); $credential_fingerprint = hash( 'sha256', $ak . '|' . $sk ); return hash( 'sha256', implode( '|', array( (string) $s3['endpoint'], (string) $s3['region'], (string) $s3['bucket'], (string) $s3['prefix'], ! empty( $s3['path_style'] ) ? '1' : '0', $credential_fingerprint ) ) ); } /** * The destination's state: its provider, whether it is ready (Google Drive connected with its folder; S3 with its keys * and a passed connection test of these settings), its label and detail, schedule, retention and whether the local * copy is kept. * * @return array The state. */ public static function connection(): array { $s = self::settings(); $provider = $s['provider']; $ready = false; $label = __( 'Local download only', 'affstackdev-image-manager' ); $detail = __( 'Completed snapshots can be downloaded to this device without a cloud connection.', 'affstackdev-image-manager' ); if ( 'google' === $provider ) { $g = self::google_config( $s ); $ready = '' !== $g['client_id'] && '' !== $g['client_secret'] && '' !== $g['refresh_token'] && '' !== $g['folder_id']; $label = 'Google Drive'; $detail = $ready ? __( 'Connected to the dedicated Image Manager backup folder in the authorized Google Drive account.', 'affstackdev-image-manager' ) : __( 'Save OAuth app credentials, then connect Google Drive.', 'affstackdev-image-manager' ); } elseif ( 's3' === $provider ) { $c = self::s3_config( $s ); $ready = '' !== $c['bucket'] && '' !== $c['access_key_plain'] && '' !== $c['secret_key_plain'] && (int) $s['s3']['verified_at'] > 0 && hash_equals( (string) $s['s3']['verified_signature'], self::s3_signature( $s['s3'] ) ); $label = __( 'S3-compatible storage', 'affstackdev-image-manager' ); $detail = $ready ? __( 'Connection was verified with a temporary object in the configured private prefix.', 'affstackdev-image-manager' ) : __( 'Save the endpoint/bucket credentials and run the connection test.', 'affstackdev-image-manager' ); } return array( 'provider' => $provider, 'ready' => $ready, 'label' => $label, 'detail' => $detail, 'schedule' => $s['schedule'], 'retention' => $s['retention'], 'keep_local' => $s['keep_local'], ); } /** * The Google sign-in address that connects Google Drive (access only to the files Image Manager makes), with a one-use * state kept 10 minutes for this user. * * @return string The address. * @throws \RuntimeException With remote_google_credentials_missing or remote_secret_storage_unavailable. */ public static function google_authorize_url(): string { $g = self::google_config(); if ( '' === $g['client_id'] || '' === $g['client_secret'] ) { throw new \RuntimeException( 'remote_google_credentials_missing' ); } if ( ! function_exists( 'openssl_encrypt' ) ) { throw new \RuntimeException( 'remote_secret_storage_unavailable' ); } // Google returns the state as given: a random one-use value kept for this user, and a WordPress nonce. $state = bin2hex( random_bytes( 24 ) ) . '.' . wp_create_nonce( 'afsd_im_google_callback' ); Reviewed_Store::set_transient( 'afsd_im_google_state_' . get_current_user_id(), $state, 10 * MINUTE_IN_SECONDS ); return add_query_arg( array( 'client_id' => $g['client_id'], 'redirect_uri' => admin_url( 'admin-post.php?action=afsd_im_google_callback' ), 'response_type' => 'code', 'scope' => 'https://www.googleapis.com/auth/drive.file', 'access_type' => 'offline', 'prompt' => 'consent', 'include_granted_scopes' => 'true', 'state' => $state, ), 'https://accounts.google.com/o/oauth2/v2/auth' ); } /** * Finish connecting Google Drive when Google sends the owner back: check the user, the state and the code, exchange * the code for a token, make the backup folder, save the token sealed and send the owner to Backups with a notice. * * @throws \RuntimeException When Image Manager's lock fails for a reason other than being busy (passed on); a failure * while connecting is caught and shown as a notice. */ public static function google_callback(): void { if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) || ! current_user_can( 'upload_files' ) ) { wp_die( esc_html__( 'Permission denied.', 'affstackdev-image-manager' ), '', array( 'response' => 403 ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the state carries the WordPress nonce checked on the next lines. $state = isset( $_GET['state'] ) && is_string( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : ''; $dot = strrpos( $state, '.' ); if ( false === $dot || ! wp_verify_nonce( substr( $state, $dot + 1 ), 'afsd_im_google_callback' ) ) { self::google_finish_notice( __( 'Google Drive connection could not be verified. Start the connection again.', 'affstackdev-image-manager' ) ); } try { $im_reviewed_owner_lock = Reviewed_Store::enter(); } catch ( \RuntimeException $e ) { if ( ! Reviewed_Store::busy( $e ) ) { throw $e; } // Nothing was read, stored or exchanged; a new connection attempt starts cleanly. self::google_finish_notice( __( 'Image Manager is finishing background work. Start the Google Drive connection again in a moment.', 'affstackdev-image-manager' ) ); } try { $expected = get_transient( 'afsd_im_google_state_' . get_current_user_id() ); Reviewed_Store::delete_transient( 'afsd_im_google_state_' . get_current_user_id() ); if ( ! is_string( $expected ) || '' === $expected || ! hash_equals( $expected, $state ) ) { self::google_finish_notice( __( 'Google Drive connection could not be verified. Start the connection again.', 'affstackdev-image-manager' ) ); } if ( isset( $_GET['error'] ) ) { self::google_finish_notice( __( 'Google Drive authorization was cancelled or denied.', 'affstackdev-image-manager' ) ); } $code = isset( $_GET['code'] ) && is_string( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : ''; if ( '' === $code || strlen( $code ) > 4096 || ! hash_equals( $code, (string) wp_unslash( $_GET['code'] ) ) ) { self::google_finish_notice( __( 'Google Drive did not return a valid authorization code.', 'affstackdev-image-manager' ) ); } try { $g = self::google_config(); $response = Reviewed_Store::wp_remote_post( 'https://oauth2.googleapis.com/token', array( 'timeout' => 20, 'redirection' => 0, 'sslverify' => true, 'body' => array( 'code' => $code, 'client_id' => $g['client_id'], 'client_secret' => $g['client_secret'], 'redirect_uri' => admin_url( 'admin-post.php?action=afsd_im_google_callback' ), 'grant_type' => 'authorization_code', ), ) ); $data = self::json_response( $response, array( 200 ) ); $refresh = (string) ( $data['refresh_token'] ?? '' ); $access = (string) ( $data['access_token'] ?? '' ); if ( '' === $refresh || '' === $access ) { throw new \RuntimeException( 'remote_google_token_missing' ); } $folder = self::google_create_folder( $access ); $s = self::settings(); $s['google']['refresh_token'] = self::seal( $refresh ); $s['google']['folder_id'] = $folder; $s['google']['connected_at'] = time(); self::save_settings_raw( $s ); self::reschedule(); self::google_finish_notice( __( 'Google Drive connected. New remote backups will be stored in the dedicated AffStackDev Image Manager Backups folder.', 'affstackdev-image-manager' ) ); } catch ( \Throwable $e ) { self::google_finish_notice( self::human_error( $e->getMessage() ) ); } } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Send the owner to the Backups screen with a notice, and end the request. * * @param string $message The notice. */ private static function google_finish_notice( string $message ): void { Reviewed_Store::set_transient( 'afsd_im_notice_' . get_current_user_id(), $message, 120 ); wp_safe_redirect( Admin::url( 'backups' ) ); exit; } /** * Make the AffStackDev Image Manager Backups folder in Google Drive. * * @param string $access An access token. * @return string The folder's ID. * @throws \RuntimeException With remote_google_folder_failed, or a remote_ reason code for the request. */ private static function google_create_folder( string $access ): string { $body = wp_json_encode( array( 'name' => 'AffStackDev Image Manager Backups', 'mimeType' => 'application/vnd.google-apps.folder', ) ); $r = Reviewed_Store::wp_remote_post( 'https://www.googleapis.com/drive/v3/files?fields=id', array( 'timeout' => 20, 'redirection' => 0, 'sslverify' => true, 'headers' => array( 'Authorization' => 'Bearer ' . $access, 'Content-Type' => 'application/json; charset=utf-8', ), 'body' => $body, ) ); $d = self::json_response( $r, array( 200 ) ); $id = (string) ( $d['id'] ?? '' ); if ( ! preg_match( '/^[A-Za-z0-9_-]{10,200}$/D', $id ) ) { throw new \RuntimeException( 'remote_google_folder_failed' ); }return $id; } /** * A fresh Google access token from the saved refresh token. * * @return string The token. * @throws \RuntimeException With remote_google_not_connected, remote_google_token_failed, or a remote_ reason code for * the request. */ private static function google_access_token(): string { $g = self::google_config(); if ( '' === $g['client_id'] || '' === $g['client_secret'] || '' === $g['refresh_token'] ) { throw new \RuntimeException( 'remote_google_not_connected' ); } $r = Reviewed_Store::wp_remote_post( 'https://oauth2.googleapis.com/token', array( 'timeout' => 20, 'redirection' => 0, 'sslverify' => true, 'body' => array( 'client_id' => $g['client_id'], 'client_secret' => $g['client_secret'], 'refresh_token' => $g['refresh_token'], 'grant_type' => 'refresh_token', ), ) ); $d = self::json_response( $r, array( 200 ) ); $token = (string) ( $d['access_token'] ?? '' ); if ( '' === $token ) { throw new \RuntimeException( 'remote_google_token_failed' ); }return $token; } /** * The JSON body of a response with an expected status. * * @param array|\WP_Error $response The response. * @param array $codes The expected statuses. * @return array The decoded body (an empty array when it is not JSON). * @throws \RuntimeException With remote_network_error, remote_transient_http (408, 429, 5xx), remote_auth_failed (401, * 403) or remote_http_failed. */ private static function json_response( $response, array $codes ): array { if ( is_wp_error( $response ) ) { throw new \RuntimeException( 'remote_network_error' ); }$code = (int) wp_remote_retrieve_response_code( $response ); $body = (string) wp_remote_retrieve_body( $response ); if ( ! in_array( $code, $codes, true ) ) { if ( in_array( $code, array( 408, 429, 500, 502, 503, 504 ), true ) ) { throw new \RuntimeException( 'remote_transient_http' ); }if ( in_array( $code, array( 401, 403 ), true ) ) { throw new \RuntimeException( 'remote_auth_failed' ); }throw new \RuntimeException( 'remote_http_failed' ); }$d = json_decode( $body, true ); return is_array( $d ) ? $d : array(); } /** * Forget the Google Drive connection (its token and folder) and reschedule. */ public static function disconnect_google(): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { $s = self::settings(); $s['google']['refresh_token'] = ''; $s['google']['folder_id'] = ''; $s['google']['connected_at'] = 0; self::save_settings_raw( $s ); self::reschedule(); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Test the S3 settings: put a 32-byte test object in the prefix, read its size and hash back, delete it, and record * these settings as proven. * * @return array The connection's state. * @throws \RuntimeException With remote_s3_credentials_missing, remote_integrity_failed, or a remote_ reason code for * a request. */ public static function test_s3(): array { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { $s = self::settings(); $c = self::s3_config( $s ); if ( '' === $c['bucket'] || '' === $c['access_key_plain'] || '' === $c['secret_key_plain'] ) { throw new \RuntimeException( 'remote_s3_credentials_missing' ); } $key = $c['prefix'] . '.connection-test-' . bin2hex( random_bytes( 8 ) ); $body = random_bytes( 32 ); $sha = hash( 'sha256', $body ); self::s3_request( 'PUT', $key, array(), array( 'content-type' => 'application/octet-stream', 'x-amz-meta-afsd-sha256' => $sha, ), $body, array( 200 ) ); $head = self::s3_request( 'HEAD', $key, array(), array(), '', array( 200 ) ); $got = (string) wp_remote_retrieve_header( $head, 'x-amz-meta-afsd-sha256' ); $len = (int) wp_remote_retrieve_header( $head, 'content-length' ); Reviewed_Store::update_option( 'afsd_im_s3_test_cleanup', array( 'confirmed' => false, 'time' => time(), ), false ); self::s3_request( 'DELETE', $key, array(), array(), '', array( 204, 200, 404 ) ); Reviewed_Store::update_option( 'afsd_im_s3_test_cleanup', array( 'confirmed' => true, 'time' => time(), ), false ); if ( ! hash_equals( $sha, $got ) || $len !== strlen( $body ) ) { throw new \RuntimeException( 'remote_integrity_failed' ); }$s['s3']['verified_at'] = time(); $s['s3']['verified_signature'] = self::s3_signature( $s['s3'] ); self::save_settings_raw( $s ); self::reschedule(); return self::connection(); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * A path with each part URL-encoded, as AWS signing wants it. * * @param string $path The path. * @return string The path. */ private static function aws_encode_path( string $path ): string { $parts = explode( '/', ltrim( $path, '/' ) ); return '/' . implode( '/', array_map( 'rawurlencode', $parts ) ); } /** * A query string sorted and encoded, as AWS signing wants it. * * @param array $query The query. * @return string The query string. */ private static function canonical_query( array $query ): string { ksort( $query, SORT_STRING ); $parts = array(); foreach ( $query as $k => $v ) { $parts[] = rawurlencode( (string) $k ) . '=' . rawurlencode( (string) $v ); }return implode( '&', $parts ); } /** * Send one S3 request signed with AWS Signature Version 4 (HTTPS, no redirects, unsafe addresses refused). * * @param string $method The HTTP method. * @param string $key The object's key. * @param array $query The query. * @param array $headers The headers. * @param string $body The body. * @param array $ok The expected statuses. * @param array $extra More request arguments. * @return array The response. * @throws \RuntimeException With remote_network_error, remote_transient_http, remote_auth_failed or * remote_http_failed. */ private static function s3_request( string $method, string $key, array $query, array $headers, string $body, array $ok, array $extra = array() ) { $c = self::s3_config(); $endpoint = wp_parse_url( $c['endpoint'] ); $basehost = (string) $endpoint['host']; $port = isset( $endpoint['port'] ) ? (int) $endpoint['port'] : null; $bucket = (string) $c['bucket']; if ( ! empty( $c['path_style'] ) ) { $host = $basehost; $path = '/' . $bucket . '/' . ltrim( $key, '/' ); } else { $host = $bucket . '.' . $basehost; $path = '/' . ltrim( $key, '/' ); } $host_header = $host . ( $port ? ':' . $port : '' ); $query_string = self::canonical_query( $query ); $url = 'https://' . $host_header . self::aws_encode_path( $path ) . ( $query_string !== '' ? '?' . $query_string : '' ); $amz = gmdate( 'Ymd\THis\Z' ); $date = substr( $amz, 0, 8 ); $payload = hash( 'sha256', $body ); $h = array_change_key_case( $headers, CASE_LOWER ); $h['host'] = $host_header; $h['x-amz-content-sha256'] = $payload; $h['x-amz-date'] = $amz; ksort( $h, SORT_STRING ); $canonical = ''; $signed = array(); foreach ( $h as $name => $value ) { $value = preg_replace( '/\s+/', ' ', trim( (string) $value ) ); $canonical .= $name . ':' . $value . "\n"; $signed[] = $name; }$signed_headers = implode( ';', $signed ); $canonical_request = $method . "\n" . self::aws_encode_path( $path ) . "\n" . $query_string . "\n" . $canonical . "\n" . $signed_headers . "\n" . $payload; $scope = $date . '/' . (string) $c['region'] . '/s3/aws4_request'; $string = 'AWS4-HMAC-SHA256' . "\n" . $amz . "\n" . $scope . "\n" . hash( 'sha256', $canonical_request ); $kDate = hash_hmac( 'sha256', $date, 'AWS4' . $c['secret_key_plain'], true ); $kRegion = hash_hmac( 'sha256', (string) $c['region'], $kDate, true ); $kService = hash_hmac( 'sha256', 's3', $kRegion, true ); $kSigning = hash_hmac( 'sha256', 'aws4_request', $kService, true ); $sig = hash_hmac( 'sha256', $string, $kSigning ); $h['authorization'] = 'AWS4-HMAC-SHA256 Credential=' . $c['access_key_plain'] . '/' . $scope . ', SignedHeaders=' . $signed_headers . ', Signature=' . $sig; $args = array_merge( array( 'method' => $method, 'timeout' => 35, 'redirection' => 0, 'sslverify' => true, 'reject_unsafe_urls' => true, 'headers' => $h, 'body' => $body, ), $extra ); $r = Reviewed_Store::wp_remote_request( $url, $args ); if ( is_wp_error( $r ) ) { throw new \RuntimeException( 'remote_network_error' ); }$code = (int) wp_remote_retrieve_response_code( $r ); if ( ! in_array( $code, $ok, true ) ) { if ( in_array( $code, array( 408, 429, 500, 502, 503, 504 ), true ) ) { throw new \RuntimeException( 'remote_transient_http' ); }if ( in_array( $code, array( 401, 403 ), true ) ) { throw new \RuntimeException( 'remote_auth_failed' ); }throw new \RuntimeException( 'remote_http_failed' ); }return $r; } /** * Download an S3 object through the same signed transport into private staging. * * @param string $key The object's key. * @param string $target The private file to write. * @throws \RuntimeException With remote_download_failed when no file was written. * @throws \Throwable What the request threw, passed on after the partial file is deleted. */ private static function s3_download_key( string $key, string $target ): void { // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Checksum-verified private backup staging needs bounded binary streams and private file modes; paths are generated by Backup storage and customer destination checks precede transfer. if ( is_file( $target ) ) { @Reviewed_Store::unlink( $target ); } try { self::s3_request( 'GET', $key, array(), array(), '', array( 200 ), array( 'timeout' => 120, 'stream' => true, 'filename' => $target, ) ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Checksum-verified private backup staging needs bounded binary streams and private file modes; paths are generated by Backup storage and customer destination checks precede transfer. catch ( \Throwable $e ) { @Reviewed_Store::unlink( $target ); throw $e; } // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Checksum-verified private backup staging needs bounded binary streams and private file modes; paths are generated by Backup storage and customer destination checks precede transfer. if ( ! is_file( $target ) || is_link( $target ) ) { @Reviewed_Store::unlink( $target ); throw new \RuntimeException( 'remote_download_failed' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Checksum-verified private backup staging needs bounded binary streams and private file modes; paths are generated by Backup storage and customer destination checks precede transfer. @chmod( $target, 0600 ); } /** * The remote backups recorded, newest upload first. * * @return array The remote backups. */ public static function index(): array { $raw = get_option( self::INDEX_OPTION, array() ); if ( ! is_array( $raw ) ) { return array(); }$out = array(); foreach ( $raw as $e ) { if ( is_array( $e ) && preg_match( '/^[a-f0-9]{32}$/D', (string) ( $e['id'] ?? '' ) ) && in_array( ( $e['provider'] ?? '' ), array( 'google', 's3' ), true ) ) { $out[] = $e; } }usort( $out, static fn( $a, $b ) => (int) ( $b['uploaded'] ?? 0 ) <=> (int) ( $a['uploaded'] ?? 0 ) ); return $out; } /** * Save the list of remote backups. * * @param array $index The remote backups. */ private static function save_index( array $index ): void { $index = array_values( $index ); if ( false === get_option( self::INDEX_OPTION, false ) ) { Reviewed_Store::add_option( self::INDEX_OPTION, $index, '', false ); } else { Reviewed_Store::update_option( self::INDEX_OPTION, $index, false ); } } /** * One remote backup. * * @param string $id Its ID. * @return array|null The entry, or null. */ public static function entry( string $id ): ?array { foreach ( self::index() as $e ) { if ( hash_equals( (string) $e['id'], $id ) ) { return $e; } }return null; } /** * The transfer job, with a default for every field. * * @return array The job. */ public static function job(): array { $j = get_option( self::JOB_OPTION, array() ); if ( ! is_array( $j ) ) { $j = array(); }return array_merge( array( 'id' => '', 'active' => false, 'paused' => false, 'mode' => 'upload', 'stage' => 'idle', 'provider' => '', 'snapshot_id' => '', 'remote_id' => '', 'created' => 0, 'updated' => 0, 'offset' => 0, 'total' => 0, 'session' => '', 'upload_id' => '', 'part' => 1, 'parts' => array(), 'remote_ref' => '', 'name' => '', 'attempts' => 0, 'next_try' => 0, 'last_error' => '', ), $j ); } /** * Save the transfer job with its update time. * * @param array $j The job. */ private static function save_job( array $j ): void { $j['updated'] = time(); if ( false === get_option( self::JOB_OPTION, false ) ) { Reviewed_Store::add_option( self::JOB_OPTION, $j, '', false ); } else { Reviewed_Store::update_option( self::JOB_OPTION, $j, false ); } } /** * Start a local backup that is uploaded to the destination when it completes. * * @param array $payload The backup form's fields. * @return array The local backup's status. * @throws \RuntimeException With remote_not_ready or remote_job_active. */ public static function start_backup( array $payload ): array { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { $c = self::connection(); if ( empty( $c['ready'] ) ) { throw new \RuntimeException( 'remote_not_ready' ); }if ( Backup::job()['active'] || self::job()['active'] ) { throw new \RuntimeException( 'remote_job_active' ); } $status = Backup::start( $payload ); $j = Backup::job(); $wait = array( 'backup_job_id' => (string) $j['id'], 'provider' => $c['provider'], 'keep_local' => (bool) $c['keep_local'], 'created' => time(), ); if ( false === get_option( self::WAIT_OPTION, false ) ) { Reviewed_Store::add_option( self::WAIT_OPTION, $wait, '', false ); } else { Reviewed_Store::update_option( self::WAIT_OPTION, $wait, false ); }return $status; } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Upload a backup already made to the destination. * * @param string $snapshot_id The backup's ID. * @throws \RuntimeException With remote_not_ready when the destination is not ready. */ public static function queue_existing( string $snapshot_id ): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { $c = self::connection(); if ( empty( $c['ready'] ) ) { throw new \RuntimeException( 'remote_not_ready' ); }self::queue_upload( $snapshot_id, $c['provider'] ); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Start the transfer job of a backup to a provider. * * @param string $snapshot_id The backup's ID. * @param string $provider google or s3. * @throws \RuntimeException With remote_job_active when a transfer is already running. */ private static function queue_upload( string $snapshot_id, string $provider ): void { if ( self::job()['active'] ) { throw new \RuntimeException( 'remote_job_active' ); }$info = Backup::archive_info( $snapshot_id ); $name = 'affstackdev-media-backup-' . gmdate( 'Ymd-His', (int) $info['created'] ) . '-' . $snapshot_id . '.zip'; $j = array( 'id' => bin2hex( random_bytes( 16 ) ), 'active' => true, 'paused' => false, 'mode' => 'upload', 'stage' => 'init', 'provider' => $provider, 'snapshot_id' => $snapshot_id, 'remote_id' => '', 'created' => time(), 'offset' => 0, 'total' => (int) $info['bytes'], 'session' => '', 'upload_id' => '', 'part' => 1, 'parts' => array(), 'remote_ref' => '', 'name' => $name, 'attempts' => 0, 'next_try' => 0, 'last_error' => '', ); self::save_job( $j ); self::ensure_transfer(); } /** * When the local backup started for the destination completes, start its upload (a failure to start is recorded on the * transfer job). * * @param array $item The completed backup's list entry. */ public static function backup_completed( array $item ): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { $wait = get_option( self::WAIT_OPTION, array() ); if ( ! is_array( $wait ) || empty( $wait['backup_job_id'] ) || ! hash_equals( (string) $wait['backup_job_id'], (string) ( $item['id'] ?? '' ) ) ) { return; }Reviewed_Store::delete_option( self::WAIT_OPTION ); try { self::queue_upload( (string) $item['id'], (string) $wait['provider'] ); } catch ( \Throwable $e ) { $j = self::job(); $j['active'] = false; $j['stage'] = 'failed'; $j['last_error'] = sanitize_key( $e->getMessage() ); self::save_job( $j ); } } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** Recover the handoff if a local backup finished while the remote completion hook was interrupted. */ private static function reconcile_waiting_backup(): void { $wait = get_option( self::WAIT_OPTION, array() ); if ( ! is_array( $wait ) || empty( $wait['backup_job_id'] ) ) { return; } $id = (string) $wait['backup_job_id']; $local = Backup::job(); if ( ! empty( $local['active'] ) && hash_equals( $id, (string) ( $local['id'] ?? '' ) ) ) { return; } if ( self::job()['active'] ) { return; } $snap = Backup::snapshot( $id ); if ( $snap ) { Reviewed_Store::delete_option( self::WAIT_OPTION ); try { self::queue_upload( $id, (string) ( $wait['provider'] ?? '' ) ); } catch ( \Throwable $e ) { $j = self::job(); $j['active'] = false; $j['stage'] = 'failed'; $j['last_error'] = sanitize_key( $e->getMessage() ); self::save_job( $j ); }return; } if ( hash_equals( $id, (string) ( $local['id'] ?? '' ) ) || ( ! empty( $local['id'] ) && ! hash_equals( $id, (string) $local['id'] ) ) ) { Reviewed_Store::delete_option( self::WAIT_OPTION ); } } /** * The local archive of the job's backup, checked against its recorded hash. * * @param array $j The job. * @return array The archive's details (Backup::archive_info()). */ private static function snapshot_path( array $j ): array { return Backup::archive_info( (string) $j['snapshot_id'] ); } /** * Whether an upload session address is an HTTPS address at googleapis.com with no user or password. * * @param string $url The address. * @return bool True when it is. */ private static function google_session_ok( string $url ): bool { $p = wp_parse_url( $url ); $h = strtolower( (string) ( $p['host'] ?? '' ) ); return is_array( $p ) && 'https' === strtolower( (string) ( $p['scheme'] ?? '' ) ) && ( $h === 'www.googleapis.com' || str_ends_with( $h, '.googleapis.com' ) ) && ! isset( $p['user'] ) && ! isset( $p['pass'] ); } /** * Reconcile a resumable Google upload after an ambiguous response or interrupted request. * * @param array $j The job. * @param string $access An access token. * @return array The job, at the stage and offset Google reports. * @throws \RuntimeException With remote_network_error, remote_transient_http, remote_auth_failed or * remote_google_upload_failed. */ private static function google_reconcile_session( array $j, string $access ): array { if ( empty( $j['session'] ) || ! self::google_session_ok( (string) $j['session'] ) ) { $j['stage'] = 'init'; $j['session'] = ''; $j['offset'] = 0; return $j; } $r = Reviewed_Store::wp_remote_request( (string) $j['session'], array( 'method' => 'PUT', 'timeout' => 25, 'redirection' => 0, 'sslverify' => true, 'reject_unsafe_urls' => true, 'headers' => array( 'Authorization' => 'Bearer ' . $access, 'Content-Length' => '0', 'Content-Range' => 'bytes */' . $j['total'], ), 'body' => '', ) ); if ( is_wp_error( $r ) ) { throw new \RuntimeException( 'remote_network_error' ); } $code = (int) wp_remote_retrieve_response_code( $r ); if ( 308 === $code ) { $range = (string) wp_remote_retrieve_header( $r, 'range' ); $offset = 0; if ( preg_match( '/bytes=0-(\d+)/D', $range, $m ) ) { $offset = (int) $m[1] + 1; }$j['offset'] = min( (int) $j['total'], $offset ); $j['stage'] = 'uploading'; return $j; } if ( in_array( $code, array( 200, 201 ), true ) ) { $d = json_decode( (string) wp_remote_retrieve_body( $r ), true ); $id = is_array( $d ) ? (string) ( $d['id'] ?? '' ) : ''; if ( ! preg_match( '/^[A-Za-z0-9_-]{10,200}$/D', $id ) ) { throw new \RuntimeException( 'remote_google_upload_failed' ); }$j['remote_ref'] = $id; $j['offset'] = $j['total']; $j['stage'] = 'verifying'; return $j; } if ( 404 === $code ) { $j['stage'] = 'init'; $j['session'] = ''; $j['offset'] = 0; return $j; } if ( in_array( $code, array( 408, 429, 500, 502, 503, 504 ), true ) ) { throw new \RuntimeException( 'remote_transient_http' ); } if ( in_array( $code, array( 401, 403 ), true ) ) { throw new \RuntimeException( 'remote_auth_failed' ); } throw new \RuntimeException( 'remote_google_upload_failed' ); } /** * One Google Drive step: start a resumable upload into the backup folder, send the next 5 MB (asking Google where the * upload stands after an unclear answer), or check the uploaded file's size and MD5 against the archive and record it. * * @param array $j The job. * @return array The job. * @throws \RuntimeException With a remote_ reason code when a step fails. */ private static function tick_google( array $j ): array { $info = self::snapshot_path( $j ); $access = self::google_access_token(); $g = self::google_config(); if ( 'init' === $j['stage'] ) { $metadata = wp_json_encode( array( 'name' => $j['name'], 'parents' => array( $g['folder_id'] ), 'appProperties' => array( 'afsd_snapshot' => (string) $j['snapshot_id'], 'afsd_sha256' => (string) $info['sha256'], ), ) ); $r = Reviewed_Store::wp_remote_post( 'https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable&fields=id,name,size,md5Checksum', array( 'timeout' => 25, 'redirection' => 0, 'sslverify' => true, 'headers' => array( 'Authorization' => 'Bearer ' . $access, 'Content-Type' => 'application/json; charset=utf-8', 'X-Upload-Content-Type' => 'application/zip', 'X-Upload-Content-Length' => (string) $j['total'], ), 'body' => $metadata, ) ); if ( is_wp_error( $r ) ) { throw new \RuntimeException( 'remote_network_error' ); }$code = (int) wp_remote_retrieve_response_code( $r ); if ( ! in_array( $code, array( 200, 201 ), true ) ) { if ( in_array( $code, array( 408, 429, 500, 502, 503, 504 ), true ) ) { throw new \RuntimeException( 'remote_transient_http' ); }throw new \RuntimeException( 'remote_google_upload_failed' ); }$session = (string) wp_remote_retrieve_header( $r, 'location' ); if ( ! self::google_session_ok( $session ) ) { throw new \RuntimeException( 'remote_google_session_invalid' ); }$j['session'] = $session; $j['stage'] = 'uploading'; return $j; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose,WordPress.WP.AlternativeFunctions.file_system_operations_fopen,WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Checksum-verified private backup staging needs bounded binary streams and private file modes; paths are generated by Backup storage and customer destination checks precede transfer. if ( 'uploading' === $j['stage'] ) { $offset = (int) $j['offset']; $len = min( self::CHUNK, (int) $j['total'] - $offset ); if ( $len <= 0 ) { $j['stage'] = 'verifying'; return $j; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Checksum-verified private backup staging needs bounded binary streams and private file modes; paths are generated by Backup storage and customer destination checks precede transfer. }$fh = fopen( $info['path'], 'rb' ); if ( ! $fh || fseek( $fh, $offset ) !== 0 ) { if ( $fh ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Checksum-verified private backup staging needs bounded binary streams and private file modes; paths are generated by Backup storage and customer destination checks precede transfer. fclose( $fh ); }throw new \RuntimeException( 'remote_local_archive_missing' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Checksum-verified private backup staging needs bounded binary streams and private file modes; paths are generated by Backup storage and customer destination checks precede transfer. }$body = fread( $fh, $len ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Checksum-verified private backup staging needs bounded binary streams and private file modes; paths are generated by Backup storage and customer destination checks precede transfer. fclose( $fh ); if ( ! is_string( $body ) || strlen( $body ) !== $len ) { throw new \RuntimeException( 'remote_local_archive_missing' ); }$end = $offset + $len - 1; $r = Reviewed_Store::wp_remote_request( $j['session'], array( 'method' => 'PUT', 'timeout' => 45, 'redirection' => 0, 'sslverify' => true, 'reject_unsafe_urls' => true, 'headers' => array( 'Authorization' => 'Bearer ' . $access, 'Content-Length' => (string) $len, 'Content-Range' => 'bytes ' . $offset . '-' . $end . '/' . $j['total'], ), 'body' => $body, ) ); if ( is_wp_error( $r ) ) { return self::google_reconcile_session( $j, $access ); }$code = (int) wp_remote_retrieve_response_code( $r ); if ( 308 === $code ) { $range = (string) wp_remote_retrieve_header( $r, 'range' ); $j['offset'] = $end + 1; if ( preg_match( '/bytes=0-(\d+)/D', $range, $m ) ) { $j['offset'] = min( (int) $j['total'], (int) $m[1] + 1 ); }return $j; }if ( ! in_array( $code, array( 200, 201 ), true ) ) { if ( in_array( $code, array( 408, 429, 500, 502, 503, 504 ), true ) ) { return self::google_reconcile_session( $j, $access ); }throw new \RuntimeException( 'remote_google_upload_failed' ); }$d = json_decode( (string) wp_remote_retrieve_body( $r ), true ); $id = is_array( $d ) ? (string) ( $d['id'] ?? '' ) : ''; if ( ! preg_match( '/^[A-Za-z0-9_-]{10,200}$/D', $id ) ) { throw new \RuntimeException( 'remote_google_upload_failed' ); }$j['remote_ref'] = $id; $j['offset'] = $j['total']; $j['stage'] = 'verifying'; return $j; } if ( 'verifying' === $j['stage'] ) { $url = 'https://www.googleapis.com/drive/v3/files/' . rawurlencode( (string) $j['remote_ref'] ) . '?fields=id,size,md5Checksum'; $r = Reviewed_Store::wp_remote_get( $url, array( 'timeout' => 20, 'redirection' => 0, 'sslverify' => true, 'headers' => array( 'Authorization' => 'Bearer ' . $access ), ) ); $d = self::json_response( $r, array( 200 ) ); $size = (int) ( $d['size'] ?? -1 ); $md5 = (string) ( $d['md5Checksum'] ?? '' ); if ( $size !== $j['total'] || '' === $md5 || ! hash_equals( md5_file( $info['path'] ), $md5 ) ) { throw new \RuntimeException( 'remote_integrity_failed' ); }$j['stage'] = 'complete'; return self::complete_job( $j, 'remote_size_and_md5_verified' ); } return $j; } /** * The text of the first tag of a name in an S3 XML answer. * * @param string $xml The XML. * @param string $tag The tag's name. * @return string The text, or ''. */ private static function parse_xml_tag( string $xml, string $tag ): string { if ( preg_match( '~<' . $tag . '>(.*?)~s', $xml, $m ) ) { return html_entity_decode( trim( $m[1] ), ENT_QUOTES | ENT_XML1, 'UTF-8' ); }return ''; } /** * One S3 step: start a multipart upload, send the next 5 MB part, complete the upload, or check the object's size and * recorded hash, download it to the private folder and compare its SHA-256, then record it. * * @param array $j The job. * @return array The job. * @throws \RuntimeException With a remote_ reason code when a step fails, or backup_storage_unavailable. */ private static function tick_s3( array $j ): array { $info = self::snapshot_path( $j ); $key = self::settings()['s3']['prefix'] . $j['name']; $j['remote_ref'] = $key; if ( 'init' === $j['stage'] ) { $r = self::s3_request( 'POST', $key, array( 'uploads' => '' ), array( 'content-type' => 'application/zip', 'x-amz-meta-afsd-sha256' => (string) $info['sha256'], 'x-amz-meta-afsd-snapshot' => (string) $j['snapshot_id'], ), '', array( 200 ) ); $id = self::parse_xml_tag( (string) wp_remote_retrieve_body( $r ), 'UploadId' ); if ( '' === $id ) { throw new \RuntimeException( 'remote_s3_multipart_failed' ); }$j['upload_id'] = $id; $j['stage'] = 'uploading'; return $j; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose,WordPress.WP.AlternativeFunctions.file_system_operations_fopen,WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Checksum-verified private backup staging needs bounded binary streams and private file modes; paths are generated by Backup storage and customer destination checks precede transfer. if ( 'uploading' === $j['stage'] ) { $offset = (int) $j['offset']; $len = min( self::CHUNK, (int) $j['total'] - $offset ); if ( $len <= 0 ) { $j['stage'] = 'completing'; return $j; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Checksum-verified private backup staging needs bounded binary streams and private file modes; paths are generated by Backup storage and customer destination checks precede transfer. }$fh = fopen( $info['path'], 'rb' ); if ( ! $fh || fseek( $fh, $offset ) !== 0 ) { if ( $fh ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Checksum-verified private backup staging needs bounded binary streams and private file modes; paths are generated by Backup storage and customer destination checks precede transfer. fclose( $fh ); }throw new \RuntimeException( 'remote_local_archive_missing' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Checksum-verified private backup staging needs bounded binary streams and private file modes; paths are generated by Backup storage and customer destination checks precede transfer. }$body = fread( $fh, $len ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Checksum-verified private backup staging needs bounded binary streams and private file modes; paths are generated by Backup storage and customer destination checks precede transfer. fclose( $fh ); if ( ! is_string( $body ) || strlen( $body ) !== $len ) { throw new \RuntimeException( 'remote_local_archive_missing' ); }$part = (int) $j['part']; $r = self::s3_request( 'PUT', $key, array( 'partNumber' => (string) $part, 'uploadId' => (string) $j['upload_id'], ), array(), $body, array( 200 ) ); $etag = (string) wp_remote_retrieve_header( $r, 'etag' ); if ( '' === $etag ) { throw new \RuntimeException( 'remote_s3_multipart_failed' ); }$parts = is_array( $j['parts'] ) ? $j['parts'] : array(); $parts[] = array( 'part' => $part, 'etag' => $etag, ); $j['parts'] = $parts; $j['offset'] = $offset + $len; $j['part'] = $part + 1; if ( $j['offset'] >= $j['total'] ) { $j['stage'] = 'completing'; }return $j; } if ( 'completing' === $j['stage'] ) { $xml = ''; foreach ( (array) $j['parts'] as $p ) { $xml .= '' . (int) $p['part'] . '' . htmlspecialchars( (string) $p['etag'], ENT_XML1 | ENT_QUOTES, 'UTF-8' ) . ''; }$xml .= ''; self::s3_request( 'POST', $key, array( 'uploadId' => (string) $j['upload_id'] ), array( 'content-type' => 'application/xml' ), $xml, array( 200 ) ); $j['stage'] = 'verifying'; return $j; } // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Checksum-verified private backup staging needs bounded binary streams and private file modes; paths are generated by Backup storage and customer destination checks precede transfer. if ( 'verifying' === $j['stage'] ) { $r = self::s3_request( 'HEAD', $key, array(), array(), '', array( 200 ) ); $declared = (string) wp_remote_retrieve_header( $r, 'x-amz-meta-afsd-sha256' ); $size = (int) wp_remote_retrieve_header( $r, 'content-length' ); if ( ! hash_equals( (string) $info['sha256'], $declared ) || $size !== $j['total'] ) { throw new \RuntimeException( 'remote_integrity_failed' ); }$storage = Backup::storage_info( true ); if ( empty( $storage['available'] ) ) { throw new \RuntimeException( 'backup_storage_unavailable' ); }$verify = $storage['path'] . '/remote-verify-' . bin2hex( random_bytes( 12 ) ) . '.zip'; try { self::s3_download_key( $key, $verify ); $actual = is_file( $verify ) ? hash_file( 'sha256', $verify ) : false; $actual_size = is_file( $verify ) ? filesize( $verify ) : false; if ( ! is_string( $actual ) || false === $actual_size || (int) $actual_size !== $j['total'] || ! hash_equals( (string) $info['sha256'], $actual ) ) { throw new \RuntimeException( 'remote_integrity_failed' ); } } finally { @Reviewed_Store::unlink( $verify ); }$j['stage'] = 'complete'; return self::complete_job( $j, 'remote_size_and_downloaded_sha256_verified' ); } return $j; } /** * Record a finished upload in the list of remote backups, keep only as many copies at that destination as the * retention allows, and delete the local backup when it is not to be kept. * * @param array $j The job. * @param string $integrity How the upload was checked. * @return array The job, complete. */ private static function complete_job( array $j, string $integrity ): array { $snap = Backup::snapshot( (string) $j['snapshot_id'] ); $s = self::settings(); $dest = self::destination_id( $j['provider'], $s ); $entry = array( 'id' => bin2hex( random_bytes( 16 ) ), 'provider' => $j['provider'], 'destination_id' => $dest, 'snapshot_id' => $j['snapshot_id'], 'name' => $j['name'], 'remote_ref' => $j['remote_ref'], 'bytes' => (int) $j['total'], 'sha256' => (string) ( $snap['sha256'] ?? '' ), 'uploaded' => time(), 'integrity' => $integrity, 'encrypted' => ! empty( $snap['encrypted'] ), 'profile' => (string) ( $snap['profile'] ?? '' ), 'scope_label' => (string) ( $snap['scope_label'] ?? __( 'Media backup', 'affstackdev-image-manager' ) ), 'site_uuid' => (string) ( $snap['site_uuid'] ?? Backup::site_uuid() ), ); $index = self::index(); array_unshift( $index, $entry ); self::save_index( $index ); $j['active'] = false; $j['paused'] = false; $j['stage'] = 'complete'; $j['remote_id'] = $entry['id']; $j['last_error'] = ''; self::save_job( $j ); self::prune( $j['provider'], $dest, $s['retention'] ); if ( empty( $s['keep_local'] ) && $snap ) { try { Backup::delete_snapshot( (string) $j['snapshot_id'] ); } catch ( \Throwable $ignored ) { // The remote copy is recorded either way; a local backup that cannot be deleted stays in the list. } }return $j; } /** * A fingerprint of a destination: the Google Drive folder, or the S3 endpoint, bucket, prefix and path style. * * @param string $provider google or s3. * @param array|null $s The settings (the saved ones when null). * @return string SHA-256. */ private static function destination_id( string $provider, ?array $s = null ): string { $s = $s ?: self::settings(); if ( 'google' === $provider ) { return hash( 'sha256', 'google|' . (string) $s['google']['folder_id'] ); }$x = $s['s3']; return hash( 'sha256', 's3|' . $x['endpoint'] . '|' . $x['bucket'] . '|' . $x['prefix'] . '|' . ( ! empty( $x['path_style'] ) ? '1' : '0' ) ); } /** * One step of the transfer job, for its provider. * * @param array $j The job. * @return array The job. * @throws \RuntimeException With remote_provider_invalid for another provider. */ private static function tick( array $j ): array { if ( 'google' === $j['provider'] ) { return self::tick_google( $j ); }if ( 's3' === $j['provider'] ) { return self::tick_s3( $j ); }throw new \RuntimeException( 'remote_provider_invalid' ); } /** * Run transfer steps for a while under the worker's lease (up to 4 steps or 8 seconds from the browser, 2 steps or 4 * seconds in the background); a network or temporary HTTP failure is tried again after 1, 5 and 15 minutes, any other * failure ends the job. * * @param string $origin browser or background. */ public static function work( string $origin = 'browser' ): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { $j = self::job(); if ( ! $j['active'] || $j['paused'] ) { return; }if ( (int) $j['next_try'] > time() ) { self::ensure_transfer(); return; }$token = Repository::acquire( 'remote_backup', 300 ); if ( ! $token ) { return; }try { $start = microtime( true ); $limit = 'background' === $origin ? 2 : 4;for ( $i = 0; $i < $limit && microtime( true ) - $start < ( 'background' === $origin ? 4.0 : 8.0 ); $i++ ) { $j = self::job(); if ( ! $j['active'] || $j['paused'] || (int) $j['next_try'] > time() ) { break; }try { $j = self::tick( $j ); $j['attempts'] = 0; $j['next_try'] = 0; $j['last_error'] = ''; self::save_job( $j ); } catch ( \Throwable $e ) { $code = sanitize_key( $e->getMessage() ); if ( in_array( $code, array( 'remote_network_error', 'remote_transient_http' ), true ) && (int) $j['attempts'] < self::MAX_RETRIES ) { $j['attempts'] = (int) $j['attempts'] + 1; $delay = array( 60, 300, 900 )[ $j['attempts'] - 1 ] ?? 900; $j['next_try'] = time() + $delay; $j['last_error'] = $code; self::save_job( $j ); break; }$j['active'] = false; $j['stage'] = 'failed'; $j['last_error'] = $code; self::save_job( $j ); break; } } } finally { Repository::release( $token, 'remote_backup' ); }self::ensure_transfer(); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * The background step: run the transfer, never twice at once in one request. */ public static function dispatch(): void { if ( self::$dispatching ) { return; }self::$dispatching = true; try { self::work( 'background' ); } finally { self::$dispatching = false; } } /** * Keep an active, unpaused transfer scheduled (Action Scheduler or WP-Cron, at its next try and at least 15 seconds * on), after starting the upload of a finished local backup whose hand-off was missed; stop the schedule otherwise. */ public static function ensure_transfer(): void { self::reconcile_waiting_backup(); $j = self::job(); if ( ! $j['active'] || $j['paused'] ) { self::stop_transfer_schedule(); return; }$when = max( time() + 15, (int) $j['next_try'] ); try { if ( 'Action Scheduler' === Scheduler::backend() && function_exists( 'as_has_scheduled_action' ) ) { if ( ! as_has_scheduled_action( self::HOOK, array(), Engine::GROUP ) ) { as_schedule_single_action( $when, self::HOOK, array(), Engine::GROUP, false, 25 ); }Reviewed_Store::wp_clear_scheduled_hook( self::HOOK ); } elseif ( ! wp_next_scheduled( self::HOOK ) ) { Reviewed_Store::wp_schedule_single_event( $when, self::HOOK, array(), true ); } } catch ( \Throwable $ignored ) { // The transfer stays saved; ensure_transfer() runs again on the next page load and schedules it then. } } /** * Remove the transfer's scheduled steps from WP-Cron and Action Scheduler. */ public static function stop_transfer_schedule(): void { Reviewed_Store::wp_clear_scheduled_hook( self::HOOK ); if ( 'Action Scheduler' === Scheduler::backend() && function_exists( 'as_unschedule_all_actions' ) ) { as_unschedule_all_actions( self::HOOK, array(), Engine::GROUP ); } } /** * Pause the active transfer and stop its schedule. */ public static function pause(): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { $j = self::job(); if ( $j['active'] ) { $j['paused'] = true; self::save_job( $j ); self::stop_transfer_schedule(); } } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Resume the paused transfer and schedule it. */ public static function resume(): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { $j = self::job(); if ( $j['active'] ) { $j['paused'] = false; self::save_job( $j ); self::ensure_transfer(); } } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Stop the active transfer; an unfinished S3 multipart upload is aborted, and whether S3 confirmed that is recorded. */ public static function cancel(): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { $j = self::job(); if ( ! $j['active'] ) { return; }$j['multipart_abort'] = 'not_required'; if ( 's3' === $j['provider'] && ! empty( $j['upload_id'] ) && ! empty( $j['remote_ref'] ) ) { try { self::s3_request( 'DELETE', (string) $j['remote_ref'], array( 'uploadId' => (string) $j['upload_id'] ), array(), '', array( 204, 200, 404 ) ); $j['multipart_abort'] = 'confirmed'; } catch ( \Throwable $ignored ) { $j['multipart_abort'] = 'unconfirmed'; $j['last_error'] = 'remote_abort_unconfirmed'; } }$j['active'] = false; $j['paused'] = false; $j['stage'] = 'cancelled'; self::save_job( $j ); self::stop_transfer_schedule(); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * The transfer's state for the screen: stage, percent, title and detail. * * @return array The status. */ public static function status(): array { $j = self::job(); $percent = $j['total'] > 0 ? (int) min( 99, floor( ( (int) $j['offset'] / (int) $j['total'] ) * 95 ) ) : 0; if ( 'verifying' === $j['stage'] ) { $percent = 98; }if ( ! $j['active'] && 'complete' === $j['stage'] ) { $percent = 100; }$title = __( 'No remote transfer is running', 'affstackdev-image-manager' ); $detail = __( 'Local snapshots remain downloadable to this device.', 'affstackdev-image-manager' ); if ( $j['active'] ) { if ( $j['paused'] ) { $title = __( 'Remote transfer paused', 'affstackdev-image-manager' ); $detail = __( 'Saved upload progress is retained.', 'affstackdev-image-manager' ); } elseif ( (int) $j['next_try'] > time() ) { $title = __( 'Remote transfer waiting to retry', 'affstackdev-image-manager' ); $detail = __( 'A temporary network/provider problem occurred. Progress is saved.', 'affstackdev-image-manager' ); } else { $title = sprintf( /* translators: %s: the remote destination, such as Google Drive. */__( 'Sending backup to %s', 'affstackdev-image-manager' ), 'google' === $j['provider'] ? 'Google Drive' : __( 'S3-compatible storage', 'affstackdev-image-manager' ) ); $detail = sprintf( /* translators: 1: transfer stage; 2: amount transferred, such as 4 MB; 3: total size. */ __( '%1$s · %2$s of %3$s transferred.', 'affstackdev-image-manager' ), array( 'init' => __( 'Init', 'affstackdev-image-manager' ), 'uploading' => __( 'Uploading', 'affstackdev-image-manager' ), 'completing' => __( 'Completing', 'affstackdev-image-manager' ), 'verifying' => __( 'Verifying', 'affstackdev-image-manager' ), )[ $j['stage'] ] ?? ucwords( str_replace( '_', ' ', $j['stage'] ) ), size_format( (int) $j['offset'], 1 ), size_format( (int) $j['total'], 1 ) ); } } elseif ( 'complete' === $j['stage'] ) { $title = __( 'Remote backup verified', 'affstackdev-image-manager' ); $detail = __( 'The remote object passed the configured provider integrity check.', 'affstackdev-image-manager' ); } elseif ( 'failed' === $j['stage'] ) { $title = __( 'Remote transfer needs attention', 'affstackdev-image-manager' ); $detail = self::human_error( (string) $j['last_error'] ); }return array( 'active' => (bool) $j['active'], 'paused' => (bool) $j['paused'], 'stage' => $j['stage'], 'percent' => $percent, 'title' => $title, 'detail' => $detail, 'assist' => (bool) $j['active'], 'watch' => (bool) $j['active'], 'last_error' => $j['last_error'], ); } /** * When the scheduled backup last tried and last started, and its last error. * * @return array last_attempt, last_started and last_error. */ public static function schedule_status(): array { $raw = get_option( self::SCHEDULE_STATUS_OPTION, array() ); if ( ! is_array( $raw ) ) { $raw = array(); }return array_merge( array( 'last_attempt' => 0, 'last_started' => 0, 'last_error' => '', ), $raw ); } /** * Save the scheduled backup's status. * * @param array $status The status. */ private static function save_schedule_status( array $status ): void { $safe = array( 'last_attempt' => absint( $status['last_attempt'] ?? 0 ), 'last_started' => absint( $status['last_started'] ?? 0 ), 'last_error' => sanitize_key( (string) ( $status['last_error'] ?? '' ) ), ); if ( false === get_option( self::SCHEDULE_STATUS_OPTION, false ) ) { Reviewed_Store::add_option( self::SCHEDULE_STATUS_OPTION, $safe, '', false ); } else { Reviewed_Store::update_option( self::SCHEDULE_STATUS_OPTION, $safe, false ); } } /** * Set the scheduled backup again from the settings. */ public static function reschedule(): void { self::stop_schedule(); self::ensure_schedule( true ); } /** * Schedule the daily or weekly backup (its first run 5 minutes on) when a schedule is chosen and the destination is * ready. * * @param bool $force Whether to set it again when it is already scheduled. */ public static function ensure_schedule( bool $force = false ): void { $s = self::settings(); if ( 'manual' === $s['schedule'] || 'none' === $s['provider'] ) { return; }$conn = self::connection(); if ( empty( $conn['ready'] ) ) { return; }if ( ! $force && wp_next_scheduled( self::SCHEDULE_HOOK ) ) { return; }if ( $force ) { Reviewed_Store::wp_clear_scheduled_hook( self::SCHEDULE_HOOK ); }Reviewed_Store::wp_schedule_event( time() + 300, $s['schedule'], self::SCHEDULE_HOOK ); } /** * Remove the scheduled backup and its retry. */ public static function stop_schedule(): void { Reviewed_Store::wp_clear_scheduled_hook( self::SCHEDULE_HOOK ); Reviewed_Store::wp_clear_scheduled_hook( self::SCHEDULE_RETRY_HOOK ); } /** * The scheduled backup's hook: run it through the reviewed store's housekeeping, or try again in 15 minutes when Image * Manager is busy. */ public static function scheduled_background(): void { if ( Reviewed_Store::housekeeping( array( self::class, 'scheduled_run' ) ) ) { return; } // Same outcome as the existing "busy" branch: keep the scheduled backup instead of skipping a whole period. if ( ! wp_next_scheduled( self::SCHEDULE_RETRY_HOOK ) ) { wp_schedule_single_event( time() + 900, self::SCHEDULE_RETRY_HOOK ); } } /** * Start the scheduled backup with the saved profile, scope and password, unless the schedule is manual, the * destination is not ready, or another media job is running (then it retries in 15 minutes); the outcome is recorded. */ public static function scheduled_run(): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { $s = self::settings(); $st = self::schedule_status(); $st['last_attempt'] = time(); $st['last_error'] = ''; if ( 'manual' === $s['schedule'] ) { self::save_schedule_status( $st ); return; }if ( empty( self::connection()['ready'] ) ) { $st['last_error'] = 'remote_not_ready'; self::save_schedule_status( $st ); return; }if ( Backup::job()['active'] || self::job()['active'] || ! empty( Workflow::snapshot()['active'] ) ) { $st['last_error'] = 'remote_schedule_busy'; self::save_schedule_status( $st ); if ( ! wp_next_scheduled( self::SCHEDULE_RETRY_HOOK ) ) { Reviewed_Store::wp_schedule_single_event( time() + 900, self::SCHEDULE_RETRY_HOOK ); }return; }$payload = array( 'backup_profile' => $s['profile'], 'backup_scope' => $s['scope'], 'backup_scope_id' => $s['scope_id'], ); if ( $s['schedule_encrypt'] ) { $p = self::unseal( (string) $s['schedule_password'] ); if ( '' === $p ) { $st['last_error'] = 'remote_schedule_password_unavailable'; self::save_schedule_status( $st ); return; }$payload['backup_encrypt'] = 1; $payload['backup_password'] = $p; $payload['backup_password_confirm'] = $p; }try { self::start_backup( $payload ); $st['last_started'] = time(); $st['last_error'] = ''; } catch ( \Throwable $e ) { $st['last_error'] = sanitize_key( $e->getMessage() ); }self::save_schedule_status( $st ); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * On deactivation: remove the schedules and pause an active transfer. */ public static function deactivate(): void { self::stop_schedule(); self::stop_transfer_schedule(); $j = self::job(); if ( $j['active'] ) { $j['paused'] = true; self::save_job( $j ); } } /** * Whether a remote backup can be downloaded, restored or deleted from here: it is in the destination connected now. * * @param array $entry The remote backup. * @return bool True when it can. */ public static function can_manage_entry( array $entry ): bool { return self::current_destination_matches( $entry ); } /** * A provider's name. * * @param string $provider google or s3. * @return string Google Drive, S3-compatible storage, or Remote storage. */ public static function provider_label( string $provider ): string { return 'google' === $provider ? 'Google Drive' : ( 's3' === $provider ? __( 'S3-compatible storage', 'affstackdev-image-manager' ) : __( 'Remote storage', 'affstackdev-image-manager' ) ); } /** * Whether a remote backup is in the destination now configured, and that destination is ready. * * @param array $entry The remote backup. * @return bool True when it is. */ private static function current_destination_matches( array $entry ): bool { $s = self::settings(); return ( $entry['provider'] ?? '' ) === $s['provider'] && hash_equals( (string) ( $entry['destination_id'] ?? '' ), self::destination_id( (string) $entry['provider'], $s ) ) && ! empty( self::connection()['ready'] ); } /** * Delete a remote backup's file from Google Drive (a file already gone counts as deleted). * * @param array $entry The remote backup. * @throws \RuntimeException With remote_network_error or remote_delete_failed. */ private static function google_delete( array $entry ): void { $access = self::google_access_token(); $r = Reviewed_Store::wp_remote_request( 'https://www.googleapis.com/drive/v3/files/' . rawurlencode( (string) $entry['remote_ref'] ), array( 'method' => 'DELETE', 'timeout' => 20, 'redirection' => 0, 'sslverify' => true, 'headers' => array( 'Authorization' => 'Bearer ' . $access ), ) ); if ( is_wp_error( $r ) ) { throw new \RuntimeException( 'remote_network_error' ); }$code = (int) wp_remote_retrieve_response_code( $r ); if ( ! in_array( $code, array( 204, 404 ), true ) ) { throw new \RuntimeException( 'remote_delete_failed' ); } } /** * Delete a remote backup's object from S3 (an object already gone counts as deleted). * * @param array $entry The remote backup. */ private static function s3_delete( array $entry ): void { self::s3_request( 'DELETE', (string) $entry['remote_ref'], array(), array(), '', array( 204, 200, 404 ) ); } /** * Delete a remote backup, once confirmed, from the destination that holds it and from the list. * * @param string $id The remote backup's ID. * @param bool $confirm Whether the delete was confirmed. * @throws \RuntimeException With remote_delete_confirmation_required, remote_record_missing or * remote_destination_mismatch. */ public static function delete_remote( string $id, bool $confirm ): void { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { if ( ! $confirm ) { throw new \RuntimeException( 'remote_delete_confirmation_required' ); }$e = self::entry( $id ); if ( ! $e ) { throw new \RuntimeException( 'remote_record_missing' ); }if ( ! self::current_destination_matches( $e ) ) { throw new \RuntimeException( 'remote_destination_mismatch' ); }if ( 'google' === $e['provider'] ) { self::google_delete( $e ); } else { self::s3_delete( $e ); }self::save_index( array_values( array_filter( self::index(), static fn( $x ) => ! hash_equals( (string) $x['id'], $id ) ) ) ); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Delete a destination's oldest remote backups beyond the number to keep; it stops at the first one that cannot be * deleted. * * @param string $provider google or s3. * @param string $destination The destination's fingerprint. * @param int $retain How many to keep. */ private static function prune( string $provider, string $destination, int $retain ): void { $entries = array_values( array_filter( self::index(), fn( $e ) => ( $e['provider'] ?? '' ) === $provider && hash_equals( (string) ( $e['destination_id'] ?? '' ), $destination ) ) ); if ( count( $entries ) <= $retain ) { return; }foreach ( array_slice( $entries, $retain ) as $e ) { try { if ( ! self::current_destination_matches( $e ) ) { continue; }if ( 'google' === $provider ) { self::google_delete( $e ); } else { self::s3_delete( $e ); }self::save_index( array_values( array_filter( self::index(), static fn( $x ) => ! hash_equals( (string) $x['id'], (string) $e['id'] ) ) ) ); } catch ( \Throwable $ignored ) { break; } } } // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Checksum-verified private backup staging needs bounded binary streams and private file modes; paths are generated by Backup storage and customer destination checks precede transfer. /** * Download a remote backup to the private folder and check its size and SHA-256. * * @param array $entry The remote backup. * @return string The downloaded file's path. * @throws \RuntimeException With remote_destination_mismatch, backup_storage_unavailable, remote_download_failed or * remote_integrity_failed. */ private static function remote_download_file( array $entry ): string { if ( ! self::current_destination_matches( $entry ) ) { throw new \RuntimeException( 'remote_destination_mismatch' ); }$storage = Backup::storage_info( true ); if ( empty( $storage['available'] ) ) { throw new \RuntimeException( 'backup_storage_unavailable' ); }$tmp = $storage['path'] . '/remote-download-' . bin2hex( random_bytes( 12 ) ) . '.zip'; if ( 'google' === $entry['provider'] ) { $access = self::google_access_token(); $url = 'https://www.googleapis.com/drive/v3/files/' . rawurlencode( (string) $entry['remote_ref'] ) . '?alt=media'; $r = Reviewed_Store::wp_remote_get( $url, array( 'timeout' => 120, 'redirection' => 0, 'sslverify' => true, 'stream' => true, 'filename' => $tmp, 'headers' => array( 'Authorization' => 'Bearer ' . $access ), ) ); if ( is_wp_error( $r ) || 200 !== (int) wp_remote_retrieve_response_code( $r ) ) { @Reviewed_Store::unlink( $tmp ); throw new \RuntimeException( 'remote_download_failed' ); } } else { self::s3_download_key( (string) $entry['remote_ref'], $tmp ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod,WordPress.WP.AlternativeFunctions.unlink_unlink -- Checksum-verified private backup staging needs bounded binary streams and private file modes; paths are generated by Backup storage and customer destination checks precede transfer. if ( ! is_file( $tmp ) || (int) filesize( $tmp ) !== (int) $entry['bytes'] || ! hash_equals( (string) $entry['sha256'], (string) hash_file( 'sha256', $tmp ) ) ) { @Reviewed_Store::unlink( $tmp ); throw new \RuntimeException( 'remote_integrity_failed' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Checksum-verified private backup staging needs bounded binary streams and private file modes; paths are generated by Backup storage and customer destination checks precede transfer. }@chmod( $tmp, 0600 ); return $tmp; } // Binary ZIP attachment with nosniff, exact length and verified checksum; HTML escaping would corrupt the archive. // Admin::action() checks capability and nonce. Checksum-verified private backup staging needs bounded binary // streams and private file modes; paths are generated by Backup storage and customer destination checks precede // transfer. // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped,WordPress.WP.AlternativeFunctions.file_system_operations_fclose,WordPress.WP.AlternativeFunctions.file_system_operations_fopen,WordPress.WP.AlternativeFunctions.file_system_operations_fread,WordPress.WP.AlternativeFunctions.unlink_unlink -- see the note above. /** * Download a remote backup, checked, and send it to the browser; then end the request. * * @param string $id The remote backup's ID. * @throws \RuntimeException With remote_record_missing or remote_download_failed. */ public static function stream_remote_download( string $id ): void { $e = self::entry( $id ); if ( ! $e ) { throw new \RuntimeException( 'remote_record_missing' ); }$tmp = self::remote_download_file( $e ); nocache_headers(); header( 'Content-Type: application/zip' ); header( 'X-Content-Type-Options: nosniff' ); header( 'Content-Disposition: attachment; filename="' . sanitize_file_name( (string) $e['name'] ) . '"' ); header( 'Content-Length: ' . (string) filesize( $tmp ) ); // Binary ZIP attachment with nosniff, exact length and verified checksum; HTML escaping would corrupt the // archive. Admin::action() checks capability and nonce. Checksum-verified private backup staging needs bounded // binary streams and private file modes; paths are generated by Backup storage and customer destination checks // precede transfer. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- see the note above. $fh = fopen( $tmp, 'rb' ); if ( ! $fh ) { @Reviewed_Store::unlink( $tmp ); throw new \RuntimeException( 'remote_download_failed' ); }while ( ! feof( $fh ) ) { // Binary ZIP attachment with nosniff, exact length and verified checksum; HTML escaping would corrupt the // archive. Admin::action() checks capability and nonce. Checksum-verified private backup staging needs // bounded binary streams and private file modes; paths are generated by Backup storage and customer // destination checks precede transfer. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- see the note above. $chunk = fread( $fh, 1048576 ); if ( false === $chunk ) { break; // Binary ZIP attachment with nosniff, exact length and verified checksum; HTML escaping would corrupt // the archive. Admin::action() checks capability and nonce. Checksum-verified private backup staging // needs bounded binary streams and private file modes; paths are generated by Backup storage and // customer destination checks precede transfer. // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- see the note above. }echo $chunk; // Binary ZIP attachment with nosniff, exact length and verified checksum; HTML escaping would corrupt the // archive. Admin::action() checks capability and nonce. Checksum-verified private backup staging needs // bounded binary streams and private file modes; paths are generated by Backup storage and customer // destination checks precede transfer. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- see the note above. }fclose( $fh ); @Reviewed_Store::unlink( $tmp ); exit; } // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Checksum-verified private backup staging needs bounded binary streams and private file modes; paths are generated by Backup storage and customer destination checks precede transfer. /** * Bring a remote backup back as a local backup: download it, check it and import it. * * @param string $id The remote backup's ID. * @param string $password Its password ('' when it is not encrypted). * @return array The local backup's list entry. * @throws \RuntimeException With remote_record_missing for an unknown remote backup. */ public static function fetch_local( string $id, string $password = '' ): array { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { $e = self::entry( $id ); if ( ! $e ) { throw new \RuntimeException( 'remote_record_missing' ); }$tmp = self::remote_download_file( $e ); try { return Backup::import_file_path( $tmp, $password, 'remote-' . $e['provider'] ); } finally { @Reviewed_Store::unlink( $tmp ); } } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * The words for a remote backup reason code. * * @param string $code The reason code. * @return string The translated words. */ public static function human_error( string $code ): string { $map = array( 'remote_provider_invalid' => __( 'Choose Local only, Google Drive, or S3-compatible storage.', 'affstackdev-image-manager' ), 'remote_schedule_invalid' => __( 'Choose Manual, Daily, or Weekly scheduling.', 'affstackdev-image-manager' ), 'remote_secret_storage_unavailable' => __( 'This host cannot safely store remote credentials in WordPress. Use server configuration constants or enable OpenSSL support.', 'affstackdev-image-manager' ), 'remote_google_credentials_missing' => __( 'Save a Google OAuth client ID and client secret before connecting Drive.', 'affstackdev-image-manager' ), 'remote_google_not_connected' => __( 'Google Drive is not connected.', 'affstackdev-image-manager' ), 'remote_google_token_missing' => __( 'Google did not provide the refresh credentials required for scheduled backups. Reconnect and grant access.', 'affstackdev-image-manager' ), 'remote_google_token_failed' => __( 'Google Drive authorization could not be refreshed. Reconnect the destination.', 'affstackdev-image-manager' ), 'remote_google_folder_failed' => __( 'Image Manager could not create its dedicated Google Drive backup folder.', 'affstackdev-image-manager' ), 'remote_google_upload_failed' => __( 'The Google Drive upload could not continue. Saved local backup data is unchanged.', 'affstackdev-image-manager' ), 'remote_google_session_invalid' => __( 'Google Drive returned an unexpected upload session address, so the transfer was refused.', 'affstackdev-image-manager' ), 'remote_s3_credentials_missing' => __( 'Enter the S3 endpoint, bucket, access key, and secret key, then test the connection.', 'affstackdev-image-manager' ), 'remote_s3_bucket_invalid' => __( 'The S3 bucket name is invalid.', 'affstackdev-image-manager' ), 'remote_endpoint_invalid' => __( 'Use a public HTTPS S3-compatible endpoint with no embedded credentials, path, query, or local IP address.', 'affstackdev-image-manager' ), 'remote_s3_multipart_failed' => __( 'The S3-compatible multipart upload could not continue.', 'affstackdev-image-manager' ), 'remote_not_ready' => __( 'Connect and verify a remote destination before sending a backup.', 'affstackdev-image-manager' ), 'remote_job_active' => __( 'A backup or remote transfer is already active. Finish or pause it first.', 'affstackdev-image-manager' ), 'remote_network_error' => __( 'The remote provider could not be reached. Saved progress is retained for retry.', 'affstackdev-image-manager' ), 'remote_transient_http' => __( 'The remote provider returned a temporary error. Saved progress will retry with backoff.', 'affstackdev-image-manager' ), 'remote_auth_failed' => __( 'The remote provider rejected the saved credentials. Reconnect or update them.', 'affstackdev-image-manager' ), 'remote_http_failed' => __( 'The remote provider returned an unexpected response. Existing local backups remain intact.', 'affstackdev-image-manager' ), 'remote_integrity_failed' => __( 'The remote copy did not pass the recorded size/checksum verification. The local backup remains available.', 'affstackdev-image-manager' ), 'remote_local_archive_missing' => __( 'The local portable snapshot needed for this upload is unavailable or changed.', 'affstackdev-image-manager' ), 'remote_delete_confirmation_required' => __( 'Confirm before deleting the remote backup copy. Local media and other backups are not deleted.', 'affstackdev-image-manager' ), 'remote_delete_failed' => __( 'The remote provider did not confirm deletion. The backup remains listed for review.', 'affstackdev-image-manager' ), 'remote_record_missing' => __( 'That remote backup record is unavailable.', 'affstackdev-image-manager' ), 'remote_destination_mismatch' => __( 'Reconnect the same remote destination that owns this backup before downloading, restoring, or deleting it through Image Manager.', 'affstackdev-image-manager' ), 'remote_download_failed' => __( 'The remote backup could not be downloaded to private staging.', 'affstackdev-image-manager' ), 'remote_schedule_password_required' => __( 'Enter and confirm the scheduled-backup password, or turn scheduled archive encryption off.', 'affstackdev-image-manager' ), 'remote_schedule_password_unavailable' => __( 'The saved scheduled-backup password can no longer be opened with this site’s current secrets. Set the password again.', 'affstackdev-image-manager' ), 'remote_schedule_busy' => __( 'The scheduled backup waited because another Image Manager media job was active; a retry is scheduled.', 'affstackdev-image-manager' ), ); return $map[ $code ] ?? __( 'The remote backup action could not finish. Local media and completed snapshots remain unchanged.', 'affstackdev-image-manager' ); } }