Plugin Directory

Changeset 3740977


Ignore:
Timestamp:
10/12/2026 09:34:26 AM (6 days ago)
Author:
Dhruvin
Message:

1.7.7 release

Location:
woo-addon-uploads
Files:
16 added
5 edited

Legend:

Unmodified
Added
Removed
  • woo-addon-uploads/trunk/i18n/languages/woo-addon-uploads.pot

    r3714269 r3740977  
    33msgid ""
    44msgstr ""
    5 "Project-Id-Version: File Uploads Addon for WooCommerce 1.7.6\n"
     5"Project-Id-Version: File Uploads Addon for WooCommerce 1.7.7\n"
    66"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/woo-addon-uploads\n"
    77"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
    … …  
    8888
    8989#: includes/class-wau-admin.php:135
    90 msgid "Private upload storage is unavailable, so new customer uploads are paused."
     90msgid "Private and encrypted storage are unavailable, so new customer uploads are paused."
    9191msgstr ""
    9292
    … …  
    212212msgstr ""
    213213
    214 #: includes/class-wau-admin.php:226
    215 msgid "No immediate action is required. New uploads are using private storage and legacy files are either migrated or not present."
     214#: includes/class-wau-admin.php:228
     215msgid "No immediate security action is required. New uploads are using private storage or authenticated encrypted storage, and no public plaintext customer files remain."
    216216msgstr ""
    217217
    … …  
    237237
    238238#. translators: %s: PHP constant example.
    239 #: includes/class-wau-admin.php:331
    240 msgid "New customer uploads are paused because private storage is unavailable. Ask your host for a writable private directory outside the public web root, then add %s to wp-config.php with that absolute path."
     239#: includes/class-wau-admin.php:350
     240msgid "New customer uploads are paused because neither private nor encrypted storage is available. Ask your host for a writable private directory outside the public web root, then add %s to wp-config.php with that absolute path."
    241241msgstr ""
    242242
    … …  
    259259msgstr ""
    260260
    261 #: includes/class-wau-admin.php:361
    262 msgid "Legacy files are still being migrated to private storage in small batches. Keep the plugin active and allow normal site traffic or admin page visits to continue the migration."
     261#: includes/class-wau-admin.php:380
     262msgid "Public plaintext files are still being migrated in small batches. Keep the plugin active and allow normal site traffic or admin page visits to continue the migration."
    263263msgstr ""
    264264
    … …  
    283283msgstr ""
    284284
    285 #: includes/class-wau-admin.php:451
    286 msgid "Waiting for private storage"
     285#: includes/class-wau-admin.php:520
     286msgid "Blocked: no secure storage backend is available"
    287287msgstr ""
    288288
    … …  
    331331msgstr ""
    332332
    333 #: includes/class-wau-front-end.php:891
    334 msgid "File upload is temporarily unavailable because private upload storage could not be created. Please contact the store administrator."
     333#: includes/class-wau-front-end.php:1433
     334msgid "File upload is temporarily unavailable because secure upload storage could not be created. Please contact the store administrator."
    335335msgstr ""
    336336
    … …  
    373373msgid "File does not exist."
    374374msgstr ""
     375
     376#: includes/class-wau-admin.php:190
     377msgid "Encrypted fallback storage"
     378msgstr ""
     379
     380#: includes/class-wau-admin.php:191
     381msgid "Encrypted container count"
     382msgstr ""
     383
     384#: includes/class-wau-admin.php:196
     385msgid "Public plaintext file count"
     386msgstr ""
     387
     388#: includes/class-wau-admin.php:290
     389msgid "Available with authenticated encryption"
     390msgstr ""
     391
     392#: includes/class-wau-admin.php:290
     393msgid "Unavailable: encryption key is missing"
     394msgstr ""
     395
     396#: includes/class-wau-admin.php:344
     397msgid "Encrypted upload containers exist, but their encryption key is missing from the WordPress database. Restore the wau_encryption_key option from a matching database backup. Do not delete the encrypted files or generate a replacement key."
     398msgstr ""
     399
     400#: includes/class-wau-front-end.php:1457
     401msgid "File upload failed while creating secure encrypted storage. Please try again."
     402msgstr ""
     403
     404#: includes/class-wau-front-end.php:2163
     405msgid "The encrypted file could not be authenticated."
     406msgstr ""
  • woo-addon-uploads/trunk/includes/class-wau-admin.php

    r3714269 r3740977  
    132132                                                        <?php esc_html_e( 'Legacy customer files are still present in the public uploads folder. Confirm that direct requests to the path below return 403 or 404 until migration is complete.', 'woo-addon-uploads' ); ?>
    133133                                                </p>
    134                                                 <?php if ( ! $status['private_storage_ok'] ) : ?>
    135                                                         <p><?php esc_html_e( 'Private upload storage is unavailable, so new customer uploads are paused.', 'woo-addon-uploads' ); ?></p>
     134                                                <?php if ( ! $status['private_storage_ok'] && ! $status['encrypted_storage_ok'] ) : ?>
     135                                                        <p><?php esc_html_e( 'Private and encrypted storage are unavailable, so new customer uploads are paused.', 'woo-addon-uploads' ); ?></p>
    136136                                                <?php endif; ?>
    137137                                                <?php if ( $status['uses_nginx_location_rules'] ) : ?>
    … …  
    188188                                                        <?php $this->render_status_row( __( 'Private upload storage', 'woo-addon-uploads' ), $status['private_storage_label'], $status['private_storage_ok'] ? 'success' : 'warning' ); ?>
    189189                                                        <?php $this->render_status_row( __( 'Private upload path', 'woo-addon-uploads' ), $status['private_path'] ? $status['private_path'] : __( 'Not available', 'woo-addon-uploads' ), $status['private_storage_ok'] ? 'info' : 'warning' ); ?>
     190                                                        <?php $this->render_status_row( __( 'Encrypted fallback storage', 'woo-addon-uploads' ), $status['encrypted_storage_label'], $status['encrypted_storage_ok'] ? 'success' : 'warning' ); ?>
     191                                                        <?php $this->render_status_row( __( 'Encrypted container count', 'woo-addon-uploads' ), (string) $status['encrypted_file_count'], 'info' ); ?>
    190192                                                        <?php $this->render_status_row( __( 'Manual override', 'woo-addon-uploads' ), "define( 'WAU_PRIVATE_UPLOAD_DIR', '/absolute/private/path/' );", 'info' ); ?>
    191193                                                        <?php $this->render_status_row( __( 'Legacy upload path', 'woo-addon-uploads' ), $status['legacy_path'], 'info' ); ?>
    192194                                                        <?php $this->render_status_row( __( 'Legacy upload URL path', 'woo-addon-uploads' ), $status['legacy_url_path'], 'info' ); ?>
    193195                                                        <?php $this->render_status_row( __( 'Legacy folder exists', 'woo-addon-uploads' ), $status['legacy_exists'] ? __( 'Yes', 'woo-addon-uploads' ) : __( 'No', 'woo-addon-uploads' ), $status['legacy_exists'] ? 'info' : 'success' ); ?>
    194                                                         <?php $this->render_status_row( __( 'Legacy file count', 'woo-addon-uploads' ), (string) $status['legacy_file_count'], $status['legacy_file_count'] > 0 ? 'warning' : 'success' ); ?>
     196                                                        <?php $this->render_status_row( __( 'Public plaintext file count', 'woo-addon-uploads' ), (string) $status['legacy_file_count'], $status['legacy_file_count'] > 0 ? 'warning' : 'success' ); ?>
    195197                                                </tbody>
    196198                                        </table>
    … …  
    224226                                                        </ul>
    225227                                                <?php else : ?>
    226                                                         <p><?php esc_html_e( 'No immediate action is required. New uploads are using private storage and legacy files are either migrated or not present.', 'woo-addon-uploads' ); ?></p>
     228                                                        <p><?php esc_html_e( 'No immediate security action is required. New uploads are using private storage or authenticated encrypted storage, and no public plaintext customer files remain.', 'woo-addon-uploads' ); ?></p>
    227229                                                <?php endif; ?>
    228230                                        </div>
    … …  
    254256                        $legacy_url_path  = trailingslashit( $baseurl_path ? $baseurl_path : '/wp-content/uploads' ) . 'wau-uploads/';
    255257                        $legacy_exists    = $legacy_path && is_dir( $legacy_path );
     258                        $encrypted_path   = $legacy_path ? trailingslashit( $legacy_path ) . 'encrypted/' . $this->get_site_storage_key() . '/' : '';
    256259                        $private_status   = get_option( 'wau_private_uploads_available' );
    257260                        $private_path     = get_option( 'wau_private_uploads_path' );
    … …  
    265268                        $server_unknown     = ( empty( $server_lower ) || ( ! $uses_nginx_rules && ! $uses_openlitespeed && ! $uses_caddy && ! $uses_apache ) );
    266269                        $legacy_count     = $legacy_exists ? $this->count_legacy_upload_files( $legacy_path ) : 0;
     270                        $encrypted_count  = $encrypted_path && is_dir( $encrypted_path ) ? $this->count_encrypted_upload_files( $encrypted_path ) : 0;
     271                        $encryption_ok    = $this->encrypted_fallback_is_available( $legacy_path, $encrypted_path, $upload_dir );
     272                        $encoded_key      = get_option( 'wau_encryption_key', '' );
     273                        $encryption_key   = is_string( $encoded_key ) ? base64_decode( $encoded_key, true ) : false;
     274                        $key_missing      = $encrypted_count > 0 && ( ! is_string( $encryption_key ) || 32 !== strlen( $encryption_key ) );
     275                        if ( $key_missing ) {
     276                                $encryption_ok = false;
     277                        }
    267278                        $htaccess_status  = $this->get_protection_file_status( $legacy_path . '.htaccess', $this->get_expected_htaccess_content(), $legacy_exists );
    268279                        $web_conf_status  = $this->get_protection_file_status( $legacy_path . 'web.config', $this->get_expected_web_config_content(), $legacy_exists );
    … …  
    276287                                'private_storage_label'    => $private_ok ? __( 'Available and writable', 'woo-addon-uploads' ) : __( 'Not available or not writable', 'woo-addon-uploads' ),
    277288                                'private_path'             => $private_path,
     289                                'encrypted_storage_ok'     => $encryption_ok,
     290                                'encrypted_storage_label'  => $encryption_ok ? __( 'Available with authenticated encryption', 'woo-addon-uploads' ) : ( $key_missing ? __( 'Unavailable: encryption key is missing', 'woo-addon-uploads' ) : __( 'Unavailable', 'woo-addon-uploads' ) ),
     291                                'encrypted_file_count'     => $encrypted_count,
     292                                'encryption_key_missing'   => $key_missing,
    278293                                'legacy_path'              => $legacy_path ? $legacy_path : __( 'Unavailable', 'woo-addon-uploads' ),
    279294                                'legacy_url_path'          => $legacy_url_path,
    … …  
    288303                                'legacy_verification_needed' => $verify_needed,
    289304                                'uses_nginx_location_rules' => $uses_nginx_rules,
    290                                 'migration_complete'       => ( 0 === $legacy_count && $private_ok ),
    291                                 'migration_label'          => $this->get_migration_label( $private_ok, $legacy_count ),
     305                                'migration_complete'       => ( 0 === $legacy_count ),
     306                                'migration_label'          => $this->get_migration_label( $private_ok, $encryption_ok, $legacy_count ),
    292307                                'migration_batch_size'     => $batch_size,
    293                                 'has_required_action'      => ( ! $private_ok || $server_needed || $verify_needed || ( $legacy_exists && ( ! $htaccess_status['ok'] || ! $web_conf_status['ok'] ) ) || ( $private_ok && $legacy_count > 0 ) ),
     308                                'has_required_action'      => ( ( ! $private_ok && ! $encryption_ok ) || $server_needed || $verify_needed || ( $legacy_exists && ( ! $htaccess_status['ok'] || ! $web_conf_status['ok'] ) ) || $legacy_count > 0 ),
    294309                        );
    295310                }
    … …  
    326341                        $items = array();
    327342
    328                         if ( ! $status['private_storage_ok'] ) {
     343                        if ( $status['encryption_key_missing'] ) {
     344                                $items[] = __( 'Encrypted upload containers exist, but their encryption key is missing from the WordPress database. Restore the wau_encryption_key option from a matching database backup. Do not delete the encrypted files or generate a replacement key.', 'woo-addon-uploads' );
     345                        }
     346
     347                        if ( ! $status['private_storage_ok'] && ! $status['encrypted_storage_ok'] ) {
    329348                                $items[] = sprintf(
    330349                                        /* translators: %s: PHP constant example. */
    331                                         __( 'New customer uploads are paused because private storage is unavailable. Ask your host for a writable private directory outside the public web root, then add %s to wp-config.php with that absolute path.', 'woo-addon-uploads' ),
     350                                        __( 'New customer uploads are paused because neither private nor encrypted storage is available. Ask your host for a writable private directory outside the public web root, then add %s to wp-config.php with that absolute path.', 'woo-addon-uploads' ),
    332351                                        '<code>define( \'WAU_PRIVATE_UPLOAD_DIR\', \'/absolute/private/path/\' );</code>'
    333352                                );
    … …  
    358377                        }
    359378
    360                         if ( $status['private_storage_ok'] && $status['legacy_file_count'] > 0 && ! $status['migration_complete'] ) {
    361                                 $items[] = __( 'Legacy files are still being migrated to private storage in small batches. Keep the plugin active and allow normal site traffic or admin page visits to continue the migration.', 'woo-addon-uploads' );
     379                        if ( ( $status['private_storage_ok'] || $status['encrypted_storage_ok'] ) && $status['legacy_file_count'] > 0 && ! $status['migration_complete'] ) {
     380                                $items[] = __( 'Public plaintext files are still being migrated in small batches. Keep the plugin active and allow normal site traffic or admin page visits to continue the migration.', 'woo-addon-uploads' );
    362381                        }
    363382
    … …  
    385404                                }
    386405
    387                                 if ( in_array( strtolower( $file_info->getFilename() ), array( '.htaccess', 'web.config', 'index.php' ), true ) ) {
     406                                if ( in_array( strtolower( $file_info->getFilename() ), array( '.htaccess', 'web.config', 'index.php' ), true ) || '.wau-pending' === substr( $file_info->getFilename(), -12 ) ) {
    388407                                        continue;
    389408                                }
    … …  
    393412
    394413                        return $count;
     414                }
     415
     416                /**
     417                 * Count authenticated encrypted containers.
     418                 *
     419                 * @param string $encrypted_path Encrypted fallback path.
     420                 * @return int
     421                 */
     422                private function count_encrypted_upload_files( $encrypted_path ) {
     423                        $count = 0;
     424
     425                        try {
     426                                $directory = new DirectoryIterator( $encrypted_path );
     427                        } catch ( Exception $exception ) {
     428                                return 0;
     429                        }
     430
     431                        foreach ( $directory as $file_info ) {
     432                                if ( ! $file_info->isDot() && $file_info->isFile() && '.wau' === strtolower( substr( $file_info->getFilename(), -4 ) ) ) {
     433                                        $count++;
     434                                }
     435                        }
     436
     437                        return $count;
     438                }
     439
     440                /**
     441                 * Check whether the host can use authenticated encrypted fallback storage.
     442                 *
     443                 * @param string $legacy_path    Legacy storage path.
     444                 * @param string $encrypted_path Encrypted fallback path.
     445                 * @param array  $upload_dir     WordPress upload directory data.
     446                 * @return bool
     447                 */
     448                private function encrypted_fallback_is_available( $legacy_path, $encrypted_path, $upload_dir ) {
     449                        $sodium_available = function_exists( 'sodium_crypto_secretstream_xchacha20poly1305_init_push' )
     450                                && function_exists( 'sodium_crypto_secretstream_xchacha20poly1305_init_pull' );
     451                        $openssl_available = function_exists( 'openssl_encrypt' )
     452                                && function_exists( 'openssl_decrypt' )
     453                                && in_array( 'aes-256-gcm', openssl_get_cipher_methods(), true );
     454
     455                        if ( ! $sodium_available && ! $openssl_available ) {
     456                                return false;
     457                        }
     458
     459                        if ( $encrypted_path && is_dir( $encrypted_path ) ) {
     460                                return wp_is_writable( $encrypted_path );
     461                        }
     462
     463                        if ( $legacy_path && is_dir( $legacy_path ) ) {
     464                                return wp_is_writable( $legacy_path );
     465                        }
     466
     467                        return empty( $upload_dir['error'] ) && is_dir( $upload_dir['basedir'] ) && wp_is_writable( $upload_dir['basedir'] );
     468                }
     469
     470                /**
     471                 * Build the same site-specific storage key used by the upload handler.
     472                 *
     473                 * @return string
     474                 */
     475                private function get_site_storage_key() {
     476                        $home_url = home_url( '/' );
     477                        $host     = wp_parse_url( $home_url, PHP_URL_HOST );
     478                        $host     = $host ? strtolower( $host ) : 'site';
     479                        $host     = preg_replace( '/[^a-z0-9._-]/', '-', $host );
     480                        $host     = str_replace( '.', '-', $host );
     481                        $blog_id  = function_exists( 'get_current_blog_id' ) ? get_current_blog_id() : 1;
     482                        $hash     = substr( hash( 'sha256', $home_url . '|' . ABSPATH . '|' . $blog_id ), 0, 12 );
     483
     484                        return sanitize_file_name( $host . '-' . $blog_id . '-' . $hash );
    395485                }
    396486
    … …  
    443533                 * Get migration label.
    444534                 *
    445                  * @param bool $private_ok   Whether private storage is available.
    446                  * @param int  $legacy_count Legacy file count.
     535                 * @param bool $private_ok    Whether private storage is available.
     536                 * @param bool $encryption_ok Whether encrypted fallback is available.
     537                 * @param int  $legacy_count  Legacy file count.
    447538                 * @return string
    448539                 */
    449                 private function get_migration_label( $private_ok, $legacy_count ) {
    450                         if ( ! $private_ok ) {
    451                                 return __( 'Waiting for private storage', 'woo-addon-uploads' );
     540                private function get_migration_label( $private_ok, $encryption_ok, $legacy_count ) {
     541                        if ( ! $private_ok && ! $encryption_ok ) {
     542                                return __( 'Blocked: no secure storage backend is available', 'woo-addon-uploads' );
    452543                        }
    453544
  • woo-addon-uploads/trunk/includes/class-wau-front-end.php

    r3714269 r3740977  
    2525        class wau_front_end_class {
    2626
     27                const WAU_ENCRYPTED_FILE_MAGIC = 'WAUENC01';
     28                const WAU_ENCRYPTED_CHUNK_SIZE = 1048576;
     29
    2730                /**
    2831                 * Resolved private upload directory for the current request.
    … …  
    4851
    4952                        add_filter( 'wau_category_checks', array( $this, 'wau_check_category_allowed' ), 10, 2 );
    50 
    5153                        add_action( 'woocommerce_cart_item_removed', array( $this, 'wau_remove_cart_action' ), 10, 2 );
    5254
    … …  
    166168
    167169                /**
    168                  * Move legacy public uploads into private storage in small batches.
     170                 * Move legacy public uploads into private storage or encrypt them in place.
    169171                 *
    170172                 * @since 1.7.5
    … …  
    176178                        $legacy_upload_dir  = $this->wau_get_legacy_upload_directory();
    177179
    178                         if ( empty( $private_upload_dir['path'] ) || empty( $legacy_upload_dir['path'] ) ) {
     180                        if ( empty( $legacy_upload_dir['path'] ) || ! is_dir( $legacy_upload_dir['path'] ) ) {
    179181                                return;
    180182                        }
    181183
    182                         $private_path = $private_upload_dir['path'];
     184                        if ( get_transient( 'wau_legacy_upload_migration_lock' ) ) {
     185                                return;
     186                        }
     187
     188                        set_transient( 'wau_legacy_upload_migration_lock', 1, 5 * MINUTE_IN_SECONDS );
     189
     190                        $private_path = ! empty( $private_upload_dir['path'] ) ? $private_upload_dir['path'] : '';
    183191                        $legacy_path  = $legacy_upload_dir['path'];
    184192
    185                         if ( $this->wau_paths_match( $private_path, $legacy_path ) || ! is_dir( $legacy_path ) ) {
     193                        if ( $private_path && $this->wau_paths_match( $private_path, $legacy_path ) ) {
    186194                                update_option( 'wau_legacy_upload_migration_complete', current_time( 'mysql' ), false );
     195                                delete_transient( 'wau_legacy_upload_migration_lock' );
    187196                                return;
    188197                        }
    … …  
    195204                        WP_Filesystem();
    196205
    197                         if ( ! $wp_filesystem || ! $this->wau_prepare_upload_directory( $private_path ) ) {
     206                        $encrypted_path = '';
     207                        if ( empty( $private_path ) ) {
     208                                $encrypted_dir  = $this->wau_get_encrypted_upload_directory( true );
     209                                $encrypted_path = $encrypted_dir['path'];
     210                        }
     211
     212                        if ( ( $private_path && ! $wp_filesystem ) || ( empty( $private_path ) && empty( $encrypted_path ) ) ) {
     213                                delete_transient( 'wau_legacy_upload_migration_lock' );
    198214                                return;
    199215                        }
    … …  
    207223                                $directory = new DirectoryIterator( $legacy_path );
    208224                        } catch ( Exception $exception ) {
     225                                delete_transient( 'wau_legacy_upload_migration_lock' );
    209226                                return;
    210227                        }
    … …  
    217234                                $file_name = $file_info->getFilename();
    218235
    219                                 if ( in_array( strtolower( $file_name ), array( '.htaccess', 'web.config', 'index.php' ), true ) ) {
     236                                if ( in_array( strtolower( $file_name ), array( '.htaccess', 'web.config', 'index.php' ), true ) || '.wau-pending' === substr( $file_name, -12 ) ) {
    220237                                        continue;
    221238                                }
    … …  
    226243                                }
    227244
    228                                 $source      = trailingslashit( $legacy_path ) . $file_name;
     245                                $source = trailingslashit( $legacy_path ) . $file_name;
     246
     247                                if ( empty( $private_path ) ) {
     248                                        if ( $this->wau_encrypt_and_verify_file( $source, $file_name ) ) {
     249                                                wp_delete_file( $source );
     250                                                wp_delete_file( $source . '.wau-pending' );
     251                                                $migrated++;
     252                                        } else {
     253                                                $remaining = true;
     254                                        }
     255                                        continue;
     256                                }
     257
    229258                                $destination = trailingslashit( $private_path ) . $file_name;
    230259
    … …  
    251280                                update_option( 'wau_legacy_upload_migration_complete', current_time( 'mysql' ), false );
    252281                        }
     282
     283                        delete_transient( 'wau_legacy_upload_migration_lock' );
    253284                }
    254285
    … …  
    335366
    336367                /**
     368                 * Get the public directory used only for authenticated encrypted containers.
     369                 *
     370                 * The files in this directory contain authenticated ciphertext and use opaque
     371                 * names. Plaintext is never written here.
     372                 *
     373                 * @param bool $create Whether to create and protect the directory.
     374                 * @return array
     375                 */
     376                private function wau_get_encrypted_upload_directory( $create = false ) {
     377                        $legacy_dir = $this->wau_get_legacy_upload_directory();
     378
     379                        if ( empty( $legacy_dir['path'] ) || ! $this->wau_encryption_is_available() ) {
     380                                return array( 'path' => '', 'url' => '' );
     381                        }
     382
     383                        $site_key  = $this->wau_get_site_storage_key();
     384                        $directory = trailingslashit( $legacy_dir['path'] ) . 'encrypted/' . $site_key . '/';
     385
     386                        if ( $create ) {
     387                                $this->wau_ensure_upload_directory_protection( true );
     388
     389                                if ( ! $this->wau_prepare_upload_directory( $directory ) || ! $this->wau_get_encryption_key( true ) ) {
     390                                        return array( 'path' => '', 'url' => '' );
     391                                }
     392
     393                                $this->wau_write_storage_protection_files( $directory );
     394                        }
     395
     396                        return array(
     397                                'path' => $directory,
     398                                'url'  => trailingslashit( $legacy_dir['url'] ) . 'encrypted/' . $site_key . '/',
     399                        );
     400                }
     401
     402                /**
     403                 * Check whether an authenticated encryption implementation is available.
     404                 *
     405                 * @return bool
     406                 */
     407                private function wau_encryption_is_available() {
     408                        $sodium_available = function_exists( 'sodium_crypto_secretstream_xchacha20poly1305_init_push' )
     409                                && function_exists( 'sodium_crypto_secretstream_xchacha20poly1305_init_pull' );
     410                        $openssl_available = function_exists( 'openssl_encrypt' )
     411                                && function_exists( 'openssl_decrypt' )
     412                                && in_array( 'aes-256-gcm', openssl_get_cipher_methods(), true );
     413
     414                        return $sodium_available || $openssl_available;
     415                }
     416
     417                /**
     418                 * Get or create this site's encryption key.
     419                 *
     420                 * @param bool $create Whether to create a key when no encrypted files exist.
     421                 * @return string|false Raw 32-byte key, or false.
     422                 */
     423                private function wau_get_encryption_key( $create = false ) {
     424                        $encoded_key = get_option( 'wau_encryption_key', '' );
     425                        $key         = is_string( $encoded_key ) ? base64_decode( $encoded_key, true ) : false;
     426
     427                        if ( is_string( $key ) && 32 === strlen( $key ) ) {
     428                                return $key;
     429                        }
     430
     431                        if ( ! $create ) {
     432                                return false;
     433                        }
     434
     435                        $legacy_dir    = $this->wau_get_legacy_upload_directory();
     436                        $encrypted_dir = ! empty( $legacy_dir['path'] ) ? trailingslashit( $legacy_dir['path'] ) . 'encrypted/' . $this->wau_get_site_storage_key() . '/' : '';
     437                        if ( $encrypted_dir && is_dir( $encrypted_dir ) ) {
     438                                try {
     439                                        $directory = new DirectoryIterator( $encrypted_dir );
     440                                        foreach ( $directory as $file_info ) {
     441                                                if ( ! $file_info->isDot() && $file_info->isFile() && '.wau' === strtolower( substr( $file_info->getFilename(), -4 ) ) ) {
     442                                                        return false;
     443                                                }
     444                                        }
     445                                } catch ( Exception $exception ) {
     446                                        return false;
     447                                }
     448                        }
     449
     450                        try {
     451                                $new_key = random_bytes( 32 );
     452                        } catch ( Throwable $exception ) {
     453                                return false;
     454                        }
     455
     456                        $encoded_new_key = base64_encode( $new_key );
     457                        if ( ! add_option( 'wau_encryption_key', $encoded_new_key, '', false ) ) {
     458                                $existing_key = get_option( 'wau_encryption_key', '' );
     459                                $decoded_key  = is_string( $existing_key ) ? base64_decode( $existing_key, true ) : false;
     460
     461                                if ( is_string( $decoded_key ) && 32 === strlen( $decoded_key ) ) {
     462                                        return $decoded_key;
     463                                }
     464
     465                                update_option( 'wau_encryption_key', $encoded_new_key, false );
     466                        }
     467                        $encoded_key = get_option( 'wau_encryption_key', '' );
     468                        $key         = is_string( $encoded_key ) ? base64_decode( $encoded_key, true ) : false;
     469
     470                        return is_string( $key ) && 32 === strlen( $key ) ? $key : false;
     471                }
     472
     473                /**
     474                 * Resolve the opaque encrypted-container path for a logical filename.
     475                 *
     476                 * @param string $file_name        Logical filename stored in cart/order data.
     477                 * @param bool   $create_directory Whether to create the fallback directory.
     478                 * @return string|false
     479                 */
     480                private function wau_get_encrypted_file_path( $file_name, $create_directory = false ) {
     481                        $safe_filename = sanitize_file_name( basename( $file_name ) );
     482                        $directory     = $this->wau_get_encrypted_upload_directory( $create_directory );
     483                        $key           = $this->wau_get_encryption_key( $create_directory );
     484
     485                        if ( empty( $safe_filename ) || empty( $directory['path'] ) || ! $key ) {
     486                                return false;
     487                        }
     488
     489                        $opaque_name = substr( hash_hmac( 'sha256', $safe_filename, $key ), 0, 40 ) . '.wau';
     490
     491                        return trailingslashit( $directory['path'] ) . $opaque_name;
     492                }
     493
     494                /**
     495                 * Encrypt a plaintext file and verify it before the caller removes the source.
     496                 *
     497                 * @param string $source_path Plaintext source path.
     498                 * @param string $file_name   Logical filename.
     499                 * @return bool
     500                 */
     501                private function wau_encrypt_and_verify_file( $source_path, $file_name ) {
     502                        if ( ! is_file( $source_path ) ) {
     503                                return false;
     504                        }
     505
     506                        $destination = $this->wau_get_encrypted_file_path( $file_name, true );
     507                        $source_hash = @hash_file( 'sha256', $source_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
     508                        $source_size = @filesize( $source_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
     509
     510                        if ( ! $destination || false === $source_hash || false === $source_size ) {
     511                                return false;
     512                        }
     513
     514                        if ( file_exists( $destination ) && $this->wau_verify_encrypted_file( $destination, $file_name, $source_hash, $source_size ) ) {
     515                                return true;
     516                        }
     517
     518                        try {
     519                                $temp_path = $destination . '.tmp-' . bin2hex( random_bytes( 4 ) );
     520                        } catch ( Throwable $exception ) {
     521                                return false;
     522                        }
     523
     524                        if ( ! $this->wau_encrypt_file( $source_path, $temp_path, $file_name, $source_size )
     525                                || ! $this->wau_verify_encrypted_file( $temp_path, $file_name, $source_hash, $source_size ) ) {
     526                                wp_delete_file( $temp_path );
     527                                return false;
     528                        }
     529
     530                        if ( file_exists( $destination ) ) {
     531                                wp_delete_file( $destination );
     532                        }
     533
     534                        if ( ! @rename( $temp_path, $destination ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
     535                                wp_delete_file( $temp_path );
     536                                return false;
     537                        }
     538
     539                        return $this->wau_verify_encrypted_file( $destination, $file_name, $source_hash, $source_size );
     540                }
     541
     542                /**
     543                 * Write a versioned authenticated-encryption container.
     544                 *
     545                 * @param string $source_path      Plaintext source path.
     546                 * @param string $destination_path Container destination path.
     547                 * @param string $file_name        Logical filename.
     548                 * @param int    $plain_size       Plaintext byte size.
     549                 * @return bool
     550                 */
     551                private function wau_encrypt_file( $source_path, $destination_path, $file_name, $plain_size ) {
     552                        $key = $this->wau_get_encryption_key( true );
     553
     554                        if ( ! $key ) {
     555                                return false;
     556                        }
     557
     558                        $use_sodium = function_exists( 'sodium_crypto_secretstream_xchacha20poly1305_init_push' );
     559                        $algorithm  = $use_sodium ? 'S' : 'O';
     560                        $prefix     = self::WAU_ENCRYPTED_FILE_MAGIC . $algorithm . str_pad( (string) $plain_size, 20, '0', STR_PAD_LEFT );
     561                        $input      = @fopen( $source_path, 'rb' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
     562                        $output     = @fopen( $destination_path, 'xb' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
     563
     564                        if ( ! $input || ! $output || ! $this->wau_write_all( $output, $prefix ) ) {
     565                                if ( is_resource( $input ) ) {
     566                                        fclose( $input );
     567                                }
     568                                if ( is_resource( $output ) ) {
     569                                        fclose( $output );
     570                                }
     571                                return false;
     572                        }
     573
     574                        try {
     575                                $success = $use_sodium
     576                                        ? $this->wau_encrypt_stream_sodium( $input, $output, $key, $prefix, $file_name )
     577                                        : $this->wau_encrypt_stream_openssl( $input, $output, $key, $prefix, $file_name );
     578                        } catch ( Throwable $exception ) {
     579                                $success = false;
     580                        }
     581
     582                        fclose( $input );
     583                        fclose( $output );
     584
     585                        return $success;
     586                }
     587
     588                /**
     589                 * Encrypt a stream with libsodium secretstream.
     590                 */
     591                private function wau_encrypt_stream_sodium( $input, $output, $key, $prefix, $file_name ) {
     592                        list( $state, $header ) = sodium_crypto_secretstream_xchacha20poly1305_init_push( $key );
     593
     594                        if ( ! $this->wau_write_all( $output, $header ) ) {
     595                                return false;
     596                        }
     597
     598                        $current = fread( $input, self::WAU_ENCRYPTED_CHUNK_SIZE );
     599                        if ( false === $current ) {
     600                                return false;
     601                        }
     602
     603                        do {
     604                                $next = feof( $input ) ? '' : fread( $input, self::WAU_ENCRYPTED_CHUNK_SIZE );
     605                                if ( false === $next ) {
     606                                        return false;
     607                                }
     608
     609                                $is_final   = feof( $input ) && '' === $next;
     610                                $tag        = $is_final ? SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL : SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_MESSAGE;
     611                                $ciphertext = sodium_crypto_secretstream_xchacha20poly1305_push( $state, $current, $prefix . $file_name, $tag );
     612
     613                                if ( ! $this->wau_write_all( $output, pack( 'N', strlen( $ciphertext ) ) . $ciphertext ) ) {
     614                                        return false;
     615                                }
     616
     617                                $current = $next;
     618                        } while ( ! $is_final );
     619
     620                        return true;
     621                }
     622
     623                /**
     624                 * Encrypt a stream with independently authenticated AES-256-GCM records.
     625                 */
     626                private function wau_encrypt_stream_openssl( $input, $output, $key, $prefix, $file_name ) {
     627                        if ( ! function_exists( 'openssl_encrypt' ) || ! in_array( 'aes-256-gcm', openssl_get_cipher_methods(), true ) ) {
     628                                return false;
     629                        }
     630
     631                        $current = fread( $input, self::WAU_ENCRYPTED_CHUNK_SIZE );
     632                        $index   = 0;
     633                        if ( false === $current ) {
     634                                return false;
     635                        }
     636
     637                        do {
     638                                $next = feof( $input ) ? '' : fread( $input, self::WAU_ENCRYPTED_CHUNK_SIZE );
     639                                if ( false === $next ) {
     640                                        return false;
     641                                }
     642
     643                                $is_final = feof( $input ) && '' === $next;
     644                                $flags    = $is_final ? "\x01" : "\x00";
     645                                $length   = pack( 'N', strlen( $current ) );
     646                                $iv       = random_bytes( 12 );
     647                                $tag      = '';
     648                                $aad      = $prefix . $file_name . pack( 'N', $index ) . $flags . $length;
     649                                $ciphertext = openssl_encrypt( $current, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, $aad, 16 );
     650
     651                                if ( false === $ciphertext || 16 !== strlen( $tag ) || ! $this->wau_write_all( $output, $flags . $length . $iv . $tag . $ciphertext ) ) {
     652                                        return false;
     653                                }
     654
     655                                $current = $next;
     656                                $index++;
     657                        } while ( ! $is_final );
     658
     659                        return true;
     660                }
     661
     662                /**
     663                 * Verify an encrypted file against the original content details.
     664                 */
     665                private function wau_verify_encrypted_file( $file_path, $file_name, $expected_hash, $expected_size ) {
     666                        $hash_context = hash_init( 'sha256' );
     667                        $actual_size  = 0;
     668                        $success      = $this->wau_read_encrypted_file(
     669                                $file_path,
     670                                $file_name,
     671                                function ( $chunk ) use ( $hash_context, &$actual_size ) {
     672                                        hash_update( $hash_context, $chunk );
     673                                        $actual_size += strlen( $chunk );
     674                                        return true;
     675                                }
     676                        );
     677
     678                        $actual_hash = hash_final( $hash_context );
     679
     680                        return $success && (int) $expected_size === $actual_size && hash_equals( $expected_hash, $actual_hash );
     681                }
     682
     683                /**
     684                 * Read and authenticate an encrypted container chunk by chunk.
     685                 *
     686                 * @param string   $file_path Container path.
     687                 * @param string   $file_name Logical filename.
     688                 * @param callable $consumer  Receives each plaintext chunk.
     689                 * @return bool
     690                 */
     691                private function wau_read_encrypted_file( $file_path, $file_name, $consumer ) {
     692                        $key    = $this->wau_get_encryption_key();
     693                        $handle = @fopen( $file_path, 'rb' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
     694
     695                        if ( ! $key || ! $handle ) {
     696                                return false;
     697                        }
     698
     699                        $prefix = $this->wau_read_exact( $handle, 29 );
     700                        if ( false === $prefix || self::WAU_ENCRYPTED_FILE_MAGIC !== substr( $prefix, 0, 8 ) || ! ctype_digit( substr( $prefix, 9, 20 ) ) ) {
     701                                fclose( $handle );
     702                                return false;
     703                        }
     704
     705                        $algorithm   = substr( $prefix, 8, 1 );
     706                        $expected_size = (int) ltrim( substr( $prefix, 9, 20 ), '0' );
     707                        $total_size  = 0;
     708                        $success     = 'S' === $algorithm
     709                                ? $this->wau_decrypt_stream_sodium( $handle, $key, $prefix, $file_name, $consumer, $total_size )
     710                                : ( 'O' === $algorithm ? $this->wau_decrypt_stream_openssl( $handle, $key, $prefix, $file_name, $consumer, $total_size ) : false );
     711
     712                        fclose( $handle );
     713
     714                        return $success && $expected_size === $total_size;
     715                }
     716
     717                /**
     718                 * Decrypt a libsodium container stream.
     719                 */
     720                private function wau_decrypt_stream_sodium( $handle, $key, $prefix, $file_name, $consumer, &$total_size ) {
     721                        if ( ! function_exists( 'sodium_crypto_secretstream_xchacha20poly1305_init_pull' ) ) {
     722                                return false;
     723                        }
     724
     725                        $header = $this->wau_read_exact( $handle, SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_HEADERBYTES );
     726                        if ( false === $header ) {
     727                                return false;
     728                        }
     729
     730                        $state = sodium_crypto_secretstream_xchacha20poly1305_init_pull( $header, $key );
     731                        while ( true ) {
     732                                $packed_length = $this->wau_read_exact( $handle, 4 );
     733                                if ( false === $packed_length ) {
     734                                        return false;
     735                                }
     736
     737                                $length = unpack( 'Nlength', $packed_length )['length'];
     738                                if ( $length > self::WAU_ENCRYPTED_CHUNK_SIZE + SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_ABYTES ) {
     739                                        return false;
     740                                }
     741
     742                                $ciphertext = $this->wau_read_exact( $handle, $length );
     743                                $result     = false === $ciphertext ? false : sodium_crypto_secretstream_xchacha20poly1305_pull( $state, $ciphertext, $prefix . $file_name );
     744                                if ( false === $result ) {
     745                                        return false;
     746                                }
     747
     748                                list( $plaintext, $tag ) = $result;
     749                                $total_size += strlen( $plaintext );
     750                                if ( false === call_user_func( $consumer, $plaintext ) ) {
     751                                        return false;
     752                                }
     753
     754                                if ( SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL === $tag ) {
     755                                        return '' === fread( $handle, 1 );
     756                                }
     757                        }
     758                }
     759
     760                /**
     761                 * Decrypt an AES-256-GCM container stream.
     762                 */
     763                private function wau_decrypt_stream_openssl( $handle, $key, $prefix, $file_name, $consumer, &$total_size ) {
     764                        if ( ! function_exists( 'openssl_decrypt' ) ) {
     765                                return false;
     766                        }
     767
     768                        $index = 0;
     769                        while ( true ) {
     770                                $flags  = $this->wau_read_exact( $handle, 1 );
     771                                $length = $this->wau_read_exact( $handle, 4 );
     772                                if ( false === $flags || false === $length || ( "\x00" !== $flags && "\x01" !== $flags ) ) {
     773                                        return false;
     774                                }
     775
     776                                $plain_length = unpack( 'Nlength', $length )['length'];
     777                                if ( $plain_length > self::WAU_ENCRYPTED_CHUNK_SIZE ) {
     778                                        return false;
     779                                }
     780
     781                                $iv         = $this->wau_read_exact( $handle, 12 );
     782                                $tag        = $this->wau_read_exact( $handle, 16 );
     783                                $ciphertext = $this->wau_read_exact( $handle, $plain_length );
     784                                if ( false === $iv || false === $tag || false === $ciphertext ) {
     785                                        return false;
     786                                }
     787
     788                                $aad       = $prefix . $file_name . pack( 'N', $index ) . $flags . $length;
     789                                $plaintext = openssl_decrypt( $ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, $aad );
     790                                if ( false === $plaintext || strlen( $plaintext ) !== $plain_length ) {
     791                                        return false;
     792                                }
     793
     794                                $total_size += strlen( $plaintext );
     795                                if ( false === call_user_func( $consumer, $plaintext ) ) {
     796                                        return false;
     797                                }
     798
     799                                if ( "\x01" === $flags ) {
     800                                        return '' === fread( $handle, 1 );
     801                                }
     802
     803                                $index++;
     804                        }
     805                }
     806
     807                /**
     808                 * Read exactly the requested number of bytes from a file handle.
     809                 */
     810                private function wau_read_exact( $handle, $length ) {
     811                        $data = '';
     812
     813                        while ( strlen( $data ) < $length && ! feof( $handle ) ) {
     814                                $chunk = fread( $handle, $length - strlen( $data ) );
     815                                if ( false === $chunk || '' === $chunk ) {
     816                                        break;
     817                                }
     818                                $data .= $chunk;
     819                        }
     820
     821                        return strlen( $data ) === $length ? $data : false;
     822                }
     823
     824                /**
     825                 * Write all bytes to a file handle.
     826                 */
     827                private function wau_write_all( $handle, $data ) {
     828                        $written = 0;
     829                        $length  = strlen( $data );
     830
     831                        while ( $written < $length ) {
     832                                $result = fwrite( $handle, substr( $data, $written ) );
     833                                if ( false === $result || 0 === $result ) {
     834                                        return false;
     835                                }
     836                                $written += $result;
     837                        }
     838
     839                        return true;
     840                }
     841
     842                /**
     843                 * Read encrypted-container metadata without decrypting its contents.
     844                 *
     845                 * @return array|false
     846                 */
     847                private function wau_get_encrypted_file_metadata( $file_path ) {
     848                        if ( '.wau' !== strtolower( substr( $file_path, -4 ) ) ) {
     849                                return false;
     850                        }
     851
     852                        $handle = @fopen( $file_path, 'rb' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
     853                        if ( ! $handle ) {
     854                                return false;
     855                        }
     856
     857                        $prefix = $this->wau_read_exact( $handle, 29 );
     858                        fclose( $handle );
     859
     860                        if ( false === $prefix || self::WAU_ENCRYPTED_FILE_MAGIC !== substr( $prefix, 0, 8 ) || ! ctype_digit( substr( $prefix, 9, 20 ) ) ) {
     861                                return false;
     862                        }
     863
     864                        return array(
     865                                'algorithm' => substr( $prefix, 8, 1 ),
     866                                'size'      => (int) ltrim( substr( $prefix, 9, 20 ), '0' ),
     867                        );
     868                }
     869
     870                /**
    337871                 * Get the preferred private upload directory for this site.
    338872                 *
    … …  
    413947                                        'url'     => '',
    414948                                        'storage' => 'private',
     949                                );
     950                        }
     951
     952                        $encrypted_upload_dir = $this->wau_get_encrypted_upload_directory( true );
     953                        if ( ! empty( $encrypted_upload_dir['path'] ) ) {
     954                                return array(
     955                                        'path'    => $encrypted_upload_dir['path'],
     956                                        'url'     => '',
     957                                        'storage' => 'encrypted',
    415958                                );
    416959                        }
    … …  
    5871130                        global $wp_filesystem;
    5881131
    589                         if ( $wp_filesystem && $this->wau_is_allowed_upload_path( $file_path ) ) {
     1132                        if ( ! $this->wau_is_allowed_upload_path( $file_path ) ) {
     1133                                return;
     1134                        }
     1135
     1136                        if ( $wp_filesystem ) {
    5901137                                $wp_filesystem->put_contents( $file_path . '.wau-pending', (string) time(), FS_CHMOD_FILE );
     1138                        } else {
     1139                                @file_put_contents( $file_path . '.wau-pending', (string) time(), LOCK_EX ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
    5911140                        }
    5921141                }
    … …  
    6161165                        }
    6171166
    618                         $private_upload_dir = $this->wau_get_private_upload_directory();
    619                         if ( empty( $private_upload_dir['path'] ) || ! is_dir( $private_upload_dir['path'] ) ) {
     1167                        $private_upload_dir   = $this->wau_get_private_upload_directory();
     1168                        $encrypted_upload_dir = $this->wau_get_encrypted_upload_directory();
     1169                        $cleanup_directories  = array_filter(
     1170                                array(
     1171                                        $private_upload_dir['path'],
     1172                                        $encrypted_upload_dir['path'],
     1173                                )
     1174                        );
     1175
     1176                        if ( empty( $cleanup_directories ) ) {
    6201177                                return;
    6211178                        }
    … …  
    6291186                        $cutoff    = time() - $retention;
    6301187
    631                         try {
    632                                 $directory = new DirectoryIterator( $private_upload_dir['path'] );
    633                         } catch ( Exception $exception ) {
    634                                 return;
    635                         }
    636 
    637                         foreach ( $directory as $file_info ) {
    638                                 $file_name = $file_info->getFilename();
    639 
    640                                 if ( $file_info->isDot() || ! $file_info->isFile() || '.wau-pending' !== substr( $file_name, -12 ) ) {
     1188                        foreach ( $cleanup_directories as $cleanup_directory ) {
     1189                                if ( ! is_dir( $cleanup_directory ) ) {
    6411190                                        continue;
    6421191                                }
    6431192
    644                                 $marker_path = $file_info->getPathname();
    645                                 if ( $file_info->getMTime() > $cutoff ) {
     1193                                try {
     1194                                        $directory = new DirectoryIterator( $cleanup_directory );
     1195                                } catch ( Exception $exception ) {
    6461196                                        continue;
    6471197                                }
    6481198
    649                                 $upload_path = substr( $marker_path, 0, -12 );
    650                                 if ( file_exists( $upload_path ) && $this->wau_is_allowed_upload_path( $upload_path ) ) {
    651                                         wp_delete_file( $upload_path );
    652                                 }
    653 
    654                                 wp_delete_file( $marker_path );
     1199                                foreach ( $directory as $file_info ) {
     1200                                        $file_name = $file_info->getFilename();
     1201
     1202                                        if ( $file_info->isDot() || ! $file_info->isFile() || '.wau-pending' !== substr( $file_name, -12 ) ) {
     1203                                                continue;
     1204                                        }
     1205
     1206                                        $marker_path = $file_info->getPathname();
     1207                                        if ( $file_info->getMTime() > $cutoff ) {
     1208                                                continue;
     1209                                        }
     1210
     1211                                        $upload_path = substr( $marker_path, 0, -12 );
     1212                                        if ( file_exists( $upload_path ) && $this->wau_is_allowed_upload_path( $upload_path ) ) {
     1213                                                wp_delete_file( $upload_path );
     1214                                        }
     1215
     1216                                        wp_delete_file( $marker_path );
     1217                                }
    6551218                        }
    6561219                }
    … …  
    7951358                        WP_Filesystem();
    7961359
    797                         if ( ! $wp_filesystem ) {
    798                                 wc_add_notice( __( 'File upload failed. Please try again.', 'woo-addon-uploads' ), 'error' );
    799                                 return $cart_item_meta;
    800                         }
    801 
    8021360                        // Check if file is uploaded.
    8031361                        $post_file = wp_unslash( $_FILES );
    … …  
    8891447                                // Ensure directory exists.
    8901448                                if ( empty( $custom_dir ) || ! $this->wau_prepare_upload_directory( $custom_dir ) ) {
    891                                         wc_add_notice( __( 'File upload is temporarily unavailable because private upload storage could not be created. Please contact the store administrator.', 'woo-addon-uploads' ), 'error' );
     1449                                        wc_add_notice( __( 'File upload is temporarily unavailable because secure upload storage could not be created. Please contact the store administrator.', 'woo-addon-uploads' ), 'error' );
    8921450                                        return $cart_item_meta;
    8931451                                }
    … …  
    8971455                                if ( false !== $free_space && $free_space - $actual_size < $minimum_free_space ) {
    8981456                                        wc_add_notice( __( 'File upload is temporarily unavailable because the server is low on storage space.', 'woo-addon-uploads' ), 'error' );
     1457                                        return $cart_item_meta;
     1458                                }
     1459
     1460                                if ( 'encrypted' === $storage_dir['storage'] ) {
     1461                                        $original_name = sanitize_file_name( basename( (string) $file['name'] ) );
     1462                                        $original_name = $original_name ? $original_name : 'upload.' . $file_ext;
     1463
     1464                                        try {
     1465                                                $file_name = bin2hex( random_bytes( 16 ) ) . '-' . time() . '-' . $original_name;
     1466                                        } catch ( Throwable $exception ) {
     1467                                                wc_add_notice( __( 'File upload failed. Please try again.', 'woo-addon-uploads' ), 'error' );
     1468                                                return $cart_item_meta;
     1469                                        }
     1470
     1471                                        $encrypted_path = $this->wau_get_encrypted_file_path( $file_name, true );
     1472                                        if ( ! $encrypted_path || ! $this->wau_encrypt_and_verify_file( $tmp_name, $file_name ) ) {
     1473                                                wc_add_notice( __( 'File upload failed while creating secure encrypted storage. Please try again.', 'woo-addon-uploads' ), 'error' );
     1474                                                return $cart_item_meta;
     1475                                        }
     1476
     1477                                        $access_key = wp_generate_password( 32, false, false );
     1478                                        $addon_id   = array(
     1479                                                'file_path'  => $encrypted_path,
     1480                                                'file_url'   => '',
     1481                                                'file_name'  => $file_name,
     1482                                                'storage'    => 'encrypted',
     1483                                                'access_key' => $access_key,
     1484                                        );
     1485                                        $this->wau_mark_upload_pending( $encrypted_path );
     1486                                        $cart_item_meta['wau_addon_ids'][] = $addon_id;
     1487
     1488                                        return $cart_item_meta;
     1489                                }
     1490
     1491                                if ( ! $wp_filesystem ) {
     1492                                        wc_add_notice( __( 'File upload failed. Please try again.', 'woo-addon-uploads' ), 'error' );
    8991493                                        return $cart_item_meta;
    9001494                                }
    … …  
    14782072                        }
    14792073
     2074                        $encrypted_path = $this->wau_get_encrypted_file_path( $safe_filename );
     2075                        if ( $encrypted_path && file_exists( $encrypted_path ) && is_file( $encrypted_path ) && $this->wau_is_allowed_upload_path( $encrypted_path ) ) {
     2076                                return $encrypted_path;
     2077                        }
     2078
    14802079                        return false;
    14812080                }
    … …  
    14922091                        $private_upload_dir = $this->wau_get_private_upload_directory();
    14932092                        $legacy_upload_dir  = $this->wau_get_legacy_upload_directory();
     2093                        $encrypted_upload_dir = $this->wau_get_encrypted_upload_directory();
    14942094                        $directories        = array();
    14952095
    … …  
    15002100                        if ( ! empty( $legacy_upload_dir['path'] ) ) {
    15012101                                $directories[] = $legacy_upload_dir['path'];
     2102                        }
     2103
     2104                        if ( ! empty( $encrypted_upload_dir['path'] ) ) {
     2105                                $directories[] = $encrypted_upload_dir['path'];
    15022106                        }
    15032107
    … …  
    15582162                                }
    15592163
    1560                                 $file_path     = $this->wau_locate_uploaded_file( $safe_filename );
     2164                                $file_path = $this->wau_locate_uploaded_file( $safe_filename );
    15612165
    15622166                                // 2. Clear any active output buffers to prevent file corruption/whitespace injections
    … …  
    15662170
    15672171                                if ( $file_path && file_exists( $file_path ) ) {
    1568                                         $filetype  = wp_check_filetype( $file_path );
     2172                                        $filetype  = wp_check_filetype( $safe_filename );
    15692173                                        $mime_type = $filetype['type'] ? $filetype['type'] : 'application/octet-stream';
     2174                                        $encrypted_metadata = $this->wau_get_encrypted_file_metadata( $file_path );
     2175                                        if ( $encrypted_metadata ) {
     2176                                                $is_valid = $this->wau_read_encrypted_file(
     2177                                                        $file_path,
     2178                                                        $safe_filename,
     2179                                                        function () {
     2180                                                                return true;
     2181                                                        }
     2182                                                );
     2183
     2184                                                if ( ! $is_valid ) {
     2185                                                        wp_die( esc_html__( 'The encrypted file could not be authenticated.', 'woo-addon-uploads' ) );
     2186                                                }
     2187                                        }
    15702188
    15712189                                        // Only known passive raster formats may render inline. Other formats are downloads.
    … …  
    15752193
    15762194                                        send_nosniff_header();
     2195                                        nocache_headers();
    15772196                                        header( 'Content-Type: ' . $mime_type );
    1578                                         header( 'Content-Disposition: ' . $disposition . '; filename="' . basename( $file_path ) . '"' );
    1579                                         header( 'Content-Length: ' . filesize( $file_path ) );
    1580                                         readfile( $file_path ); // phpcs:ignore
     2197                                        header( 'Content-Disposition: ' . $disposition . '; filename="' . $safe_filename . '"' );
     2198
     2199                                        if ( $encrypted_metadata ) {
     2200                                                header( 'Content-Length: ' . $encrypted_metadata['size'] );
     2201                                                $this->wau_read_encrypted_file(
     2202                                                        $file_path,
     2203                                                        $safe_filename,
     2204                                                        function ( $chunk ) {
     2205                                                                echo $chunk; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
     2206                                                                return ! connection_aborted();
     2207                                                        }
     2208                                                );
     2209                                        } else {
     2210                                                header( 'Content-Length: ' . filesize( $file_path ) );
     2211                                                readfile( $file_path ); // phpcs:ignore
     2212                                        }
    15812213                                        exit;
    15822214                                } else {
  • woo-addon-uploads/trunk/readme.txt

    r3714269 r3740977  
    55Requires at least: 5.0
    66Tested up to: 7.1
    7 Stable tag: 1.7.6
     7Stable tag: 1.7.7
    88Requires PHP: 7.4
    99License: GPLv2 or later
    … …  
    143143== Changelog ==
    144144
     145= 1.7.7 (09.10.2026) =
     146* Security fix: Encrypt legacy public uploads with authenticated encryption when private filesystem storage cannot be created, then remove the verified plaintext originals.
     147* Security fix: Store new uploads directly as opaque encrypted containers when private filesystem storage is unavailable, without writing plaintext into the public uploads folder.
     148* Reliability fix: Verify each encrypted migration by byte size and SHA-256 before deleting its public plaintext source.
     149* System Status: Report encrypted fallback availability, encrypted container totals, and the number of public plaintext files still awaiting migration.
     150
    145151= 1.7.6 (26.09.2026) =
    146152* Security fix: Prevent new uploads from falling back to the public uploads directory when private storage is unavailable.
    … …  
    260266= What happens if my host does not allow private upload storage? =
    261267
    262 Some shared or managed hosts do not allow WordPress to create folders outside the public web root. If private storage is not available, new customer uploads are paused instead of being stored in a public web-accessible folder.
    263 
    264 For files uploaded before this update, the plugin continues to harden the legacy folder's `.htaccess` and `web.config` protection files where supported. On Nginx, Caddy, OpenLiteSpeed, or similar servers, directory-level `.htaccess` files may not be honored, so your host may need to block direct web access to:
     268Some shared or managed hosts do not allow WordPress to create folders outside the public web root. If private storage is not available, the plugin stores new uploads as authenticated encrypted containers with opaque names under:
     269
     270`
     271/wp-content/uploads/wau-uploads/encrypted/<site-specific-folder>/
     272`
     273
     274The site-specific folder uses the site host, blog ID, and a short hash so subdomain and multisite stores do not share encrypted storage. The original customer filename is not used as the container filename, and plaintext upload contents are never written to that directory. Direct requests can retrieve only authenticated ciphertext, which is decrypted exclusively by the plugin's authorized download handler.
     275
     276Files uploaded before this update are moved to private storage when available. Otherwise, they are encrypted and verified in small batches, and each public plaintext original is deleted only after its encrypted copy matches the original byte size and SHA-256 hash.
     277
     278While public plaintext files are still awaiting migration, the plugin continues to harden the legacy folder's `.htaccess` and `web.config` protection files where supported. On Nginx, Caddy, OpenLiteSpeed, or similar servers, directory-level `.htaccess` files may not be honored, so your host should temporarily block direct web access to:
    265279
    266280`
    … …  
    280294OpenLiteSpeed and Caddy use different configuration formats; share the displayed URL path with your host instead of copying the Nginx rule.
    281295
    282 On Apache or LiteSpeed, the protection file can be present even when the server is configured to ignore it. While legacy files remain, test a known legacy file URL and confirm that it returns HTTP 403 or 404. The System Status page keeps this verification visible until migration is complete.
     296On Apache or LiteSpeed, the protection file can be present even when the server is configured to ignore it. While public plaintext files remain, test a known legacy file URL and confirm that it returns HTTP 403 or 404. The System Status page keeps this verification visible until migration is complete.
    283297
    284298You can also review the current storage status and recommended action items from WooCommerce admin under Addon Upload Settings > System Status.
    … …  
    298312= What happens to files uploaded before this update? =
    299313
    300 Existing files in `wp-content/uploads/wau-uploads/` remain accessible to authorized users through the plugin's secure download handler. When private storage is available, the plugin migrates legacy files to the private folder in small batches during normal site activity.
     314Existing files in `wp-content/uploads/wau-uploads/` remain accessible to authorized users through the plugin's secure download handler. The plugin migrates them in small batches during normal site activity. It moves them outside the web root when private storage is available; otherwise, it converts them into opaque authenticated encrypted containers and deletes the verified public plaintext originals.
    301315
    302316Existing order links that already use the plugin's secure download handler continue to work. Older direct links to `/wau-uploads/` are rewritten to secure download links when displayed in order metadata.
    … …  
    304318Logged-in customers are authorized through order ownership. Guest customers are authorized using the WooCommerce order key included in their generated download link. Each new upload also has its own access key.
    305319
     320The encryption key is stored in the WordPress database and is not embedded in the encrypted files. Back up the WordPress database and uploaded files together; encrypted fallback files cannot be recovered without that database key.
     321
    306322= What file types are supported? =
    307323
  • woo-addon-uploads/trunk/woocommerce-addon-uploads.php

    r3714269 r3740977  
    44 * Plugin URI: https://imaginate-solutions.com/downloads/woocommerce-addon-uploads/
    55 * Description: WooCommerce addon to upload additional files before adding product to cart
    6  * Version: 1.7.6
     6 * Version: 1.7.7
    77 * Author: Imaginate Solutions
    88 * Author URI: https://imaginate-solutions.com
    … …  
    4545                 * @var      string    $version    The current version of the plugin.
    4646                 */
    47                 protected $version = '1.7.6';
     47                protected $version = '1.7.7';
    4848
    4949                /**
Note: See TracChangeset for help on using the changeset viewer.