Refine module manifest discovery handling

This commit is contained in:
michele 2026-04-20 10:40:59 +00:00
parent c883063543
commit dd825b4450
2 changed files with 282 additions and 53 deletions

View File

@ -1,17 +1,16 @@
<?php <?php
namespace App\Services; namespace App\Services;
use Illuminate\Support\Facades\File; use App\Support\Modules\ModuleManifest;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use ZipArchive; use ZipArchive;
/** /**
* NetGescon Module Manager * NetGescon Module Manager
* *
* Gestisce installazione, disinstallazione e aggiornamento moduli * Gestisce installazione, disinstallazione e aggiornamento moduli
* Supporta sistema hook, permessi e menu dinamici * Supporta sistema hook, permessi e menu dinamici
*/ */
@ -31,7 +30,7 @@ public function __construct()
*/ */
protected function loadActiveModules() protected function loadActiveModules()
{ {
if (!$this->modulesTableExists()) { if (! $this->modulesTableExists()) {
$this->activeModules = []; $this->activeModules = [];
return; return;
} }
@ -58,10 +57,10 @@ public function installModule($zipPath, $validateLicense = true)
// 1. Estrai e valida // 1. Estrai e valida
$extractPath = $this->extractModule($zipPath); $extractPath = $this->extractModule($zipPath);
$config = $this->validateModuleConfig($extractPath); $config = $this->validateModuleConfig($extractPath);
// 2. Verifica licenza (se richiesta) // 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']}"); throw new \Exception("Licenza non valida per il modulo {$config['name']}");
} }
@ -97,7 +96,7 @@ public function installModule($zipPath, $validateLicense = true)
return [ return [
'success' => true, 'success' => true,
'message' => "Modulo {$config['display_name']} installato con successo", 'message' => "Modulo {$config['display_name']} installato con successo",
'module' => $config 'module' => $config,
]; ];
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error("Errore installazione modulo: " . $e->getMessage()); Log::error("Errore installazione modulo: " . $e->getMessage());
@ -109,7 +108,7 @@ public function installModule($zipPath, $validateLicense = true)
return [ return [
'success' => false, 'success' => false,
'message' => $e->getMessage() 'message' => $e->getMessage(),
]; ];
} }
} }
@ -126,12 +125,12 @@ public function uninstallModule($moduleName, $keepData = false)
// 1. Controlla dipendenze inverse // 1. Controlla dipendenze inverse
$dependentModules = $this->getDependentModules($moduleName); $dependentModules = $this->getDependentModules($moduleName);
if (!empty($dependentModules)) { if (! empty($dependentModules)) {
throw new \Exception("Impossibile disinstallare: moduli dipendenti attivi: " . implode(', ', $dependentModules)); throw new \Exception("Impossibile disinstallare: moduli dipendenti attivi: " . implode(', ', $dependentModules));
} }
// 2. Backup prima della disinstallazione // 2. Backup prima della disinstallazione
if (!$keepData) { if (! $keepData) {
$this->createBackup($moduleName); $this->createBackup($moduleName);
} }
@ -148,7 +147,7 @@ public function uninstallModule($moduleName, $keepData = false)
$this->removeMenuItems($config); $this->removeMenuItems($config);
// 7. Esegui migrazioni rollback (se non si tengono i dati) // 7. Esegui migrazioni rollback (se non si tengono i dati)
if (!$keepData) { if (! $keepData) {
$this->rollbackMigrations($moduleName); $this->rollbackMigrations($moduleName);
} }
@ -168,14 +167,14 @@ public function uninstallModule($moduleName, $keepData = false)
return [ return [
'success' => true, 'success' => true,
'message' => "Modulo {$moduleName} disinstallato con successo" 'message' => "Modulo {$moduleName} disinstallato con successo",
]; ];
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error("Errore disinstallazione modulo: " . $e->getMessage()); Log::error("Errore disinstallazione modulo: " . $e->getMessage());
return [ return [
'success' => false, 'success' => false,
'message' => $e->getMessage() 'message' => $e->getMessage(),
]; ];
} }
} }
@ -195,10 +194,10 @@ public function updateModule($moduleName, $zipPath)
// 2. Estrai nuova versione // 2. Estrai nuova versione
$extractPath = $this->extractModule($zipPath); $extractPath = $this->extractModule($zipPath);
$newConfig = $this->validateModuleConfig($extractPath); $newConfig = $this->validateModuleConfig($extractPath);
// 3. Verifica compatibilità versioni // 3. Verifica compatibilità versioni
if (!$this->isCompatibleVersion($oldConfig, $newConfig)) { if (! $this->isCompatibleVersion($oldConfig, $newConfig)) {
throw new \Exception("Versione non compatibile"); throw new \Exception("Versione non compatibile");
} }
@ -226,7 +225,7 @@ public function updateModule($moduleName, $zipPath)
'success' => true, 'success' => true,
'message' => "Modulo {$moduleName} aggiornato alla versione {$newConfig['version']}", 'message' => "Modulo {$moduleName} aggiornato alla versione {$newConfig['version']}",
'old_version' => $oldConfig['version'], 'old_version' => $oldConfig['version'],
'new_version' => $newConfig['version'] 'new_version' => $newConfig['version'],
]; ];
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error("Errore aggiornamento modulo: " . $e->getMessage()); Log::error("Errore aggiornamento modulo: " . $e->getMessage());
@ -238,7 +237,7 @@ public function updateModule($moduleName, $zipPath)
return [ return [
'success' => false, 'success' => false,
'message' => $e->getMessage() 'message' => $e->getMessage(),
]; ];
} }
} }
@ -258,16 +257,45 @@ public function getActiveModules()
*/ */
public function getAllModules() public function getAllModules()
{ {
if (!$this->modulesTableExists()) { if (! $this->modulesTableExists()) {
return collect(); return collect();
} }
return DB::table('modules')->get()->map(function ($module) { 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; 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) * Verifica licenza modulo (connessione server licenze)
*/ */
@ -279,7 +307,7 @@ public function validateLicense($config)
// TODO: Implementare verifica licenza online // TODO: Implementare verifica licenza online
$licenseServer = config('netgescon.license_server'); $licenseServer = config('netgescon.license_server');
$licenseKey = config("modules.{$config['name']}.license_key"); $licenseKey = config("modules.{$config['name']}.license_key");
// Chiamata API al server licenze // Chiamata API al server licenze
// return $this->callLicenseServer($licenseServer, $config['name'], $licenseKey); // return $this->callLicenseServer($licenseServer, $config['name'], $licenseKey);
@ -293,21 +321,21 @@ public function validateLicense($config)
*/ */
public function toggleModule($moduleName) public function toggleModule($moduleName)
{ {
if (!$this->modulesTableExists()) { if (! $this->modulesTableExists()) {
throw new \Exception('Tabella modules non disponibile'); throw new \Exception('Tabella modules non disponibile');
} }
$module = DB::table('modules')->where('name', $moduleName)->first(); $module = DB::table('modules')->where('name', $moduleName)->first();
if (!$module) { if (! $module) {
throw new \Exception("Modulo {$moduleName} non trovato"); throw new \Exception("Modulo {$moduleName} non trovato");
} }
$newStatus = $module->status === 'active' ? 'inactive' : 'active'; $newStatus = $module->status === 'active' ? 'inactive' : 'active';
DB::table('modules')->where('name', $moduleName)->update([ DB::table('modules')->where('name', $moduleName)->update([
'status' => $newStatus, 'status' => $newStatus,
'updated_at' => now() 'updated_at' => now(),
]); ]);
$this->clearModuleCache(); $this->clearModuleCache();
@ -321,10 +349,10 @@ public function toggleModule($moduleName)
private function extractModule($zipPath) private function extractModule($zipPath)
{ {
$zip = new ZipArchive; $zip = new ZipArchive;
$extractPath = storage_path('app/modules/temp/' . uniqid()); $extractPath = storage_path('app/modules/temp/' . uniqid());
if ($zip->open($zipPath) === TRUE) { if ($zip->open($zipPath) === true) {
$zip->extractTo($extractPath); $zip->extractTo($extractPath);
$zip->close(); $zip->close();
return $extractPath; return $extractPath;
@ -337,27 +365,27 @@ private function validateModuleConfig($extractPath)
{ {
$configPath = $extractPath . '/module.json'; $configPath = $extractPath . '/module.json';
if (!File::exists($configPath)) { if (! File::exists($configPath)) {
throw new \Exception("File module.json non trovato"); throw new \Exception("File module.json non trovato");
} }
$config = json_decode(File::get($configPath), true); $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"); throw new \Exception("Configurazione modulo non valida");
} }
return $config; return ModuleManifest::normalize($config);
} }
private function checkDependencies($config) private function checkDependencies($config)
{ {
if (!isset($config['dependencies'])) { if (! isset($config['dependencies'])) {
return true; return true;
} }
foreach ($config['dependencies'] as $dependency) { foreach ($config['dependencies'] as $dependency) {
if (!in_array($dependency, $this->activeModules)) { if (! in_array($dependency, $this->activeModules)) {
throw new \Exception("Dipendenza mancante: {$dependency}"); throw new \Exception("Dipendenza mancante: {$dependency}");
} }
} }
@ -367,20 +395,22 @@ private function checkDependencies($config)
private function registerModule($config) private function registerModule($config)
{ {
if (!$this->modulesTableExists()) { if (! $this->modulesTableExists()) {
throw new \Exception('Tabella modules non disponibile'); throw new \Exception('Tabella modules non disponibile');
} }
$config = ModuleManifest::normalize(is_array($config) ? $config : []);
DB::table('modules')->updateOrInsert( DB::table('modules')->updateOrInsert(
['name' => $config['name']], ['name' => $config['name']],
[ [
'name' => $config['name'], 'name' => $config['name'],
'display_name' => $config['display_name'], 'display_name' => $config['display_name'] ?? $config['name'],
'version' => $config['version'], 'version' => $config['version'],
'status' => 'active', 'status' => 'active',
'config' => json_encode($config), 'config' => json_encode($config),
'installed_at' => now(), 'installed_at' => now(),
'updated_at' => now() 'updated_at' => now(),
] ]
); );
} }
@ -399,11 +429,16 @@ private function getModuleConfig($moduleName)
{ {
$configPath = $this->modulesPath . "/{$moduleName}/module.json"; $configPath = $this->modulesPath . "/{$moduleName}/module.json";
if (!File::exists($configPath)) { if (! File::exists($configPath)) {
throw new \Exception("Configurazione modulo {$moduleName} non trovata"); 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) private function getModuleInfo($moduleName)
@ -417,15 +452,15 @@ private function getModuleInfo($moduleName)
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::warning('ModuleManager: impossibile recuperare info modulo', [ Log::warning('ModuleManager: impossibile recuperare info modulo', [
'module' => $moduleName, 'module' => $moduleName,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
} }
} }
return (object) array_merge($config, [ return (object) array_merge($config, [
'status' => $dbInfo->status ?? 'unknown', 'status' => $dbInfo->status ?? 'unknown',
'installed_at' => $dbInfo->installed_at ?? null, '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) 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; return;
} }
$permissions = $config['permissions']; $permissions = $config['permissions'];
$createdPermissions = []; $createdPermissions = [];
try { try {
@ -463,10 +500,10 @@ private function installPermissions($config)
// Crea il permesso se non esiste // Crea il permesso se non esiste
$existingPermission = \Spatie\Permission\Models\Permission::where('name', $permission['name'])->first(); $existingPermission = \Spatie\Permission\Models\Permission::where('name', $permission['name'])->first();
if (!$existingPermission) { if (! $existingPermission) {
$newPermission = \Spatie\Permission\Models\Permission::create([ $newPermission = \Spatie\Permission\Models\Permission::create([
'name' => $permission['name'], 'name' => $permission['name'],
'guard_name' => $permission['guard_name'] ?? 'web', 'guard_name' => $permission['guard_name'] ?? 'web',
'description' => $permission['description'] ?? null, 'description' => $permission['description'] ?? null,
]); ]);
@ -494,7 +531,7 @@ private function assignPermissionsToRoles($permissions)
$adminRoles = ['admin', 'amministratore', 'super-admin']; $adminRoles = ['admin', 'amministratore', 'super-admin'];
foreach ($permissions as $permission) { foreach ($permissions as $permission) {
$permName = $permission['name']; $permName = $permission['name'];
$assignToRoles = $permission['roles'] ?? $adminRoles; // Default: assegna agli admin $assignToRoles = $permission['roles'] ?? $adminRoles; // Default: assegna agli admin
foreach ($assignToRoles as $roleName) { foreach ($assignToRoles as $roleName) {
@ -502,7 +539,7 @@ private function assignPermissionsToRoles($permissions)
if ($role) { if ($role) {
// Assegna il permesso al ruolo se non ce l'ha già // Assegna il permesso al ruolo se non ce l'ha già
if (!$role->hasPermissionTo($permName)) { if (! $role->hasPermissionTo($permName)) {
$role->givePermissionTo($permName); $role->givePermissionTo($permName);
Log::info("Permesso {$permName} assegnato al ruolo {$roleName}"); Log::info("Permesso {$permName} assegnato al ruolo {$roleName}");
} }
@ -513,7 +550,7 @@ private function assignPermissionsToRoles($permissions)
private function uninstallPermissions($config) private function uninstallPermissions($config)
{ {
if (!isset($config['permissions']) || empty($config['permissions'])) { if (! isset($config['permissions']) || empty($config['permissions'])) {
return; return;
} }

View File

@ -0,0 +1,192 @@
<?php
namespace App\Support\Modules;
final class ModuleManifest
{
/**
* @param array<string, mixed> $manifest
* @return array<string, mixed>
*/
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<int, string>
*/
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<int, array{name:string,display_name:string,description:?string,guard_name:string}>
*/
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<int, array<string, mixed>>
*/
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<mixed> $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));
}
}