false, 'show_ui' => false, 'supports' => array( 'title' ), 'rewrite' => false ) ); wp_register_script( 'sendtim-form-block', plugins_url( 'assets/form-block.js', SENDTIM_SIGNUP_FILE ), array( 'wp-blocks', 'wp-element', 'wp-components', 'wp-block-editor' ), SENDTIM_SIGNUP_VERSION, true ); wp_localize_script( 'sendtim-form-block', 'sendtimFormChoices', self::choices() ); register_block_type( 'sendtim/signup-form', array( 'api_version' => 3, 'editor_script' => 'sendtim-form-block', 'attributes' => array( 'formId' => array( 'type' => 'string', 'default' => '' ) ), 'render_callback' => function ( $attributes ) { return self::shortcode( array( 'id' => $attributes['formId'] ?? '' ) ); }, ) ); } public static function settings() { return wp_parse_args( get_option( self::OPTIONS, array() ), array( 'popups' => true, 'double_opt_in' => false ) ); } public static function posts() { return get_posts( array( 'post_type' => self::TYPE, 'post_status' => 'publish', 'numberposts' => -1, 'orderby' => 'title', 'order' => 'ASC' ) ); } public static function choices() { return array_map( function ( $post ) { return array( 'value' => (string) $post->ID, 'label' => $post->post_title ); }, self::posts() ); } /** Cache is bound to the saved credential, so changing workspaces cannot reuse forms. */ public static function remote( $fresh = false ) { $key = Sendtim_Signup_Settings::api_key(); if ( '' === $key ) { return new WP_Error( 'not_connected', 'Connect your Sendtim account first.' ); } $cache = 'sendtim_forms_' . hash( 'sha256', $key ); $rows = $fresh ? false : get_transient( $cache ); if ( false !== $rows ) { return $rows; } $rows = array(); $page = 1; do { $result = ( new Sendtim_Signup_Api() )->forms( $page ); if ( is_wp_error( $result ) ) { set_transient( $cache, $result, 30 ); return $result; } if ( ! isset( $result['data'] ) || ! is_array( $result['data'] ) ) { return new WP_Error( 'invalid_forms', 'Invalid Sendtim forms response.' ); } $rows = array_merge( $rows, $result['data'] ); $page++; } while ( ! empty( $result['hasMore'] ) && $page <= 100 ); if ( ! empty( $result['hasMore'] ) ) { return new WP_Error( 'form_limit', 'Too many forms to synchronize.' ); } set_transient( $cache, $rows, MINUTE_IN_SECONDS ); return $rows; } public static function remote_form( $id, $fresh = false ) { $rows = self::remote( $fresh ); if ( is_wp_error( $rows ) ) { return $rows; } foreach ( $rows as $row ) { if ( ( $row['id'] ?? '' ) === $id && 'published' === ( $row['status'] ?? '' ) ) { $origin = wp_parse_url( home_url( '/' ) ); $origin = $origin['scheme'] . '://' . $origin['host'] . ( isset( $origin['port'] ) ? ':' . $origin['port'] : '' ); if ( ! empty( $row['allowedOrigins'] ) && ! in_array( $origin, $row['allowedOrigins'], true ) ) { return new WP_Error( 'origin', 'This website is not allowed to display the form.' ); } return array( 'title' => $row['name'], 'definition' => $row['definition'], 'list_id' => $row['listId'] ?? '', 'list_double_opt_in' => ! empty( $row['listDoubleOptIn'] ), 'interests' => array(), 'remote_id' => $id ); } } return new WP_Error( 'unavailable', 'This form is no longer published.' ); } public static function resolve( $ref, $fresh = false ) { if ( 0 === strpos( $ref, 'remote:' ) ) { return self::remote_form( substr( $ref, 7 ), $fresh ); } $post = get_post( absint( $ref ) ); if ( ! $post || self::TYPE !== $post->post_type || 'publish' !== $post->post_status ) { return new WP_Error( 'unavailable', 'Form unavailable.' ); } $form = get_post_meta( $post->ID, self::META, true ); if ( ! is_array( $form ) ) { return new WP_Error( 'unavailable', 'Form unavailable.' ); } // A credential change requires deliberately reconnecting saved forms. if ( ! hash_equals( $form['account_hash'] ?? '', hash( 'sha256', Sendtim_Signup_Settings::api_key() ) ) ) { return new WP_Error( 'account', 'Reconnect this form to your current account.' ); } if ( ! empty( $form['remote_id'] ) ) { return self::remote_form( $form['remote_id'], $fresh ); } $form['title'] = $post->post_title; return $form; } public static function assets() { wp_enqueue_style( 'sendtim-forms', plugins_url( 'assets/forms.css', SENDTIM_SIGNUP_FILE ), array(), SENDTIM_SIGNUP_VERSION ); if ( self::settings()['popups'] && '' !== Sendtim_Signup_Settings::api_key() ) { wp_enqueue_script( 'sendtim-forms', plugins_url( 'assets/forms.js', SENDTIM_SIGNUP_FILE ), array(), SENDTIM_SIGNUP_VERSION, true ); } } public static function shortcode( $attributes ) { $attributes = shortcode_atts( array( 'id' => '' ), $attributes ); $ref = (string) absint( $attributes['id'] ); $form = self::resolve( $ref ); if ( is_wp_error( $form ) || '' === Sendtim_Signup_Settings::api_key() ) { return ''; } // Account pop-ups are injected once globally, never again through an embed. if ( ! empty( $form['remote_id'] ) && 'inline' !== ( $form['definition']['settings']['display']['mode'] ?? 'inline' ) ) { return ''; } return self::render( $form, $ref ); } public static function render( $form, $ref ) { $d = $form['definition']; $s = $d['style']; $uid = wp_unique_id( 'sendtim-' ); $accent = sanitize_hex_color( $s['accentColor'] ?? '#4f46e5' ) ?: '#4f46e5'; $text = sanitize_hex_color( $s['textColor'] ?? '#172033' ) ?: '#172033'; $background = sanitize_hex_color( $s['backgroundColor'] ?? '#ffffff' ) ?: '#ffffff'; $style = '--st-accent:' . $accent . ';--st-text:' . $text . ';--st-bg:' . $background . ';--st-radius:' . min( 24, absint( $s['borderRadius'] ?? 12 ) ) . 'px;max-width:' . min( 720, max( 240, absint( $s['width'] ?? 480 ) ) ) . 'px'; $fonts = array( 'system' => 'system-ui,sans-serif', 'sans' => 'Arial,sans-serif', 'serif' => 'Georgia,serif', 'mono' => 'monospace' ); $style .= ';font-family:' . ( $fonts[ $s['fontFamily'] ?? 'system' ] ?? $fonts['system'] ); $status = self::input( $_GET, 'sendtim_result' ); $matches = self::input( $_GET, 'sendtim_form' ) === $ref; ob_start(); ?>

', esc_attr( $name ), esc_attr( $field['value'] ?? '' ) ); return; } $check = in_array( $type, array( 'checkbox', 'consent' ), true ); echo '
'; printf( '', esc_attr( $id ), $hide_label && ! $check ? 'screen-reader-text' : '', esc_html( $field['label'] ), ! empty( $field['required'] ) ? ' *' : '' ); $common = ' id="' . esc_attr( $id ) . '" name="' . esc_attr( $name ) . '"' . ( ! empty( $field['required'] ) ? ' required' : '' ); if ( ! empty( $field['helpText'] ) ) { $common .= ' aria-describedby="' . esc_attr( $id . '-help' ) . '"'; } $common .= ' placeholder="' . esc_attr( $field['placeholder'] ?? '' ) . '"'; // All values in $common are escaped above. Element names are fixed below. if ( 'textarea' === $type ) { echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } elseif ( 'select' === $type ) { echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped foreach ( $field['options'] as $option ) { printf( '', esc_attr( $option['value'] ), selected( $value, $option['value'], false ), esc_html( $option['label'] ) ); } echo ''; } else { $input_type = $check ? 'checkbox' : ( 'phone' === $type ? 'tel' : ( in_array( $type, array( 'email', 'number' ), true ) ? $type : 'text' ) ); $autocomplete = array( 'email' => 'email', 'first_name' => 'given-name', 'last_name' => 'family-name', 'phone' => 'tel' ); $auto = $autocomplete[ $field['mapping']['key'] ?? $type ] ?? 'off'; echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } if ( ! empty( $field['helpText'] ) ) { echo '' . esc_html( $field['helpText'] ) . ''; } echo '
'; } public static function popups() { if ( ! self::settings()['popups'] || '' === Sendtim_Signup_Settings::api_key() || is_admin() || is_feed() ) { return; } $rows = self::remote(); if ( is_wp_error( $rows ) ) { return; } foreach ( $rows as $row ) { $display = $row['definition']['settings']['display'] ?? array( 'mode' => 'inline' ); if ( ! in_array( $display['mode'], array( 'popup', 'slide_in' ), true ) ) { continue; } $form = self::remote_form( $row['id'] ); if ( is_wp_error( $form ) ) { continue; } $ref = 'remote:' . $row['id']; printf( '', esc_attr( $row['id'] ), esc_attr( $display['mode'] ), absint( $display['delaySeconds'] ?? 5 ), absint( $display['scrollPercent'] ?? 50 ), ! empty( $display['exitIntent'] ) ? '1' : '0', esc_attr( $row['name'] ), esc_attr__( 'Close', 'sendtim-signup-forms' ), self::render( $form, $ref ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- renderer escapes every field. } } public static function input( $input, $key ) { return isset( $input[ $key ] ) && is_scalar( $input[ $key ] ) ? sanitize_text_field( wp_unslash( (string) $input[ $key ] ) ) : ''; } /** Validate only the stored definition; visitors cannot supply fields, mapping or lists. */ public static function contact( $form, $input ) { $contact = array( 'source' => substr( $form['definition']['settings']['sourceLabel'] ?? 'wordpress:signup', 0, 64 ), 'consentGiven' => true, 'consentSource' => 'form' ); $mapping = array( 'first_name' => 'firstName', 'last_name' => 'lastName', 'company' => 'company', 'job_title' => 'jobTitle', 'phone' => 'phone', 'country' => 'country', 'city' => 'city' ); $limits = array( 'first_name' => 120, 'last_name' => 120, 'company' => 200, 'job_title' => 200, 'phone' => 50, 'country' => 100, 'city' => 120 ); foreach ( $form['definition']['fields'] as $field ) { $type = $field['type']; $value = 'hidden' === $type ? ( $field['value'] ?? '' ) : self::input( $input, $field['id'] ); $is_check = in_array( $type, array( 'checkbox', 'consent' ), true ); if ( $is_check ) { $value = in_array( $value, array( '1', 'on', 'true' ), true ); } if ( ( ! empty( $field['required'] ) && ( '' === $value || false === $value ) ) || ( 'consent' === $type && ! $value ) ) { return new WP_Error( 'field', 'Required field missing.' ); } if ( strlen( (string) $value ) > ( 'textarea' === $type ? 1000 : 500 ) ) { return new WP_Error( 'field', 'Field is too long.' ); } if ( 'email' === $type ) { if ( ! is_email( $value ) || strlen( $value ) > 254 ) { return new WP_Error( 'email', 'Invalid email.' ); } $contact['email'] = strtolower( $value ); continue; } if ( 'select' === $type && '' !== $value && ! in_array( $value, array_column( $field['options'], 'value' ), true ) ) { return new WP_Error( 'field', 'Invalid choice.' ); } if ( 'number' === $type && '' !== $value ) { if ( ! is_numeric( $value ) || ! is_finite( (float) $value ) ) { return new WP_Error( 'field', 'Invalid number.' ); } $value = (float) $value; } $map = $field['mapping'] ?? array( 'kind' => 'none' ); if ( 'contact_field' === $map['kind'] && isset( $mapping[ $map['key'] ] ) && '' !== $value ) { if ( strlen( (string) $value ) > $limits[ $map['key'] ] ) { return new WP_Error( 'field', 'Field is too long.' ); } $contact[ $mapping[ $map['key'] ] ] = (string) $value; } elseif ( 'custom_property' === $map['kind'] && preg_match( '/^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,63}$/', $map['key'] ) ) { $contact['customProperties'][ $map['key'] ] = $value; } if ( 'consent' === $type ) { $contact['customProperties']['sendtim_consent_text'] = $field['label']; } } return isset( $contact['email'] ) ? $contact : new WP_Error( 'email', 'Email field missing.' ); } public static function signup() { $ref = self::input( $_POST, 'form' ); if ( ! wp_verify_nonce( self::input( $_POST, '_wpnonce' ), 'sendtim_form_' . $ref ) || (int) ( $_SERVER['CONTENT_LENGTH'] ?? 0 ) > 32768 ) { self::redirect( $ref, 'error' ); } $form = self::resolve( $ref, true ); if ( is_wp_error( $form ) || '' === Sendtim_Signup_Settings::api_key() ) { self::redirect( $ref, 'error' ); } if ( '' !== self::input( $_POST, 'website' ) ) { self::redirect( $ref, 'confirm' ); } $contact = self::contact( $form, isset( $_POST['fields'] ) && is_array( $_POST['fields'] ) ? $_POST['fields'] : array() ); if ( is_wp_error( $contact ) ) { self::redirect( $ref, 'error' ); } foreach ( array( array( strtolower( $contact['email'] ), 5 ), array( $_SERVER['REMOTE_ADDR'] ?? 'unknown', 30 ) ) as $limit ) { $key = 'sendtim_forms_limit_' . hash_hmac( 'sha256', $limit[0], wp_salt( 'nonce' ) ); $count = (int) get_transient( $key ); if ( $count >= $limit[1] ) { self::redirect( $ref, 'error' ); } set_transient( $key, $count + 1, HOUR_IN_SECONDS ); } $lists = empty( $form['list_id'] ) ? array() : array( $form['list_id'] ); $chosen = isset( $_POST['interests'] ) && is_array( $_POST['interests'] ) ? array_filter( $_POST['interests'], 'is_string' ) : array(); foreach ( $form['interests'] ?? array() as $interest ) { if ( in_array( $interest['id'], $chosen, true ) ) { $lists[] = $interest['id']; } } $rule = $form['definition']['settings']['doubleOptIn'] ?? 'inherit'; $double = empty( $form['remote_id'] ) ? self::settings()['double_opt_in'] : ( 'on' === $rule || ( 'inherit' === $rule && ! empty( $form['list_double_opt_in'] ) ) ); $payload = array( 'contact' => $contact, 'lists' => array_values( array_unique( $lists ) ), 'ref' => $ref, 'return' => wp_validate_redirect( wp_get_referer(), home_url( '/' ) ), 'account' => hash( 'sha256', Sendtim_Signup_Settings::api_key() ) ); if ( $double ) { $result = self::send_confirmation( $payload ); self::redirect( $ref, is_wp_error( $result ) ? 'error' : 'confirm' ); } $result = self::subscribe( $payload ); self::redirect( $ref, is_wp_error( $result ) ? 'error' : 'success', '', $form ); } public static function subscribe( $payload ) { if ( ! hash_equals( $payload['account'], hash( 'sha256', Sendtim_Signup_Settings::api_key() ) ) ) { return new WP_Error( 'account', 'Account changed.' ); } $api = new Sendtim_Signup_Api(); $lists = $payload['lists']; $result = $api->subscribe_contact( $payload['contact'], array_shift( $lists ) ?? '' ); if ( is_wp_error( $result ) ) { return $result; } if ( $lists ) { return $api->add_lists( $result['id'], $lists ); } return $result; } public static function send_confirmation( $payload ) { try { $token = bin2hex( random_bytes( 24 ) ); } catch ( Throwable $e ) { return new WP_Error( 'token', 'Could not generate confirmation.' ); } $key = 'sendtim_forms_confirm_' . hash( 'sha256', $token ); if ( ! set_transient( $key, $payload, 2 * DAY_IN_SECONDS ) ) { return new WP_Error( 'token', 'Could not save confirmation.' ); } $link = add_query_arg( array( 'action' => 'sendtim_forms_confirm', 'token' => $token ), admin_url( 'admin-post.php' ) ); $message = sprintf( // translators: %s: Link to confirm the email subscription. __( 'Confirm your email subscription within 48 hours: %s', 'sendtim-signup-forms' ), $link ); $sent = wp_mail( $payload['contact']['email'], __( 'Confirm your subscription', 'sendtim-signup-forms' ), $message ); if ( ! $sent ) { delete_transient( $key ); return new WP_Error( 'mail', 'Confirmation could not be sent.' ); } return true; } public static function confirm() { nocache_headers(); header( 'Referrer-Policy: no-referrer' ); $token = self::input( 'POST' === ( $_SERVER['REQUEST_METHOD'] ?? '' ) ? $_POST : $_GET, 'token' ); if ( ! preg_match( '/^[a-f0-9]{48}$/', $token ) ) { self::redirect( '', 'error' ); } $key = 'sendtim_forms_confirm_' . hash( 'sha256', $token ); $payload = get_transient( $key ); if ( ! is_array( $payload ) ) { self::redirect( '', 'error' ); } $form = self::resolve( $payload['ref'], true ); if ( is_wp_error( $form ) ) { self::redirect( $payload['ref'], 'error', $payload['return'] ); } if ( 'POST' !== ( $_SERVER['REQUEST_METHOD'] ?? '' ) ) { $html = '

' . esc_html__( 'Ready to join the list?', 'sendtim-signup-forms' ) . '

'; wp_die( $html, esc_html__( 'Confirm subscription', 'sendtim-signup-forms' ), array( 'response' => 200 ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } $payload['contact']['consentSource'] = 'double_opt_in'; $result = self::subscribe( $payload ); if ( ! is_wp_error( $result ) ) { delete_transient( $key ); } self::redirect( $payload['ref'], is_wp_error( $result ) ? 'error' : 'success', $payload['return'], $form ); } private static function redirect( $ref, $status, $url = '', $form = array() ) { $url = $url ?: ( wp_get_referer() ?: home_url( '/' ) ); $success = $form['definition']['settings']['successBehavior'] ?? array(); if ( 'success' === $status && 'redirect' === ( $success['kind'] ?? '' ) && 0 === strpos( $success['url'] ?? '', 'https://' ) ) { // Explicit destination from the administrator-owned definition; never from POST. wp_redirect( esc_url_raw( $success['url'] ), 303 ); exit; } wp_safe_redirect( add_query_arg( array( 'sendtim_form' => $ref, 'sendtim_result' => $status ), remove_query_arg( array( 'sendtim_form', 'sendtim_result' ), $url ) ), 303 ); exit; } }