blackout = $blackout; $this->settings = $settings; $this->providers = $providers; $this->resolver = $resolver; } /** * Registers hooks. * * @return void */ public function register(): void { add_action( 'admin_menu', array( $this, 'add_menu' ), 13 ); add_action( 'admin_post_aiblackout_save_blackout', array( $this, 'handle_save' ) ); add_action( 'admin_post_aiblackout_clear_blackout_log', array( $this, 'handle_clear' ) ); } /** * Adds the submenu item. * * @return void */ public function add_menu(): void { add_submenu_page( MainScreen::SLUG, __( 'AI inside WordPress', 'ai-blackout' ), __( 'AI inside WordPress', 'ai-blackout' ), self::CAPABILITY, self::SLUG, array( $this, 'render' ) ); } /** * Returns the URL of this screen. * * @param array $args Optional query arguments to add. * @return string Admin URL. */ private function get_screen_url( array $args = array() ): string { $url = admin_url( 'admin.php?page=' . self::SLUG ); return empty( $args ) ? $url : add_query_arg( $args, $url ); } /** * Saves the settings. * * @return void */ public function handle_save(): void { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to change these settings.', 'ai-blackout' ), '', array( 'response' => 403 ) ); } check_admin_referer( self::NONCE_SAVE ); $blackout = $this->settings->get( 'blackout', array() ); $blackout = is_array( $blackout ) ? $blackout : array(); foreach ( array( Settings::BLACKOUT_CORE_AI, Settings::BLACKOUT_ABILITIES, Settings::BLACKOUT_CONNECTORS_UI, Settings::BLACKOUT_OUTBOUND ) as $switch ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- check_admin_referer() ran above. $blackout[ $switch ] = isset( $_POST['aiblackout'][ $switch ] ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- check_admin_referer() ran above; the value is validated against a fixed list below. $mode = isset( $_POST['aiblackout']['outbound_mode'] ) ? sanitize_key( wp_unslash( $_POST['aiblackout']['outbound_mode'] ) ) : Settings::OUTBOUND_MONITOR; if ( ! in_array( $mode, array( Settings::OUTBOUND_OFF, Settings::OUTBOUND_MONITOR, Settings::OUTBOUND_BLOCK ), true ) ) { $mode = Settings::OUTBOUND_MONITOR; } $blackout['outbound_mode'] = $mode; $integrations = array(); foreach ( array_keys( $this->blackout->get_integrations() ) as $id ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- check_admin_referer() ran above. $integrations[ $id ] = isset( $_POST['aiblackout']['integrations'][ $id ] ); } $blackout['integrations'] = $integrations; // Only ids present in the bundled list are ever stored. $submitted = array(); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- check_admin_referer() ran above. if ( isset( $_POST['aiblackout']['providers'] ) && is_array( $_POST['aiblackout']['providers'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- check_admin_referer() ran above; each value is sanitized in the loop. foreach ( wp_unslash( $_POST['aiblackout']['providers'] ) as $id ) { $submitted[] = sanitize_key( $id ); } } $providers = array(); foreach ( $this->providers->get_providers() as $provider ) { if ( in_array( $provider['id'], $submitted, true ) ) { $providers[] = $provider['id']; } } $blackout['providers'] = $providers; $this->settings->set( 'blackout', $blackout ); wp_safe_redirect( $this->get_screen_url( array( 'aiblackout_saved' => '1' ) ) ); exit; } /** * Empties the call log. * * @return void */ public function handle_clear(): void { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to clear this log.', 'ai-blackout' ), '', array( 'response' => 403 ) ); } check_admin_referer( self::NONCE_CLEAR ); $this->blackout->log()->clear(); wp_safe_redirect( $this->get_screen_url( array( 'aiblackout_cleared' => '1' ) ) ); exit; } /** * Renders the screen. * * @return void */ public function render(): void { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to view this page.', 'ai-blackout' ), '', array( 'response' => 403 ) ); } echo '
'; printf( '

%s

', esc_html__( 'AI inside WordPress', 'ai-blackout' ) ); $this->render_notices(); $this->render_kill_switch_notice(); printf( '

%s

', esc_html__( 'WordPress 7.0 ships with AI built in, and many plugins add their own. This page switches that off and shows you what your site has been sending to AI companies.', 'ai-blackout' ) ); $this->render_card( __( 'What your site has been sending to AI companies', 'ai-blackout' ), $this->activity_status(), array( $this, 'render_activity' ) ); echo '
'; echo ''; wp_nonce_field( self::NONCE_SAVE ); $core_on = 0; foreach ( array( Settings::BLACKOUT_CORE_AI, Settings::BLACKOUT_ABILITIES, Settings::BLACKOUT_CONNECTORS_UI ) as $key ) { if ( $this->settings->is_blackout_enabled( $key ) ) { ++$core_on; } } $this->render_card( __( 'AI built into WordPress', 'ai-blackout' ), $this->on_of_status( $core_on, 3 ), array( $this, 'render_core_switches' ) ); $this->render_card( __( 'AI added by your other plugins', 'ai-blackout' ), $this->integrations_status(), array( $this, 'render_integrations' ) ); $this->render_card( __( 'Outgoing requests to AI companies', 'ai-blackout' ), $this->outbound_status(), array( $this, 'render_outbound' ) ); $watched = count( $this->blackout->firewall()->get_enabled_provider_ids() ); $this->render_card( __( 'Which AI companies to watch for', 'ai-blackout' ), $watched > 0 ? sprintf( /* translators: %s: Number of AI companies being watched. */ _n( '%s watched', '%s watched', $watched, 'ai-blackout' ), number_format_i18n( $watched ) ) : __( 'None watched', 'ai-blackout' ), array( $this, 'render_providers' ) ); printf( '

', esc_html__( 'Save changes', 'ai-blackout' ) ); echo '
'; echo '
'; } /** * Summarises the activity log for the card header. * * @return string Compact status text. */ private function activity_status(): string { $log = $this->blackout->log(); if ( ! $log->is_installed() ) { return __( 'Log missing', 'ai-blackout' ); } $count = count( $log->get_recent( 30, 100 ) ); if ( 0 === $count ) { return __( 'Nothing recorded', 'ai-blackout' ); } return sprintf( /* translators: %s: Number of distinct sources recorded. */ _n( '%s entry', '%s entries', $count, 'ai-blackout' ), number_format_i18n( $count ) ); } /** * Summarises the per-plugin strippers for the card header. * * @return string Compact status text. */ private function integrations_status(): string { $active = $this->blackout->get_active_integrations(); if ( empty( $active ) ) { return __( 'None installed', 'ai-blackout' ); } $on = 0; foreach ( array_keys( $active ) as $id ) { if ( $this->blackout->is_integration_enabled( $id ) ) { ++$on; } } return $this->on_of_status( $on, count( $active ) ); } /** * Summarises the outbound firewall mode for the card header. * * @return string Compact status text. */ private function outbound_status(): string { switch ( $this->settings->get_outbound_mode() ) { case Settings::OUTBOUND_BLOCK: return __( 'Blocking', 'ai-blackout' ); case Settings::OUTBOUND_MONITOR: return __( 'Watching', 'ai-blackout' ); default: return __( 'Off', 'ai-blackout' ); } } /** * Formats an "N of M on" style status, or "Off" / "All on" at the * extremes. * * @param int $on Number currently on. * @param int $total Total number available. * @return string Compact status text. */ private function on_of_status( int $on, int $total ): string { if ( 0 === $on ) { return __( 'Off', 'ai-blackout' ); } if ( $on === $total ) { return __( 'All on', 'ai-blackout' ); } return sprintf( /* translators: 1: Number switched on. 2: Total number available. */ __( '%1$s of %2$s on', 'ai-blackout' ), number_format_i18n( $on ), number_format_i18n( $total ) ); } /** * Prints the saved and cleared notices. * * @return void */ private function render_notices(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display-only flag; the actions themselves are nonce-checked. if ( isset( $_GET['aiblackout_saved'] ) ) { printf( '

%s

', esc_html__( 'Settings saved.', 'ai-blackout' ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display-only flag; the actions themselves are nonce-checked. if ( isset( $_GET['aiblackout_cleared'] ) ) { printf( '

%s

', esc_html__( 'Activity log cleared.', 'ai-blackout' ) ); } } /** * Warns when the global kill switch is on. * * @return void */ private function render_kill_switch_notice(): void { if ( ! aiblackout_is_disabled() ) { return; } printf( '

%s %s

', esc_html__( 'AI Blackout is switched off.', 'ai-blackout' ), esc_html__( 'AIBLACKOUT_DISABLE is set in wp-config.php, so nothing on this page is currently doing anything. You can still change these settings; they will take effect when that line is removed.', 'ai-blackout' ) ); } /** * Prints the outbound activity table. * * @return void */ private function render_activity(): void { $log = $this->blackout->log(); $rows = $log->get_recent( 30, 100 ); if ( ! $log->is_installed() ) { printf( '

%s

', esc_html__( 'The activity log table is missing, so nothing can be recorded. Deactivating and reactivating AI Blackout will create it.', 'ai-blackout' ) ); return; } if ( empty( $rows ) ) { echo '
'; printf( '

%s

', esc_html__( 'Nothing recorded yet.', 'ai-blackout' ) ); if ( Settings::OUTBOUND_OFF === $this->blackout->firewall()->get_mode() ) { printf( '

%s

', esc_html__( 'Watching outgoing requests is switched off, so nothing is being recorded. Turn it on below to find out which of your plugins talk to AI companies.', 'ai-blackout' ) ); } else { printf( '

%s

', esc_html__( 'Nothing on your site has tried to reach an AI company since this was switched on. Attempts appear here as they happen.', 'ai-blackout' ) ); } echo '
'; return; } echo ''; echo ''; printf( '', esc_html__( 'What made the request', 'ai-blackout' ) ); printf( '', esc_html__( 'Where it was going', 'ai-blackout' ) ); printf( '', esc_html__( 'Times', 'ai-blackout' ) ); printf( '', esc_html__( 'Result', 'ai-blackout' ) ); printf( '', esc_html__( 'Last seen', 'ai-blackout' ) ); echo ''; foreach ( $rows as $row ) { $blocked = BlockedCalls::BLOCKED === (int) $row['outcome']; echo ''; printf( '', esc_html( $this->resolver->get_display_name( (string) $row['source'] ) ) ); printf( '', esc_html( (string) $row['host'] ) ); printf( '', esc_html( number_format_i18n( (int) $row['hits'] ) ) ); printf( '', esc_attr( $blocked ? 'blocked' : 'watched' ), esc_html( $blocked ? __( 'Blocked', 'ai-blackout' ) : __( 'Allowed through', 'ai-blackout' ) ) ); printf( '', esc_html( mysql2date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), (string) $row['last_seen'] ) ) ); echo ''; } echo '
%s%s%s%s%s
%s%s%s%s%s
'; printf( '

%s

', esc_html__( 'Counts are grouped by day. Records older than 30 days are removed automatically.', 'ai-blackout' ) ); echo '
'; echo ''; wp_nonce_field( self::NONCE_CLEAR ); printf( '

', esc_html__( 'Clear this log', 'ai-blackout' ) ); echo '
'; } /** * Prints the WordPress core switches. * * @return void */ private function render_core_switches(): void { $core_ai = $this->blackout->core_ai(); if ( ! $core_ai->has_core_ai() ) { printf( '

%s

', esc_html__( 'This version of WordPress does not include AI features, so there is nothing here to switch off. These settings will start applying if you update to a version that does.', 'ai-blackout' ) ); } echo ''; $this->render_switch( Settings::BLACKOUT_CORE_AI, __( "Turn off WordPress's AI", 'ai-blackout' ), __( 'Stops WordPress offering AI features to any plugin, and stops it registering AI providers or storing their API keys. This uses the off-switch WordPress itself provides, so plugins asking for AI get a clean refusal rather than an error.', 'ai-blackout' ) ); $this->render_switch( Settings::BLACKOUT_ABILITIES, __( 'Close the AI agent interface', 'ai-blackout' ), __( 'WordPress 7.0 added a way for AI agents to list and run actions on your site over the web. This removes every registered action and makes those web addresses return "not found". The code still loads; the doors are shut.', 'ai-blackout' ) ); $this->render_switch( Settings::BLACKOUT_CONNECTORS_UI, __( 'Hide the AI keys screen', 'ai-blackout' ), __( 'Removes Settings → Connectors, where AI provider API keys are entered, and refuses direct access to it. Keys already saved are left alone rather than deleted. They simply cannot be used while AI is off.', 'ai-blackout' ) ); echo ''; $removed = $core_ai->get_unregistered(); if ( ! empty( $removed ) ) { printf( '

%s

', esc_html( sprintf( /* translators: %s: A comma-separated list of ability names. */ _n( 'One AI action was removed on this page load: %s.', 'These AI actions were removed on this page load: %s.', count( $removed ), 'ai-blackout' ), implode( ', ', $removed ) ) ) ); } $keys = $core_ai->find_environment_keys(); if ( ! empty( $keys ) ) { printf( '

%s %s

', esc_html__( 'AI keys are set on your server.', 'ai-blackout' ), esc_html( sprintf( /* translators: %s: A comma-separated list of constant or environment variable names. */ __( 'Found: %s. These live in wp-config.php or your server environment, where no plugin can reach them. They cannot be used while AI is switched off here, but they would work again if this plugin were removed.', 'ai-blackout' ), implode( ', ', $keys ) ) ) ); } } /** * Prints the per-plugin strippers. * * @return void */ private function render_integrations(): void { $integrations = $this->blackout->get_integrations(); $active = $this->blackout->get_active_integrations(); if ( empty( $active ) ) { printf( '

%s

', esc_html__( 'None of the plugins AI Blackout knows how to switch AI off in are installed here. Nothing to do.', 'ai-blackout' ) ); return; } echo ''; foreach ( $integrations as $id => $integration ) { if ( ! $integration->is_target_active() ) { continue; } $note = ''; if ( '' !== $integration->get_target_version() && $integration->get_target_version() !== $integration->get_verified_version() ) { $note = sprintf( /* translators: 1: Installed version, 2: Version this was tested against. */ __( 'You have version %1$s; this was tested against %2$s. If that plugin has moved its AI setting since, this may no longer switch it off. The protection status page will not claim it did.', 'ai-blackout' ), $integration->get_target_version(), $integration->get_verified_version() ); } $this->render_checkbox( 'aiblackout[integrations][' . $id . ']', $this->blackout->is_integration_enabled( $id ), sprintf( /* translators: %s: Plugin name. */ __( 'Turn off AI in %s', 'ai-blackout' ), $integration->get_label() ), $integration->get_description(), $note ); } echo ''; } /** * Prints the outgoing-request mode selector. * * @return void */ private function render_outbound(): void { $mode = $this->settings->get_outbound_mode(); $on = $this->settings->is_blackout_enabled( Settings::BLACKOUT_OUTBOUND ); printf( '

%s

', esc_html__( 'This watches every request your site makes through WordPress and can refuse the ones going to AI companies. It covers WordPress itself and nearly every plugin. A plugin that makes its own network connection instead of using WordPress would not be stopped, and this page will never claim otherwise.', 'ai-blackout' ) ); echo ''; $this->render_switch( Settings::BLACKOUT_OUTBOUND, __( 'Watch outgoing AI requests', 'ai-blackout' ), __( 'Turning this off means nothing is watched and nothing is recorded.', 'ai-blackout' ) ); echo ''; printf( '', esc_html__( 'What to do with them', 'ai-blackout' ) ); echo ''; echo ''; } /** * Prints the provider checklist. * * @return void */ private function render_providers(): void { $enabled = $this->blackout->firewall()->get_enabled_provider_ids(); if ( ! $this->providers->is_available() ) { printf( '

%s

', esc_html__( 'The bundled list of AI companies could not be read, so nothing can be watched or blocked. Reinstalling AI Blackout will replace the missing file.', 'ai-blackout' ) ); return; } echo '
'; foreach ( $this->providers->get_group( ProviderRegistry::GROUP_PROVIDER ) as $provider ) { $this->render_provider_checkbox( $provider, $enabled ); } echo '
'; $proxies = $this->providers->get_group( ProviderRegistry::GROUP_PROXY ); if ( empty( $proxies ) ) { return; } echo '

' . esc_html__( 'AI built into plugins you may be paying for', 'ai-blackout' ) . '

'; printf( '

%s

', esc_html__( 'These are switched off here by default. Blocking one of these stops that plugin\'s AI at the network level. That is a blunter tool than the per-plugin switches above, so use those first.', 'ai-blackout' ) ); echo '
'; foreach ( $proxies as $provider ) { $this->render_provider_checkbox( $provider, $enabled ); } echo '
'; } /** * Prints one provider checkbox. * * @param array $provider Provider array. * @param string[] $enabled Currently enabled provider ids. * @return void */ private function render_provider_checkbox( array $provider, array $enabled ): void { $hosts = array_merge( $provider['hosts'], $provider['host_suffixes'] ); printf( '', esc_attr( $provider['id'] ), checked( in_array( $provider['id'], $enabled, true ), true, false ), esc_html( $provider['label'] ), esc_html( implode( ', ', $hosts ) ), '' !== $provider['warning'] ? '' . esc_html( $provider['warning'] ) . '' : '' ); } /** * Prints one blackout switch row. * * @param string $key One of the BLACKOUT_* constants. * @param string $label Row label. * @param string $description What the switch does. * @return void */ private function render_switch( string $key, string $label, string $description ): void { $this->render_checkbox( 'aiblackout[' . $key . ']', $this->settings->is_blackout_enabled( $key ), $label, $description ); } /** * Prints one checkbox row. * * @param string $name Field name. * @param bool $checked Whether it is on. * @param string $label Row label. * @param string $description What it does. * @param string $note Optional extra warning. * @return void */ private function render_checkbox( string $name, bool $checked, string $label, string $description, string $note = '' ): void { echo ''; printf( '%s', esc_html( $label ) ); echo ''; printf( '', esc_attr( $name ), checked( $checked, true, false ), esc_html__( 'On', 'ai-blackout' ) ); printf( '

%s

', esc_html( $description ) ); if ( '' !== $note ) { printf( '

%s

', esc_html( $note ) ); } echo ''; } }