15s timeout * της πιο αργής πολιτικής του Api_Client). * * @var int */ const LOCK_SECONDS = 30; /** @var string Ομάδα object cache για το lock. */ const CACHE_GROUP = 'addsynapse_search'; /** * Αποτέλεσμα tenant_token() για το τρέχον αίτημα PHP (null = δεν ρωτήθηκε ακόμα). * Το is_active() και το get_config() το ζητούν στην ίδια σελίδα — μία φορά αρκεί. * * @var string|false|null */ private static $memo_token = null; /** * Αν η συνδρομή είναι ενεργή αυτή τη στιγμή. Κάνει lazy refresh του token αν χρειάζεται * (άρα ενδεχόμενη — αλλά cached μέσω transient έως 24 ώρες — κλήση δικτύου). * Στο frontend η κλήση είναι το πολύ ΜΙΑ ανά αίτημα, 3s, και μετά από * αποτυχία καμία για BACKOFF_SECONDS (βλ. tenant_token()). * * @return bool */ public static function is_active() { $key = Addsynapse_Search_Settings::get( 'license_key', '' ); if ( '' === $key ) { return false; } $token = self::tenant_token(); if ( false === $token ) { return false; } /* * 🔴 29/09 (noimatest «έχασε» την άδεια): η κατάσταση είναι transient * ΞΕΧΩΡΙΣΤΟ από το token — μπορεί να χαθεί ανεξάρτητα (eviction σε * persistent object cache, καθαρισμός transients από plugin * βελτιστοποίησης, λάθος TTL). Τότε ένα ΕΓΚΥΡΟ token σερβιριζόταν * αλλά το is_active() έλεγε false → αναζήτηση/φίλτρα έσβηναν ως τη * λήξη του token (~24h), ΧΩΡΙΣ καμία κλήση δικτύου. Ρητή ανάκληση * σβήνει ΠΑΝΤΑ το token (mark_revoked()), άρα έγκυρο token χωρίς * καταγεγραμμένη κατάσταση = ενεργό (βλ. status()). Μόνο ρητό * `active: false` κλείνει. */ $status = self::status(); return ! empty( $status['active'] ); } /** * Το shop_id που έδωσε ο server κατά την ενεργοποίηση. * * @return string */ public static function shop_id() { return (string) Addsynapse_Search_Settings::get( 'shop_id', '' ); } /** * Δένει ένα license key στο τρέχον domain. * * @param string $key Το license key που έδωσε ο χρήστης. * @return true|WP_Error */ public static function activate( $key ) { $key = is_string( $key ) ? trim( $key ) : ''; if ( '' === $key ) { return new WP_Error( 'addsynapse_search_license_empty', __( 'Please enter a license key.', 'addsynapse-search-filter' ) ); } $response = Addsynapse_Search_Api_Client::request( 'POST', '/v1/license/activate', array( 'domain' => self::current_domain(), // Γλώσσα καταλόγου -> stop words του δείκτη (backend, docs/STOPWORDS.md). 'locale' => Addsynapse_Search_Frontend::catalog_lang(), ), $key ); if ( is_wp_error( $response ) ) { return $response; } return self::apply_activation( $response, $key ); } /** * Αποθηκεύει μια απάντηση ενεργοποίησης του κόμβου (POST /v1/license/activate ή /v1/connect/link — * ΙΔΙΟ σχήμα, backend LicenseActivationController::activationPayload()) μαζί με το κλειδί της. * * @param array $response Απάντηση API. * @param string $key Το license key του κόμβου. * @return true|WP_Error */ public static function apply_activation( $response, $key ) { $key = is_string( $key ) ? trim( $key ) : ''; if ( ! is_array( $response ) || '' === $key ) { return new WP_Error( 'addsynapse_search_license_invalid', __( 'Invalid activation response from the server.', 'addsynapse-search-filter' ) ); } // 🔴 Έλεγχος ΤΥΠΩΝ πριν αποθηκευτεί οτιδήποτε (audit extra #6): ένα // array/object στη θέση του token ή του shop_id θα γραφόταν στο option // και θα έσπαγε κάθε επόμενη σελίδα σιωπηλά. if ( ! self::valid_shop_id( isset( $response['shop_id'] ) ? $response['shop_id'] : null ) || ! self::valid_token( isset( $response['token'] ) ? $response['token'] : null ) ) { return new WP_Error( 'addsynapse_search_license_invalid', __( 'Invalid activation response from the server.', 'addsynapse-search-filter' ) ); } Addsynapse_Search_Settings::set( 'license_key', $key ); Addsynapse_Search_Settings::set( 'shop_id', sanitize_text_field( (string) $response['shop_id'] ) ); // Ο server δίνει και τα endpoints του κόμβου (api_base/meilisearch_host/ // analytics_endpoint) κατά την ενεργοποίηση — ΙΔΙΑ λογική αποθήκευσης // χρειάζεται και στο refresh_token() (ανανέωση κάθε 24 ώρες, εκεί // «ακολουθεί» μια μετακόμιση καταστήματος σε άλλον κόμβο), γι' αυτό // είναι κοινό helper — βλ. store_node_endpoints_from_response(). self::store_node_endpoints_from_response( $response ); // Κατώφλι σχετικότητας Meilisearch — βαθμονομείται server-side, το // plugin απλώς το αποθηκεύει και το στέλνει σε κάθε αναζήτηση // (δεν μπορεί να επιβληθεί server-side, βλ. σχόλιο στο // backend/config/meilisearch.php). isset() (όχι empty()) γιατί 0.0 // είναι έγκυρη -αν και ασυνήθιστη- τιμή. if ( isset( $response['ranking_score_threshold'] ) && is_numeric( $response['ranking_score_threshold'] ) ) { Addsynapse_Search_Settings::set( 'ranking_score_threshold', (float) $response['ranking_score_threshold'] ); } if ( isset( $response['variation_indexing'] ) ) { self::sync_variation_indexing_flag( (bool) $response['variation_indexing'] ); } self::reset_token_cache(); self::store_token_from_response( $response ); self::store_status( array( 'active' => ! empty( $response['active'] ), 'plan' => isset( $response['plan'] ) && is_scalar( $response['plan'] ) ? sanitize_text_field( (string) $response['plan'] ) : '', 'expires_at' => isset( $response['expires_at'] ) && is_scalar( $response['expires_at'] ) ? sanitize_text_field( (string) $response['expires_at'] ) : '', 'checked_at' => time(), 'error' => '', 'hub_linked' => ! empty( $response['hub_linked'] ), ) ); return true; } /** * Πού στέλνουμε τον πελάτη για αναβάθμιση πακέτου. * * 🔴 Χτίζεται από το `api_base` ΚΑΙ ΟΧΙ hardcoded. Μέχρι τις 23/09 οι * σύνδεσμοι «Upgrade your plan» έδειχναν σε σταθερό URL που ΔΕΝ ΥΠΗΡΧΕ — * ο πελάτης χτυπούσε το όριο, πατούσε «αναβάθμισε» και έπαιρνε 404, τη * χειρότερη δυνατή στιγμή. Παίρνοντας τη βάση από τις ρυθμίσεις, ένα * μελλοντικό domain αλλάζει από τον server (βλ. * store_node_endpoints_from_response) χωρίς νέα έκδοση plugin. * * Το domain πάει στο query string ώστε η σελίδα να ξέρει ΠΟΙΟ κατάστημα * αναβαθμίζεται — αλλιώς ο καταστηματάρχης θα έπρεπε να το * πληκτρολογήσει σωστά, και κάθε τυπογραφικό θα κατέληγε σε πληρωμή που * δεν μπορεί να εφαρμοστεί. * * @return string */ public static function pricing_url() { // Συνδεδεμένο με AddSynapse: πακέτα/χρέωση στην πύλη (σελίδα του site). if ( class_exists( 'Addsynapse_Search_Hub' ) && Addsynapse_Search_Hub::is_connected() && '' !== Addsynapse_Search_Hub::manage_url() ) { return Addsynapse_Search_Hub::manage_url(); } $base = rtrim( (string) Addsynapse_Search_Settings::get( 'api_base', Addsynapse_Search_Settings::DEFAULT_API_BASE ), '/' ); if ( '' === $base ) { $base = Addsynapse_Search_Settings::DEFAULT_API_BASE; } return add_query_arg( array( 'domain' => self::current_domain() ), $base . '/pricing' ); } /** * Λύνει τη σύνδεση του key με αυτό το site (server-side) και καθαρίζει τοπικά. * * @return true|WP_Error */ public static function deactivate() { $key = Addsynapse_Search_Settings::get( 'license_key', '' ); if ( '' !== $key ) { // Best effort: αν αποτύχει η κλήση, καθαρίζουμε ούτως ή άλλως τοπικά. Addsynapse_Search_Api_Client::request( 'POST', '/v1/license/deactivate', array( 'domain' => self::current_domain(), 'shop_id' => self::shop_id(), ), $key ); } Addsynapse_Search_Settings::set( 'license_key', '' ); Addsynapse_Search_Settings::set( 'shop_id', '' ); delete_transient( self::TRANSIENT_TOKEN ); delete_transient( self::TRANSIENT_STATUS ); delete_transient( self::TRANSIENT_REFRESH_LOCK ); self::reset_token_cache(); // Χωρίς σύνδεση δεν μένει τίποτα προγραμματισμένο (νυχτερινό, sync, διαγραφές, reindex): // ίδια κατάσταση με μια φρέσκια εγκατάσταση. Η λίστα εκκρεμών διαγραφών μένει στη βάση // και ξαναπιάνεται στην επόμενη σύνδεση· το transient σβήνεται ώστε το ensure_cron() // να προγραμματίσει αμέσως τότε, όχι μετά από 12 ώρες. if ( class_exists( 'Addsynapse_Search_Indexer' ) ) { Addsynapse_Search_Indexer::unschedule_cron(); } delete_transient( 'addsynapse_search_cron_checked' ); return true; } /** * Το τρέχον tenant token (JWT) για απευθείας αναζήτηση στο Meilisearch. * Ανανεώνεται αυτόματα όταν περάσει το `refresh_after` του cached αντιγράφου. * * 🔴 audit F01: προσωρινή βλάβη (timeout/5xx/δίκτυο) ΔΕΝ κλείνει την * υπηρεσία — σερβίρεται το cached token όσο έχει ≥ MIN_SERVE_LIFETIME ζωή, * η κατάσταση μένει ως είχε και ξαναδοκιμάζουμε μετά από σύντομο backoff. * ΜΟΝΟ ρητή άρνηση του server (βλ. is_revocation_error()) απενεργοποιεί. * * @return string|false */ public static function tenant_token() { if ( null !== self::$memo_token ) { return self::$memo_token; } $record = self::cached_record(); if ( null !== $record && time() < $record['refresh_after'] ) { self::$memo_token = $record['token']; return self::$memo_token; } self::$memo_token = self::refresh_single_flight( $record, '' ); return self::$memo_token; } /** * Ανανέωση token που ο browser ανέφερε ως ΝΕΚΡΟ (401/403 από το Meilisearch * — π.χ. ο server το ανακάλεσε πριν λήξει). Για τον δημόσιο refresh * endpoint του frontend· επιστρέφει ΜΟΝΟ tenant token, ποτέ το license key. * * - Αν το cached token είναι ΗΔΗ άλλο από το $stale_token, κάποιος το * ανανέωσε ήδη → επιστρέφεται αυτό, χωρίς δίκτυο. * - Αλλιώς: νέο αίτημα το πολύ ΜΙΑ φορά ανά REFRESH_LOCK_SECONDS για όλο * το site, ΠΟΤΕ όσο ισχύει backoff, και μέσα από το ΙΔΙΟ single-flight * lock με την κανονική ανανέωση (audit F03). * - 🔴 audit F01: το cached ΔΕΝ σβήνεται πριν την κλήση. Σε προσωρινή βλάβη * μένει όπως ήταν (και η κατάσταση «ενεργό» επίσης)· ο ισχυρισμός «νεκρό» * έρχεται από δημόσιο endpoint και μπορεί να είναι ψευδής. * * @param string $stale_token Το token που απέτυχε στον browser. * @return string|false Νέο (ή ήδη ανανεωμένο) token, ή false. */ public static function tenant_token_force_refresh( $stale_token ) { $stale_token = is_string( $stale_token ) ? trim( $stale_token ) : ''; if ( '' === $stale_token || ! self::valid_token( $stale_token ) ) { return self::tenant_token(); } $record = self::cached_record(); if ( null === $record ) { // Τίποτα cached: η κανονική διαδρομή (backoff + lock) αρκεί. return self::tenant_token(); } if ( ! hash_equals( $record['token'], $stale_token ) ) { self::$memo_token = $record['token']; return self::$memo_token; } if ( false !== get_transient( self::TRANSIENT_BACKOFF ) || false !== get_transient( self::TRANSIENT_REFRESH_LOCK ) ) { // Μέσα στο παράθυρο: ό,τι έχουμε (ίσως το ίδιο νεκρό) — καμία κλήση. return $record['token']; } set_transient( self::TRANSIENT_REFRESH_LOCK, 1, self::REFRESH_LOCK_SECONDS ); self::$memo_token = self::refresh_single_flight( $record, $stale_token ); return self::$memo_token; } /** * Το cached token ΜΟΝΟ αν σερβίρεται ακόμα με ασφάλεια (έγκυρη μορφή, * ≥ MIN_SERVE_LIFETIME ζωή). Συμπληρώνει `refresh_after` σε εγγραφές * παλαιότερης έκδοσης (≤0.4.1, χωρίς το πεδίο). * * @return array|null {token, expires_at, refresh_after} */ private static function cached_record() { $cached = get_transient( self::TRANSIENT_TOKEN ); if ( ! is_array( $cached ) || empty( $cached['token'] ) || ! self::valid_token( $cached['token'] ) || empty( $cached['expires_at'] ) ) { return null; } $expires_at = (int) $cached['expires_at']; if ( $expires_at - time() < self::MIN_SERVE_LIFETIME ) { return null; } return array( 'token' => (string) $cached['token'], 'expires_at' => $expires_at, 'refresh_after' => isset( $cached['refresh_after'] ) ? (int) $cached['refresh_after'] : $expires_at - HOUR_IN_SECONDS, ); } /** * Ανανέωση με ΕΝΑ κοινό, ατομικό lock (audit F03). Όποιος δεν το πάρει * σερβίρει το cached (αν υπάρχει) χωρίς δίκτυο. * * @param array|null $record Το cached token πριν την απόφαση ανανέωσης. * @param string $stale_token Για βίαιη ανανέωση: το token που «πέθανε»· '' για κανονική. * @return string|false */ private static function refresh_single_flight( $record, $stale_token ) { $fallback = null !== $record ? $record['token'] : false; if ( '' === (string) Addsynapse_Search_Settings::get( 'license_key', '' ) ) { return false; } // Πρόσφατη αποτυχία: καμία κλήση δικτύου μέχρι να λήξει το backoff. if ( false !== get_transient( self::TRANSIENT_BACKOFF ) ) { return $fallback; } $lock = self::acquire_lock( self::LOCK_REFRESH, self::LOCK_SECONDS ); if ( false === $lock ) { // Άλλο αίτημα ανανεώνει τώρα — δεν το διπλασιάζουμε. return $fallback; } try { // Διπλός έλεγχος ΜΕΣΑ στο lock: ίσως κάποιος ανανέωσε μόλις πριν. $fresh = self::cached_record(); if ( null !== $fresh ) { $already = '' === $stale_token ? time() < $fresh['refresh_after'] : ! hash_equals( $fresh['token'], $stale_token ); if ( $already ) { return $fresh['token']; } } $result = self::refresh_token(); } finally { self::release_lock( self::LOCK_REFRESH, $lock ); } if ( is_string( $result ) ) { return $result; } // false = ρητή ανάκληση → ΚΑΝΕΝΑ fallback. null = προσωρινή βλάβη. return false === $result ? false : $fallback; } /** * Ατομική απόκτηση lock με λήξη. Επιστρέφει την τιμή-ταυτότητα του κατόχου * (για release_lock()) ή false. * * - Persistent object cache: `wp_cache_add()` = ατομικό add-if-absent (NX). * - Αλλιώς `INSERT IGNORE` στο wp_options (unique option_name). ΟΧΙ * add_option(): εκείνο κάνει `ON DUPLICATE KEY UPDATE`, άρα σε race * «κερδίζουν» και οι δύο. Ληγμένο lock (ο κάτοχος πέθανε) κλέβεται με * compare-and-swap UPDATE — μόνο ένας το πετυχαίνει. * * @param string $name Όνομα lock. * @param int $ttl Δευτερόλεπτα έως αυτόματη λήξη. * @return string|false */ public static function acquire_lock( $name, $ttl ) { $value = ( time() + (int) $ttl ) . '|' . wp_generate_password( 16, false ); if ( wp_using_ext_object_cache() ) { return wp_cache_add( $name, $value, self::CACHE_GROUP, (int) $ttl ) ? $value : false; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- ατομικό lock· το Options API δεν έχει add-if-absent. $inserted = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'off')", $name, $value ) ); if ( 1 === (int) $inserted ) { return $value; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- ανάγνωση του lock ΧΩΡΙΣ cache (πρέπει να είναι η τρέχουσα τιμή). $current = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $name ) ); if ( ! is_string( $current ) ) { return false; } $until = (int) strtok( $current, '|' ); if ( $until > time() ) { return false; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- compare-and-swap ληγμένου lock. $stolen = $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s", $value, $name, $current ) ); return 1 === (int) $stolen ? $value : false; } /** * Απελευθέρωση ΜΟΝΟ αν το lock είναι ακόμα δικό μας (όχι αν έληξε και το * πήρε άλλος). * * @param string $name Όνομα lock. * @param string $value Η τιμή που επέστρεψε το acquire_lock(). * @return void */ public static function release_lock( $name, $value ) { if ( wp_using_ext_object_cache() ) { if ( wp_cache_get( $name, self::CACHE_GROUP ) === $value ) { wp_cache_delete( $name, self::CACHE_GROUP ); } return; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- απελευθέρωση ατομικού lock (βλ. acquire_lock()). $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", $name, $value ) ); } /** * Καθαρίζει memo + backoff — μετά από activate/deactivate η επόμενη * ερώτηση πρέπει να δει τη νέα κατάσταση αμέσως. * * @return void */ private static function reset_token_cache() { self::$memo_token = null; delete_transient( self::TRANSIENT_BACKOFF ); delete_transient( self::TRANSIENT_FAILURES ); } /** * Η τελευταία γνωστή κατάσταση συνδρομής. * * @return array {active, plan, expires_at, checked_at, error} */ public static function status() { $status = get_transient( self::TRANSIENT_STATUS ); if ( ! is_array( $status ) ) { // Χαμένη κατάσταση (βλ. is_active()): έγκυρο cached token = ο server // το έδωσε και δεν το ανακάλεσε (η ανάκληση σβήνει το token). $status = array( 'active' => '' !== (string) Addsynapse_Search_Settings::get( 'license_key', '' ) && null !== self::cached_record(), 'plan' => '', 'expires_at' => '', 'checked_at' => 0, 'error' => '', ); } return $status; } /** * Ζητά νέο tenant token από το API (χρησιμοποιεί το αποθηκευμένο license key). * Καλείται ΜΟΝΟ μέσα από refresh_single_flight() (κάτω από το lock). * * @return string|false|null Token· false = ΡΗΤΗ ανάκληση/ανενεργό (καμία * εφεδρεία)· null = προσωρινή βλάβη (κράτα ό,τι ισχύει). */ private static function refresh_token() { $key = Addsynapse_Search_Settings::get( 'license_key', '' ); if ( '' === $key ) { return false; } $response = Addsynapse_Search_Api_Client::token(); if ( is_wp_error( $response ) ) { if ( self::is_revocation_error( $response ) ) { self::mark_revoked( '', $response->get_error_message() ); return false; } self::note_transient_failure( $response->get_error_message() ); return null; } if ( ! is_array( $response ) ) { self::note_transient_failure( 'invalid_response' ); return null; } // Σύμβαση backend (AuthTokenController): ανενεργό κατάστημα = 200 με // `active: false` και ΧΩΡΙΣ token. Ρητή απάντηση — όχι βλάβη. if ( array_key_exists( 'active', $response ) && empty( $response['active'] ) ) { self::mark_revoked( isset( $response['plan'] ) && is_scalar( $response['plan'] ) ? sanitize_text_field( (string) $response['plan'] ) : '', 'inactive' ); return false; } if ( ! self::valid_token( isset( $response['token'] ) ? $response['token'] : null ) ) { self::note_transient_failure( 'empty_token' ); return null; } delete_transient( self::TRANSIENT_BACKOFF ); delete_transient( self::TRANSIENT_FAILURES ); self::store_token_from_response( $response ); // 🔴 ΕΔΩ «ακολουθεί» το plugin μια μετακόμιση καταστήματος σε άλλον // κόμβο (ARCHITECTURE.md §9): το refresh_token() τρέχει κάθε 24 ώρες // (lazy, όταν λήξει το cached tenant token) — αν ο server άλλαξε // shops.node_id, η απάντηση εδώ φέρνει το api_base/meilisearch_host/ // analytics_endpoint του ΝΕΟΥ κόμβου. Ίδιο helper με activate(). self::store_node_endpoints_from_response( $response ); self::store_status( array( 'active' => true, 'plan' => isset( $response['plan'] ) && is_scalar( $response['plan'] ) ? sanitize_text_field( (string) $response['plan'] ) : '', 'expires_at' => isset( $response['expires_at'] ) && is_scalar( $response['expires_at'] ) ? sanitize_text_field( (string) $response['expires_at'] ) : '', 'checked_at' => time(), 'error' => '', 'hub_linked' => ! empty( $response['hub_linked'] ), ) ); return (string) $response['token']; } /** * Ρητή άρνηση του server, κατά τη σύμβαση του backend: * - 401 = το middleware AuthenticateLicense (άγνωστο/ανακληθέν κλειδί — * ΙΔΙΟ 401 σκόπιμα, χωρίς κωδικό)· * - 402/403 ΜΟΝΟ με ρητό `code` στο JSON (όχι σκέτο 403 από WAF/CDN). * Όλα τα υπόλοιπα (δίκτυο, timeout, 5xx, 429, redirect, άκυρο JSON) είναι * προσωρινά. * * @param WP_Error $error Σφάλμα από Addsynapse_Search_Api_Client::token(). * @return bool */ private static function is_revocation_error( WP_Error $error ) { $data = $error->get_error_data(); $status = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 0; $code = is_array( $data ) && isset( $data['code'] ) ? (string) $data['code'] : ''; if ( 401 === $status ) { return true; } return ( 402 === $status || 403 === $status ) && '' !== $code; } /** * Ρητή ανάκληση/ανενεργό: ΚΑΝΕΝΑ εφεδρικό token, κατάσταση ανενεργή, * backoff BACKOFF_SECONDS ώστε να μη ρωτάμε σε κάθε προβολή σελίδας * (η ενεργοποίηση από το admin το καθαρίζει — reset_token_cache()). * * @param string $plan Πακέτο (αν το έδωσε ο server). * @param string $error Μήνυμα για το admin. * @return void */ private static function mark_revoked( $plan, $error ) { delete_transient( self::TRANSIENT_TOKEN ); delete_transient( self::TRANSIENT_FAILURES ); set_transient( self::TRANSIENT_BACKOFF, time() + self::BACKOFF_SECONDS, self::BACKOFF_SECONDS ); self::store_status( array( 'active' => false, 'plan' => (string) $plan, 'expires_at' => '', 'checked_at' => time(), 'error' => (string) $error, ) ); } /** * Προσωρινή βλάβη: η κατάσταση ΜΕΝΕΙ ως είχε (μόνο σημειώνεται το σφάλμα) * και το cached token ΔΕΝ αγγίζεται. Εκθετικό backoff 60s → 600s. * * @param string $error Μήνυμα για το admin. * @return void */ private static function note_transient_failure( $error ) { $failures = (int) get_transient( self::TRANSIENT_FAILURES ) + 1; set_transient( self::TRANSIENT_FAILURES, $failures, DAY_IN_SECONDS ); $delay = (int) min( self::BACKOFF_SECONDS, self::TRANSIENT_BACKOFF_SECONDS * pow( 2, min( 10, $failures - 1 ) ) ); set_transient( self::TRANSIENT_BACKOFF, time() + $delay, $delay ); // Χαμένη κατάσταση + εφεδρικό token → status() δίνει «ενεργό»· το // γράφουμε ως τέτοιο (όχι «ανενεργό», που θα έκλεινε την αναζήτηση // ακριβώς σε προσωρινή βλάβη — audit F01). $status = self::status(); $status['error'] = (string) $error; self::store_status( $status ); self::log( 'προσωρινή αποτυχία ανανέωσης token (#' . $failures . ') — επόμενη προσπάθεια σε ' . $delay . 's.' ); } /** * Έγκυρο tenant token = μη κενό string σε μορφή JWT (τρία base64url κομμάτια). * * @param mixed $token Τιμή από την απάντηση. * @return bool */ private static function valid_token( $token ) { return is_string( $token ) && strlen( $token ) <= 4096 && 1 === preg_match( '/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/', $token ); } /** * Έγκυρο shop_id = θετικός ακέραιος ή σύντομο αλφαριθμητικό string. * * @param mixed $shop_id Τιμή από την απάντηση. * @return bool */ private static function valid_shop_id( $shop_id ) { if ( is_int( $shop_id ) ) { return $shop_id > 0; } return is_string( $shop_id ) && 1 === preg_match( '/^[A-Za-z0-9_-]{1,64}$/', $shop_id ); } /** * Πόσα δευτερόλεπτα ζωής έχει ΠΡΑΓΜΑΤΙΚΑ ακόμα το token: το μικρότερο * από το `ttl` της απάντησης και το `exp` μέσα στο ίδιο το JWT (αν * διαβάζεται). 0 = άγνωστο/άκυρο. * * @param array $response Απάντηση API. * @return int */ private static function token_remaining_lifetime( $response ) { $remaining = 0; if ( isset( $response['ttl'] ) && is_numeric( $response['ttl'] ) && (int) $response['ttl'] > 0 ) { $remaining = (int) $response['ttl']; } $token = isset( $response['token'] ) ? (string) $response['token'] : ''; $parts = explode( '.', $token ); if ( 3 === count( $parts ) ) { $payload = json_decode( (string) base64_decode( strtr( $parts[1], '-_', '+/' ), true ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- αποκωδικοποίηση JWT payload (exp), όχι obfuscation. if ( is_array( $payload ) && isset( $payload['exp'] ) && is_numeric( $payload['exp'] ) ) { $from_exp = (int) $payload['exp'] - time(); $remaining = $remaining > 0 ? min( $remaining, $from_exp ) : $from_exp; } } return max( 0, $remaining ); } /** * Η λήξη (unix time) του cached tenant token, ή 0 αν δεν υπάρχει. * Για τον δημόσιο refresh endpoint του frontend (ποτέ το license key). * * @return int */ public static function tenant_token_expires() { $cached = get_transient( self::TRANSIENT_TOKEN ); return is_array( $cached ) && isset( $cached['expires_at'] ) ? (int) $cached['expires_at'] : 0; } /** * Αποθηκεύει τα endpoints του κόμβου (api_base/meilisearch_host/ * analytics_endpoint) από μια απάντηση activate/auth-token — κοινό * μεταξύ activate() και refresh_token(), ίδιο σχήμα απάντησης * (LicenseActivationController::resolveNode() / AuthTokenController στο backend). * * 🔴 ΕΠΙΚΥΡΩΣΗ api_base (task-brief §5, ΜΗΝ την παραλείψεις): δέχεται την * τιμή ΜΟΝΟ αν είναι ένα έγκυρο `https://` URL με host — αλλιώς ΚΡΑΤΑ την * προηγούμενη αποθηκευμένη τιμή και καταγράφει. Ένα κατεστραμμένο/κενό * api_base θα έστελνε ΚΑΘΕ επόμενη κλήση του πελάτη (ingest/settings/ * license) σε άγνωστο προορισμό — δεν κάνουμε ποτέ σιωπηλή αλλαγή εδώ. * Από 0.3.4 ΚΑΙ τα meilisearch_host/analytics_endpoint περνούν από την * ίδια allowlist (audit F07). * * @param array $response Απάντηση API (activate ή auth/token). * @return void */ private static function store_node_endpoints_from_response( $response ) { // 🔴 ΚΑΙ ΤΑ ΤΡΙΑ περνούν από την ίδια allowlist (audit F07, // Addsynapse_Search_Settings::is_allowed_service_url()): στο api_base πάει το // license key, στο meilisearch_host ο browser του επισκέπτη, στο // analytics_endpoint τα beacons. Άκυρη τιμή → κρατάμε την προηγούμενη. // Συνδεδεμένο με AddSynapse: τους hosts τους ορίζει ο hub (routing του /api/v1/search/status, // Addsynapse_Search_Hub::apply_routing()) — ο κόμβος δεν τους αλλάζει (αλλιώς θα «αναβόσβηναν»). $hub_routes = class_exists( 'Addsynapse_Search_Hub' ) && Addsynapse_Search_Hub::owns_routing(); foreach ( array( 'api_base', 'meilisearch_host', 'analytics_endpoint' ) as $endpoint_key ) { if ( $hub_routes && ( 'analytics_endpoint' !== $endpoint_key || '' !== (string) Addsynapse_Search_Settings::get( 'analytics_endpoint', '' ) ) ) { continue; } if ( ! isset( $response[ $endpoint_key ] ) || '' === $response[ $endpoint_key ] ) { continue; } if ( self::is_valid_api_base( $response[ $endpoint_key ] ) ) { Addsynapse_Search_Settings::set( $endpoint_key, trim( $response[ $endpoint_key ] ) ); } else { self::log( 'Ο server έστειλε μη επιτρεπτό ' . $endpoint_key . ' ("' . self::describe_for_log( $response[ $endpoint_key ] ) . '") — κρατάμε το προηγούμενο.' ); } } /* * 🔴 ΤΟ ΟΝΟΜΑ ΤΟΥ ΔΕΙΚΤΗ ΕΡΧΕΤΑΙ ΑΠΟ ΤΟΝ SERVER — μην το κατασκευάζεις. * * Ο server το ορίζει (App\Models\Shop::index_name) και το στέλνει στην * απάντηση ενεργοποίησης. Μέχρι τις 23/09 το plugin το έφτιαχνε μόνο * του ως prefix + shop_id — και η μετονομασία του prefix το έσπασε * σιωπηλά: το token επέτρεπε `sups_3`, το plugin ζητούσε `addsynapse_search_3`, και * το Meilisearch απαντούσε ότι δεν επιτρέπεται. Καμία αναζήτηση δεν θα * δούλευε σε κανέναν πελάτη. */ if ( isset( $response['index'] ) && is_string( $response['index'] ) && 1 === preg_match( '/^[A-Za-z0-9_-]{1,100}$/', $response['index'] ) ) { Addsynapse_Search_Settings::set( 'index_uid', $response['index'] ); } } /** * Έγκυρο endpoint υπηρεσίας = https, εγκεκριμένος host (βλ. * Addsynapse_Search_Settings::is_allowed_service_url() — allowlist, audit F07). * * @param mixed $value Η τιμή από την απάντηση του server. * @return bool */ private static function is_valid_api_base( $value ) { return Addsynapse_Search_Settings::is_allowed_service_url( $value ); } /** * Ασφαλές, σύντομο κείμενο μιας άκυρης τιμής για log — ΠΟΤΕ ευαίσθητα * δεδομένα εδώ (το api_base δεν είναι μυστικό, σε αντίθεση με το * license key, αλλά κρατάμε το ίδιο προσεκτικό ύφος/όριο μήκους). * * @param mixed $value Η ακατέργαστη τιμή. * @return string */ private static function describe_for_log( $value ) { if ( ! is_string( $value ) ) { return gettype( $value ); } return substr( $value, 0, 100 ); } /** * Καταγραφή μέσω addsynapse_search_log() — ίδιο μοτίβο με * Addsynapse_Search_Indexer::log(). * * @param string $message Το μήνυμα. * @return void */ private static function log( $message ) { addsynapse_search_log( 'License', (string) $message ); } /** * Αποθηκεύει το token σε transient, με TTL ΑΥΣΤΗΡΑ μικρότερο από την * πραγματική υπολειπόμενη ζωή του. * * 🔴 audit F05: πριν ήταν max(60, ttl - 3600) — token με ttl 30s έμενε * στην cache 60s, δηλαδή σερβιριζόταν ΛΗΓΜΕΝΟ για 30s (και για πάντα σε * full-page cache). Τώρα: περιθώριο 10% της ζωής (60s ≤ περιθώριο ≤ 1h), * και token με ζωή < MIN_CACHEABLE_TOKEN_LIFETIME ή άγνωστη ΔΕΝ μπαίνει * καθόλου σε cache (χρησιμοποιείται μόνο στο τρέχον αίτημα). * * @param array $response Απάντηση API με `token` και `ttl` (ή JWT `exp`). * @return void */ private static function store_token_from_response( $response ) { delete_transient( self::TRANSIENT_TOKEN ); if ( ! is_array( $response ) || ! self::valid_token( isset( $response['token'] ) ? $response['token'] : null ) ) { return; } $remaining = self::token_remaining_lifetime( $response ); if ( $remaining < self::MIN_CACHEABLE_TOKEN_LIFETIME ) { self::log( 'tenant token με ζωή ' . $remaining . 's — δεν μπαίνει σε cache.' ); return; } $margin = (int) min( HOUR_IN_SECONDS, max( MINUTE_IN_SECONDS, (int) floor( $remaining / 10 ) ) ); $safe_ttl = $remaining - $margin; // 🔴 audit F01: η εγγραφή ζει ως (σχεδόν) την ΠΡΑΓΜΑΤΙΚΗ λήξη ώστε να // υπάρχει εφεδρικό σε προσωρινή βλάβη· η ανανέωση όμως ξεκινά στο // `refresh_after` (με το περιθώριο), και το cached_record() δεν σερβίρει // ποτέ token με ζωή < MIN_SERVE_LIFETIME. set_transient( self::TRANSIENT_TOKEN, array( 'token' => (string) $response['token'], 'expires_at' => time() + $remaining, 'refresh_after' => time() + $safe_ttl, ), $remaining - self::MIN_SERVE_LIFETIME ); } /** * Αποθηκεύει την κατάσταση συνδρομής (για admin notices κλπ). * * @param array $status Δεδομένα κατάστασης. * @return void */ private static function store_status( array $status ) { set_transient( self::TRANSIENT_STATUS, $status, DAY_IN_SECONDS ); } /** * Συγχρονίζει το τοπικό `variation_indexing` με ό,τι μόλις έστειλε ο * server (activate() ΕΔΩ, ή Addsynapse_Search_Analytics::get_stats() στο άλλο άκρο * του polling -- ΙΔΙΟ helper, καλείται και από τα δύο σημεία). Read-only * consumer, βλ. CLAUDE.md task-brief §1: το flag το ελέγχει ΜΟΝΟ το * εσωτερικό admin (backend ShopController::toggleVariationIndexing()). * * 🔴 ΚΡΙΣΙΜΟ (task-brief §4): αν η τιμή ΑΛΛΑΞΕ σε σχέση με ό,τι είχαμε * τοπικά, ο server έχει ΗΔΗ αδειάσει τον δείκτη (βλ. backend * ShopController::toggleVariationIndexing() -> Meili::deleteAllDocuments()). * Χρειάζεται ΠΛΗΡΗΣ επαναδεικτοδότηση εδώ για να ξαναγεμίσει με το ΣΩΣΤΟ * σχήμα εγγράφων -- αλλιώς ο πελάτης μένει με άδεια/ημιτελή αναζήτηση * μέχρι να το προσέξει κάποιος χειροκίνητα. * * @param bool $new_value Η τιμή που μόλις έστειλε ο server. * @return void */ public static function sync_variation_indexing_flag( $new_value ) { $new_value = (bool) $new_value; $old_value = (bool) Addsynapse_Search_Settings::get( 'variation_indexing', false ); Addsynapse_Search_Settings::set( 'variation_indexing', $new_value ); if ( $new_value === $old_value ) { return; } if ( class_exists( 'Addsynapse_Search_Indexer' ) && method_exists( 'Addsynapse_Search_Indexer', 'start_reindex_all' ) ) { Addsynapse_Search_Indexer::start_reindex_all(); } } /** * Το domain του τρέχοντος site, όπως το βλέπει ο επισκέπτης. * * @return string */ private static function current_domain() { $host = wp_parse_url( home_url(), PHP_URL_HOST ); return is_string( $host ) ? $host : ''; } }