adssd
All checks were successful
Deploy / deploy-staging (push) Successful in 25s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-06-26 01:45:44 +02:00
parent 8c1ae513ea
commit dad3e0a629
8 changed files with 486 additions and 25 deletions

View File

@@ -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>>

View 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);
}
}