registry = $registry; $this->policy = $policy; $this->server = $server; $this->htaccess = $htaccess; $this->nginx = $nginx; $this->publisher = $publisher; $this->onboarding = $onboarding; $this->table = $table; $this->declarations = $declarations; $this->auditor = $auditor; $this->record = $record; } /** * Registers hooks. * * @return void */ public function register(): void { add_action( 'admin_menu', array( $this, 'add_menu' ) ); add_action( 'admin_post_aiblackout_save', array( $this, 'handle_save' ) ); add_action( 'admin_post_aiblackout_save_crawlers', array( $this, 'handle_save_crawlers' ) ); add_action( 'admin_post_aiblackout_self_test', array( $this, 'handle_self_test' ) ); } /** * Adds the menu item. * * @return void */ public function add_menu(): void { $title = __( 'AI Blackout', 'ai-blackout' ); /** * Filters the top-level menu title. * * @since 2.0.0 * * @param string $title The menu title. */ $menu_title = (string) apply_filters( 'aiblackout_menu_title', $title ); add_menu_page( $title, $menu_title, self::CAPABILITY, self::SLUG, array( $this, 'render' ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Not obfuscation: core only accepts a menu icon as a data:image/svg+xml;base64 URI, and the markup is built from a readable constant above. 'data:image/svg+xml;base64,' . base64_encode( self::icon_svg( self::MENU_ICON_COLOR ) ), 80 ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restoring core's own value to what an undecorated label would have produced; nothing else reads or writes this key. $GLOBALS['admin_page_hooks'][ self::SLUG ] = sanitize_title( $title ); add_submenu_page( self::SLUG, __( 'AI Blackout', 'ai-blackout' ), __( 'Your content', 'ai-blackout' ), self::CAPABILITY, self::SLUG, array( $this, 'render' ) ); } /** * Returns the brand glyph as inline SVG markup. * * @param string $fill Any CSS colour value. Defaults to currentColor. * @return string SVG markup. */ public static function icon_svg( string $fill = 'currentColor' ): string { return sprintf( '', $fill, self::MENU_ICON_PATH ); } /** * Returns the URL of this screen. * * @param array $args Optional query arguments to add. * @param string $tab Optional tab to land on. Defaults to the first. * @return string Admin URL. */ public static function url( array $args = array(), string $tab = '' ): string { $url = admin_url( 'admin.php?page=' . self::SLUG ); $url = empty( $args ) ? $url : add_query_arg( $args, $url ); return '' === $tab ? $url : $url . '#' . $tab; } /** * Whether the screen being rendered is one of this plugin's. * * @return bool True on an AI Blackout screen. */ public static function is_own_screen(): bool { if ( ! function_exists( 'get_current_screen' ) ) { return false; } $screen = get_current_screen(); return $screen instanceof \WP_Screen && false !== strpos( $screen->id, self::SCREEN_MATCH ); } /** * Refuses the request when the current user may not act here. * * @param string $message Message shown on refusal. * @return void */ private function require_capability( string $message ): void { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html( $message ), '', array( 'response' => 403 ) ); } } /** * Returns the transient key holding this user's last self-test. * * @return string Transient key. */ private function get_result_key(): string { return 'aiblackout_self_test_' . get_current_user_id(); } /** * Returns the transient key holding this user's last refusals. * * @return string Transient key. */ private function get_refusal_key(): string { return 'aiblackout_refusals_' . get_current_user_id(); } /** * Saves the choice, then makes the files match it. * * @return void */ public function handle_save(): void { $this->require_capability( __( 'You do not have permission to change these settings.', 'ai-blackout' ) ); check_admin_referer( self::NONCE_SAVE ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- check_admin_referer() ran above; the choice is validated against a fixed list inside apply_choice(). $posted = isset( $_POST['aiblackout'] ) && is_array( $_POST['aiblackout'] ) ? wp_unslash( $_POST['aiblackout'] ) : array(); $problems = array(); if ( ! empty( $posted['choice'] ) ) { $problems = $this->onboarding->apply_choice( sanitize_key( $posted['choice'] ), ! empty( $posted['accept_search_loss'] ) ); } $this->finish_save( $problems, self::TAB_PROTECTION ); } /** * Saves the Advanced tab's per-crawler choices, then makes the files * match. * * @return void */ public function handle_save_crawlers(): void { $this->require_capability( __( 'You do not have permission to change these settings.', 'ai-blackout' ) ); check_admin_referer( self::NONCE_SAVE_CRAWLERS ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- check_admin_referer() ran above; every value is validated against the bundled registry inside CrawlerTable::save(). $posted = isset( $_POST['aiblackout'] ) && is_array( $_POST['aiblackout'] ) ? wp_unslash( $_POST['aiblackout'] ) : array(); $this->finish_save( $this->table->save( $posted ), self::TAB_ADVANCED ); } /** * The half of a save both forms share: write the files, then report. * * @param string[] $problems Anything the model already refused. * @param string $tab Tab to land back on. * @return void */ private function finish_save( array $problems, string $tab ): void { $problems = array_merge( $problems, $this->publisher->publish() ); $this->record->record_daily_check(); $args = array( 'aiblackout_saved' => '1' ); if ( ! empty( $problems ) ) { set_transient( $this->get_refusal_key(), $problems, 5 * MINUTE_IN_SECONDS ); $args['aiblackout_problems'] = '1'; } wp_safe_redirect( self::url( $args, $tab ) ); exit; } /** * Runs the self-test and redirects back. * * @return void */ public function handle_self_test(): void { $this->require_capability( __( 'You do not have permission to run this test.', 'ai-blackout' ) ); check_admin_referer( self::NONCE_SELF_TEST ); set_transient( $this->get_result_key(), $this->auditor->report( true ), 5 * MINUTE_IN_SECONDS ); wp_safe_redirect( self::url( array( 'aiblackout_tested' => '1' ), self::TAB_ADVANCED ) ); exit; } /** * Renders the screen. * * @return void */ public function render(): void { $this->require_capability( __( 'You do not have permission to view this page.', 'ai-blackout' ) ); $problems = $this->publisher->sync(); $tested = isset( $_GET['aiblackout_tested'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display-only flag; the test itself is nonce-checked in handle_self_test(). $stored = $tested ? get_transient( $this->get_result_key() ) : false; $report = is_array( $stored ) ? $stored : $this->auditor->report( false ); echo '
'; printf( '

%2$s

', esc_url( AIBLACKOUT_URL . 'assets/brand-mark.svg' ), esc_html__( 'AI Blackout', 'ai-blackout' ) ); $this->render_notices(); $this->render_sync_problems( $problems ); if ( ! $this->registry->is_available() ) { printf( '

%s

', esc_html__( 'The bundled crawler list could not be read, so no crawler can be blocked. Reinstalling AI Blackout will replace the missing file.', 'ai-blackout' ) ); return; } $this->render_tab_nav(); printf( '
', esc_attr( self::TAB_PROTECTION ) ); $this->render_state( $report ); $this->render_attention( $report ); $this->render_server_gap(); $this->render_record(); $this->render_choice(); echo '
'; printf( '
', esc_attr( self::TAB_DECLARATIONS ) ); $this->declarations->render_sections(); echo '
'; printf( '
', esc_attr( self::TAB_ADVANCED ) ); $this->render_advanced( $report, is_array( $stored ) ); echo '
'; echo ''; } /** * Prints the tab bar. * * @return void */ private function render_tab_nav(): void { $tabs = array( self::TAB_PROTECTION => __( 'Protection', 'ai-blackout' ), self::TAB_DECLARATIONS => __( 'Legal declaration', 'ai-blackout' ), self::TAB_ADVANCED => __( 'Advanced', 'ai-blackout' ), ); echo ''; } /** * Prints what is true right now, in one card. * * @param array $report Auditor report. * @return void */ private function render_state( array $report ): void { $summary = $report['summary']; printf( '
', esc_attr( $summary['status'] ) ); echo '
'; printf( '', esc_attr( Result::icon_for( $summary['status'] ) ) ); echo '
'; printf( '

%s

', esc_html( $summary['headline'] ) ); printf( '

%s

', esc_html( $this->protection_line() ) ); if ( aiblackout_is_disabled() ) { printf( '

%s

', esc_html( $summary['action'] ) ); } echo '
'; $this->render_state_layers( $report['results'] ); echo '
'; } /** * Prints the plain-language list of what is doing the protecting. * * @param array $results Result data from Auditor::report(). * @return void */ private function render_state_layers( array $results ): void { if ( aiblackout_is_disabled() ) { return; } $names = array( 'htaccess' => __( 'Your server', 'ai-blackout' ), 'php_gate' => __( 'WordPress', 'ai-blackout' ), 'robots_txt' => __( 'robots.txt', 'ai-blackout' ), 'declarations' => __( 'Your legal declaration', 'ai-blackout' ), 'blackout' => __( 'AI switched off inside WordPress', 'ai-blackout' ), ); $live = array(); foreach ( $results as $result ) { $enforcing = Result::PROTECTED_STATE === $result['status']; if ( ! isset( $names[ $result['id'] ] ) || ( ! $enforcing && Result::DECLARED !== $result['status'] ) ) { continue; } $live[] = sprintf( '%2$s', $enforcing ? 'enforcing' : 'declaring', esc_html( $names[ $result['id'] ] ) ); } if ( empty( $live ) ) { return; } printf( '
%1$s%2$s
', esc_html__( 'Protecting you right now:', 'ai-blackout' ), implode( '', $live ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Fixed markup around names escaped as each was added. ); } /** * Prints anything that needs attention, in full, at the top of the screen. * * @param array $report Auditor report. * @return void */ private function render_attention( array $report ): void { if ( aiblackout_is_disabled() ) { return; } $needs = array(); foreach ( $report['results'] as $result ) { if ( in_array( $result['status'], array( Result::BROKEN, Result::INERT, Result::UNKNOWN ), true ) ) { $needs[] = $result; } } if ( empty( $needs ) ) { return; } echo '
'; printf( '

%s

', esc_html( sprintf( /* translators: %s: Number of findings. */ _n( '%s thing needs your attention', '%s things need your attention', count( $needs ), 'ai-blackout' ), number_format_i18n( count( $needs ) ) ) ) ); foreach ( $needs as $result ) { echo '
'; printf( '

%s

', esc_html( $result['summary'] ) ); printf( '

%s

', esc_html( $result['consequence'] ) ); if ( '' !== $result['next_action'] ) { printf( '

%1$s %2$s

', esc_html__( 'What to do:', 'ai-blackout' ), esc_html( $result['next_action'] ) ); } echo '
'; } echo '
'; } /** * Returns the concrete, countable version of the verdict. * * @return string One sentence, or two. */ private function protection_line(): string { if ( aiblackout_is_disabled() ) { return __( 'The AIBLACKOUT_DISABLE setting in wp-config.php turns off everything this plugin does.', 'ai-blackout' ); } $blocked = count( $this->policy->agents_with( array( Policy::BLOCK ) ) ); $asked = count( $this->policy->agents_with( array( Policy::SIGNAL_ONLY ) ) ); if ( 0 === $blocked && 0 === $asked ) { return __( 'No AI crawler is being refused or asked to stay away. Your legal declaration is still published.', 'ai-blackout' ); } if ( 0 === $blocked ) { return sprintf( /* translators: %s: Number of crawlers. */ _n( '%s AI crawler is asked in robots.txt to stay away. Nothing is being refused, which is what you chose.', '%s AI crawlers are asked in robots.txt to stay away. Nothing is being refused, which is what you chose.', $asked, 'ai-blackout' ), number_format_i18n( $asked ) ); } if ( $this->htaccess->is_applied() ) { return sprintf( /* translators: %s: Number of crawlers. */ _n( '%s AI crawler is refused by your server, before WordPress runs — so pages served from a cache are covered too.', '%s AI crawlers are refused by your server, before WordPress runs — so pages served from a cache are covered too.', $blocked, 'ai-blackout' ), number_format_i18n( $blocked ) ); } return sprintf( /* translators: %s: Number of crawlers. */ _n( '%s AI crawler is refused by WordPress. Pages served from a cache are not covered, because WordPress never runs for them.', '%s AI crawlers are refused by WordPress. Pages served from a cache are not covered, because WordPress never runs for them.', $blocked, 'ai-blackout' ), number_format_i18n( $blocked ) ); } /** * Prints the one thing the plugin genuinely cannot do for the user. * * @return void */ private function render_server_gap(): void { if ( aiblackout_is_disabled() || $this->server->supports_htaccess() ) { return; } if ( empty( $this->policy->agents_to_enforce() ) ) { return; } echo '
'; printf( '

%s

', esc_html__( 'One line needs adding by hand', 'ai-blackout' ) ); printf( '

%s

', esc_html( $this->server->is_nginx() ? __( 'Your site runs on nginx, which keeps its configuration outside your site folder. Changing it needs root access and a server restart, so no WordPress plugin can do it for you — any plugin that says it blocks crawlers at the server on nginx is not telling you the truth.', 'ai-blackout' ) : __( 'We could not confirm which web server this site runs on, so nothing has been written to it. Blocking is still happening inside WordPress.', 'ai-blackout' ) ) ); echo '
'; printf( '%s', esc_html__( 'The rule, and a message you can send your host', 'ai-blackout' ) ); echo '
'; printf( '

%s

', esc_html__( 'The nginx rule', 'ai-blackout' ), esc_textarea( $this->nginx->build() ) ); printf( '

%s

', esc_html__( 'A message you can send to your host', 'ai-blackout' ), esc_textarea( $this->host_message() ) ); echo '
'; printf( '

%s

', esc_html__( 'Until that rule is installed, blocked crawlers are still refused by robots.txt and by WordPress itself.', 'ai-blackout' ) ); echo '
'; } /** * Builds the ready-to-send request for a host's support desk. * * @return string Message body. */ private function host_message(): string { return sprintf( /* translators: 1: Site URL. 2: The nginx configuration snippet. */ __( 'Hello, I would like to block a number of AI crawlers from my site, %1$s, at the server rather than in WordPress, so that the rule still applies to cached pages. Could you add the following to the nginx server block for this site and reload nginx? %2$s Thank you.', 'ai-blackout' ), home_url( '/' ), $this->nginx->build() ); } /** * Prints the only question this plugin asks. * * @return void */ private function render_choice(): void { $choice = $this->onboarding->current_choice(); echo '
'; echo ''; wp_nonce_field( self::NONCE_SAVE ); echo '
'; printf( '

%s

', esc_html__( 'What should AI be allowed to do with your content?', 'ai-blackout' ) ); $this->render_choice_option( Onboarding::CHOICE_TRAINING, __( 'Block AI from taking my content', 'ai-blackout' ), __( 'Refuses every crawler that collects content to train AI models, build datasets or scrape sites wholesale. Leaves AI search alone, so people can still find you.', 'ai-blackout' ), __( 'Recommended. Stops what people mean by "AI is stealing my content", without costing you a single visitor.', 'ai-blackout' ), $choice ); $this->render_choice_option( Onboarding::CHOICE_EVERYTHING, __( 'Block everything AI', 'ai-blackout' ), __( 'Also refuses the crawlers behind ChatGPT, Claude and Perplexity search, and the ones that fetch a page because a person asked. Ordinary search engines are never touched.', 'ai-blackout' ), '', $choice, $this->render_search_loss_confirm() ); $this->render_choice_option( Onboarding::CHOICE_DECLARE, __( 'Just state my terms', 'ai-blackout' ), __( 'Refuses nobody. Publishes your reservation of rights and asks in robots.txt that your work is not used for AI training.', 'ai-blackout' ), '', $choice ); echo '
'; printf( '

', esc_html__( 'Save changes', 'ai-blackout' ) ); printf( '

%s

', esc_html__( 'Saving writes every layer your server can take. Nothing else to apply, and nothing to switch on afterwards.', 'ai-blackout' ) ); echo '
'; } /** * Returns the confirmation that rides inside the strictest option's card. * * @return string Markup. */ private function render_search_loss_confirm(): string { return sprintf( '', esc_html__( 'I understand this takes my pages out of AI assistants\' answers, and I still want it.', 'ai-blackout' ), checked( $this->onboarding->has_accepted_search_loss(), true, false ) ); } /** * Prints one answer. * * @param string $value Choice value. * @param string $label Answer heading. * @param string $what What it does. * @param string $note Optional extra line. * @param string $current Currently selected choice. * @param string $extra Optional markup rendered inside the card. * @return void */ private function render_choice_option( string $value, string $label, string $what, string $note, string $current, string $extra = '' ): void { printf( '', esc_attr( $value ), checked( $current, $value, false ), esc_html( $label ), esc_html( $what ), '' === $note ? '' : '' . esc_html( $note ) . '', $extra // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built by render_search_loss_confirm() from escaped strings. ); } /** * Prints the ambient record: streak, since dates, and who is blocked. * * @return void */ private function render_record(): void { $streak = $this->record->get_streak(); $protecting_since = $this->record->get_protecting_since(); $companies = $this->record->get_blocked_companies(); $lifetime_total = $this->record->get_lifetime_total(); $blocking_since = $this->record->get_blocking_since(); if ( 0 === $streak && null === $protecting_since && empty( $companies ) && 0 === $lifetime_total ) { return; } echo '
'; printf( '

%s

', esc_html__( 'Since you installed it', 'ai-blackout' ) ); echo '
'; if ( ! empty( $companies ) ) { $this->render_stat( sprintf( /* translators: 1: Number of companies blocked. 2: Total known companies. */ __( '%1$s of %2$s', 'ai-blackout' ), number_format_i18n( count( $companies ) ), number_format_i18n( $this->record->get_total_company_count() ) ), __( 'AI companies blocked', 'ai-blackout' ), implode( ', ', $companies ) ); } if ( $streak > 0 ) { $this->render_stat( number_format_i18n( $streak ), /* translators: %s: Number of consecutive days. */ _n( 'day verified protected', 'days verified protected', $streak, 'ai-blackout' ), '' ); } if ( $lifetime_total > 0 && null !== $blocking_since ) { $this->render_stat( number_format_i18n( $lifetime_total ), __( 'requests refused by WordPress', 'ai-blackout' ), sprintf( /* translators: %s: Date. */ __( 'Since %s — a floor, not a total. Your server turns most crawlers away before WordPress ever sees them.', 'ai-blackout' ), date_i18n( get_option( 'date_format' ), $blocking_since ) ) ); } if ( null !== $protecting_since ) { $this->render_stat( date_i18n( get_option( 'date_format' ), $protecting_since ), __( 'rights reserved since', 'ai-blackout' ), '' ); } echo '
'; } /** * Prints one figure from the record. * * @param string $value The figure itself. * @param string $label What it counts. * @param string $note Optional smaller line beneath, or ''. * @return void */ private function render_stat( string $value, string $label, string $note ): void { printf( '
%1$s%2$s%3$s
', esc_html( $value ), esc_html( $label ), '' === $note ? '' : '' . esc_html( $note ) . '' ); } /** * Prints the Advanced tab: per-crawler control, then the evidence. * * @param array $report Auditor report. * @param bool $tested Whether these results came from a just-run self-test. * @return void */ private function render_advanced( array $report, bool $tested ): void { printf( '

%s

', esc_html__( 'Everything on this tab is optional. The answer on the Protection tab already sets every crawler below; this is for changing one of them by hand, and for seeing exactly what the plugin has written to your site.', 'ai-blackout' ) ); echo '
'; echo ''; wp_nonce_field( self::NONCE_SAVE_CRAWLERS ); $this->table->render(); printf( '

', esc_html__( 'Save crawler choices', 'ai-blackout' ) ); echo '
'; $this->render_evidence( $report, $tested ); } /** * Prints the layer-by-layer readout and the self-test. * * @param array $report Auditor report. * @param bool $tested Whether these results came from a just-run self-test. * @return void */ private function render_evidence( array $report, bool $tested ): void { printf( '

%s

', esc_html__( 'Layer by layer', 'ai-blackout' ) ); printf( '

%s

', esc_html__( 'Each layer of protection, what it is doing, and the evidence for it. Reading files and settings tells you what should happen; the button below finds out what really does, by asking your own site for a page while pretending to be an AI crawler. Nothing is sent anywhere else.', 'ai-blackout' ) ); echo '
'; echo ''; wp_nonce_field( self::NONCE_SELF_TEST ); printf( '', esc_html__( 'Test it from the outside', 'ai-blackout' ) ); echo '
'; if ( $tested ) { printf( '

%s

', esc_html__( 'Showing the results of a real request made to your site just now, pretending to be an AI crawler.', 'ai-blackout' ) ); } foreach ( $report['results'] as $result ) { $this->render_result( $result ); } printf( '

%s

', esc_html__( 'AI Blackout refuses crawlers that identify themselves, and publishes a declaration that covers the rest in law. It cannot stop a crawler that hides what it is. Nothing running on your own server can.', 'ai-blackout' ) ); } /** * Prints one layer's finding. * * @param array $result Result data from Result::to_array(). * @return void */ private function render_result( array $result ): void { $needs_attention = in_array( $result['status'], array( Result::BROKEN, Result::INERT, Result::UNKNOWN ), true ); printf( '
', esc_attr( $result['status'] ), $needs_attention ? ' open' : '' ); echo ''; printf( '%s', esc_html( Result::label_for( $result['status'] ) ) ); printf( '%s', esc_html( $result['label'] ) ); printf( '%s', esc_html( $result['summary'] ) ); echo ''; echo ''; echo '
'; printf( '

%s

', esc_html( $result['consequence'] ) ); if ( '' !== $result['next_action'] ) { printf( '

%s %s

', esc_html__( 'What to do:', 'ai-blackout' ), esc_html( $result['next_action'] ) ); } $this->render_result_detail( $result ); echo '
'; } /** * Prints the raw file contents a check captured, where it captured any. * * @param array $result Result data from Result::to_array(). * @return void */ private function render_result_detail( array $result ): void { $blocks = array( 'served' => __( 'The exact bytes crawlers receive at /robots.txt', 'ai-blackout' ), 'would_generate' => __( 'What WordPress would serve at /robots.txt', 'ai-blackout' ), 'physical_contents' => __( 'The robots.txt file currently on disk', 'ai-blackout' ), ); foreach ( $blocks as $key => $heading ) { if ( empty( $result['detail'][ $key ] ) || ! is_string( $result['detail'][ $key ] ) ) { continue; } printf( '
%s
%s
', esc_html( $heading ), esc_html( $result['detail'][ $key ] ) ); } } /** * Prints the outcome notices. * * @return void */ private function render_notices(): void { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Display-only flags; each action is nonce-checked in its own handler. if ( isset( $_GET['aiblackout_saved'] ) && ! isset( $_GET['aiblackout_problems'] ) ) { printf( '

%s

', esc_html__( 'Saved, and your site has been updated to match.', 'ai-blackout' ) ); } if ( isset( $_GET['aiblackout_problems'] ) ) { $this->render_problems(); } // phpcs:enable WordPress.Security.NonceVerification.Recommended if ( aiblackout_is_disabled() ) { printf( '

%1$s %2$s

', esc_html__( 'AI Blackout is switched off.', 'ai-blackout' ), esc_html__( 'AIBLACKOUT_DISABLE is set in wp-config.php, so nothing is being blocked and nothing is being declared. You can still change these settings; they take effect when that line is removed.', 'ai-blackout' ) ); } } /** * Prints whatever the repair on page load could not do. * * @param string[] $problems Failure messages from Publisher::sync(). * @return void */ private function render_sync_problems( array $problems ): void { if ( empty( $problems ) ) { return; } echo '

'; esc_html_e( 'Your protection could not be written to your server:', 'ai-blackout' ); echo '

'; } /** * Prints whatever the last save could not do. * * @return void */ private function render_problems(): void { $problems = get_transient( $this->get_refusal_key() ); delete_transient( $this->get_refusal_key() ); if ( empty( $problems ) || ! is_array( $problems ) ) { return; } echo '

'; esc_html_e( 'Saved, but some of it could not be applied:', 'ai-blackout' ); echo '

'; } }