v1.1.0
This commit is contained in:
parent
a157afe625
commit
02cd01e862
20 changed files with 1365 additions and 646 deletions
476
class-robotstxt-updater.php
Normal file
476
class-robotstxt-updater.php
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
<?php
|
||||
/**
|
||||
* Generic JSON-based updater for ROBOTSTXT plugins.
|
||||
*
|
||||
* Copy this file into any ROBOTSTXT plugin. It auto-configures itself by
|
||||
* reading the plugin headers and looking for a `Gitea Plugin URI` header to
|
||||
* construct the remote update.json URL.
|
||||
*
|
||||
* Usage in the main plugin file:
|
||||
* require_once AI_TRANSLATOR_DIR . 'class-robotstxt-updater.php';
|
||||
* Robotstxt_Updater::init( AI_TRANSLATOR_FILE );
|
||||
*
|
||||
* @package ROBOTSTXT
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'Robotstxt_Updater' ) ) {
|
||||
|
||||
/**
|
||||
* Generic updater that works with any ROBOTSTXT plugin.
|
||||
*
|
||||
* Reads the plugin headers to construct the remote update.json URL and
|
||||
* hooks into WordPress's standard update mechanism.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Robotstxt_Updater {
|
||||
|
||||
/**
|
||||
* Absolute path to the main plugin file.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @var string
|
||||
*/
|
||||
private $plugin_file_path;
|
||||
|
||||
/**
|
||||
* Plugin basename (e.g. 'my-plugin/my-plugin.php').
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @var string
|
||||
*/
|
||||
private $plugin_basename;
|
||||
|
||||
/**
|
||||
* Plugin slug (directory name).
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @var string
|
||||
*/
|
||||
private $plugin_slug;
|
||||
|
||||
/**
|
||||
* URL of the remote update.json file.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @var string
|
||||
*/
|
||||
private $json_url;
|
||||
|
||||
/**
|
||||
* Transient cache key for this plugin's remote data.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @var string
|
||||
*/
|
||||
private $cache_key;
|
||||
|
||||
/**
|
||||
* Parsed plugin file headers.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @var array<string,bool|string>
|
||||
*/
|
||||
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<string,bool|string>
|
||||
*/
|
||||
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<string,mixed> $result The existing result.
|
||||
* @param string $action The type of information being requested.
|
||||
* @param object $args Plugin API arguments.
|
||||
*
|
||||
* @return false|object|array<string,mixed>
|
||||
*/
|
||||
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<string,mixed>
|
||||
*/
|
||||
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<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $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' );
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue