add_action( 'admin_menu', $this, 'add_menu_pages' ); $loader->add_action( 'admin_enqueue_scripts', $this, 'enqueue_assets' ); $loader->add_action( 'enqueue_block_editor_assets', $this, 'enqueue_block_editor_assets' ); $loader->add_action( 'admin_post_abds_seo_save_settings', $this, 'handle_save_settings' ); $loader->add_action( 'admin_post_abds_seo_run_migration', $this, 'handle_run_migration' ); $loader->add_action( 'admin_post_abds_seo_deactivate_source', $this, 'handle_deactivate_source' ); $loader->add_action( 'admin_post_abds_seo_export_csv', $this, 'handle_export_csv' ); $loader->add_action( 'admin_post_abds_seo_export_monthly_csv', $this, 'handle_export_monthly_csv' ); $loader->add_action( 'admin_post_abds_seo_run_content_gaps', $this, 'handle_run_content_gaps' ); $loader->add_action( 'admin_post_abds_seo_gsc_connect', $this, 'handle_gsc_connect' ); $loader->add_action( 'admin_post_abds_seo_gsc_callback', $this, 'handle_gsc_callback' ); } /** * Starts the GSC OAuth flow: stashes a CSRF state, then redirects the * admin to Google's consent screen. */ public function handle_gsc_connect(): void { if ( ! current_user_can( ABDS_SEO_Activator::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'abds-seo' ) ); } check_admin_referer( 'abds_seo_gsc_connect' ); // GSC integration ships in the ABDS SEO Pro add-on; without it the class // is absent, so bail to settings rather than fatally instantiating it. if ( ! class_exists( 'ABDS_SEO_GSC_Settings' ) ) { wp_safe_redirect( $this->settings_url() ); exit; } $settings = new ABDS_SEO_GSC_Settings(); if ( ! $settings->has_client_credentials() ) { wp_safe_redirect( add_query_arg( 'abds-seo-gsc-error', 'no_credentials', $this->settings_url() ) ); exit; } $state = wp_generate_password( 24, false ); set_transient( 'abds_seo_gsc_state_' . get_current_user_id(), $state, 15 * MINUTE_IN_SECONDS ); wp_redirect( // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- external redirect to Google's OAuth consent endpoint is the whole point; wp_safe_redirect would strip the off-site host. ABDS_SEO_GSC_OAuth::build_auth_url( $settings->get()['client_id'], ABDS_SEO_GSC_Settings::redirect_uri(), $state ) ); exit; } /** * Handles Google's OAuth callback: verifies state, exchanges the code * for tokens, stores them, and returns to the settings page. */ public function handle_gsc_callback(): void { if ( ! current_user_can( ABDS_SEO_Activator::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'abds-seo' ) ); } // The OAuth callback is a redirect FROM Google, not a form submit, so // it cannot carry a WP nonce. The CSRF guard is the OAuth `state`: // generated in handle_gsc_connect(), stored in a per-user transient, // and compared here with hash_equals — the standard mechanism. // phpcs:disable WordPress.Security.NonceVerification.Recommended $expected = get_transient( 'abds_seo_gsc_state_' . get_current_user_id() ); $state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : ''; $code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended // GSC integration is a Pro feature; if the add-on is absent there is no // handler class, so bail to settings instead of fatally instantiating it. if ( ! class_exists( 'ABDS_SEO_GSC_Settings' ) ) { wp_safe_redirect( $this->settings_url() ); exit; } if ( ! $expected || ! hash_equals( (string) $expected, $state ) ) { wp_safe_redirect( add_query_arg( 'abds-seo-gsc-error', 'state', $this->settings_url() ) ); exit; } delete_transient( 'abds_seo_gsc_state_' . get_current_user_id() ); if ( '' === $code ) { wp_safe_redirect( add_query_arg( 'abds-seo-gsc-error', 'denied', $this->settings_url() ) ); exit; } $settings = new ABDS_SEO_GSC_Settings(); $tokens = ( new ABDS_SEO_GSC_OAuth() )->exchange_code( $code, $settings->get()['client_id'], $settings->get_client_secret(), ABDS_SEO_GSC_Settings::redirect_uri() ); if ( is_wp_error( $tokens ) ) { wp_safe_redirect( add_query_arg( 'abds-seo-gsc-error', 'exchange', $this->settings_url() ) ); exit; } $settings->store_tokens( $tokens['access_token'], $tokens['refresh_token'], $tokens['expires_in'] ); wp_safe_redirect( add_query_arg( 'abds-seo-gsc-connected', '1', $this->settings_url() ) ); exit; } /** * URL of the plugin settings page. * * @return string */ protected function settings_url(): string { return admin_url( 'admin.php?page=' . self::MENU_SLUG . '-settings' ); } /** * Builds a URL to the migration wizard page, optionally with extra query args. * * @param array $args Query args to append. * @return string */ protected function migration_wizard_url( array $args = array() ): string { return add_query_arg( $args, admin_url( 'admin.php?page=' . self::MENU_SLUG . '-migration' ) ); } /** * Handles the "Перенести дані" form submission on the migration wizard. */ public function handle_run_migration(): void { if ( ! current_user_can( ABDS_SEO_Activator::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'abds-seo' ) ); } check_admin_referer( 'abds_seo_run_migration' ); $source = isset( $_POST['source'] ) ? sanitize_key( wp_unslash( $_POST['source'] ) ) : ''; $manager = new ABDS_SEO_Migration_Manager(); $adapter = null; foreach ( $manager->get_adapters() as $candidate ) { if ( $candidate->get_slug() === $source ) { $adapter = $candidate; break; } } if ( $adapter ) { $result = $manager->migrate( $adapter ); wp_safe_redirect( $this->migration_wizard_url( array( 'abds-seo-migrated' => '1', 'source' => $source, 'total' => $result['pages_total'], 'migrated' => $result['pages_migrated'], ) ) ); exit; } wp_safe_redirect( $this->migration_wizard_url( array( 'abds-seo-migration-error' => '1' ) ) ); exit; } /** * Handles the "Деактивувати цей плагін" form submission on the migration wizard. */ public function handle_deactivate_source(): void { if ( ! current_user_can( ABDS_SEO_Activator::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'abds-seo' ) ); } check_admin_referer( 'abds_seo_deactivate_source' ); $source = isset( $_POST['source'] ) ? sanitize_key( wp_unslash( $_POST['source'] ) ) : ''; $manager = new ABDS_SEO_Migration_Manager(); $deactivated = false; $found = false; foreach ( $manager->get_adapters() as $candidate ) { if ( $candidate->get_slug() === $source ) { $found = true; $deactivated = $candidate->deactivate_self(); break; } } if ( $deactivated ) { $redirect_args = array( 'abds-seo-deactivated' => '1' ); } elseif ( $found ) { $redirect_args = array( 'abds-seo-deactivation-error' => '1' ); } else { $redirect_args = array( 'abds-seo-migration-error' => '1' ); } wp_safe_redirect( $this->migration_wizard_url( $redirect_args ) ); exit; } /** * Saves the plain-PHP-form settings (no-JS fallback). Richer settings * (AI provider/BYOK key, added in a later phase) will go through the * REST API from the React settings screen instead. */ public function handle_save_settings(): void { if ( ! current_user_can( ABDS_SEO_Activator::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'abds-seo' ) ); } check_admin_referer( 'abds_seo_save_settings' ); $settings = get_option( ABDS_SEO_Migration_Manager::SETTINGS_OPTION, array() ); $settings['keep_data_on_uninstall'] = ! empty( $_POST['keep_data_on_uninstall'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above via check_admin_referer(). update_option( ABDS_SEO_Migration_Manager::SETTINGS_OPTION, $settings ); $referer = wp_get_referer(); $redirect_url = $referer ? $referer : admin_url( 'admin.php?page=' . self::MENU_SLUG . '-settings' ); wp_safe_redirect( add_query_arg( 'abds-seo-saved', '1', $redirect_url ) ); exit; } /** * Registers the top-level "ABDS SEO" menu and its four submenu pages. */ public function add_menu_pages(): void { $capability = ABDS_SEO_Activator::CAPABILITY; $this->own_page_hooks[] = add_menu_page( __( 'ABDS SEO', 'abds-seo' ), __( 'ABDS SEO', 'abds-seo' ), $capability, self::MENU_SLUG, array( $this, 'render_dashboard_page' ), $this->menu_icon(), 80 ); $this->own_page_hooks[] = add_submenu_page( self::MENU_SLUG, __( 'Dashboard', 'abds-seo' ), __( 'Dashboard', 'abds-seo' ), $capability, self::MENU_SLUG, array( $this, 'render_dashboard_page' ) ); $this->own_page_hooks[] = add_submenu_page( self::MENU_SLUG, __( 'Migration', 'abds-seo' ), __( 'Migration', 'abds-seo' ), $capability, self::MENU_SLUG . '-migration', array( $this, 'render_migration_wizard_page' ) ); $this->own_page_hooks[] = add_submenu_page( self::MENU_SLUG, __( 'Reports', 'abds-seo' ), __( 'Reports', 'abds-seo' ), $capability, self::MENU_SLUG . '-reports', array( $this, 'render_reports_page' ) ); $this->own_page_hooks[] = add_submenu_page( self::MENU_SLUG, __( 'Monthly report', 'abds-seo' ), __( 'Monthly report', 'abds-seo' ), $capability, self::MENU_SLUG . '-monthly', array( $this, 'render_monthly_report_page' ) ); $this->own_page_hooks[] = add_submenu_page( self::MENU_SLUG, __( 'Settings', 'abds-seo' ), __( 'Settings', 'abds-seo' ), $capability, self::MENU_SLUG . '-settings', array( $this, 'render_settings_page' ) ); } /** * The admin-menu icon as a base64 SVG data URI — a monochrome magnifier + * trend mark. WordPress recolors data-URI SVG menu icons to the current * admin colour scheme, so it renders native (grey, white when active) * instead of clashing. The colourful blue-yellow logo lives in assets/. * * @return string A data:image/svg+xml;base64 URI. */ private function menu_icon(): string { $svg = ''; // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- encoding an inline SVG for a menu-icon data URI, not code obfuscation. return 'data:image/svg+xml;base64,' . base64_encode( $svg ); } /** * Cache-busting version for a plugin-relative asset: the file's mtime, so a * CSS edit reaches browsers even though ABDS_SEO_VERSION stays fixed between * releases. Falls back to the plugin version if the file can't be stat'd. * * @param string $relative Path relative to the plugin dir. * @return string */ private function asset_version( string $relative ): string { $mtime = @filemtime( ABDS_SEO_PLUGIN_DIR . $relative ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a missing/unreadable asset just falls back to the plugin version below; no need to surface a warning on every admin page load. return false !== $mtime ? (string) $mtime : ABDS_SEO_VERSION; } /** * Enqueues the plugin's admin CSS/JS, scoped to its own pages only. * * @param string $hook_suffix Current admin page hook suffix. */ public function enqueue_assets( string $hook_suffix ): void { if ( ! in_array( $hook_suffix, $this->own_page_hooks, true ) ) { return; } // Design tokens first, so every other stylesheet (and the React UI) can // reference the CSS custom properties they define. wp_enqueue_style( 'abds-seo-tokens', ABDS_SEO_PLUGIN_URL . 'admin/css/tokens.css', array(), $this->asset_version( 'admin/css/tokens.css' ) ); wp_enqueue_style( 'abds-seo-admin', ABDS_SEO_PLUGIN_URL . 'admin/css/admin.css', array( 'abds-seo-tokens' ), $this->asset_version( 'admin/css/admin.css' ) ); $asset_file = ABDS_SEO_PLUGIN_DIR . 'build/admin.asset.php'; if ( ! file_exists( $asset_file ) ) { return; // Build not run yet; pages still render server-side via partials. } $asset = require $asset_file; wp_enqueue_script( 'abds-seo-admin', ABDS_SEO_PLUGIN_URL . 'build/admin.js', $asset['dependencies'], $asset['version'], true ); wp_set_script_translations( 'abds-seo-admin', 'abds-seo', ABDS_SEO_PLUGIN_DIR . 'languages' ); wp_localize_script( 'abds-seo-admin', 'abdsSeoAdmin', array( 'restUrl' => esc_url_raw( rest_url( ABDS_SEO_Rest_Controller::NAMESPACE_V1 ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'canManage' => current_user_can( ABDS_SEO_Activator::CAPABILITY ), ) ); // The site's effective tier + the presentation copy for every gated // feature, so the UI can show a current-tier badge and turn locked // features into teasers (name/benefit/required tier) rather than hiding // them. Read-only display data; the gate itself is unchanged. $gate = new ABDS_SEO_License_Gate(); $tier = $gate->effective_tier(); wp_localize_script( 'abds-seo-admin', 'abdsSeoLicense', array( 'tier' => $tier, 'tierLabel' => ABDS_SEO_Feature_Presentation::tier_label( $tier ), 'features' => ABDS_SEO_Feature_Presentation::describe( $tier ), 'settingsUrl' => esc_url_raw( admin_url( 'admin.php?page=' . self::MENU_SLUG . '-settings' ) ), ) ); // The optional onboarding wizard's per-user state, so it can decide to // auto-open (only when still pending) without a fetch flash. wp_localize_script( 'abds-seo-admin', 'abdsSeoOnboarding', ( new ABDS_SEO_Onboarding() )->get_state( get_current_user_id() ) ); } /** * Loads the Gutenberg sidebar bundle only in the block editor, never on * the frontend and never outside the editor screen — and only for users * who hold the content capability, so a role without it (e.g. a plain * Contributor) never loads a panel whose REST calls would just 403. */ public function enqueue_block_editor_assets(): void { if ( ! current_user_can( ABDS_SEO_Activator::CONTENT_CAPABILITY ) ) { return; } $asset_file = ABDS_SEO_PLUGIN_DIR . 'build/index.asset.php'; if ( ! file_exists( $asset_file ) ) { return; } $asset = require $asset_file; wp_enqueue_script( 'abds-seo-editor', ABDS_SEO_PLUGIN_URL . 'build/index.js', $asset['dependencies'], $asset['version'], true ); wp_set_script_translations( 'abds-seo-editor', 'abds-seo', ABDS_SEO_PLUGIN_DIR . 'languages' ); wp_localize_script( 'abds-seo-editor', 'abdsSeoEditor', array( 'restUrl' => esc_url_raw( rest_url( ABDS_SEO_Rest_Controller::NAMESPACE_V1 ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'settingsUrl' => esc_url_raw( admin_url( 'admin.php?page=' . self::MENU_SLUG . '-settings' ) ), ) ); // Design tokens + the sidebar's own styles inside the block editor, so // the panel picks up the same palette as the standalone admin pages. The // tokens must load first (the sidebar CSS references their variables). wp_enqueue_style( 'abds-seo-tokens', ABDS_SEO_PLUGIN_URL . 'admin/css/tokens.css', array(), $this->asset_version( 'admin/css/tokens.css' ) ); $style_file = ABDS_SEO_PLUGIN_DIR . 'build/style-index.css'; if ( file_exists( $style_file ) ) { wp_enqueue_style( 'abds-seo-editor', ABDS_SEO_PLUGIN_URL . 'build/style-index.css', array( 'abds-seo-tokens' ), $asset['version'] ); } } /** * Renders the dashboard page. */ public function render_dashboard_page(): void { if ( ! current_user_can( ABDS_SEO_Activator::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to view this page.', 'abds-seo' ) ); } require ABDS_SEO_PLUGIN_DIR . 'admin/partials/dashboard-page.php'; } /** * Renders the settings page. */ public function render_settings_page(): void { if ( ! current_user_can( ABDS_SEO_Activator::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to view this page.', 'abds-seo' ) ); } require ABDS_SEO_PLUGIN_DIR . 'admin/partials/settings-page.php'; } /** * Renders the migration wizard page. */ public function render_migration_wizard_page(): void { if ( ! current_user_can( ABDS_SEO_Activator::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to view this page.', 'abds-seo' ) ); } require ABDS_SEO_PLUGIN_DIR . 'admin/partials/migration-wizard-page.php'; } /** * Renders the reports page. */ public function render_reports_page(): void { if ( ! current_user_can( ABDS_SEO_Activator::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to view this page.', 'abds-seo' ) ); } require ABDS_SEO_PLUGIN_DIR . 'admin/partials/reports-page.php'; } /** * Queues the background content-gaps computation from the reports page * form. The page itself only ever reads stored status/results — the * heavy SERP+AI work happens on Action Scheduler's queue, never on an * admin request. */ public function handle_run_content_gaps(): void { if ( ! current_user_can( ABDS_SEO_Activator::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'abds-seo' ) ); } check_admin_referer( 'abds_seo_run_content_gaps' ); // Content-gap analysis is a Pro feature; without the add-on the class is // absent, so bail to reports rather than fatally instantiating it. if ( ! class_exists( 'ABDS_SEO_Content_Gaps' ) ) { wp_safe_redirect( admin_url( 'admin.php?page=' . self::MENU_SLUG . '-reports' ) ); exit; } ( new ABDS_SEO_Content_Gaps() )->schedule(); $referer = wp_get_referer(); $redirect_url = $referer ? $referer : admin_url( 'admin.php?page=' . self::MENU_SLUG . '-reports' ); wp_safe_redirect( add_query_arg( 'abds-seo-gaps-queued', '1', $redirect_url ) ); exit; } /** * Renders the monthly consolidated report, styled for printing so the * browser's "Save as PDF" produces the PDF deliverable without this * plugin shipping a PDF engine (and the font problems that come with * one for non-Latin text). */ public function render_monthly_report_page(): void { if ( ! current_user_can( ABDS_SEO_Activator::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to view this page.', 'abds-seo' ) ); } // The monthly report is a Pro feature (ABDS SEO Pro add-on). Without it // the builder classes are absent, so show a teaser instead of loading the // partial that would instantiate them. if ( ! class_exists( 'ABDS_SEO_Monthly_Report' ) ) { echo '

' . esc_html__( 'ABDS SEO — Monthly report', 'abds-seo' ) . '

'; echo '

' . esc_html__( 'The white-label monthly report is available on a paid plan.', 'abds-seo' ) . '

'; return; } require ABDS_SEO_PLUGIN_DIR . 'admin/partials/monthly-report-page.php'; } /** * Streams the monthly report as CSV — the same structure the print view * renders, from the same builder, so the file and the page can never * disagree. */ public function handle_export_monthly_csv(): void { if ( ! current_user_can( ABDS_SEO_Activator::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'abds-seo' ) ); } check_admin_referer( 'abds_seo_export_monthly_csv' ); // Monthly report is a Pro feature; without the add-on the builder classes // are absent, so bail rather than fatally instantiating them. if ( ! class_exists( 'ABDS_SEO_Monthly_Report' ) ) { wp_safe_redirect( admin_url( 'admin.php?page=' . self::MENU_SLUG . '-reports' ) ); exit; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above via check_admin_referer(); the value is re-validated by ABDS_SEO_Monthly_Report_Builder::resolve_period(), which falls back to the last complete month for anything malformed. $period = isset( $_GET['period'] ) ? sanitize_text_field( wp_unslash( $_GET['period'] ) ) : ''; $report = ( new ABDS_SEO_Monthly_Report() )->generate( $period ); nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="abds-seo-monthly-' . $report['period']['period'] . '.csv"' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- php://output is a stream handle, not a real file; WP_Filesystem has no equivalent for streaming to the response body. $output = fopen( 'php://output', 'w' ); // A BOM so Excel opens the Cyrillic column headers as UTF-8 rather // than mojibake — the same file is otherwise fine everywhere else. fwrite( $output, "\xEF\xBB\xBF" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- writing to php://output, see above. foreach ( ABDS_SEO_Monthly_Report_Builder::to_csv_rows( $report ) as $row ) { fputcsv( $output, $row ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- pairs with the fopen( 'php://output' ) above. fclose( $output ); exit; } /** * Streams the content-score report as a CSV download. Uses the exact * same ABDS_SEO_Reports query the on-screen reports table and the * /reports REST endpoint use, so the CSV always matches what's * currently visible for the same filters — a JSON REST endpoint isn't * used here on purpose: a browser file download from a REST response * needs a Blob/JS round trip for no benefit over the classic * admin-post.php GET-with-nonce pattern WordPress already provides. */ public function handle_export_csv(): void { if ( ! current_user_can( ABDS_SEO_Activator::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'abds-seo' ) ); } check_admin_referer( 'abds_seo_export_csv' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above via check_admin_referer(); every value is re-validated/clamped by ABDS_SEO_Reports::normalize_args() regardless. $args = array( 'date_from' => isset( $_GET['date_from'] ) ? sanitize_text_field( wp_unslash( $_GET['date_from'] ) ) : '', 'date_to' => isset( $_GET['date_to'] ) ? sanitize_text_field( wp_unslash( $_GET['date_to'] ) ) : '', 'post_type' => isset( $_GET['post_type'] ) ? sanitize_key( wp_unslash( $_GET['post_type'] ) ) : '', 'min_score' => isset( $_GET['min_score'] ) ? sanitize_text_field( wp_unslash( $_GET['min_score'] ) ) : '', 'max_score' => isset( $_GET['max_score'] ) ? sanitize_text_field( wp_unslash( $_GET['max_score'] ) ) : '', 'orderby' => isset( $_GET['orderby'] ) ? sanitize_key( wp_unslash( $_GET['orderby'] ) ) : 'content_score', 'order' => isset( $_GET['order'] ) ? sanitize_key( wp_unslash( $_GET['order'] ) ) : 'asc', ); nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="abds-seo-report-' . gmdate( 'Y-m-d' ) . '.csv"' ); ( new ABDS_SEO_Reports() )->stream_csv( $args ); exit; } }