| 1 |
<?php |
| 2 |
|
| 3 |
namespace Templately\Modules\FullSiteImport\Concerns; |
| 4 |
|
| 5 |
use Elementor\Plugin; |
| 6 |
use Error; |
| 7 |
use Exception; |
| 8 |
use Templately\Modules\FullSiteImport\Exception\FatalErrorException; |
| 9 |
use Templately\Modules\FullSiteImport\Exception\RetryableErrorException; |
| 10 |
use Templately\Modules\FullSiteImport\Exception\UnknownErrorException; |
| 11 |
use Templately\Modules\FullSiteImport\Runners\Finalizer; |
| 12 |
use Templately\Modules\FullSiteImport\Utils\LogHandler; |
| 13 |
use Templately\Modules\FullSiteImport\Utils\Utils; |
| 14 |
use Templately\Modules\FullSiteImport\Utils\SessionData; |
| 15 |
use Templately\Modules\FullSiteImport\Utils\AIUtils; |
| 16 |
use Templately\Utils\Helper; |
| 17 |
use Templately\Utils\Response\ResponseNormalizer; |
| 18 |
use Templately\Utils\Options; |
| 19 |
|
| 20 |
/** |
| 21 |
* DownloadsPack — extracted verbatim from FullSiteImport (behavior-preserving). |
| 22 |
* Composed back into FullSiteImport via `use`; $this and method resolution unchanged. |
| 23 |
*/ |
| 24 |
trait DownloadsPack { |
| 25 |
/** |
| 26 |
* @throws Exception |
| 27 |
*/ |
| 28 |
private function check_writing_permission() { |
| 29 |
$upload_dir = wp_upload_dir(); |
| 30 |
|
| 31 |
if (!is_writable($upload_dir['basedir'])) { |
| 32 |
$this->throw(__('Upload directory is not writable.', 'templately')); |
| 33 |
} |
| 34 |
|
| 35 |
// Goes through Helper so the wp-uploads/templately root gets its |
| 36 |
// index.php / .htaccess / web.config guards before anything is written |
| 37 |
// into it — the extracted pack lives here and uploads is web-served. |
| 38 |
$this->tmp_dir = Helper::upload_dir('tmp'); |
| 39 |
|
| 40 |
if (!is_dir($this->tmp_dir)) { |
| 41 |
wp_mkdir_p($this->tmp_dir); |
| 42 |
} |
| 43 |
|
| 44 |
$this->sse_log('writing_permission_check', __('Permission Passed', 'templately'), 100); |
| 45 |
} |
| 46 |
|
| 47 |
/** |
| 48 |
* @throws Exception |
| 49 |
*/ |
| 50 |
private function download_zip( $id, $is_ai = false ) { |
| 51 |
$this->sse_log( 'download', __( 'Downloading Template Pack', 'templately' ), 1 ); |
| 52 |
$extra_headers = [ |
| 53 |
// Same normalization as Utils\PackInfoFetcher::fetch() — a raw PHP boolean |
| 54 |
// stringifies to "1"/"" when WP's HTTP layer serializes the header, which the |
| 55 |
// upstream API does not recognize as true/false. Send the literal string. |
| 56 |
'x-templately-is-ai' => rest_sanitize_boolean( $is_ai ) ? 'true' : 'false', |
| 57 |
'x-templately-session-id' => $this->session_id, |
| 58 |
'x-templately-requested-platform' => Helper::get_requested_platform(), |
| 59 |
]; |
| 60 |
// A run that stops in this method used to leave ONE "Downloading Template Pack" |
| 61 |
// line and nothing else — no status, no size, no timing, no way to tell a refused |
| 62 |
// download from a request that simply ended. Everything below narrates it. |
| 63 |
Helper::log( |
| 64 |
sprintf( 'download_zip: requesting pack %s (session=%s, is_ai=%s)', $id, $this->session_id, $is_ai ? 'yes' : 'no' ), |
| 65 |
'download_zip', |
| 66 |
'info' |
| 67 |
); |
| 68 |
|
| 69 |
// The decisive instrument. `register_shutdown_function` runs on a fatal AND on a |
| 70 |
// client disconnect, so if the request ends before the pack reaches disk this says |
| 71 |
// WHICH: `connection_aborted=1` means the browser hung up (PHP has |
| 72 |
// ignore_user_abort=0, so it stops at the next write, silently and with no error), |
| 73 |
// and a non-null `last_error` means PHP died. |
| 74 |
$download_started = microtime( true ); |
| 75 |
$session_id = $this->session_id; |
| 76 |
register_shutdown_function( function () use ( $id, $session_id, $download_started ) { |
| 77 |
if ( defined( 'TEMPLATELY_PACK_SAVED' ) ) { |
| 78 |
return; |
| 79 |
} |
| 80 |
|
| 81 |
Helper::log( |
| 82 |
sprintf( |
| 83 |
'download_zip: request ENDED before the pack was saved after %.1fs (pack=%s, session=%s, connection_aborted=%d, last_error=%s)', |
| 84 |
microtime( true ) - $download_started, |
| 85 |
$id, |
| 86 |
$session_id, |
| 87 |
connection_aborted(), |
| 88 |
wp_json_encode( error_get_last() ) |
| 89 |
), |
| 90 |
'download_zip', |
| 91 |
'error' |
| 92 |
); |
| 93 |
} ); |
| 94 |
|
| 95 |
$response = Helper::make_api_get_request("v2/import/pack/$id", [], $extra_headers, 90); |
| 96 |
|
| 97 |
Helper::log( |
| 98 |
sprintf( |
| 99 |
'download_zip: pack %s answered %s in %.1fs (%s bytes, type=%s, download-key=%s)', |
| 100 |
$id, |
| 101 |
is_wp_error( $response ) ? 'WP_Error: ' . $response->get_error_message() : 'HTTP ' . (int) wp_remote_retrieve_response_code( $response ), |
| 102 |
microtime( true ) - $download_started, |
| 103 |
is_wp_error( $response ) ? '0' : strlen( (string) wp_remote_retrieve_body( $response ) ), |
| 104 |
is_wp_error( $response ) ? '-' : (string) wp_remote_retrieve_header( $response, 'content-type' ), |
| 105 |
wp_remote_retrieve_header( $response, 'download-key' ) ? 'present' : 'missing' |
| 106 |
), |
| 107 |
'download_zip', |
| 108 |
is_wp_error( $response ) ? 'error' : 'info' |
| 109 |
); |
| 110 |
|
| 111 |
// The pack is a ZIP, so the RAW response is kept: the body is bytes to write |
| 112 |
// to disk and `download-key` is a header the session needs. FR-012 — binary |
| 113 |
// payloads pass through the normalizer untouched — so the normalizer is used |
| 114 |
// only to CLASSIFY, never to reshape what gets written. |
| 115 |
$this->download_key = wp_remote_retrieve_header($response, 'download-key'); |
| 116 |
|
| 117 |
$normalized = ResponseNormalizer::normalize($response, [ |
| 118 |
'raw' => true, |
| 119 |
'side_effects' => false, |
| 120 |
]); |
| 121 |
|
| 122 |
if ($normalized->is_error()) { |
| 123 |
$error = $normalized->error(); |
| 124 |
|
| 125 |
Helper::log( |
| 126 |
sprintf( 'download_zip: refused — %s: %s (retryable=%s)', $error->code(), wp_strip_all_tags( $error->message() ), $error->is_retryable() ? 'yes' : 'no' ), |
| 127 |
'download_zip', |
| 128 |
'error' |
| 129 |
); |
| 130 |
|
| 131 |
// Retryability now comes from the registry rather than from guessing at |
| 132 |
// the transport: a dropped connection or a 5xx is worth another attempt, |
| 133 |
// an expired session or a missing pack is not. |
| 134 |
if ($error->is_retryable()) { |
| 135 |
$this->throw_retryable(__('Template pack download failed. ', 'templately') . $error->message()); |
| 136 |
} |
| 137 |
|
| 138 |
$support_message = ''; |
| 139 |
if (strpos($error->message(), Helper::web_url( '', [ 'support' => 'open' ] )) === false) { |
| 140 |
$support_message = sprintf(__(" Please try again or contact <a href='%s' target='_blank'>support</a>.", "templately"), Helper::web_url( '', [ 'support' => 'open' ] )); |
| 141 |
} |
| 142 |
|
| 143 |
$this->throw_non_retryable($error->message() . $support_message); |
| 144 |
} |
| 145 |
|
| 146 |
$this->sse_log('download', __('Downloading Template Pack', 'templately'), 57); |
| 147 |
|
| 148 |
SessionData::set($this->session_id, 'download_key', $this->download_key); |
| 149 |
|
| 150 |
// Security: Validate file path is within WordPress upload directory before writing |
| 151 |
$validation = AIUtils::validate_file_path($this->filePath); |
| 152 |
if (is_wp_error($validation)) { |
| 153 |
$this->throw($validation->get_error_message()); |
| 154 |
} |
| 155 |
|
| 156 |
wp_mkdir_p(dirname($this->filePath)); |
| 157 |
|
| 158 |
// A full disk is not transient: a retry loop against it never resolves. |
| 159 |
// Require the zip size plus headroom for extraction (packs expand). |
| 160 |
$needed = strlen($response['body']) * 3; |
| 161 |
$free = @disk_free_space(dirname($this->filePath)); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- some hosts disable the function; false skips the check. |
| 162 |
if (false !== $free && $free < $needed) { |
| 163 |
$this->throw(__('Not enough disk space to import this pack. Please free up space and try again.', 'templately')); |
| 164 |
} |
| 165 |
|
| 166 |
$written = file_put_contents($this->filePath, $response['body']); // phpcs:ignore |
| 167 |
|
| 168 |
Helper::log( |
| 169 |
sprintf( 'download_zip: wrote %s bytes to %s', var_export( $written, true ), $this->filePath ), |
| 170 |
'download_zip', |
| 171 |
false === $written ? 'error' : 'info' |
| 172 |
); |
| 173 |
|
| 174 |
if ($written) { |
| 175 |
// Past this point the shutdown notice above would be a false alarm. |
| 176 |
if ( ! defined( 'TEMPLATELY_PACK_SAVED' ) ) { |
| 177 |
define( 'TEMPLATELY_PACK_SAVED', true ); |
| 178 |
} |
| 179 |
|
| 180 |
$this->sse_log('download', __('Downloading Template Pack', 'templately'), 100); |
| 181 |
|
| 182 |
$this->unzip(); |
| 183 |
|
| 184 |
Helper::log( sprintf( 'download_zip: unzipped into %s', $this->dir_path ?? '(unset)' ), 'download_zip', 'info' ); |
| 185 |
} else { |
| 186 |
$this->throw_retryable(__('Downloading Failed. Please try again', 'templately')); |
| 187 |
} |
| 188 |
} |
| 189 |
|
| 190 |
/** |
| 191 |
* @throws Exception |
| 192 |
*/ |
| 193 |
protected function unzip() { |
| 194 |
if (!WP_Filesystem()) { |
| 195 |
$this->throw(__('WP_Filesystem cannot be initialized', 'templately')); |
| 196 |
} |
| 197 |
$unzip = unzip_file($this->filePath, $this->dir_path); |
| 198 |
if (is_wp_error($unzip)) { |
| 199 |
// Fall back to our own ZipArchive-based extractor. Some Templately |
| 200 |
// packs carry entries with a leading "./" (or embedded "/./") path |
| 201 |
// segment, which WordPress core's unzip_file() fails to extract. |
| 202 |
// self::unzip_file() extracts with native ZipArchive and normalizes |
| 203 |
// those "./" segments so the pack still imports. See |
| 204 |
// self::unzip_file() for the extraction and path-traversal guard. |
| 205 |
$unzip = $this->unzip_file($this->filePath, $this->dir_path); |
| 206 |
} |
| 207 |
|
| 208 |
if (is_wp_error($unzip)) { |
| 209 |
// Both extractors failed. Previously this result was dropped and the |
| 210 |
// import limped on to a misleading "manifest is corrupted" error. |
| 211 |
if ('zip_extract_write_failed' === $unzip->get_error_code()) { |
| 212 |
// Disk full / not writable — retrying cannot fix it. |
| 213 |
$this->throw($unzip->get_error_message()); |
| 214 |
} |
| 215 |
// A corrupt/truncated archive is often a transient download problem — |
| 216 |
// a retry re-downloads the zip. |
| 217 |
$this->throw_retryable($unzip->get_error_message()); |
| 218 |
} |
| 219 |
|
| 220 |
// Core's unzip_file() extracts whatever the archive holds, and the pack |
| 221 |
// lands under web-served wp-uploads. Sweep before anything else touches |
| 222 |
// the tree, so the relocation below cannot copy an executable upward. |
| 223 |
$this->purge_disallowed_files($this->dir_path); |
| 224 |
|
| 225 |
$manifest_file = $this->dir_path . 'manifest.json'; |
| 226 |
|
| 227 |
// If manifest.json is missing, but any subdirectory contains manifest.json, move all its contents up and remove the subdirectory. |
| 228 |
if ( ! file_exists( $manifest_file ) ) { |
| 229 |
$entries = array_diff( scandir( $this->dir_path ), [ '.', '..' ] ); |
| 230 |
// Plain closures, not arrow functions: this file must parse on the |
| 231 |
// advertised PHP 7.2 floor (`Requires PHP` in readme.txt), and `fn()` |
| 232 |
// is 7.4+. `$this` is bound in a closure declared inside an instance |
| 233 |
// method exactly as it is in an arrow function, so `$this->dir_path` |
| 234 |
// resolves identically and no `use` clause is needed. |
| 235 |
$dirs = array_filter( $entries, function( $e ) { |
| 236 |
return is_dir( $this->dir_path . $e ); |
| 237 |
} ); |
| 238 |
$files = array_filter( $entries, function( $e ) { |
| 239 |
return is_file( $this->dir_path . $e ); |
| 240 |
} ); |
| 241 |
foreach ($dirs as $subdir) { |
| 242 |
$subdir_path = $this->dir_path . $subdir . DIRECTORY_SEPARATOR; |
| 243 |
if ( file_exists( $subdir_path . 'manifest.json' ) ) { |
| 244 |
copy($subdir_path . 'manifest.json', $manifest_file); |
| 245 |
|
| 246 |
foreach ( array_diff( scandir( $subdir_path ), [ '.', '..' ] ) as $item ) { |
| 247 |
$src = $subdir_path . $item; |
| 248 |
$dst = $this->dir_path . $item; |
| 249 |
if (is_dir($src)) { |
| 250 |
if (!file_exists($dst)) { |
| 251 |
wp_mkdir_p($dst); |
| 252 |
} |
| 253 |
// Recursively copy directory |
| 254 |
$this->copyDirectory($src, $dst); |
| 255 |
} else { |
| 256 |
copy($src, $dst); |
| 257 |
} |
| 258 |
} |
| 259 |
// Remove the subdirectory and its contents |
| 260 |
$this->removeDirectory($subdir_path); |
| 261 |
break; // Only process the first subdir with manifest.json |
| 262 |
} |
| 263 |
} |
| 264 |
} |
| 265 |
|
| 266 |
if (is_wp_error($unzip)) { |
| 267 |
$error = $unzip->get_error_message(); |
| 268 |
if (empty($error)) { |
| 269 |
// Generic error message |
| 270 |
Helper::log($unzip); |
| 271 |
$error_message = sprintf(__("It seems we're experiencing technical difficulties. Please try again or contact <a href='%s' target='_blank'>support</a>.", "templately"), Helper::web_url( '', [ 'support' => 'open' ] )); |
| 272 |
$this->throw($error_message); |
| 273 |
} else { |
| 274 |
$this->throw($unzip->get_error_message()); |
| 275 |
} |
| 276 |
} |
| 277 |
|
| 278 |
if ($unzip) { |
| 279 |
unlink($this->filePath); |
| 280 |
} |
| 281 |
} |
| 282 |
|
| 283 |
/** |
| 284 |
* Recursively copy a directory |
| 285 |
*/ |
| 286 |
private function copyDirectory($src, $dst) { |
| 287 |
$dir = opendir($src); |
| 288 |
wp_mkdir_p($dst); |
| 289 |
while(false !== ($file = readdir($dir))) { |
| 290 |
if (($file != '.') && ($file != '..')) { |
| 291 |
if (is_dir($src . DIRECTORY_SEPARATOR . $file)) { |
| 292 |
$this->copyDirectory($src . DIRECTORY_SEPARATOR . $file, $dst . DIRECTORY_SEPARATOR . $file); |
| 293 |
} else { |
| 294 |
copy($src . DIRECTORY_SEPARATOR . $file, $dst . DIRECTORY_SEPARATOR . $file); |
| 295 |
} |
| 296 |
} |
| 297 |
} |
| 298 |
closedir($dir); |
| 299 |
} |
| 300 |
|
| 301 |
/** |
| 302 |
* Recursively remove a directory |
| 303 |
*/ |
| 304 |
private function removeDirectory($dir) { |
| 305 |
if (!file_exists($dir)) return; |
| 306 |
$items = array_diff(scandir($dir), ['.', '..']); |
| 307 |
foreach ($items as $item) { |
| 308 |
$path = $dir . DIRECTORY_SEPARATOR . $item; |
| 309 |
if (is_dir($path)) { |
| 310 |
$this->removeDirectory($path); |
| 311 |
} else { |
| 312 |
unlink($path); |
| 313 |
} |
| 314 |
} |
| 315 |
rmdir($dir); |
| 316 |
} |
| 317 |
|
| 318 |
/** |
| 319 |
* Unzip a specified ZIP file to a location on the Filesystem. |
| 320 |
* |
| 321 |
* Why this custom extractor exists: some Templately packs carry entries with |
| 322 |
* a leading "./" (or embedded "/./") path segment, which WordPress core's |
| 323 |
* unzip_file() fails to extract. This method extracts with PHP's native |
| 324 |
* ZipArchive and normalizes the redundant "./" segments (see |
| 325 |
* normalize_zip_entry_name()) so the pack still imports. It is a fallback: |
| 326 |
* self::unzip() only calls it after the core unzip_file() returns a WP_Error. |
| 327 |
* |
| 328 |
* Each archive member is validated before extraction rather than |
| 329 |
* calling ZipArchive::extractTo() blindly: entries that resolve outside the |
| 330 |
* destination (path traversal / "Zip Slip"), absolute paths, and Windows |
| 331 |
* drive-letter paths are skipped via validate_file(), mirroring the guard |
| 332 |
* WordPress core applies in _unzip_file_ziparchive(). Redundant "./" path |
| 333 |
* segments are normalized first so members land at their intended location. |
| 334 |
* |
| 335 |
* @param string $file Full path and filename of ZIP archive. |
| 336 |
* @param string $to Full path on the filesystem to extract archive to. |
| 337 |
* @return true|WP_Error True on success, WP_Error on failure. |
| 338 |
*/ |
| 339 |
/** |
| 340 |
* Extensions an extracted pack member is permitted to use. |
| 341 |
* |
| 342 |
* Deliberately an allowlist, and deliberately WordPress's own: naming the |
| 343 |
* dangerous extensions instead means being exhaustive about `.phtml`, `.pht`, |
| 344 |
* `.phar`, `.cgi`, `.htaccess`, `.user.ini` and whatever a future server |
| 345 |
* config decides to execute — one omission and the guard is silent. |
| 346 |
* get_allowed_mime_types() already encodes what this site accepts, follows |
| 347 |
* the `upload_mimes` filter, and gains new formats as WordPress does. |
| 348 |
* |
| 349 |
* @return array Extension => true lookup. |
| 350 |
*/ |
| 351 |
protected static function allowed_pack_extensions() { |
| 352 |
$allowed = array_fill_keys(self::PACK_EXTENSIONS, true); |
| 353 |
|
| 354 |
// Keys are alternation patterns: 'jpg|jpeg|jpe' => 'image/jpeg'. |
| 355 |
foreach (array_keys(get_allowed_mime_types()) as $pattern) { |
| 356 |
foreach (explode('|', $pattern) as $extension) { |
| 357 |
$allowed[$extension] = true; |
| 358 |
} |
| 359 |
} |
| 360 |
|
| 361 |
return $allowed; |
| 362 |
} |
| 363 |
|
| 364 |
/** |
| 365 |
* Segments that must never appear anywhere in a pack member's name. |
| 366 |
* |
| 367 |
* This one IS a denylist, and only because it is applied to the segments |
| 368 |
* *before* the real extension, which the allowlist has already vetted. A |
| 369 |
* permissive `AddHandler` runs `shell.php.gif` as PHP, so an inner segment |
| 370 |
* still has to be refused — but refusing every inner segment the allowlist |
| 371 |
* does not know would delete ordinary names like `style.min.css` or |
| 372 |
* `hero.2x.png`, where the inner segment is a word, not an extension. |
| 373 |
* |
| 374 |
* An omission here is far less serious than in the allowlist: it only |
| 375 |
* matters for a name whose final extension is already an accepted upload |
| 376 |
* type, on a server configured to hand an inner extension to a handler. |
| 377 |
* |
| 378 |
* A METHOD, not a `const`: this is a trait, and constants in traits are a |
| 379 |
* PHP 8.2 feature — declaring one here is a parse-time fatal on every host |
| 380 |
* below that, which is most of the supported range. `latest` carries the |
| 381 |
* same list as `FullSiteImport::EXECUTABLE_SEGMENTS` because there it sits |
| 382 |
* on a class. |
| 383 |
* |
| 384 |
* @return array Segment => true lookup. |
| 385 |
*/ |
| 386 |
protected static function executable_segments() { |
| 387 |
return array_fill_keys( [ |
| 388 |
'php', 'php3', 'php4', 'php5', 'php6', 'php7', 'php8', |
| 389 |
'phps', 'phtml', 'phtm', 'pht', 'phar', 'inc', |
| 390 |
'cgi', 'fcgi', 'pl', 'py', 'rb', 'sh', 'bash', 'ksh', 'csh', 'zsh', |
| 391 |
'asp', 'aspx', 'ascx', 'ashx', 'asmx', 'cfm', 'cfml', |
| 392 |
'jsp', 'jspx', 'jar', 'war', |
| 393 |
'shtml', 'shtm', |
| 394 |
'exe', 'com', 'bat', 'cmd', 'dll', 'so', |
| 395 |
'htaccess', 'htpasswd', 'ini', 'env', 'conf', |
| 396 |
], true ); |
| 397 |
} |
| 398 |
|
| 399 |
/** |
| 400 |
* Whether an archive entry (or extracted file) is something a pack may hold. |
| 401 |
* |
| 402 |
* Two different rules, because a filename's dots do not all mean the same |
| 403 |
* thing. The LAST segment is the extension the server dispatches on, so it |
| 404 |
* is checked against the allowlist. The segments before it are usually just |
| 405 |
* part of the name — `style.min.css`, `hero.2x.png`, `logo.v2.png`, |
| 406 |
* `12.ai.json` — so they are only checked against EXECUTABLE_SEGMENTS, |
| 407 |
* which is what still refuses `shell.php.gif` under a permissive |
| 408 |
* `AddHandler`. |
| 409 |
* |
| 410 |
* A name with no extension at all is refused, which is what catches |
| 411 |
* `.htaccess`, `.user.ini` and `.env` — none of them have one. |
| 412 |
* |
| 413 |
* @param string $name Entry name or file path. |
| 414 |
* @param array $allowed Lookup from allowed_pack_extensions(). Built on |
| 415 |
* demand when omitted; pass it in when looping. |
| 416 |
* |
| 417 |
* @return bool |
| 418 |
*/ |
| 419 |
protected static function is_allowed_entry($name, $allowed = null) { |
| 420 |
if (null === $allowed) { |
| 421 |
$allowed = self::allowed_pack_extensions(); |
| 422 |
} |
| 423 |
|
| 424 |
$segments = explode('.', strtolower(wp_basename($name))); |
| 425 |
|
| 426 |
// No extension, or a leading-dot name whose only "segment" is empty. |
| 427 |
if (count($segments) < 2 || '' === $segments[0]) { |
| 428 |
return false; |
| 429 |
} |
| 430 |
|
| 431 |
$extension = array_pop($segments); |
| 432 |
if (!isset($allowed[$extension])) { |
| 433 |
return false; |
| 434 |
} |
| 435 |
|
| 436 |
$executable = self::executable_segments(); |
| 437 |
|
| 438 |
// array_slice() drops the base name; only what sits between it and the |
| 439 |
// extension is a masking risk. |
| 440 |
foreach (array_slice($segments, 1) as $segment) { |
| 441 |
if (isset($executable[$segment])) { |
| 442 |
return false; |
| 443 |
} |
| 444 |
} |
| 445 |
|
| 446 |
return true; |
| 447 |
} |
| 448 |
|
| 449 |
/** |
| 450 |
* Deletes anything an extracted pack has no business containing. |
| 451 |
* |
| 452 |
* The fallback extractor below refuses these entries outright, but WordPress |
| 453 |
* core's unzip_file() runs first and has no such filter, so the guard has to |
| 454 |
* exist on the extracted tree as well. |
| 455 |
* |
| 456 |
* @param string $dir Extracted pack root. |
| 457 |
* |
| 458 |
* @return int Number of files removed. |
| 459 |
*/ |
| 460 |
protected function purge_disallowed_files($dir) { |
| 461 |
if (empty($dir) || !is_dir($dir)) { |
| 462 |
return 0; |
| 463 |
} |
| 464 |
|
| 465 |
$removed = 0; |
| 466 |
$allowed = self::allowed_pack_extensions(); |
| 467 |
|
| 468 |
try { |
| 469 |
$files = new \RecursiveIteratorIterator( |
| 470 |
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), |
| 471 |
\RecursiveIteratorIterator::CHILD_FIRST |
| 472 |
); |
| 473 |
|
| 474 |
foreach ($files as $fileinfo) { |
| 475 |
if (!$fileinfo->isFile() || self::is_allowed_entry($fileinfo->getFilename(), $allowed)) { |
| 476 |
continue; |
| 477 |
} |
| 478 |
|
| 479 |
if (@unlink($fileinfo->getPathname())) { |
| 480 |
$removed++; |
| 481 |
Helper::log($fileinfo->getPathname(), 'unzip: removed disallowed file from extracted pack', 'warning'); |
| 482 |
} |
| 483 |
} |
| 484 |
} catch (\Exception $e) { |
| 485 |
Helper::log($e->getMessage(), 'purge_disallowed_files', 'warning'); |
| 486 |
} |
| 487 |
|
| 488 |
return $removed; |
| 489 |
} |
| 490 |
|
| 491 |
function unzip_file($file, $to) { |
| 492 |
$zip = new \ZipArchive; |
| 493 |
|
| 494 |
$res = $zip->open($file); |
| 495 |
if ($res !== TRUE) { |
| 496 |
// Report the open() RETURN CODE, never the handle. Reading |
| 497 |
// $zip->status / $zip->getStatusString() here interrogates an |
| 498 |
// archive that was never opened: ext-zip before PHP 8.0 answers |
| 499 |
// that with "Invalid or uninitialized Zip object" — a warning |
| 500 |
// raised inside the advertised PHP range (readme floor 7.2), on |
| 501 |
// the very path that reports a corrupt download. |
| 502 |
return new \WP_Error('zip_error_' . $res, sprintf( |
| 503 |
/* translators: %d: PHP ZipArchive::open() error code. */ |
| 504 |
__('Could not open the downloaded archive (ZipArchive error code %d).', 'templately'), |
| 505 |
$res |
| 506 |
)); |
| 507 |
} |
| 508 |
|
| 509 |
// Close the archive handle on every exit path (success, mid-loop |
| 510 |
// exception, or early return) so it is never leaked. |
| 511 |
try { |
| 512 |
$to = trailingslashit($to); |
| 513 |
|
| 514 |
$allowed_extensions = self::allowed_pack_extensions(); |
| 515 |
|
| 516 |
for ($i = 0; $i < $zip->numFiles; $i++) { |
| 517 |
$name = $zip->getNameIndex($i); |
| 518 |
if ($name === false) { |
| 519 |
continue; |
| 520 |
} |
| 521 |
|
| 522 |
// Normalize redundant "./" segments so the destination path |
| 523 |
// is computed from a clean entry name. |
| 524 |
$name = $this->normalize_zip_entry_name($name); |
| 525 |
if ($name === '') { |
| 526 |
continue; // Archive root (e.g. a "./" entry). |
| 527 |
} |
| 528 |
|
| 529 |
// Skip the OS X-created __MACOSX directory. |
| 530 |
if (strpos($name, '__MACOSX/') === 0) { |
| 531 |
continue; |
| 532 |
} |
| 533 |
|
| 534 |
// Don't extract invalid files: reject "../" traversal, |
| 535 |
// absolute, and drive-letter paths so no member can be |
| 536 |
// written outside $to. Log the skipped entry name so a |
| 537 |
// hostile or corrupt pack leaves a forensic trail rather |
| 538 |
// than silently extracting only part of its contents. |
| 539 |
if (0 !== validate_file($name)) { |
| 540 |
Helper::log($name, 'unzip_file: skipped unsafe archive entry', 'warning'); |
| 541 |
continue; |
| 542 |
} |
| 543 |
|
| 544 |
// validate_file() stops a member escaping $to; it says nothing |
| 545 |
// about what the member *is*. $to lives under web-served |
| 546 |
// wp-uploads, so an executable member would be directly |
| 547 |
// requestable. |
| 548 |
if (substr($name, -1) !== '/' && !self::is_allowed_entry($name, $allowed_extensions)) { |
| 549 |
Helper::log($name, 'unzip_file: skipped disallowed archive entry', 'warning'); |
| 550 |
continue; |
| 551 |
} |
| 552 |
|
| 553 |
if (substr($name, -1) === '/') { |
| 554 |
// Directory entry. |
| 555 |
wp_mkdir_p($to . untrailingslashit($name)); |
| 556 |
continue; |
| 557 |
} |
| 558 |
|
| 559 |
$contents = $zip->getFromIndex($i); |
| 560 |
if ($contents === false) { |
| 561 |
// An unreadable member means a corrupt archive. Fail HERE — |
| 562 |
// silently continuing produced a partial extract whose |
| 563 |
// missing/truncated JSON only surfaced runners later as an |
| 564 |
// unrelated "corrupted" error. |
| 565 |
return new \WP_Error( |
| 566 |
'zip_member_unreadable', |
| 567 |
sprintf( |
| 568 |
/* translators: %s: archive entry name */ |
| 569 |
__('The template pack archive is corrupted (unreadable entry: %s). Please try again.', 'templately'), |
| 570 |
$name |
| 571 |
) |
| 572 |
); |
| 573 |
} |
| 574 |
|
| 575 |
$target = $to . $name; |
| 576 |
wp_mkdir_p(dirname($target)); |
| 577 |
$written = file_put_contents($target, $contents); // phpcs:ignore |
| 578 |
if (false === $written || $written < strlen($contents)) { |
| 579 |
// Disk full / permissions mid-extraction: fail loudly now, |
| 580 |
// not later when a runner reads the truncated file. |
| 581 |
return new \WP_Error( |
| 582 |
'zip_extract_write_failed', |
| 583 |
__('Could not write the template pack to disk (disk full or not writable). Please free up space and try again.', 'templately') |
| 584 |
); |
| 585 |
} |
| 586 |
} |
| 587 |
|
| 588 |
return true; |
| 589 |
} catch (\Throwable $th) { |
| 590 |
return new \WP_Error('exception_caught', $th->getMessage()); |
| 591 |
} finally { |
| 592 |
$zip->close(); |
| 593 |
} |
| 594 |
} |
| 595 |
|
| 596 |
/** |
| 597 |
* Removes redundant current-directory ("./") segments from a ZIP entry path. |
| 598 |
* |
| 599 |
* Some archive tools store entry names with a leading "./" or embedded "/./" |
| 600 |
* segment (for example "./manifest.json" or "content/./page.json"). Parent |
| 601 |
* ("..") segments are intentionally left untouched so validate_file() can |
| 602 |
* still reject them. |
| 603 |
* |
| 604 |
* @param string $name A ZIP archive entry path. |
| 605 |
* @return string The entry path with current-directory segments removed. |
| 606 |
*/ |
| 607 |
protected function normalize_zip_entry_name($name) { |
| 608 |
// Fast path: bail when there is no current-directory segment to remove. |
| 609 |
if ('.' !== $name |
| 610 |
&& strpos($name, './') !== 0 |
| 611 |
&& strpos($name, '/./') === false |
| 612 |
&& substr($name, -2) !== '/.' |
| 613 |
) { |
| 614 |
return $name; |
| 615 |
} |
| 616 |
|
| 617 |
// A trailing slash, or a trailing "/." or bare ".", denotes a directory. |
| 618 |
$is_directory = substr($name, -1) === '/' || substr($name, -2) === '/.' || '.' === $name; |
| 619 |
|
| 620 |
$segments = array(); |
| 621 |
foreach (explode('/', $name) as $segment) { |
| 622 |
if ('.' !== $segment) { |
| 623 |
$segments[] = $segment; |
| 624 |
} |
| 625 |
} |
| 626 |
|
| 627 |
$name = implode('/', $segments); |
| 628 |
|
| 629 |
// Preserve the trailing slash that marks a directory entry. |
| 630 |
if ($is_directory && '' !== $name && substr($name, -1) !== '/') { |
| 631 |
$name .= '/'; |
| 632 |
} |
| 633 |
|
| 634 |
return $name; |
| 635 |
} |
| 636 |
|
| 637 |
/** |
| 638 |
* @throws Exception |
| 639 |
*/ |
| 640 |
private function read_manifest($dir_path) { |
| 641 |
$manifest_content = file_get_contents($dir_path . 'manifest.json'); |
| 642 |
if (empty($manifest_content)) { |
| 643 |
$this->throw(__('Cannot be imported, as the manifest file is corrupted', 'templately')); |
| 644 |
} |
| 645 |
|
| 646 |
$manifest_content = json_decode($manifest_content, true); |
| 647 |
$this->removeLog('temp'); |
| 648 |
|
| 649 |
return $manifest_content; |
| 650 |
// TODO: Read & Broadcast the LOG for waiting list |
| 651 |
// $this->sse_log( 'plugin', 'Installing required plugins', '--', 'updateLog', 'processing' ); |
| 652 |
// // $this->sse_log( 'extra-content', 'Import Extra Contents (i.e: Forms)', '--', 'updateLog', 'processing' ); |
| 653 |
// $this->sse_log( 'templates', 'Import Templates (i.e: Header, Footer etc)', '--', 'updateLog', 'processing' ); |
| 654 |
// // $this->sse_log( 'content', 'Import Pages, Posts etc', '--', 'updateLog', 'processing' ); |
| 655 |
// $this->sse_log( 'wp-content', 'Importing Pages, Posts, Navigation, etc', '--', 'updateLog', 'processing' ); |
| 656 |
// $this->sse_log( 'finalize', 'Finalizing Your Imports', '--', 'updateLog', 'processing' ); |
| 657 |
} |
| 658 |
|
| 659 |
} |
| 660 |
|