/download. * * @package Transtrix */ namespace Transtrix\Rest\Controllers; use Transtrix\Modules\Core\Core_Exporter; use Transtrix\Package\Package_Store; use Transtrix\Plugin; use Transtrix\Runner\Job; use Transtrix\Runner\Job_Cancelled; use Transtrix\Runner\Job_Store; use Transtrix\Runner\Object_Query; use WP_REST_Request; defined( 'ABSPATH' ) || exit; /** * Start an export, then hand the finished package over. * * The export itself is Core_Exporter's; this class picks the objects the * request asked for and records the job (A10). */ class Export extends Controller { /** * Register routes. * * @return void */ public function register_routes(): void { register_rest_route( self::NAMESPACE_V1, '/export', array( array( 'methods' => 'POST', 'callback' => array( $this, 'start_export' ), 'permission_callback' => array( $this, 'permission_check' ), 'args' => array( 'module_id' => array( 'description' => __( 'Which module to export.', 'transtrix' ), 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_key', 'validate_callback' => static function ( $value ) { return null !== Plugin::instance()->modules()->get( sanitize_key( (string) $value ) ); }, ), 'post_types' => array( 'description' => __( 'Restrict the export to these post types.', 'transtrix' ), 'type' => 'array', 'default' => array(), 'items' => array( 'type' => 'string' ), 'sanitize_callback' => static function ( $value ) { return array_values( array_filter( array_map( 'sanitize_key', (array) $value ) ) ); }, 'validate_callback' => static function ( $value, $request ) { $module = Plugin::instance()->modules()->get( sanitize_key( (string) $request['module_id'] ) ); if ( null === $module ) { return false; } // Only the module's own types: its meta exclusions (A6) apply to // nothing else, so another module's type must not ride along. $allowed = array_merge( array_diff( $module->post_types(), $module->excluded_post_types() ), array_keys( $module->payload_types() ) ); foreach ( (array) $value as $post_type ) { if ( ! in_array( sanitize_key( (string) $post_type ), $allowed, true ) ) { return false; } } return true; }, ), 'post_ids' => array( 'description' => __( 'Export exactly these objects, ignoring the type totals.', 'transtrix' ), 'type' => 'array', 'default' => array(), 'items' => array( 'type' => 'integer' ), 'sanitize_callback' => static function ( $value ) { return array_values( array_filter( array_map( 'absint', (array) $value ) ) ); }, ), 'limit' => array( 'description' => __( 'Maximum number of objects.', 'transtrix' ), 'type' => 'integer', 'default' => 500, 'sanitize_callback' => 'absint', 'validate_callback' => static function ( $value ) { return (int) $value >= 1 && (int) $value <= 5000; }, ), ), ), ) ); register_rest_route( self::NAMESPACE_V1, '/export/(?P[a-f0-9]{32})/download', array( array( 'methods' => 'GET', 'callback' => array( $this, 'download' ), 'permission_callback' => array( $this, 'permission_check' ), 'args' => $this->package_id_arg(), ), ) ); } /** * Run an export and record the job. * * @param WP_REST_Request $request The request. * @return \WP_REST_Response|\WP_Error */ public function start_export( WP_REST_Request $request ) { $module = Plugin::instance()->modules()->get( (string) $request['module_id'] ); if ( null === $module ) { return $this->error( 'transtrix_unknown_module', __( 'That module is not available on this site.', 'transtrix' ), 404 ); } $post_types = (array) $request['post_types']; foreach ( $post_types as $post_type ) { if ( ! $this->can_export_type( $module, (string) $post_type ) ) { return $this->type_forbidden(); } } $limit = (int) $request['limit']; $chosen = (array) $request['post_ids']; $allowed = function ( $type ) use ( $module ) { return $this->can_export_type( $module, (string) $type ); }; // The two arguments compose: whole types come from post_types, individually // picked items from post_ids, and a request that names neither takes the // module's own types the user may export. Picked ids are re-checked against // those types before the exporter sees them (invariant A4: the module // decides scope). $types = array() === $post_types && array() === $chosen ? array_values( array_filter( array_merge( $module->selectable_post_types(), array_keys( $module->payload_types() ) ), $allowed ) ) : $post_types; // Orders and customers are not posts: the module exports them itself. $payload_types = array_values( array_intersect( $types, array_keys( $module->payload_types() ) ) ); $types = array_values( array_diff( $types, $payload_types ) ); $post_ids = array_merge( array() === $types ? array() : Object_Query::ids( $types, $limit ), array() === $chosen ? array() : Object_Query::filter_ids( $chosen, array_values( array_filter( $module->post_types(), $allowed ) ) ) ); $post_ids = array_slice( array_values( array_unique( $post_ids ) ), 0, $limit ); if ( array() === $post_ids && array() === $payload_types ) { return $this->error( 'transtrix_nothing_to_export', __( 'Nothing on this site matches that selection. Try selecting more content types.', 'transtrix' ), 400 ); } $job = new Job( $module->id(), $post_ids ); $job->set_state( 'payload_types', $payload_types ); $package_id = Package_Store::new_id(); $path = Package_Store::path( $package_id ); if ( is_wp_error( $path ) ) { return $this->from_engine( $path, 500 ); } Job_Store::save( $job, array( 'type' => 'export' ) ); // This export runs in one request; a host's 30-second default cuts a // large one off part way. shortcut: not stepped like whole-site export, // upgrade to a Steppable when single-request exports keep timing out. if ( function_exists( 'set_time_limit' ) ) { set_time_limit( \Transtrix\Runner\Step_Lock::TTL - 60 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- one long export or restore step, bounded by the step lock. } try { $report = ( new Core_Exporter( $module ) )->export( $job, $path ); } catch ( Job_Cancelled $cancelled ) { return $this->error( 'transtrix_job_cancelled', __( 'The export stopped because its page was closed.', 'transtrix' ), 409 ); } if ( is_wp_error( $report ) ) { Job_Store::save( $job, array( 'status' => 'failed', 'finished_at' => gmdate( 'c' ), ) ); return $this->from_engine( $report, 500 ); } $size = file_exists( $path ) ? (int) filesize( $path ) : 0; $record = Job_Store::save( $job, array( 'status' => 'complete', 'counts' => $report->counts(), 'failures' => $report->failures(), 'package_id' => $package_id, 'package_name' => sanitize_file_name( $module->id() . '-' . gmdate( 'Y-m-d-His' ) . '.zip' ), 'size' => $size, 'finished_at' => gmdate( 'c' ), ) ); return rest_ensure_response( $record ); } /** * Stream a finished package. * * @param WP_REST_Request $request The request. * @return void|\WP_Error */ public function download( WP_REST_Request $request ) { $owner = $this->package_owner_check( (string) $request['package_id'] ); if ( is_wp_error( $owner ) ) { return $owner; } $path = Package_Store::path( (string) $request['package_id'] ); if ( is_wp_error( $path ) ) { return $this->from_engine( $path, 404 ); } if ( ! is_readable( $path ) ) { return $this->error( 'transtrix_package_not_found', __( 'That package is no longer stored on this site. Upload it again to continue.', 'transtrix' ), 404 ); } $allowed = $this->full_site_package_check( $path ); if ( is_wp_error( $allowed ) ) { return $allowed; } $name = $this->download_name( (string) $request['package_id'], $path ); // Whole-site packages run to gigabytes: any open output buffer would hold // the entire file in memory, so drop them and stream in chunks. while ( ob_get_level() > 0 ) { ob_end_clean(); } if ( function_exists( 'set_time_limit' ) ) { set_time_limit( 0 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- streaming a multi-gigabyte package must not hit max_execution_time. } header( 'Content-Type: application/zip' ); $name = str_replace( array( '"', "'", "\r", "\n" ), '', sanitize_file_name( $name ) ); header( 'Content-Disposition: attachment; filename="' . $name . '"' ); header( 'Content-Length: ' . (string) filesize( $path ) ); header( 'X-Accel-Buffering: no' ); $handle = fopen( $path, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- direct file I/O; WP_Filesystem needs credentials a REST request does not have. while ( false !== $handle && ! feof( $handle ) ) { echo fread( $handle, 1048576 ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped, WordPress.WP.AlternativeFunctions.file_system_operations_fread -- direct file I/O; WP_Filesystem needs credentials a REST request does not have. flush(); } if ( false !== $handle ) { fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- direct file I/O; WP_Filesystem needs credentials a REST request does not have. } exit; } /** * The name a package is offered under. * * Packages are stored by id, so the file on disk is a hash. The job record * carries the readable name the export was written with. * * @param string $package_id Package id. * @param string $path Stored path. * @return string */ private function download_name( string $package_id, string $path ): string { foreach ( Job_Store::recent( 100 ) as $record ) { if ( ( $record['package_id'] ?? '' ) === $package_id ) { return Package_Store::display_name( $record ); } } return wp_basename( $path ); } }