throw(__('Upload directory is not writable.', 'templately'));
}
// Goes through Helper so the wp-uploads/templately root gets its
// index.php / .htaccess / web.config guards before anything is written
// into it — the extracted pack lives here and uploads is web-served.
$this->tmp_dir = Helper::upload_dir('tmp');
if (!is_dir($this->tmp_dir)) {
wp_mkdir_p($this->tmp_dir);
}
$this->sse_log('writing_permission_check', __('Permission Passed', 'templately'), 100);
}
/**
* @throws Exception
*/
private function download_zip( $id, $is_ai = false ) {
$this->sse_log( 'download', __( 'Downloading Template Pack', 'templately' ), 1 );
$extra_headers = [
// Same normalization as Utils\PackInfoFetcher::fetch() — a raw PHP boolean
// stringifies to "1"/"" when WP's HTTP layer serializes the header, which the
// upstream API does not recognize as true/false. Send the literal string.
'x-templately-is-ai' => rest_sanitize_boolean( $is_ai ) ? 'true' : 'false',
'x-templately-session-id' => $this->session_id,
'x-templately-requested-platform' => Helper::get_requested_platform(),
];
// A run that stops in this method used to leave ONE "Downloading Template Pack"
// line and nothing else — no status, no size, no timing, no way to tell a refused
// download from a request that simply ended. Everything below narrates it.
Helper::log(
sprintf( 'download_zip: requesting pack %s (session=%s, is_ai=%s)', $id, $this->session_id, $is_ai ? 'yes' : 'no' ),
'download_zip',
'info'
);
// The decisive instrument. `register_shutdown_function` runs on a fatal AND on a
// client disconnect, so if the request ends before the pack reaches disk this says
// WHICH: `connection_aborted=1` means the browser hung up (PHP has
// ignore_user_abort=0, so it stops at the next write, silently and with no error),
// and a non-null `last_error` means PHP died.
$download_started = microtime( true );
$session_id = $this->session_id;
register_shutdown_function( function () use ( $id, $session_id, $download_started ) {
if ( defined( 'TEMPLATELY_PACK_SAVED' ) ) {
return;
}
Helper::log(
sprintf(
'download_zip: request ENDED before the pack was saved after %.1fs (pack=%s, session=%s, connection_aborted=%d, last_error=%s)',
microtime( true ) - $download_started,
$id,
$session_id,
connection_aborted(),
wp_json_encode( error_get_last() )
),
'download_zip',
'error'
);
} );
$response = Helper::make_api_get_request("v2/import/pack/$id", [], $extra_headers, 90);
Helper::log(
sprintf(
'download_zip: pack %s answered %s in %.1fs (%s bytes, type=%s, download-key=%s)',
$id,
is_wp_error( $response ) ? 'WP_Error: ' . $response->get_error_message() : 'HTTP ' . (int) wp_remote_retrieve_response_code( $response ),
microtime( true ) - $download_started,
is_wp_error( $response ) ? '0' : strlen( (string) wp_remote_retrieve_body( $response ) ),
is_wp_error( $response ) ? '-' : (string) wp_remote_retrieve_header( $response, 'content-type' ),
wp_remote_retrieve_header( $response, 'download-key' ) ? 'present' : 'missing'
),
'download_zip',
is_wp_error( $response ) ? 'error' : 'info'
);
// The pack is a ZIP, so the RAW response is kept: the body is bytes to write
// to disk and `download-key` is a header the session needs. FR-012 — binary
// payloads pass through the normalizer untouched — so the normalizer is used
// only to CLASSIFY, never to reshape what gets written.
$this->download_key = wp_remote_retrieve_header($response, 'download-key');
$normalized = ResponseNormalizer::normalize($response, [
'raw' => true,
'side_effects' => false,
]);
if ($normalized->is_error()) {
$error = $normalized->error();
Helper::log(
sprintf( 'download_zip: refused — %s: %s (retryable=%s)', $error->code(), wp_strip_all_tags( $error->message() ), $error->is_retryable() ? 'yes' : 'no' ),
'download_zip',
'error'
);
// Retryability now comes from the registry rather than from guessing at
// the transport: a dropped connection or a 5xx is worth another attempt,
// an expired session or a missing pack is not.
if ($error->is_retryable()) {
$this->throw_retryable(__('Template pack download failed. ', 'templately') . $error->message());
}
$support_message = '';
if (strpos($error->message(), Helper::web_url( '', [ 'support' => 'open' ] )) === false) {
$support_message = sprintf(__(" Please try again or contact support.", "templately"), Helper::web_url( '', [ 'support' => 'open' ] ));
}
$this->throw_non_retryable($error->message() . $support_message);
}
$this->sse_log('download', __('Downloading Template Pack', 'templately'), 57);
SessionData::set($this->session_id, 'download_key', $this->download_key);
// Security: Validate file path is within WordPress upload directory before writing
$validation = AIUtils::validate_file_path($this->filePath);
if (is_wp_error($validation)) {
$this->throw($validation->get_error_message());
}
wp_mkdir_p(dirname($this->filePath));
// A full disk is not transient: a retry loop against it never resolves.
// Require the zip size plus headroom for extraction (packs expand).
$needed = strlen($response['body']) * 3;
$free = @disk_free_space(dirname($this->filePath)); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- some hosts disable the function; false skips the check.
if (false !== $free && $free < $needed) {
$this->throw(__('Not enough disk space to import this pack. Please free up space and try again.', 'templately'));
}
$written = file_put_contents($this->filePath, $response['body']); // phpcs:ignore
Helper::log(
sprintf( 'download_zip: wrote %s bytes to %s', var_export( $written, true ), $this->filePath ),
'download_zip',
false === $written ? 'error' : 'info'
);
if ($written) {
// Past this point the shutdown notice above would be a false alarm.
if ( ! defined( 'TEMPLATELY_PACK_SAVED' ) ) {
define( 'TEMPLATELY_PACK_SAVED', true );
}
$this->sse_log('download', __('Downloading Template Pack', 'templately'), 100);
$this->unzip();
Helper::log( sprintf( 'download_zip: unzipped into %s', $this->dir_path ?? '(unset)' ), 'download_zip', 'info' );
} else {
$this->throw_retryable(__('Downloading Failed. Please try again', 'templately'));
}
}
/**
* @throws Exception
*/
protected function unzip() {
if (!WP_Filesystem()) {
$this->throw(__('WP_Filesystem cannot be initialized', 'templately'));
}
$unzip = unzip_file($this->filePath, $this->dir_path);
if (is_wp_error($unzip)) {
// Fall back to our own ZipArchive-based extractor. Some Templately
// packs carry entries with a leading "./" (or embedded "/./") path
// segment, which WordPress core's unzip_file() fails to extract.
// self::unzip_file() extracts with native ZipArchive and normalizes
// those "./" segments so the pack still imports. See
// self::unzip_file() for the extraction and path-traversal guard.
$unzip = $this->unzip_file($this->filePath, $this->dir_path);
}
if (is_wp_error($unzip)) {
// Both extractors failed. Previously this result was dropped and the
// import limped on to a misleading "manifest is corrupted" error.
if ('zip_extract_write_failed' === $unzip->get_error_code()) {
// Disk full / not writable — retrying cannot fix it.
$this->throw($unzip->get_error_message());
}
// A corrupt/truncated archive is often a transient download problem —
// a retry re-downloads the zip.
$this->throw_retryable($unzip->get_error_message());
}
// Core's unzip_file() extracts whatever the archive holds, and the pack
// lands under web-served wp-uploads. Sweep before anything else touches
// the tree, so the relocation below cannot copy an executable upward.
$this->purge_disallowed_files($this->dir_path);
$manifest_file = $this->dir_path . 'manifest.json';
// If manifest.json is missing, but any subdirectory contains manifest.json, move all its contents up and remove the subdirectory.
if ( ! file_exists( $manifest_file ) ) {
$entries = array_diff( scandir( $this->dir_path ), [ '.', '..' ] );
// Plain closures, not arrow functions: this file must parse on the
// advertised PHP 7.2 floor (`Requires PHP` in readme.txt), and `fn()`
// is 7.4+. `$this` is bound in a closure declared inside an instance
// method exactly as it is in an arrow function, so `$this->dir_path`
// resolves identically and no `use` clause is needed.
$dirs = array_filter( $entries, function( $e ) {
return is_dir( $this->dir_path . $e );
} );
$files = array_filter( $entries, function( $e ) {
return is_file( $this->dir_path . $e );
} );
foreach ($dirs as $subdir) {
$subdir_path = $this->dir_path . $subdir . DIRECTORY_SEPARATOR;
if ( file_exists( $subdir_path . 'manifest.json' ) ) {
copy($subdir_path . 'manifest.json', $manifest_file);
foreach ( array_diff( scandir( $subdir_path ), [ '.', '..' ] ) as $item ) {
$src = $subdir_path . $item;
$dst = $this->dir_path . $item;
if (is_dir($src)) {
if (!file_exists($dst)) {
wp_mkdir_p($dst);
}
// Recursively copy directory
$this->copyDirectory($src, $dst);
} else {
copy($src, $dst);
}
}
// Remove the subdirectory and its contents
$this->removeDirectory($subdir_path);
break; // Only process the first subdir with manifest.json
}
}
}
if (is_wp_error($unzip)) {
$error = $unzip->get_error_message();
if (empty($error)) {
// Generic error message
Helper::log($unzip);
$error_message = sprintf(__("It seems we're experiencing technical difficulties. Please try again or contact support.", "templately"), Helper::web_url( '', [ 'support' => 'open' ] ));
$this->throw($error_message);
} else {
$this->throw($unzip->get_error_message());
}
}
if ($unzip) {
unlink($this->filePath);
}
}
/**
* Recursively copy a directory
*/
private function copyDirectory($src, $dst) {
$dir = opendir($src);
wp_mkdir_p($dst);
while(false !== ($file = readdir($dir))) {
if (($file != '.') && ($file != '..')) {
if (is_dir($src . DIRECTORY_SEPARATOR . $file)) {
$this->copyDirectory($src . DIRECTORY_SEPARATOR . $file, $dst . DIRECTORY_SEPARATOR . $file);
} else {
copy($src . DIRECTORY_SEPARATOR . $file, $dst . DIRECTORY_SEPARATOR . $file);
}
}
}
closedir($dir);
}
/**
* Recursively remove a directory
*/
private function removeDirectory($dir) {
if (!file_exists($dir)) return;
$items = array_diff(scandir($dir), ['.', '..']);
foreach ($items as $item) {
$path = $dir . DIRECTORY_SEPARATOR . $item;
if (is_dir($path)) {
$this->removeDirectory($path);
} else {
unlink($path);
}
}
rmdir($dir);
}
/**
* Unzip a specified ZIP file to a location on the Filesystem.
*
* Why this custom extractor exists: some Templately packs carry entries with
* a leading "./" (or embedded "/./") path segment, which WordPress core's
* unzip_file() fails to extract. This method extracts with PHP's native
* ZipArchive and normalizes the redundant "./" segments (see
* normalize_zip_entry_name()) so the pack still imports. It is a fallback:
* self::unzip() only calls it after the core unzip_file() returns a WP_Error.
*
* Each archive member is validated before extraction rather than
* calling ZipArchive::extractTo() blindly: entries that resolve outside the
* destination (path traversal / "Zip Slip"), absolute paths, and Windows
* drive-letter paths are skipped via validate_file(), mirroring the guard
* WordPress core applies in _unzip_file_ziparchive(). Redundant "./" path
* segments are normalized first so members land at their intended location.
*
* @param string $file Full path and filename of ZIP archive.
* @param string $to Full path on the filesystem to extract archive to.
* @return true|WP_Error True on success, WP_Error on failure.
*/
/**
* Extensions an extracted pack member is permitted to use.
*
* Deliberately an allowlist, and deliberately WordPress's own: naming the
* dangerous extensions instead means being exhaustive about `.phtml`, `.pht`,
* `.phar`, `.cgi`, `.htaccess`, `.user.ini` and whatever a future server
* config decides to execute — one omission and the guard is silent.
* get_allowed_mime_types() already encodes what this site accepts, follows
* the `upload_mimes` filter, and gains new formats as WordPress does.
*
* @return array Extension => true lookup.
*/
protected static function allowed_pack_extensions() {
$allowed = array_fill_keys(self::PACK_EXTENSIONS, true);
// Keys are alternation patterns: 'jpg|jpeg|jpe' => 'image/jpeg'.
foreach (array_keys(get_allowed_mime_types()) as $pattern) {
foreach (explode('|', $pattern) as $extension) {
$allowed[$extension] = true;
}
}
return $allowed;
}
/**
* Segments that must never appear anywhere in a pack member's name.
*
* This one IS a denylist, and only because it is applied to the segments
* *before* the real extension, which the allowlist has already vetted. A
* permissive `AddHandler` runs `shell.php.gif` as PHP, so an inner segment
* still has to be refused — but refusing every inner segment the allowlist
* does not know would delete ordinary names like `style.min.css` or
* `hero.2x.png`, where the inner segment is a word, not an extension.
*
* An omission here is far less serious than in the allowlist: it only
* matters for a name whose final extension is already an accepted upload
* type, on a server configured to hand an inner extension to a handler.
*
* A METHOD, not a `const`: this is a trait, and constants in traits are a
* PHP 8.2 feature — declaring one here is a parse-time fatal on every host
* below that, which is most of the supported range. `latest` carries the
* same list as `FullSiteImport::EXECUTABLE_SEGMENTS` because there it sits
* on a class.
*
* @return array Segment => true lookup.
*/
protected static function executable_segments() {
return array_fill_keys( [
'php', 'php3', 'php4', 'php5', 'php6', 'php7', 'php8',
'phps', 'phtml', 'phtm', 'pht', 'phar', 'inc',
'cgi', 'fcgi', 'pl', 'py', 'rb', 'sh', 'bash', 'ksh', 'csh', 'zsh',
'asp', 'aspx', 'ascx', 'ashx', 'asmx', 'cfm', 'cfml',
'jsp', 'jspx', 'jar', 'war',
'shtml', 'shtm',
'exe', 'com', 'bat', 'cmd', 'dll', 'so',
'htaccess', 'htpasswd', 'ini', 'env', 'conf',
], true );
}
/**
* Whether an archive entry (or extracted file) is something a pack may hold.
*
* Two different rules, because a filename's dots do not all mean the same
* thing. The LAST segment is the extension the server dispatches on, so it
* is checked against the allowlist. The segments before it are usually just
* part of the name — `style.min.css`, `hero.2x.png`, `logo.v2.png`,
* `12.ai.json` — so they are only checked against EXECUTABLE_SEGMENTS,
* which is what still refuses `shell.php.gif` under a permissive
* `AddHandler`.
*
* A name with no extension at all is refused, which is what catches
* `.htaccess`, `.user.ini` and `.env` — none of them have one.
*
* @param string $name Entry name or file path.
* @param array $allowed Lookup from allowed_pack_extensions(). Built on
* demand when omitted; pass it in when looping.
*
* @return bool
*/
protected static function is_allowed_entry($name, $allowed = null) {
if (null === $allowed) {
$allowed = self::allowed_pack_extensions();
}
$segments = explode('.', strtolower(wp_basename($name)));
// No extension, or a leading-dot name whose only "segment" is empty.
if (count($segments) < 2 || '' === $segments[0]) {
return false;
}
$extension = array_pop($segments);
if (!isset($allowed[$extension])) {
return false;
}
$executable = self::executable_segments();
// array_slice() drops the base name; only what sits between it and the
// extension is a masking risk.
foreach (array_slice($segments, 1) as $segment) {
if (isset($executable[$segment])) {
return false;
}
}
return true;
}
/**
* Deletes anything an extracted pack has no business containing.
*
* The fallback extractor below refuses these entries outright, but WordPress
* core's unzip_file() runs first and has no such filter, so the guard has to
* exist on the extracted tree as well.
*
* @param string $dir Extracted pack root.
*
* @return int Number of files removed.
*/
protected function purge_disallowed_files($dir) {
if (empty($dir) || !is_dir($dir)) {
return 0;
}
$removed = 0;
$allowed = self::allowed_pack_extensions();
try {
$files = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($files as $fileinfo) {
if (!$fileinfo->isFile() || self::is_allowed_entry($fileinfo->getFilename(), $allowed)) {
continue;
}
if (@unlink($fileinfo->getPathname())) {
$removed++;
Helper::log($fileinfo->getPathname(), 'unzip: removed disallowed file from extracted pack', 'warning');
}
}
} catch (\Exception $e) {
Helper::log($e->getMessage(), 'purge_disallowed_files', 'warning');
}
return $removed;
}
function unzip_file($file, $to) {
$zip = new \ZipArchive;
$res = $zip->open($file);
if ($res !== TRUE) {
// Report the open() RETURN CODE, never the handle. Reading
// $zip->status / $zip->getStatusString() here interrogates an
// archive that was never opened: ext-zip before PHP 8.0 answers
// that with "Invalid or uninitialized Zip object" — a warning
// raised inside the advertised PHP range (readme floor 7.2), on
// the very path that reports a corrupt download.
return new \WP_Error('zip_error_' . $res, sprintf(
/* translators: %d: PHP ZipArchive::open() error code. */
__('Could not open the downloaded archive (ZipArchive error code %d).', 'templately'),
$res
));
}
// Close the archive handle on every exit path (success, mid-loop
// exception, or early return) so it is never leaked.
try {
$to = trailingslashit($to);
$allowed_extensions = self::allowed_pack_extensions();
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if ($name === false) {
continue;
}
// Normalize redundant "./" segments so the destination path
// is computed from a clean entry name.
$name = $this->normalize_zip_entry_name($name);
if ($name === '') {
continue; // Archive root (e.g. a "./" entry).
}
// Skip the OS X-created __MACOSX directory.
if (strpos($name, '__MACOSX/') === 0) {
continue;
}
// Don't extract invalid files: reject "../" traversal,
// absolute, and drive-letter paths so no member can be
// written outside $to. Log the skipped entry name so a
// hostile or corrupt pack leaves a forensic trail rather
// than silently extracting only part of its contents.
if (0 !== validate_file($name)) {
Helper::log($name, 'unzip_file: skipped unsafe archive entry', 'warning');
continue;
}
// validate_file() stops a member escaping $to; it says nothing
// about what the member *is*. $to lives under web-served
// wp-uploads, so an executable member would be directly
// requestable.
if (substr($name, -1) !== '/' && !self::is_allowed_entry($name, $allowed_extensions)) {
Helper::log($name, 'unzip_file: skipped disallowed archive entry', 'warning');
continue;
}
if (substr($name, -1) === '/') {
// Directory entry.
wp_mkdir_p($to . untrailingslashit($name));
continue;
}
$contents = $zip->getFromIndex($i);
if ($contents === false) {
// An unreadable member means a corrupt archive. Fail HERE —
// silently continuing produced a partial extract whose
// missing/truncated JSON only surfaced runners later as an
// unrelated "corrupted" error.
return new \WP_Error(
'zip_member_unreadable',
sprintf(
/* translators: %s: archive entry name */
__('The template pack archive is corrupted (unreadable entry: %s). Please try again.', 'templately'),
$name
)
);
}
$target = $to . $name;
wp_mkdir_p(dirname($target));
$written = file_put_contents($target, $contents); // phpcs:ignore
if (false === $written || $written < strlen($contents)) {
// Disk full / permissions mid-extraction: fail loudly now,
// not later when a runner reads the truncated file.
return new \WP_Error(
'zip_extract_write_failed',
__('Could not write the template pack to disk (disk full or not writable). Please free up space and try again.', 'templately')
);
}
}
return true;
} catch (\Throwable $th) {
return new \WP_Error('exception_caught', $th->getMessage());
} finally {
$zip->close();
}
}
/**
* Removes redundant current-directory ("./") segments from a ZIP entry path.
*
* Some archive tools store entry names with a leading "./" or embedded "/./"
* segment (for example "./manifest.json" or "content/./page.json"). Parent
* ("..") segments are intentionally left untouched so validate_file() can
* still reject them.
*
* @param string $name A ZIP archive entry path.
* @return string The entry path with current-directory segments removed.
*/
protected function normalize_zip_entry_name($name) {
// Fast path: bail when there is no current-directory segment to remove.
if ('.' !== $name
&& strpos($name, './') !== 0
&& strpos($name, '/./') === false
&& substr($name, -2) !== '/.'
) {
return $name;
}
// A trailing slash, or a trailing "/." or bare ".", denotes a directory.
$is_directory = substr($name, -1) === '/' || substr($name, -2) === '/.' || '.' === $name;
$segments = array();
foreach (explode('/', $name) as $segment) {
if ('.' !== $segment) {
$segments[] = $segment;
}
}
$name = implode('/', $segments);
// Preserve the trailing slash that marks a directory entry.
if ($is_directory && '' !== $name && substr($name, -1) !== '/') {
$name .= '/';
}
return $name;
}
/**
* @throws Exception
*/
private function read_manifest($dir_path) {
$manifest_content = file_get_contents($dir_path . 'manifest.json');
if (empty($manifest_content)) {
$this->throw(__('Cannot be imported, as the manifest file is corrupted', 'templately'));
}
$manifest_content = json_decode($manifest_content, true);
$this->removeLog('temp');
return $manifest_content;
// TODO: Read & Broadcast the LOG for waiting list
// $this->sse_log( 'plugin', 'Installing required plugins', '--', 'updateLog', 'processing' );
// // $this->sse_log( 'extra-content', 'Import Extra Contents (i.e: Forms)', '--', 'updateLog', 'processing' );
// $this->sse_log( 'templates', 'Import Templates (i.e: Header, Footer etc)', '--', 'updateLog', 'processing' );
// // $this->sse_log( 'content', 'Import Pages, Posts etc', '--', 'updateLog', 'processing' );
// $this->sse_log( 'wp-content', 'Importing Pages, Posts, Navigation, etc', '--', 'updateLog', 'processing' );
// $this->sse_log( 'finalize', 'Finalizing Your Imports', '--', 'updateLog', 'processing' );
}
}