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()
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()
89    {
90        self::$instance = (self::$instance instanceof Bootstrap) ? self::$instance : new self();
91        return self::$instance;
92    }
93
94    protected function configureAppStatics()
95    {
96        if (getenv('ZMS_URL_SIGNATURE_KEY') !== false) {
97            App::$urlSignatureSecret = getenv('ZMS_URL_SIGNATURE_KEY');
98        }
99    }
100
101    protected function configureLocale(
102        $charset = App::CHARSET,
103        $timezone = App::TIMEZONE
104    ) {
105        ini_set('default_charset', $charset);
106        date_default_timezone_set($timezone);
107        mb_internal_encoding($charset);
108        App::$now = (! App::$now) ? new \DateTimeImmutable() : App::$now;
109    }
110
111    protected static $debuglevels = array(
112        'DEBUG'     => Logger::DEBUG,
113        'INFO'      => Logger::INFO,
114        'NOTICE'    => Logger::NOTICE,
115        'WARNING'   => Logger::WARNING,
116        'ERROR'     => Logger::ERROR,
117        'CRITICAL'  => Logger::CRITICAL,
118        'ALERT'     => Logger::ALERT,
119        'EMERGENCY' => Logger::EMERGENCY,
120    );
121
122    protected function parseDebugLevel($level)
123    {
124        return isset(static::$debuglevels[$level]) ? static::$debuglevels[$level] : static::$debuglevels['DEBUG'];
125    }
126
127    /**
128     * PSR-3 / Monolog method name (lowercase) for App::$log->{$level}().
129     */
130    public static function normalizeLogLevelName(string $level): string
131    {
132        $upper = strtoupper($level);
133        if ($upper === 'WARN') {
134            $upper = 'WARNING';
135        }
136        if (!isset(static::$debuglevels[$upper])) {
137            return 'info';
138        }
139
140        return strtolower($upper);
141    }
142
143    /**
144     * True when ZMS_CRON_LOG is set by cronjob.* shell entrypoints (searchable JSON field "cron").
145     */
146    public static function isCronLogging(): bool
147    {
148        $value = getenv('ZMS_CRON_LOG');
149        if ($value === false || $value === '') {
150            return false;
151        }
152
153        return !in_array(strtolower((string) $value), ['0', 'false', 'off', 'no'], true);
154    }
155
156    /**
157     * Cron job id from ZMS_CRON_NAME (e.g. zmsapi_hourly).
158     */
159    public static function getCronLogName(): string
160    {
161        if (!static::isCronLogging()) {
162            return '';
163        }
164        $name = getenv('ZMS_CRON_NAME');
165        if ($name === false || $name === '') {
166            return '';
167        }
168
169        return (string) $name;
170    }
171
172    protected function configureLogger(string $level, string $identifier): void
173    {
174        App::$log = new Logger($identifier);
175        $level = $this->parseDebugLevel($level);
176        // Cron/CLI: stdout so Kubernetes/CAP collectors parse JSON; web: stderr
177        $stream = PHP_SAPI === 'cli' ? 'php://stdout' : 'php://stderr';
178        $handler = new StreamHandler($stream, $level);
179
180        $formatter = new JsonFormatter();
181
182        // Add processor to format time_local first
183        App::$log->pushProcessor(function ($record) {
184            return array(
185                'time_local' => (new \DateTime())->format('Y-m-d\TH:i:sP'),
186                'client_ip' => $_SERVER['REMOTE_ADDR'] ?? '',
187                'remote_addr' => $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '',
188                'remote_user' => '',
189                'application' => defined('\\App::IDENTIFIER') ? App::IDENTIFIER : 'zms',
190                'module' => defined('\\App::MODULE_NAME') ? App::MODULE_NAME : 'zmsslim',
191                'cron' => static::isCronLogging(),
192                'cron_name' => static::getCronLogName(),
193                'message' => $record['message'],
194                'level' => $record['level_name'],
195                'context' => $record['context'],
196                'extra' => $record['extra']
197            );
198        });
199
200        $handler->setFormatter($formatter);
201        App::$log->pushHandler($handler);
202
203        App::$log = App::$log;
204
205        PhpErrorHandler::register();
206    }
207
208    protected function configureSlim()
209    {
210        $container = $this->buildContainer();
211
212        // instantiate slim
213        App::$slim = new SlimApp(
214            new ResponseFactory(),
215            $container
216        );
217        App::$slim->determineBasePath();
218
219        $container->set('router', App::$slim->getRouteCollector());
220
221        // Configure caching
222        App::$slim->add(new \Slim\HttpCache\Cache('public', 300));
223        App::$slim->add(new Middleware\Validator());
224        App::$slim->add('BO\Slim\Middleware\Route:getInfo');
225        App::$slim->addRoutingMiddleware();
226        App::$slim->add(new Middleware\Profiler());
227        App::$slim->add(new Middleware\IpAddress(true, true));
228        App::$slim->add(new Middleware\ZmsSlimRequest());
229        App::$slim->add(new Middleware\TrailingSlash());
230
231        $errorMiddleware = App::$slim->addErrorMiddleware(App::DEBUG, App::LOG_ERRORS, App::LOG_DETAILS, App::$log);
232        $container->set('errorMiddleware', $errorMiddleware);
233
234        self::addTwigExtension(new TwigExtensionsAndFilter(
235            $container
236        ));
237        self::addTwigExtension(new DebugExtension());
238
239        App::$slim->get('__noroute', function () {
240            throw new \Exception('Route missing');
241        })->setName('noroute');
242    }
243
244    public static function getTwigView(): Twig
245    {
246        $customTemplatesPath = 'custom_templates/';
247        $templatePaths = (is_array(App::TEMPLATE_PATH)) ? App::TEMPLATE_PATH : [App::APP_PATH  . App::TEMPLATE_PATH];
248
249
250        if (getenv("ZMS_CUSTOM_TEMPLATES_PATH")) {
251            $customTemplatesPath = getenv("ZMS_CUSTOM_TEMPLATES_PATH");
252        }
253
254        if (is_dir($customTemplatesPath)) {
255            array_unshift($templatePaths, $customTemplatesPath);
256        }
257
258        return new Twig(
259            new FilesystemLoader($templatePaths),
260            [
261                'cache' => self::readCacheDir(),
262                'debug' => App::DEBUG,
263            ]
264        );
265    }
266
267    public static function readCacheDir()
268    {
269        $path = false;
270        if (App::TWIG_CACHE) {
271            $path = App::APP_PATH . App::TWIG_CACHE;
272            $userinfo = posix_getpwuid(posix_getuid());
273            $user = $userinfo['name'];
274            $githead = Git::readCurrentHash();
275            $path .= ($githead) ? '/' . $user . $githead . '/' : '/' . $user . '/';
276            if (!is_dir($path)) {
277                mkdir($path);
278                chmod($path, 0777);
279            }
280        }
281        return $path;
282    }
283
284    public static function addTwigExtension($extension)
285    {
286        /** @var Twig $twig */
287        $twig = App::$slim->getContainer()->get('view');
288        $twig->addExtension($extension);
289    }
290
291    public static function addTwigFilter($filter)
292    {
293        $twig = App::$slim->getContainer()->get('view');
294        $twig->getEnvironment()->addFilter($filter);
295    }
296
297    public static function addTwigTemplateDirectory($namespace, $path)
298    {
299        $twig = App::$slim->getContainer()->get('view');
300        $loader = $twig->getLoader();
301        $loader->addPath($path, $namespace);
302    }
303
304    public static function loadRouting($filename)
305    {
306        $container = App::$slim->getContainer();
307        $cacheFile = static::readCacheDir();
308        if ($cacheFile) {
309            $cacheFile = $cacheFile . '/routing.cache';
310            try {
311                $container['router']->setCacheFile($cacheFile);
312            } catch (\Exception $exception) {
313                App::$log->warning('Could not write router cache file', [
314                    'cacheFile' => $cacheFile,
315                    'exception' => $exception->getMessage(),
316                ]);
317                throw $exception;
318            }
319        }
320        require($filename);
321    }
322
323    /**
324     * @return Container
325     */
326    protected function buildContainer(): Container
327    {
328        $container = new Container();
329        $container->set('debug', App::DEBUG);
330        $container->set('cache', new CacheProvider());
331        $container->set('settings', []);
332
333        // configure slim views with twig
334        $container->set('view', self::getTwigView());
335
336        $container->set('request', ServerRequestFactory::createFromGlobals());
337
338        return $container;
339    }
340}