Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.32% covered (warning)
76.32%
116 / 152
50.00% covered (danger)
50.00%
10 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
Bootstrap
76.32% covered (warning)
76.32%
116 / 152
50.00% covered (danger)
50.00%
10 / 20
95.19
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
6
 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
2
 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
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
4.06
 readCacheDir
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
4.10
 addTwigExtension
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addTwigFilter
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addTwigTemplateDirectory
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 loadRouting
50.00% covered (danger)
50.00%
6 / 12
0.00% covered (danger)
0.00%
0 / 1
4.12
 buildContainer
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
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 $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 : (getenv('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 never
103     */
104    protected function configureLocale(
105        $charset = App::CHARSET,
106        $timezone = App::TIMEZONE
107    ) {
108        ini_set('default_charset', $charset);
109        date_default_timezone_set($timezone);
110        mb_internal_encoding($charset);
111        App::$now = (! App::$now) ? new \DateTimeImmutable() : App::$now;
112    }
113
114    protected static $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)
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((string) $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 (string) $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 ($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 = (is_array(App::TEMPLATE_PATH)) ? App::TEMPLATE_PATH : [App::APP_PATH  . App::TEMPLATE_PATH];
251
252
253        if (getenv("ZMS_CUSTOM_TEMPLATES_PATH")) {
254            $customTemplatesPath = getenv("ZMS_CUSTOM_TEMPLATES_PATH");
255        }
256
257        if (is_dir($customTemplatesPath)) {
258            array_unshift($templatePaths, $customTemplatesPath);
259        }
260
261        return new Twig(
262            new FilesystemLoader($templatePaths),
263            [
264                'cache' => self::readCacheDir(),
265                'debug' => App::DEBUG,
266            ]
267        );
268    }
269
270    /**
271     * @return false|string
272     */
273    public static function readCacheDir(): string|false
274    {
275        $path = false;
276        if (App::TWIG_CACHE) {
277            $path = App::APP_PATH . App::TWIG_CACHE;
278            $userinfo = posix_getpwuid(posix_getuid());
279            $user = $userinfo['name'];
280            $githead = Git::readCurrentHash();
281            $path .= ($githead) ? '/' . $user . $githead . '/' : '/' . $user . '/';
282            if (!is_dir($path)) {
283                mkdir($path);
284                chmod($path, 0777);
285            }
286        }
287        return $path;
288    }
289
290    public static function addTwigExtension(\Twig\Extension\ExtensionInterface $extension): void
291    {
292        /** @var Twig $twig */
293        $twig = App::$slim->getContainer()->get('view');
294        $twig->addExtension($extension);
295    }
296
297    public static function addTwigFilter($filter): void
298    {
299        $twig = App::$slim->getContainer()->get('view');
300        $twig->getEnvironment()->addFilter($filter);
301    }
302
303    public static function addTwigTemplateDirectory($namespace, $path): void
304    {
305        $twig = App::$slim->getContainer()->get('view');
306        $loader = $twig->getLoader();
307        $loader->addPath($path, $namespace);
308    }
309
310    /**
311     * @return void
312     */
313    public static function loadRouting($filename)
314    {
315        $container = App::$slim->getContainer();
316        $cacheFile = static::readCacheDir();
317        if ($cacheFile) {
318            $cacheFile = $cacheFile . '/routing.cache';
319            try {
320                $container['router']->setCacheFile($cacheFile);
321            } catch (\Exception $exception) {
322                App::$log->warning('Could not write router cache file', [
323                    'cacheFile' => $cacheFile,
324                    'exception' => $exception->getMessage(),
325                ]);
326                throw $exception;
327            }
328        }
329        require($filename);
330    }
331
332    /**
333     * @return Container
334     */
335    protected function buildContainer(): Container
336    {
337        $container = new Container();
338        $container->set('debug', App::DEBUG);
339        $container->set('cache', new CacheProvider());
340        $container->set('settings', []);
341
342        // configure slim views with twig
343        $container->set('view', self::getTwigView());
344
345        $container->set('request', ServerRequestFactory::createFromGlobals());
346
347        return $container;
348    }
349}