Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.92% covered (success)
96.92%
126 / 130
80.00% covered (warning)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
SecurityAndTemplateConfigurator
96.90% covered (success)
96.90%
125 / 129
80.00% covered (warning)
80.00%
4 / 5
30
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
63 / 63
100.00% covered (success)
100.00%
1 / 1
3
 initSession
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
7
 initTwigEnvironment
82.61% covered (warning)
82.61%
19 / 23
0.00% covered (danger)
0.00%
0 / 1
7.26
 resolveAppLogo
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 loadCoreSettings
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
9
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Bootstrap\Configurator;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Audit\Application\Service\AuditDataSanitizer;
12use App\Core\Audit\Application\Service\AuditIntegrityService;
13use App\Core\Audit\Application\Service\AuditRetentionService;
14use App\Core\Audit\Application\Service\SecurityAuditLogger;
15use App\Core\Audit\Infrastructure\Repository\SqlSecurityAuditRepository;
16use App\Core\Audit\Presentation\Api\AuditApiController;
17use App\Core\Profile\Application\Service\ProfileManager;
18use App\Core\Profile\Domain\Model\Profile;
19use App\Core\Profile\Infrastructure\Repository\FilesystemProfileRegistry;
20use App\Core\Security\Csrf\CsrfTokenManagerInterface;
21use App\Core\Security\Csrf\TwigCsrfExtension;
22use App\Core\Security\Csrf\YiiSessionCsrfTokenManager;
23use App\Core\Security\Middleware\CsrfMiddleware;
24use App\Core\Security\Middleware\SecurityHeadersMiddleware;
25use App\Core\Session\SqlSessionHandler;
26use App\Modules\Structure\Infrastructure\Repository\SqlStructureRepository;
27use App\Shared\Infrastructure\Config\InstallerConfigLoader;
28use Nyholm\Psr7\Factory\Psr17Factory;
29use PDO;
30use Twig\Environment as TwigEnvironment;
31use Twig\Loader\FilesystemLoader;
32use Yiisoft\Aliases\Aliases;
33use Yiisoft\Assets\AssetLoader;
34use Yiisoft\Assets\AssetManager;
35use Yiisoft\Session\Session;
36use Yiisoft\Session\SessionInterface;
37use 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 */
46final 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}