settings = $settings; $this->scanner = $scanner; $this->inventory = $inventory; } /** * Hook the routes. */ public function register(): void { add_action( 'rest_api_init', array( $this, 'register_routes' ) ); } /** * Register the routes. */ public function register_routes(): void { register_rest_route( self::NAMESPACE, self::ROUTE, array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get' ), 'permission_callback' => array( $this, 'can_manage' ), ), array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'save' ), 'permission_callback' => array( $this, 'can_manage' ), ), ) ); foreach ( array( 'run', 'assign', 'ignore', 'restore', 'bump' ) as $action ) { register_rest_route( self::NAMESPACE, self::ROUTE . '/' . $action, array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, $action ), 'permission_callback' => array( $this, 'can_manage' ), ) ); } } /** * Whether the current user may manage the plugin. */ public function can_manage(): bool { return current_user_can( Capabilities::MANAGE ); } /** * Current scan status, inventory and settings. */ public function get(): WP_REST_Response { return new WP_REST_Response( $this->payload() ); } /** * Save the runtime collection switch. * * @param WP_REST_Request $request Request whose JSON body holds `runtime_collect`. * @return WP_REST_Response|WP_Error */ public function save( WP_REST_Request $request ) { $body = $request->get_json_params(); if ( ! is_array( $body ) ) { return self::invalid_body(); } $errors = array(); foreach ( array_keys( array_diff_key( $body, array( 'runtime_collect' => true ) ) ) as $field ) { $errors[ 'scanner.' . $field ] = __( 'This setting cannot be changed here.', 'consentfold' ); } if ( array() !== $errors ) { return self::invalid_settings( $errors ); } if ( array_key_exists( 'runtime_collect', $body ) && ! $this->locked( 'scanner.runtime_collect' ) ) { $site = $this->settings->get_site(); $scanner = is_array( $site['scanner'] ?? null ) ? $site['scanner'] : array(); $error = $this->store( array_merge( $site, array( 'scanner' => array_merge( $scanner, array( 'runtime_collect' => $body['runtime_collect'] ) ) ) ) ); if ( null !== $error ) { return $error; } } return new WP_REST_Response( $this->payload() ); } /** * Run the scan for a few seconds: begin a new one, or continue the one in progress. A scan another run is working * on is left alone: the response says where it stands. * * @param WP_REST_Request $request Request whose JSON body may hold `resume`. */ public function run( WP_REST_Request $request ): WP_REST_Response { $body = $request->get_json_params(); $resume = is_array( $body ) && true === ( $body['resume'] ?? false ); if ( $resume && ! $this->scanner->running() ) { return new WP_REST_Response( $this->payload() ); } if ( ! $resume ) { $this->scanner->start(); } $this->scanner->advance( self::RUN_BUDGET ); return new WP_REST_Response( $this->payload() ); } /** * Block an unclassified host: add a blocking rule for it under a category. * * @param WP_REST_Request $request Request whose JSON body holds `key` (the host) and `category`. * @return WP_REST_Response|WP_Error */ public function assign( WP_REST_Request $request ) { $body = $request->get_json_params(); $key = is_array( $body ) && is_string( $body['key'] ?? null ) ? $body['key'] : ''; $category = is_array( $body ) && is_string( $body['category'] ?? null ) ? $body['category'] : ''; if ( ! isset( $this->inventory->get()['unclassified'][ $key ] ) ) { return self::unknown_item(); } if ( $this->locked( 'blocking.user_rules' ) ) { return self::locked_error(); } $site = $this->settings->get_site(); $blocking = is_array( $site['blocking'] ?? null ) ? $site['blocking'] : array(); $rules = (array) $this->settings->get()['blocking']['user_rules']; $rules[] = array( 'pattern' => $key, 'type' => 'src', 'category' => $category, ); $error = $this->store( array_merge( $site, array( 'blocking' => array_merge( $blocking, array( 'user_rules' => $rules ) ) ) ) ); return $error ?? new WP_REST_Response( $this->payload() ); } /** * Stop listing an unclassified item. * * @param WP_REST_Request $request Request whose JSON body holds `key`. * @return WP_REST_Response|WP_Error */ public function ignore( WP_REST_Request $request ) { $body = $request->get_json_params(); $key = is_array( $body ) && is_string( $body['key'] ?? null ) ? $body['key'] : ''; if ( ! isset( $this->inventory->get()['unclassified'][ $key ] ) ) { return self::unknown_item(); } $this->inventory->ignore( $key ); return new WP_REST_Response( $this->payload() ); } /** * List an ignored item again. * * @param WP_REST_Request $request Request whose JSON body holds `key`. * @return WP_REST_Response|WP_Error */ public function restore( WP_REST_Request $request ) { $body = $request->get_json_params(); $key = is_array( $body ) && is_string( $body['key'] ?? null ) ? $body['key'] : ''; if ( ! in_array( $key, $this->inventory->ignored(), true ) ) { return self::unknown_item(); } $this->inventory->unignore( $key ); return new WP_REST_Response( $this->payload() ); } /** * Raise the banner version by one, which asks every visitor for consent again. Only on the admin's confirmation, * and only while the inventory suggests it. * * @param WP_REST_Request $request Request whose JSON body holds `confirm: true`. * @return WP_REST_Response|WP_Error */ public function bump( WP_REST_Request $request ) { $body = $request->get_json_params(); if ( ! is_array( $body ) || true !== ( $body['confirm'] ?? null ) ) { return new WP_Error( 'consentfold_confirm_required', __( 'Confirm that every visitor should be asked for consent again.', 'consentfold' ), array( 'status' => 400 ) ); } if ( $this->locked( 'banner_version' ) ) { return self::locked_error(); } if ( ! $this->inventory->get()['bump']['suggested'] ) { return new WP_Error( 'consentfold_no_bump', __( 'No new service needs consent, or the banner version was raised already.', 'consentfold' ), array( 'status' => 409 ) ); } $error = $this->store( array_merge( $this->settings->get_site(), array( 'banner_version' => (int) $this->settings->get()['banner_version'] + 1 ) ) ); return $error ?? new WP_REST_Response( $this->payload() ); } /** * Save a site document through the repository. * * @param array $document Settings document. * @return WP_Error|null Null when it was saved. */ private function store( array $document ): ?WP_Error { unset( $document['schema_version'] ); // The repository carries it over. try { $this->settings->set( $document ); } catch ( InvalidSettingsException $e ) { return self::invalid_settings( $e->errors() ); } return null; } /** * What the tab shows: the settings it edits, the locks, the categories a rule may target, the scan status and the inventory. * * @return array */ private function payload(): array { $settings = $this->settings->get(); $inventory = $this->inventory->get(); $registry = Categories::resolve( $settings ); $categories = array(); foreach ( $this->settings->rule_categories( $settings ) as $slug => $category ) { $categories[] = array( 'slug' => $slug, 'label' => $category['label'], ); } $services = array(); foreach ( $inventory['services'] as $service ) { $service['category_label'] = $registry[ $service['category'] ]['label'] ?? $service['category']; $services[] = $service; } return apply_filters( 'consentfold_scanner_payload', array( 'runtime_collect' => true === ( $settings['scanner']['runtime_collect'] ?? false ), 'banner_version' => (int) $settings['banner_version'], 'locked' => array_values( array_filter( self::LOCKABLE, fn ( string $path ): bool => $this->locked( $path ) ) ), 'categories' => $categories, 'status' => $this->scanner->status(), 'inventory' => array( 'built' => $inventory['built'], 'services' => $services, 'unclassified' => array_values( $inventory['unclassified'] ), 'ignored' => $this->inventory->ignored(), 'added' => $inventory['diff']['added'], 'bump' => $inventory['bump'], ), ) ); } /** * Whether the network locked a setting, by itself or through the branch it belongs to. * * @param string $path Dotted settings path. */ private function locked( string $path ): bool { foreach ( $this->settings->locked_keys() as $locked ) { if ( $path === $locked || 0 === strpos( $path, $locked . '.' ) ) { return true; } } return false; } /** * Error for a body that is no JSON object. */ private static function invalid_body(): WP_Error { return new WP_Error( 'consentfold_invalid_body', __( 'Send the scanner settings as a JSON object.', 'consentfold' ), array( 'status' => 400 ) ); } /** * Error for an item that is not in the list (any more). */ private static function unknown_item(): WP_Error { return new WP_Error( 'consentfold_unknown_item', __( 'That item is not in the list any more.', 'consentfold' ), array( 'status' => 404 ) ); } /** * Error for a setting the network locked. */ private static function locked_error(): WP_Error { return new WP_Error( 'consentfold_locked', __( 'The network administrator set this and it cannot be changed here.', 'consentfold' ), array( 'status' => 409 ) ); } /** * Error that carries the per-field messages. * * @param array $errors Messages keyed by dotted settings path. */ private static function invalid_settings( array $errors ): WP_Error { return new WP_Error( 'consentfold_invalid_settings', __( 'The scanner settings are not valid.', 'consentfold' ), array( 'status' => 400, 'errors' => $errors, ) ); } }