plugin = $plugin; } /** * Registers the routes. */ public function register(): void { $admin = array( $this, 'can_manage' ); $id = '(?P[0-9]{8}-[0-9]{6}-[a-z0-9]{6})'; register_rest_route( self::NS, '/info', array( 'methods' => 'GET', 'callback' => array( $this, 'info' ), 'permission_callback' => $admin ) ); register_rest_route( self::NS, '/bundles', array( 'methods' => 'POST', 'callback' => array( $this, 'create_bundle' ), 'permission_callback' => $admin ) ); register_rest_route( self::NS, '/bundles/' . $id . '/chunk', array( 'methods' => 'POST', 'callback' => array( $this, 'chunk' ), 'permission_callback' => $admin ) ); register_rest_route( self::NS, '/bundles/' . $id . '/plan', array( 'methods' => 'POST', 'callback' => array( $this, 'plan' ), 'permission_callback' => $admin ) ); register_rest_route( self::NS, '/bundles/' . $id . '/apply', array( 'methods' => 'POST', 'callback' => array( $this, 'apply' ), 'permission_callback' => $admin ) ); register_rest_route( self::NS, '/bundles/' . $id . '/diff', array( 'methods' => 'GET', 'callback' => array( $this, 'diff' ), 'permission_callback' => $admin ) ); register_rest_route( self::NS, '/releases', array( 'methods' => 'GET', 'callback' => array( $this, 'releases' ), 'permission_callback' => $admin ) ); register_rest_route( self::NS, '/releases/' . $id . '/rollback', array( 'methods' => 'POST', 'callback' => array( $this, 'rollback' ), 'permission_callback' => $admin ) ); register_rest_route( self::NS, '/releases/' . $id, array( 'methods' => 'GET', 'callback' => array( $this, 'release' ), 'permission_callback' => $admin ) ); register_rest_route( self::NS, '/pull/changes', array( 'methods' => 'POST', 'callback' => array( $this, 'pull_changes' ), 'permission_callback' => $admin ) ); register_rest_route( self::NS, '/pull/packages', array( 'methods' => 'POST', 'callback' => array( $this, 'pull_package' ), 'permission_callback' => $admin ) ); register_rest_route( self::NS, '/pull/packages/' . $id, array( 'methods' => 'GET', 'callback' => array( $this, 'pull_chunk' ), 'permission_callback' => $admin ) ); } /** * Live → staging: objects that differ from the baseline hashes of the staging copy. * * @param \WP_REST_Request $request Request. */ public function pull_changes( \WP_REST_Request $request ) { try { return rest_ensure_response( $this->plugin->sync()->serve_changes( self::string_map( $request->get_json_params()['hashes'] ?? array() ) ) ); } catch ( \RuntimeException $e ) { return new \WP_Error( 'wonderful_sync_pull', $e->getMessage(), array( 'status' => 409 ) ); } } /** * Live → staging: builds a package of the selection. * * @param \WP_REST_Request $request Request. */ public function pull_package( \WP_REST_Request $request ) { $params = (array) $request->get_json_params(); $gone = array(); foreach ( (array) ( $params['gone'] ?? array() ) as $key => $row ) { if ( is_array( $row ) ) { $gone[ (string) $key ] = self::string_map( array_intersect_key( $row, array_flip( array( 'k', 'c', 't', 's' ) ) ) ); } } try { return rest_ensure_response( $this->plugin->sync()->serve_package( array_values( array_map( 'strval', array_filter( (array) ( $params['keys'] ?? array() ), 'is_scalar' ) ) ), self::string_map( $params['hashes'] ?? array() ), $gone, (int) ( $params['created'] ?? 0 ) ) ); } catch ( \RuntimeException $e ) { return new \WP_Error( 'wonderful_sync_pull', $e->getMessage(), array( 'status' => 409 ) ); } } /** * Live → staging: one part of a package. * * @param \WP_REST_Request $request Request. */ public function pull_chunk( \WP_REST_Request $request ) { try { return rest_ensure_response( $this->plugin->sync()->serve_chunk( (string) $request['id'], (int) $request->get_param( 'offset' ) ) ); } catch ( \RuntimeException $e ) { return new \WP_Error( 'wonderful_sync_pull', $e->getMessage(), array( 'status' => 404 ) ); } } /** * Scalar values of a JSON object as strings. * * @param mixed $value Value. * @return array */ private static function string_map( $value ): array { $out = array(); foreach ( (array) $value as $key => $item ) { if ( is_scalar( $item ) ) { $out[ (string) $key ] = (string) $item; } } return $out; } /** * Permission check. */ public function can_manage(): bool { return current_user_can( Wonderful_Sync_Plugin::CAPABILITY ); } /** * Site information: URLs, versions, plugins and themes. */ public function info(): \WP_REST_Response { return rest_ensure_response( Bundle::site_info() ); } /** * Starts an upload and returns its ID. */ public function create_bundle() { if ( ! Storage::is_private() ) { return new \WP_Error( 'wonderful_sync_storage', Importer::storage_message(), array( 'status' => 409 ) ); } // Unfinished uploads older than a day and previewed bundles older than an hour are // removed; at most five uploads may be in progress at a time. // Counting and creating happen under one lock, so parallel requests cannot exceed it. $guard = fopen( Storage::dir( 'incoming' ) . '.lock', 'c' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- directory lock. if ( ! $guard || ! flock( $guard, LOCK_EX ) ) { return new \WP_Error( 'wonderful_sync_io', __( 'The upload could not be started.', 'wonderful-sync' ), array( 'status' => 500 ) ); } $release_guard = function ( $result ) use ( $guard ) { flock( $guard, LOCK_UN ); fclose( $guard ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose return $result; }; $pending = glob( Storage::dir( 'incoming' ) . '*.zip' ); foreach ( (array) $pending as $old ) { $frozen = file_exists( $old . '.frozen' ); $age = time() - (int) filemtime( $frozen ? $old . '.frozen' : $old ); if ( ! file_exists( $old . '.applying' ) && ( $age > DAY_IN_SECONDS || ( $frozen && $age > HOUR_IN_SECONDS ) ) ) { wp_delete_file( $old ); wp_delete_file( $old . '.frozen' ); } } $uploading = array_filter( (array) glob( Storage::dir( 'incoming' ) . '*.zip' ), function ( $file ) { return ! file_exists( $file . '.frozen' ); } ); if ( count( $uploading ) >= 5 || count( (array) glob( Storage::dir( 'incoming' ) . '*.zip' ) ) >= 20 ) { return $release_guard( new \WP_Error( 'wonderful_sync_busy', __( 'Too many unfinished uploads on this site. Try again later.', 'wonderful-sync' ), array( 'status' => 429 ) ) ); } $id = Storage::new_id(); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- empty upload target. file_put_contents( $this->path( $id ), '' ); return $release_guard( rest_ensure_response( array( 'id' => $id ) ) ); } /** * Appends a chunk. The offset must equal the current file size, so retries are safe. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function chunk( \WP_REST_Request $request ) { $file = $this->path( (string) $request['id'] ); if ( ! is_file( $file ) ) { return new \WP_Error( 'wonderful_sync_unknown_bundle', __( 'Unknown package.', 'wonderful-sync' ), array( 'status' => 404 ) ); } $body = (string) $request->get_body(); $offset = (int) $request->get_param( 'offset' ); // Check and append under one exclusive lock, so parallel retries cannot append twice. $handle = fopen( $file, 'c+' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- locked append. if ( ! $handle || ! flock( $handle, LOCK_EX ) ) { return new \WP_Error( 'wonderful_sync_io', __( 'The upload could not be written.', 'wonderful-sync' ), array( 'status' => 500 ) ); } $size = (int) fstat( $handle )['size']; $done = function ( $result ) use ( $handle ) { flock( $handle, LOCK_UN ); fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose return $result; }; // Checked under the same lock the planner takes: once frozen, nothing is appended. if ( file_exists( $file . '.frozen' ) ) { return $done( new \WP_Error( 'wonderful_sync_locked', __( 'This package was already previewed and cannot change.', 'wonderful-sync' ), array( 'status' => 409 ) ) ); } if ( strlen( $body ) > self::CHUNK_LIMIT ) { return $done( new \WP_Error( 'wonderful_sync_chunk_too_large', __( 'Chunk too large.', 'wonderful-sync' ), array( 'status' => 413 ) ) ); } if ( $offset < $size ) { // Already received (retry after a lost response). return $done( rest_ensure_response( array( 'size' => $size ) ) ); } /** * Filters the largest bundle this site accepts, in bytes. * * @param int $bytes Default 2 GB. */ if ( $size + strlen( $body ) > (int) apply_filters( 'wonderful_sync_max_bundle_bytes', 2 * GB_IN_BYTES ) ) { $done( null ); wp_delete_file( $file ); return new \WP_Error( 'wonderful_sync_too_large', __( 'The package is larger than this site accepts.', 'wonderful-sync' ), array( 'status' => 413 ) ); } if ( $offset > $size ) { return $done( new \WP_Error( 'wonderful_sync_offset', __( 'Chunk out of order.', 'wonderful-sync' ), array( 'status' => 409, 'size' => $size ) ) ); } fseek( $handle, 0, SEEK_END ); fwrite( $handle, $body ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite fflush( $handle ); $size = (int) fstat( $handle )['size']; return $done( rest_ensure_response( array( 'size' => $size ) ) ); } /** * Plans a received bundle. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function plan( \WP_REST_Request $request ) { try { $file = $this->existing( (string) $request['id'] ); $digest = $this->freeze( $file ); $plan = $this->plugin->importer()->plan( Bundle::read_manifest( $file ), $file ); $plan['digest'] = $digest; return rest_ensure_response( $plan ); } catch ( \Throwable $e ) { return new \WP_Error( 'wonderful_sync_plan', $e->getMessage(), array( 'status' => 400 ) ); } } /** * Field differences of one object between this site and a received bundle. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function diff( \WP_REST_Request $request ) { try { $file = $this->existing( (string) $request['id'] ); return rest_ensure_response( $this->plugin->importer()->diff( Bundle::read_manifest( $file ), $file, (string) $request->get_param( 'key' ) ) ); } catch ( \Throwable $e ) { return new \WP_Error( 'wonderful_sync_diff', $e->getMessage(), array( 'status' => 400 ) ); } } /** * Applies a received bundle. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function apply( \WP_REST_Request $request ) { try { $decisions = array_map( 'sanitize_key', (array) $request->get_param( 'decisions' ) ); $file = $this->existing( (string) $request['id'] ); // Apply exactly what was previewed. if ( ! file_exists( $file . '.frozen' ) || ! hash_equals( (string) hash_file( 'sha256', $file ), (string) $request->get_param( 'digest' ) ) ) { return new \WP_Error( 'wonderful_sync_digest', __( 'The package differs from the previewed one. Preview it again.', 'wonderful-sync' ), array( 'status' => 409 ) ); } $marker = $file . '.applying'; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- marks the running apply. file_put_contents( $marker, (string) time() ); $release = $this->plugin->importer()->apply( $this->existing( (string) $request['id'] ), $decisions, (bool) $request->get_param( 'force' ), (string) $request['id'] ); Bundle::close( $file ); wp_delete_file( $file ); wp_delete_file( $marker ); wp_delete_file( $file . '.frozen' ); return rest_ensure_response( $release ); } catch ( \Throwable $e ) { if ( isset( $marker ) ) { wp_delete_file( $marker ); } return new \WP_Error( 'wonderful_sync_apply', $e->getMessage(), array( 'status' => 400 ) ); } } /** * Lists releases. */ public function releases(): \WP_REST_Response { return rest_ensure_response( Importer::releases() ); } /** * One release: status, warnings and the keys it changed (no snapshot data). * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function release( \WP_REST_Request $request ) { $record = Importer::release( (string) $request['id'] ); if ( ! $record ) { return new \WP_Error( 'wonderful_sync_unknown_release', __( 'Unknown transfer.', 'wonderful-sync' ), array( 'status' => 404 ) ); } return rest_ensure_response( array( 'id' => $record['id'], 'status' => $record['status'], 'warnings' => (array) ( $record['warnings'] ?? array() ), 'entries' => array_fill_keys( array_keys( (array) $record['entries'] ), true ), ) ); } /** * Rolls a release back. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function rollback( \WP_REST_Request $request ) { try { return rest_ensure_response( $this->plugin->importer()->rollback( (string) $request['id'], (bool) $request->get_param( 'force' ) ) ); } catch ( \Throwable $e ) { return new \WP_Error( 'wonderful_sync_rollback', $e->getMessage(), array( 'status' => 400 ) ); } } /** * Freezes an uploaded bundle under the upload lock and returns its SHA-256 digest. * * @param string $file Bundle path. */ private function freeze( string $file ): string { $handle = fopen( $file, 'r' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- shared lock with chunk(). if ( ! $handle || ! flock( $handle, LOCK_EX ) ) { throw new \RuntimeException( esc_html__( 'The package could not be locked.', 'wonderful-sync' ) ); } if ( ! file_exists( $file . '.frozen' ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- freeze marker. file_put_contents( $file . '.frozen', (string) time() ); } $digest = (string) hash_file( 'sha256', $file ); flock( $handle, LOCK_UN ); fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose return $digest; } /** * Path of an incoming bundle. * * @param string $id Bundle ID. */ private function path( string $id ): string { if ( ! Storage::valid_id( $id ) ) { throw new \InvalidArgumentException( 'invalid id' ); } return Storage::dir( 'incoming' ) . $id . '.zip'; } /** * Path of an incoming bundle that must exist. * * @param string $id Bundle ID. */ private function existing( string $id ): string { $file = $this->path( $id ); if ( ! is_file( $file ) ) { throw new \RuntimeException( esc_html__( 'Unknown package.', 'wonderful-sync' ) ); } return $file; } }