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 | ||
| 26 | class 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 | } |