'GET', 'callback' => [self::class, 'render'], 'permission_callback' => '__return_true', ]); register_rest_route(self::NAMESPACE, '/verify', [ 'methods' => 'POST', 'callback' => [self::class, 'verify'], 'permission_callback' => fn() => current_user_can(self::CAPABILITY), ]); register_rest_route(self::NAMESPACE, '/events', [ 'methods' => 'GET', 'callback' => [self::class, 'events'], 'permission_callback' => fn() => current_user_can(self::CAPABILITY), ]); } public static function render(\WP_REST_Request $request): \WP_REST_Response { $request->set_param('_fields', null); if (self::limited(false)) return self::error(429); $input = self::validate((array) $request->get_query_params()); if ($input === null) return self::error(400); $eventId = $input['atts']['id']; if (!Shortcodes::published($eventId)) return self::error(404); Api::setMinTtl(self::MIN_TTL); if (Api::needsFetch($eventId) && self::limited(true)) return self::error(429); $input['atts'] = self::narrow($eventId, $input['atts']); $key = md5((string) wp_json_encode([ $input['type'], $input['atts'], get_post_modified_time('U', true, $eventId), determine_locale(), BLYZER_EVENTS_VERSION, Shortcodes::defaultTheme(), Shortcodes::brandingEnabled(), md5(serialize(get_option('blyzer_templates_global', []))), ])); $html = wp_cache_get($key, self::CACHE_GROUP); if (!is_string($html) || $html === '') { $html = Shortcodes::output($input['type'], $input['atts'], self::SENTINEL); if ($html === '') return self::error(404); if (!Api::degraded()) wp_cache_set($key, $html, self::CACHE_GROUP, max(self::MIN_TTL, Api::liveTtl())); } $html = (string) preg_replace( ['/\bbze-' . $eventId . '-' . self::SENTINEL . '(?![0-9])/', '/([?;&]n=)' . self::SENTINEL . '(?![0-9])/'], ['bze-' . $eventId . '-' . $input['n'], '${1}' . $input['n']], $html ); $response = new \WP_REST_Response(['html' => $html], 200); $response->header('Cache-Control', 'no-store'); $response->header('X-Robots-Tag', 'noindex'); return $response; } public static function verify(\WP_REST_Request $request): \WP_REST_Response { $postId = absint($request->get_param('id')); if ($postId && (get_post_type($postId) !== 'blyzer_event' || !current_user_can('edit_post', $postId))) { return self::error(404); } $key = preg_replace('/\s+/', '', (string) $request->get_param('key')); if ($key === '' || !Api::isValidKey($key)) { return new \WP_REST_Response(['status' => 'format', 'message' => __('The key may contain only letters and digits.', 'blyzer-events')], 200); } $limitKey = 'blyzer_verify_' . get_current_user_id() . '_' . (int) floor(time() / self::RATE_WINDOW); $count = (int) get_transient($limitKey); if ($count >= 10) { return new \WP_REST_Response(['status' => 'limit', 'message' => __('Too many checks. Wait a minute and try again.', 'blyzer-events')], 429); } set_transient($limitKey, $count + 1, self::RATE_WINDOW); $result = Api::probe($key); $info = Connection::describe($result['code'] > 0 ? $result['code'] : 500, $result['event']); $response = new \WP_REST_Response([ 'status' => $info['status'], 'message' => $info['message'], 'title' => $info['title'], 'teams' => $info['teams'], ], 200); $response->header('Cache-Control', 'no-store'); return $response; } public static function events(): array { $posts = get_posts([ 'post_type' => 'blyzer_event', 'post_status' => 'publish', 'numberposts' => 200, 'orderby' => 'title', 'order' => 'ASC', ]); return array_map(function ($post) { $status = Api::status((int) $post->ID); return [ 'id' => (int) $post->ID, 'title' => html_entity_decode(wp_strip_all_tags(get_the_title($post)), ENT_QUOTES, 'UTF-8'), 'ok' => $status === null ? null : ($status >= 200 && $status < 300 ? true : (in_array($status, [401, 403, 404], true) ? false : null)), ]; }, $posts); } public static function validate(array $params): ?array { $known = array_merge(['id', 'type', 'n'], Shortcodes::QUERY_KEYS); $input = []; foreach ($params as $key => $value) { if (!is_string($key) || !in_array($key, $known, true)) continue; if (!is_string($value) || strlen($value) > 200) return null; $input[$key] = $value; } if (!isset($input['id'], $input['type'])) return null; if (!preg_match('/^[1-9][0-9]{0,9}$/', $input['id'])) return null; if (!in_array($input['type'], Shortcodes::types(), true)) return null; $n = $input['n'] ?? '1'; if (!preg_match('/^[1-9][0-9]{0,2}$/', $n)) return null; $atts = ['id' => (int) $input['id']]; foreach (Shortcodes::QUERY_KEYS as $key) { if (!isset($input[$key])) continue; $value = $input[$key]; if (isset(Shortcodes::ENUMS[$key])) { $valid = in_array($value, Shortcodes::ENUMS[$key], true); } elseif ($key === 'upcoming') { $valid = $value === 'all' || ((bool) preg_match('/^[0-9]{1,3}$/', $value) && (int) $value <= 100); } elseif ($key === 'recent') { $valid = (bool) preg_match('/^[0-9]{1,3}$/', $value) && (int) $value <= 100; } elseif ($key === 'limit') { $valid = (bool) preg_match('/^[0-9]{1,4}$/', $value) && (int) $value <= 1000; } elseif ($key === 'team') { $valid = (bool) preg_match('/^[0-9]{1,10}$/', $value); } elseif ($key === 'group') { $valid = $value === sanitize_text_field($value) && mb_strlen($value) <= 50; } elseif ($key === 'sort') { $valid = $value === Data::statKey($value) && strlen($value) <= 20; } elseif ($key === 'sections') { $valid = !array_diff(explode(',', $value), Shortcodes::PANELS); } else { $valid = false; } if (!$valid) return null; $atts[$key] = $value; } return ['type' => $input['type'], 'n' => (int) $n, 'atts' => $atts]; } private static function narrow(int $eventId, array $atts): array { if (isset($atts['sections'])) { $sections = array_intersect(Shortcodes::PANELS, explode(',', $atts['sections'])); if ($sections) { $atts['sections'] = implode(',', $sections); } else { unset($atts['sections']); } } if (isset($atts['group'])) { $groups = []; foreach (Api::fetch($eventId, 'groups') as $group) { if (is_array($group)) $groups[] = mb_strtolower(Data::str($group['label'] ?? '')); } foreach (Api::fetch($eventId, 'matches') as $match) { if (is_array($match)) $groups[] = mb_strtolower(Data::str($match['groupLabel'] ?? '')); } if (!in_array(mb_strtolower($atts['group']), array_filter($groups), true)) unset($atts['group']); } if (isset($atts['team'])) { $teams = []; foreach (Api::fetch($eventId, 'teams') as $team) { if (is_array($team)) $teams[] = Data::str($team['id'] ?? ''); } if ((int) $atts['team'] === 0 || !in_array((string) (int) $atts['team'], $teams, true)) unset($atts['team']); } if (isset($atts['sort'])) { $keys = ['sum', 'name']; foreach ((array) (Api::meta($eventId, 'players')['statistics'] ?? []) as $statistic) { if (is_array($statistic)) $keys[] = Data::statKey(Data::str($statistic['label'] ?? '')); } if (!in_array($atts['sort'], $keys, true)) unset($atts['sort']); } ksort($atts); return $atts; } private static function clientKey(): string { $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : ''; $ip = (string) apply_filters('blyzer_events_client_ip', $ip); $packed = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? inet_pton($ip) : false; if ($packed !== false && strlen($packed) === 16) { $ip = bin2hex(substr($packed, 0, 8)) . '::/64'; } return substr(md5(wp_salt('nonce') . '|' . $ip), 0, 16); } private static function limited(bool $expensive): bool { $limit = $expensive ? (int) apply_filters('blyzer_events_rest_rate_limit', self::RATE_LIMIT) : (int) apply_filters('blyzer_events_rest_rate_limit_cached', self::RATE_LIMIT_CHEAP); if ($limit <= 0) return false; $client = self::clientKey(); $window = (int) floor(time() / self::RATE_WINDOW); if (wp_using_ext_object_cache()) { $key = ($expensive ? 'x' : 'c') . $client . $window; wp_cache_add($key, 0, 'blyzer_events_rl', self::RATE_WINDOW * 2); $count = (int) wp_cache_incr($key, 1, 'blyzer_events_rl'); return $count > $limit; } if (!$expensive) return false; $hits = get_transient('blyzer_rl'); $hits = is_array($hits) ? array_filter($hits, fn($hit) => is_array($hit) && ($hit[0] ?? 0) === $window) : []; $count = (int) ($hits[$client][1] ?? 0); if ($count >= $limit) return true; $hits[$client] = [$window, $count + 1]; if (count($hits) > self::RATE_SLOTS) $hits = array_slice($hits, -self::RATE_SLOTS, null, true); set_transient('blyzer_rl', $hits, self::RATE_WINDOW * 2); return false; } private static function error(int $status): \WP_REST_Response { $messages = [ 400 => __('Invalid request.', 'blyzer-events'), 404 => __('Not found.', 'blyzer-events'), 429 => __('Too many requests. Try again later.', 'blyzer-events'), ]; $response = new \WP_REST_Response([ 'code' => 'blyzer_events_' . $status, 'message' => $messages[$status] ?? $messages[400], 'data' => ['status' => $status], ], $status); if ($status === 429) $response->header('Retry-After', (string) self::RATE_WINDOW); $response->header('Cache-Control', 'no-store'); return $response; } }