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(); ?>
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.' );
}
}
}