registry = $registry; $this->runtime = $runtime; } /** * Hook the events that change the inventory. */ public function register(): void { add_action( 'consentfold_banner_version_bumped', array( $this, 'clear_bump' ) ); add_action( 'consentfold_settings_saved', array( $this, 'settings_saved' ), 10, 2 ); add_action( 'consentfold_runtime_collected', array( $this, 'runtime_collected' ) ); } /** * The inventory built last; empty before the first scan. * * Services are keyed by slug (`user:` for an admin rule that belongs to no service): `slug`, `name`, * `provider`, `policy_url`, `category`, `source` (`db` or `user`), `origin` (`scan`, or `browsing` when only the runtime collector * saw it) and `found_in` (where it was seen, a few labels). Unclassified items are keyed by host: `key`, `type` and `found_in`. `diff` is what the last rebuild * changed. `bump` says whether bumping the banner version is suggested, for which services and since when. * * @return array{built: int, services: array>, unclassified: array>, diff: array{added: list, removed: list, unclassified_added: list, unclassified_removed: list}, bump: array{suggested: bool, services: list, since: int}} */ public function get(): array { $stored = get_option( self::OPTION, null ); $stored = is_array( $stored ) ? $stored : array(); return array( 'built' => (int) ( $stored['built'] ?? 0 ), 'services' => is_array( $stored['services'] ?? null ) ? $stored['services'] : array(), 'unclassified' => is_array( $stored['unclassified'] ?? null ) ? $stored['unclassified'] : array(), 'diff' => array_merge( self::no_diff(), is_array( $stored['diff'] ?? null ) ? $stored['diff'] : array() ), 'bump' => array_merge( self::no_bump(), is_array( $stored['bump'] ?? null ) ? $stored['bump'] : array() ), ); } /** * The keys the admin ignored. * * @return list */ public function ignored(): array { $ignored = get_option( self::IGNORED_OPTION, array() ); return is_array( $ignored ) ? array_values( array_filter( $ignored, 'is_string' ) ) : array(); } /** * Merge the scan findings with the service database and the admin's rules, diff against the previous inventory * and store the result. Fires `consentfold_inventory_changed` when a service appeared or disappeared. * * @param bool $keep_marks Keep the earlier diff's marks too, for a rebuild that no full scan caused. * @return array The new inventory, see {@see self::get()}. */ public function rebuild( bool $keep_marks = false ): array { $previous = get_option( self::OPTION, null ); $known = $this->registry->services(); $rules = $this->registry->detection_rules(); $observed = $this->observed(); $ignored = $this->ignored(); $services = array(); foreach ( array_keys( $observed['services'] ) as $key ) { if ( isset( $known[ $key ] ) ) { $services[ $key ] = array( 'slug' => $key, 'name' => $known[ $key ]['name'], 'provider' => $known[ $key ]['provider'], 'policy_url' => $known[ $key ]['policy_url'], 'category' => $known[ $key ]['category'], 'source' => 'db', 'origin' => in_array( $key, $observed['browsing'], true ) ? 'browsing' : 'scan', 'found_in' => $observed['services'][ $key ], ); } } foreach ( $rules as $rule ) { $key = Detector::key( $rule ); if ( '' === $rule->service && ! isset( $services[ $key ] ) ) { $services[ $key ] = array( 'slug' => $key, 'name' => $rule->pattern, 'provider' => '', 'policy_url' => '', 'category' => $this->registry->effective_category( $rule->category ), 'source' => 'user', 'origin' => in_array( $key, $observed['browsing'], true ) ? 'browsing' : 'scan', 'found_in' => $observed['services'][ $key ] ?? array(), ); } } $claims = new Detector( $rules ); $unclassified = array(); foreach ( $observed['hosts'] as $host => $places ) { if ( ! in_array( $host, $ignored, true ) && ! $claims->claims( $host ) ) { $unclassified[ $host ] = array( 'key' => $host, 'type' => 'host', 'found_in' => $places, ); } } ksort( $services ); ksort( $unclassified ); $diff = self::diff( $previous, $services, $unclassified ); $inventory = array( 'built' => time(), 'services' => $services, 'unclassified' => $unclassified, 'diff' => $keep_marks ? self::carry( $previous, $diff, $services, $unclassified ) : $diff, 'bump' => self::bump( $previous, $services, $diff['added'] ), ); update_option( self::OPTION, $inventory, false ); if ( array() !== $diff['added'] || array() !== $diff['removed'] ) { /** * Fires after a rebuild found services that are new or gone since the previous inventory. * * @param array $inventory The new inventory. * @param array> $diff `added` and `removed` service keys, and `unclassified_added` and * `unclassified_removed` hosts. */ do_action( 'consentfold_inventory_changed', $inventory, $diff ); } return $inventory; } /** * Stop listing an unclassified item. * * @param string $key Key of the item, its host. */ public function ignore( string $key ): void { $ignored = $this->ignored(); if ( '' === $key || strlen( $key ) > 255 || in_array( $key, $ignored, true ) || count( $ignored ) >= self::MAX_IGNORED ) { return; } $ignored[] = $key; update_option( self::IGNORED_OPTION, $ignored, false ); $stored = get_option( self::OPTION, null ); if ( is_array( $stored ) && isset( $stored['unclassified'][ $key ] ) ) { unset( $stored['unclassified'][ $key ] ); update_option( self::OPTION, $stored, false ); } } /** * List an ignored item again. * * @param string $key Key of the item, its host. */ public function unignore( string $key ): void { $ignored = $this->ignored(); if ( in_array( $key, $ignored, true ) ) { update_option( self::IGNORED_OPTION, array_values( array_diff( $ignored, array( $key ) ) ), false ); $this->rebuild( true ); } } /** * Forget the suggestion to bump the banner version, because it was bumped. */ public function clear_bump(): void { $stored = get_option( self::OPTION, null ); if ( is_array( $stored ) ) { $stored['bump'] = self::no_bump(); update_option( self::OPTION, $stored, false ); } } /** * Rebuild once the admin's rules changed, so the inventory lists them without waiting for the next scan. * * @param array $clean Stored settings. * @param array $previous Previously stored settings. */ public function settings_saved( array $clean, array $previous ): void { if ( null !== get_option( self::OPTION, null ) && ( $clean['blocking']['user_rules'] ?? null ) !== ( $previous['blocking']['user_rules'] ?? null ) ) { $this->rebuild( true ); } } /** * Rebuild once the runtime collector reported something new, when a scan has built the inventory before. */ public function runtime_collected(): void { if ( null !== get_option( self::OPTION, null ) ) { $this->rebuild( true ); } } /** * What the scans saw: the static scan's findings, joined by the runtime collector's. * * @return array{services: array>, hosts: array>, browsing: list} `browsing` lists the services only the collector saw. */ private function observed(): array { $static = get_option( StaticScanner::RESULTS_OPTION, null ); $observed = array( 'services' => is_array( $static ) && is_array( $static['services'] ?? null ) ? $static['services'] : array(), 'hosts' => is_array( $static ) && is_array( $static['hosts'] ?? null ) ? $static['hosts'] : array(), 'browsing' => array(), ); foreach ( null === $this->runtime ? array() : $this->runtime->observed() as $kind => $found ) { foreach ( $found as $key => $places ) { if ( 'services' === $kind && ! isset( $observed['services'][ $key ] ) ) { $observed['browsing'][] = (string) $key; } $observed[ $kind ][ $key ] = array_slice( array_values( array_unique( array_merge( $observed[ $kind ][ $key ] ?? array(), $places ) ) ), 0, 5 ); } } return $observed; } /** * What changed against the previous inventory; nothing when there is none. * * @param mixed $previous Stored previous inventory. * @param array> $services New services. * @param array> $unclassified New unclassified items. * @return array{added: list, removed: list, unclassified_added: list, unclassified_removed: list} */ private static function diff( $previous, array $services, array $unclassified ): array { if ( ! is_array( $previous ) ) { return self::no_diff(); } $was_services = is_array( $previous['services'] ?? null ) ? array_keys( $previous['services'] ) : array(); $was_unclassified = is_array( $previous['unclassified'] ?? null ) ? array_keys( $previous['unclassified'] ) : array(); return array( 'added' => array_map( 'strval', array_values( array_diff( array_keys( $services ), $was_services ) ) ), 'removed' => array_map( 'strval', array_values( array_diff( $was_services, array_keys( $services ) ) ) ), 'unclassified_added' => array_map( 'strval', array_values( array_diff( array_keys( $unclassified ), $was_unclassified ) ) ), 'unclassified_removed' => array_map( 'strval', array_values( array_diff( $was_unclassified, array_keys( $unclassified ) ) ) ), ); } /** * The diff with the marks of the previous inventory's diff that still hold. * * @param mixed $previous Stored previous inventory. * @param array> $diff What this rebuild changed. * @param array> $services New services. * @param array> $unclassified New unclassified items. * @return array{added: list, removed: list, unclassified_added: list, unclassified_removed: list} */ private static function carry( $previous, array $diff, array $services, array $unclassified ): array { $was = array_merge( self::no_diff(), is_array( $previous ) && is_array( $previous['diff'] ?? null ) ? $previous['diff'] : array() ); $merge = static fn ( string $kind, callable $keep ): array => array_values( array_filter( array_unique( array_merge( $was[ $kind ], $diff[ $kind ] ) ), $keep ) ); return array( 'added' => $merge( 'added', static fn ( string $key ): bool => isset( $services[ $key ] ) ), 'removed' => $merge( 'removed', static fn ( string $key ): bool => ! isset( $services[ $key ] ) ), 'unclassified_added' => $merge( 'unclassified_added', static fn ( string $key ): bool => isset( $unclassified[ $key ] ) ), 'unclassified_removed' => $merge( 'unclassified_removed', static fn ( string $key ): bool => ! isset( $unclassified[ $key ] ) ), ); } /** * The bump suggestion after a rebuild: the earlier one without the services that are gone, extended by the added * services that need consent. Nothing is suggested once none is left. * * @param mixed $previous Stored previous inventory. * @param array> $services New services. * @param array $added Keys of the added services. * @return array{suggested: bool, services: list, since: int} */ private static function bump( $previous, array $services, array $added ): array { $bump = array_merge( self::no_bump(), is_array( $previous ) && is_array( $previous['bump'] ?? null ) ? $previous['bump'] : array() ); $bump['services'] = array_values( array_filter( $bump['services'], static fn ( string $key ): bool => isset( $services[ $key ] ) ) ); if ( array() === $bump['services'] ) { $bump = self::no_bump(); } $needing_consent = array_values( array_filter( $added, static fn ( string $key ): bool => Categories::NECESSARY !== $services[ $key ]['category'] ) ); if ( array() === $needing_consent ) { return $bump; } return array( 'suggested' => true, 'services' => array_values( array_unique( array_merge( $bump['services'], $needing_consent ) ) ), 'since' => $bump['since'] > 0 ? $bump['since'] : time(), ); } /** * An empty diff. * * @return array{added: list, removed: list, unclassified_added: list, unclassified_removed: list} */ private static function no_diff(): array { return array( 'added' => array(), 'removed' => array(), 'unclassified_added' => array(), 'unclassified_removed' => array(), ); } /** * No suggestion. * * @return array{suggested: bool, services: list, since: int} */ private static function no_bump(): array { return array( 'suggested' => false, 'services' => array(), 'since' => 0, ); } }