diff --git a/src/class-ss-util.php b/src/class-ss-util.php index 5500432f..bfc6cd14 100644 --- a/src/class-ss-util.php +++ b/src/class-ss-util.php @@ -2825,6 +2825,22 @@ public static function add_archive_status_message( $messages, $task_name, $messa public static function abs_path_to_url( $path = '' ) { $normalized_path = wp_normalize_path( $path ); + // Uploads may live outside WP_CONTENT_DIR (or behind a symlink), so use + // WordPress's authoritative filesystem-to-URL mapping before checking the + // standard WordPress roots. + if ( function_exists( 'wp_upload_dir' ) ) { + $upload_dir = wp_upload_dir(); + if ( is_array( $upload_dir ) && ! empty( $upload_dir['basedir'] ) && ! empty( $upload_dir['baseurl'] ) ) { + $normalized_upload_dir = wp_normalize_path( untrailingslashit( $upload_dir['basedir'] ) ); + + if ( $normalized_path === $normalized_upload_dir || 0 === strpos( $normalized_path, $normalized_upload_dir . '/' ) ) { + $url = untrailingslashit( $upload_dir['baseurl'] ) . substr( $normalized_path, strlen( $normalized_upload_dir ) ); + + return esc_url_raw( $url ); + } + } + } + // Check if the path is within WP_CONTENT_DIR if ( defined( 'WP_CONTENT_DIR' ) && defined( 'WP_CONTENT_URL' ) ) { $normalized_content_dir = wp_normalize_path( untrailingslashit( WP_CONTENT_DIR ) ); diff --git a/src/tasks/class-ss-create-zip-archive.php b/src/tasks/class-ss-create-zip-archive.php index 7a61438c..096561dd 100644 --- a/src/tasks/class-ss-create-zip-archive.php +++ b/src/tasks/class-ss-create-zip-archive.php @@ -191,9 +191,7 @@ public function create_zip() { do_action( 'ss_zip_file_created', $zip_archive ); - $download_url = Util::abs_path_to_url( $zip_archive->zipname ); - - return $download_url; + return $this->get_zip_download_url( $zip_archive->zipname ); } /** @@ -215,7 +213,35 @@ private function create_empty_zip( $zip_filename ) { do_action( 'ss_zip_file_created', (object) array( 'zipname' => $zip_filename ) ); - return Util::abs_path_to_url( $zip_filename ); + return $this->get_zip_download_url( $zip_filename ); + } + + /** + * Validate a completed ZIP and resolve its public download URL. + * + * @param string $zip_filename Full path to the zip file. + * + * @return string|WP_Error Download URL on success, WP_Error otherwise. + */ + private function get_zip_download_url( $zip_filename ) { + clearstatcache( true, $zip_filename ); + + if ( ! is_file( $zip_filename ) || ! is_readable( $zip_filename ) || filesize( $zip_filename ) < 22 ) { + return new \WP_Error( + 'zip_file_missing', + __( 'The ZIP archive could not be found after it was created.', 'simply-static' ) + ); + } + + $download_url = Util::abs_path_to_url( $zip_filename ); + if ( empty( $download_url ) ) { + return new \WP_Error( + 'zip_url_unavailable', + __( 'The ZIP archive was created, but its temporary files directory is not publicly accessible. Choose a directory inside the WordPress uploads directory and try again.', 'simply-static' ) + ); + } + + return $download_url; } /** @@ -489,7 +515,6 @@ private function create_zip_batched( $zip_filename, $archive_dir, $batch_offset, do_action( 'ss_zip_file_created', (object) array( 'zipname' => $zip_filename ) ); - $download_url = Util::abs_path_to_url( $zip_filename ); - return $download_url; + return $this->get_zip_download_url( $zip_filename ); } } diff --git a/tests/Unit/UtilSecurityTest.php b/tests/Unit/UtilSecurityTest.php index 23e633b8..0e321a77 100644 --- a/tests/Unit/UtilSecurityTest.php +++ b/tests/Unit/UtilSecurityTest.php @@ -177,6 +177,10 @@ public function test_absolute_paths_are_converted_only_inside_wordpress_roots(): WP_CONTENT_URL . '/simply-static/export.zip', Util::abs_path_to_url( WP_CONTENT_DIR . '/simply-static/export.zip' ) ); + self::assertSame( + WpEnv::$upload_dir['baseurl'] . '/simply-static/temp-files/export.zip', + Util::abs_path_to_url( WpEnv::$upload_dir['basedir'] . '/simply-static/temp-files/export.zip' ) + ); self::assertSame( 'https://example.test/wp-content-backup/export.zip', Util::abs_path_to_url( WP_CONTENT_DIR . '-backup/export.zip' ) diff --git a/tests/Unit/ZipArchiveTaskTest.php b/tests/Unit/ZipArchiveTaskTest.php index d66cb166..200dc832 100644 --- a/tests/Unit/ZipArchiveTaskTest.php +++ b/tests/Unit/ZipArchiveTaskTest.php @@ -106,6 +106,37 @@ public function test_perform_publishes_structured_download_link(): void { self::assertStringEndsWith( '/activity-link-test.zip', $status['link']['url'] ); } + public function test_perform_maps_an_external_uploads_directory_to_its_public_url(): void { + file_put_contents( $this->archive_dir . 'index.html', '

Archived

' ); + + self::assertTrue( ( new Create_Zip_Archive_Task() )->perform() ); + + $zip_file = untrailingslashit( $this->archive_dir ) . '.zip'; + $status = WpEnv::$options['simply-static']['archive_status_messages']['create_zip_archive']; + $relative = substr( wp_normalize_path( $zip_file ), strlen( wp_normalize_path( WpEnv::$upload_dir['basedir'] ) ) ); + + self::assertFileExists( $zip_file ); + self::assertGreaterThanOrEqual( 22, filesize( $zip_file ) ); + self::assertSame( WpEnv::$upload_dir['baseurl'] . $relative, $status['link']['url'] ); + } + + public function test_perform_does_not_report_success_when_the_zip_has_no_public_url(): void { + $private_temp_dir = sys_get_temp_dir() . '/simply-static-private-zip-' . uniqid( '', true ); + $private_archive = trailingslashit( $private_temp_dir ) . 'site/'; + wp_mkdir_p( $private_archive ); + file_put_contents( $private_archive . 'index.html', '

Archived

' ); + + WpEnv::$options['simply-static']['temp_files_dir'] = $private_temp_dir; + Options::reinstance(); + + $result = ( new Create_Zip_Archive_Task() )->perform(); + + self::assertInstanceOf( \WP_Error::class, $result ); + self::assertSame( 'zip_url_unavailable', $result->get_error_code() ); + self::assertFileExists( untrailingslashit( $private_archive ) . '.zip' ); + self::assertArrayNotHasKey( 'create_zip_archive', WpEnv::$options['simply-static']['archive_status_messages'] ); + } + public function test_filtered_files_outside_archive_and_symlink_escapes_are_excluded(): void { file_put_contents( $this->archive_dir . 'safe.txt', 'safe' ); $outside = WpEnv::$upload_dir['basedir'] . '/outside-secret.txt';