addDirectory($this->appDir); $loader->addDirectory(__DIR__ . '/../Model/Entities/Enums'); $loader->rebuild(); $classes = array_keys($loader->getIndexedClasses()); $requiredResources = array_unique(array_merge( $this->findPresenterResources($classes), $this->findEnumResources($classes), )); sort($requiredResources); $existingResources = $this->getExistingResources(); $missing = array_diff($requiredResources, $existingResources); if (empty($missing)) { $io->success('All ACL resources are already present in the database.'); return Command::SUCCESS; } $io->info(sprintf('Found %d missing ACL resources:', count($missing))); foreach ($missing as $resource) { $io->writeln(' - ' . $resource); } $migrationPath = $this->generateMigration($missing); $io->success(sprintf('Migration generated: %s', $migrationPath)); return Command::SUCCESS; } /** * @param string[] $classes * @return string[] */ private function findPresenterResources(array $classes): array { $resources = []; foreach ($classes as $class) { if (!class_exists($class)) { continue; } $reflection = new ReflectionClass($class); if ($reflection->isAbstract()) { continue; } if (!$reflection->implementsInterface(AuthPresenter::class)) { continue; } $resource = $this->resolveResourceName($class); if ($resource) { $resources[] = $resource; } } return $resources; } /** * Finds resource names from string-backed enums implementing Nette\Security\Resource. * * @param string[] $classes * @return string[] */ private function findEnumResources(array $classes): array { $resources = []; foreach ($classes as $class) { if (!enum_exists($class)) { continue; } $reflection = new ReflectionEnum($class); if (!$reflection->implementsInterface(Resource::class)) { continue; } if (!$reflection->isBacked() || (string) $reflection->getBackingType() !== 'string') { continue; } foreach ($class::cases() as $case) { $resources[] = $case->value; } } return $resources; } /** * Derives ACL resource name from class namespace. * * E.g. App\UI\Portal\Backoffice\Presenters\Accounts\AccountsPresenter * → module parts: [Portal, Backoffice] → PortalBackoffice * → presenter: Accounts * → resource: portalBackoffice.accounts */ private function resolveResourceName(string $class): ?string { $parts = explode('\\', $class); // Find the last 'Presenters' segment $presentersIndex = array_search('Presenters', array_reverse($parts, true)); if ($presentersIndex === false) { return null; } // Module: everything between 'App\UI\' and 'Presenters', joined $moduleParts = array_slice($parts, 2, $presentersIndex - 2); if (empty($moduleParts)) { return null; } $module = implode('', $moduleParts); // Presenter parts: everything after 'Presenters' $presenterParts = array_slice($parts, $presentersIndex + 1); if (count($presenterParts) < 2) { return null; // Skip classes not in a subfolder (e.g. BasePresenter) } $presenterName = $presenterParts[0]; return lcfirst($module) . '.' . lcfirst($presenterName); } /** * @return string[] */ private function getExistingResources(): array { return $this->em->getConnection() ->executeQuery('SELECT name FROM acl_resource') ->fetchFirstColumn(); } /** * @param string[] $missingResources */ private function generateMigration(array $missingResources): string { $timestamp = date('YmdHis'); $className = 'Version' . $timestamp; $migrationsDir = $this->appDir . '/../migrations'; $sqlStatements = ''; foreach ($missingResources as $resource) { $escaped = addslashes($resource); $sqlStatements .= "\t\t\$this->addSql(\"INSERT IGNORE INTO acl_resource (name, title) VALUES ('$escaped', '$escaped')\");\n"; } $content = <<