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