get_col( $wpdb->prepare( 'SELECT option_name FROM %i WHERE option_name LIKE %s', $wpdb->options, $wpdb->esc_like( $prefix . 'shiftora_' ) . '%' ) ); $deleted = 0; foreach ( (array) $names as $name ) { if ( delete_transient( substr( (string) $name, strlen( $prefix ) ) ) ) { $deleted++; } } return $deleted; } /** * Marks every migration that still holds a slot as cancelled. * * @param string $note Stored as the migration's error message. * * @return int Migrations cancelled. */ private function cancel_live_migrations( string $note ): int { global $wpdb; $table = $wpdb->prefix . 'shiftora_migrations'; $statuses = [ 'running', 'pending', 'paused' ]; $select = array_merge( [ $table ], $statuses ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT id FROM %i WHERE status IN (' . implode( ', ', array_fill( 0, count( $statuses ), '%s' ) ) . ')', $select ) ); if ( empty( $ids ) ) { return 0; } $values = array_merge( [ $table, $note, current_time( 'mysql', true ) ], $ids ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- custom table, fresh read required $wpdb->query( $wpdb->prepare( "UPDATE %i SET status = 'cancelled', error_message = %s, updated_at = %s WHERE id IN (" . implode( ', ', array_fill( 0, count( $ids ), '%d' ) ) . ')', $values ) ); return count( $ids ); } /** * Skips tables this install never created. * * @param string[] $results Appended to as tables are truncated. */ private function truncate_owned_tables( array &$results ): void { global $wpdb; foreach ( $this->resettable_tables() as $table ) { $table_name = $wpdb->prefix . $table; if ( TableDdl::exists( $wpdb, $table_name ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery -- custom table, emptied on a full reset $wpdb->query( $wpdb->prepare( 'TRUNCATE TABLE %i', $table_name ) ); $results[] = $table; } } } /** * Options a full reset keeps. The schema markers describe tables that are truncated, * not dropped; clearing them would replay the legacy column migrations against an * emptied database. */ public const PRESERVED_OPTIONS = [ 'shiftora_schema_rev', 'shiftora_db_version', ]; /** * Prefix-driven rather than a name list, which would drift from the code that writes options. * * @param string[] $option_names Every option name on the site. * * @return string[] */ public function resettable_options( array $option_names ): array { return array_values( array_filter( $option_names, static fn( string $name ): bool => str_starts_with( $name, 'shiftora_' ) && ! in_array( $name, self::PRESERVED_OPTIONS, true ) ) ); } /** @return string[] Names of the options removed. */ private function sweep_plugin_options(): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the live option rows, fresh read required $found = (array) $wpdb->get_col( $wpdb->prepare( 'SELECT option_name FROM %i WHERE option_name LIKE %s', $wpdb->options, $wpdb->esc_like( 'shiftora_' ) . '%' ) ); $deleted = []; foreach ( $this->resettable_options( $found ) as $option ) { if ( delete_option( $option ) ) { $deleted[] = $option; } } return $deleted; } public function register(): void { register_rest_route( 'shiftora/v1', '/admin/reset-plugin', [ 'methods' => 'POST', 'callback' => [ $this, 'reset_plugin' ], 'permission_callback' => [ $this, 'can_manage' ], 'args' => [ 'confirm' => [ 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], ], ] ); register_rest_route( 'shiftora/v1', '/admin/full-reset', [ 'methods' => 'POST', 'callback' => [ $this, 'full_reset' ], 'permission_callback' => [ $this, 'can_manage' ], 'args' => [ 'confirm' => [ 'required' => true, 'type' => 'string', 'validate_callback' => static function ( $value ) { return $value === 'RESET' ? true : new WP_Error( 'shiftora_invalid_confirmation', __( 'Please type RESET to confirm full plugin reset.', 'shiftora' ) ); }, ], ], ] ); register_rest_route( 'shiftora/v1', '/admin/diagnostics', [ 'methods' => 'GET', 'callback' => [ $this, 'diagnostics' ], 'permission_callback' => [ $this, 'can_manage' ], ] ); register_rest_route( 'shiftora/v1', '/admin/flush-cache', [ 'methods' => 'POST', 'callback' => [ $this, 'flush_cache' ], 'permission_callback' => [ $this, 'can_manage' ], ] ); register_rest_route( 'shiftora/v1', '/admin/report-issue', [ 'methods' => 'POST', 'callback' => [ $this, 'report_issue' ], 'permission_callback' => [ $this, 'can_manage' ], ] ); register_rest_route( 'shiftora/v1', '/admin/reporting-settings', [ 'methods' => 'POST', 'callback' => [ $this, 'reporting_settings' ], 'permission_callback' => [ $this, 'can_manage' ], ] ); } public function can_manage(): bool { return current_user_can( 'manage_options' ); } /** * POST /admin/reset-plugin: returns the plugin to a fresh-install state. Deletes * connections, migrations, backups, logs, scheduled actions, transients and * options, and regenerates the secret key. */ public function reset_plugin( WP_REST_Request $request ): WP_REST_Response { $permission = $this->assert_destructive_request( $request, 'RESET' ); if ( is_wp_error( $permission ) ) { return new WP_REST_Response( [ 'code' => $permission->get_error_code(), 'message' => $permission->get_error_message(), ], (int) ( $permission->get_error_data()['status'] ?? 403 ) ); } global $wpdb; $results = [ 'migrations_cancelled' => 0, 'tables_truncated' => [], 'backup_files_removed' => 0, 'actions_unscheduled' => 0, 'transients_deleted' => 0, 'options_deleted' => 0, 'secret_key_regenerated' => false, ]; // Cancel all running and pending migrations. $results['migrations_cancelled'] = $this->cancel_live_migrations( 'Cancelled by plugin reset.' ); // Unschedule all Action Scheduler actions. $results['actions_unscheduled'] += $this->unschedule_plugin_actions(); // Delete all backup and export files from disk; the folder guards stay. $results['backup_files_removed'] = DataDirectory::clear( DataDirectory::BACKUPS ); DataDirectory::clear( DataDirectory::EXPORTS ); // After unscheduling: a worker mid-chunk would write its receipt straight back // and the reset would report a clean site it hasn't produced. $this->truncate_owned_tables( $results['tables_truncated'] ); // Delete all Shiftora transients. $results['transients_deleted'] += $this->delete_plugin_transients(); // Schema markers are kept, see PRESERVED_OPTIONS. $deleted_options = $this->sweep_plugin_options(); // Regenerate the secret key for a fresh start. $new_secret = TokenGenerator::generate_secret(); add_option( 'shiftora_secret_key', $new_secret, '', false ); $results['secret_key_regenerated'] = true; $results['options_deleted'] = count( $deleted_options ); // Reset default options to fresh-install state. add_option( 'shiftora_chunk_size', 500 ); add_option( 'shiftora_request_timeout', HttpClient::INSTALLED_DEFAULT_TIMEOUT ); add_option( 'shiftora_ssl_verify', HttpClient::TLS_AUTO ); add_option( 'shiftora_allow_pull', true ); // Flush caches. wp_cache_flush(); return new WP_REST_Response( [ 'success' => true, 'message' => __( 'Plugin fully reset. All connections, migrations, backups, logs, and settings have been cleared. The plugin is now in a fresh state.', 'shiftora' ), 'results' => $results, ], 200 ); } /** POST /admin/full-reset: same wipe as reset-plugin; the body must carry `confirm: "RESET"`. */ public function full_reset( WP_REST_Request $request ): WP_REST_Response { $permission = $this->assert_destructive_request( $request, 'RESET' ); if ( is_wp_error( $permission ) ) { return new WP_REST_Response( [ 'code' => $permission->get_error_code(), 'message' => $permission->get_error_message(), ], (int) ( $permission->get_error_data()['status'] ?? 403 ) ); } global $wpdb; $results = [ 'connections_deleted' => 0, 'migrations_cancelled' => 0, 'tables_truncated' => [], 'backups_deleted' => 0, 'backup_files_removed' => 0, 'logs_cleared' => 0, 'actions_unscheduled' => 0, 'transients_deleted' => 0, 'options_deleted' => 0, 'secret_key_regenerated' => false, ]; // Cancel all running and pending migrations. $results['migrations_cancelled'] = $this->cancel_live_migrations( 'Cancelled by full plugin reset.' ); // Unschedule all Action Scheduler actions. $results['actions_unscheduled'] += $this->unschedule_plugin_actions(); // Delete all backup files first, before truncating the table. $results['backup_files_removed'] = DataDirectory::clear( DataDirectory::BACKUPS ); // Truncate all Shiftora tables. $this->truncate_owned_tables( $results['tables_truncated'] ); // Clear all Shiftora transients. $results['transients_deleted'] += $this->delete_plugin_transients(); $deleted_options = $this->sweep_plugin_options(); // Regenerate the secret key for a fresh start. $new_secret = TokenGenerator::generate_secret(); add_option( 'shiftora_secret_key', $new_secret, '', false ); $results['secret_key_regenerated'] = true; $results['options_deleted'] = count( $deleted_options ); // Reset default options. add_option( 'shiftora_chunk_size', 500 ); add_option( 'shiftora_request_timeout', HttpClient::INSTALLED_DEFAULT_TIMEOUT ); add_option( 'shiftora_ssl_verify', HttpClient::TLS_AUTO ); add_option( 'shiftora_allow_pull', true ); // Flush caches. wp_cache_flush(); return new WP_REST_Response( [ 'success' => true, 'message' => __( 'Plugin fully reset. All connections, migrations, backups, and logs have been cleared. A new secret key has been generated.', 'shiftora' ), 'results' => $results, 'new_secret' => substr( $new_secret, 0, 8 ) . '... (copy from Connection Info)', ], 200 ); } public function diagnostics( WP_REST_Request $request ): WP_REST_Response { $service = $this->diagnostics_service(); return new WP_REST_Response( [ 'success' => true, 'snapshot' => $service->build_snapshot(), 'recent_errors' => $service->get_recent_errors( 10 ), 'auto_report_enabled' => $service->auto_report_enabled(), ], 200 ); } /** * Hard flush of interrupted transfers, staging tables and cached values. * Connections, settings, logs and history are left to the full reset. * * Discarding an unfinished lease discards its recovery state too, so a migration * interrupted mid-finalize restarts rather than resumes. The UI warns first. */ public function flush_cache( WP_REST_Request $request ): WP_REST_Response { $flushed = ( new PluginCacheFlush( ServiceFactory::wpdb(), ServiceFactory::transfer_registry(), MigrationEngineFactory::make() ) )->flush(); return new WP_REST_Response( array_merge( [ 'success' => true ], $flushed ), 200 ); } public function report_issue( WP_REST_Request $request ): WP_REST_Response|WP_Error { $payload = $request->get_json_params(); $payload = is_array( $payload ) ? $payload : []; $result = $this->diagnostics_service()->report_issue( $payload ); return new WP_REST_Response( [ 'success' => true, 'sent' => (bool) ( $result['sent'] ?? false ), 'saved_locally' => (bool) ( $result['saved_locally'] ?? false ), 'delivery' => (string) ( $result['delivery'] ?? 'support_mail' ), 'message' => (string) ( $result['message'] ?? __( 'Your support report has been sent.', 'shiftora' ) ), ], 200 ); } public function reporting_settings( WP_REST_Request $request ): WP_REST_Response { $enabled = (bool) $request->get_param( 'enabled' ); $this->diagnostics_service()->update_auto_report( $enabled ); return new WP_REST_Response( [ 'success' => true, 'auto_report_enabled' => $enabled, ], 200 ); } private function diagnostics_service(): DiagnosticsService { return new DiagnosticsService(); } private function assert_destructive_request( WP_REST_Request $request, string $confirmation ): bool|WP_Error { if ( ! $this->has_valid_rest_nonce( $request ) ) { return new WP_Error( 'shiftora_invalid_nonce', __( 'Security check failed. Please refresh the page and try again.', 'shiftora' ), [ 'status' => 403 ] ); } if ( (string) $request->get_param( 'confirm' ) !== $confirmation ) { return new WP_Error( 'shiftora_invalid_confirmation', __( 'Please type RESET to confirm full plugin reset.', 'shiftora' ), [ 'status' => 400 ] ); } return true; } private function has_valid_rest_nonce( WP_REST_Request $request ): bool { $nonce = (string) ( $request->get_header( 'X-WP-Nonce' ) ?: $request->get_param( '_wpnonce' ) ); return '' !== $nonce && (bool) wp_verify_nonce( sanitize_text_field( wp_unslash( $nonce ) ), 'wp_rest' ); } }