/dry-run, /run and /rollback. * * @package Transtrix */ namespace Transtrix\Rest\Controllers; use Transtrix\Modules\Core\Core_Importer; use Transtrix\Import\Conflict_Resolver; use Transtrix\Import\Restore_Point; use Transtrix\Package\Package_Reader; use Transtrix\Package\Package_Store; use Transtrix\Plugin; use Transtrix\Rest\Upload_Validator; use Transtrix\Runner\Job; use Transtrix\Runner\Job_Cancelled; use Transtrix\Runner\Job_Store; use Transtrix\Settings; use WP_REST_Request; defined( 'ABSPATH' ) || exit; /** * The import half of the contract. * * Upload is the highest-risk surface in the plugin, so it does nothing itself: * Upload_Validator decides whether the file is acceptable, Package_Store decides * where it lives, and Core_Importer does the importing. */ class Import extends Controller { /** * Register routes. * * @return void */ public function register_routes(): void { register_rest_route( self::NAMESPACE_V1, '/import/upload', array( array( 'methods' => 'POST', 'callback' => array( $this, 'upload' ), 'permission_callback' => array( $this, 'permission_check' ), 'args' => array(), ), ) ); register_rest_route( self::NAMESPACE_V1, '/import/upload-chunk', array( array( 'methods' => 'POST', 'callback' => array( $this, 'upload_chunk' ), 'permission_callback' => array( $this, 'permission_check' ), 'args' => array( 'upload_id' => array( 'type' => 'string', 'required' => true, 'pattern' => '^[a-f0-9]{32}$', ), 'offset' => array( 'type' => 'integer', 'required' => true, 'minimum' => 0, ), 'total' => array( 'type' => 'integer', 'required' => true, 'minimum' => 1, ), 'name' => array( 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_file_name', ), ), ), ) ); register_rest_route( self::NAMESPACE_V1, '/import/(?P[a-f0-9]{32})', array( array( 'methods' => 'DELETE', 'callback' => array( $this, 'delete_package' ), 'permission_callback' => array( $this, 'permission_check' ), 'args' => $this->package_id_arg(), ), ) ); $package_routes = array( 'dry-run' => 'dry_run', 'run' => 'run', 'rollback' => 'rollback', ); foreach ( $package_routes as $segment => $method ) { register_rest_route( self::NAMESPACE_V1, '/import/(?P[a-f0-9]{32})/' . $segment, array( array( 'methods' => 'POST', 'callback' => array( $this, $method ), 'permission_callback' => array( $this, 'rollback' === $segment ? 'admin_permission_check' : 'permission_check' ), 'args' => 'rollback' === $segment ? array_merge( $this->package_id_arg(), $this->job_id_arg() ) : array_merge( $this->package_id_arg(), $this->import_args() ), ), ) ); } } /** * Accept a package file. * * @param WP_REST_Request $request The request. * @return \WP_REST_Response|\WP_Error */ public function upload( WP_REST_Request $request ) { $files = $request->get_file_params(); $file = isset( $files['package'] ) ? (array) $files['package'] : array(); if ( array() === $file ) { return $this->error( 'transtrix_upload_missing', __( 'No package was received. Choose a .zip file and try again.', 'transtrix' ) ); } $tmp = isset( $file['tmp_name'] ) ? (string) $file['tmp_name'] : ''; if ( '' === $tmp || ! is_uploaded_file( $tmp ) ) { return $this->error( 'transtrix_upload_missing', __( 'No package was received. Choose a .zip file and try again.', 'transtrix' ) ); } return $this->accept( $tmp, (string) ( $file['name'] ?? '' ) ); } /** * Accept one piece of a package too large for a single request. * * The browser sends the file in order, each piece under the server's * upload limit. Pieces are appended to a part file; the last one hands the * whole file to the same checks a single upload gets. An offset that does * not match what is on disk is refused, so a retried piece is never * written twice. * * @param WP_REST_Request $request The request. * @return \WP_REST_Response|\WP_Error */ public function upload_chunk( WP_REST_Request $request ) { $files = $request->get_file_params(); $chunk = isset( $files['chunk']['tmp_name'] ) ? (string) $files['chunk']['tmp_name'] : ''; $total = (int) $request['total']; if ( '' === $chunk || ! is_uploaded_file( $chunk ) ) { return $this->error( 'transtrix_upload_missing', __( 'Part of the file did not arrive. Try again.', 'transtrix' ) ); } if ( $total > Upload_Validator::MAX_BYTES ) { return $this->error( 'transtrix_upload_too_large', __( 'The package is larger than this site accepts. Ask your host to raise the upload limit.', 'transtrix' ), 413 ); } $owner_key = 'transtrix_upload_' . (string) $request['upload_id']; $user_id = get_current_user_id(); if ( 0 === (int) $request['offset'] ) { if ( '.zip' !== strtolower( substr( (string) $request['name'], -4 ) ) ) { return $this->error( 'transtrix_upload_not_zip', __( 'Choose a .zip file and try again.', 'transtrix' ) ); } $owner = get_transient( $owner_key ); // With no owner on record, an id that is already stored belongs to // someone else's upload or export: only an administrator may take it. if ( false === $owner ? Package_Store::exists( (string) $request['upload_id'] ) && ! $this->can_access_full_site() : (int) $owner !== $user_id ) { return $this->error( 'transtrix_upload_forbidden', __( 'This upload belongs to another user.', 'transtrix' ), 403 ); } set_transient( $owner_key, $user_id, DAY_IN_SECONDS ); // The same file (name, size, modified time) was sent before and is // still stored: use it rather than writing another 3 GB copy. $stored = Package_Store::path( (string) $request['upload_id'] ); if ( ! is_wp_error( $stored ) && is_file( $stored ) && filesize( $stored ) === $total ) { return $this->describe( $stored, (string) $request['upload_id'], (string) $request['name'] ); } } elseif ( (int) get_transient( $owner_key ) !== $user_id ) { return $this->error( 'transtrix_upload_forbidden', __( 'This upload belongs to another user.', 'transtrix' ), 403 ); } $dir = Package_Store::dir(); if ( is_wp_error( $dir ) ) { return $this->from_engine( $dir, 500 ); } // An abandoned part file is swept after a day (Package_Store::sweep_parts), // or deleted with its failed History row. $part = $dir . (string) $request['upload_id'] . '.part'; $have = file_exists( $part ) ? (int) filesize( $part ) : 0; // Tell the browser where this file actually stands, so a dropped // connection or a reload carries on from there instead of from zero. if ( (int) $request['offset'] !== $have ) { return new \WP_Error( 'transtrix_upload_out_of_order', __( 'The upload was interrupted. Upload the file again.', 'transtrix' ), array( 'status' => 409, 'received' => $have, ) ); } // phpcs:disable WordPress.WP.AlternativeFunctions -- direct file I/O; WP_Filesystem needs credentials a REST request does not have. $in = fopen( $chunk, 'rb' ); $out = fopen( $part, 'ab' ); if ( false === $in || false === $out ) { return $this->error( 'transtrix_upload_write_failed', __( 'The file could not be saved on this site. Check that the uploads folder is writable.', 'transtrix' ), 500 ); } stream_copy_to_stream( $in, $out ); fclose( $in ); fclose( $out ); // phpcs:enable clearstatcache( true, $part ); $have = (int) filesize( $part ); if ( $have > $total ) { wp_delete_file( $part ); $error = $this->error( 'transtrix_upload_out_of_order', __( 'The upload was interrupted. Upload the file again.', 'transtrix' ), 409 ); Job_Store::fail_upload( (string) $request['upload_id'], $error->get_error_message() ); return $error; } Job_Store::upload_sent( (string) $request['upload_id'], sanitize_file_name( (string) $request['name'] ), $have, $total ); if ( $have < $total ) { return rest_ensure_response( array( 'received' => $have ) ); } $accepted = $this->accept( $part, (string) $request['name'], (string) $request['upload_id'] ); if ( is_wp_error( $accepted ) ) { wp_delete_file( $part ); Job_Store::fail_upload( (string) $request['upload_id'], $accepted->get_error_message() ); } return $accepted; } /** * Check a complete file and move it into the store. * * @param string $tmp Path to the received file. * @param string $name Name the browser gave it. * @param string $package_id Id to store it under; the upload id, so a * second upload of one file lands on the first. * @return \WP_REST_Response|\WP_Error */ private function accept( string $tmp, string $name, string $package_id = '' ) { $valid = ( new Upload_Validator() )->validate( array( 'tmp_name' => $tmp, 'name' => $name, ) ); if ( is_wp_error( $valid ) ) { return $valid; } $package_id = '' !== $package_id ? $package_id : Package_Store::new_id(); $stored = Package_Store::put( $tmp, $package_id ); if ( is_wp_error( $stored ) ) { return $this->from_engine( $stored, 500 ); } return $this->describe( $stored, $package_id, $name ); } /** * What the UI needs to know about a stored package. * * @param string $stored Path to the stored package. * @param string $package_id Package id. * @param string $name Name the browser gave it. * @return \WP_REST_Response|\WP_Error */ private function describe( string $stored, string $package_id, string $name ) { // The kind decides which screen the package goes to. Reading it here is // one entry out of the zip, and it saves the UI from guessing by // filename or from asking for a dry run a whole-site archive cannot // give it. $manifest = Package_Reader::peek( $stored ); // A whole-site package holds the user table; one uploaded by someone // below the full-site gate is refused, not stored for an admin to find. $allowed = is_wp_error( $manifest ) ? $manifest : $this->full_site_package_check( $stored ); if ( is_wp_error( $allowed ) ) { Package_Store::delete( $package_id ); return is_wp_error( $manifest ) ? $this->from_engine( $manifest ) : $allowed; } Job_Store::record_upload( $package_id, sanitize_file_name( $name ), $manifest ); return rest_ensure_response( array( 'package_id' => $package_id, 'filename' => sanitize_file_name( $name ), 'size' => (int) filesize( $stored ), 'kind' => (string) $manifest['kind'], 'layers' => isset( $manifest['layers'] ) ? (array) $manifest['layers'] : array(), ) ); } /** * Delete an uploaded package. * * @param WP_REST_Request $request The request. * @return \WP_REST_Response|\WP_Error */ public function delete_package( WP_REST_Request $request ) { $package_id = (string) $request['package_id']; $owner = $this->package_owner_check( $package_id ); if ( is_wp_error( $owner ) ) { return $owner; } if ( in_array( $package_id, Job_Store::package_ids( true ), true ) ) { return $this->error( 'transtrix_package_in_use', __( 'This file is being used by an import that is still running.', 'transtrix' ), 409 ); } $path = Package_Store::path( $package_id ); if ( ! is_wp_error( $path ) && is_readable( $path ) ) { $allowed = $this->full_site_package_check( $path ); if ( is_wp_error( $allowed ) ) { return $allowed; } } Package_Store::delete( $package_id ); return rest_ensure_response( array( 'deleted' => true ) ); } /** * Report what importing the package would do. * * @param WP_REST_Request $request The request. * @return \WP_REST_Response|\WP_Error */ public function dry_run( WP_REST_Request $request ) { $context = $this->context( $request ); if ( is_wp_error( $context ) ) { return $context; } $result = ( new Core_Importer( $context['module'], array( $this, 'can_write' ) ) )->dry_run( $context['job'], $context['path'], $context['strategy'], $context['skip_missing'] ); if ( is_wp_error( $result ) ) { return $this->from_engine( $result, 400 ); } return rest_ensure_response( array( 'package_id' => $context['package_id'], 'counts' => $result->counts(), 'unresolved' => $result->unresolved(), 'blockers' => $result->blockers(), 'skippable' => $result->skippable(), ) ); } /** * Import the package. * * @param WP_REST_Request $request The request. * @return \WP_REST_Response|\WP_Error */ public function run( WP_REST_Request $request ) { $context = $this->context( $request ); if ( is_wp_error( $context ) ) { return $context; } Job_Store::save( $context['job'], array( 'type' => 'import', 'package_id' => $context['package_id'], ) ); try { $report = ( new Core_Importer( $context['module'], array( $this, 'can_write' ) ) )->import( $context['job'], $context['path'], $context['strategy'], $context['skip_missing'] ); } catch ( Job_Cancelled $cancelled ) { return $this->error( 'transtrix_job_cancelled', __( 'The import stopped because its page was closed. Nothing it added was kept.', 'transtrix' ), 409 ); } if ( is_wp_error( $report ) ) { Job_Store::save( $context['job'], array( 'status' => 'failed', 'finished_at' => gmdate( 'c' ), ) ); return $this->from_engine( $report, 'transtrix_import_blocked' === $report->get_error_code() ? 409 : 400 ); } $record = Job_Store::save( $context['job'], array( 'status' => 'complete', 'counts' => $report->counts(), 'failures' => $report->failures(), 'warnings' => $report->warnings(), 'finished_at' => gmdate( 'c' ), ) ); // Only on success: a failed or cancelled import keeps its file for a retry. Job_Store::release_upload( $context['package_id'] ); return rest_ensure_response( $record ); } /** * Undo an import. * * @param WP_REST_Request $request The request. * @return \WP_REST_Response|\WP_Error */ public function rollback( WP_REST_Request $request ) { $job_id = (string) $request['job_id']; $job = Job_Store::get( $job_id ); if ( null === $job || 'import' !== ( $job['type'] ?? '' ) || ( $job['package_id'] ?? '' ) !== (string) $request['package_id'] ) { return $this->error( 'transtrix_job_not_found', __( 'That import was not found.', 'transtrix' ), 404 ); } $restore = Restore_Point::load( $job_id ); if ( ! $restore instanceof Restore_Point ) { return $this->error( 'transtrix_no_restore_point', __( 'This import can no longer be undone. Imports can only be undone for the number of days set in Settings.', 'transtrix' ), 404 ); } $undone = $restore->rollback( Plugin::instance()->modules()->get( (string) ( $job['module_id'] ?? '' ) ) ); Job_Store::update( $job_id, array( 'status' => 'rolled-back', 'finished_at' => gmdate( 'c' ), ) ); return rest_ensure_response( array( 'job_id' => $job_id, 'undone' => $undone, ) ); } /** * Everything a run or dry run needs, or the reason it cannot proceed. * * @param WP_REST_Request $request The request. * @return array|\WP_Error */ private function context( WP_REST_Request $request ) { $package_id = (string) $request['package_id']; $owner = $this->package_owner_check( $package_id ); if ( is_wp_error( $owner ) ) { return $owner; } $path = Package_Store::path( $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; } // The package knows which module wrote it; the request is the fallback // for packages whose module is not active here. $module_id = Package_Reader::module_id( $path ); $module = '' === $module_id ? null : Plugin::instance()->modules()->get( $module_id ); if ( null === $module ) { $module_id = (string) $request['module_id']; $module = Plugin::instance()->modules()->get( $module_id ); } if ( null === $module ) { return $this->error( 'transtrix_unknown_module', __( 'That module is not available on this site.', 'transtrix' ), 404 ); } return array( 'package_id' => $package_id, 'path' => $path, 'module' => $module, 'strategy' => (string) $request['conflict'], 'skip_missing' => (bool) $request['skip_missing'], 'job' => new Job( $module_id ), ); } /** * Arguments shared by dry-run and run. * * @return array> */ private function import_args(): array { return array( 'module_id' => array( 'description' => __( 'Which module\'s reference hints apply.', 'transtrix' ), 'type' => 'string', 'default' => 'wp-core', 'sanitize_callback' => 'sanitize_key', 'validate_callback' => static function ( $value ) { return null !== Plugin::instance()->modules()->get( sanitize_key( (string) $value ) ); }, ), 'conflict' => array( 'description' => __( 'What to do when content already exists.', 'transtrix' ), 'type' => 'string', 'default' => Settings::get( 'default_conflict' ), 'enum' => Conflict_Resolver::strategies(), 'sanitize_callback' => 'sanitize_key', 'validate_callback' => static function ( $value ) { return in_array( (string) $value, Conflict_Resolver::strategies(), true ); }, ), 'skip_missing' => array( 'description' => __( 'Leave out content types this site does not register instead of refusing the import.', 'transtrix' ), 'type' => 'boolean', 'default' => false, ), ); } /** * The job id argument, used by rollback. * * @return array> */ private function job_id_arg(): array { return array( 'job_id' => array( 'description' => __( 'The import job to undo.', 'transtrix' ), 'type' => 'string', 'required' => true, 'sanitize_callback' => static function ( $value ) { return preg_replace( '/[^A-Za-z0-9_.]/', '', (string) $value ); }, 'validate_callback' => static function ( $value ) { return 1 === preg_match( '/^[A-Za-z0-9_.]{1,64}$/', (string) $value ); }, ), ); } /** * Whether the current user may write one imported object. * * The plugin's capability may be an editor's, so each object is held to * the same check the post editor would make. * * @param array $postarr The post about to be written. * @param int $existing_id The post being overwritten, or 0. * @return bool */ public function can_write( array $postarr, int $existing_id ): bool { $type = get_post_type_object( (string) $postarr['post_type'] ); if ( null === $type ) { return false; } // 'do_not_allow' only hides "Add New" (WP Travel Engine enquiries, customers); who may edit those records may hold them. $create = 'do_not_allow' === $type->cap->create_posts ? $type->cap->edit_posts : $type->cap->create_posts; if ( $existing_id > 0 ? ! current_user_can( 'edit_post', $existing_id ) : ! current_user_can( $create ) ) { return false; } return ! in_array( $postarr['post_status'], array( 'publish', 'future', 'private' ), true ) || current_user_can( $type->cap->publish_posts ); } }