Plugin Directory

Changeset 3395228


Ignore:
Timestamp:
11/13/2025 05:13:57 PM (4 months ago)
Author:
pulsechat
Message:

Release version 2.2.3 - Fixed license validation with Supabase Authorization header

Location:
pulse-chat-ai
Files:
6 edited

Legend:

Unmodified
Added
Removed
  • pulse-chat-ai/tags/2.2.3/includes/class-license-manager.php

    r3395179 r3395228  
    1515    private $api_url = 'https://uhnaedqfygrqdptjngqb.supabase.co/functions/v1/license-check';
    1616   
     17    // Supabase Anon Key
     18    // This is a public key safe to include in the plugin code
     19    // It can be overridden via constant or filter if needed
     20    private $supabase_anon_key = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InVobmFlZHFmeWdycWRwdGpuZ3FiIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjI3MDE2NjQsImV4cCI6MjA3ODI3NzY2NH0.USJDoMqD-B9rYYb4EHUnpwI99QHqJpty3IJLO8Kh7uM';
     21   
     22    public function __construct() {
     23        // Allow anon key to be overridden via constant or filter (for custom deployments)
     24        if (defined('PULSE_CHAT_AI_SUPABASE_ANON_KEY')) {
     25            $this->supabase_anon_key = PULSE_CHAT_AI_SUPABASE_ANON_KEY;
     26        } else {
     27            $this->supabase_anon_key = apply_filters('pulse_chat_ai_supabase_anon_key', $this->supabase_anon_key);
     28        }
     29       
     30        // Hook for periodic license validation (every 24 hours)
     31        add_action('admin_init', array($this, 'validate_license_periodically'));
     32       
     33        // Hook to verify license before showing Pro features
     34        add_action('plugins_loaded', array($this, 'init_license_check'));
     35    }
     36   
    1737    // Product slug (must match EXACTLY the one used in admin/license creation)
    1838    // IMPORTANT: This must be identical character-by-character in both systems
     
    2343    private $option_name = 'pulse_chat_ai_license_key';
    2444    private $license_data_option = 'pulse_chat_ai_license_data';
    25    
    26     public function __construct() {
    27         // Hook for periodic license validation (every 24 hours)
    28         add_action('admin_init', array($this, 'validate_license_periodically'));
    29        
    30         // Hook to verify license before showing Pro features
    31         add_action('plugins_loaded', array($this, 'init_license_check'));
    32     }
    3345   
    3446    /**
     
    101113            error_log('Domain (normalized): ' . $domain);
    102114            error_log('API URL: ' . $url);
    103         }
    104        
    105         // Make request
    106         $response = wp_remote_get($url, array(
     115            error_log('Request timestamp: ' . current_time('mysql'));
     116        }
     117       
     118        // Make request to Supabase Edge Function
     119        // Note: Supabase Edge Functions may require Authorization header even if JWT verification is disabled
     120        $headers = array(
     121            'Content-Type' => 'application/json',
     122            'Accept' => 'application/json',
     123        );
     124       
     125        // Add Authorization header if anon key is configured
     126        // This helps avoid "Missing authorization header" errors from Supabase gateway
     127        // Even if JWT verification is disabled, Supabase gateway may still require this header
     128        if (!empty($this->supabase_anon_key)) {
     129            $headers['Authorization'] = 'Bearer ' . $this->supabase_anon_key;
     130            if (defined('WP_DEBUG') && WP_DEBUG) {
     131                error_log('Adding Authorization header with anon key (key length: ' . strlen($this->supabase_anon_key) . ')');
     132            }
     133        } else {
     134            if (defined('WP_DEBUG') && WP_DEBUG) {
     135                error_log('WARNING: No Supabase anon key configured. Supabase may reject the request.');
     136                error_log('To fix: Add define(\'PULSE_CHAT_AI_SUPABASE_ANON_KEY\', \'your-key\') in wp-config.php');
     137            }
     138        }
     139       
     140        $request_args = array(
    107141            'timeout' => 10,
    108             'sslverify' => true
    109         ));
     142            'sslverify' => true,
     143            'headers' => $headers,
     144            'user-agent' => 'WordPress/' . get_bloginfo('version') . '; ' . home_url(),
     145            'blocking' => true,
     146        );
     147       
     148        if (defined('WP_DEBUG') && WP_DEBUG) {
     149            error_log('Request args: ' . print_r($request_args, true));
     150        }
     151       
     152        $response = wp_remote_get($url, $request_args);
     153       
     154        if (defined('WP_DEBUG') && WP_DEBUG) {
     155            error_log('Request completed. Is WP_Error: ' . (is_wp_error($response) ? 'YES' : 'NO'));
     156        }
    110157       
    111158        // Check for connection errors
     
    125172        $status_code = wp_remote_retrieve_response_code($response);
    126173        $body = wp_remote_retrieve_body($response);
    127         $data = json_decode($body, true);
     174        $response_headers = wp_remote_retrieve_headers($response);
    128175       
    129176        // Debug logging
    130177        if (defined('WP_DEBUG') && WP_DEBUG) {
    131178            error_log('Response Status Code: ' . $status_code);
    132             error_log('Response Body: ' . $body);
    133             error_log('Response Data: ' . print_r($data, true));
     179            error_log('Response Headers: ' . print_r($response_headers, true));
     180            error_log('Response Body (raw): ' . $body);
     181            error_log('Response Body length: ' . strlen($body));
     182        }
     183       
     184        $data = json_decode($body, true);
     185       
     186        // Debug logging
     187        if (defined('WP_DEBUG') && WP_DEBUG) {
     188            error_log('Response Data (parsed): ' . print_r($data, true));
     189            error_log('JSON decode error: ' . json_last_error_msg());
    134190        }
    135191       
     
    138194            if (defined('WP_DEBUG') && WP_DEBUG) {
    139195                error_log('Error al parsear respuesta de API: ' . $body);
     196                error_log('Status Code: ' . $status_code);
    140197            }
    141198            return array(
     
    143200                'reason' => 'invalid_response',
    144201                'plan' => null,
    145                 'error' => 'Invalid response from server'
     202                'error' => 'Invalid response from server. Response body: ' . substr($body, 0, 200)
    146203            );
    147204        }
     
    162219            }
    163220           
     221            // Extract error message from response
     222            $error_message = 'Server error';
     223            if (isset($data['error'])) {
     224                $error_message = $data['error'];
     225            } elseif (isset($data['message'])) {
     226                $error_message = $data['message'];
     227            } elseif (isset($data['code'])) {
     228                $error_message = $data['code'];
     229            }
     230           
    164231            $reason = 'server_error';
    165232            if (isset($data['reason'])) {
     
    167234            } else if ($status_code === 404) {
    168235                $reason = 'not_found';
    169             }
    170            
    171             if (defined('WP_DEBUG') && WP_DEBUG) {
    172                 error_log('Licencia inválida. Razón: ' . $reason);
     236            } else if ($status_code === 401) {
     237                $reason = 'unauthorized';
     238            } else if ($status_code === 403) {
     239                $reason = 'forbidden';
     240            }
     241           
     242            if (defined('WP_DEBUG') && WP_DEBUG) {
     243                error_log('Licencia inválida. Status: ' . $status_code . ', Razón: ' . $reason . ', Error: ' . $error_message);
     244                error_log('Response data: ' . print_r($data, true));
    173245            }
    174246           
     
    177249                'reason' => $reason,
    178250                'plan' => isset($data['plan']) ? $data['plan'] : null,
    179                 'error' => isset($data['message']) ? $data['message'] : 'Server error'
     251                'error' => $error_message
    180252            );
    181253        }
  • pulse-chat-ai/tags/2.2.3/pulse-chat-ai.php

    r3395179 r3395228  
    256256            'methods' => 'GET',
    257257            'callback' => array($this, 'get_conversations'),
    258             'permission_callback' => array($this, 'check_admin_permissions'),
     258            'permission_callback' => function($request) {
     259                return $this->check_admin_permissions($request);
     260            },
    259261        ));
    260262       
     
    262264            'methods' => 'DELETE',
    263265            'callback' => array($this, 'delete_conversation'),
    264             'permission_callback' => array($this, 'check_admin_permissions'),
     266            'permission_callback' => function($request) {
     267                return $this->check_admin_permissions($request);
     268            },
    265269            'args' => array(
    266270                'id' => array(
     
    275279            'methods' => 'POST',
    276280            'callback' => array($this, 'reset_usage_stats'),
    277             'permission_callback' => array($this, 'check_admin_permissions'),
     281            'permission_callback' => function($request) {
     282                return $this->check_admin_permissions($request);
     283            },
    278284        ));
    279285       
     
    282288            'methods' => 'POST',
    283289            'callback' => array($this, 'validate_license_endpoint'),
    284             'permission_callback' => array($this, 'check_admin_permissions'),
     290            'permission_callback' => function($request) {
     291                return $this->check_admin_permissions($request);
     292            },
    285293            'args' => array(
    286294                'license_key' => array(
     
    296304            'methods' => 'GET',
    297305            'callback' => array($this, 'get_license_status'),
    298             'permission_callback' => array($this, 'check_admin_permissions'),
     306            'permission_callback' => function($request) {
     307                return $this->check_admin_permissions($request);
     308            },
    299309        ));
    300310    }
     
    302312    /**
    303313     * Check admin permissions for REST API
    304      */
    305     public function check_admin_permissions() {
    306         return current_user_can('manage_options');
     314     *
     315     * @param WP_REST_Request $request The REST request object
     316     * @return bool|WP_Error True if user has permission, WP_Error otherwise
     317     */
     318    public function check_admin_permissions($request = null) {
     319        // Check if user is logged in
     320        if (!is_user_logged_in()) {
     321            return new WP_Error(
     322                'rest_forbidden',
     323                'You must be logged in to access this endpoint.',
     324                array('status' => 401)
     325            );
     326        }
     327       
     328        // Check if user has admin capabilities
     329        if (!current_user_can('manage_options')) {
     330            return new WP_Error(
     331                'rest_forbidden',
     332                'You do not have permission to access this endpoint.',
     333                array('status' => 403)
     334            );
     335        }
     336       
     337        return true;
    307338    }
    308339   
     
    671702     */
    672703    public function validate_license_endpoint($request) {
    673         $license_key = $request->get_param('license_key');
    674        
    675         if (empty($license_key)) {
    676             return new WP_Error('missing_license', 'License key is required', array('status' => 400));
    677         }
    678        
    679         // Validate license
    680         $result = $this->license_manager->validate_license($license_key, true);
    681        
    682         return rest_ensure_response($result);
     704        try {
     705            $license_key = $request->get_param('license_key');
     706           
     707            if (empty($license_key)) {
     708                return rest_ensure_response(array(
     709                    'valid' => false,
     710                    'reason' => 'missing_license',
     711                    'plan' => null,
     712                    'error' => 'License key is required'
     713                ));
     714            }
     715           
     716            // Validate license
     717            $result = $this->license_manager->validate_license($license_key, true);
     718           
     719            // Ensure result is always an array with the expected structure
     720            if (!is_array($result)) {
     721                return rest_ensure_response(array(
     722                    'valid' => false,
     723                    'reason' => 'server_error',
     724                    'plan' => null,
     725                    'error' => 'Invalid response from license validation'
     726                ));
     727            }
     728           
     729            return rest_ensure_response($result);
     730        } catch (Exception $e) {
     731            if (defined('WP_DEBUG') && WP_DEBUG) {
     732                error_log('Pulse Chat AI: Error in validate_license_endpoint: ' . $e->getMessage());
     733            }
     734            return rest_ensure_response(array(
     735                'valid' => false,
     736                'reason' => 'server_error',
     737                'plan' => null,
     738                'error' => $e->getMessage()
     739            ));
     740        }
    683741    }
    684742   
  • pulse-chat-ai/tags/2.2.3/readme.txt

    r3395179 r3395228  
    128128== Changelog ==
    129129
    130 = 2.2.3 - January 2026 =
     130= 2.2.3 - November 2025 =
     131* FIXED: License validation now includes Supabase Authorization header (anon key)
     132* FIXED: "Missing authorization header" error resolved - license validation works correctly
    131133* FIXED: Domain normalization now always removes ports (no localhost exception)
    132134* FIXED: License validation domain mismatch issues resolved
    133135* IMPROVED: Domain normalization aligned with Supabase license system
    134136* IMPROVED: Added important comments about product_slug matching requirements
     137* IMPROVED: Enhanced error handling and logging for license validation
     138* IMPROVED: Better error messages for license validation failures
    135139
    136140= 2.2.0 - January 2026 =
  • pulse-chat-ai/trunk/includes/class-license-manager.php

    r3395179 r3395228  
    1515    private $api_url = 'https://uhnaedqfygrqdptjngqb.supabase.co/functions/v1/license-check';
    1616   
     17    // Supabase Anon Key
     18    // This is a public key safe to include in the plugin code
     19    // It can be overridden via constant or filter if needed
     20    private $supabase_anon_key = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InVobmFlZHFmeWdycWRwdGpuZ3FiIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjI3MDE2NjQsImV4cCI6MjA3ODI3NzY2NH0.USJDoMqD-B9rYYb4EHUnpwI99QHqJpty3IJLO8Kh7uM';
     21   
     22    public function __construct() {
     23        // Allow anon key to be overridden via constant or filter (for custom deployments)
     24        if (defined('PULSE_CHAT_AI_SUPABASE_ANON_KEY')) {
     25            $this->supabase_anon_key = PULSE_CHAT_AI_SUPABASE_ANON_KEY;
     26        } else {
     27            $this->supabase_anon_key = apply_filters('pulse_chat_ai_supabase_anon_key', $this->supabase_anon_key);
     28        }
     29       
     30        // Hook for periodic license validation (every 24 hours)
     31        add_action('admin_init', array($this, 'validate_license_periodically'));
     32       
     33        // Hook to verify license before showing Pro features
     34        add_action('plugins_loaded', array($this, 'init_license_check'));
     35    }
     36   
    1737    // Product slug (must match EXACTLY the one used in admin/license creation)
    1838    // IMPORTANT: This must be identical character-by-character in both systems
     
    2343    private $option_name = 'pulse_chat_ai_license_key';
    2444    private $license_data_option = 'pulse_chat_ai_license_data';
    25    
    26     public function __construct() {
    27         // Hook for periodic license validation (every 24 hours)
    28         add_action('admin_init', array($this, 'validate_license_periodically'));
    29        
    30         // Hook to verify license before showing Pro features
    31         add_action('plugins_loaded', array($this, 'init_license_check'));
    32     }
    3345   
    3446    /**
     
    101113            error_log('Domain (normalized): ' . $domain);
    102114            error_log('API URL: ' . $url);
    103         }
    104        
    105         // Make request
    106         $response = wp_remote_get($url, array(
     115            error_log('Request timestamp: ' . current_time('mysql'));
     116        }
     117       
     118        // Make request to Supabase Edge Function
     119        // Note: Supabase Edge Functions may require Authorization header even if JWT verification is disabled
     120        $headers = array(
     121            'Content-Type' => 'application/json',
     122            'Accept' => 'application/json',
     123        );
     124       
     125        // Add Authorization header if anon key is configured
     126        // This helps avoid "Missing authorization header" errors from Supabase gateway
     127        // Even if JWT verification is disabled, Supabase gateway may still require this header
     128        if (!empty($this->supabase_anon_key)) {
     129            $headers['Authorization'] = 'Bearer ' . $this->supabase_anon_key;
     130            if (defined('WP_DEBUG') && WP_DEBUG) {
     131                error_log('Adding Authorization header with anon key (key length: ' . strlen($this->supabase_anon_key) . ')');
     132            }
     133        } else {
     134            if (defined('WP_DEBUG') && WP_DEBUG) {
     135                error_log('WARNING: No Supabase anon key configured. Supabase may reject the request.');
     136                error_log('To fix: Add define(\'PULSE_CHAT_AI_SUPABASE_ANON_KEY\', \'your-key\') in wp-config.php');
     137            }
     138        }
     139       
     140        $request_args = array(
    107141            'timeout' => 10,
    108             'sslverify' => true
    109         ));
     142            'sslverify' => true,
     143            'headers' => $headers,
     144            'user-agent' => 'WordPress/' . get_bloginfo('version') . '; ' . home_url(),
     145            'blocking' => true,
     146        );
     147       
     148        if (defined('WP_DEBUG') && WP_DEBUG) {
     149            error_log('Request args: ' . print_r($request_args, true));
     150        }
     151       
     152        $response = wp_remote_get($url, $request_args);
     153       
     154        if (defined('WP_DEBUG') && WP_DEBUG) {
     155            error_log('Request completed. Is WP_Error: ' . (is_wp_error($response) ? 'YES' : 'NO'));
     156        }
    110157       
    111158        // Check for connection errors
     
    125172        $status_code = wp_remote_retrieve_response_code($response);
    126173        $body = wp_remote_retrieve_body($response);
    127         $data = json_decode($body, true);
     174        $response_headers = wp_remote_retrieve_headers($response);
    128175       
    129176        // Debug logging
    130177        if (defined('WP_DEBUG') && WP_DEBUG) {
    131178            error_log('Response Status Code: ' . $status_code);
    132             error_log('Response Body: ' . $body);
    133             error_log('Response Data: ' . print_r($data, true));
     179            error_log('Response Headers: ' . print_r($response_headers, true));
     180            error_log('Response Body (raw): ' . $body);
     181            error_log('Response Body length: ' . strlen($body));
     182        }
     183       
     184        $data = json_decode($body, true);
     185       
     186        // Debug logging
     187        if (defined('WP_DEBUG') && WP_DEBUG) {
     188            error_log('Response Data (parsed): ' . print_r($data, true));
     189            error_log('JSON decode error: ' . json_last_error_msg());
    134190        }
    135191       
     
    138194            if (defined('WP_DEBUG') && WP_DEBUG) {
    139195                error_log('Error al parsear respuesta de API: ' . $body);
     196                error_log('Status Code: ' . $status_code);
    140197            }
    141198            return array(
     
    143200                'reason' => 'invalid_response',
    144201                'plan' => null,
    145                 'error' => 'Invalid response from server'
     202                'error' => 'Invalid response from server. Response body: ' . substr($body, 0, 200)
    146203            );
    147204        }
     
    162219            }
    163220           
     221            // Extract error message from response
     222            $error_message = 'Server error';
     223            if (isset($data['error'])) {
     224                $error_message = $data['error'];
     225            } elseif (isset($data['message'])) {
     226                $error_message = $data['message'];
     227            } elseif (isset($data['code'])) {
     228                $error_message = $data['code'];
     229            }
     230           
    164231            $reason = 'server_error';
    165232            if (isset($data['reason'])) {
     
    167234            } else if ($status_code === 404) {
    168235                $reason = 'not_found';
    169             }
    170            
    171             if (defined('WP_DEBUG') && WP_DEBUG) {
    172                 error_log('Licencia inválida. Razón: ' . $reason);
     236            } else if ($status_code === 401) {
     237                $reason = 'unauthorized';
     238            } else if ($status_code === 403) {
     239                $reason = 'forbidden';
     240            }
     241           
     242            if (defined('WP_DEBUG') && WP_DEBUG) {
     243                error_log('Licencia inválida. Status: ' . $status_code . ', Razón: ' . $reason . ', Error: ' . $error_message);
     244                error_log('Response data: ' . print_r($data, true));
    173245            }
    174246           
     
    177249                'reason' => $reason,
    178250                'plan' => isset($data['plan']) ? $data['plan'] : null,
    179                 'error' => isset($data['message']) ? $data['message'] : 'Server error'
     251                'error' => $error_message
    180252            );
    181253        }
  • pulse-chat-ai/trunk/pulse-chat-ai.php

    r3395179 r3395228  
    256256            'methods' => 'GET',
    257257            'callback' => array($this, 'get_conversations'),
    258             'permission_callback' => array($this, 'check_admin_permissions'),
     258            'permission_callback' => function($request) {
     259                return $this->check_admin_permissions($request);
     260            },
    259261        ));
    260262       
     
    262264            'methods' => 'DELETE',
    263265            'callback' => array($this, 'delete_conversation'),
    264             'permission_callback' => array($this, 'check_admin_permissions'),
     266            'permission_callback' => function($request) {
     267                return $this->check_admin_permissions($request);
     268            },
    265269            'args' => array(
    266270                'id' => array(
     
    275279            'methods' => 'POST',
    276280            'callback' => array($this, 'reset_usage_stats'),
    277             'permission_callback' => array($this, 'check_admin_permissions'),
     281            'permission_callback' => function($request) {
     282                return $this->check_admin_permissions($request);
     283            },
    278284        ));
    279285       
     
    282288            'methods' => 'POST',
    283289            'callback' => array($this, 'validate_license_endpoint'),
    284             'permission_callback' => array($this, 'check_admin_permissions'),
     290            'permission_callback' => function($request) {
     291                return $this->check_admin_permissions($request);
     292            },
    285293            'args' => array(
    286294                'license_key' => array(
     
    296304            'methods' => 'GET',
    297305            'callback' => array($this, 'get_license_status'),
    298             'permission_callback' => array($this, 'check_admin_permissions'),
     306            'permission_callback' => function($request) {
     307                return $this->check_admin_permissions($request);
     308            },
    299309        ));
    300310    }
     
    302312    /**
    303313     * Check admin permissions for REST API
    304      */
    305     public function check_admin_permissions() {
    306         return current_user_can('manage_options');
     314     *
     315     * @param WP_REST_Request $request The REST request object
     316     * @return bool|WP_Error True if user has permission, WP_Error otherwise
     317     */
     318    public function check_admin_permissions($request = null) {
     319        // Check if user is logged in
     320        if (!is_user_logged_in()) {
     321            return new WP_Error(
     322                'rest_forbidden',
     323                'You must be logged in to access this endpoint.',
     324                array('status' => 401)
     325            );
     326        }
     327       
     328        // Check if user has admin capabilities
     329        if (!current_user_can('manage_options')) {
     330            return new WP_Error(
     331                'rest_forbidden',
     332                'You do not have permission to access this endpoint.',
     333                array('status' => 403)
     334            );
     335        }
     336       
     337        return true;
    307338    }
    308339   
     
    671702     */
    672703    public function validate_license_endpoint($request) {
    673         $license_key = $request->get_param('license_key');
    674        
    675         if (empty($license_key)) {
    676             return new WP_Error('missing_license', 'License key is required', array('status' => 400));
    677         }
    678        
    679         // Validate license
    680         $result = $this->license_manager->validate_license($license_key, true);
    681        
    682         return rest_ensure_response($result);
     704        try {
     705            $license_key = $request->get_param('license_key');
     706           
     707            if (empty($license_key)) {
     708                return rest_ensure_response(array(
     709                    'valid' => false,
     710                    'reason' => 'missing_license',
     711                    'plan' => null,
     712                    'error' => 'License key is required'
     713                ));
     714            }
     715           
     716            // Validate license
     717            $result = $this->license_manager->validate_license($license_key, true);
     718           
     719            // Ensure result is always an array with the expected structure
     720            if (!is_array($result)) {
     721                return rest_ensure_response(array(
     722                    'valid' => false,
     723                    'reason' => 'server_error',
     724                    'plan' => null,
     725                    'error' => 'Invalid response from license validation'
     726                ));
     727            }
     728           
     729            return rest_ensure_response($result);
     730        } catch (Exception $e) {
     731            if (defined('WP_DEBUG') && WP_DEBUG) {
     732                error_log('Pulse Chat AI: Error in validate_license_endpoint: ' . $e->getMessage());
     733            }
     734            return rest_ensure_response(array(
     735                'valid' => false,
     736                'reason' => 'server_error',
     737                'plan' => null,
     738                'error' => $e->getMessage()
     739            ));
     740        }
    683741    }
    684742   
  • pulse-chat-ai/trunk/readme.txt

    r3395179 r3395228  
    128128== Changelog ==
    129129
    130 = 2.2.3 - January 2026 =
     130= 2.2.3 - November 2025 =
     131* FIXED: License validation now includes Supabase Authorization header (anon key)
     132* FIXED: "Missing authorization header" error resolved - license validation works correctly
    131133* FIXED: Domain normalization now always removes ports (no localhost exception)
    132134* FIXED: License validation domain mismatch issues resolved
    133135* IMPROVED: Domain normalization aligned with Supabase license system
    134136* IMPROVED: Added important comments about product_slug matching requirements
     137* IMPROVED: Enhanced error handling and logging for license validation
     138* IMPROVED: Better error messages for license validation failures
    135139
    136140= 2.2.0 - January 2026 =
Note: See TracChangeset for help on using the changeset viewer.