, created: , * global: { settings, css, breakpoints }, * pages: [ { title, slug, type, status, data, css, pageSettings, template } ], * library: [ { title, type, tags, elements, css, pageSettings } ], * media: [ { id, url, file, title, alt } ] // file = path inside the zip (media/-) * } * * Import sideloads the media first, then rewrites every `{id, url}` media value and every occurrence of an old * attachment URL (settings strings, CSS) to the new attachment, creates the pages and library items and * finally stores the global settings. */ final class SiteKit { public const VERSION = 1; public const MAX_ENTRIES = 2000; public const MAX_UNCOMPRESSED = 268435456; // 256 MB public const MAX_KIT_JSON = 20971520; // 20 MB public const MEDIA_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg', 'ico', 'mp4', 'webm', 'mp3', 'ogg', 'wav', 'pdf']; private WidgetRegistry $widgets; private Settings $settings; private Store $store; public function __construct(WidgetRegistry $widgets, Settings $settings, Store $store) { $this->widgets = $widgets; $this->settings = $settings; $this->store = $store; } /* ------------------------------------------------------------------ export */ /** Builder pages that can be exported: [{id, title, type, status}]. */ public function exportable_pages(): array { $posts = get_posts([ 'post_type' => $this->settings->post_types(), 'post_status' => ['publish', 'draft', 'pending', 'private', 'future'], 'posts_per_page' => 500, // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- exports pages built with Zadex (mode meta); admin-only, bounded. 'meta_key' => Document::META_MODE, 'meta_value' => 'builder', // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value 'orderby' => 'title', 'order' => 'ASC', 'no_found_rows' => true, ]); $out = []; foreach ($posts as $post) { if (!Document::is_builder((int) $post->ID)) { continue; } $out[] = ['id' => (int) $post->ID, 'title' => (string) $post->post_title, 'type' => (string) $post->post_type, 'status' => (string) $post->post_status]; } return $out; } /** * Builds kit.json data plus the list of media files to pack. * * @param int[]|null $page_ids null = every builder page * @return array{kit: array, files: array} files: zip path => absolute file path */ public function build(?array $page_ids = null, bool $with_library = true, bool $with_global = true): array { $allowed = array_column($this->exportable_pages(), 'id'); $ids = $page_ids === null ? $allowed : array_values(array_intersect(array_map('intval', $page_ids), $allowed)); $pages = []; $media_refs = []; foreach ($ids as $id) { $post = get_post($id); if (!$post) { continue; } $data = Document::get_data($id); $page = Document::get_page_settings($id); $pages[] = [ 'title' => (string) $post->post_title, 'slug' => (string) ($post->post_name ?? ''), 'type' => (string) $post->post_type, 'status' => (string) $post->post_status, 'data' => $data, 'css' => Document::get_css($id), 'pageSettings' => $page ? $page : (object) [], 'template' => Templates::get($id), ]; self::collect_media($data['elements'], $media_refs); self::collect_media($page, $media_refs); } $library = []; if ($with_library) { foreach ($this->store->all() as $summary) { $item = $this->store->get((int) $summary['id']); if ($item === null) { continue; } $library[] = [ 'title' => $item['title'], 'type' => $item['type'], 'tags' => $item['tags'], 'elements' => $item['elements'], 'css' => $item['css'], 'pageSettings' => $item['pageSettings'], ]; self::collect_media($item['elements'], $media_refs); } } $global = null; if ($with_global) { $global = ['settings' => $this->settings->global(), 'css' => $this->settings->global_css(), 'breakpoints' => $this->settings->breakpoints()]; self::collect_media($global['settings'], $media_refs); } $media = []; $files = []; foreach ($media_refs as $id => $url) { if (get_post_type($id) !== 'attachment') { continue; } $path = function_exists('get_attached_file') ? (string) get_attached_file($id) : ''; if ($path === '' || !is_file($path) || !is_readable($path)) { continue; } $name = self::safe_name(basename($path)); if (!self::allowed_media_name($name)) { continue; } $zip_path = 'media/' . $id . '-' . $name; $files[$zip_path] = $path; $attachment = get_post($id); $media[] = [ 'id' => $id, 'url' => function_exists('wp_get_attachment_url') ? (string) (wp_get_attachment_url($id) ?: $url) : $url, 'file' => $zip_path, 'title' => $attachment ? (string) $attachment->post_title : '', 'alt' => (string) get_post_meta($id, '_wp_attachment_image_alt', true), ]; } $kit = [ 'version' => self::VERSION, 'generator' => 'zadex', 'site' => function_exists('home_url') ? (string) home_url('/') : '', 'created' => gmdate('c'), 'global' => $global, 'pages' => $pages, 'library' => $library, 'media' => $media, ]; return ['kit' => $kit, 'files' => $files]; } /** Writes the zip. @return true|WP_Error */ public function export_zip(string $zip_file, ?array $page_ids = null, bool $with_library = true, bool $with_global = true) { if (!class_exists(\ZipArchive::class)) { return new WP_Error('zadex_no_zip', __('The PHP zip extension is not available on this server.', 'zadex')); } $built = $this->build($page_ids, $with_library, $with_global); $zip = new \ZipArchive(); if ($zip->open($zip_file, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { return new WP_Error('zadex_zip_failed', __('Could not create the kit file.', 'zadex')); } $zip->addFromString('kit.json', (string) wp_json_encode($built['kit'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); foreach ($built['files'] as $inside => $path) { $zip->addFile($path, $inside); } $zip->close(); return true; } /** * Collects `{id, url}` media values (images, backgrounds, gallery items ...) found anywhere in a tree. * * @param array $refs attachment id => url (by reference) */ public static function collect_media($value, array &$refs, int $depth = 0): void { if (!is_array($value) || $depth > 80) { return; } if (isset($value['id'], $value['url']) && is_numeric($value['id']) && (int) $value['id'] > 0 && is_string($value['url']) && $value['url'] !== '') { $refs[(int) $value['id']] = $value['url']; } foreach ($value as $child) { if (is_array($child)) { self::collect_media($child, $refs, $depth + 1); } } } /* ------------------------------------------------------------------ import */ /** * Imports a kit zip. * * @param array{status?: string, global?: bool, library?: bool} $options * @return array|WP_Error report {pages: [{id, title}], library: int, media: int, global: bool, errors: string[]} */ public function import_zip(string $zip_file, array $options = []) { if (!class_exists(\ZipArchive::class)) { return new WP_Error('zadex_no_zip', __('The PHP zip extension is not available on this server.', 'zadex')); } $zip = new \ZipArchive(); if ($zip->open($zip_file) !== true) { return new WP_Error('zadex_bad_kit', __('The file is not a valid zip archive.', 'zadex')); } $temp = []; try { if ($zip->numFiles > self::MAX_ENTRIES) { return new WP_Error('zadex_bad_kit', __('The kit contains too many files.', 'zadex')); } $total = 0; for ($i = 0; $i < $zip->numFiles; $i++) { $stat = $zip->statIndex($i); $total += is_array($stat) ? (int) $stat['size'] : 0; } if ($total > self::MAX_UNCOMPRESSED) { return new WP_Error('zadex_bad_kit', __('The kit is too large once unpacked.', 'zadex')); } $stat = $zip->statName('kit.json'); if (!is_array($stat) || (int) $stat['size'] > self::MAX_KIT_JSON) { return new WP_Error('zadex_bad_kit', __('kit.json is missing or too large.', 'zadex')); } $kit = json_decode((string) $zip->getFromName('kit.json'), true); if (!is_array($kit)) { return new WP_Error('zadex_bad_kit', __('kit.json is not valid JSON.', 'zadex')); } // Media are never extracted with extractTo(): each allowed entry is copied to a fresh temp file. $files = []; foreach (is_array($kit['media'] ?? null) ? $kit['media'] : [] as $item) { $inside = is_array($item) && is_string($item['file'] ?? null) ? $item['file'] : ''; if (!self::safe_zip_path($inside)) { continue; } $content = $zip->getFromName($inside); if (!is_string($content)) { continue; } $tmp = self::temp_file(); if ($tmp === '' || !\Zadex\Filesystem::put($tmp, $content)) { continue; } $temp[] = $tmp; $files[$inside] = $tmp; } return $this->import($kit, $files, $options); } finally { $zip->close(); foreach ($temp as $tmp) { if (is_file($tmp)) { wp_delete_file($tmp); } } } } /** * Imports decoded kit data. `$files` maps kit media paths to local temp files (consumed by the sideload). * * @return array|WP_Error */ public function import(array $kit, array $files, array $options = []) { if ((int) ($kit['version'] ?? 0) < 1 || (int) ($kit['version'] ?? 0) > self::VERSION || !is_array($kit['pages'] ?? [])) { return new WP_Error('zadex_bad_kit', __('This is not a Zadex site kit (or it was made by a newer version).', 'zadex')); } $status = ($options['status'] ?? 'draft') === 'publish' && current_user_can('publish_pages') ? 'publish' : 'draft'; $report = ['pages' => [], 'library' => 0, 'media' => 0, 'global' => false, 'errors' => []]; // 1. Media. $id_map = []; $url_map = []; foreach (is_array($kit['media'] ?? null) ? $kit['media'] : [] as $item) { if (!is_array($item)) { continue; } $inside = is_string($item['file'] ?? null) ? $item['file'] : ''; $old_id = (int) ($item['id'] ?? 0); $old_url = is_string($item['url'] ?? null) ? $item['url'] : ''; if ($old_id <= 0 || !isset($files[$inside])) { continue; } $new = $this->sideload($files[$inside], preg_replace('/^\d+-/', '', basename($inside)) ?? basename($inside), is_string($item['title'] ?? null) ? $item['title'] : '', is_string($item['alt'] ?? null) ? $item['alt'] : ''); if (is_wp_error($new)) { $report['errors'][] = $new->get_error_message(); continue; } $id_map[$old_id] = $new['id']; if ($old_url !== '') { $url_map[$old_url] = $new['url']; } $report['media']++; } // 2. Pages. $sanitizer = new Sanitizer($this->widgets); $types = $this->settings->post_types(); foreach ($kit['pages'] as $page) { if (!is_array($page) || !is_array($page['data']['elements'] ?? null)) { continue; } $type = is_string($page['type'] ?? null) && in_array($page['type'], $types, true) ? $page['type'] : 'page'; $title = is_string($page['title'] ?? null) ? sanitize_text_field($page['title']) : ''; $post_id = wp_insert_post(wp_slash([ 'post_title' => $title !== '' ? $title : __('Imported page', 'zadex'), 'post_type' => $type, 'post_status' => $status, 'post_content' => '', ]), true); if (is_wp_error($post_id)) { $report['errors'][] = $post_id->get_error_message(); continue; } $post_id = (int) $post_id; $elements = self::remap($page['data']['elements'], $id_map, $url_map); $css = self::replace_urls(is_string($page['css'] ?? null) ? $page['css'] : '', $url_map); $page_settings = is_array($page['pageSettings'] ?? null) ? self::remap($page['pageSettings'], $id_map, $url_map) : []; $this->write_document($sanitizer, $post_id, $elements, $css, $page_settings); if (is_string($page['template'] ?? null) && $page['template'] !== '') { Templates::save($post_id, $page['template']); } $report['pages'][] = ['id' => $post_id, 'title' => $title]; } // 3. Library items. if (($options['library'] ?? true) && is_array($kit['library'] ?? null)) { foreach ($kit['library'] as $item) { if (!is_array($item) || !is_array($item['elements'] ?? null)) { continue; } $created = $this->store->create( is_string($item['title'] ?? null) ? $item['title'] : __('Imported template', 'zadex'), Store::sanitize_type($item['type'] ?? 'section'), self::remap($item['elements'], $id_map, $url_map), self::replace_urls(is_string($item['css'] ?? null) ? $item['css'] : '', $url_map), is_array($item['pageSettings'] ?? null) ? self::remap($item['pageSettings'], $id_map, $url_map) : null, Store::sanitize_tags($item['tags'] ?? []) ); if (is_wp_error($created)) { $report['errors'][] = $created->get_error_message(); } else { $report['library']++; } } } // 4. Global settings (colours, fonts, theme style, breakpoints). if (($options['global'] ?? true) && is_array($kit['global'] ?? null) && is_array($kit['global']['settings'] ?? null) && current_user_can('manage_options')) { $global = $sanitizer->sanitize_settings_generic(self::remap($kit['global']['settings'], $id_map, $url_map)); $css = is_string($kit['global']['css'] ?? null) ? Document::sanitize_css(self::replace_urls($kit['global']['css'], $url_map)) : null; $this->settings->update_global($global, $css); if (is_array($kit['global']['breakpoints'] ?? null)) { $this->settings->update(['breakpoints' => self::sanitize_breakpoints($kit['global']['breakpoints'])]); } $report['global'] = true; } do_action('zadex/kit/imported', $report, $kit); return $report; } /** Stores a document the same way Document::save() does, without touching the (just created) post. */ private function write_document(Sanitizer $sanitizer, int $post_id, array $elements, string $css, array $page_settings): void { $clean = $sanitizer->sanitize_document(['elements' => $elements]); update_post_meta($post_id, Document::META_DATA, wp_slash(wp_json_encode($clean, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES))); update_post_meta($post_id, Document::META_CSS, wp_slash(Document::sanitize_css($css))); update_post_meta($post_id, Document::META_MODE, 'builder'); if ($page_settings) { update_post_meta($post_id, Document::META_PAGE, wp_slash(wp_json_encode($sanitizer->sanitize_settings_generic($page_settings), JSON_UNESCAPED_UNICODE))); } do_action('zadex/document/saved', $post_id, $clean, $css); } /** * Sideloads one media file into the media library. * * @return array{id:int, url:string}|WP_Error */ private function sideload(string $tmp, string $name, string $title, string $alt) { $name = self::safe_name($name); if (!self::allowed_media_name($name)) { /* translators: %s: file name. */ return new WP_Error('zadex_bad_media', sprintf(__('Skipped %s: file type not allowed.', 'zadex'), $name)); } $check = function_exists('wp_check_filetype') ? wp_check_filetype($name) : ['ext' => pathinfo($name, PATHINFO_EXTENSION)]; if (empty($check['ext'])) { /* translators: %s: file name. */ return new WP_Error('zadex_bad_media', sprintf(__('Skipped %s: file type not allowed.', 'zadex'), $name)); } if (defined('ABSPATH') && !function_exists('media_handle_sideload') && is_file(ABSPATH . 'wp-admin/includes/media.php')) { require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; } // media_handle_sideload() moves the file: hand it a copy so the caller can always clean its temp file. $copy = self::temp_file(); if ($copy === '' || !@copy($tmp, $copy)) { /* translators: %s: file name. */ return new WP_Error('zadex_bad_media', sprintf(__('Could not read %s.', 'zadex'), $name)); } $id = media_handle_sideload(['name' => $name, 'tmp_name' => $copy], 0, $title !== '' ? $title : null); if (is_file($copy)) { wp_delete_file($copy); } if (is_wp_error($id)) { return $id; } if ($alt !== '') { update_post_meta((int) $id, '_wp_attachment_image_alt', sanitize_text_field($alt)); } return ['id' => (int) $id, 'url' => (string) wp_get_attachment_url((int) $id)]; } /** * Rewrites media references: `{id, url}` values of remapped attachments get the new id and url (their size * variants are dropped, the widgets fall back to the main file) and old URLs inside any string are replaced. */ public static function remap($value, array $id_map, array $url_map, int $depth = 0) { if (is_string($value)) { return self::replace_urls($value, $url_map); } if (!is_array($value) || $depth > 80) { return $value; } if (isset($value['id'], $value['url']) && is_numeric($value['id']) && isset($id_map[(int) $value['id']])) { $old_url = is_string($value['url']) ? $value['url'] : ''; $value['id'] = $id_map[(int) $value['id']]; $value['url'] = $url_map[$old_url] ?? self::replace_urls($old_url, $url_map); unset($value['sizes']); } foreach ($value as $k => $child) { if ($k === 'id' || $k === 'url') { continue; } $value[$k] = self::remap($child, $id_map, $url_map, $depth + 1); } return $value; } public static function replace_urls(string $text, array $url_map): string { if ($text === '' || !$url_map) { return $text; } // Longest first so a URL that is a prefix of another does not break it. uksort($url_map, static fn($a, $b): int => strlen((string) $b) <=> strlen((string) $a)); return strtr($text, $url_map); } public static function sanitize_breakpoints(array $input): array { $out = []; foreach (Settings::default_breakpoints() as $key => $bp) { $in = isset($input[$key]) && is_array($input[$key]) ? $input[$key] : []; $out[$key] = [ 'value' => isset($in['value']) ? max(200, min(6000, (int) $in['value'])) : $bp['value'], 'enabled' => $key === 'desktop' ? true : (isset($in['enabled']) ? (bool) $in['enabled'] : $bp['enabled']), ]; if ($key === 'desktop') { $out[$key]['value'] = 0; } } return $out; } /** A zip entry name we are willing to read: media/, no traversal, no absolute paths. */ public static function safe_zip_path(string $path): bool { return (bool) preg_match('#^media/[A-Za-z0-9][A-Za-z0-9._\-]{0,180}$#', $path) && !str_contains($path, '..'); } public static function safe_name(string $name): string { $name = preg_replace('/[^A-Za-z0-9._\-]+/', '-', $name) ?? ''; $name = trim($name, '.-'); return $name !== '' ? substr($name, -120) : 'file'; } public static function allowed_media_name(string $name): bool { $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); return in_array($ext, self::MEDIA_EXT, true) && !preg_match('/\.(php\d?|phtml|phar|html?|js|exe|sh)\./i', $name); } private static function temp_file(): string { if (function_exists('wp_tempnam')) { return (string) wp_tempnam('zadex-kit'); } $tmp = tempnam(sys_get_temp_dir(), 'zxk'); return is_string($tmp) ? $tmp : ''; } }