__( 'Speed', 'getreport' ), 'seo' => __( 'SEO', 'getreport' ), 'security' => __( 'Security', 'getreport' ), 'a11y' => __( 'Accessibility', 'getreport' ), 'schema' => __( 'Structured data', 'getreport' ), 'social' => __( 'Social', 'getreport' ), 'practices' => __( 'Best practices', 'getreport' ), ); } /** * The screens: slug suffix → [menu label, renderer]. * * @return array */ public static function screens() { return array( '' => array( __( 'Overview', 'getreport' ), 'overview' ), '-audit' => array( __( 'AI audit', 'getreport' ), 'audit' ), '-crawlers' => array( __( 'AI crawlers', 'getreport' ), 'crawlers' ), '-referrals' => array( __( 'AI referrals', 'getreport' ), 'referrals' ), '-fixes' => array( __( 'Fixes', 'getreport' ), 'fixes' ), '-report' => array( __( 'Website report', 'getreport' ), 'report' ), '-settings' => array( __( 'Settings', 'getreport' ), 'settings' ), ); } /** Hooks the admin side. */ public static function register() { add_action( 'admin_menu', array( __CLASS__, 'menu' ) ); add_action( 'admin_init', array( __CLASS__, 'old_tools_link' ) ); add_action( 'admin_enqueue_scripts', array( __CLASS__, 'assets' ) ); add_action( 'wp_dashboard_setup', array( __CLASS__, 'dashboard' ) ); add_action( 'admin_notices', array( __CLASS__, 'welcome' ) ); add_filter( 'post_row_actions', array( __CLASS__, 'row_action' ), 10, 2 ); add_filter( 'page_row_actions', array( __CLASS__, 'row_action' ), 10, 2 ); add_filter( 'plugin_action_links_' . plugin_basename( GETREPORT_FILE ), array( __CLASS__, 'plugin_links' ) ); add_filter( 'plugin_row_meta', array( __CLASS__, 'plugin_meta' ), 10, 2 ); $actions = array( 'connect', 'autokey', 'disconnect', 'key', 'check', 'fix', 'undo', 'weekly', 'audit', 'robots', 'settings', 'import', 'access', 'ips', 'welcome', 'purge' ); foreach ( $actions as $a ) { add_action( 'admin_post_getreport_' . $a, array( __CLASS__, 'handle_' . $a ) ); } add_action( 'wp_ajax_getreport_audit_step', array( __CLASS__, 'ajax_audit_step' ) ); } /** The getReport menu, with its own icon, in the default position (Guideline 11: no top-of-menu placement). */ public static function menu() { $icon = 'data:image/svg+xml;base64,' . base64_encode( '' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- the menu icon, a data URI add_menu_page( __( 'getReport', 'getreport' ), __( 'getReport', 'getreport' ), self::CAP, self::PAGE, array( 'GetReport_Admin_Pages', 'overview' ), $icon ); foreach ( self::screens() as $suffix => $s ) { add_submenu_page( self::PAGE, $s[0] . ' ‹ getReport', $s[0], self::CAP, self::PAGE . $suffix, array( 'GetReport_Admin_Pages', $s[1] ) ); } } /** Tools → getReport (version 1) now lives in its own menu. */ public static function old_tools_link() { global $pagenow; if ( 'tools.php' === $pagenow && isset( $_GET['page'] ) && self::PAGE === $_GET['page'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- a redirect only wp_safe_redirect( self::url() ); exit; } } /** * A screen's URL, with an optional notice code and extra arguments. * * @param string $suffix Screen suffix ('' = Overview). * @param string $notice Notice code. * @param array $args Extra query arguments. */ public static function url( $suffix = '', $notice = '', $args = array() ) { $url = admin_url( 'admin.php?page=' . self::PAGE . $suffix ); if ( $notice ) { $args['getreport_notice'] = $notice; } return $args ? add_query_arg( $args, $url ) : $url; } /** * Styles and the audit page's runner, on our screens only. * * @param string $hook Screen hook. */ public static function assets( $hook ) { if ( false === strpos( (string) $hook, self::PAGE ) ) { return; } wp_enqueue_style( 'getreport-admin', plugins_url( 'assets/admin.css', GETREPORT_FILE ), array(), GETREPORT_VERSION ); wp_enqueue_script( 'getreport-admin', plugins_url( 'assets/admin.js', GETREPORT_FILE ), array(), GETREPORT_VERSION, true ); wp_add_inline_script( 'getreport-admin', 'window.getreportAdmin = ' . wp_json_encode( array( 'ajax' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'getreport_audit_step' ), 'i18n' => array( /* translators: 1: posts done, 2: all posts. */ 'progress' => __( '%1$d of %2$d posts audited…', 'getreport' ), 'done' => __( 'Done. Reloading…', 'getreport' ), 'copied' => __( 'Copied', 'getreport' ), ), ) ) . ';', 'before' ); } /** * A POST form to admin-post.php with a nonce. * * @param string $action Action suffix. * @param string $label Button label. * @param array $fields Hidden fields. * @param string $button_class Button class. * @param string $extra Extra fields before the button (HTML, limited to allowed_html()). */ public static function form( $action, $label, $fields = array(), $button_class = 'button', $extra = '' ) { echo '
'; echo ''; wp_nonce_field( 'getreport_' . $action ); foreach ( $fields as $k => $v ) { echo ''; } echo wp_kses( $extra, self::allowed_html() ); echo '
'; } /** * The HTML the getReport screens print from their own helpers (score rings, bars, buttons, * forms with a nonce), for wp_kses() when that markup is echoed. * * @return array> */ public static function allowed_html() { $common = array( 'class' => true, 'id' => true, 'style' => true, 'role' => true, 'aria-hidden' => true, 'aria-label' => true, 'aria-current' => true, ); $svg = array( 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-dasharray' => true, 'transform' => true, ); return array( 'a' => $common + array( 'href' => true, 'target' => true, 'rel' => true, ), 'span' => $common, 'div' => $common, 'p' => $common, 'strong' => $common, 'em' => $common, 'code' => $common, 'br' => array(), 'hr' => $common, 'tr' => $common, 'td' => $common + array( 'colspan' => true ), 'th' => $common + array( 'scope' => true, 'colspan' => true, ), 'ul' => $common, 'li' => $common, 'details' => $common, 'summary' => $common, 'form' => $common + array( 'method' => true, 'action' => true, ), 'input' => $common + array( 'type' => true, 'name' => true, 'value' => true, 'required' => true, 'checked' => true, 'autocomplete' => true, 'placeholder' => true, ), 'label' => $common + array( 'for' => true ), 'button' => $common + array( 'type' => true, 'name' => true, 'value' => true, ), 'svg' => $common + array( 'width' => true, 'height' => true, 'viewbox' => true, ), 'circle' => $svg + array( 'cx' => true, 'cy' => true, 'r' => true, ), 'path' => $svg + array( 'd' => true ), 'rect' => $svg + array( 'width' => true, 'height' => true, 'rx' => true, ), 'text' => $svg + array( 'x' => true, 'y' => true, 'text-anchor' => true, 'font-size' => true, 'font-weight' => true, ), ); } /** Stops unless the user may manage the plugin. */ private static function require_cap() { if ( ! current_user_can( self::CAP ) ) { wp_die( esc_html__( 'You are not allowed to do this.', 'getreport' ), 403 ); } } /** * Redirects back to a screen with a notice. * * @param string $notice Notice code. * @param string $suffix Screen suffix; default: the screen the form was on. * @param array $args Extra arguments. */ private static function back( $notice, $suffix = null, $args = array() ) { if ( null === $suffix ) { $suffix = isset( $_POST['screen'] ) ? sanitize_key( wp_unslash( $_POST['screen'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- checked by the handler $suffix = array_key_exists( '-' . $suffix, self::screens() ) ? '-' . $suffix : ''; } wp_safe_redirect( self::url( $suffix, $notice, $args ) ); exit; } /** Latest website-report scores, cached for 10 minutes (only once connected). */ public static function latest_cached() { if ( ! GetReport_Plugin::connected() ) { return null; } $cached = get_transient( 'getreport_latest' ); if ( false !== $cached ) { return $cached ? $cached : null; } $latest = GetReport_Api::latest(); if ( is_wp_error( $latest ) ) { return $latest; } // "Not checked yet" is kept for a minute only, so a check you just ran shows up soon. set_transient( 'getreport_latest', $latest ? $latest : 0, $latest ? 10 * MINUTE_IN_SECONDS : MINUTE_IN_SECONDS ); return $latest; } /** The site's hub on getreport.app. */ public static function hub_link() { return GetReport_Api::site_url() . '/site/' . rawurlencode( GetReport_Api::host() ); } /** * Starts a re-test after a change, when there is a key, remembering the score before. * * @param string $id Fix id. */ private static function retest( $id ) { $module = GetReport_Fixes::all()[ $id ]['module'] ?? ''; if ( 'ai' === $module ) { // AI fixes are measured locally: the audit re-runs with the new site facts. delete_option( GetReport_Audit::FACTS ); if ( GetReport_Audit::progress()['total'] ) { GetReport_Audit::start(); } return; } if ( ! GetReport_Plugin::connected() || ! GetReport_Plugin::api_key() ) { return; } $latest = GetReport_Api::latest(); $before = ( is_array( $latest ) && isset( $latest['scores'][ $module ] ) ) ? $latest['scores'][ $module ] : null; $report = GetReport_Api::start_check( home_url( '/' ) ); if ( ! is_wp_error( $report ) ) { GetReport_Fixes::remember_retest( $id, $report, $before ); delete_transient( 'getreport_latest' ); } } // ---- Handlers (admin-post.php) ---- /** Connect: consent to contact getReport. */ public static function handle_connect() { self::require_cap(); check_admin_referer( 'getreport_connect' ); update_option( 'getreport_connected', 1, false ); delete_transient( 'getreport_latest' ); if ( GetReport_Plugin::api_key() ) { self::back( 'connected', '-report' ); } self::autokey(); } /** Gets this site's key now (Connect, or "Get the key" after it failed once). */ public static function handle_autokey() { self::require_cap(); check_admin_referer( 'getreport_autokey' ); self::autokey(); } /** Runs the automatic key and goes back to Website report with the outcome. */ private static function autokey() { $r = GetReport_Plugin::fetch_site_key(); if ( is_wp_error( $r ) ) { set_transient( 'getreport_autokey_error', $r->get_error_message(), HOUR_IN_SECONDS ); self::back( 'connected_nokey', '-report' ); } delete_transient( 'getreport_autokey_error' ); self::back( 'connected_key', '-report' ); } /** Disconnect: stop contacting getReport, forget the key, stop the email. */ public static function handle_disconnect() { self::require_cap(); check_admin_referer( 'getreport_disconnect' ); update_option( 'getreport_connected', 0, false ); delete_option( 'getreport_api_key' ); delete_option( 'getreport_verify_token' ); delete_transient( 'getreport_autokey_error' ); GetReport_Cron::set( false ); delete_transient( 'getreport_latest' ); self::back( 'disconnected', '-report' ); } /** Saves the API key. */ public static function handle_key() { self::require_cap(); check_admin_referer( 'getreport_key' ); $key = isset( $_POST['getreport_api_key'] ) ? sanitize_text_field( wp_unslash( $_POST['getreport_api_key'] ) ) : ''; if ( ! preg_match( '/^gr_[A-Za-z0-9_-]{8,200}$/', $key ) ) { self::back( 'error', '-report' ); } update_option( 'getreport_api_key', $key, false ); self::back( 'key', '-report' ); } /** Check now, with the key. */ public static function handle_check() { self::require_cap(); check_admin_referer( 'getreport_check' ); $id = GetReport_Plugin::connected() ? GetReport_Api::start_check( home_url( '/' ) ) : new WP_Error( 'x', 'not connected' ); delete_transient( 'getreport_latest' ); self::back( is_wp_error( $id ) ? 'error' : 'checking', '-report' ); } /** Applies a fix. */ public static function handle_fix() { self::require_cap(); check_admin_referer( 'getreport_fix' ); $id = isset( $_POST['fix'] ) ? sanitize_key( wp_unslash( $_POST['fix'] ) ) : ''; $all = GetReport_Fixes::all(); if ( ! empty( $all[ $id ]['confirm'] ) && empty( $_POST['confirm'] ) ) { self::back( 'error' ); } $r = GetReport_Fixes::apply( $id ); if ( ! is_wp_error( $r ) ) { self::retest( $id ); } self::back( is_wp_error( $r ) ? 'error' : 'applied' ); } /** Undoes a fix. */ public static function handle_undo() { self::require_cap(); check_admin_referer( 'getreport_undo' ); $id = isset( $_POST['fix'] ) ? sanitize_key( wp_unslash( $_POST['fix'] ) ) : ''; $r = GetReport_Fixes::undo( $id ); if ( ! is_wp_error( $r ) ) { self::retest( $id ); } self::back( is_wp_error( $r ) ? 'error' : 'undone' ); } /** Turns the weekly email on or off. */ public static function handle_weekly() { self::require_cap(); check_admin_referer( 'getreport_weekly' ); $on = isset( $_POST['on'] ) && '1' === $_POST['on']; GetReport_Cron::set( $on && GetReport_Plugin::connected() && GetReport_Plugin::api_key() ); self::back( 'weekly', '-report' ); } /** Starts the site audit. */ public static function handle_audit() { self::require_cap(); check_admin_referer( 'getreport_audit' ); GetReport_Audit::start(); self::back( 'audit', '-audit' ); } /** One batch of the audit, while the audit page is open (admin-ajax). */ public static function ajax_audit_step() { if ( ! current_user_can( self::CAP ) ) { wp_send_json_error( null, 403 ); } check_ajax_referer( 'getreport_audit_step' ); wp_send_json_success( GetReport_Audit::run_batch() ); } /** Saves the per-bot robots.txt rules and turns the robots fix on. */ public static function handle_robots() { self::require_cap(); check_admin_referer( 'getreport_robots' ); $rules = array(); $posted = isset( $_POST['rule'] ) && is_array( $_POST['rule'] ) ? map_deep( wp_unslash( $_POST['rule'] ), 'sanitize_key' ) : array(); foreach ( GetReport_Fixes::default_robots_rules() as $token => $default ) { $v = isset( $posted[ $token ] ) ? sanitize_key( $posted[ $token ] ) : $default; $rules[ $token ] = 'block' === $v ? 'block' : 'allow'; } GetReport_Fixes::set_config( 'robots-ai-rules', $rules ); if ( ! GetReport_Fixes::is_on( 'robots-ai-rules' ) ) { GetReport_Fixes::apply( 'robots-ai-rules' ); } delete_option( GetReport_Audit::FACTS ); self::back( 'robots', '-crawlers' ); } /** Saves one Settings tab. */ public static function handle_settings() { self::require_cap(); check_admin_referer( 'getreport_settings' ); $tab = isset( $_POST['tab'] ) ? sanitize_key( wp_unslash( $_POST['tab'] ) ) : 'general'; $vals = array(); // phpcs:disable WordPress.Security.NonceVerification.Missing -- checked above $types = function ( $key ) { $in = isset( $_POST[ $key ] ) && is_array( $_POST[ $key ] ) ? array_map( 'sanitize_key', wp_unslash( $_POST[ $key ] ) ) : array(); return array_values( array_filter( $in, 'post_type_exists' ) ); }; if ( 'general' === $tab ) { $vals['post_types'] = $types( 'post_types' ); $vals['date_types'] = $types( 'date_types' ); $vals['author_types'] = $types( 'author_types' ); $vals['stale_months'] = isset( $_POST['stale_months'] ) ? max( 3, min( 60, absint( $_POST['stale_months'] ) ) ) : 18; } elseif ( 'facts' === $tab ) { $f = array(); foreach ( array( 'name', 'founded', 'address', 'phone', 'email' ) as $k ) { $f[ $k ] = isset( $_POST[ 'fact_' . $k ] ) ? sanitize_text_field( wp_unslash( $_POST[ 'fact_' . $k ] ) ) : ''; } $f['founded'] = preg_match( '/^\d{4}$/', $f['founded'] ) ? $f['founded'] : ''; $f['email'] = is_email( $f['email'] ) ? $f['email'] : ''; $same = isset( $_POST['fact_same_as'] ) ? sanitize_textarea_field( wp_unslash( $_POST['fact_same_as'] ) ) : ''; $f['same_as'] = implode( "\n", array_filter( array_map( 'esc_url_raw', preg_split( '/\s+/', $same ) ) ) ); $vals['facts'] = $f; } elseif ( 'crawlers' === $tab ) { $vals['capture_bots'] = ! empty( $_POST['capture_bots'] ); $vals['ip_refresh'] = ! empty( $_POST['ip_refresh'] ); $vals['retention_days'] = isset( $_POST['retention_days'] ) ? max( 7, min( 365, absint( $_POST['retention_days'] ) ) ) : 90; $vals['trusted_proxies'] = isset( $_POST['trusted_proxies'] ) ? sanitize_textarea_field( wp_unslash( $_POST['trusted_proxies'] ) ) : ''; } elseif ( 'referrals' === $tab ) { $vals['beacon'] = ! empty( $_POST['beacon'] ); } elseif ( 'privacy' === $tab ) { $vals['delete_on_uninstall'] = ! empty( $_POST['delete_on_uninstall'] ); } // phpcs:enable GetReport_Plugin::save_settings( $vals ); if ( in_array( $tab, array( 'general', 'facts' ), true ) ) { delete_option( GetReport_Audit::FACTS ); } if ( 'crawlers' === $tab ) { GetReport_Cron::schedule_daily(); } $screen = isset( $_POST['screen'] ) ? sanitize_key( wp_unslash( $_POST['screen'] ) ) : 'settings'; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- checked above self::back( 'saved', '-' . ( 'referrals' === $screen ? 'referrals' : 'settings' ), array( 'tab' => $tab ) ); } /** Imports a server access log (upload, or a path WordPress can read). */ public static function handle_import() { self::require_cap(); check_admin_referer( 'getreport_import' ); $path = ''; // phpcs:disable WordPress.Security.NonceVerification.Missing -- checked above $tmp = isset( $_FILES['log']['tmp_name'] ) ? sanitize_text_field( $_FILES['log']['tmp_name'] ) : ''; if ( '' !== $tmp && is_uploaded_file( $tmp ) ) { $path = $tmp; } elseif ( ! empty( $_POST['log_path'] ) ) { $path = sanitize_text_field( wp_unslash( $_POST['log_path'] ) ); // Only plain log files: no PHP, no config, nothing outside a log-like name. if ( ! preg_match( '/(access|log)[^\/\\\\]*(\.log|\.gz|\.\d+)?$/i', $path ) || preg_match( '/\.php|wp-config/i', $path ) ) { self::back( 'log_bad', '-crawlers' ); } } // phpcs:enable $r = '' === $path ? new WP_Error( 'x', 'no file' ) : GetReport_Bots::import_log( $path ); if ( is_wp_error( $r ) ) { self::back( 'log_bad', '-crawlers' ); } set_transient( 'getreport_import_result', $r, 10 * MINUTE_IN_SECONDS ); self::back( 'imported', '-crawlers' ); } /** Runs the bot access test and keeps the result for the page. */ public static function handle_access() { self::require_cap(); check_admin_referer( 'getreport_access' ); set_transient( 'getreport_access_test', array( 'at' => time(), 'results' => GetReport_Bots::access_test( array( 'OAI-SearchBot', 'ChatGPT-User', 'PerplexityBot', 'Claude-SearchBot', 'GPTBot', 'ClaudeBot' ) ), ), DAY_IN_SECONDS ); self::back( '', '-crawlers', array( 'tested' => 1 ) ); } /** Downloads the bot IP lists now (only when "Keep bot lists up to date" is on). */ public static function handle_ips() { self::require_cap(); check_admin_referer( 'getreport_ips' ); if ( ! GetReport_Plugin::setting( 'ip_refresh' ) ) { self::back( 'error', '-settings', array( 'tab' => 'crawlers' ) ); } $r = GetReport_Registry::refresh_ip_ranges(); self::back( is_wp_error( $r ) ? 'error' : 'ips', '-settings', array( 'tab' => 'crawlers' ) ); } /** Dismisses the welcome notice. */ public static function handle_welcome() { self::require_cap(); check_admin_referer( 'getreport_welcome' ); GetReport_Plugin::save_settings( array( 'welcome_dismissed' => true ) ); wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url() ); exit; } /** Deletes the collected bot, referral and audit data now (settings and fixes stay). */ public static function handle_purge() { global $wpdb; self::require_cap(); check_admin_referer( 'getreport_purge' ); foreach ( array( 'bot_hits', 'bot_daily', 'ref_daily', 'audit' ) as $t ) { $wpdb->query( $wpdb->prepare( 'DELETE FROM %i', GetReport_Db::table( $t ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the plugin's own tables } delete_option( GetReport_Audit::QUEUE ); delete_option( GetReport_Audit::FACTS ); self::back( 'purged', '-settings', array( 'tab' => 'privacy' ) ); } // ---- Welcome, dashboard widget, row action, plugin links ---- /** One dismissible welcome notice after activation (and never again once dismissed). */ public static function welcome() { $screen = get_current_screen(); if ( ! current_user_can( self::CAP ) || GetReport_Plugin::setting( 'welcome_dismissed' ) || ! $screen || ! in_array( $screen->id, array( 'dashboard', 'plugins' ), true ) ) { return; } echo '

' . esc_html__( 'getReport is ready.', 'getreport' ) . ' '; echo esc_html__( 'It is in the left menu under getReport: run the AI audit of your posts, see which AI crawlers visit, and apply one-click fixes. Nothing is sent anywhere until you choose to connect.', 'getreport' ) . '

'; echo '' . esc_html__( 'Open getReport', 'getreport' ) . ' '; self::form( 'welcome', __( 'Dismiss', 'getreport' ), array(), 'button-link' ); echo '

'; } /** The dashboard widget. */ public static function dashboard() { if ( current_user_can( self::CAP ) ) { wp_add_dashboard_widget( 'getreport_scores', __( 'getReport', 'getreport' ), array( 'GetReport_Admin_Pages', 'widget' ) ); } } /** * "Check with getReport" under each published post and page (opens getreport.app). * * @param array $actions Row actions. * @param WP_Post $post Post. */ public static function row_action( $actions, $post ) { if ( 'publish' === $post->post_status && current_user_can( 'edit_post', $post->ID ) ) { $actions['getreport'] = '' . esc_html__( 'Check with getReport', 'getreport' ) . ' ' . esc_html__( '(opens getreport.app in a new tab)', 'getreport' ) . ''; } return $actions; } /** * "Open" and "Settings" links on the Plugins screen. * * @param array $links Links. */ public static function plugin_links( $links ) { array_unshift( $links, '' . esc_html__( 'Open', 'getreport' ) . '', '' . esc_html__( 'Settings', 'getreport' ) . '' ); return $links; } /** * "Documentation" and "Support" under the plugin's description on the Plugins screen. * * @param array $meta Row meta links. * @param string $file Plugin file of the row. */ public static function plugin_meta( $meta, $file ) { if ( plugin_basename( GETREPORT_FILE ) === $file ) { $meta[] = '' . esc_html__( 'Documentation', 'getreport' ) . ''; $meta[] = '' . esc_html__( 'Support', 'getreport' ) . ''; } return $meta; } }