dbxapp 4.1.3
CMS, Shop, Workflows und modulare Geschäftsanwendungen
Loading...
Searching...
No Matches
dbxDesignService.class.php
Go to the documentation of this file.
1<?php
2namespace dbx\dbxDesign_admin;
3
13
14 public const CONTRACT = 'dbx.design.v1';
15
19 public function designRoot(): string {
20 return rtrim(dbx()->os_path(dbx()->get_base_dir() . 'dbx/design'), '/\\');
21 }
22
26 public function normalizeName(string $name): string {
27 $name = strtolower(trim($name));
28 $name = preg_replace('/[^a-z0-9_-]+/', '-', $name);
29 return trim((string)$name, '-_');
30 }
31
35 public function isValidName(string $name): bool {
36 return preg_match('/^[a-z0-9][a-z0-9_-]{1,62}$/', $name) === 1;
37 }
38
42 public function designDir(string $name): string {
43 $name = $this->normalizeName($name);
44 if (!$this->isValidName($name)) {
45 throw new \InvalidArgumentException('Ungueltiger Designname.');
46 }
47 return $this->designRoot() . DIRECTORY_SEPARATOR . $name;
48 }
49
53 public function listDesigns(): array {
54 $out = array();
55 foreach (glob($this->designRoot() . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR) ?: array() as $dir) {
56 $name = basename($dir);
57 if (!$this->isValidName($name) || !is_file($dir . DIRECTORY_SEPARATOR . 'htm' . DIRECTORY_SEPARATOR . 'default.htm')) {
58 continue;
59 }
60 $meta = $this->readMetadata($name);
62 $out[$name] = array(
63 'name' => $name,
64 'title' => trim((string)($meta['title'] ?? '')) ?: ($name === 'dbxapp' ? 'dbXapp' : ucfirst(str_replace(array('-', '_'), ' ', $name))),
65 'description' => (string)($meta['description'] ?? ''),
66 'source_design' => (string)($meta['source_design'] ?? ''),
67 'layout' => (string)($meta['layout'] ?? $this->detectLayout($dir)),
68 'managed' => is_file($dir . DIRECTORY_SEPARATOR . 'design.json'),
69 'valid' => empty($validation['errors']),
70 'warnings' => $validation['warnings'],
71 );
72 }
73 uasort($out, static function(array $a, array $b): int {
74 return strnatcasecmp($a['title'], $b['title']);
75 });
76 return $out;
77 }
78
82 public function readMetadata(string $name): array {
83 $file = $this->designDir($name) . DIRECTORY_SEPARATOR . 'design.json';
84 if (!is_file($file)) {
85 return array();
86 }
87 $data = json_decode((string)file_get_contents($file), true);
88 return is_array($data) ? $data : array();
89 }
90
94 public function defaults(string $source = 'dbxapp'): array {
95 return array(
96 'source_design' => $source,
97 'target_design' => '',
98 'title' => '',
99 'description' => '',
100 'layout' => 'top',
101 'menu_style' => 'tabs',
102 'content_width' => 'wide',
103 'footer_mode' => 'full',
104 'brand_name' => 'Meine Website',
105 'tagline' => 'Einfach. Klar. Persoenlich.',
106 'logo_mode' => 'icon',
107 'logo_icon' => 'bi-stars',
108 'primary_color' => '#2563eb',
109 'secondary_color' => '#0f172a',
110 'accent_color' => '#14b8a6',
111 'background_color' => '#f4f7fb',
112 'surface_color' => '#ffffff',
113 'text_color' => '#172033',
114 'font_family' => 'modern',
115 'radius' => 'soft',
116 'footer_text' => 'Mit dbXapp erstellt',
117 'legal_links' => 1,
118 'set_default' => 0,
119 );
120 }
121
130 public function createFromWizard(array $input, array $logoUpload = array()): array {
131 $source = $this->normalizeName((string)($input['source_design'] ?? 'dbxapp'));
132 $target = $this->normalizeName((string)($input['target_design'] ?? ''));
133 if (!$this->isValidName($source) || !is_dir($this->designDir($source))) {
134 throw new \InvalidArgumentException('Das Ausgangsdesign existiert nicht.');
135 }
136 if (!$this->isValidName($target)) {
137 throw new \InvalidArgumentException('Der technische Name muss 2 bis 63 Zeichen aus a-z, 0-9, - oder _ enthalten.');
138 }
139 if ($target === $source) {
140 throw new \InvalidArgumentException('Die Personalisierung wird als eigenes Design angelegt. Bitte einen neuen Namen verwenden.');
141 }
142 $targetDir = $this->designDir($target);
143 if (is_dir($targetDir)) {
144 throw new \RuntimeException('Das Zieldesign existiert bereits.');
145 }
146
147 $stage = $this->stageDir($target);
148 $this->removeDirectory($stage);
149 if (!mkdir($stage, 0777, true) && !is_dir($stage)) {
150 throw new \RuntimeException('Staging-Verzeichnis konnte nicht angelegt werden.');
151 }
152
153 try {
154 $this->copyDirectory($this->designDir($source), $stage);
155 $this->rewriteDesignPaths($stage, $source, $target);
156 $this->writeWizardFiles($stage, $target, $input, $logoUpload);
157 $validation = $this->validateDesignDirectory($stage, true);
158 if ($validation['errors']) {
159 throw new \RuntimeException(implode(' ', $validation['errors']));
160 }
161 if (!rename($stage, $targetDir)) {
162 throw new \RuntimeException('Das fertige Design konnte nicht aktiviert werden.');
163 }
164 } catch (\Throwable $e) {
165 $this->removeDirectory($stage);
166 throw $e;
167 }
168
169 if (!empty($input['set_default'])) {
170 $config = dbx()->get_config('dbx');
171 if (!is_array($config)) {
172 $config = array();
173 }
174 $config['default_design_user'] = $target;
175 dbx()->set_config('dbx', $config);
176 }
177
178 return array(
179 'name' => $target,
180 'dir' => $targetDir,
181 'validation' => $validation,
182 'default_changed' => !empty($input['set_default']),
183 );
184 }
185
189 public function backupDesign(string $name): string {
190 $dir = $this->designDir($name);
191 if (!is_dir($dir)) {
192 throw new \RuntimeException('Design fuer Backup nicht gefunden.');
193 }
194 if (!class_exists('ZipArchive')) {
195 throw new \RuntimeException('ZipArchive ist nicht verfuegbar.');
196 }
197 $backupDir = dbx()->os_path(rtrim(dbx()->get_file_dir(), '/\\') . '/sys/design-backups');
198 if (!is_dir($backupDir) && !mkdir($backupDir, 0777, true) && !is_dir($backupDir)) {
199 throw new \RuntimeException('Backup-Verzeichnis konnte nicht angelegt werden.');
200 }
201 $file = $backupDir . DIRECTORY_SEPARATOR . date('Ymd-His') . '-' . $name . '.zip';
202 $zip = new \ZipArchive();
203 if ($zip->open($file, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
204 throw new \RuntimeException('Design-Backup konnte nicht erstellt werden.');
205 }
206 $this->addDirectoryToZip($zip, $dir, '');
207 $zip->close();
208 return $file;
209 }
210
214 public function validateDesignDirectory(string $dir, bool $strict = true): array {
215 $errors = array();
216 $warnings = array();
217 $default = $dir . DIRECTORY_SEPARATOR . 'htm' . DIRECTORY_SEPARATOR . 'default.htm';
218 if (!is_file($default)) {
219 $errors[] = 'htm/default.htm fehlt.';
220 return array('errors' => $errors, 'warnings' => $warnings);
221 }
222 $html = (string)file_get_contents($default);
223 if (substr_count($html, '[dbx:content]') !== 1) {
224 $errors[] = 'htm/default.htm muss [dbx:content] genau einmal enthalten.';
225 }
226 foreach (array('{dbx:title}', '{dbx:design}', '{dbx:skin_css}', '{dbx:skin_class}', 'dbx/js/lib/core.js') as $required) {
227 if (strpos($html, $required) === false) {
228 if ($strict) {
229 $errors[] = 'Template-Marker fehlt: ' . $required;
230 } else {
231 $warnings[] = 'Template-Marker fehlt: ' . $required;
232 }
233 }
234 }
235 foreach (array('logo', 'branding', 'footer') as $slot) {
236 $marker = '[dbx:' . $slot . ']';
237 $fragmentFile = $dir . DIRECTORY_SEPARATOR . 'htm' . DIRECTORY_SEPARATOR . $slot . '.htm';
238 if (substr_count($html, $marker) > 1) {
239 $errors[] = $marker . ' darf in der Designschale hoechstens einmal vorkommen.';
240 }
241 if (strpos($html, $marker) !== false && !is_file($fragmentFile)) {
242 $errors[] = 'Fragment fuer ' . $marker . ' fehlt.';
243 }
244 if (is_file($fragmentFile)) {
245 $fragmentContent = (string)file_get_contents($fragmentFile);
246 if (strpos($fragmentContent, '[dbx:content]') !== false) {
247 $errors[] = 'Fragment ' . $slot . '.htm darf keinen Content-Slot enthalten.';
248 }
249 foreach (array('[dbx:logo]', '[dbx:branding]', '[dbx:footer]') as $nestedSlot) {
250 if (strpos($fragmentContent, $nestedSlot) !== false) {
251 $errors[] = 'Fragment ' . $slot . '.htm darf keine verschachtelten Design-Slots enthalten.';
252 break;
253 }
254 }
255 }
256 }
257 foreach ($this->relativeFiles($dir) as $relative => $absolute) {
258 if (!$this->isAllowedDesignFile($relative)) {
259 $errors[] = 'Nicht erlaubte Designdatei: ' . $relative;
260 }
261 if (is_link($absolute)) {
262 $errors[] = 'Symbolische Links sind in Designpaketen nicht erlaubt: ' . $relative;
263 }
264 }
265 return array('errors' => array_values(array_unique($errors)), 'warnings' => array_values(array_unique($warnings)));
266 }
267
271 public function relativeFiles(string $dir): array {
272 $files = array();
273 if (!is_dir($dir)) {
274 return $files;
275 }
276 $root = rtrim(str_replace('\\', '/', $dir), '/');
277 $it = new \RecursiveIteratorIterator(
278 new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)
279 );
280 foreach ($it as $file) {
281 if (!$file->isFile() && !$file->isLink()) {
282 continue;
283 }
284 $absolute = $file->getPathname();
285 $relative = ltrim(substr(str_replace('\\', '/', $absolute), strlen($root)), '/');
286 $files[$relative] = $absolute;
287 }
288 ksort($files);
289 return $files;
290 }
291
295 public function isAllowedDesignFile(string $relative): bool {
296 $relative = str_replace('\\', '/', $relative);
297 if ($relative === '' || $relative[0] === '/' || strpos($relative, '..') !== false || strpos($relative, "\0") !== false) {
298 return false;
299 }
300 $ext = strtolower(pathinfo($relative, PATHINFO_EXTENSION));
301 return in_array($ext, array(
302 'htm', 'html', 'css', 'js', 'json', 'txt', 'md',
303 'png', 'jpg', 'jpeg', 'webp', 'gif', 'svg', 'ico',
304 'woff', 'woff2', 'ttf', 'otf',
305 ), true);
306 }
307
319 public function validateResultFile(string $relative, string $absolute): string {
320 if (!$this->isAllowedDesignFile($relative) || !is_file($absolute) || is_link($absolute)) {
321 return 'Nicht erlaubte Ergebnisdatei: ' . $relative;
322 }
323 $ext = strtolower(pathinfo($relative, PATHINFO_EXTENSION));
324 if (!in_array($ext, array('htm', 'html', 'css', 'js', 'json', 'svg'), true)) {
325 return '';
326 }
327 $content = (string)file_get_contents($absolute);
328 if (strpos($content, '<?') !== false) {
329 return 'Servercode ist in Designdateien nicht erlaubt: ' . $relative;
330 }
331 if ($ext === 'json' && json_decode($content, true) === null && trim($content) !== 'null') {
332 return 'Ungueltiges JSON im Design-Ergebnis: ' . $relative;
333 }
334 if ($ext === 'htm' || $ext === 'html') {
335 if (preg_match('/\son[a-z]+\s*=/i', $content)
336 || preg_match('/javascript\s*:/i', $content)
337 || preg_match('/<script\b(?![^>]*\bsrc\s*=)[^>]*>/i', $content)
338 || preg_match('/<(?:script|link)\b[^>]*(?:src|href)\s*=\s*["\']?\s*(?:https?:)?\/\//i', $content)) {
339 return 'Aktiver Inline- oder Fremdcode ist im Design-HTML nicht erlaubt: ' . $relative;
340 }
341 }
342 if ($ext === 'css' && preg_match('/(?:@import|url\s*\‍()\s*["\']?\s*(?:https?:)?\/\//i', $content)) {
343 return 'Externe CSS-Ressourcen sind im eigenstaendigen Design nicht erlaubt: ' . $relative;
344 }
345 if ($ext === 'js' && preg_match('/\b(?:fetch|XMLHttpRequest|WebSocket|EventSource|sendBeacon)\s*(?:\‍(|\.)/i', $content)) {
346 return 'Eigener Netzwerktransport ist im Design-JavaScript nicht erlaubt: ' . $relative;
347 }
348 if ($ext === 'svg' && preg_match('/<(?:script|foreignObject)\b|\son[a-z]+\s*=|javascript\s*:|<!ENTITY|<\?xml-stylesheet/i', $content)) {
349 return 'Aktiver SVG-Inhalt ist im Design-Ergebnis nicht erlaubt: ' . $relative;
350 }
351 return '';
352 }
353
358 public function applyResult(string $source, string $target, string $resultDir, string $mode): array {
359 $source = $this->normalizeName($source);
360 $target = $this->normalizeName($target);
361 $mode = $mode === 'update' ? 'update' : 'create';
362 if (!$this->isValidName($target)) {
363 throw new \InvalidArgumentException('Ungueltiges Zieldesign.');
364 }
365 $targetDir = $this->designDir($target);
366 $backup = '';
367 if ($mode === 'update') {
368 if (!is_dir($targetDir)) {
369 throw new \RuntimeException('Das zu aktualisierende Design existiert nicht.');
370 }
371 $backup = $this->backupDesign($target);
372 } elseif (is_dir($targetDir)) {
373 throw new \RuntimeException('Das Zieldesign existiert bereits.');
374 }
375
376 $stage = $this->stageDir($target);
377 $this->removeDirectory($stage);
378 if (!mkdir($stage, 0777, true) && !is_dir($stage)) {
379 throw new \RuntimeException('Staging-Verzeichnis konnte nicht angelegt werden.');
380 }
381
382 try {
383 if ($mode === 'update') {
384 $this->copyDirectory($targetDir, $stage);
385 } else {
386 if (!$this->isValidName($source) || !is_dir($this->designDir($source))) {
387 throw new \RuntimeException('Ausgangsdesign fuer die Neuanlage fehlt.');
388 }
389 $this->copyDirectory($this->designDir($source), $stage);
390 $this->rewriteDesignPaths($stage, $source, $target);
391 }
392 foreach ($this->relativeFiles($resultDir) as $relative => $absolute) {
393 $fileError = $this->validateResultFile($relative, $absolute);
394 if ($fileError !== '') {
395 throw new \RuntimeException($fileError);
396 }
397 $dest = $stage . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative);
398 $parent = dirname($dest);
399 if (!is_dir($parent) && !mkdir($parent, 0777, true) && !is_dir($parent)) {
400 throw new \RuntimeException('Zielordner konnte nicht angelegt werden: ' . $relative);
401 }
402 if (!copy($absolute, $dest)) {
403 throw new \RuntimeException('Ergebnisdatei konnte nicht uebernommen werden: ' . $relative);
404 }
405 }
406 $validation = $this->validateDesignDirectory($stage, true);
407 if ($validation['errors']) {
408 throw new \RuntimeException(implode(' ', $validation['errors']));
409 }
410 if ($mode === 'update') {
411 $old = $targetDir . '.dbx-old-' . bin2hex(random_bytes(4));
412 if (!rename($targetDir, $old)) {
413 throw new \RuntimeException('Bestehendes Design konnte nicht fuer den Austausch vorbereitet werden.');
414 }
415 if (!rename($stage, $targetDir)) {
416 rename($old, $targetDir);
417 throw new \RuntimeException('Neues Design konnte nicht aktiviert werden.');
418 }
419 $this->removeDirectory($old);
420 } elseif (!rename($stage, $targetDir)) {
421 throw new \RuntimeException('Neues Design konnte nicht aktiviert werden.');
422 }
423 } catch (\Throwable $e) {
424 $this->removeDirectory($stage);
425 throw $e;
426 }
427
428 return array('name' => $target, 'backup' => $backup, 'validation' => $validation);
429 }
430
431 private function stageDir(string $target): string {
432 $root = dbx()->os_path(rtrim(dbx()->get_file_dir(), '/\\') . '/tmp/design-admin');
433 if (!is_dir($root) && !mkdir($root, 0777, true) && !is_dir($root)) {
434 throw new \RuntimeException('Design-Staging-Root konnte nicht angelegt werden.');
435 }
436 return $root . DIRECTORY_SEPARATOR . $target . '-' . bin2hex(random_bytes(5));
437 }
438
439 private function detectLayout(string $dir): string {
440 $html = is_file($dir . '/htm/default.htm') ? (string)file_get_contents($dir . '/htm/default.htm') : '';
441 return strpos($html, '<aside') !== false ? 'sidebar' : 'top';
442 }
443
444 private function copyDirectory(string $source, string $target): void {
445 $it = new \RecursiveIteratorIterator(
446 new \RecursiveDirectoryIterator($source, \FilesystemIterator::SKIP_DOTS),
447 \RecursiveIteratorIterator::SELF_FIRST
448 );
449 foreach ($it as $file) {
450 if ($file->isLink()) {
451 continue;
452 }
453 $relative = substr($file->getPathname(), strlen(rtrim($source, '/\\')) + 1);
454 $dest = $target . DIRECTORY_SEPARATOR . $relative;
455 if ($file->isDir()) {
456 if (!is_dir($dest) && !mkdir($dest, 0777, true) && !is_dir($dest)) {
457 throw new \RuntimeException('Ordner konnte nicht kopiert werden: ' . $relative);
458 }
459 } elseif (!copy($file->getPathname(), $dest)) {
460 throw new \RuntimeException('Datei konnte nicht kopiert werden: ' . $relative);
461 }
462 }
463 }
464
465 private function removeDirectory(string $dir): void {
466 if (!is_dir($dir)) {
467 return;
468 }
469 $it = new \RecursiveIteratorIterator(
470 new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
471 \RecursiveIteratorIterator::CHILD_FIRST
472 );
473 foreach ($it as $file) {
474 if ($file->isDir()) {
475 @rmdir($file->getPathname());
476 } else {
477 @unlink($file->getPathname());
478 }
479 }
480 @rmdir($dir);
481 }
482
483 private function rewriteDesignPaths(string $dir, string $source, string $target): void {
484 foreach ($this->relativeFiles($dir) as $relative => $absolute) {
485 $ext = strtolower(pathinfo($relative, PATHINFO_EXTENSION));
486 if (!in_array($ext, array('htm', 'html', 'css', 'js', 'json', 'txt', 'md'), true)) {
487 continue;
488 }
489 $content = (string)file_get_contents($absolute);
490 $new = str_replace(
491 array('dbx/design/' . $source . '/', 'design/' . $source . '/'),
492 array('dbx/design/' . $target . '/', 'design/' . $target . '/'),
494 );
495 if ($new !== $content) {
496 file_put_contents($absolute, $new);
497 }
498 }
499 }
500
501 private function writeWizardFiles(string $dir, string $target, array $input, array $logoUpload): void {
502 foreach (array('htm', 'css', 'img', 'js') as $sub) {
503 if (!is_dir($dir . DIRECTORY_SEPARATOR . $sub)) {
504 mkdir($dir . DIRECTORY_SEPARATOR . $sub, 0777, true);
505 }
506 }
507 $input = array_merge($this->defaults((string)($input['source_design'] ?? 'dbxapp')), $input);
508 $logoAsset = $this->storeLogoUpload($dir, $logoUpload);
509 file_put_contents($dir . '/htm/default.htm', $this->buildDefaultTemplate($target, $input));
510 file_put_contents($dir . '/htm/logo.htm', $this->buildLogoFragment($input, $logoAsset));
511 file_put_contents($dir . '/htm/branding.htm', $this->buildBrandingFragment($input));
512 file_put_contents($dir . '/htm/footer.htm', $this->buildFooterFragment($input));
513 file_put_contents($dir . '/css/design-custom.css', $this->buildCustomCss($input));
514 $metadata = array_merge($input, array(
515 'contract' => self::CONTRACT,
516 'name' => $target,
517 'created_at' => date(DATE_ATOM),
518 'managed_by' => 'dbxDesign_admin',
519 'slots' => array('logo', 'branding', 'footer'),
520 'logo_asset' => $logoAsset,
521 ));
522 unset($metadata['set_default']);
523 file_put_contents(
524 $dir . '/design.json',
525 json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
526 );
527 }
528
529 private function storeLogoUpload(string $dir, array $file): string {
530 if (!$file || ($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
531 return '';
532 }
533 if (($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
534 throw new \InvalidArgumentException('Logo-Upload fehlgeschlagen.');
535 }
536 if ((int)($file['size'] ?? 0) > 5 * 1024 * 1024) {
537 throw new \InvalidArgumentException('Das Logo darf maximal 5 MB gross sein.');
538 }
539 $tmp = (string)($file['tmp_name'] ?? '');
540 $mime = $tmp !== '' && is_file($tmp) && function_exists('mime_content_type') ? (string)mime_content_type($tmp) : '';
541 $extensions = array(
542 'image/png' => 'png',
543 'image/jpeg' => 'jpg',
544 'image/webp' => 'webp',
545 'image/gif' => 'gif',
546 );
547 if (!isset($extensions[$mime])) {
548 throw new \InvalidArgumentException('Logo bitte als PNG, JPG, WEBP oder GIF hochladen.');
549 }
550 $name = 'logo.' . $extensions[$mime];
551 $dest = $dir . DIRECTORY_SEPARATOR . 'img' . DIRECTORY_SEPARATOR . $name;
552 if (!copy($tmp, $dest)) {
553 throw new \RuntimeException('Logo konnte nicht gespeichert werden.');
554 }
555 return 'img/' . $name;
556 }
557
558 private function buildDefaultTemplate(string $target, array $in): string {
559 $layout = in_array($in['layout'], array('top', 'sidebar', 'hybrid'), true) ? $in['layout'] : 'top';
560 $extraCss = is_file($this->designDir((string)$in['source_design']) . '/css/glass-3d.css')
561 ? ' <link rel="stylesheet" href="dbx/design/{dbx:design}/css/glass-3d.css">' . "\n"
562 : '';
563 $adminMenu = <<<'HTML'
564[inc=has_group('admin')]
565<nav id="dbx_admin_menu" class="dbx-menu dbx-menu-admin" data-dbx='lib=menu|id=admin|label=<i class="bi bi-shield-lock"></i> Admin' style="visibility:hidden">
566 [modul=dbxMenu]dbx_run1=load&tpl=modul|dbx-top-admin[/modul]
567</nav>
568[/inc]
569HTML;
570 $mainMenu = <<<'HTML'
571<nav id="dbx_main_menu" class="dbx-menu dbx-menu-main" data-dbx='lib=menu|id=user|label=<i class="bi bi-list"></i> Menue' style="visibility:hidden">
572 [modul=dbxMenu]dbx_run1=load&tpl=modul|dbx-top-main[/modul]
573</nav>
574HTML;
575 if ($layout === 'sidebar') {
576 $shell = '<div id="dbxApp" class="dbx-design-shell dbx-layout-sidebar">'
577 . '<aside id="dbxHeader" class="dbx-design-side"><div class="dbx-brand-row">[dbx:logo][dbx:branding]</div>'
578 . $mainMenu . '<div class="dbx-admin-side">' . $adminMenu . '</div></aside>'
579 . '<div class="dbx-design-stage"><main id="dbxMain"><div class="dbx-content"><div id="dbxContent" class="dbx-content-inner">[dbx:content]</div></div></main>[dbx:footer]</div></div>';
580 } elseif ($layout === 'hybrid') {
581 $shell = '<div id="dbxApp" class="dbx-design-shell dbx-layout-hybrid">'
582 . '<header id="dbxHeader"><div class="dbx-brand-row">[dbx:logo][dbx:branding]</div>' . $adminMenu . '</header>'
583 . '<div class="dbx-hybrid-grid"><aside class="dbx-design-side">' . $mainMenu . '</aside>'
584 . '<div class="dbx-design-stage"><main id="dbxMain"><div class="dbx-content"><div id="dbxContent" class="dbx-content-inner">[dbx:content]</div></div></main>[dbx:footer]</div></div></div>';
585 } else {
586 $shell = '<div id="dbxApp" class="dbx-design-shell dbx-layout-top">'
587 . '<header id="dbxHeader"><div class="dbx-brand-row">[dbx:logo][dbx:branding]</div><div class="dbx-menu-stack">'
588 . $adminMenu . $mainMenu . '</div></header>'
589 . '<main id="dbxMain"><div class="dbx-content"><div id="dbxContent" class="dbx-content-inner">[dbx:content]</div></div></main>[dbx:footer]</div>';
590 }
591 return '<!DOCTYPE html>' . "\n"
592 . '<html lang="{dbx:lng}"><head>' . "\n"
593 . ' <meta charset="utf-8">' . "\n"
594 . ' <meta name="viewport" content="width=device-width, initial-scale=1">' . "\n"
595 . ' <title>{dbx:title}</title>{dbx:head_meta}' . "\n"
596 . ' <link rel="stylesheet" href="dbx/vendor/twbs/bootstrap/dist/css/bootstrap.min.css">' . "\n"
597 . ' <link rel="stylesheet" href="dbx/vendor/twbs/bootstrap-icons/font/bootstrap-icons.css">' . "\n"
598 . ' <link rel="stylesheet" href="dbx/design/{dbx:design}/css/colors.css">' . "\n"
599 . ' <link rel="stylesheet" href="{dbx:skin_css}">' . "\n"
600 . ' <link rel="stylesheet" href="dbx/design/{dbx:design}/css/base.css">' . "\n"
601 . ' <link rel="stylesheet" href="dbx/design/{dbx:design}/css/theme.css">' . "\n"
602 . $extraCss
603 . ' <link rel="stylesheet" href="dbx/design/{dbx:design}/css/design-custom.css">' . "\n"
604 . '</head>' . "\n"
605 . '<body class="dbx-app dbx-design-generated dbx-layout-' . $layout . ' {dbx:skin_class}"'
606 . '[inc=is_dbx_edit()] data-dbx="lib=icons_editor"[/inc] data-dbx-design="{dbx:design}" data-dbx-skin="{dbx:color}">' . "\n"
607 . $shell . "\n"
608 . '<div id="dbxPreloader" class="dbx-preloader" style="display:none!important;pointer-events:none!important"></div>' . "\n"
609 . '<button id="dbxBackToTop" class="btn btn-primary" type="button" aria-label="Nach oben"><i class="bi bi-arrow-up"></i></button>' . "\n"
610 . '<script src="dbx/vendor/components/jquery/jquery.min.js"></script>' . "\n"
611 . '<script src="dbx/js/lib/core.js?design={dbx:design}"></script>' . "\n"
612 . '</body></html>';
613 }
614
615 private function buildLogoFragment(array $in, string $asset): string {
616 $name = htmlspecialchars((string)$in['brand_name'], ENT_QUOTES, 'UTF-8');
617 if ($asset !== '') {
618 return '<a class="dbx-design-logo" href="{base_url}" aria-label="' . $name . '"><img src="dbx/design/{dbx:design}/' . $asset . '" alt="' . $name . '"></a>';
619 }
620 if (($in['logo_mode'] ?? 'icon') === 'none') {
621 return '';
622 }
623 $icon = preg_replace('/[^a-z0-9_-]+/i', '', (string)($in['logo_icon'] ?? 'bi-stars')) ?: 'bi-stars';
624 return '<a class="dbx-design-logo dbx-design-logo-icon" href="{base_url}" aria-label="' . $name . '"><i class="bi ' . $icon . '" aria-hidden="true"></i></a>';
625 }
626
627 private function buildBrandingFragment(array $in): string {
628 $name = htmlspecialchars((string)$in['brand_name'], ENT_QUOTES, 'UTF-8');
629 $tagline = htmlspecialchars((string)$in['tagline'], ENT_QUOTES, 'UTF-8');
630 return '<a class="dbx-design-branding" href="{base_url}"><strong>' . $name . '</strong>'
631 . ($tagline !== '' ? '<small>' . $tagline . '</small>' : '') . '</a>';
632 }
633
634 private function buildFooterFragment(array $in): string {
635 $mode = in_array($in['footer_mode'], array('full', 'minimal', 'none'), true) ? $in['footer_mode'] : 'full';
636 $dock = '<div class="dbx-footer-dockbar" aria-label="Fensterleiste"><div id="windrop" class="dbx-window-dock" aria-label="Minimierte Fenster"></div>'
637 . '<button id="dbxWindowCloseAll" class="dbx-footer-window-close-all" type="button" title="Alle Fenster schliessen" aria-label="Alle Fenster schliessen"><i class="bi bi-x-square"></i></button></div>';
638 if ($mode === 'none') {
639 return '<footer id="dbxFooter" class="dbx-design-footer dbx-footer-dock-only">' . $dock . '</footer>';
640 }
641 $brand = htmlspecialchars((string)$in['brand_name'], ENT_QUOTES, 'UTF-8');
642 $text = htmlspecialchars((string)$in['footer_text'], ENT_QUOTES, 'UTF-8');
643 $legal = !empty($in['legal_links'])
644 ? '<a href="datenschutz">Datenschutz</a><a href="impressum">Impressum</a>'
645 : '';
646 $details = $mode === 'full' && $text !== '' ? '<span class="dbx-design-footer-text">' . $text . '</span>' : '';
647 return '<footer id="dbxFooter" class="dbx-design-footer">' . $dock
648 . '<div class="dbx-design-footer-info">' . $details . '<span class="dbx-design-footer-links">' . $legal . '</span>'
649 . '<span>© ' . date('Y') . ' ' . $brand . '</span><span data-dbx-runtime data-dbx-php-runtime class="visually-hidden">...</span></div></footer>';
650 }
651
652 private function buildCustomCss(array $in): string {
653 $colors = array(
654 'primary_color' => '#2563eb',
655 'secondary_color' => '#0f172a',
656 'accent_color' => '#14b8a6',
657 'background_color' => '#f4f7fb',
658 'surface_color' => '#ffffff',
659 'text_color' => '#172033',
660 );
661 foreach ($colors as $key => $fallback) {
662 if (preg_match('/^#[0-9a-f]{6}$/i', (string)($in[$key] ?? '')) !== 1) {
663 $in[$key] = $fallback;
664 }
665 }
666 $fonts = array(
667 'system' => 'system-ui, -apple-system, "Segoe UI", sans-serif',
668 'modern' => '"Inter", "Segoe UI", system-ui, sans-serif',
669 'editorial' => 'Georgia, "Times New Roman", serif',
670 'rounded' => '"Trebuchet MS", "Segoe UI", sans-serif',
671 );
672 $radius = array('square' => '0.25rem', 'soft' => '0.85rem', 'round' => '1.5rem');
673 $width = array('compact' => '960px', 'wide' => '1440px', 'full' => '100%');
674 $font = $fonts[$in['font_family']] ?? $fonts['modern'];
675 $radiusValue = $radius[$in['radius']] ?? $radius['soft'];
676 $maxWidth = $width[$in['content_width']] ?? $width['wide'];
677 $menuStyle = in_array($in['menu_style'], array('tabs', 'pills', 'compact'), true) ? $in['menu_style'] : 'tabs';
678 return '/* Durch dbxDesign_admin erzeugte, bewusst kleine Anpassungsschicht. */' . "\n"
679 . ':root{' . "\n"
680 . ' --design-primary:' . $in['primary_color'] . ';--design-secondary:' . $in['secondary_color'] . ';--design-accent:' . $in['accent_color'] . ';' . "\n"
681 . ' --design-bg:' . $in['background_color'] . ';--design-surface:' . $in['surface_color'] . ';--design-text:' . $in['text_color'] . ';' . "\n"
682 . ' --design-radius:' . $radiusValue . ';--design-content-width:' . $maxWidth . ';' . "\n"
683 . ' --dbx-primary:var(--design-primary);--bs-primary:var(--design-primary);' . "\n"
684 . '}' . "\n"
685 . 'html,body{min-height:100%;}body.dbx-design-generated{margin:0;background:var(--design-bg);color:var(--design-text);font-family:' . $font . ';}' . "\n"
686 . '.dbx-design-shell{min-height:100vh;}.dbx-design-stage{min-width:0;display:flex;flex-direction:column;min-height:100vh;}' . "\n"
687 . '#dbxHeader{background:var(--design-surface);border-bottom:1px solid color-mix(in srgb,var(--design-secondary) 18%,transparent);box-shadow:0 8px 30px color-mix(in srgb,var(--design-secondary) 10%,transparent);}' . "\n"
688 . '.dbx-brand-row{display:flex;align-items:center;gap:.8rem;padding:.8rem 1rem;}.dbx-design-logo{display:grid;place-items:center;width:2.8rem;height:2.8rem;border-radius:var(--design-radius);background:var(--design-primary);color:#fff;font-size:1.35rem;overflow:hidden;flex:0 0 auto;}.dbx-design-logo img{width:100%;height:100%;object-fit:contain;background:#fff;}.dbx-design-branding{display:grid;color:var(--design-text);text-decoration:none;line-height:1.15;}.dbx-design-branding strong{font-size:1.05rem}.dbx-design-branding small{margin-top:.2rem;color:color-mix(in srgb,var(--design-text) 68%,transparent);}' . "\n"
689 . '.dbx-menu-stack{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;padding:0 1rem .8rem}.dbx-layout-top #dbxHeader{display:flex;align-items:center;justify-content:space-between;gap:1rem;position:sticky;top:0;z-index:1030}.dbx-layout-top .dbx-menu-stack{padding:.7rem 1rem;}' . "\n"
690 . '.dbx-layout-sidebar{display:grid;grid-template-columns:minmax(230px,290px) minmax(0,1fr)}.dbx-layout-sidebar>.dbx-design-side,.dbx-hybrid-grid>.dbx-design-side{min-height:100vh;border-right:1px solid color-mix(in srgb,var(--design-secondary) 16%,transparent);background:var(--design-surface);}.dbx-layout-sidebar>.dbx-design-side{position:sticky;top:0;height:100vh;overflow:auto}.dbx-layout-sidebar .dbx-menu,.dbx-hybrid-grid>.dbx-design-side .dbx-menu{display:block;padding:.5rem 1rem}.dbx-admin-side{margin-top:auto;padding-top:1rem}' . "\n"
691 . '.dbx-hybrid-grid{display:grid;grid-template-columns:minmax(210px,260px) minmax(0,1fr)}.dbx-layout-hybrid>#dbxHeader{display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:1030}.dbx-hybrid-grid>.dbx-design-side{min-height:calc(100vh - 70px);}' . "\n"
692 . '#dbxMain{flex:1;min-width:0}.dbx-content-inner{width:min(100%,var(--design-content-width));margin-inline:auto;padding:clamp(.75rem,2vw,1.5rem)}' . "\n"
693 . '.dbx-design-footer{margin-top:auto;background:var(--design-surface);border-top:1px solid color-mix(in srgb,var(--design-secondary) 16%,transparent)}.dbx-design-footer-info{display:flex;flex-wrap:wrap;justify-content:space-between;gap:.75rem;padding:.8rem 1rem;font-size:.85rem}.dbx-design-footer-links{display:flex;gap:1rem}.dbx-footer-dock-only .dbx-footer-dockbar:empty{display:none}' . "\n"
694 . '.dbx-design-generated .btn-primary{--bs-btn-bg:var(--design-primary);--bs-btn-border-color:var(--design-primary)}.dbx-design-generated .card,.dbx-design-generated .dbx-panel{border-radius:var(--design-radius)}' . "\n"
695 . ($menuStyle === 'pills' ? '.dbx-design-generated .dbx-menu a{border-radius:999px}' : ($menuStyle === 'compact' ? '.dbx-design-generated .dbx-menu a{padding:.3rem .55rem;font-size:.88rem}' : '.dbx-design-generated .dbx-menu a{border-radius:var(--design-radius)}')) . "\n"
696 . '@media(max-width:900px){.dbx-layout-top #dbxHeader{position:relative;display:block}.dbx-layout-sidebar,.dbx-hybrid-grid{grid-template-columns:1fr}.dbx-layout-sidebar>.dbx-design-side,.dbx-hybrid-grid>.dbx-design-side{position:relative;height:auto;min-height:0;border-right:0;border-bottom:1px solid color-mix(in srgb,var(--design-secondary) 16%,transparent)}.dbx-menu-stack{display:block}.dbx-design-stage{min-height:auto}}' . "\n";
697 }
698
699 private function addDirectoryToZip(\ZipArchive $zip, string $dir, string $prefix): void {
700 foreach ($this->relativeFiles($dir) as $relative => $absolute) {
701 $zip->addFile($absolute, ltrim($prefix . '/' . $relative, '/'));
702 }
703 }
704}
705?>
Dateibasierte Design-Domäne für Wizard, Backup und KI-Import.
normalizeName(string $name)
Normalisiert einen technischen Designnamen.
validateResultFile(string $relative, string $absolute)
Prüft aktive Inhalte einer von außen gelieferten Ergebnisdatei.
validateDesignDirectory(string $dir, bool $strict=true)
Validiert den Designvertrag und sicherheitsrelevante Dateien.
readMetadata(string $name)
Liest optionale Wizard-Metadaten.
createFromWizard(array $input, array $logoUpload=array())
Erzeugt aus einem vorhandenen kompatiblen Design ein unabhängiges Paket.
listDesigns()
Listet alle öffentlichen Designs mit Metadaten und Vertragsstatus.
isValidName(string $name)
Prüft den technischen Designnamen.
isAllowedDesignFile(string $relative)
Prüft Dateitypen, die eine KI in einem Designpaket liefern darf.
designRoot()
Liefert den absoluten Design-Root.
defaults(string $source='dbxapp')
Voreinstellungen für einen komfortablen Wizard.
relativeFiles(string $dir)
Liefert alle Paketdateien als relative, slash-normalisierte Pfade.
backupDesign(string $name)
Erstellt eine wiederherstellbare ZIP-Sicherung eines Designs.
designDir(string $name)
Liefert den absoluten Pfad eines validierten Designs.
applyResult(string $source, string $target, string $resultDir, string $mode)
Kopiert ein Design als sichere Grundlage und überschreibt es mit validierten Ergebnisdateien einer KI...
os_path(string $path)
Normalisiert einen Pfad fuer das aktuelle Betriebssystem.
Definition dbxApi.php:1538
has_group($access_groups='', $user_groups='')
Definition dbxApi.php:1410
get_base_dir(int $cutData=0)
Liefert das Basisverzeichnis der Installation.
Definition dbxApi.php:1507
get_file_dir()
Liefert das files/-Verzeichnis der Installation.
Definition dbxApi.php:1524
if($resolved !==$expectedBase . 'files/test/') $config
if(($result['name'] ?? '') !=='customer-design'||!is_dir($target)) if(substr_count( $defaultHtml, '[dbx:content]') !==1) foreach(array('[dbx:logo]', '[dbx:branding]', '[dbx:footer]') as $slot) if(( $metadata[ 'contract'] ?? '') !==\dbx\dbxDesign_admin\dbxDesignService::CONTRACT) if((dbx() ->config['dbx']['default_design_user'] ?? '') !=='customer-design') $validation
if(preg_match('/core\.js\? foreach[^"\']*v=(\d+)/', $designTemplate, $assetMatch) !== 1 || !str_contains($shopReference, 'dbxapp-Asset-Version ' . $assetMatch[1])) foreach (array( 'reference\\archive', 'provision_docs_content.php', 'dbxSelfTest') as $needle)(array('Installation'=> $installation, 'Installations-Tutorial'=> $installationTutorial, 'SelfTest'=> $selfTest) as $label=> $html)
if(!defined( 'IMG_WEBP')) define( 'IMG_WEBP'
DBX schema administration.