safeLoad(); } $appEnv = getenv('APP_ENV') ?: 'production'; if ($appEnv !== 'production') { ini_set('display_errors', '1'); error_reporting(E_ALL); } else { ini_set('display_errors', '0'); } // --------------------------------------------------------------------------- // Migrations — cheap no-op when already current // --------------------------------------------------------------------------- try { $pdo = Connection::pdo(); (new Migrator($pdo))->migrate(); } catch (\Throwable $e) { 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; } // --------------------------------------------------------------------------- // 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); $audit = new AuditLogger($pdo); $auth = new AuthController($pdo, $users, $audit, $view); $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, ); // --------------------------------------------------------------------------- // 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')); $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->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(); $response = $router->dispatch($request); // Apply security headers to every response (spec §9). Kept here (instead of // Response::send) so the policy is visible + editable in one place. $isHttps = str_starts_with((string) (getenv('APP_BASE_URL') ?: ''), 'https://'); // Strict CSP (Phase 11 + Phase 19). Tailwind is pre-compiled at image-build // time, jQuery / jQuery UI are gone, and Alpine (CSP build), htmx, and // SortableJS are vendored under /assets/js/vendor/ — so script-src and // style-src are 'self' only, no 'unsafe-eval', no 'unsafe-inline', no CDN // hosts. font-src keeps `data:` for the few inline data-URL glyphs. $csp = implode('; ', [ "default-src 'self'", "script-src 'self'", "style-src 'self'", "img-src 'self' data:", "font-src 'self' data:", "connect-src 'self'", "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self' https://login.microsoftonline.com", ]); $response ->withHeader('X-Content-Type-Options', 'nosniff') ->withHeader('X-Frame-Options', 'DENY') ->withHeader('Referrer-Policy', 'strict-origin-when-cross-origin') ->withHeader('Content-Security-Policy', $csp); if ($isHttps) { $response->withHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); } $response->send(); // Flush the output buffer opened at the top. if (ob_get_level() > 0) { @ob_end_flush(); }