0 ? ' ' . (int) $count . '' : ''; self::$hooks = array(); self::$hooks[] = add_menu_page( __( 'Watchfox', 'watchfox-ai' ), __( 'Watchfox AI', 'watchfox-ai' ) . $badge, 'manage_options', self::PAGE, array( __CLASS__, 'render_page' ), self::icon(), 80 ); add_submenu_page( self::PAGE, __( 'Watchfox', 'watchfox-ai' ), __( 'Watchfox', 'watchfox-ai' ), 'manage_options', self::PAGE, array( __CLASS__, 'render_page' ) ); self::$hooks[] = add_submenu_page( self::PAGE, __( 'Watchfox settings', 'watchfox-ai' ), __( 'Settings', 'watchfox-ai' ), 'manage_options', self::PAGE_SETTINGS, array( __CLASS__, 'render_settings' ) ); } /** Suggested text for Settings, Privacy, so site owners can describe what Watchfox AI sends. */ public static function privacy_policy() { if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) { return; } $html = '

' . esc_html__( 'Watchfox AI', 'watchfox-ai' ) . '

'; $html .= '

' . esc_html__( 'Watchfox AI checks the plugins, themes and WordPress version of this site against a public list of known security problems. The check runs on your server. Nothing is sent until the site owner finishes setup.', 'watchfox-ai' ) . '

'; $html .= '

' . esc_html__( 'What is sent to api.watchfox.site', 'watchfox-ai' ) . '

'; $html .= '

' . esc_html__( 'When setup is finished, the email address and language the site owner chose, the site address, and the Watchfox AI, WordPress and PHP versions are sent to api.watchfox.site, so we can email warnings.', 'watchfox-ai' ) . '

'; $html .= '

' . esc_html__( 'After each check, a report with only the problems found (an ID, the item folder name, its version, a status, how serious it is and the item name) is sent, together with the email and warning settings. The full list of plugins and themes is never sent. When the plugin is deleted, the site is removed from the service.', 'watchfox-ai' ) . '

'; $html .= '

' . esc_html__( 'The address of your wp-admin is sent too, so the Fix it now button in a warning email opens the right page. If you use Fix it now or Auto-fix, the next report also says what was done: the warning ID, the item folder name and name, the versions before and after, and whether it worked or was put back. Never file contents or backups.', 'watchfox-ai' ) . '

'; $html .= '

' . esc_html__( 'Reports also say whether Auto-fix is on or off. When the plugin is deactivated on the Plugins screen, the site owner can choose to send one reason and a short note. Nothing is sent if they skip it.', 'watchfox-ai' ) . '

'; $html .= '

' . esc_html__( 'Safety ratings: when an update is ready, your site downloads a small public file from api.watchfox.site to see how that update went on other sites. Nothing about your site is sent with that request. When Fix it now or Auto-fix updates something, whether the update was kept or put back is pooled anonymously with the same result from other sites. Only rounded totals (such as "100+ sites") are published, and only once at least 10 different site owners reported the same update.', 'watchfox-ai' ) . '

'; $html .= '

' . wp_kses_post( sprintf( /* translators: 1: link to the Watchfox AI privacy policy. */ __( 'Privacy policy: %1$s', 'watchfox-ai' ), 'https://watchfox.site/privacy' ) ) . '

'; $html .= '

' . esc_html__( 'Other services', 'watchfox-ai' ) . '

'; $html .= '

' . wp_kses_post( sprintf( /* translators: 1: link to the Brevo privacy policy. */ __( 'Emails are delivered by Brevo. The email address and the name and address of the site are passed to Brevo only to deliver them. Brevo privacy policy: %1$s', 'watchfox-ai' ), 'https://www.brevo.com/legal/privacypolicy/' ) ) . '

'; $html .= '

' . wp_kses_post( sprintf( /* translators: 1: link to the Dodo Payments privacy policy. */ __( 'If a paid plan is chosen, payment is handled by Dodo Payments on its own page. Card details never pass through this site. Dodo Payments privacy policy: %1$s', 'watchfox-ai' ), 'https://dodopayments.com/legal/privacy-policy' ) ) . '

'; wp_add_privacy_policy_content( 'Watchfox AI', $html ); } /** A Settings link on the Plugins screen row, the WordPress way. */ public static function action_links( $links ) { $done = Watchfox_Settings::is_done(); $extra = array( '' . esc_html( $done ? __( 'Open Watchfox', 'watchfox-ai' ) : __( 'Set up', 'watchfox-ai' ) ) . '' ); if ( $done ) { $extra[] = '' . esc_html__( 'Settings', 'watchfox-ai' ) . ''; } return array_merge( $extra, $links ); } /** Help links under the description on the Plugins screen, all on watchfox.site. */ public static function row_meta( $meta, $file ) { if ( plugin_basename( WATCHFOX_FILE ) !== $file ) { return $meta; } $es = 0 === strpos( determine_locale(), 'es' ) ? 'es/' : ''; $link = static function ( $path, $label ) use ( $es ) { return '' . esc_html( $label ) . ''; }; $meta[] = $link( 'help', __( 'Help', 'watchfox-ai' ) ); $meta[] = $link( 'pricing', __( 'Plans', 'watchfox-ai' ) ); $meta[] = $link( 'levels', __( 'Warning levels', 'watchfox-ai' ) ); return $meta; } /** Styles and script load only on the two Watchfox screens. */ public static function assets( $hook ) { if ( ! in_array( (string) $hook, self::$hooks, true ) ) { return; } // Printed inline, not linked: some hosts serve wp-admin over https but build plugin URLs as http, and the browser // then blocks the files as mixed content, leaving the screens unstyled. Inline styles and script always load. $dir = plugin_dir_path( WATCHFOX_FILE ) . 'assets/'; $css = self::read_asset( $dir . 'admin.css' ); $css = preg_replace_callback( '/url\(([a-z-]+\.svg)\)/', static function ( $m ) use ( $dir ) { return 'url("data:image/svg+xml,' . rawurlencode( self::read_asset( $dir . $m[1] ) ) . '")'; }, $css ); wp_register_style( 'watchfox-admin', false, array(), WATCHFOX_VERSION ); wp_enqueue_style( 'watchfox-admin' ); wp_add_inline_style( 'watchfox-admin', $css ); wp_register_script( 'watchfox-admin', false, array(), WATCHFOX_VERSION, true ); wp_enqueue_script( 'watchfox-admin' ); wp_add_inline_script( 'watchfox-admin', self::read_asset( $dir . 'admin.js' ) ); } /** The screen hooks of the Watchfox screens (filled on admin_menu). */ public static function screens() { return self::$hooks; } /** Reads one of the plugin's own bundled asset files; never a remote URL. */ public static function read_asset( $file ) { if ( ! is_readable( $file ) ) { return ''; } $text = file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- a local file shipped with the plugin. return false === $text ? '' : $text; } private static function icon() { $svg = ''; // Base64 on purpose: this is the format WordPress documents for add_menu_page(), and only a base64 data URI makes // core's svg-painter script recolour the icon to match the admin colour scheme (a plain data URI is shown as a fixed image). return 'data:image/svg+xml;base64,' . base64_encode( $svg ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- WP-documented menu icon format. } public static function today_count() { $n = 0; foreach ( Watchfox_Checker::findings() as $f ) { if ( 'urgent' === $f['level'] ) { $n++; } } return $n; } private static function lang() { return Watchfox_Words::lang_from_locale( function_exists( 'get_user_locale' ) ? get_user_locale() : get_locale() ); } /* ---------- the shared frame ---------- */ /** The Watchfox header on every screen: the fox mark, the name, a status chip and the two screens. */ private static function header( $current, $setup = false ) { list( $chip, $tone ) = self::status_chip( $setup ); echo '
'; echo '

' . esc_html__( 'Watchfox AI', 'watchfox-ai' ) . '

'; echo '' . esc_html( $chip ) . ''; if ( Watchfox_Settings::is_done() ) { echo ''; } echo '

'; } /** The words and tone of the header chip: Setting up, Needs you, Trial: X days left or Protected. */ public static function status_chip( $setup = false ) { if ( ! Watchfox_Settings::is_done() ) { return array( __( 'Setting up', 'watchfox-ai' ), 'calm' ); } $state = Watchfox_Hub::is_registered() ? Watchfox_Hub::state() : array( 'confirmed' => true ); $unconfirmed = true !== $state['confirmed']; if ( $setup ) { // The last setup step: still setting up, or waiting for the confirm click. return $unconfirmed ? array( __( 'Confirm your email', 'watchfox-ai' ), 'calm' ) : array( __( 'Setting up', 'watchfox-ai' ), 'calm' ); } if ( self::today_count() > 0 || Watchfox_Hub::copy_detected() || Watchfox_Hub::connection_lost() ) { return array( __( 'Needs you', 'watchfox-ai' ), 'alert' ); } if ( $unconfirmed ) { return array( __( 'Confirm your email', 'watchfox-ai' ), 'alert' ); } $p = Watchfox_Hub::plan(); if ( null !== $p && 'trial' === $p['name'] && ! empty( $p['confirmed'] ) ) { return array( self::plan_sentence( $p ), 'calm' ); } return array( __( 'Protected', 'watchfox-ai' ), 'ok' ); } /** A level pill as in the emails: shape and word, never colour alone. */ private static function pill( $level, $lang ) { $shape = array( 'urgent' => '●', 'possible' => '▲', 'low' => '■' ); return ' ' . esc_html( Watchfox_Words::level( $level, $lang ) ) . ''; } private static function form_open( $action, $class = '' ) { echo '
'; wp_nonce_field( 'watchfox_' . $action ); } /* ---------- the Watchfox page ---------- */ public static function render_page() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to see this page.', 'watchfox-ai' ) ); } Watchfox_Hub::sync_email_state(); $step3 = isset( $_GET['step'] ) && 3 === (int) $_GET['step']; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- only picks a screen. $fix = isset( $_GET['fix'] ) ? sanitize_text_field( wp_unslash( $_GET['fix'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- opens a confirm screen only; the change is a POST with a nonce. $off = isset( $_GET['off'] ) ? sanitize_text_field( wp_unslash( $_GET['off'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- same. echo '
'; self::header( self::PAGE, $step3 || ! Watchfox_Settings::is_done() ); self::notice(); if ( ! Watchfox_Settings::is_done() ) { $step = isset( $_GET['step'] ) ? (int) $_GET['step'] : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- only picks a screen. self::render_setup( 2 === $step ? 2 : 1 ); } elseif ( $step3 ) { self::render_done(); } elseif ( '' !== $fix || '' !== $off ) { echo '
'; Watchfox_Fixui::render_confirm( '' !== $fix ? 'update' : 'off', '' !== $fix ? $fix : $off ); echo '
'; } else { self::render_findings(); } echo '
'; } /** The 1, 2, 3 stepper above the setup screens. */ private static function stepper( $step ) { $names = array( 1 => __( 'Your email', 'watchfox-ai' ), 2 => __( 'What to hear about', 'watchfox-ai' ), 3 => __( 'First check', 'watchfox-ai' ) ); echo '
    '; foreach ( $names as $n => $name ) { $state = $n < $step ? 'done' : ( $n === $step ? 'now' : 'next' ); echo '
  1. ' . esc_html( $name ) . '
  2. '; } echo '
'; } /** The calm side panel next to the setup steps. */ private static function setup_aside( array $points, $foot ) { echo ''; } /** The heading row of a setup card: the title and line, with the fox beside them. */ private static function setup_title( $fox, $title, $lede ) { echo '

' . esc_html( $title ) . '

' . esc_html( $lede ) . '

'; } public static function render_setup( $step ) { $s = Watchfox_Settings::get(); echo '
'; self::stepper( $step ); if ( 1 === $step ) { $email = '' !== $s['email'] ? $s['email'] : get_option( 'admin_email', '' ); $lang = '' !== $s['email'] ? $s['lang'] : Watchfox_Words::lang_from_locale( get_locale() ); self::setup_title( 'sleep', __( 'Where should we send warnings?', 'watchfox-ai' ), __( 'We only email you when something needs doing. Nothing is sent anywhere until you finish setup.', 'watchfox-ai' ) ); self::form_open( 'setup_email' ); echo '
'; echo ''; echo '

' . esc_html__( "Is this an inbox you read? We'll send one confirm email here.", 'watchfox-ai' ); if ( '' === $s['email'] ) { echo ' ' . esc_html__( 'We filled in your site admin email, which some hosts make up. Change it if you need to.', 'watchfox-ai' ); } echo '

'; echo '
'; echo '
'; echo '
'; } else { self::setup_title( 'sleep', __( 'How much do you want to hear?', 'watchfox-ai' ), __( 'Pick the warnings you want by email. Every finding still shows here.', 'watchfox-ai' ) ); self::form_open( 'setup_levels' ); self::level_fields( $s ); echo '
' . esc_html__( 'Back', 'watchfox-ai' ) . '
'; } echo '
'; self::setup_aside( array( __( 'We check your plugins and theme against our list. It takes a few seconds.', 'watchfox-ai' ), __( 'We send one email so you can confirm your address.', 'watchfox-ai' ), __( 'After that, we only write when something needs doing.', 'watchfox-ai' ), ), __( 'We never see your content or visitors.', 'watchfox-ai' ) ); echo '
'; } /** The level choices as plain rows. Real checkboxes; "Today" is locked on. */ private static function level_fields( array $s ) { $lang = self::lang(); $rows = array( array( 'urgent', '', true, true, __( 'Attackers are using it right now. Always on.', 'watchfox-ai' ) ), array( 'possible', 'level_possible', $s['levels']['possible'], false, __( 'A real problem, no attacks yet.', 'watchfox-ai' ) ), array( 'low', 'level_low', $s['levels']['low'], false, __( 'Early talk, not confirmed.', 'watchfox-ai' ) ), ); echo '
' . esc_html__( 'Warnings to email', 'watchfox-ai' ) . ''; foreach ( $rows as $r ) { list( $level, $name, $on, $locked, $text ) = $r; echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- pill() escapes. } echo ''; echo '
'; } public static function render_done() { list( $shown, $unknown ) = self::split_findings( Watchfox_Checker::findings() ); $quiet = ! $shown; $state = Watchfox_Hub::is_registered() ? Watchfox_Hub::state() : array( 'confirmed' => true ); $unconfirmed = true !== $state['confirmed']; echo '
'; self::stepper( 3 ); self::setup_title( 'sorted', $unconfirmed ? __( 'First check done', 'watchfox-ai' ) : __( "You're all set", 'watchfox-ai' ), /* translators: %s: the result of the first check, for example "All quiet" or "1 thing to do this week" */ sprintf( __( 'First check: %s. From now on we check every hour.', 'watchfox-ai' ), self::top_line( $shown, count( $unknown ) ) ) ); self::render_email_state( false ); echo '
'; self::setup_aside( array( __( 'Your 14-day trial starts when you confirm your email.', 'watchfox-ai' ), __( 'We check your site every hour from now on.', 'watchfox-ai' ), __( 'When something needs doing, we email you what to do and by when.', 'watchfox-ai' ), ), __( 'If you ever delete the plugin, it leaves nothing behind on your site.', 'watchfox-ai' ) ); echo '
'; } /** * Until the email is confirmed: one banner saying where we sent the confirmation, with Send it again and Change email. * Once confirmed: a button that sends a sample warning. */ public static function render_email_state( $sample = true ) { if ( ! Watchfox_Hub::is_registered() ) { return; } $state = Watchfox_Hub::state(); if ( true === $state['confirmed'] && null === $state['pending_email'] ) { if ( ! $sample ) { return; } self::form_open( 'sample', 'wfx-sample' ); echo ''; return; } $s = Watchfox_Settings::get(); $email = null !== $state['pending_email'] ? $state['pending_email'] : $s['email']; // The same calm line for everyone: it never says whether a purchase is waiting. echo '
'; echo '

' . esc_html__( 'Confirm your email', 'watchfox-ai' ) . '

'; /* translators: %s: email address */ echo '

' . esc_html( sprintf( __( 'Check your inbox and click Confirm. We sent it to %s. If you\'ve bought Pro, it switches on then.', 'watchfox-ai' ), $email ) ) . '

'; if ( ! empty( $state['limited'] ) ) { echo '

' . esc_html__( 'Too many emails today. Try again tomorrow.', 'watchfox-ai' ) . '

'; } echo '
'; self::form_open( 'resend', 'wfx-inline' ); echo ''; self::form_open( 'sync', 'wfx-inline' ); echo ''; echo '' . esc_html__( 'Change email', 'watchfox-ai' ) . '
'; } public static function top_line( array $shown, $unknown_count ) { $by = array( 'urgent' => 0, 'possible' => 0, 'low' => 0 ); foreach ( self::group_findings( $shown ) as $g ) { $by[ $g[0]['level'] ]++; } if ( $by['urgent'] > 0 ) { /* translators: %d: number of things */ return sprintf( _n( '%d thing to do today', '%d things to do today', $by['urgent'], 'watchfox-ai' ), $by['urgent'] ); } if ( $by['possible'] > 0 ) { /* translators: %d: number of things */ return sprintf( _n( '%d thing to do this week', '%d things to do this week', $by['possible'], 'watchfox-ai' ), $by['possible'] ); } if ( $by['low'] > 0 ) { /* translators: %d: number of things */ return sprintf( _n( '%d thing to do when you can', '%d things to do when you can', $by['low'], 'watchfox-ai' ), $by['low'] ); } return __( 'All quiet', 'watchfox-ai' ); } /** * Groups findings by component (level, ecosystem, slug, version), in the order first seen: one group is one card. * * @return array[] Each group is a non-empty list of findings. */ public static function group_findings( array $findings ) { $groups = array(); foreach ( $findings as $f ) { $key = ( isset( $f['level'] ) ? (string) $f['level'] : '' ) . '|' . ( isset( $f['ecosystem'] ) ? $f['ecosystem'] : '' ) . '|' . ( isset( $f['slug'] ) ? strtolower( $f['slug'] ) : '' ) . '|' . ( isset( $f['version'] ) ? $f['version'] : '' ); $groups[ $key ][] = $f; } return array_values( $groups ); } /** Findings the page lists: a level, not muted. */ public static function split_findings( array $all ) { $shown = array(); $unknown = array(); foreach ( $all as $f ) { if ( 'unknown' === $f['status'] ) { $unknown[] = $f; } elseif ( null !== $f['level'] && ( 'urgent' === $f['level'] || ! Watchfox_Settings::is_muted( $f['threat_id'] ) ) ) { $shown[] = $f; } } return array( $shown, $unknown ); } /** All problem notices in one place, one style; the raw code sits behind a toggle. */ private static function render_notices() { $items = array(); if ( Watchfox_Hub::copy_detected() || Watchfox_Hub::connection_lost() ) { $items[] = array( 'warn', Watchfox_Hub::copy_detected() ? __( 'This looks like a copy of your site. Set it up again to watch it.', 'watchfox-ai' ) : __( 'Watchfox lost its connection. Set it up again.', 'watchfox-ai' ), 'reset', '' ); } $net = Watchfox_Hub::last_net_error(); if ( '' !== $net ) { $items[] = array( 'info', __( 'Something went wrong reaching Watchfox. We will try again soon.', 'watchfox-ai' ), '', $net ); } if ( Watchfox_List::is_stale() ) { $items[] = array( 'info', __( "We couldn't get the latest list. Your last check is still shown.", 'watchfox-ai' ), '', '' ); } if ( ! $items ) { return; } echo '
'; foreach ( $items as $it ) { list( $tone, $text, $action, $code ) = $it; echo ''; } echo '
'; } /** The hero line under the title: the first thing to do, named. */ private static function hero_sub( array $shown, $failed ) { if ( ! $shown ) { return $failed ? __( "We couldn't check just now. We will try again soon.", 'watchfox-ai' ) : __( 'Nothing on your site needs you right now. We keep checking every hour.', 'watchfox-ai' ); } $order = array( 'urgent' => 0, 'possible' => 1, 'low' => 2 ); usort( $shown, function ( $a, $b ) use ( $order ) { return $order[ $a['level'] ] - $order[ $b['level'] ]; } ); $first = trim( $shown[0]['name'] . ' ' . $shown[0]['version'] ); if ( 'urgent' === $shown[0]['level'] ) { /* translators: %s: plugin name and version */ return sprintf( __( '%s is being attacked. Update it today.', 'watchfox-ai' ), $first ); } if ( 'possible' === $shown[0]['level'] ) { /* translators: %s: plugin name and version */ return sprintf( __( 'Start with %s. Fix it this week.', 'watchfox-ai' ), $first ); } /* translators: %s: plugin name and version */ return sprintf( __( 'Start with %s when you have a moment.', 'watchfox-ai' ), $first ); } public static function render_findings() { $lang = self::lang(); list( $shown, $unknown ) = self::split_findings( Watchfox_Checker::findings() ); self::render_notices(); // The summary hero, full width. $failed = '' !== Watchfox_Hub::last_net_error() || Watchfox_List::is_stale(); $has_today = self::today_count() > 0; $tone = ! $shown ? 'quiet' : ( $has_today ? 'urgent' : 'todo' ); $title = self::top_line( $shown, count( $unknown ) ); $checked = Watchfox_Checker::checked_at(); if ( ! $shown && $failed && null !== $checked ) { /* translators: %s: date and time of the last check that worked */ $title = sprintf( __( 'Last known: all quiet, %s', 'watchfox-ai' ), self::fmt_date( $checked ) ); $tone = 'stale'; } echo '
'; echo '

' . esc_html( $title ) . ( 'quiet' === $tone ? ' ' : '' ) . '

'; echo '

' . esc_html( self::hero_sub( $shown, $failed ) ) . '

'; $quiet = ! $shown && ! $unknown; echo '
'; if ( ! $quiet ) { $names = array( 'urgent' => Watchfox_Words::level( 'urgent', $lang ), 'possible' => Watchfox_Words::level( 'possible', $lang ), 'low' => Watchfox_Words::level( 'low', $lang ), ); foreach ( $names as $level => $name ) { $group = array_filter( $shown, function ( $f ) use ( $level ) { return $f['level'] === $level; } ); if ( ! $group ) { continue; } $cards = self::group_findings( $group ); echo '

' . esc_html( $name ) . ' (' . (int) count( $cards ) . ')

'; foreach ( $cards as $g ) { self::render_group( $g, $lang ); } echo '
'; } if ( $unknown ) { echo '

' . esc_html__( "We couldn't read this version", 'watchfox-ai' ) . ' (' . (int) count( $unknown ) . ')

'; foreach ( $unknown as $f ) { self::render_card( $f, $lang ); } echo '
'; } } self::render_watching( array_merge( $shown, $unknown ), $quiet ); Watchfox_Fixui::render_activity(); echo '
'; self::render_rail( $lang ); echo '
'; } /** * What we're watching: everything the last check looked at that has no known problem. Local data only * (the same inventory the check uses), no network call. Open on a quiet page; one closed row under findings. */ private static function render_watching( array $problems, $quiet ) { if ( ! function_exists( 'wp_get_themes' ) ) { return; } $bad = array(); foreach ( $problems as $f ) { $bad[ ( isset( $f['ecosystem'] ) ? $f['ecosystem'] : '' ) . ':' . $f['slug'] ] = true; } $plugins = 0; $themes = 0; $fine = array(); foreach ( Watchfox_Inventory::collect() as $it ) { if ( 'wordpress-plugin' === $it['ecosystem'] ) { $plugins++; } elseif ( 'wordpress-theme' === $it['ecosystem'] ) { $themes++; } if ( ! isset( $bad[ $it['ecosystem'] . ':' . $it['slug'] ] ) ) { $fine[] = $it; } } if ( ! $fine ) { return; } $kinds = array( 'wordpress-core' => __( 'WordPress', 'watchfox-ai' ), 'php' => __( 'PHP', 'watchfox-ai' ), 'server' => __( 'Web server', 'watchfox-ai' ), 'wordpress-plugin' => __( 'Plugin', 'watchfox-ai' ), 'wordpress-theme' => __( 'Theme', 'watchfox-ai' ), ); /* translators: 1: number of plugins, 2: number of themes */ $line = sprintf( __( 'We checked %1$s, %2$s, WordPress and PHP.', 'watchfox-ai' ), sprintf( _n( '%d plugin', '%d plugins', $plugins, 'watchfox-ai' ), $plugins ), sprintf( _n( '%d theme', '%d themes', $themes, 'watchfox-ai' ), $themes ) ); echo '
'; if ( $quiet ) { echo '

' . esc_html__( "What we're watching", 'watchfox-ai' ) . '

' . esc_html( $line ) . '

'; } else { /* translators: %d: number of items with no known problems */ echo '
' . esc_html__( "What we're watching", 'watchfox-ai' ) . '' . esc_html( sprintf( _n( '%d more is fine', '%d more are fine', count( $fine ), 'watchfox-ai' ), count( $fine ) ) ) . '

' . esc_html( $line ) . '

'; } echo ''; foreach ( $fine as $it ) { $kind = isset( $kinds[ $it['ecosystem'] ] ) ? $kinds[ $it['ecosystem'] ] : ''; echo ''; } echo '
' . esc_html__( 'Item', 'watchfox-ai' ) . '' . esc_html__( 'Version', 'watchfox-ai' ) . '' . esc_html__( 'Status', 'watchfox-ai' ) . '
' . esc_html( $it['name'] ) . '' . ( in_array( $it['ecosystem'], array( 'wordpress-plugin', 'wordpress-theme' ), true ) ? ' ' . esc_html( $kind ) . '' : '' ) . '' . esc_html( '' !== $it['version'] ? $it['version'] : '-' ) . ' ' . esc_html__( 'No known problems', 'watchfox-ai' ) . '
' . ( $quiet ? '' : '
' ) . '
'; } /** The right rail of the main page: status with Check again, the confirm card, the levels link and the privacy line. */ private static function render_rail( $lang ) { $checked = Watchfox_Checker::checked_at(); $listed = Watchfox_List::last_updated(); echo ''; } /** One empty state for every screen: a short heading, one helpful line, an optional small note, and the fox only where asked. */ private static function empty_state( $fox, $title, $text, $note = '' ) { echo '
'; if ( '' !== $fox ) { echo ''; } echo '

' . esc_html( $title ) . '

' . esc_html( $text ) . '

'; if ( '' !== $note ) { echo '

' . esc_html( $note ) . '

'; } echo '
'; } /** One quiet line about the plan, with a link to the Plans tab. */ private static function render_plan_line() { $p = Watchfox_Hub::plan(); if ( null === $p ) { return; } echo '

' . esc_html( self::plan_sentence( $p ) ) . ' ' . esc_html__( 'See plans', 'watchfox-ai' ) . '

'; } public static function render_card( array $f, $lang ) { self::render_group( array( $f ), $lang ); } /** One proof row per source, then the official ID. */ private static function proof_rows( array $proof ) { foreach ( $proof['sources'] as $s ) { echo '
  • ' . esc_html( $s['org'] ) . '' . esc_html( $s['trusted'] ? __( 'trusted source', 'watchfox-ai' ) : __( 'less trusted source', 'watchfox-ai' ) ) . ''; if ( '' !== $s['date'] ) { echo '' . esc_html( $s['date'] ) . ''; } if ( '' !== $s['url'] ) { echo '' . esc_html__( 'Open', 'watchfox-ai' ) . ''; } echo '
  • '; } if ( '' !== $proof['official_id'] ) { echo '
  • ' . esc_html__( 'Official ID', 'watchfox-ai' ) . ': ' . esc_html( $proof['official_id'] ) . '
  • '; } } private static function mute_form( array $ids, $single ) { echo '
    '; foreach ( $ids as $id ) { echo ''; } wp_nonce_field( 'watchfox_mute' ); echo '
    '; } /** * One item with all its findings at one level. "Today" cards are always open; the others are one row that opens * (a native details element, so it works without JS). * * @param array[] $group Findings of one component and level. */ public static function render_group( array $group, $lang ) { $f = Watchfox_Words::most_serious( $group ); $count = count( $group ); $level = null === $f['level'] ? 'unknown' : $f['level']; $fixf = Watchfox_Words::combined( $group ); list( $path, $kind ) = Watchfox_Words::action( $f ); $name = trim( $f['name'] . ' ' . $f['version'] ); $button = '' . esc_html( 'update' === $kind ? __( 'Update', 'watchfox-ai' ) : __( 'See how', 'watchfox-ai' ) ) . ''; $open = 'urgent' === $level; $mutable = array(); foreach ( $group as $g ) { if ( 'urgent' !== $g['level'] && null !== $g['level'] ) { $mutable[] = $g['threat_id']; } } if ( $open ) { echo '
    ' . self::pill( $level, $lang ) . '

    ' . esc_html( $name ) . '

    '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- pill() escapes. } else { echo '
    ' . ( null !== $f['level'] ? self::pill( $level, $lang ) : '' ) . '

    ' . esc_html( $name ) . '

    '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- pill() escapes. } if ( $count > 1 ) { /* translators: %d: number of security problems found in one item */ echo '

    ' . esc_html( sprintf( _n( '%d security problem', '%d security problems', $count, 'watchfox-ai' ), $count ) ) . '

    '; } else { echo '

    ' . esc_html( Watchfox_Words::what_short( $f, $lang ) ) . '

    '; } echo '

    ' . esc_html( Watchfox_Words::why( $f, $lang ) ) . '

    '; $ui = Watchfox_Fixui::card( $group, $f ); echo '
    ' . esc_html__( 'What to do:', 'watchfox-ai' ) . ' ' . esc_html( '' !== $ui['text'] ? $ui['text'] : Watchfox_Words::fix( $fixf, $lang ) ) . '
    '; if ( $ui['steps'] ) { echo '
      '; foreach ( $ui['steps'] as $step ) { echo '
    1. ' . esc_html( $step ) . '
    2. '; } echo '
    '; } if ( ! empty( $ui['safety'] ) ) { echo '

    ' . esc_html( $ui['safety']['text'] ) . '

    '; } if ( '' !== $ui['note'] ) { echo '

    ' . esc_html( $ui['note'] ) . '

    '; } echo '
    ' . ( '' !== $ui['text'] ? $ui['buttons'] : $button ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from escaped parts above. if ( $mutable && count( $mutable ) === $count ) { self::mute_form( $mutable, 1 === $count ); } echo '
    '; $has_proof = false; foreach ( $group as $g ) { $p = Watchfox_Words::proof( $g ); if ( $p['sources'] || '' !== $p['official_id'] ) { $has_proof = true; } } if ( $has_proof ) { echo '
    ' . esc_html__( 'The proof', 'watchfox-ai' ) . '
      '; if ( 1 === $count ) { self::proof_rows( Watchfox_Words::proof( $group[0] ) ); } else { foreach ( $group as $g ) { $p = Watchfox_Words::proof( $g ); echo '
      • '; if ( '' !== $p['official_id'] ) { echo '
      • ' . esc_html__( 'Official ID', 'watchfox-ai' ) . ': ' . esc_html( $p['official_id'] ) . '
      • '; $p['official_id'] = ''; } self::proof_rows( $p ); if ( 'urgent' !== $g['level'] && null !== $g['level'] ) { echo '
      • '; self::mute_form( array( $g['threat_id'] ), true ); echo '
      • '; } echo '
    • '; } } echo '
    '; } echo '
    ' . ( $open ? '
    ' : '' ); } private static function fmt_date( $ts ) { return function_exists( 'wp_date' ) ? wp_date( 'j M Y, H:i', $ts ) : gmdate( 'j M Y, H:i', $ts ); } private static function notice() { $n = isset( $_GET['notice'] ) ? sanitize_key( wp_unslash( $_GET['notice'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only, whitelisted. if ( ! in_array( $n, self::NOTICES, true ) ) { return; } if ( 'fixresult' === $n ) { Watchfox_Fixui::render_result(); return; } $text = array_merge( Watchfox_Fixui::autofix_notices(), array( 'saved' => __( 'Saved.', 'watchfox-ai' ), 'email' => __( 'That email address does not look right. Please check it.', 'watchfox-ai' ), 'checked' => __( 'Checked just now.', 'watchfox-ai' ), 'muted' => __( "Done. We won't warn you about this again.", 'watchfox-ai' ), 'unmuted' => __( 'Done. We will warn you about this again.', 'watchfox-ai' ), 'reset' => __( 'Set up again.', 'watchfox-ai' ), 'resent' => __( 'Sent. Check your inbox, and your spam folder too.', 'watchfox-ai' ), 'sample' => __( 'Sample sent. Check your inbox.', 'watchfox-ai' ), 'already' => __( 'Your email is already confirmed.', 'watchfox-ai' ), 'confirmed' => __( 'Email confirmed. Warnings will come to your inbox.', 'watchfox-ai' ), 'not_yet' => __( 'Not confirmed yet. Click the Confirm button in the email, then try again.', 'watchfox-ai' ), 'resend_limit' => __( 'We already sent it 3 times today. Please check your spam folder, or try again tomorrow.', 'watchfox-ai' ), 'confirm_limit' => __( 'Too many emails today. Try again tomorrow.', 'watchfox-ai' ), 'mail_failed' => __( "We couldn't send that just now. Please try again in a little while.", 'watchfox-ai' ), 'pay_failed' => __( "We couldn't open the payment page. Please try again in a minute, or write to alerts@watchfox.site.", 'watchfox-ai' ), 'pay_unconfirmed' => __( 'Confirm your email first, then you can choose a plan.', 'watchfox-ai' ), 'pay_paid' => __( 'This site already has a paid plan.', 'watchfox-ai' ), 'pay_none' => __( "You haven't paid yet, so there is nothing to manage.", 'watchfox-ai' ), 'pay_elsewhere' => self::billing_elsewhere_text(), ) ); $bad = in_array( $n, array( 'email', 'resend_limit', 'confirm_limit', 'mail_failed', 'pay_failed', 'pay_unconfirmed', 'pay_paid', 'pay_none', 'pay_elsewhere', 'autofix_no_cap', 'autofix_no_consent', 'autofix_file_mods', 'autofix_needs_credentials', 'autofix_cannot_verify' ), true ); echo '

    ' . esc_html( $text[ $n ] ) . '

    '; $muted = isset( $_GET['muted'] ) ? sanitize_text_field( wp_unslash( $_GET['muted'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- fills an Undo form that has its own nonce. if ( 'muted' === $n && '' !== $muted && Watchfox_Settings::is_muted( $muted ) ) { echo '
    '; wp_nonce_field( 'watchfox_unmute' ); echo '
    '; } echo '
    '; } /* ---------- plans ---------- */ const CHECKOUT_HOSTS = array( 'checkout.dodopayments.com' ); private static function fmt_day( $iso ) { $ts = strtotime( (string) $iso ); if ( false === $ts ) { return ''; } return function_exists( 'wp_date' ) ? wp_date( 'j M Y', $ts ) : gmdate( 'j M Y', $ts ); } /** The one sentence about the plan, from the last reply of the hub. */ public static function plan_sentence( array $p ) { $name = isset( $p['name'] ) ? $p['name'] : ''; $paid = in_array( $name, array( 'pro', 'agency' ), true ); if ( empty( $p['confirmed'] ) ) { return __( 'Your 14-day trial starts when you confirm your email.', 'watchfox-ai' ); } if ( $paid && ! empty( $p['grace_ends_at'] ) ) { /* translators: %s: date */ return sprintf( __( 'Payment problem: please update your card by %s.', 'watchfox-ai' ), self::fmt_day( $p['grace_ends_at'] ) ); } if ( $paid && ! empty( $p['cancelling'] ) ) { /* translators: %s: date */ return sprintf( __( 'Cancelled, works until %s.', 'watchfox-ai' ), self::fmt_day( $p['paid_until'] ) ); } switch ( $name ) { case 'trial': $days = max( 0, (int) ceil( ( strtotime( (string) $p['trial_ends_at'] ) - watchfox_now() ) / DAY_IN_SECONDS ) ); /* translators: %d: days left in the free trial */ return sprintf( _n( 'Trial: %d day left', 'Trial: %d days left', $days, 'watchfox-ai' ), $days ); case 'pro': /* translators: %s: date */ return sprintf( __( 'Pro, renews %s', 'watchfox-ai' ), self::fmt_day( $p['paid_until'] ) ); case 'agency': /* translators: %s: date */ return sprintf( __( 'Agency, renews %s', 'watchfox-ai' ), self::fmt_day( $p['paid_until'] ) ); case 'free_extra': return __( 'Free plan. Another site on your account has the free spot, so this site gets no warning emails.', 'watchfox-ai' ); default: return __( 'Free plan', 'watchfox-ai' ); } } /** "Billing is managed from j***@example.com's Watchfox account page." Empty when the plan has no such address. */ public static function billing_elsewhere_text() { $p = Watchfox_Hub::plan(); if ( null === $p || empty( $p['billing_email'] ) ) { return __( "Billing is managed from your Watchfox account page.", 'watchfox-ai' ); } /* translators: %s: masked email address, for example j***@example.com */ return sprintf( __( "Billing is managed from %s's Watchfox account page.", 'watchfox-ai' ), $p['billing_email'] ); } /** The Plans tab. Findings are shown on every plan; the plan only decides which emails the hub sends. */ public static function render_plans() { echo '
    '; $p = Watchfox_Hub::plan(); if ( null === $p ) { echo '
    '; self::empty_state( '', __( 'No plan yet', 'watchfox-ai' ), __( 'Your plan shows here after your first check.', 'watchfox-ai' ) ); echo '
    '; echo '
    '; return; } $name = $p['name']; $paid = in_array( $name, array( 'pro', 'agency' ), true ); $elsewhere = ! empty( $p['billing_email'] ); $yearly = ! empty( $p['yearly'] ) && ! $elsewhere; echo '

    ' . esc_html( self::plan_sentence( $p ) ) . '

    '; if ( 'trial' === $name ) { echo '

    ' . esc_html__( 'When the trial ends, every finding still shows here. Only the warning emails change.', 'watchfox-ai' ) . '

    '; } if ( '' !== $p['suggestion'] && ! $paid ) { echo '

    ' . esc_html( $p['suggestion'] ) . '

    '; } echo '
    '; if ( $yearly ) { // A CSS-only switch: the radios sit before the grid and show the monthly or the yearly prices and buttons. echo '
    ' . esc_html__( 'Billing period', 'watchfox-ai' ) . ''; echo ''; echo '
    '; } echo '
    '; if ( $elsewhere ) { echo '

    ' . esc_html( self::billing_elsewhere_text() ) . '

    '; } $current = 'free_extra' === $name ? 'free' : $name; $cards = array( 'free' => array( __( 'Free', 'watchfox-ai' ), __( '$0', 'watchfox-ai' ), __( 'forever', 'watchfox-ai' ), __( '$0', 'watchfox-ai' ), __( 'forever', 'watchfox-ai' ), array( __( '1 site', 'watchfox-ai' ), __( 'One check a month', 'watchfox-ai' ), __( 'The first "Today" warning each month, in full', 'watchfox-ai' ) ) ), 'pro' => array( __( 'Pro', 'watchfox-ai' ), __( '$4', 'watchfox-ai' ), __( 'a month, per site', 'watchfox-ai' ), __( '$40', 'watchfox-ai' ), __( 'a year, per site', 'watchfox-ai' ), array( __( 'Checked all day, every day', 'watchfox-ai' ), __( 'Every warning, with its proof', 'watchfox-ai' ), __( 'Early warnings from the news', 'watchfox-ai' ), __( 'Optional weekly summary', 'watchfox-ai' ) ) ), 'agency' => array( __( 'Agency', 'watchfox-ai' ), __( '$19', 'watchfox-ai' ), __( 'a month, up to 10 sites', 'watchfox-ai' ), __( '$190', 'watchfox-ai' ), __( 'a year, up to 10 sites', 'watchfox-ai' ), array( __( 'Everything in Pro', 'watchfox-ai' ), __( 'All sites on one page', 'watchfox-ai' ), __( 'One combined email, not ten', 'watchfox-ai' ) ) ), ); echo '
    '; foreach ( $cards as $key => $c ) { $is_now = $key === $current; echo '
    '; if ( $is_now ) { echo '' . esc_html__( 'Your plan', 'watchfox-ai' ) . ''; } elseif ( 'pro' === $key && 'trial' === $current ) { echo '' . esc_html__( 'You are trying this now', 'watchfox-ai' ) . ''; } echo '

    ' . esc_html( $c[0] ) . '

    '; echo '

    ' . esc_html( $c[1] ) . ' ' . esc_html( $c[2] ) . '

    '; if ( $yearly ) { echo '

    ' . esc_html( $c[3] ) . ' ' . esc_html( $c[4] ) . '

    '; } echo '
    '; if ( $elsewhere ) { if ( $is_now ) { echo '

    ' . esc_html__( "You're on this plan", 'watchfox-ai' ) . '

    '; } } elseif ( 'pro' === $key && ! $paid ) { self::plan_button( 'pro', __( 'Get Pro for this site', 'watchfox-ai' ), __( 'Get Pro for this site, yearly ($40)', 'watchfox-ai' ), $yearly ); } elseif ( 'agency' === $key && 'agency' !== $name ) { self::plan_button( 'agency', __( 'Get Agency', 'watchfox-ai' ), __( 'Get Agency, yearly ($190)', 'watchfox-ai' ), $yearly, 'pro' !== $name ); } elseif ( $is_now && $paid && ! empty( $p['can_portal'] ) ) { echo '

    ' . esc_html__( "You're on this plan", 'watchfox-ai' ) . '

    '; echo '
    '; wp_nonce_field( 'watchfox_portal' ); echo '
    '; } elseif ( $is_now ) { echo '

    ' . esc_html__( "You're on this plan", 'watchfox-ai' ) . '

    '; } else { echo '' . esc_html__( 'Compare plans', 'watchfox-ai' ) . ''; } echo '
    '; } echo '
    '; echo '

    ' . esc_html__( 'Deleting the plugin does not cancel a paid plan. Cancel here or from any payment email.', 'watchfox-ai' ) . '

    '; } /** Monthly and yearly checkout buttons; with yearly on offer, the period switch shows one of them. */ private static function plan_button( $plan, $label, $yearly_label, $yearly, $ghost = false ) { echo '
    '; wp_nonce_field( 'watchfox_checkout' ); echo ''; if ( $yearly ) { echo ''; } echo '
    '; } /** * Where a payment redirect may go: https on Dodo's checkout hosts, and for the customer portal any dodopayments.com host * the hub handed over. Anything else is refused, so a wrong reply can never send the owner elsewhere. * * @return string|null The URL, or null when it is not allowed. */ public static function redirect_target( $url, $kind ) { $parts = is_string( $url ) ? wp_parse_url( $url ) : false; if ( ! is_array( $parts ) || empty( $parts['host'] ) || ! isset( $parts['scheme'] ) || 'https' !== $parts['scheme'] || isset( $parts['user'] ) || isset( $parts['port'] ) ) { return null; } $host = strtolower( $parts['host'] ); $ok = in_array( $host, self::CHECKOUT_HOSTS, true ) || ( 'portal' === $kind && ( 'dodopayments.com' === $host || '.dodopayments.com' === substr( $host, -17 ) ) ); return $ok ? $url : null; } public static function handle_checkout() { self::guard( 'watchfox_checkout' ); self::pay_redirect( Watchfox_Hub::checkout( 'agency' === self::post( 'plan' ) ? 'agency' : 'pro', 'year' === self::post( 'interval' ) ? 'year' : 'month' ), 'checkout' ); } public static function handle_portal() { self::guard( 'watchfox_portal' ); self::pay_redirect( Watchfox_Hub::portal(), 'portal' ); } /** Sends the owner to the payment page, or back to the Plans tab with an honest reason. */ private static function pay_redirect( array $r, $kind ) { if ( $r['ok'] ) { $target = self::redirect_target( $r['url'], $kind ); if ( null !== $target ) { $host = (string) wp_parse_url( $target, PHP_URL_HOST ); add_filter( 'allowed_redirect_hosts', function ( $hosts ) use ( $host ) { $hosts[] = $host; return $hosts; } ); wp_safe_redirect( $target ); exit; } } $map = array( 'unconfirmed' => 'pay_unconfirmed', 'already_paid' => 'pay_paid', 'no_customer' => 'pay_none', 'billing_elsewhere' => 'pay_elsewhere' ); self::go( self::PAGE_SETTINGS, array( 'notice' => isset( $map[ $r['error'] ] ) ? $map[ $r['error'] ] : 'pay_failed' ), '#plans' ); } /* ---------- settings ---------- */ public static function render_settings() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to see this page.', 'watchfox-ai' ) ); } Watchfox_Hub::sync_email_state(); $s = Watchfox_Settings::get(); echo '
    '; self::header( self::PAGE_SETTINGS ); self::notice(); echo '

    ' . esc_html__( 'Settings', 'watchfox-ai' ) . '

    '; // Without JS the tabs are plain links to the sections below; with JS they switch panels. echo ''; echo '
    '; self::form_open( 'save_emails', 'wfx-panel' ); echo '
    '; echo '
    '; self::level_fields( $s ); echo ''; echo '
    '; echo '
    '; if ( ! $s['muted'] ) { echo '
    '; self::empty_state( '', __( 'Nothing is muted', 'watchfox-ai' ), __( 'When you stop a warning, it shows up here so you can undo it.', 'watchfox-ai' ) ); echo '
    '; } else { $names = array(); foreach ( Watchfox_Checker::findings() as $f ) { $names[ $f['threat_id'] ] = trim( $f['name'] . ' ' . $f['version'] ); } echo '
      '; foreach ( $s['muted'] as $id ) { echo '
    • ' . esc_html( isset( $names[ $id ] ) ? $names[ $id ] : $id ) . ''; echo '
      '; wp_nonce_field( 'watchfox_unmute' ); echo '
    • '; } echo '
    '; } echo '
    '; Watchfox_Fixui::render_autofix_panel(); self::render_plans(); echo '
    '; } /* ---------- form handlers ---------- */ private static function guard( $action ) { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'watchfox-ai' ), '', array( 'response' => 403 ) ); } check_admin_referer( $action ); } private static function go( $page, array $args, $hash = '' ) { wp_safe_redirect( add_query_arg( array_merge( array( 'page' => $page ), $args ), admin_url( 'admin.php' ) ) . $hash ); exit; } private static function post( $key ) { return isset( $_POST[ $key ] ) ? sanitize_text_field( wp_unslash( $_POST[ $key ] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- guard() verified the nonce. } public static function handle_setup_email() { self::guard( 'watchfox_setup_email' ); $email = sanitize_email( self::post( 'email' ) ); if ( '' === $email ) { self::go( self::PAGE, array( 'notice' => 'email' ) ); } Watchfox_Settings::save( array( 'email' => $email, 'lang' => self::post( 'lang' ) ) ); self::go( self::PAGE, array( 'step' => 2 ) ); } public static function handle_setup_levels() { self::guard( 'watchfox_setup_levels' ); Watchfox_Settings::save( self::levels_from_post() ); $s = Watchfox_Settings::get(); Watchfox_Hub::register( $s['email'], $s['lang'] ); // If the hub is down the first check registers later. Watchfox_Settings::save( array( 'done' => true ) ); Watchfox_Checker::schedule(); Watchfox_Checker::run(); self::go( self::PAGE, array( 'step' => 3 ) ); } private static function levels_from_post() { // phpcs:disable WordPress.Security.NonceVerification.Missing -- guard() verified the nonce. return array( 'levels' => array( 'possible' => ! empty( $_POST['level_possible'] ), 'low' => ! empty( $_POST['level_low'] ) ), 'weekly' => ! empty( $_POST['weekly'] ), ); // phpcs:enable } public static function handle_save_emails() { self::guard( 'watchfox_save_emails' ); $email = sanitize_email( self::post( 'email' ) ); if ( '' === $email ) { self::go( self::PAGE_SETTINGS, array( 'notice' => 'email' ) ); } $review = array( 'review_mail' => ! empty( $_POST['review_mail'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- guard() verified the nonce. Watchfox_Settings::save( array_merge( array( 'email' => $email, 'lang' => self::post( 'lang' ) ), self::levels_from_post(), $review ) ); self::push_settings(); self::go( self::PAGE_SETTINGS, array( 'notice' => 'saved' ) ); } public static function handle_mute() { self::guard( 'watchfox_mute' ); $ids = array(); if ( isset( $_POST['threat_ids'] ) && is_array( $_POST['threat_ids'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- guard() verified the nonce. $ids = array_map( 'sanitize_text_field', wp_unslash( $_POST['threat_ids'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized by array_map. } if ( '' !== self::post( 'threat_id' ) ) { $ids[] = self::post( 'threat_id' ); } $done = false; foreach ( Watchfox_Checker::findings() as $f ) { // "Today" warnings are refused by Settings::mute. if ( in_array( $f['threat_id'], $ids, true ) && null !== $f['level'] && Watchfox_Settings::mute( $f['threat_id'], $f['level'] ) ) { $done = true; } } if ( $done ) { self::push_settings(); self::go( self::PAGE, array( 'notice' => 'muted' ) ); } self::go( self::PAGE, array() ); } public static function handle_unmute() { self::guard( 'watchfox_unmute' ); Watchfox_Settings::unmute( self::post( 'threat_id' ) ); self::push_settings(); self::go( 'main' === self::post( 'from' ) ? self::PAGE : self::PAGE_SETTINGS, array( 'notice' => 'unmuted' ) ); } /** "I've confirmed, check again": asks the hub now, skipping the 30 second wait. */ public static function handle_sync() { self::guard( 'watchfox_sync' ); Watchfox_Hub::sync_email_state( true ); $state = Watchfox_Hub::state(); self::go( self::PAGE, true === $state['confirmed'] && null === $state['pending_email'] ? array( 'notice' => 'confirmed' ) : array( 'notice' => 'not_yet' ) ); } public static function handle_recheck() { self::guard( 'watchfox_recheck' ); Watchfox_Checker::run(); if ( Watchfox_Hub::plan_age() > 60 ) { Watchfox_Hub::refresh_plan(); // A payment may have gone through since the last report. } self::go( self::PAGE, array( 'notice' => 'checked' ) ); } public static function handle_resend() { self::guard( 'watchfox_resend' ); self::go( self::PAGE, array( 'notice' => self::mail_notice( Watchfox_Hub::resend_confirm(), 'resent' ) ) ); } public static function handle_sample() { self::guard( 'watchfox_sample' ); self::go( self::PAGE, array( 'notice' => self::mail_notice( Watchfox_Hub::send_sample(), 'sample' ) ) ); } /** The notice for a resend or sample reply: success, or an honest reason. */ public static function mail_notice( array $r, $ok_notice ) { if ( $r['ok'] ) { return $ok_notice; } switch ( $r['error'] ) { case 'resend_daily': return 'resend_limit'; case 'confirm_limit': case 'sample_daily': return 'confirm_limit'; case 'confirmed': return 'already'; default: return 'mail_failed'; } } public static function handle_reset() { self::guard( 'watchfox_reset' ); delete_option( Watchfox_Hub::OPT_SITE ); delete_option( Watchfox_Hub::OPT_STATE ); delete_option( Watchfox_Hub::OPT_COPY ); delete_option( Watchfox_Hub::OPT_LOST ); delete_option( Watchfox_Hub::OPT_HUB ); delete_option( Watchfox_Hub::OPT_PLAN ); delete_option( Watchfox_Hub::OPT_CLEARED ); Watchfox_Settings::save( array( 'done' => false ) ); self::go( self::PAGE, array( 'notice' => 'reset' ) ); } /** Settings changed: tell the hub now (the report is skipped if nothing changed). */ private static function push_settings() { if ( Watchfox_Hub::is_registered() ) { // Under the check lock so it cannot overlap a running check; if one is running, its report carries the settings. Watchfox_Checker::with_lock( function () { Watchfox_Hub::report( Watchfox_Checker::findings(), Watchfox_Settings::for_report() ); } ); } } }