6 private const MODULE_BRIEFING_VERSION =
'0.6';
8 private function esc(
$value):
string {
9 return htmlspecialchars((
string)
$value, ENT_QUOTES,
'UTF-8');
12 private function modulesRoot():
string {
16 private function validName($name):
bool {
17 return is_string($name) && preg_match(
'/^[A-Za-z][A-Za-z0-9_]{1,63}$/', $name);
20 private function moduleDir(
string $modul):
string {
21 return dbx()->os_path($this->modulesRoot() . $modul . DIRECTORY_SEPARATOR);
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) {
30 $base = $this->moduleDir($modul);
31 $path =
dbx()->os_path(
$base . $rel);
32 $baseNorm = str_replace(
'\\',
'/', rtrim(
$base,
'/\\') .
'/');
33 $pathNorm = str_replace(
'\\',
'/', $path);
34 return strpos($pathNorm, $baseNorm) === 0 ? $path :
'';
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);
59 private function managedForm(
string $fid,
string $template,
string $action, array $replacements = array()) {
60 $form =
dbx()->get_system_obj(
'dbxForm');
62 $form->_action = $action;
63 $form->_msg_info =
'';
64 foreach ($replacements as $key =>
$value) {
70 private function selectOptions(array
$options,
string $selected):
string {
73 $sel = (string)
$value === $selected ?
' selected' :
'';
74 $html .=
'<option value="' . $this->esc(
$value) .
'"' . $sel .
'>' . $this->esc($label) .
'</option>';
79 private function moduleOptions(
string $selected =
''):
string {
80 $html =
'<option value="">Bitte Modul waehlen</option>';
81 $dirs = glob($this->modulesRoot() .
'*', GLOB_ONLYDIR) ?: array();
83 foreach ($dirs as
$dir) {
84 $name = basename(
$dir);
85 if (!$this->validName($name)) {
88 $sel = $name === $selected ?
' selected' :
'';
89 $html .=
'<option value="' . $this->esc($name) .
'"' . $sel .
'>' . $this->esc($name) .
'</option>';
94 private function fileTree(
string $modul,
bool $withContent =
false): array {
95 $dir = $this->moduleDir($modul);
100 $it = new \RecursiveIteratorIterator(
101 new \RecursiveDirectoryIterator(
$dir, \FilesystemIterator::SKIP_DOTS),
102 \RecursiveIteratorIterator::SELF_FIRST
104 foreach (
$it as $item) {
105 $full = $item->getPathname();
106 $rel = str_replace(
'\\',
'/', substr($full, strlen(rtrim(
$dir,
'/\\')) + 1));
108 'path' =>
'dbx/modules/' . $modul .
'/' . $rel,
109 'type' => $item->isDir() ?
'dir' :
'file',
110 'size' => $item->isFile() ? (
int)$item->getSize() : 0,
112 if ($withContent && $item->isFile() && $this->isTextFile($full) && $item->getSize() <= 262144) {
113 $row[
'content'] = (string)file_get_contents($full);
120 private function isTextFile(
string $file):
bool {
121 return (
bool)preg_match(
'/\.(php|phtml|pht|htm|html|css|js|json|md|txt|sql|xml|svg)$/i',
$file);
124 private function moduleDescribe(
string $modul =
''): array {
129 'briefing_version' => self::MODULE_BRIEFING_VERSION,
130 'endpoint' => $this->moduleUrl(
'module_api'),
131 'hard_rules' => $this->hardRules($modul ?:
'{module}'),
132 'dbxapp_way' => $this->dbxappWay(),
133 'reference_standard' => $this->referenceStandard(),
134 'module_pipeline' => $this->modulePipeline($modul ?:
'{module}'),
135 'ui_contract' => $this->uiContract(),
136 'dbx_api_contract' => $this->dbxApiContract($modul ?:
'{module}'),
137 'api_actions' => $this->apiCatalog(),
138 'answer_zip_contract' => $this->answerZipContract($modul ?:
'{module}'),
142 private function hardRules(
string $modul): array {
144 'Nur Dateien unter dbx/modules/' . $modul .
'/ bearbeiten.',
145 'Keine Aenderungen an dbx/include, globaler config.php, anderen Modulen oder files/ ausser explizit erlaubten Modul-Assets.',
146 'Vor destruktiven Aenderungen muss ein vollstaendiges Modul-ZIP als Backup existieren.',
147 'Fachliche Datenbankzugriffe ausschliesslich ueber dbxDB und DD-Namen; kein PDO, mysqli, rohes SQL oder direkter Zugriff auf db3-Dateien.',
148 'create_date, create_uid, update_date, update_uid und owner werden von dbxDB gesetzt und duerfen im Modulcode nicht manuell geschrieben werden.',
149 'DD-Dateien muessen im dbxapp-Exportformat vollstaendig und direkt lesbar sein: TABLE, FIELDS und INDEXES explizit mit $table[...], $field[...] und $index[...] definieren. Keine $addField-Closure, keine DD-Includes und keine versteckende Hilfsabstraktion.',
150 'Produktive Tabellen-DDs verwenden trace=0, sofern keine ausdruecklich dokumentierte Systemausnahme besteht.',
151 'DD->DB Sync nur fuer ' . $modul .
'|{dd}. Keine Migrationen, keine Altlasten.',
152 'Templates ueber dbxTPL lesen/rendern: dbx()->get_system_obj("dbxTPL")->get_tpl("' . $modul .
'|template", $data).',
153 'Formulare ueber dbxForm bauen. Nach einem Insert die von dbxDB gelieferte RID in Formular und Action uebernehmen, damit weiteres Speichern ein Update ausfuehrt. Modulcode fuegt normalen Form-Actions keinen dbx_token hinzu; dbxForm signiert nur automatisch erkannte delete/save-RID-Actions.',
154 'Reports ueber dbxReport bauen; berechnete Spalten und Summen ueber den {fid}_next_record-Default umsetzen, Footerwerte spaet per add_rep() setzen und {rpt:colspan} nutzen. Keine reine str_replace()-Footermethode und keine unnoetigen Callback-Setter. Mutierende Standardaktionen werden automatisch signiert. Schreibende Grid-Routen verwenden *_grid_save, *_grid_insert, *_grid_delete, *_grid_sort oder *_grid_sync; Read bleibt tokenlos.',
155 'Keine Modulmethoden anlegen, die lediglich einen dbx()- oder get_system_obj()-Aufruf weiterreichen.',
156 'Nicht pauschal escapen. Nur an einer echten Ausgabe- oder Syntaxgrenze passend zum Zielkontext behandeln.',
157 'GET bleibt fuer Navigation und reine Anzeige erhalten. delete und save werden in dbx_run1/dbx_run2/dbx_run3/dbx_do zusammen mit rid automatisch erkannt; den Link nur mit dbx()->action_url($url) signieren. Keine action_routes-Konfiguration fuer diese Standardfaelle, keine manuellen Scopes und keine check_action_token()-Pruefung im Modulservice.',
158 'Ajax und normaler POST muessen denselben dbxForm-Ablauf verwenden und beide getestet werden.',
159 'Bestehende Tests erweitern oder einen fokussierten Vertrags-/Integrationstest im Modul ergaenzen.',
160 'Modul-README und Doxygen-Kommentare aktualisieren, wenn Verhalten, DD, Formular, Report oder API geaendert werden.',
161 'Das mitgelieferte myInvoices-Modul ist die ausfuehrbare Architektur-Referenz; Zielmodul und seine bestehenden Konventionen bleiben fuer Fachverhalten massgeblich.',
165 private function dbxappWay(): array {
167 'module_entry' =>
'dbx/modules/{module}/{module}.class.php routet dbx_run1 und delegiert an include Services.',
168 'service_code' =>
'Fachlogik liegt unter include/*.class.php im Modul.',
169 'dd' =>
'Data Dictionary liegt unter dd/*.dd.php und beschreibt TABLE, FIELDS und INDEXES explizit im dbxapp-Exportformat. Jedes Feld wird direkt ueber $field[...] und $fields[]=$field definiert.',
170 'fd' =>
'Form-/Report-Felder liegen unter fd/*.fd.php.',
171 'templates' =>
'Templates liegen unter tpl/htm und werden per dbxTPL get_tpl gerendert.',
172 'sqlite' =>
'*.db3 fuer ein Modul bleibt im Modulkontext; DD->DB Sync ueber dbxAdmin Schema.',
173 'database' =>
'Fachliche Abfragen und Aenderungen laufen ausschliesslich ueber dbxDB mit DD; Auditfelder setzt dbxDB automatisch.',
174 'create_update' =>
'dbxForm speichert neue Datensaetze als Insert und arbeitet danach mit der gelieferten RID weiter, sodass derselbe Dialog Updates ausfuehrt.',
175 'form_security' =>
'Normale POST-Form-Routen verwenden den rotierenden dbxForm-Submit-Schutz. Modulcode fuegt keinen dbx_token hinzu; nur eine als delete/save plus rid erkannte Action wird zentral signiert.',
176 'navigation' =>
'GET fuer Navigation/Anzeige beibehalten; delete/save plus rid werden von action_url automatisch als mutierende Link-Aktion erkannt.',
177 'action_security' =>
'dbxWebApp bindet die RID und prueft automatisch erkannte Routen vor dem Modulstart; Fachservices bauen oder pruefen keine eigenen Link-Token-Scopes.',
178 'grid_security' =>
'dbxReport signiert Grid-Save, -Insert, -Delete, -Sort und -Sync anhand der eigentlichen *_grid_<aktion>-Route. Unbekannte Schreibkonventionen werden nicht unsigniert ausgegeben.',
179 'callbacks' =>
'dbxForm uebernimmt den direkten Aufrufer als Owner; dbxReport nutzt {fid}_{event}-Defaults. Summen im next_record-Callback akkumulieren, spaet per add_rep() setzen und {rpt:colspan} verwenden.',
180 'system_objects' =>
'dbx-Systemobjekte am Verwendungsort holen; keine privaten Methoden, die nur dbx()-Aufrufe kapseln.',
181 'reference' =>
'reference/myInvoices und reference/25_Verbindliches_Modulhandbuch.md zeigen den verbindlichen Standard ausfuehrbar.',
185 private function referenceStandard(): array {
187 'manual_source' =>
'25_Verbindliches_Modulhandbuch.md',
188 'reference_module_source' =>
'dbx/modules/myInvoices',
189 'export_manual' =>
'reference/25_Verbindliches_Modulhandbuch.md',
190 'export_module' =>
'reference/myInvoices',
191 'purpose' =>
'Ausfuehrbare Referenz fuer dbxTPL, dbxDB, dbxForm, DD, FD, dbxReport, Callbacks, Ajax, Confirm, zentrale Action-Policies und Tests.',
192 'priority' =>
'Die Referenz bestimmt Architektur und Vorgehen; vorhandenes Fachverhalten des Zielmoduls darf nicht unbeabsichtigt veraendert werden.',
196 private function apiCatalog(): array {
198 'system.describe' => array(
'method' =>
'GET/POST',
'params' => array(),
'result' =>
'Regeln, Aktionen, Antwort-ZIP-Vertrag'),
199 'module.describe' => array(
'params' => array(
'xmodul'),
'result' =>
'Regeln und Kontext fuer ein Modul'),
200 'module.snapshot' => array(
'params' => array(
'xmodul',
'with_content=0|1'),
'result' =>
'Dateibaum, optional kleine Textdateien mit Inhalt'),
201 'module.file.read' => array(
'params' => array(
'xmodul',
'path'),
'result' =>
'Einzelne Textdatei innerhalb des Moduls'),
202 'module.pipeline_guide' => array(
'params' => array(
'xmodul',
'task_type'),
'result' =>
'Verbindliches Job-Schema fuer Modul-Aenderungen'),
203 'module.job.execute' => array(
'method' =>
'POST JSON',
'params' => array(
'manifest',
'job'),
'result' =>
'Fuehrt ein Modul-job.json direkt ueber dieselbe Pipeline aus'),
207 private function answerZipContract(
string $modul): array {
209 'filename' =>
'antwort.zip',
210 'required_files' => array(
'manifest.json',
'job.json',
'README.md'),
211 'allowed_payload_paths' => array(
'dbx/modules/' . $modul .
'
455 private function addReferenceToZip(\ZipArchive $zip): void {
456 $manual = dbx()->os_path(dbx()->get_base_dir() . '25_Verbindliches_Modulhandbuch.md
');
457 if (is_file($manual)) {
458 $zip->addFile($manual, 'reference/25_Verbindliches_Modulhandbuch.md
');
465 $it = new \RecursiveIteratorIterator(
466 new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
467 \RecursiveIteratorIterator::SELF_FIRST
469 foreach ($it as $item) {
470 if (!$item->isFile()) {
473 $full = $item->getPathname();
474 $rel = str_replace('\\
', '/
', substr($full, strlen(rtrim($dir, '/\\
')) + 1));
475 if (str_starts_with($rel, 'db/
') || !$this->isTextFile($full) || $item->getSize() > 262144) {
478 $zip->addFile($full, 'reference/
myInvoices/
' . $rel);
482 private function referenceText(): string {
483 return "# Verbindlicher dbXapp-Modulstandard\n\n"
484 . "- `25_Verbindliches_Modulhandbuch.md` beschreibt Architektur und Vorgehen.\n"
485 . "- `myInvoices/` ist die ausfuehrbare Referenz fuer DD/FD, dbxDB, dbxTPL, dbxForm, dbxReport, Callbacks, Ajax, Confirm, zentrale Action-Policies und Tests.\n"
486 . "- Die Referenz ist nur lesbar. Aenderungen duerfen ausschliesslich das im Manifest genannte Zielmodul betreffen.\n"
487 . "- Bestehendes Fachverhalten und vorhandene Schnittstellen des Zielmoduls haben Vorrang vor einer blinden Kopie des Beispiels.\n";
490 private function startText(string $modul): string {
492 . "1. Lies `KI-AUFTRAG.md`.\n"
493 . "2. Lies `briefing.json`, `module.describe.json`, `module.snapshot.json`.\n"
494 . "3. Lies `reference/25_Verbindliches_Modulhandbuch.md` und `reference/myInvoices/` als Architekturstandard.\n"
495 . "4. Nutze `module_context/dbx/modules/$modul/` als Wahrheit fuer bestehendes Fachverhalten und Schnittstellen.\n"
496 . "5. Liefere `antwort.zip` mit `manifest.json`, `job.json`, optional `assets/` und README.\n"
497 . "6. Keine eigenen Tools, kein SQL, keine freie Dateiliste. Nur die module.* Actions aus `module.describe.json` verwenden.\n";
500 private function auftragText(string $modul, string $dd, string $brief): string {
501 return "# KI-Auftrag Modulprogrammierung\n\n"
502 . "Modul: `$modul`\nDD: `$dd`\n\n"
503 . "## Aufgabe\n\n" . trim($brief) . "\n\n"
504 . "## Verbindliche Regeln\n\n- " . implode("\n- ", $this->hardRules($modul)) . "\n\n"
505 . "## UI/API Regeln\n\n"
506 . "- UI-State ueber remember vars, openWin ueber data-dbx, AJAX ueber dbxAjax/json_response, Confirm ueber dbxConfirm.\n"
507 . "- Templates mit dbxTPL lesen/rendern; Antwort schreibt Templates nur via `module.template.set`.\n"
508 . "- DDs im direkt lesbaren dbxapp-Exportformat mit expliziten TABLE-, FIELDS- und INDEXES-Abschnitten schreiben; keine `\$addField`-Closure.\n"
509 . "- Nach einem Insert die neue RID in dbxForm und Action uebernehmen; erneutes Speichern muss ein Update sein.\n"
510 . "- dbxForm-Actions nie manuell mit dbx_token versehen; nur automatisch erkannte delete/save-RID-Actions werden vom System signiert.\n"
511 . "- dbxReport-Summen im {fid}_next_record-Default bilden, Footerwerte spaet per add_rep() setzen und {rpt:colspan} nutzen; keine unnoetigen Callback-Setter.\n"
512 . "- Schreibende dbxReport-Grid-Routen als `*_grid_save`, `*_grid_insert`, `*_grid_delete`, `*_grid_sort` oder `*_grid_sync` benennen; keinen eigenen Tokenmarker anfuegen.\n"
513 . "- delete/save plus rid ohne action_routes-Konfiguration ueber action_url() automatisch signieren und keine Token-Pruefung im Fachservice duplizieren.\n"
514 . "- DD->DB Sync nur via `module.dd.sync` und nur fuer `$modul|$dd`.\n\n"
516 . "- `reference/25_Verbindliches_Modulhandbuch.md` ist die verbindliche Anleitung.\n"
517 . "- `reference/myInvoices/` ist das ausfuehrbare Beispiel. Nicht in das Zielmodul kopieren, sondern die passenden Muster anwenden.\n\n"
518 . "## Erlaubte job.json Actions\n\n"
519 . "- `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"
520 . "## Antwort\n\nLiefere `antwort.zip` mit `manifest.json`, `job.json`, optional `assets/` und README. `manifest.auto_execute` bleibt true. Keine Erklaerung statt ZIP.\n";
523 public function handleBundleImport(): string {
526 $form = $this->managedForm(
527 'ki-module-bundle-
import',
528 'ki-module-bundle-
import',
529 $this->moduleUrl('module_bundle_import
')
531 if (!$form->submit()) {
532 throw new \RuntimeException('Ungueltiger oder abgelaufener Formular-Token.
');
534 $payload = $this->readModuleBundlePayload();
535 $manifest = is_array($payload['manifest
'] ?? null) ? $payload['manifest
'] : array();
536 $job = is_array($payload['job
'] ?? null) ? $payload['job
'] : array();
537 $assetsDir = (string)($payload['assets_dir
'] ?? '');
538 $root = (string)($payload['root
'] ?? '');
539 $this->validateModulePayload($manifest, $job);
540 $result = $this->executeModuleJob($manifest, $job, $assetsDir);
541 if ($root !== '') $this->removeDir($root);
542 return $this->renderModuleBundleResult($result);
543 } catch (\Throwable $e) {
544 if ($root !== '') $this->removeDir($root);
545 dbx()->sys_msg('error
', 'dbxKi', 'module_bundle_import
', 'Import fehlgeschlagen
', $e->getMessage());
546 return '<div
class=
"container py-4"><div
class=
"alert alert-danger">
' . $this->esc($e->getMessage()) . '</div>
'
547 . '<a
class=
"btn btn-secondary" href=
"' . $this->esc($this->moduleUrl('module_bundle')) . '">Zurueck</a></div>
';
551 private function readModuleBundlePayload(): array {
553 foreach ($_FILES as $upload) {
554 if (is_array($upload) && (int)($upload['error
'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK && is_uploaded_file((string)($upload['tmp_name
'] ?? ''))) {
555 $file = (string)$upload['tmp_name
'];
560 throw new \InvalidArgumentException('Keine Antwort-ZIP hochgeladen.
');
562 if (!class_exists('\\ZipArchive
')) {
563 throw new \RuntimeException('ZipArchive nicht verfuegbar.
');
565 $root = rtrim(sys_get_temp_dir(), '/\\
') . DIRECTORY_SEPARATOR . 'dbxki-module-
' . bin2hex(random_bytes(8));
566 if (!mkdir($root, 0777, true) && !is_dir($root)) {
567 throw new \RuntimeException('Temp-Verzeichnis konnte nicht erstellt werden.
');
569 $zip = new \ZipArchive();
570 if ($zip->open($file) !== true) {
571 $this->removeDir($root);
572 throw new \InvalidArgumentException('ZIP konnte nicht geoeffnet werden.
');
574 for ($i = 0; $i < $zip->numFiles; $i++) {
575 $name = str_replace('\\
', '/
', (string)$zip->getNameIndex($i));
576 if ($name === '' || strpos($name, '../
') !== false || str_starts_with($name, '/
')) {
578 $this->removeDir($root);
579 throw new \InvalidArgumentException('Ungueltiger ZIP-Pfad:
' . $name);
582 $zip->extractTo($root);
584 $manifest = $this->readJson($root . '/manifest.json
', true);
585 $job = $this->readJson($root . '/job.json
', true);
588 'assets_dir
' => $root . '/assets
',
589 'manifest
' => $manifest,
594 private function readJson(string $file, bool $required): array {
595 if (!is_file($file)) {
596 if ($required) throw new \InvalidArgumentException('Pflichtdatei fehlt:
' . basename($file));
599 $data = json_decode((string)file_get_contents($file), true);
600 if (!is_array($data)) {
601 throw new \InvalidArgumentException('Ungueltiges JSON:
' . basename($file));
606 private function removeDir(string $dir): void {
607 if (!is_dir($dir)) return;
608 $it = new \RecursiveIteratorIterator(
609 new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
610 \RecursiveIteratorIterator::CHILD_FIRST
612 foreach ($it as $item) {
613 $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
618 private function validateModulePayload(array $manifest, array $job): void {
619 $modul = trim((string)($manifest['module
'] ?? $job['module
'] ?? ''));
620 if (($manifest['area
'] ?? '') !== 'module
' || !$this->validName($modul) || !is_dir($this->moduleDir($modul))) {
621 throw new \InvalidArgumentException('manifest.module ist ungueltig oder area ist nicht module.
');
623 if (!is_array($job['steps
'] ?? null) || !count($job['steps
'])) {
624 throw new \InvalidArgumentException('job.json benoetigt steps[].
');
626 foreach ($job['steps
'] as $pos => $step) {
627 if (!is_array($step)) throw new \InvalidArgumentException('Step #
' . ($pos + 1) . ' ist ungueltig.
');
628 $action = trim((string)($step['action
'] ?? ''));
629 if (!$this->moduleActionAllowed($action)) {
630 throw new \InvalidArgumentException('Aktion nicht erlaubt:
' . $action);
632 $stepModule = trim((string)($step['params
']['module
'] ?? $modul));
633 if ($stepModule !== $modul) {
634 throw new \InvalidArgumentException('Step-Modul passt nicht zum Manifest:
' . $action);
639 private function moduleActionAllowed(string $action): bool {
640 return in_array($action, array(
643 'module.file.delete
',
646 'module.template.set
',
647 'module.asset.write
',
651 private function executeModuleJob(array $manifest, array $job, string $assetsDir): array {
652 $modul = trim((string)($manifest['module
'] ?? $job['module
'] ?? ''));
653 $auto = $this->truthy($manifest['auto_execute
'] ?? false);
655 throw new \RuntimeException('manifest.auto_execute ist nicht
true.
');
659 $needsBackup = false;
660 foreach ($job['steps
'] as $step) {
661 if (($step['action
'] ?? '') !== 'module.backup
') {
667 $results['_auto_backup
'] = $this->createModuleBackup($modul);
670 foreach ($job['steps
'] as $pos => $step) {
671 $id = trim((string)($step['id'] ?? ('step_
' . ($pos + 1))));
672 $params = $this->resolveModuleParams(is_array($step['params
'] ?? null) ? $step['params
'] : array(), $results);
673 $params['module
'] = $modul;
674 $results[$id] = $this->executeModuleStep((string)$step['action
'], $params, $assetsDir);
676 return array('ok
' => 1, 'module
' => $modul, 'results
' => $results);
679 private function truthy($value): bool {
680 if (is_bool($value)) return $value;
681 return in_array(strtolower(trim((string)$value)), array('1
', 'true', 'yes
', 'ja
', 'on
'), true);
684 private function resolveModuleParams($value, array $results) {
685 if (is_array($value)) {
687 foreach ($value as $key => $item) $out[$key] = $this->resolveModuleParams($item, $results);
690 if (!is_string($value) || strpos($value, '$ref:
') === false) return $value;
691 return preg_replace_callback('/\$ref:([A-Za-z0-9_.-]+)/
', function($m) use ($results) {
692 $parts = explode('.
', $m[1]);
693 $step = array_shift($parts);
694 $v = $results[$step] ?? null;
695 foreach ($parts as $part) {
696 $v = is_array($v) && array_key_exists($part, $v) ? $v[$part] : '';
702 private function executeModuleStep(string $action, array $params, string $assetsDir): array {
704 case 'module.backup
':
705 return $this->createModuleBackup((string)$params['module
']);
706 case 'module.file.write
':
707 return $this->moduleFileWrite($params, $assetsDir);
708 case 'module.file.delete
':
709 return $this->moduleFileDelete($params);
710 case 'module.dd.write
':
711 return $this->moduleDdWrite($params);
712 case 'module.dd.sync
':
713 return $this->moduleDdSync($params);
714 case 'module.template.set
':
715 return $this->moduleTemplateSet($params);
716 case 'module.asset.write
':
717 return $this->moduleAssetWrite($params, $assetsDir);
719 throw new \InvalidArgumentException('Aktion nicht implementiert:
' . $action);
722 private function createModuleBackup(string $modul): array {
723 $dir = dbx()->os_path(dbx()->get_file_dir() . 'temp/
dbxKi/module-backups/
');
724 if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
725 throw new \RuntimeException('Backup-Verzeichnis konnte nicht erstellt werden.
');
727 $file = $dir . $modul . '-
' . date('Ymd-His
') . '.zip
';
728 $zip = new \ZipArchive();
729 if ($zip->open($file, \ZipArchive::OVERWRITE) !== true) {
730 throw new \RuntimeException('Modul-Backup konnte nicht erstellt werden.
');
732 $this->addModuleToZip($zip, $modul);
734 return array('backup
' => str_replace('\\
', '/
', $file), 'size
' => (int)@filesize($file));
737 private function relFromPath(string $modul, string $path): string {
738 $path = str_replace(array('\\
', "\0"), array('/
', ''), trim($path));
739 $prefix = 'dbx/modules/
' . $modul . '/
';
740 if (strpos($path, $prefix) === 0) $path = substr($path, strlen($prefix));
741 $path = ltrim($path, '/
');
742 if ($path === '' || strpos($path, '../
') !== false || str_starts_with($path, '/
')) {
743 throw new \InvalidArgumentException('Ungueltiger Modulpfad.
');
748 private function targetFile(string $modul, string $path): string {
749 $rel = $this->relFromPath($modul, $path);
750 $file = $this->modulePath($modul, $rel);
751 if ($file === '') throw new \InvalidArgumentException('Pfad ausserhalb des Moduls.
');
755 private function bytesFromParams(array $params, string $assetsDir): string {
756 if (array_key_exists('content
', $params)) return (string)$params['content
'];
757 if (array_key_exists('content_base64
', $params)) {
758 $bytes = base64_decode((string)$params['content_base64
'], true);
759 if ($bytes === false) throw new \InvalidArgumentException('content_base64 ist ungueltig.
');
762 $assetRef = trim(str_replace('\\
', '/
', (string)($params['asset_ref
'] ?? '')));
763 if ($assetRef !== '') {
764 if (strpos($assetRef, '../
') !== false || str_starts_with($assetRef, '/
')) {
765 throw new \InvalidArgumentException('asset_ref ist ungueltig.
');
767 $file = rtrim($assetsDir, '/\\
') . DIRECTORY_SEPARATOR . str_replace('/
', DIRECTORY_SEPARATOR, $assetRef);
768 if (!is_file($file)) throw new \InvalidArgumentException('Asset fehlt:
' . $assetRef);
769 return (string)file_get_contents($file);
771 throw new \InvalidArgumentException('content, content_base64 oder asset_ref erforderlich.
');
774 private function writeTargetFile(string $file, string $bytes): array {
775 $dir = dirname($file);
776 if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
777 throw new \RuntimeException('Zielverzeichnis konnte nicht erstellt werden.
');
779 if (file_put_contents($file, $bytes) === false) {
780 throw new \RuntimeException('Datei konnte nicht geschrieben werden.
');
782 return array('path
' => str_replace('\\
', '/
', $file), 'bytes
' => strlen($bytes), 'sha256
' => hash('sha256
', $bytes));
785 private function moduleFileWrite(array $params, string $assetsDir): array {
786 $modul = (string)$params['module
'];
787 $rel = $this->relFromPath($modul, (string)($params['path
'] ?? ''));
788 if (preg_match('#^(dd/|tpl/|img/|css/|js/)#i
', $rel)) {
789 throw new \InvalidArgumentException('Fuer DD/Templates/Assets die spezialisierten module.* Actions verwenden.
');
791 if (preg_match('/\.(db3|sqlite|sqlite3)$/i
', $rel)) {
792 throw new \InvalidArgumentException('Datenbanken werden nicht direkt geschrieben. DD->DB Sync ueber module.dd.sync verwenden.
');
794 $file = $this->targetFile($modul, $rel);
795 return $this->writeTargetFile($file, $this->bytesFromParams($params, $assetsDir));
798 private function moduleFileDelete(array $params): array {
799 if (!$this->truthy($params['confirm
'] ?? false)) {
800 throw new \InvalidArgumentException('module.file.delete benoetigt confirm=
true.
');
802 $modul = (string)$params['module
'];
803 $file = $this->targetFile($modul, (string)($params['path
'] ?? ''));
804 if (!is_file($file)) return array('deleted
' => false, 'path
' => str_replace('\\
', '/
', $file));
805 if (!unlink($file)) throw new \RuntimeException('Datei konnte nicht geloescht werden.
');
806 return array('deleted
' => true, 'path
' => str_replace('\\
', '/
', $file));
809 private function moduleDdWrite(array $params): array {
810 $modul = (string)$params['module
'];
811 $dd = trim((string)($params['dd
'] ?? ''));
812 if (!$this->validName($dd)) throw new \InvalidArgumentException('Ungueltiger DD-Name.
');
813 $content = (string)($params['content
'] ?? '');
814 if ($content === '' || preg_match('/\b(require|include)(_once)?\b/i
', $content)) {
815 throw new \InvalidArgumentException('DD muss vollstaendig sein und darf keine include/require verwenden.
');
817 $file = $this->targetFile($modul, 'dd/
' . $dd . '.dd.php
');
818 return $this->writeTargetFile($file, $content);
821 private function moduleTemplateSet(array $params): array {
822 $modul = (string)$params['module
'];
823 $template = trim((string)($params['template'] ?? ''));
824 $path = trim((string)($params['path
'] ?? ''));
826 $template = preg_replace('/[^A-Za-z0-9_.-]+/
', '-
', $template);
827 if ($template === '') throw new \InvalidArgumentException('template ist erforderlich.
');
828 if (!preg_match('/\.htm[l]?$/i
', $template)) $template .= '.htm
';
829 $path = 'tpl/htm/
' . $template;
831 if (strpos(str_replace('\\
', '/
', $path), 'tpl/
') !== 0) {
832 throw new \InvalidArgumentException('module.template.set darf nur unter tpl/ schreiben.
');
834 $file = $this->targetFile($modul, $path);
835 return $this->writeTargetFile($file, (string)($params['content
'] ?? ''));
838 private function moduleAssetWrite(array $params, string $assetsDir): array {
839 $modul = (string)$params['module
'];
840 $path = $this->relFromPath($modul, (string)($params['path
'] ?? ''));
841 if (!preg_match('#^(tpl/(img|mod)/|img/|css/|js/)#i
', $path)) {
842 throw new \InvalidArgumentException('module.asset.write darf nur tpl/img, tpl/mod, img, css oder js schreiben.
');
844 return $this->writeTargetFile($this->targetFile($modul, $path), $this->bytesFromParams($params, $assetsDir));
847 private function moduleDdSync(array $params): array {
848 $modul = (string)$params['module
'];
849 $dd = trim((string)($params['dd
'] ?? ''));
850 if (!$this->validName($dd)) throw new \InvalidArgumentException('Ungueltiger DD-Name.
');
851 if (!is_file($this->targetFile($modul, 'dd/
' . $dd . '.dd.php
'))) {
852 throw new \InvalidArgumentException('DD-Datei fehlt:
' . $modul . '|
' . $dd);
854 $oDD = dbx()->get_system_obj('dbxDD');
855 if (!is_object($oDD) || !method_exists($oDD, 'sync_dd_to_db
')) {
856 throw new \RuntimeException('dbxDD Sync-API nicht verfuegbar.
');
858 $oDD->sync_dd_to_db($modul, $dd, 'reset
');
860 for ($i = 0; $i < 80; $i++) {
861 $state = $oDD->sync_dd_to_db($modul, $dd, 'apply
');
862 $status = strtolower((string)($state['status
'] ?? ''));
863 if (in_array($status, array('finished
', 'error
', 'canceled
'), true)) break;
864 if ((int)($state['percent
'] ?? 0) >= 100) break;
866 return array('dd
' => $modul . '|
' . $dd, 'state
' => $state);
869 private function renderModuleBundleResult(array $result): string {
871 foreach (is_array($result['results
'] ?? null) ? $result['results
'] : array() as $id => $row) {
872 $items .= '<li
class=
"list-group-item"><strong>
' . $this->esc($id) . '</strong><pre
class=
"small mb-0">
'
873 . $this->esc(json_encode($row, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES))
876 return '<div
class=
"container py-4" style=
"max-width:1100px"><div
class=
"alert alert-success">Modul-Bundle ausgefuehrt:
'
877 . $this->esc((string)($result['module
'] ?? '')) . '</div><ul
class=
"list-group mb-3">
' . $items . '</ul>
'
878 . '<a
class=
"btn btn-primary" href=
"' . $this->esc($this->moduleUrl('briefing_module', array('xmodul' => (string)($result['module'] ?? '')))) . '">Zur Modul-KI</a></div>
';
881 private function modulePipelineGuide(string $modul, string $taskType = 'update
'): array {
883 'workflow
' => $this->modulePipeline($modul),
884 'rules
' => $this->hardRules($modul),
885 'ui_contract
' => $this->uiContract(),
886 'dbx_api_contract
' => $this->dbxApiContract($modul),
887 'reference_standard
' => $this->referenceStandard(),
889 'bundle_version
' => self::MODULE_BRIEFING_VERSION,
892 'recipe
' => 'module.
update.v1
',
893 'task_type
' => $taskType,
894 'auto_execute
' => true,
897 'job_version
' => self::MODULE_BRIEFING_VERSION,
901 array('id' => 'backup
', 'action
' => 'module.backup
', 'params
' => array('module
' => $modul)),
902 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___
')),
903 array('id' => 'write_template
', 'action
' => 'module.template.set
', 'params
' => array('module
' => $modul, 'template' => 'template-name
', 'content
' => '___HTML_TEMPLATE___
')),
904 array('id' => 'write_dd
', 'action
' => 'module.dd.write
', 'params
' => array('module
' => $modul, 'dd
' => 'ddName
', 'content
' => '___VOLLSTAENDIGE_DD_DATEI___
')),
905 array('id' => 'sync_dd
', 'action
' => 'module.dd.sync
', 'params
' => array('module
' => $modul, 'dd
' => 'ddName
')),
911 public function handleApi(): void {
912 $raw = file_get_contents('php:
914 if (is_string($raw) && trim($raw) !==
'') {
915 $decoded = json_decode($raw,
true);
916 if (is_array($decoded))
$body = $decoded;
918 $params = is_array(
$body[
'params'] ??
null) ?
$body[
'params'] : array();
919 $action = (string)(
$body[
'action'] ??
dbx()->get_request_var(
'action',
'system.describe',
'parameter+.'));
920 $modul = (string)($params[
'xmodul'] ?? $params[
'module'] ??
dbx()->get_request_var(
'xmodul',
'',
'parameter'));
923 case 'system.describe':
924 dbx()->json_response($this->moduleDescribe(
''),
true);
926 case 'module.describe':
927 if (!$this->validName($modul))
throw new \InvalidArgumentException(
'Ungueltiges Modul');
928 dbx()->json_response($this->moduleDescribe($modul),
true);
930 case 'module.snapshot':
931 if (!$this->validName($modul))
throw new \InvalidArgumentException(
'Ungueltiges Modul');
932 $withContent = (int)($params[
'with_content'] ??
dbx()->get_request_var(
'with_content', 0,
'int')) === 1;
933 dbx()->json_response(array(
'ok' => 1,
'module' => $modul,
'files' => $this->fileTree($modul, $withContent)),
true);
935 case 'module.pipeline_guide':
936 if (!$this->validName($modul))
throw new \InvalidArgumentException(
'Ungueltiges Modul');
937 $taskType = (string)($params[
'task_type'] ??
dbx()->get_request_var(
'task_type',
'update',
'parameter'));
938 dbx()->json_response(array(
'ok' => 1,
'result' => $this->modulePipelineGuide($modul, $taskType)),
true);
940 case 'module.job.execute':
941 $manifest = is_array($params[
'manifest'] ??
null) ? $params[
'manifest'] : array();
942 $job = is_array($params[
'job'] ??
null) ? $params[
'job'] : array();
944 if (!$job && is_array(
$body[
'job'] ??
null)) $job =
$body[
'job'];
945 $this->validateModulePayload(
$manifest, $job);
946 dbx()->json_response($this->executeModuleJob(
$manifest, $job,
''),
true);
948 case 'module.file.read':
949 if (!$this->validName($modul))
throw new \InvalidArgumentException(
'Ungueltiges Modul');
950 $path = (string)($params[
'path'] ??
dbx()->get_request_var(
'path',
'',
'*'));
951 $prefix =
'dbx/modules/' . $modul .
'/';
952 if (strpos($path, $prefix) === 0) {
953 $path = substr($path, strlen($prefix));
955 $file = $this->modulePath($modul, $path);
957 throw new \InvalidArgumentException(
'Datei nicht lesbar');
959 dbx()->json_response(array(
'ok' => 1,
'module' => $modul,
'path' =>
'dbx/modules/' . $modul .
'/' . str_replace(
'\\',
'/', $path),
'content' => (
string)file_get_contents(
$file)),
true);
962 throw new \InvalidArgumentException(
'Unbekannte action');
963 }
catch (\Throwable $e) {
964 dbx()->json_response(array(
'ok' => 0,
'error' => $e->getMessage()),
true);