'POST', 'callback' => [ $this, 'handle' ], 'permission_callback' => static function () { return current_user_can( 'manage_options' ); }, ] ); // The download itself is an admin-post handler, not REST. add_action( 'admin_post_shiftora_export_file', [ $this, 'serve_download' ] ); } public function handle( WP_REST_Request $request ): WP_REST_Response|WP_Error { global $wpdb; $body = $request->get_json_params(); $tables = is_array( $body['tables'] ?? null ) ? $body['tables'] : []; if ( empty( $tables ) ) { return new WP_Error( 'shiftora_no_tables', __( 'No tables selected.', 'shiftora' ), [ 'status' => 400 ] ); } // Views have no CREATE TABLE to dump and would fail the whole export. $all_tables = $wpdb->get_col( "SHOW FULL TABLES WHERE Table_type = 'BASE TABLE'" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- SHOW FULL TABLES lists the live schema, which no WordPress API exposes. $safe_tables = array_values( array_filter( $tables, fn( $t ) => in_array( $t, $all_tables, true ) ) ); if ( empty( $safe_tables ) ) { return new WP_Error( 'shiftora_invalid_tables', __( 'None of the given tables exist in this database.', 'shiftora' ), [ 'status' => 400 ] ); } // Exports stay out of direct web reach. $tmp_dir = DataDirectory::ensure( DataDirectory::EXPORTS ); if ( '' === $tmp_dir ) { return new WP_Error( 'shiftora_export_failed', __( 'The export folder under uploads could not be created.', 'shiftora' ), [ 'status' => 500 ] ); } // An export nobody downloads would otherwise stay on disk for good. foreach ( glob( $tmp_dir . DIRECTORY_SEPARATOR . '*.sql' ) ?: [] as $stale ) { if ( filemtime( $stale ) < time() - HOUR_IN_SECONDS ) { wp_delete_file( $stale ); } } $filename = PrivateFileName::make( 'shiftora-export-' . gmdate( 'Ymd-His' ), '.sql' ); $file_path = $tmp_dir . DIRECTORY_SEPARATOR . $filename; $dumper = new SqlDumper( $wpdb ); try { $dumper->dump( array_values( $safe_tables ), $file_path ); } catch ( \RuntimeException $e ) { return new WP_Error( 'shiftora_export_failed', $e->getMessage(), [ 'status' => 500 ] ); } $token = bin2hex( random_bytes( 16 ) ); set_transient( 'shiftora_export_' . $token, $file_path, 10 * MINUTE_IN_SECONDS ); $download_url = add_query_arg( [ 'action' => 'shiftora_export_file', 'token' => rawurlencode( $token ), '_wpnonce' => wp_create_nonce( 'shiftora_export_' . $token ), ], admin_url( 'admin-post.php' ) ); return new WP_REST_Response( [ 'download_url' => $download_url ], 200 ); } public function serve_download(): void { $token = isset( $_GET['token'] ) && is_string( $_GET['token'] ) ? sanitize_key( wp_unslash( $_GET['token'] ) ) : ''; if ( ! current_user_can( 'manage_options' ) || empty( $token ) || ! isset( $_GET['_wpnonce'] ) || ! is_string( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'shiftora_export_' . $token ) ) { wp_die( esc_html__( 'Permission denied or link expired.', 'shiftora' ), 403 ); } $file_path = (string) get_transient( 'shiftora_export_' . $token ); if ( empty( $file_path ) || ! is_file( $file_path ) ) { wp_die( esc_html__( 'Export file not found or has expired. Please generate a new export.', 'shiftora' ), 404 ); } $real_path = realpath( $file_path ); $base_dir = realpath( DataDirectory::path( DataDirectory::EXPORTS ) ); if ( false === $real_path || false === $base_dir || ! str_starts_with( $real_path, $base_dir . DIRECTORY_SEPARATOR ) ) { delete_transient( 'shiftora_export_' . $token ); wp_die( esc_html__( 'Invalid export path.', 'shiftora' ), 403 ); } // One-time link. delete_transient( 'shiftora_export_' . $token ); $filename = PrivateFileName::display( basename( $real_path ) ); $safe_filename = sanitize_file_name( $filename ); header( 'Content-Type: application/octet-stream' ); header( "Content-Disposition: attachment; filename=\"{$safe_filename}\"; filename*=UTF-8''" . rawurlencode( $safe_filename ) ); header( 'Content-Length: ' . (string) filesize( $real_path ) ); // no-transform: a proxy that gzips or re-chunks a dump changes the bytes the importer receives. header( 'Cache-Control: no-cache, no-store, no-transform' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- readfile streams a verified file inside the plugin's own data folder to the browser readfile( $real_path ); wp_delete_file( $real_path ); exit; } }