getModules(); if (count($activeModules) > 0 || is_dir($runtimePageRoot) || is_link($runtimePageRoot)) { $expectedFingerprint = ModuleRuntimePageBuilder::expectedFingerprint($activeModules); $actualFingerprint = ModuleRuntimePageBuilder::readFingerprint($runtimePageRoot); if ($expectedFingerprint !== $actualFingerprint) { throw new RuntimeException( "Module runtime is stale (expected: '{$expectedFingerprint}', actual: '{$actualFingerprint}'). " . 'Run: php bin/console module:sync' ); } if (is_dir($runtimePageRoot) || is_link($runtimePageRoot)) { Router::$pageRoot = 'storage/runtime/pages/'; } } // ── 3. Routes ─────────────────────────────────────────────────────── require 'config/router.php'; // ── 4. Request context ────────────────────────────────────────────── RequestContext::start(); header('X-Request-Id: ' . RequestContext::requestHeaderValue()); Firewall::start(); // ── 5. Parse URI + detect channel ─────────────────────────────────── // Channel detection happens BEFORE session/auth so that API requests // never touch cookies, session state or remember-me flows. $originalUri = $_SERVER['REQUEST_URI'] ?? ''; $_SERVER['MINTY_ORIGINAL_URI'] = $originalUri; $localeResolver = new LocaleResolver(); $parsedUri = $localeResolver->parseUri($originalUri); $pathWithoutLocale = $parsedUri['pathWithoutLocale']; $requestedLocale = $parsedUri['locale']; $_SERVER['MINTY_LOCALE'] = $requestedLocale; RequestContext::setPath($pathWithoutLocale); if ($parsedUri['hadLocaleInUrl']) { $_SERVER['REQUEST_URI'] = $localeResolver->buildUriWithoutLocale( $pathWithoutLocale, $parsedUri['query'] ); } $isAssetRequest = AssetDetector::isAssetRequest($pathWithoutLocale); $isApiRequest = str_starts_with($pathWithoutLocale, 'api/'); RequestContext::setChannel($isApiRequest ? 'api' : 'web'); // ── 6. API channel ────────────────────────────────────────────────── // API endpoints use Bearer-token auth (ApiBootstrap). They never need // PHP sessions, remember-me cookies, locale redirects or tenant UI refresh. if ($isApiRequest) { if (!defined('MINTY_API_REQUEST')) { define('MINTY_API_REQUEST', true); } // Set default locale so t() works in API error messages. $defaultLocale = appDefaultLocale(); if ($defaultLocale !== null) { I18n::$defaultLocale = $defaultLocale; I18n::$locale = $defaultLocale; } // Minty router enforces CSRF for non-GET requests before action execution. // Our API is Bearer-token based and does not use form-CSRF, so mark only // API write requests as AJAX to bypass the router-level form CSRF gate. $requestMethod = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); $isApiWriteMethod = !in_array($requestMethod, ['GET', 'HEAD', 'OPTIONS'], true); if ($isApiWriteMethod && empty($_SERVER['HTTP_X_REQUESTED_WITH'])) { $_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest'; } } // ── 7. Web channel: session, auth, locale, tenant ─────────────────── if (!$isApiRequest) { // Start the session Session::start(); $authServicesFactory = app(AuthServicesFactory::class); $authService = $authServicesFactory->createAuthService(); $rememberMeService = $authServicesFactory->createRememberMeService(); // Initialize default locale from tenant/app settings $defaultLocale = appDefaultLocale(); if ($defaultLocale !== null) { I18n::$defaultLocale = $defaultLocale; if (I18n::$locale === '') { I18n::$locale = $defaultLocale; } } // Auto-login from remember-me cookie if (empty($_SESSION['user']['id'])) { $autoLoginResult = $rememberMeService->autoLoginFromCookie(); if ($autoLoginResult) { // The session timeout flash is no longer relevant — the user was // seamlessly re-authenticated and never saw the login page. Flash::dismissByKey('session_timeout', 'login'); // After auto-login (e.g. session timeout with active remember-me cookie), // honour ?return_to= so the user lands on the page they were on. $returnToParam = trim((string) ($_GET['return_to'] ?? '')); if ($returnToParam !== '') { $intendedUrlService = app(IntendedUrlService::class); $sanitized = $intendedUrlService->sanitize($returnToParam); if ($sanitized !== '') { $routePath = $intendedUrlService->toRoutePath($sanitized); Router::redirect($routePath); } } } } // Session timeout enforcement (idle + absolute) if (!empty($_SESSION['user']['id'])) { $now = time(); $sessionStartedAt = (int) ($_SESSION['session_started_at'] ?? 0); $sessionLastActivity = (int) ($_SESSION['session_last_activity'] ?? 0); // Gracefully handle pre-existing sessions that lack timestamps. if ($sessionStartedAt === 0) { $_SESSION['session_started_at'] = $now; $sessionStartedAt = $now; } if ($sessionLastActivity === 0) { $_SESSION['session_last_activity'] = $now; $sessionLastActivity = $now; } $sessionGateway = app(\MintyPHP\Service\Settings\SettingsSessionGateway::class); $idleTimeoutSeconds = $sessionGateway->getSessionIdleTimeoutSeconds(); $absoluteTimeoutSeconds = $sessionGateway->getSessionAbsoluteTimeoutSeconds(); $isIdle = ($now - $sessionLastActivity) > $idleTimeoutSeconds; $isAbsolute = ($now - $sessionStartedAt) > $absoluteTimeoutSeconds; if ($isIdle || $isAbsolute) { // Capture the current URL before session destruction so the user // can be redirected back after re-authentication. $intendedUrl = $_SERVER['REQUEST_URI'] ?? ''; $authService->logoutDueToSessionTimeout(); Flash::error(t('Your session has expired. Please log in again.'), 'login', 'session_timeout'); $intendedUrlService = app(IntendedUrlService::class); $locale = I18n::$locale ?? I18n::$defaultLocale; $loginTarget = $intendedUrlService->buildLoginRedirect( Request::withLocale('login', $locale), $intendedUrl ); Router::redirect($loginTarget); } $_SESSION['session_last_activity'] = $now; } // Resolve effective locale from URL > Session > Cookie > Default I18n::$locale = $localeResolver->resolveLocale( $requestedLocale, $_SESSION['user']['locale'] ?? null, $_COOKIE['locale'] ?? null ); // Refresh tenant context if stale or missing (skip for asset requests) if (!empty($_SESSION['user']['id']) && !$isAssetRequest) { $userId = (int) ($_SESSION['user']['id'] ?? 0); if ($userId > 0) { $authState = $authService->refreshSessionAuthState($userId); if (!empty($authState['logout_required'])) { $authService->logout(); $reason = (string) ($authState['reason'] ?? ''); if ($reason === 'inactive') { Flash::error(t('Account is inactive'), 'login', 'login_inactive'); } else { Flash::error(t('No active tenant assigned'), 'login', 'no_active_tenant'); } Router::redirect('login'); } $ttl = 120; $lastRefresh = (int) ($_SESSION['tenant_snapshot_at'] ?? 0); $needsRefresh = empty($_SESSION['current_tenant']) || empty($_SESSION['available_tenants']); if ((time() - $lastRefresh) >= $ttl) { $needsRefresh = true; } if ($needsRefresh) { $authService->loadTenantDataIntoSession($userId); $_SESSION['tenant_snapshot_at'] = time(); } } } // Redirect to locale-prefixed URL if missing if ($requestedLocale === '' && !$isAssetRequest) { $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); if (in_array($method, ['GET', 'HEAD'], true)) { $target = Request::withLocale($pathWithoutLocale, I18n::$locale ?? I18n::$defaultLocale); $target .= $parsedUri['query'] !== '' ? '?' . $parsedUri['query'] : ''; Router::redirect($target); } } } // ── 8. Access control guard ───────────────────────────────────────── $allPublicPaths = array_values(array_unique(array_merge( defined('APP_PUBLIC_PATHS') ? APP_PUBLIC_PATHS : [], $moduleRegistry->getPublicPaths() ))); $accessControl = new AccessControl($allPublicPaths, app(IntendedUrlService::class)); $accessControl->requireAuthOrRedirect($pathWithoutLocale, !empty($_SESSION['user']['id'])); // ── 9. Code analysis (debug only) ────────────────────────────────── if (Debugger::$enabled) { Analyzer::execute(); } // ── 10. Action execution ──────────────────────────────────────────── ob_start(); if (Router::getTemplateAction()) { require Router::getTemplateAction(); } if (ob_get_contents()) { ob_end_flush(); if (!defined('MINTY_ALLOW_OUTPUT') || !MINTY_ALLOW_OUTPUT) { // @phpstan-ignore booleanNot.alwaysFalse trigger_error('MintyPHP template action"' . Router::getTemplateAction() . '" should not send output. Error raised ', E_USER_WARNING); } } else { ob_end_clean(); } if (defined('MINTY_ALLOW_OUTPUT') && MINTY_ALLOW_OUTPUT) { // @phpstan-ignore booleanAnd.rightAlwaysTrue Session::end(); DB::close(); exit; } ob_start(); if (Router::getAction()) { extract(Router::getParameters(), EXTR_SKIP); require Router::getAction(); } if (ob_get_contents()) { ob_end_flush(); if (!defined('MINTY_ALLOW_OUTPUT') || !MINTY_ALLOW_OUTPUT) { // @phpstan-ignore booleanNot.alwaysFalse trigger_error('MintyPHP action "' . Router::getAction() . '" should not send output. Error raised ', E_USER_WARNING); } } else { ob_end_clean(); } if (defined('MINTY_ALLOW_OUTPUT') && MINTY_ALLOW_OUTPUT) { // @phpstan-ignore booleanAnd.rightAlwaysTrue Session::end(); DB::close(); exit; } // ── 11. Layout preparation (web only, before session/DB close) ────── if (Router::getTemplateView()) { $viewAuth = is_array($viewAuth ?? null) ? $viewAuth : []; $layoutAuth = $viewAuth['layout'] ?? null; if (!is_array($layoutAuth)) { $actorUserId = (int) (($_SESSION['user']['id'] ?? 0)); $viewAuth['layout'] = app(UiAccessService::class)->layoutCapabilities($actorUserId); $layoutAuth = $viewAuth['layout']; } $layoutNav = appBuildLayoutNavContext( is_array($layoutAuth) ? $layoutAuth : [], is_array($_SESSION ?? null) ? $_SESSION : [], requestInput()->queryAll() ); } // ── 12. Session & DB close ────────────────────────────────────────── Session::end(); DB::close(); // ── 13. View rendering ────────────────────────────────────────────── if (Router::getTemplateView()) { Buffer::start('html'); if (Router::getView()) { require Router::getView(); } if (Debugger::$enabled) { Debugger::toolbar(); } Buffer::end('html'); require Router::getTemplateView(); } else { if (Router::getView()) { require Router::getView(); } }