diff --git a/aaa-option-optimizer.php b/aaa-option-optimizer.php index 3bacb4d..0cd997b 100644 --- a/aaa-option-optimizer.php +++ b/aaa-option-optimizer.php @@ -73,6 +73,8 @@ function aaa_option_optimizer_activation() { function aaa_option_optimizer_deactivation() { $aaa_option_value = get_option( 'option_optimizer' ); update_option( 'option_optimizer', $aaa_option_value, false ); + + wp_clear_scheduled_hook( Progress_Planner\OptionOptimizer\Known_Plugins::CRON_HOOK ); } /** diff --git a/bin/validate-known-plugins.php b/bin/validate-known-plugins.php new file mode 100644 index 0000000..52d86f0 --- /dev/null +++ b/bin/validate-known-plugins.php @@ -0,0 +1,94 @@ + $entry ) { + if ( ! is_string( $slug ) || '' === $slug ) { + $errors[] = 'Slug must be a non-empty string.'; + continue; + } + + if ( ! is_array( $entry ) ) { + $errors[] = "Entry for '{$slug}' must be an object."; + continue; + } + + if ( empty( $entry['name'] ) || ! is_string( $entry['name'] ) ) { + $errors[] = "Entry '{$slug}' missing 'name' string."; + } + + if ( empty( $entry['option_prefixes'] ) || ! is_array( $entry['option_prefixes'] ) ) { + $errors[] = "Entry '{$slug}' missing non-empty 'option_prefixes' array."; + continue; + } + + foreach ( $entry['option_prefixes'] as $prefix ) { + if ( ! is_string( $prefix ) || '' === $prefix ) { + $errors[] = "Entry '{$slug}' has empty/non-string prefix."; + continue; + } + + // Reject dangerously generic prefixes that would match thousands of options. + if ( strlen( $prefix ) < 3 ) { + $errors[] = "Entry '{$slug}' prefix '{$prefix}' is too short (<3 chars)."; + } + + if ( in_array( $prefix, [ 'wp_', 'option_', '_transient_' ], true ) ) { + $errors[] = "Entry '{$slug}' prefix '{$prefix}' is reserved/dangerous."; + } + + if ( isset( $seen_prefixes[ $prefix ] ) && $seen_prefixes[ $prefix ] !== $slug ) { + $warnings[] = "Prefix '{$prefix}' claimed by both '{$seen_prefixes[ $prefix ]}' and '{$slug}'."; + } + $seen_prefixes[ $prefix ] = $slug; + } +} + +foreach ( $warnings as $warn ) { + fwrite( STDERR, "WARNING: {$warn}\n" ); +} + +if ( ! empty( $errors ) ) { + fwrite( STDERR, "Validation failed:\n" ); + foreach ( $errors as $err ) { + fwrite( STDERR, " - {$err}\n" ); + } + exit( 1 ); +} + +$count = count( $data ); +echo "OK: {$count} entries validated.\n"; +exit( 0 ); diff --git a/css/style.css b/css/style.css index 21e90b6..39c4442 100644 --- a/css/style.css +++ b/css/style.css @@ -91,6 +91,38 @@ div.dt-container .dt-input { .aaa-option-optimizer-popover__close:hover { cursor: pointer; } +.aaa-report-trigger { + background: none; + border: 0; + padding: 0; + font: inherit; + color: inherit; + cursor: pointer; + line-height: 1.4; + text-align: left; +} +.aaa-report-trigger .aaa-report-trigger__action { + display: block; + color: #2271b1; + text-decoration: underline; + text-underline-offset: 2px; +} +.aaa-report-trigger:hover .aaa-report-trigger__action, +.aaa-report-trigger:focus-visible .aaa-report-trigger__action { + color: #135e96; +} +.aaa-report-popover { + min-width: 380px; + min-height: auto; +} +.aaa-report-popover .aaa-report-input { + width: 100%; + margin-top: 4px; +} +.aaa-report-popover .aaa-report-status { + min-height: 1.2em; + margin: 8px 0; +} .aaa-option-optimizer-tabs { clear: both; display: flex; diff --git a/js/admin-script.js b/js/admin-script.js index 3531a73..d0e8dcd 100644 --- a/js/admin-script.js +++ b/js/admin-script.js @@ -183,7 +183,11 @@ jQuery( document ).ready( function () { className: 'select-all', }, { name: 'name', data: 'name' }, - { name: 'source', data: 'plugin' }, + { + name: 'source', + data: 'plugin', + render: ( data, type, row ) => renderSourceColumn( row ), + }, { name: 'size', data: 'size', searchable: false }, { name: 'autoload', @@ -204,7 +208,12 @@ jQuery( document ).ready( function () { if ( selector === '#requested_do_not_exist_table' ) { return [ { name: 'option', data: 'name' }, - { name: 'source', data: 'plugin', searchable: false }, + { + name: 'source', + data: 'plugin', + searchable: false, + render: ( data, type, row ) => renderSourceColumn( row ), + }, { name: 'calls', data: 'count', searchable: false }, { name: 'option_name', @@ -227,7 +236,11 @@ jQuery( document ).ready( function () { className: 'select-all', }, { name: 'name', data: 'name' }, - { name: 'source', data: 'plugin' }, + { + name: 'source', + data: 'plugin', + render: ( data, type, row ) => renderSourceColumn( row ), + }, { name: 'size', data: 'size', searchable: false }, { name: 'autoload', @@ -257,7 +270,11 @@ jQuery( document ).ready( function () { className: 'select-all', }, { name: 'name', data: 'name' }, - { name: 'source', data: 'plugin' }, + { + name: 'source', + data: 'plugin', + render: ( data, type, row ) => renderSourceColumn( row ), + }, { name: 'size', data: 'size', @@ -383,6 +400,303 @@ jQuery( document ).ready( function () { `; } + /** + * Escape HTML for safe insertion as text. + * + * @param {string} unsafe - The string to escape. + * @return {string} - The escaped string. + */ + function escapeHtml( unsafe ) { + return String( unsafe ) + .replace( /&/g, '&' ) + .replace( //g, '>' ) + .replace( /"/g, '"' ) + .replace( /'/g, ''' ); + } + + /** + * Renders the Source column. For unknown sources, wraps the label + * in a button that opens a Report Origin popover. + * + * @param {Object} row - The row data. + * + * @return {string} - The HTML for the source column. + */ + let reportPopoverSeq = 0; + function renderSourceColumn( row ) { + const label = escapeHtml( row.plugin ); + if ( row.plugin_known ) { + return label; + } + const popoverId = `aaa_report_${ ++reportPopoverSeq }`; + return `${ renderReportPopover( row, popoverId ) } + `; + } + + /** + * Renders the Report Origin popover for an unknown row. + * + * @param {Object} row - The row data. + * @param {string} popoverId - The popover element id. + * + * @return {string} - The popover HTML. + */ + function renderReportPopover( row, popoverId ) { + const i18n = aaaOptionOptimizer.i18n; + const optionName = escapeHtml( row.name ); + return `
+ +

${ i18n.reportOriginOf } ${ optionName }

+

+ +

+

+

${ escapeHtml( i18n.reportPrivacyNote ) }

+ ${ renderConsentField() } +

+ + +

+
`; + } + + /** + * Renders the consent checkbox shown in the Report popover when the user + * has not yet consented to contacting our servers. Returns an empty string + * once consent has been granted (globally or earlier this session). + * + * @return {string} - The consent field HTML, or an empty string. + */ + function renderConsentField() { + if ( aaaOptionOptimizer.hasRemoteConsent ) { + return ''; + } + return ``; + } + + /** + * Whether the report in this popover may be submitted: a slug must be + * verified, and consent must be granted (globally or via the popover + * checkbox). + * + * @param {jQuery} $popover - The popover jQuery element. + * @return {boolean} - True when the report may be submitted. + */ + function reportCanSubmit( $popover ) { + const optionName = $popover.data( 'option' ); + const state = reportState[ optionName ]; + const verified = !! ( state && state.slug && state.verifiedName ); + return verified && hasReportConsent( $popover ); + } + + /** + * Whether consent is satisfied for this popover. + * + * @param {jQuery} $popover - The popover jQuery element. + * @return {boolean} - True when consent is satisfied. + */ + function hasReportConsent( $popover ) { + if ( aaaOptionOptimizer.hasRemoteConsent ) { + return true; + } + return $popover.find( '.aaa-report-consent-input' ).is( ':checked' ); + } + + /** + * Extract a wp.org plugin slug from a slug or URL. + * + * @param {string} input - User input. + * @return {string} - Normalized slug, or empty string if invalid. + */ + function normalizeSlug( input ) { + const trimmed = String( input || '' ).trim(); + if ( ! trimmed ) { + return ''; + } + // Try to pull a slug out of a wordpress.org URL. + const urlMatch = trimmed.match( + /wordpress\.org\/plugins\/([a-z0-9-]+)/i + ); + if ( urlMatch ) { + return urlMatch[ 1 ].toLowerCase(); + } + // Otherwise treat input as a slug. + if ( /^[a-z0-9-]+$/i.test( trimmed ) ) { + return trimmed.toLowerCase(); + } + return ''; + } + + // Per-popover state for the wp.org verification step. + const reportState = {}; + + /** + * Verify a slug against the wordpress.org plugin directory and update + * the popover UI accordingly. + * + * @param {jQuery} $popover - The popover jQuery element. + * @param {string} slug - The slug to verify. + */ + function verifyReportSlug( $popover, slug ) { + const optionName = $popover.data( 'option' ); + const i18n = aaaOptionOptimizer.i18n; + const $status = $popover.find( '.aaa-report-status' ); + const $submit = $popover.find( '.aaa-report-submit' ); + + reportState[ optionName ] = { slug: '', verifiedName: '' }; + $submit.prop( 'disabled', true ); + + if ( ! slug ) { + $status.text( '' ); + return; + } + + $status.text( i18n.reportVerifying ); + + jQuery + .ajax( { + url: `https://api.wordpress.org/plugins/info/1.0/${ encodeURIComponent( + slug + ) }.json`, + method: 'GET', + dataType: 'json', + timeout: 8000, + } ) + .done( function ( data ) { + if ( ! data || data.error || ! data.name ) { + $status.text( i18n.reportNotFound ); + return; + } + reportState[ optionName ] = { + slug, + verifiedName: data.name, + }; + $status.html( + `✓ ${ i18n.reportVerified } ${ escapeHtml( + data.name + ) }` + ); + $submit.prop( 'disabled', ! reportCanSubmit( $popover ) ); + } ) + .fail( function () { + $status.text( i18n.reportVerifyError ); + } ); + } + + /** + * Submit a report to the configured endpoint. + * + * @param {jQuery} $popover - The popover jQuery element. + */ + function submitReport( $popover ) { + const optionName = $popover.data( 'option' ); + const i18n = aaaOptionOptimizer.i18n; + const state = reportState[ optionName ]; + if ( ! state || ! state.slug ) { + return; + } + if ( ! hasReportConsent( $popover ) ) { + return; + } + + const $status = $popover.find( '.aaa-report-status' ); + const $submit = $popover.find( '.aaa-report-submit' ); + $submit.prop( 'disabled', true ); + $status.text( i18n.reportSubmitting ); + + // If consent was granted here (not previously), persist it so the daily + // refresh starts and the user isn't asked again. + if ( ! aaaOptionOptimizer.hasRemoteConsent ) { + persistConsent(); + } + + jQuery + .ajax( { + url: aaaOptionOptimizer.reportUrl, + method: 'POST', + contentType: 'application/json', + data: JSON.stringify( { + option_name: optionName, + slug: state.slug, + site: window.location.hostname, + } ), + timeout: 10000, + } ) + .done( function () { + $status.text( i18n.reportThanks ); + } ) + .fail( function () { + $status.text( i18n.reportFailed ); + $submit.prop( 'disabled', false ); + } ); + } + + /** + * Persist the user's consent to contacting our servers, and remember it for + * the rest of this page session so further popovers don't ask again. Best + * effort — a failure here doesn't block the report the user just made. + */ + function persistConsent() { + aaaOptionOptimizer.hasRemoteConsent = true; + jQuery.ajax( { + url: `${ aaaOptionOptimizer.root }aaa-option-optimizer/v1/set-consent`, + method: 'POST', + contentType: 'application/json', + beforeSend: ( xhr ) => + xhr.setRequestHeader( 'X-WP-Nonce', aaaOptionOptimizer.nonce ), + data: JSON.stringify( { consent: true } ), + } ); + } + + // Debounced wp.org verification on input change. Per-popover timer so + // concurrently-open popovers don't cancel each other's verification. + jQuery( document ).on( 'input', '.aaa-report-input', function () { + const $popover = jQuery( this ).closest( '.aaa-report-popover' ); + const raw = jQuery( this ).val(); + const slug = normalizeSlug( raw ); + const previous = $popover.data( 'verifyTimer' ); + if ( previous ) { + clearTimeout( previous ); + } + $popover.data( + 'verifyTimer', + setTimeout( () => verifyReportSlug( $popover, slug ), 350 ) + ); + } ); + + // Re-evaluate the submit button when consent is toggled. + jQuery( document ).on( 'change', '.aaa-report-consent-input', function () { + const $popover = jQuery( this ).closest( '.aaa-report-popover' ); + $popover + .find( '.aaa-report-submit' ) + .prop( 'disabled', ! reportCanSubmit( $popover ) ); + } ); + + // Submit handler. + jQuery( document ).on( 'click', '.aaa-report-submit', function () { + const $popover = jQuery( this ).closest( '.aaa-report-popover' ); + submitReport( $popover ); + } ); + jQuery( '#aaa-option-reset-data' ).on( 'click', function ( e ) { e.preventDefault(); jQuery.ajax( { @@ -651,8 +965,7 @@ jQuery( document ).ready( function () { function migrateChunk() { jQuery.ajax( { url: - aaaOptionOptimizer.root + - 'aaa-option-optimizer/v1/migrate', + aaaOptionOptimizer.root + 'aaa-option-optimizer/v1/migrate', method: 'POST', beforeSend: ( xhr ) => xhr.setRequestHeader( diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 7c4ebd2..16b2f59 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -22,6 +22,9 @@ /coverage/* /js/vendor/* + + /bin/* + *.js *.css diff --git a/readme.txt b/readme.txt index d0ae23a..39c0222 100644 --- a/readme.txt +++ b/readme.txt @@ -52,8 +52,31 @@ Please do a pull request via GitHub on [this file](https://github.com/ProgressPl 1. Screenshot of the admin screen, initial tab. 2. Screenshot of the "All options" screen, showing you can browse all the options. +== External services == + +This plugin connects to two external services to identify the source plugins of WordPress options. + += WordPress.org plugin directory (api.wordpress.org) = + +When you click "Report" on an option whose source is "Unknown" and type a plugin slug, the plugin queries `https://api.wordpress.org/plugins/info/1.0/{slug}.json` to verify the slug exists and to display the official plugin name for confirmation. Only the slug you type is sent. + +WordPress.org terms of service: https://wordpress.org/about/privacy/ + += Known-plugins mapping and origin reporting (option-optimizer-api.progressplanner.com) = + +**This is off until you opt in.** Once you enable "Keep the known-plugins list up to date automatically" (on the plugin's settings tab, or by agreeing in the Report popover), the plugin fetches an updated list of recognized plugins once a day from `https://option-optimizer-api.progressplanner.com/known-plugins.json`. This lets the plugin identify newly-added plugins as the maintained list grows, without requiring a plugin update. With this request the plugin sends your plugin version and WordPress version, so the maintainers can keep anonymous usage statistics. No site identity is sent. Until you opt in, only the list bundled with the plugin is used and no request is made. The fetch URL can be overridden, or the feature disabled, via the `aaa_option_optimizer_known_plugins_url` filter. + +When you submit a "Report origin" form, the plugin sends the option name you reported, the wp.org plugin slug you supplied, and your site's hostname to `https://option-optimizer-api.progressplanner.com/submit`. The submission is recorded as a GitHub issue for a maintainer to review and add to the recognized plugins list. The site hostname is hashed before storage and never published. The option name and slug appear in the public GitHub issue. Submissions only happen when you click Submit on the Report form; nothing is sent automatically. + +The endpoint is operated by the plugin maintainers. The submission URL can be overridden via the `aaa_option_optimizer_report_url` filter for users who want to disable or redirect the feature. + == Changelog == += 1.7.0 = + +* Add "Report origin" feature: for options whose source plugin is unknown, users can submit the matching wp.org slug to help maintainers expand the recognized plugins list. Submissions land as GitHub issues for maintainer review; no auto-merge. +* Recognized-plugins list can refresh once a day in the background from the maintainers' server, so the list grows for users without requiring plugin updates. This is opt-in: enable it on the settings tab or agree in the Report popover. The refresh sends only your plugin and WordPress version for anonymous statistics; no site identity is sent. + = 1.6.1 = * Fix infinite recursion in option access monitoring that could cause a fatal error in certain hosting environments. diff --git a/src/class-admin-page.php b/src/class-admin-page.php index e45a243..ba5cca8 100644 --- a/src/class-admin-page.php +++ b/src/class-admin-page.php @@ -73,6 +73,11 @@ public function sanitize_settings( $input ): array { } $existing['settings']['option_tracking'] = $option_tracking; + // Consent to contacting our servers (checkbox: present means granted). + $existing['settings']['remote_data_consent'] = isset( $input['settings']['remote_data_consent'] ) + ? (bool) $input['settings']['remote_data_consent'] + : false; + // Return the full option structure with merged settings. return $existing; } @@ -84,7 +89,8 @@ public function sanitize_settings( $input ): array { */ public static function get_settings(): array { $defaults = [ - 'option_tracking' => 'pre_option', + 'option_tracking' => 'pre_option', + 'remote_data_consent' => false, ]; $option_optimizer = \get_option( self::OPTION_NAME, [] ); @@ -185,20 +191,45 @@ public function enqueue_scripts( $hook ) { true // In footer. ); + /** + * Filter the URL the plugin POSTs unknown-option reports to. + * + * @param string $url The submission endpoint URL. + */ + $report_url = \apply_filters( 'aaa_option_optimizer_report_url', 'https://option-optimizer-api.progressplanner.com/submit' ); + \wp_localize_script( 'aaa-option-optimizer-admin-js', 'aaaOptionOptimizer', [ - 'root' => \esc_url_raw( \rest_url() ), - 'nonce' => \wp_create_nonce( 'wp_rest' ), - 'migration' => Database::get_migration_status(), - 'i18n' => [ + 'root' => \esc_url_raw( \rest_url() ), + 'nonce' => \wp_create_nonce( 'wp_rest' ), + 'migration' => Database::get_migration_status(), + 'reportUrl' => \esc_url_raw( $report_url ), + 'hasRemoteConsent' => Known_Plugins::has_consent(), + 'i18n' => [ 'filterBySource' => \esc_html__( 'Filter by source', 'aaa-option-optimizer' ), 'showValue' => \esc_html__( 'Show', 'aaa-option-optimizer' ), 'addAutoload' => \esc_html__( 'Add autoload', 'aaa-option-optimizer' ), 'removeAutoload' => \esc_html__( 'Remove autoload', 'aaa-option-optimizer' ), 'deleteOption' => \esc_html__( 'Delete', 'aaa-option-optimizer' ), 'createOptionFalse' => \esc_html__( 'Create option with value false', 'aaa-option-optimizer' ), + 'unknownLabel' => \esc_html__( 'Unknown', 'aaa-option-optimizer' ), + 'reportOrigin' => \esc_html__( 'Report', 'aaa-option-optimizer' ), + 'reportOriginOf' => \esc_html__( 'Report origin of', 'aaa-option-optimizer' ), + 'reportSlugOrUrlLabel' => \esc_html__( 'wp.org slug or URL', 'aaa-option-optimizer' ), + 'reportSlugPlaceholder' => \esc_html__( 'e.g. wp125 or https://wordpress.org/plugins/wp125/', 'aaa-option-optimizer' ), + 'reportVerifying' => \esc_html__( 'Checking wp.org…', 'aaa-option-optimizer' ), + 'reportNotFound' => \esc_html__( 'Plugin not found on wordpress.org.', 'aaa-option-optimizer' ), + 'reportVerifyError' => \esc_html__( 'Could not verify with wordpress.org.', 'aaa-option-optimizer' ), + 'reportVerified' => \esc_html__( 'Verified:', 'aaa-option-optimizer' ), + 'reportSubmit' => \esc_html__( 'Submit', 'aaa-option-optimizer' ), + 'reportCancel' => \esc_html__( 'Cancel', 'aaa-option-optimizer' ), + 'reportSubmitting' => \esc_html__( 'Submitting…', 'aaa-option-optimizer' ), + 'reportThanks' => \esc_html__( 'Thanks! Your report has been submitted.', 'aaa-option-optimizer' ), + 'reportFailed' => \esc_html__( 'Submission failed. Please try again.', 'aaa-option-optimizer' ), + 'reportPrivacyNote' => \esc_html__( 'We send the option name and the slug you provide. Never the option value.', 'aaa-option-optimizer' ), + 'reportConsentLabel' => \esc_html__( 'I agree to send this report to option-optimizer-api.progressplanner.com, and to let the plugin keep the known-plugins list up to date daily.', 'aaa-option-optimizer' ), 'noAutoloadedButNotUsed' => \esc_html__( 'All autoloaded options are in use.', 'aaa-option-optimizer' ), 'noUsedButNotAutoloaded' => \esc_html__( 'All options that are used are autoloaded.', 'aaa-option-optimizer' ), 'noOptionsSelected' => \esc_html__( 'No options selected.', 'aaa-option-optimizer' ), @@ -533,6 +564,23 @@ private function render_settings_tab( $option_optimizer, $result ): void { + +

+ >|null + */ + private $list; + + /** + * Get the known-plugins mapping. + * + * @return array> + */ + public function get(): array { + if ( null !== $this->list ) { + return $this->list; + } + + $cached = \get_option( self::CACHE_KEY ); + if ( \is_array( $cached ) && ! empty( $cached ) ) { + $this->list = $cached; + return $this->list; + } + + $this->list = $this->load_bundled(); + return $this->list; + } + + /** + * Refresh the cached mapping from the remote URL. + * + * Sends the plugin and WordPress versions so the maintainers can keep + * anonymous usage statistics for the maintained mapping. No site identity + * is transmitted. + * + * @return bool True when a fresh copy was stored, false otherwise. + */ + public function refresh(): bool { + // Never contact our servers without the user's consent. + if ( ! self::has_consent() ) { + return false; + } + + /** + * Filters the URL the known-plugins mapping is fetched from. + * + * Allows users to redirect or disable the remote fetch. + * + * @param string $url The remote URL. + */ + $url = \apply_filters( 'aaa_option_optimizer_known_plugins_url', self::REMOTE_URL ); + + $response = \wp_remote_post( + $url, + [ + 'timeout' => 10, + 'headers' => [ 'Content-Type' => 'application/json' ], + 'body' => (string) \wp_json_encode( $this->stats_payload() ), + ] + ); + + if ( \is_wp_error( $response ) ) { + return false; + } + + if ( 200 !== \wp_remote_retrieve_response_code( $response ) ) { + return false; + } + + $body = \wp_remote_retrieve_body( $response ); + $data = \json_decode( $body, true ); + + if ( ! \is_array( $data ) || empty( $data ) ) { + return false; + } + + \update_option( self::CACHE_KEY, $data, false ); + $this->list = $data; + return true; + } + + /** + * Build the anonymous stats payload sent with the refresh request. + * + * @return array + */ + private function stats_payload(): array { + global $wp_version; + + $plugin_version = ''; + if ( \function_exists( 'get_file_data' ) ) { + $data = \get_file_data( AAA_OPTION_OPTIMIZER_FILE, [ 'Version' => 'Version' ] ); + $plugin_version = isset( $data['Version'] ) ? (string) $data['Version'] : ''; + } + + return [ + 'plugin_version' => $plugin_version, + 'wp_version' => (string) $wp_version, + ]; + } + + /** + * Load the JSON file bundled with the plugin. + * + * @return array> + */ + private function load_bundled(): array { + $path = \plugin_dir_path( AAA_OPTION_OPTIMIZER_FILE ) . 'known-plugins/known-plugins.json'; + if ( ! \file_exists( $path ) ) { + return []; + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading a file bundled with the plugin. + $data = \json_decode( (string) \file_get_contents( $path ), true ); + return \is_array( $data ) ? $data : []; + } +} diff --git a/src/class-map-plugin-to-options.php b/src/class-map-plugin-to-options.php index 799d8fe..06ca4c2 100644 --- a/src/class-map-plugin-to-options.php +++ b/src/class-map-plugin-to-options.php @@ -16,7 +16,7 @@ class Map_Plugin_To_Options { /** * List of plugins we can recognize. * - * @var object[] + * @var array> */ private $plugins_list = []; @@ -28,22 +28,56 @@ class Map_Plugin_To_Options { * @return string */ public function get_plugin_name( string $option ): string { - $plugins_list = []; + $match = $this->find_match( $option ); + return null !== $match ? $match : __( 'Unknown', 'aaa-option-optimizer' ); + } + + /** + * Whether the option's source plugin is known. + * + * @param string $option The option name. + * + * @return bool + */ + public function is_known( string $option ): bool { + return null !== $this->find_match( $option ); + } + + /** + * Look up the plugin name for an option, or null if no match. + * + * @param string $option The option name. + * + * @return string|null + */ + private function find_match( string $option ): ?string { if ( empty( $this->plugins_list ) ) { - $this->plugins_list = json_decode( file_get_contents( plugin_dir_path( AAA_OPTION_OPTIMIZER_FILE ) . 'known-plugins/known-plugins.json' ), true ); + $known = new Known_Plugins(); + $this->plugins_list = $known->get(); } - // for each plugin in the list, check if the option starts with the prefix. + $match = null; foreach ( $this->plugins_list as $plugin ) { foreach ( $plugin['option_prefixes'] as $prefix ) { - if ( strpos( $option, $prefix ) === 0 ) { - if ( isset( $plugin['name'] ) ) { - return $plugin['name']; - } + if ( strpos( $option, $prefix ) === 0 && isset( $plugin['name'] ) ) { + $match = $plugin['name']; + break 2; } } } - return __( 'Unknown', 'aaa-option-optimizer' ); + /** + * Filters the plugin name an option is mapped to. + * + * Lets integrations recognize options the bundled known-plugins list + * does not, or override an existing match. Return a plugin name to mark + * the option as known, or null to leave it unrecognized. Both + * get_plugin_name() and is_known() honor this filter, so a filtered + * match is treated as known everywhere. + * + * @param string|null $match The matched plugin name, or null if unknown. + * @param string $option The option name being looked up. + */ + return \apply_filters( 'aaa_option_optimizer_plugin_name', $match, $option ); } } diff --git a/src/class-plugin.php b/src/class-plugin.php index 7213d73..46bdcd6 100644 --- a/src/class-plugin.php +++ b/src/class-plugin.php @@ -79,6 +79,18 @@ public function register_hooks() { // Use the shutdown action to update the option with tracked data. \add_action( 'shutdown', [ $this, 'update_tracked_options' ] ); + // Daily refresh of the known-plugins mapping, only with user consent. + // The mapping fetch contacts our servers and sends version stats, so it + // stays unscheduled until the user opts in (settings or Report popover). + \add_action( Known_Plugins::CRON_HOOK, [ $this, 'refresh_known_plugins' ] ); + if ( Known_Plugins::has_consent() ) { + if ( ! \wp_next_scheduled( Known_Plugins::CRON_HOOK ) ) { + \wp_schedule_event( \time(), 'daily', Known_Plugins::CRON_HOOK ); + } + } elseif ( \wp_next_scheduled( Known_Plugins::CRON_HOOK ) ) { + \wp_clear_scheduled_hook( Known_Plugins::CRON_HOOK ); + } + // Register the REST routes. $rest = new REST(); $rest->register_hooks(); @@ -160,6 +172,15 @@ protected function add_option_usage( $option_name ) { ++$this->accessed_options[ $option_name ]; } + /** + * Refresh the cached known-plugins mapping from the maintainers' server. + * + * @return void + */ + public function refresh_known_plugins() { + ( new Known_Plugins() )->refresh(); + } + /** * Update the tracked options at the end of the page load. * diff --git a/src/class-rest.php b/src/class-rest.php index 472eea4..b3957c6 100644 --- a/src/class-rest.php +++ b/src/class-rest.php @@ -40,6 +40,24 @@ public function register_hooks() { * @return void */ public function register_rest_routes() { + \register_rest_route( + 'aaa-option-optimizer/v1', + '/set-consent', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'set_consent' ], + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + 'args' => [ + 'consent' => [ + 'required' => true, + 'type' => 'boolean', + ], + ], + ] + ); + \register_rest_route( 'aaa-option-optimizer/v1', '/update-autoload', @@ -205,6 +223,42 @@ public function reset_stats() { return new \WP_REST_Response( [ 'success' => true ], 200 ); } + /** + * Set the user's consent to contacting our servers. + * + * Persists the flag into the plugin settings and (un)schedules the daily + * known-plugins refresh to match, so granting consent from the Report + * popover takes effect immediately. + * + * @param WP_REST_Request $request The REST request. + * + * @return \WP_REST_Response + */ + public function set_consent( $request ) { + $consent = (bool) $request->get_param( 'consent' ); + + $option = \get_option( Admin_Page::OPTION_NAME, [] ); + $option['settings'] = isset( $option['settings'] ) ? $option['settings'] : []; + $option['settings']['remote_data_consent'] = $consent; + \update_option( Admin_Page::OPTION_NAME, $option ); + + if ( $consent ) { + if ( ! \wp_next_scheduled( Known_Plugins::CRON_HOOK ) ) { + \wp_schedule_event( \time(), 'daily', Known_Plugins::CRON_HOOK ); + } + } elseif ( \wp_next_scheduled( Known_Plugins::CRON_HOOK ) ) { + \wp_clear_scheduled_hook( Known_Plugins::CRON_HOOK ); + } + + return new \WP_REST_Response( + [ + 'success' => true, + 'consent' => $consent, + ], + 200 + ); + } + /** * Migrate a chunk of data from old format to custom table. * @@ -228,12 +282,13 @@ public function get_all_options() { $options = $wpdb->get_results( "SELECT option_name, option_value, autoload FROM $wpdb->options" ); foreach ( $options as $option ) { $output[] = [ - 'name' => $option->option_name, - 'plugin' => $this->get_plugin_name( $option->option_name ), - 'value' => htmlentities( $option->option_value, ENT_QUOTES | ENT_SUBSTITUTE ), - 'size' => $this->get_length( $option->option_value ), - 'raw_size' => strlen( $option->option_value ), - 'autoload' => $option->autoload, + 'name' => $option->option_name, + 'plugin' => $this->get_plugin_name( $option->option_name ), + 'plugin_known' => $this->is_plugin_known( $option->option_name ), + 'value' => htmlentities( $option->option_value, ENT_QUOTES | ENT_SUBSTITUTE ), + 'size' => $this->get_length( $option->option_value ), + 'raw_size' => strlen( $option->option_value ), + 'autoload' => $option->autoload, ]; } return new \WP_REST_Response( [ 'data' => $output ], 200 ); @@ -320,12 +375,13 @@ public function get_unused_options() { // Format output. foreach ( $results as $row ) { $response_data[] = [ - 'name' => $row->option_name, - 'plugin' => $this->get_plugin_name( $row->option_name ), - 'value' => htmlentities( $row->option_value, ENT_QUOTES | ENT_SUBSTITUTE ), - 'size' => $this->get_length( $row->option_value ), - 'raw_size' => strlen( $row->option_value ), - 'autoload' => 'yes', + 'name' => $row->option_name, + 'plugin' => $this->get_plugin_name( $row->option_name ), + 'plugin_known' => $this->is_plugin_known( $row->option_name ), + 'value' => htmlentities( $row->option_value, ENT_QUOTES | ENT_SUBSTITUTE ), + 'size' => $this->get_length( $row->option_value ), + 'raw_size' => strlen( $row->option_value ), + 'autoload' => 'yes', ]; } @@ -459,13 +515,14 @@ function ( $option_name ) use ( $search ) { foreach ( $results as $row ) { $response_data[] = [ - 'name' => $row->option_name, - 'plugin' => $this->get_plugin_name( $row->option_name ), - 'value' => htmlentities( maybe_serialize( $row->option_value ), ENT_QUOTES | ENT_SUBSTITUTE ), - 'size' => $this->get_length( $row->option_value ), - 'raw_size' => strlen( $row->option_value ), - 'autoload' => 'no', - 'count' => $used_options[ $row->option_name ] ?? 0, + 'name' => $row->option_name, + 'plugin' => $this->get_plugin_name( $row->option_name ), + 'plugin_known' => $this->is_plugin_known( $row->option_name ), + 'value' => htmlentities( maybe_serialize( $row->option_value ), ENT_QUOTES | ENT_SUBSTITUTE ), + 'size' => $this->get_length( $row->option_value ), + 'raw_size' => strlen( $row->option_value ), + 'autoload' => 'no', + 'count' => $used_options[ $row->option_name ] ?? 0, ]; } @@ -560,10 +617,11 @@ public function get_options_that_do_not_exist() { foreach ( $non_autoloaded_keys as $option => $count ) { if ( ! isset( $existing_keys[ $option ] ) ) { $non_existing_options[ $option ] = [ - 'name' => $option, - 'plugin' => $this->get_plugin_name( $option ), - 'count' => $count, - 'option_name' => $option, + 'name' => $option, + 'plugin' => $this->get_plugin_name( $option ), + 'plugin_known' => $this->is_plugin_known( $option ), + 'count' => $count, + 'option_name' => $option, ]; } } @@ -866,6 +924,17 @@ private function get_plugin_name( $option ) { return $this->map_plugin_to_options->get_plugin_name( $option ); } + /** + * Whether the option's source plugin is known. + * + * @param string $option The option name. + * + * @return bool + */ + private function is_plugin_known( $option ) { + return $this->map_plugin_to_options->is_known( $option ); + } + /** * Filter options array by source (plugin) name. * diff --git a/uninstall.php b/uninstall.php index 05bf9fe..630eb9d 100644 --- a/uninstall.php +++ b/uninstall.php @@ -24,3 +24,9 @@ // Delete the plugin option. delete_option( 'option_optimizer' ); + +// Delete the cached known-plugins mapping. +delete_option( 'aaa_option_optimizer_known_plugins' ); + +// Clear the daily refresh cron event (defense-in-depth; deactivation already does this). +wp_clear_scheduled_hook( 'aaa_option_optimizer_refresh_known_plugins' );