settings = $settings; $this->registry = $registry; $this->common_hosts_file = $common_hosts_file; $this->dir = $dir; $this->url = $url; $this->version = $version; } /** * Hook the route and the script. */ public function register(): void { add_action( 'rest_api_init', array( $this, 'register_route' ) ); add_action( 'wp_enqueue_scripts', array( $this, 'enqueue' ) ); } /** * Register the route. */ public function register_route(): void { $items = array( 'type' => 'array', 'maxItems' => self::MAX_BATCH, 'items' => array( 'type' => 'string', 'maxLength' => self::MAX_NAME, ), ); register_rest_route( self::NAMESPACE, self::ROUTE, array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'receive' ), 'permission_callback' => array( $this, 'can_collect' ), 'args' => array( 'cookies' => $items, 'storage' => $items, 'hosts' => $items, ), ) ); } /** * Whether the current user may send observations and collection is on. */ public function can_collect(): bool { return current_user_can( Capabilities::MANAGE ) && $this->enabled(); } /** * Add the collector to the front end for a user who may manage the plugin. */ public function enqueue(): void { $script = BuiltAsset::path( $this->dir, self::SCRIPT ); if ( ! $this->can_collect() || ! is_readable( $this->dir . $script ) ) { return; } wp_enqueue_script( self::HANDLE, $this->url . $script, array( Assets::HANDLE ), $this->version, array( 'strategy' => 'defer', 'in_footer' => true, ) ); wp_add_inline_script( self::HANDLE, 'window.consentfold_collector=' . wp_json_encode( array( 'url' => rest_url( self::NAMESPACE . self::ROUTE ), 'nonce' => wp_create_nonce( 'wp_rest' ), ) ) . ';', 'before' ); } /** * Keep a page's observations and update the inventory. * * @param WP_REST_Request $request Request with `cookies`, `storage` and `hosts` lists of strings. */ public function receive( WP_REST_Request $request ): WP_REST_Response { $stored = $this->stored(); $changed = false; $added = 0; $skipped = new Detector( array(), $this->own_and_common_hosts() ); foreach ( self::KINDS as $kind ) { $names = $request->get_param( $kind ); foreach ( is_array( $names ) ? array_slice( $names, 0, self::MAX_BATCH ) : array() as $name ) { $name = 'hosts' === $kind ? self::clean_host( $name ) : self::clean_name( $name ); if ( null === $name || ( 'hosts' === $kind && $skipped->skips( $name ) ) ) { continue; } if ( isset( $stored[ $kind ][ $name ] ) ) { if ( time() - $stored[ $kind ][ $name ] >= self::REFRESH ) { $stored[ $kind ][ $name ] = time(); $changed = true; } continue; } if ( count( $stored[ $kind ] ) < self::MAX_STORED ) { $stored[ $kind ][ $name ] = time(); ++$added; } } } if ( $added > 0 || $changed ) { update_option( self::OPTION, $stored + array( self::SEEN => true ), false ); } if ( $added > 0 ) { /** * Fires after the collector reported names or hosts it had not reported before. * * @param int $added Number of new names and hosts. */ do_action( 'consentfold_runtime_collected', $added ); } return new WP_REST_Response( array( 'added' => $added ) ); } /** * What the collector saw, matched to services: the keys of the services whose cookie names, storage keys or * script hosts were seen (and where), and the hosts no rule claims, as {@see StaticScanner} reports its findings. * * @return array{services: array>, hosts: array>} */ public function observed(): array { $stored = $this->stored(); $services = array(); $hosts = array(); $names = array(); foreach ( $this->registry->services() as $slug => $service ) { foreach ( $service['cookies'] as $cookie ) { $names[] = array( (string) $cookie['name'], (string) $slug ); } } foreach ( array( 'cookies' => 'runtime:cookie', 'storage' => 'runtime:storage', ) as $kind => $label ) { foreach ( array_keys( $stored[ $kind ] ) as $seen ) { foreach ( $names as [ $pattern, $slug ] ) { if ( self::name_matches( $pattern, (string) $seen ) ) { self::note( $services, $slug, $label ); } } } } $rules = array_filter( $this->registry->detection_rules(), static fn ( Rule $rule ): bool => Rule::SRC === $rule->type ); foreach ( array_keys( $stored['hosts'] ) as $host ) { $claimed = false; foreach ( $rules as $rule ) { if ( $rule->matches( 'https://' . $host . '/' ) || self::host_matches( $rule->pattern, (string) $host ) ) { self::note( $services, Detector::key( $rule ), 'runtime:host' ); $claimed = true; } } if ( ! $claimed ) { self::note( $hosts, (string) $host, 'runtime:host' ); } } return array( 'services' => $services, 'hosts' => $hosts, ); } /** * Whether `scanner.runtime_collect` is on. */ private function enabled(): bool { return true === ( $this->settings->get()['scanner']['runtime_collect'] ?? false ); } /** * Hosts that are never kept: the site's own and those of library CDNs. The reserved documentation domains * (`example.*`) stay: a page that really loads from one is worth reporting. * * @return list */ private function own_and_common_hosts(): array { $hosts = array_diff( Detector::common_hosts( $this->common_hosts_file ), array( 'example.com', 'example.org', 'example.net' ) ); foreach ( array( home_url(), site_url() ) as $url ) { $host = wp_parse_url( $url, PHP_URL_HOST ); if ( is_string( $host ) ) { $hosts[] = $host; } } return array_values( $hosts ); } /** * The stored observations: for each kind, name or host => time last seen. * * Entries not seen for {@see self::KEEP} are left out; the option drops them with its next write. * * @return array{cookies: array, storage: array, hosts: array} */ private function stored(): array { $option = get_option( self::OPTION, null ); $option = is_array( $option ) ? $option : array(); if ( array() !== $option && true !== ( $option[ self::SEEN ] ?? null ) ) { // Written before times meant "last seen": count everything as seen now, so nothing expires on upgrade. foreach ( self::KINDS as $kind ) { $option[ $kind ] = array_map( static fn (): int => time(), is_array( $option[ $kind ] ?? null ) ? $option[ $kind ] : array() ); } $option[ self::SEEN ] = true; update_option( self::OPTION, $option, false ); } $fresh = static fn ( $seen ): bool => is_int( $seen ) && $seen >= time() - self::KEEP; $kept = static fn ( $items ): array => is_array( $items ) ? array_filter( $items, $fresh ) : array(); return array( 'cookies' => $kept( $option['cookies'] ?? null ), 'storage' => $kept( $option['storage'] ?? null ), 'hosts' => $kept( $option['hosts'] ?? null ), ); } /** * A cookie name or storage key as plain text; null when it is empty, too long or has control characters. * * @param mixed $name Candidate. */ private static function clean_name( $name ): ?string { if ( ! is_string( $name ) || '' === $name || strlen( $name ) > self::MAX_NAME || 1 === preg_match( '/[\x00-\x1F\x7F]/', $name ) || '' === wp_check_invalid_utf8( $name ) ) { return null; } return $name; } /** * A host in lower case; null when it is not a plausible domain name. * * @param mixed $host Candidate. */ private static function clean_host( $host ): ?string { $host = is_string( $host ) ? strtolower( $host ) : ''; return strlen( $host ) <= self::MAX_HOST && 1 === preg_match( '/^[a-z0-9-]+(?:\.[a-z0-9-]+)+$/D', $host ) ? $host : null; } /** * Whether a cookie name pattern of the service database matches a name; a `*` at the end stands for any ending. * * @param string $pattern Pattern. * @param string $name Seen name. */ private static function name_matches( string $pattern, string $name ): bool { return str_ends_with( $pattern, '*' ) ? str_starts_with( $name, substr( $pattern, 0, -1 ) ) : $pattern === $name; } /** * Whether the host part of a `src` pattern (what comes before the first `/` or `?`) is the host or a parent domain of it, * so a pattern with a path such as `youtube.com/embed/` still claims the host the page loaded it from. * * @param string $pattern Lower-case `src` pattern. * @param string $host Seen host, lower case. */ private static function host_matches( string $pattern, string $host ): bool { $part = (string) strtok( $pattern, '/?' ); return str_contains( $part, '.' ) && ( $host === $part || str_ends_with( $host, '.' . $part ) ); } /** * Remember where a service or host was seen. * * @param array> $bucket Findings by key, changed in place. * @param string $key Service key or host. * @param string $label Where it was seen. */ private static function note( array &$bucket, string $key, string $label ): void { $places = $bucket[ $key ] ?? array(); if ( ! in_array( $label, $places, true ) ) { $places[] = $label; } $bucket[ $key ] = $places; } }