wordpress-turnstile

Wordpress paywall plugin
Log | Files | Refs | README | LICENSE

class-taler-merchant-api.php (26475B)


      1 <?php
      2 /**
      3  * Taler Merchant API Service
      4  *
      5  * Handles communication with the GNU Taler merchant backend.
      6  */
      7 
      8 if (!defined('ABSPATH')) {
      9     exit;
     10 }
     11 
     12 /**
     13  * Taler error codes used in this module
     14  */
     15 class Taler_Error_Code {
     16     const TALER_EC_NONE = 0;
     17     const TALER_EC_MERCHANT_GENERIC_INSTANCE_UNKNOWN = 2000;
     18     const TALER_EC_MERCHANT_GENERIC_ORDER_UNKNOWN = 2005;
     19 }
     20 
     21 class Taler_Merchant_API {
     22 
     23     /**
     24      * How long are orders valid by default? 24h.
     25      */
     26     const ORDER_VALIDITY_SECONDS = 86400;
     27 
     28     /**
     29      * How long do we cache /config and token family data from the backend?
     30      */
     31     const CACHE_BACKEND_DATA_SECONDS = 60;
     32 
     33     /**
     34      * Return the base URL for the given backend URL (without instance!)
     35      *
     36      * @param string $backend_url Backend URL to check, may include '/instances/$ID' path
     37      * @return string|null Base URL, or NULL if the backend URL is invalid
     38      */
     39     private static function get_base_url($backend_url) {
     40         if (empty($backend_url)) {
     41             return null;
     42         }
     43 
     44         if (!str_ends_with($backend_url, '/')) {
     45             return null;
     46         }
     47 
     48         $parsed_url = wp_parse_url($backend_url);
     49         $path = isset($parsed_url['path']) ? $parsed_url['path'] : '/';
     50         $cleaned_path = preg_replace('#^/instances/[^/]+/?#', '/', $path);
     51 
     52         $base = $parsed_url['scheme'] . '://' . $parsed_url['host'];
     53 
     54         if (isset($parsed_url['port'])) {
     55             $base .= ':' . $parsed_url['port'];
     56         }
     57 
     58         return $base . $cleaned_path;
     59     }
     60 
     61     /**
     62      * Check if the payment backend URL is valid
     63      *
     64      * @param string $url Backend URL to check
     65      * @return bool TRUE if this is a valid backend URL for a Taler backend
     66      */
     67     public static function check_config($url) {
     68         $base_url = self::get_base_url($url);
     69 
     70         if ($base_url === null) {
     71             return false;
     72         }
     73 
     74         try {
     75             $response = wp_remote_get($base_url . 'config', array(
     76                 'timeout' => 5,
     77                 'redirection' => 5
     78             ));
     79 
     80             if (is_wp_error($response)) {
     81                 return false;
     82             }
     83 
     84             if (wp_remote_retrieve_response_code($response) !== 200) {
     85                 return false;
     86             }
     87 
     88             $body = wp_remote_retrieve_body($response);
     89             $data = json_decode($body, true);
     90 
     91             return isset($data['name']) && $data['name'] === 'taler-merchant';
     92         } catch (Exception $e) {
     93             return false;
     94         }
     95     }
     96 
     97     /**
     98      * Check access to the merchant backend
     99      *
    100      * @param string $backend_url Backend URL to check
    101      * @param string $access_token Access token to talk to the instance
    102      * @return int HTTP status code (200/204 if successful, 0 on error)
    103      */
    104     public static function check_access($backend_url, $access_token) {
    105         try {
    106             $args = array(
    107                 'timeout' => 5,
    108                 'redirection' => 5,
    109                 'headers' => array()
    110             );
    111 
    112             if (!empty($access_token)) {
    113                 $args['headers']['Authorization'] = 'Bearer ' . $access_token;
    114             }
    115 
    116             $response = wp_remote_get($backend_url . 'private/orders?limit=1', $args);
    117 
    118             if (is_wp_error($response)) {
    119                 return 0;
    120             }
    121 
    122             return wp_remote_retrieve_response_code($response);
    123         } catch (Exception $e) {
    124             return 0;
    125         }
    126     }
    127 
    128     /**
    129      * Get available subscriptions from the backend
    130      *
    131      * @return array Array mapping token family IDs to subscription data
    132      */
    133     public static function get_subscriptions() {
    134         $cache_key = 'taler_turnstile_subscriptions';
    135         $cached = get_transient($cache_key);
    136 
    137         if ($cached !== false) {
    138             return $cached;
    139         }
    140 
    141         // Default: always include "no subscription" option
    142         $result = array(
    143             '%none%' => array(
    144                 'name' => 'none',
    145                 'label' => __('No reduction', 'taler-turnstile'),
    146                 'description' => __('No subscription', 'taler-turnstile'),
    147                 'description_i18n' => self::build_translation_map(__('No subscription', 'taler-turnstile'))
    148             )
    149         );
    150 
    151         $backend_url = get_option('taler_turnstile_payment_backend_url');
    152         $access_token = get_option('taler_turnstile_access_token');
    153 
    154         if (empty($backend_url) || empty($access_token)) {
    155             error_log('No GNU Taler Turnstile backend configured, returning "none" for subscriptions.');
    156             return $result;
    157         }
    158 
    159         try {
    160             $args = array(
    161                 'timeout' => 5,
    162                 'redirection' => 5,
    163                 'headers' => array(
    164                     'Authorization' => 'Bearer ' . $access_token
    165                 )
    166             );
    167 
    168             $response = wp_remote_get($backend_url . 'private/tokenfamilies', $args);
    169 
    170             if (is_wp_error($response)) {
    171                 error_log('Failed to obtain token family list: ' . $response->get_error_message());
    172                 return $result;
    173             }
    174 
    175             $http_status = wp_remote_retrieve_response_code($response);
    176             $body = wp_remote_retrieve_body($response);
    177             $jbody = json_decode($body, true);
    178 
    179             switch ($http_status) {
    180                 case 200:
    181                     if (!isset($jbody['token_families'])) {
    182                         error_log('Failed to obtain token family list: HTTP success response unexpectedly lacks "token_families" field.');
    183                         return $result;
    184                     }
    185                     break;
    186 
    187                 case 204:
    188                     // Empty list
    189                     set_transient($cache_key, $result, self::CACHE_BACKEND_DATA_SECONDS);
    190                     return $result;
    191 
    192                 case 403:
    193                     error_log('Access denied by the merchant backend. Did your credentials change or expire? Check your GNU Taler Turnstile configuration!');
    194                     return $result;
    195 
    196                 case 404:
    197                     error_log('Failed to fetch token family list: ' . json_encode($jbody));
    198                     return $result;
    199 
    200                 default:
    201                     error_log('Unexpected HTTP status code ' . $http_status . ' trying to fetch token family list');
    202                     return $result;
    203             }
    204 
    205             $token_families = $jbody['token_families'];
    206             $now = time (); // in seconds since Epoch
    207             foreach ($token_families as $family) {
    208                 $valid_before = ($family['valid_before']['t_s'] === 'never')
    209                    ? PHP_INT_MAX
    210                    : $family['valid_before']['t_s'];
    211                 if ( ($family['kind'] === 'subscription') &&
    212                      ($valid_before >= $now) &&
    213                      ($family['valid_after']['t_s'] < $now) ) {
    214                     $slug = $family['slug'];
    215                     $result[$slug] = array(
    216                         'name' => $family['name'],
    217                         'label' => $slug,
    218                         'valid_before_s' => $valid_before,
    219                         'description' => $family['description'] ?? 'no description (outdated backend)',
    220                         'description_i18n' => ($family['description_i18n'] ?? NULL)
    221                     );
    222                 }
    223             }
    224 
    225             set_transient($cache_key, $result, self::CACHE_BACKEND_DATA_SECONDS);
    226             return $result;
    227 
    228         } catch (Exception $e) {
    229             error_log('Failed to obtain list of token families: ' . $e->getMessage());
    230         }
    231 
    232         return $result;
    233     }
    234 
    235     /**
    236      * Get available currencies from the backend
    237      *
    238      * @return array Array of currencies with code, name, label, and step
    239      */
    240     public static function get_currencies() {
    241         $cache_key = 'taler_turnstile_currencies';
    242         $cached = get_transient($cache_key);
    243 
    244         if ($cached !== false) {
    245             return $cached;
    246         }
    247 
    248         $backend_url = get_option('taler_turnstile_payment_backend_url');
    249 
    250         if (empty($backend_url)) {
    251             error_log('Taler merchant backend not configured; cannot obtain currency list');
    252             return array();
    253         }
    254 
    255         try {
    256             $config_url = $backend_url . 'config';
    257             $response = wp_remote_get($config_url, array(
    258                 'timeout' => 5,
    259                 'redirection' => 5
    260             ));
    261 
    262             if (is_wp_error($response)) {
    263                 error_log('Failed to fetch backend config: ' . $response->get_error_message());
    264                 return array();
    265             }
    266 
    267             if (wp_remote_retrieve_response_code($response) !== 200) {
    268                 error_log('Taler merchant backend did not respond; cannot obtain currency list');
    269                 return array();
    270             }
    271 
    272             $body = wp_remote_retrieve_body($response);
    273             $backend_config = json_decode($body, true);
    274 
    275             if (!$backend_config || !is_array($backend_config)) {
    276                 error_log('Taler merchant backend returned invalid /config response');
    277                 return array();
    278             }
    279 
    280             if (!isset($backend_config['currencies'])) {
    281                 error_log('Backend returned malformed response for /config');
    282                 return array();
    283             }
    284 
    285             $currencies = $backend_config['currencies'];
    286 
    287             $result = array_map(function($currency) {
    288                 return array(
    289                     'code' => $currency['currency'],
    290                     'name' => $currency['name'],
    291                     'label' => isset($currency['alt_unit_names'][0]) ? $currency['alt_unit_names'][0] : $currency['currency'],
    292                     'step' => pow(0.1, isset($currency['num_fractional_input_digits']) ? $currency['num_fractional_input_digits'] : 2)
    293                 );
    294             }, $currencies);
    295 
    296             set_transient($cache_key, $result, self::CACHE_BACKEND_DATA_SECONDS);
    297             return $result;
    298 
    299         } catch (Exception $e) {
    300             error_log('Failed to obtain configuration from backend: ' . $e->getMessage());
    301             return array();
    302         }
    303     }
    304 
    305     /**
    306      * Check order status with Taler backend
    307      *
    308      * @param string $order_id The order ID to check
    309      * @return array|false Order status information or false on failure
    310      */
    311     public static function check_order_status($order_id) {
    312         $backend_url = get_option('taler_turnstile_payment_backend_url');
    313         $access_token = get_option('taler_turnstile_access_token');
    314 
    315         if (empty($backend_url) || empty($access_token)) {
    316             error_log('No GNU Taler Turnstile backend configured, cannot check order status!');
    317             return false;
    318         }
    319 
    320         try {
    321             $args = array(
    322                 'timeout' => 5,
    323                 'redirection' => 5,
    324                 'headers' => array(
    325                     'Authorization' => 'Bearer ' . $access_token
    326                 )
    327             );
    328 
    329             $response = wp_remote_get($backend_url . 'private/orders/' . $order_id, $args);
    330 
    331             if (is_wp_error($response)) {
    332                 error_log('Failed to check order status: ' . $response->get_error_message());
    333                 return false;
    334             }
    335 
    336             $http_status = wp_remote_retrieve_response_code($response);
    337             $body = wp_remote_retrieve_body($response);
    338             $jbody = json_decode($body, true);
    339 
    340             switch ($http_status) {
    341                 case 200:
    342                     // Success
    343                     break;
    344 
    345                 case 403:
    346                     error_log('Access denied by the merchant backend. Check your GNU Taler Turnstile configuration!');
    347                     return false;
    348 
    349                 case 404:
    350                     $ec = isset($jbody['code']) ? $jbody['code'] : Taler_Error_Code::TALER_EC_NONE;
    351 
    352                     switch ($ec) {
    353                         case Taler_Error_Code::TALER_EC_MERCHANT_GENERIC_INSTANCE_UNKNOWN:
    354                             error_log('Configured instance unknown to merchant backend. Check your configuration!');
    355                             return false;
    356 
    357                         case Taler_Error_Code::TALER_EC_MERCHANT_GENERIC_ORDER_UNKNOWN:
    358                             error_log('Order ' . $order_id . ' disappeared in the backend.');
    359                             return false;
    360 
    361                         default:
    362                             error_log('Unexpected error checking order status: ' . json_encode($jbody));
    363                             return false;
    364                     }
    365 
    366                 default:
    367                     error_log('Unexpected HTTP status code ' . $http_status . ' from merchant backend');
    368                     return false;
    369             }
    370 
    371             $order_status = isset($jbody['order_status']) ? $jbody['order_status'] : 'unknown';
    372             $subscription_expiration = 0;
    373             $subscription_slug = false;
    374             $pay_deadline = 0;
    375             $paid = false;
    376 
    377             switch ($order_status) {
    378                 case 'unpaid':
    379                     $pay_deadline = isset($jbody['pay_deadline']['t_s'])
    380                         ? $jbody['pay_deadline']['t_s']
    381                         : (self::ORDER_VALIDITY_SECONDS + (isset($jbody['creation_time']['t_s']) ? $jbody['creation_time']['t_s'] : 0));
    382                     break;
    383 
    384                 case 'claimed':
    385                     $contract_terms = $jbody['contract_terms'];
    386                     $pay_deadline = isset($contract_terms['pay_deadline']['t_s']) ? $contract_terms['pay_deadline']['t_s'] : 0;
    387                     break;
    388 
    389                 case 'paid':
    390                     $paid = true;
    391                     $contract_terms = $jbody['contract_terms'];
    392                     $contract_version = isset($jbody['version']) ? $jbody['version'] : 0;
    393                     $now = time();
    394 
    395                     if ($contract_version === 1) {
    396                         $choice_index = isset($jbody['choice_index']) ? $jbody['choice_index'] : 0;
    397                         $token_families = $contract_terms['token_families'];
    398                         $contract_choice = $contract_terms['choices'][$choice_index];
    399                         $outputs = isset($contract_choice['outputs']) ? $contract_choice['outputs'] : array();
    400 
    401                         foreach ($outputs as $output) {
    402                             $slug = $output['token_family_slug'];
    403                             $token_family = $token_families[$slug];
    404                             $details = $token_family['details'];
    405 
    406                             if (isset($details['class']) && $details['class'] !== 'subscription') {
    407                                 continue;
    408                             }
    409 
    410                             $keys = $token_family['keys'];
    411 
    412                             foreach ($keys as $key) {
    413                                 $sig_start = $key['signature_validity_start']['t_s'];
    414                                 $sig_end = $key['signature_validity_end']['t_s'];
    415 
    416                                 if ($sig_start <= $now && $sig_end > $now) {
    417                                     $subscription_slug = $slug;
    418                                     $subscription_expiration = $sig_end;
    419                                     break 2;
    420                                 }
    421                             }
    422                         }
    423                     }
    424                     break;
    425 
    426                 default:
    427                     error_log('Got unexpected order status: ' . $order_status);
    428                     break;
    429             }
    430 
    431             return array(
    432                 'order_id' => $order_id,
    433                 'paid' => $paid,
    434                 'subscription_slug' => $subscription_slug,
    435                 'subscription_expiration' => $subscription_expiration,
    436                 'order_expiration' => $pay_deadline
    437             );
    438 
    439         } catch (Exception $e) {
    440             error_log('Failed to check order status: ' . $e->getMessage());
    441             return false;
    442         }
    443     }
    444 
    445     /**
    446      * Create a new Taler order
    447      *
    448      * @param int $post_id The post ID to create an order for
    449      * @return array|false Order information or false on failure
    450      */
    451     public static function create_order($post_id) {
    452         $backend_url = get_option('taler_turnstile_payment_backend_url');
    453         $access_token = get_option('taler_turnstile_access_token');
    454         if (empty($backend_url) || empty($access_token)) {
    455             error_log('No backend, cannot setup new order');
    456             return false;
    457         }
    458 
    459         $price_category_id = get_post_meta($post_id, '_taler_price_category', true);
    460         if (empty($price_category_id)) {
    461             error_log('No price category selected');
    462             return false;
    463         }
    464 
    465         $price_category = Taler_Price_Category::get($price_category_id);
    466         if (!$price_category) {
    467             error_log('No price category, cannot setup new order');
    468             return false;
    469         }
    470         $price_hint = Taler_Price_Category::getPriceHint($price_category_id);
    471         $subscription_hint = Taler_Price_Category::getSubscriptionHint($price_category_id);
    472 
    473         $subscriptions = Taler_Merchant_API::get_subscriptions();
    474         $choices = Taler_Price_Category::get_payment_choices($price_category_id, $subscriptions);
    475         if (empty($choices)) {
    476             error_log('Price list is empty, cannot setup new order');
    477             return false;
    478         }
    479 
    480         $fulfillment_url = get_permalink($post_id);
    481         $hashed_session_id = self::get_hashed_session_id();
    482 
    483         $order_expiration = time() + self::ORDER_VALIDITY_SECONDS;
    484 
    485         $order_data = array(
    486             'order' => array(
    487                 'version' => 1,
    488                 'choices' => $choices,
    489                 'summary' => 'Access to: ' . get_the_title($post_id),
    490                  'summary_i18n' => self::build_translation_map (__('Access to: @title', 'taler-turnstile'),
    491                    ['@title' => get_the_title($post_id)]),
    492                 'fulfillment_url' => $fulfillment_url,
    493                 'pay_deadline' => array(
    494                     't_s' => $order_expiration
    495                 )
    496             ),
    497             'session_id' => $hashed_session_id,
    498             'create_token' => false
    499         );
    500 
    501         try {
    502             $args = array(
    503                 'timeout' => 5,
    504                 'redirection' => 5,
    505                 'headers' => array(
    506                     'Authorization' => 'Bearer ' . $access_token,
    507                     'Content-Type' => 'application/json'
    508                 ),
    509                 'body' => json_encode($order_data)
    510             );
    511 
    512             $response = wp_remote_post($backend_url . 'private/orders', $args);
    513 
    514             if (is_wp_error($response)) {
    515                 error_log('Failed to create Taler order: ' . $response->get_error_message());
    516                 return false;
    517             }
    518 
    519             $http_status = wp_remote_retrieve_response_code($response);
    520             $body = wp_remote_retrieve_body($response);
    521             $jbody = json_decode($body, true);
    522 
    523             switch ($http_status) {
    524                 case 200:
    525                     if (!isset($jbody['order_id'])) {
    526                         error_log('Failed to create order: response lacks "order_id" field.');
    527                         return false;
    528                     }
    529                     break;
    530 
    531                 case 403:
    532                     error_log('Access denied by the merchant backend. Check your configuration!');
    533                     return false;
    534 
    535                 case 451:
    536                     error_log('Failed to create order as legitimization is required first.');
    537                     return false;
    538 
    539                 default:
    540                     error_log('Unexpected HTTP status code ' . $http_status . ' trying to create order');
    541                     return false;
    542             }
    543 
    544             $order_id = $jbody['order_id'];
    545 
    546             return array(
    547                 'order_id' => $order_id,
    548                 'payment_url' => $backend_url . 'orders/' . $order_id,
    549                 'order_expiration' => $order_expiration,
    550                 'price_hint' => $price_hint,
    551                 'subscription_hint' => $subscription_hint,
    552                 'paid' => false,
    553                 'session_id' => $hashed_session_id
    554             );
    555 
    556         } catch (Exception $e) {
    557             error_log('Failed to create Taler order: ' . $e->getMessage());
    558             return false;
    559         }
    560     }
    561 
    562 
    563     /**
    564      * Build a translation map for all enabled languages
    565      *
    566      * Detects available multilingual plugins and generates translations accordingly.
    567      * Supports: WPML, Polylang, TranslatePress, qTranslate-XT
    568      *
    569      * NOTE: This was not yet tested properly with the respective
    570      * translation engines. Reports on issues particularly welcome.
    571      *
    572      * @param string $string The translatable string
    573      * @param array $args placeholder replacements for the translatable string
    574      * @return array Map of language codes to translated strings
    575      */
    576     private static function build_translation_map($string, $args = []) {
    577         $translations = array();
    578 
    579        // Detect and use WPML (WordPress Multilingual Plugin)
    580        if (defined('ICL_LANGUAGE_CODE') && function_exists('icl_translate')) {
    581             global $sitepress;
    582 
    583             if ($sitepress) {
    584                 $active_languages = $sitepress->get_active_languages();
    585 
    586                 foreach ($active_languages as $lang_code => $lang_data) {
    587                     // Register string if not already registered
    588                     do_action('wpml_register_single_string', 'taler-turnstile', 'subscription_description', $string);
    589 
    590                    // Get translation
    591                     $translated = apply_filters('wpml_translate_single_string', $string, 'taler-turnstile', 'subscription_description', $lang_code);
    592                     $translated = self::apply_placeholder_replacements($translated, $args);
    593                     $translations[$lang_code] = $translated;
    594                 }
    595 
    596                 return $translations;
    597             }
    598         }
    599 
    600         // Detect and use Polylang
    601         if (function_exists('pll_languages_list') && function_exists('pll_translate_string')) {
    602             $languages = pll_languages_list();
    603 
    604             foreach ($languages as $lang_code) {
    605                 // Register string for translation
    606                 pll_register_string('subscription_description', $string, 'taler-turnstile');
    607 
    608                 // Get translation
    609                 $translated = pll_translate_string($string, $lang_code);
    610                 $translated = self::apply_placeholder_replacements($translated, $args);
    611                 $translations[$lang_code] = $translated;
    612             }
    613 
    614             return $translations;
    615         }
    616 
    617         // Detect and use TranslatePress
    618         if (class_exists('TRP_Translate_Press')) {
    619             // Note to reviewers: this is NOT a symbol of ours, but
    620             // one from "TRP_Translate_Press" which we are *importing* here.
    621             global $TRP_LANGUAGE;
    622 
    623             $trp = TRP_Translate_Press::get_trp_instance();
    624             $settings = $trp->get_component('settings');
    625 
    626             if ($settings) {
    627                 $trp_settings = $settings->get_settings();
    628                 $languages = isset($trp_settings['publish-languages']) ? $trp_settings['publish-languages'] : array();
    629 
    630                 $trp_query = $trp->get_component('query');
    631 
    632                 foreach ($languages as $lang_code) {
    633                     // Get translation from TranslatePress database
    634                     $translated = $trp_query->get_existing_translation(
    635                         array(
    636                             'original' => $string,
    637                             'language' => $lang_code
    638                         )
    639                     );
    640                     $translated = self::apply_placeholder_replacements($translated, $args);
    641                     $translations[$lang_code] = !empty($translated) ? $translated : $string;
    642                 }
    643 
    644                 return $translations;
    645             }
    646         }
    647 
    648         // Detect and use qTranslate-XT (or qTranslate-X)
    649         if (function_exists('qtranxf_getLanguage') && function_exists('qtranxf_getEnabledLanguages')) {
    650             $languages = qtranxf_getEnabledLanguages();
    651 
    652             foreach ($languages as $lang_code) {
    653                 // qTranslate uses quicktags format [:en]text[:de]text
    654                 // For dynamic strings, we'll use the string as-is for all languages
    655                 // unless it's already in quicktag format
    656                 if (preg_match('/\[:.+\]/', $string)) {
    657                     $translated = qtranxf_use($lang_code, $string);
    658                 } else {
    659                     $translated = $string;
    660                 }
    661                 $translated = self::apply_placeholder_replacements($translated, $args);
    662                 $translations[$lang_code] = $translated;
    663             }
    664             return $translations;
    665         }
    666 
    667         // Fallback: No multilingual plugin detected
    668         // Return English only with placeholder replacements
    669         $translated = self::apply_placeholder_replacements($string, $args);
    670         return array('en' => $translated);
    671     }
    672 
    673     /**
    674      * Apply placeholder replacements to a translated string
    675      *
    676      * @param string $string The string containing placeholders (e.g., "Kaufe @title")
    677      * @param array $args Placeholder replacements (e.g., ['@title' => '1984'])
    678      * @return string String with placeholders replaced
    679      */
    680     private static function apply_placeholder_replacements($string, $args) {
    681         if (empty($args)) {
    682             return $string;
    683         }
    684         foreach ($args as $placeholder => $value) {
    685             $string = str_replace($placeholder, $value, $string);
    686         }
    687         return $string;
    688     }
    689 
    690     /**
    691      * Generate a hashed session identifier for payment tracking
    692      *
    693      * @return string Base64-encoded SHA-256 hash of the session ID (URL-safe)
    694      */
    695     private static function get_hashed_session_id() {
    696         if (session_status() === PHP_SESSION_NONE) {
    697             session_start();
    698         }
    699 
    700         $raw_session_id = session_id();
    701 
    702         if (empty($raw_session_id)) {
    703             $raw_session_id = wp_get_session_token();
    704         }
    705 
    706         $hash = hash('sha256', $raw_session_id, true);
    707 
    708         // Encode as URL-safe base64
    709         return rtrim(strtr(base64_encode($hash), '+/', '-_'), '=');
    710     }
    711 
    712 
    713 }