cache key => Unix time). */ const FILTER_COMBOS_OPTION = 'akla_filter_combos'; /** Transient: current minute and the uncached filter requests counted in it. Never visitor data. */ const FILTER_RATE_KEY = 'akla_filter_rate'; /** Option: when visitors last met the per-minute filter limit, for Site Health. Never visitor data. */ const FILTER_LIMITED_OPTION = 'akla_filter_limited'; /** Most visitor-selected categories forwarded (same bound as the service). */ const MAX_FILTER_CATEGORIES = 16; /** * Whether the current request serves visitors through the plugin's public results route. * * @var bool */ private static $visitor_route = false; /** Shared synchronous page deadline. * * @var float|null */ private static $page_deadline = null; /** Whether the current fetch prepares a snapshot without a visit. * * @var bool */ private static $warming = false; /** Only these service codes prove that this installation identity is no longer usable. */ const REVOCATION_CODES = array( 'DISTRIBUTION_INSTALLATION_INVALID' => 'reconnect', 'DISTRIBUTION_CREDENTIAL_INVALID' => 'reconnect', 'DISTRIBUTION_ORIGIN_CHANGED_RECONNECT' => 'origin_changed', ); /** Course fields kept in the local cache: exactly what the renderer and visitor filters read. */ const ITEM_FIELDS = array( 'courseId', 'title', 'category', 'categories', 'categoryLabels', 'ageMin', 'ageMax', 'ageLabel', 'city', 'cityId', 'cityIds', 'province', 'venue', 'startsAt', 'endsAt', 'timeZone', 'price', 'priceMax', 'currency', 'priceUnit', 'detailUrl', 'registrationUrl', 'sourceName', 'sourceUrl', 'lastSuccessfulCheckAt', 'contentExpiresAt', 'freshness', 'occurrences', 'offerings', 'dataLicense' ); /** Class fields kept in the local cache. */ const OFFERING_FIELDS = array( 'scheduleId', 'cityId', 'venue', 'startsAt', 'endsAt', 'timeZone', 'price', 'priceMax', 'currency', 'priceUnit', 'registrationUrl', 'occurrences' ); /** Session fields kept in the local cache. */ const OCCURRENCE_FIELDS = array( 'startsAt', 'endsAt', 'timeZone', 'venue', 'price', 'currency' ); /** Small, bounded snapshot-level lists kept in the local cache; other snapshot-level lists are dropped. */ const SNAPSHOT_LISTS = array( 'city', 'categories', 'nearbyCities' ); /** Most category facets and age range choices kept from a snapshot. */ const MAX_FACETS = 64; /** * Names of the wp-config.php constants that manage the installation credentials. * Tests swap these names through reflection so process-wide constants never leak between cases. * * @var string[] */ private static $credential_constants = array( 'AKLA_INSTALLATION_ID', 'AKLA_INSTALLATION_SECRET' ); /** * Whether wp-config.php constants supply the installation identity and secret. * * @return bool True when both constants are non-empty strings. */ public static function managed_by_constants() { foreach ( self::$credential_constants as $name ) { if ( ! defined( $name ) || ! is_string( constant( $name ) ) || '' === constant( $name ) ) { return false; } } return true; } /** * Return the server-only local installation state. * * @return array Stored state, with constant-managed credentials applied when present. */ public static function connection() { $value = get_option( 'akla_connection', array() ); $value = is_array( $value ) ? $value : array(); if ( self::managed_by_constants() ) { $value['uuid'] = constant( self::$credential_constants[0] ); $value['secret'] = constant( self::$credential_constants[1] ); } return $value; } /** * Persist local installation state without ever copying constant-managed credentials into the database. * * @param array $connection Connection state. * @return void */ private static function save_connection( $connection ) { if ( self::managed_by_constants() ) { unset( $connection['uuid'], $connection['secret'] ); } update_option( 'akla_connection', $connection, false ); } /** * Normalize this WordPress site origin without contacting it. * * @return mixed Result or rendered output as documented by the caller. */ public static function origin() { $parts = wp_parse_url( home_url( '/' ) ); if ( empty( $parts['scheme'] ) || empty( $parts['host'] ) ) { return ''; } return strtolower( $parts['scheme'] . '://' . $parts['host'] ) . ( isset( $parts['port'] ) ? ':' . (int) $parts['port'] : '' ); } /** * Check active local state and clone-origin consistency. * * @return mixed Result or rendered output as documented by the caller. */ public static function connected() { $connection = self::connection(); return isset( $connection['status'], $connection['origin'] ) && 'connected' === $connection['status'] && self::origin() === $connection['origin']; } /** * Summarize connection health for administrators. * * @return array{state: string, last_success: int|null, failures: int, last_error: string|null, managed_by_constants: bool} */ public static function status() { $connection = self::connection(); $health = self::health(); $stored = $connection['status'] ?? ''; if ( empty( $connection['uuid'] ) || ! in_array( $stored, array( 'connected', 'reconnect', 'origin_changed' ), true ) ) { $state = 'not_connected'; } elseif ( 'origin_changed' === $stored || self::origin() !== ( $connection['origin'] ?? '' ) ) { $state = 'origin_changed'; } elseif ( 'reconnect' === $stored ) { $state = 'reconnect'; } elseif ( 'check' === ( $health['state'] ?? '' ) ) { $state = 'check'; } else { $state = 'connected'; } return array( 'state' => $state, 'last_success' => empty( $health['last_success'] ) ? null : (int) $health['last_success'], 'failures' => (int) ( $health['failures'] ?? 0 ), 'last_error' => isset( $health['last_error'] ) && is_string( $health['last_error'] ) ? $health['last_error'] : null, 'managed_by_constants' => self::managed_by_constants(), ); } /** * Read the persisted connection health record. * * @return array Health record. */ private static function health() { $health = get_option( self::HEALTH_OPTION, array() ); return is_array( $health ) ? $health : array(); } /** * Record a successful service contact: health returns to connected and the failure streak resets. * * @return void */ private static function record_success() { $health = array( 'state' => 'connected', 'last_success' => time(), 'failures' => 0, 'first_failure' => null, 'last_error' => null, ); update_option( self::HEALTH_OPTION, $health, false ); } /** * Count a temporary failure; a sustained streak asks the site owner to check the connection. * * The connection and identity are never cleared here: only a revocation code can do that. * * @param string $error_id Short identifier such as a service code or http_403. Never a response body. * @return void */ private static function record_failure( $error_id ) { $health = self::health(); $now = time(); $failures = (int) ( $health['failures'] ?? 0 ) + 1; $first = empty( $health['first_failure'] ) ? $now : (int) $health['first_failure']; $state = $health['state'] ?? 'connected'; if ( $failures >= self::CHECK_FAILURES && $now - $first >= self::CHECK_SECONDS ) { $state = 'check'; } $health = array( 'state' => $state, 'last_success' => $health['last_success'] ?? null, 'failures' => $failures, 'first_failure' => $first, 'last_error' => $error_id, ); update_option( self::HEALTH_OPTION, $health, false ); } /** * Note a visible problem that is not a connection failure. * * @param string $error_id Short identifier. * @return void */ private static function record_error( $error_id ) { $health = self::health(); $health['last_error'] = $error_id; update_option( self::HEALTH_OPTION, $health, false ); } /** * Extract a declared service error code from a response body without retaining the body. * * @param string $raw Response body. * @return string|null DISTRIBUTION_* code, or null for HTML, non-JSON or code-less bodies. */ private static function service_code( $raw ) { if ( ! is_string( $raw ) || '' === $raw || strlen( $raw ) > 4096 ) { return null; } $data = json_decode( $raw, true, 4 ); if ( ! is_array( $data ) || ! isset( $data['code'] ) || ! is_string( $data['code'] ) || ! preg_match( '/^DISTRIBUTION_[A-Z_]{1,100}$/D', $data['code'] ) ) { return null; } return $data['code']; } /** * Call a fixed, allowlisted HTTPS service endpoint. * * @param mixed $path Path. * @param mixed $method Method. * @param mixed $body Body. * @param mixed $authenticated Authenticated. * @param mixed $connection Connection. * @param int $timeout Seconds to wait for the service. * * @return mixed Result or rendered output as documented by the caller. */ public static function request( $path, $method = 'GET', $body = null, $authenticated = false, $connection = null, $timeout = self::BACK_TIMEOUT ) { $allowed = array( '/api/public/distribution/v1/capabilities', '/api/public/distribution/v1/cities', '/api/public/distribution/v1/cities/resolve', '/api/public/distribution/v1/coverage', '/api/public/distribution/v1/installations', '/api/distribution/v1/catalog/query', '/api/distribution/v1/credentials/rotate', '/api/distribution/v1/disconnect', ); $route = explode( '?', $path, 2 )[0]; if ( ! in_array( $route, $allowed, true ) || ! in_array( $method, array( 'GET', 'POST' ), true ) ) { return new WP_Error( 'akla_invalid_request', __( 'The request is not supported.', 'actikidz-local-activities' ) ); } $headers = array( 'Accept' => 'application/json', 'Content-Type' => 'application/json', ); if ( $authenticated ) { $connection = null === $connection ? self::connection() : $connection; if ( empty( $connection['uuid'] ) || empty( $connection['secret'] ) || self::origin() !== ( $connection['origin'] ?? '' ) ) { return new WP_Error( 'akla_disconnected', __( 'Connect this site to ActiKidz first.', 'actikidz-local-activities' ) ); } $headers['Authorization'] = 'Distribution ' . $connection['uuid'] . ':' . $connection['secret']; } // Rotation recovery deliberately retries a rejected secret, so its failures say nothing about connection health. $track = $authenticated && '/api/distribution/v1/credentials/rotate' !== $route; $response_limit = in_array( $route, array( '/api/public/distribution/v1/coverage', '/api/distribution/v1/catalog/query' ), true ) ? self::CATALOG_RESPONSE_BYTES : self::RESPONSE_BYTES; $args = array( 'method' => $method, 'headers' => $headers, 'timeout' => max( 0.1, (float) $timeout ), 'redirection' => 0, 'sslverify' => true, 'cookies' => array(), 'user-agent' => 'ActiKidz-WordPress/' . AKLA_VERSION, 'limit_response_size' => $response_limit, ); if ( null !== $body ) { $args['body'] = wp_json_encode( $body ); } $response = wp_safe_remote_request( self::BASE . $path, $args ); if ( is_wp_error( $response ) ) { if ( $track ) { self::record_failure( 'network' ); } return new WP_Error( 'akla_network', __( 'The service is temporarily unavailable. Please retry.', 'actikidz-local-activities' ), array( 'transient' => true ) ); } $status = (int) wp_remote_retrieve_response_code( $response ); if ( $status < 200 || $status >= 300 ) { $code = self::service_code( wp_remote_retrieve_body( $response ) ); $revoked = null !== $code && isset( self::REVOCATION_CODES[ $code ] ); if ( $revoked && ( $authenticated || '/api/public/distribution/v1/installations' === $route ) && '/api/distribution/v1/credentials/rotate' !== $route ) { self::clear_cache(); $stored = self::connection(); $stored['status'] = self::REVOCATION_CODES[ $code ]; self::save_connection( $stored ); self::record_error( $code ); } // HTML, non-JSON, code-less and unknown-code answers may come from a CDN, WAF or proxy: retry later, never revoke. $temporary = ! $revoked && ( $status >= 500 || 429 === $status || null === $code || in_array( $status, array( 401, 403, 410 ), true ) ); if ( $track && $temporary ) { self::record_failure( null === $code ? 'http_' . $status : $code ); } return new WP_Error( 'akla_http_' . $status, self::error_message( $status, $code ), array( 'status' => $status, 'code' => $code, 'transient' => $temporary, 'retryAfter' => max( 0, min( DAY_IN_SECONDS, (int) wp_remote_retrieve_header( $response, 'retry-after' ) ) ), ) ); } $raw = wp_remote_retrieve_body( $response ); $data = json_decode( $raw, true, 32 ); if ( strlen( $raw ) >= $response_limit || JSON_ERROR_NONE !== json_last_error() || ! is_array( $data ) ) { if ( $track ) { self::record_failure( 'invalid_response' ); } return new WP_Error( 'akla_invalid_response', __( 'The service returned an invalid response. Please retry.', 'actikidz-local-activities' ), array( 'transient' => true ) ); } if ( $track ) { self::record_success(); } return $data; } /** * Return safe translated text for an HTTP failure. * * @param mixed $status Status. * @param string|null $code Declared service error code, if any. * * @return mixed Result or rendered output as documented by the caller. */ public static function error_message( $status, $code = null ) { if ( 'DISTRIBUTION_ORIGIN_CHANGED_RECONNECT' === $code ) { return __( 'This site address changed after it was connected. Reconnect this site in settings.', 'actikidz-local-activities' ); } if ( null !== $code && isset( self::REVOCATION_CODES[ $code ] ) ) { return __( 'This connection needs attention. Reconnect in settings.', 'actikidz-local-activities' ); } if ( 409 === $status ) { return __( 'The saved connection changed. Retry the same operation or reconnect this site.', 'actikidz-local-activities' ); } if ( 429 === $status ) { return __( 'Too many requests. Please wait a minute and retry.', 'actikidz-local-activities' ); } return __( 'The service is temporarily unavailable. Please retry.', 'actikidz-local-activities' ); } /** * Persist retryable credentials before explicit service connection. * * @return mixed Result or rendered output as documented by the caller. */ public static function connect() { $connection = self::connection(); if ( empty( $connection['uuid'] ) || self::origin() !== ( $connection['origin'] ?? '' ) || in_array( $connection['status'] ?? '', array( 'reconnect', 'origin_changed', 'disconnect_pending' ), true ) ) { $managed = self::managed_by_constants(); $connection = array( 'uuid' => $managed ? $connection['uuid'] : wp_generate_uuid4(), 'secret' => $managed ? $connection['secret'] : bin2hex( random_bytes( 32 ) ), 'origin' => self::origin(), 'status' => 'pending', ); } // Persist before sending, so a lost response retries the identical registration. self::save_connection( $connection ); $capabilities = self::request( '/api/public/distribution/v1/capabilities' ); if ( is_wp_error( $capabilities ) ) { return $capabilities; } if ( empty( $capabilities['installation'] ) || empty( $capabilities['catalog'] ) ) { return new WP_Error( 'akla_unavailable', __( 'The course service is not open yet. Your settings are saved; retry later.', 'actikidz-local-activities' ) ); } $result = self::request( '/api/public/distribution/v1/installations', 'POST', array( 'installationUuid' => $connection['uuid'], 'secret' => $connection['secret'], 'origin' => $connection['origin'], 'consentVersion' => self::CONSENT, ) ); if ( is_wp_error( $result ) ) { return $result; } if ( empty( $result['publicId'] ) || 'ACTIVE' !== ( $result['status'] ?? '' ) ) { return new WP_Error( 'akla_invalid_response', __( 'The service returned an invalid response. Please retry.', 'actikidz-local-activities' ) ); } $connection['public_id'] = sanitize_text_field( $result['publicId'] ); $connection['environment'] = sanitize_text_field( $result['environment'] ?? 'TEST' ); $connection['status'] = 'connected'; self::save_connection( $connection ); self::record_success(); self::clear_cache(); return true; } /** * Explain that wp-config.php owns the credentials, after dropping only local cached output. * * @param string $operation Either rotate or disconnect. * @return WP_Error Explicit refusal. */ private static function managed_refusal( $operation ) { self::clear_cache(); if ( 'rotate' === $operation ) { return new WP_Error( 'akla_managed_by_constants', __( 'This connection is managed in wp-config.php. To rotate the secret, change AKLA_INSTALLATION_SECRET there. Local cached courses were cleared.', 'actikidz-local-activities' ) ); } return new WP_Error( 'akla_managed_by_constants', __( 'This connection is managed in wp-config.php. To disconnect, remove AKLA_INSTALLATION_ID and AKLA_INSTALLATION_SECRET there. Local cached courses were cleared.', 'actikidz-local-activities' ) ); } /** * Recover or start an idempotent credential refresh. * * @return mixed Result or rendered output as documented by the caller. */ public static function rotate() { if ( self::managed_by_constants() ) { return self::managed_refusal( 'rotate' ); } $connection = self::connection(); if ( ! self::connected() ) { return new WP_Error( 'akla_disconnected', __( 'Connect this site to ActiKidz first.', 'actikidz-local-activities' ) ); } if ( empty( $connection['rotation'] ) ) { $connection['rotation'] = array( 'requestId' => wp_generate_uuid4(), 'newSecret' => bin2hex( random_bytes( 32 ) ), ); self::save_connection( $connection ); self::clear_cache(); } $result = self::request( '/api/distribution/v1/credentials/rotate', 'POST', $connection['rotation'], true, $connection ); if ( is_wp_error( $result ) && 'akla_http_401' === $result->get_error_code() ) { // If the old overlap expired after a lost response, the persisted new secret can recover the same operation. $recovery = $connection; $recovery['secret'] = $connection['rotation']['newSecret']; $result = self::request( '/api/distribution/v1/credentials/rotate', 'POST', $connection['rotation'], true, $recovery ); } if ( is_wp_error( $result ) ) { return $result; } $connection['secret'] = $connection['rotation']['newSecret']; unset( $connection['rotation'] ); self::save_connection( $connection ); self::clear_cache(); return true; } /** * Revoke remotely or disable local output until retry succeeds. * * @return mixed Result or rendered output as documented by the caller. */ public static function disconnect() { if ( self::managed_by_constants() ) { return self::managed_refusal( 'disconnect' ); } $connection = self::connection(); self::clear_cache(); if ( empty( $connection['uuid'] ) ) { delete_option( 'akla_connection' ); delete_option( self::HEALTH_OPTION ); return true; } $result = self::request( '/api/distribution/v1/disconnect', 'POST', array(), true, $connection ); if ( is_wp_error( $result ) && ! in_array( $result->get_error_code(), array( 'akla_http_401', 'akla_http_403', 'akla_http_410' ), true ) ) { $connection['status'] = 'disconnect_pending'; self::save_connection( $connection ); return $result; } delete_option( 'akla_connection' ); delete_option( self::HEALTH_OPTION ); return true; } /** * Whether this render serves visitors: LIVE output outside administration, REST previews and cron. * * @param string $purpose Rendering purpose. * @return bool True for visitor-facing renders that must not wait on the service when cached data exists. */ private static function is_frontend( $purpose ) { if ( 'LIVE' === $purpose && self::$visitor_route ) { return true; } return 'LIVE' === $purpose && ! is_admin() && ! wp_doing_cron() && ! ( defined( 'REST_REQUEST' ) && REST_REQUEST ); } /** * Mark the current request as a visitor request through the plugin's public results route (or end it). * * A REST request is normally not a visitor render; this route is, so it gets the visitor path: short * timeout, lock, backoff, background refresh, and the per-minute limit on uncached requests. * * @param bool $on Whether a visitor results request is being served. * @return void */ public static function serve_visitor_route( $on ) { self::$visitor_route = (bool) $on; } /** * Keep only well-formed visitor filters, narrowed to what the configuration allows. * * Visitors only narrow a configuration: selected categories outside the configured ones are dropped, and an * age choice is dropped when the configuration already fixes ages (the service accepts one age criterion). * * @param mixed $filters Visitor filters (categories, weekdays, ageRange). * @param mixed $query Configured catalog query. * @return array Normalized filters; empty when nothing applies. */ public static function visitor_filters( $filters, $query ) { $filters = is_array( $filters ) ? $filters : array(); $query = is_array( $query ) ? $query : array(); $normalized = array(); $configured = self::configured_categories( $query ); $categories = array(); foreach ( is_array( $filters['categories'] ?? null ) ? $filters['categories'] : array() as $code ) { if ( is_string( $code ) && preg_match( '/^[a-z0-9_-]{1,64}$/D', $code ) && ( empty( $configured ) || in_array( $code, $configured, true ) ) ) { $categories[ $code ] = true; } } if ( ! empty( $categories ) ) { $normalized['categories'] = array_slice( array_keys( $categories ), 0, self::MAX_FILTER_CATEGORIES ); sort( $normalized['categories'] ); } $days = array(); foreach ( is_array( $filters['weekdays'] ?? null ) ? $filters['weekdays'] : array() as $day ) { if ( is_int( $day ) && $day >= 1 && $day <= 7 ) { $days[ $day ] = true; } } if ( ! empty( $days ) && count( $days ) < 7 ) { $normalized['weekdays'] = array_keys( $days ); sort( $normalized['weekdays'] ); } $age = $filters['ageRange'] ?? null; if ( is_string( $age ) && preg_match( '/^[0-9A-Za-z+_-]{1,32}$/D', $age ) && ! self::configured_age( $query ) ) { $normalized['ageRange'] = $age; } return $normalized; } /** * Category codes a configuration is limited to (the list, or the legacy single value). * * @param array $query Configured catalog query. * @return string[] Codes; empty for every category. */ public static function configured_categories( $query ) { $codes = array(); $list = is_array( $query['categories'] ?? null ) ? $query['categories'] : array(); if ( is_string( $query['category'] ?? null ) && '' !== $query['category'] ) { $list[] = $query['category']; } foreach ( $list as $code ) { if ( is_string( $code ) && '' !== $code && ! in_array( $code, $codes, true ) ) { $codes[] = $code; } } return $codes; } /** * Whether a configuration already fixes ages (an age range or a minimum / maximum age). * * @param array $query Configured catalog query. * @return bool True when visitors cannot choose an age. */ public static function configured_age( $query ) { return ( is_string( $query['ageRange'] ?? null ) && '' !== $query['ageRange'] ) || isset( $query['ageMin'] ) || isset( $query['ageMax'] ); } /** * Keep up to five valid, distinct positive city ids. * * @param mixed $city_ids Raw configured city ids. * @return int[] Normalized list, possibly empty. */ private static function city_ids( $city_ids ) { $valid = array(); foreach ( is_array( $city_ids ) ? $city_ids : array() as $city_id ) { if ( is_string( $city_id ) && ctype_digit( $city_id ) ) { $city_id = (int) $city_id; } if ( is_int( $city_id ) && $city_id > 0 && ! in_array( $city_id, $valid, true ) ) { $valid[] = $city_id; } } return array_slice( $valid, 0, self::MAX_CITY_IDS ); } /** * Build the catalog request body for one configuration. * * @param array $profile Configuration. * @param string $widget_id Widget id. * @param string $purpose Purpose. * @param array $filters Normalized visitor filters (visitor_filters()); none for the configured snapshot. * @return array Request body. It carries no visitor data: no address, browser, cookie or language header. */ private static function catalog_body( $profile, $widget_id, $purpose, $filters = array() ) { $query = $profile['query'] ?? array(); if ( is_array( $query ) && array_key_exists( 'cityIds', $query ) ) { $city_ids = self::city_ids( $query['cityIds'] ); if ( empty( $city_ids ) ) { unset( $query['cityIds'] ); } else { $query['cityIds'] = $city_ids; } } if ( is_array( $query ) && ! empty( $filters['categories'] ) ) { $query['categories'] = $filters['categories']; unset( $query['category'] ); } if ( is_array( $query ) && ! empty( $filters['weekdays'] ) ) { $query['weekdays'] = $filters['weekdays']; } if ( is_array( $query ) && ! empty( $filters['ageRange'] ) ) { $query['ageRange'] = $filters['ageRange']; } return array( 'schemaVersion' => 1, 'widgetId' => $widget_id, 'purpose' => $purpose, 'query' => $query, ) + ( ! empty( $filters['pageToken'] ) ? array( 'pageToken' => $filters['pageToken'] ) : array() ); } /** * Derive the cache key for a request body under the current generation and identity. * * @param array $body Request body. * @return string Transient name. */ private static function cache_key( $body ) { return 'akla_' . md5( wp_json_encode( array( get_option( 'akla_cache_generation', '' ), self::connection()['uuid'] ?? '', $body ) ) ); } /** * Check the preconditions shared by visitor renders, previews and scheduled refreshes. * * @param mixed $widget_id Widget id. * @param mixed $purpose Purpose. * @return true|WP_Error True when a catalog may be read. */ private static function catalog_allowed( $widget_id, $purpose ) { if ( ! self::connected() || ! empty( self::connection()['rotation'] ) ) { return new WP_Error( 'akla_disconnected', __( 'Connect this site to ActiKidz first.', 'actikidz-local-activities' ) ); } if ( ! in_array( $purpose, array( 'PREVIEW', 'LIVE' ), true ) || ! is_string( $widget_id ) || ! wp_is_uuid( $widget_id ) ) { return new WP_Error( 'akla_invalid_request', __( 'The request is not supported.', 'actikidz-local-activities' ) ); } return true; } /** * Read a purpose-specific snapshot. * * Visitor renders never wait on the service while usable cached data exists: due data is served and a * single background refresh is scheduled. Only a visitor render without usable data contacts the service, * with a bounded timeout (FRONT_TIMEOUT; BACK_TIMEOUT when the visitor chose filters), a per-snapshot lock * and a failure backoff. Administration, previews and cron refresh synchronously. * * Visitor filters (categories, weekdays, ageRange) read a separate snapshot of the same configuration. Each * module keeps at most FILTER_COMBOS_PER_MODULE of them, and visitor requests that must contact the service * for a filter (or through the public results route) are limited to FILTER_REQUESTS_PER_MINUTE site-wide. * * @param mixed $profile Profile. * @param mixed $widget_id Widget id. * @param mixed $purpose Purpose. * @param mixed $filters Visitor filters; normalized here and narrowed to the configuration. * * @return mixed Result or rendered output as documented by the caller. */ public static function catalog( $profile, $widget_id, $purpose, $filters = array() ) { $allowed = self::catalog_allowed( $widget_id, $purpose ); if ( is_wp_error( $allowed ) ) { return $allowed; } $page = is_string( $filters['pageToken'] ?? null ) && preg_match( '/^[A-Za-z0-9_.-]{1,1024}$/D', $filters['pageToken'] ) ? $filters['pageToken'] : ''; $filters = self::visitor_filters( $filters, is_array( $profile ) ? ( $profile['query'] ?? array() ) : array() ); if ( '' !== $page ) { $filters['pageToken'] = $page; } // Rebuild exactly this effective query on cron, even if its language/limit differs from the saved defaults. $filters['_context'] = array( 'language' => $profile['query']['language'] ?? 'en', 'limit' => $profile['query']['limit'] ?? 6, ); $body = self::catalog_body( $profile, $widget_id, $purpose, $filters ); $key = self::cache_key( $body ); self::register_profile_key( $profile, $widget_id, $key ); $result = self::snapshot( $profile, $widget_id, $purpose, $filters, $body, $key ); if ( ! empty( array_diff_key( $filters, array( '_context' => true ) ) ) && ! is_wp_error( $result ) ) { // Only combinations that produced data count (and may push out the least recently used one). self::touch_combination( $widget_id, $key ); } return $result; } /** * Read one snapshot for catalog(): cache, visitor path or synchronous refresh. * * @param mixed $profile Profile. * @param string $widget_id Widget id. * @param string $purpose Purpose. * @param array $filters Normalized visitor filters. * @param array $body Request body. * @param string $key Cache key. * @return array|WP_Error Catalog data. */ private static function snapshot( $profile, $widget_id, $purpose, $filters, $body, $key ) { $cached = get_transient( $key ); $now = time(); $usable = self::usable_cache( $cached, $now ); if ( $usable && $now < $cached['refresh_at'] ) { return self::cached_data( $cached, $now ); } if ( ! self::is_frontend( $purpose ) ) { return self::refresh( $body, $key, $cached, self::BACK_TIMEOUT, false ); } $backoff = self::backoff_active( $key, $now ); if ( $usable && ! self::$visitor_route ) { $profile_id = $backoff ? '' : self::profile_id( $profile ); if ( $backoff || '' !== $profile_id ) { if ( '' !== $profile_id ) { self::schedule_refresh( $profile_id, $widget_id, $purpose, null, $filters ); } $data = self::cached_data( $cached, $now ); if ( $backoff ) { $data['_stale'] = true; } return $data; } } if ( $backoff ) { self::schedule_retry( $profile, $widget_id, $purpose, $key, $filters ); return self::unavailable( self::backoff_remaining( $key, time() ) ); } // No usable data, or a configuration the background refresh cannot rebuild: one bounded visitor request. $limited = ! empty( array_diff_key( $filters, array( '_context' => true ) ) ) || self::$visitor_route; // A visitor who chose filters (page address or results route) is waiting for that answer and sees a loading // state, so an uncached combination may take BACK_TIMEOUT. Plain page renders keep FRONT_TIMEOUT. The lock, // backoff and per-minute limit apply the same way. $timeout = self::BACK_TIMEOUT; if ( ! self::$visitor_route ) { self::$page_deadline = self::$page_deadline ?? microtime( true ) + self::FRONT_TIMEOUT; $timeout = self::$page_deadline - microtime( true ); if ( $timeout < 0.1 ) { self::schedule_retry( $profile, $widget_id, $purpose, $key, $filters ); return self::unavailable(); } } $result = self::refresh( $body, $key, $cached, $timeout, true, $limited ); if ( is_wp_error( $result ) && 'akla_filter_limited' !== $result->get_error_code() ) { // Visitors only wait a bounded time. Without this, a city whose first answer takes longer would never be // cached: every visitor times out and backs off. The background refresh uses BACK_TIMEOUT. self::schedule_retry( $profile, $widget_id, $purpose, $key, $filters ); } return $result; } /** * Background refresh for one saved configuration (cron callback for REFRESH_HOOK). * * @param mixed $profile_id Saved configuration id. * @param mixed $widget_id Widget id. * @param mixed $purpose Purpose. * @param mixed $filters Visitor filters of a cached combination; it is refreshed only while still kept. * @return void */ public static function scheduled_refresh( $profile_id, $widget_id, $purpose, $filters = array() ) { $profiles = get_option( 'akla_profiles', array() ); if ( ! is_string( $profile_id ) || ! is_array( $profiles ) || ! isset( $profiles[ $profile_id ] ) || ! is_array( $profiles[ $profile_id ] ) ) { return; } if ( is_wp_error( self::catalog_allowed( $widget_id, $purpose ) ) ) { return; } $profile = self::normalized( $profiles[ $profile_id ] ); $context = AKLA_Query_Context::resolve( $profile, is_array( $filters['_context'] ?? null ) ? $filters['_context'] : array(), $widget_id ); $profile = $context['profile']; $profile['id'] = $profile_id; $page = $filters['pageToken'] ?? ''; $context_options = $filters['_context'] ?? array(); $filters = self::visitor_filters( $filters, $profile['query'] ?? array() ); if ( is_string( $page ) && preg_match( '/^[A-Za-z0-9_.-]{1,1024}$/D', $page ) ) { $filters['pageToken'] = $page; } $filters['_context'] = $context_options; $body = self::catalog_body( $profile, $widget_id, $purpose, $filters ); $key = self::cache_key( $body ); self::register_profile_key( $profile, $widget_id, $key ); if ( ! empty( array_diff_key( $filters, array( '_context' => true ) ) ) && ! self::combination_kept( $widget_id, $key ) ) { return; } $cached = get_transient( $key ); $now = time(); if ( ( self::usable_cache( $cached, $now ) && $now < $cached['refresh_at'] ) || self::backoff_active( $key, $now ) ) { return; } if ( ! self::capability( 'catalogWarmup' ) ) { return; } self::$warming = true; try { self::refresh( $body, $key, $cached, self::BACK_TIMEOUT, true ); } finally { self::$warming = false; } } /** * Record that a module used a filter combination; beyond the per-module limit, drop the least recently used one. * * Stores only cache key names and times: nothing about the visitor. * * @param string $widget_id Module (widget) id. * @param string $key Cache key of the filtered snapshot. * @return void */ private static function touch_combination( $widget_id, $key ) { $now = time(); $modules = get_option( self::FILTER_COMBOS_OPTION, array() ); $modules = is_array( $modules ) ? $modules : array(); $combos = isset( $modules[ $widget_id ] ) && is_array( $modules[ $widget_id ] ) ? $modules[ $widget_id ] : array(); if ( isset( $combos[ $key ] ) && (int) $combos[ $key ] > $now - MINUTE_IN_SECONDS ) { return; // Recently recorded: no write on every page view. } $combos[ $key ] = $now; asort( $combos ); foreach ( array_slice( array_keys( $combos ), 0, max( 0, count( $combos ) - self::FILTER_COMBOS_PER_MODULE ) ) as $oldest ) { unset( $combos[ $oldest ] ); self::forget_snapshot( $oldest ); } unset( $modules[ $widget_id ] ); $modules[ $widget_id ] = $combos; foreach ( array_slice( array_keys( $modules ), 0, max( 0, count( $modules ) - self::FILTER_MODULES ) ) as $dropped ) { foreach ( array_keys( (array) $modules[ $dropped ] ) as $old_key ) { self::forget_snapshot( $old_key ); } unset( $modules[ $dropped ] ); } update_option( self::FILTER_COMBOS_OPTION, $modules, false ); } /** * Whether a filter combination of a module is still kept (not dropped as least recently used). * * @param string $widget_id Module (widget) id. * @param string $key Cache key of the filtered snapshot. * @return bool True while kept. */ private static function combination_kept( $widget_id, $key ) { $modules = get_option( self::FILTER_COMBOS_OPTION, array() ); return is_array( $modules ) && isset( $modules[ $widget_id ][ $key ] ); } /** * Delete one cached snapshot and its backoff. * * @param string $key Cache key. * @return void */ private static function forget_snapshot( $key ) { if ( is_string( $key ) && str_starts_with( $key, 'akla_' ) ) { delete_transient( $key ); delete_transient( self::backoff_key( $key ) ); } } /** * Count one uncached visitor filter request against the site-wide per-minute limit. * * Counts requests only; never who made them (no address, browser or cookie is read or stored). * * @return bool Whether this request may contact the service. */ private static function take_filter_budget() { $now = time(); $minute = intdiv( $now, MINUTE_IN_SECONDS ); $rate = get_transient( self::FILTER_RATE_KEY ); $count = is_array( $rate ) && ( $rate['minute'] ?? null ) === $minute ? (int) ( $rate['count'] ?? 0 ) : 0; if ( $count >= self::FILTER_REQUESTS_PER_MINUTE ) { $limited = get_option( self::FILTER_LIMITED_OPTION, array() ); $limited = is_array( $limited ) ? $limited : array(); if ( ( $limited['minute'] ?? null ) !== $minute ) { // One write per limited minute, however many visitors are turned away in it. $recent = ! empty( $limited['last'] ) && (int) $limited['last'] > $now - DAY_IN_SECONDS; update_option( self::FILTER_LIMITED_OPTION, array( 'minute' => $minute, 'last' => $now, 'minutes' => ( $recent ? (int) ( $limited['minutes'] ?? 0 ) : 0 ) + 1, ), false ); } return false; } set_transient( self::FILTER_RATE_KEY, array( 'minute' => $minute, 'count' => $count + 1, ), 2 * MINUTE_IN_SECONDS ); return true; } /** * When visitors last met the per-minute filter limit, for Site Health. * * @return array{last: int, minutes: int}|null Last time and limited minutes in the 24 hours before it, or null. */ public static function filter_limit_status() { $limited = get_option( self::FILTER_LIMITED_OPTION, array() ); if ( ! is_array( $limited ) || empty( $limited['last'] ) ) { return null; } return array( 'last' => (int) $limited['last'], 'minutes' => max( 1, (int) ( $limited['minutes'] ?? 1 ) ), ); } /** * Find a saved configuration whose query matches, so a background refresh rebuilds this exact snapshot. * * @param mixed $profile Configuration being rendered. * @return string Saved configuration id, or an empty string when none matches. */ private static function profile_id( $profile ) { $profiles = get_option( 'akla_profiles', array() ); if ( ! is_array( $profiles ) || ! is_array( $profile ) || ! isset( $profile['query'] ) ) { return ''; } $candidates = isset( $profile['id'] ) && is_string( $profile['id'] ) ? array( $profile['id'] ) : array_keys( $profiles ); foreach ( $candidates as $id ) { if ( is_string( $id ) && wp_is_uuid( $id ) && isset( $profiles[ $id ]['query'] ) && is_array( $profiles[ $id ] ) && ( ( $profile['id'] ?? '' ) === $id || self::normalized( $profiles[ $id ] )['query'] === $profile['query'] ) ) { return $id; } } return ''; } /** * Read a stored configuration the same way the renderer does, so legacy configurations match their background refresh. * * @param array $profile Stored configuration. * @return array Normalized configuration. */ private static function normalized( array $profile ) { return class_exists( 'AKLA_Admin' ) ? AKLA_Admin::normalize_profile( $profile ) : $profile; } /** * Schedule one background refresh unless the same refresh is already queued. * * @param string $profile_id Saved configuration id. * @param string $widget_id Widget id. * @param string $purpose Purpose. * @param int $at Unix time to run at; now when omitted. * @param array $filters Normalized visitor filters; the event carries them only when there are some. * @return void */ private static function schedule_refresh( $profile_id, $widget_id, $purpose, $at = null, $filters = array() ) { $args = array( $profile_id, $widget_id, $purpose ); if ( ! empty( $filters ) ) { $args[] = $filters; } if ( false === wp_next_scheduled( self::REFRESH_HOOK, $args ) ) { wp_schedule_single_event( null === $at ? time() : (int) $at, self::REFRESH_HOOK, $args ); } } /** * After a visitor found no usable data and could not fetch it, queue one background refresh for when the * backoff ends (a refresh during the backoff would be skipped by scheduled_refresh()). * * @param mixed $profile Configuration being rendered. * @param string $widget_id Widget id. * @param string $purpose Purpose. * @param string $key Cache key. * @param array $filters Normalized visitor filters. * @return void */ private static function schedule_retry( $profile, $widget_id, $purpose, $key, $filters = array() ) { $profile_id = self::connected() ? self::profile_id( $profile ) : ''; if ( '' === $profile_id ) { return; } $backoff = get_transient( self::backoff_key( $key ) ); $at = is_array( $backoff ) && isset( $backoff['until'] ) ? max( time(), (int) $backoff['until'] ) : time(); self::schedule_refresh( $profile_id, $widget_id, $purpose, $at, $filters ); } /** * Visitor-safe error used when the service must not be contacted right now. * * @param int $retry_after Seconds until another request may contact the service. * @return WP_Error Temporary unavailability. */ private static function unavailable( $retry_after = 60 ) { return new WP_Error( 'akla_backoff', __( 'The service is temporarily unavailable. Please retry.', 'actikidz-local-activities' ), array( 'transient' => true, 'retryAfter' => max( 1, $retry_after ), ) ); } /** * Refresh one snapshot, optionally under the per-snapshot lock, with bounded stale fallback. * * @param array $body Request body. * @param string $key Cache key. * @param mixed $cached Current cache entry, if any. * @param int $timeout Seconds to wait for the service. * @param bool $use_lock Whether to skip the request when another process is refreshing this snapshot. * @param bool $limited Whether this request counts against the per-minute visitor filter limit. * @return array|WP_Error Catalog data. */ private static function refresh( $body, $key, $cached, $timeout, $use_lock, $limited = false ) { $now = time(); if ( $use_lock && ! self::acquire_lock( $key ) ) { return self::usable_cache( $cached, $now ) ? self::cached_data( $cached, $now ) : self::unavailable(); } if ( $limited && ! self::take_filter_budget() ) { if ( $use_lock ) { self::release_lock( $key ); } if ( self::usable_cache( $cached, $now ) ) { $data = self::cached_data( $cached, $now ); $data['_stale'] = true; return $data; } return new WP_Error( 'akla_filter_limited', __( 'Filtering is temporarily unavailable. Please try again later.', 'actikidz-local-activities' ), array( 'transient' => true ) ); } try { $result = self::fetch( $body, $key, $timeout ); } finally { if ( $use_lock ) { self::release_lock( $key ); } } if ( is_wp_error( $result ) ) { $error_data = (array) $result->get_error_data(); $error_data['retryAfter'] = max( (int) ( $error_data['retryAfter'] ?? 0 ), self::backoff_remaining( $key, time() ) ); $result->add_data( $error_data ); if ( self::connected() && ! empty( $error_data['transient'] ) && self::usable_cache( $cached, $now ) ) { $data = self::cached_data( $cached, $now ); $data['_stale'] = true; return $data; } } return $result; } /** * Request, validate and cache one snapshot. Every failure starts or extends the backoff. * * @param array $body Request body. * @param string $key Cache key. * @param int $timeout Seconds to wait for the service. * @return array|WP_Error Pruned catalog data. */ private static function fetch( $body, $key, $timeout ) { $now = time(); if ( self::$warming ) { $body['warmup'] = true; } $result = self::request( '/api/distribution/v1/catalog/query', 'POST', $body, true, null, $timeout ); if ( is_wp_error( $result ) ) { self::note_backoff( $key, $result->get_error_data()['retryAfter'] ?? 0 ); return $result; } if ( ! isset( $result['previewItems'], $result['snapshotId'], $result['sourceState'], $result['resultState'] ) || ! is_array( $result['previewItems'] ) || count( $result['previewItems'] ) > 12 ) { self::note_backoff( $key ); return new WP_Error( 'akla_invalid_response', __( 'The service returned an invalid response. Please retry.', 'actikidz-local-activities' ) ); } $course_ids = array(); foreach ( $result['previewItems'] as $item ) { if ( ! is_array( $item ) || ! is_int( $item['courseId'] ?? null ) || $item['courseId'] <= 0 || isset( $course_ids[ $item['courseId'] ] ) || ( isset( $item['ageLabel'] ) && ! is_string( $item['ageLabel'] ) ) || ( isset( $item['occurrences'] ) && ! self::valid_occurrences( $item['occurrences'] ) ) || ( array_key_exists( 'offerings', $item ) && ! self::valid_offerings( $item['offerings'] ) ) ) { self::note_backoff( $key ); return new WP_Error( 'akla_invalid_response', __( 'The service returned an invalid response. Please retry.', 'actikidz-local-activities' ) ); } $course_ids[ $item['courseId'] ] = true; } $hard = empty( $result['contentExpiresAt'] ) ? null : strtotime( $result['contentExpiresAt'] ); if ( ! empty( $result['previewItems'] ) && ( false === $hard || ( null !== $hard && $hard <= $now ) || ( null === $hard && count( array_filter( $result['previewItems'], array( self::class, 'pending_time' ) ) ) !== count( $result['previewItems'] ) ) ) ) { self::note_backoff( $key ); return new WP_Error( 'akla_expired', __( 'Course information is being refreshed. Please try again later.', 'actikidz-local-activities' ) ); } $until = min( $now + self::STALE_SECONDS, $hard ? $hard : $now + self::STALE_SECONDS ); $refresh = empty( $result['snapshotRefreshAt'] ) ? $now + self::FRESH_SECONDS : strtotime( $result['snapshotRefreshAt'] ); $data = self::prune( self::cache_fields( $result ), $now ); $entry = array( 'data' => $data, 'valid_until' => $until, 'refresh_at' => min( $now + self::FRESH_SECONDS, $refresh ? $refresh : $now ), 'prune_at' => self::next_prune_at( $data, $until ), ); if ( strlen( maybe_serialize( $entry ) ) > self::CACHE_ENTRY_BYTES ) { // Object caches such as Memcached drop large values silently: keep this answer for this request only. self::record_error( 'cache_too_large' ); self::note_backoff( $key ); return $data; } set_transient( $key, $entry, max( 1, $until - $now ) ); delete_transient( self::backoff_key( $key ) ); $keys = get_option( 'akla_cache_keys', array() ); $keys = is_array( $keys ) ? $keys : array(); $keys[ $key ] = $until; $keys = array_filter( $keys, function ( $expiry ) use ( $now ) { return $expiry > $now; } ); foreach ( array_slice( array_keys( $keys ), 0, max( 0, count( $keys ) - 256 ) ) as $old_key ) { delete_transient( $old_key ); } update_option( 'akla_cache_keys', array_slice( $keys, -256, null, true ), false ); return $data; } /** * Keep only the snapshot fields the renderer reads. * * @param array $result Validated service response. * @return array Trimmed snapshot. */ private static function cache_fields( $result ) { $snapshot = array(); foreach ( $result as $field => $value ) { if ( 'previewItems' !== $field && ( is_scalar( $value ) || null === $value || ( in_array( $field, self::SNAPSHOT_LISTS, true ) && is_array( $value ) ) ) ) { $snapshot[ $field ] = $value; } } $snapshot['facets'] = array( 'categories' => self::category_facets( $result['facets']['categories'] ?? null ) ); $snapshot['ageRanges'] = self::age_ranges( $result['ageRanges'] ?? null ); $page = $result['pagination'] ?? null; if ( is_array( $page ) && is_bool( $page['hasNext'] ?? null ) && is_int( $page['pageSize'] ?? null ) && $page['pageSize'] >= 1 && $page['pageSize'] <= 12 && is_int( $page['returnedCount'] ?? null ) && $page['returnedCount'] >= 0 && $page['returnedCount'] <= $page['pageSize'] && count( $result['previewItems'] ) === $page['returnedCount'] && ( $page['hasNext'] || null === ( $page['nextPageToken'] ?? null ) ) && ( ! $page['hasNext'] || ( is_string( $page['nextPageToken'] ?? null ) && preg_match( '/^[A-Za-z0-9_.-]{1,1024}$/D', $page['nextPageToken'] ) ) ) ) { $snapshot['pagination'] = array_intersect_key( $page, array_flip( array( 'hasNext', 'nextPageToken', 'pageSize', 'returnedCount' ) ) ); } $snapshot['previewItems'] = array(); foreach ( $result['previewItems'] as $item ) { $item = array_intersect_key( $item, array_flip( self::ITEM_FIELDS ) ); foreach ( array( 'categories', 'categoryLabels' ) as $list ) { if ( array_key_exists( $list, $item ) ) { $item[ $list ] = self::text_list( $item[ $list ] ); } } if ( isset( $item['occurrences'] ) && is_array( $item['occurrences'] ) ) { $item['occurrences'] = self::trim_occurrences( $item['occurrences'] ); } if ( array_key_exists( 'dataLicense', $item ) ) { $license = self::data_license( $item['dataLicense'] ); if ( null === $license ) { unset( $item['dataLicense'] ); } else { $item['dataLicense'] = $license; } } if ( isset( $item['offerings'] ) && is_array( $item['offerings'] ) ) { foreach ( $item['offerings'] as $index => $offering ) { $offering = array_intersect_key( $offering, array_flip( self::OFFERING_FIELDS ) ); if ( isset( $offering['occurrences'] ) && is_array( $offering['occurrences'] ) ) { $offering['occurrences'] = self::trim_occurrences( $offering['occurrences'] ); } $item['offerings'][ $index ] = $offering; } } $snapshot['previewItems'][] = $item; } return $snapshot; } /** * Keep an open-data licence (name, https licence URL, prescribed attribution statement) as plain strings. * * The attribution is shown verbatim, so it is only trimmed and length-bounded, never rewritten. * * @param mixed $license Service CourseItem.dataLicense. * @return array|null name / url / attribution (each string or null), or null without a name and attribution. */ private static function data_license( $license ) { if ( ! is_array( $license ) ) { return null; } $text = static function ( $value, $max ) { return is_string( $value ) && '' !== trim( $value ) ? mb_substr( trim( $value ), 0, $max ) : null; }; $name = $text( $license['name'] ?? null, 200 ); $attribution = $text( $license['attribution'] ?? null, 1000 ); if ( null === $name && null === $attribution ) { return null; } $url = $license['url'] ?? null; $url = is_string( $url ) && strlen( $url ) <= 500 && str_starts_with( strtolower( $url ), 'https://' ) ? $url : null; return array( 'name' => $name, 'url' => $url, 'attribution' => $attribution, ); } /** * Keep well-formed platform category facets: code, display name and course count, in the service's order. * * @param mixed $facets Service facets.categories. * @return array[] Facets with code, label and count. */ private static function category_facets( $facets ) { $kept = array(); foreach ( is_array( $facets ) ? $facets : array() as $facet ) { if ( ! is_array( $facet ) || ! is_string( $facet['code'] ?? null ) || ! preg_match( '/^[a-z0-9_-]{1,64}$/D', $facet['code'] ) || ! is_string( $facet['label'] ?? null ) || '' === trim( $facet['label'] ) || ! is_int( $facet['count'] ?? null ) || $facet['count'] < 0 ) { continue; } $kept[ $facet['code'] ] = array( 'code' => $facet['code'], 'label' => mb_substr( $facet['label'], 0, 120 ), 'count' => $facet['count'], ); } return array_slice( array_values( $kept ), 0, self::MAX_FACETS ); } /** * Keep well-formed platform age range choices (value and display name), in the service's order. * * @param mixed $ranges Service ageRanges. * @return array[] Choices with value and label. */ private static function age_ranges( $ranges ) { $kept = array(); foreach ( is_array( $ranges ) ? $ranges : array() as $range ) { if ( ! is_array( $range ) || ! is_string( $range['value'] ?? null ) || ! preg_match( '/^[0-9A-Za-z+_-]{1,32}$/D', $range['value'] ) || ! is_string( $range['label'] ?? null ) || '' === trim( $range['label'] ) ) { continue; } $kept[ $range['value'] ] = array( 'value' => $range['value'], 'label' => mb_substr( $range['label'], 0, 120 ), ); } return array_slice( array_values( $kept ), 0, self::MAX_FACETS ); } /** * A bounded list of short texts (category codes or names). * * @param mixed $values Raw list. * @return string[] Up to 16 non-empty strings of at most 120 characters, in order. */ private static function text_list( $values ) { $kept = array(); foreach ( is_array( $values ) ? $values : array() as $value ) { if ( is_string( $value ) && '' !== trim( $value ) ) { $kept[] = mb_substr( $value, 0, 120 ); } } return array_slice( $kept, 0, self::MAX_FILTER_CATEGORIES ); } /** * Keep only the session fields the renderer reads. * * @param array $sessions Validated sessions. * @return array Trimmed sessions. */ private static function trim_occurrences( $sessions ) { return array_map( function ( $session ) { return array_intersect_key( $session, array_flip( self::OCCURRENCE_FIELDS ) ); }, $sessions ); } /** * Earliest instant at which pruning could remove a course or session from this snapshot. * * @param array $data Pruned snapshot. * @param int $until Cache hard cutoff. * @return int Unix instant. */ private static function next_prune_at( $data, $until ) { $next = $until; foreach ( $data['previewItems'] as $item ) { $instants = array( $item['contentExpiresAt'] ?? null ); if ( ! empty( $item['occurrences'] ) ) { $instants = array_merge( $instants, array_column( $item['occurrences'], 'endsAt' ) ); } else { $instants[] = $item['endsAt'] ?? null; } foreach ( $instants as $instant ) { $time = is_string( $instant ) ? strtotime( $instant ) : false; if ( false !== $time && $time < $next ) { $next = $time; } } } return $next; } /** * Return cached data, recomputing the prune only after a course or session could have ended. * * @param array $cached Usable cache entry. * @param int $now Current Unix instant. * @return array Catalog data. */ private static function cached_data( $cached, $now ) { if ( isset( $cached['prune_at'] ) && $now < $cached['prune_at'] ) { return $cached['data']; } return self::prune( $cached['data'], $now ); } /** * Backoff transient name for a cache key. * * @param string $key Cache key. * @return string Transient name. */ private static function backoff_key( $key ) { return 'akla_n_' . substr( $key, 5 ); } /** * Whether visitors must not contact the service for this snapshot yet. * * @param string $key Cache key. * @param int $now Current Unix instant. * @return bool True during the backoff window. */ private static function backoff_active( $key, $now ) { return self::backoff_remaining( $key, $now ) > 0; } /** * Remaining wait shared by the service guard and browser retry metadata. * * @param string $key Cache key. * @param int $now Current Unix instant. * @return int Seconds before contacting the service is allowed. */ private static function backoff_remaining( $key, $now ) { $backoff = get_transient( self::backoff_key( $key ) ); return is_array( $backoff ) && isset( $backoff['until'] ) ? max( 0, (int) $backoff['until'] - $now ) : 0; } /** * Start or double the visitor backoff for a snapshot: 60, 120, 240, then 300 seconds. * * The streak is kept for an hour after the last failure so consecutive failures keep doubling. * * @param string $key Cache key. * @param int $retry_after Upstream minimum retry interval in seconds. * @return void */ private static function note_backoff( $key, $retry_after = 0 ) { $previous = get_transient( self::backoff_key( $key ) ); $streak = is_array( $previous ) && isset( $previous['streak'] ) ? min( 10, (int) $previous['streak'] + 1 ) : 1; $seconds = (int) min( self::NEGATIVE_MAX_SECONDS, self::NEGATIVE_MIN_SECONDS * ( 2 ** ( $streak - 1 ) ) ); $seconds = max( $seconds, min( DAY_IN_SECONDS, (int) $retry_after ) ); set_transient( self::backoff_key( $key ), array( 'until' => time() + $seconds, 'streak' => $streak, 'seconds' => $seconds, ), HOUR_IN_SECONDS ); } /** * Take the 30-second refresh lock for one snapshot. * * With a persistent object cache, wp_cache_add() is an atomic add (Redis SET NX / Memcached add). * Otherwise add_option() refuses an existing row, which makes concurrent refreshes rare but not * impossible: two processes reading "absent" in the same instant may both refresh once. That costs one * extra service request, never wrong output. An expired lock left by a crashed process is replaced. * * @param string $key Cache key. * @return bool Whether this process holds the lock. */ private static function acquire_lock( $key ) { $name = self::LOCK_PREFIX . substr( $key, 5 ); $now = time(); $expires = $now + self::LOCK_SECONDS; if ( wp_using_ext_object_cache() ) { return wp_cache_add( $name, $expires, 'akla', self::LOCK_SECONDS ); } if ( add_option( $name, $expires, '', false ) ) { return true; } if ( (int) get_option( $name, 0 ) > $now ) { return false; } delete_option( $name ); return add_option( $name, $expires, '', false ); } /** * Release a refresh lock held by this process. * * @param string $key Cache key. * @return void */ private static function release_lock( $key ) { $name = self::LOCK_PREFIX . substr( $key, 5 ); if ( wp_using_ext_object_cache() ) { wp_cache_delete( $name, 'akla' ); return; } delete_option( $name ); } /** * Check the local snapshot hard cutoff. * * @param mixed $cached Cached. * @param mixed $now Now. * * @return mixed Result or rendered output as documented by the caller. */ private static function usable_cache( $cached, $now ) { return is_array( $cached ) && isset( $cached['data'], $cached['valid_until'], $cached['refresh_at'] ) && $cached['valid_until'] > $now; } /** * Check bounded occurrence facts before caching an extended catalog response. * * @param mixed $sessions Occurrence list. * @return bool Whether every occurrence has a valid interval and zone. */ private static function valid_occurrences( $sessions ) { if ( ! is_array( $sessions ) || count( $sessions ) > 512 || ! array_is_list( $sessions ) ) { return false; } foreach ( $sessions as $session ) { if ( ! is_array( $session ) || ! is_string( $session['startsAt'] ?? null ) || ! is_string( $session['endsAt'] ?? null ) || ! is_string( $session['timeZone'] ?? null ) ) { return false; } $start = strtotime( $session['startsAt'] ); $end = strtotime( $session['endsAt'] ); if ( false === $start || false === $end || $end <= $start || ( isset( $session['venue'] ) && ! is_string( $session['venue'] ) ) || ( isset( $session['price'] ) && ! is_numeric( $session['price'] ) ) || ( isset( $session['currency'] ) && ! is_string( $session['currency'] ) ) ) { return false; } try { new DateTimeZone( $session['timeZone'] ); } catch ( Exception $error ) { return false; } } return true; } /** * Validate one nullable JSON monetary amount without coercing text or booleans. * * @param mixed $amount Amount. * @return bool Whether the amount is finite and nonnegative. */ private static function valid_amount( $amount ) { return null === $amount || ( ( is_int( $amount ) || is_float( $amount ) ) && is_finite( (float) $amount ) && $amount >= 0 ); } /** * Validate independent schedule groups within a course before caching. * * @param mixed $offerings Schedule groups. * @return bool Whether identifiers, intervals and prices form a bounded contract. */ private static function valid_offerings( $offerings ) { if ( ! is_array( $offerings ) || ! array_is_list( $offerings ) || count( $offerings ) > 512 ) { return false; } $ids = array(); $count = 0; foreach ( $offerings as $offering ) { if ( ! is_array( $offering ) || ! is_int( $offering['scheduleId'] ?? null ) || $offering['scheduleId'] <= 0 || isset( $ids[ $offering['scheduleId'] ] ) || ! self::valid_occurrences( array( $offering ) ) || empty( $offering['occurrences'] ) || ! self::valid_occurrences( $offering['occurrences'] ) || ! self::valid_amount( $offering['price'] ?? null ) || ! self::valid_amount( $offering['priceMax'] ?? null ) || ( isset( $offering['priceMax'] ) && ( ! isset( $offering['price'] ) || $offering['priceMax'] < $offering['price'] ) ) || ( isset( $offering['priceUnit'] ) && ! is_string( $offering['priceUnit'] ) ) ) { return false; } foreach ( $offering['occurrences'] as $session ) { if ( ! self::valid_amount( $session['price'] ?? null ) || strtotime( $session['startsAt'] ) < strtotime( $offering['startsAt'] ) || strtotime( $session['endsAt'] ) > strtotime( $offering['endsAt'] ) || $session['timeZone'] !== $offering['timeZone'] ) { return false; } } $ids[ $offering['scheduleId'] ] = true; $count += count( $offering['occurrences'] ); if ( $count > 512 ) { return false; } } return true; } /** * Keep unended sessions, ordered by their source instants. * * @param array $sessions Validated occurrence list. * @param int $now Current Unix instant. * @return array Remaining occurrences without cross-schedule deduplication. */ private static function future_occurrences( $sessions, $now ) { $future = array_values( array_filter( $sessions, function ( $session ) use ( $now ) { return strtotime( $session['endsAt'] ) > $now; } ) ); usort( $future, function ( $left, $right ) { return strtotime( $left['startsAt'] ) <=> strtotime( $right['startsAt'] ); } ); return $future; } /** * Recognize the explicit pending-time contract without treating missing data as valid. * * @param array $item Course item. * @return bool Whether dates are explicitly pending. */ public static function pending_time( $item ) { return is_array( $item ) && array_key_exists( 'startsAt', $item ) && null === $item['startsAt'] && array_key_exists( 'endsAt', $item ) && null === $item['endsAt'] && array_key_exists( 'contentExpiresAt', $item ) && null === $item['contentExpiresAt'] && array() === ( $item['occurrences'] ?? null ) && array() === ( $item['offerings'] ?? null ); } /** * Keep pending-time items and remove known expired entries. * * @param array $data Catalog response. * @param int $now Current Unix instant. * @return array Pruned catalog response. */ public static function prune( $data, $now ) { $items = array(); foreach ( $data['previewItems'] as $item ) { if ( ! is_array( $item ) ) { continue; } if ( self::pending_time( $item ) ) { $items[] = $item; continue; } $expiry = ! empty( $item['contentExpiresAt'] ) ? strtotime( $item['contentExpiresAt'] ) : false; if ( false === $expiry || $expiry <= $now ) { continue; } $sessions = null; if ( array_key_exists( 'offerings', $item ) && ! self::valid_offerings( $item['offerings'] ) ) { continue; } if ( ! empty( $item['offerings'] ) ) { $groups = array(); $sessions = array(); foreach ( $item['offerings'] as $offering ) { $offering['occurrences'] = self::future_occurrences( $offering['occurrences'], $now ); if ( empty( $offering['occurrences'] ) ) { continue; } $first = $offering['occurrences'][0]; $offering['startsAt'] = $first['startsAt']; $offering['endsAt'] = $first['endsAt']; foreach ( $offering['occurrences'] as $session ) { if ( strtotime( $session['endsAt'] ) > strtotime( $offering['endsAt'] ) ) { $offering['endsAt'] = $session['endsAt']; } } $groups[] = $offering; $sessions = array_merge( $sessions, $offering['occurrences'] ); } $item['offerings'] = $groups; } elseif ( ! empty( $item['occurrences'] ) ) { if ( ! self::valid_occurrences( $item['occurrences'] ) ) { continue; } $sessions = self::future_occurrences( $item['occurrences'], $now ); } if ( null !== $sessions ) { if ( empty( $sessions ) ) { continue; } $sessions = self::future_occurrences( $sessions, $now ); $item['occurrences'] = $sessions; $item['startsAt'] = $sessions[0]['startsAt']; $item['endsAt'] = $sessions[0]['endsAt']; $item['timeZone'] = $sessions[0]['timeZone']; } else { $end = ! empty( $item['endsAt'] ) ? strtotime( $item['endsAt'] ) : false; if ( false === $end || $end <= $now ) { continue; } } $items[] = $item; } $data['previewItems'] = $items; return $data; } /** * Invalidate this installation cache and its generation. * * @return mixed Result or rendered output as documented by the caller. */ public static function clear_cache() { foreach ( array_keys( (array) get_option( 'akla_cache_keys', array() ) ) as $key ) { if ( str_starts_with( $key, 'akla_' ) ) { delete_transient( $key ); } } update_option( 'akla_cache_keys', array(), false ); update_option( 'akla_cache_generation', wp_generate_uuid4(), false ); delete_option( self::FILTER_COMBOS_OPTION ); delete_option( 'akla_profile_cache' ); delete_transient( 'akla_capabilities' ); } /** * Read a cached optional capability; old services remain disabled. * * @param string $name Capability name. * @return mixed Result documented by the caller. */ public static function capability( $name ) { $data = get_transient( 'akla_capabilities' ); if ( ! is_array( $data ) ) { $data = self::request( '/api/public/distribution/v1/capabilities' ); $data = is_wp_error( $data ) ? array() : $data; set_transient( 'akla_capabilities', $data, 300 ); } return true === ( $data[ $name ] ?? false ); } /** * Register a bounded set of cache keys owned by this configuration. * * @param array $profile Effective profile. * @param string $widget Widget identity. * @param string $key Snapshot cache key. * @return mixed Result documented by the caller. */ private static function register_profile_key( $profile, $widget, $key ) { $id = $profile['id'] ?? ''; if ( ! wp_is_uuid( $id ) ) { return; } $registry = (array) get_option( 'akla_profile_cache', array() ); $keys = (array) ( $registry[ $id ] ?? array() ); if ( isset( $keys[ $key ] ) ) { return; } // A warm hit must not update an option on every page view. $keys[ $key ] = time(); if ( count( $keys ) > 128 ) { asort( $keys ); foreach ( array_slice( array_keys( $keys ), 0, count( $keys ) - 128 ) as $old ) { self::forget_snapshot( $old ); unset( $keys[ $old ] ); } } $registry[ $id ] = $keys; update_option( 'akla_profile_cache', array_slice( $registry, -20, null, true ), false ); } /** * Invalidate this configuration without clearing other modules. * * @param string $id Configuration UUID. * @return mixed Result documented by the caller. */ public static function clear_profile_cache( $id ) { $registry = (array) get_option( 'akla_profile_cache', array() ); foreach ( array_keys( (array) ( $registry[ $id ] ?? array() ) ) as $key ) { self::forget_snapshot( $key ); } unset( $registry[ $id ] ); update_option( 'akla_profile_cache', $registry, false ); } /** * Prepare a LIVE snapshot without recording a visitor. * * @param string $id Configuration UUID. * @return mixed Result documented by the caller. */ public static function prepare( $id ) { $profiles = (array) get_option( 'akla_profiles', array() ); if ( ! isset( $profiles[ $id ] ) || ! self::capability( 'catalogWarmup' ) ) { return self::unavailable(); } $profile = AKLA_Admin::normalize_profile( $profiles[ $id ] ); $profile['id'] = $id; self::$warming = true; try { return self::catalog( $profile, $profile['widget_id'], 'LIVE' ); } finally { self::$warming = false; } } }