self::ID, 'name' => __( 'Forms', 'forensix-digital' ), 'description' => __( 'Contact and quote forms with spam protection and an inbox. Messages are saved before any email is sent, so a failing mail never loses one.', 'forensix-digital' ), 'category' => 'forms', 'edition' => Edition::FREE, 'features' => array( 'F-FORMS-01' => Edition::FREE, 'F-FORMS-02' => Edition::FREE, 'F-FORMS-03' => Edition::FREE, 'F-FORMS-04' => Edition::FREE, 'F-FORMS-05' => Edition::FREE, 'F-FORMS-06' => Edition::FREE, ), 'deactivation_effects' => array( __( 'Forms on your pages disappear. Visitors can no longer send messages through them.', 'forensix-digital' ), __( 'Saved forms and messages stay in the database and you can still read them after turning the app on again.', 'forensix-digital' ), ), 'factory' => static fn(): self => new self(), ) ); } /** * Hooks the app while it is active. */ public function register(): void { add_shortcode( 'forensix_form', array( $this, 'shortcode' ) ); add_action( 'admin_post_nopriv_forensix_form_submit', array( $this, 'handle_submission' ) ); add_action( 'admin_post_forensix_form_submit', array( $this, 'handle_submission' ) ); add_action( Retention::HOOK, array( $this, 'run_retention' ) ); ( new Privacy( new EntryStore() ) )->register(); add_action( 'rest_api_init', array( $this, 'register_routes' ) ); add_filter( 'forensix_admin_screens', array( $this, 'add_screen' ) ); add_action( 'init', array( $this, 'register_block' ) ); add_action( 'elementor/widgets/register', array( $this, 'register_widget' ) ); add_filter( 'forensix_scan_stored_checks', array( $this, 'add_stored_checks' ) ); } /** * Adds the failed-notification check to a site scan. * * @param mixed $checks Stored-content checks of the scan. * @return mixed */ public function add_stored_checks( $checks ) { if ( ! is_array( $checks ) ) { return $checks; } $checks[] = new MailFailureCheck( SiteScanApp::ID, Plugin::container()->get( FindingStore::class ), new EntryStore() ); return $checks; } /** * Registers the Gutenberg block. */ public function register_block(): void { ( new FormBlock( array( self::class, 'render_form' ) ) )->register(); } /** * Registers the Elementor widget, when Elementor is active. * * @param mixed $manager Elementor widgets manager. */ public function register_widget( $manager ): void { if ( class_exists( '\Elementor\Widget_Base' ) && $manager instanceof \Elementor\Widgets_Manager ) { ElementorWidget::add( $manager ); } } /** * Registers the REST routes for the editor and the inbox. */ public function register_routes(): void { ( new FormsRoutes( new FormStore(), new EntryStore() ) )->register(); } /** * Adds the Forms page to the Forensix menu. * * @param array $screens Screens. * @phpstan-param array $screens * @return array */ public function add_screen( array $screens ): array { $screens['forensix-forms'] = array( 'key' => 'forms', 'title' => __( 'Forms', 'forensix-digital' ), 'capability' => Capabilities::VIEW_FORM_ENTRIES, ); return $screens; } /** * Creates the tables and schedules the daily clean-up. */ public function activate(): void { FormStore::install(); if ( false === wp_next_scheduled( Retention::HOOK ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', Retention::HOOK ); } } /** * Deletes entries older than each form's retention period. */ public function run_retention(): void { ( new Retention( new FormStore(), new EntryStore() ) )->run(); } /** * Removes the hooks. Forms and entries stay. */ public function deactivate(): void { remove_shortcode( 'forensix_form' ); remove_action( 'rest_api_init', array( $this, 'register_routes' ) ); remove_action( 'init', array( $this, 'register_block' ) ); remove_action( 'elementor/widgets/register', array( $this, 'register_widget' ) ); ( new FormBlock( array( self::class, 'render_form' ) ) )->unregister(); remove_filter( 'forensix_admin_screens', array( $this, 'add_screen' ) ); remove_action( 'admin_post_nopriv_forensix_form_submit', array( $this, 'handle_submission' ) ); remove_action( 'admin_post_forensix_form_submit', array( $this, 'handle_submission' ) ); remove_action( Retention::HOOK, array( $this, 'run_retention' ) ); ( new Privacy( new EntryStore() ) )->unregister(); wp_clear_scheduled_hook( Retention::HOOK ); } /** * Drops the tables. */ public function delete_data(): void { FormStore::drop(); delete_option( Captcha::OPTION ); } /** * The [forensix_form id="1"] shortcode. * * @param mixed $atts Shortcode attributes. */ public function shortcode( $atts ): string { $atts = shortcode_atts( array( 'id' => '0' ), is_array( $atts ) ? $atts : array(), 'forensix_form' ); return self::render_form( absint( $atts['id'] ) ); } /** * The markup of a form for shortcode, block and Elementor widget. * * @param int $form_id Form id. */ public static function render_form( int $form_id ): string { $state = array(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only reads the key of a short-lived result; it is checked against the form id below. $key = isset( $_GET[ self::STATE_QUERY ] ) ? preg_replace( '/[^A-Za-z0-9]/', '', sanitize_text_field( wp_unslash( (string) $_GET[ self::STATE_QUERY ] ) ) ) : ''; if ( is_string( $key ) && '' !== $key ) { $saved = get_transient( 'forensix_fs_' . $key ); if ( is_array( $saved ) && (int) ( $saved['form_id'] ?? 0 ) === $form_id ) { $state = $saved; } } /** * Filters what the form starts with: the result of the last submission, or for example * answers an add-on saved earlier. Keys: status, message, errors, values (by field id). * * @param array $state State, empty when nothing was sent. * @param int $form_id Form id. */ $state = self::state_shape( apply_filters( 'forensix_form_state', $state, $form_id ), $state ); $permalink = get_permalink(); $return = false !== $permalink ? $permalink : home_url( '/' ); $html = ( new FormRenderer( new FormStore() ) )->render( $form_id, $state, time(), $return ); if ( '' !== $html ) { self::enqueue_assets(); } return FormMarkup::escape( $html ); } /** * Front-end script of the forms (shows the value of range fields). */ private static function enqueue_assets(): void { wp_enqueue_script( 'fxd-forms', FORENSIX_URL . 'assets/forms.js', array(), FORENSIX_VERSION, array( 'in_footer' => true, 'strategy' => 'defer', ) ); } /** * Takes the result of the forensix_form_state filter if it has the right shape. * * @param mixed $candidate Filter result. * @param array $fallback State before the filter. * @return array{status?: string, message?: string, errors?: array, values?: array} */ private static function state_shape( mixed $candidate, array $fallback ): array { $source = is_array( $candidate ) ? $candidate : $fallback; $state = array(); if ( is_scalar( $source['status'] ?? null ) ) { $state['status'] = (string) $source['status']; } if ( is_scalar( $source['message'] ?? null ) ) { $state['message'] = (string) $source['message']; } if ( is_array( $source['errors'] ?? null ) ) { $state['errors'] = self::strings( $source['errors'] ); } if ( is_array( $source['values'] ?? null ) ) { $state['values'] = self::strings( $source['values'] ); } return $state; } /** * The text values of a list, by text key. * * @param array $items List. * @return array */ private static function strings( array $items ): array { $out = array(); foreach ( $items as $key => $value ) { if ( is_scalar( $value ) ) { $out[ (string) $key ] = (string) $value; } } return $out; } /** * Receives a submission, then sends the visitor back to the page with the result. */ public function handle_submission(): void { // phpcs:disable WordPress.Security.NonceVerification.Missing -- Public form; protected by SpamGuard and the rate limit, see the class comment. $back = isset( $_POST['forensix_return'] ) ? esc_url_raw( wp_unslash( (string) $_POST['forensix_return'] ) ) : ''; $back = wp_validate_redirect( $back, home_url( '/' ) ); $post = FormInput::post( (array) wp_unslash( $_POST ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- FormInput::post() sanitises every key and value. $form_id = absint( $post['form_id'] ?? 0 ); $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? (string) filter_var( sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ), FILTER_VALIDATE_IP ) : ''; $files = FormInput::files( $_FILES ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- FormInput::files() sanitises every part and keeps only real uploads. // phpcs:enable $result = ( new Submitter( new FormStore(), new EntryStore() ) )->handle( $form_id, $post, time(), $ip, $files ); if ( 'sent' === $result['status'] && '' !== $result['redirect'] ) { wp_redirect( $result['redirect'] ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- The address was set by the form's editor and checked to be http(s). exit; } $key = wp_generate_password( 12, false ); set_transient( 'forensix_fs_' . $key, array( 'form_id' => $form_id, 'status' => $result['status'], 'message' => $result['message'], 'errors' => $result['errors'], 'values' => $result['values'], ), self::STATE_TTL ); wp_safe_redirect( add_query_arg( self::STATE_QUERY, $key, $back ) . '#fxd-' . $form_id ); exit; } }