465 lines
14 KiB
PHP
465 lines
14 KiB
PHP
<?php
|
|
/**
|
|
* Generic JSON-based updater for ROBOTSTXT plugins.
|
|
*
|
|
* This file is designed to be copied to any ROBOTSTXT plugin.
|
|
* It auto-configures itself by reading the plugin headers.
|
|
*
|
|
* @package ROBOTSTXT
|
|
* @since 1.0.0
|
|
*/
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
if ( ! class_exists( 'Robotstxt_Updater' ) ) {
|
|
/**
|
|
* Class Robotstxt_Updater
|
|
*
|
|
* Generic updater that works with any plugin.
|
|
* Reads plugin headers and constructs update URL automatically.
|
|
*
|
|
* @since 1.0.0
|
|
*/
|
|
class Robotstxt_Updater {
|
|
|
|
/**
|
|
* Plugin file path.
|
|
*
|
|
* @since 1.0.0
|
|
* @var string
|
|
*/
|
|
private string $plugin_file_path;
|
|
|
|
/**
|
|
* Plugin basename (e.g., 'my-plugin/my-plugin.php').
|
|
*
|
|
* @since 1.0.0
|
|
* @var string
|
|
*/
|
|
private string $plugin_basename;
|
|
|
|
/**
|
|
* Plugin slug (directory name).
|
|
*
|
|
* @since 1.0.0
|
|
* @var string
|
|
*/
|
|
private string $plugin_slug;
|
|
|
|
/**
|
|
* Remote JSON URL.
|
|
*
|
|
* @since 1.0.0
|
|
* @var string
|
|
*/
|
|
private string $json_url;
|
|
|
|
/**
|
|
* Cache key.
|
|
*
|
|
* @since 1.0.0
|
|
* @var string
|
|
*/
|
|
private string $cache_key;
|
|
|
|
/**
|
|
* Plugin headers.
|
|
*
|
|
* @since 1.0.0
|
|
* @var array<string, mixed>
|
|
*/
|
|
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<string, mixed> 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<string, mixed> 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<string, mixed> 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<string, mixed> $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<mixed, mixed> $data Raw array from transient or API.
|
|
* @return array<string, mixed>
|
|
*/
|
|
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' );
|
|
}
|
|
}
|
|
}
|