path = $path; } /** * Read just the manifest, without extracting anything. * * The validate() method below extracts every entry to a working directory, which is * right for a module package of a few megabytes and wrong for a whole-site * archive: it would need a second copy of the site on disk before deciding * what to do with it. A restore needs to know the kind first, and that is * one entry. * * @param string $path Package path. * @return array|WP_Error */ public static function peek( string $path ) { if ( ! is_readable( $path ) ) { return new WP_Error( 'transtrix_package_unreadable', __( 'The package file could not be read.', 'transtrix' ) ); } $zip = new ZipArchive(); if ( true !== $zip->open( $path ) ) { return new WP_Error( 'transtrix_package_not_zip', __( 'The package is not a readable .zip file.', 'transtrix' ) ); } $raw = $zip->getFromName( 'manifest.json' ); $zip->close(); if ( false === $raw ) { return new WP_Error( 'transtrix_manifest_missing', __( 'This file is not a Transtrix package.', 'transtrix' ) ); } $decoded = json_decode( $raw, true ); if ( ! is_array( $decoded ) ) { return new WP_Error( 'transtrix_manifest_invalid', __( 'This package is damaged and cannot be read. Export it again.', 'transtrix' ) ); } if ( (int) ( isset( $decoded['format_version'] ) ? $decoded['format_version'] : 0 ) > Manifest::FORMAT_VERSION ) { return new WP_Error( 'transtrix_package_too_new', __( 'This package was built by a newer version of Transtrix. Update the plugin and try again.', 'transtrix' ) ); } if ( ! isset( $decoded['kind'] ) ) { $decoded['kind'] = Manifest::KIND_MODULE; } return $decoded; } /** * The module a package was exported by, from its manifest. * * Its reference hints are the only ones that can rewrite its ids: importing * a WP Travel Engine package with the core module's hints leaves every * package, gallery and service id pointing at the source site. * * @param string $path Package path. * @return string Empty when the manifest does not say. */ public static function module_id( string $path ): string { $manifest = self::peek( $path ); return is_array( $manifest ) && isset( $manifest['module_id'] ) ? sanitize_key( (string) $manifest['module_id'] ) : ''; } /** * Extract and validate the package. * * @return true|WP_Error */ public function validate() { if ( '' !== $this->work_dir ) { return true; } if ( ! is_readable( $this->path ) ) { return new WP_Error( 'transtrix_package_unreadable', __( 'The package file could not be read.', 'transtrix' ) ); } $zip = new ZipArchive(); if ( true !== $zip->open( $this->path ) ) { return new WP_Error( 'transtrix_package_not_zip', __( 'The package is not a readable .zip file.', 'transtrix' ) ); } $peeked = self::peek( $this->path ); if ( is_wp_error( $peeked ) ) { $zip->close(); return $peeked; } $prefixes = Manifest::KIND_FULL_SITE === $peeked['kind'] ? array_merge( self::ALLOWED_PREFIXES, self::FULL_SITE_PREFIXES ) : self::ALLOWED_PREFIXES; $dir = $this->make_work_dir(); if ( is_wp_error( $dir ) ) { $zip->close(); return $dir; } $written = 0; $refused = array(); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- ZipArchive's own property name. $total = $zip->numFiles; for ( $i = 0; $i < $total; $i++ ) { $entry = $zip->getNameIndex( $i ); if ( ! is_string( $entry ) || ! $this->is_safe_entry( $entry, $prefixes ) ) { continue; } // Media lands in uploads before the importer vets it, so a file type // this site would refuse (a .php) never reaches disk. The importer // reports it as missing. Whole-site files/ carries plugin code by design. if ( 0 === strpos( $entry, 'media/' ) && ! wp_check_filetype( $entry )['ext'] ) { $refused[] = $entry; continue; } $target = $dir . $entry; if ( ! wp_mkdir_p( dirname( $target ) ) ) { continue; } $written = $this->extract_entry( $zip, $entry, $target, $written ); if ( $written > self::MAX_EXTRACTED_BYTES ) { $zip->close(); $this->work_dir = $dir; $this->cleanup(); return new WP_Error( 'transtrix_package_too_large', __( 'The package expands to more than this site accepts.', 'transtrix' ) ); } } $zip->close(); // From here the extracted copy exists: every refusal removes it, so a // rejected package leaves no files behind in the temp directory. $this->work_dir = $dir; $manifest_path = $dir . 'manifest.json'; if ( ! is_readable( $manifest_path ) ) { $this->cleanup(); return new WP_Error( 'transtrix_manifest_missing', __( 'This file is not a Transtrix package.', 'transtrix' ) ); } $decoded = json_decode( (string) file_get_contents( $manifest_path ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- direct file I/O; WP_Filesystem needs credentials a REST request does not have. if ( ! is_array( $decoded ) ) { $this->cleanup(); return new WP_Error( 'transtrix_manifest_invalid', __( 'This package is damaged and cannot be read. Export it again.', 'transtrix' ) ); } $manifest = Manifest::from_array( $decoded ); if ( (int) $manifest->get( 'format_version', 0 ) > Manifest::FORMAT_VERSION ) { $this->cleanup(); return new WP_Error( 'transtrix_package_too_new', __( 'This package was built by a newer version of Transtrix. Update the plugin and try again.', 'transtrix' ) ); } if ( ! $manifest->verify_checksums( $dir, $refused ) ) { $this->cleanup(); return new WP_Error( 'transtrix_checksum_mismatch', __( 'This package was damaged in transit. Download or copy it again.', 'transtrix' ) ); } $this->manifest = $manifest; return true; } /** * The validated manifest. * * @return Manifest|null Null when the package has not validated. */ public function manifest(): ?Manifest { return $this->manifest; } /** * The post records the package carries. * * @return array> */ public function posts(): array { return $this->read_json( 'content/posts.json' ); } /** * Terms the package carries in their own right. * * Referenced by id from a meta value rather than assigned to any post, so * they do not appear in a post record's own terms. * * @return array> */ public function terms(): array { return $this->read_json( 'content/terms.json' ); } /** * Global settings the module's posts depend on, option name => keys. * * Empty for packages written before settings travelled. * * @return array> */ public function settings(): array { return $this->read_json( 'content/settings.json' ); } /** * The module's own payload. Empty for packages written before it existed. * * @return array */ public function payload(): array { return $this->read_json( 'content/payload.json' ); } /** * The media records the package carries. * * @return array> */ public function media(): array { return $this->read_json( 'content/media.json' ); } /** * Where the package was extracted, so media files can be read back. * * @return string Empty string when the package has not validated. */ public function work_dir(): string { return $this->work_dir; } /** * Decode one JSON file from the extracted package. * * @param string $relative_path Path inside the package. * @return array> */ private function read_json( string $relative_path ): array { if ( '' === $this->work_dir ) { return array(); } $path = $this->work_dir . $relative_path; if ( ! is_readable( $path ) ) { return array(); } $decoded = json_decode( (string) file_get_contents( $path ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- direct file I/O; WP_Filesystem needs credentials a REST request does not have. return is_array( $decoded ) ? $decoded : array(); } /** * Remove the extracted copy. * * @return void */ public function cleanup(): void { if ( '' === $this->work_dir ) { return; } self::delete_tree( $this->work_dir ); $this->work_dir = ''; $this->manifest = null; } /** * Delete a directory this class created, deepest first. * * Scoped to the work directory: it only ever contains what is_safe_entry() * allowed through, which is why a recursive delete is safe here and would * not be anywhere else. * * @param string $dir Directory to remove. * @return void */ private static function delete_tree( string $dir ): void { $entries = glob( trailingslashit( $dir ) . '*' ); foreach ( is_array( $entries ) ? $entries : array() as $entry ) { if ( is_dir( $entry ) ) { self::delete_tree( $entry ); continue; } wp_delete_file( $entry ); } @rmdir( $dir ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- direct file I/O; WP_Filesystem needs credentials a REST request does not have. } /** * Remove extracted copies a request died before cleaning up. * * Every validate() removes its copy when the run ends; one older than a day * was left by a fatal error or a timeout. * * @return void */ public static function sweep_stale(): void { $store = Package_Store::dir(); if ( is_wp_error( $store ) ) { return; } foreach ( (array) glob( $store . 'work/extract-*', GLOB_ONLYDIR ) as $dir ) { if ( is_string( $dir ) && filemtime( $dir ) < time() - DAY_IN_SECONDS ) { self::delete_tree( $dir ); } } } /** * Whether a zip entry is one we are willing to write to disk. * * The allow-list is the real defence; the traversal checks are the belt to * its braces, because an allow-list is only as good as the next edit to it. * * @param string $entry Entry name from the archive. * @param string[] $prefixes Directory prefixes allowed for this package kind. * @return bool */ private function is_safe_entry( string $entry, array $prefixes ): bool { $allowed = in_array( $entry, self::ALLOWED_ENTRIES, true ); foreach ( $prefixes as $prefix ) { if ( 0 === strpos( $entry, $prefix ) && strlen( $entry ) > strlen( $prefix ) ) { $allowed = true; } } if ( ! $allowed ) { return false; } if ( false !== strpos( $entry, '..' ) ) { return false; } if ( 0 === strpos( $entry, '/' ) || 1 === preg_match( '#^[a-zA-Z]:#', $entry ) ) { return false; } return true; } /** * Stream one entry to disk, stopping once the running total passes the cap. * * @param ZipArchive $zip Open archive. * @param string $entry Entry name. * @param string $target Destination file. * @param int $written Bytes written so far. * @return int Bytes written so far, including this entry. */ private function extract_entry( ZipArchive $zip, string $entry, string $target, int $written ): int { $in = $zip->getStream( $entry ); if ( false === $in ) { return $written; } $out = fopen( $target, 'wb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- direct file I/O; WP_Filesystem needs credentials a REST request does not have. if ( false === $out ) { fclose( $in ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- direct file I/O; WP_Filesystem needs credentials a REST request does not have. return $written; } while ( ! feof( $in ) && $written <= self::MAX_EXTRACTED_BYTES ) { $chunk = fread( $in, 1048576 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- direct file I/O; WP_Filesystem needs credentials a REST request does not have. if ( false === $chunk || '' === $chunk ) { break; } $written += strlen( $chunk ); fwrite( $out, $chunk ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- direct file I/O; WP_Filesystem needs credentials a REST request does not have. } fclose( $in ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- direct file I/O; WP_Filesystem needs credentials a REST request does not have. fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- direct file I/O; WP_Filesystem needs credentials a REST request does not have. return $written; } /** * Create a private directory to extract into. * * @return string|WP_Error Trailing-slashed path, or an error. */ private function make_work_dir() { $store = Package_Store::dir(); if ( is_wp_error( $store ) ) { return $store; } // Inside the guarded package store, never the server's temp directory outside WordPress. $base = $store . 'work/extract-' . wp_generate_password( 12, false, false ) . '/'; if ( ! wp_mkdir_p( $base ) ) { return new WP_Error( 'transtrix_workdir_failed', __( 'A temporary folder could not be created. Check that the uploads folder is writable.', 'transtrix' ) ); } return $base; } }