*/ private array $schema; /** * In-request cache of decoded values (secrets decrypted). * * @var array|null */ private ?array $cache = null; /** * Encryption service. * * @var Encryption */ private Encryption $encryption; /** * Database handle. * * @var wpdb */ private wpdb $db; /** * Wire encryption and an optional database handle. * * @param Encryption $encryption Encryption service. * @param wpdb|null $db Database handle. */ public function __construct( Encryption $encryption, ?wpdb $db = null ) { $this->encryption = $encryption; if ( null === $db ) { global $wpdb; $db = $wpdb; } $this->db = $db; $this->schema = self::merge_schema( self::schema() ); } /** * Let the commercial zip add keys (PayPal) without naming them in directory PHP. * * @param array $schema Core schema. * @return array */ private static function merge_schema( array $schema ): array { if ( ! function_exists( 'apply_filters' ) ) { return $schema; } $filtered = apply_filters( 'bookline/settings/schema', $schema ); if ( ! is_array( $filtered ) ) { return $schema; } foreach ( $filtered as $key => $definition ) { if ( ! is_string( $key ) || ! is_array( $definition ) ) { continue; } if ( ! isset( $definition['type'], $definition['secret'], $definition['autoload'] ) ) { continue; } if ( ! array_key_exists( 'default', $definition ) ) { continue; } $schema[ $key ] = [ 'type' => (string) $definition['type'], 'default' => $definition['default'], 'secret' => (bool) $definition['secret'], 'autoload' => (bool) $definition['autoload'], ]; } return $schema; } /** * Typed schema registry for every known setting key. * * @return array * * @since 0.1.0 */ public static function schema(): array { return [ 'general.business_name' => [ 'type' => 'string', 'default' => '', 'secret' => false, 'autoload' => true, ], 'general.currency' => [ 'type' => 'string', 'default' => 'GBP', 'secret' => false, 'autoload' => true, ], 'general.timezone' => [ 'type' => 'string', 'default' => 'Europe/London', 'secret' => false, 'autoload' => true, ], 'general.lead_time_min' => [ 'type' => 'int', 'default' => 60, 'secret' => false, 'autoload' => true, ], 'general.horizon_days' => [ 'type' => 'int', 'default' => 90, 'secret' => false, 'autoload' => true, ], 'general.slot_step_min' => [ 'type' => 'int', 'default' => 15, 'secret' => false, 'autoload' => true, ], 'notifications.from_name' => [ 'type' => 'string', 'default' => '', 'secret' => false, 'autoload' => true, ], 'notifications.from_email' => [ 'type' => 'string', 'default' => '', 'secret' => false, 'autoload' => true, ], 'payments.stripe_secret_key' => [ 'type' => 'string', 'default' => '', 'secret' => true, 'autoload' => false, ], 'payments.stripe_publishable_key' => [ 'type' => 'string', 'default' => '', 'secret' => false, 'autoload' => true, ], 'payments.stripe_webhook_secret' => [ 'type' => 'string', 'default' => '', 'secret' => true, 'autoload' => false, ], 'payments.test_mode' => [ 'type' => 'bool', 'default' => true, 'secret' => false, 'autoload' => true, ], 'retention.intent_days' => [ 'type' => 'int', 'default' => 30, 'secret' => false, 'autoload' => true, ], 'booking.hold_minutes' => [ 'type' => 'int', 'default' => 10, 'secret' => false, 'autoload' => true, ], 'booking.cancel_window_hours' => [ 'type' => 'int', 'default' => 24, 'secret' => false, 'autoload' => true, ], 'booking.reschedule_window_hours' => [ 'type' => 'int', 'default' => 24, 'secret' => false, 'autoload' => true, ], 'payments.any_gateway_configured' => [ 'type' => 'bool', 'default' => false, 'secret' => false, 'autoload' => true, ], 'payments.pay_on_site_only' => [ 'type' => 'bool', 'default' => true, 'secret' => false, 'autoload' => true, ], 'payments.global_prepayment' => [ 'type' => 'string', 'default' => 'optional', 'secret' => false, 'autoload' => true, ], 'booking.cart_enabled' => [ 'type' => 'bool', 'default' => true, 'secret' => false, 'autoload' => true, ], 'booking.session_idle_minutes' => [ 'type' => 'int', 'default' => 60, 'secret' => false, 'autoload' => true, ], 'appearance.accent' => [ 'type' => 'string', 'default' => 'var(--bl-accent)', 'secret' => false, 'autoload' => true, ], 'appearance.density' => [ 'type' => 'string', 'default' => 'compact', 'secret' => false, 'autoload' => true, ], 'general.week_start' => [ 'type' => 'int', 'default' => 1, 'secret' => false, 'autoload' => true, ], 'general.time_format' => [ 'type' => 'string', 'default' => '12h', 'secret' => false, 'autoload' => true, ], ]; } /** * Get a setting value, falling back to the schema default. * * @param string $key Setting key. * @return mixed * * @throws InvalidArgumentException When the key is unknown. * * @since 0.1.0 */ public function get( string $key ): mixed { $this->assert_known( $key ); $all = $this->all(); return $all[ $key ]; } /** * Persist a setting value. * * @param string $key Setting key. * @param mixed $value Raw value (will be sanitised). * * @throws RuntimeException When the database write fails. * * @since 0.1.0 */ public function set( string $key, mixed $value ): void { $this->assert_known( $key ); $definition = $this->schema[ $key ]; $sanitised = $this->sanitise( $key, $value ); $stored = $this->encode( $sanitised ); $encrypted = false; if ( $definition['secret'] && '' !== $stored ) { $stored = $this->encryption->encrypt( $stored ); $encrypted = true; } $table = $this->db->prefix . 'bookline_settings'; $now = gmdate( 'Y-m-d H:i:s' ); $autoload = $definition['autoload'] ? 1 : 0; $is_enc = $encrypted ? 1 : 0; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $result = $this->db->replace( $table, [ 'setting_key' => $key, 'setting_value' => $stored, 'is_encrypted' => $is_enc, 'autoload' => $autoload, 'updated_at' => $now, ], [ '%s', '%s', '%d', '%d', '%s' ] ); if ( false === $result ) { throw new RuntimeException( sprintf( 'Failed to persist setting "%s".', esc_html( $key ) ) ); } if ( null !== $this->cache ) { $this->cache[ $key ] = $sanitised; } } /** * All known settings with defaults filled in. * * @return array * * @since 0.1.0 */ public function all(): array { if ( null !== $this->cache ) { return $this->cache; } $values = []; foreach ( $this->schema as $key => $definition ) { $values[ $key ] = $definition['default']; } $table = $this->db->prefix . 'bookline_settings'; $sql = $this->db->prepare( 'SELECT `setting_key`, `setting_value`, `is_encrypted` FROM %i', $table ); if ( ! is_string( $sql ) ) { return $values; } // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $rows = $this->db->get_results( $sql, ARRAY_A ); if ( is_array( $rows ) ) { foreach ( $rows as $row ) { if ( ! is_array( $row ) ) { continue; } $key = isset( $row['setting_key'] ) && is_string( $row['setting_key'] ) ? $row['setting_key'] : ''; if ( '' === $key || ! isset( $this->schema[ $key ] ) ) { continue; } $raw = isset( $row['setting_value'] ) && is_string( $row['setting_value'] ) ? $row['setting_value'] : ''; if ( ! empty( $row['is_encrypted'] ) || $this->encryption->is_encrypted( $raw ) ) { $raw = $this->encryption->decrypt( $raw ); } $values[ $key ] = $this->decode( $key, $raw ); if ( 'appearance.accent' === $key ) { $values[ $key ] = $this->sanitise_accent( $values[ $key ] ); } } } $this->cache = $values; return $values; } /** * Settings safe for REST responses — secrets replaced with a boolean presence flag. * * @return array * * @since 0.1.0 */ public function all_public(): array { $all = $this->all(); $out = []; foreach ( $all as $key => $value ) { if ( ! empty( $this->schema[ $key ]['secret'] ) ) { $out[ $key ] = [ 'is_set' => is_string( $value ) ? '' !== $value : null !== $value && false !== $value, ]; continue; } $out[ $key ] = $value; } return $out; } /** * Insert schema defaults when a key is missing. * * @since 0.1.0 */ public function seed_defaults(): void { foreach ( $this->schema as $key => $definition ) { if ( $this->exists( $key ) ) { continue; } $this->set( $key, $definition['default'] ); } // Ensure the uninstall opt-in flag exists as a WordPress option (checked from uninstall.php). if ( false === get_option( 'bookline_delete_data_on_uninstall', false ) ) { add_option( 'bookline_delete_data_on_uninstall', false, '', false ); } } /** * Flush the in-request cache (tests / after external writes). * * @since 0.1.0 */ public function flush_cache(): void { $this->cache = null; } /** * Whether a row exists for the key. * * @param string $key Setting key. */ private function exists( string $key ): bool { $table = $this->db->prefix . 'bookline_settings'; $sql = $this->db->prepare( 'SELECT COUNT(*) FROM %i WHERE `setting_key` = %s', $table, $key ); if ( ! is_string( $sql ) ) { return false; } // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return (int) $this->db->get_var( $sql ) > 0; } /** * Whether the key is in the schema registry. * * @param string $key Setting key. * @return bool * * @since 0.1.0 */ public function is_known( string $key ): bool { return isset( $this->schema[ $key ] ); } /** * Reject unknown setting keys. * * @param string $key Setting key. * * @throws InvalidArgumentException When unknown. */ private function assert_known( string $key ): void { if ( ! $this->is_known( $key ) ) { throw new InvalidArgumentException( sprintf( 'Unknown Bookline setting "%s".', esc_html( $key ) ) ); } } /** * Coerce a raw value to the schema type. * * @param string $key Setting key. * @param mixed $value Raw value. * @return mixed * * @throws InvalidArgumentException When the type does not match. */ private function sanitise( string $key, mixed $value ): mixed { if ( 'appearance.accent' === $key ) { return $this->sanitise_accent( $value ); } if ( 'appearance.density' === $key ) { return 'comfortable' === $value ? 'comfortable' : 'compact'; } if ( 'general.week_start' === $key ) { $day = is_numeric( $value ) ? (int) $value : 1; return ( $day >= 0 && $day <= 6 ) ? $day : 1; } if ( 'general.time_format' === $key ) { return '24h' === $value ? '24h' : '12h'; } $type = $this->schema[ $key ]['type']; return match ( $type ) { 'string' => is_string( $value ) ? $value : ( is_scalar( $value ) ? (string) $value : '' ), 'int' => is_numeric( $value ) ? (int) $value : 0, 'bool' => (bool) $value, default => throw new InvalidArgumentException( sprintf( 'Unsupported setting type for "%s".', esc_html( $key ) ) ), }; } /** * Allow a six-digit hex colour or a CSS custom-property reference. * * @param mixed $value Raw value. * @return string */ private function sanitise_accent( mixed $value ): string { if ( ! is_string( $value ) ) { return 'var(--bl-accent)'; } $trimmed = trim( $value ); if ( in_array( strtolower( $trimmed ), [ '#1b5299', '#123a6b', '#4a8fd4' ], true ) ) { return 'var(--bl-accent)'; } if ( 1 === preg_match( '/^#[0-9A-Fa-f]{6}$/', $trimmed ) ) { return strtolower( $trimmed ); } if ( 1 === preg_match( '/^var\(--[a-zA-Z0-9-]+\)$/', $trimmed ) ) { return $trimmed; } return 'var(--bl-accent)'; } /** * Encode a sanitised value for storage. * * @param mixed $value Sanitised value. */ private function encode( mixed $value ): string { if ( is_bool( $value ) ) { return $value ? '1' : '0'; } if ( is_int( $value ) ) { return (string) $value; } return is_string( $value ) ? $value : ''; } /** * Decode a stored plaintext string to the schema type. * * @param string $key Setting key. * @param string $raw Stored plaintext. * @return mixed */ private function decode( string $key, string $raw ): mixed { $type = $this->schema[ $key ]['type']; return match ( $type ) { 'string' => $raw, 'int' => (int) $raw, 'bool' => '1' === $raw || 'true' === strtolower( $raw ), default => $raw, }; } }