This commit is contained in:
Javier Casares 2026-08-12 13:18:40 +00:00
commit bc1cb5e00a
32 changed files with 4254 additions and 0 deletions

View 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 );
}
}
}

View 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,
)
);
}
}

View 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
);
}
}

View 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'] );
}
}
}
}

View 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();
}
}