entries_option = get_option( self::OPTION_NAME, [] ); add_action( 'admin_init', [ $this, 'create_db' ] ); add_action( 'admin_init', [ $this, 'handle_admin_entry_actions' ] ); add_action( 'admin_post_adminoptim_submit_form', [ $this, 'handle_form_submission' ] ); add_action( 'admin_post_nopriv_adminoptim_submit_form', [ $this, 'handle_form_submission' ] ); add_action( 'wp_ajax_aofb_refresh_nonce', [ $this, 'handle_refresh_submission_nonce' ] ); add_action( 'wp_ajax_nopriv_aofb_refresh_nonce', [ $this, 'handle_refresh_submission_nonce' ] ); add_action( 'wp_ajax_aofb_ajax_submit', [ $this, 'handle_ajax_form_submission' ] ); add_action( 'wp_ajax_nopriv_aofb_ajax_submit', [ $this, 'handle_ajax_form_submission' ] ); add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_settings_assets' ] ); } /** * Return a fresh frontend submission nonce. * * Frontend pages may be cached or remain open while the visitor's login state * changes, so the nonce embedded in the form can belong to the wrong session. * * @return void */ public function handle_refresh_submission_nonce() { $form_id = isset( $_POST['form_id'] ) ? absint( wp_unslash( $_POST['form_id'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- this endpoint issues a fresh nonce, nothing to verify yet. $form = get_post( $form_id ); if ( ! $form_id || ! $form || 'adminoptim-form' !== $form->post_type || 'publish' !== $form->post_status ) { wp_send_json_error( [ 'message' => __( 'Invalid form.', 'admin-optimizer' ) ] ); } wp_send_json_success( [ 'nonce' => wp_create_nonce( 'aofb_submit_form_' . $form_id ), ] ); } /** * Validate CSRF protection for an authenticated frontend submission. * * Anonymous forms are intentionally public and WordPress guest nonces all use * user ID 0, so requiring one does not add per-visitor CSRF protection. Guest * abuse is handled by the configured challenge, honeypot, and rate limiter. * * @param int $form_id Form post ID. * * @return bool */ private function is_submission_nonce_valid( $form_id ) { if ( ! is_user_logged_in() ) { return true; } return isset( $_POST['aofb_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['aofb_nonce'] ) ), 'aofb_submit_form_' . $form_id ); } /** * Create database table * * @return void */ public function create_db() { global $wpdb; $table_name = $wpdb->prefix . self::TABLE_NAME; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; if ( ! isset( $this->entries_option['db_installed'] ) || ( isset( $this->entries_option['db_version'] ) && version_compare( $this->entries_option['db_version'], self::DB_VERSION, '<' ) ) ) { $sql = "CREATE TABLE {$table_name} ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, form_id BIGINT(20) UNSIGNED NOT NULL, entry_data LONGTEXT NOT NULL, ip_address VARCHAR(45) DEFAULT NULL, user_agent TEXT DEFAULT NULL, date_created DATETIME DEFAULT '0000-00-00 00:00:00' NOT NULL, PRIMARY KEY (id), KEY form_id (form_id) ) {$wpdb->get_charset_collate()};"; dbDelta( $sql ); $this->entries_option['db_installed'] = 1; $this->entries_option['db_version'] = self::DB_VERSION; update_option( self::OPTION_NAME, $this->entries_option, false ); } } /** * Render entries page * * @return void */ public function render_entries_page() { if ( ! Form_Builder::can_manage_forms() ) { wp_die( esc_html__( 'Sorry, you are not allowed to manage form entries.', 'admin-optimizer' ) ); } $entry = null; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( isset( $_GET['entry_id'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $entry = $this->get_entry( absint( $_GET['entry_id'] ) ); if ( ! $entry ) { wp_die( esc_html__( 'Entry unavailable or access denied.', 'admin-optimizer' ) ); } } $entries_list_table = null; if ( ! $entry ) { $list_table_class = apply_filters( 'adminoptim_form_builder_entries_list_table_class', Form_Builder_Entries_List_Table::class ); $entries_list_table = new $list_table_class(); $entries_list_table->prepare_items(); } ?>


maybe_add_entries_admin_notice(); ?>
render_entry_view( $entry ); ?> render_entries_export_controls(); ?>
display(); ?>
get_requested_entry_action(); if ( ! in_array( $action, $allowed_actions, true ) ) { return; } if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Sorry, you are not allowed to manage form entries.', 'admin-optimizer' ) ); } if ( 'bulk_delete' === $action ) { $this->handle_bulk_delete_entries(); return; } if ( 'export_csv' === $action ) { do_action( 'adminoptim_form_builder_handle_export_csv' ); return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended $entry_id = isset( $_GET['entry_id'] ) ? absint( $_GET['entry_id'] ) : 0; $redirect_args = [ 'aofb_entry_deleted' => 0 ]; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( isset( $_GET['form_id'] ) && absint( $_GET['form_id'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $redirect_args['form_id'] = absint( $_GET['form_id'] ); } if ( ! $entry_id ) { wp_safe_redirect( $this->get_entries_page_url( $redirect_args ) ); exit; } check_admin_referer( 'aofb_delete_entry_' . $entry_id ); $deleted = $this->delete_entry( $entry_id ); $redirect_args['aofb_entry_deleted'] = $deleted ? 1 : 0; wp_safe_redirect( $this->get_entries_page_url( $redirect_args ) ); exit; } /** * Get requested entry action. * * @return string */ private function get_requested_entry_action() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : ''; if ( '-1' === $action || '' === $action ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $action = isset( $_REQUEST['action2'] ) ? sanitize_key( wp_unslash( $_REQUEST['action2'] ) ) : ''; } return '-1' === $action ? '' : $action; } /** * Handle bulk entry deletion. * * @return void */ private function handle_bulk_delete_entries() { $redirect_args = [ 'aofb_bulk_deleted' => 0 ]; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( isset( $_REQUEST['form_id'] ) && absint( $_REQUEST['form_id'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $redirect_args['form_id'] = absint( $_REQUEST['form_id'] ); } if ( ! isset( $_REQUEST['aofb_bulk_entries_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_REQUEST['aofb_bulk_entries_nonce'] ) ), 'aofb_bulk_entries' ) ) { wp_safe_redirect( $this->get_entries_page_url( $redirect_args ) ); exit; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended $entry_ids = isset( $_REQUEST['entry'] ) ? array_map( 'absint', (array) wp_unslash( $_REQUEST['entry'] ) ) : []; $entry_ids = array_values( array_filter( $entry_ids ) ); if ( ! empty( $entry_ids ) ) { $redirect_args['aofb_bulk_deleted'] = $this->delete_entries( $entry_ids ); } wp_safe_redirect( $this->get_entries_page_url( $redirect_args ) ); exit; } /** * Add admin notices for entry actions. * * @return void */ private function maybe_add_entries_admin_notice() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( isset( $_GET['aofb_bulk_deleted'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $deleted = absint( $_GET['aofb_bulk_deleted'] ); if ( $deleted ) { add_settings_error( 'aofb_entries', 'entries_deleted', sprintf( /* translators: %d: deleted entry count. */ _n( '%d entry deleted.', '%d entries deleted.', $deleted, 'admin-optimizer' ), $deleted ), 'updated' ); } else { add_settings_error( 'aofb_entries', 'entries_not_deleted', __( 'No entries were deleted.', 'admin-optimizer' ), 'error' ); } return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! isset( $_GET['aofb_entry_deleted'] ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( absint( $_GET['aofb_entry_deleted'] ) ) { add_settings_error( 'aofb_entries', 'entry_deleted', __( 'Entry deleted.', 'admin-optimizer' ), 'updated' ); } else { add_settings_error( 'aofb_entries', 'entry_not_deleted', __( 'Entry could not be deleted.', 'admin-optimizer' ), 'error' ); } } /** * Render CSV export controls for the entries screen. * * @return void */ private function render_entries_export_controls() { if ( ! current_user_can( 'manage_options' ) ) { return; } if ( Module_Registry::is_pro_active() ) { do_action( 'adminoptim_form_builder_render_export_controls' ); return; } $current_form_id = 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( isset( $_REQUEST['form_id'] ) && absint( $_REQUEST['form_id'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $current_form_id = absint( $_REQUEST['form_id'] ); } ?>

', '' ); ?>

100 || $page - 1 > intdiv( PHP_INT_MAX, $per_page ) ) { return new \WP_Error( 'ao_list_form_entries_invalid_input', __( 'Invalid form entry listing parameters.', 'admin-optimizer' ) ); } if ( ! Form_Builder::can_manage_forms() || ! Form_Builder::can_edit_form( $form_id ) ) { return new \WP_Error( 'ao_list_form_entries_forbidden', __( 'You are not allowed to view entries for this form.', 'admin-optimizer' ) ); } if ( null !== $entry_id && $entry_id < 1 ) { return new \WP_Error( 'ao_list_form_entries_invalid_input', __( 'Invalid entry ID.', 'admin-optimizer' ) ); } $table = $wpdb->prefix . self::TABLE_NAME; $where = $wpdb->prepare( 'form_id = %d', $form_id ); if ( null !== $entry_id ) { $where .= $wpdb->prepare( ' AND id = %d', $entry_id ); } $columns = $include_values ? 'id, form_id, date_created, entry_data' : 'id, form_id, date_created'; // The table identifier is internal; all caller-controlled values are prepared. // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery $total = $wpdb->get_var( "SELECT COUNT(id) FROM {$table} WHERE {$where}" ); if ( null === $total || $wpdb->last_error ) { return new \WP_Error( 'ao_list_form_entries_query_failed', __( 'Unable to list form entries.', 'admin-optimizer' ) ); } $rows = $wpdb->get_results( $wpdb->prepare( "SELECT {$columns} FROM {$table} WHERE {$where} ORDER BY date_created DESC, id DESC LIMIT %d OFFSET %d", $per_page, ( $page - 1 ) * $per_page ), ARRAY_A ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery if ( ! is_array( $rows ) || $wpdb->last_error ) { return new \WP_Error( 'ao_list_form_entries_query_failed', __( 'Unable to list form entries.', 'admin-optimizer' ) ); } $fields = $include_values ? Form_Builder_Forms::get_form_fields( $form_id ) : []; $fields = is_array( $fields ) ? self::flatten_submission_fields( $fields ) : []; $entries = []; foreach ( $rows as $row ) { if ( ! is_array( $row ) || absint( $row['form_id'] ?? 0 ) !== $form_id || ! Form_Builder::can_edit_form( $form_id ) ) { continue; } $entry = [ 'id' => absint( $row['id'] ), 'form_id' => $form_id, 'created_at' => str_replace( ' ', 'T', (string) $row['date_created'] ), ]; if ( $include_values ) { $entry['data'] = self::get_ability_entry_values( is_string( $row['entry_data'] ?? null ) ? $row['entry_data'] : null, $fields ); } $entries[] = $entry; } return [ 'entries' => $entries, 'pagination' => [ 'page' => $page, 'per_page' => $per_page, 'total' => absint( $total ), 'total_pages' => (int) ceil( absint( $total ) / $per_page ) ] ]; } /** * Minimise opt-in values using the current form's visible field allowlist. * Unknown/deleted fields, hidden values, uploads and internal/security fields * are omitted. Never return settings, request metadata or nested objects. */ private static function get_ability_entry_values( ?string $raw, array $fields ): \stdClass { $data = is_string( $raw ) ? json_decode( $raw, true ) : null; $result = []; if ( ! is_array( $data ) ) { return (object) $result; } foreach ( array_slice( $fields, 0, 100 ) as $field ) { if ( ! is_array( $field ) || ! is_string( $field['name'] ?? null ) || ! is_string( $field['type'] ?? null ) ) { continue; } $name = $field['name']; $label = is_string( $field['label'] ?? null ) ? $field['label'] : ''; if ( '' === $name || '_' === $name[0] || preg_match( '/passw|passwd|pwd|secret|token|nonce|credential|api.?key|authorization|auth.?key|smtp|security|captcha|honeypot|session|cookie|private.?key/i', $name . ' ' . $label ) || ! in_array( $field['type'], [ 'text', 'textarea', 'email', 'url', 'phone', 'number', 'datepicker', 'select', 'check', 'radio', 'rating' ], true ) || ! array_key_exists( $name, $data ) ) { continue; } $value = $data[ $name ]; if ( is_scalar( $value ) ) { $result[ $name ] = sanitize_textarea_field( substr( (string) $value, 0, 10000 ) ); } elseif ( is_array( $value ) ) { $result[ $name ] = []; foreach ( array_slice( array_values( $value ), 0, 100 ) as $item ) { if ( is_scalar( $item ) ) { $result[ $name ][] = sanitize_textarea_field( substr( (string) $item, 0, 10000 ) ); } } } } return (object) $result; } /** * Get an entry by ID. * * @param int $entry_id Entry ID. * * @return array|null */ private function get_entry( $entry_id ) { global $wpdb; if ( ! $entry_id ) { return null; } $table_name = $wpdb->prefix . self::TABLE_NAME; $form_id = $wpdb->get_var( $wpdb->prepare( "SELECT form_id FROM {$table_name} WHERE id = %d", $entry_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table name is $wpdb->prefix + class constant. if ( ! $form_id || ! Form_Builder::can_edit_form( (int) $form_id ) ) { return null; } // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table_name} WHERE id = %d", $entry_id ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery // phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching } /** * Delete an entry by ID. * * @param int $entry_id Entry ID. * * @return bool */ private function delete_entry( $entry_id ) { global $wpdb; if ( ! $entry_id ) { return false; } $table_name = $wpdb->prefix . self::TABLE_NAME; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching return (bool) $wpdb->delete( $table_name, [ 'id' => $entry_id ], [ '%d' ] ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery // phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching } /** * Delete entries by ID. * * @param array $entry_ids Entry IDs. * * @return int */ private function delete_entries( array $entry_ids ) { $deleted = 0; foreach ( $entry_ids as $entry_id ) { if ( $this->delete_entry( $entry_id ) ) { ++$deleted; } } return $deleted; } /** * Render a single entry. * * @param array $entry Entry row. * * @return void */ private function render_entry_view( array $entry ) { $form_post = get_post( $entry['form_id'] ); $form_name = $form_post ? $form_post->post_title : __( 'Deleted Form', 'admin-optimizer' ); $data = json_decode( $entry['entry_data'], true ); $data = is_array( $data ) ? $data : [ __( 'Raw Data', 'admin-optimizer' ) => $entry['entry_data'] ]; $back_args = []; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( isset( $_GET['form_id'] ) && absint( $_GET['form_id'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $back_args['form_id'] = absint( $_GET['form_id'] ); } ?>

$value ) : ?>
render_entry_value( $value, true, $key, $entry ), $key, $value, $entry ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
render_entry_value( $item, $show_download, $key, $entry ); }, $value ) ); } $attachment_id = self::get_entry_attachment_id( $value, $key, $entry ); if ( $attachment_id && wp_get_attachment_url( $attachment_id ) ) { return $this->render_attachment_value( $attachment_id ); } $value = is_scalar( $value ) ? (string) $value : ''; if ( '' === $value ) { return ''; } if ( $this->is_file_url( $value ) ) { $links = [ sprintf( '%2$s', esc_url( $value ), esc_html__( 'View file', 'admin-optimizer' ) ), ]; if ( $show_download ) { $links[] = sprintf( '%2$s', esc_url( $value ), esc_html__( 'Download file', 'admin-optimizer' ) ); } return ( $show_download ? $this->render_file_preview( $value ) : '' ) . implode( ' | ', $links ); } return esc_html( $value ); } /** * Render an uploaded file's preview: the image itself, or a file icon. * * @param string $url File URL. * * @return string */ private function render_file_preview( $url ) { $filetype = wp_check_filetype( (string) wp_parse_url( $url, PHP_URL_PATH ) ); $uploads = wp_get_upload_dir(); // Any text field can hold a URL ending in .png. Only load images from // this site, so viewing an entry never requests a stranger's server. if ( 0 !== strpos( (string) $filetype['type'], 'image/' ) || 0 !== strpos( $url, trailingslashit( (string) $uploads['baseurl'] ) ) ) { return ' '; } return sprintf( '', esc_url( $url ), self::FILE_PREVIEW_STYLE ); } /** * Render a Media Library file: its thumbnail or file-type icon, and links. * * @param int $attachment_id Attachment ID. * * @return string */ private function render_attachment_value( $attachment_id ) { $url = (string) wp_get_attachment_url( $attachment_id ); $links = [ sprintf( '%2$s', esc_url( $url ), esc_html__( 'View file', 'admin-optimizer' ) ), sprintf( '%2$s', esc_url( $url ), esc_html__( 'Download file', 'admin-optimizer' ) ), ]; $edit_url = get_edit_post_link( $attachment_id ); if ( $edit_url ) { $links[] = sprintf( '%2$s', esc_url( $edit_url ), esc_html__( 'Edit attachment', 'admin-optimizer' ) ); } // The third argument falls back to a file-type icon for non-images. $preview = wp_get_attachment_image( $attachment_id, 'thumbnail', true, [ 'style' => self::FILE_PREVIEW_STYLE ] ); return sprintf( '%2$s', esc_url( $url ), $preview ) . implode( ' | ', $links ); } /** * The Media Library ID held by an entry value, if any. * * @param mixed $value Entry value. * @param string $key Field name. * @param array $entry Entry row. * * @return int Attachment ID, or 0. */ public static function get_entry_attachment_id( $value, $key, array $entry ) { if ( ! is_scalar( $value ) || ! preg_match( self::ATTACHMENT_VALUE_PATTERN, (string) $value ) ) { return 0; } $fields = self::map_fields_by_name( Form_Builder_Forms::get_form_fields( absint( $entry['form_id'] ?? 0 ) ) ); return self::get_field_attachment_id( $value, $fields[ $key ] ?? [] ); } /** * The Media Library ID held by a field's value, if any. * * @param mixed $value Field value. * @param array $field Field definition, empty when unknown, such as a deleted field. * * @return int Attachment ID, or 0. */ private static function get_field_attachment_id( $value, array $field ) { if ( $field && ! in_array( (string) ( $field['type'] ?? '' ), [ 'file', 'image' ], true ) ) { return 0; } if ( ! is_scalar( $value ) || ! preg_match( self::ATTACHMENT_VALUE_PATTERN, (string) $value, $matches ) ) { return 0; } return 'attachment' === get_post_type( (int) $matches[1] ) ? (int) $matches[1] : 0; } /** * The text for an uploaded file in emails and exports: its URL when the * value is a Media Library ID. * * @param mixed $value Stored value. * @param array $field Field definition, empty when unknown. * * @return string */ public static function get_file_value_text( $value, array $field = [] ) { $attachment_id = self::get_field_attachment_id( $value, $field ); $attachment_url = $attachment_id ? wp_get_attachment_url( $attachment_id ) : ''; return $attachment_url ? (string) $attachment_url : (string) $value; } /** * Check whether a value is a URL to a stored file. * * @param string $value Entry value. * * @return bool */ private function is_file_url( $value ) { if ( ! wp_http_validate_url( $value ) ) { return false; } $filetype = wp_check_filetype( (string) wp_parse_url( $value, PHP_URL_PATH ) ); return ! empty( $filetype['ext'] ); } /** * Get entries admin page URL. * * @param array $args Additional query args. * * @return string */ private function get_entries_page_url( array $args = [] ) { $args = array_merge( [ 'post_type' => 'adminoptim-form', 'page' => 'adminoptim-form-entries', ], $args ); return add_query_arg( $args, admin_url( 'edit.php' ) ); } /** * Handle frontend form submission (traditional POST → redirect). * * @return void */ public function handle_form_submission() { $form_id = isset( $_POST['form_id'] ) ? absint( wp_unslash( $_POST['form_id'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified below via is_submission_nonce_valid(). $form = get_post( $form_id ); if ( ! $form_id || ! $form || 'adminoptim-form' !== $form->post_type || 'publish' !== $form->post_status ) { wp_die( esc_html__( 'Invalid form submission.', 'admin-optimizer' ) ); } if ( ! $this->is_submission_nonce_valid( $form_id ) ) { wp_die( esc_html__( 'Security check failed.', 'admin-optimizer' ) ); } $redirect_url = $this->get_submission_redirect_url(); $settings = Form_Builder_Forms::get_form_settings( $form_id ); $availability = self::get_form_availability( $form_id, $settings, false ); if ( empty( $availability['open'] ) && 'limit' !== $availability['reason'] ) { $this->redirect_with_form_state( $form_id, $redirect_url, [ 'success' => 0, 'errors' => [ '_form' => $availability['message'] ], 'values' => [], ] ); } if ( ! $this->check_submission_rate_limit( $form_id ) ) { $this->redirect_with_form_state( $form_id, $redirect_url, [ 'success' => 0, 'errors' => [ '_form' => __( 'Too many submissions. Please wait a few minutes and try again.', 'admin-optimizer' ) ], 'values' => [], ] ); } $fields = Form_Builder_Forms::get_form_fields( $form_id ); $raw = isset( $_POST['adminoptim_form_fields'] ) ? wp_unslash( $_POST['adminoptim_form_fields'] ) : []; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Missing -- nonce already verified above. $raw = is_array( $raw ) ? $raw : []; $raw = $this->resolve_authoritative_hidden_values( $fields, $raw ); $honeypot_error = $this->verify_honeypot( $settings ); if ( $honeypot_error ) { $this->redirect_with_form_state( $form_id, $redirect_url, [ 'success' => 0, 'errors' => [ '_form' => $honeypot_error ], 'values' => [], ] ); } $antispam_error = $this->verify_antispam_challenges( $fields, $form_id ); if ( $antispam_error ) { $this->redirect_with_form_state( $form_id, $redirect_url, [ 'success' => 0, 'errors' => [ '_form' => $antispam_error ], 'values' => [], ] ); } $active_fields = $this->resolve_active_fields( $fields, $raw, $form_id ); $result = $this->validate_submission( $fields, $raw, $active_fields ); if ( ! empty( $result['errors'] ) ) { $this->redirect_with_form_state( $form_id, $redirect_url, [ 'success' => 0, 'errors' => $result['errors'], 'values' => $result['values'], ] ); } /** * Filters whether a validated submission may proceed to delivery. * Pro hooks this to atomically reserve a submission-limit slot * (avoiding a check-then-increment race) and returns a WP_Error when * the limit was reached — Free itself always allows delivery. * * @param true|\WP_Error $can_deliver true to proceed, or a WP_Error to block. * @param int $form_id Form ID. * @param array $settings Form settings. */ $can_deliver = apply_filters( 'adminoptim_form_builder_before_deliver_submission', true, $form_id, $settings ); if ( is_wp_error( $can_deliver ) ) { $this->redirect_with_form_state( $form_id, $redirect_url, [ 'success' => 0, 'errors' => [ '_form' => $can_deliver->get_error_message() ], 'values' => $result['values'], ] ); } $this->deliver_submission( $form, $fields, $result['values'], $settings ); if ( ! empty( $settings['redirect_url'] ) ) { wp_safe_redirect( esc_url_raw( $settings['redirect_url'] ) ); exit; } $this->redirect_with_form_state( $form_id, $redirect_url, [ 'success' => 1, ] ); } /** * Handle frontend form submission via AJAX. Returns JSON. * * @return void */ public function handle_ajax_form_submission() { $form_id = isset( $_POST['form_id'] ) ? absint( wp_unslash( $_POST['form_id'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified below via is_submission_nonce_valid(). $form = get_post( $form_id ); if ( ! $form_id || ! $form || 'adminoptim-form' !== $form->post_type || 'publish' !== $form->post_status ) { wp_send_json_error( [ 'message' => __( 'Invalid form.', 'admin-optimizer' ) ] ); } if ( ! $this->is_submission_nonce_valid( $form_id ) ) { wp_send_json_error( [ 'message' => __( 'Security check failed.', 'admin-optimizer' ) ] ); } $settings = Form_Builder_Forms::get_form_settings( $form_id ); $availability = self::get_form_availability( $form_id, $settings, false ); if ( empty( $availability['open'] ) && 'limit' !== $availability['reason'] ) { wp_send_json_error( [ 'errors' => [ '_form' => $availability['message'] ], 'message' => $availability['message'], ] ); } if ( ! $this->check_submission_rate_limit( $form_id ) ) { wp_send_json_error( [ 'message' => __( 'Too many submissions. Please wait a few minutes and try again.', 'admin-optimizer' ) ] ); } $fields = Form_Builder_Forms::get_form_fields( $form_id ); $raw = isset( $_POST['adminoptim_form_fields'] ) ? wp_unslash( $_POST['adminoptim_form_fields'] ) : []; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Missing -- nonce already verified above. $raw = is_array( $raw ) ? $raw : []; $raw = $this->resolve_authoritative_hidden_values( $fields, $raw ); $honeypot_error = $this->verify_honeypot( $settings ); if ( $honeypot_error ) { wp_send_json_error( [ 'errors' => [ '_form' => $honeypot_error ], 'message' => $honeypot_error, ] ); } $antispam_error = $this->verify_antispam_challenges( $fields, $form_id ); if ( $antispam_error ) { wp_send_json_error( [ 'errors' => [ '_form' => $antispam_error ], 'message' => $antispam_error, ] ); } $active_fields = $this->resolve_active_fields( $fields, $raw, $form_id ); $result = $this->validate_submission( $fields, $raw, $active_fields ); if ( ! empty( $result['errors'] ) ) { wp_send_json_error( [ 'errors' => $result['errors'], 'message' => __( 'Please fix the highlighted fields and try again.', 'admin-optimizer' ), ] ); } /** This filter is documented in handle_form_submission() above. */ $can_deliver = apply_filters( 'adminoptim_form_builder_before_deliver_submission', true, $form_id, $settings ); if ( is_wp_error( $can_deliver ) ) { wp_send_json_error( [ 'errors' => [ '_form' => $can_deliver->get_error_message() ], 'message' => $can_deliver->get_error_message(), ] ); } $this->deliver_submission( $form, $fields, $result['values'], $settings ); $success_response = [ 'message' => ! empty( $settings['success_message'] ) ? $settings['success_message'] : __( 'Thanks, your submission has been received.', 'admin-optimizer' ), 'success_behavior' => 'replace' === ( $settings['success_behavior'] ?? '' ) ? 'replace' : 'message', ]; if ( ! empty( $settings['redirect_url'] ) ) { $success_response['redirect_url'] = esc_url_raw( $settings['redirect_url'] ); } wp_send_json_success( $success_response ); } /** * Verify the hidden honeypot field for forms that enable it. * * @param array $settings Form settings. * * @return string Empty string on success, otherwise a user-facing error. */ private function verify_honeypot( array $settings ) { if ( empty( $settings['enable_honeypot'] ) ) { return ''; } $field_names = [ 'aofb_company_website', 'aofb_business_url', 'aofb_email_confirm', 'aofb_profile_link', 'aofb_organization_site', ]; foreach ( $field_names as $field_name ) { $value = isset( $_POST[ $field_name ] ) ? sanitize_text_field( wp_unslash( $_POST[ $field_name ] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( '' !== trim( $value ) ) { return __( 'Your submission could not be accepted. Please try again.', 'admin-optimizer' ); } } return ''; } /** * Verify enabled anti-spam challenges for the current request. * * @param array $fields Form fields. * * @return string Empty string on success, otherwise a user-facing error. */ private function verify_antispam_challenges( array $fields, $form_id ) { $settings = $this->get_antispam_settings(); if ( $this->has_antispam_field( $fields, 'recaptcha' ) ) { if ( '' === $settings['recaptcha_site_key'] || '' === $settings['recaptcha_secret_key'] ) { $this->warning( 'A reCAPTCHA field is present, but a required key is missing.', [], 'recaptcha_key_missing' ); return __( 'This form is temporarily unavailable. Please try again later.', 'admin-optimizer' ); } $token = isset( $_POST['g-recaptcha-response'] ) ? sanitize_text_field( wp_unslash( $_POST['g-recaptcha-response'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( '' === $token ) { return __( 'Please complete the reCAPTCHA challenge.', 'admin-optimizer' ); } if ( ! $this->verify_antispam_token( 'recaptcha', $settings['recaptcha_secret_key'], $token ) ) { return __( 'reCAPTCHA verification failed. Please try again.', 'admin-optimizer' ); } } if ( $this->has_antispam_field( $fields, 'turnstile' ) ) { $mode = 'managed'; foreach ( $fields as $field ) { if ( 'turnstile' === ( $field['type'] ?? '' ) ) { $mode = 'invisible' === ( $field['display_mode'] ?? '' ) ? 'invisible' : 'managed'; break; } } $prefix = 'invisible' === $mode ? 'turnstile_invisible_' : 'turnstile_'; if ( '' === $settings[ $prefix . 'site_key' ] || '' === $settings[ $prefix . 'secret_key' ] ) { $this->warning( 'A Turnstile field is present, but a required key is missing.', [], 'turnstile_key_missing' ); return __( 'This form is temporarily unavailable. Please try again later.', 'admin-optimizer' ); } $token = isset( $_POST['cf-turnstile-response'] ) ? sanitize_text_field( wp_unslash( $_POST['cf-turnstile-response'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( '' === $token ) { return __( 'Please complete the Turnstile challenge.', 'admin-optimizer' ); } if ( ! $this->verify_antispam_token( 'turnstile', $settings[ $prefix . 'secret_key' ], $token, 'aofb_' . $form_id ) ) { return __( 'Turnstile verification failed. Please try again.', 'admin-optimizer' ); } } return ''; } /** * Check whether a form includes a specific anti-spam field type. * * @param array> $fields Form fields. * @param string $type Field type. * * @return bool */ private function has_antispam_field( array $fields, $type ) { foreach ( $fields as $field ) { if ( isset( $field['type'] ) && $type === (string) $field['type'] ) { return true; } if ( ! empty( $field['children'] ) && is_array( $field['children'] ) && $this->has_antispam_field( $field['children'], $type ) ) { return true; } } return false; } /** * Get sanitized module-level anti-spam settings. * * @return array */ private function get_antispam_settings() { $options = get_option( Form_Builder::OPTION_NAME, [] ); $options = is_array( $options ) ? $options : []; return [ 'enable_recaptcha' => ! empty( $options['enable_recaptcha'] ) ? 1 : 0, 'recaptcha_site_key' => isset( $options['recaptcha_site_key'] ) ? sanitize_text_field( $options['recaptcha_site_key'] ) : '', 'recaptcha_secret_key' => isset( $options['recaptcha_secret_key'] ) ? sanitize_text_field( $options['recaptcha_secret_key'] ) : '', 'enable_turnstile' => ! empty( $options['enable_turnstile'] ) ? 1 : 0, 'turnstile_site_key' => isset( $options['turnstile_site_key'] ) ? sanitize_text_field( $options['turnstile_site_key'] ) : '', 'turnstile_secret_key' => isset( $options['turnstile_secret_key'] ) ? sanitize_text_field( $options['turnstile_secret_key'] ) : '', 'turnstile_invisible_site_key' => isset( $options['turnstile_invisible_site_key'] ) ? sanitize_text_field( $options['turnstile_invisible_site_key'] ) : '', 'turnstile_invisible_secret_key' => isset( $options['turnstile_invisible_secret_key'] ) ? sanitize_text_field( $options['turnstile_invisible_secret_key'] ) : '', ]; } /** * Verify a provider token against its siteverify endpoint. * * @param string $provider Provider key. * @param string $secret Secret key. * @param string $token Challenge response token. * * @return bool */ private function verify_antispam_token( $provider, $secret, $token, $expected_action = '' ) { // phpcs:disable PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- Server-to-server anti-spam token verification API call, not an offloaded front-end asset. $endpoint = 'turnstile' === $provider ? 'https://challenges.cloudflare.com/turnstile/v0/siteverify' : 'https://www.google.com/recaptcha/api/siteverify'; // phpcs:enable PluginCheck.CodeAnalysis.Offloading.OffloadedContent $body = [ 'secret' => $secret, 'response' => $token, ]; if ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) { $body['remoteip'] = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ); } $response = wp_remote_post( $endpoint, [ 'timeout' => 10, 'body' => $body, ] ); if ( is_wp_error( $response ) ) { $this->warning( 'Anti-spam verification request failed.', [ 'provider' => $provider, 'error' => $response->get_error_message(), ] ); return false; } $status_code = wp_remote_retrieve_response_code( $response ); $data = json_decode( wp_remote_retrieve_body( $response ), true ); $data = is_array( $data ) ? $data : []; if ( $status_code < 200 || $status_code >= 300 || empty( $data['success'] ) ) { $error_codes = isset( $data['error-codes'] ) && is_array( $data['error-codes'] ) ? $data['error-codes'] : []; if ( $this->should_log_antispam_failure( $status_code, $error_codes ) ) { $this->warning( 'Anti-spam verification failed.', [ 'provider' => $provider, 'status_code' => $status_code, 'error_codes' => $error_codes, ] ); } return false; } return '' === $expected_action || ( isset( $data['action'] ) && hash_equals( $expected_action, (string) $data['action'] ) ); } /** * Determine whether an anti-spam rejection indicates a technical failure. * * Routine missing, invalid, expired, or duplicate challenge responses are * expected validation outcomes and do not belong in the central log. * * @param int $status_code HTTP response status. * @param array $error_codes Provider error codes. * * @return bool */ private function should_log_antispam_failure( $status_code, array $error_codes ) { if ( $status_code < 200 || $status_code >= 300 ) { return true; } $routine_codes = [ 'missing-input-response', 'invalid-input-response', 'timeout-or-duplicate', ]; $error_codes = array_values( array_filter( array_map( 'strval', $error_codes ) ) ); return empty( $error_codes ) || ! empty( array_diff( $error_codes, $routine_codes ) ); } /** * Resolve which fields are currently active (not conditionally hidden) for * this submission, so required-field validation can be skipped for the * rest. Free always returns the full field tree unconditionally — Free's * own conditional-logic evaluator is an intentional pass-through, the * same shape as get_form_availability(). Pro attaches the real evaluator, * which walks each field/Row's `conditions` against the submitted raw * values. * * Storage, delivery, and type/bounds/choice validation are never affected * by this — only which fields are exempt from the required check in * validate_submission(). * * @param array> $fields Full sanitized form field tree. * @param array $raw Raw submitted field values. * @param int $form_id Form ID. * * @return array> Field tree pruned to only the active fields. */ private function resolve_active_fields( array $fields, array $raw, $form_id ) { /** * Filters the resolved active-field tree used to skip required-field * validation for conditionally-hidden fields. Free returns $fields * unconditionally; Pro prunes it to fields whose own conditions (and * every ancestor Row's conditions) evaluate to true. * * @param array> $active_fields Active field tree. Defaults to the full tree. * @param array> $fields Full sanitized form field tree. * @param array $raw Raw submitted field values. * @param int $form_id Form ID. */ return apply_filters( 'adminoptim_form_builder_resolve_active_fields', $fields, $fields, $raw, $form_id ); } /** * Resolve each Hidden field's submitted value before conditional logic runs. * A Hidden field with prepopulation off is always replaced by its saved * configuration (browser values are not trusted). One with prepopulation on * (the default) keeps the value posted by its own form instance, because * that is how a per-embed initial value reaches the submission; it falls * back to the saved value only when the browser sent nothing. Either way * the value is still sanitized in validate_submission(), and it is a * visitor-editable value, not trusted metadata. * * @param array> $fields Form field definitions. * @param array $raw Raw submitted field values. * * @return array */ private function resolve_authoritative_hidden_values( array $fields, array $raw ) { foreach ( self::flatten_submission_fields( $fields ) as $field ) { if ( 'hidden' !== (string) ( $field['type'] ?? '' ) || empty( $field['name'] ) ) { continue; } $name = (string) $field['name']; if ( ! empty( $field['allow_prepopulation'] ) && isset( $raw[ $name ] ) && is_scalar( $raw[ $name ] ) ) { continue; } $raw[ $name ] = $field['hidden_value'] ?? ''; } return $raw; } /** * Validate and sanitize a form submission. * * @param array> $fields Form field definitions. * @param array $raw Raw POST values. * @param array> $active_fields Field tree resolved as currently active * (not conditionally hidden); required-field * validation is skipped for anything outside it. * * @return array{values: array, errors: array} */ private function validate_submission( array $fields, array $raw, array $active_fields ) { $values = []; $errors = []; $active_names = array_flip( wp_list_pluck( self::flatten_submission_fields( $active_fields ), 'name' ) ); foreach ( self::flatten_submission_fields( $fields ) as $field ) { $field_name = (string) $field['name']; $field_type = (string) $field['type']; if ( in_array( $field_type, [ 'html_message', 'recaptcha', 'turnstile' ], true ) || '' === $field_name ) { continue; } if ( in_array( $field_type, [ 'file', 'image' ], true ) ) { $uploaded = $this->handle_uploaded_file( $field ); if ( is_wp_error( $uploaded ) ) { $errors[ $field_name ] = $uploaded->get_error_message(); $values[ $field_name ] = ''; continue; } // Pro can store a Media Library ID here, which the URL sanitizer // would turn into "http://123". $sanitized = is_int( $uploaded ) ? (string) $uploaded : Fields::sanitize_runtime_value( $field, $uploaded ); } elseif ( 'hidden' === $field_type ) { // resolve_authoritative_hidden_values() has already decided whether // the posted value or the saved one applies. $sanitized = Fields::sanitize_runtime_value( $field, $raw[ $field_name ] ?? ( $field['hidden_value'] ?? '' ) ); } else { $sanitized = Fields::sanitize_runtime_value( $field, $raw[ $field_name ] ?? null ); } $values[ $field_name ] = $sanitized; if ( ! empty( $field['required'] ) && isset( $active_names[ $field_name ] ) && Fields::is_effectively_empty_value( $field, $sanitized ) ) { $errors[ $field_name ] = __( 'This field is required.', 'admin-optimizer' ); continue; } $validation_error = self::validate_field_constraints( $field, $sanitized, $raw[ $field_name ] ?? null ); if ( '' !== $validation_error ) { $errors[ $field_name ] = $validation_error; } } return [ 'values' => $values, 'errors' => $errors, ]; } /** * Validate type-specific constraints after runtime sanitization. * * @param array $field Field definition. * @param mixed $sanitized Sanitized value. * @param mixed $raw_value Raw submitted value. * * @return string Empty when valid, otherwise an error message. */ public static function validate_field_constraints( array $field, $sanitized, $raw_value ) { $type = (string) ( $field['type'] ?? 'text' ); if ( Fields::is_empty_runtime_value( $raw_value ) ) { return ''; } if ( Fields::is_effectively_empty_value( $field, $sanitized ) && ! in_array( $type, [ 'select', 'radio', 'check', 'email', 'url', 'number', 'datepicker' ], true ) ) { return ''; } if ( in_array( $type, [ 'text', 'textarea' ], true ) ) { $length = function_exists( 'mb_strlen' ) ? mb_strlen( (string) $sanitized ) : strlen( (string) $sanitized ); if ( isset( $field['minlength'] ) && '' !== (string) $field['minlength'] && $length < absint( $field['minlength'] ) ) { return sprintf( /* translators: %d: minimum character length */ __( 'Please enter at least %d characters.', 'admin-optimizer' ), absint( $field['minlength'] ) ); } if ( isset( $field['maxlength'] ) && '' !== (string) $field['maxlength'] && $length > absint( $field['maxlength'] ) ) { return sprintf( /* translators: %d: maximum character length */ __( 'Please enter no more than %d characters.', 'admin-optimizer' ), absint( $field['maxlength'] ) ); } } if ( 'number' === $type ) { // The shared sanitizer blanks out-of-range numbers, so check the // submitted value to say which limit it broke. $error = self::validate_number_constraints( $field, $raw_value ); if ( '' !== $error ) { return $error; } // The sanitizer's step check is slightly looser than the one above, // so a value can pass here and still be blanked. Reject it rather // than save an empty answer without telling the visitor. return is_numeric( $sanitized ) ? '' : __( 'Please enter a valid number.', 'admin-optimizer' ); } if ( in_array( $type, [ 'select', 'radio', 'check' ], true ) ) { return self::validate_choice_constraints( $field, $raw_value ); } if ( 'datepicker' === $type ) { return self::validate_date_constraints( $field, (string) $sanitized ); } if ( 'email' === $type && '' !== (string) $raw_value && ! is_email( (string) $sanitized ) ) { return __( 'Please enter a valid email address.', 'admin-optimizer' ); } if ( 'url' === $type && '' !== (string) $raw_value && ! wp_http_validate_url( (string) $sanitized ) ) { return __( 'Please enter a valid URL.', 'admin-optimizer' ); } return ''; } /** * Validate numeric field constraints. * * @param array $field Field definition. * @param mixed $value Sanitized value. * * @return string */ public static function validate_number_constraints( array $field, $value ) { if ( ! is_numeric( $value ) ) { return __( 'Please enter a valid number.', 'admin-optimizer' ); } $number = (float) $value; if ( isset( $field['number_min'] ) && '' !== (string) $field['number_min'] && $number < (float) $field['number_min'] ) { return sprintf( /* translators: %s: minimum number */ __( 'Please enter a number greater than or equal to %s.', 'admin-optimizer' ), (string) $field['number_min'] ); } if ( isset( $field['number_max'] ) && '' !== (string) $field['number_max'] && $number > (float) $field['number_max'] ) { return sprintf( /* translators: %s: maximum number */ __( 'Please enter a number less than or equal to %s.', 'admin-optimizer' ), (string) $field['number_max'] ); } if ( isset( $field['number_step'] ) && '' !== (string) $field['number_step'] && is_numeric( $field['number_step'] ) && (float) $field['number_step'] > 0 ) { $base = isset( $field['number_min'] ) && '' !== (string) $field['number_min'] && is_numeric( $field['number_min'] ) ? (float) $field['number_min'] : 0.0; $step = (float) $field['number_step']; $remainder = fmod( abs( $number - $base ), $step ); if ( $remainder > 0.000001 && abs( $remainder - $step ) > 0.000001 ) { return sprintf( /* translators: %s: step size */ __( 'Please enter a number using increments of %s.', 'admin-optimizer' ), (string) $field['number_step'] ); } } return ''; } /** * Validate configured choice membership. * * @param array $field Field definition. * @param mixed $value Submitted value. * * @return string */ public static function validate_choice_constraints( array $field, $value ) { $choices = Fields::parse_choices( $field['choices'] ?? '' ); if ( empty( $choices ) ) { return ''; } $values = is_array( $value ) ? $value : [ $value ]; $values = array_values( array_filter( array_map( 'strval', $values ), 'strlen' ) ); if ( empty( $values ) ) { return ''; } foreach ( $values as $choice_value ) { if ( ! isset( $choices[ (string) $choice_value ] ) ) { return __( 'Please choose a valid option.', 'admin-optimizer' ); } } return ''; } /** * Validate date format and bounds. * * @param array $field Field definition. * @param string $value Submitted date. * * @return string */ public static function validate_date_constraints( array $field, $value ) { if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $value ) ) { return __( 'Please enter a valid date.', 'admin-optimizer' ); } $date = date_create_from_format( 'Y-m-d', $value ); if ( ! $date || $date->format( 'Y-m-d' ) !== $value ) { return __( 'Please enter a valid date.', 'admin-optimizer' ); } if ( ! empty( $field['date_min'] ) && $value < (string) $field['date_min'] ) { return sprintf( /* translators: %s: minimum date */ __( 'Please enter a date on or after %s.', 'admin-optimizer' ), (string) $field['date_min'] ); } if ( ! empty( $field['date_max'] ) && $value > (string) $field['date_max'] ) { return sprintf( /* translators: %s: maximum date */ __( 'Please enter a date on or before %s.', 'admin-optimizer' ), (string) $field['date_max'] ); } return ''; } /** * Flatten value-bearing fields from layout containers. * * @param array> $fields Field definitions. * * @return array> */ public static function flatten_submission_fields( array $fields ) { $flat = []; foreach ( $fields as $field ) { if ( ! is_array( $field ) ) { continue; } if ( isset( $field['type'] ) && 'row' === (string) $field['type'] ) { $flat = array_merge( $flat, self::flatten_submission_fields( (array) ( $field['children'] ?? [] ) ) ); continue; } if ( isset( $field['type'] ) && 'submit' === (string) $field['type'] ) { continue; } $flat[] = $field; } return $flat; } /** * Get valid submitted Email-type fields for an entry in form field order. * * @param array $entry Entry row. * * @return array,name:string,value:string}> */ public static function get_entry_email_fields( array $entry ) { $form_id = absint( $entry['form_id'] ?? 0 ); $data = json_decode( (string) ( $entry['entry_data'] ?? '' ), true ); if ( ! $form_id || ! is_array( $data ) ) { return []; } $email_fields = []; foreach ( self::flatten_submission_fields( Form_Builder_Forms::get_form_fields( $form_id ) ) as $field ) { if ( 'email' !== (string) ( $field['type'] ?? '' ) || empty( $field['name'] ) ) { continue; } $name = (string) $field['name']; $value = isset( $data[ $name ] ) && is_scalar( $data[ $name ] ) ? (string) $data[ $name ] : ''; if ( ! is_email( $value ) ) { continue; } $email_fields[] = [ 'field' => $field, 'name' => $name, 'value' => $value, ]; } return $email_fields; } /** * Get the first non-empty submitted Textarea field value in form order. * * @param array $entry Entry row. * * @return string */ public static function get_entry_first_textarea_value( array $entry ) { $form_id = absint( $entry['form_id'] ?? 0 ); $data = json_decode( (string) ( $entry['entry_data'] ?? '' ), true ); if ( ! $form_id || ! is_array( $data ) ) { return ''; } foreach ( self::flatten_submission_fields( Form_Builder_Forms::get_form_fields( $form_id ) ) as $field ) { if ( 'textarea' !== (string) ( $field['type'] ?? '' ) || empty( $field['name'] ) ) { continue; } $name = (string) $field['name']; $value = isset( $data[ $name ] ) && is_scalar( $data[ $name ] ) ? (string) $data[ $name ] : ''; if ( '' !== trim( $value ) ) { return $value; } } return ''; } /** * Build a reply body containing the original submitted textarea message. * * @param array $entry Entry row. * @param string $reply_email Email address being replied to. * @param bool $html Whether to format the body as HTML. * * @return string */ public static function get_entry_reply_message( array $entry, $reply_email, $html = false ) { $message = self::get_entry_first_textarea_value( $entry ); $reply_email = is_email( (string) $reply_email ); if ( '' === $message || ! $reply_email ) { return ''; } $date = isset( $entry['date_created'] ) && is_scalar( $entry['date_created'] ) ? trim( (string) $entry['date_created'] ) : ''; if ( '' !== $date ) { $header = sprintf( /* translators: 1: form submission date, 2: submitter email address */ __( 'On %1$s, %2$s wrote:', 'admin-optimizer' ), $date, $reply_email ); } else { $header = sprintf( /* translators: %s: submitter email address */ __( '%s wrote:', 'admin-optimizer' ), $reply_email ); } $reply = "\n\n\n" . $header . "\n\n" . $message; if ( $html ) { return nl2br( esc_html( $reply ) ); } return $reply; } /** * Store and/or email a validated submission. * * @param \WP_Post $form Form post. * @param array> $fields Field definitions. * @param array $values Sanitized values. * @param array $settings Form settings. * * Delivery infrastructure failures are logged internally but do not invalidate * an otherwise accepted frontend submission. * * @return array{stored: bool, mailed: bool} */ private function deliver_submission( \WP_Post $form, array $fields, array $values, array $settings ) { $stored = false; $mailed = false; $entry_id = null; if ( empty( $settings['delivery_mode'] ) || ! in_array( $settings['delivery_mode'], [ 'email_and_store', 'email_only', 'store_only' ], true ) ) { $this->warning( 'Form submission had an invalid delivery mode; falling back to email and store.', [ 'form_id' => $form->ID, 'delivery_mode' => $settings['delivery_mode'] ?? '', ] ); $settings['delivery_mode'] = 'email_and_store'; } if ( in_array( $settings['delivery_mode'], [ 'email_and_store', 'store_only' ], true ) ) { $entry_id = $this->store_form_entry( $form, $values ); $entry_id = false !== $entry_id ? (int) $entry_id : null; $stored = null !== $entry_id; if ( ! $stored ) { $this->error( 'Form submission could not be stored.', [ 'form_id' => $form->ID, 'delivery_mode' => $settings['delivery_mode'], ] ); } } if ( in_array( $settings['delivery_mode'], [ 'email_and_store', 'email_only' ], true ) ) { $mailed = $this->send_form_notification( $form, $fields, $values, $settings ); if ( ! empty( $settings['enable_autoresponder'] ) ) { $this->send_form_autoresponder( $form, $fields, $values, $settings ); } } if ( 'store_only' === $settings['delivery_mode'] ) { $mailed = true; } if ( 'email_only' === $settings['delivery_mode'] ) { $stored = true; } /** * Fires after an accepted submission has completed its selected storage and * email delivery attempts. Integrations must not alter the visitor response. * * @param int $form_id Form ID. * @param array $settings Sanitized form settings. * @param array> $fields Field definitions. * @param array $values Sanitized submitted values. * @param int|null $entry_id Stored entry ID, or null. */ try { do_action( 'adminoptim_form_builder_after_deliver_submission', (int) $form->ID, $settings, $fields, $values, $entry_id ); } catch ( \Throwable $e ) { $this->exception( $e, 'after_deliver_submission_listener_failed', [ 'form_id' => (int) $form->ID ] ); } return [ 'stored' => $stored, 'mailed' => $mailed, ]; } /** * Redirect with stored transient state. * * @param int $form_id Form ID. * @param string $redirect_url Redirect URL. * @param array $state Submission state. * * @return void */ private function redirect_with_form_state( $form_id, $redirect_url, array $state ) { $placement_key = isset( $_POST['aofb_instance'] ) ? Form_Builder::normalize_placement_key( wp_unslash( $_POST['aofb_instance'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce already verified by caller; value is sanitized by normalize_placement_key(). $state['form_id'] = absint( $form_id ); $state['placement_key'] = $placement_key; $token = wp_generate_password( 12, false, false ); set_transient( 'aofb_state_' . $token, $state, 5 * MINUTE_IN_SECONDS ); $redirect_url = add_query_arg( [ 'aofb_form' => $form_id, 'aofb_state' => $token, 'aofb_instance' => $placement_key, ], remove_query_arg( [ 'aofb_form', 'aofb_state', 'aofb_instance' ], $redirect_url ) ); wp_safe_redirect( $redirect_url ); exit; } /** * Get redirect target after submission. * * @return string */ private function get_submission_redirect_url() { $redirect = wp_get_referer(); if ( ! $redirect ) { $redirect = home_url( '/' ); } return (string) $redirect; } /** * Handle a single uploaded file field. * * @param array $field Field definition. * * @return int|string|\WP_Error */ private function handle_uploaded_file( array $field ) { $field_name = (string) $field['name']; $field_type = (string) $field['type']; if ( empty( $_FILES['adminoptim_form_files']['name'][ $field_name ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing return ''; } require_once ABSPATH . 'wp-admin/includes/file.php'; $file = [ 'name' => $_FILES['adminoptim_form_files']['name'][ $field_name ], // phpcs:ignore 'type' => $_FILES['adminoptim_form_files']['type'][ $field_name ], // phpcs:ignore 'tmp_name' => $_FILES['adminoptim_form_files']['tmp_name'][ $field_name ], // phpcs:ignore 'error' => $_FILES['adminoptim_form_files']['error'][ $field_name ], // phpcs:ignore 'size' => $_FILES['adminoptim_form_files']['size'][ $field_name ], // phpcs:ignore ]; // Free enforces only a flat size ceiling so a public form can never be // used to fill disk space when Pro is inactive (Pro's own filter below // takes over entirely once active, including its field-configurable, // possibly-higher, max_file_size_mb — this baseline must never also // apply on top of that or it would silently clamp Pro's own setting). if ( ! Module_Registry::is_pro_active() && ! empty( $file['size'] ) && (int) $file['size'] > self::FREE_MAX_UPLOAD_BYTES ) { return new \WP_Error( 'upload_error', sprintf( /* translators: %s: maximum upload size in megabytes. */ __( 'The uploaded file is too large. Maximum size is %s MB.', 'admin-optimizer' ), self::FREE_MAX_UPLOAD_BYTES / MB_IN_BYTES ) ); } /** * Filters whether an uploaded file passes validation before it's * handed to wp_handle_upload(). Pro hooks this to enforce the field's * `allowed_file_types`/`max_file_size_mb` settings — Free itself * applies no such limits beyond the flat size ceiling above. * * @param true|\WP_Error $valid true when valid, or a WP_Error to reject the upload. * @param array $file Raw $_FILES-shaped file data. * @param array $field Field definition. */ $valid = apply_filters( 'adminoptim_form_builder_validate_upload', true, $file, $field ); if ( is_wp_error( $valid ) ) { return $valid; } $upload_dir_filter = static function ( $dirs ) { $subdir = '/ao-form-uploads/' . gmdate( 'Y/m' ); $dirs['subdir'] = $subdir; $dirs['path'] = $dirs['basedir'] . $subdir; $dirs['url'] = $dirs['baseurl'] . $subdir; return $dirs; }; $filename_filter = static function ( $file ) { $info = pathinfo( $file['name'] ); $ext = empty( $info['extension'] ) ? '' : '.' . strtolower( $info['extension'] ); $name = basename( $file['name'], $ext ); $hash = wp_generate_password( 16, false, false ); // Neutralize dangerous extensions (defense in depth for Nginx/other servers). if ( preg_match( '/\.(php|phtml|phtm|pht|php3|php4|php5|phps|phar|pl|py|jsp|asp|htm|html|shtml|svg|js|sh|cgi|exe|bat|cmd|vbs)$/i', $ext ) ) { $ext .= '.txt'; } $file['name'] = sanitize_file_name( $name . '-' . $hash . $ext ); return $file; }; add_filter( 'upload_dir', $upload_dir_filter ); add_filter( 'wp_handle_upload_prefilter', $filename_filter ); $result = wp_handle_upload( $file, [ 'test_form' => false ] ); remove_filter( 'wp_handle_upload_prefilter', $filename_filter ); remove_filter( 'upload_dir', $upload_dir_filter ); $this->maybe_protect_upload_dir(); if ( isset( $result['error'] ) ) { return new \WP_Error( 'upload_error', (string) $result['error'] ); } if ( 'image' === $field_type && ( empty( $result['type'] ) || 0 !== strpos( $result['type'], 'image/' ) ) ) { if ( ! empty( $result['file'] ) && file_exists( $result['file'] ) ) { wp_delete_file( $result['file'] ); } return new \WP_Error( 'upload_error', __( 'Only image files are allowed for this field.', 'admin-optimizer' ) ); } if ( ! isset( $result['url'] ) ) { return ''; } /** * Filters the final stored value for a successful upload. Pro hooks * this to swap in a Media Library attachment ID when the field's * `store_as_attachment` setting is on — Free itself always returns * the raw uploaded-file URL. * * @param string|int $url Raw uploaded-file URL, Free's own result. Return an int to store a Media Library ID. * @param array $file Raw $_FILES-shaped file data. * @param array $field Field definition. * @param array $uploaded wp_handle_upload() result. */ return apply_filters( 'adminoptim_form_builder_uploaded_file_result', (string) $result['url'], $file, $field, $result ); } /** * Store a submission as a table row. * * @param \WP_Post $form Form post. * @param array $values Sanitized values. * * @return int|false */ private function store_form_entry( \WP_Post $form, array $values ) { global $wpdb; $table_name = $wpdb->prefix . self::TABLE_NAME; // phpcs:ignore $inserted = $wpdb->insert( $table_name, [ 'form_id' => $form->ID, 'entry_data' => wp_json_encode( $values ), 'ip_address' => isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '', 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_textarea_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '', 'date_created' => current_time( 'mysql' ), ], [ '%d', '%s', '%s', '%s', '%s' ] ); return $inserted ? $wpdb->insert_id : false; } /** * Send form notification email. * * @param \WP_Post $form Form post. * @param array> $fields Fields. * @param array $values Values. * @param array $settings Form settings. * * @return bool */ private function send_form_notification( \WP_Post $form, array $fields, array $values, array $settings ) { $defaults = Form_Builder_Forms::get_default_form_settings(); $token_values = $this->build_mail_token_map( $form, $fields, $values ); $settings = $this->normalize_mail_settings( $form->ID, $settings, $defaults ); $to = str_replace( [ "\r", "\n" ], '', $this->replace_mail_placeholders( (string) $settings['mail_to'], $token_values ) ); $subject = str_replace( [ "\r", "\n" ], ' ', $this->replace_mail_placeholders( (string) $settings['mail_subject'], $token_values ) ); $raw_body = (string) $settings['mail_message']; $has_custom_body = '' !== trim( wp_strip_all_tags( $raw_body ) ) && $raw_body !== (string) $defaults['mail_message']; $body = $this->replace_mail_placeholders( $raw_body, $token_values ); $is_html = ! empty( $settings['mail_send_as_html'] ); if ( '' === trim( $to ) ) { $this->warning( 'Form notification recipient was empty; falling back to default admin email.', [ 'form_id' => $form->ID, 'default_mail_to' => $defaults['mail_to'], 'setting_mail_to' => $settings['mail_to'] ?? '', 'available_tokens' => array_keys( $token_values ), ] ); $to = (string) $defaults['mail_to']; } if ( '' === trim( $subject ) ) { $this->warning( 'Form notification subject was empty; falling back to default subject.', [ 'form_id' => $form->ID, ] ); $subject = (string) $defaults['mail_subject']; } if ( '' === trim( wp_strip_all_tags( $body ) ) ) { $this->warning( 'Form notification body was empty; falling back to default message.', [ 'form_id' => $form->ID, ] ); $body = (string) $defaults['mail_message']; } if ( ! $has_custom_body ) { if ( $is_html ) { $body .= $this->build_submission_summary_html( $fields, $values ); } else { $body .= $this->build_submission_summary_text( $fields, $values ); } } elseif ( $is_html ) { $body = nl2br( $body ); } $headers = []; $from_name = str_replace( [ "\r", "\n" ], ' ', $this->replace_mail_placeholders( (string) $settings['mail_from_name'], $token_values ) ); $from_email = str_replace( [ "\r", "\n" ], '', $this->replace_mail_placeholders( (string) $settings['mail_from_email'], $token_values ) ); $reply_to = str_replace( [ "\r", "\n" ], '', $this->replace_mail_placeholders( (string) $settings['mail_reply_to'], $token_values ) ); if ( '' === trim( $from_name ) ) { $from_name = (string) $defaults['mail_from_name']; } if ( '' === trim( $from_email ) ) { $from_email = (string) $defaults['mail_from_email']; } if ( is_email( trim( $from_email ) ) ) { $headers[] = 'From: ' . trim( $from_name ) . ' <' . trim( $from_email ) . '>'; } elseif ( '' !== trim( $from_email ) ) { $this->warning( 'Form notification From email is invalid and was skipped.', [ 'form_id' => $form->ID, 'from_email' => $from_email, ] ); } if ( is_email( trim( $reply_to ) ) ) { $headers[] = 'Reply-To: ' . trim( $reply_to ); } elseif ( '' !== trim( $reply_to ) ) { $this->warning( 'Form notification Reply-To email is invalid and was skipped.', [ 'form_id' => $form->ID, 'reply_to' => $reply_to, ] ); } if ( $is_html ) { $headers[] = 'Content-Type: text/html; charset=UTF-8'; } $mail_callback = static function () use ( $to, $subject, $body, $headers ) { return wp_mail( $to, $subject, $body, $headers ); }; $profile_id = (string) ( $settings['mail_smtp_profile'] ?? '' ); if ( '' !== $profile_id && function_exists( 'adminoptim_smtp_with_profile' ) ) { $sent = adminoptim_smtp_with_profile( $profile_id, $mail_callback ); } elseif ( function_exists( 'adminoptim_smtp_with_route' ) ) { $sent = adminoptim_smtp_with_route( 'form_notification', $mail_callback ); } else { $sent = $mail_callback(); } return $sent; } /** * Send a confirmation email to the form submitter, if enabled. * * @param \WP_Post $form Form post. * @param array> $fields Fields. * @param array $values Values. * @param array $settings Form settings. * * @return bool */ private function send_form_autoresponder( \WP_Post $form, array $fields, array $values, array $settings ) { $to = $this->resolve_autoresponder_recipient( $fields, $values, $settings ); if ( '' === $to ) { return false; } $defaults = Form_Builder_Forms::get_default_form_settings(); $token_values = $this->build_mail_token_map( $form, $fields, $values ); $settings = $this->normalize_mail_settings( $form->ID, $settings, $defaults ); $subject = str_replace( [ "\r", "\n" ], ' ', $this->replace_mail_placeholders( (string) $settings['autoresponder_subject'], $token_values ) ); $body = $this->replace_mail_placeholders( (string) $settings['autoresponder_message'], $token_values ); $is_html = ! empty( $settings['autoresponder_send_as_html'] ); if ( $is_html ) { $body = nl2br( $body ); } $headers = []; $from_name = str_replace( [ "\r", "\n" ], ' ', $this->replace_mail_placeholders( (string) $settings['mail_from_name'], $token_values ) ); $from_email = str_replace( [ "\r", "\n" ], '', $this->replace_mail_placeholders( (string) $settings['mail_from_email'], $token_values ) ); if ( '' === trim( $from_name ) ) { $from_name = (string) $defaults['mail_from_name']; } if ( '' === trim( $from_email ) ) { $from_email = (string) $defaults['mail_from_email']; } if ( is_email( trim( $from_email ) ) ) { $headers[] = 'From: ' . trim( $from_name ) . ' <' . trim( $from_email ) . '>'; } if ( $is_html ) { $headers[] = 'Content-Type: text/html; charset=UTF-8'; } $mail_callback = static function () use ( $to, $subject, $body, $headers ) { return wp_mail( $to, $subject, $body, $headers ); }; $profile_id = (string) ( $settings['autoresponder_smtp_profile'] ?? '' ); if ( '' !== $profile_id && function_exists( 'adminoptim_smtp_with_profile' ) ) { return adminoptim_smtp_with_profile( $profile_id, $mail_callback ); } if ( function_exists( 'adminoptim_smtp_with_route' ) ) { return adminoptim_smtp_with_route( 'form_autoresponder', $mail_callback ); } return $mail_callback(); } /** * Resolve the submitter's email address for the autoresponder. * * @param array> $fields Fields. * @param array $values Submitted values. * @param array $settings Form settings. * * @return string */ private function resolve_autoresponder_recipient( array $fields, array $values, array $settings ) { $email_field_names = []; foreach ( self::flatten_submission_fields( $fields ) as $field ) { $name = isset( $field['name'] ) ? (string) $field['name'] : ''; if ( '' !== $name && 'email' === (string) ( $field['type'] ?? '' ) ) { $email_field_names[] = $name; } } $preferred = (string) ( $settings['autoresponder_to_field'] ?? '' ); if ( '' !== $preferred && in_array( $preferred, $email_field_names, true ) ) { $candidate = isset( $values[ $preferred ] ) && is_scalar( $values[ $preferred ] ) ? (string) $values[ $preferred ] : ''; if ( is_email( $candidate ) ) { return $candidate; } } foreach ( $email_field_names as $name ) { $candidate = isset( $values[ $name ] ) && is_scalar( $values[ $name ] ) ? (string) $values[ $name ] : ''; if ( is_email( $candidate ) ) { return $candidate; } } return ''; } /** * Reload and normalize form mail settings for delivery. * * @param int $form_id Form ID. * @param array $settings Current settings. * @param array $defaults Default settings. * * @return array */ private function normalize_mail_settings( $form_id, array $settings, array $defaults ) { $persisted = get_post_meta( $form_id, Form_Builder::META_SETTINGS, true ); $persisted = $this->maybe_unserialize_settings( $persisted ); if ( is_array( $persisted ) ) { $settings = array_merge( $settings, $persisted ); } $settings = wp_parse_args( $settings, $defaults ); if ( empty( $settings['delivery_mode'] ) || ! in_array( $settings['delivery_mode'], [ 'email_and_store', 'email_only', 'store_only' ], true ) ) { $settings['delivery_mode'] = $defaults['delivery_mode']; } foreach ( [ 'mail_to', 'mail_from_name', 'mail_from_email', 'mail_subject', 'mail_message', 'autoresponder_subject', 'autoresponder_message' ] as $setting_key ) { if ( '' === trim( (string) $settings[ $setting_key ] ) ) { $settings[ $setting_key ] = $defaults[ $setting_key ]; } } return $settings; } /** * Unserialize settings that may have been serialized more than once. * * @param mixed $settings Raw settings. * * @return mixed */ private function maybe_unserialize_settings( $settings ) { for ( $i = 0; $i < 3 && is_string( $settings ) && is_serialized( $settings ); $i++ ) { $settings = maybe_unserialize( $settings ); } return $settings; } /** * Build field label map. * * @param array> $fields Fields. * * @return array */ private function map_field_labels( array $fields ) { $labels = []; foreach ( self::flatten_submission_fields( $fields ) as $field ) { if ( empty( $field['name'] ) ) { continue; } $labels[ $field['name'] ] = (string) $field['label']; } return $labels; } /** * Build field-by-name map. * * @param array> $fields Fields. * * @return array> */ public static function map_fields_by_name( array $fields ) { $map = []; foreach ( self::flatten_submission_fields( $fields ) as $field ) { if ( empty( $field['name'] ) ) { continue; } $map[ $field['name'] ] = $field; } return $map; } /** * Build token map for mail placeholders. * * @param \WP_Post $form Form post. * @param array> $fields Fields. * @param array $values Values. * * @return array */ private function build_mail_token_map( \WP_Post $form, array $fields, array $values ) { $tokens = [ 'form_title' => $form->post_title, 'site_name' => get_bloginfo( 'name' ), ]; foreach ( self::flatten_submission_fields( $fields ) as $field ) { $name = isset( $field['name'] ) ? (string) $field['name'] : ''; if ( '' === $name || ! array_key_exists( $name, $values ) ) { continue; } $value = self::stringify_field_value( $values[ $name ], $field ); foreach ( $this->get_mail_token_keys_for_field( $field ) as $token_key ) { if ( '' !== $token_key ) { $tokens[ $token_key ] = $value; } } } return $tokens; } /** * Get all accepted placeholder keys for a field. * * @param array $field Field definition. * * @return array */ private function get_mail_token_keys_for_field( array $field ) { $keys = []; $name = isset( $field['name'] ) ? (string) $field['name'] : ''; $label = isset( $field['label'] ) ? (string) $field['label'] : ''; if ( '' !== $name ) { $keys[] = sanitize_key( $name ); } if ( '' !== $label ) { $keys[] = sanitize_key( $label ); $keys[] = str_replace( '-', '_', sanitize_title( $label ) ); } return array_values( array_unique( array_filter( $keys ) ) ); } /** * Replace mail placeholders in a string. * * @param string $text Source text. * @param array $tokens Token map. * * @return string */ private function replace_mail_placeholders( $text, array $tokens ) { return (string) preg_replace_callback( '/\{([a-zA-Z0-9_\-]+)\}/', static function ( $matches ) use ( $tokens ) { $key = sanitize_key( $matches[1] ); return $tokens[ $key ] ?? $matches[0]; }, (string) $text ); } /** * Build plain-text summary block. * * @param array> $fields Fields. * @param array $values Values. * * @return string */ private function build_submission_summary_text( array $fields, array $values ) { $lines = [ '', '', __( 'Submission details:', 'admin-optimizer' ) ]; $labels = $this->map_field_labels( $fields ); $fields_by_name = self::map_fields_by_name( $fields ); foreach ( $values as $name => $value ) { $lines[] = sprintf( '%1$s: %2$s', $labels[ $name ] ?? $name, self::stringify_field_value( $value, $fields_by_name[ $name ] ?? null ) ); } return implode( "\n", $lines ); } /** * Build HTML summary block. * * @param array> $fields Fields. * @param array $values Values. * * @return string */ private function build_submission_summary_html( array $fields, array $values ) { $labels = $this->map_field_labels( $fields ); $fields_by_name = self::map_fields_by_name( $fields ); $html = '

' . esc_html__( 'Submission details', 'admin-optimizer' ) . '

    '; foreach ( $values as $name => $value ) { $html .= '
  • ' . esc_html( $labels[ $name ] ?? $name ) . ': ' . esc_html( self::stringify_field_value( $value, $fields_by_name[ $name ] ?? null ) ) . '
  • '; } $html .= '
'; return $html; } /** * Convert a field value to a readable string. * * @param mixed $value Value. * @param array|null $field Field definition, when known. * * @return string */ public static function stringify_field_value( $value, ?array $field = null ) { if ( is_array( $value ) ) { return implode( ', ', array_map( 'strval', $value ) ); } if ( $field && 'rating' === (string) ( $field['type'] ?? '' ) && is_scalar( $value ) && '' !== (string) $value ) { $max = isset( $field['rating_max'] ) && in_array( (string) absint( $field['rating_max'] ), [ '5', '10' ], true ) ? absint( $field['rating_max'] ) : 5; return sprintf( '%1$s/%2$s', (string) $value, $max ); } if ( $field && in_array( (string) ( $field['type'] ?? '' ), [ 'file', 'image' ], true ) && is_scalar( $value ) ) { return self::get_file_value_text( $value, $field ); } return is_scalar( $value ) ? (string) $value : ''; } /** * Determine whether a form can currently render or accept submissions. * * @param int $form_id Form ID. * @param array $settings Form settings. * @param bool $include_limit Whether to include the entry limit check. * * @return array{open: bool, reason: string, message: string} */ public static function get_form_availability( $form_id, array $settings, $include_limit = true ) { $availability = [ 'open' => true, 'reason' => '', 'message' => '', ]; /** * Filters form availability (open/closed/blocked). Pro hooks this to * enforce access control (login/role), a scheduled close date, and a * submission-count limit — Free itself always leaves a form open. * * @param array{open: bool, reason: string, message: string} $availability Base availability. * @param int $form_id Form ID. * @param array $settings Form settings. * @param bool $include_limit Whether to include the entry-limit check. */ return apply_filters( 'adminoptim_form_builder_availability', $availability, $form_id, $settings, $include_limit ); } /** * Get form state from redirect transient. * * @param int $form_id Form ID. * * @return array */ public static function get_form_state( $form_id, $placement_key = '' ) { $state_key = isset( $_GET['aofb_state'] ) ? sanitize_key( wp_unslash( $_GET['aofb_state'] ) ) : ''; //phpcs:ignore WordPress.Security.NonceVerification.Recommended $state_form = isset( $_GET['aofb_form'] ) ? absint( wp_unslash( $_GET['aofb_form'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $state_placement = isset( $_GET['aofb_instance'] ) ? Form_Builder::normalize_placement_key( wp_unslash( $_GET['aofb_instance'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- value is sanitized by normalize_placement_key(). $placement_key = Form_Builder::normalize_placement_key( $placement_key ); if ( ! $state_key || $form_id !== $state_form || '' === $placement_key || $placement_key !== $state_placement ) { return []; } $state = get_transient( 'aofb_state_' . $state_key ); if ( ! is_array( $state ) || absint( $state['form_id'] ?? 0 ) !== absint( $form_id ) || $placement_key !== Form_Builder::normalize_placement_key( $state['placement_key'] ?? '' ) ) { return []; } delete_transient( 'aofb_state_' . $state_key ); return is_array( $state ) ? $state : []; } /** * Enforce a per-IP submission rate limit (5 submissions per 5 minutes per form). * * @param int $form_id Form ID. * * @return bool True if the request is within the limit, false if throttled. */ private function check_submission_rate_limit( $form_id ) { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; $key = 'aofb_rate_' . md5( $ip . '_' . (int) $form_id ); $count = (int) get_transient( $key ); if ( $count >= 5 ) { return false; } set_transient( $key, $count + 1, 5 * MINUTE_IN_SECONDS ); return true; } /** * Write a .htaccess and index.php into the dedicated form-uploads directory. * * Prevents PHP execution and directory listing. Called after every upload so * the files are created as soon as the directory exists. * * @return void */ private function maybe_protect_upload_dir() { $upload_dir = wp_upload_dir(); $target = $upload_dir['basedir'] . '/ao-form-uploads'; if ( ! is_dir( $target ) ) { return; } $htaccess = $target . '/.htaccess'; if ( ! file_exists( $htaccess ) ) { $content = "Options -Indexes\n" . "\n" . " Order deny,allow\n" . " Deny from all\n" . "\n"; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents file_put_contents( $htaccess, $content ); } $webconfig = $target . '/web.config'; if ( ! file_exists( $webconfig ) ) { $content = "\n" . "\n" . " \n" . " \n" . " \n" . "\n"; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents file_put_contents( $webconfig, $content ); } $index = $target . '/index.php'; if ( ! file_exists( $index ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents file_put_contents( $index, 'Silence is golden.' ); } } }