get_charset_collate(); dbDelta( "CREATE TABLE $table ( token_hash char(64) NOT NULL, form_id bigint(20) unsigned NOT NULL, revision char(64) NOT NULL, version bigint unsigned NOT NULL DEFAULT 1, created_at bigint unsigned NOT NULL, expires_at bigint unsigned NOT NULL, content longtext NOT NULL, PRIMARY KEY (token_hash), KEY form_expiry (form_id,expires_at), KEY expires_at (expires_at) ) ENGINE=InnoDB $charset;" ); dbDelta( "CREATE TABLE $subjects ( token_hash char(64) NOT NULL, email_hash char(64) NOT NULL, PRIMARY KEY (token_hash,email_hash), KEY email_hash (email_hash) ) ENGINE=InnoDB $charset;" ); if ( self::storage_ready() ) { update_option( 'kconv_resume_db_version', '1', false ); } } public static function storage_ready() { global $wpdb; if ( ! KCONV_Free_Ownership::verify_table( self::table() ) || ! KCONV_Free_Ownership::verify_table( self::subjects() ) ) { return false; } foreach ( array( self::table(), self::subjects(), KCONV_Core::table( 'limits' ) ) as $table ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Encrypted resume reads/writes use fixed tables, prepared values, optimistic versions and row locks; token, expiry, quota and consumed-state checks require fresh transactional data. if ( 'innodb' !== strtolower( (string) $wpdb->get_var( $wpdb->prepare( 'SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', $table ) ) ) ) { return false; } } return true; } /** Distinguish a new site with no draft tables from unavailable or partial storage. */ private static function inventory() { global $wpdb; $found = 0; foreach ( array( self::table(), self::subjects() ) as $table ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Fixed draft table inventory must distinguish absent storage from a failed privacy lookup. $actual = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ); if ( $wpdb->last_error ) { return self::storage_error(); } if ( $table === $actual ) { $found++; } } if ( 1 === $found || ( 0 === $found && null !== get_option( 'kconv_resume_db_version', null ) ) ) { return self::storage_error(); } return 2 === $found; } public static function exists() { return true === self::inventory(); } private static function storage_error() { return new WP_Error( 'draft_storage', __( 'Saved draft storage could not be read. Please repair storage and retry.', 'killerconversions' ) ); } private static function erasure_error() { return new WP_Error( 'draft_storage', __( 'Saved draft erasure could not be completed. Please repair storage and retry.', 'killerconversions' ) ); } public static function email_hash( $email ) { return hash_hmac( 'sha256', strtolower( trim( $email ) ), wp_salt( 'auth' ) . '|kconv-resume-email-v1' ); } public static function decrypt( $row ) { $data = self::decrypt_envelope( $row ); return is_wp_error( $data ) ? $data : $data['values']; } /** The authenticated envelope is private; public callers must project eligible answers/metadata. */ public static function decrypt_envelope( $row ) { if ( ! function_exists( 'sodium_crypto_secretbox_open' ) ) { return new WP_Error( 'draft_crypto', __( 'PHP Sodium is needed to read this encrypted draft.', 'killerconversions' ) ); } $bytes = base64_decode( $row['content'], true ); if ( ! is_string( $bytes ) || strlen( $bytes ) < SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + SODIUM_CRYPTO_SECRETBOX_MACBYTES ) { return new WP_Error( 'draft_integrity', __( 'Encrypted draft integrity check failed.', 'killerconversions' ) ); } try { $plain = sodium_crypto_secretbox_open( substr( $bytes, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ), substr( $bytes, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ), hash( 'sha256', wp_salt( 'auth' ) . '|kconv-resume-encryption-v1', true ) ); } catch ( Throwable $error ) { $plain = false; } $data = is_string( $plain ) ? json_decode( $plain, true, 32 ) : null; if ( ! is_array( $data ) || ( isset( $data['hash'] ) ? $data['hash'] : '' ) !== $row['token_hash'] || (int) ( isset( $data['form'] ) ? $data['form'] : 0 ) !== (int) $row['form_id'] || ( isset( $data['revision'] ) ? $data['revision'] : '' ) !== $row['revision'] || ! isset( $data['values'] ) || ! is_array( $data['values'] ) ) { return new WP_Error( 'draft_integrity', __( 'Encrypted draft integrity check failed.', 'killerconversions' ) ); } return $data; } public static function cleanup() { if ( ! self::exists() ) { return; } global $wpdb; // Expiry is enforced at every access; cron only removes already-inaccessible storage. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Private encrypted draft/subject tables are fixed plugin names. Expiry, erasure and form deletion use prepared identifiers/values and transactional rollback. Fresh privacy state must not be cached. for ( $i = 0; $i < 20; $i++ ) { $count = $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE expires_at<=%d LIMIT 500', self::table(), time() ) ); if ( false === $count || $count < 500 ) { break; } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Private encrypted draft/subject tables are fixed plugin names. Expiry, erasure and form deletion use prepared identifiers/values and transactional rollback. Fresh privacy state must not be cached. $wpdb->query( $wpdb->prepare( 'DELETE s FROM %i s LEFT JOIN %i d ON d.token_hash=s.token_hash WHERE d.token_hash IS NULL', self::subjects(), self::table() ) ); } public static function delete_form( $id ) { if ( 'kconv_form' !== get_post_type( $id ) || ! self::exists() ) { return; } global $wpdb; $outer = class_exists( 'KCONV_Authoring' ) && KCONV_Authoring::transaction_active(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Private encrypted draft/subject tables are fixed plugin names. Expiry, erasure and form deletion use prepared identifiers/values and transactional rollback. Fresh privacy state must not be cached. if ( ! $outer && false === $wpdb->query( 'START TRANSACTION' ) ) { return false; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Private encrypted draft/subject tables are fixed plugin names. Expiry, erasure and form deletion use prepared identifiers/values and transactional rollback. Fresh privacy state must not be cached. $wpdb->get_col( $wpdb->prepare( 'SELECT token_hash FROM %i WHERE form_id=%d FOR UPDATE', self::table(), $id ) ); $ok = ! $wpdb->last_error; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Private encrypted draft/subject tables are fixed plugin names. Expiry, erasure and form deletion use prepared identifiers/values and transactional rollback. Fresh privacy state must not be cached. $subjects = $ok ? $wpdb->query( $wpdb->prepare( 'DELETE s FROM %i s INNER JOIN %i d ON d.token_hash=s.token_hash WHERE d.form_id=%d', self::subjects(), self::table(), $id ) ) : false; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Private encrypted draft/subject tables are fixed plugin names. Expiry, erasure and form deletion use prepared identifiers/values and transactional rollback. Fresh privacy state must not be cached. $drafts = false !== $subjects ? $wpdb->delete( self::table(), array( 'form_id' => $id ) ) : false; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Private encrypted draft/subject tables are fixed plugin names. Expiry, erasure and form deletion use prepared identifiers/values and transactional rollback. Fresh privacy state must not be cached. if ( false === $subjects || false === $drafts || ( ! $outer && false === $wpdb->query( 'COMMIT' ) ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Private encrypted draft/subject tables are fixed plugin names. Expiry, erasure and form deletion use prepared identifiers/values and transactional rollback. Fresh privacy state must not be cached. if ( $outer ) { do_action( 'kconv_authoring_storage_error', 'draft_delete' ); } else { $wpdb->query( 'ROLLBACK' ); } return false; } return true; } public static function exporters( $exporters ) { if ( false !== self::inventory() ) { $exporters['killerconversions-drafts'] = array( 'exporter_friendly_name' => 'KillerConversions saved visitor drafts', 'callback' => array( __CLASS__, 'privacy_export' ) ); } return $exporters; } public static function erasers( $erasers ) { if ( false !== self::inventory() ) { $erasers['killerconversions-drafts'] = array( 'eraser_friendly_name' => 'KillerConversions saved visitor drafts', 'callback' => array( __CLASS__, 'privacy_erase' ) ); } return $erasers; } public static function privacy_export( $email, $page = 1 ) { $available = self::inventory(); if ( is_wp_error( $available ) ) { return $available; } if ( ! $available ) { return array( 'data' => array(), 'done' => true ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Private encrypted draft/subject tables are fixed plugin names. Expiry, erasure and form deletion use prepared identifiers/values and transactional rollback. Fresh privacy state must not be cached. global $wpdb; $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT d.* FROM %i d INNER JOIN %i s ON s.token_hash=d.token_hash WHERE s.email_hash=%s AND d.expires_at>%d ORDER BY d.token_hash LIMIT %d OFFSET %d', self::table(), self::subjects(), self::email_hash( $email ), time(), self::EXPORT_PAGE_SIZE, max( 0, ( (int) $page - 1 ) * self::EXPORT_PAGE_SIZE ) ), ARRAY_A ); $data = array(); if ( $wpdb->last_error ) { return self::storage_error(); } foreach ( $rows as $row ) { $envelope = self::decrypt_envelope( $row ); $items = array( array( 'name' => 'Form', 'value' => (string) $row['form_id'] ), array( 'name' => 'Expires (UTC)', 'value' => gmdate( 'Y-m-d H:i:s', $row['expires_at'] ) ) ); if ( is_wp_error( $envelope ) ) { $items[] = array( 'name' => 'Draft', 'value' => 'Encrypted content cannot be recovered with this site’s current keys.' ); } else { foreach ( $envelope['values'] as $key => $value ) { $items[] = array( 'name' => $key, 'value' => KCONV_Core::value_text( $value ) ); } // Verified privacy export includes the subject's attachments; visitor load never exposes file bodies. if ( isset( $envelope['files'] ) && is_array( $envelope['files'] ) ) { foreach ( $envelope['files'] as $field => $file ) { if ( ! is_array( $file ) || ! isset( $file['name'], $file['type'], $file['data'], $file['sha256'] ) || ! is_string( $file['data'] ) || strlen( $file['data'] ) > 13981016 || ! is_string( $file['sha256'] ) ) { continue; } $bytes = base64_decode( $file['data'], true ); if ( ! is_string( $bytes ) || strlen( $bytes ) > 10485760 || ! hash_equals( $file['sha256'], hash( 'sha256', $bytes ) ) ) { continue; } $items[] = array( 'name' => $field . ' attachment', 'value' => $file['name'] . ' (' . $file['type'] . ', ' . strlen( $bytes ) . ' bytes; SHA-256 ' . $file['sha256'] . ')' ); $items[] = array( 'name' => $field . ' attachment body (base64)', 'value' => $file['data'] ); } } } $data[] = array( 'group_id' => 'killerconversions-drafts', 'group_label' => 'KillerConversions saved visitor drafts', 'item_id' => 'kconv-draft-' . substr( $row['token_hash'], 0, 16 ), 'data' => $items ); } return array( 'data' => $data, 'done' => count( $rows ) < self::EXPORT_PAGE_SIZE ); } /** Own a fresh native transaction without committing a caller's pending work. */ private static function begin_erasure() { global $wpdb; $connection = $wpdb->dbh; if ( ! ( $connection instanceof mysqli ) ) { return false; } // Native calls keep a query filter from replacing the ownership probe or // starting an outer transaction between that probe and START TRANSACTION. $name = 'kconv_privacy_' . str_replace( '-', '', wp_generate_uuid4() ); try { // phpcs:ignore WordPress.DB.RestrictedFunctions.mysql_mysqli_query -- WordPress has no transaction-state API; this private native probe must bypass SQL-rewriting filters. The savepoint identifier is locally generated and prepared. if ( false === mysqli_query( $connection, $wpdb->prepare( 'SAVEPOINT %i', $name ) ) ) { return false; } try { // phpcs:ignore WordPress.DB.RestrictedFunctions.mysql_mysqli_query,WordPress.PHP.NoSilencedErrors.Discouraged -- Missing savepoint error1305 is the expected autocommit result; no caller write is committed or rolled back. Other native errors refuse erasure. $released = @mysqli_query( $connection, $wpdb->prepare( 'RELEASE SAVEPOINT %i', $name ) ); } catch ( mysqli_sql_exception $error ) { $released = false; } // phpcs:ignore WordPress.DB.RestrictedFunctions.mysql_mysqli_errno -- Only the native numeric missing-savepoint code establishes that no transaction owned the probe. if ( false !== $released || 1305 !== mysqli_errno( $connection ) ) { return false; } // phpcs:ignore WordPress.DB.RestrictedFunctions.mysql_mysqli_query -- Immediately own the verified idle native connection; a query filter must not insert an outer transaction here. return true === mysqli_query( $connection, 'START TRANSACTION' ); } catch ( Throwable $error ) { return false; } } public static function privacy_erase( $email, $page = 1 ) { $available = self::inventory(); if ( is_wp_error( $available ) ) { return self::erasure_error(); } if ( ! $available ) { return array( 'items_removed' => false, 'items_retained' => false, 'messages' => array(), 'done' => true ); } global $wpdb; foreach ( array( self::table(), self::subjects() ) as $table ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The bounded privacy batch requires transactional draft and subject storage; cached engine state cannot guarantee rollback. $engine = $wpdb->get_var( $wpdb->prepare( 'SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', $table ) ); if ( $wpdb->last_error || 'innodb' !== strtolower( (string) $engine ) ) { return self::erasure_error(); } } if ( ! self::begin_erasure() ) { return self::erasure_error(); } try { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Fixed table, prepared email hash and a bounded locked batch protect the selected privacy records through deletion. $hashes = $wpdb->get_col( $wpdb->prepare( 'SELECT token_hash FROM %i WHERE email_hash=%s ORDER BY token_hash LIMIT 100 FOR UPDATE', self::subjects(), self::email_hash( $email ) ) ); if ( $wpdb->last_error ) { throw new RuntimeException( 'draft_lookup' ); } foreach ( $hashes as $hash ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The token hashes come from the locked bounded selection; draft and subject removals share this transaction. if ( false === $wpdb->delete( self::table(), array( 'token_hash' => $hash ) ) ) { throw new RuntimeException( 'draft_delete' ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Delete all subject references to the erased draft in the same transaction. if ( false === $wpdb->delete( self::subjects(), array( 'token_hash' => $hash ) ) ) { throw new RuntimeException( 'draft_subject_delete' ); } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Report removed records only after the complete bounded privacy batch commits. if ( false === $wpdb->query( 'COMMIT' ) ) { throw new RuntimeException( 'draft_commit' ); } } catch ( Throwable $error ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Roll back this failed privacy batch; the next explicit request can retry safely. $wpdb->query( 'ROLLBACK' ); return self::erasure_error(); } return array( 'items_removed' => ! empty( $hashes ), 'items_retained' => false, 'messages' => array(), 'done' => count( $hashes ) < 100 ); } }