valid_subject( $subject_type, $subject_id ) ) { throw new SubscriptionException( esc_html__( 'Enter a valid email address and subscription subject.', 'senti-feedback' ) ); } $verification = bin2hex( random_bytes( 32 ) ); $table = "{$this->wpdb->prefix}senti_subscriptions"; $hash = $this->email_hash( $email ); $query = $this->wpdb->prepare( 'SELECT id, state FROM %i WHERE email_hash = %s AND subject_type = %s AND subject_id = %d', $table, $hash, $subject_type, $subject_id ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $query is prepared immediately above. $existing = is_string( $query ) ? $this->wpdb->get_row( $query ) : null; $user_id = get_current_user_id(); $user = $user_id > 0 ? get_userdata( $user_id ) : false; $user_id = $user instanceof \WP_User && strtolower( $user->user_email ) === strtolower( $email ) ? $user_id : null; $values = [ 'email' => $email, 'email_hash' => $hash, 'user_id' => $user_id, 'subject_type' => $subject_type, 'subject_id' => $subject_id, 'state' => 'pending', 'verification_token_hash' => $this->token_hash( $verification ), 'unsubscribe_token_hash' => '', 'source' => 'public', 'created_gmt' => current_time( 'mysql', true ), 'verified_gmt' => null, 'unsubscribed_gmt' => null, ]; if ( $existing !== null ) { if ( $existing->state === 'active' ) { return (int) $existing->id; } $updated = $this->wpdb->update( $table, $values, [ 'id' => (int) $existing->id ] ); if ( $updated === false ) { throw new SubscriptionException( esc_html__( 'The subscription could not be saved.', 'senti-feedback' ) ); } $id = (int) $existing->id; } else { $inserted = $this->wpdb->insert( $table, $values ); if ( $inserted === false ) { // The unique key handles simultaneous attempts; reuse the row created by the winning request. $query = $this->wpdb->prepare( 'SELECT id, state FROM %i WHERE email_hash = %s AND subject_type = %s AND subject_id = %d', $table, $hash, $subject_type, $subject_id ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $query is prepared immediately above. $raced = is_string( $query ) ? $this->wpdb->get_row( $query ) : null; if ( $raced !== null && $raced->state === 'active' ) { return (int) $raced->id; } if ( $raced !== null ) { throw new SubscriptionException( esc_html__( 'A confirmation email is already being prepared for this address.', 'senti-feedback' ) ); } throw new SubscriptionException( esc_html__( 'The subscription could not be saved.', 'senti-feedback' ) ); } $id = (int) $this->wpdb->insert_id; } if ( $id === 0 ) { throw new SubscriptionException( esc_html__( 'The subscription could not be saved.', 'senti-feedback' ) ); } $token_saved = $this->wpdb->update( $table, [ 'unsubscribe_token_hash' => $this->token_hash( $this->unsubscribe_token( $id, $email ) ) ], [ 'id' => $id ], [ '%s' ], [ '%d' ] ); if ( $token_saved === false ) { throw new SubscriptionException( esc_html__( 'The subscription could not be saved.', 'senti-feedback' ) ); } if ( ! $this->mailer->send( $email, __( 'Confirm your Senti subscription', 'senti-feedback' ), sprintf( /* translators: %s: Subscription confirmation URL. */ __( 'Confirm your subscription: %s', 'senti-feedback' ), add_query_arg( [ 'action' => 'senti_verify_subscription', 'token' => rawurlencode( $verification ), ], admin_url( 'admin-post.php' ) ) ) ) ) { throw new SubscriptionException( esc_html__( 'The confirmation email could not be sent. Please try again.', 'senti-feedback' ) ); } return $id; } public function unsubscribe_url( int $subscription_id, string $email ): string { return add_query_arg( [ 'action' => 'senti_unsubscribe', 'token' => rawurlencode( $this->unsubscribe_token( $subscription_id, $email ) ), ], admin_url( 'admin-post.php' ) ); } public function unsubscribe_token_is_valid( string $token ): bool { if ( ! preg_match( '/^(\d+)\.([a-f0-9]{64})$/', $token, $matches ) ) { return false; } $query = $this->wpdb->prepare( 'SELECT id, unsubscribe_token_hash, state FROM %i WHERE id = %d', $this->wpdb->prefix . 'senti_subscriptions', (int) $matches[1] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $query is prepared immediately above. $row = is_string( $query ) ? $this->wpdb->get_row( $query ) : null; return $row !== null && $row->state === 'active' && hash_equals( (string) $row->unsubscribe_token_hash, $this->token_hash( $token ) ); } public function verify( string $token ): bool { return $this->wpdb->update( "{$this->wpdb->prefix}senti_subscriptions", [ 'state' => 'active', 'verified_gmt' => current_time( 'mysql', true ), 'verification_token_hash' => null, ], [ 'verification_token_hash' => $this->token_hash( $token ), 'state' => 'pending', ], [ '%s', '%s', '%s' ], [ '%s', '%s' ] ) === 1; } public function unsubscribe( string $token ): bool { $changed = $this->wpdb->update( "{$this->wpdb->prefix}senti_subscriptions", [ 'state' => 'unsubscribed', 'unsubscribed_gmt' => current_time( 'mysql', true ), ], [ 'unsubscribe_token_hash' => $this->token_hash( $token ), 'state' => 'active', ], [ '%s', '%s' ], [ '%s', '%s' ] ) === 1; if ( $changed ) { $this->wpdb->update( "{$this->wpdb->prefix}senti_notifications", [ 'state' => 'cancelled' ], [ 'subscription_id' => $this->subscription_id_from_token( $token ), 'state' => 'pending', ], [ '%s' ], [ '%d', '%s' ] ); } return $changed; } /** @return int[] */ public function active_for_feedback( int $feedback_id ): array { $table = "{$this->wpdb->prefix}senti_subscriptions"; $query = $this->wpdb->prepare( 'SELECT id FROM %i WHERE state = %s AND subject_type = %s AND subject_id = %d', $table, 'active', 'feedback', $feedback_id ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $query is prepared immediately above. return is_string( $query ) ? array_map( 'intval', $this->wpdb->get_col( $query ) ) : []; } /** @return int[] */ public function active_for_release( int $release_id ): array { $product_ids = wp_get_post_terms( $release_id, Identifiers::TAXONOMY_PRODUCT, [ 'fields' => 'ids' ] ); $query = $this->wpdb->prepare( 'SELECT source_id FROM %i WHERE target_id = %d AND relationship = %s', $this->wpdb->prefix . 'senti_relationships', $release_id, 'feedback_release' ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $query is prepared immediately above. $feedback_ids = is_string( $query ) ? array_values( array_unique( array_map( 'intval', $this->wpdb->get_col( $query ) ) ) ) : []; $product_ids = is_wp_error( $product_ids ) ? [] : array_values( array_unique( array_map( 'intval', $product_ids ) ) ); $table = "{$this->wpdb->prefix}senti_subscriptions"; $clauses = [ "(subject_type = 'portal' AND subject_id = 0)" ]; $params = [ $table, 'active' ]; if ( $product_ids !== [] ) { $prod_placeholders = implode( ',', array_fill( 0, count( $product_ids ), '%d' ) ); $clauses[] = "(subject_type = 'product' AND subject_id IN ($prod_placeholders))"; $params = array_merge( $params, $product_ids ); } if ( $feedback_ids !== [] ) { $fb_placeholders = implode( ',', array_fill( 0, count( $feedback_ids ), '%d' ) ); $clauses[] = "(subject_type = 'feedback' AND subject_id IN ($fb_placeholders))"; $params = array_merge( $params, $feedback_ids ); } $where_clause = implode( ' OR ', $clauses ); $sql = "SELECT id FROM %i WHERE state = %s AND ($where_clause)"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared $results_query = $this->wpdb->prepare( $sql, ...$params ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $results = is_string( $results_query ) ? array_map( 'intval', $this->wpdb->get_col( $results_query ) ) : []; return array_values( array_unique( $results ) ); } private function valid_subject( string $type, int $id ): bool { if ( $type === 'portal' ) { return $id === 0; } if ( $type === 'feedback' ) { return get_post_type( $id ) === Identifiers::POST_TYPE_FEEDBACK && in_array( get_post_status( $id ), [ 'publish', 'pending' ], true ); } if ( $type !== 'product' ) { return false; } $term = term_exists( $id, Identifiers::TAXONOMY_PRODUCT ); return is_int( $term ) || is_array( $term ); } private function email_hash( string $email ): string { return hash( 'sha256', strtolower( $email ) ); } private function token_hash( string $token ): string { return hash( 'sha256', $token ); } private function unsubscribe_token( int $subscription_id, string $email ): string { $payload = $subscription_id . ':' . strtolower( sanitize_email( $email ) ); return $subscription_id . '.' . hash( 'sha256', $payload ); } private function subscription_id_from_token( string $token ): int { $separator = strpos( $token, '.' ); return $separator === false ? 0 : absint( substr( $token, 0, $separator ) ); } }