Translated labels keyed by tab slug. */ public static function tabs(): array { return array( 'general' => __( 'General', 'devplugins-custom-form-builder' ), 'email' => __( 'Email', 'devplugins-custom-form-builder' ), 'security' => __( 'Security', 'devplugins-custom-form-builder' ), 'uploads' => __( 'Uploads', 'devplugins-custom-form-builder' ), 'integrations' => __( 'Integrations', 'devplugins-custom-form-builder' ), 'export' => __( 'Export', 'devplugins-custom-form-builder' ), 'developer' => __( 'Developer', 'devplugins-custom-form-builder' ), ); } /** * Returns the tabs that no longer exist and where they went. * * The challenge settings used to have a tab of their own and now sit under * Security. Someone who bookmarked the old address, or followed a link from * their own notes, should land on the settings they were looking for rather * than on whichever tab happens to be first. * * @return array New slugs keyed by the retired one. */ public static function moved_tabs(): array { return array( 'recaptcha' => 'security', 'captcha' => 'security', ); } /** * Returns the page slug of a tab. * * @param string $tab Tab slug. */ public static function page( string $tab ): string { return SettingsPage::SLUG . '-' . $tab; } /** * Returns every registered field. * * @return array> Field definitions. */ public static function fields(): array { $fields = array( array( 'key' => 'submit_label', 'tab' => 'general', 'type' => 'text', 'label' => __( 'Submit button label', 'devplugins-custom-form-builder' ), 'description' => __( 'Used by forms that do not set their own label.', 'devplugins-custom-form-builder' ), ), array( 'key' => 'success_message', 'tab' => 'general', 'type' => 'textarea', 'label' => __( 'Success message', 'devplugins-custom-form-builder' ), 'description' => __( 'Shown after a submission unless the form overrides it.', 'devplugins-custom-form-builder' ), ), array( 'key' => 'save_progress', 'tab' => 'general', 'type' => 'checkbox', 'label' => __( 'Save progress on multi-step forms', 'devplugins-custom-form-builder' ), 'description' => __( 'Lets visitors come back to a form they have not finished. Their answers are held on the server until the link expires, so leave this off for forms that ask for anything sensitive.', 'devplugins-custom-form-builder' ), ), array( 'key' => 'progress_lifetime', 'tab' => 'general', 'type' => 'number', 'label' => __( 'Keep saved progress for (days)', 'devplugins-custom-form-builder' ), 'min' => 1, 'max' => 90, 'description' => __( 'How long a resume link keeps working. Unfinished answers are deleted when it expires.', 'devplugins-custom-form-builder' ), ), array( 'key' => 'from_name', 'tab' => 'email', 'type' => 'text', 'label' => __( 'From name', 'devplugins-custom-form-builder' ), 'description' => __( 'Leave empty to use the site title.', 'devplugins-custom-form-builder' ), ), array( 'key' => 'from_email', 'tab' => 'email', 'type' => 'text', 'label' => __( 'From address', 'devplugins-custom-form-builder' ), 'description' => __( 'Leave empty to let your SMTP plugin pick its verified sender.', 'devplugins-custom-form-builder' ), ), array( 'key' => 'admin_recipients', 'tab' => 'email', 'type' => 'text', 'label' => __( 'Default recipients', 'devplugins-custom-form-builder' ), 'description' => __( 'Comma separated. Used by forms that do not set their own recipients. Defaults to the site admin address.', 'devplugins-custom-form-builder' ), ), array( 'key' => 'queue', 'tab' => 'email', 'type' => 'select', 'label' => __( 'Delivery', 'devplugins-custom-form-builder' ), 'options' => array( 'immediate' => __( 'Send during the submission request', 'devplugins-custom-form-builder' ), 'cron' => __( 'Queue and send on the next cron run', 'devplugins-custom-form-builder' ), ), 'description' => __( 'Queueing answers the visitor faster when the mail server is slow.', 'devplugins-custom-form-builder' ), ), array( 'key' => 'honeypot', 'tab' => 'security', 'type' => 'checkbox', 'label' => __( 'Honeypot', 'devplugins-custom-form-builder' ), 'description' => __( 'Add a hidden field that bots fill in and people never see.', 'devplugins-custom-form-builder' ), ), array( 'key' => 'rate_limit', 'tab' => 'security', 'type' => 'number', 'label' => __( 'Rate limit', 'devplugins-custom-form-builder' ), 'min' => 0, 'max' => 100, 'description' => __( 'Submissions allowed per form, per address, per minute. Set to 0 to switch the limit off.', 'devplugins-custom-form-builder' ), ), array( 'key' => 'submission_interval', 'tab' => 'security', 'type' => 'number', 'label' => __( 'Seconds between submissions', 'devplugins-custom-form-builder' ), 'min' => 0, 'max' => 3600, 'description' => __( 'Smallest gap allowed between two submissions from one address, across all forms. Set to 0 to switch the throttle off.', 'devplugins-custom-form-builder' ), ), array( 'key' => 'min_submit_seconds', 'tab' => 'security', 'type' => 'number', 'label' => __( 'Minimum time to fill in', 'devplugins-custom-form-builder' ), 'min' => 0, 'max' => 300, 'description' => __( 'A form returned faster than this looks automated. Counts towards the spam score rather than refusing on its own. Set to 0 to switch it off.', 'devplugins-custom-form-builder' ), ), array( 'key' => 'spam_threshold', 'tab' => 'security', 'type' => 'number', 'label' => __( 'Spam score threshold', 'devplugins-custom-form-builder' ), 'min' => 1, 'max' => 500, 'description' => __( 'Submissions scoring this many points or more are refused. A filled honeypot or a failed challenge is worth 100 on its own; lower this to also refuse submissions that merely look suspicious.', 'devplugins-custom-form-builder' ), ), array( 'key' => 'blocked_ips', 'tab' => 'security', 'type' => 'textarea', 'label' => __( 'Blocked addresses', 'devplugins-custom-form-builder' ), 'description' => __( 'One per line. Accepts an exact address, a CIDR range such as 203.0.113.0/24, or a wildcard such as 203.0.113.*. Lines starting with # are notes. Addresses are shared and reassigned, so use this as a last resort.', 'devplugins-custom-form-builder' ), ), array( 'key' => 'captcha_provider', 'tab' => 'security', 'type' => 'select', 'label' => __( 'Challenge', 'devplugins-custom-form-builder' ), 'options' => CaptchaManager::choices(), 'description' => __( 'The keys for the chosen provider must be filled in below, or the challenge stays off rather than locking visitors out of the form.', 'devplugins-custom-form-builder' ), ), array( 'key' => 'recaptcha_site_key', 'tab' => 'security', 'type' => 'text', 'label' => __( 'reCAPTCHA site key', 'devplugins-custom-form-builder' ), 'description' => __( 'Used by both reCAPTCHA versions. The keys are not interchangeable: a v2 key will not work in v3 mode.', 'devplugins-custom-form-builder' ), ), array( 'key' => 'recaptcha_secret', 'tab' => 'security', 'type' => 'password', 'label' => __( 'reCAPTCHA secret key', 'devplugins-custom-form-builder' ), ), array( 'key' => 'recaptcha_threshold', 'tab' => 'security', 'type' => 'number', 'label' => __( 'reCAPTCHA v3 score threshold', 'devplugins-custom-form-builder' ), 'min' => 0, 'max' => 1, 'step' => 0.1, 'description' => __( 'Submissions scoring below this value fail the challenge. 0.5 is the value Google suggests. Ignored by v2, which answers yes or no.', 'devplugins-custom-form-builder' ), ), array( 'key' => 'turnstile_site_key', 'tab' => 'security', 'type' => 'text', 'label' => __( 'Turnstile site key', 'devplugins-custom-form-builder' ), ), array( 'key' => 'turnstile_secret', 'tab' => 'security', 'type' => 'password', 'label' => __( 'Turnstile secret key', 'devplugins-custom-form-builder' ), ), array( 'key' => 'upload_max_size', 'tab' => 'uploads', 'type' => 'number', 'label' => __( 'Maximum size (KB)', 'devplugins-custom-form-builder' ), 'min' => 0, 'description' => __( 'Applied to upload fields that do not set their own limit. The server limit still wins when it is lower.', 'devplugins-custom-form-builder' ), ), array( 'key' => 'upload_allowed_types', 'tab' => 'uploads', 'type' => 'text', 'label' => __( 'Allowed file types', 'devplugins-custom-form-builder' ), 'description' => __( 'Comma separated extensions, applied to upload fields that do not set their own list.', 'devplugins-custom-form-builder' ), ), array( 'key' => 'upload_to_media_library', 'tab' => 'uploads', 'type' => 'checkbox', 'label' => __( 'Media Library', 'devplugins-custom-form-builder' ), 'description' => __( 'Register uploads as attachments. They then appear in the Media Library and are deleted with it.', 'devplugins-custom-form-builder' ), ), array( 'key' => 'upload_cleanup', 'tab' => 'uploads', 'type' => 'checkbox', 'label' => __( 'Delete orphaned files', 'devplugins-custom-form-builder' ), 'description' => __( 'Run a daily cleanup that removes uploads no entry points at any more.', 'devplugins-custom-form-builder' ), ), array( 'key' => 'upload_retention', 'tab' => 'uploads', 'type' => 'number', 'label' => __( 'Keep unreferenced files for (hours)', 'devplugins-custom-form-builder' ), 'min' => 1, 'max' => 8760, 'description' => __( 'A grace period, so a file that belongs to a submission still being written is never removed.', 'devplugins-custom-form-builder' ), ), array( 'key' => 'export_delimiter', 'tab' => 'export', 'type' => 'select', 'label' => __( 'CSV delimiter', 'devplugins-custom-form-builder' ), 'options' => array( ',' => __( 'Comma', 'devplugins-custom-form-builder' ), ';' => __( 'Semicolon', 'devplugins-custom-form-builder' ), "\t" => __( 'Tab', 'devplugins-custom-form-builder' ), ), 'description' => __( 'Spreadsheets in some locales expect a semicolon.', 'devplugins-custom-form-builder' ), ), array( 'key' => 'export_include_meta', 'tab' => 'export', 'type' => 'checkbox', 'label' => __( 'Include metadata', 'devplugins-custom-form-builder' ), 'description' => __( 'Add the status, date and IP address columns to the export. The IP address is personal data in most jurisdictions, so this is off by default.', 'devplugins-custom-form-builder' ), ), array( 'key' => 'integrations_enabled', 'tab' => 'integrations', 'type' => 'multicheck', 'label' => __( 'Available services', 'devplugins-custom-form-builder' ), 'options' => self::integration_choices(), 'description' => __( 'Only the services ticked here appear in the form editor. Switching one off leaves what each form stored for it untouched.', 'devplugins-custom-form-builder' ), ), array( 'key' => 'rest_enabled', 'tab' => 'developer', 'type' => 'checkbox', 'label' => __( 'REST API', 'devplugins-custom-form-builder' ), 'description' => __( 'Expose the plugin routes under /wp-json/formpilot-builder/v1. Reading forms and entries still requires the matching capability.', 'devplugins-custom-form-builder' ), ), array( 'key' => 'log_level', 'tab' => 'developer', 'type' => 'select', 'label' => __( 'Logging', 'devplugins-custom-form-builder' ), 'options' => LogLevel::choices(), 'description' => __( 'What the plugin records about emails, deliveries, uploads, refused submissions, REST requests and database errors. Debug is for while something is misbehaving, not for leaving on.', 'devplugins-custom-form-builder' ), ), array( 'key' => 'log_retention', 'tab' => 'developer', 'type' => 'number', 'label' => __( 'Keep logs for (days)', 'devplugins-custom-form-builder' ), 'min' => 0, 'max' => 365, 'description' => __( 'Log files older than this are deleted daily. Set to 0 to keep them until you clear them by hand.', 'devplugins-custom-form-builder' ), ), array( 'key' => 'webhook_retention', 'tab' => 'developer', 'type' => 'number', 'label' => __( 'Keep delivery log for (days)', 'devplugins-custom-form-builder' ), 'min' => 0, 'max' => 365, 'description' => __( 'Finished webhook and integration deliveries older than this are removed daily. Set to 0 to keep them all. Deliveries still waiting to be retried are never removed by age.', 'devplugins-custom-form-builder' ), ), array( 'key' => 'remove_data', 'tab' => 'developer', 'type' => 'checkbox', 'label' => __( 'Delete data on uninstall', 'devplugins-custom-form-builder' ), 'description' => __( 'Remove every form, entry and upload when the plugin is deleted. This cannot be undone.', 'devplugins-custom-form-builder' ), ), ); $fields = array_merge( $fields, self::integration_fields() ); /** * Filters the registered settings fields. * * @since 1.0.0 * * @param array> $fields Field definitions. */ return (array) apply_filters( 'formpilot_settings_fields', $fields ); } /** * Returns the integrations that can be switched on, for the tick list. * * @return array Labels keyed by integration slug. */ private static function integration_choices(): array { $choices = array(); foreach ( ( new IntegrationRegistry() )->registered() as $id => $integration ) { $choices[ $id ] = $integration->label(); } return $choices; } /** * Returns the site wide credentials the integrations declare. * * An integration marks an option `shared` when it is an account credential * rather than a per form choice — an API key is the same key on every form, * and typing it into each one is both tedious and a worse place to have to * rotate it. A form may still override the value; this is the fallback. * * @return array> Field definitions. */ private static function integration_fields(): array { $fields = array(); foreach ( ( new IntegrationRegistry() )->registered() as $id => $integration ) { foreach ( $integration->options() as $option ) { if ( empty( $option['shared'] ) ) { continue; } $fields[] = array_merge( $option, array( 'key' => IntegrationRegistry::shared_key( $id, (string) $option['key'] ), 'tab' => 'integrations', 'label' => $integration->label() . ': ' . (string) ( $option['label'] ?? $option['key'] ), // A site wide default is never itself required; a form can // always supply its own. 'required' => false, ) ); } } return $fields; } /** * Determines whether a field holds a credential. * * Used by the export, which leaves them out unless asked. A password field is * a secret by definition; anything else says so for itself. * * @param array $field Field definition. * * @return bool True when the value should be treated as a credential. */ public static function is_secret( array $field ): bool { return 'password' === (string) ( $field['type'] ?? '' ) || ! empty( $field['secret'] ); } /** * Returns the field definitions keyed by setting name. * * @return array> Definitions keyed by setting. */ public static function map(): array { $map = array(); foreach ( self::fields() as $field ) { $key = (string) ( $field['key'] ?? '' ); if ( '' !== $key ) { $map[ $key ] = $field; } } return $map; } /** * Cleans one value against its field definition. * * Exposed so the import runs every value through exactly the checks the * settings form does, rather than trusting a file somebody uploaded. * * @param array $field Field definition. * @param mixed $value Submitted value. * * @return mixed The value to store. */ public static function clean( array $field, mixed $value ): mixed { return ( new self() )->sanitize_field( $field, $value ); } /** * Registers the setting, its sections and its fields. */ public function register(): void { register_setting( self::GROUP, Settings::OPTION, array( 'type' => 'array', 'sanitize_callback' => array( $this, 'sanitize' ), 'default' => Settings::defaults(), ) ); foreach ( array_keys( self::tabs() ) as $tab ) { add_settings_section( 'formpilot_' . $tab, '', '__return_false', self::page( $tab ) ); } foreach ( self::fields() as $field ) { $tab = (string) ( $field['tab'] ?? 'general' ); $key = (string) ( $field['key'] ?? '' ); if ( '' === $key ) { continue; } add_settings_field( 'formpilot_field_' . $key, (string) ( $field['label'] ?? $key ), array( $this, 'render_field' ), self::page( $tab ), 'formpilot_' . $tab, array_merge( $field, array( 'label_for' => 'formpilot-setting-' . $key ) ) ); } } /** * Renders a single field. * * @param array $args Field definition. */ public function render_field( array $args ): void { $key = (string) ( $args['key'] ?? '' ); $type = (string) ( $args['type'] ?? 'text' ); $id = 'formpilot-setting-' . $key; $name = Settings::OPTION . '[' . $key . ']'; $value = Settings::get( $key ); switch ( $type ) { case 'checkbox': printf( '', esc_attr( $id ), esc_attr( $name ), checked( (bool) $value, true, false ), esc_html( (string) ( $args['description'] ?? '' ) ) ); return; case 'multicheck': $options = (array) ( $args['options'] ?? array() ); /* * Never saved shows everything ticked, because that is what the * setting means when it is absent. Rendering it as all unticked * would say the opposite of what the site is actually doing. */ $current = is_array( $value ) ? array_map( 'strval', $value ) : array_map( 'strval', array_keys( $options ) ); echo '
'; foreach ( $options as $option_value => $option_label ) { printf( '
', esc_attr( $name ), esc_attr( (string) $option_value ), checked( in_array( (string) $option_value, $current, true ), true, false ), esc_html( (string) $option_label ) ); } echo '
'; break; case 'textarea': printf( '', esc_attr( $id ), esc_attr( $name ), esc_textarea( is_scalar( $value ) ? (string) $value : '' ) ); break; case 'select': $options = (array) ( $args['options'] ?? array() ); echo ''; break; case 'number': printf( '', esc_attr( $id ), esc_attr( $name ), esc_attr( is_scalar( $value ) ? (string) $value : '' ), esc_attr( (string) ( $args['min'] ?? '' ) ), esc_attr( (string) ( $args['max'] ?? '' ) ), esc_attr( (string) ( $args['step'] ?? '1' ) ) ); break; case 'password': /* * Masked, like the per-form credentials. These are the same kinds of * secret — a captcha secret, a Mailchimp key, a HubSpot token — and the * remediation that masked them on the form editor left this screen * printing the real value into the HTML. A stored key is only ever * written, never read back by a person, so showing that one exists is * all this field owes the administrator. */ printf( '', esc_attr( $id ), esc_attr( $name ), esc_attr( Redact::secret( $value ) ) ); break; default: printf( '', esc_attr( $id ), esc_attr( $name ), esc_attr( is_scalar( $value ) ? (string) $value : '' ) ); break; } if ( ! empty( $args['description'] ) ) { printf( '

%s

', esc_html( (string) $args['description'] ) ); } } /** * Sanitises a submitted settings page. * * Each tab posts only its own fields, so the submitted values are merged * over the stored ones instead of replacing them. Without that, saving the * Email tab would blank every security setting. * * @param mixed $input Submitted values. * * @return array The settings to store. */ public function sanitize( mixed $input ): array { $stored = get_option( Settings::OPTION, array() ); $stored = is_array( $stored ) ? $stored : array(); $input = is_array( $input ) ? $input : array(); $settings = array_merge( Settings::defaults(), $stored ); $fields = self::map(); $submitted_tabs = $this->submitted_tabs( $input, $fields ); // The marker is how the form says which tab it is; it is not a setting. unset( $input[ self::TAB_MARKER ] ); foreach ( $fields as $key => $field ) { $tab = (string) ( $field['tab'] ?? 'general' ); if ( ! in_array( $tab, $submitted_tabs, true ) ) { continue; } $settings[ $key ] = $this->sanitize_field( $field, $input[ $key ] ?? null ); } Settings::flush(); return $settings; } /** * Works out which tabs the submission covers. * * An unchecked checkbox is simply absent from the request, so the tab has to * be identified from the fields that did arrive before those checkboxes can * be read as "off". * * @param array $input Submitted values. * @param array> $fields Field definitions keyed by setting. * * @return array Tab slugs. */ private function submitted_tabs( array $input, array $fields ): array { /* * The form says which tab it is, in a hidden field. It used to be worked * out from which settings arrived, which cannot answer the question at * all when every control on the tab is a checkbox or a tick list: an * unticked box is simply absent, so a tab where nothing is ticked submits * nothing and looks identical to a tab that was never opened. */ $marker = isset( $input[ self::TAB_MARKER ] ) ? sanitize_key( (string) $input[ self::TAB_MARKER ] ) : ''; if ( '' !== $marker && array_key_exists( $marker, self::tabs() ) ) { return array( $marker ); } // An older form, or one built by an add-on, still resolves by its keys. $tabs = array(); foreach ( array_keys( $input ) as $key ) { $tab = (string) ( $fields[ (string) $key ]['tab'] ?? '' ); if ( '' !== $tab && ! in_array( $tab, $tabs, true ) ) { $tabs[] = $tab; } } return $tabs; } /** * Sanitises one submitted value. * * @param array $field Field definition. * @param mixed $value Submitted value. * * @return mixed The value to store. */ private function sanitize_field( array $field, mixed $value ): mixed { $type = (string) ( $field['type'] ?? 'text' ); /* * An untouched credential field posts back the mask the screen rendered. * Storing it would replace the key with a row of asterisks. */ if ( self::is_secret( $field ) && Redact::is_mask( $value ) ) { return Settings::get( (string) ( $field['key'] ?? '' ), '' ); } switch ( $type ) { case 'checkbox': return ! empty( $value ); case 'multicheck': $options = (array) ( $field['options'] ?? array() ); $value = is_array( $value ) ? array_map( 'strval', $value ) : array(); return array_values( array_intersect( $value, array_map( 'strval', array_keys( $options ) ) ) ); case 'textarea': return sanitize_textarea_field( is_scalar( $value ) ? (string) $value : '' ); case 'number': $number = is_scalar( $value ) ? (float) $value : 0.0; if ( isset( $field['min'] ) ) { $number = max( (float) $field['min'], $number ); } if ( isset( $field['max'] ) ) { $number = min( (float) $field['max'], $number ); } $fractional = isset( $field['step'] ) && (float) $field['step'] < 1; return $fractional ? $number : (int) $number; case 'select': $options = (array) ( $field['options'] ?? array() ); $value = is_scalar( $value ) ? (string) $value : ''; if ( array_key_exists( $value, $options ) ) { return $value; } /* * Falls back to the setting's own default, not to whichever option * happens to be listed first. `array_key_first()` meant an * unrecognised value silently became something arbitrary — for a * setting like the captcha provider or the queue mode, a change of * behaviour nobody asked for and nothing recorded. */ $default = (string) ( $field['default'] ?? Settings::defaults()[ (string) ( $field['key'] ?? '' ) ] ?? '' ); if ( array_key_exists( $default, $options ) ) { return $default; } /* * Falls back to the first option rather than to `''` when even the default * is not offered — which a filter can cause, by removing a captcha provider * or an export format. `''` is not a valid value for any of these settings, * and writing it would switch the feature off rather than pick something. */ return array() === $options ? '' : (string) array_key_first( $options ); default: return sanitize_text_field( is_scalar( $value ) ? (string) $value : '' ); } } }