true, 'excluded' => array(), 'shop_data' => false, ); } /** * Current settings with defaults. * * @return array{enabled: bool, excluded: int[], shop_data: bool} */ public static function settings(): array { $stored = get_option( self::SETTINGS, array() ); $stored = is_array( $stored ) ? $stored : array(); $merged = array_merge( self::default_settings(), $stored ); return array( 'enabled' => (bool) $merged['enabled'], 'excluded' => self::ids( $merged['excluded'] ), 'shop_data' => (bool) $merged['shop_data'], ); } /** * Save settings. * * @param array $settings Partial settings. */ public static function save_settings( array $settings ): void { $next = array_merge( self::settings(), $settings ); update_option( self::SETTINGS, array( 'enabled' => (bool) $next['enabled'], 'excluded' => self::ids( $next['excluded'] ), 'shop_data' => (bool) $next['shop_data'], ), true ); } /** * Active connection or null. * * @return array{installation_id: string, widget_key: string, secret_key: string, console_url: string, connected_at: int}|null */ public static function connection(): ?array { $stored = get_option( self::CONNECTION, null ); if ( ! is_array( $stored ) || empty( $stored['installation_id'] ) || empty( $stored['widget_key'] ) ) { return null; } $secret = self::decrypt( (string) ( $stored['secret_key'] ?? '' ) ); if ( '' === $secret ) { return null; } return array( 'installation_id' => (string) $stored['installation_id'], 'widget_key' => (string) $stored['widget_key'], 'secret_key' => $secret, 'console_url' => (string) ( $stored['console_url'] ?? '' ), 'connected_at' => (int) ( $stored['connected_at'] ?? 0 ), ); } /** * Store a new connection. Not autoloaded: only needed on the pages that use it. * * @param string $installation_id Installation id from FAMMRO. * @param string $widget_key Public key of the chat window. * @param string $secret_key Raw Ed25519 secret key. * @param string $console_url Link into the FAMMRO console. */ public static function save_connection( string $installation_id, string $widget_key, string $secret_key, string $console_url ): void { update_option( self::CONNECTION, array( 'installation_id' => $installation_id, 'widget_key' => $widget_key, 'secret_key' => self::encrypt( $secret_key ), 'console_url' => $console_url, 'connected_at' => time(), ), false ); } /** * Remove the connection. */ public static function clear_connection(): void { delete_option( self::CONNECTION ); } /** * WooCommerce state: id of the read only REST key created for FAMMRO. * * @return array{key_id: int} */ public static function woocommerce(): array { $stored = get_option( self::WOO, array() ); return array( 'key_id' => is_array( $stored ) ? absint( $stored['key_id'] ?? 0 ) : 0 ); } /** * Save WooCommerce state. * * @param int $key_id Row id in the WooCommerce API key table, 0 when none. */ public static function save_woocommerce( int $key_id ): void { if ( $key_id > 0 ) { update_option( self::WOO, array( 'key_id' => $key_id ), false ); } else { delete_option( self::WOO ); } } /** * Positive, unique post ids. * * @param mixed $value Raw list. * @return int[] */ public static function ids( $value ): array { if ( ! is_array( $value ) ) { return array(); } $ids = array_filter( array_map( 'absint', $value ) ); return array_values( array_unique( $ids ) ); } /** * Encryption key derived from the WordPress salts. */ private static function box_key(): string { return sodium_crypto_generichash( 'fammro-connection|' . wp_salt( 'auth' ), '', SODIUM_CRYPTO_SECRETBOX_KEYBYTES ); } /** * Encrypt a secret for storage. * * @param string $plain Secret. */ private static function encrypt( string $plain ): string { $nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ); return base64_encode( $nonce . sodium_crypto_secretbox( $plain, $nonce, self::box_key() ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Binary storage format, not obfuscation. } /** * Decrypt a stored secret. Empty string when the salts changed or the value is damaged. * * @param string $stored Stored value. */ private static function decrypt( string $stored ): string { $raw = base64_decode( $stored, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Binary storage format, not obfuscation. if ( false === $raw || strlen( $raw ) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ) { return ''; } $nonce = substr( $raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ); $cipher = substr( $raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ); try { $plain = sodium_crypto_secretbox_open( $cipher, $nonce, self::box_key() ); } catch ( \SodiumException $e ) { return ''; } return false === $plain ? '' : $plain; } }