adssd
This commit is contained in:
@@ -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/<app>/` 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.
|
||||
|
||||
@@ -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/<app>/`
|
||||
- `Mining-Checker` als erstes angebundenes klassisches Modul
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -97,6 +97,28 @@ final class AdminAppsService
|
||||
return AppAccessConfig::saveRequiredGroups($this->projectRoot, $appId, $requiredGroups, $availableWithoutLogin);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $uploadedFile
|
||||
* @param array<int, string> $requiredGroups
|
||||
* @return array{module_directory: string, module_path: string, app_id: string, manifest: array<string, mixed>}
|
||||
*/
|
||||
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<int, array<string, mixed>> $apps
|
||||
* @return array<int, array<string, mixed>>
|
||||
|
||||
337
src/App/UploadedModuleZipInstaller.php
Normal file
337
src/App/UploadedModuleZipInstaller.php
Normal file
@@ -0,0 +1,337 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class UploadedModuleZipInstaller
|
||||
{
|
||||
private const REQUIRED_FILES = [
|
||||
'module.json',
|
||||
'desktop.php',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $uploadedFile
|
||||
* @param array<int, string> $requiredGroups
|
||||
* @return array{module_directory: string, module_path: string, app_id: string, manifest: array<string, mixed>}
|
||||
*/
|
||||
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<string, mixed> $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<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
<div>
|
||||
<p class="window-app-kicker aa-kicker">Installation</p>
|
||||
<h3 class="aa-section-title">Aktueller Installationsweg</h3>
|
||||
<p class="aa-copy">Neue Apps koennen aktuell als Modul-Verzeichnis unter <code>custom/apps/</code> abgelegt werden. Ein eigener Upload-Bereich fuer ZIP-Dateien oder Verzeichnisse ist noch offen und wird als naechster Ausbauschritt vorbereitet.</p>
|
||||
<p class="aa-copy">Neue Apps koennen als Modul-Verzeichnis unter <code>custom/apps/</code> liegen. Fuer ZIP-Dateien steht der eigentliche Installer im Bereich <strong>Installation</strong> zur Verfuegung.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -358,25 +387,72 @@ ob_start();
|
||||
<section class="window-app-card aa-card">
|
||||
<div class="aa-section-head">
|
||||
<div>
|
||||
<p class="window-app-kicker aa-kicker">Installationswege</p>
|
||||
<h3 class="aa-section-title">Aktueller und geplanter Ablauf</h3>
|
||||
<p class="aa-copy">Installierbare Apps liegen im Projekt unter <code>custom/apps/</code>. Der Desktop erkennt neue Module automatisch ueber deren Desktop-Metadaten.</p>
|
||||
<p class="window-app-kicker aa-kicker">ZIP-Installer</p>
|
||||
<h3 class="aa-section-title">App als ZIP hochladen</h3>
|
||||
<p class="aa-copy">Das ZIP muss genau ein Modulverzeichnis enthalten. Geprueft werden oberstes Verzeichnis, Pflichtdateien wie <code>module.json</code> und <code>desktop.php</code>, Name-Konflikte sowie einfache Hochrisiko-Muster im Code.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aa-grid">
|
||||
<article class="window-app-card aa-card aa-inline-card">
|
||||
<strong>1. Manuell ueber den Server</strong>
|
||||
<p>Ein Modul-Verzeichnis wird nach <code>custom/apps/<app>/</code> kopiert. Das ist der aktuell gueltige produktive Weg.</p>
|
||||
</article>
|
||||
<article class="window-app-card aa-card aa-inline-card">
|
||||
<strong>2. Verzeichnis-Upload</strong>
|
||||
<p>Ein Browser-Upload fuer ein vorbereitetes Modul-Verzeichnis ist fachlich vorgesehen, aber noch nicht implementiert.</p>
|
||||
</article>
|
||||
<article class="window-app-card aa-card aa-inline-card">
|
||||
<strong>3. ZIP-Upload</strong>
|
||||
<p>Ein ZIP-Import mit Entpacken und Validierung ist vorgesehen, benoetigt aber noch einen dedizierten Installations-Workflow.</p>
|
||||
</article>
|
||||
<form class="aa-form" method="post" action="/admin/apps/" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="<?= $h($csrfToken) ?>">
|
||||
<input type="hidden" name="action" value="install-app">
|
||||
<input type="hidden" name="active_section" value="installation">
|
||||
|
||||
<div class="aa-form-grid">
|
||||
<label class="aa-field aa-field--full">
|
||||
<span>ZIP-Datei</span>
|
||||
<input type="file" name="module_zip" accept=".zip,application/zip">
|
||||
</label>
|
||||
<label class="aa-field">
|
||||
<span>Zielverzeichnis in <code>custom/apps/</code></span>
|
||||
<input type="text" name="target_directory_name" value="<?= $h((string) $installForm['target_directory_name']) ?>" placeholder="Optional, sonst Name aus ZIP">
|
||||
</label>
|
||||
<label class="aa-field aa-field--full aa-field--check">
|
||||
<span class="aa-check-row">
|
||||
<input type="checkbox" name="available_without_login" value="1" <?= !empty($installForm['available_without_login']) ? 'checked' : '' ?>>
|
||||
<span>App nach der Installation direkt fuer den Logoff-Desktop freigeben</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="aa-install-groups">
|
||||
<strong>LDAP-Gruppen nach der Installation</strong>
|
||||
<p class="aa-copy">Leere Auswahl bedeutet: alle sichtbaren Benutzer. Die Werte werden sofort in <?= $h((string) ($meta['app_access_config_path'] ?? '')) ?> hinterlegt.</p>
|
||||
<div class="aa-access-groups">
|
||||
<?php if ($availableGroups === []): ?>
|
||||
<p class="aa-copy">Keine LDAP-Gruppen aus der Registration-Konfiguration gefunden.</p>
|
||||
<?php else: ?>
|
||||
<?php foreach ($availableGroups as $group): ?>
|
||||
<label class="window-app-item aa-group-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="required_groups[]"
|
||||
value="<?= $h((string) ($group['id'] ?? '')) ?>"
|
||||
<?= in_array((string) ($group['id'] ?? ''), array_values(array_map('strval', (array) $installForm['required_groups'])), true) ? 'checked' : '' ?>
|
||||
>
|
||||
<span class="window-app-item-main aa-group-main">
|
||||
<strong><?= $h((string) ($group['label'] ?? '')) ?></strong>
|
||||
<span class="aa-meta"><?= $h((string) ($group['id'] ?? '')) ?></span>
|
||||
</span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aa-actions">
|
||||
<button class="window-app-button aa-primary" type="submit">ZIP pruefen und installieren</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="window-app-card aa-card">
|
||||
<div class="aa-section-head">
|
||||
<div>
|
||||
<p class="window-app-kicker aa-kicker">Checks</p>
|
||||
<h3 class="aa-section-title">Was geprueft wird</h3>
|
||||
<p class="aa-copy">Der Installer erwartet ein einzelnes Modulverzeichnis im ZIP, prueft auf <code>module.json</code> und <code>desktop.php</code>, blockiert Pfad-Traversal, Symlinks und typische Hochrisiko-Funktionen wie <code>exec()</code> oder <code>shell_exec()</code>. Das ist ein Plausibilitaetscheck, keine vollstaendige Sicherheitsgarantie.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
@@ -386,8 +462,8 @@ ob_start();
|
||||
<div class="aa-section-head">
|
||||
<div>
|
||||
<p class="window-app-kicker aa-kicker">Widget Registry</p>
|
||||
<h3 class="aa-section-title">Alle Widgets</h3>
|
||||
<p class="aa-copy">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.</p>
|
||||
<h3 class="aa-section-title">Registrierte Widgets</h3>
|
||||
<p class="aa-copy">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.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user