*/ private array $plugin_data; /** * Initialize the updater. * * Usage in your main plugin file: * require_once __DIR__ . '/class-robotstxt-updater.php'; * Robotstxt_Updater::init( __FILE__ ); * * @since 1.0.0 * * @param string $plugin_file_path Absolute path to the main plugin file. * @return void */ public static function init( string $plugin_file_path ): void { $instance = new self( $plugin_file_path ); $instance->register(); } /** * Constructor. * * @since 1.0.0 * * @param string $plugin_file_path Absolute path to the main plugin file. */ private function __construct( string $plugin_file_path ) { $this->plugin_file_path = $plugin_file_path; $this->plugin_basename = plugin_basename( $plugin_file_path ); $this->plugin_slug = dirname( $this->plugin_basename ); $this->plugin_data = $this->get_plugin_data(); $this->json_url = $this->build_json_url(); $this->cache_key = 'robotstxt_updater_' . md5( $this->plugin_basename ); } /** * Register WordPress hooks. * * @since 1.0.0 * * @return void */ private function register(): void { add_filter( 'pre_set_site_transient_update_plugins', array( $this, 'inject_update_info' ) ); add_filter( 'plugins_api', array( $this, 'provide_plugin_details' ), 10, 3 ); add_action( 'admin_init', array( $this, 'handle_cache_clear' ) ); add_action( 'robotstxt_updater_clear_cache', array( $this, 'clear_cache' ) ); } /** * Get plugin headers. * * @since 1.0.0 * * @return array Plugin data. */ private function get_plugin_data(): array { if ( ! function_exists( 'get_plugin_data' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } return get_plugin_data( $this->plugin_file_path, false, false ); } /** * Build JSON URL from plugin headers. * * Tries to use "Gitea Plugin URI" header to construct the URL. * Falls back to Plugin URI if Gitea URI is not available. * * @since 1.0.0 * * @return string JSON URL. */ private function build_json_url(): string { $gitea_uri = isset( $this->plugin_data['Gitea Plugin URI'] ) && is_string( $this->plugin_data['Gitea Plugin URI'] ) ? $this->plugin_data['Gitea Plugin URI'] : ''; if ( '' !== $gitea_uri ) { if ( str_starts_with( $gitea_uri, 'http' ) ) { return rtrim( $gitea_uri, '/' ) . '/raw/branch/main/update.json'; } if ( preg_match( '#^[^/]+/[^/]+$#', $gitea_uri ) ) { return 'https://git.robotstxt.es/' . $gitea_uri . '/raw/branch/main/update.json'; } } $plugin_uri = isset( $this->plugin_data['PluginURI'] ) && is_string( $this->plugin_data['PluginURI'] ) ? $this->plugin_data['PluginURI'] : ''; if ( '' !== $plugin_uri && str_contains( $plugin_uri, 'git.robotstxt.es' ) ) { return rtrim( $plugin_uri, '/' ) . '/raw/branch/main/update.json'; } return 'https://git.robotstxt.es/ROBOTSTXT/' . $this->plugin_slug . '/raw/branch/main/update.json'; } /** * Inject update info into WP's plugin update transient. * * @since 1.0.0 * * @param mixed $transient The update_plugins transient value. * @return mixed The modified transient. */ public function inject_update_info( $transient ) { if ( ! ( $transient instanceof stdClass ) ) { return $transient; } if ( ! isset( $transient->checked ) || ! is_array( $transient->checked ) ) { return $transient; } if ( ! isset( $transient->checked[ $this->plugin_basename ] ) || ! is_string( $transient->checked[ $this->plugin_basename ] ) ) { return $transient; } $current_version = $transient->checked[ $this->plugin_basename ]; $remote = $this->get_remote_data(); $remote_version = isset( $remote['version'] ) && is_string( $remote['version'] ) ? $remote['version'] : ''; $download_url = isset( $remote['download_url'] ) && is_string( $remote['download_url'] ) ? $remote['download_url'] : ''; if ( '' === $remote_version || '' === $download_url ) { return $transient; } if ( ! $this->is_compatible( $remote ) ) { return $transient; } if ( version_compare( $remote_version, $current_version, '>' ) ) { $plugin_uri = isset( $this->plugin_data['PluginURI'] ) && is_string( $this->plugin_data['PluginURI'] ) ? $this->plugin_data['PluginURI'] : ''; $update = (object) array( 'slug' => isset( $remote['slug'] ) && is_string( $remote['slug'] ) ? $remote['slug'] : $this->plugin_slug, 'plugin' => $this->plugin_basename, 'new_version' => $remote_version, 'url' => isset( $remote['homepage'] ) && is_string( $remote['homepage'] ) ? $remote['homepage'] : $plugin_uri, 'package' => $download_url, 'tested' => isset( $remote['tested'] ) && is_string( $remote['tested'] ) ? $remote['tested'] : '', 'requires' => isset( $remote['requires'] ) && is_string( $remote['requires'] ) ? $remote['requires'] : '', 'requires_php' => isset( $remote['requires_php'] ) && is_string( $remote['requires_php'] ) ? $remote['requires_php'] : '', ); if ( ! isset( $transient->response ) || ! is_array( $transient->response ) ) { $transient->response = array(); } $transient->response[ $this->plugin_basename ] = $update; } return $transient; } /** * Provide "View details" modal content. * * @since 1.0.0 * * @param false|object $result The result object or false if no result yet. * @param string $action The type of information being requested. * @param object $args Plugin API arguments. * @return false|object The plugin information object or false. */ public function provide_plugin_details( $result, string $action, object $args ) { if ( 'plugin_information' !== $action ) { return $result; } if ( ! isset( $args->slug ) || ! is_string( $args->slug ) || $args->slug !== $this->plugin_slug ) { return $result; } $remote = $this->get_remote_data(); $remote_version = isset( $remote['version'] ) && is_string( $remote['version'] ) ? $remote['version'] : ''; if ( '' === $remote_version ) { return $result; } $plugin_name = isset( $this->plugin_data['Name'] ) && is_string( $this->plugin_data['Name'] ) ? $this->plugin_data['Name'] : $this->plugin_slug; $plugin_uri = isset( $this->plugin_data['PluginURI'] ) && is_string( $this->plugin_data['PluginURI'] ) ? $this->plugin_data['PluginURI'] : ''; $description = isset( $this->plugin_data['Description'] ) && is_string( $this->plugin_data['Description'] ) ? $this->plugin_data['Description'] : ''; $author = isset( $this->plugin_data['Author'] ) && is_string( $this->plugin_data['Author'] ) ? $this->plugin_data['Author'] : ''; return (object) array( 'name' => isset( $remote['name'] ) && is_string( $remote['name'] ) ? $remote['name'] : $plugin_name, 'slug' => isset( $remote['slug'] ) && is_string( $remote['slug'] ) ? $remote['slug'] : $this->plugin_slug, 'version' => $remote_version, 'author' => isset( $remote['author'] ) && is_string( $remote['author'] ) ? $remote['author'] : $author, 'homepage' => isset( $remote['homepage'] ) && is_string( $remote['homepage'] ) ? $remote['homepage'] : $plugin_uri, 'requires' => isset( $remote['requires'] ) && is_string( $remote['requires'] ) ? $remote['requires'] : '', 'tested' => isset( $remote['tested'] ) && is_string( $remote['tested'] ) ? $remote['tested'] : '', 'requires_php' => isset( $remote['requires_php'] ) && is_string( $remote['requires_php'] ) ? $remote['requires_php'] : '', 'sections' => array( 'description' => isset( $remote['description'] ) && is_string( $remote['description'] ) ? $remote['description'] : $description, 'changelog' => isset( $remote['changelog'] ) && is_string( $remote['changelog'] ) ? $remote['changelog'] : '', ), 'download_link' => isset( $remote['download_url'] ) && is_string( $remote['download_url'] ) ? $remote['download_url'] : '', ); } /** * Get remote data with caching and HMAC signature verification. * * @since 1.0.0 * * @return array Remote data. */ private function get_remote_data(): array { $cached = get_site_transient( $this->cache_key ); if ( false !== $cached && defined( 'AUTH_SALT' ) && '' !== AUTH_SALT ) { if ( is_array( $cached ) && isset( $cached['signature'], $cached['data'] ) && is_string( $cached['signature'] ) && is_array( $cached['data'] ) ) { $expected_sig = hash_hmac( 'sha256', $this->cache_key . wp_json_encode( $cached['data'] ), AUTH_SALT ); if ( hash_equals( $expected_sig, $cached['signature'] ) ) { return $this->normalize_array( $cached['data'] ); } delete_site_transient( $this->cache_key ); $cached = false; } } if ( false === $cached ) { $remote = $this->fetch_json(); if ( defined( 'AUTH_SALT' ) && '' !== AUTH_SALT ) { $payload = array( 'data' => $remote, 'timestamp' => time(), 'signature' => hash_hmac( 'sha256', $this->cache_key . wp_json_encode( $remote ), AUTH_SALT ), ); set_site_transient( $this->cache_key, $payload, 6 * HOUR_IN_SECONDS ); } else { set_site_transient( $this->cache_key, $remote, 6 * HOUR_IN_SECONDS ); } return $remote; } return is_array( $cached ) ? $this->normalize_array( $cached ) : array(); } /** * Fetch JSON from remote URL. * * @since 1.0.0 * * @return array Decoded JSON data. */ private function fetch_json(): array { $response = wp_remote_get( $this->json_url, array( 'timeout' => 10, 'headers' => array( 'Accept' => 'application/json', ), ) ); if ( is_wp_error( $response ) ) { return array(); } $code = (int) wp_remote_retrieve_response_code( $response ); if ( $code < 200 || $code >= 300 ) { return array(); } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); return is_array( $data ) ? $data : array(); } /** * Check compatibility with current environment. * * @since 1.0.0 * * @param array $remote Remote data. * @return bool True if compatible. */ private function is_compatible( array $remote ): bool { if ( ! empty( $remote['requires_php'] ) && is_string( $remote['requires_php'] ) ) { if ( version_compare( PHP_VERSION, $remote['requires_php'], '<' ) ) { return false; } } if ( ! empty( $remote['requires'] ) && is_string( $remote['requires'] ) ) { if ( version_compare( get_bloginfo( 'version' ), $remote['requires'], '<' ) ) { return false; } } return true; } /** * Handle manual cache clear via URL parameter. * * @since 1.0.0 * * @return void */ public function handle_cache_clear(): void { $clear_cache = filter_input( INPUT_GET, 'robotstxt_clear_update_cache', FILTER_UNSAFE_RAW ); if ( null === $clear_cache ) { return; } $nonce_raw = filter_input( INPUT_GET, '_wpnonce', FILTER_UNSAFE_RAW ); $nonce = ( null !== $nonce_raw && is_string( $nonce_raw ) ) ? sanitize_text_field( wp_unslash( $nonce_raw ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'robotstxt_clear_update_cache' ) ) { wp_die( esc_html__( 'Security check failed.', 'robotstxt-documentation-markdown' ) ); } if ( ! current_user_can( 'update_plugins' ) ) { wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'robotstxt-documentation-markdown' ) ); } $this->clear_cache(); wp_safe_redirect( remove_query_arg( array( 'robotstxt_clear_update_cache', '_wpnonce' ) ) ); exit; } /** * Rebuild an array guaranteeing string keys for PHPStan type safety. * * @since 1.0.0 * * @param array $data Raw array from transient or API. * @return array */ private function normalize_array( array $data ): array { $result = array(); foreach ( $data as $k => $v ) { if ( is_string( $k ) ) { $result[ $k ] = $v; } } return $result; } /** * Clear update cache. * * @since 1.0.0 * * @return void */ public function clear_cache(): void { delete_site_transient( $this->cache_key ); delete_site_transient( 'update_plugins' ); } } }