serverRoot = $rootDir; } /** * List all modules as unified Module objects * * @param bool $installedOnly If true, only return modules that are in the database * @param bool $enabledOnly If true, only return modules that are enabled (implies installedOnly) * @return Module[] */ public function list(bool $installedOnly = true, $enabledOnly = true): ModuleCollection { $modules = New ModuleCollection(); // Always include core module $coreModule = $this->coreModule(); if ($coreModule) { $modules['core'] = new ModuleObject($coreModule, null); } // load all modules from store $entries = $this->repository->list(); foreach ($entries as $entry) { if ($enabledOnly && !$entry->getEnabled()) { continue; // Skip disabled modules if filtering for enabled only } // instance module $handle = $entry->getHandle(); if (isset($this->moduleInstances[$entry->getHandle()])) { $modules[$handle] = new ModuleObject($this->moduleInstances[$handle], $entry); } else { $moduleInstance = $this->moduleInstance($handle, $entry->getNamespace()); $modules[$handle] = new ModuleObject($moduleInstance, $entry); $this->moduleInstances[$handle] = $moduleInstance; } } // load all modules from filesystem if ($installedOnly === false) { $discovered = $this->modulesDiscover(); foreach ($discovered as $moduleInstance) { $handle = $moduleInstance->handle(); if (!isset($modules[$handle])) { $modules[$handle] = new ModuleObject($moduleInstance, null); } } } return $modules; } public function install(string $handle): void { // First, try to find the module by scanning the filesystem $modulesDir = $this->serverRoot . '/modules'; $namespace = null; // Scan for the module by checking if handle matches any folder or module's handle() method if (is_dir($modulesDir)) { $moduleDirs = glob($modulesDir . '/*', GLOB_ONLYDIR); foreach ($moduleDirs as $moduleDir) { $testModuleFile = $moduleDir . '/lib/Module.php'; if (!file_exists($testModuleFile)) { continue; } // Extract namespace from the Module.php file $testNamespace = $this->extractNamespaceFromFile($testModuleFile); if (!$testNamespace) { continue; } // Try to instantiate with a temporary handle to check if it matches $folderName = basename($moduleDir); $testInstance = $this->moduleInstance($folderName, $testNamespace); if ($testInstance && $testInstance->handle() === $handle) { $namespace = $testNamespace; break; } } } if (!$namespace) { $this->logger->error('Module not found for installation', ['handle' => $handle]); return; } $moduleInstance = $this->moduleInstance($handle, $namespace); if (!$moduleInstance) { return; } try { $moduleInstance->install(); } catch (Exception $e) { $this->logger->error('Module installation failed: ' . $handle, [ 'exception' => [ 'code' => $e->getCode(), 'message' => $e->getMessage(), ] ]); return; } $module = new ModuleEntry(); $module->setHandle($handle); $module->setVersion($moduleInstance->version()); $module->setEnabled(false); $module->setInstalled(true); // Store the namespace we found $module->setNamespace($namespace); $this->repository->deposit($module); } public function uninstall(string $handle): void { $moduleEntry = $this->repository->fetch($handle); if (!$moduleEntry || !$moduleEntry->getInstalled()) { $this->logger->warning('Attempted to uninstall non-installed module: ' . $handle); throw new Exception('Module not installed: ' . $handle); } $moduleInstance = $this->moduleInstance($moduleEntry->getHandle(), $moduleEntry->getNamespace()); if (!$moduleInstance) { return; } try { $moduleInstance->uninstall(); } catch (Exception $e) { $this->logger->error('Module uninstallation failed: ' . $moduleEntry->getHandle(), [ 'exception' => [ 'code' => $e->getCode(), 'message' => $e->getMessage(), ] ]); return; } $this->repository->destroy($moduleEntry); } public function enable(string $handle): void { $moduleEntry = $this->repository->fetch($handle); if (!$moduleEntry || !$moduleEntry->getInstalled()) { $this->logger->warning('Attempted to uninstall non-installed module: ' . $handle); throw new Exception('Module not installed: ' . $handle); } $moduleInstance = $this->moduleInstance($moduleEntry->getHandle(), $moduleEntry->getNamespace()); if (!$moduleInstance) { return; } try { $moduleInstance->enable(); } catch (Exception $e) { $this->logger->error('Module enabling failed: ' . $moduleEntry->getHandle(), [ 'exception' => [ 'code' => $e->getCode(), 'message' => $e->getMessage(), ] ]); return; } $moduleEntry->setEnabled(true); $this->repository->deposit($moduleEntry); } public function disable(string $handle): void { $moduleEntry = $this->repository->fetch($handle); if (!$moduleEntry || !$moduleEntry->getInstalled()) { $this->logger->warning('Attempted to uninstall non-installed module: ' . $handle); throw new Exception('Module not installed: ' . $handle); } $moduleInstance = $this->moduleInstance($moduleEntry->getHandle(), $moduleEntry->getNamespace()); if (!$moduleInstance) { return; } try { $moduleInstance->disable(); } catch (Exception $e) { $this->logger->error('Module disabling failed: ' . $moduleEntry->getHandle(), [ 'exception' => [ 'code' => $e->getCode(), 'message' => $e->getMessage(), ] ]); return; } $moduleEntry->setEnabled(false); $this->repository->deposit($moduleEntry); } public function upgrade(string $handle): void { $moduleEntry = $this->repository->fetch($handle); if (!$moduleEntry || !$moduleEntry->getInstalled()) { $this->logger->warning('Attempted to uninstall non-installed module: ' . $handle); throw new Exception('Module not installed: ' . $handle); } $moduleInstance = $this->moduleInstance($moduleEntry->getHandle(), $moduleEntry->getNamespace()); if (!$moduleInstance) { return; } try { $moduleInstance->upgrade(); } catch (Exception $e) { $this->logger->error('Module upgrade failed: ' . $moduleEntry->getHandle(), [ 'exception' => [ 'code' => $e->getCode(), 'message' => $e->getMessage(), ] ]); return; } $moduleEntry->setVersion($moduleInstance->version()); $this->repository->deposit($moduleEntry); } /** * Boot all enabled modules (must be called after container is ready). */ public function modulesBoot(): void { // Only load modules that are enabled in the database $modules = $this->list(); $this->logger->debug('Booting enabled modules', ['count' => count($modules)]); foreach ($modules as $module) { $handle = $module->handle(); try { $module->boot(); $this->logger->debug('Module booted', ['handle' => $handle]); } catch (Exception $e) { $this->logger->error('Module boot failed: ' . $handle, [ 'exception' => $e, 'message' => $e->getMessage(), 'code' => $e->getCode(), ]); } } } /** * Scan filesystem for module directories and return module instances * * @return array Map of handle => ModuleInstanceInterface */ private function modulesDiscover(): array { $modules = []; $modulesDir = $this->serverRoot . '/modules'; if (!is_dir($modulesDir)) { return $modules; } // Get list of installed module handles to skip $installedHandles = []; foreach ($this->repository->list() as $entry) { $installedHandles[] = $entry->getHandle(); } // Scan for module directories $moduleDirs = glob($modulesDir . '/*', GLOB_ONLYDIR); foreach ($moduleDirs as $moduleDir) { $moduleFile = $moduleDir . '/lib/Module.php'; if (!file_exists($moduleFile)) { continue; } // Extract namespace from the Module.php file $namespace = $this->extractNamespaceFromFile($moduleFile); if (!$namespace) { $this->logger->warning('Could not extract namespace from Module.php', [ 'file' => $moduleFile ]); continue; } // Use the folder name as a temporary handle to instantiate the module $folderName = basename($moduleDir); $moduleInstance = $this->moduleInstance($folderName, $namespace); if (!$moduleInstance) { continue; } // Get the actual handle from the module instance $handle = $moduleInstance->handle(); // Skip if already installed if (in_array($handle, $installedHandles)) { continue; } // Re-cache with the correct handle if different from folder name if ($handle !== $folderName) { unset($this->moduleInstances[$folderName]); $this->moduleInstances[$handle] = $moduleInstance; } $modules[$handle] = $moduleInstance; } return $modules; } public function moduleInstance(string $handle, ?string $namespace = null): ?ModuleInstanceInterface { // Load module's vendor autoloader if it exists $this->loadModuleVendor($handle); // Return from cache if already instantiated if (isset($this->moduleInstances[$handle])) { return $this->moduleInstances[$handle]; } // Determine the namespace segment // If namespace is provided, use it; otherwise derive from handle $nsSegment = $namespace ?: $this->studly($handle); $className = 'KTXM\\' . $nsSegment . '\\Module'; if (!class_exists($className)) { $this->logger->error('Module class not found', [ 'handle' => $handle, 'namespace' => $namespace, 'resolved' => $className ]); return null; } if (!in_array(ModuleInstanceInterface::class, class_implements($className))) { $this->logger->error('Module class does not implement ModuleInstanceInterface', [ 'class' => $className ]); return null; } try { $module = $this->moduleLoad($className); } catch (Exception $e) { $this->logger->error('Failed to lazily create module instance', [ 'handle' => $handle, 'namespace' => $namespace, 'exception' => $e->getMessage() ]); return null; } // Cache by handle if ($module) { $this->moduleInstances[$handle] = $module; } return $module; } private function moduleLoad(string $className): ?ModuleInstanceInterface { try { // Use reflection to check constructor requirements $reflectionClass = new ReflectionClass($className); $constructor = $reflectionClass->getConstructor(); if (!$constructor || $constructor->getNumberOfRequiredParameters() === 0) { // Simple instantiation for modules without dependencies return new $className(); } // For modules with dependencies, try to resolve them from the container $parameters = $constructor->getParameters(); $args = []; foreach ($parameters as $parameter) { $type = $parameter->getType(); if ($type && !$type->isBuiltin()) { $typeName = $type->getName(); // Try to get service from container if ($this->container->has($typeName)) { $args[] = $this->container->get($typeName); } elseif ($parameter->isDefaultValueAvailable()) { $args[] = $parameter->getDefaultValue(); } else { // Cannot resolve dependency $this->logger->warning('Cannot resolve dependency for module: ' . $className, [ 'dependency' => $typeName ]); return null; } } elseif ($parameter->isDefaultValueAvailable()) { $args[] = $parameter->getDefaultValue(); } else { // Cannot resolve primitive dependency return null; } } return $reflectionClass->newInstanceArgs($args); } catch (Exception $e) { $this->logger->error('Failed to instantiate module: ' . $className, [ 'exception' => $e->getMessage() ]); return null; } } /** * Load a module's vendor autoloader if it has dependencies * * @param string $handle Module handle * @throws Exception If module has dependencies but vendor directory is missing */ private function loadModuleVendor(string $handle): void { $moduleDir = $this->serverRoot . '/modules/' . $handle; $composerJson = $moduleDir . '/composer.json'; $vendorAutoload = $moduleDir . '/lib/vendor/autoload.php'; // Check if module has a composer.json with dependencies if (file_exists($composerJson)) { $composerData = json_decode(file_get_contents($composerJson), true); $hasDependencies = !empty($composerData['require']) && count($composerData['require']) > 1; // More than just PHP if ($hasDependencies) { if (file_exists($vendorAutoload)) { require_once $vendorAutoload; $this->logger->debug("Loaded vendor autoloader for module: {$handle}"); } else { throw new Exception( "Module '{$handle}' declares dependencies in composer.json but vendor directory is missing. " . "Run 'composer install' in {$moduleDir}" ); } } } } private function studly(string $value): string { $value = str_replace(['-', '_'], ' ', strtolower($value)); $value = ucwords($value); return str_replace(' ', '', $value); } /** * Extract the PHP namespace from a Module.php file by parsing its contents * * @param string $moduleFilePath Absolute path to the Module.php file (located at /modules/{handle}/lib/Module.php) * @return string|null The namespace segment (e.g., 'ContactsManager' from 'KTXM\ContactsManager') */ private function extractNamespaceFromFile(string $moduleFilePath): ?string { if (!file_exists($moduleFilePath)) { return null; } $content = file_get_contents($moduleFilePath); if ($content === false) { return null; } // Match namespace declaration: namespace KTXM\; if (preg_match('/^\s*namespace\s+KTXM\\\\([a-zA-Z0-9_]+)\s*;/m', $content, $matches)) { return $matches[1]; } return null; } /** * Get all available permissions from all modules * * @return array Grouped permissions with metadata */ public function availablePermissions(): array { $permissions = []; foreach ($this->list() as $module) { $modulePermissions = $module->permissions(); foreach ($modulePermissions as $permission => $meta) { $permissions[$permission] = array_merge($meta, [ 'module' => $module->handle() ]); } } // Group by category $grouped = []; foreach ($permissions as $permission => $meta) { $group = $meta['group'] ?? 'Other'; if (!isset($grouped[$group])) { $grouped[$group] = []; } $grouped[$group][$permission] = $meta; } // Sort groups alphabetically ksort($grouped); return $grouped; } /** * Validate if a permission exists */ public function permissionExists(string $permission): bool { foreach ($this->list() as $module) { $modulePermissions = $module->permissions(); // Exact match if (isset($modulePermissions[$permission])) { return true; } // Wildcard match (e.g., user_manager.users.create matches user_manager.users.*) foreach (array_keys($modulePermissions) as $registered) { if (str_ends_with($registered, '.*')) { $prefix = substr($registered, 0, -2); if (str_starts_with($permission, $prefix . '.')) { return true; } } } } return false; } /** * Get the core module instance */ private function coreModule(): ?\KTXF\Module\ModuleInstanceInterface { if (isset($this->moduleInstances['core'])) { return $this->moduleInstances['core']; } try { $coreModuleClass = \KTXC\Module\Module::class; if (!class_exists($coreModuleClass)) { return null; } $instance = $this->container->get($coreModuleClass); $this->moduleInstances['core'] = $instance; return $instance; } catch (\Throwable $e) { $this->logger->error('Failed to load core module', [ 'error' => $e->getMessage() ]); return null; } } }