Lines 71.97% 113 / 157
Methods 33.33% 7 / 21
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 init 100.00% 7 / 7 100.00% 1 / 1 1
 initForCli 100.00% 8 / 8 100.00% 1 / 1 5
 ensureLogger 0.00% 0 / 8 0.00% 0 / 1 42
 loggerUsesJsonFormatter 0.00% 0 / 5 0.00% 0 / 1 20
 getInstance 100.00% 2 / 2 100.00% 1 / 1 2
 configureAppStatics 50.00% 1 / 2 0.00% 0 / 1 2.50
 configureLocale 100.00% 4 / 4 100.00% 1 / 1 3
 parseDebugLevel 100.00% 1 / 1 100.00% 1 / 1 2
 normalizeLogLevelName 0.00% 0 / 6 0.00% 0 / 1 12
 isCronLogging 75.00% 3 / 4 0.00% 0 / 1 3.14
 getCronLogName 33.33% 2 / 6 0.00% 0 / 1 8.74
 configureLogger 100.00% 8 / 8 100.00% 1 / 1 2
 configureSlim 95.83% 23 / 24 0.00% 0 / 1 1
 getTwigView 87.50% 14 / 16 0.00% 0 / 1 5.05
 readCacheDir 83.33% 10 / 12 0.00% 0 / 1 8.30
 addTwigExtension 80.00% 4 / 5 0.00% 0 / 1 2.03
 addTwigFilter 80.00% 4 / 5 0.00% 0 / 1 2.03
 addTwigTemplateDirectory 85.71% 6 / 7 0.00% 0 / 1 3.03
 loadRouting 56.25% 9 / 16 0.00% 0 / 1 13.36
 buildContainer 100.00% 7 / 7 100.00% 1 / 1 1
 getenvOrDefault 0.00% 0 / 4 0.00% 0 / 1 20
26class Bootstrap
27{
28    protected static ?self $instance = null;
29
30    public static function init(): void
31    {
32        Profiler::init();
33        $bootstrap = self::getInstance();
34        $bootstrap->configureAppStatics();
35        $bootstrap->configureLogger(App::DEBUGLEVEL, App::IDENTIFIER);
36        $bootstrap->configureSlim();
37        $bootstrap->configureLocale();
38        Profiler::add("Init");
39    }
40
41    /**
42     * Logger + locale for CLI/cron without loading Slim (same JSON format as init()).
43     */
44    public static function initForCli(): void
45    {
46        $bootstrap = self::getInstance();
47        $bootstrap->configureAppStatics();
48        $level = defined('\\App::DEBUGLEVEL') ? \App::DEBUGLEVEL : self::getenvOrDefault('DEBUGLEVEL', 'INFO');
49        $identifier = defined('\\App::IDENTIFIER') ? \App::IDENTIFIER : 'zms';
50        $bootstrap->configureLogger($level, $identifier);
51        $charset = defined('\\App::CHARSET') ? \App::CHARSET : 'UTF-8';
52        $timezone = defined('\\App::TIMEZONE') ? \App::TIMEZONE : 'Europe/Berlin';
53        $bootstrap->configureLocale($charset, $timezone);
54    }
55
56    /**
57     * Guarantee App::$log for CLI/cron entrypoints (idempotent).
58     * Replaces legacy config.php loggers (stdout + LineFormatter) with JSON on stdout (CLI) or stderr (web).
59     */
60    public static function ensureLogger(): void
61    {
62        if (!class_exists('\App', false)) {
63            return;
64        }
65        if (\App::$log instanceof LoggerInterface && !(\App::$log instanceof Logger)) {
66            return;
67        }
68        if (\App::$log instanceof Logger && self::loggerUsesJsonFormatter(\App::$log)) {
69            return;
70        }
71        \App::$log = null;
72        self::initForCli();
73    }
74
75    protected static function loggerUsesJsonFormatter(Logger $log): bool
76    {
77        foreach ($log->getHandlers() as $handler) {
78            if (
79                $handler instanceof FormattableHandlerInterface
80                && $handler->getFormatter() instanceof JsonFormatter
81            ) {
82                return true;
83            }
84        }
85
86        return false;
87    }
88
89    public static function getInstance(): self
90    {
91        self::$instance = (self::$instance instanceof Bootstrap) ? self::$instance : new self();
92        return self::$instance;
93    }
94
95    protected function configureAppStatics(): void
96    {
97        if (getenv('ZMS_URL_SIGNATURE_KEY') !== false) {
98            App::$urlSignatureSecret = getenv('ZMS_URL_SIGNATURE_KEY');
99        }
100    }
101
102    /**
103     * @return void
104     */
105    protected function configureLocale(
106        string $charset = App::CHARSET,
107        string $timezone = App::TIMEZONE
108    ): void {
109        ini_set('default_charset', $charset);
110        date_default_timezone_set($timezone !== '' ? $timezone : 'Europe/Berlin');
111        mb_internal_encoding($charset);
112        App::$now = ($now = App::$now) instanceof \DateTimeInterface ? $now : new \DateTimeImmutable();
113    }
114
115    protected static array $debuglevels = array(
116        'DEBUG'     => Logger::DEBUG,
117        'INFO'      => Logger::INFO,
118        'NOTICE'    => Logger::NOTICE,
119        'WARNING'   => Logger::WARNING,
120        'ERROR'     => Logger::ERROR,
121        'CRITICAL'  => Logger::CRITICAL,
122        'ALERT'     => Logger::ALERT,
123        'EMERGENCY' => Logger::EMERGENCY,
124    );
125
126    protected function parseDebugLevel(string $level): int
127    {
128        return isset(static::$debuglevels[$level]) ? static::$debuglevels[$level] : static::$debuglevels['DEBUG'];
129    }
130
131    /**
132     * PSR-3 / Monolog method name (lowercase) for App::$log->{$level}().
133     */
134    public static function normalizeLogLevelName(string $level): string
135    {
136        $upper = strtoupper($level);
137        if ($upper === 'WARN') {
138            $upper = 'WARNING';
139        }
140        if (!isset(static::$debuglevels[$upper])) {
141            return 'info';
142        }
143
144        return strtolower($upper);
145    }
146
147    /**
148     * True when ZMS_CRON_LOG is set by cronjob.* shell entrypoints (searchable JSON field "cron").
149     */
150    public static function isCronLogging(): bool
151    {
152        $value = getenv('ZMS_CRON_LOG');
153        if ($value === false || $value === '') {
154            return false;
155        }
156
157        return !in_array(strtolower($value), ['0', 'false', 'off', 'no'], true);
158    }
159
160    /**
161     * Cron job id from ZMS_CRON_NAME (e.g. zmsapi_hourly).
162     */
163    public static function getCronLogName(): string
164    {
165        if (!static::isCronLogging()) {
166            return '';
167        }
168        $name = getenv('ZMS_CRON_NAME');
169        if ($name === false || $name === '') {
170            return '';
171        }
172
173        return $name;
174    }
175
176    protected function configureLogger(string $level, string $identifier): void
177    {
178        App::$log = new Logger($identifier);
179        $level = $this->parseDebugLevel($level);
180        // Cron/CLI: stdout so Kubernetes/CAP collectors parse JSON; web: stderr
181        $stream = PHP_SAPI === 'cli' ? 'php://stdout' : 'php://stderr';
182        $handler = new StreamHandler($stream, $level);
183        $handler->setFormatter(new JsonLogFormatter());
184        App::$log->pushHandler($handler);
185
186        App::$log = App::$log;
187
188        PhpErrorHandler::register();
189    }
190
191    protected function configureSlim(): void
192    {
193        $container = $this->buildContainer();
194
195        // instantiate slim
196        App::$slim = new SlimApp(
197            new ResponseFactory(),
198            $container
199        );
200        App::$slim->determineBasePath();
201
202        $container->set('router', App::$slim->getRouteCollector());
203
204        // Configure caching
205        App::$slim->add(new \Slim\HttpCache\Cache('public', 300));
206        App::$slim->add(new Middleware\Validator());
207        App::$slim->add('BO\Slim\Middleware\Route:getInfo');
208        App::$slim->addRoutingMiddleware();
209        App::$slim->add(new Middleware\Profiler());
210        App::$slim->add(new Middleware\IpAddress(true, true));
211        App::$slim->add(new Middleware\ZmsSlimRequest());
212        App::$slim->add(new Middleware\TrailingSlash());
213
214        $errorMiddleware = App::$slim->addErrorMiddleware(App::DEBUG, App::LOG_ERRORS, App::LOG_DETAILS, App::$log);
215        $container->set('errorMiddleware', $errorMiddleware);
216
217        self::addTwigExtension(new TwigExtensionsAndFilter(
218            $container
219        ));
220        self::addTwigExtension(new DebugExtension());
221
222        App::$slim->get('__noroute', function () {
223            throw new \Exception('Route missing');
224        })->setName('noroute');
225    }
226
227    public static function getTwigView(): Twig
228    {
229        $customTemplatesPath = 'custom_templates/';
230        $templatePaths = [App::APP_PATH . App::TEMPLATE_PATH];
231
232        $envCustomTemplatesPath = getenv('ZMS_CUSTOM_TEMPLATES_PATH');
233        if (
234            is_string($envCustomTemplatesPath)
235            && $envCustomTemplatesPath !== ''
236            && $envCustomTemplatesPath !== '0'
237        ) {
238            $customTemplatesPath = $envCustomTemplatesPath;
239        }
240
241        if (is_dir($customTemplatesPath)) {
242            array_unshift($templatePaths, $customTemplatesPath);
243        }
244
245        return new Twig(
246            new FilesystemLoader($templatePaths),
247            [
248                'cache' => self::readCacheDir(),
249                'debug' => App::DEBUG,
250            ]
251        );
252    }
253
254    /**
255     * @return false|string
256     */
257    public static function readCacheDir(): string|false
258    {
259        $path = false;
260        $cacheDir = App::TWIG_CACHE;
261        /** @psalm-suppress TypeDoesNotContainType Module App subclasses may set TWIG_CACHE to a path string. */
262        if (is_string($cacheDir) && $cacheDir !== '') {
263            $path = App::APP_PATH . $cacheDir;
264            $userinfo = posix_getpwuid(posix_getuid());
265            $user = (is_array($userinfo) && isset($userinfo['name'])) ? $userinfo['name'] : 'user';
266            $githead = Git::readCurrentHash();
267            $path .= (is_string($githead) && $githead !== '') ? '/' . $user . $githead . '/' : '/' . $user . '/';
268            if (!is_dir($path)) {
269                mkdir($path);
270                chmod($path, 0777);
271            }
272        }
273        return $path;
274    }
275
276    public static function addTwigExtension(\Twig\Extension\ExtensionInterface $extension): void
277    {
278        $container = App::$slim->getContainer();
279        if (!$container instanceof Container) {
280            throw new \RuntimeException('Slim container is not initialized');
281        }
282        /** @var Twig $twig */
283        $twig = $container->get('view');
284        $twig->addExtension($extension);
285    }
286
287    public static function addTwigFilter(\Twig\TwigFilter $filter): void
288    {
289        $container = App::$slim->getContainer();
290        if (!$container instanceof Container) {
291            throw new \RuntimeException('Slim container is not initialized');
292        }
293        $twig = $container->get('view');
294        $twig->getEnvironment()->addFilter($filter);
295    }
296
297    public static function addTwigTemplateDirectory(string $namespace, string $path): void
298    {
299        $container = App::$slim->getContainer();
300        if (!$container instanceof Container) {
301            throw new \RuntimeException('Slim container is not initialized');
302        }
303        $twig = $container->get('view');
304        $loader = $twig->getLoader();
305        if ($loader instanceof FilesystemLoader) {
306            $loader->addPath($path, $namespace);
307        }
308    }
309
310    /**
311     * @return void
312     */
313    public static function loadRouting(string $filename): void
314    {
315        $container = App::$slim->getContainer();
316        if (!$container instanceof Container) {
317            throw new \RuntimeException('Slim container is not initialized');
318        }
319        $cacheFile = static::readCacheDir();
320        if (is_string($cacheFile) && $cacheFile !== '' && $cacheFile !== '0') {
321            $cacheFile = $cacheFile . '/routing.cache';
322            try {
323                $router = $container->get('router');
324                if (is_object($router) && method_exists($router, 'setCacheFile')) {
325                    $router->setCacheFile($cacheFile);
326                }
327            } catch (\Exception $exception) {
328                App::$log->warning('Could not write router cache file', [
329                    'cacheFile' => $cacheFile,
330                    'exception' => $exception->getMessage(),
331                ]);
332                throw $exception;
333            }
334        }
335        /** @psalm-suppress UnresolvableInclude Routing files are supplied by consuming apps. */
336        require($filename);
337    }
338
339    /**
340     * @return Container
341     */
342    protected function buildContainer(): Container
343    {
344        $container = new Container();
345        $container->set('debug', App::DEBUG);
346        $container->set('cache', new CacheProvider());
347        $container->set('settings', []);
348
349        // configure slim views with twig
350        $container->set('view', self::getTwigView());
351
352        $container->set('request', ServerRequestFactory::createFromGlobals());
353
354        return $container;
355    }
356
357    private static function getenvOrDefault(string $name, string $default): string
358    {
359        $value = getenv($name);
360        if ($value === false || $value === '' || $value === '0') {
361            return $default;
362        }
363
364        return $value;
365    }
366}