» (Free ενεργοποιείται αυτόματα). * 3. Callback = η σελίδα ρυθμίσεων (admin → απαιτεί login + ίδιο χρήστη): code + verifier ανταλλάσσονται * server-to-server με site key (`pk_…`) + signing secret (αποθηκεύεται ΚΡΥΠΤΟΓΡΑΦΗΜΕΝΟ). * 0.5.1: η πύλη ανοίγει σε ΝΕΑ καρτέλα· η αρχική κάνει polling (admin-ajax → POST {hub}/api/v1/connect/poll * {state, code_verifier}) ως δίχτυ ασφαλείας. Callback και poll μοιράζονται το finish() (lock + idempotent: * όποιος φτάσει πρώτος). Hub χωρίς /poll (404/405) → μόνο callback. Πρωτόκολλο: docs/CONNECT-PROTOCOL.md. * 4. Υπογεγραμμένο POST /api/v1/search/status (απάντηση ΥΠΟΓΕΓΡΑΜΜΕΝΗ — επαληθεύεται, αλλιώς απορρίπτεται): * πακέτο, όρια, χρέωση, routing (api_base + search_endpoint = οι hosts που χρησιμοποιεί μετά το plugin). * 5. POST {api_base}/v1/connect/link στον κόμβο αναζήτησης: proof = ένα ΔΕΥΤΕΡΟ υπογεγραμμένο status που ο * κόμβος προωθεί στον hub (ο κόμβος δεν ξέρει το secret) → license key κόμβου + δείκτης (παλιό κλειδί = * μετάπτωση ΙΔΙΟΥ δείκτη). Από εκεί και πέρα ingest/token/stats όπως πάντα. * * Προβλήματα πληρωμής δεν κόβουν ποτέ την αναζήτηση (ο hub δίνει Free/free_fallback)· αποτυχία του hub = * κρατάμε το τελευταίο γνωστό. Καμία κλήση δικτύου σε hooks του frontend — μόνο admin/cron. */ class Addsynapse_Search_Hub { /** Προεπιλεγμένος hub (API, όχι η πύλη). Αλλάζει μόνο με τη σταθερά ADDSYNAPSE_SEARCH_HUB_URL. */ const DEFAULT_HUB_URL = 'https://api.addsynapse.com'; const PATH_INFO = '/api/v1/connect/info'; const PATH_TOKEN = '/api/v1/connect/token'; const PATH_DISCONNECT = '/api/v1/connect/disconnect'; const PATH_STATUS = '/api/v1/search/status'; const PATH_POLL = '/api/v1/connect/poll'; /** Option: hub_url, site_key, secret (κρυπτογραφημένο), portal_url, connected_at, connected_by. */ const OPTION = 'addsynapse_search_hub'; /** Option: τελευταία ΕΠΑΛΗΘΕΥΜΕΝΗ απάντηση status (+ checked_at, error) — fail-open όταν ο hub δεν απαντά. */ const STATUS_OPTION = 'addsynapse_search_hub_status'; /** Transient prefix της εκκρεμούς ροής Connect (ανά state). */ const STATE_PREFIX = 'addsynapse_search_connect_'; /** Transient: τελευταία προσπάθεια αυτόματου link στον κόμβο (όχι πάνω από μία/ώρα). */ const RELINK_GUARD = 'addsynapse_search_relink_guard'; /** * Ζωή του state/verifier (callback ΚΑΙ polling): ο hub v2 κρατά το αίτημα 24 ώρες (`connect_intents`) — εγγραφή, * επιβεβαίωση email σε άλλη συσκευή, 2FA (salesagent-platform/docs/search-plugin-handoff.md §3a). Το 0.5.0 είχε 15′. */ const STATE_TTL = DAY_IN_SECONDS; /** Transient ανά διαχειριστή: η εκκρεμής ροή του (state + URL πύλης) — polling + επαναχρησιμοποίηση στο 2ο κλικ. */ const PENDING_PREFIX = 'addsynapse_search_connect_pending_'; /** Transient: ο hub δεν έχει /connect/poll (404/405) → μόνο callback, ξαναδοκιμή μετά από 12 ώρες. */ const NOPOLL = 'addsynapse_search_connect_nopoll'; /** Γραμμή wp_options (ατομικό lock ανά state): callback και poll δεν ανταλλάσσουν ποτέ ταυτόχρονα. */ const LOCK_PREFIX = 'addsynapse_search_connect_lock_'; /** Timeout ανυπόγραφων κλήσεων προς τον hub (info/token/poll) — 8″, όχι 20″ (ο χρήστης περιμένει). */ const HTTP_TIMEOUT = 8; const CRON_HOOK = 'addsynapse_search_hub_refresh'; const ACTION_START = 'addsynapse_search_connect_start'; const ACTION_DISCONNECT = 'addsynapse_search_disconnect'; const ACTION_REFRESH = 'addsynapse_search_hub_refresh_now'; /** AJAX (σελίδα ρυθμίσεων): άνοιγμα σε νέα καρτέλα, polling, ακύρωση — κοινό nonce. */ const ACTION_BEGIN = 'addsynapse_search_connect_begin'; const ACTION_POLL = 'addsynapse_search_connect_poll'; const ACTION_CANCEL = 'addsynapse_search_connect_cancel'; const AJAX_NONCE = 'addsynapse_search_connect_ajax'; const SITE_KEY_PATTERN = '/^pk_[A-Za-z0-9]{8,64}$/D'; const SECRET_PATTERN = '/^[A-Za-z0-9_-]{16,200}$/D'; const STATE_PATTERN = '/^[A-Za-z0-9_-]{32,128}$/D'; const CODE_PATTERN = '/^ac_[A-Za-z0-9]{40}$/D'; /** * Hooks που χρειάζονται σε ΚΑΘΕ αίτημα (cron). Τα admin hooks: register_admin_hooks(). * * @return void */ public static function init() { add_action( self::CRON_HOOK, array( __CLASS__, 'cron_refresh' ) ); // Συνδεδεμένο αλλά χωρίς προγραμματισμό (π.χ. μετά από απενεργοποίηση/ενεργοποίηση) → ξανά. if ( is_admin() && self::is_connected() ) { self::schedule(); } } /** * Admin hooks (admin-post + callback). * * @return void */ public static function register_admin_hooks() { add_action( 'admin_post_' . self::ACTION_START, array( __CLASS__, 'handle_start' ) ); add_action( 'admin_post_' . self::ACTION_DISCONNECT, array( __CLASS__, 'handle_disconnect' ) ); add_action( 'admin_post_' . self::ACTION_REFRESH, array( __CLASS__, 'handle_refresh' ) ); add_action( 'admin_init', array( __CLASS__, 'maybe_callback' ) ); add_action( 'wp_ajax_' . self::ACTION_BEGIN, array( __CLASS__, 'ajax_begin' ) ); add_action( 'wp_ajax_' . self::ACTION_POLL, array( __CLASS__, 'ajax_poll' ) ); add_action( 'wp_ajax_' . self::ACTION_CANCEL, array( __CLASS__, 'ajax_cancel' ) ); } /* ----------------------------------------------------------------- * Κατάσταση σύνδεσης * --------------------------------------------------------------- */ /** * Ποιος συνδέει/αποσυνδέει: διαχειριστής (manage_options) που βλέπει και τη σελίδα (manage_woocommerce). * * @return bool */ public static function allowed() { return current_user_can( 'manage_options' ) && current_user_can( 'manage_woocommerce' ); } /** * @return array */ private static function record() { $record = get_option( self::OPTION, array() ); return is_array( $record ) ? $record : array(); } /** * Συνδεδεμένο με AddSynapse (site key + secret αποθηκευμένα). * * @return bool */ public static function is_connected() { return '' !== self::site_key() && '' !== self::secret(); } /** * @return string Το δημόσιο site key (pk_…) ή ''. */ public static function site_key() { $record = self::record(); $key = isset( $record['site_key'] ) ? (string) $record['site_key'] : ''; return preg_match( self::SITE_KEY_PATTERN, $key ) ? $key : ''; } /** * @return string Το signing secret (αποκρυπτογραφημένο) ή ''. */ private static function secret() { $record = self::record(); if ( empty( $record['secret'] ) ) { return ''; } $plain = self::decrypt( (string) $record['secret'] ); return preg_match( self::SECRET_PATTERN, $plain ) ? $plain : ''; } /** * Ο hub με τον οποίο μιλάμε: της σύνδεσης (αν επιτρέπεται), αλλιώς η σταθερά ADDSYNAPSE_SEARCH_HUB_URL, * αλλιώς ο προεπιλεγμένος. '' = η ρύθμιση δεν επιτρέπεται. * * @return string */ public static function hub_url() { $record = self::record(); if ( ! empty( $record['hub_url'] ) ) { return self::url_allowed( (string) $record['hub_url'] ) ? untrailingslashit( (string) $record['hub_url'] ) : ''; } if ( defined( 'ADDSYNAPSE_SEARCH_HUB_URL' ) && is_string( ADDSYNAPSE_SEARCH_HUB_URL ) && '' !== ADDSYNAPSE_SEARCH_HUB_URL ) { return self::url_allowed( ADDSYNAPSE_SEARCH_HUB_URL ) ? untrailingslashit( ADDSYNAPSE_SEARCH_HUB_URL ) : ''; } return self::DEFAULT_HUB_URL; } /** * Επιτρεπτός hub: https σε host της allowlist (Addsynapse_Search_Settings::is_allowed_service_url()), * ή — ΜΟΝΟ σε WP_ENVIRONMENT_TYPE local/development — http προς εσωτερικό host ανάπτυξης * (localhost, ddev-*, *.ddev.site). * * @param string $url URL. * @return bool */ public static function url_allowed( $url ) { if ( Addsynapse_Search_Settings::is_allowed_service_url( $url ) ) { return true; } $env = function_exists( 'wp_get_environment_type' ) ? wp_get_environment_type() : 'production'; if ( ! in_array( $env, array( 'local', 'development' ), true ) ) { return false; } $parts = wp_parse_url( (string) $url ); if ( ! is_array( $parts ) || empty( $parts['host'] ) || isset( $parts['user'] ) || isset( $parts['pass'] ) || ! in_array( strtolower( (string) ( isset( $parts['scheme'] ) ? $parts['scheme'] : '' ) ), array( 'http', 'https' ), true ) ) { return false; } $host = strtolower( (string) $parts['host'] ); return 'localhost' === $host || 0 === strpos( $host, 'ddev-' ) || '.ddev.site' === substr( $host, -10 ); } /** * Τελευταία επαληθευμένη κατάσταση από τον hub (ή κενός πίνακας). * * @return array */ public static function status() { $status = get_option( self::STATUS_OPTION, array() ); return is_array( $status ) ? $status : array(); } /** * Η απάντηση status έχει routing που ισχύει → τα endpoints του κόμβου τα ορίζει ο hub * (το token refresh του κόμβου δεν τα αλλάζει — βλ. Addsynapse_Search_License). * * @return bool */ public static function owns_routing() { if ( ! self::is_connected() ) { return false; } $status = self::status(); return ! empty( $status['routing']['api_base'] ); } /** * URL πύλης για «Manage / Upgrade» (manage_url του site), αλλιώς η πύλη της σύνδεσης, αλλιώς ''. * * @return string */ public static function manage_url() { $status = self::status(); foreach ( array( isset( $status['portal']['manage_url'] ) ? $status['portal']['manage_url'] : '', isset( self::record()['portal_url'] ) ? self::record()['portal_url'] : '' ) as $candidate ) { if ( is_string( $candidate ) && '' !== $candidate && self::portal_url_allowed( $candidate ) ) { return $candidate; } } return ''; } /** * Σύνδεσμοι πύλης: https σε host της allowlist (ή dev host σε τοπικό περιβάλλον). * * @param string $url URL. * @return bool */ private static function portal_url_allowed( $url ) { return self::url_allowed( $url ) && 0 === strpos( strtolower( $url ), 'http' ); } /* ----------------------------------------------------------------- * One-click connect * --------------------------------------------------------------- */ /** * URL της σελίδας ρυθμίσεων (και callback της ροής). * * @param array $args Query args. * @return string */ public static function page_url( array $args = array() ) { return add_query_arg( array_merge( array( 'page' => Addsynapse_Search_Admin::MENU_SLUG, 'tab' => 'settings', ), $args ), admin_url( 'admin.php' ) ); } /** * Βήμα 1 (admin-post). * * @return void */ public static function handle_start() { if ( ! self::allowed() ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'addsynapse-search-filter' ), 403 ); } check_admin_referer( self::ACTION_START ); $result = self::start( get_current_user_id() ); if ( is_wp_error( $result ) ) { wp_safe_redirect( self::page_url( array( 'addsynapse_search_connect_error' => $result->get_error_code() ) ) ); exit; } // Η πύλη του hub (connect_url από /connect/info, ελεγμένο με portal_url_allowed()) — ρητή άδεια ΜΟΝΟ γι' αυτόν τον host. $host = (string) wp_parse_url( $result, PHP_URL_HOST ); add_filter( 'allowed_redirect_hosts', static function ( $hosts ) use ( $host ) { $hosts[] = $host; return $hosts; } ); wp_safe_redirect( $result ); exit; } /** * Έλεγχος ΠΡΙΝ φύγει ο browser, για διευθύνσεις που ο hub ΣΙΓΟΥΡΑ απορρίπτει. * * Από 0.6.3 ο hub δέχεται «development sites»: τοπικές διευθύνσεις (βλ. is_development_address()), * με οποιαδήποτε θύρα, http ή https → συνδέονται ως development site (Free, για δοκιμές). Αυτές ΔΕΝ * μπλοκάρονται εδώ· η σελίδα δείχνει απλώς ενημερωτική σημείωση. Μπλοκάρονται μόνο δημόσιες * διευθύνσεις που δεν είναι καθαρό https χωρίς θύρα, IP εκτός ιδιωτικών περιοχών και ψευδο-TLD * που δεν είναι στη λίστα του hub. Παλιότερος hub που απορρίπτει τοπική διεύθυνση απαντά * `invalid_shop_url` → φιλικό μήνυμα (error_message()). * * Φίλτρο `addsynapse_search_connect_allow_local` (συμβατότητα): true = κανένας έλεγχος * (προεπιλογή true σε WP_ENVIRONMENT_TYPE local/development). * * @param string|null $url Διεύθυνση (προεπιλογή home_url('/')). * Από 0.6.4, ΠΡΙΝ από το φίλτρο (ο hub τα απορρίπτει παντού): διεύθυνση με user:pass → shop_has_credentials· * τοπική διεύθυνση όπου η διαχείριση (callback) είναι σε άλλο host:port από τη διεύθυνση του site * (WordPress Address ≠ Site Address) → shop_callback_mismatch (§3b: callback = ίδιο host:port). * * @param string|null $url Διεύθυνση (προεπιλογή home_url('/')). * @param string|null $callback Διεύθυνση επιστροφής (προεπιλογή admin_url() — μόνο όταν και το $url είναι η προεπιλογή). * @return string '' = εντάξει, αλλιώς κωδικός: shop_has_credentials | shop_callback_mismatch | shop_ip_host | * shop_local_host | shop_has_port | shop_not_https. */ public static function preflight( $url = null, $callback = null ) { if ( null === $url && null === $callback ) { $callback = admin_url(); } $url = null === $url ? home_url( '/' ) : (string) $url; $parts = wp_parse_url( $url ); if ( is_array( $parts ) && ( isset( $parts['user'] ) || isset( $parts['pass'] ) ) ) { return 'shop_has_credentials'; } if ( null !== $callback && self::is_development_address( $url ) && self::host_port( $url ) !== self::host_port( (string) $callback ) ) { return 'shop_callback_mismatch'; } $env = function_exists( 'wp_get_environment_type' ) ? wp_get_environment_type() : 'production'; $allow = (bool) apply_filters( 'addsynapse_search_connect_allow_local', in_array( $env, array( 'local', 'development' ), true ) ); if ( $allow ) { return ''; } if ( self::is_development_address( $url ) ) { return ''; } $host = is_array( $parts ) && ! empty( $parts['host'] ) ? strtolower( trim( (string) $parts['host'], '[]' ) ) : ''; $scheme = is_array( $parts ) && isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : ''; if ( '' === $host ) { return 'shop_local_host'; } if ( false !== filter_var( $host, FILTER_VALIDATE_IP ) ) { return 'shop_ip_host'; } if ( false === strpos( $host, '.' ) ) { return 'shop_local_host'; } foreach ( array( '.example', '.invalid', '.internal', '.lan', '.home.arpa' ) as $suffix ) { if ( substr( $host, -strlen( $suffix ) ) === $suffix ) { return 'shop_local_host'; } } if ( isset( $parts['port'] ) && 443 !== (int) $parts['port'] ) { return 'shop_has_port'; } if ( 'https' !== $scheme ) { return 'shop_not_https'; } return ''; } /** * «host[:port]» με πεζά (θύρα μόνο αν δηλώνεται ρητά) — σύγκριση shop_url ↔ callback (§3b). * * @param string $url URL. * @return string */ private static function host_port( $url ) { $parts = wp_parse_url( (string) $url ); if ( ! is_array( $parts ) || empty( $parts['host'] ) ) { return ''; } return strtolower( trim( (string) $parts['host'], '[]' ) ) . ( isset( $parts['port'] ) ? ':' . (int) $parts['port'] : '' ); } /** * Σελίδα «Sites» της πύλης AddSynapse (για όρια sites): api. → app./sites, αλλιώς η προεπιλογή. * * @return string */ public static function portal_sites_url() { $parts = wp_parse_url( self::hub_url() ); if ( is_array( $parts ) && ! empty( $parts['host'] ) && 0 === strpos( strtolower( (string) $parts['host'] ), 'api.' ) ) { $url = ( isset( $parts['scheme'] ) ? $parts['scheme'] : 'https' ) . '://app.' . substr( (string) $parts['host'], 4 ) . ( isset( $parts['port'] ) ? ':' . (int) $parts['port'] : '' ) . '/sites'; if ( self::portal_url_allowed( $url ) ) { return $url; } } return 'https://app.addsynapse.com/sites'; } /** * Τοπική/ανάπτυξης διεύθυνση που ο hub συνδέει ως «development site» (Free, για δοκιμές): * localhost, 127.0.0.0/8, ::1, *.local (και *.wp.local), *.test, *.localhost, *.ddev.site, *.lndo.site, * 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 — με οποιαδήποτε θύρα, http ή https. * * @param string|null $url Διεύθυνση (προεπιλογή home_url('/')). * @return bool */ public static function is_development_address( $url = null ) { $parts = wp_parse_url( null === $url ? home_url( '/' ) : (string) $url ); $host = is_array( $parts ) && ! empty( $parts['host'] ) ? strtolower( trim( (string) $parts['host'], '[]' ) ) : ''; if ( '' === $host ) { return false; } if ( 'localhost' === $host || '::1' === $host ) { return true; } if ( false !== filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { $octets = array_map( 'intval', explode( '.', $host ) ); return 127 === $octets[0] || 10 === $octets[0] || ( 172 === $octets[0] && $octets[1] >= 16 && $octets[1] <= 31 ) || ( 192 === $octets[0] && 168 === $octets[1] ); } foreach ( array( '.local', '.test', '.localhost', '.ddev.site', '.lndo.site' ) as $suffix ) { if ( substr( $host, -strlen( $suffix ) ) === $suffix ) { return true; } } return false; } /** * Ο hub έχει σημειώσει αυτό το site ως «development site» (`environment` στο status ή στην απάντηση * token). Τέτοια sites είναι μόνο Free — καμία υπόσχεση αναβάθμισης στο UI. * * @return bool */ public static function is_development_site() { if ( ! self::is_connected() ) { return false; } $status = self::status(); if ( isset( $status['environment'] ) && '' !== $status['environment'] ) { return 'development' === $status['environment']; } $record = self::record(); return isset( $record['environment'] ) && 'development' === $record['environment']; } /** * Κανονικοποίηση του `environment` του hub: development | production | '' (άγνωστο/παλιός hub). * * @param mixed $value Τιμή από τον hub. * @return string */ private static function sanitize_environment( $value ) { return is_string( $value ) && in_array( $value, array( 'development', 'production' ), true ) ? $value : ''; } /** * Φτιάχνει state + PKCE και επιστρέφει το URL της πύλης. Αν ο ΙΔΙΟΣ διαχειριστής έχει ήδη εκκρεμή ροή, * επιστρέφει το ΙΔΙΟ URL (2ο κλικ = ίδιο αίτημα, όχι νέο state που θα ακύρωνε το πρώτο). * * @param int $user_id Ο διαχειριστής που ξεκινά (και πρέπει να ολοκληρώσει). * @return string|WP_Error */ public static function start( $user_id ) { $blocked = self::preflight(); if ( '' !== $blocked ) { return new WP_Error( $blocked ); } $pending = self::pending( $user_id ); if ( null !== $pending ) { return $pending['url']; } $hub = self::hub_url(); if ( '' === $hub ) { return new WP_Error( 'invalid_url' ); } $info = self::post_json( $hub . self::PATH_INFO, array() ); if ( is_wp_error( $info ) ) { return $info; } $connect_url = isset( $info['connect_url'] ) && is_string( $info['connect_url'] ) ? $info['connect_url'] : ''; if ( '' === $connect_url || ! self::portal_url_allowed( $connect_url ) ) { return new WP_Error( 'invalid_response' ); } $verifier = self::random( 64 ); $state = self::random( 43 ); $callback = self::page_url( array( 'addsynapse_search_connect' => 'callback' ) ); $expires = time() + self::STATE_TTL; set_transient( self::state_key( $state ), array( 'status' => 'pending', 'verifier' => $verifier, 'user' => (int) $user_id, 'hub_url' => $hub, 'callback' => $callback, 'portal' => isset( $info['portal_url'] ) && is_string( $info['portal_url'] ) ? $info['portal_url'] : '', 'expires' => $expires, ), self::STATE_TTL ); $name = wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ); $name = function_exists( 'mb_substr' ) ? mb_substr( $name, 0, 120 ) : substr( $name, 0, 120 ); $user = get_userdata( (int) $user_id ); $query = array( 'platform' => 'woocommerce', 'kind' => 'search', 'shop_url' => home_url( '/' ), 'shop_name' => $name, 'connector_version' => 'addsynapse-search/' . ADDSYNAPSE_SEARCH_VERSION, 'callback' => $callback, 'state' => $state, 'code_challenge' => self::challenge( $verifier ), 'code_challenge_method' => 'S256', // Προσυμπλήρωση εγγραφής/σύνδεσης στην πύλη (ο hub μπορεί να τα αγνοήσει· ΠΟΤΕ απόδειξη ταυτότητας). 'email' => $user && is_email( $user->user_email ) ? (string) $user->user_email : '', 'site_name' => $name, ); $url = $connect_url . ( false !== strpos( $connect_url, '?' ) ? '&' : '?' ) . http_build_query( array_filter( $query, 'strlen' ), '', '&', PHP_QUERY_RFC3986 ); set_transient( self::PENDING_PREFIX . (int) $user_id, array( 'state' => $state, 'url' => $url, 'started' => time(), 'expires' => $expires, 'last_poll' => 0, ), self::STATE_TTL ); return $url; } /** * Η εκκρεμής ροή του διαχειριστή, αν το state της είναι ακόμα σε αναμονή. * * @param int $user_id Χρήστης. * @return array|null {state, url, started, expires, last_poll} */ public static function pending( $user_id ) { $pointer = get_transient( self::PENDING_PREFIX . (int) $user_id ); if ( ! is_array( $pointer ) || empty( $pointer['state'] ) || empty( $pointer['url'] ) || ! preg_match( self::STATE_PATTERN, (string) $pointer['state'] ) ) { return null; } $record = self::read_state( (string) $pointer['state'] ); if ( ! is_array( $record ) || 'pending' !== self::record_status( $record ) || (int) $record['user'] !== (int) $user_id ) { return null; } return $pointer; } /** * Βήμα 3: η πύλη γύρισε τον browser εδώ (?addsynapse_search_connect=callback&code&state | &error&state). * * @return void */ public static function maybe_callback() { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- το state μίας χρήσης δεμένο στον χρήστη ΕΙΝΑΙ η προστασία CSRF (OAuth callback). if ( ! isset( $_GET['addsynapse_search_connect'], $_GET['page'] ) || 'callback' !== $_GET['addsynapse_search_connect'] || Addsynapse_Search_Admin::MENU_SLUG !== sanitize_key( wp_unslash( $_GET['page'] ) ) ) { return; } if ( ! self::allowed() ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'addsynapse-search-filter' ), 403 ); } $state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : ''; $code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : ''; $error = isset( $_GET['error'] ) ? sanitize_key( wp_unslash( $_GET['error'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended if ( '' !== $error && '' === $code ) { $error = self::known_error( $error ) ? $error : 'not_completed'; $result = new WP_Error( $error ); self::fail_state( $state, get_current_user_id(), $error ); } else { $result = self::finish( $state, $code, get_current_user_id() ); // Το poll της αρχικής καρτέλας ανταλλάσσει ΤΩΡΑ τον ίδιο κωδικό → περίμενε το αποτέλεσμά του. if ( is_wp_error( $result ) && 'connect_in_progress' === $result->get_error_code() ) { $result = self::wait_for_result( $state, get_current_user_id() ); } } wp_safe_redirect( self::page_url( is_wp_error( $result ) ? array( 'addsynapse_search_connect_error' => $result->get_error_code() ) : array( 'addsynapse_search_notice' => $result ) ) ); exit; } /** * Ανταλλαγή code → κλειδιά, αποθήκευση, status, link στον κόμβο. * * ΚΟΙΝΗ για callback και poll, ΙΔΕΜΠΟΤΕΝΤΗ: ατομικό lock ανά state· όποιος φτάσει πρώτος ανταλλάσσει, ο * δεύτερος βρίσκει `done` και παίρνει το ίδιο αποτέλεσμα χωρίς καμία κλήση (no-op). Αποτυχία = το state * «καίγεται» (`failed`) — εκτός από προσωρινές βλάβες δικτύου, όπου μένει `pending` για νέα προσπάθεια. * * @param string $state State. * @param string $code Κωδικός μίας χρήσης. * @param int $user_id Ο χρήστης που ολοκληρώνει. * @return string|WP_Error Κωδικός notice ('hub_connected' / 'hub_connected_pending') ή σφάλμα * ('connect_in_progress' = το άλλο μονοπάτι ανταλλάσσει αυτή τη στιγμή). */ public static function finish( $state, $code, $user_id ) { if ( ! preg_match( self::STATE_PATTERN, (string) $state ) ) { return new WP_Error( 'invalid_state' ); } $lock_name = self::LOCK_PREFIX . substr( hash( 'sha256', (string) $state ), 0, 32 ); $lock = Addsynapse_Search_License::acquire_lock( $lock_name, 120 ); if ( false === $lock ) { return new WP_Error( 'connect_in_progress' ); } try { return self::finish_locked( (string) $state, (string) $code, (int) $user_id ); } finally { Addsynapse_Search_License::release_lock( $lock_name, $lock ); } } /** * finish() κάτω από το lock. * * @param string $state State. * @param string $code Κωδικός. * @param int $user_id Χρήστης. * @return string|WP_Error */ private static function finish_locked( $state, $code, $user_id ) { $record = self::read_state( $state ); if ( ! is_array( $record ) ) { return new WP_Error( 'invalid_state' ); } $status = self::record_status( $record ); if ( 'done' === $status ) { // Το άλλο μονοπάτι (callback ή poll) ολοκλήρωσε ήδη: ίδιο αποτέλεσμα, καμία κλήση. return (int) $record['user'] === $user_id ? (string) $record['result'] : new WP_Error( 'wrong_user' ); } if ( 'pending' !== $status || empty( $record['verifier'] ) ) { return new WP_Error( 'invalid_state' ); } if ( (int) $record['user'] !== $user_id ) { self::consume_state( $state, $record, 'failed', 'wrong_user' ); return new WP_Error( 'wrong_user' ); } if ( ! preg_match( self::CODE_PATTERN, $code ) ) { self::consume_state( $state, $record, 'failed', 'invalid_code' ); return new WP_Error( 'invalid_code' ); } $hub = (string) $record['hub_url']; $data = self::post_json( $hub . self::PATH_TOKEN, array( 'code' => $code, 'code_verifier' => (string) $record['verifier'], 'callback' => (string) $record['callback'], 'home_url' => home_url(), ) ); // Ο κωδικός ανταλλάσσεται ΜΙΑ φορά (callback/poll/token)· invalid_grant ενώ έχουμε ήδη κλειδιά = το άλλο // μονοπάτι κέρδισε (π.χ. χωρίς κοινή εγγραφή state) → «ήδη συνδεδεμένο», όχι σφάλμα (handoff §3a). if ( is_wp_error( $data ) && 'invalid_grant' === $data->get_error_code() && self::is_connected() ) { delete_transient( self::PENDING_PREFIX . $user_id ); self::consume_state( $state, $record, 'done', 'hub_connected' ); return 'hub_connected'; } if ( is_wp_error( $data ) ) { // Προσωρινή βλάβη: ο κωδικός μάλλον δεν καταναλώθηκε → το state μένει για νέα προσπάθεια (poll/callback). if ( ! self::is_transient_error( $data->get_error_code() ) ) { self::consume_state( $state, $record, 'failed', $data->get_error_code() ); } return $data; } $site_key = isset( $data['site_key'] ) ? (string) $data['site_key'] : ''; $secret = isset( $data['signing_secret'] ) ? (string) $data['signing_secret'] : ''; if ( ! preg_match( self::SITE_KEY_PATTERN, $site_key ) || ! preg_match( self::SECRET_PATTERN, $secret ) ) { self::consume_state( $state, $record, 'failed', 'invalid_response' ); return new WP_Error( 'invalid_response' ); } $portal = isset( $data['portal_url'] ) && is_string( $data['portal_url'] ) ? $data['portal_url'] : (string) $record['portal']; update_option( self::OPTION, array( 'hub_url' => $hub, 'site_key' => $site_key, 'secret' => self::encrypt( $secret ), 'portal_url' => self::portal_url_allowed( $portal ) ? esc_url_raw( $portal ) : '', 'connected_at' => time(), 'connected_by' => $user_id, 'environment' => self::sanitize_environment( isset( $data['environment'] ) ? $data['environment'] : ( isset( $data['site']['environment'] ) ? $data['site']['environment'] : '' ) ), ), false ); delete_option( self::STATUS_OPTION ); delete_transient( self::RELINK_GUARD ); delete_transient( self::PENDING_PREFIX . $user_id ); // Τα κλειδιά αποθηκεύτηκαν: ό,τι κι αν γίνει παρακάτω, το state δεν ανταλλάσσεται ξανά. self::consume_state( $state, $record, 'done', 'hub_connected_pending' ); self::schedule(); $result = 'hub_connected_pending'; $status = self::refresh_status( true ); if ( ! is_wp_error( $status ) && ! empty( $status['entitlement']['entitled'] ) ) { $linked = self::link_node(); $result = is_wp_error( $linked ) ? 'hub_connected_pending' : 'hub_connected'; } self::consume_state( $state, $record, 'done', $result ); return $result; } /** * Callback σε σφάλμα/ακύρωση (?error=…): το εκκρεμές state «καίγεται» ώστε και το poll να το δει. * * @param string $state State. * @param int $user_id Χρήστης. * @param string $error Κωδικός. * @return void */ private static function fail_state( $state, $user_id, $error ) { if ( ! preg_match( self::STATE_PATTERN, (string) $state ) ) { return; } $record = self::read_state( $state ); if ( is_array( $record ) && 'pending' === self::record_status( $record ) && (int) $record['user'] === (int) $user_id ) { self::consume_state( $state, $record, 'failed', $error ); delete_transient( self::PENDING_PREFIX . (int) $user_id ); } } /** * Callback ενώ το poll ανταλλάσσει: περιμένει (έως ~20″) το αποτέλεσμα αντί για ψεύτικο σφάλμα. * * @param string $state State. * @param int $user_id Χρήστης. * @return string|WP_Error */ private static function wait_for_result( $state, $user_id ) { for ( $i = 0; $i < 20; $i++ ) { sleep( 1 ); $record = self::read_state( $state ); $status = is_array( $record ) ? self::record_status( $record ) : ''; if ( 'done' === $status ) { return (int) $record['user'] === (int) $user_id ? (string) $record['result'] : new WP_Error( 'wrong_user' ); } if ( 'pending' !== $status ) { return self::is_connected() ? 'hub_connected_pending' : new WP_Error( is_array( $record ) && ! empty( $record['error'] ) ? (string) $record['error'] : 'invalid_state' ); } } return self::is_connected() ? 'hub_connected_pending' : new WP_Error( 'connect_in_progress' ); } /* ----------------------------------------------------------------- * Polling (δίχτυ ασφαλείας του callback) — POST {hub}/api/v1/connect/poll * --------------------------------------------------------------- */ /** * Μία ερώτηση «τελείωσε η έγκριση;» για την εκκρεμή ροή του χρήστη. Ο verifier μένει ΕΔΩ (server-side)· * ο browser δεν τον βλέπει ποτέ. * * @param int $user_id Χρήστης. * @return array {status: none|pending|connected|denied|expired, poll?: bool, slow?: bool, result?: string, error?: string} */ public static function poll( $user_id ) { $user_id = (int) $user_id; if ( self::is_connected() ) { delete_transient( self::PENDING_PREFIX . $user_id ); return array( 'status' => 'connected', 'result' => 'hub_connected', ); } $pointer = get_transient( self::PENDING_PREFIX . $user_id ); if ( ! is_array( $pointer ) || empty( $pointer['state'] ) || ! preg_match( self::STATE_PATTERN, (string) $pointer['state'] ) ) { return array( 'status' => 'none' ); } $state = (string) $pointer['state']; $record = self::read_state( $state ); if ( ! is_array( $record ) ) { delete_transient( self::PENDING_PREFIX . $user_id ); return array( 'status' => 'expired' ); } $status = self::record_status( $record ); if ( 'done' === $status ) { delete_transient( self::PENDING_PREFIX . $user_id ); return array( 'status' => 'connected', 'result' => (string) $record['result'], ); } if ( 'pending' !== $status ) { delete_transient( self::PENDING_PREFIX . $user_id ); return array( 'status' => isset( $record['error'] ) && 'expired' === $record['error'] ? 'expired' : 'denied', 'error' => isset( $record['error'] ) && '' !== (string) $record['error'] ? (string) $record['error'] : 'invalid_state', ); } $pending = array( 'status' => 'pending', 'poll' => false === get_transient( self::NOPOLL ), 'interval' => 3, ); // Ο hub δεν υποστηρίζει polling, ή ρωτήσαμε πριν λίγο (πολλές καρτέλες) → μόνο τοπικός έλεγχος. if ( ! $pending['poll'] || time() - (int) $pointer['last_poll'] < 2 ) { return $pending; } $pointer['last_poll'] = time(); set_transient( self::PENDING_PREFIX . $user_id, $pointer, max( 60, (int) $pointer['expires'] - time() ) ); $response = wp_remote_post( (string) $record['hub_url'] . self::PATH_POLL, array( 'timeout' => self::HTTP_TIMEOUT, 'redirection' => 0, 'headers' => array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'User-Agent' => 'addsynapse-search/' . ADDSYNAPSE_SEARCH_VERSION, ), 'body' => (string) wp_json_encode( array( 'state' => $state, 'code_verifier' => (string) $record['verifier'], ) ), ) ); if ( is_wp_error( $response ) ) { return $pending; } $http = (int) wp_remote_retrieve_response_code( $response ); $data = json_decode( (string) wp_remote_retrieve_body( $response ), true ); $data = is_array( $data ) ? $data : array(); $hub = isset( $data['status'] ) && is_string( $data['status'] ) ? $data['status'] : ''; // Feature detection: ο hub δεν έχει ακόμα /poll (route 404/405 ΧΩΡΙΣ σώμα του πρωτοκόλλου) → μόνο callback. if ( ( 404 === $http || 405 === $http ) && '' === $hub ) { set_transient( self::NOPOLL, time(), 12 * HOUR_IN_SECONDS ); $pending['poll'] = false; return $pending; } if ( 410 === $http || 'expired' === $hub ) { // «used» = ο κωδικός ανταλλάχθηκε ήδη — από το callback μας (άλλο αίτημα) → συνδεδεμένο. if ( self::is_connected() ) { delete_transient( self::PENDING_PREFIX . $user_id ); return array( 'status' => 'connected', 'result' => 'hub_connected', ); } self::consume_state( $state, $record, 'failed', 'expired' ); delete_transient( self::PENDING_PREFIX . $user_id ); return array( 'status' => 'expired', 'error' => 'expired', ); } if ( 429 === $http ) { $pending['slow'] = true; return $pending; } // 400 invalid_grant (verifier ≠ challenge) / 422 invalid_request: οριστικό — σταματάμε. if ( ( 400 === $http || 422 === $http ) && 'invalid' === $hub ) { $error = isset( $data['error'] ) && is_string( $data['error'] ) && '' !== sanitize_key( $data['error'] ) ? sanitize_key( $data['error'] ) : 'invalid_grant'; self::consume_state( $state, $record, 'failed', $error ); delete_transient( self::PENDING_PREFIX . $user_id ); return array( 'status' => 'denied', 'error' => $error, ); } if ( 200 !== $http ) { return $pending; } if ( 'pending' === $hub && isset( $data['interval'] ) && is_numeric( $data['interval'] ) ) { $pending['interval'] = max( 2, min( 60, (int) $data['interval'] ) ); return $pending; } if ( 'approved' === $hub && isset( $data['code'] ) && is_string( $data['code'] ) && preg_match( self::CODE_PATTERN, $data['code'] ) ) { $result = self::finish( $state, $data['code'], $user_id ); if ( ! is_wp_error( $result ) ) { return array( 'status' => 'connected', 'result' => $result, ); } $code = $result->get_error_code(); if ( 'connect_in_progress' === $code || self::is_transient_error( $code ) ) { return $pending; } delete_transient( self::PENDING_PREFIX . $user_id ); return array( 'status' => 'denied', 'error' => $code, ); } if ( 'denied' === $hub ) { $error = isset( $data['error'] ) && is_string( $data['error'] ) ? sanitize_key( $data['error'] ) : ''; $error = self::known_error( $error ) ? $error : 'not_completed'; self::consume_state( $state, $record, 'failed', $error ); delete_transient( self::PENDING_PREFIX . $user_id ); return array( 'status' => 'denied', 'error' => $error, ); } return $pending; } /** * Ο χρήστης πάτησε «Cancel» στο πάνελ αναμονής: η ροή «καίγεται» (ένα μεταγενέστερο callback → invalid_state). * * @param int $user_id Χρήστης. * @return void */ public static function cancel( $user_id ) { $pointer = get_transient( self::PENDING_PREFIX . (int) $user_id ); if ( is_array( $pointer ) && ! empty( $pointer['state'] ) ) { self::fail_state( (string) $pointer['state'], $user_id, 'cancelled' ); } delete_transient( self::PENDING_PREFIX . (int) $user_id ); } /** * AJAX: ξεκίνα (ή ξαναχρησιμοποίησε) τη ροή — το JS ανοίγει το URL σε νέα καρτέλα. * * @return void */ public static function ajax_begin() { self::ajax_guard(); $url = self::start( get_current_user_id() ); if ( is_wp_error( $url ) ) { wp_send_json_error( array( 'code' => $url->get_error_code(), 'message' => self::error_message( $url->get_error_code() ), ) ); } $pending = self::pending( get_current_user_id() ); wp_send_json_success( array( 'url' => $url, 'ttl' => $pending ? max( 0, (int) $pending['expires'] - time() ) : self::STATE_TTL, 'poll' => false === get_transient( self::NOPOLL ), ) ); } /** * AJAX: polling της σελίδας ρυθμίσεων. * * @return void */ public static function ajax_poll() { self::ajax_guard(); $result = self::poll( get_current_user_id() ); if ( ! empty( $result['error'] ) ) { $result['message'] = self::error_message( $result['error'] ); } if ( 'connected' === $result['status'] ) { $result['redirect'] = self::page_url( array( 'addsynapse_search_notice' => isset( $result['result'] ) ? $result['result'] : 'hub_connected' ) ); } wp_send_json_success( $result ); } /** * AJAX: «Cancel». * * @return void */ public static function ajax_cancel() { self::ajax_guard(); self::cancel( get_current_user_id() ); wp_send_json_success( array( 'status' => 'none' ) ); } /** * Nonce + δικαίωμα (ίδιο με το Connect: manage_options + manage_woocommerce). * * @return void */ private static function ajax_guard() { if ( ! check_ajax_referer( self::AJAX_NONCE, 'nonce', false ) ) { wp_send_json_error( array( 'code' => 'bad_nonce' ), 403 ); } if ( ! self::allowed() ) { wp_send_json_error( array( 'code' => 'forbidden' ), 403 ); } } /** * Ρυθμίσεις του JS της σελίδας ρυθμίσεων (admin.js → addsynapseSearchAdmin.connect). Ποτέ verifier/secret. * * @return array */ public static function js_config() { $user = get_current_user_id(); $enabled = ! self::is_connected() && self::allowed() && '' === self::preflight(); $pending = $enabled ? self::pending( $user ) : null; // Αυτή η φόρτωση είναι η κατάληξη του callback (συνήθως στη νέα καρτέλα) → ειδοποίηση της αρχικής. $broadcast = null; // phpcs:disable WordPress.Security.NonceVerification.Recommended -- μόνο ανάγνωση κωδικού για εμφάνιση· κανένα side effect. if ( isset( $_GET['addsynapse_search_notice'] ) && in_array( sanitize_key( wp_unslash( $_GET['addsynapse_search_notice'] ) ), array( 'hub_connected', 'hub_connected_pending' ), true ) ) { $broadcast = array( 'status' => 'connected' ); } elseif ( isset( $_GET['addsynapse_search_connect_error'] ) ) { $broadcast = array( 'status' => 'error', 'message' => self::error_message( sanitize_key( wp_unslash( $_GET['addsynapse_search_connect_error'] ) ) ), ); } // phpcs:enable WordPress.Security.NonceVerification.Recommended return array( 'enabled' => $enabled, 'nonce' => $enabled ? wp_create_nonce( self::AJAX_NONCE ) : '', 'actions' => array( 'begin' => self::ACTION_BEGIN, 'poll' => self::ACTION_POLL, 'cancel' => self::ACTION_CANCEL, ), 'pending' => $pending ? array( 'url' => $pending['url'], 'ttl' => max( 0, (int) $pending['expires'] - time() ), ) : null, 'poll' => false === get_transient( self::NOPOLL ), 'settingsUrl' => self::page_url(), 'broadcast' => $broadcast, 'i18n' => array( 'opening' => __( 'Opening AddSynapse…', 'addsynapse-search-filter' ), 'waiting' => __( 'Finish connecting in the AddSynapse tab… (we\'ll detect it automatically)', 'addsynapse-search-filter' ), 'blocked' => __( 'Your browser blocked the new tab. Click "Didn\'t open? Open again" below.', 'addsynapse-search-filter' ), 'connected' => __( 'Connected to AddSynapse. Loading your plan…', 'addsynapse-search-filter' ), 'expired' => self::error_message( 'expired' ), 'error' => self::error_message( '' ), ), ); } /* ----------------------------------------------------------------- * Εγγραφή state (transient ανά sha256(state)) * --------------------------------------------------------------- */ /** * @param string $state State. * @return string */ private static function state_key( $state ) { return self::STATE_PREFIX . substr( hash( 'sha256', (string) $state ), 0, 32 ); } /** * Φρέσκια ανάγνωση (το άλλο μονοπάτι μπορεί να το άλλαξε σε άλλο αίτημα· χωρίς persistent cache το * get_transient κρατά αντίγραφο ανά αίτημα). * * @param string $state State. * @return array|false */ private static function read_state( $state ) { $key = self::state_key( $state ); if ( ! wp_using_ext_object_cache() ) { wp_cache_delete( '_transient_' . $key, 'options' ); wp_cache_delete( '_transient_timeout_' . $key, 'options' ); } $record = get_transient( $key ); return is_array( $record ) ? $record : false; } /** * pending | done | failed (εγγραφές του 0.5.0 χωρίς `status` = pending). * * @param array $record Εγγραφή. * @return string */ private static function record_status( array $record ) { return isset( $record['status'] ) ? (string) $record['status'] : ( empty( $record['verifier'] ) ? 'failed' : 'pending' ); } /** * Τέλος ροής: ο verifier σβήνεται, μένει μόνο το αποτέλεσμα (ώστε το δεύτερο μονοπάτι να το βρει) ως τη λήξη. * * @param string $state State. * @param array $record Εγγραφή. * @param string $status done|failed. * @param string $value Αποτέλεσμα (done) ή κωδικός σφάλματος (failed). * @return void */ private static function consume_state( $state, array $record, $status, $value ) { $record['status'] = $status; $record['verifier'] = ''; $record['result'] = 'done' === $status ? (string) $value : ''; $record['error'] = 'failed' === $status ? (string) $value : ''; $expires = isset( $record['expires'] ) ? (int) $record['expires'] : time() + self::STATE_TTL; set_transient( self::state_key( $state ), $record, max( 60, $expires - time() ) ); } /** * Σφάλματα όπου ο κωδικός μάλλον ΔΕΝ καταναλώθηκε → το state μένει για νέα προσπάθεια. * * @param string $code Κωδικός. * @return bool */ private static function is_transient_error( $code ) { return in_array( $code, array( 'hub_unreachable', 'rate_limited', 'http_429', 'http_500', 'http_502', 'http_503', 'http_504', 'connect_in_progress' ), true ); } /** * Κωδικοί σφάλματος που ξέρουμε να εξηγήσουμε (από ?error= του hub ή από το poll). * * @param string $code Κωδικός. * @return bool */ private static function known_error( $code ) { return in_array( $code, array( 'access_denied', 'cancelled', 'invalid_shop_url', 'shop_not_public', 'invalid_state', 'invalid_request', 'invalid_platform', 'invalid_grant', 'callback_mismatch', 'expired', 'expired_request', 'domain_taken', 'domain_mismatch', 'no_account', 'too_many_sites', 'too_many_dev_sites', 'platform_mismatch', 'platform_unavailable', 'rate_limited', 'site_suspended', 'hub_unreachable', ), true ); } /* ----------------------------------------------------------------- * Κατάσταση πακέτου (POST /api/v1/search/status) * --------------------------------------------------------------- */ /** * Ρωτά τον hub (αν $force ή πέρασε το refresh_after). Επιστρέφει την ΤΡΕΧΟΥΣΑ αποθηκευμένη κατάσταση ή * WP_Error (η τελευταία επαληθευμένη μένει αποθηκευμένη — fail-open). * * @param bool $force Αγνόησε το refresh_after. * @param bool $fast Σύντομο timeout (φόρτωση σελίδας admin). * @return array|WP_Error */ public static function refresh_status( $force = false, $fast = false ) { if ( ! self::is_connected() ) { return new WP_Error( 'not_connected' ); } $current = self::status(); if ( ! $force && ! self::is_stale( $current ) ) { return $current; } $body = array( 'home_url' => home_url(), 'plugin_version' => ADDSYNAPSE_SEARCH_VERSION, 'local' => self::local_facts(), ); // 401 υπογραφής (ρολόι/nonce): ΜΙΑ επανάληψη με νέο nonce, μετά «Reconnect». $response = self::signed_post( self::PATH_STATUS, $body, $fast ? 8 : 15 ); if ( ! is_wp_error( $response ) && 401 === $response['status'] ) { $response = self::signed_post( self::PATH_STATUS, $body, $fast ? 8 : 15 ); } if ( is_wp_error( $response ) ) { return self::note_status_error( $current, $response->get_error_code() ); } $data = $response['data']; if ( 200 === $response['status'] && $response['verified'] && ! empty( $data['ok'] ) ) { $status = self::sanitize_status( $data ); update_option( self::STATUS_OPTION, $status, false ); self::apply_routing( $status ); return $status; } $error = is_array( $data ) && isset( $data['error'] ) && is_string( $data['error'] ) ? sanitize_key( $data['error'] ) : 'http_' . $response['status']; // Υπογραφή απορρίφθηκε ΚΑΙ στη 2η προσπάθεια → τα κλειδιά δεν ισχύουν πια (ανακλήθηκαν/άλλαξαν). if ( 401 === $response['status'] ) { $error = 'reconnect'; } if ( 403 === $response['status'] && $response['verified'] && 'search_not_enabled' === $error ) { // Ρητή απάντηση: το module δεν είναι ενεργό για το site — κρατάμε μόνο τον σύνδεσμο πύλης. $status = array( 'checked_at' => time(), 'error' => 'search_not_enabled', 'refresh_after' => 600, 'portal' => isset( $data['portal'] ) && is_array( $data['portal'] ) ? self::sanitize_urls( $data['portal'] ) : array(), 'entitlement' => array( 'status' => 'not_enabled', 'entitled' => false, ), ); update_option( self::STATUS_OPTION, $status, false ); return $status; } return self::note_status_error( $current, $error ); } /** * Cron: ανανέωση κατάστασης + αυτόματο link στον κόμβο αν η άδεια χάθηκε (π.χ. επανασύνδεση αλλού). * * @return void */ public static function cron_refresh() { if ( ! self::is_connected() ) { self::unschedule(); return; } $status = self::refresh_status( false ); if ( is_wp_error( $status ) || empty( $status['entitlement']['entitled'] ) ) { return; } self::maybe_relink(); } /** * Link στον κόμβο αν λείπει ενεργή άδεια — το πολύ μία προσπάθεια την ώρα. * * @return void */ public static function maybe_relink() { $key = (string) Addsynapse_Search_Settings::get( 'license_key', '' ); $status = Addsynapse_Search_License::status(); if ( '' !== $key && ! empty( $status['active'] ) && ! empty( $status['hub_linked'] ) ) { return; } if ( false !== get_transient( self::RELINK_GUARD ) ) { return; } set_transient( self::RELINK_GUARD, time(), HOUR_IN_SECONDS ); self::link_node(); } /** * @param array $status Αποθηκευμένη κατάσταση. * @return bool */ private static function is_stale( array $status ) { if ( empty( $status['checked_at'] ) ) { return true; } $after = isset( $status['refresh_after'] ) ? max( 60, (int) $status['refresh_after'] ) : 600; // Μετά από σφάλμα: ξαναδοκιμάζουμε νωρίτερα (αλλά όχι σε κάθε φόρτωση σελίδας). if ( ! empty( $status['error'] ) ) { $after = min( $after, 120 ); } return time() - (int) $status['checked_at'] >= $after; } /** * Προσωρινή αποτυχία: η τελευταία επαληθευμένη κατάσταση ΜΕΝΕΙ, σημειώνεται μόνο το σφάλμα. * * @param array $current Αποθηκευμένη κατάσταση. * @param string $error Κωδικός. * @return WP_Error */ private static function note_status_error( array $current, $error ) { $current['error'] = (string) $error; $current['checked_at'] = time(); update_option( self::STATUS_OPTION, $current, false ); return new WP_Error( (string) $error ); } /** * Μόνο γνωστά πεδία, με τους σωστούς τύπους (η απάντηση είναι ήδη επαληθευμένη, αλλά ό,τι γράφεται στη * βάση και τυπώνεται στο admin πρέπει να έχει σταθερό σχήμα). * * @param array $data Απάντηση του hub. * @return array */ private static function sanitize_status( array $data ) { $e = isset( $data['entitlement'] ) && is_array( $data['entitlement'] ) ? $data['entitlement'] : array(); $limit = function ( $value ) { return null === $value ? null : ( is_numeric( $value ) ? max( 0, (int) $value ) : null ); }; $text = function ( $value, $max = 64 ) { return is_scalar( $value ) ? substr( sanitize_text_field( (string) $value ), 0, $max ) : ''; }; $billing = isset( $e['billing'] ) && is_array( $e['billing'] ) ? $e['billing'] : array(); $routing = isset( $data['routing'] ) && is_array( $data['routing'] ) ? $data['routing'] : array(); return array( 'checked_at' => time(), 'error' => '', 'environment' => self::sanitize_environment( isset( $data['environment'] ) ? $data['environment'] : ( isset( $data['site']['environment'] ) ? $data['site']['environment'] : '' ) ), 'refresh_after' => isset( $data['refresh_after'] ) ? max( 60, min( DAY_IN_SECONDS, (int) $data['refresh_after'] ) ) : 600, 'site' => array( 'id' => isset( $data['site']['id'] ) ? (int) $data['site']['id'] : 0, 'domain' => isset( $data['site']['domain'] ) ? $text( $data['site']['domain'], 190 ) : '', ), 'entitlement' => array( 'status' => $text( isset( $e['status'] ) ? $e['status'] : '' ), 'entitled' => isset( $e['entitled'] ) && true === $e['entitled'], 'plan' => $text( isset( $e['plan'] ) ? $e['plan'] : '' ), 'plan_label' => $text( isset( $e['plan_label'] ) ? $e['plan_label'] : '' ), 'effective_plan' => $text( isset( $e['effective_plan'] ) ? $e['effective_plan'] : '' ), 'limits' => array( 'products' => isset( $e['limits'] ) && is_array( $e['limits'] ) && array_key_exists( 'products', $e['limits'] ) ? $limit( $e['limits']['products'] ) : null, 'monthly_searches' => isset( $e['limits'] ) && is_array( $e['limits'] ) && array_key_exists( 'monthly_searches', $e['limits'] ) ? $limit( $e['limits']['monthly_searches'] ) : null, ), 'fair_use' => array( 'searches_per_month' => isset( $e['fair_use']['searches_per_month'] ) ? $limit( $e['fair_use']['searches_per_month'] ) : null, ), 'billing' => array( 'state' => $text( isset( $billing['state'] ) ? $billing['state'] : '' ), 'interval' => $text( isset( $billing['interval'] ) ? $billing['interval'] : '' ), 'current_period_end' => $text( isset( $billing['current_period_end'] ) ? $billing['current_period_end'] : '' ), 'cancel_at_period_end' => ! empty( $billing['cancel_at_period_end'] ), 'grace_ends_at' => $text( isset( $billing['grace_ends_at'] ) ? $billing['grace_ends_at'] : '' ), 'trial_ends_at' => $text( isset( $billing['trial_ends_at'] ) ? $billing['trial_ends_at'] : '' ), ), // 0.8.0 — δοκιμή Pro 5 ημερών (docs/HUB-HANDOFF-SEARCH.md § Pro trial). Απόν = καμία δοκιμή. 'trial' => self::sanitize_trial( isset( $e['trial'] ) && is_array( $e['trial'] ) ? $e['trial'] : array(), $e, $billing ), ), 'routing' => array( 'node' => isset( $routing['node'] ) ? $text( $routing['node'], 32 ) : '', 'api_base' => isset( $routing['api_base'] ) && Addsynapse_Search_Settings::is_allowed_service_url( $routing['api_base'] ) ? untrailingslashit( $routing['api_base'] ) : '', 'search_endpoint' => isset( $routing['search_endpoint'] ) && Addsynapse_Search_Settings::is_allowed_service_url( $routing['search_endpoint'] ) ? untrailingslashit( $routing['search_endpoint'] ) : '', 'analytics_endpoint' => isset( $routing['analytics_endpoint'] ) && Addsynapse_Search_Settings::is_allowed_service_url( $routing['analytics_endpoint'] ) ? $routing['analytics_endpoint'] : '', ), 'portal' => isset( $data['portal'] ) && is_array( $data['portal'] ) ? self::sanitize_urls( $data['portal'] ) : array(), 'plugin' => array( 'latest_version' => isset( $data['plugin']['latest_version'] ) && preg_match( '/^[0-9A-Za-z.+_-]{1,32}$/', (string) $data['plugin']['latest_version'] ) ? (string) $data['plugin']['latest_version'] : '', ), ); } /** * Μπλοκ δοκιμής: `entitlement.trial` {active, ends_at, eligible, days}, αλλιώς από status/billing 'trial'. * * @param array $t entitlement.trial. * @param array $e entitlement. * @param array $billing entitlement.billing. * @return array{active:bool,ends_at:string,eligible:bool|null,days:int} */ private static function sanitize_trial( array $t, array $e, array $billing ) { $active = isset( $t['active'] ) ? true === $t['active'] : ( ( isset( $e['status'] ) && 'trial' === $e['status'] ) || ( isset( $billing['state'] ) && 'trial' === $billing['state'] ) ); $ends = isset( $t['ends_at'] ) ? $t['ends_at'] : ( isset( $billing['trial_ends_at'] ) ? $billing['trial_ends_at'] : '' ); $ends = is_string( $ends ) && false !== strtotime( $ends ) ? substr( sanitize_text_field( $ends ), 0, 40 ) : ''; return array( 'active' => $active, 'ends_at' => $ends, 'eligible' => array_key_exists( 'eligible', $t ) ? true === $t['eligible'] : null, 'days' => isset( $t['days'] ) && is_numeric( $t['days'] ) ? max( 0, min( 90, (int) $t['days'] ) ) : 0, ); } /** * @param array $urls manage_url/billing_url/pricing_url/upgrade_url/trial_url. * @return array */ private static function sanitize_urls( array $urls ) { $out = array(); foreach ( array( 'manage_url', 'billing_url', 'pricing_url', 'upgrade_url', 'trial_url' ) as $key ) { if ( isset( $urls[ $key ] ) && is_string( $urls[ $key ] ) && self::portal_url_allowed( $urls[ $key ] ) ) { $out[ $key ] = esc_url_raw( $urls[ $key ] ); } } return $out; } /** * Υιοθετεί τα endpoints του κόμβου που δίνει ο hub (ΚΑΝΕΝΑΣ host hardcoded στο plugin). * * @param array $status Κατάσταση (sanitize_status). * @return void */ private static function apply_routing( array $status ) { $routing = isset( $status['routing'] ) ? $status['routing'] : array(); $map = array( 'api_base' => 'api_base', 'search_endpoint' => 'meilisearch_host', 'analytics_endpoint' => 'analytics_endpoint', ); foreach ( $map as $from => $setting ) { if ( ! empty( $routing[ $from ] ) && (string) Addsynapse_Search_Settings::get( $setting, '' ) !== $routing[ $from ] ) { Addsynapse_Search_Settings::set( $setting, $routing[ $from ] ); } } } /** * Τοπικά στοιχεία για την κάρτα της πύλης (πλήθος κύριων προϊόντων, εκδόσεις). Όχι προσωπικά δεδομένα. * * @return array */ private static function local_facts() { $counts = function_exists( 'wp_count_posts' ) ? wp_count_posts( 'product' ) : null; $facts = array( 'products' => is_object( $counts ) && isset( $counts->publish ) ? (int) $counts->publish : 0, 'wp_version' => substr( (string) get_bloginfo( 'version' ), 0, 32 ), ); if ( defined( 'WC_VERSION' ) ) { $facts['wc_version'] = substr( (string) WC_VERSION, 0, 32 ); } return $facts; } /* ----------------------------------------------------------------- * Link στον κόμβο αναζήτησης (POST {api_base}/v1/connect/link) * --------------------------------------------------------------- */ /** * Δένει αυτό το site στον κόμβο: κλειδί άδειας κόμβου + δείκτης. Αν υπάρχει παλιό κλειδί, πάει ως Bearer * (ο ίδιος δείκτης μεταφέρεται — τίποτα δεν ξαναδεικτοδοτείται). * * @return true|WP_Error */ public static function link_node() { $status = self::status(); if ( empty( $status['routing']['api_base'] ) || empty( $status['routing']['node'] ) ) { return new WP_Error( 'no_routing' ); } $body = wp_json_encode( array( 'home_url' => home_url(), 'plugin_version' => ADDSYNAPSE_SEARCH_VERSION, 'purpose' => 'node_link', 'node' => $status['routing']['node'], 'local' => self::local_facts(), ) ); // Proof: υπογεγραμμένο αίτημα status που ΔΕΝ στέλνουμε στον hub — το προωθεί ο κόμβος. $proof = array( 'headers' => self::sign( 'POST', self::PATH_STATUS, (string) $body ), 'body' => (string) $body, ); $legacy_key = (string) Addsynapse_Search_Settings::get( 'license_key', '' ); $response = Addsynapse_Search_Api_Client::request( 'POST', '/v1/connect/link', array( 'domain' => (string) wp_parse_url( home_url(), PHP_URL_HOST ), 'locale' => Addsynapse_Search_Frontend::catalog_lang(), 'proof' => $proof, ), $legacy_key, false ); // Παλιό κλειδί που ο κόμβος δεν αναγνωρίζει πια (ανάκληση) → ξανά χωρίς Bearer. if ( is_wp_error( $response ) && '' !== $legacy_key && self::error_status( $response ) === 401 && 'invalid_license' === self::error_code( $response ) ) { $proof['headers'] = self::sign( 'POST', self::PATH_STATUS, (string) $body ); $response = Addsynapse_Search_Api_Client::request( 'POST', '/v1/connect/link', array( 'domain' => (string) wp_parse_url( home_url(), PHP_URL_HOST ), 'locale' => Addsynapse_Search_Frontend::catalog_lang(), 'proof' => $proof, ), '', false ); } if ( is_wp_error( $response ) ) { self::note_link_error( self::error_code( $response ) ); return $response; } $key = isset( $response['license_key'] ) && is_string( $response['license_key'] ) && '' !== $response['license_key'] ? $response['license_key'] : $legacy_key; $applied = Addsynapse_Search_License::apply_activation( $response, $key ); if ( is_wp_error( $applied ) ) { self::note_link_error( $applied->get_error_code() ); return $applied; } $record = self::record(); $record['linked_at'] = time(); $record['link_error'] = ''; $record['hub_site_id'] = isset( $response['hub_site_id'] ) ? (int) $response['hub_site_id'] : 0; update_option( self::OPTION, $record, false ); // Νέος (άδειος) δείκτης → πλήρης δεικτοδότηση. Μετάπτωση ίδιου δείκτη → τίποτα. if ( $key !== $legacy_key && class_exists( 'Addsynapse_Search_Indexer' ) && method_exists( 'Addsynapse_Search_Indexer', 'start_reindex_all' ) ) { Addsynapse_Search_Indexer::start_reindex_all(); } return true; } /** * @param string $code Κωδικός σφάλματος link. * @return void */ private static function note_link_error( $code ) { $record = self::record(); $record['link_error'] = sanitize_key( (string) $code ); update_option( self::OPTION, $record, false ); } /** * @param WP_Error $error Σφάλμα Api_Client. * @return int */ private static function error_status( WP_Error $error ) { $data = $error->get_error_data(); return is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 0; } /** * @param WP_Error $error Σφάλμα Api_Client. * @return string */ private static function error_code( WP_Error $error ) { $data = $error->get_error_data(); return is_array( $data ) && ! empty( $data['code'] ) ? sanitize_key( (string) $data['code'] ) : $error->get_error_code(); } /* ----------------------------------------------------------------- * Αποσύνδεση / χειροκίνητη ανανέωση * --------------------------------------------------------------- */ /** * @return void */ public static function handle_disconnect() { if ( ! self::allowed() ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'addsynapse-search-filter' ), 403 ); } check_admin_referer( self::ACTION_DISCONNECT ); $revoked = self::disconnect(); wp_safe_redirect( self::page_url( array( 'addsynapse_search_notice' => $revoked ? 'hub_disconnected' : 'hub_disconnected_local' ) ) ); exit; } /** * Ο hub ανακαλεί τα κλειδιά (υπογεγραμμένη κλήση), εδώ σβήνονται — και όταν ο hub δεν απαντά. * Η αναζήτηση σταματά (ρητή ενέργεια διαχειριστή — όχι πρόβλημα πληρωμής): η άδεια του κόμβου * αποδεσμεύεται. Νέο «Connect» ξαναδένει τον ΙΔΙΟ δείκτη. * * @return bool true = ο hub επιβεβαίωσε. */ public static function disconnect() { $revoked = false; if ( self::is_connected() ) { $response = self::signed_post( self::PATH_DISCONNECT, array( 'home_url' => home_url() ), 15 ); $revoked = ! is_wp_error( $response ) && $response['verified'] && $response['status'] >= 200 && $response['status'] < 300; } delete_option( self::OPTION ); delete_option( self::STATUS_OPTION ); delete_transient( self::RELINK_GUARD ); self::unschedule(); if ( '' !== (string) Addsynapse_Search_Settings::get( 'license_key', '' ) ) { Addsynapse_Search_License::deactivate(); } return $revoked; } /** * «Refresh now» (admin-post). * * @return void */ public static function handle_refresh() { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'addsynapse-search-filter' ), 403 ); } check_admin_referer( self::ACTION_REFRESH ); $status = self::refresh_status( true ); $notice = is_wp_error( $status ) ? 'hub_refresh_failed' : 'hub_refreshed'; if ( ! is_wp_error( $status ) && ! empty( $status['entitlement']['entitled'] ) ) { delete_transient( self::RELINK_GUARD ); self::maybe_relink(); } wp_safe_redirect( self::page_url( array( 'addsynapse_search_notice' => $notice ) ) ); exit; } /* ----------------------------------------------------------------- * Cron * --------------------------------------------------------------- */ /** * @return void */ public static function schedule() { if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { wp_schedule_event( time() + 300, 'hourly', self::CRON_HOOK ); } } /** * @return void */ public static function unschedule() { wp_clear_scheduled_hook( self::CRON_HOOK ); } /* ----------------------------------------------------------------- * HTTP + υπογραφές (connector-core Signer, ίδια σύμβαση) * --------------------------------------------------------------- */ /** * Υπογεγραμμένες κεφαλίδες: X-Ops-Signature = hex(HMAC-SHA256(secret, METHOD\nPATH\nTS\nNONCE\nhex(sha256(body)))). * * @param string $method Μέθοδος. * @param string $path Path όπως τον βλέπει ο hub. * @param string $body Ωμό σώμα. * @return array */ private static function sign( $method, $path, $body ) { $timestamp = (string) time(); $nonce = bin2hex( random_bytes( 16 ) ); return array( 'X-Ops-Site' => self::site_key(), 'X-Ops-Timestamp' => $timestamp, 'X-Ops-Nonce' => $nonce, 'X-Ops-Signature' => hash_hmac( 'sha256', self::canonical( $method, $path, $timestamp, $nonce, $body ), self::secret() ), ); } /** * @param string $method Μέθοδος. * @param string $path Path. * @param string $timestamp Unix. * @param string $nonce Nonce. * @param string $body Σώμα. * @return string */ public static function canonical( $method, $path, $timestamp, $nonce, $body ) { return implode( "\n", array( strtoupper( $method ), '/' . ltrim( $path, '/' ), $timestamp, $nonce, hash( 'sha256', $body ) ) ); } /** * Υπογεγραμμένο POST προς τον hub· η απάντηση ΠΡΕΠΕΙ να είναι υπογεγραμμένη για το δικό μας nonce. * * @param string $path Path. * @param array $payload Σώμα. * @param int $timeout Δευτ. * @return array|WP_Error {status, data} */ private static function signed_post( $path, array $payload, $timeout ) { $hub = self::hub_url(); if ( '' === $hub || ! self::is_connected() ) { return new WP_Error( 'not_connected' ); } $body = (string) wp_json_encode( $payload ); $canon = rtrim( (string) wp_parse_url( $hub, PHP_URL_PATH ), '/' ) . $path; $headers = self::sign( 'POST', $canon, $body ); $response = wp_remote_post( $hub . $path, array( 'timeout' => (int) $timeout, 'redirection' => 0, 'headers' => array_merge( $headers, array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'User-Agent' => 'addsynapse-search/' . ADDSYNAPSE_SEARCH_VERSION, ) ), 'body' => $body, ) ); if ( is_wp_error( $response ) ) { return new WP_Error( 'hub_unreachable' ); } $status = (int) wp_remote_retrieve_response_code( $response ); $raw = (string) wp_remote_retrieve_body( $response ); $verified = self::verify_response( $status, $canon, $headers['X-Ops-Nonce'], $response, $raw ); $data = json_decode( $raw, true ); // Χωρίς έγκυρη υπογραφή = σαν να μην απάντησε (ψεύτικος hub/ενδιάμεσος). Εξαίρεση: οι αρνήσεις // 401/403 του middleware του hub (υπογραφή/αναστολή/domain) φεύγουν ΠΡΙΝ την υπογραφή — τις // κρατάμε ΜΟΝΟ για μήνυμα, ποτέ για αλλαγή κατάστασης (`verified` false). if ( ! $verified && ! in_array( $status, array( 401, 403 ), true ) ) { return new WP_Error( 'unsigned_response' ); } return array( 'status' => $status, 'data' => is_array( $data ) ? $data : null, 'verified' => $verified, ); } /** * X-Ops-Response-Signature = hex(HMAC(secret, "RESPONSE\nSTATUS\nPATH\nTS\nREQUEST_NONCE\nhex(sha256(body))")), ±300s. * * @param int $status HTTP status. * @param string $path Path. * @param string $nonce Το nonce ΤΟΥ ΑΙΤΗΜΑΤΟΣ μας. * @param array|WP_Error $response wp_remote_* απάντηση. * @param string $raw Σώμα. * @return bool */ private static function verify_response( $status, $path, $nonce, $response, $raw ) { // 🔴 wp_remote_retrieve_header (όχι (array) των headers — CaseInsensitiveDictionary, βλ. shopWorker gotcha). $timestamp = trim( (string) wp_remote_retrieve_header( $response, 'x-ops-response-timestamp' ) ); $signature = strtolower( trim( (string) wp_remote_retrieve_header( $response, 'x-ops-response-signature' ) ) ); $secret = self::secret(); if ( '' === $secret || ! ctype_digit( $timestamp ) || ! preg_match( '/^[a-f0-9]{64}$/', $signature ) || abs( time() - (int) $timestamp ) > 300 ) { return false; } $expected = hash_hmac( 'sha256', implode( "\n", array( 'RESPONSE', (string) $status, '/' . ltrim( $path, '/' ), $timestamp, $nonce, hash( 'sha256', $raw ) ) ), $secret ); return hash_equals( $expected, $signature ); } /** * Ανυπόγραφο JSON POST (μόνο connect/info και connect/token — πριν υπάρξουν κλειδιά). * * @param string $url URL. * @param array $body Σώμα. * @return array|WP_Error */ private static function post_json( $url, array $body ) { $response = wp_remote_post( $url, array( 'timeout' => self::HTTP_TIMEOUT, 'redirection' => 0, 'headers' => array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'User-Agent' => 'addsynapse-search/' . ADDSYNAPSE_SEARCH_VERSION, ), 'body' => (string) wp_json_encode( (object) $body ), ) ); if ( is_wp_error( $response ) ) { return new WP_Error( 'hub_unreachable' ); } $status = (int) wp_remote_retrieve_response_code( $response ); $data = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( 200 !== $status ) { $code = is_array( $data ) && isset( $data['error'] ) && is_string( $data['error'] ) ? sanitize_key( $data['error'] ) : 'http_' . $status; return new WP_Error( '' !== $code ? $code : 'hub_refused' ); } return is_array( $data ) ? $data : new WP_Error( 'invalid_response' ); } /* ----------------------------------------------------------------- * Κρυπτογράφηση secret (sodium secretbox, κλειδί από τα salts του WP) * --------------------------------------------------------------- */ /** * @return string 32 bytes. */ private static function crypto_key() { return hash( 'sha256', wp_salt( 'auth' ) . '|addsynapse-search-hub-secret', true ); } /** * @param string $plain Κείμενο. * @return string base64(nonce|ciphertext) */ private static function encrypt( $plain ) { $nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ); return 'sb1:' . base64_encode( $nonce . sodium_crypto_secretbox( (string) $plain, $nonce, self::crypto_key() ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- αποθήκευση κρυπτογραφημένου secret, όχι obfuscation. } /** * @param string $stored Αποθηκευμένη τιμή. * @return string '' αν δεν αποκρυπτογραφείται (π.χ. άλλαξαν τα salts → ξανά Connect). */ private static function decrypt( $stored ) { if ( 0 !== strpos( $stored, 'sb1:' ) ) { return ''; } $raw = base64_decode( substr( $stored, 4 ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- βλ. encrypt(). if ( false === $raw || strlen( $raw ) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ) { return ''; } try { $plain = sodium_crypto_secretbox_open( substr( $raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ), substr( $raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ), self::crypto_key() ); } catch ( Exception $e ) { return ''; } return false === $plain ? '' : (string) $plain; } /* ----------------------------------------------------------------- * PKCE * --------------------------------------------------------------- */ /** * base64url(sha256(verifier)) χωρίς padding (RFC 7636 S256). * * @param string $verifier Verifier. * @return string */ public static function challenge( $verifier ) { return rtrim( strtr( base64_encode( hash( 'sha256', $verifier, true ) ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- PKCE (RFC 7636). } /** * @param int $length Μήκος. * @return string [A-Za-z0-9_-] */ private static function random( $length ) { return substr( rtrim( strtr( base64_encode( random_bytes( $length ) ), '+/', '-_' ), '=' ), 0, $length ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- τυχαίο state/verifier. } /** * Φιλικό μήνυμα για κωδικό σφάλματος σύνδεσης. * * @param string $code Κωδικός. * @return string */ public static function error_message( $code ) { $home = untrailingslashit( home_url() ); switch ( $code ) { case 'shop_local_host': /* translators: %s: this site's address */ return sprintf( __( 'This site runs on a local development address (%s) that AddSynapse cannot reach or verify. Connect from a public https address instead — your live site or a staging copy (see Help).', 'addsynapse-search-filter' ), $home ); case 'shop_ip_host': /* translators: %s: this site's address */ return sprintf( __( 'This site\'s address (%s) is an IP address. AddSynapse can connect only sites with a public https domain name — connect from your live site or a staging copy.', 'addsynapse-search-filter' ), $home ); case 'shop_has_port': /* translators: %s: this site's address */ return sprintf( __( 'This site\'s address (%s) includes a port number. AddSynapse can connect only sites with a public https address without a port — connect from your live site or a staging copy.', 'addsynapse-search-filter' ), $home ); case 'shop_not_https': /* translators: %s: this site's address */ return sprintf( __( 'This site\'s address (%s) does not use https. AddSynapse can connect only sites served over https — switch the site to https (Settings → General) and try again.', 'addsynapse-search-filter' ), $home ); case 'shop_has_credentials': return __( 'This site\'s address contains a username or password (user:pass@…), which AddSynapse does not accept. Remove them from the WordPress Address and Site Address (Settings → General) and try again.', 'addsynapse-search-filter' ); case 'shop_callback_mismatch': case 'callback_mismatch': /* translators: 1: this site's address, 2: this site's admin address */ return sprintf( __( 'AddSynapse returns to the same address it connects: the site address (%1$s) and the admin address (%2$s) must use the same host and port. Make the WordPress Address and Site Address match (Settings → General) and try again.', 'addsynapse-search-filter' ), $home, untrailingslashit( admin_url() ) ); case 'too_many_dev_sites': /* translators: %s: AddSynapse portal Sites page URL */ return sprintf( __( 'You already have 5 development sites on your AddSynapse account. Remove one in the AddSynapse portal (Sites: %s) and try again.', 'addsynapse-search-filter' ), self::portal_sites_url() ); case 'invalid_shop_url': case 'shop_not_public': if ( self::is_development_address() ) { /* translators: %s: this site's address */ return sprintf( __( 'AddSynapse did not accept this development address (%s). Local sites connect as development sites only when the address has no username/password and the WordPress Address and Site Address (Settings → General) use the same host and port. If they already do, connect from a public https address instead — your live site or a staging copy.', 'addsynapse-search-filter' ), $home ); } /* translators: %s: this site's address */ return sprintf( __( 'AddSynapse did not accept this site\'s address (%s). It needs a public https address without a port — connect from your live site or a staging copy.', 'addsynapse-search-filter' ), $home ); case 'invalid_url': return __( 'The AddSynapse address configured on this site is not allowed (it must be an https:// AddSynapse address).', 'addsynapse-search-filter' ); case 'hub_unreachable': case 'network': return __( 'This server could not reach AddSynapse (api.addsynapse.com). Please try again in a minute. If it keeps happening, ask your host to check that the server can make outgoing https connections (DNS/firewall).', 'addsynapse-search-filter' ); case 'rate_limited': case 'http_429': return __( 'Too many connection attempts. Please wait a few minutes and try again.', 'addsynapse-search-filter' ); case 'invalid_state': case 'invalid_grant': case 'invalid_code': case 'invalid_request': return __( 'The connection request expired or was already used. Click "Connect to AddSynapse" again.', 'addsynapse-search-filter' ); case 'expired': case 'expired_request': return __( 'The connection request expired before it was approved. Click "Connect to AddSynapse" to try again.', 'addsynapse-search-filter' ); case 'wrong_user': return __( 'The connection must be completed by the same administrator who started it.', 'addsynapse-search-filter' ); case 'domain_mismatch': return __( 'AddSynapse issued this connection for another domain. Start again from this site.', 'addsynapse-search-filter' ); case 'domain_taken': return __( 'This site\'s domain is already connected to another AddSynapse account. Sign in with that account, or ask its owner to remove the site, then try again.', 'addsynapse-search-filter' ); case 'no_account': return __( 'Your AddSynapse user cannot add sites to any account (you need to be an owner or admin of an account). Create an account on AddSynapse or ask its owner for access, then try again.', 'addsynapse-search-filter' ); case 'too_many_sites': return __( 'Your AddSynapse account has reached its maximum number of sites. Remove a site there, or contact us, then try again.', 'addsynapse-search-filter' ); case 'platform_mismatch': return __( 'This domain is already registered on AddSynapse for a different shop platform. Contact us to move it.', 'addsynapse-search-filter' ); case 'platform_unavailable': case 'invalid_platform': return __( 'AddSynapse cannot connect WooCommerce sites right now. Please try again later.', 'addsynapse-search-filter' ); case 'site_suspended': return __( 'This site is suspended on AddSynapse.', 'addsynapse-search-filter' ); case 'connect_in_progress': return __( 'The connection is still being completed. Reload this page in a few seconds.', 'addsynapse-search-filter' ); case 'cancelled': case 'access_denied': return __( 'The connection was cancelled on AddSynapse.', 'addsynapse-search-filter' ); default: return __( 'The connection was not completed. Click "Connect to AddSynapse" to try again.', 'addsynapse-search-filter' ); } } }