|null */ private ?array $conditions = null; /** * Compiled segments, cached after first use. * * @var array|null */ private ?array $segments = null; /** * Problems found while compiling the form. * * @var string[] */ private array $problems = array(); /** * Constructor. * * @param int $id Post ID. * @param string $title Form title. * @param string $template Template markup with form tags. * @param array $mail Mail settings (see default_mail_settings()). * @param array $messages Response messages (see default_messages()). * @param array $settings Behaviour settings (see default_settings()). * @param Form_Schema|null $schema Structured definition; when set it * replaces the template. */ public function __construct( public readonly int $id, public readonly string $title, public readonly string $template, public readonly array $mail, public readonly array $messages, public readonly array $settings = array(), public readonly ?Form_Schema $schema = null, ) {} /** * Loads a published form by ID. * * @param int $id Post ID. */ public static function find( int $id ): ?self { if ( $id <= 0 ) { return null; } $post = get_post( $id ); if ( ! $post instanceof \WP_Post || Form_Post_Type::POST_TYPE !== $post->post_type || 'publish' !== $post->post_status ) { return null; } return self::from_post( $post ); } /** * Builds a form from a post of any status (used by the admin screen). * * @param \WP_Post $post Form post. */ public static function from_post( \WP_Post $post ): self { $template = get_post_meta( $post->ID, Form_Post_Type::META_TEMPLATE, true ); $mail = get_post_meta( $post->ID, Form_Post_Type::META_MAIL, true ); $messages = get_post_meta( $post->ID, Form_Post_Type::META_MESSAGES, true ); $settings = get_post_meta( $post->ID, Form_Post_Type::META_SETTINGS, true ); $json = get_post_meta( $post->ID, Form_Post_Type::META_SCHEMA, true ); // A readable schema wins over the template; sections it leaves out // (notifications, confirmations, settings) come from the other meta. $schema = is_string( $json ) && '' !== $json ? Form_Schema::from_json( $json ) : null; $mail = $schema?->mail() ?? ( is_array( $mail ) ? $mail : array() ); $messages = array_merge( is_array( $messages ) ? $messages : array(), $schema?->messages() ?? array() ); $settings = array_merge( is_array( $settings ) ? $settings : array(), $schema?->settings() ?? array() ); return new self( $post->ID, // Plain text; it is escaped wherever it is output. html_entity_decode( get_the_title( $post ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ), is_string( $template ) && '' !== $template ? $template : self::default_template(), array_merge( self::default_mail_settings(), $mail ), array_merge( self::default_messages(), array_filter( $messages, 'strlen' ) ), array_merge( self::default_settings(), $settings ), $schema, ); } /** * The form as HTML strings, Field objects and Condition markers, in * render order, from the schema when there is one, else the template. * * @return array */ public function segments(): array { if ( null === $this->segments ) { if ( null !== $this->schema ) { $this->segments = $this->schema->segments(); $this->problems = $this->schema->errors(); } else { $parser = new Tag_Parser(); $this->segments = $parser->segments( $this->template ); $this->problems = $parser->errors(); } } return $this->segments; } /** * Problems found in the form definition (invalid fields and the like). * * @return string[] */ public function problems(): array { $this->segments(); return $this->problems; } /** * The form's fields in order. * * @return Field[] */ public function fields(): array { $this->fields ??= array_values( array_filter( $this->segments(), static fn( $segment ) => $segment instanceof Field ) ); return $this->fields; } /** * Conditions that apply to each field (outermost first), keyed by name. * * @return array */ public function conditions(): array { $this->conditions ??= Tag_Parser::conditions_in( $this->segments() ); return $this->conditions; } /** * Input fields keyed by name (excludes submit buttons). * * @return array */ public function input_fields(): array { $out = array(); foreach ( $this->fields() as $field ) { if ( $field->is_input() ) { $out[ $field->name ] = $field; } } return $out; } /** * Returns a response message by key. * * @param string $key Message key. */ public function message( string $key ): string { return $this->messages[ $key ] ?? self::default_messages()[ $key ] ?? ''; } // Defaults. /** * Template used for new forms. */ public static function default_template(): string { $row = static fn( string $label, string $tag ): string => '
'; return implode( "\n", array( $row( __( 'Your name', 'flexsolutions-form-builder' ), '[text* your-name autocomplete:name maxlength:100]' ), $row( __( 'Your email', 'flexsolutions-form-builder' ), '[email* your-email autocomplete:email]' ), $row( __( 'Subject', 'flexsolutions-form-builder' ), '[text your-subject maxlength:150]' ), $row( __( 'Your message', 'flexsolutions-form-builder' ), '[textarea* your-message maxlength:5000]' ), '
[submit "' . __( 'Send', 'flexsolutions-form-builder' ) . '"]
', ) ); } /** * Default mail settings. * * @return array */ public static function default_mail_settings(): array { return array( 'recipient' => '', 'cc' => '', 'bcc' => '', 'from_name' => '', 'from_email' => '', 'subject' => '[_site_title] "[your-subject]"', 'body' => "From: [your-name] <[your-email]>\nSubject: [your-subject]\n\nMessage:\n[your-message]\n\n-- \nSent from [_form_title] on [_site_title] ([_site_url])", 'reply_to' => '[your-email]', 'use_html' => false, 'exclude_blank' => false, 'attach_files' => true, 'autoreply_enabled' => false, 'autoreply_to' => '[your-email]', 'autoreply_subject' => '[_site_title] — ' . __( 'We received your message', 'flexsolutions-form-builder' ), 'autoreply_body' => __( "Thank you for getting in touch. We have received your message and will reply soon.\n\n-- \n[_site_title]", 'flexsolutions-form-builder' ), ); } /** * Default behaviour settings. * * @return array */ public static function default_settings(): array { return array( 'demo_mode' => false, // Validate but never send mail. 'logged_in_only' => false, // Only logged-in users can see and submit the form. 'redirect_url' => '', // Same-site URL to open after a successful submission. 'store_entries' => true, // Save submissions under Flexsolutions Form Builder → Entries. 'captcha' => false, // Require the CAPTCHA chosen in Flexsolutions Form Builder → Settings. 'akismet' => false, // Check submissions with Akismet (when installed). ); } /** * Default response and validation messages. * * @return array */ public static function default_messages(): array { return array( 'success' => __( 'Thank you for your message. It has been sent.', 'flexsolutions-form-builder' ), 'validation_error' => __( 'One or more fields have an error. Please check and try again.', 'flexsolutions-form-builder' ), 'mail_failed' => __( 'There was an error trying to send your message. Please try again later.', 'flexsolutions-form-builder' ), 'spam' => __( 'Your submission could not be accepted. Please try again.', 'flexsolutions-form-builder' ), 'rate_limited' => __( 'Too many submissions. Please wait a few minutes and try again.', 'flexsolutions-form-builder' ), 'login_required' => __( 'Please log in to use this form.', 'flexsolutions-form-builder' ), 'captcha_failed' => __( 'Please complete the verification and try again.', 'flexsolutions-form-builder' ), 'quiz_wrong' => __( 'The answer to the quiz is incorrect.', 'flexsolutions-form-builder' ), 'file_too_large' => __( 'The file is too large.', 'flexsolutions-form-builder' ), 'file_type' => __( 'This type of file is not allowed.', 'flexsolutions-form-builder' ), 'upload_failed' => __( 'The file could not be uploaded. Please try again.', 'flexsolutions-form-builder' ), 'required' => __( 'Please fill out this field.', 'flexsolutions-form-builder' ), 'invalid_email' => __( 'Please enter a valid email address.', 'flexsolutions-form-builder' ), 'invalid_url' => __( 'Please enter a valid URL starting with http:// or https://.', 'flexsolutions-form-builder' ), 'invalid_tel' => __( 'Please enter a valid phone number.', 'flexsolutions-form-builder' ), 'invalid_number' => __( 'Please enter a number.', 'flexsolutions-form-builder' ), 'number_too_small' => __( 'This number is smaller than the minimum allowed.', 'flexsolutions-form-builder' ), 'number_too_large' => __( 'This number is larger than the maximum allowed.', 'flexsolutions-form-builder' ), 'invalid_date' => __( 'Please enter a valid date.', 'flexsolutions-form-builder' ), 'date_too_early' => __( 'This date is earlier than allowed.', 'flexsolutions-form-builder' ), 'date_too_late' => __( 'This date is later than allowed.', 'flexsolutions-form-builder' ), 'too_long' => __( 'This field is too long.', 'flexsolutions-form-builder' ), 'too_short' => __( 'This field is too short.', 'flexsolutions-form-builder' ), 'invalid_choice' => __( 'Please choose one of the available options.', 'flexsolutions-form-builder' ), 'acceptance' => __( 'Please accept the terms to continue.', 'flexsolutions-form-builder' ), ); } // Sanitizers (used by register_post_meta and the admin save handler). /** * Sanitizes template markup. Only post-safe HTML is kept, so a form can * never carry scripts even if a lower-privileged role is later given * access to forms. Form tags are plain text and pass through untouched. * Line ends become "\n" (browsers post textareas with CRLF), so builder * rows are recognized one per line. * * @param mixed $value Raw template. */ public static function sanitize_template( mixed $value ): string { if ( ! is_string( $value ) ) { return ''; } $value = wp_kses( str_replace( array( "\r\n", "\r" ), "\n", $value ), self::allowed_template_html() ); return trim( $value ); } /** * HTML allowed inside templates. * * @return array> */ public static function allowed_template_html(): array { $allowed = wp_kses_allowed_html( 'post' ); // Forms must not contain nested forms or raw inputs that bypass validation. unset( $allowed['form'], $allowed['input'], $allowed['select'], $allowed['textarea'], $allowed['button'], $allowed['option'] ); return $allowed; } /** * Sanitizes mail settings. * * @param mixed $value Raw settings array. * @return array */ public static function sanitize_mail_settings( mixed $value ): array { $value = is_array( $value ) ? $value : array(); $defaults = self::default_mail_settings(); $clean = array(); // Recipients, CC and BCC are fixed by the admin. Placeholders are allowed // only for routing through piped choices ("Sales|sales@example.com"), // whose addresses also come from the admin; Mailer enforces that. foreach ( array( 'recipient', 'cc', 'bcc' ) as $key ) { $clean[ $key ] = self::sanitize_address_list( (string) ( $value[ $key ] ?? '' ) ); } // Single-line fields: sanitize_text_field strips line breaks, which is // what prevents mail header injection. $clean['from_name'] = sanitize_text_field( (string) ( $value['from_name'] ?? '' ) ); // A fixed address only (never a placeholder): the From address must be one // your domain is allowed to send as, so visitor input can never set it. $from_email = trim( (string) ( $value['from_email'] ?? '' ) ); $clean['from_email'] = '' !== $from_email && ! preg_match( '/[\s\x00-\x1F\x7F]/', $from_email ) && is_email( $from_email ) ? sanitize_email( $from_email ) : ''; $clean['subject'] = sanitize_text_field( (string) ( $value['subject'] ?? $defaults['subject'] ) ); $clean['autoreply_subject'] = sanitize_text_field( (string) ( $value['autoreply_subject'] ?? $defaults['autoreply_subject'] ) ); $clean['use_html'] = ! empty( $value['use_html'] ); $clean['attach_files'] = ! empty( $value['attach_files'] ); $clean['exclude_blank'] = ! empty( $value['exclude_blank'] ); // HTML mail keeps post-safe HTML; plain-text mail strips all tags. $body_sanitizer = $clean['use_html'] ? 'wp_kses_post' : array( self::class, 'sanitize_plain_body' ); $clean['body'] = $body_sanitizer( (string) ( $value['body'] ?? $defaults['body'] ) ); $clean['autoreply_body'] = $body_sanitizer( (string) ( $value['autoreply_body'] ?? $defaults['autoreply_body'] ) ); $clean['autoreply_enabled'] = ! empty( $value['autoreply_enabled'] ); // Reply-To may be a fixed address or a single field placeholder such as [your-email]. $reply_to = trim( (string) ( $value['reply_to'] ?? '' ) ); $clean['reply_to'] = self::is_placeholder( $reply_to ) || ( '' !== $reply_to && is_email( $reply_to ) ) ? $reply_to : ''; // Auto-reply recipient must be a field placeholder (the visitor's own address). $autoreply_to = trim( (string) ( $value['autoreply_to'] ?? '' ) ); $clean['autoreply_to'] = self::is_placeholder( $autoreply_to ) ? $autoreply_to : ''; return $clean; } /** * Cleans a comma-separated list of email addresses and [field] placeholders. * * @param string $addresses Raw list. */ public static function sanitize_address_list( string $addresses ): string { $out = array(); foreach ( explode( ',', $addresses ) as $item ) { $item = trim( $item ); if ( self::is_placeholder( $item ) ) { $out[] = $item; continue; } // Validate before sanitizing: sanitize_email() would silently glue // "a@b.com\nBcc: x" into a different (valid-looking) address. if ( '' === $item || preg_match( '/[\s\x00-\x1F\x7F]/', $item ) || ! is_email( $item ) ) { continue; } $out[] = sanitize_email( $item ); } return implode( ', ', array_unique( $out ) ); } /** * Cleans a plain-text mail body: no tags, but a placeholder in angle * brackets ("From: [your-name] <[your-email]>") is kept, where tag * stripping alone would remove it as a tag. * * @param string $body Raw body. */ public static function sanitize_plain_body( string $body ): string { $open = 'TFLEXFORMS-LT-' . md5( $body ); $close = 'TFLEXFORMS-GT-' . md5( $body ); $body = (string) preg_replace( '/<(\[[A-Za-z_][A-Za-z0-9_-]*\])>/', $open . '$1' . $close, $body ); return str_replace( array( $open, $close ), array( '<', '>' ), sanitize_textarea_field( $body ) ); } /** * Sanitizes response messages. * * @param mixed $value Raw messages array. * @return array */ public static function sanitize_messages( mixed $value ): array { $value = is_array( $value ) ? $value : array(); $clean = array(); foreach ( array_keys( self::default_messages() ) as $key ) { if ( isset( $value[ $key ] ) && is_string( $value[ $key ] ) ) { $clean[ $key ] = sanitize_text_field( $value[ $key ] ); } } return $clean; } /** * Sanitizes behaviour settings. The redirect must stay on this site, so a * form can never be used to bounce visitors to another domain. * * @param mixed $value Raw settings array. * @return array */ public static function sanitize_settings( mixed $value ): array { $value = is_array( $value ) ? $value : array(); $redirect = trim( (string) ( $value['redirect_url'] ?? '' ) ); if ( '' !== $redirect ) { $redirect = esc_url_raw( $redirect, array( 'http', 'https' ) ); $redirect = '' !== $redirect ? wp_validate_redirect( $redirect, '' ) : ''; } return array( 'demo_mode' => ! empty( $value['demo_mode'] ), 'logged_in_only' => ! empty( $value['logged_in_only'] ), 'redirect_url' => (string) $redirect, 'store_entries' => ! empty( $value['store_entries'] ), 'captcha' => ! empty( $value['captcha'] ), 'akismet' => ! empty( $value['akismet'] ), ); } /** * Whether a string is exactly one field placeholder, e.g. "[your-email]". * * @param string $value Value to test. */ public static function is_placeholder( string $value ): bool { return 1 === preg_match( '/^\[[A-Za-z][A-Za-z0-9_-]*\]$/', $value ); } }