*/ private $plugin_data; /** * Creates the updater instance and registers WordPress hooks. * * @since 1.0.0 * * @param string $plugin_file_path Absolute path to the main plugin file. * * @return void */ public static function init( $plugin_file_path ) { $instance = new self( (string) $plugin_file_path ); $instance->register(); } /** * Constructor — populates instance state from the plugin file headers. * * @since 1.0.0 * * @param string $plugin_file_path Absolute path to the main plugin file. */ private function __construct( $plugin_file_path ) { $this->plugin_file_path = (string) $plugin_file_path; $this->plugin_basename = plugin_basename( $this->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 ); } /** * Registers the WordPress hooks needed for update checking. * * @since 1.0.0 * * @return void */ private function register() { 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' ) ); } /** * Returns the plugin headers, loading the admin helper file if needed. * * @since 1.0.0 * * @return array */ private function get_plugin_data() { if ( ! function_exists( 'get_plugin_data' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $data = get_plugin_data( $this->plugin_file_path, false, false ); return is_array( $data ) ? $data : array(); } /** * Builds the remote update.json URL from the plugin headers. * * Uses the `Gitea Plugin URI` header when present. Falls back to the * `Plugin URI` header for legacy configurations. If neither yields a * usable URL, constructs one from the plugin slug. * * @since 1.0.0 * * @return string Fully-qualified URL. */ private function build_json_url() { $gitea_uri = isset( $this->plugin_data['Gitea Plugin URI'] ) ? (string) $this->plugin_data['Gitea Plugin URI'] : ''; if ( '' !== $gitea_uri ) { // Full URL already provided. if ( 0 === strpos( $gitea_uri, 'http' ) ) { return rtrim( $gitea_uri, '/' ) . '/raw/branch/main/update.json'; } // "OWNER/REPO" short-form. if ( (bool) preg_match( '#^[^/]+/[^/]+$#', $gitea_uri ) ) { return 'https://git.robotstxt.es/' . $gitea_uri . '/raw/branch/main/update.json'; } } // Fallback: Plugin URI on the Gitea server. $plugin_uri = isset( $this->plugin_data['PluginURI'] ) ? (string) $this->plugin_data['PluginURI'] : ''; if ( '' !== $plugin_uri && false !== strpos( $plugin_uri, 'git.robotstxt.es' ) ) { return rtrim( $plugin_uri, '/' ) . '/raw/branch/main/update.json'; } // Last resort: derive from the plugin slug. return 'https://git.robotstxt.es/ROBOTSTXT/' . $this->plugin_slug . '/raw/branch/main/update.json'; } /** * Injects update information into WordPress's plugin update transient. * * Hooked to `pre_set_site_transient_update_plugins`. * * @since 1.0.0 * * @param mixed $transient The update_plugins transient value. * * @return mixed The (possibly modified) transient. */ public function inject_update_info( $transient ) { if ( ! is_object( $transient ) ) { return $transient; } /** * Type narrowed to stdClass after is_object() check above. * * @var stdClass $transient */ if ( empty( $transient->checked ) || ! is_array( $transient->checked ) ) { return $transient; } if ( empty( $transient->checked[ $this->plugin_basename ] ) ) { return $transient; } $checked_value = $transient->checked[ $this->plugin_basename ]; $current_version = is_scalar( $checked_value ) ? (string) $checked_value : ''; $remote = $this->get_remote_data(); if ( ! isset( $remote['version'], $remote['download_url'] ) ) { return $transient; } if ( ! is_string( $remote['version'] ) || ! is_string( $remote['download_url'] ) ) { return $transient; } if ( '' === $remote['download_url'] ) { return $transient; } if ( ! $this->is_compatible( $remote ) ) { return $transient; } if ( version_compare( $remote['version'], $current_version, '>' ) ) { $slug = isset( $remote['slug'] ) && is_string( $remote['slug'] ) ? $remote['slug'] : $this->plugin_slug; $homepage = isset( $remote['homepage'] ) && is_string( $remote['homepage'] ) ? $remote['homepage'] : ''; if ( '' === $homepage && isset( $this->plugin_data['PluginURI'] ) ) { $homepage = (string) $this->plugin_data['PluginURI']; } $update = (object) array( 'slug' => $slug, 'plugin' => $this->plugin_basename, 'new_version' => $remote['version'], 'url' => $homepage, 'package' => $remote['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; } /** * Provides plugin details for the "View details" modal. * * Hooked to `plugins_api`. * * @since 1.0.0 * * @param false|object|array $result The existing result. * @param string $action The type of information being requested. * @param object $args Plugin API arguments. * * @return false|object|array */ public function provide_plugin_details( $result, $action, $args ) { if ( 'plugin_information' !== $action ) { return $result; } if ( ! is_object( $args ) || empty( $args->slug ) || $args->slug !== $this->plugin_slug ) { return $result; } $remote = $this->get_remote_data(); if ( empty( $remote['version'] ) ) { return $result; } $name = isset( $remote['name'] ) && is_string( $remote['name'] ) ? $remote['name'] : ( isset( $this->plugin_data['Name'] ) ? (string) $this->plugin_data['Name'] : $this->plugin_slug ); $slug = isset( $remote['slug'] ) && is_string( $remote['slug'] ) ? $remote['slug'] : $this->plugin_slug; $version = is_string( $remote['version'] ) ? $remote['version'] : ''; $author = isset( $remote['author'] ) && is_string( $remote['author'] ) ? $remote['author'] : ( isset( $this->plugin_data['Author'] ) ? (string) $this->plugin_data['Author'] : '' ); $homepage = isset( $remote['homepage'] ) && is_string( $remote['homepage'] ) ? $remote['homepage'] : ( isset( $this->plugin_data['PluginURI'] ) ? (string) $this->plugin_data['PluginURI'] : '' ); $requires = isset( $remote['requires'] ) && is_string( $remote['requires'] ) ? $remote['requires'] : ''; $tested = isset( $remote['tested'] ) && is_string( $remote['tested'] ) ? $remote['tested'] : ''; $req_php = isset( $remote['requires_php'] ) && is_string( $remote['requires_php'] ) ? $remote['requires_php'] : ''; $description = isset( $remote['description'] ) && is_string( $remote['description'] ) ? $remote['description'] : ( isset( $this->plugin_data['Description'] ) ? (string) $this->plugin_data['Description'] : '' ); $changelog = isset( $remote['changelog'] ) && is_string( $remote['changelog'] ) ? $remote['changelog'] : ''; $download = isset( $remote['download_url'] ) && is_string( $remote['download_url'] ) ? $remote['download_url'] : ''; return (object) array( 'name' => $name, 'slug' => $slug, 'version' => $version, 'author' => $author, 'homepage' => $homepage, 'requires' => $requires, 'tested' => $tested, 'requires_php' => $req_php, 'sections' => array( 'description' => $description, 'changelog' => $changelog, ), 'download_link' => $download, ); } /** * Returns the remote update data, reading from cache or fetching fresh. * * The data is stored with an HMAC signature (using AUTH_SALT) to detect * cache tampering. Falls back to unsigned caching when AUTH_SALT is empty. * * @since 1.0.0 * * @return array */ private function get_remote_data() { $cached = get_site_transient( $this->cache_key ); // Verify HMAC signature when AUTH_SALT is available. if ( false !== $cached && defined( 'AUTH_SALT' ) && '' !== AUTH_SALT ) { if ( is_array( $cached ) && isset( $cached['signature'], $cached['data'] ) && is_string( $cached['signature'] ) ) { $payload_data = is_array( $cached['data'] ) ? $cached['data'] : array(); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Used only for HMAC; data is never unserialized. $expected_sig = hash_hmac( 'sha256', $this->cache_key . serialize( $payload_data ), AUTH_SALT ); if ( hash_equals( $expected_sig, $cached['signature'] ) ) { return $payload_data; } // Signature invalid — delete corrupted cache entry. delete_site_transient( $this->cache_key ); $cached = false; } } if ( false !== $cached ) { // Legacy cache format (no HMAC). return is_array( $cached ) ? $cached : array(); } $remote = $this->fetch_json(); $data = is_array( $remote ) ? $remote : array(); if ( defined( 'AUTH_SALT' ) && '' !== AUTH_SALT ) { // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Used only for HMAC; data is never unserialized. $signature = hash_hmac( 'sha256', $this->cache_key . serialize( $data ), AUTH_SALT ); $payload = array( 'data' => $data, 'timestamp' => time(), 'signature' => $signature, ); set_site_transient( $this->cache_key, $payload, 6 * HOUR_IN_SECONDS ); } else { set_site_transient( $this->cache_key, $data, 6 * HOUR_IN_SECONDS ); } return $data; } /** * Fetches and decodes the remote update.json file. * * @since 1.0.0 * * @return array */ private function fetch_json() { $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(); } /** * Checks whether the remote version is compatible with the current environment. * * @since 1.0.0 * * @param array $remote Remote update data. * * @return bool True if compatible. */ private function is_compatible( array $remote ) { if ( isset( $remote['requires_php'] ) && is_string( $remote['requires_php'] ) && '' !== $remote['requires_php'] ) { if ( version_compare( PHP_VERSION, $remote['requires_php'], '<' ) ) { return false; } } if ( isset( $remote['requires'] ) && is_string( $remote['requires'] ) && '' !== $remote['requires'] ) { if ( version_compare( (string) get_bloginfo( 'version' ), $remote['requires'], '<' ) ) { return false; } } return true; } /** * Handles a manual cache-clear request triggered via a URL parameter. * * Validates nonce and capability before clearing. * * @since 1.0.0 * * @return void */ public function handle_cache_clear() { $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 = 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-ai-translator' ) ); } if ( ! current_user_can( 'update_plugins' ) ) { wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'robotstxt-ai-translator' ) ); } $this->clear_cache(); wp_safe_redirect( remove_query_arg( array( 'robotstxt_clear_update_cache', '_wpnonce' ) ) ); exit; } /** * Deletes the cached remote data for this plugin. * * @since 1.0.0 * * @return void */ public function clear_cache() { delete_site_transient( $this->cache_key ); delete_site_transient( 'update_plugins' ); } } }