dbXapp 2.0
RAD, CMS, Module und Runtime-IDE fuer dbXapp
Loading...
Searching...
No Matches
dbxKiModuleBriefingService.class.php
Go to the documentation of this file.
1<?php
2namespace dbx\dbxKi;
3
5
6 private const MODULE_BRIEFING_VERSION = '0.1';
7
8 private function esc($value): string {
9 return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
10 }
11
12 private function modulesRoot(): string {
13 return dbx_os_path_file(dbx_get_base_dir() . 'dbx/modules/');
14 }
15
16 private function validName($name): bool {
17 return is_string($name) && preg_match('/^[A-Za-z][A-Za-z0-9_]{1,63}$/', $name);
18 }
19
20 private function moduleDir(string $modul): string {
21 return dbx_os_path_file($this->modulesRoot() . $modul . DIRECTORY_SEPARATOR);
22 }
23
24 private function modulePath(string $modul, string $rel): string {
25 $rel = str_replace(array('\\', "\0"), array('/', ''), $rel);
26 $rel = ltrim($rel, '/');
27 if ($rel === '' || strpos($rel, '../') !== false) {
28 return '';
29 }
30 $base = $this->moduleDir($modul);
31 $path = dbx_os_path_file($base . $rel);
32 $baseNorm = str_replace('\\', '/', rtrim($base, '/\\') . '/');
33 $pathNorm = str_replace('\\', '/', $path);
34 return strpos($pathNorm, $baseNorm) === 0 ? $path : '';
35 }
36
37 private function moduleUrl(string $run1, array $params = array()): string {
38 $url = '?dbx_modul=dbxKi&dbx_run1=' . rawurlencode($run1);
39 foreach ($params as $key => $value) {
40 $url .= '&' . rawurlencode((string)$key) . '=' . rawurlencode((string)$value);
41 }
42 return $url;
43 }
44
45 private function tpl() {
46 return dbx()->get_system_obj('dbxTPL');
47 }
48
49 private function help(): dbxKiHelp {
50 return dbx()->get_include_obj('dbxKiHelp', 'dbxKi');
51 }
52
53 private function moduleBar(string $screen, string $actionsHtml = ''): string {
54 return $this->tpl()->get_tpl('dbx|module-bar', $this->help()->moduleBarTemplateData($screen, $actionsHtml));
55 }
56
57 private function selectOptions(array $options, string $selected): string {
58 $html = '';
59 foreach ($options as $value => $label) {
60 $sel = (string)$value === $selected ? ' selected' : '';
61 $html .= '<option value="' . $this->esc($value) . '"' . $sel . '>' . $this->esc($label) . '</option>';
62 }
63 return $html;
64 }
65
66 private function moduleOptions(string $selected = ''): string {
67 $html = '<option value="">Bitte Modul waehlen</option>';
68 $dirs = glob($this->modulesRoot() . '*', GLOB_ONLYDIR) ?: array();
69 sort($dirs);
70 foreach ($dirs as $dir) {
71 $name = basename($dir);
72 if (!$this->validName($name)) {
73 continue;
74 }
75 $sel = $name === $selected ? ' selected' : '';
76 $html .= '<option value="' . $this->esc($name) . '"' . $sel . '>' . $this->esc($name) . '</option>';
77 }
78 return $html;
79 }
80
81 private function fileTree(string $modul, bool $withContent = false): array {
82 $dir = $this->moduleDir($modul);
83 $rows = array();
84 if (!is_dir($dir)) {
85 return $rows;
86 }
87 $it = new \RecursiveIteratorIterator(
88 new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
89 \RecursiveIteratorIterator::SELF_FIRST
90 );
91 foreach ($it as $item) {
92 $full = $item->getPathname();
93 $rel = str_replace('\\', '/', substr($full, strlen(rtrim($dir, '/\\')) + 1));
94 $row = array(
95 'path' => 'dbx/modules/' . $modul . '/' . $rel,
96 'type' => $item->isDir() ? 'dir' : 'file',
97 'size' => $item->isFile() ? (int)$item->getSize() : 0,
98 );
99 if ($withContent && $item->isFile() && $this->isTextFile($full) && $item->getSize() <= 262144) {
100 $row['content'] = (string)file_get_contents($full);
101 }
102 $rows[] = $row;
103 }
104 return $rows;
105 }
106
107 private function isTextFile(string $file): bool {
108 return (bool)preg_match('/\.(php|phtml|pht|htm|html|css|js|json|md|txt|sql|xml|svg)$/i', $file);
109 }
110
111 private function moduleDescribe(string $modul = ''): array {
112 return array(
113 'ok' => 1,
114 'area' => 'module',
115 'module' => $modul,
116 'briefing_version' => self::MODULE_BRIEFING_VERSION,
117 'endpoint' => $this->moduleUrl('module_api'),
118 'hard_rules' => $this->hardRules($modul ?: '{module}'),
119 'dbxapp_way' => $this->dbxappWay(),
120 'module_pipeline' => $this->modulePipeline($modul ?: '{module}'),
121 'ui_contract' => $this->uiContract(),
122 'dbx_api_contract' => $this->dbxApiContract($modul ?: '{module}'),
123 'api_actions' => $this->apiCatalog(),
124 'answer_zip_contract' => $this->answerZipContract($modul ?: '{module}'),
125 );
126 }
127
128 private function hardRules(string $modul): array {
129 return array(
130 'Nur Dateien unter dbx/modules/' . $modul . '/ bearbeiten.',
131 'Keine Aenderungen an dbx/include, globaler config.php, anderen Modulen oder files/ ausser explizit erlaubten Modul-Assets.',
132 'Vor destruktiven Aenderungen muss ein vollstaendiges Modul-ZIP als Backup existieren.',
133 'DD-Dateien muessen vollstaendig sein; keine DD-Includes.',
134 'DD->DB Sync nur fuer ' . $modul . '|{dd}. Keine Migrationen, keine Altlasten.',
135 'Templates ueber dbxTPL lesen/rendern: dbx()->get_system_obj("dbxTPL")->get_tpl("' . $modul . '|template", $data).',
136 'Formulare ueber dbxForm bauen: save_post(), add_flds(), Callbacks, Modul-Bar Actions.',
137 'Reports ueber dbxReport bauen: Remember-Multi-Select, Multi-Delete, Row-Edit, Row-Detail, Row-Delete, Callbacks.',
138 'README-MODUL-WIZARD.md aktualisieren, wenn Verhalten oder API geaendert wird.',
139 );
140 }
141
142 private function dbxappWay(): array {
143 return array(
144 'module_entry' => 'dbx/modules/{module}/{module}.class.php routet dbx_run1 und delegiert an include Services.',
145 'service_code' => 'Fachlogik liegt unter include/*.class.php im Modul.',
146 'dd' => 'Data Dictionary liegt unter dd/*.dd.php und beschreibt Tabelle, Rechte, Felder, Indexe vollstaendig.',
147 'fd' => 'Form-/Report-Felder liegen unter fd/*.fd.php.',
148 'templates' => 'Templates liegen unter tpl/htm und werden per dbxTPL get_tpl gerendert.',
149 'sqlite' => '*.db3 fuer ein Modul bleibt im Modulkontext; DD->DB Sync ueber dbxAdmin Schema.',
150 );
151 }
152
153 private function apiCatalog(): array {
154 return array(
155 'system.describe' => array('method' => 'GET/POST', 'params' => array(), 'result' => 'Regeln, Aktionen, Antwort-ZIP-Vertrag'),
156 'module.describe' => array('params' => array('xmodul'), 'result' => 'Regeln und Kontext fuer ein Modul'),
157 'module.snapshot' => array('params' => array('xmodul', 'with_content=0|1'), 'result' => 'Dateibaum, optional kleine Textdateien mit Inhalt'),
158 'module.file.read' => array('params' => array('xmodul', 'path'), 'result' => 'Einzelne Textdatei innerhalb des Moduls'),
159 'module.pipeline_guide' => array('params' => array('xmodul', 'task_type'), 'result' => 'Verbindliches Job-Schema fuer Modul-Aenderungen'),
160 'module.job.execute' => array('method' => 'POST JSON', 'params' => array('manifest', 'job'), 'result' => 'Fuehrt ein Modul-job.json direkt ueber dieselbe Pipeline aus'),
161 );
162 }
163
164 private function answerZipContract(string $modul): array {
165 return array(
166 'filename' => 'antwort.zip',
167 'required_files' => array('manifest.json', 'job.json', 'README.md'),
168 'allowed_payload_paths' => array('dbx/modules/' . $modul . '
235 public function renderBriefing(): string {
236 $selected = dbx()->get_modul_var('xmodul', '', 'parameter');
237 $dd = dbx()->get_modul_var('dd_name', '', 'parameter');
238 $taskType = dbx()->get_modul_var('task_type', 'extend', 'parameter');
239 $includeContext = dbx()->get_modul_var('include_context', 'full', 'parameter');
240 $export = $this->moduleUrl('briefing_module_export');
241 $api = $this->moduleUrl('module_api', array('action' => 'system.describe'));
242 $bundle = $this->moduleUrl('module_bundle');
243 $taskOptions = $this->selectOptions(array(
244 'extend' => 'Bestehendes Modul erweitern',
245 'repair' => 'Fehler im Modul reparieren',
246 'form_report' => 'Formular/Report erweitern',
247 'api' => 'Modul-API erweitern',
248 'refactor' => 'Intern aufraeumen',
249 ), $taskType);
250 $contextOptions = $this->selectOptions(array(
251 'full' => 'Komplettes Modul ins ZIP',
252 'tree' => 'Nur Dateibaum + Regeln',
253 ), $includeContext);
254
255 return '<div class="dbx-panel dbx-ki-module-briefing">'
256 . $this->moduleBar('briefing_module',
257 '<a class="btn btn-success btn-sm" href="' . $this->esc($bundle) . '"><i class="bi bi-upload"></i> Antwort importieren</a>'
258 . '<a class="btn btn-outline-secondary btn-sm" target="_blank" href="' . $this->esc($api) . '"><i class="bi bi-braces"></i> API</a>')
259 . '<div class="container-fluid py-3 px-3 px-xl-4" style="max-width:1500px">'
260 . '<div class="row g-3 align-items-stretch">'
261 . '<div class="col-12 col-xl-4">'
262 . '<div class="card h-100 border-primary shadow-sm overflow-hidden">'
263 . '<div class="card-body p-3 p-xl-4">'
264 . '<div class="d-flex justify-content-center align-items-center bg-light border rounded mb-3" style="min-height:220px">'
265 . '<img src="dbx/modules/dbxKi/tpl/img/dbxKi.png" alt="dbxKi" style="display:block;max-width:82%;max-height:210px;object-fit:contain">'
266 . '</div>'
267 . '<div class="text-uppercase small fw-bold text-muted mb-1">dbxKi Modul-Pipeline</div>'
268 . '<h2 class="h3 mb-2">KI-Auftrag fuer bestehende Module</h2>'
269 . '<p class="text-muted mb-3">Die KI liefert nur feste JSON-Strukturen. dbxKi prueft, sichert und schreibt ausschliesslich innerhalb des gewaehlten Moduls.</p>'
270 . '<div class="row g-2 text-center">'
271 . '<div class="col-4"><div class="border rounded bg-light p-2 h-100"><strong class="d-block">ZIP</strong><small class="text-muted">Kontext</small></div></div>'
272 . '<div class="col-4"><div class="border rounded bg-light p-2 h-100"><strong class="d-block">API</strong><small class="text-muted">Regeln</small></div></div>'
273 . '<div class="col-4"><div class="border rounded bg-light p-2 h-100"><strong class="d-block">Backup</strong><small class="text-muted">vor Schreibzugriff</small></div></div>'
274 . '</div>'
275 . '</div></div></div>'
276 . '<div class="col-12 col-xl-8">'
277 . '<div class="card h-100 shadow-sm">'
278 . '<div class="card-body p-3 p-xl-4">'
279 . '<form method="post" action="' . $this->esc($export) . '">'
280 . '<div class="row g-3">'
281 . '<div class="col-md-6"><label class="form-label fw-semibold">Modul</label><select name="xmodul" class="form-select" required>' . $this->moduleOptions($selected) . '</select></div>'
282 . '<div class="col-md-6"><label class="form-label fw-semibold">DD</label><input name="dd_name" class="form-control" value="' . $this->esc($dd) . '" placeholder="optional"></div>'
283 . '<div class="col-md-6"><label class="form-label fw-semibold">Aufgabe</label><select name="task_type" class="form-select">' . $taskOptions . '</select></div>'
284 . '<div class="col-md-6"><label class="form-label fw-semibold">Kontext</label><select name="include_context" class="form-select">' . $contextOptions . '</select></div>'
285 . '<div class="col-12"><label class="form-label fw-semibold">Auftrag fuer die KI</label><textarea name="brief" class="form-control" rows="10" placeholder="Was soll im Modul gemacht werden?"></textarea></div>'
286 . '<div class="col-12 d-flex flex-wrap gap-2 pt-1">'
287 . '<button class="btn btn-primary btn-lg" type="submit"><i class="bi bi-download"></i> KI-Auftrags-ZIP erstellen</button>'
288 . '<a class="btn btn-success btn-lg" href="' . $this->esc($bundle) . '"><i class="bi bi-upload"></i> Modul-Antwort importieren</a>'
289 . '<a class="btn btn-outline-secondary btn-lg" target="_blank" href="' . $this->esc($api) . '"><i class="bi bi-braces"></i> Modul-KI API</a>'
290 . '</div></div></form>'
291 . '</div></div></div>'
292 . '</div></div></div>';
293 }
294
295 public function renderBundleStart(): string {
296 return '<div class="dbx-panel dbx-ki-module-bundle">'
297 . $this->moduleBar('module_bundle',
298 '<a class="btn btn-outline-secondary btn-sm" href="' . $this->esc($this->moduleUrl('briefing_module')) . '"><i class="bi bi-arrow-left"></i> Modul-KI</a>')
299 . '<div class="container py-3" style="max-width:980px">'
300 . '<div class="card border-primary shadow-sm"><div class="card-body p-3 p-xl-4">'
301 . '<h2 class="h4 mb-2"><i class="bi bi-upload"></i> Modul-KI Antwort importieren</h2>'
302 . '<p class="text-muted">Importiert eine <code>antwort.zip</code> mit <code>manifest.json</code>, <code>job.json</code> und optional <code>assets/</code>. dbxKi prueft das feste Schema, erstellt ein Modul-Backup und fuehrt erlaubte Aktionen aus.</p>'
303 . '<form method="post" enctype="multipart/form-data" action="' . $this->esc($this->moduleUrl('module_bundle_import')) . '">'
304 . '<div class="mb-3"><label class="form-label fw-semibold">Antwort-ZIP</label><input class="form-control form-control-lg" type="file" name="bundle_zip" accept=".zip" required></div>'
305 . '<div class="d-flex flex-wrap gap-2"><button class="btn btn-primary btn-lg" type="submit"><i class="bi bi-play-fill"></i> Importieren und ausfuehren</button>'
306 . '<a class="btn btn-outline-secondary btn-lg" href="' . $this->esc($this->moduleUrl('briefing_module')) . '">Zurueck</a></div>'
307 . '</form></div></div></div>';
308 }
309
310 public function handleExport(): void {
311 $modul = dbx()->get_request_var('xmodul', '', 'parameter');
312 if (!$this->validName($modul) || !is_dir($this->moduleDir($modul))) {
313 dbx()->json_response(array('ok' => 0, 'error' => 'Ungueltiges oder fehlendes Modul'), true);
314 }
315 $dd = dbx()->get_request_var('dd_name', '', 'parameter');
316 $brief = dbx()->get_request_var('brief', '', '*');
317 $taskType = dbx()->get_request_var('task_type', 'extend', 'parameter');
318 $includeContext = dbx()->get_request_var('include_context', 'full', 'parameter');
319
320 $manifest = $this->moduleDescribe($modul);
321 $briefing = array(
322 'briefing_version' => self::MODULE_BRIEFING_VERSION,
323 'area' => 'module',
324 'module' => $modul,
325 'dd' => $dd,
326 'task_type' => $taskType,
327 'brief' => $brief,
328 'rules' => $this->hardRules($modul),
329 'pipeline' => $this->modulePipeline($modul),
330 'ui_contract' => $this->uiContract(),
331 'dbx_api_contract' => $this->dbxApiContract($modul),
332 );
333 $job = array(
334 'job_version' => self::MODULE_BRIEFING_VERSION,
335 'area' => 'module',
336 'module' => $modul,
337 'steps' => array(
338 array('id' => 'backup', 'action' => 'module.backup', 'params' => array('module' => $modul)),
339 array('id' => 'change_1', 'action' => 'module.file.write', 'params' => array('module' => $modul, 'path' => 'dbx/modules/' . $modul . '/___PFAD___', 'content' => '___KI_FUELLEN___')),
340 ),
341 );
342 $answerManifest = array(
343 'bundle_version' => self::MODULE_BRIEFING_VERSION,
344 'area' => 'module',
345 'module' => $modul,
346 'recipe' => 'module.update.v1',
347 'task_type' => $taskType,
348 'auto_execute' => true,
349 );
350
351 $files = array(
352 '00-START.md' => $this->startText($modul),
353 'KI-AUFTRAG.md' => $this->auftragText($modul, $dd, $brief),
354 'briefing.json' => json_encode($briefing, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
355 'job.vorlage.json' => json_encode($job, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
356 'manifest.vorlage.json' => json_encode($answerManifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
357 'module.describe.json' => json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
358 'module.snapshot.json' => json_encode(array('module' => $modul, 'files' => $this->fileTree($modul, false)), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
359 'README.md' => 'dbxKi Modul-Auftrag fuer ' . $modul . "\n",
360 );
361
362 $tmp = tempnam(sys_get_temp_dir(), 'dbxkimod');
363 $zip = new \ZipArchive();
364 if ($zip->open($tmp, \ZipArchive::OVERWRITE) !== true) {
365 dbx()->json_response(array('ok' => 0, 'error' => 'ZIP konnte nicht erstellt werden'), true);
366 }
367 foreach ($files as $path => $content) {
368 $zip->addFromString($path, (string)$content);
369 }
370 if ($includeContext === 'full') {
371 $this->addModuleToZip($zip, $modul);
372 }
373 $zip->close();
374 $name = 'dbxki-modul-' . preg_replace('/[^A-Za-z0-9_-]+/', '-', $modul) . '.zip';
375 header('Content-Type: application/zip');
376 header('Content-Disposition: attachment; filename="' . $name . '"');
377 header('Content-Length: ' . filesize($tmp));
378 readfile($tmp);
379 @unlink($tmp);
380 exit;
381 }
382
383 private function addModuleToZip(\ZipArchive $zip, string $modul): void {
384 $dir = $this->moduleDir($modul);
385 $it = new \RecursiveIteratorIterator(
386 new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
387 \RecursiveIteratorIterator::SELF_FIRST
388 );
389 foreach ($it as $item) {
390 $full = $item->getPathname();
391 $rel = str_replace('\\', '/', substr($full, strlen(rtrim($dir, '/\\')) + 1));
392 $zipRel = 'module_context/dbx/modules/' . $modul . '/' . $rel;
393 if ($item->isDir()) {
394 $zip->addEmptyDir($zipRel);
395 } else {
396 $zip->addFile($full, $zipRel);
397 }
398 }
399 }
400
401 private function startText(string $modul): string {
402 return "# START\n\n"
403 . "1. Lies `KI-AUFTRAG.md`.\n"
404 . "2. Lies `briefing.json`, `module.describe.json`, `module.snapshot.json`.\n"
405 . "3. Nutze `module_context/dbx/modules/$modul/` als einzige Code-Wahrheit.\n"
406 . "4. Liefere `antwort.zip` mit `manifest.json`, `job.json`, optional `assets/` und README.\n"
407 . "5. Keine eigenen Tools, kein SQL, keine freie Dateiliste. Nur die module.* Actions aus `module.describe.json` verwenden.\n";
408 }
409
410 private function auftragText(string $modul, string $dd, string $brief): string {
411 return "# KI-Auftrag Modulprogrammierung\n\n"
412 . "Modul: `$modul`\nDD: `$dd`\n\n"
413 . "## Aufgabe\n\n" . trim($brief) . "\n\n"
414 . "## Verbindliche Regeln\n\n- " . implode("\n- ", $this->hardRules($modul)) . "\n\n"
415 . "## UI/API Regeln\n\n"
416 . "- UI-State ueber remember vars, openWin ueber data-dbx, AJAX ueber dbxAjax/json_response, Confirm ueber dbxConfirm.\n"
417 . "- Templates mit dbxTPL lesen/rendern; Antwort schreibt Templates nur via `module.template.set`.\n"
418 . "- DD->DB Sync nur via `module.dd.sync` und nur fuer `$modul|$dd`.\n\n"
419 . "## Erlaubte job.json Actions\n\n"
420 . "- `module.backup`\n- `module.file.write`\n- `module.file.delete`\n- `module.dd.write`\n- `module.dd.sync`\n- `module.template.set`\n- `module.asset.write`\n\n"
421 . "## Antwort\n\nLiefere `antwort.zip` mit `manifest.json`, `job.json`, optional `assets/` und README. `manifest.auto_execute` bleibt true. Keine Erklaerung statt ZIP.\n";
422 }
423
424 public function handleBundleImport(): string {
425 $root = '';
426 try {
427 $payload = $this->readModuleBundlePayload();
428 $manifest = is_array($payload['manifest'] ?? null) ? $payload['manifest'] : array();
429 $job = is_array($payload['job'] ?? null) ? $payload['job'] : array();
430 $assetsDir = (string)($payload['assets_dir'] ?? '');
431 $root = (string)($payload['root'] ?? '');
432 $this->validateModulePayload($manifest, $job);
433 $result = $this->executeModuleJob($manifest, $job, $assetsDir);
434 if ($root !== '') $this->removeDir($root);
435 return $this->renderModuleBundleResult($result);
436 } catch (\Throwable $e) {
437 if ($root !== '') $this->removeDir($root);
438 dbx()->sys_msg('error', 'dbxKi', 'module_bundle_import', 'Import fehlgeschlagen', $e->getMessage());
439 return '<div class="container py-4"><div class="alert alert-danger">' . $this->esc($e->getMessage()) . '</div>'
440 . '<a class="btn btn-secondary" href="' . $this->esc($this->moduleUrl('module_bundle')) . '">Zurueck</a></div>';
441 }
442 }
443
444 private function readModuleBundlePayload(): array {
445 $file = null;
446 foreach ($_FILES as $upload) {
447 if (is_array($upload) && (int)($upload['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK && is_uploaded_file((string)($upload['tmp_name'] ?? ''))) {
448 $file = (string)$upload['tmp_name'];
449 break;
450 }
451 }
452 if (!$file) {
453 throw new \InvalidArgumentException('Keine Antwort-ZIP hochgeladen.');
454 }
455 if (!class_exists('\\ZipArchive')) {
456 throw new \RuntimeException('ZipArchive nicht verfuegbar.');
457 }
458 $root = rtrim(sys_get_temp_dir(), '/\\') . DIRECTORY_SEPARATOR . 'dbxki-module-' . bin2hex(random_bytes(8));
459 if (!mkdir($root, 0777, true) && !is_dir($root)) {
460 throw new \RuntimeException('Temp-Verzeichnis konnte nicht erstellt werden.');
461 }
462 $zip = new \ZipArchive();
463 if ($zip->open($file) !== true) {
464 $this->removeDir($root);
465 throw new \InvalidArgumentException('ZIP konnte nicht geoeffnet werden.');
466 }
467 for ($i = 0; $i < $zip->numFiles; $i++) {
468 $name = str_replace('\\', '/', (string)$zip->getNameIndex($i));
469 if ($name === '' || strpos($name, '../') !== false || str_starts_with($name, '/')) {
470 $zip->close();
471 $this->removeDir($root);
472 throw new \InvalidArgumentException('Ungueltiger ZIP-Pfad: ' . $name);
473 }
474 }
475 $zip->extractTo($root);
476 $zip->close();
477 $manifest = $this->readJson($root . '/manifest.json', true);
478 $job = $this->readJson($root . '/job.json', true);
479 return array(
480 'root' => $root,
481 'assets_dir' => $root . '/assets',
482 'manifest' => $manifest,
483 'job' => $job,
484 );
485 }
486
487 private function readJson(string $file, bool $required): array {
488 if (!is_file($file)) {
489 if ($required) throw new \InvalidArgumentException('Pflichtdatei fehlt: ' . basename($file));
490 return array();
491 }
492 $data = json_decode((string)file_get_contents($file), true);
493 if (!is_array($data)) {
494 throw new \InvalidArgumentException('Ungueltiges JSON: ' . basename($file));
495 }
496 return $data;
497 }
498
499 private function removeDir(string $dir): void {
500 if (!is_dir($dir)) return;
501 $it = new \RecursiveIteratorIterator(
502 new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
503 \RecursiveIteratorIterator::CHILD_FIRST
504 );
505 foreach ($it as $item) {
506 $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
507 }
508 @rmdir($dir);
509 }
510
511 private function validateModulePayload(array $manifest, array $job): void {
512 $modul = trim((string)($manifest['module'] ?? $job['module'] ?? ''));
513 if (($manifest['area'] ?? '') !== 'module' || !$this->validName($modul) || !is_dir($this->moduleDir($modul))) {
514 throw new \InvalidArgumentException('manifest.module ist ungueltig oder area ist nicht module.');
515 }
516 if (!is_array($job['steps'] ?? null) || !count($job['steps'])) {
517 throw new \InvalidArgumentException('job.json benoetigt steps[].');
518 }
519 foreach ($job['steps'] as $pos => $step) {
520 if (!is_array($step)) throw new \InvalidArgumentException('Step #' . ($pos + 1) . ' ist ungueltig.');
521 $action = trim((string)($step['action'] ?? ''));
522 if (!$this->moduleActionAllowed($action)) {
523 throw new \InvalidArgumentException('Aktion nicht erlaubt: ' . $action);
524 }
525 $stepModule = trim((string)($step['params']['module'] ?? $modul));
526 if ($stepModule !== $modul) {
527 throw new \InvalidArgumentException('Step-Modul passt nicht zum Manifest: ' . $action);
528 }
529 }
530 }
531
532 private function moduleActionAllowed(string $action): bool {
533 return in_array($action, array(
534 'module.backup',
535 'module.file.write',
536 'module.file.delete',
537 'module.dd.write',
538 'module.dd.sync',
539 'module.template.set',
540 'module.asset.write',
541 ), true);
542 }
543
544 private function executeModuleJob(array $manifest, array $job, string $assetsDir): array {
545 $modul = trim((string)($manifest['module'] ?? $job['module'] ?? ''));
546 $auto = $this->truthy($manifest['auto_execute'] ?? false);
547 if (!$auto) {
548 throw new \RuntimeException('manifest.auto_execute ist nicht true.');
549 }
550
551 $results = array();
552 $needsBackup = false;
553 foreach ($job['steps'] as $step) {
554 if (($step['action'] ?? '') !== 'module.backup') {
555 $needsBackup = true;
556 break;
557 }
558 }
559 if ($needsBackup) {
560 $results['_auto_backup'] = $this->createModuleBackup($modul);
561 }
562
563 foreach ($job['steps'] as $pos => $step) {
564 $id = trim((string)($step['id'] ?? ('step_' . ($pos + 1))));
565 $params = $this->resolveModuleParams(is_array($step['params'] ?? null) ? $step['params'] : array(), $results);
566 $params['module'] = $modul;
567 $results[$id] = $this->executeModuleStep((string)$step['action'], $params, $assetsDir);
568 }
569 return array('ok' => 1, 'module' => $modul, 'results' => $results);
570 }
571
572 private function truthy($value): bool {
573 if (is_bool($value)) return $value;
574 return in_array(strtolower(trim((string)$value)), array('1', 'true', 'yes', 'ja', 'on'), true);
575 }
576
577 private function resolveModuleParams($value, array $results) {
578 if (is_array($value)) {
579 $out = array();
580 foreach ($value as $key => $item) $out[$key] = $this->resolveModuleParams($item, $results);
581 return $out;
582 }
583 if (!is_string($value) || strpos($value, '$ref:') === false) return $value;
584 return preg_replace_callback('/\$ref:([A-Za-z0-9_.-]+)/', function($m) use ($results) {
585 $parts = explode('.', $m[1]);
586 $step = array_shift($parts);
587 $v = $results[$step] ?? null;
588 foreach ($parts as $part) {
589 $v = is_array($v) && array_key_exists($part, $v) ? $v[$part] : '';
590 }
591 return (string)$v;
592 }, $value);
593 }
594
595 private function executeModuleStep(string $action, array $params, string $assetsDir): array {
596 switch ($action) {
597 case 'module.backup':
598 return $this->createModuleBackup((string)$params['module']);
599 case 'module.file.write':
600 return $this->moduleFileWrite($params, $assetsDir);
601 case 'module.file.delete':
602 return $this->moduleFileDelete($params);
603 case 'module.dd.write':
604 return $this->moduleDdWrite($params);
605 case 'module.dd.sync':
606 return $this->moduleDdSync($params);
607 case 'module.template.set':
608 return $this->moduleTemplateSet($params);
609 case 'module.asset.write':
610 return $this->moduleAssetWrite($params, $assetsDir);
611 }
612 throw new \InvalidArgumentException('Aktion nicht implementiert: ' . $action);
613 }
614
615 private function createModuleBackup(string $modul): array {
616 $dir = dbx_os_path_file(dbx_get_file_dir() . 'temp/dbxKi/module-backups/');
617 if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
618 throw new \RuntimeException('Backup-Verzeichnis konnte nicht erstellt werden.');
619 }
620 $file = $dir . $modul . '-' . date('Ymd-His') . '.zip';
621 $zip = new \ZipArchive();
622 if ($zip->open($file, \ZipArchive::OVERWRITE) !== true) {
623 throw new \RuntimeException('Modul-Backup konnte nicht erstellt werden.');
624 }
625 $this->addModuleToZip($zip, $modul);
626 $zip->close();
627 return array('backup' => str_replace('\\', '/', $file), 'size' => (int)@filesize($file));
628 }
629
630 private function relFromPath(string $modul, string $path): string {
631 $path = str_replace(array('\\', "\0"), array('/', ''), trim($path));
632 $prefix = 'dbx/modules/' . $modul . '/';
633 if (strpos($path, $prefix) === 0) $path = substr($path, strlen($prefix));
634 $path = ltrim($path, '/');
635 if ($path === '' || strpos($path, '../') !== false || str_starts_with($path, '/')) {
636 throw new \InvalidArgumentException('Ungueltiger Modulpfad.');
637 }
638 return $path;
639 }
640
641 private function targetFile(string $modul, string $path): string {
642 $rel = $this->relFromPath($modul, $path);
643 $file = $this->modulePath($modul, $rel);
644 if ($file === '') throw new \InvalidArgumentException('Pfad ausserhalb des Moduls.');
645 return $file;
646 }
647
648 private function bytesFromParams(array $params, string $assetsDir): string {
649 if (array_key_exists('content', $params)) return (string)$params['content'];
650 if (array_key_exists('content_base64', $params)) {
651 $bytes = base64_decode((string)$params['content_base64'], true);
652 if ($bytes === false) throw new \InvalidArgumentException('content_base64 ist ungueltig.');
653 return $bytes;
654 }
655 $assetRef = trim(str_replace('\\', '/', (string)($params['asset_ref'] ?? '')));
656 if ($assetRef !== '') {
657 if (strpos($assetRef, '../') !== false || str_starts_with($assetRef, '/')) {
658 throw new \InvalidArgumentException('asset_ref ist ungueltig.');
659 }
660 $file = rtrim($assetsDir, '/\\') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $assetRef);
661 if (!is_file($file)) throw new \InvalidArgumentException('Asset fehlt: ' . $assetRef);
662 return (string)file_get_contents($file);
663 }
664 throw new \InvalidArgumentException('content, content_base64 oder asset_ref erforderlich.');
665 }
666
667 private function writeTargetFile(string $file, string $bytes): array {
668 $dir = dirname($file);
669 if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
670 throw new \RuntimeException('Zielverzeichnis konnte nicht erstellt werden.');
671 }
672 if (file_put_contents($file, $bytes) === false) {
673 throw new \RuntimeException('Datei konnte nicht geschrieben werden.');
674 }
675 return array('path' => str_replace('\\', '/', $file), 'bytes' => strlen($bytes), 'sha256' => hash('sha256', $bytes));
676 }
677
678 private function moduleFileWrite(array $params, string $assetsDir): array {
679 $modul = (string)$params['module'];
680 $rel = $this->relFromPath($modul, (string)($params['path'] ?? ''));
681 if (preg_match('#^(dd/|tpl/|img/|css/|js/)#i', $rel)) {
682 throw new \InvalidArgumentException('Fuer DD/Templates/Assets die spezialisierten module.* Actions verwenden.');
683 }
684 if (preg_match('/\.(db3|sqlite|sqlite3)$/i', $rel)) {
685 throw new \InvalidArgumentException('Datenbanken werden nicht direkt geschrieben. DD->DB Sync ueber module.dd.sync verwenden.');
686 }
687 $file = $this->targetFile($modul, $rel);
688 return $this->writeTargetFile($file, $this->bytesFromParams($params, $assetsDir));
689 }
690
691 private function moduleFileDelete(array $params): array {
692 if (!$this->truthy($params['confirm'] ?? false)) {
693 throw new \InvalidArgumentException('module.file.delete benoetigt confirm=true.');
694 }
695 $modul = (string)$params['module'];
696 $file = $this->targetFile($modul, (string)($params['path'] ?? ''));
697 if (!is_file($file)) return array('deleted' => false, 'path' => str_replace('\\', '/', $file));
698 if (!unlink($file)) throw new \RuntimeException('Datei konnte nicht geloescht werden.');
699 return array('deleted' => true, 'path' => str_replace('\\', '/', $file));
700 }
701
702 private function moduleDdWrite(array $params): array {
703 $modul = (string)$params['module'];
704 $dd = trim((string)($params['dd'] ?? ''));
705 if (!$this->validName($dd)) throw new \InvalidArgumentException('Ungueltiger DD-Name.');
706 $content = (string)($params['content'] ?? '');
707 if ($content === '' || preg_match('/\b(require|include)(_once)?\b/i', $content)) {
708 throw new \InvalidArgumentException('DD muss vollstaendig sein und darf keine include/require verwenden.');
709 }
710 $file = $this->targetFile($modul, 'dd/' . $dd . '.dd.php');
711 return $this->writeTargetFile($file, $content);
712 }
713
714 private function moduleTemplateSet(array $params): array {
715 $modul = (string)$params['module'];
716 $template = trim((string)($params['template'] ?? ''));
717 $path = trim((string)($params['path'] ?? ''));
718 if ($path === '') {
719 $template = preg_replace('/[^A-Za-z0-9_.-]+/', '-', $template);
720 if ($template === '') throw new \InvalidArgumentException('template ist erforderlich.');
721 if (!preg_match('/\.htm[l]?$/i', $template)) $template .= '.htm';
722 $path = 'tpl/htm/' . $template;
723 }
724 if (strpos(str_replace('\\', '/', $path), 'tpl/') !== 0) {
725 throw new \InvalidArgumentException('module.template.set darf nur unter tpl/ schreiben.');
726 }
727 $file = $this->targetFile($modul, $path);
728 return $this->writeTargetFile($file, (string)($params['content'] ?? ''));
729 }
730
731 private function moduleAssetWrite(array $params, string $assetsDir): array {
732 $modul = (string)$params['module'];
733 $path = $this->relFromPath($modul, (string)($params['path'] ?? ''));
734 if (!preg_match('#^(tpl/(img|mod)/|img/|css/|js/)#i', $path)) {
735 throw new \InvalidArgumentException('module.asset.write darf nur tpl/img, tpl/mod, img, css oder js schreiben.');
736 }
737 return $this->writeTargetFile($this->targetFile($modul, $path), $this->bytesFromParams($params, $assetsDir));
738 }
739
740 private function moduleDdSync(array $params): array {
741 $modul = (string)$params['module'];
742 $dd = trim((string)($params['dd'] ?? ''));
743 if (!$this->validName($dd)) throw new \InvalidArgumentException('Ungueltiger DD-Name.');
744 if (!is_file($this->targetFile($modul, 'dd/' . $dd . '.dd.php'))) {
745 throw new \InvalidArgumentException('DD-Datei fehlt: ' . $modul . '|' . $dd);
746 }
747 $oDD = dbx()->get_system_obj('dbxDD');
748 if (!is_object($oDD) || !method_exists($oDD, 'sync_dd_to_db')) {
749 throw new \RuntimeException('dbxDD Sync-API nicht verfuegbar.');
750 }
751 $oDD->sync_dd_to_db($modul, $dd, 'reset');
752 $state = array();
753 for ($i = 0; $i < 80; $i++) {
754 $state = $oDD->sync_dd_to_db($modul, $dd, 'apply');
755 $status = strtolower((string)($state['status'] ?? ''));
756 if (in_array($status, array('finished', 'error', 'canceled'), true)) break;
757 if ((int)($state['percent'] ?? 0) >= 100) break;
758 }
759 return array('dd' => $modul . '|' . $dd, 'state' => $state);
760 }
761
762 private function renderModuleBundleResult(array $result): string {
763 $items = '';
764 foreach (is_array($result['results'] ?? null) ? $result['results'] : array() as $id => $row) {
765 $items .= '<li class="list-group-item"><strong>' . $this->esc($id) . '</strong><pre class="small mb-0">'
766 . $this->esc(json_encode($row, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES))
767 . '</pre></li>';
768 }
769 return '<div class="container py-4" style="max-width:1100px"><div class="alert alert-success">Modul-Bundle ausgefuehrt: '
770 . $this->esc((string)($result['module'] ?? '')) . '</div><ul class="list-group mb-3">' . $items . '</ul>'
771 . '<a class="btn btn-primary" href="' . $this->esc($this->moduleUrl('briefing_module', array('xmodul' => (string)($result['module'] ?? '')))) . '">Zur Modul-KI</a></div>';
772 }
773
774 private function modulePipelineGuide(string $modul, string $taskType = 'extend'): array {
775 return array(
776 'workflow' => $this->modulePipeline($modul),
777 'rules' => $this->hardRules($modul),
778 'ui_contract' => $this->uiContract(),
779 'dbx_api_contract' => $this->dbxApiContract($modul),
780 'manifest' => array(
781 'bundle_version' => self::MODULE_BRIEFING_VERSION,
782 'area' => 'module',
783 'module' => $modul,
784 'recipe' => 'module.update.v1',
785 'task_type' => $taskType,
786 'auto_execute' => true,
787 ),
788 'job' => array(
789 'job_version' => self::MODULE_BRIEFING_VERSION,
790 'area' => 'module',
791 'module' => $modul,
792 'steps' => array(
793 array('id' => 'backup', 'action' => 'module.backup', 'params' => array('module' => $modul)),
794 array('id' => 'write_source', 'action' => 'module.file.write', 'params' => array('module' => $modul, 'path' => 'dbx/modules/' . $modul . '/include/Service.class.php', 'content' => '___PHP_ODER_TEXT_CONTENT___')),
795 array('id' => 'write_template', 'action' => 'module.template.set', 'params' => array('module' => $modul, 'template' => 'template-name', 'content' => '___HTML_TEMPLATE___')),
796 array('id' => 'write_dd', 'action' => 'module.dd.write', 'params' => array('module' => $modul, 'dd' => 'ddName', 'content' => '___VOLLSTAENDIGE_DD_DATEI___')),
797 array('id' => 'sync_dd', 'action' => 'module.dd.sync', 'params' => array('module' => $modul, 'dd' => 'ddName')),
798 ),
799 ),
800 );
801 }
802
803 public function handleApi(): void {
804 $raw = file_get_contents('php://input');
805 $body = array();
806 if (is_string($raw) && trim($raw) !== '') {
807 $decoded = json_decode($raw, true);
808 if (is_array($decoded)) $body = $decoded;
809 }
810 $params = is_array($body['params'] ?? null) ? $body['params'] : array();
811 $action = (string)($body['action'] ?? dbx()->get_request_var('action', 'system.describe', 'parameter+.'));
812 $modul = (string)($params['xmodul'] ?? $params['module'] ?? dbx()->get_request_var('xmodul', '', 'parameter'));
813 try {
814 switch ($action) {
815 case 'system.describe':
816 dbx()->json_response($this->moduleDescribe(''), true);
817 return;
818 case 'module.describe':
819 if (!$this->validName($modul)) throw new \InvalidArgumentException('Ungueltiges Modul');
820 dbx()->json_response($this->moduleDescribe($modul), true);
821 return;
822 case 'module.snapshot':
823 if (!$this->validName($modul)) throw new \InvalidArgumentException('Ungueltiges Modul');
824 $withContent = (int)($params['with_content'] ?? dbx()->get_request_var('with_content', 0, 'int')) === 1;
825 dbx()->json_response(array('ok' => 1, 'module' => $modul, 'files' => $this->fileTree($modul, $withContent)), true);
826 return;
827 case 'module.pipeline_guide':
828 if (!$this->validName($modul)) throw new \InvalidArgumentException('Ungueltiges Modul');
829 $taskType = (string)($params['task_type'] ?? dbx()->get_request_var('task_type', 'extend', 'parameter'));
830 dbx()->json_response(array('ok' => 1, 'result' => $this->modulePipelineGuide($modul, $taskType)), true);
831 return;
832 case 'module.job.execute':
833 $manifest = is_array($params['manifest'] ?? null) ? $params['manifest'] : array();
834 $job = is_array($params['job'] ?? null) ? $params['job'] : array();
835 if (!$manifest && is_array($body['manifest'] ?? null)) $manifest = $body['manifest'];
836 if (!$job && is_array($body['job'] ?? null)) $job = $body['job'];
837 $this->validateModulePayload($manifest, $job);
838 dbx()->json_response($this->executeModuleJob($manifest, $job, ''), true);
839 return;
840 case 'module.file.read':
841 if (!$this->validName($modul)) throw new \InvalidArgumentException('Ungueltiges Modul');
842 $path = (string)($params['path'] ?? dbx()->get_request_var('path', '', '*'));
843 $prefix = 'dbx/modules/' . $modul . '/';
844 if (strpos($path, $prefix) === 0) {
845 $path = substr($path, strlen($prefix));
846 }
847 $file = $this->modulePath($modul, $path);
848 if ($file === '' || !is_file($file) || !$this->isTextFile($file)) {
849 throw new \InvalidArgumentException('Datei nicht lesbar');
850 }
851 dbx()->json_response(array('ok' => 1, 'module' => $modul, 'path' => 'dbx/modules/' . $modul . '/' . str_replace('\\', '/', $path), 'content' => (string)file_get_contents($file)), true);
852 return;
853 }
854 throw new \InvalidArgumentException('Unbekannte action');
855 } catch (\Throwable $e) {
856 dbx()->json_response(array('ok' => 0, 'error' => $e->getMessage()), true);
857 }
858 }
859}
dbx_os_path_file($path_file)
Definition index.php:164
dbx_get_base_dir($cut_Data=0)
Definition index.php:157
DBX schema administration.