v0.1.0
This commit is contained in:
commit
bc1cb5e00a
32 changed files with 4254 additions and 0 deletions
54
includes/class-robotstxt-manager-activator.php
Normal file
54
includes/class-robotstxt-manager-activator.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
/**
|
||||
* Handles plugin activation and deactivation.
|
||||
*
|
||||
* @package Robotstxt_Manager
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_Manager_Activator
|
||||
*
|
||||
* Activation stores plugin defaults. Deactivation clears the catalog
|
||||
* transient only — no user data is removed.
|
||||
*/
|
||||
class Robotstxt_Manager_Activator {
|
||||
|
||||
/**
|
||||
* Runs on plugin activation.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function activate(): void {
|
||||
self::ensure_defaults();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs on plugin deactivation.
|
||||
*
|
||||
* Clears transient caches created by this plugin. No user data is removed.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function deactivate(): void {
|
||||
delete_transient( 'robotstxt_manager_catalog' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures default option values are present without overwriting existing settings.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function ensure_defaults(): void {
|
||||
if ( '' === get_option( 'robotstxt_manager_store_url', '' ) ) {
|
||||
update_option( 'robotstxt_manager_store_url', 'https://plugins.robotstxt.es' );
|
||||
}
|
||||
|
||||
if ( '' === get_option( 'robotstxt_manager_cache_ttl_minutes', '' ) ) {
|
||||
update_option( 'robotstxt_manager_cache_ttl_minutes', 60 );
|
||||
}
|
||||
}
|
||||
}
|
||||
267
includes/class-robotstxt-manager-core-client.php
Normal file
267
includes/class-robotstxt-manager-core-client.php
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
<?php
|
||||
/**
|
||||
* HTTP client for the remote Plugins Core REST API.
|
||||
*
|
||||
* @package Robotstxt_Manager
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_Manager_Core_Client
|
||||
*
|
||||
* Wraps the small subset of Plugins Core's REST API that Manager needs:
|
||||
* - GET /plugins (catalog)
|
||||
* - GET /plugins/{slug} (single plugin info, future phase)
|
||||
* - GET /plugins/{slug}/update-check (per-installed-plugin update probe, future phase)
|
||||
* - GET /me/subscriptions (remote subscription status, future phase — blocked on Core endpoint)
|
||||
*
|
||||
* All requests are authenticated with the account-level API key, sent via
|
||||
* the `Authorization: Bearer <key>` header. The key is read from the
|
||||
* encrypted option; never logged, never exposed to the client.
|
||||
*
|
||||
* Responses are cached in WordPress transients where it makes sense
|
||||
* (catalog, per-plugin update probe).
|
||||
*/
|
||||
class Robotstxt_Manager_Core_Client {
|
||||
|
||||
/**
|
||||
* REST namespace on the remote Core install.
|
||||
*
|
||||
* @var non-falsy-string
|
||||
*/
|
||||
private const REST_NAMESPACE = 'robotstxt-core/v1';
|
||||
|
||||
/**
|
||||
* Default request timeout in seconds.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private const TIMEOUT = 15;
|
||||
|
||||
/**
|
||||
* Transient TTL for the catalog cache (seconds). Default 1 hour.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public const CATALOG_TTL_DEFAULT = HOUR_IN_SECONDS;
|
||||
|
||||
/**
|
||||
* Base URL of the remote Core install (no trailing slash).
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private string $store_url;
|
||||
|
||||
/**
|
||||
* Account-level API key (plaintext, never logged).
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private string $api_key;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param string $store_url Base URL of the remote Plugins Core install.
|
||||
* @param string $api_key Account-level API key (plaintext).
|
||||
*/
|
||||
public function __construct( string $store_url, string $api_key ) {
|
||||
$this->store_url = rtrim( $store_url, '/' );
|
||||
$this->api_key = $api_key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a client configured from the saved plugin options.
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public static function from_options(): self {
|
||||
$store_url_raw = get_option( 'robotstxt_manager_store_url', '' );
|
||||
$api_key_raw = get_option( 'robotstxt_manager_api_key', '' );
|
||||
|
||||
$store_url = is_string( $store_url_raw ) ? $store_url_raw : '';
|
||||
$api_key = is_string( $api_key_raw )
|
||||
? Robotstxt_Manager_Encryption::decrypt( $api_key_raw )
|
||||
: '';
|
||||
|
||||
return new self( $store_url, $api_key );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the client has both a URL and a key configured.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_configured(): bool {
|
||||
return '' !== $this->store_url && '' !== $this->api_key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the configured store URL.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_store_url(): string {
|
||||
return $this->store_url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests connectivity to the remote Core install.
|
||||
*
|
||||
* Performs a lightweight authenticated GET against the catalog endpoint.
|
||||
* Used by the Settings screen "Test connection" button and by the
|
||||
* pre-save validation in Robotstxt_Manager_Settings::sanitize_api_key().
|
||||
*
|
||||
* @return array{
|
||||
* ok:bool,
|
||||
* message:string,
|
||||
* catalog_count?:int,
|
||||
* }
|
||||
*/
|
||||
public function test_connection(): array {
|
||||
if ( '' === $this->store_url ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'message' => __( 'Store URL is not configured.', 'robotstxt-manager' ),
|
||||
);
|
||||
}
|
||||
|
||||
if ( '' === $this->api_key ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'message' => __( 'API key is not configured.', 'robotstxt-manager' ),
|
||||
);
|
||||
}
|
||||
|
||||
$response = $this->get( '/plugins' );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
/* translators: %s: HTTP transport error message. */
|
||||
'message' => sprintf( __( 'Could not reach Plugins Core: %s', 'robotstxt-manager' ), $response->get_error_message() ),
|
||||
);
|
||||
}
|
||||
|
||||
$body = wp_remote_retrieve_body( $response );
|
||||
$count = is_array( json_decode( $body, true ) ) ? count( json_decode( $body, true ) ) : 0;
|
||||
|
||||
return array(
|
||||
'ok' => true,
|
||||
'message' => __( 'Connected.', 'robotstxt-manager' ),
|
||||
'catalog_count' => $count,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full plugin catalog from Core, cached in a transient.
|
||||
*
|
||||
* @return list<array<string,mixed>> Catalog entries (slug, name, type, price, etc.).
|
||||
*/
|
||||
public function get_catalog(): array {
|
||||
if ( ! $this->is_configured() ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$cache_key = 'robotstxt_manager_catalog';
|
||||
$cached = get_transient( $cache_key );
|
||||
|
||||
if ( is_array( $cached ) ) {
|
||||
$typed = array();
|
||||
foreach ( $cached as $entry ) {
|
||||
if ( is_array( $entry ) ) {
|
||||
$row = array();
|
||||
foreach ( $entry as $k => $v ) {
|
||||
if ( is_string( $k ) ) {
|
||||
$row[ $k ] = $v;
|
||||
}
|
||||
}
|
||||
$typed[] = $row;
|
||||
}
|
||||
}
|
||||
return $typed;
|
||||
}
|
||||
|
||||
$response = $this->get( '/plugins' );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$body = wp_remote_retrieve_body( $response );
|
||||
$data = json_decode( $body, true );
|
||||
|
||||
if ( ! is_array( $data ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$catalog = array();
|
||||
foreach ( $data as $entry ) {
|
||||
if ( is_array( $entry ) ) {
|
||||
$row = array();
|
||||
foreach ( $entry as $k => $v ) {
|
||||
if ( is_string( $k ) ) {
|
||||
$row[ $k ] = $v;
|
||||
}
|
||||
}
|
||||
$catalog[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
$ttl = $this->get_catalog_ttl();
|
||||
set_transient( $cache_key, $catalog, $ttl );
|
||||
|
||||
return $catalog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the cached catalog response. Called when settings change or
|
||||
* when the admin user clicks "Refresh" on the catalog screen.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function clear_catalog_cache(): void {
|
||||
delete_transient( 'robotstxt_manager_catalog' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the configured catalog-cache TTL in seconds.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
private function get_catalog_ttl(): int {
|
||||
$raw = get_option( 'robotstxt_manager_cache_ttl_minutes', 60 );
|
||||
$min = is_numeric( $raw ) ? (int) $raw : 60;
|
||||
|
||||
if ( $min < 1 ) {
|
||||
return self::CATALOG_TTL_DEFAULT;
|
||||
}
|
||||
|
||||
return $min * MINUTE_IN_SECONDS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs an authenticated GET request to the Core REST API.
|
||||
*
|
||||
* @param string $endpoint Path relative to the REST namespace (e.g. '/plugins').
|
||||
*
|
||||
* @return WP_Error|array<string, mixed> WP_Error on failure, or the wp_remote_get response array on success.
|
||||
*/
|
||||
private function get( string $endpoint ) {
|
||||
$url = $this->store_url . '/wp-json/' . self::REST_NAMESPACE . $endpoint;
|
||||
|
||||
return wp_remote_get(
|
||||
$url,
|
||||
array(
|
||||
'headers' => array(
|
||||
'Authorization' => 'Bearer ' . $this->api_key,
|
||||
'Accept' => 'application/json',
|
||||
),
|
||||
'timeout' => self::TIMEOUT,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
111
includes/class-robotstxt-manager-encryption.php
Normal file
111
includes/class-robotstxt-manager-encryption.php
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<?php
|
||||
/**
|
||||
* AES-256-CBC encryption helper for the account-level API key.
|
||||
*
|
||||
* @package Robotstxt_Manager
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_Manager_Encryption
|
||||
*
|
||||
* Same symmetric AES-256-CBC pattern Plugins Core uses for its Forgejo
|
||||
* token. Encrypted values are base64-encoded strings with the IV prepended
|
||||
* to the ciphertext, safe for wp_options storage.
|
||||
*/
|
||||
class Robotstxt_Manager_Encryption {
|
||||
|
||||
/**
|
||||
* OpenSSL cipher method.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private const CIPHER = 'aes-256-cbc';
|
||||
|
||||
/**
|
||||
* Context string used to derive a plugin-specific key.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private const CONTEXT = 'robotstxt_manager_encryption_v1';
|
||||
|
||||
/**
|
||||
* Encrypts a plaintext string and returns a base64-encoded payload.
|
||||
*
|
||||
* @param string $plaintext The value to encrypt.
|
||||
*
|
||||
* @return string Base64-encoded ciphertext, or empty string on failure.
|
||||
*/
|
||||
public static function encrypt( string $plaintext ): string {
|
||||
if ( '' === $plaintext ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$key = self::derive_key();
|
||||
$iv_length = openssl_cipher_iv_length( self::CIPHER );
|
||||
|
||||
if ( false === $iv_length ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$iv = openssl_random_pseudo_bytes( $iv_length );
|
||||
$ciphertext = openssl_encrypt( $plaintext, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv );
|
||||
|
||||
if ( false === $ciphertext ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return base64_encode( $iv . $ciphertext ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts a base64-encoded payload previously produced by encrypt().
|
||||
*
|
||||
* @param string $encoded The base64-encoded ciphertext.
|
||||
*
|
||||
* @return string The original plaintext, or empty string on failure.
|
||||
*/
|
||||
public static function decrypt( string $encoded ): string {
|
||||
if ( '' === $encoded ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$decoded = base64_decode( $encoded, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
|
||||
|
||||
if ( false === $decoded ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$iv_length = openssl_cipher_iv_length( self::CIPHER );
|
||||
|
||||
if ( false === $iv_length || strlen( $decoded ) <= $iv_length ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$key = self::derive_key();
|
||||
$iv = substr( $decoded, 0, $iv_length );
|
||||
$ciphertext = substr( $decoded, $iv_length );
|
||||
$plaintext = openssl_decrypt( $ciphertext, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv );
|
||||
|
||||
return false !== $plaintext ? $plaintext : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a 32-byte encryption key from WordPress secret constants.
|
||||
*
|
||||
* @return string 32-byte raw key.
|
||||
*/
|
||||
private static function derive_key(): string {
|
||||
$auth_key = defined( 'AUTH_KEY' ) ? AUTH_KEY : 'auth_key_not_defined';
|
||||
$auth_salt = defined( 'AUTH_SALT' ) ? AUTH_SALT : 'auth_salt_not_defined';
|
||||
|
||||
return substr(
|
||||
hash_hmac( 'sha256', self::CONTEXT, $auth_key . $auth_salt, true ),
|
||||
0,
|
||||
32
|
||||
);
|
||||
}
|
||||
}
|
||||
108
includes/class-robotstxt-manager-loader.php
Normal file
108
includes/class-robotstxt-manager-loader.php
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<?php
|
||||
/**
|
||||
* Registers all WordPress hooks for the plugin.
|
||||
*
|
||||
* @package Robotstxt_Manager
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_Manager_Loader
|
||||
*
|
||||
* Collects all actions and filters to be registered with WordPress and
|
||||
* registers them in bulk when run() is called.
|
||||
*/
|
||||
class Robotstxt_Manager_Loader {
|
||||
|
||||
/**
|
||||
* Registered action hooks.
|
||||
*
|
||||
* @var array<int, array{hook: string, component: object, callback: string, priority: int, accepted_args: int}>
|
||||
*/
|
||||
private array $actions = array();
|
||||
|
||||
/**
|
||||
* Registered filter hooks.
|
||||
*
|
||||
* @var array<int, array{hook: string, component: object, callback: string, priority: int, accepted_args: int}>
|
||||
*/
|
||||
private array $filters = array();
|
||||
|
||||
/**
|
||||
* Queues an action hook for registration.
|
||||
*
|
||||
* @param string $hook The name of the WordPress action.
|
||||
* @param object $component The object instance containing the callback.
|
||||
* @param string $callback The method name to call on $component.
|
||||
* @param int $priority Hook priority. Default 10.
|
||||
* @param int $accepted_args Number of arguments the callback accepts. Default 1.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function add_action(
|
||||
string $hook,
|
||||
object $component,
|
||||
string $callback,
|
||||
int $priority = 10,
|
||||
int $accepted_args = 1
|
||||
): void {
|
||||
$this->actions[] = array(
|
||||
'hook' => $hook,
|
||||
'component' => $component,
|
||||
'callback' => $callback,
|
||||
'priority' => $priority,
|
||||
'accepted_args' => $accepted_args,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues a filter hook for registration.
|
||||
*
|
||||
* @param string $hook The name of the WordPress filter.
|
||||
* @param object $component The object instance containing the callback.
|
||||
* @param string $callback The method name to call on $component.
|
||||
* @param int $priority Hook priority. Default 10.
|
||||
* @param int $accepted_args Number of arguments the callback accepts. Default 1.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function add_filter(
|
||||
string $hook,
|
||||
object $component,
|
||||
string $callback,
|
||||
int $priority = 10,
|
||||
int $accepted_args = 1
|
||||
): void {
|
||||
$this->filters[] = array(
|
||||
'hook' => $hook,
|
||||
'component' => $component,
|
||||
'callback' => $callback,
|
||||
'priority' => $priority,
|
||||
'accepted_args' => $accepted_args,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers all queued actions and filters with WordPress.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function run(): void {
|
||||
foreach ( $this->actions as $hook ) {
|
||||
$cb = array( $hook['component'], $hook['callback'] );
|
||||
if ( is_callable( $cb ) ) {
|
||||
add_action( $hook['hook'], $cb, $hook['priority'], $hook['accepted_args'] );
|
||||
}
|
||||
}
|
||||
|
||||
foreach ( $this->filters as $hook ) {
|
||||
$cb = array( $hook['component'], $hook['callback'] );
|
||||
if ( is_callable( $cb ) ) {
|
||||
add_filter( $hook['hook'], $cb, $hook['priority'], $hook['accepted_args'] );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
90
includes/class-robotstxt-manager-plugin.php
Normal file
90
includes/class-robotstxt-manager-plugin.php
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<?php
|
||||
/**
|
||||
* Core plugin class — entry point for the ROBOTSTXT Manager plugin.
|
||||
*
|
||||
* @package Robotstxt_Manager
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_Manager_Plugin
|
||||
*
|
||||
* Singleton that owns the loader instance and wires up all component hooks.
|
||||
*/
|
||||
final class Robotstxt_Manager_Plugin {
|
||||
|
||||
/**
|
||||
* Singleton instance.
|
||||
*
|
||||
* @var self|null
|
||||
*/
|
||||
private static ?self $instance = null;
|
||||
|
||||
/**
|
||||
* Hook loader.
|
||||
*
|
||||
* @var Robotstxt_Manager_Loader
|
||||
*/
|
||||
private Robotstxt_Manager_Loader $loader;
|
||||
|
||||
/**
|
||||
* Returns the singleton instance, creating it on the first call.
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public static function get_instance(): self {
|
||||
if ( null === self::$instance ) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor — private to enforce singleton usage.
|
||||
*/
|
||||
private function __construct() {
|
||||
$this->loader = new Robotstxt_Manager_Loader();
|
||||
$this->define_hooks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires component callbacks into the loader.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function define_hooks(): void {
|
||||
$this->loader->add_action( 'init', $this, 'load_textdomain' );
|
||||
|
||||
$admin = new Robotstxt_Manager_Admin();
|
||||
$settings = new Robotstxt_Manager_Settings();
|
||||
|
||||
$admin->register( $this->loader );
|
||||
$settings->register( $this->loader );
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the plugin text domain for i18n.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function load_textdomain(): void {
|
||||
load_plugin_textdomain(
|
||||
'robotstxt-manager',
|
||||
false,
|
||||
dirname( ROBOTSTXT_MANAGER_BASENAME ) . '/languages'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches all registered hooks to WordPress.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function run(): void {
|
||||
$this->loader->run();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue