safeLoad(); } $appEnv = getenv('APP_ENV') ?: 'production'; if ($appEnv !== 'production') { ini_set('display_errors', '1'); error_reporting(E_ALL); } else { ini_set('display_errors', '0'); } // --------------------------------------------------------------------------- // R01-N13: install the fatal-error safety net AS EARLY AS POSSIBLE — before // migrations, before service wiring. An uncaught throwable from anywhere // below now produces a minimal 500 page with full security headers instead // of leaking whatever was buffered. We re-register later (with the resolved // $isHttps) to flip the HSTS bit, but having the handler installed up-front // covers fatals during bootstrap (e.g. broken migration, missing class). FatalErrorHandler::register($appEnv, false); // --------------------------------------------------------------------------- // R01-N22: schema sanity check (NOT migration). // // The request path never applies SQL — `bin/migrate.php` (run by the Docker // entrypoint or by the operator at deploy time) is the only path that can // move the schema forward. Here we only OPEN the connection and verify there // are no pending migration files. If there are, refuse to serve with 503 so // stale-schema serving cannot happen silently after a forgotten deploy step. // --------------------------------------------------------------------------- try { $pdo = Connection::pdo(); $pending = (new Migrator($pdo))->pendingFiles(); } catch (\Throwable $e) { error_log('database bootstrap failed: ' . $e->getMessage()); http_response_code(500); header('Content-Type: text/plain; charset=utf-8'); echo "Database bootstrap failed.\n"; if ($appEnv !== 'production') { echo $e->getMessage() . "\n"; } exit; } if ($pending !== []) { error_log( 'schema out of date — pending migrations: ' . implode(', ', $pending) . '. Run `php bin/migrate.php` to apply.' ); http_response_code(503); header('Content-Type: text/plain; charset=utf-8'); header('Retry-After: 30'); echo "Service Unavailable: database schema is out of date.\n"; if ($appEnv !== 'production') { echo "Pending migrations: " . implode(', ', $pending) . "\n"; echo "Run: php bin/migrate.php\n"; } exit; } // --------------------------------------------------------------------------- // Shared services // --------------------------------------------------------------------------- $twigCacheDir = APP_ROOT . '/data/twig-cache'; if (!is_dir($twigCacheDir)) { @mkdir($twigCacheDir, 0775, true); } $view = new View(APP_ROOT . '/views', $twigCacheDir); $users = new UserRepository($pdo); $workers = new WorkerRepository($pdo); $sprints = new SprintRepository($pdo); $sprintWeeks = new SprintWeekRepository($pdo); $sprintWorkers = new SprintWorkerRepository($pdo); $swDays = new SprintWorkerDayRepository($pdo); $tasks = new TaskRepository($pdo); $taskAssign = new TaskAssignmentRepository($pdo); $auditRepo = new AuditRepository($pdo); $appSettings = new AppSettingsRepository($pdo); $authThrottle = new AuthThrottleRepository($pdo); $audit = new AuditLogger($pdo); $auth = new AuthController($pdo, $users, $audit, $view, $authThrottle); $workerCtrl = new WorkerController($pdo, $users, $workers, $audit, $view); $sprintCtrl = new SprintController( $pdo, $users, $sprints, $sprintWeeks, $sprintWorkers, $swDays, $tasks, $taskAssign, $workers, $audit, $view, $appSettings, ); $taskCtrl = new TaskController( $pdo, $users, $sprints, $sprintWorkers, $swDays, $tasks, $taskAssign, $workers, $audit, $appSettings, ); $auditCtrl = new AuditController($users, $auditRepo, $view); $userCtrl = new UserController($pdo, $users, $audit, $view); $settingsCtrl = new SettingsController($pdo, $users, $appSettings, $audit, $view); $xlsxParser = new XlsxSprintImporter(); $importCommit = new SprintImporter( $pdo, $sprints, $sprintWeeks, $sprintWorkers, $swDays, $tasks, $taskAssign, $workers, $audit, ); $importCtrl = new ImportController( $pdo, $users, $sprints, $xlsxParser, $importCommit, $view, $audit, ); $cspReportCtrl = new CspReportController($audit); // --------------------------------------------------------------------------- // Routing // --------------------------------------------------------------------------- $router = new Router(); $router->get('/', function (Request $req) use ($view, $pdo, $users, $sprints, $appEnv): Response { $currentUser = SessionGuard::currentUser($users); $schemaVersion = (int) $pdo->query( 'SELECT COALESCE(MAX(version), 0) FROM schema_version' )->fetchColumn(); $sprintRows = $currentUser === null ? [] : $sprints->allWithCounts(); return Response::html($view->render('home', [ 'title' => 'Sprint Planner', 'currentUser' => $currentUser, 'schemaVersion' => $schemaVersion, 'dbPath' => Connection::path(), 'appEnv' => $appEnv, 'oidcConfigured' => OidcClient::isConfigured(), 'localAdminEnabled' => LocalAdmin::isEnabled(), 'authError' => isset($req->query['auth_error']), 'deletedSprintName' => $req->queryString('deleted'), 'csrfToken' => SessionGuard::csrfToken(), 'sprintRows' => $sprintRows, ])); }); $router->get('/healthz', fn() => Response::text('ok')); // R01-N19: browser-fired CSP violation reports. Public POST (no auth, no // CSRF — see CspReportController). Body capped at 16 KiB; one audit row // per accepted report. $router->post('/csp-report', $cspReportCtrl->report(...)); $router->get('/auth/login', $auth->login(...)); $router->get('/auth/callback', $auth->callback(...)); $router->post('/auth/logout', $auth->logout(...)); $router->get('/auth/local', $auth->loginLocalForm(...)); $router->post('/auth/local', $auth->loginLocal(...)); $router->get('/workers', $workerCtrl->index(...)); $router->post('/workers', $workerCtrl->create(...)); $router->post('/workers/{id}', $workerCtrl->update(...)); $router->get('/users', $userCtrl->index(...)); $router->post('/users/{id}', $userCtrl->update(...)); $router->post('/users/{id}/tombstone', $userCtrl->tombstone(...)); $router->get('/sprints/import', $importCtrl->newForm(...)); $router->post('/sprints/import', $importCtrl->upload(...)); $router->get('/sprints/import/{token}', $importCtrl->preview(...)); $router->post('/sprints/import/{token}', $importCtrl->commit(...)); $router->get('/sprints/new', $sprintCtrl->newForm(...)); $router->post('/sprints', $sprintCtrl->create(...)); $router->get('/sprints/{id}', $sprintCtrl->show(...)); $router->get('/sprints/{id}/present', $sprintCtrl->present(...)); $router->get('/sprints/{id}/settings', $sprintCtrl->settings(...)); $router->post('/sprints/{id}/delete', $sprintCtrl->delete(...)); // JSON mutation endpoints (admin, CSRF via X-CSRF-Token header): $router->patch('/sprints/{id}', $sprintCtrl->updateMeta(...)); $router->post('/sprints/{id}/weeks', $sprintCtrl->replaceWeeks(...)); $router->post('/sprints/{id}/workers', $sprintCtrl->addWorker(...)); $router->delete('/sprints/{id}/workers/{sw_id}', $sprintCtrl->removeWorker(...)); $router->post('/sprints/{id}/workers/reorder', $sprintCtrl->reorderWorkers(...)); $router->patch('/sprints/{id}/workers/{sw_id}', $sprintCtrl->updateWorker(...)); // Phase 5 — Arbeitstage grid: $router->patch('/sprints/{id}/week-cells', $sprintCtrl->updateWeekCells(...)); $router->patch('/sprints/{id}/week/{week_id}', $sprintCtrl->updateWeekDays(...)); // Phase 6 — Task list: $router->get('/audit', $auditCtrl->index(...)); $router->post('/sprints/{id}/tasks', $taskCtrl->create(...)); $router->post('/sprints/{id}/tasks/reorder', $taskCtrl->reorder(...)); $router->patch('/tasks/{id}', $taskCtrl->update(...)); $router->delete('/tasks/{id}', $taskCtrl->delete(...)); $router->patch('/tasks/{id}/assignments', $taskCtrl->updateAssignments(...)); // Phase 18 — task-cell status (any signed-in user, gated by global flag): $router->patch('/tasks/{id}/assignments/status', $taskCtrl->updateAssignmentsStatus(...)); // Phase 22 — task move/copy across sprints (admin): $router->post('/tasks/{id}/move', $taskCtrl->moveToSprint(...)); $router->post('/tasks/{id}/copy', $taskCtrl->copyToSprint(...)); // Phase 18 — global app settings (admin): $router->get('/settings', $settingsCtrl->show(...)); $router->post('/settings', $settingsCtrl->update(...)); // --------------------------------------------------------------------------- // Dispatch // --------------------------------------------------------------------------- $request = Request::fromGlobals(); // R01-N05: when `APP_BASE_URL` declares HTTPS, refuse to serve sensitive // flows over plain HTTP — redirect the user to the canonical scheme before // any controller, session, or auth logic runs. `/healthz` is exempt so // liveness probes continue to work over either scheme. The decision uses // the trusted-proxy helper so a TLS-terminating reverse proxy can pass // `X-Forwarded-Proto: https` and the app will treat the request as secure. $baseUrl = (string) (getenv('APP_BASE_URL') ?: ''); $baseIsHttps = str_starts_with($baseUrl, 'https://'); $proxies = TrustedProxies::fromEnv(); $requestIsHttps = $proxies->isHttps($_SERVER); // Only redirect when we can be SURE the live request is genuinely HTTP — // otherwise a TLS proxy that forgot to set `X-Forwarded-Proto` would loop // forever (proxy talks HTTPS to user, talks HTTP to us, we redirect, …). // Sure cases: // * no `TRUSTED_PROXIES` configured → REMOTE_ADDR is the user, so the // server-side scheme is authoritative; // * `TRUSTED_PROXIES` configured AND REMOTE_ADDR is a trusted proxy AND // it explicitly told us `X-Forwarded-Proto: http`. $xfpRaw = (string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''); $xfp = strtolower(trim(strtok($xfpRaw, ',') ?: '')); $remote = (string) ($_SERVER['REMOTE_ADDR'] ?? ''); $noProxy = getenv('TRUSTED_PROXIES') === false || trim((string) getenv('TRUSTED_PROXIES')) === ''; $knownHttp = !$requestIsHttps && ( $noProxy || ($remote !== '' && $proxies->isTrusted($remote) && $xfp === 'http') ); if ($baseIsHttps && $knownHttp && $request->path !== '/healthz') { // Cross-origin (scheme-changing) redirect — must go through // Response::external() because Response::redirect() only accepts // path-only locations now (R01-N20). $target = rtrim($baseUrl, '/') . ($_SERVER['REQUEST_URI'] ?? '/'); Response::external($target, 308)->send(); if (ob_get_level() > 0) { @ob_end_flush(); } exit; } // R01-N13: now that we know the resolved HTTPS posture, re-register the // fatal handler so a fatal mid-dispatch lands HSTS too. Cheap; just a // closure replacement. FatalErrorHandler::register($appEnv, $baseIsHttps); $response = $router->dispatch($request); // Apply security headers to every response (spec §9). Sourced from the // FatalErrorHandler so the happy path and the 500-fallback share a single // CSP + header set — there's no way for a future edit to drift between // the two paths. foreach (FatalErrorHandler::securityHeaders($baseIsHttps) as $name => $value) { $response->withHeader($name, $value); } $response->send(); // Flush the output buffer opened at the top. if (ob_get_level() > 0) { @ob_end_flush(); }