100, 'consent_mode' => 90, 'cmp' => 70, 'ob_rewriter' => 60, 'policy' => 50, 'cron' => 40, 'https' => 30, 'scanner' => 20, 'languages' => 10, ); /** * The checks that read the home page. */ public const PAGE_CHECKS = array( 'consent_mode', 'cache', 'ob_rewriter' ); /** * Languages the plugin ships; `en_US` is the language of the source strings and needs no file. */ public const LANGUAGES = TextDomain::SHIPPED; /** * Well-known consent management plugins: basename => name and whether the Import tab can read their settings. Plugins * that answer the WP Consent API are found without this list; it adds the ones that do not. */ public const KNOWN_CMPS = array( 'cookie-law-info/cookie-law-info.php' => array( 'CookieYes', true ), 'complianz-gdpr/complianz-gpdr.php' => array( 'Complianz', true ), 'complianz-gdpr-premium/complianz-gpdr-premium.php' => array( 'Complianz', true ), 'cookiebot/cookiebot.php' => array( 'Cookiebot', false ), 'borlabs-cookie/borlabs-cookie.php' => array( 'Borlabs Cookie', false ), 'cookie-notice/cookie-notice.php' => array( 'Cookie Notice', false ), 'gdpr-cookie-compliance/moove-gdpr.php' => array( 'GDPR Cookie Compliance', false ), 'iubenda-cookie-law-solution/iubenda_cookie_law.php' => array( 'iubenda', false ), 'real-cookie-banner/index.php' => array( 'Real Cookie Banner', false ), 'cookie-script-com/cookie-script.php' => array( 'Cookie-Script', false ), ); /** * Seconds a scheduled task may be overdue before WP-Cron counts as not running. */ private const CRON_GRACE = DAY_IN_SECONDS; /** * Days after which the last scan counts as old. */ private const SCAN_MAX_AGE_DAYS = 30; /** * Settings repository. * * @var Repository */ private Repository $settings; /** * WP Consent API bridge. * * @var ConsentApiBridge */ private ConsentApiBridge $bridge; /** * Absolute path of the plugin directory, with trailing slash. * * @var string */ private string $dir; /** * Fetches the home page; replaced in tests. * * @var callable */ private $fetch; /** * Constructor. * * @param Repository $settings Settings repository. * @param ConsentApiBridge $bridge WP Consent API bridge. * @param string $dir Absolute path of the plugin directory, with trailing slash. * @param callable|null $fetch Returns `array{status: int, body: string, headers: array}` for the home page, or null when the site cannot be reached; the loopback request by default. */ public function __construct( Repository $settings, ConsentApiBridge $bridge, string $dir, ?callable $fetch = null ) { $this->settings = $settings; $this->bridge = $bridge; $this->dir = $dir; $this->fetch = $fetch ?? array( $this, 'fetch_home' ); } /** * Drop what was read of the home page when something that shapes it changes. */ public function register(): void { foreach ( array( 'consentfold_settings_saved', 'activated_plugin', 'deactivated_plugin', 'switch_theme' ) as $hook ) { add_action( $hook, array( $this, 'forget' ) ); } } /** * Forget what was read of the home page. */ public function forget(): void { delete_option( self::OPTION ); } /** * Every check, in listing order. * * @param bool $fetch Whether the home page may be fetched when nothing valid is kept. * @param bool $refresh Whether to fetch it again whatever is kept. * @return list */ public function results( bool $fetch = true, bool $refresh = false ): array { $page = $this->page( $fetch, $refresh ); $results = array(); foreach ( array_keys( self::SEVERITY ) as $id ) { $check = $this->evaluate( $id, $page ); if ( null !== $check ) { $results[] = $check; } } return $results; } /** * One check. * * @param string $id Key of {@see self::SEVERITY}. * @param bool $fetch Whether the home page may be fetched when nothing valid is kept. * @return array{id: string, label: string, status: string, message: string, fix: string, severity: int}|null Null for an unknown id, or a home page check with nothing to go on. */ public function check( string $id, bool $fetch = true ): ?array { return $this->evaluate( $id, in_array( $id, self::PAGE_CHECKS, true ) ? $this->page( $fetch, false ) : null ); } /** * The checks that did not pass, the most serious first: failures before warnings, then by severity. * * @param bool $fetch Whether the home page may be fetched when nothing valid is kept. * @return list */ public function problems( bool $fetch = false ): array { $problems = array_values( array_filter( $this->results( $fetch ), static fn ( array $check ): bool => self::PASS !== $check['status'] ) ); usort( $problems, static fn ( array $a, array $b ): int => array( self::FAIL === $b['status'], $b['severity'] ) <=> array( self::FAIL === $a['status'], $a['severity'] ) ); return $problems; } /** * The most serious check that did not pass, if any. * * @param bool $fetch Whether the home page may be fetched when nothing valid is kept. * @return array{id: string, label: string, status: string, message: string, fix: string, severity: int}|null */ public function worst( bool $fetch = false ): ?array { return $this->problems( $fetch )[0] ?? null; } /** * The chosen cookie policy page, 0 when none. */ public function policy_page_id(): int { return (int) ( $this->settings->get()['policy_page_id'] ?? 0 ); } /** * When the home page was last read, 0 when never. */ public function checked(): int { return $this->stored()['time'] ?? 0; } /** * Run one check. * * @param string $id Check id. * @param array{time: int, reachable: bool, code: int, consent_mode: string, config: bool|null, banner: bool|null, buffered: bool, cache: list}|null $page What the home page showed, null when it was not read. * @return array{id: string, label: string, status: string, message: string, fix: string, severity: int}|null */ private function evaluate( string $id, ?array $page ): ?array { if ( in_array( $id, self::PAGE_CHECKS, true ) ) { return null === $page ? null : $this->evaluate_page( $id, $page ); } switch ( $id ) { case 'cron': return $this->cron(); case 'cmp': return $this->cmp(); case 'policy': return $this->policy(); case 'scanner': return $this->scanner(); case 'https': return $this->https(); case 'languages': return $this->languages(); default: return null; } } /** * Run a check that reads the home page. * * @param string $id Check id. * @param array{time: int, reachable: bool, code: int, consent_mode: string, config: bool|null, banner: bool|null, buffered: bool, cache: list} $page What the home page showed. * @return array{id: string, label: string, status: string, message: string, fix: string, severity: int} */ private function evaluate_page( string $id, array $page ): array { $settings = $this->settings->get(); $labels = array( 'consent_mode' => __( 'Consent Mode default', 'consentfold' ), 'cache' => __( 'Banner on the home page', 'consentfold' ), 'ob_rewriter' => __( 'Output-buffer blocking', 'consentfold' ), ); // A check that has nothing to look at when its feature is off passes before the page is considered. if ( 'consent_mode' === $id && ! ( true === ( $settings['consent_mode']['enabled'] ?? false ) && in_array( $settings['consent_mode']['mode'] ?? 'off', array( 'gtag', 'gtm' ), true ) ) ) { return self::make( $id, $labels[ $id ], self::PASS, __( 'Google Consent Mode is off, so the page needs no default.', 'consentfold' ) ); } if ( 'ob_rewriter' === $id && ( true !== ( $settings['blocking']['output_buffer'] ?? false ) || 'none' === ( $settings['regime_mode'] ?? 'auto' ) ) ) { return self::make( $id, $labels[ $id ], self::PASS, __( 'The output-buffer layer of the blocking is off.', 'consentfold' ) ); } if ( ! $page['reachable'] ) { return self::make( $id, $labels[ $id ], self::WARN, 0 === $page['code'] ? __( 'The site could not load its own home page, so this could not be checked.', 'consentfold' ) /* translators: %d: HTTP status code. */ : sprintf( __( 'The site could not load its own home page (HTTP status %d), so this could not be checked.', 'consentfold' ), $page['code'] ), __( 'A firewall, a maintenance mode, HTTP authentication or a host that blocks a site from calling itself can cause this. Check again once the site can reach its own address.', 'consentfold' ) ); } switch ( $id ) { case 'consent_mode': return $this->consent_mode( $page['consent_mode'] ); case 'cache': return $this->banner( $page ); default: return $this->ob_rewriter( $page['buffered'] ); } } /** * WP-Cron: the tasks that came due in the last day have run. * * @return array{id: string, label: string, status: string, message: string, fix: string, severity: int} */ private function cron(): array { $label = __( 'WP-Cron', 'consentfold' ); $due = array_keys( wp_get_ready_cron_jobs() ); $overdue = array() === $due ? 0 : time() - (int) min( $due ); if ( $overdue <= self::CRON_GRACE ) { return self::make( 'cron', $label, self::PASS, __( 'Scheduled tasks run on time.', 'consentfold' ) ); } return self::make( 'cron', $label, self::FAIL, /* translators: %s: how long ago, e.g. "3 days". */ sprintf( __( 'WP-Cron has not run: a scheduled task has been due for %s.', 'consentfold' ), human_time_diff( time() - $overdue ) ), defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ? __( 'WP-Cron is switched off in wp-config.php (DISABLE_WP_CRON) and nothing runs it. Set up a system cron job that requests wp-cron.php every few minutes, or remove the constant.', 'consentfold' ) : __( 'Visits to the site start WP-Cron, and a site that gets few visits or blocks its own requests never runs it. Set up a system cron job that requests wp-cron.php every few minutes, and add define( \'DISABLE_WP_CRON\', true ); to wp-config.php.', 'consentfold' ) ); } /** * Another consent management plugin that answers the WP Consent API. * * @return array{id: string, label: string, status: string, message: string, fix: string, severity: int} */ private function cmp(): array { $label = __( 'Other consent plugins', 'consentfold' ); $found = array(); $conflicts = array(); $importable = false; foreach ( $this->bridge->conflicts() as $conflict ) { $found[ $conflict['plugin'] ] = $conflict['name']; $conflicts[] = $conflict['plugin']; } foreach ( ConsentApiBridge::active_plugins() as $plugin ) { if ( isset( self::KNOWN_CMPS[ $plugin ] ) ) { $found[ $plugin ] = $found[ $plugin ] ?? self::KNOWN_CMPS[ $plugin ][0]; $importable = $importable || self::KNOWN_CMPS[ $plugin ][1]; } } if ( array() === $found ) { return self::make( 'cmp', $label, self::PASS, __( 'No other consent management plugin is active.', 'consentfold' ) ); } $names = implode( ', ', array_unique( array_values( $found ) ) ); // Following another plugin only works for one that answers the WP Consent API. if ( 'other' === $this->bridge->authority() && array() === array_diff( array_keys( $found ), $conflicts ) ) { /* translators: %s: names of plugins. */ return self::make( 'cmp', $label, self::PASS, sprintf( __( '%s manages consent and Consentfold follows its decisions.', 'consentfold' ), $names ) ); } return self::make( 'cmp', $label, self::WARN, /* translators: %s: names of plugins. */ sprintf( __( 'Another consent management plugin is active: %s. Two banners ask visitors twice and can decide differently.', 'consentfold' ), $names ), __( 'Deactivate one of them.', 'consentfold' ) . ( $importable ? ' ' . __( 'To move CookieYes or Complianz settings over first, use the Import tab.', 'consentfold' ) : '' ) . ( array() !== $conflicts ? ' ' . __( 'To keep the other plugin and let Consentfold follow it, choose it as the one that decides on the Consent Mode tab.', 'consentfold' ) : '' ) ); } /** * The policy page exists, is published and appears in a menu. * * @return array{id: string, label: string, status: string, message: string, fix: string, severity: int} */ private function policy(): array { $label = __( 'Cookie policy page', 'consentfold' ); $id = $this->policy_page_id(); $fix = __( 'Choose or create the page on the Policy tab.', 'consentfold' ); if ( 0 === $id ) { return self::make( 'policy', $label, self::WARN, __( 'No cookie policy page is chosen.', 'consentfold' ), $fix ); } $page = get_post( $id ); if ( ! $page instanceof WP_Post || 'page' !== $page->post_type || 'trash' === $page->post_status ) { return self::make( 'policy', $label, self::FAIL, __( 'The cookie policy page is gone or in the trash.', 'consentfold' ), $fix ); } if ( 'publish' !== $page->post_status ) { return self::make( 'policy', $label, self::WARN, __( 'The cookie policy page is not published, so visitors cannot open it.', 'consentfold' ), __( 'Publish the page.', 'consentfold' ) ); } if ( ! $this->linked( $page ) ) { return self::make( 'policy', $label, self::WARN, __( 'The cookie policy page is published but is in no menu.', 'consentfold' ), __( 'Link the page from a menu or the footer, so visitors can find it.', 'consentfold' ) ); } return self::make( 'policy', $label, self::PASS, __( 'The cookie policy page is published and linked from a menu.', 'consentfold' ) ); } /** * The last scan is not too old. * * @return array{id: string, label: string, status: string, message: string, fix: string, severity: int} */ private function scanner(): array { $label = __( 'Scanner', 'consentfold' ); $last = (int) ( $this->settings->get()['scanner']['last_run'] ?? 0 ); $fix = (string) apply_filters( 'consentfold_scanner_health_fix', __( 'Run a scan on the Scanner tab.', 'consentfold' ) ); if ( 0 === $last ) { return self::make( 'scanner', $label, self::WARN, __( 'The site has not been scanned yet.', 'consentfold' ), $fix ); } if ( time() - $last > self::SCAN_MAX_AGE_DAYS * DAY_IN_SECONDS ) { /* translators: %s: how long ago, e.g. "2 days". */ return self::make( 'scanner', $label, self::WARN, sprintf( __( 'The last scan was %s ago.', 'consentfold' ), human_time_diff( $last ) ), $fix ); } /* translators: %s: how long ago, e.g. "2 days". */ return self::make( 'scanner', $label, self::PASS, sprintf( __( 'The last scan was %s ago.', 'consentfold' ), human_time_diff( $last ) ) ); } /** * The site is served over HTTPS, so the consent cookie can carry `Secure`. * * @return array{id: string, label: string, status: string, message: string, fix: string, severity: int} */ private function https(): array { $label = __( 'HTTPS', 'consentfold' ); return wp_is_using_https() ? self::make( 'https', $label, self::PASS, __( 'The site uses HTTPS, so the consent cookie is marked Secure.', 'consentfold' ) ) : self::make( 'https', $label, self::WARN, __( 'The site is not served over HTTPS, so the consent cookie cannot be marked Secure.', 'consentfold' ), __( 'Serve the site over HTTPS and set the WordPress Address and the Site Address to https:// addresses under Settings › General.', 'consentfold' ) ); } /** * The language files the plugin ships are all there. * * @return array{id: string, label: string, status: string, message: string, fix: string, severity: int} */ private function languages(): array { $label = __( 'Languages', 'consentfold' ); $missing = array_values( array_filter( self::LANGUAGES, fn ( string $locale ): bool => 'en_US' !== $locale && ! is_readable( $this->dir . 'languages/consentfold-' . $locale . '.mo' ) ) ); if ( array() === $missing ) { /* translators: %d: number of languages. */ return self::make( 'languages', $label, self::PASS, sprintf( __( 'All %d languages the plugin ships are present.', 'consentfold' ), count( self::LANGUAGES ) ) ); } return self::make( 'languages', $label, self::WARN, /* translators: %s: list of locales, e.g. "de_DE, fr_FR". */ sprintf( __( 'Language files are missing: %s.', 'consentfold' ), implode( ', ', $missing ) ), __( 'Reinstall the plugin: its languages folder is incomplete, and the banner and the policy block fall back to English.', 'consentfold' ) ); } /** * The Consent Mode default comes before every Google tag of the home page. * * @param string $found What {@see HomePage::inspect()} found: `ok`, `late` or `missing`. * @return array{id: string, label: string, status: string, message: string, fix: string, severity: int} */ private function consent_mode( string $found ): array { $label = __( 'Consent Mode default', 'consentfold' ); if ( 'ok' === $found ) { return self::make( 'consent_mode', $label, self::PASS, __( 'The home page sets the Consent Mode default before any Google tag.', 'consentfold' ) ); } if ( 'late' === $found ) { return self::make( 'consent_mode', $label, self::FAIL, __( 'A Google tag on the home page loads before the Consent Mode default, so it starts without the visitor\'s choice.', 'consentfold' ), __( 'Find the plugin or theme that prints the Google tag in the head, and let Consentfold block and load it, or have it print after wp_head priority 0. The Consent Mode tab can verify the result.', 'consentfold' ) ); } if ( 'unknown' === $found ) { return self::make( 'consent_mode', $label, self::WARN, __( 'The home page is too large or too complex to check for the Consent Mode default.', 'consentfold' ), __( 'The Consent Mode tab can verify the result in a browser.', 'consentfold' ) ); } return self::make( 'consent_mode', $label, self::FAIL, __( 'The home page has no Consent Mode default.', 'consentfold' ), __( 'Clear the page cache. If the snippet is still missing, the theme may not call wp_head(), or a plugin removes the script.', 'consentfold' ) ); } /** * The banner's config and script are on the home page, also when a page cache serves it. * * @param array{time: int, reachable: bool, code: int, consent_mode: string, config: bool|null, banner: bool|null, buffered: bool, cache: list} $page What the home page showed. * @return array{id: string, label: string, status: string, message: string, fix: string, severity: int} */ private function banner( array $page ): array { $label = __( 'Banner on the home page', 'consentfold' ); $cached = array() !== $page['cache']; if ( $page['config'] && $page['banner'] ) { return self::make( 'cache', $label, self::PASS, $cached ? __( 'A page cache or CDN is in front of the site, and the home page it serves carries the banner.', 'consentfold' ) : __( 'The home page carries the banner and its config.', 'consentfold' ) ); } // A combined or renamed script (script optimisers) hides the banner's file name, and a failed reading shows nothing. if ( null === $page['config'] || null === $page['banner'] || ( $page['config'] && ! $page['banner'] ) ) { return self::make( 'cache', $label, self::WARN, __( 'The banner script could not be confirmed on the home page: the page was too complex to read, or a plugin combined or renamed the script.', 'consentfold' ), __( 'Check that the banner shows on the home page in a private window. If a script optimiser is active, exclude the banner script from combining.', 'consentfold' ) ); } return self::make( 'cache', $label, self::FAIL, $cached ? __( 'A page cache or CDN serves the home page without the banner, so visitors get no consent request.', 'consentfold' ) : __( 'The home page does not carry the banner, so visitors get no consent request.', 'consentfold' ), $cached ? __( 'Clear the page cache. If the banner is still missing, exclude the banner script from cache optimisation (minifying, combining and delaying scripts).', 'consentfold' ) : __( 'Make sure the theme calls wp_head(), and that no optimisation plugin removes the banner script. On a source checkout, build the assets with npm run build.', 'consentfold' ) ); } /** * The output-buffer rewriter processed the home page. * * @param bool $buffered Whether the page carries the rewriter's marker. * @return array{id: string, label: string, status: string, message: string, fix: string, severity: int} */ private function ob_rewriter( bool $buffered ): array { $label = __( 'Output-buffer blocking', 'consentfold' ); if ( $buffered ) { return self::make( 'ob_rewriter', $label, self::PASS, __( 'The output-buffer layer processed the home page.', 'consentfold' ) ); } return self::make( 'ob_rewriter', $label, self::FAIL, __( 'The output-buffer layer did not process the home page, so scripts printed by hand are not blocked.', 'consentfold' ), __( 'Something sends output early (a theme or plugin that flushes, or a streaming setup), or a filter on consentfold_ob_skip skips the page. Remove the early flush, or turn the output-buffer layer off on the Blocking tab.', 'consentfold' ) ); } /** * Whether a menu links to the page: a classic menu item, or a block navigation that holds its id or a page list, or a theme menu location with no menu, which prints the page menu. * * @param WP_Post $page Published page. */ private function linked( WP_Post $page ): bool { foreach ( wp_get_nav_menus() as $menu ) { foreach ( (array) wp_get_nav_menu_items( $menu ) as $item ) { if ( (int) $item->object_id === $page->ID && 'post_type' === $item->type ) { return true; } } } $navigations = get_posts( // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.get_posts_get_posts -- Filters stay on, so the query is cached. array( 'post_type' => 'wp_navigation', 'post_status' => 'publish', 'numberposts' => 20, 'suppress_filters' => false, ) ); foreach ( $navigations as $navigation ) { // A page list block lists every published page, so it links this one too. if ( str_contains( $navigation->post_content, '