isset( $_POST[ self::FIELD_LIST ] ) && is_string( $_POST[ self::FIELD_LIST ] ) ? sanitize_key( wp_unslash( $_POST[ self::FIELD_LIST ] ) ) : '', self::FIELD_EMAIL => isset( $_POST[ self::FIELD_EMAIL ] ) && is_string( $_POST[ self::FIELD_EMAIL ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::FIELD_EMAIL ] ) ) : '', self::FIELD_NAME => isset( $_POST[ self::FIELD_NAME ] ) && is_string( $_POST[ self::FIELD_NAME ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::FIELD_NAME ] ) ) : '', self::FIELD_CONSENT => isset( $_POST[ self::FIELD_CONSENT ] ) && is_string( $_POST[ self::FIELD_CONSENT ] ) ? sanitize_key( wp_unslash( $_POST[ self::FIELD_CONSENT ] ) ) : '', self::FIELD_CONSENT_HASH => isset( $_POST[ self::FIELD_CONSENT_HASH ] ) && is_string( $_POST[ self::FIELD_CONSENT_HASH ] ) ? sanitize_key( wp_unslash( $_POST[ self::FIELD_CONSENT_HASH ] ) ) : '', TimeTrap::FIELD => isset( $_POST[ TimeTrap::FIELD ] ) && is_string( $_POST[ TimeTrap::FIELD ] ) ? sanitize_text_field( wp_unslash( $_POST[ TimeTrap::FIELD ] ) ) : '', // Anything but an empty string gives a bot away, an array as well. Honeypot::FIELD => isset( $_POST[ Honeypot::FIELD ] ) ? ( is_string( $_POST[ Honeypot::FIELD ] ) ? sanitize_text_field( wp_unslash( $_POST[ Honeypot::FIELD ] ) ) : 'array' ) : '', ); // phpcs:enable WordPress.Security.NonceVerification.Missing $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; $path = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/'; // The answer depends on the input; no cache may keep it. nocache_headers(); $submission = self::process( $fields, self::client_ip( $ip ), time(), $path, (int) get_queried_object_id(), $verified ); if ( null !== $submission->redirect ) { wp_safe_redirect( $submission->redirect, 303 ); exit; } self::$current = $submission; } /** * The failed submission of this request for a list, if any. * * @param string $key List key. */ public static function current( string $key ): ?Submission { return null !== self::$current && self::$current->key === $key ? self::$current : null; } /** * Keep a failed submission for the renderer (handle() does this; also for tests). * * @param ?Submission $submission Submission or null to forget it. */ public static function remember( ?Submission $submission ): void { self::$current = $submission; } /** * Process a submitted form. * * @param array $fields Submitted fields, each read and sanitized on its own. * @param string $ip IP address of the visitor. * @param int $now Unix time. * @param string $path Path and query of the page the form was sent to. * @param int $page_id Post of that page, 0 if none. * @param bool $verified False if a logged-in user sent no valid nonce. */ public static function process( array $fields, string $ip, int $now, string $path, int $page_id, bool $verified = true ): Submission { $key = sanitize_key( $fields[ self::FIELD_LIST ] ?? '' ); $return = self::return_url( $path ); $mailing_list = '' === $key ? null : Settings::get( $key ); $typed = mb_substr( trim( $fields[ self::FIELD_EMAIL ] ?? '' ), 0, 255 ); $name = mb_substr( trim( $fields[ self::FIELD_NAME ] ?? '' ), 0, 255 ); $failed = static fn ( string $outcome ): Submission => new Submission( $key, $outcome, $typed, $name ); if ( null === $mailing_list ) { return $failed( self::UNAVAILABLE ); } if ( ! $verified ) { // The renderer shows the form again with a fresh nonce. return $failed( self::EXPIRED ); } $trap = TimeTrap::check( $fields[ TimeTrap::FIELD ] ?? null, $key, $now ); if ( Honeypot::triggered( $fields ) || TimeTrap::INVALID === $trap ) { // Bots get the same answer as people, so they learn nothing. return self::sent( $return, $key ); } if ( TimeTrap::TOO_FAST === $trap ) { return $failed( self::TOO_FAST ); } if ( ! RateLimit::hit( $ip, $now ) ) { return $failed( self::LIMITED ); } if ( ! hash_equals( Consent::hash( $mailing_list ), $fields[ self::FIELD_CONSENT_HASH ] ?? '' ) ) { // The visitor saw another text: changed on the settings page meanwhile. Ask again. return $failed( self::CONSENT_CHANGED ); } $email = sanitize_email( $typed ); if ( '' === $email || ! is_email( $email ) ) { return $failed( self::INVALID ); } if ( '1' !== ( $fields[ self::FIELD_CONSENT ] ?? '' ) ) { return $failed( self::CONSENT ); } $result = Client::signup( $mailing_list->token, self::payload( $mailing_list, $email, $name, $return, $page_id, $ip ) ); $outcome = self::outcome( $result ); if ( self::SENT === $outcome ) { Status::signup_succeeded( $key ); return self::sent( $return, $key ); } if ( self::UNAVAILABLE === $outcome ) { Status::signup_failed( $key, $result, $now ); } return $failed( $outcome ); } /** * What the visitor learns about an answer of GruVer. * * @param Result $result Answer of POST /distribution/signup. */ public static function outcome( Result $result ): string { return match ( $result->code ) { Result::PENDING => self::SENT, Result::INVALID_EMAIL => self::INVALID, Result::CONSENT_MISSING => self::CONSENT, Result::RATE_LIMITED => self::LIMITED, default => self::UNAVAILABLE, }; } /** * Fields of SignupRequest. The IP address goes to GruVer shortened only. * * @param MailingList $mailing_list List. * @param string $email Email address. * @param string $name Name, may be empty. * @param string $source_url Page with the form. * @param int $page_id Post of that page, 0 if unknown. * @param string $ip Full IP address of the visitor. * @return array */ public static function payload( MailingList $mailing_list, string $email, string $name, string $source_url, int $page_id, string $ip ): array { $title = null; if ( $page_id > 0 && 'publish' === get_post_status( $page_id ) ) { $title = html_entity_decode( wp_strip_all_tags( get_the_title( $page_id ) ), ENT_QUOTES, 'UTF-8' ); } return array( 'email' => $email, 'name' => '' === $name ? null : $name, 'consent' => true, 'consent_text' => Consent::text( $mailing_list ), 'source_url' => $source_url, 'source_title' => '' === $title ? null : $title, 'ip' => false === filter_var( $ip, FILTER_VALIDATE_IP ) ? null : wp_privacy_anonymize_ip( $ip ), 'plugin_version' => GRUVER_SUBSCRIBE_VERSION, ); } /** * The page to return to: a path on this website, never somewhere else. * * @param string $path Path and query of the page with the form. */ public static function return_url( string $path ): string { $home = home_url( '/' ); $url = $home; if ( str_starts_with( $path, '/' ) && ! str_starts_with( $path, '//' ) ) { $parts = wp_parse_url( $home ); $url = ( $parts['scheme'] ?? 'https' ) . '://' . ( $parts['host'] ?? '' ) . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' ) . $path; } $url = wp_validate_redirect( esc_url_raw( $url ), $home ); return remove_query_arg( array( self::QUERY_STATUS, self::QUERY_LIST ), (string) strtok( $url, '#' ) ); } /** * Success: back to the page with the outcome, the form of the list in view. * * @param string $url Page with the form. * @param string $key List key. */ private static function sent( string $url, string $key ): Submission { $redirect = add_query_arg( array( self::QUERY_STATUS => self::SENT, self::QUERY_LIST => $key, ), $url ) . '#' . Renderer::anchor( $key ); return new Submission( $key, self::SENT, redirect: $redirect ); } /** * IP address of the visitor. REMOTE_ADDR by default; sites behind a proxy can pass the real address through the * filter. * * @param string $remote_addr REMOTE_ADDR. */ private static function client_ip( string $remote_addr ): string { /** * Filters the IP address used for the limit per IP and, shortened, for the proof of consent. * * @param string $ip REMOTE_ADDR of the request. */ return (string) apply_filters( 'gruver_subscribe_client_ip', $remote_addr ); } }