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