get_charset_collate(); dbDelta( "CREATE TABLE $table ( file_id char(36) NOT NULL, entry_id bigint(20) unsigned NOT NULL, field_id varchar(40) NOT NULL, name varchar(200) NOT NULL, mime varchar(64) NOT NULL, bytes int unsigned NOT NULL, sha256 char(64) NOT NULL, content longblob NOT NULL, created_at datetime NOT NULL, PRIMARY KEY (file_id), UNIQUE KEY entry_field (entry_id,field_id), KEY entry_id (entry_id) ) ENGINE=InnoDB $charset;" ); if ( KCONV_Free_Ownership::verify_table( $table ) ) { update_option( 'kconv_files_db_version', self::DB_VERSION, false ); } } public static function allowed() { return KCONV_Core::can( 'kconv_view_entries' ); } public static function entry_links( $entry ) { if ( ! self::allowed() || ! KCONV_Entry_Access::allowed( $entry, 'file' ) ) { return; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Private file rows and host packet capacity are queried directly with prepared identifiers/values. Full entry scope and expiry are checked before blob retrieval, followed by byte-length and SHA256 verification. $files = $wpdb->get_results( $wpdb->prepare( 'SELECT f.file_id,f.name,f.mime,f.bytes FROM %i f INNER JOIN %i e ON e.id=f.entry_id WHERE f.entry_id=%d AND e.expires_at>%s ORDER BY f.field_id LIMIT 50', self::table(), KCONV_Core::table( 'entries' ), $entry['id'], gmdate( 'Y-m-d H:i:s' ) ), ARRAY_A ); foreach ( $files as $file ) { $url = wp_nonce_url( add_query_arg( array( 'action' => 'kconv_file_download', 'entry_id' => (int) $entry['id'], 'file_id' => $file['file_id'] ), admin_url( 'admin-post.php' ) ), 'kconv_file_' . $entry['id'] . '_' . $file['file_id'] ); /* translators: Private attachment filename. */ echo '
' . esc_html( sprintf( __( 'Download %s', 'killerconversions' ), $file['name'] ) ) . ' — ' . esc_html( $file['mime'] . ' · ' . size_format( $file['bytes'] ) ) . '
'; } } public static function authorized_file( $entry_id, $file_id, $nonce ) { if ( ! self::allowed() ) { return new WP_Error( 'forbidden', __( 'This private download is not authorized.', 'killerconversions' ), array( 'status' => 403 ) ); } if ( ! is_string( $file_id ) || ! preg_match( '/^[a-f0-9-]{36}$/D', $file_id ) || ! is_string( $nonce ) ) { return new WP_Error( 'forbidden', __( 'This private download is not authorized.', 'killerconversions' ), array( 'status' => 403 ) ); } if ( ! wp_verify_nonce( sanitize_text_field( $nonce ), 'kconv_file_' . absint( $entry_id ) . '_' . $file_id ) ) { return new WP_Error( 'forbidden', __( 'This private download is not authorized.', 'killerconversions' ), array( 'status' => 403 ) ); } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Private file rows and host packet capacity are queried directly with prepared identifiers/values. Full entry scope and expiry are checked before blob retrieval, followed by byte-length and SHA256 verification. $entry = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id=%d', KCONV_Core::table( 'entries' ), $entry_id ), ARRAY_A ); if ( ! $entry || ! KCONV_Entry_Access::allowed( $entry, 'file' ) ) { return new WP_Error( 'forbidden', __( 'This private download is not authorized.', 'killerconversions' ), array( 'status' => 403 ) ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Private file rows and host packet capacity are queried directly with prepared identifiers/values. Full entry scope and expiry are checked before blob retrieval, followed by byte-length and SHA256 verification. $row = $wpdb->get_row( $wpdb->prepare( 'SELECT f.* FROM %i f INNER JOIN %i e ON e.id=f.entry_id WHERE f.file_id=%s AND f.entry_id=%d AND e.expires_at>%s', self::table(), KCONV_Core::table( 'entries' ), $file_id, absint( $entry_id ), gmdate( 'Y-m-d H:i:s' ) ), ARRAY_A ); if ( ! $row ) { return new WP_Error( 'missing', __( 'This file was removed or its entry expired.', 'killerconversions' ), array( 'status' => 404 ) ); } if ( strlen( $row['content'] ) !== (int) $row['bytes'] || ! hash_equals( $row['sha256'], hash( 'sha256', $row['content'] ) ) ) { return new WP_Error( 'integrity', __( 'The stored file failed its integrity check.', 'killerconversions' ), array( 'status' => 500 ) ); } return $row; } public static function download() { if ( ! self::allowed() ) { wp_die( esc_html__( 'This private download is not authorized.', 'killerconversions' ), '', array( 'response' => 403 ) ); } $entry_id = isset( $_GET['entry_id'] ) && is_scalar( $_GET['entry_id'] ) ? absint( $_GET['entry_id'] ) : 0; $file_id = isset( $_GET['file_id'] ) && is_string( $_GET['file_id'] ) ? sanitize_text_field( wp_unslash( $_GET['file_id'] ) ) : ''; if ( ! isset( $_GET['_wpnonce'] ) || ! is_string( $_GET['_wpnonce'] ) ) { wp_die( esc_html__( 'This private download is not authorized.', 'killerconversions' ), '', array( 'response' => 403 ) ); } $nonce = sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ); if ( ! wp_verify_nonce( $nonce, 'kconv_file_' . $entry_id . '_' . $file_id ) ) { wp_die( esc_html__( 'This private download is not authorized.', 'killerconversions' ), '', array( 'response' => 403 ) ); } $file = self::authorized_file( $entry_id, $file_id, $nonce ); if ( is_wp_error( $file ) ) { wp_die( esc_html( $file->get_error_message() ), '', array( 'response' => absint( $file->get_error_data()['status'] ) ) ); } nocache_headers(); header( 'Cache-Control: private, no-store, max-age=0' ); header( 'Content-Type: application/octet-stream' ); header( 'X-Content-Type-Options: nosniff' ); header( "Content-Security-Policy: default-src 'none'; sandbox" ); $fallback = preg_replace( '/[^a-zA-Z0-9._-]/', '_', $file['name'] ); header( 'Content-Disposition: attachment; filename="' . $fallback . '"; filename*=UTF-8\'\'' . rawurlencode( $file['name'] ) ); header( 'Content-Length: ' . strlen( $file['content'] ) ); echo $file['content']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Authorized binary attachment, never HTML. exit; } public static function exporters( $exporters ) { $exporters['killerconversions-files'] = array( 'exporter_friendly_name' => 'KillerConversions private files', 'callback' => array( __CLASS__, 'privacy_export' ) ); return $exporters; } public static function privacy_export( $email, $page = 1 ) { return KCONV_Core::privacy_files( $email, $page ); }}