diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index 1cf8887c..2b8daedc 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -67,6 +67,8 @@ Administratoren nutzen zusaetzlich `Admin Apps` fuer den globalen App-Bestand, I Vor dem Login erscheint nun zusaetzlich ein offener `Logoff-Desktop`. Dort gibt es unten nur die Login-Lasche. Sichtbar sind dort ausschliesslich Desktop-Apps, die ein Admin explizit fuer die Nutzung ohne Login freigegeben hat. +Neue Fach-Apps koennen im Bereich `Admin Apps > Installation` direkt als ZIP hochgeladen werden. Das ZIP muss genau ein Modulverzeichnis enthalten und wird nach einer Basispruefung nach `custom/apps/` entpackt. + ## Module Installierbare Fach-Apps liegen unter `custom/apps//` und koennen als normale `App` im Desktop erscheinen. @@ -84,6 +86,7 @@ Aktuell gilt: - `Widgets` koennen von Modulen bereitgestellt werden, wenn das Modul diese Funktion im Manifest hinterlegt. - Widgets sind fachlich kleine Desktop-Elemente ohne Fensterfunktionen; aktuell erscheinen sie technisch noch uebergangsweise im rechten `Infobereich`. - Widget- und Tray-Funktionen stehen nur dann zur Auswahl, wenn der Benutzer auf die zugrundeliegende App Zugriff hat. +- Der Abschnitt `Widgets` in `Admin Apps` ist eine technische Registry-Uebersicht und zeigt Herkunft, Launch-App und APIs vorhandener Widgets. - Das `Cron Tool` ist ein globales `Systemtool` fuer Administratoren. - Dort erscheinen Modul-Cronjobs automatisch, sobald ein Modul sie in `module.json` hinterlegt. - Cron-Aufrufe laufen auf demselben Host und verwenden dieselbe Projektbasis wie der Desktop. diff --git a/docs/CONTENT.md b/docs/CONTENT.md index a0057ea7..08a842a8 100644 --- a/docs/CONTENT.md +++ b/docs/CONTENT.md @@ -91,6 +91,7 @@ Stand dieser Datei: - rechter `Infobereich` mit benutzerbezogener Sichtbarkeit als aktueller Uebergangsplatz fuer Widgets - `User Self Management` als Setup-App - `Admin Apps` als zentrale Admin-App fuer App-Bestand, Widgets und erste Integrations-Einstellungen +- ZIP-basierte Modulinstallation ueber `Admin Apps` mit Basispruefung und Entpacken nach `custom/apps/` - getrennte Nutzersteuerung fuer `Apps`, `Tray-Apps`, `Widgets` und optionale `Desktop-Icons` - erster echter App-Pfad fuer installierbare Fach-Apps mit Discovery aus `custom/apps//` - `Mining-Checker` als erstes angebundenes klassisches Modul diff --git a/docs/README.md b/docs/README.md index 49fe971b..9d82d72a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -65,6 +65,7 @@ Aktuell wichtig: - der Gitea-Deploy-Status liegt als optionales Addon unter `system/addons/gitea-deploy-status/` und wird serverseitig ueber `config/gitea.php` angebunden - `Admin Apps` ist als globales `Systemtool` fuer App-Bestand, Widgets und erste Integrations-Einstellungen vorhanden - LDAP-Gruppenberechtigungen fuer Apps werden zentral ueber die Desktop-App-Verwaltung gepflegt +- der Installationsbereich in `Admin Apps` kann ZIP-Dateien nach Basispruefung nach `custom/apps/` entpacken - das Oeffnen des Waehrungs-Checkers darf keinen externen Kursabruf ausloesen; Refreshes laufen nur nach Altersregel oder mit `force` - `Systemtools` und installierbare `Module` werden getrennt behandelt; Systemtools sind nicht Teil der Benutzer-Installationsauswahl - das `Cron Tool` ist als globales Systemtool vorhanden und sammelt Modul-Cronjobs automatisch ueber Manifest-Metadaten diff --git a/docs/WEITERENTWICKLUNG.md b/docs/WEITERENTWICKLUNG.md index bad10051..336937a3 100644 --- a/docs/WEITERENTWICKLUNG.md +++ b/docs/WEITERENTWICKLUNG.md @@ -40,6 +40,7 @@ Verbindlich ist: - Widget-Logik einer App muss dieselben fachlichen Regeln wie die App-API verwenden, nicht eigene Sonderwege - App-Berechtigungen fuer LDAP-Gruppen muessen zentral und pro App nachvollziehbar pflegbar bleiben - die Freigabe fuer den offenen Logoff-Desktop muss pro App zentral pflegbar bleiben +- ZIP-Installationen nach `custom/apps/` muessen mindestens Struktur-, Pflichtdatei- und Plausibilitaetschecks durchlaufen - Systemtools duerfen nicht als installierbare Module modelliert werden - neue Modul-Widgets und Modul-Cronjobs sollen ueber Manifest-Metadaten registriert werden statt ueber versteckte Sonderlisten diff --git a/src/App/AdminAppsService.php b/src/App/AdminAppsService.php index 577fbbee..dd2fca88 100644 --- a/src/App/AdminAppsService.php +++ b/src/App/AdminAppsService.php @@ -97,6 +97,28 @@ final class AdminAppsService return AppAccessConfig::saveRequiredGroups($this->projectRoot, $appId, $requiredGroups, $availableWithoutLogin); } + /** + * @param array $uploadedFile + * @param array $requiredGroups + * @return array{module_directory: string, module_path: string, app_id: string, manifest: array} + */ + public function installUploadedApp( + array $uploadedFile, + ?string $targetDirectoryName, + array $requiredGroups, + bool $availableWithoutLogin + ): array { + $installer = new UploadedModuleZipInstaller(); + + return $installer->install( + $this->projectRoot, + $uploadedFile, + $targetDirectoryName, + $requiredGroups, + $availableWithoutLogin + ); + } + /** * @param array> $apps * @return array> diff --git a/src/App/UploadedModuleZipInstaller.php b/src/App/UploadedModuleZipInstaller.php new file mode 100644 index 00000000..3aee685a --- /dev/null +++ b/src/App/UploadedModuleZipInstaller.php @@ -0,0 +1,337 @@ + $uploadedFile + * @param array $requiredGroups + * @return array{module_directory: string, module_path: string, app_id: string, manifest: array} + */ + public function install( + string $projectRoot, + array $uploadedFile, + ?string $requestedDirectoryName, + array $requiredGroups, + bool $availableWithoutLogin + ): array { + if (!class_exists(\ZipArchive::class)) { + throw new \RuntimeException('ZIP-Unterstuetzung ist auf dem Server nicht verfuegbar.'); + } + + $tmpUpload = $this->validateUpload($uploadedFile); + $archive = new \ZipArchive(); + if ($archive->open($tmpUpload) !== true) { + throw new \RuntimeException('ZIP-Datei konnte nicht geoeffnet werden.'); + } + + try { + $topLevelDirectory = $this->detectSingleTopLevelDirectory($archive); + $tempRoot = $this->createTempDirectory(); + + try { + $this->assertArchivePathsSafe($archive); + if (!$archive->extractTo($tempRoot)) { + throw new \RuntimeException('ZIP-Datei konnte nicht entpackt werden.'); + } + + $sourceDirectory = $tempRoot . '/' . $topLevelDirectory; + if (!is_dir($sourceDirectory)) { + throw new \RuntimeException('Im ZIP wurde kein gueltiges Modulverzeichnis gefunden.'); + } + + $moduleDirectory = $this->resolveTargetDirectoryName($topLevelDirectory, $requestedDirectoryName); + $targetPath = AppPaths::customAppPath($projectRoot, $moduleDirectory); + if (is_dir($targetPath)) { + throw new \RuntimeException( + 'Das Zielverzeichnis existiert bereits. Bitte Zielverzeichnis aendern, z.B. ' . $this->suggestDirectoryName($moduleDirectory) . '.' + ); + } + + $manifest = $this->validateExtractedModule($sourceDirectory); + $appId = $this->resolveAppId($manifest, $moduleDirectory); + + $this->copyDirectory($sourceDirectory, $targetPath); + AppAccessConfig::saveRequiredGroups($projectRoot, $appId, $requiredGroups, $availableWithoutLogin); + + return [ + 'module_directory' => $moduleDirectory, + 'module_path' => $targetPath, + 'app_id' => $appId, + 'manifest' => $manifest, + ]; + } finally { + $this->deleteDirectory($tempRoot); + } + } finally { + $archive->close(); + } + } + + /** + * @param array $uploadedFile + */ + private function validateUpload(array $uploadedFile): string + { + $errorCode = (int) ($uploadedFile['error'] ?? UPLOAD_ERR_NO_FILE); + if ($errorCode !== UPLOAD_ERR_OK) { + throw new \RuntimeException(match ($errorCode) { + UPLOAD_ERR_NO_FILE => 'Bitte eine ZIP-Datei auswaehlen.', + UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'Die ZIP-Datei ist zu gross.', + default => 'Die ZIP-Datei konnte nicht hochgeladen werden.', + }); + } + + $originalName = (string) ($uploadedFile['name'] ?? ''); + if (strtolower(pathinfo($originalName, PATHINFO_EXTENSION)) !== 'zip') { + throw new \RuntimeException('Bitte eine ZIP-Datei hochladen.'); + } + + $tmpName = (string) ($uploadedFile['tmp_name'] ?? ''); + if ($tmpName === '' || !is_uploaded_file($tmpName)) { + throw new \RuntimeException('Die hochgeladene Datei ist ungueltig.'); + } + + return $tmpName; + } + + private function detectSingleTopLevelDirectory(\ZipArchive $archive): string + { + $topLevel = []; + + for ($index = 0; $index < $archive->numFiles; $index += 1) { + $stat = $archive->statIndex($index); + $name = str_replace('\\', '/', (string) ($stat['name'] ?? '')); + $name = ltrim($name, '/'); + if ($name === '') { + continue; + } + + $parts = explode('/', $name); + $first = trim((string) ($parts[0] ?? '')); + if ($first === '') { + continue; + } + + $topLevel[$first] = true; + } + + if (count($topLevel) !== 1) { + throw new \RuntimeException('Die ZIP-Datei muss genau ein oberstes Modulverzeichnis enthalten.'); + } + + $directory = array_key_first($topLevel); + if (!is_string($directory) || $directory === '') { + throw new \RuntimeException('Das Modulverzeichnis im ZIP konnte nicht ermittelt werden.'); + } + + return $directory; + } + + private function assertArchivePathsSafe(\ZipArchive $archive): void + { + for ($index = 0; $index < $archive->numFiles; $index += 1) { + $stat = $archive->statIndex($index); + $name = str_replace('\\', '/', (string) ($stat['name'] ?? '')); + $normalized = ltrim($name, '/'); + + if ($normalized === '' || str_contains($normalized, '../') || str_starts_with($name, '/')) { + throw new \RuntimeException('Die ZIP-Datei enthaelt ungueltige Pfade.'); + } + } + } + + private function createTempDirectory(): string + { + $path = rtrim(sys_get_temp_dir(), '/') . '/desktop-app-install-' . bin2hex(random_bytes(8)); + if (!mkdir($path, 0775, true) && !is_dir($path)) { + throw new \RuntimeException('Temporres Verzeichnis konnte nicht angelegt werden.'); + } + + return $path; + } + + private function resolveTargetDirectoryName(string $archiveDirectory, ?string $requestedDirectoryName): string + { + $candidate = trim((string) ($requestedDirectoryName ?? '')); + if ($candidate === '') { + $candidate = $archiveDirectory; + } + + $normalized = strtolower(trim(preg_replace('/[^a-z0-9._-]+/i', '-', $candidate) ?? '', '-')); + if ($normalized === '') { + throw new \RuntimeException('Das Zielverzeichnis ist ungueltig.'); + } + + return $normalized; + } + + private function suggestDirectoryName(string $directoryName): string + { + return $directoryName . '-copy'; + } + + /** + * @return array + */ + private function validateExtractedModule(string $sourceDirectory): array + { + foreach (self::REQUIRED_FILES as $requiredFile) { + if (!is_file($sourceDirectory . '/' . $requiredFile)) { + throw new \RuntimeException('Pflichtdatei fehlt: ' . $requiredFile . '.'); + } + } + + $manifestRaw = file_get_contents($sourceDirectory . '/module.json'); + $manifest = is_string($manifestRaw) ? json_decode($manifestRaw, true) : null; + if (!is_array($manifest)) { + throw new \RuntimeException('module.json ist kein gueltiges JSON.'); + } + + if (trim((string) ($manifest['title'] ?? '')) === '') { + throw new \RuntimeException('module.json enthaelt keinen Titel.'); + } + + $this->assertNoSymlinks($sourceDirectory); + $this->assertNoSuspiciousCode($sourceDirectory); + + return $manifest; + } + + /** + * @param array $manifest + */ + private function resolveAppId(array $manifest, string $moduleDirectory): string + { + $candidate = trim((string) ($manifest['app_id'] ?? '')); + if ($candidate === '') { + $candidate = $moduleDirectory; + } + + $normalized = strtolower(trim(preg_replace('/[^a-z0-9._-]+/i', '-', $candidate) ?? '', '-')); + if ($normalized === '') { + throw new \RuntimeException('Die App-ID des Moduls ist ungueltig.'); + } + + return $normalized; + } + + private function assertNoSymlinks(string $directory): void + { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS) + ); + + foreach ($iterator as $path) { + if ($path->isLink()) { + throw new \RuntimeException('Das Modul enthaelt symbolische Links und wurde deshalb blockiert.'); + } + } + } + + private function assertNoSuspiciousCode(string $directory): void + { + $patterns = [ + '/\b(shell_exec|exec|passthru|proc_open|popen|pcntl_exec|curl_multi_exec)\s*\(/i', + '/\b(eval|assert)\s*\(/i', + '/`\s*[^`]+\s*`/', + ]; + $extensions = ['php', 'phtml', 'php5', 'inc', 'js', 'sh', 'bash']; + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS) + ); + + foreach ($iterator as $path) { + if (!$path->isFile()) { + continue; + } + + $extension = strtolower($path->getExtension()); + if (!in_array($extension, $extensions, true)) { + continue; + } + + if ($path->getSize() > 1024 * 1024 * 2) { + continue; + } + + $contents = file_get_contents($path->getPathname()); + if (!is_string($contents)) { + continue; + } + + foreach ($patterns as $pattern) { + if (preg_match($pattern, $contents) === 1) { + throw new \RuntimeException( + 'Das Modul wurde wegen eines Hochrisiko-Musters blockiert: ' . str_replace($directory . '/', '', $path->getPathname()) + ); + } + } + } + } + + private function copyDirectory(string $source, string $target): void + { + if (!mkdir($target, 0775, true) && !is_dir($target)) { + throw new \RuntimeException('Zielverzeichnis konnte nicht angelegt werden.'); + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($source, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $item) { + $relativePath = substr($item->getPathname(), strlen($source) + 1); + $destination = $target . '/' . $relativePath; + + if ($item->isDir()) { + if (!mkdir($destination, 0775, true) && !is_dir($destination)) { + throw new \RuntimeException('Unterverzeichnis konnte nicht angelegt werden: ' . $relativePath); + } + continue; + } + + $parent = dirname($destination); + if (!is_dir($parent) && !mkdir($parent, 0775, true) && !is_dir($parent)) { + throw new \RuntimeException('Zielpfad konnte nicht vorbereitet werden: ' . $relativePath); + } + + if (!copy($item->getPathname(), $destination)) { + throw new \RuntimeException('Datei konnte nicht kopiert werden: ' . $relativePath); + } + } + } + + private function deleteDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $item) { + if ($item->isDir()) { + @rmdir($item->getPathname()); + continue; + } + + @unlink($item->getPathname()); + } + + @rmdir($directory); + } +} diff --git a/system/apps/admin-apps/assets/app.css b/system/apps/admin-apps/assets/app.css index 43af5fa8..4a987dfa 100644 --- a/system/apps/admin-apps/assets/app.css +++ b/system/apps/admin-apps/assets/app.css @@ -109,6 +109,21 @@ grid-column: 1 / -1; } +#admin-apps-app .aa-field--check { + gap: 10px; +} + +#admin-apps-app .aa-check-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 10px; + align-items: start; +} + +#admin-apps-app .aa-check-row input { + margin-top: 4px; +} + #admin-apps-app .aa-field input, #admin-apps-app .aa-field textarea, #admin-apps-app .aa-field select, @@ -199,6 +214,11 @@ gap: 16px; } +#admin-apps-app .aa-install-groups { + display: grid; + gap: 12px; +} + #admin-apps-app .aa-access-groups { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); diff --git a/system/apps/admin-apps/page.php b/system/apps/admin-apps/page.php index 4dbfddf5..38e2421a 100644 --- a/system/apps/admin-apps/page.php +++ b/system/apps/admin-apps/page.php @@ -52,6 +52,11 @@ if ($csrfToken === '') { $service = new AdminAppsService($projectRoot); $messages = []; $errors = []; +$installForm = [ + 'target_directory_name' => '', + 'required_groups' => [], + 'available_without_login' => false, +]; $section = trim((string) ($_GET['section'] ?? $_POST['active_section'] ?? 'overview')); $allowedSections = ['overview', 'installation', 'widgets', 'integrations']; if (!in_array($section, $allowedSections, true)) { @@ -59,6 +64,11 @@ if (!in_array($section, $allowedSections, true)) { } if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $installForm = [ + 'target_directory_name' => trim((string) ($_POST['target_directory_name'] ?? '')), + 'required_groups' => array_values(array_map('strval', (array) ($_POST['required_groups'] ?? []))), + 'available_without_login' => !empty($_POST['available_without_login']), + ]; $postedToken = (string) ($_POST['csrf_token'] ?? ''); if ($postedToken === '' || !hash_equals($csrfToken, $postedToken)) { $errors[] = 'Die Formularpruefung ist fehlgeschlagen.'; @@ -86,9 +96,28 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { ); $messages[] = 'LDAP-Gruppen fuer die App wurden gespeichert.'; $section = 'overview'; + } elseif ($action === 'install-app') { + $result = $service->installUploadedApp( + is_array($_FILES['module_zip'] ?? null) ? $_FILES['module_zip'] : [], + $installForm['target_directory_name'], + $installForm['required_groups'], + $installForm['available_without_login'] + ); + $messages[] = 'App installiert: ' . (string) ($result['manifest']['title'] ?? $result['module_directory']); + $messages[] = 'Zielverzeichnis: custom/apps/' . (string) $result['module_directory']; + $messages[] = 'App-ID fuer Berechtigungen: ' . (string) $result['app_id']; + $installForm = [ + 'target_directory_name' => '', + 'required_groups' => [], + 'available_without_login' => false, + ]; + $section = 'installation'; } } catch (\Throwable $exception) { $errors[] = $exception->getMessage(); + if ($action === 'install-app') { + $section = 'installation'; + } } } } @@ -114,15 +143,15 @@ $sectionMeta = [ ], 'installation' => [ 'label' => 'Installation', - 'title' => 'Installationswege fuer Apps', - 'description' => 'Installierbare Fach-Apps liegen unter custom/apps/. Upload- und Importwege werden hier als naechster Ausbauschritt vorbereitet.', - 'nav' => 'ZIP-, Verzeichnis- und Server-Workflow beschreiben.', + 'title' => 'ZIP-Upload und Installation', + 'description' => 'Installierbare Fach-Apps liegen unter custom/apps/. ZIP-Dateien koennen hier geprueft, entpackt und mit LDAP-Gruppen verknuepft werden.', + 'nav' => 'ZIP hochladen und nach /custom/apps/ installieren.', ], 'widgets' => [ 'label' => 'Widgets', - 'title' => 'Widgets und Quellen', - 'description' => 'Widgets sind kleine Desktop-Bereiche ohne klassische Fensterfunktionen. Sie koennen eigenstaendig oder Teil einer App sein.', - 'nav' => 'Widget-Quellen und Bedeutung pruefen.', + 'title' => 'Widget-Quellen', + 'description' => 'Hier sieht man nur, welche Widgets aktuell im System registriert sind, aus welcher App sie kommen und welche Start-App oder Status-API dahinterliegt.', + 'nav' => 'Registrierte Widgets und Herkunft pruefen.', ], 'integrations' => [ 'label' => 'Integrationen', @@ -326,7 +355,7 @@ ob_start();

Installation

Aktueller Installationsweg

-

Neue Apps koennen aktuell als Modul-Verzeichnis unter custom/apps/ abgelegt werden. Ein eigener Upload-Bereich fuer ZIP-Dateien oder Verzeichnisse ist noch offen und wird als naechster Ausbauschritt vorbereitet.

+

Neue Apps koennen als Modul-Verzeichnis unter custom/apps/ liegen. Fuer ZIP-Dateien steht der eigentliche Installer im Bereich Installation zur Verfuegung.

@@ -358,25 +387,72 @@ ob_start();
-

Installationswege

-

Aktueller und geplanter Ablauf

-

Installierbare Apps liegen im Projekt unter custom/apps/. Der Desktop erkennt neue Module automatisch ueber deren Desktop-Metadaten.

+

ZIP-Installer

+

App als ZIP hochladen

+

Das ZIP muss genau ein Modulverzeichnis enthalten. Geprueft werden oberstes Verzeichnis, Pflichtdateien wie module.json und desktop.php, Name-Konflikte sowie einfache Hochrisiko-Muster im Code.

-
-
- 1. Manuell ueber den Server -

Ein Modul-Verzeichnis wird nach custom/apps/<app>/ kopiert. Das ist der aktuell gueltige produktive Weg.

-
-
- 2. Verzeichnis-Upload -

Ein Browser-Upload fuer ein vorbereitetes Modul-Verzeichnis ist fachlich vorgesehen, aber noch nicht implementiert.

-
-
- 3. ZIP-Upload -

Ein ZIP-Import mit Entpacken und Validierung ist vorgesehen, benoetigt aber noch einen dedizierten Installations-Workflow.

-
+
+ + + + +
+ + + +
+ +
+ LDAP-Gruppen nach der Installation +

Leere Auswahl bedeutet: alle sichtbaren Benutzer. Die Werte werden sofort in hinterlegt.

+
+ +

Keine LDAP-Gruppen aus der Registration-Konfiguration gefunden.

+ + + + + +
+
+ +
+ +
+
+
+ +
+
+
+

Checks

+

Was geprueft wird

+

Der Installer erwartet ein einzelnes Modulverzeichnis im ZIP, prueft auf module.json und desktop.php, blockiert Pfad-Traversal, Symlinks und typische Hochrisiko-Funktionen wie exec() oder shell_exec(). Das ist ein Plausibilitaetscheck, keine vollstaendige Sicherheitsgarantie.

+
@@ -386,8 +462,8 @@ ob_start();

Widget Registry

-

Alle Widgets

-

Globale Widgets und modulbasierte Widgets werden hier gemeinsam sichtbar. Fachlich sind Widgets kleine Desktop-Elemente; technisch werden sie aktuell noch uebergangsweise im rechten Infobereich gerendert.

+

Registrierte Widgets

+

Dieser Bereich ist rein technisch gemeint: Er zeigt, welche Widgets derzeit registriert sind, aus welcher Quelle sie kommen und welche App oder API damit verbunden ist. Er ist keine separate Benutzerverwaltung.