Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
96.92% |
126 / 130 |
|
80.00% |
4 / 5 |
CRAP | |
0.00% |
0 / 1 |
| SecurityAndTemplateConfigurator | |
96.90% |
125 / 129 |
|
80.00% |
4 / 5 |
30 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
63 / 63 |
|
100.00% |
1 / 1 |
3 | |||
| initSession | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
7 | |||
| initTwigEnvironment | |
82.61% |
19 / 23 |
|
0.00% |
0 / 1 |
7.26 | |||
| resolveAppLogo | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
| loadCoreSettings | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
9 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Core\Bootstrap\Configurator; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Core\Audit\Application\Service\AuditDataSanitizer; |
| 12 | use App\Core\Audit\Application\Service\AuditIntegrityService; |
| 13 | use App\Core\Audit\Application\Service\AuditRetentionService; |
| 14 | use App\Core\Audit\Application\Service\SecurityAuditLogger; |
| 15 | use App\Core\Audit\Infrastructure\Repository\SqlSecurityAuditRepository; |
| 16 | use App\Core\Audit\Presentation\Api\AuditApiController; |
| 17 | use App\Core\Profile\Application\Service\ProfileManager; |
| 18 | use App\Core\Profile\Domain\Model\Profile; |
| 19 | use App\Core\Profile\Infrastructure\Repository\FilesystemProfileRegistry; |
| 20 | use App\Core\Security\Csrf\CsrfTokenManagerInterface; |
| 21 | use App\Core\Security\Csrf\TwigCsrfExtension; |
| 22 | use App\Core\Security\Csrf\YiiSessionCsrfTokenManager; |
| 23 | use App\Core\Security\Middleware\CsrfMiddleware; |
| 24 | use App\Core\Security\Middleware\SecurityHeadersMiddleware; |
| 25 | use App\Core\Session\SqlSessionHandler; |
| 26 | use App\Modules\Structure\Infrastructure\Repository\SqlStructureRepository; |
| 27 | use App\Shared\Infrastructure\Config\InstallerConfigLoader; |
| 28 | use Nyholm\Psr7\Factory\Psr17Factory; |
| 29 | use PDO; |
| 30 | use Twig\Environment as TwigEnvironment; |
| 31 | use Twig\Loader\FilesystemLoader; |
| 32 | use Yiisoft\Aliases\Aliases; |
| 33 | use Yiisoft\Assets\AssetLoader; |
| 34 | use Yiisoft\Assets\AssetManager; |
| 35 | use Yiisoft\Session\Session; |
| 36 | use Yiisoft\Session\SessionInterface; |
| 37 | use Yiisoft\Session\SessionMiddleware; |
| 38 | |
| 39 | /** |
| 40 | * SecurityAndTemplateConfigurator. |
| 41 | * |
| 42 | * Initializes session management, CSRF protection, security headers, audit services, and Twig environment. |
| 43 | * |
| 44 | * @package App\Core\Bootstrap\Configurator |
| 45 | */ |
| 46 | final readonly class SecurityAndTemplateConfigurator |
| 47 | { |
| 48 | private const string PUBLIC_DIR_SUFFIX = '/public'; |
| 49 | |
| 50 | public SessionInterface $session; |
| 51 | public SessionMiddleware $sessionMiddleware; |
| 52 | public SecurityHeadersMiddleware $securityHeadersMiddleware; |
| 53 | public CsrfTokenManagerInterface $csrfTokenManager; |
| 54 | public CsrfMiddleware $csrfMiddleware; |
| 55 | public TwigCsrfExtension $twigCsrfExtension; |
| 56 | public \App\Core\Security\Csp\TwigCspExtension $twigCspExtension; |
| 57 | public SqlSecurityAuditRepository $securityAuditRepo; |
| 58 | public SecurityAuditLogger $securityAuditLogger; |
| 59 | public AuditIntegrityService $auditIntegrityService; |
| 60 | public AuditRetentionService $auditRetentionService; |
| 61 | public AuditApiController $auditApiController; |
| 62 | public ProfileManager $profileManager; |
| 63 | public Profile $activeProfile; |
| 64 | public TwigEnvironment $twig; |
| 65 | public \App\Core\Security\Twig\TwigUserExtension $twigUserExtension; |
| 66 | public \App\Modules\User\Application\Service\UserImpersonationService $impersonationService; |
| 67 | public \App\Modules\User\Infrastructure\Repository\SqlUserRepository $userRepository; |
| 68 | |
| 69 | public function __construct( |
| 70 | string $basePath, |
| 71 | PDO $pdo, |
| 72 | string $prefix, |
| 73 | string $appEnv, |
| 74 | bool $appDebug, |
| 75 | Psr17Factory $psr17Factory |
| 76 | ) { |
| 77 | $this->session = $this->initSession($pdo, $prefix); |
| 78 | |
| 79 | $this->securityAuditRepo = new SqlSecurityAuditRepository($pdo, $prefix); |
| 80 | $this->securityAuditLogger = new SecurityAuditLogger($this->securityAuditRepo, new AuditDataSanitizer()); |
| 81 | $this->auditIntegrityService = new AuditIntegrityService($pdo, prefix: $prefix); |
| 82 | $this->auditRetentionService = new AuditRetentionService($pdo, $prefix); |
| 83 | $this->auditApiController = new AuditApiController( |
| 84 | $psr17Factory, |
| 85 | $this->auditIntegrityService, |
| 86 | $this->securityAuditRepo, |
| 87 | $this->auditRetentionService |
| 88 | ); |
| 89 | |
| 90 | $this->sessionMiddleware = new SessionMiddleware($this->session); |
| 91 | $cspNonceManager = new \App\Core\Security\Csp\CspNonceManager(); |
| 92 | $this->securityHeadersMiddleware = new SecurityHeadersMiddleware($appDebug, $cspNonceManager); |
| 93 | $this->csrfTokenManager = new YiiSessionCsrfTokenManager($this->session); |
| 94 | $this->csrfMiddleware = new CsrfMiddleware( |
| 95 | $this->csrfTokenManager, |
| 96 | $psr17Factory, |
| 97 | $this->securityAuditLogger |
| 98 | ); |
| 99 | $this->twigCsrfExtension = new TwigCsrfExtension($this->csrfTokenManager); |
| 100 | $this->twigCspExtension = new \App\Core\Security\Csp\TwigCspExtension($cspNonceManager); |
| 101 | |
| 102 | $registry = new FilesystemProfileRegistry($basePath); |
| 103 | $this->profileManager = new ProfileManager($registry, $pdo, $prefix); |
| 104 | $this->activeProfile = $this->profileManager->resolveActiveProfile(); |
| 105 | $appProfile = $this->activeProfile->name; |
| 106 | |
| 107 | $settings = $this->loadCoreSettings($pdo, $prefix); |
| 108 | $this->twig = $this->initTwigEnvironment($basePath, $appProfile, $appDebug); |
| 109 | |
| 110 | $publicPath = $basePath . self::PUBLIC_DIR_SUFFIX; |
| 111 | $assetManager = new \App\Core\Asset\YiiAssetManager($publicPath); |
| 112 | $aliases = new Aliases(['@root' => $publicPath]); |
| 113 | $yiiLoader = new AssetLoader($aliases); |
| 114 | $yiiAssetManager = new AssetManager($aliases, $yiiLoader); |
| 115 | $this->twig->addExtension(new \App\Core\Asset\TwigAssetExtension($assetManager, $yiiAssetManager)); |
| 116 | $this->twig->addExtension($this->twigCsrfExtension); |
| 117 | $this->twig->addExtension($this->twigCspExtension); |
| 118 | |
| 119 | $userPrefix = ($appProfile === 'client') ? 'c_' : $prefix; |
| 120 | $this->userRepository = new \App\Modules\User\Infrastructure\Repository\SqlUserRepository($pdo, $userPrefix); |
| 121 | $impersonationRepo = new \App\Modules\User\Infrastructure\Repository\SqlUserImpersonationRepository( |
| 122 | $pdo, |
| 123 | $userPrefix |
| 124 | ); |
| 125 | $this->impersonationService = new \App\Modules\User\Application\Service\UserImpersonationService( |
| 126 | $impersonationRepo, |
| 127 | $this->userRepository |
| 128 | ); |
| 129 | $this->twigUserExtension = new \App\Core\Security\Twig\TwigUserExtension( |
| 130 | $this->session, |
| 131 | $this->userRepository, |
| 132 | $this->impersonationService |
| 133 | ); |
| 134 | $this->twig->addExtension($this->twigUserExtension); |
| 135 | |
| 136 | $this->twig->addGlobal('app_env', $appEnv); |
| 137 | $this->twig->addGlobal('app_debug', $appDebug); |
| 138 | $this->twig->addGlobal('app_profile', $appProfile); |
| 139 | $this->twig->addGlobal('app_name', $settings['appName']); |
| 140 | |
| 141 | $structurePrefix = ($appProfile === 'client') ? 'c_' : $prefix; |
| 142 | $structureRepo = new SqlStructureRepository($pdo, null, $structurePrefix); |
| 143 | $appLogo = $this->resolveAppLogo($structureRepo, $publicPath); |
| 144 | |
| 145 | $this->twig->addGlobal('app_logo', $appLogo); |
| 146 | $this->twig->addGlobal('record_sections_display_mode', $settings['recordSectionsDisplayMode']); |
| 147 | $this->twig->addGlobal('mail_sync_interval', $settings['mailSyncInterval']); |
| 148 | $this->twig->addGlobal('system_version', '1.0.0'); |
| 149 | } |
| 150 | |
| 151 | private function initSession(PDO $pdo, string $prefix): Session |
| 152 | { |
| 153 | $isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') |
| 154 | || (string)($_SERVER['SERVER_PORT'] ?? '') === '443' |
| 155 | || strtolower((string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https'; |
| 156 | |
| 157 | $isClient = InstallerConfigLoader::isClientContext(); |
| 158 | $sessionCookieName = $isClient ? 'AMMONLY_CLIENT_SESSID' : 'AMMONLY_ADMIN_SESSID'; |
| 159 | $sessionPrefix = $isClient ? 'c_' : $prefix; |
| 160 | |
| 161 | $sessionHandler = new SqlSessionHandler($pdo, $sessionPrefix); |
| 162 | return new Session( |
| 163 | [ |
| 164 | 'name' => $sessionCookieName, |
| 165 | 'cookie_secure' => $isHttps ? 1 : 0, |
| 166 | 'cookie_httponly' => 1, |
| 167 | 'cookie_samesite' => 'Lax', |
| 168 | 'use_strict_mode' => 1, |
| 169 | ], |
| 170 | $sessionHandler |
| 171 | ); |
| 172 | } |
| 173 | |
| 174 | private function initTwigEnvironment( |
| 175 | string $basePath, |
| 176 | string $appProfile, |
| 177 | bool $appDebug |
| 178 | ): TwigEnvironment { |
| 179 | $templatePaths = []; |
| 180 | if ($this->activeProfile->hasViews()) { |
| 181 | $templatePaths[] = $this->activeProfile->viewsPath; |
| 182 | } |
| 183 | $profileLegacyViews = $basePath . '/resources/themes/tabler/profiles/' . $appProfile . '/views'; |
| 184 | if (is_dir($profileLegacyViews)) { |
| 185 | $templatePaths[] = $profileLegacyViews; |
| 186 | } |
| 187 | $templatePaths[] = $basePath . '/resources/themes/tabler/views'; |
| 188 | $templatePaths[] = $basePath . '/resources/themes/tabler'; |
| 189 | $templatePaths[] = $basePath . '/resources/templates'; |
| 190 | |
| 191 | $loader = new FilesystemLoader($templatePaths); |
| 192 | if ($this->activeProfile->hasViews()) { |
| 193 | $loader->addPath($this->activeProfile->viewsPath, 'profile'); |
| 194 | } |
| 195 | $loader->addPath($basePath . '/resources/themes/tabler', 'tabler'); |
| 196 | $loader->addPath($basePath . '/resources/templates', 'templates'); |
| 197 | |
| 198 | $twigCachePath = $basePath . '/storage/cache/twig'; |
| 199 | if (!is_dir($twigCachePath)) { |
| 200 | @mkdir($twigCachePath, 0755, true); |
| 201 | } |
| 202 | $useTwigCache = is_dir($twigCachePath) && is_writable($twigCachePath) ? $twigCachePath : false; |
| 203 | |
| 204 | return new TwigEnvironment($loader, [ |
| 205 | 'cache' => $useTwigCache, |
| 206 | 'debug' => $appDebug, |
| 207 | 'auto_reload' => true, |
| 208 | ]); |
| 209 | } |
| 210 | |
| 211 | private function resolveAppLogo(SqlStructureRepository $structureRepo, string $publicPath): ?string |
| 212 | { |
| 213 | $highestLogo = $structureRepo->findHighestHierarchyLogo(); |
| 214 | if ($highestLogo === null || $highestLogo === '') { |
| 215 | return null; |
| 216 | } |
| 217 | $logoPath = (string)parse_url($highestLogo, PHP_URL_PATH); |
| 218 | $logoFsPath = $publicPath . $logoPath; |
| 219 | $version = file_exists($logoFsPath) ? (string)filemtime($logoFsPath) : '1'; |
| 220 | return $highestLogo . '?v=' . $version; |
| 221 | } |
| 222 | |
| 223 | /** |
| 224 | * Loads core visual and runtime settings from database with safe fallback defaults. |
| 225 | * |
| 226 | * @return array{appName: string, recordSectionsDisplayMode: string, mailSyncInterval: int} |
| 227 | */ |
| 228 | private function loadCoreSettings(PDO $pdo, string $prefix): array |
| 229 | { |
| 230 | $settings = [ |
| 231 | 'appName' => 'Ammonly Admin', |
| 232 | 'recordSectionsDisplayMode' => 'icons_only', |
| 233 | 'mailSyncInterval' => 120, |
| 234 | ]; |
| 235 | |
| 236 | try { |
| 237 | $stmt = $pdo->prepare( |
| 238 | "SELECT `setting_key`, `setting_value` FROM `{$prefix}core_settings_records` " . |
| 239 | "WHERE `setting_key` IN ('app_name', 'record_sections_display_mode', 'mail_sync_interval')" |
| 240 | ); |
| 241 | $stmt->execute(); |
| 242 | $rows = $stmt->fetchAll(PDO::FETCH_KEY_PAIR); |
| 243 | |
| 244 | if (isset($rows['app_name']) && is_string($rows['app_name']) && $rows['app_name'] !== '') { |
| 245 | $settings['appName'] = $rows['app_name']; |
| 246 | } |
| 247 | if (isset($rows['record_sections_display_mode']) && is_string($rows['record_sections_display_mode'])) { |
| 248 | $settings['recordSectionsDisplayMode'] = $rows['record_sections_display_mode']; |
| 249 | } |
| 250 | if (isset($rows['mail_sync_interval']) && is_numeric($rows['mail_sync_interval'])) { |
| 251 | $settings['mailSyncInterval'] = max(15, (int) $rows['mail_sync_interval']); |
| 252 | } |
| 253 | } catch (\Throwable) { |
| 254 | // Keep default fallback |
| 255 | } |
| 256 | |
| 257 | return $settings; |
| 258 | } |
| 259 | } |