From dd825b4450ff4d3b8e2bf72f0f88df6c1ce02853 Mon Sep 17 00:00:00 2001 From: michele Date: Mon, 20 Apr 2026 10:40:59 +0000 Subject: [PATCH] Refine module manifest discovery handling --- app/Services/ModuleManager.php | 143 +++++++++++------- app/Support/Modules/ModuleManifest.php | 192 +++++++++++++++++++++++++ 2 files changed, 282 insertions(+), 53 deletions(-) create mode 100644 app/Support/Modules/ModuleManifest.php diff --git a/app/Services/ModuleManager.php b/app/Services/ModuleManager.php index d99df4b..02ec13a 100644 --- a/app/Services/ModuleManager.php +++ b/app/Services/ModuleManager.php @@ -1,17 +1,16 @@ modulesTableExists()) { + if (! $this->modulesTableExists()) { $this->activeModules = []; return; } @@ -58,10 +57,10 @@ public function installModule($zipPath, $validateLicense = true) // 1. Estrai e valida $extractPath = $this->extractModule($zipPath); - $config = $this->validateModuleConfig($extractPath); + $config = $this->validateModuleConfig($extractPath); // 2. Verifica licenza (se richiesta) - if ($validateLicense && !$this->validateLicense($config)) { + if ($validateLicense && ! $this->validateLicense($config)) { throw new \Exception("Licenza non valida per il modulo {$config['name']}"); } @@ -97,7 +96,7 @@ public function installModule($zipPath, $validateLicense = true) return [ 'success' => true, 'message' => "Modulo {$config['display_name']} installato con successo", - 'module' => $config + 'module' => $config, ]; } catch (\Exception $e) { Log::error("Errore installazione modulo: " . $e->getMessage()); @@ -109,7 +108,7 @@ public function installModule($zipPath, $validateLicense = true) return [ 'success' => false, - 'message' => $e->getMessage() + 'message' => $e->getMessage(), ]; } } @@ -126,12 +125,12 @@ public function uninstallModule($moduleName, $keepData = false) // 1. Controlla dipendenze inverse $dependentModules = $this->getDependentModules($moduleName); - if (!empty($dependentModules)) { + if (! empty($dependentModules)) { throw new \Exception("Impossibile disinstallare: moduli dipendenti attivi: " . implode(', ', $dependentModules)); } // 2. Backup prima della disinstallazione - if (!$keepData) { + if (! $keepData) { $this->createBackup($moduleName); } @@ -148,7 +147,7 @@ public function uninstallModule($moduleName, $keepData = false) $this->removeMenuItems($config); // 7. Esegui migrazioni rollback (se non si tengono i dati) - if (!$keepData) { + if (! $keepData) { $this->rollbackMigrations($moduleName); } @@ -168,14 +167,14 @@ public function uninstallModule($moduleName, $keepData = false) return [ 'success' => true, - 'message' => "Modulo {$moduleName} disinstallato con successo" + 'message' => "Modulo {$moduleName} disinstallato con successo", ]; } catch (\Exception $e) { Log::error("Errore disinstallazione modulo: " . $e->getMessage()); return [ 'success' => false, - 'message' => $e->getMessage() + 'message' => $e->getMessage(), ]; } } @@ -195,10 +194,10 @@ public function updateModule($moduleName, $zipPath) // 2. Estrai nuova versione $extractPath = $this->extractModule($zipPath); - $newConfig = $this->validateModuleConfig($extractPath); + $newConfig = $this->validateModuleConfig($extractPath); // 3. Verifica compatibilità versioni - if (!$this->isCompatibleVersion($oldConfig, $newConfig)) { + if (! $this->isCompatibleVersion($oldConfig, $newConfig)) { throw new \Exception("Versione non compatibile"); } @@ -226,7 +225,7 @@ public function updateModule($moduleName, $zipPath) 'success' => true, 'message' => "Modulo {$moduleName} aggiornato alla versione {$newConfig['version']}", 'old_version' => $oldConfig['version'], - 'new_version' => $newConfig['version'] + 'new_version' => $newConfig['version'], ]; } catch (\Exception $e) { Log::error("Errore aggiornamento modulo: " . $e->getMessage()); @@ -238,7 +237,7 @@ public function updateModule($moduleName, $zipPath) return [ 'success' => false, - 'message' => $e->getMessage() + 'message' => $e->getMessage(), ]; } } @@ -258,16 +257,45 @@ public function getActiveModules() */ public function getAllModules() { - if (!$this->modulesTableExists()) { + if (! $this->modulesTableExists()) { return collect(); } return DB::table('modules')->get()->map(function ($module) { - $module->config = json_decode($module->config, true); + $config = json_decode($module->config, true); + $module->config = is_array($config) ? ModuleManifest::normalize($config) : []; return $module; }); } + /** + * Ottieni il catalogo locale dei moduli con manifest normalizzato. + */ + public function discoverLocalModules() + { + if (! File::isDirectory($this->modulesPath)) { + return collect(); + } + + return collect(File::directories($this->modulesPath)) + ->map(function (string $modulePath) { + $moduleName = basename($modulePath); + + try { + return (object) $this->getModuleConfig($moduleName); + } catch (\Throwable $e) { + Log::warning('ModuleManager: manifest locale non leggibile', [ + 'module' => $moduleName, + 'error' => $e->getMessage(), + ]); + + return null; + } + }) + ->filter() + ->values(); + } + /** * Verifica licenza modulo (connessione server licenze) */ @@ -279,7 +307,7 @@ public function validateLicense($config) // TODO: Implementare verifica licenza online $licenseServer = config('netgescon.license_server'); - $licenseKey = config("modules.{$config['name']}.license_key"); + $licenseKey = config("modules.{$config['name']}.license_key"); // Chiamata API al server licenze // return $this->callLicenseServer($licenseServer, $config['name'], $licenseKey); @@ -293,21 +321,21 @@ public function validateLicense($config) */ public function toggleModule($moduleName) { - if (!$this->modulesTableExists()) { + if (! $this->modulesTableExists()) { throw new \Exception('Tabella modules non disponibile'); } $module = DB::table('modules')->where('name', $moduleName)->first(); - if (!$module) { + if (! $module) { throw new \Exception("Modulo {$moduleName} non trovato"); } $newStatus = $module->status === 'active' ? 'inactive' : 'active'; DB::table('modules')->where('name', $moduleName)->update([ - 'status' => $newStatus, - 'updated_at' => now() + 'status' => $newStatus, + 'updated_at' => now(), ]); $this->clearModuleCache(); @@ -321,10 +349,10 @@ public function toggleModule($moduleName) private function extractModule($zipPath) { - $zip = new ZipArchive; + $zip = new ZipArchive; $extractPath = storage_path('app/modules/temp/' . uniqid()); - if ($zip->open($zipPath) === TRUE) { + if ($zip->open($zipPath) === true) { $zip->extractTo($extractPath); $zip->close(); return $extractPath; @@ -337,27 +365,27 @@ private function validateModuleConfig($extractPath) { $configPath = $extractPath . '/module.json'; - if (!File::exists($configPath)) { + if (! File::exists($configPath)) { throw new \Exception("File module.json non trovato"); } $config = json_decode(File::get($configPath), true); - if (!$config || !isset($config['name'], $config['version'])) { + if (! $config || ! isset($config['name'], $config['version'])) { throw new \Exception("Configurazione modulo non valida"); } - return $config; + return ModuleManifest::normalize($config); } private function checkDependencies($config) { - if (!isset($config['dependencies'])) { + if (! isset($config['dependencies'])) { return true; } foreach ($config['dependencies'] as $dependency) { - if (!in_array($dependency, $this->activeModules)) { + if (! in_array($dependency, $this->activeModules)) { throw new \Exception("Dipendenza mancante: {$dependency}"); } } @@ -367,20 +395,22 @@ private function checkDependencies($config) private function registerModule($config) { - if (!$this->modulesTableExists()) { + if (! $this->modulesTableExists()) { throw new \Exception('Tabella modules non disponibile'); } + $config = ModuleManifest::normalize(is_array($config) ? $config : []); + DB::table('modules')->updateOrInsert( ['name' => $config['name']], [ - 'name' => $config['name'], - 'display_name' => $config['display_name'], - 'version' => $config['version'], - 'status' => 'active', - 'config' => json_encode($config), + 'name' => $config['name'], + 'display_name' => $config['display_name'] ?? $config['name'], + 'version' => $config['version'], + 'status' => 'active', + 'config' => json_encode($config), 'installed_at' => now(), - 'updated_at' => now() + 'updated_at' => now(), ] ); } @@ -399,11 +429,16 @@ private function getModuleConfig($moduleName) { $configPath = $this->modulesPath . "/{$moduleName}/module.json"; - if (!File::exists($configPath)) { + if (! File::exists($configPath)) { throw new \Exception("Configurazione modulo {$moduleName} non trovata"); } - return json_decode(File::get($configPath), true); + $config = json_decode(File::get($configPath), true); + if (! is_array($config)) { + throw new \Exception("Configurazione modulo {$moduleName} non valida"); + } + + return ModuleManifest::normalize($config); } private function getModuleInfo($moduleName) @@ -417,15 +452,15 @@ private function getModuleInfo($moduleName) } catch (\Throwable $e) { Log::warning('ModuleManager: impossibile recuperare info modulo', [ 'module' => $moduleName, - 'error' => $e->getMessage(), + 'error' => $e->getMessage(), ]); } } return (object) array_merge($config, [ - 'status' => $dbInfo->status ?? 'unknown', + 'status' => $dbInfo->status ?? 'unknown', 'installed_at' => $dbInfo->installed_at ?? null, - 'updated_at' => $dbInfo->updated_at ?? null + 'updated_at' => $dbInfo->updated_at ?? null, ]); } @@ -451,11 +486,13 @@ private function registerRoutes($moduleName) } private function installPermissions($config) { - if (!isset($config['permissions']) || empty($config['permissions'])) { + $config = ModuleManifest::normalize(is_array($config) ? $config : []); + + if (! isset($config['permissions']) || empty($config['permissions'])) { return; } - $permissions = $config['permissions']; + $permissions = $config['permissions']; $createdPermissions = []; try { @@ -463,10 +500,10 @@ private function installPermissions($config) // Crea il permesso se non esiste $existingPermission = \Spatie\Permission\Models\Permission::where('name', $permission['name'])->first(); - if (!$existingPermission) { + if (! $existingPermission) { $newPermission = \Spatie\Permission\Models\Permission::create([ - 'name' => $permission['name'], - 'guard_name' => $permission['guard_name'] ?? 'web', + 'name' => $permission['name'], + 'guard_name' => $permission['guard_name'] ?? 'web', 'description' => $permission['description'] ?? null, ]); @@ -494,7 +531,7 @@ private function assignPermissionsToRoles($permissions) $adminRoles = ['admin', 'amministratore', 'super-admin']; foreach ($permissions as $permission) { - $permName = $permission['name']; + $permName = $permission['name']; $assignToRoles = $permission['roles'] ?? $adminRoles; // Default: assegna agli admin foreach ($assignToRoles as $roleName) { @@ -502,7 +539,7 @@ private function assignPermissionsToRoles($permissions) if ($role) { // Assegna il permesso al ruolo se non ce l'ha già - if (!$role->hasPermissionTo($permName)) { + if (! $role->hasPermissionTo($permName)) { $role->givePermissionTo($permName); Log::info("Permesso {$permName} assegnato al ruolo {$roleName}"); } @@ -513,7 +550,7 @@ private function assignPermissionsToRoles($permissions) private function uninstallPermissions($config) { - if (!isset($config['permissions']) || empty($config['permissions'])) { + if (! isset($config['permissions']) || empty($config['permissions'])) { return; } diff --git a/app/Support/Modules/ModuleManifest.php b/app/Support/Modules/ModuleManifest.php new file mode 100644 index 0000000..24e85a2 --- /dev/null +++ b/app/Support/Modules/ModuleManifest.php @@ -0,0 +1,192 @@ + $manifest + * @return array + */ + public static function normalize(array $manifest): array + { + $normalized = $manifest; + + $name = trim((string) ($manifest['name'] ?? '')); + $normalized['name'] = $name; + $normalized['display_name'] = self::normalizeNullableString($manifest['display_name'] ?? null) ?? self::normalizeNullableString($manifest['title'] ?? null) ?? $name; + $normalized['description'] = self::normalizeNullableString($manifest['description'] ?? null); + $normalized['version'] = self::normalizeNullableString($manifest['version'] ?? null) ?? '0.0.0'; + $normalized['author'] = self::normalizeNullableString($manifest['author'] ?? null); + $normalized['license'] = self::normalizeNullableString($manifest['license'] ?? null) ?? 'free'; + $normalized['category'] = self::normalizeNullableString($manifest['category'] ?? null); + $normalized['dependencies'] = self::normalizeStringList($manifest['dependencies'] ?? []); + $normalized['optional_dependencies'] = self::normalizeStringList($manifest['optional_dependencies'] ?? []); + $normalized['hooks'] = self::normalizeStringList($manifest['hooks'] ?? []); + $normalized['features'] = self::normalizeStringList($manifest['features'] ?? []); + $normalized['permissions'] = self::normalizePermissions($manifest['permissions'] ?? []); + $normalized['menu_items'] = self::normalizeMenuItems($manifest['menu_items'] ?? []); + $normalized['requirements'] = is_array($manifest['requirements'] ?? null) ? $manifest['requirements'] : []; + $normalized['config'] = is_array($manifest['config'] ?? null) ? $manifest['config'] : []; + $normalized['manifest_version'] = (int) ($manifest['manifest_version'] ?? 1); + + return $normalized; + } + + /** + * @param mixed $value + */ + private static function normalizeNullableString(mixed $value): ?string + { + if (! is_scalar($value)) { + return null; + } + + $normalized = trim((string) $value); + + return $normalized !== '' ? $normalized : null; + } + + /** + * @param mixed $value + * @return array + */ + private static function normalizeStringList(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + $normalized = []; + foreach ($value as $item) { + $item = self::normalizeNullableString($item); + if ($item !== null) { + $normalized[] = $item; + } + } + + return array_values(array_unique($normalized)); + } + + /** + * @param mixed $value + * @return array + */ + private static function normalizePermissions(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + $permissions = []; + + if (self::isAssociative($value)) { + foreach ($value as $name => $description) { + $permissionName = self::normalizeNullableString($name); + if ($permissionName === null) { + continue; + } + + $display = self::normalizeNullableString($description) ?? self::humanizePermissionName($permissionName); + + $permissions[] = [ + 'name' => $permissionName, + 'display_name' => $display, + 'description' => self::normalizeNullableString($description) ?? $display, + 'guard_name' => 'web', + ]; + } + + return $permissions; + } + + foreach ($value as $permission) { + if (is_string($permission)) { + $permissionName = self::normalizeNullableString($permission); + if ($permissionName === null) { + continue; + } + + $permissions[] = [ + 'name' => $permissionName, + 'display_name' => self::humanizePermissionName($permissionName), + 'description' => null, + 'guard_name' => 'web', + ]; + + continue; + } + + if (! is_array($permission)) { + continue; + } + + $permissionName = self::normalizeNullableString($permission['name'] ?? null); + if ($permissionName === null) { + continue; + } + + $permissions[] = [ + 'name' => $permissionName, + 'display_name' => self::normalizeNullableString($permission['display_name'] ?? null) ?? self::humanizePermissionName($permissionName), + 'description' => self::normalizeNullableString($permission['description'] ?? null), + 'guard_name' => self::normalizeNullableString($permission['guard_name'] ?? null) ?? 'web', + ]; + } + + return $permissions; + } + + /** + * @param mixed $value + * @return array> + */ + private static function normalizeMenuItems(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + $menuItems = []; + foreach ($value as $item) { + if (! is_array($item)) { + continue; + } + + $title = self::normalizeNullableString($item['title'] ?? null); + $route = self::normalizeNullableString($item['route'] ?? null); + if ($title === null || $route === null) { + continue; + } + + $menuItems[] = [ + 'title' => $title, + 'route' => $route, + 'icon' => self::normalizeNullableString($item['icon'] ?? null), + 'permission' => self::normalizeNullableString($item['permission'] ?? null), + 'parent' => self::normalizeNullableString($item['parent'] ?? null), + 'description' => self::normalizeNullableString($item['description'] ?? null), + 'order' => isset($item['order']) && is_numeric($item['order']) ? (int) $item['order'] : 0, + 'badge' => $item['badge'] ?? null, + ]; + } + + usort($menuItems, static fn(array $left, array $right): int => ($left['order'] ?? 0) <=> ($right['order'] ?? 0)); + + return $menuItems; + } + + /** + * @param array $value + */ + private static function isAssociative(array $value): bool + { + return array_keys($value) !== range(0, count($value) - 1); + } + + private static function humanizePermissionName(string $permissionName): string + { + $label = str_replace(['.', '_', '-'], ' ', $permissionName); + + return ucfirst(trim(preg_replace('/\s+/', ' ', $label) ?? $label)); + } +}