get_option( 'admin_email' ), 'honeypot_enabled' => true, 'block_disposable_emails' => false, 'delete_data_on_uninstall' => false, 'webhook_default_url' => '', 'entries_date_format' => 'M j, Y g:ia', // Off by default (WordPress.org guidelines: no remote call may // run without explicit opt-in, even a purely cosmetic one like a // webfont) — a site owner who wants the Poppins admin-UI font // turns this on themselves under Global Settings > General. 'google_fonts_enabled' => false, // Layout 'label_placement' => 'top', // top | left 'help_placement' => 'below', // below | tooltip 'error_placement' => 'below', // below | above 'asterisk_position' => 'right', // right | left — matches the frontend's own fallback default (class-shortcode.php) and the per-form override's own enum (class-form-settings-page.php) // Email Summaries (weekly digest, WP-Cron) 'email_summaries_enabled' => false, 'email_summaries_send_to' => 'admin', // admin | custom 'email_summaries_custom_email' => '', 'email_summaries_day' => 'monday', 'email_summaries_subject' => 'Your weekly ChargeForms report', // Integration failure notices 'integration_failure_email_enabled' => true, // Double opt-in default (per-form setting can still override) 'double_optin_enabled' => false, // Save & Resume — a visitor's in-progress form is saved as a // draft (server-side, keyed by a bearer token, never a database // id — see Draft_Token's class doc comment) and can be resumed // later via an emailed link or, for a logged-in visitor, // automatically on their next visit to the same form. // // Opt-in, not opt-out: real bug reported live (a "Save Draft" + // "email me a link" row appearing on a published form the site // owner never asked for it on, with no way to turn it off per // form) traced to this defaulting to `true` — every existing // and new form got extra front-end UI nobody explicitly // enabled. There's no per-form override for this setting today // (checked admin/class-form-settings-page.php — none exists), // so the only honest default until one does is off. 'save_resume_enabled' => false, // Sliding expiration — extended on every save, capped at 3x // this value from a draft's original creation (Draft:: // max_lifetime_seconds()) so an actively-saved draft can't be // kept alive forever. 'save_resume_expiry_days' => 30, // How often the front end's background autosave timer fires — // purely a client-side pacing knob; the server additionally // never writes anything an autosave tick didn't actually change // (Draft_Service::save()'s values_hash short-circuit) regardless // of how often the client calls in. 'save_resume_autosave_seconds' => 20, // Mailchimp — a single site-wide API key (Mailchimp's own account // model is one key per account, same reasoning as Stripe/PayPal // keys below being site-wide rather than per-form). Which // audience/list a given form subscribes to, and any tags, are // per-form (Form::settings.integrations.mailchimp — see // Mailchimp_Integration). 'mailchimp_api_key' => '', 'brevo_api_key' => '', 'activecampaign_api_key' => '', 'activecampaign_api_url' => '', // Slack — an Incoming Webhook URL is inherently channel-specific // (created per-channel in Slack), so this is only ever a // fallback for a form that doesn't set its own — same pattern as // webhook_default_url above (see Slack_Integration::resolve_url()). 'slack_default_webhook_url' => '', // Discord — same "channel-specific webhook, global is just the // fallback" reasoning as Slack above (see Discord_Integration::resolve_url()). 'discord_default_webhook_url' => '', // Telegram — two separate pieces of config, not one URL: a bot // token (a real secret, unlike a channel-scoped webhook URL — // see Telegram_Integration's own class doc comment) and the // numeric chat id the bot should post into. 'telegram_default_bot_token' => '', 'telegram_default_chat_id' => '', // Notion — an Internal Integration token (a real secret) plus a // target database id. The token also needs the target database // explicitly shared with it inside Notion itself, or every // request 404s even with correct credentials — see // Notion_Integration's own class doc comment. 'notion_default_token' => '', 'notion_default_database_id' => '', // HubSpot — a Private App access token (HubSpot's own simpler // alternative to OAuth for a single-account integration), one // per site, same reasoning as mailchimp_api_key. 'hubspot_access_token' => '', // Google Sheets — a Service Account JSON key (see // Google_Sheets_Client's class doc comment for why a service // account rather than interactive OAuth). Large multiline JSON, // hence sanitize_textarea_field rather than sanitize_text_field // in update()'s $string_fields map below — a plain text-field // sanitizer would strip the newlines a PEM private key needs. 'google_sheets_service_account_json' => '', // MCP for AI Agents — off by default (security-conscious: a // write-capable API surface for AI agents should be an explicit // opt-in, not silently available the moment the plugin updates). // See includes/mcp/class-mcp-server.php. 'mcp_enabled' => false, // reCAPTCHA v2 / hCaptcha / Cloudflare Turnstile — all three verified // server-side against their own API, not just a UI widget. 'recaptcha_site_key' => '', 'recaptcha_secret_key' => '', 'hcaptcha_site_key' => '', 'hcaptcha_secret_key' => '', 'turnstile_site_key' => '', 'turnstile_secret_key' => '', // Address field autocomplete — 'none' (plain inputs, the // long-standing default), 'google' (Places Autocomplete, needs a // site-owner-supplied key below), or 'osm' (OpenStreetMap's free // Nominatim search, no key needed — client-side debounced to stay // under Nominatim's public 1 req/sec usage policy). 'address_autocomplete_provider' => 'none', 'google_maps_api_key' => '', // Stripe — test or live keys, whichever the site owner pastes in. // Payment amount is always recomputed server-side from the form's // own field config at PaymentIntent creation time; these keys // never let a form's price be client-controlled. 'stripe_publishable_key' => '', 'stripe_secret_key' => '', 'stripe_currency' => 'usd', // Signing secret for the /chargeforms/v1/webhooks/stripe endpoint // (Stripe Dashboard > Webhooks > this endpoint > "Signing // secret"). Every inbound webhook request is verified against // this before its payload is trusted for anything — see // Stripe_Webhook_Handler::verify_signature(). Left empty, the // webhook endpoint rejects every request rather than silently // trusting unsigned payloads. 'stripe_webhook_secret' => '', // PayPal — Orders v2 REST app credentials. `paypal_mode` defaults // to sandbox, same "don't accidentally go live" reasoning as // starting a fresh Stripe setup with test keys until swapped // deliberately. 'paypal_client_id' => '', 'paypal_client_secret' => '', 'paypal_mode' => 'sandbox', // sandbox | live 'paypal_currency' => 'usd', // Payment module — a site-wide on/off separate from any one // gateway's own keys, plus the business info shown on receipts. 'payment_module_enabled' => true, 'payment_business_name' => '', 'payment_business_address' => '', // Validation Messages — defaults match the plain English every // field already falls back to; only actually used once an admin // overrides one, so leaving them untouched changes nothing. 'validation_msg_required' => __( 'This field is required', 'chargeforms' ), 'validation_msg_email' => __( 'This field must contain a valid email', 'chargeforms' ), 'validation_msg_numeric' => __( 'This field must contain numeric value', 'chargeforms' ), 'validation_msg_min' => __( 'Validation fails for minimum value', 'chargeforms' ), 'validation_msg_max' => __( 'Validation fails for maximum value', 'chargeforms' ), 'validation_msg_digits' => __( 'Validation fails for limited digits', 'chargeforms' ), 'validation_msg_url' => __( 'This field must contain a valid url', 'chargeforms' ), 'validation_msg_image_type' => __( 'Allowed image types does not match', 'chargeforms' ), 'validation_msg_file_type' => __( 'Invalid file type', 'chargeforms' ), 'validation_msg_file_size' => __( 'Validation fails for maximum file size', 'chargeforms' ), 'validation_msg_file_count' => __( 'Validation fails for maximum file count', 'chargeforms' ), // Miscellaneous 'disable_ip_logging' => false, 'disable_form_analytics' => false, 'auto_tab_index_enabled' => false, 'admin_top_nav_enabled' => true, 'email_footer_text' => '', 'date_time_format' => 'relative', // relative | wp_default 'default_style_template_enabled' => false, // CleanTalk anti-spam 'cleantalk_access_key' => '', 'cleantalk_spam_validation' => 'mark_spam', // mark_spam | fail | mark_spam_skip // Role Based permissions — which non-Administrator roles also get // the chargeforms_manage_forms capability. Administrators always // have it regardless of this list (granted separately at // activation) — this only ever ADDS roles, never removes Admin. 'permitted_roles' => [], // License (Settings > License, License_Client/License_Page/ // License_Cron) — entirely optional. The free plugin has every // feature working with no key; a license only exists once // someone buys a paid tier at chargeforms.com/pricing, and its // only job is raising how many sites one key can be active on // at once. Never read/written through the generic // chargeforms_settings_fields save flow below — License_Page's // activate/deactivate handlers call Settings::set_license() // directly after a real API round trip, since "what to store" // depends on what the license server actually said, not on // blindly trusting form input the way a plain settings field // would. 'license_key' => '', 'license_status' => '', // '' | 'active' | 'error' 'license_plan' => '', 'license_activation_limit' => null, // null = unlimited (Unlimited plan) or simply unset 'license_last_error' => '', 'license_last_verified' => 0, // unix timestamp, 0 = never 'license_last_validated' => 0, 'license_site_hash' => '', 'license_expires_at' => null, 'license_pending_deactivation' => [], ]; } public static function all() { $saved = get_option( self::OPTION_KEY, [] ); return wp_parse_args( is_array( $saved ) ? $saved : [], self::defaults() ); } public static function get( $key, $default = null ) { $all = self::all(); return $all[ $key ] ?? $default; } /** * Updates only the fields listed in $data['chargeforms_settings_fields'] * (a comma-separated hidden input each settings form includes). Without * this, a form that only shows one field — like the Integrations page's * webhook URL — would silently reset every checkbox it doesn't render * (absent checkbox = unchecked in $_POST, indistinguishable from "not * part of this form" otherwise). */ public static function update( array $data ) { $current = self::all(); $touched = isset( $data['chargeforms_settings_fields'] ) ? array_filter( array_map( 'sanitize_key', explode( ',', $data['chargeforms_settings_fields'] ) ) ) : array_keys( self::defaults() ); $clean = $current; $string_fields = [ 'webhook_default_url' => 'esc_url_raw', 'entries_date_format' => 'sanitize_text_field', 'email_summaries_custom_email' => 'sanitize_email', 'email_summaries_subject' => 'sanitize_text_field', 'mailchimp_api_key' => 'sanitize_text_field', 'brevo_api_key' => 'sanitize_text_field', 'activecampaign_api_key' => 'sanitize_text_field', 'activecampaign_api_url' => 'esc_url_raw', 'slack_default_webhook_url' => 'esc_url_raw', 'discord_default_webhook_url' => 'esc_url_raw', 'telegram_default_bot_token' => 'sanitize_text_field', 'telegram_default_chat_id' => 'sanitize_text_field', 'notion_default_token' => 'sanitize_text_field', 'notion_default_database_id' => 'sanitize_text_field', 'hubspot_access_token' => 'sanitize_text_field', 'address_autocomplete_provider' => 'sanitize_key', 'google_maps_api_key' => 'sanitize_text_field', 'recaptcha_site_key' => 'sanitize_text_field', 'recaptcha_secret_key' => 'sanitize_text_field', 'hcaptcha_site_key' => 'sanitize_text_field', 'hcaptcha_secret_key' => 'sanitize_text_field', 'turnstile_site_key' => 'sanitize_text_field', 'turnstile_secret_key' => 'sanitize_text_field', 'stripe_publishable_key' => 'sanitize_text_field', 'stripe_secret_key' => 'sanitize_text_field', 'stripe_webhook_secret' => 'sanitize_text_field', 'paypal_client_id' => 'sanitize_text_field', 'paypal_client_secret' => 'sanitize_text_field', 'payment_business_name' => 'sanitize_text_field', 'payment_business_address' => 'sanitize_text_field', 'validation_msg_required' => 'sanitize_text_field', 'validation_msg_email' => 'sanitize_text_field', 'validation_msg_numeric' => 'sanitize_text_field', 'validation_msg_min' => 'sanitize_text_field', 'validation_msg_max' => 'sanitize_text_field', 'validation_msg_digits' => 'sanitize_text_field', 'validation_msg_url' => 'sanitize_text_field', 'validation_msg_image_type' => 'sanitize_text_field', 'validation_msg_file_type' => 'sanitize_text_field', 'validation_msg_file_size' => 'sanitize_text_field', 'validation_msg_file_count' => 'sanitize_text_field', 'email_footer_text' => 'sanitize_textarea_field', 'google_sheets_service_account_json' => 'sanitize_textarea_field', 'cleantalk_access_key' => 'sanitize_text_field', ]; $key_fields = [ 'label_placement', 'help_placement', 'error_placement', 'asterisk_position', 'email_summaries_send_to', 'email_summaries_day', 'stripe_currency', 'paypal_mode', 'paypal_currency', 'date_time_format', 'cleantalk_spam_validation' ]; $bool_fields = [ 'honeypot_enabled', 'block_disposable_emails', 'delete_data_on_uninstall', 'email_summaries_enabled', 'integration_failure_email_enabled', 'double_optin_enabled', 'payment_module_enabled', 'disable_ip_logging', 'disable_form_analytics', 'auto_tab_index_enabled', 'admin_top_nav_enabled', 'default_style_template_enabled', 'save_resume_enabled', 'mcp_enabled', 'google_fonts_enabled' ]; // [field => [min, max]] — clamped rather than merely cast, since // these directly drive DAY_IN_SECONDS/MINUTE_IN_SECONDS math // (Draft::default_ttl_seconds(), the front end's autosave timer) — // a stray 0 or negative value from a tampered/malformed request // must never produce an immediately-expiring draft or a runaway // sub-second autosave loop. $int_fields = [ 'save_resume_expiry_days' => [ 1, 365 ], 'save_resume_autosave_seconds' => [ 5, 300 ], ]; if ( in_array( 'notification_email', $touched, true ) ) { $clean['notification_email'] = isset( $data['notification_email'] ) ? sanitize_email( $data['notification_email'] ) : $current['notification_email']; } foreach ( $string_fields as $field => $sanitizer ) { if ( in_array( $field, $touched, true ) ) { $clean[ $field ] = isset( $data[ $field ] ) ? call_user_func( $sanitizer, $data[ $field ] ) : $current[ $field ]; } } foreach ( $key_fields as $field ) { if ( in_array( $field, $touched, true ) && isset( $data[ $field ] ) ) { $clean[ $field ] = sanitize_key( $data[ $field ] ); } } foreach ( $bool_fields as $field ) { if ( in_array( $field, $touched, true ) ) { $clean[ $field ] = ! empty( $data[ $field ] ); } } foreach ( $int_fields as $field => list( $min, $max ) ) { if ( in_array( $field, $touched, true ) && isset( $data[ $field ] ) ) { $clean[ $field ] = max( $min, min( $max, (int) $data[ $field ] ) ); } } if ( in_array( 'permitted_roles', $touched, true ) ) { $allowed_roles = [ 'editor', 'author', 'contributor' ]; $submitted = isset( $data['permitted_roles'] ) ? array_map( 'sanitize_key', (array) $data['permitted_roles'] ) : []; $new_roles = array_values( array_intersect( $submitted, $allowed_roles ) ); // Grant/revoke the actual WP role capability to match, not just // the stored list — the checkboxes are meaningless otherwise. foreach ( $allowed_roles as $role_slug ) { $role = get_role( $role_slug ); if ( ! $role ) { continue; } if ( in_array( $role_slug, $new_roles, true ) ) { $role->add_cap( 'chargeforms_manage_forms' ); } else { $role->remove_cap( 'chargeforms_manage_forms' ); } } $clean['permitted_roles'] = $new_roles; } update_option( self::OPTION_KEY, $clean ); /* translators: %d: number of settings fields changed in this save */ Activity_Log::log( 'settings_updated', 'settings', null, sprintf( __( 'Updated %d setting(s)', 'chargeforms' ), count( $touched ) ) ); return $clean; } /** * Merges the given license_* fields straight into the stored option, * bypassing update()'s touched-fields/sanitizer maps — License_Page's * handlers already decide these values from a real License_Client API * response (or from clearing on deactivate), not from raw, untrusted * form input the way update() has to assume for the generic settings * form. Callers pass only the license_* keys they mean to change; every * other stored setting is left untouched. */ public static function set_license( array $fields ) { $current = self::all(); update_option( self::OPTION_KEY, array_merge( $current, $fields ) ); } /** * Whether an optional paid account license is verified for this site. * Bundled WordPress.org functionality does not depend on this value. */ public static function has_pro() { if ( 'active' !== self::get( 'license_status' ) || ! License_Client::valid_key_format( self::get( 'license_key' ) ) || ! in_array( self::get( 'license_plan' ), [ 'single', 'agency', 'unlimited' ], true ) ) { return false; } if ( License_Client::site_hash( home_url() ) !== self::get( 'license_site_hash' ) || ! self::get( 'license_site_hash' ) ) { return false; } $validated = (int) self::get( 'license_last_validated' ); if ( $validated <= 0 || $validated > time() + 300 || time() - $validated > 72 * HOUR_IN_SECONDS ) { return false; } $expires = self::get( 'license_expires_at' ); return null === $expires || ( is_string( $expires ) && false !== strtotime( $expires ) && strtotime( $expires ) > time() ); } }