getClientOriginalExtension()) !== 'zip') { throw new RuntimeException('Only ZIP theme packages may be installed.'); } $temporary = storage_path('app/stylist/install-'.bin2hex(random_bytes(8))); $this->files->ensureDirectoryExists($temporary); $zip = new ZipArchive; if ($zip->open($upload->getRealPath()) !== true) { throw new RuntimeException('The uploaded ZIP could not be opened.'); } for ($i = 0; $i < $zip->numFiles; $i++) { $name = $zip->getNameIndex($i); if (str_contains($name, '..') || str_starts_with($name, '/') || str_contains($name, "\0")) { $zip->close(); throw new RuntimeException('The ZIP contains an unsafe path.'); } } $zip->extractTo($temporary); $zip->close(); $manifest = collect($this->files->allFiles($temporary))->first(fn ($file) => $file->getFilename() === 'theme.json'); if (! $manifest) { $this->files->deleteDirectory($temporary); throw new RuntimeException('The package does not contain theme.json.'); } $source = $manifest->getPath(); $theme = $this->loader->fromPath($source); if ($theme->parent() && ! $this->loader->all()->has($theme->parent())) { throw new RuntimeException('Parent theme '.$theme->parent().' is not installed.'); } $issues = $this->validator->validate($theme); if (collect($issues)->contains('severity', 'error')) { throw new RuntimeException('Accessibility validation failed: '.collect($issues)->where('severity', 'error')->pluck('message')->implode(' ')); } $target = rtrim((string) config('stylist.themes_path'), '/').'/'.$theme->key(); if ($this->files->isDirectory($target)) { $this->files->deleteDirectory($target); } $this->files->copyDirectory($source, $target); $this->files->deleteDirectory($temporary); $this->loader->flush(); return $this->loader->find($theme->key()); } public function uninstall(Theme $theme): void { $active = $theme->type() === 'frontend' ? $this->settings->get('frontend_theme', config('stylist.default_frontend_theme'), 'site') : $this->settings->get('backend_theme', config('stylist.default_backend_theme'), 'site'); if ($active === $theme->key()) { throw new RuntimeException('The active theme cannot be uninstalled. Activate another theme first.'); } $projectRoot = realpath((string) config('stylist.themes_path')); $themePath = realpath($theme->path()); if (! $projectRoot || ! $themePath || ! str_starts_with($themePath, $projectRoot.DIRECTORY_SEPARATOR)) { throw new RuntimeException('Bundled themes cannot be uninstalled.'); } if (collect($this->loader->all())->contains(fn (Theme $candidate) => $candidate->parent() === $theme->key())) { throw new RuntimeException('This theme has installed child themes.'); } $this->files->deleteDirectory($themePath); $this->loader->flush(); } public function export(Theme $theme): string { $directory = storage_path('app/stylist/exports'); $this->files->ensureDirectoryExists($directory); $path = $directory.'/'.$theme->key().'-'.$theme->version().'.zip'; $zip = new ZipArchive; if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { throw new RuntimeException('Unable to create theme archive.'); } foreach ($this->files->allFiles($theme->path()) as $file) { $zip->addFile($file->getPathname(), $theme->key().'/'.$file->getRelativePathname()); } $zip->close(); return $path; } }