add_action( $menu_hook, $this, 'add_menu' ); $loader->add_action( 'admin_post_robotstxt_manager_refresh_catalog', $this, 'handle_refresh' ); } /** * Adds the top-level admin menu. * * @return void */ public function add_menu(): void { $cap = is_multisite() ? 'manage_network_options' : 'manage_options'; add_menu_page( esc_html__( 'Manager (by ROBOTSTXT) — Plugins', 'robotstxt-manager' ), esc_html__( 'ROBOTSTXT', 'robotstxt-manager' ), $cap, self::PAGE_SLUG, array( $this, 'render_page' ), 'dashicons-screenoptions', 71 ); } /** * Renders the main catalog page. * * @return void */ public function render_page(): void { if ( ! current_user_can( is_multisite() ? 'manage_network_options' : 'manage_options' ) ) { wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'robotstxt-manager' ) ); } $client = Robotstxt_Manager_Core_Client::from_options(); $catalog = $client->get_catalog(); $local = $this->resolve_local_state( $catalog ); $subscriptions = $client->get_subscriptions(); // Admin notices for subscriptions needing attention. $manager_notices = $this->build_subscription_notices( $catalog, $subscriptions ); $manager_subscriptions = $subscriptions; $manager_has_api_key = $client->has_api_key(); $manager_store_url = $client->get_store_url(); require ROBOTSTXT_MANAGER_DIR . 'admin/views/page-catalog.php'; } /** * Builds admin notices for subscriptions that need attention. * * - payment_failed: persistent warning per plugin. * - expiring within 14 days: per-plugin warning. * - expired while the plugin is still installed+active: per-plugin warning. * * @param list> $catalog Catalog entries. * @param array> $subscriptions Rows keyed by slug. * * @return list */ private function build_subscription_notices( array $catalog, array $subscriptions ): array { $notices = array(); $local = $this->resolve_local_state( $catalog ); foreach ( $subscriptions as $slug => $sub ) { $raw_status = $sub['status'] ?? ''; $raw_expires_at = $sub['expires_at'] ?? ''; $status = is_string( $raw_status ) ? $raw_status : ''; $expires_at = is_string( $raw_expires_at ) ? $raw_expires_at : ''; if ( 'payment_failed' === $status ) { $notices[] = array( 'type' => 'warning', /* translators: %s: plugin slug. */ 'message' => sprintf( __( 'The payment for %s failed. Update your payment method from your ROBOTSTXT account page to keep access.', 'robotstxt-manager' ), $slug ), ); continue; } if ( 'expired' === $status ) { $state = $local[ $slug ] ?? array(); if ( ! empty( $state['active'] ) ) { $notices[] = array( 'type' => 'warning', /* translators: %s: plugin slug. */ 'message' => sprintf( __( 'The subscription for %s has expired, but the plugin is still active on this site. Renew from your ROBOTSTXT account page to keep receiving updates.', 'robotstxt-manager' ), $slug ), ); } continue; } if ( 'active' === $status && '' !== $expires_at ) { $days = (int) floor( ( (int) strtotime( $expires_at ) - time() ) / DAY_IN_SECONDS ); if ( $days >= 0 && $days <= 14 ) { $notices[] = array( 'type' => 'warning', /* translators: 1: plugin slug, 2: days remaining. */ 'message' => sprintf( _n( 'The subscription for %1$s expires in %2$d day.', 'The subscription for %1$s expires in %2$d days.', $days, 'robotstxt-manager' ), $slug, $days ), ); } } } return $notices; } /** * Handles the "Refresh catalog" admin-post action. * * Clears the cached catalog response, purges WordPress's update_plugins * transient so native update badges re-evaluate immediately, and * redirects back to the page. Rate-limited to 6 refreshes per minute * per user so a stuck browser cannot hammer the store. * * @return void */ public function handle_refresh(): void { if ( ! current_user_can( is_multisite() ? 'manage_network_options' : 'manage_options' ) ) { wp_die( esc_html__( 'Insufficient permissions.', 'robotstxt-manager' ) ); } check_admin_referer( 'robotstxt_manager_refresh_catalog' ); $bucket = 'robotstxt_manager_refresh_' . get_current_user_id(); $hits_raw = get_site_transient( $bucket ); $hits = is_numeric( $hits_raw ) ? (int) $hits_raw : 0; if ( $hits >= 6 ) { $this->redirect_refresh_error(); } set_site_transient( $bucket, $hits + 1, MINUTE_IN_SECONDS ); $client = Robotstxt_Manager_Core_Client::from_options(); $client->clear_catalog_cache(); $client->clear_subscriptions_cache(); delete_site_transient( 'update_plugins' ); $redirect = add_query_arg( array( 'page' => self::PAGE_SLUG, 'refreshed' => '1', ), ( is_multisite() ? network_admin_url( 'admin.php' ) : admin_url( 'admin.php' ) ) ); wp_safe_redirect( $redirect ); exit; } /** * Redirects back to the catalog page with a rate-limit error notice. * * @return void */ private function redirect_refresh_error(): void { wp_safe_redirect( add_query_arg( array( 'page' => self::PAGE_SLUG, 'robotstxt_manager_result' => 'error', 'robotstxt_manager_message' => rawurlencode( __( 'Too many refreshes. Please wait a minute before refreshing again.', 'robotstxt-manager' ) ), ), ( is_multisite() ? network_admin_url( 'admin.php' ) : admin_url( 'admin.php' ) ) ) ); exit; } /** * Builds a nonce-protected admin-post action URL for a plugin row. * * @param string $action One of 'install', 'activate', 'update'. * @param string $slug Plugin slug. * * @return string The action URL. */ public static function action_url( string $action, string $slug ): string { return wp_nonce_url( add_query_arg( array( 'action' => 'robotstxt_manager_' . $action, 'slug' => $slug, ), admin_url( 'admin-post.php' ) ), 'robotstxt_manager_' . $action . '_' . $slug ); } /** * Resolves local install state for every catalog entry. * * Reads the local plugin list once and matches by slug. Does not perform * any network calls. * * @param list> $catalog Catalog entries from Core. * * @return array Keyed by slug. */ private function resolve_local_state( array $catalog ): array { if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $all_plugins = get_plugins(); $local = array(); // Build a slug → file map from local installs. $slug_to_file = array(); foreach ( $all_plugins as $file => $data ) { $slug = dirname( $file ); if ( '.' === $slug ) { $slug = basename( $file, '.php' ); } $slug_to_file[ $slug ] = $file; } foreach ( $catalog as $entry ) { $raw_slug = $entry['slug'] ?? ''; $slug = is_string( $raw_slug ) ? $raw_slug : ''; if ( '' === $slug ) { continue; } $file = $slug_to_file[ $slug ] ?? null; $version = ''; if ( null !== $file && isset( $all_plugins[ $file ]['Version'] ) ) { $raw_ver = $all_plugins[ $file ]['Version']; $version = is_string( $raw_ver ) ? $raw_ver : ''; } $active = null !== $file && is_string( $file ) && is_plugin_active( $file ); $local[ $slug ] = array( 'installed' => null !== $file, 'active' => $active, 'version' => $version, ); } return $local; } }