.json the object data * files/ media files of attachments in the bundle * * Object data lives in separate entries, so neither side ever holds the whole bundle in memory. */ class Bundle { const FORMAT = 2; /** * Default limits a bundle must stay within. * * @return array{manifest_bytes: int, object_bytes: int, total_bytes: int, objects: int} */ public static function limits(): array { /** * Filters the limits a bundle must stay within. * * @param array $limits manifest_bytes, object_bytes, total_bytes (unpacked), objects. */ return array_map( 'intval', (array) apply_filters( 'wonderful_sync_bundle_limits', array( 'manifest_bytes' => 64 * MB_IN_BYTES, 'object_bytes' => 64 * MB_IN_BYTES, 'total_bytes' => 4 * GB_IN_BYTES, 'objects' => 50000, 'entries' => 250000, ) ) ); } /** * Builds a bundle file from selected changeset keys. * * @param Exporter $exporter Exporter of this site. * @param array $changes Changeset from Changeset::compute(). * @param string[] $keys Keys to include. * @param string $file Target path of the ZIP. * @param array $source_extra Extra manifest source fields (direction of the package). * @return array Manifest (summary). */ public static function build( Exporter $exporter, array $changes, array $keys, string $file, array $source_extra = array() ): array { if ( ! class_exists( '\ZipArchive' ) ) { throw new \RuntimeException( esc_html__( 'The PHP extension zip is missing.', 'wonderful-sync' ) ); } $tmp = Storage::scratch() . Storage::new_id() . '/'; wp_mkdir_p( $tmp ); $zip = new \ZipArchive(); if ( true !== $zip->open( $file, \ZipArchive::CREATE | \ZipArchive::OVERWRITE ) ) { throw new \RuntimeException( esc_html__( 'The package file could not be created.', 'wonderful-sync' ) ); } $objects = array(); $files = array(); $n = 0; foreach ( $keys as $key ) { if ( ! isset( $changes[ $key ] ) ) { continue; } $change = $changes[ $key ]; if ( 'deleted' === $change['status'] ) { $objects[] = array( 'key' => $key, 'kind' => $change['kind'], 'class' => $change['class'], 'subtype' => '', 'title' => $change['title'], 'op' => 'delete', 'base' => $change['base'], 'hash' => '', 'source' => 0, 'file' => '', ); continue; } $object = $exporter->export_key( $key ); if ( ! $object ) { continue; } // Data goes to a temporary file that the ZIP reads on close: memory stays flat. $entry = sprintf( 'objects/%06d.json', ++$n ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- temporary bundle part. file_put_contents( $tmp . basename( $entry ), (string) wp_json_encode( $object['data'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) ); $zip->addFile( $tmp . basename( $entry ), $entry ); $objects[] = array( 'key' => $key, 'kind' => $object['kind'], 'class' => $object['class'], 'subtype' => Exporter::subtype_of( $object ), 'title' => $exporter->title_of( $object ), 'op' => 'upsert', 'base' => $change['base'], 'hash' => Normalizer::hash( $object['data'] ), 'source' => $object['id'], 'file' => $entry, ); foreach ( (array) ( $object['data']['files']['all'] ?? array() ) as $rel ) { $files[] = $rel; } unset( $object ); } $source = self::site_info(); $baseline = Changeset::meta(); $source['origin'] = (string) ( $baseline['origin'] ?? '' ); // When the staging copy was cloned: edits on the target before that are no conflict reason. $source['baseline'] = (int) ( $baseline['created'] ?? 0 ); // Live → staging: the live site says where the package goes back to. $source = array_merge( $source, $source_extra ); $manifest = array( 'format' => self::FORMAT, 'created' => time(), 'source' => $source, 'objects' => $objects, ); $zip->addFromString( 'manifest.json', (string) wp_json_encode( $manifest, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) ); $uploads = wp_upload_dir( null, false ); $base = trailingslashit( $uploads['basedir'] ); foreach ( array_unique( $files ) as $rel ) { if ( is_file( $base . $rel ) ) { $zip->addFile( $base . $rel, 'files/' . $rel ); } } $ok = $zip->close(); foreach ( (array) glob( $tmp . '*.json' ) as $part ) { wp_delete_file( $part ); } rmdir( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- own temporary directory. if ( ! $ok ) { throw new \RuntimeException( esc_html__( 'The package file could not be written.', 'wonderful-sync' ) ); } return $manifest; } /** * Reads and checks the manifest of a bundle (without object data). * * @param string $file ZIP path. * @return array */ public static function read_manifest( string $file ): array { $zip = new \ZipArchive(); if ( true !== $zip->open( $file ) ) { throw new \RuntimeException( esc_html__( 'The package could not be opened.', 'wonderful-sync' ) ); } $limits = self::limits(); $total = 0; $count = $zip->count(); $names = array(); if ( $count > $limits['entries'] ) { $zip->close(); throw new \RuntimeException( esc_html__( 'The package contains more files than this site accepts.', 'wonderful-sync' ) ); } for ( $i = 0; $i < $count; $i++ ) { $stat = $zip->statIndex( $i ); $name = (string) ( $stat['name'] ?? '' ); // Every entry must be unique and below manifest.json, objects/ or files/, without traversal. if ( isset( $names[ $name ] ) || false !== strpos( $name, '..' ) || ( 'manifest.json' !== $name && 0 !== strpos( $name, 'objects/' ) && 0 !== strpos( $name, 'files/' ) ) ) { $zip->close(); throw new \RuntimeException( esc_html__( 'The package contains unexpected files and is rejected.', 'wonderful-sync' ) ); } $names[ $name ] = true; $total += (int) ( $stat['size'] ?? 0 ); } $stat = $zip->statName( 'manifest.json' ); if ( $total > $limits['total_bytes'] || ! $stat || (int) $stat['size'] > $limits['manifest_bytes'] ) { $zip->close(); throw new \RuntimeException( esc_html__( 'The package is larger than this site accepts.', 'wonderful-sync' ) ); } $raw = $zip->getFromName( 'manifest.json' ); $zip->close(); $manifest = is_string( $raw ) ? json_decode( $raw, true ) : null; if ( ! is_array( $manifest ) || self::FORMAT !== (int) ( $manifest['format'] ?? 0 ) || ! is_array( $manifest['objects'] ?? null ) ) { throw new \RuntimeException( esc_html__( 'The package has an unknown format. Update Wonderful Sync on both sites.', 'wonderful-sync' ) ); } if ( count( $manifest['objects'] ) > $limits['objects'] ) { throw new \RuntimeException( esc_html__( 'The package contains more objects than this site accepts.', 'wonderful-sync' ) ); } $seen = array(); foreach ( $manifest['objects'] as $object ) { if ( ! is_array( $object ) || ! isset( $object['key'], $object['kind'], $object['op'] ) || ! is_string( $object['key'] ) ) { throw new \RuntimeException( esc_html__( 'The package contains a malformed object.', 'wonderful-sync' ) ); } $entry = (string) ( $object['file'] ?? '' ); if ( '' !== $entry && ! preg_match( '#^objects/\d{6}\.json$#', $entry ) ) { throw new \RuntimeException( esc_html__( 'The package contains a malformed object.', 'wonderful-sync' ) ); } if ( isset( $seen[ $object['key'] ] ) ) { /* translators: %s: object key. */ throw new \RuntimeException( esc_html( sprintf( __( 'The package contains %s twice and is rejected.', 'wonderful-sync' ), $object['key'] ) ) ); } $seen[ $object['key'] ] = true; } return $manifest; } /** * Reads the data of one object, or null (deletions, missing or oversized entry). * * @param string $file ZIP path. * @param array $object Manifest object. * @return array|null */ public static function object_data( string $file, array $object ): ?array { $entry = (string) ( $object['file'] ?? '' ); if ( '' === $entry || ! preg_match( '#^objects/\d{6}\.json$#', $entry ) ) { return null; } $zip = self::reader( $file ); if ( ! $zip ) { return null; } $stat = $zip->statName( $entry ); $raw = ( $stat && (int) $stat['size'] <= self::limits()['object_bytes'] ) ? $zip->getFromName( $entry ) : false; $data = is_string( $raw ) ? json_decode( $raw, true ) : null; return is_array( $data ) ? $data : null; } /** * Open ZIP readers, one per bundle file, kept for the request. * * @var array */ private static $readers = array(); /** * A ZIP reader for a bundle, opened once per request. * * @param string $file ZIP path. */ private static function reader( string $file ): ?\ZipArchive { if ( ! isset( self::$readers[ $file ] ) ) { $zip = new \ZipArchive(); if ( true !== $zip->open( $file, \ZipArchive::RDONLY ) ) { return null; } self::$readers[ $file ] = $zip; } return self::$readers[ $file ]; } /** * Closes the reader of a bundle (before it is deleted). * * @param string $file ZIP path. */ public static function close( string $file ): void { if ( isset( self::$readers[ $file ] ) ) { self::$readers[ $file ]->close(); unset( self::$readers[ $file ] ); } } /** * Extracts one media file of the bundle to a target path. * * @param string $file ZIP path. * @param string $rel Upload path inside the bundle. * @param string $target Absolute target path. */ public static function extract_file( string $file, string $rel, string $target ): bool { if ( false !== strpos( $rel, '..' ) ) { return false; } $zip = self::reader( $file ); if ( ! $zip ) { return false; } $stream = $zip->getStream( 'files/' . $rel ); if ( ! $stream ) { return false; } wp_mkdir_p( dirname( $target ) ); $out = fopen( $target, 'wb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- streaming copy of a media file. $ok = $out && false !== stream_copy_to_stream( $stream, $out ); fclose( $stream ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose if ( $out ) { fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose } return $ok; } /** * Information about this site sent with every bundle and returned by /info. * * @return array */ public static function site_info(): array { if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $code = array(); foreach ( get_plugins() as $file => $data ) { $code[] = array( 'type' => 'plugin', 'slug' => dirname( $file ) === '.' ? basename( $file, '.php' ) : dirname( $file ), 'name' => $data['Name'], 'version' => $data['Version'], 'active' => is_plugin_active( $file ), ); } foreach ( wp_get_themes() as $slug => $theme ) { $code[] = array( 'type' => 'theme', 'slug' => (string) $slug, 'name' => $theme->get( 'Name' ), 'version' => $theme->get( 'Version' ), 'active' => get_stylesheet() === $slug || get_template() === $slug, ); } $divi = wp_get_theme( 'Divi' ); return array( 'home' => home_url(), 'siteurl' => site_url(), 'wp' => get_bloginfo( 'version' ), 'divi' => $divi->exists() ? (string) $divi->get( 'Version' ) : '', 'plugin' => WONDERFUL_SYNC_VERSION, 'hashes' => Normalizer::VERSION, 'code' => $code, ); } }