PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / trunk
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! vtrunk
3.8.0 3.7.5 3.7.4 3.7.3 3.7.2 1-final 3.7.1 3.7.0 3.6.8 3.6.7 3.6.6 3.6.5 3.6.4 3.6.3 3.6.2 3.6.1 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 All 112 releases
templately / modules / full-site-import / Concerns / DownloadsPack.php

DownloadsPack.php in Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! trunk, at modules/full-site-import/Concerns/DownloadsPack.php

660 lines 24.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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