This commit is contained in:
Javier Casares 2026-06-03 06:24:03 +00:00
commit 6708bbb67f
43 changed files with 8342 additions and 0 deletions

View file

@ -0,0 +1,25 @@
<?php
/**
* Fired during plugin activation.
*
* @package MediaRightsAudit\Core
*/
namespace MediaRightsAudit\Core;
/**
* Handles tasks that run once when the plugin is activated.
*/
class Activator {
/**
* Creates database tables and seeds the initial DB version.
*
* @return void
*/
public static function activate(): void {
Database::create_tables();
update_option( 'robotstxt_mediaaudit_db_version', ROBOTSTXT_MEDIAAUDIT_DB_VERSION );
flush_rewrite_rules();
}
}

141
includes/Core/Database.php Normal file
View file

@ -0,0 +1,141 @@
<?php
/**
* Database schema creation and versioned migrations.
*
* @package MediaRightsAudit\Core
*/
namespace MediaRightsAudit\Core;
/**
* Manages all custom table creation and schema migrations.
*
* Migration methods are idempotent: they rely on dbDelta's ALTER TABLE
* diffing and can be run multiple times without side effects.
*/
class Database {
/**
* Creates all tables. Called on plugin activation.
*
* @return void
*/
public static function create_tables(): void {
self::migration_100();
self::migration_101();
}
/**
* Applies any pending migrations based on the currently installed schema version.
*
* Called on admin_init by Plugin::check_db_version().
*
* @return void
*/
public static function run_migrations(): void {
$raw = get_option( 'robotstxt_mediaaudit_db_version', '0.0.0' );
$current = is_string( $raw ) ? $raw : '0.0.0';
if ( version_compare( $current, '1.0.0', '<' ) ) {
self::migration_100();
}
if ( version_compare( $current, '1.0.1', '<' ) ) {
self::migration_101();
}
}
/**
* Drops all plugin tables. Used by uninstall.php.
*
* @return void
*/
public static function drop_tables(): void {
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_external_results`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_media_usage`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_media_index`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
}
// -------------------------------------------------------------------------
// Migrations
// -------------------------------------------------------------------------
/**
* Migration 1.0.0 creates the initial three tables.
*
* @return void
*/
private static function migration_100(): void {
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta(
"CREATE TABLE {$wpdb->prefix}mra_media_index (
attachment_id bigint(20) unsigned NOT NULL,
file_url text NOT NULL,
file_name varchar(255) NOT NULL DEFAULT '',
mime_type varchar(100) NOT NULL DEFAULT '',
file_size bigint(20) unsigned NOT NULL DEFAULT 0,
internal_scanned_at datetime DEFAULT NULL,
external_status enum('pending','queued','scanned','matches','error') NOT NULL DEFAULT 'pending',
external_scanned_at datetime DEFAULT NULL,
created_at datetime NOT NULL,
PRIMARY KEY (attachment_id),
KEY external_status (external_status)
) {$charset_collate};"
);
dbDelta(
"CREATE TABLE {$wpdb->prefix}mra_media_usage (
usage_id bigint(20) unsigned NOT NULL auto_increment,
attachment_id bigint(20) unsigned NOT NULL,
post_id bigint(20) unsigned NOT NULL,
post_type varchar(20) NOT NULL DEFAULT '',
context enum('featured','content','meta') NOT NULL DEFAULT 'featured',
meta_key varchar(255) DEFAULT NULL,
created_at datetime NOT NULL,
PRIMARY KEY (usage_id),
KEY attachment_id (attachment_id),
KEY post_id (post_id)
) {$charset_collate};"
);
dbDelta(
"CREATE TABLE {$wpdb->prefix}mra_external_results (
result_id bigint(20) unsigned NOT NULL auto_increment,
attachment_id bigint(20) unsigned NOT NULL,
provider enum('google_vision','tineye') NOT NULL,
raw_response json DEFAULT NULL,
match_count int(10) unsigned NOT NULL DEFAULT 0,
top_domains json DEFAULT NULL,
created_at datetime NOT NULL,
PRIMARY KEY (result_id),
UNIQUE KEY attachment_provider (attachment_id,provider)
) {$charset_collate};"
);
}
/**
* Migration 1.0.1 adds 'queued' to the external_status ENUM.
*
* Uses ALTER TABLE directly because dbDelta cannot modify ENUM column definitions.
* Safe to re-run: MariaDB/MySQL treats an identical MODIFY as a no-op.
*
* @return void
*/
private static function migration_101(): void {
global $wpdb;
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.SchemaChange
$wpdb->query(
"ALTER TABLE `{$wpdb->prefix}mra_media_index`
MODIFY COLUMN external_status
enum('pending','queued','scanned','matches','error') NOT NULL DEFAULT 'pending'"
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.SchemaChange
}
}

View file

@ -0,0 +1,28 @@
<?php
/**
* Fired during plugin deactivation.
*
* @package MediaRightsAudit\Core
*/
namespace MediaRightsAudit\Core;
use MediaRightsAudit\Core\Queue\Scheduler;
/**
* Handles tasks that run once when the plugin is deactivated.
*/
class Deactivator {
/**
* Cancels all pending scheduled actions and flushes rewrite rules.
*
* Database tables and stored data are intentionally preserved.
*
* @return void
*/
public static function deactivate(): void {
Scheduler::cancel_all();
flush_rewrite_rules();
}
}

165
includes/Core/Plugin.php Normal file
View file

@ -0,0 +1,165 @@
<?php
/**
* Core plugin bootstrap class.
*
* @package MediaRightsAudit\Core
*/
namespace MediaRightsAudit\Core;
use MediaRightsAudit\Admin\AuditPage;
use MediaRightsAudit\Admin\Settings;
use MediaRightsAudit\CLI\Command;
use MediaRightsAudit\External\AbstractProvider;
use MediaRightsAudit\External\ExternalScanner;
use MediaRightsAudit\External\GoogleVisionProvider;
use MediaRightsAudit\External\TinEyeProvider;
use MediaRightsAudit\Internal\AttachmentIndexer;
use MediaRightsAudit\Internal\UsageScanner;
use MediaRightsAudit\Privacy\DataEraser;
use MediaRightsAudit\Privacy\DataExporter;
/**
* Registers all hooks and initialises the plugin subsystems.
*/
class Plugin {
/**
* Settings page handler.
*
* @var Settings
*/
private Settings $settings;
/**
* Audit list page controller.
*
* @var AuditPage
*/
private AuditPage $audit_page;
/**
* Initialises dependencies.
*/
public function __construct() {
$this->settings = new Settings();
$this->audit_page = new AuditPage();
}
/**
* Attaches all WordPress hooks.
*
* @return void
*/
public function run(): void {
add_action( 'init', array( $this, 'load_textdomain' ) );
add_action( 'admin_menu', array( $this, 'register_admin_pages' ) );
add_action( 'admin_init', array( $this->settings, 'register' ) );
add_action( 'admin_init', array( $this, 'check_db_version' ) );
add_action( AttachmentIndexer::AS_HOOK, array( AttachmentIndexer::class, 'process_scheduled_batch' ) );
add_action( UsageScanner::AS_HOOK, array( UsageScanner::class, 'process_scheduled_batch' ) );
add_action( ExternalScanner::AS_HOOK, array( ExternalScanner::class, 'process_scheduled_batch' ) );
add_filter( 'mra/external/providers', array( $this, 'register_providers' ) );
add_filter( 'wp_privacy_personal_data_exporters', array( DataExporter::class, 'register' ) );
add_filter( 'wp_privacy_personal_data_erasers', array( DataEraser::class, 'register' ) );
$this->audit_page->register_hooks();
$this->register_cli_commands();
}
/**
* Loads the plugin text domain.
*
* @return void
*/
public function load_textdomain(): void {
load_plugin_textdomain(
'robotstxt-mediaaudit',
false,
dirname( plugin_basename( ROBOTSTXT_MEDIAAUDIT_PLUGIN_FILE ) ) . '/languages'
);
}
/**
* Registers admin menu pages.
*
* @return void
*/
public function register_admin_pages(): void {
$suffix = add_menu_page(
__( 'Media Audit', 'robotstxt-mediaaudit' ),
__( 'Media Audit', 'robotstxt-mediaaudit' ),
'edit_others_posts',
'robotstxt-mediaaudit',
array( $this->audit_page, 'render' ),
'dashicons-camera',
60
);
$this->audit_page->set_hook_suffix( $suffix );
// Rename the auto-created first submenu entry.
add_submenu_page(
'robotstxt-mediaaudit',
__( 'Media Audit', 'robotstxt-mediaaudit' ),
__( 'Media Audit', 'robotstxt-mediaaudit' ),
'edit_others_posts',
'robotstxt-mediaaudit',
array( $this->audit_page, 'render' )
);
add_submenu_page(
'robotstxt-mediaaudit',
__( 'Settings', 'robotstxt-mediaaudit' ),
__( 'Settings', 'robotstxt-mediaaudit' ),
'manage_options',
'robotstxt-mediaaudit-settings',
array( $this->settings, 'render' )
);
}
/**
* Runs pending DB migrations when the stored schema version is behind the constant.
*
* @return void
*/
public function check_db_version(): void {
$raw = get_option( 'robotstxt_mediaaudit_db_version', '0.0.0' );
$stored = is_string( $raw ) ? $raw : '0.0.0';
if ( ROBOTSTXT_MEDIAAUDIT_DB_VERSION !== $stored ) {
Database::run_migrations();
update_option( 'robotstxt_mediaaudit_db_version', ROBOTSTXT_MEDIAAUDIT_DB_VERSION );
}
}
/**
* Registers built-in external providers via the mra/external/providers filter.
*
* @param mixed $providers Accumulated provider list.
*
* @return array<\MediaRightsAudit\External\AbstractProvider>
*/
public function register_providers( $providers ): array {
$list = array();
if ( is_array( $providers ) ) {
foreach ( $providers as $p ) {
if ( $p instanceof AbstractProvider ) {
$list[] = $p;
}
}
}
$list[] = new GoogleVisionProvider();
$list[] = new TinEyeProvider();
return $list;
}
/**
* Registers WP-CLI commands when running in CLI context.
*
* @return void
*/
private function register_cli_commands(): void {
if ( defined( 'WP_CLI' ) && WP_CLI ) {
\WP_CLI::add_command( 'mra', Command::class );
}
}
}

View file

@ -0,0 +1,77 @@
<?php
/**
* Action Scheduler wrapper.
*
* @package MediaRightsAudit\Core\Queue
*/
namespace MediaRightsAudit\Core\Queue;
/**
* Thin wrapper around Action Scheduler that namespaces all jobs under a single group.
*
* Action Scheduler is declared as a required plugin (Requires Plugins: action-scheduler)
* so its functions are always available at runtime. The is_available() guard is kept
* for defensive unit-test scenarios only.
*/
class Scheduler {
/**
* Action Scheduler group for all plugin jobs.
*/
const GROUP = 'robotstxt-mediaaudit';
/**
* Returns true when Action Scheduler functions are available.
*
* @return bool
*/
public static function is_available(): bool {
return function_exists( 'as_schedule_single_action' );
}
/**
* Schedules a single action to run after the given delay.
*
* @param string $hook Action hook name.
* @param array<mixed> $args Arguments passed to the hook.
* @param int $delay Seconds from now (default 0 = immediate).
*
* @return int|null Action Scheduler action ID, or null if unavailable.
*/
public static function schedule_single( string $hook, array $args = array(), int $delay = 0 ): ?int {
if ( ! self::is_available() ) {
return null;
}
return as_schedule_single_action( time() + $delay, $hook, $args, self::GROUP );
}
/**
* Returns true if a pending (not-yet-run) action exists for this hook.
*
* @param string $hook Action hook name.
* @param array<mixed> $args Arguments to match.
*
* @return bool
*/
public static function has_pending( string $hook, array $args = array() ): bool {
if ( ! self::is_available() ) {
return false;
}
return as_has_scheduled_action( $hook, $args, self::GROUP );
}
/**
* Cancels all pending plugin actions across all hooks.
*
* Called on plugin deactivation.
*
* @return void
*/
public static function cancel_all(): void {
if ( ! self::is_available() ) {
return;
}
as_unschedule_all_actions( '', array(), self::GROUP );
}
}