*/ private $cache = null; /** * Default settings — used on activation and as a fallback when reading. * * Keys are organized into logical groups for the settings UI; the option * itself is a flat associative array. * * @return array */ public static function defaults() { return array( // Master. 'enabled' => 1, // Restrictions for unverified users. The defaults ARE the "Shop" // preset of the setup wizard (see presets()), so skipping the wizard // and picking its recommended card give the same site: // ✓ users CAN register and CAN log in right away // ✓ they see a notice on My Account, cart and checkout // ✗ unverified users CANNOT place an order // The wizard's other cards (Strict, Community) add more. 'block_login' => 0, 'block_checkout' => 1, 'block_comments' => 0, 'block_registration_at_checkout' => 0, 'block_admin_area' => 0, 'block_profile_email_change' => 0, // Capability-based blocking: drops content-creation capabilities // while the user is unverified; reading still works. Off in "Shop", // on in "Community". 'restrict_capabilities' => 0, 'restricted_capabilities' => array( 'publish_posts', 'edit_posts', 'upload_files' ), // Behaviour. 'expiry_days' => 7, 'exempt_roles' => array( 'administrator', 'editor', 'shop_manager' ), 'allow_logged_out_resend' => 1, 'force_logout_on_unverify' => 0, 'auto_resend_on_expired' => 1, 'max_send_attempts' => 5, 'attempts_cooldown_hours' => 24, // Seconds a customer must wait between two verification mails to // the same account. Enforced by the server on every send path; // the countdown shown next to resend links only mirrors it. 'resend_cooldown_seconds' => self::RESEND_WAIT_DEFAULT, // Cleanup of stale unverified accounts. Default OFF — opt-in only; // daily cron once enabled. Skipped on users with WC orders. 'auto_delete_unverified' => 0, 'auto_delete_after_days' => 30, // Email. 'email_format' => 'plain', 'email_subject' => '[%site%] Confirm your email address', 'email_body' => "Hi %name%,\n\nPlease confirm your email by opening the link below:\n\n%url%\n\nThis link is valid for %expiry% days. If you did not create this account, ignore this email.\n\n— %site%", 'from_name' => '', 'from_email' => '', // Uninstall. 'delete_data_on_uninstall' => 0, ); } /** * The preset definitions: slug => gate values. The setup wizard cards, the * bullet points on them and the "Current" badge are all generated from this * one table (see WPCEV_Presets), so what a card says can never drift from * what applying it does. * * Only these gate keys are part of a preset. Everything else (signup at * checkout, link expiry, roles, email text) is left alone when one is applied. * * @return array> */ public static function presets() { return array( self::PRESET_SHOP => array( 'block_login' => 0, 'block_checkout' => 1, 'block_comments' => 0, 'block_admin_area' => 0, 'block_profile_email_change' => 0, 'restrict_capabilities' => 0, ), self::PRESET_STRICT => array( 'block_login' => 1, 'block_checkout' => 1, 'block_comments' => 0, 'block_admin_area' => 0, 'block_profile_email_change' => 0, 'restrict_capabilities' => 0, ), self::PRESET_COMMUNITY => array( 'block_login' => 0, 'block_checkout' => 1, 'block_comments' => 1, 'block_admin_area' => 1, 'block_profile_email_change' => 1, 'restrict_capabilities' => 1, ), ); } /** * Settings overlay produced by a preset, applied on top of the existing * values rather than replacing them. * * @param string $preset self::PRESET_* * @return array partial settings to merge; empty for an unknown slug ("custom"). */ public static function preset( $preset ) { if ( self::PRESET_SOFT === $preset ) { $preset = self::PRESET_COMMUNITY; } $all = self::presets(); return isset( $all[ $preset ] ) ? $all[ $preset ] : array(); } /** * Seconds between two verification mails to the same account, clamped so a * hand-edited option can never switch the protection off. * * @return int */ public function resend_wait() { $wait = (int) $this->get( 'resend_cooldown_seconds', self::RESEND_WAIT_DEFAULT ); return max( self::RESEND_WAIT_MIN, min( self::RESEND_WAIT_MAX, $wait ) ); } public function all() { if ( null === $this->cache ) { $opt = get_option( self::OPTION_KEY, array() ); $this->cache = wp_parse_args( is_array( $opt ) ? $opt : array(), self::defaults() ); } return $this->cache; } public function get( $key, $fallback = null ) { $all = $this->all(); return array_key_exists( $key, $all ) ? $all[ $key ] : $fallback; } /** * Persist a sanitized array. Pass a partial array to merge over current * values; missing keys keep their existing values rather than reverting * to defaults (this matters for the wizard, which only saves a slice * per step). * * @param array $input Raw values. * @param bool $merge When true, merge over current settings instead of replacing. */ public function save( $input, $merge = false ) { $base = $merge ? $this->all() : self::defaults(); $clean = $this->sanitize( $input, $base ); update_option( self::OPTION_KEY, $clean, false ); $this->cache = $clean; return $clean; } /** * Sanitize raw input against the schema. Unknown keys are dropped. * * Boolean fields are evaluated only when their key is present in * `$input` *and* the special `__present` marker for that key was set * by the caller — checkboxes that aren't ticked don't appear in * $_POST at all, so a missing key means "leave existing value alone" * during merge saves and "use default" otherwise. * * @param array $input * @param array $base Existing values to merge over. * @return array */ public function sanitize( $input, $base = null ) { $d = self::defaults(); $out = is_array( $base ) ? wp_parse_args( $base, $d ) : $d; /* * Which boolean keys this submission is allowed to touch. * * An unticked checkbox is simply absent from $_POST, so a form must * declare the keys it owns via `__present` markers. When they are * missing the fallback matters enormously: * * - Merge save ($base is an array — a wizard step or any partial * form): assume the form owns NOTHING. Falling back to every key * here would let a form that renders four text inputs silently * rewrite all 13 booleans to 0, including the master `enabled` * flag, and empty exempt_roles. That is exactly what wizard step 4 * used to do — it turned the plugin off while reporting success. * - Full save ($base is null — the whole settings form): the form * really does own every key, so absence means "unticked". */ if ( isset( $input['__present'] ) && is_array( $input['__present'] ) ) { $present = $input['__present']; } else { $present = is_array( $base ) ? array() : array_keys( $d ); } $bools = array( 'enabled', 'block_login', 'block_checkout', 'block_comments', 'block_registration_at_checkout', 'block_admin_area', 'block_profile_email_change', 'restrict_capabilities', 'allow_logged_out_resend', 'force_logout_on_unverify', 'auto_resend_on_expired', 'auto_delete_unverified', 'delete_data_on_uninstall', ); foreach ( $bools as $key ) { if ( in_array( $key, $present, true ) ) { $out[ $key ] = ! empty( $input[ $key ] ) ? 1 : 0; } } // Integers. if ( isset( $input['expiry_days'] ) ) { $out['expiry_days'] = max( 1, min( 90, (int) $input['expiry_days'] ) ); } if ( isset( $input['max_send_attempts'] ) ) { $out['max_send_attempts'] = max( 0, min( 50, (int) $input['max_send_attempts'] ) ); } if ( isset( $input['attempts_cooldown_hours'] ) ) { $out['attempts_cooldown_hours'] = max( 1, min( 168, (int) $input['attempts_cooldown_hours'] ) ); } if ( isset( $input['resend_cooldown_seconds'] ) ) { $out['resend_cooldown_seconds'] = max( self::RESEND_WAIT_MIN, min( self::RESEND_WAIT_MAX, (int) $input['resend_cooldown_seconds'] ) ); } if ( isset( $input['auto_delete_after_days'] ) ) { // 7-day floor so a fat-finger doesn't nuke yesterday's signups; 365-day ceiling caps the option set sensibly. $out['auto_delete_after_days'] = max( 7, min( 365, (int) $input['auto_delete_after_days'] ) ); } // Email format enum. if ( isset( $input['email_format'] ) ) { $candidate = sanitize_key( wp_unslash( $input['email_format'] ) ); if ( in_array( $candidate, array( 'plain', 'wc', 'auto' ), true ) ) { $out['email_format'] = $candidate; } } // Exempt-roles array. if ( in_array( 'exempt_roles', $present, true ) || isset( $input['exempt_roles'] ) ) { $valid_roles = array_keys( wp_roles()->roles ); $out['exempt_roles'] = array(); if ( ! empty( $input['exempt_roles'] ) && is_array( $input['exempt_roles'] ) ) { foreach ( $input['exempt_roles'] as $role ) { $role = sanitize_key( $role ); if ( in_array( $role, $valid_roles, true ) ) { $out['exempt_roles'][] = $role; } } } } // Restricted-capabilities array. Accept a textarea string (one cap per // line) or an actual array — both are sanitized to a deduped list of // capability slugs. if ( in_array( 'restricted_capabilities', $present, true ) || isset( $input['restricted_capabilities'] ) ) { $raw = $input['restricted_capabilities'] ?? array(); if ( is_string( $raw ) ) { $raw = preg_split( '/[\r\n,]+/', $raw ); } $caps = array(); foreach ( (array) $raw as $cap ) { $cap = sanitize_key( trim( (string) $cap ) ); if ( '' !== $cap ) { $caps[ $cap ] = true; } } $out['restricted_capabilities'] = array_keys( $caps ); } // Plain text fields. if ( isset( $input['email_subject'] ) ) { $out['email_subject'] = sanitize_text_field( wp_unslash( $input['email_subject'] ) ); } if ( isset( $input['email_body'] ) ) { $out['email_body'] = sanitize_textarea_field( wp_unslash( $input['email_body'] ) ); } if ( isset( $input['from_name'] ) ) { $out['from_name'] = sanitize_text_field( wp_unslash( $input['from_name'] ) ); } if ( isset( $input['from_email'] ) ) { $candidate = sanitize_email( wp_unslash( $input['from_email'] ) ); $out['from_email'] = $candidate && is_email( $candidate ) ? $candidate : ''; } // Apply preset overlay last so it wins over individual fields when both // were submitted in the same form. if ( ! empty( $input['preset'] ) ) { $preset = sanitize_key( wp_unslash( $input['preset'] ) ); $out = array_merge( $out, self::preset( $preset ) ); } return $out; } }