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 // --------------------------------------------------------------------------- $view = new View(APP_ROOT . '/views'); $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); $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, ); $taskCtrl = new TaskController( $pdo, $users, $sprints, $sprintWorkers, $swDays, $tasks, $taskAssign, $workers, $audit, ); $auditCtrl = new AuditController($users, $auditRepo, $view); $userCtrl = new UserController($pdo, $users, $audit, $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']), '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/new', $sprintCtrl->newForm(...)); $router->post('/sprints', $sprintCtrl->create(...)); $router->get('/sprints/{id}', $sprintCtrl->show(...)); $router->get('/sprints/{id}/settings', $sprintCtrl->settings(...)); // 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->updateWeekMax(...)); // 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(...)); // --------------------------------------------------------------------------- // 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). With Tailwind pre-compiled at image-build time and // the last inline onclick replaced by `public/assets/js/app.js`, neither // 'unsafe-inline' nor the Tailwind CDN host are needed anymore. $csp = implode('; ', [ "default-src 'self'", "script-src 'self' https://code.jquery.com", "style-src 'self' https://code.jquery.com", "img-src 'self' data:", "font-src 'self' data: https://code.jquery.com", "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(); }