diff --git a/aaa-option-optimizer.php b/aaa-option-optimizer.php index 3bacb4d..aba743c 100644 --- a/aaa-option-optimizer.php +++ b/aaa-option-optimizer.php @@ -34,8 +34,14 @@ function aaa_option_optimizer_activation() { global $wpdb; - // Create the custom table. + // Create the custom tables. Progress_Planner\OptionOptimizer\Database::create_table(); + Progress_Planner\OptionOptimizer\Database::create_quarantine_table(); + + // Schedule the daily quarantine cleanup event. + if ( ! wp_next_scheduled( 'aaa_option_optimizer_quarantine_cleanup' ) ) { + wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'aaa_option_optimizer_quarantine_cleanup' ); + } $autoload_values = \wp_autoload_values_to_autoload(); $placeholders = implode( ',', array_fill( 0, count( $autoload_values ), '%s' ) ); @@ -73,6 +79,12 @@ 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 ); + + // Unschedule the quarantine cleanup event. + $timestamp = wp_next_scheduled( 'aaa_option_optimizer_quarantine_cleanup' ); + if ( $timestamp ) { + wp_unschedule_event( $timestamp, 'aaa_option_optimizer_quarantine_cleanup' ); + } } /** @@ -92,6 +104,16 @@ function aaa_option_optimizer_maybe_upgrade() { if ( ! Progress_Planner\OptionOptimizer\Database::table_exists() ) { Progress_Planner\OptionOptimizer\Database::create_table(); } + + // Check if quarantine table exists, create if not. + if ( ! Progress_Planner\OptionOptimizer\Database::quarantine_table_exists() ) { + Progress_Planner\OptionOptimizer\Database::create_quarantine_table(); + } + + // Ensure cleanup event is scheduled (covers installs that predate this feature). + if ( ! wp_next_scheduled( 'aaa_option_optimizer_quarantine_cleanup' ) ) { + wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'aaa_option_optimizer_quarantine_cleanup' ); + } } add_action( 'plugins_loaded', 'aaa_option_optimizer_maybe_upgrade' ); diff --git a/css/style.css b/css/style.css index 21e90b6..f48c748 100644 --- a/css/style.css +++ b/css/style.css @@ -19,6 +19,9 @@ .aaa_option_table .actions .button { margin-right: 10px; } +.aaa_option_table .actions > .button:last-child { + margin-right: 0; +} .aaa_option_table select { max-width: 20% !important; } @@ -27,7 +30,6 @@ max-width: 200px !important; } .aaa_option_table .actions .button-delete, .aaa-option-optimizer-reset .button-delete { - margin-right: 0; color: #a00; border-color: #a00; } diff --git a/js/admin-script.js b/js/admin-script.js index 3531a73..e43e0f7 100644 --- a/js/admin-script.js +++ b/js/admin-script.js @@ -1,4 +1,4 @@ -/* global jQuery, aaaOptionOptimizer, Option, DataTable, alert */ +/* global jQuery, aaaOptionOptimizer, Option, DataTable, alert, Blob, URL, FileReader */ /** * JavaScript for the admin page. @@ -19,6 +19,7 @@ jQuery( document ).ready( function () { '#unused_options_table', '#used_not_autoloaded_table', '#requested_do_not_exist_table', + '#quarantine_table', ]; jQuery( '#all_options_table' ).hide(); @@ -162,9 +163,105 @@ jQuery( document ).ready( function () { options.order = [ [ 1, 'asc' ] ]; // Order by 2nd column, first column is checkbox. } + if ( selector === '#quarantine_table' ) { + options.ajax = { + url: `${ aaaOptionOptimizer.root }aaa-option-optimizer/v1/quarantine`, + headers: { 'X-WP-Nonce': aaaOptionOptimizer.nonce }, + type: 'GET', + dataSrc: 'data', + }; + options.columns = [ + { name: 'name', data: 'name' }, + { name: 'size', data: 'size', searchable: false }, + { + name: 'autoload', + data: 'autoload', + searchable: false, + }, + { + name: 'quarantined_at', + data: 'quarantined_at', + searchable: false, + }, + { + name: 'expires_at', + data: 'expires_at', + searchable: false, + }, + { + name: 'actions', + data: 'name', + render: ( data, type, row ) => + renderQuarantineActionsColumn( row ), + orderable: false, + searchable: false, + className: 'actions', + }, + ]; + options.order = [ [ 3, 'desc' ] ]; + options.language = { + sZeroRecords: aaaOptionOptimizer.i18n.quarantineEmpty, + }; + delete options.initComplete; + } + new DataTable( selector, options ).columns.adjust().responsive.recalc(); } + /** + * Renders the Actions column for a quarantine row. + * + * @param {Object} row - The row data. + * @return {string} HTML. + */ + function renderQuarantineActionsColumn( row ) { + return ` + `; + } + + /** + * Handles quarantine table actions (restore, permanently-delete). + * + * @param {Event} e - The click event. + */ + function handleQuarantineActions( e ) { + e.preventDefault(); + const button = jQuery( this ); + const optionName = button.data( 'option' ); + const dt = jQuery( '#quarantine_table' ).DataTable(); + + let route; + if ( button.hasClass( 'restore-option' ) ) { + route = 'quarantine/restore'; + } else { + // eslint-disable-next-line no-alert + if ( ! window.confirm( aaaOptionOptimizer.i18n.confirmPermanentDelete ) ) { + return; + } + route = 'quarantine/delete'; + } + + jQuery.ajax( { + url: `${ aaaOptionOptimizer.root }aaa-option-optimizer/v1/${ route }`, + method: 'POST', + beforeSend: ( xhr ) => + xhr.setRequestHeader( 'X-WP-Nonce', aaaOptionOptimizer.nonce ), + data: { option_name: optionName }, + success: () => { + dt.ajax.reload( null, false ); + }, + error: ( response ) => + // eslint-disable-next-line no-console + console.error( 'Quarantine action failed.', response ), + } ); + } + /** * Retrieves the columns configuration based on the selector. * @@ -333,13 +430,10 @@ jQuery( document ).ready( function () {
${ row.value }
`; - const actions = [ - ``, - popoverContent, - row.autoload === 'no' + const protectedRow = isProtected( row.name ); + const autoloadBtn = protectedRow + ? '' + : row.autoload === 'no' ? ``, - ``; + + const deleteBtn = protectedRow + ? ` + + ${ aaaOptionOptimizer.i18n.deleteOption } + ` + : ``; + + const exportBtn = ``; + + const actions = [ + ``, + popoverContent, + autoloadBtn, + deleteBtn, + exportBtn, ]; return actions.join( '' ); } + /** + * Checks whether the given option name is protected according to the + * server-localized lookup map and prefix list. + * + * @param {string} optionName - The option name. + * @return {boolean} - Whether the option is protected. + */ + function isProtected( optionName ) { + const map = aaaOptionOptimizer.protectedOptions || {}; + if ( map[ optionName ] ) { + return true; + } + const prefixes = aaaOptionOptimizer.protectedPrefixes || []; + for ( let i = 0; i < prefixes.length; i++ ) { + if ( optionName.indexOf( prefixes[ i ] ) === 0 ) { + return true; + } + } + return false; + } + + /** + * Triggers a browser download of the given JSON payload. + * + * @param {Object} payload - The JSON payload to download. + * @param {string} filename - Suggested filename. + */ + function downloadJson( payload, filename ) { + const blob = new Blob( [ JSON.stringify( payload, null, 2 ) ], { + type: 'application/json', + } ); + const url = URL.createObjectURL( blob ); + const a = document.createElement( 'a' ); + a.href = url; + a.download = filename || 'aaa-option-optimizer-export.json'; + document.body.appendChild( a ); + a.click(); + document.body.removeChild( a ); + URL.revokeObjectURL( url ); + } + /** * Renders the value column for a row. * @@ -410,6 +566,12 @@ jQuery( document ).ready( function () { const table = button.closest( 'table' ).DataTable(); const optionName = button.data( 'option' ); + // Per-row export bypasses the standard AJAX/update pattern. + if ( button.hasClass( 'export-option' ) ) { + exportOptions( [ optionName ] ); + return; + } + const requestData = { option_name: optionName }; let action = ''; let route = ''; @@ -443,6 +605,30 @@ jQuery( document ).ready( function () { } ); } + /** + * Requests an export from the REST API and triggers a browser download. + * + * @param {string[]} optionNames - The option names to export. + */ + function exportOptions( optionNames ) { + jQuery.ajax( { + url: `${ aaaOptionOptimizer.root }aaa-option-optimizer/v1/export`, + method: 'POST', + beforeSend: ( xhr ) => + xhr.setRequestHeader( 'X-WP-Nonce', aaaOptionOptimizer.nonce ), + data: { option_names: optionNames }, + success: ( payload, status, xhr ) => { + const filename = + xhr.getResponseHeader( 'X-AAAOO-Filename' ) || + 'aaa-option-optimizer-export.json'; + downloadJson( payload, filename ); + }, + error: ( response ) => + // eslint-disable-next-line no-console + console.error( 'Failed to export options.', response ), + } ); + } + /** * Updates the row on successful AJAX response. * @@ -485,13 +671,20 @@ jQuery( document ).ready( function () { } } - // AJAX Event Handling (add-autoload, remove-autoload, delete-option). + // AJAX Event Handling (add-autoload, remove-autoload, delete-option, export-option). jQuery( 'table tbody' ).on( 'click', - '.add-autoload, .remove-autoload, .delete-option, .create-option-false', + '.add-autoload, .remove-autoload, .delete-option, .create-option-false, .export-option', handleTableActions ); + // Quarantine actions (restore / permanently delete) live on the quarantine table only. + jQuery( document ).on( + 'click', + '#quarantine_table .restore-option, #quarantine_table .permanently-delete', + handleQuarantineActions + ); + // Select all options. jQuery( '.select-all-checkbox' ).on( 'change', function () { const table = jQuery( this ).closest( 'table' ); @@ -528,6 +721,7 @@ jQuery( document ).ready( function () { ${ selectOptions } + ` ); @@ -573,14 +767,31 @@ jQuery( document ).ready( function () { return; } - // For now we only have delete in bulk action. - const requestData = { option_names: Array.from( selectedOptions ).map( ( option ) => option.getAttribute( 'data-option' ) ), }; + // Bulk export bypasses the row-removal AJAX flow and just downloads. + if ( bulkAction === 'export' ) { + exportOptions( requestData.option_names ); + return; + } + + // Warn before quarantining a large batch — quarantine is recoverable + // but sifting through hundreds of rows to find a culprit is painful. + if ( bulkAction === 'delete' && requestData.option_names.length > 25 ) { + const msg = aaaOptionOptimizer.i18n.confirmBulkQuarantine.replace( + '%d', + requestData.option_names.length + ); + // eslint-disable-next-line no-alert + if ( ! window.confirm( msg ) ) { + return; + } + } + const endpoint = 'delete' === bulkAction ? 'delete-options' @@ -701,4 +912,72 @@ jQuery( document ).ready( function () { migrateChunk(); } ); + + // Import form handler. + jQuery( '#aaa_import_form' ).on( 'submit', function ( e ) { + e.preventDefault(); + + const fileInput = document.getElementById( 'aaa_import_file' ); + const resultBox = jQuery( '#aaa_import_result' ); + const overwrite = jQuery( '#aaa_import_overwrite' ).is( ':checked' ); + + resultBox.empty(); + + if ( ! fileInput.files || ! fileInput.files[ 0 ] ) { + resultBox.html( + `

${ aaaOptionOptimizer.i18n.importSelectFile }

` + ); + return; + } + + const reader = new FileReader(); + reader.onload = function ( ev ) { + let payload; + try { + payload = JSON.parse( ev.target.result ); + } catch ( err ) { + resultBox.html( + `

${ aaaOptionOptimizer.i18n.importInvalidJson }

` + ); + return; + } + + jQuery.ajax( { + url: `${ aaaOptionOptimizer.root }aaa-option-optimizer/v1/import`, + method: 'POST', + beforeSend: ( xhr ) => + xhr.setRequestHeader( + 'X-WP-Nonce', + aaaOptionOptimizer.nonce + ), + contentType: 'application/json', + data: JSON.stringify( { payload, overwrite } ), + success: ( response ) => { + const msg = aaaOptionOptimizer.i18n.importResult + .replace( '%1$d', response.imported ) + .replace( '%2$d', response.skipped ); + let html = `

${ msg }

`; + if ( response.errors && response.errors.length ) { + const rows = response.errors + .map( + ( err ) => + `
  • ${ err.option_name }: ${ err.reason }
  • ` + ) + .join( '' ); + html += ``; + } + resultBox.html( html ); + }, + error: ( response ) => { + const reason = + ( response.responseJSON && response.responseJSON.message ) || + 'Import failed.'; + resultBox.html( + `

    ${ reason }

    ` + ); + }, + } ); + }; + reader.readAsText( fileInput.files[ 0 ] ); + } ); } ); diff --git a/src/class-admin-page.php b/src/class-admin-page.php index e45a243..49cae4c 100644 --- a/src/class-admin-page.php +++ b/src/class-admin-page.php @@ -73,6 +73,24 @@ public function sanitize_settings( $input ): array { } $existing['settings']['option_tracking'] = $option_tracking; + // Sanitize the quarantine retention days (1..30, default 7). + $retention = Quarantine::DEFAULT_RETENTION_DAYS; + if ( isset( $input['settings']['quarantine_retention_days'] ) ) { + $retention = (int) $input['settings']['quarantine_retention_days']; + $retention = \max( 1, \min( 30, $retention ) ); + } + $existing['settings']['quarantine_retention_days'] = $retention; + + // Sanitize the quarantine expiry action ('keep' or 'delete', default 'keep'). + $expiry_action = Quarantine::DEFAULT_EXPIRY_ACTION; + if ( isset( $input['settings']['quarantine_expiry_action'] ) ) { + $candidate = \sanitize_text_field( $input['settings']['quarantine_expiry_action'] ); + if ( \in_array( $candidate, [ 'keep', 'delete' ], true ) ) { + $expiry_action = $candidate; + } + } + $existing['settings']['quarantine_expiry_action'] = $expiry_action; + // Return the full option structure with merged settings. return $existing; } @@ -84,7 +102,9 @@ public function sanitize_settings( $input ): array { */ public static function get_settings(): array { $defaults = [ - 'option_tracking' => 'pre_option', + 'option_tracking' => 'pre_option', + 'quarantine_retention_days' => Quarantine::DEFAULT_RETENTION_DAYS, + 'quarantine_expiry_action' => Quarantine::DEFAULT_EXPIRY_ACTION, ]; $option_optimizer = \get_option( self::OPTION_NAME, [] ); @@ -189,10 +209,18 @@ public function enqueue_scripts( $hook ) { '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(), + 'protectedOptions' => Protected_Options::get_protected_map(), + 'protectedPrefixes' => [ + 'option_optimizer', + 'aaa_option_optimizer', + '_aaaoo_q__', + '_transient_', + '_site_transient_', + ], + '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' ), @@ -206,6 +234,26 @@ public function enqueue_scripts( $hook ) { 'noBulkActionSelected' => \esc_html__( 'No action selected.', 'aaa-option-optimizer' ), 'delete' => \esc_html__( 'Delete', 'aaa-option-optimizer' ), 'apply' => \esc_html__( 'Apply', 'aaa-option-optimizer' ), + 'export' => \esc_html__( 'Export', 'aaa-option-optimizer' ), + 'exportSelected' => \esc_html__( 'Export selected', 'aaa-option-optimizer' ), + 'protectedTooltip' => \esc_html__( 'Protected — cannot delete', 'aaa-option-optimizer' ), + 'restore' => \esc_html__( 'Restore', 'aaa-option-optimizer' ), + 'permanentlyDelete' => \esc_html__( 'Permanently delete', 'aaa-option-optimizer' ), + 'confirmPermanentDelete' => \esc_html__( 'Permanently delete this option? This cannot be undone.', 'aaa-option-optimizer' ), + /* translators: %d: number of selected options */ + 'confirmBulkQuarantine' => \esc_html__( 'You are about to quarantine %d options. They can be restored from the Quarantine tab. Continue?', 'aaa-option-optimizer' ), + 'importSelectFile' => \esc_html__( 'Select JSON file', 'aaa-option-optimizer' ), + 'importOverwriteLabel' => \esc_html__( 'Overwrite existing options', 'aaa-option-optimizer' ), + 'importButton' => \esc_html__( 'Import', 'aaa-option-optimizer' ), + /* translators: %1$d imported, %2$d skipped */ + 'importResult' => \esc_html__( 'Imported %1$d options, skipped %2$d.', 'aaa-option-optimizer' ), + 'importInvalidJson' => \esc_html__( 'Selected file is not valid JSON.', 'aaa-option-optimizer' ), + 'quarantineEmpty' => \esc_html__( 'No options are currently in quarantine.', 'aaa-option-optimizer' ), + 'quarantineColOption' => \esc_html__( 'Option', 'aaa-option-optimizer' ), + 'quarantineColSize' => \esc_html__( 'Size (KB)', 'aaa-option-optimizer' ), + 'quarantineColAutoload' => \esc_html__( 'Autoload', 'aaa-option-optimizer' ), + 'quarantineColExpires' => \esc_html__( 'Expires', 'aaa-option-optimizer' ), + 'quarantineColQueuedAt' => \esc_html__( 'Quarantined at', 'aaa-option-optimizer' ), 'search' => \esc_html__( 'Search:', 'aaa-option-optimizer' ), 'migrating' => \esc_html__( 'Migrating...', 'aaa-option-optimizer' ), @@ -458,7 +506,71 @@ public function render_admin_page_ajax() { - + +
    +

    +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + +
    +

    +

    +
    +

    + + +

    +

    + +

    +

    + +

    +
    +
    +
    + + +
    render_settings_tab( $option_optimizer, $result ); ?>
    @@ -533,6 +645,36 @@ private function render_settings_tab( $option_optimizer, $result ): void { + +

    +

    + + + + + + + + + + + prefix . self::TABLE_NAME; } + /** + * Get the full quarantine table name with prefix. + * + * @return string + */ + public static function get_quarantine_table_name() { + global $wpdb; + return $wpdb->prefix . self::QUARANTINE_TABLE_NAME; + } + /** * Create the custom table. * @@ -278,4 +295,180 @@ public static function clear_tracked_options() { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe (from constant). $wpdb->query( "TRUNCATE TABLE {$table_name}" ); } + + /** + * Create the quarantine table. + * + * @return void + */ + public static function create_quarantine_table() { + global $wpdb; + + $table_name = self::get_quarantine_table_name(); + $charset_collate = $wpdb->get_charset_collate(); + + $sql = "CREATE TABLE {$table_name} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + option_name VARCHAR(191) NOT NULL, + option_value LONGTEXT NOT NULL, + autoload VARCHAR(20) NOT NULL DEFAULT 'no', + quarantined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + expiry_action VARCHAR(10) NOT NULL DEFAULT 'keep', + PRIMARY KEY (id), + UNIQUE KEY option_name (option_name) + ) {$charset_collate};"; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + \dbDelta( $sql ); + } + + /** + * Drop the quarantine table. + * + * @return void + */ + public static function drop_quarantine_table() { + global $wpdb; + + $table_name = self::get_quarantine_table_name(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.SchemaChange -- Table name is safe (from constant). + $wpdb->query( "DROP TABLE IF EXISTS {$table_name}" ); + } + + /** + * Check if the quarantine table exists. + * + * @return bool + */ + public static function quarantine_table_exists() { + global $wpdb; + + $table_name = self::get_quarantine_table_name(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + return $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) ) === $table_name; + } + + /** + * Insert a quarantine row. + * + * @param string $option_name Option name. + * @param string $option_value Serialized option value as stored in wp_options. + * @param string $autoload Autoload value as stored in wp_options. + * @param string $expires_at MySQL DATETIME for expiry. + * @param string $expiry_action 'keep' or 'delete'. + * + * @return bool True on success. + */ + public static function insert_quarantine_row( $option_name, $option_value, $autoload, $expires_at, $expiry_action = 'keep' ) { + global $wpdb; + + $result = $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + self::get_quarantine_table_name(), + [ + 'option_name' => $option_name, + 'option_value' => $option_value, + 'autoload' => $autoload, + 'quarantined_at' => \current_time( 'mysql' ), + 'expires_at' => $expires_at, + 'expiry_action' => $expiry_action, + ], + [ '%s', '%s', '%s', '%s', '%s', '%s' ] + ); + + return false !== $result; + } + + /** + * Get a quarantine row by option name. + * + * @param string $option_name Option name. + * + * @return array|null Row data, or null if not found. + */ + public static function get_quarantine_row( $option_name ) { + global $wpdb; + + $table_name = self::get_quarantine_table_name(); + + $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( "SELECT * FROM {$table_name} WHERE option_name = %s", $option_name ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe (from constant). + ARRAY_A + ); + + return null === $row ? null : $row; + } + + /** + * Delete a quarantine row by option name. + * + * @param string $option_name Option name. + * + * @return bool True on success. + */ + public static function delete_quarantine_row( $option_name ) { + global $wpdb; + + $result = $wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + self::get_quarantine_table_name(), + [ 'option_name' => $option_name ], + [ '%s' ] + ); + + return false !== $result && $result > 0; + } + + /** + * Get all quarantine rows. + * + * @return array> Rows. + */ + public static function get_all_quarantine_rows() { + global $wpdb; + + $table_name = self::get_quarantine_table_name(); + + $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + "SELECT * FROM {$table_name} ORDER BY quarantined_at DESC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe (from constant). + ARRAY_A + ); + + return empty( $rows ) ? [] : $rows; + } + + /** + * Count quarantine rows. + * + * @return int + */ + public static function count_quarantine_rows() { + global $wpdb; + + $table_name = self::get_quarantine_table_name(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe (from constant). + return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table_name}" ); + } + + /** + * Get expired quarantine rows whose expiry_action is 'delete'. + * + * @return array> Rows. + */ + public static function get_expired_quarantine_rows() { + global $wpdb; + + $table_name = self::get_quarantine_table_name(); + + $sql = "SELECT * FROM {$table_name} WHERE expires_at <= %s AND expiry_action = %s"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe (from constant). + + $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( $sql, \current_time( 'mysql' ), 'delete' ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + ARRAY_A + ); + + return empty( $rows ) ? [] : $rows; + } } diff --git a/src/class-exporter.php b/src/class-exporter.php new file mode 100644 index 0000000..1ae82e7 --- /dev/null +++ b/src/class-exporter.php @@ -0,0 +1,90 @@ + + */ + public function export( array $option_names ) { + global $wpdb; + + $options = []; + + if ( ! empty( $option_names ) ) { + $option_names = \array_values( \array_unique( \array_filter( \array_map( 'strval', $option_names ) ) ) ); + + if ( ! empty( $option_names ) ) { + $placeholders = \implode( ',', \array_fill( 0, \count( $option_names ), '%s' ) ); + + $sql = "SELECT option_name, option_value, autoload FROM {$wpdb->options} WHERE option_name IN ( {$placeholders} )"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Placeholders are built above; $wpdb->options is safe. + + $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( $sql, ...$option_names ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + ARRAY_A + ); + + if ( $rows ) { + // Index by name so we can return in requested order. + $by_name = []; + foreach ( $rows as $row ) { + $by_name[ (string) $row['option_name'] ] = $row; + } + + foreach ( $option_names as $name ) { + if ( isset( $by_name[ $name ] ) ) { + $row = $by_name[ $name ]; + $options[] = [ + 'option_name' => (string) $row['option_name'], + 'option_value' => (string) $row['option_value'], + 'autoload' => (string) $row['autoload'], + ]; + } + } + } + } + } + + return [ + 'version' => self::VERSION, + 'exported_at' => \gmdate( 'c' ), + 'site_url' => \home_url(), + 'wp_version' => \get_bloginfo( 'version' ), + 'plugin' => 'aaa-option-optimizer', + 'options' => $options, + ]; + } + + /** + * Build a suggested filename for the export. + * + * @return string + */ + public function suggested_filename() { + $host = \wp_parse_url( \home_url(), PHP_URL_HOST ); + $host = \is_string( $host ) ? \preg_replace( '/[^a-zA-Z0-9.\-]/', '', $host ) : 'site'; + return 'aaa-option-optimizer-' . $host . '-' . \gmdate( 'Ymd-His' ) . '.json'; + } +} diff --git a/src/class-importer.php b/src/class-importer.php new file mode 100644 index 0000000..5efa917 --- /dev/null +++ b/src/class-importer.php @@ -0,0 +1,103 @@ + $payload Parsed JSON payload from Exporter. + * @param bool $overwrite If true, existing options are overwritten. + * + * @return array{imported:int, skipped:int, errors:array}|\WP_Error + */ + public function import( array $payload, $overwrite = false ) { + if ( empty( $payload['version'] ) || empty( $payload['options'] ) || ! \is_array( $payload['options'] ) ) { + return new \WP_Error( + 'invalid_payload', + \__( 'Invalid import payload: missing version or options.', 'aaa-option-optimizer' ), + [ 'status' => 400 ] + ); + } + + // Forward-compat: accept any 1.x version. Bump self::handle on breaking changes. + if ( 0 !== \strpos( (string) $payload['version'], '1.' ) ) { + return new \WP_Error( + 'unsupported_version', + \sprintf( + /* translators: %s: version string */ + \__( 'Unsupported export version: %s.', 'aaa-option-optimizer' ), + (string) $payload['version'] + ), + [ 'status' => 400 ] + ); + } + + $imported = 0; + $skipped = 0; + $errors = []; + + foreach ( $payload['options'] as $entry ) { + if ( ! \is_array( $entry ) || empty( $entry['option_name'] ) ) { + ++$skipped; + continue; + } + + $name = (string) $entry['option_name']; + + if ( Protected_Options::is_protected( $name ) ) { + ++$skipped; + $errors[] = [ + 'option_name' => $name, + 'reason' => 'protected', + ]; + continue; + } + + $raw_value = isset( $entry['option_value'] ) ? (string) $entry['option_value'] : ''; + $value = \maybe_unserialize( $raw_value ); + $autoload = isset( $entry['autoload'] ) ? (string) $entry['autoload'] : 'no'; + $bool_auto = \in_array( $autoload, \wp_autoload_values_to_autoload(), true ); + + $exists = false !== \get_option( $name, false ); + + if ( $exists ) { + if ( ! $overwrite ) { + ++$skipped; + $errors[] = [ + 'option_name' => $name, + 'reason' => 'exists', + ]; + continue; + } + + \delete_option( $name ); + } + + if ( \add_option( $name, $value, '', $bool_auto ) ) { + ++$imported; + } else { + ++$skipped; + $errors[] = [ + 'option_name' => $name, + 'reason' => 'add_option_failed', + ]; + } + } + + return [ + 'imported' => $imported, + 'skipped' => $skipped, + 'errors' => $errors, + ]; + } +} diff --git a/src/class-plugin.php b/src/class-plugin.php index 7213d73..6525140 100644 --- a/src/class-plugin.php +++ b/src/class-plugin.php @@ -83,6 +83,10 @@ public function register_hooks() { $rest = new REST(); $rest->register_hooks(); + // Register the quarantine cleanup cron callback. + $quarantine = new Quarantine(); + $quarantine->register_hooks(); + if ( \is_admin() ) { // Register the admin page. $admin_page = new Admin_Page(); diff --git a/src/class-protected-options.php b/src/class-protected-options.php new file mode 100644 index 0000000..cc2be27 --- /dev/null +++ b/src/class-protected-options.php @@ -0,0 +1,262 @@ +|null + */ + private static $cache = null; + + /** + * Cached protected prefixes. + * + * @var string[]|null + */ + private static $prefix_cache = null; + + /** + * Get the canonical hardcoded list of WordPress core options that must be protected. + * + * Curated from wp-includes/option.php, wp-includes/default-constants.php, and the + * orpharion plugin's core options list. Order is not significant. + * + * @return string[] + */ + private static function core_options() { + return [ + // Site identity & URLs. + 'siteurl', + 'home', + 'blogname', + 'blogdescription', + 'blog_charset', + 'admin_email', + 'admin_email_lifespan', + 'new_admin_email', + 'WPLANG', + 'site_icon', + + // Active plugins/theme. + 'active_plugins', + 'template', + 'stylesheet', + 'current_theme', + 'template_root', + 'stylesheet_root', + 'recently_activated', + 'uninstall_plugins', + + // Permalinks & rewrite. + 'permalink_structure', + 'category_base', + 'tag_base', + 'rewrite_rules', + 'page_on_front', + 'page_for_posts', + 'show_on_front', + + // Users & roles. + 'users_can_register', + 'default_role', + 'wp_user_roles', + + // Date/time. + 'date_format', + 'time_format', + 'gmt_offset', + 'timezone_string', + 'start_of_week', + 'links_updated_date_format', + + // Reading. + 'posts_per_page', + 'posts_per_rss', + 'rss_use_excerpt', + 'blog_public', + + // Discussion. + 'default_pingback_flag', + 'default_ping_status', + 'default_comment_status', + 'comments_notify', + 'moderation_notify', + 'comment_moderation', + 'require_name_email', + 'comment_registration', + 'close_comments_for_old_posts', + 'close_comments_days_old', + 'thread_comments', + 'thread_comments_depth', + 'page_comments', + 'comments_per_page', + 'default_comments_page', + 'comment_order', + 'comment_max_links', + 'moderation_keys', + 'disallowed_keys', + 'avatar_default', + 'avatar_rating', + 'show_avatars', + 'comment_whitelist', + 'comment_previously_approved', + + // Media. + 'thumbnail_size_w', + 'thumbnail_size_h', + 'thumbnail_crop', + 'medium_size_w', + 'medium_size_h', + 'medium_large_size_w', + 'medium_large_size_h', + 'large_size_w', + 'large_size_h', + 'image_default_link_type', + 'image_default_size', + 'image_default_align', + 'uploads_use_yearmonth_folders', + 'upload_path', + 'upload_url_path', + + // Mail. + 'mailserver_url', + 'mailserver_port', + 'mailserver_login', + 'mailserver_pass', + 'default_email_category', + + // DB & infra. + 'db_version', + 'db_upgraded', + 'initial_db_version', + 'auto_core_update_notified', + 'auto_update_core_dev', + 'auto_update_core_minor', + 'auto_update_core_major', + 'cron', + 'fresh_site', + + // Theme/customizer. + 'theme_mods', + 'theme_switched', + 'widget_block', + + // Privacy. + 'wp_page_for_privacy_policy', + + // Misc core. + 'use_smilies', + 'use_balanceTags', + 'hack_file', + 'html_type', + 'category_children', + 'finished_splitting_shared_terms', + 'finished_updating_comment_type', + 'default_category', + 'default_post_format', + 'sidebars_widgets', + ]; + } + + /** + * Get the protected option prefixes. + * + * Options whose names start with any of these prefixes are protected: + * + * - 'option_optimizer' — this plugin's own settings + * - 'aaa_option_optimizer' — this plugin's transients/scheduled events + * - '_aaaoo_q__' — quarantine prefix (defense in depth, even though + * the current design stores those in a custom table) + * - '_transient_' — transient cache options + * - '_site_transient_' — site-wide transient cache options + * + * @return string[] + */ + private static function core_prefixes() { + if ( null === self::$prefix_cache ) { + self::$prefix_cache = [ + 'option_optimizer', + 'aaa_option_optimizer', + '_aaaoo_q__', + '_transient_', + '_site_transient_', + ]; + } + return self::$prefix_cache; + } + + /** + * Get the full protected option lookup map. + * + * @return array + */ + public static function get_protected_map() { + if ( null === self::$cache ) { + $options = self::core_options(); + + /** + * Filter the list of protected option names. + * + * Use this filter to add must-not-delete options from MU plugins or + * other code that owns critical option keys. Prefix-based protection + * is handled separately via {@see is_protected()}. + * + * @param string[] $options Protected option names. + */ + $options = \apply_filters( 'aaa_option_optimizer_protected_options', $options ); + + self::$cache = \array_fill_keys( \array_map( 'strval', $options ), true ); + } + + return self::$cache; + } + + /** + * Check whether the given option name is protected. + * + * @param string $option_name Option name. + * + * @return bool + */ + public static function is_protected( $option_name ) { + if ( '' === $option_name ) { + return false; + } + + if ( isset( self::get_protected_map()[ $option_name ] ) ) { + return true; + } + + foreach ( self::core_prefixes() as $prefix ) { + if ( 0 === \strpos( $option_name, $prefix ) ) { + return true; + } + } + + return false; + } + + /** + * Reset internal caches. + * + * Useful when the filter callbacks change at runtime (e.g. tests). + * + * @return void + */ + public static function reset_cache() { + self::$cache = null; + self::$prefix_cache = null; + } +} diff --git a/src/class-quarantine.php b/src/class-quarantine.php new file mode 100644 index 0000000..b92ad6e --- /dev/null +++ b/src/class-quarantine.php @@ -0,0 +1,270 @@ +cleanup_expired(); + } + ); + } + + /** + * Quarantine an option: copy its row into the quarantine table and delete from wp_options. + * + * Protected options are refused. Cap-exceeded calls return an error. + * + * @param string $option_name Option name. + * + * @return true|\WP_Error + */ + public function quarantine( $option_name ) { + if ( Protected_Options::is_protected( $option_name ) ) { + return new \WP_Error( + 'option_protected', + \sprintf( + /* translators: %s: option name */ + \__( 'Option "%s" is protected and cannot be quarantined.', 'aaa-option-optimizer' ), + $option_name + ), + [ 'status' => 403 ] + ); + } + + global $wpdb; + + // Read the live row directly so we capture the raw stored value and autoload flag. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $row = $wpdb->get_row( + $wpdb->prepare( + "SELECT option_value, autoload FROM {$wpdb->options} WHERE option_name = %s", + $option_name + ), + ARRAY_A + ); + + if ( ! $row ) { + return new \WP_Error( + 'option_not_found', + \__( 'Option does not exist.', 'aaa-option-optimizer' ), + [ 'status' => 404 ] + ); + } + + // If already quarantined (somehow), refuse — the unique key would error anyway. + if ( null !== Database::get_quarantine_row( $option_name ) ) { + return new \WP_Error( + 'already_quarantined', + \__( 'Option is already in quarantine.', 'aaa-option-optimizer' ), + [ 'status' => 409 ] + ); + } + + $settings = self::get_settings(); + $retention = (int) $settings['retention_days']; + $expiry_action = (string) $settings['expiry_action']; + $expires_at = \gmdate( 'Y-m-d H:i:s', \strtotime( "+{$retention} days", \time() ) ); + + $inserted = Database::insert_quarantine_row( + $option_name, + (string) $row['option_value'], + (string) $row['autoload'], + $expires_at, + $expiry_action + ); + + if ( ! $inserted ) { + return new \WP_Error( + 'quarantine_failed', + \__( 'Failed to write to quarantine table.', 'aaa-option-optimizer' ), + [ 'status' => 500 ] + ); + } + + if ( ! \delete_option( $option_name ) ) { + // Roll back the quarantine row so state stays consistent. + Database::delete_quarantine_row( $option_name ); + return new \WP_Error( + 'delete_failed', + \__( 'Failed to remove option from wp_options.', 'aaa-option-optimizer' ), + [ 'status' => 500 ] + ); + } + + return true; + } + + /** + * Restore a quarantined option back into wp_options. + * + * @param string $option_name Option name. + * + * @return true|\WP_Error + */ + public function restore( $option_name ) { + $row = Database::get_quarantine_row( $option_name ); + if ( null === $row ) { + return new \WP_Error( + 'not_quarantined', + \__( 'Option is not in quarantine.', 'aaa-option-optimizer' ), + [ 'status' => 404 ] + ); + } + + $value = \maybe_unserialize( (string) $row['option_value'] ); + $autoload = self::normalize_autoload( (string) $row['autoload'] ); + + // If WordPress has recreated the option in the meantime, overwrite it. + if ( false === \get_option( $option_name, false ) ) { + $ok = \add_option( $option_name, $value, '', $autoload ); + } else { + \delete_option( $option_name ); + $ok = \add_option( $option_name, $value, '', $autoload ); + } + + if ( ! $ok ) { + return new \WP_Error( + 'restore_failed', + \__( 'Failed to restore option.', 'aaa-option-optimizer' ), + [ 'status' => 500 ] + ); + } + + Database::delete_quarantine_row( $option_name ); + + return true; + } + + /** + * Permanently delete a quarantined option. + * + * @param string $option_name Option name. + * + * @return true|\WP_Error + */ + public function permanently_delete( $option_name ) { + $row = Database::get_quarantine_row( $option_name ); + if ( null === $row ) { + return new \WP_Error( + 'not_quarantined', + \__( 'Option is not in quarantine.', 'aaa-option-optimizer' ), + [ 'status' => 404 ] + ); + } + + Database::delete_quarantine_row( $option_name ); + return true; + } + + /** + * List all quarantined options with display-ready fields. + * + * @return array> + */ + public function list_all() { + $rows = Database::get_all_quarantine_rows(); + $output = []; + + foreach ( $rows as $row ) { + $raw_value = (string) $row['option_value']; + $output[] = [ + 'name' => (string) $row['option_name'], + 'value' => \htmlentities( $raw_value, ENT_QUOTES | ENT_SUBSTITUTE ), + 'size' => \round( \strlen( $raw_value ) / 1024, 2 ), + 'autoload' => (string) $row['autoload'], + 'quarantined_at' => (string) $row['quarantined_at'], + 'expires_at' => (string) $row['expires_at'], + 'expiry_action' => (string) $row['expiry_action'], + ]; + } + + return $output; + } + + /** + * Cleanup expired quarantine rows. + * + * Only rows whose `expiry_action` is `delete` are removed; rows set to `keep` + * stay in quarantine until the user acts on them. + * + * @return int Number of rows removed. + */ + public function cleanup_expired() { + $expired = Database::get_expired_quarantine_rows(); + $count = 0; + + foreach ( $expired as $row ) { + if ( Database::delete_quarantine_row( (string) $row['option_name'] ) ) { + ++$count; + } + } + + return $count; + } + + /** + * Get plugin settings relevant to quarantine, with defaults. + * + * @return array{retention_days:int, expiry_action:string} + */ + public static function get_settings() { + $option = \get_option( Admin_Page::OPTION_NAME, [] ); + $settings = isset( $option['settings'] ) && \is_array( $option['settings'] ) ? $option['settings'] : []; + + $retention = isset( $settings['quarantine_retention_days'] ) ? (int) $settings['quarantine_retention_days'] : self::DEFAULT_RETENTION_DAYS; + $retention = \max( 1, \min( 30, $retention ) ); + + $action = isset( $settings['quarantine_expiry_action'] ) ? (string) $settings['quarantine_expiry_action'] : self::DEFAULT_EXPIRY_ACTION; + if ( ! \in_array( $action, [ 'keep', 'delete' ], true ) ) { + $action = self::DEFAULT_EXPIRY_ACTION; + } + + return [ + 'retention_days' => $retention, + 'expiry_action' => $action, + ]; + } + + /** + * Normalize an autoload value from wp_options into the bool that add_option() expects. + * + * @param string $autoload Raw autoload column value. + * + * @return bool + */ + private static function normalize_autoload( $autoload ) { + return \in_array( $autoload, \wp_autoload_values_to_autoload(), true ); + } +} diff --git a/src/class-rest.php b/src/class-rest.php index 472eea4..637e79f 100644 --- a/src/class-rest.php +++ b/src/class-rest.php @@ -193,6 +193,78 @@ public function register_rest_routes() { }, ] ); + + \register_rest_route( + 'aaa-option-optimizer/v1', + '/quarantine', + [ + 'methods' => 'GET', + 'callback' => [ $this, 'list_quarantine' ], + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + ] + ); + + \register_rest_route( + 'aaa-option-optimizer/v1', + '/quarantine/restore', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'quarantine_restore' ], + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + 'args' => [ + 'option_name' => [ + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + ], + ], + ] + ); + + \register_rest_route( + 'aaa-option-optimizer/v1', + '/quarantine/delete', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'quarantine_permanently_delete' ], + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + 'args' => [ + 'option_name' => [ + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + ], + ], + ] + ); + + \register_rest_route( + 'aaa-option-optimizer/v1', + '/export', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'export_options' ], + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + ] + ); + + \register_rest_route( + 'aaa-option-optimizer/v1', + '/import', + [ + 'methods' => 'POST', + 'callback' => [ $this, 'import_options' ], + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + ] + ); } /** @@ -632,8 +704,13 @@ function ( $row ) use ( $search ) { * @return \WP_Error|\WP_REST_Response */ public function update_option_autoload( $request ) { - $option_name = $request['option_name']; - $autoload = $request['autoload']; + $option_name = $request['option_name']; + $autoload = $request['autoload']; + + if ( Protected_Options::is_protected( $option_name ) ) { + return new \WP_Error( 'option_protected', 'Option is protected', [ 'status' => 403 ] ); + } + $option_value = get_option( $option_name ); if ( ! in_array( $autoload, [ 'yes', 'on', 'no', 'off','auto', 'auto-on', 'auto-off' ], true ) ) { @@ -661,20 +738,56 @@ public function update_option_autoload( $request ) { /** * Delete an option. * + * By default the option is moved to quarantine. Pass `force=true` to skip + * quarantine and call delete_option() directly. Protected options are always + * refused. + * * @param \WP_REST_Request $request The REST request object. * * @return \WP_REST_Response|\WP_Error */ public function delete_option( $request ) { $option_name = $request['option_name']; - if ( delete_option( $option_name ) ) { - return new \WP_REST_Response( [ 'success' => true ], 200 ); + + if ( Protected_Options::is_protected( $option_name ) ) { + return new \WP_Error( 'option_protected', 'Option is protected and cannot be deleted', [ 'status' => 403 ] ); } - return new \WP_Error( 'option_not_found_or_deleted', 'Option does not exist or could not be deleted', [ 'status' => 404 ] ); + + $force = \filter_var( $request['force'] ?? false, FILTER_VALIDATE_BOOLEAN ); + + if ( $force ) { + if ( delete_option( $option_name ) ) { + return new \WP_REST_Response( + [ + 'success' => true, + 'forced' => true, + ], + 200 + ); + } + return new \WP_Error( 'option_not_found_or_deleted', 'Option does not exist or could not be deleted', [ 'status' => 404 ] ); + } + + $quarantine = new Quarantine(); + $result = $quarantine->quarantine( $option_name ); + if ( \is_wp_error( $result ) ) { + return $result; + } + + return new \WP_REST_Response( + [ + 'success' => true, + 'quarantined' => true, + ], + 200 + ); } /** - * Delete multiple options. + * Delete (quarantine) multiple options. + * + * Protected options are skipped and reported in the response. Pass + * `force=true` to hard-delete instead of quarantining. * * @param \WP_REST_Request $request The REST request object. * @@ -686,10 +799,58 @@ public function delete_options( $request ) { } $option_names = $request['option_names']; + $force = \filter_var( $request['force'] ?? false, FILTER_VALIDATE_BOOLEAN ); + + $processed = []; + $skipped = []; + $errors = []; + + $quarantine = $force ? null : new Quarantine(); + foreach ( $option_names as $option_name ) { - delete_option( $option_name ); + $option_name = \sanitize_text_field( (string) $option_name ); + + if ( Protected_Options::is_protected( $option_name ) ) { + $skipped[] = [ + 'option_name' => $option_name, + 'reason' => 'protected', + ]; + continue; + } + + if ( $force ) { + if ( delete_option( $option_name ) ) { + $processed[] = $option_name; + } else { + $errors[] = [ + 'option_name' => $option_name, + 'reason' => 'delete_failed', + ]; + } + continue; + } + + $result = $quarantine->quarantine( $option_name ); + if ( \is_wp_error( $result ) ) { + $errors[] = [ + 'option_name' => $option_name, + 'reason' => $result->get_error_code(), + ]; + } else { + $processed[] = $option_name; + } } - return new \WP_REST_Response( [ 'success' => true ], 200 ); + + return new \WP_REST_Response( + [ + 'success' => true, + 'forced' => $force, + 'processed' => $processed, + 'skipped' => $skipped, + 'errors' => $errors, + ], + 200 + ); } /** @@ -747,6 +908,116 @@ public function create_option_false( $request ) { return new \WP_Error( 'option_not_created', 'Option could not be created', [ 'status' => 400 ] ); } + /** + * List quarantined options. + * + * @return \WP_REST_Response + */ + public function list_quarantine() { + $quarantine = new Quarantine(); + return new \WP_REST_Response( [ 'data' => $quarantine->list_all() ], 200 ); + } + + /** + * Restore an option from quarantine. + * + * @param \WP_REST_Request $request The REST request object. + * + * @return \WP_REST_Response|\WP_Error + */ + public function quarantine_restore( $request ) { + if ( ! isset( $_SERVER['HTTP_X_WP_NONCE'] ) || ! \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_X_WP_NONCE'] ) ), 'wp_rest' ) ) { + return new \WP_REST_Response( [ 'error' => 'Invalid nonce' ], 403 ); + } + + $quarantine = new Quarantine(); + $result = $quarantine->restore( (string) $request['option_name'] ); + if ( \is_wp_error( $result ) ) { + return $result; + } + return new \WP_REST_Response( [ 'success' => true ], 200 ); + } + + /** + * Permanently delete an option from quarantine. + * + * @param \WP_REST_Request $request The REST request object. + * + * @return \WP_REST_Response|\WP_Error + */ + public function quarantine_permanently_delete( $request ) { + if ( ! isset( $_SERVER['HTTP_X_WP_NONCE'] ) || ! \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_X_WP_NONCE'] ) ), 'wp_rest' ) ) { + return new \WP_REST_Response( [ 'error' => 'Invalid nonce' ], 403 ); + } + + $quarantine = new Quarantine(); + $result = $quarantine->permanently_delete( (string) $request['option_name'] ); + if ( \is_wp_error( $result ) ) { + return $result; + } + return new \WP_REST_Response( [ 'success' => true ], 200 ); + } + + /** + * Export options to a JSON payload. + * + * Responds with the JSON payload directly. The client is responsible for + * triggering the download (via a Blob URL); we just set the appropriate + * filename and content type via response headers. + * + * @param \WP_REST_Request $request The REST request object. + * + * @return \WP_REST_Response|\WP_Error + */ + public function export_options( $request ) { + if ( ! isset( $_SERVER['HTTP_X_WP_NONCE'] ) || ! \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_X_WP_NONCE'] ) ), 'wp_rest' ) ) { + return new \WP_REST_Response( [ 'error' => 'Invalid nonce' ], 403 ); + } + + $raw_names = $request['option_names'] ?? []; + if ( ! \is_array( $raw_names ) ) { + return new \WP_Error( 'invalid_args', 'option_names must be an array', [ 'status' => 400 ] ); + } + + $option_names = \array_map( 'sanitize_text_field', $raw_names ); + + $exporter = new Exporter(); + $payload = $exporter->export( $option_names ); + + $response = new \WP_REST_Response( $payload, 200 ); + $response->header( 'X-AAAOO-Filename', $exporter->suggested_filename() ); + + return $response; + } + + /** + * Import options from a JSON payload. + * + * @param \WP_REST_Request $request The REST request object. + * + * @return \WP_REST_Response|\WP_Error + */ + public function import_options( $request ) { + if ( ! isset( $_SERVER['HTTP_X_WP_NONCE'] ) || ! \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_X_WP_NONCE'] ) ), 'wp_rest' ) ) { + return new \WP_REST_Response( [ 'error' => 'Invalid nonce' ], 403 ); + } + + $payload = $request['payload'] ?? null; + if ( ! \is_array( $payload ) ) { + return new \WP_Error( 'invalid_args', 'payload must be an object/array', [ 'status' => 400 ] ); + } + + $overwrite = \filter_var( $request['overwrite'] ?? false, FILTER_VALIDATE_BOOLEAN ); + + $importer = new Importer(); + $result = $importer->import( $payload, $overwrite ); + if ( \is_wp_error( $result ) ) { + return $result; + } + + return new \WP_REST_Response( $result, 200 ); + } + /** * Sort response data array by given column and direction. *