| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517 |
- <?php
- /*
- * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
- * SPDX-License-Identifier: Apache-2.0
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * See the LICENSE file in the project root for the full license text.
- */
- declare(strict_types=1);
- namespace App\Controllers;
- use App\Auth\SessionGuard;
- use App\Domain\Import\ImportResult;
- use App\Domain\Import\ParsedSheet;
- use App\Domain\User;
- use App\Http\Request;
- use App\Http\Response;
- use App\Http\View;
- use App\Repositories\SprintRepository;
- use App\Repositories\UserRepository;
- use App\Services\AuditLogger;
- use App\Services\Import\SprintImporter;
- use App\Services\Import\XlsxSprintImporter;
- use PDO;
- use Throwable;
- /**
- * Phase 20 — Two-step XLSX → Sprint import wizard.
- *
- * GET /sprints/import upload form
- * POST /sprints/import parse, stash in session, redirect to preview
- * GET /sprints/import/{token} preview + per-sheet target picker
- * POST /sprints/import/{token} commit selected sheets in one TX each,
- * redirect to the first sprint with a flash.
- *
- * The uploaded XLSX is parsed once and the structured result is stored in
- * the session under a random token; the file itself is not persisted to disk.
- * Tokens expire after 30 minutes.
- */
- final class ImportController
- {
- public const SESSION_KEY = 'sp_imports';
- private const TTL_SECONDS = 1800;
- private const MAX_FILE_BYTES = 5 * 1024 * 1024;
- /**
- * R01-N14: hard cap on the JSON-encoded preview blob we stash in
- * `$_SESSION` between the upload and the commit step. The 5 MB
- * upload cap (`MAX_FILE_BYTES`) bounds raw bytes coming in, but
- * parsed XLSX expansion is unbounded — a hostile workbook with
- * many tabs could blow the session file size. Two megabytes is
- * roomy for a real planning workbook (the production
- * `Tool_Sprint Planning` one rarely exceeds a few hundred KB
- * serialised) and small enough that on-disk session IO stays
- * snappy.
- */
- public const MAX_SESSION_PAYLOAD_BYTES = 2 * 1024 * 1024;
- public function __construct(
- private readonly PDO $pdo,
- private readonly UserRepository $users,
- private readonly SprintRepository $sprints,
- private readonly XlsxSprintImporter $parser,
- private readonly SprintImporter $committer,
- private readonly View $view,
- private readonly AuditLogger $audit,
- ) {
- }
- public function newForm(Request $req): Response
- {
- $actor = SessionGuard::requireAdmin($this->users);
- if ($actor instanceof Response) {
- return $actor;
- }
- return Response::html($this->view->render('sprints/import_upload', [
- 'title' => 'Import sprints',
- 'currentUser' => $actor,
- 'csrfToken' => SessionGuard::csrfToken(),
- 'error' => $req->queryString('error'),
- ]));
- }
- public function upload(Request $req): Response
- {
- $actor = SessionGuard::requireAdmin($this->users);
- if ($actor instanceof Response) {
- return $actor;
- }
- if (!SessionGuard::verifyCsrf($req)) {
- return Response::text('CSRF token invalid', 403);
- }
- // Validate the upload up-front; fail-closed.
- if (!isset($_FILES['xlsx']) || !is_array($_FILES['xlsx'])) {
- return Response::redirect('/sprints/import?error=no_file');
- }
- $file = $_FILES['xlsx'];
- if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
- $code = self::uploadErrorCode((int) ($file['error'] ?? UPLOAD_ERR_NO_FILE));
- return Response::redirect('/sprints/import?error=' . $code);
- }
- $size = (int) ($file['size'] ?? 0);
- if ($size <= 0 || $size > self::MAX_FILE_BYTES) {
- return Response::redirect('/sprints/import?error=size');
- }
- $tmp = (string) ($file['tmp_name'] ?? '');
- if ($tmp === '' || !is_uploaded_file($tmp)) {
- return Response::redirect('/sprints/import?error=upload_invalid');
- }
- $orig = (string) ($file['name'] ?? '');
- if (!self::looksLikeXlsx($orig, $tmp)) {
- return Response::redirect('/sprints/import?error=not_xlsx');
- }
- try {
- $sheets = $this->parser->parse($tmp);
- } catch (Throwable) {
- return Response::redirect('/sprints/import?error=parse_failed');
- }
- // R01-N14: enforce the per-token serialised cap. We encode once
- // for the size check and stash the array form (cheap to read,
- // no decode hop on preview/commit). A parse that explodes past
- // the cap is rejected outright; nothing lands in the session.
- $sheetsArr = array_map(fn(ParsedSheet $s) => $s->toArray(), $sheets);
- $payloadBytes = self::encodedPayloadBytes($sheetsArr);
- if ($payloadBytes > self::MAX_SESSION_PAYLOAD_BYTES) {
- return Response::redirect('/sprints/import?error=too_large_payload');
- }
- $token = bin2hex(random_bytes(16));
- SessionGuard::start();
- if (!isset($_SESSION[self::SESSION_KEY]) || !is_array($_SESSION[self::SESSION_KEY])) {
- $_SESSION[self::SESSION_KEY] = [];
- }
- $_SESSION[self::SESSION_KEY][$token] = [
- 'created_at' => time(),
- 'sheets' => $sheetsArr,
- 'file_name' => basename($orig),
- 'payload_bytes' => $payloadBytes,
- ];
- $this->pruneSessionImports($req, $actor);
- return Response::redirect('/sprints/import/' . $token);
- }
- public function preview(Request $req, array $params): Response
- {
- $actor = SessionGuard::requireAdmin($this->users);
- if ($actor instanceof Response) {
- return $actor;
- }
- $token = (string) ($params['token'] ?? '');
- $entry = $this->loadSessionEntry($token, $req, $actor);
- if ($entry === null) {
- return Response::redirect('/sprints/import?error=expired');
- }
- $sheets = array_map(
- static fn(array $arr) => ParsedSheet::fromArray($arr),
- $entry['sheets'],
- );
- // Existing-sprint candidates for the per-sheet target picker: only
- // empty sprints (no weeks/workers/tasks) qualify. Spec §plan §2.
- $emptySprints = $this->emptySprintCandidates();
- // Pre-compute summary stats for the diff panel.
- $summaries = [];
- $existingWorkerFolds = $this->existingWorkerFolds();
- foreach ($sheets as $sheet) {
- $summaries[] = $this->summarise($sheet, $existingWorkerFolds);
- }
- return Response::html($this->view->render('sprints/import_preview', [
- 'title' => 'Import preview',
- 'currentUser' => $actor,
- 'csrfToken' => SessionGuard::csrfToken(),
- 'token' => $token,
- 'fileName' => (string) ($entry['file_name'] ?? ''),
- 'sheets' => $sheets,
- 'summaries' => $summaries,
- 'emptySprints' => $emptySprints,
- 'error' => $req->queryString('error'),
- ]));
- }
- public function commit(Request $req, array $params): Response
- {
- $actor = SessionGuard::requireAdmin($this->users);
- if ($actor instanceof Response) {
- return $actor;
- }
- if (!SessionGuard::verifyCsrf($req)) {
- return Response::text('CSRF token invalid', 403);
- }
- $token = (string) ($params['token'] ?? '');
- $entry = $this->loadSessionEntry($token, $req, $actor);
- if ($entry === null) {
- return Response::redirect('/sprints/import?error=expired');
- }
- $sheets = array_map(
- static fn(array $arr) => ParsedSheet::fromArray($arr),
- $entry['sheets'],
- );
- $results = [];
- foreach ($sheets as $idx => $sheet) {
- $skip = $req->postString("skip_{$idx}");
- if ($skip === '1') {
- continue;
- }
- $name = $req->postString("name_{$idx}");
- $startDate = $req->postString("start_{$idx}");
- $endDate = $req->postString("end_{$idx}");
- $target = $req->postString("target_{$idx}");
- $existing = $req->postString("existing_{$idx}");
- $existingId = ($target === 'existing' && $existing !== '' && ctype_digit($existing))
- ? (int) $existing
- : null;
- if ($name === '') {
- $name = $sheet->sheetName;
- }
- if ($startDate === '') {
- $startDate = $sheet->inferredStartDate ?? '';
- }
- if ($endDate === '') {
- $endDate = $sheet->inferredEndDate ?? '';
- }
- try {
- $results[] = $this->committer->commit(
- sheet: $sheet,
- sprintName: $name,
- startDate: $startDate,
- endDate: $endDate,
- target: $target === 'existing' ? 'existing' : 'new',
- existingSprintId: $existingId,
- req: $req,
- actor: $actor,
- );
- } catch (Throwable $e) {
- // Bail on first failure: prior sheets in this loop already
- // committed, but each is a self-contained transaction so the
- // user keeps the partial progress and can re-try the rest.
- SessionGuard::start();
- $_SESSION['flash_import_error'] = sprintf(
- 'Sheet "%s" failed: %s',
- $sheet->sheetName,
- $e->getMessage(),
- );
- return Response::redirect('/sprints/import/' . $token . '?error=commit');
- }
- }
- // Drop the session entry; the wizard is done.
- if (isset($_SESSION[self::SESSION_KEY][$token])) {
- unset($_SESSION[self::SESSION_KEY][$token]);
- }
- if ($results === []) {
- return Response::redirect('/sprints/import?error=nothing_selected');
- }
- // Land on the first imported sprint with an audible flash.
- SessionGuard::start();
- $_SESSION['flash_import_summary'] = self::summariseResults($results);
- return Response::redirect('/sprints/' . $results[0]->sprintId);
- }
- // ------------------------------------------------------------------ utils
- /** @return array{sheets: list<array<string,mixed>>, file_name: string, created_at: int}|null */
- private function loadSessionEntry(string $token, Request $req, User $actor): ?array
- {
- if (!preg_match('/^[0-9a-f]{32}$/', $token)) {
- return null;
- }
- SessionGuard::start();
- $bag = $_SESSION[self::SESSION_KEY] ?? [];
- if (!is_array($bag) || !isset($bag[$token]) || !is_array($bag[$token])) {
- return null;
- }
- $entry = $bag[$token];
- $createdAt = (int) ($entry['created_at'] ?? 0);
- if ($createdAt + self::TTL_SECONDS < time()) {
- // R01-N14: emit IMPORT_PREVIEW_ABANDONED for the token the
- // user just tried to use. They'll be redirected to the
- // upload form with `?error=expired`; the audit row makes the
- // expiry visible in `/audit` instead of disappearing
- // silently.
- $this->recordAbandonedImport($entry, $req, $actor);
- unset($_SESSION[self::SESSION_KEY][$token]);
- return null;
- }
- return $entry;
- }
- private function pruneSessionImports(Request $req, User $actor): void
- {
- SessionGuard::start();
- $bag = $_SESSION[self::SESSION_KEY] ?? [];
- if (!is_array($bag)) {
- return;
- }
- $cutoff = time() - self::TTL_SECONDS;
- foreach ($bag as $tok => $row) {
- if (!is_array($row)) {
- unset($_SESSION[self::SESSION_KEY][$tok]);
- continue;
- }
- if ((int) ($row['created_at'] ?? 0) < $cutoff) {
- // R01-N14: token aged out without a commit. Same audit
- // row as in `loadSessionEntry`.
- $this->recordAbandonedImport($row, $req, $actor);
- unset($_SESSION[self::SESSION_KEY][$tok]);
- }
- }
- }
- /**
- * R01-N14: write an IMPORT_PREVIEW_ABANDONED audit row for a
- * preview token that expired before being committed. `entity_type`
- * is `import_token` because no DB row backs the preview blob; the
- * row carries enough metadata (file name, age, sheet count,
- * payload size) for an admin reviewing `/audit` to reconstruct
- * what was abandoned.
- *
- * @param array<string,mixed> $entry
- */
- private function recordAbandonedImport(array $entry, Request $req, User $actor): void
- {
- $this->audit->recordForRequest(
- action: 'IMPORT_PREVIEW_ABANDONED',
- entityType: 'import_token',
- entityId: null,
- before: null,
- after: self::abandonedAuditPayload($entry, time()),
- req: $req,
- actor: $actor,
- );
- }
- /** @return list<array{id:int,name:string,startDate:string,endDate:string}> */
- private function emptySprintCandidates(): array
- {
- $stmt = $this->pdo->query(
- 'SELECT s.id, s.name, s.start_date, s.end_date
- FROM sprints s
- WHERE NOT EXISTS (SELECT 1 FROM sprint_weeks WHERE sprint_id = s.id)
- AND NOT EXISTS (SELECT 1 FROM sprint_workers WHERE sprint_id = s.id)
- AND NOT EXISTS (SELECT 1 FROM tasks WHERE sprint_id = s.id)
- ORDER BY s.start_date DESC, s.id DESC'
- );
- $out = [];
- foreach ($stmt as $row) {
- $out[] = [
- 'id' => (int) $row['id'],
- 'name' => (string) $row['name'],
- 'startDate' => (string) $row['start_date'],
- 'endDate' => (string) $row['end_date'],
- ];
- }
- return $out;
- }
- /** @return array<string,bool> case-folded name => true */
- private function existingWorkerFolds(): array
- {
- $stmt = $this->pdo->query('SELECT name FROM workers');
- $out = [];
- foreach ($stmt as $row) {
- $out[SprintImporter::fold((string) $row['name'])] = true;
- }
- return $out;
- }
- /**
- * @param array<string,bool> $existingFolds
- * @return array<string,mixed>
- */
- private function summarise(ParsedSheet $sheet, array $existingFolds): array
- {
- $newWorkers = [];
- foreach ($sheet->workers as $w) {
- if (!isset($existingFolds[SprintImporter::fold($w->name)])) {
- $newWorkers[] = $w->name;
- }
- }
- $cells = 0;
- $byStatus = [
- 'zugewiesen' => 0,
- 'gestartet' => 0,
- 'abgeschlossen' => 0,
- 'abgebrochen' => 0,
- ];
- foreach ($sheet->tasks as $t) {
- foreach ($t->assignments as $a) {
- if ($a->days > 0) {
- $cells++;
- }
- $byStatus[$a->status] = ($byStatus[$a->status] ?? 0) + 1;
- }
- }
- return [
- 'newWorkers' => $newWorkers,
- 'workerCount' => count($sheet->workers),
- 'taskCount' => count($sheet->tasks),
- 'weekCount' => count($sheet->weeks),
- 'cellCount' => $cells,
- 'statusCounts' => $byStatus,
- ];
- }
- /** @param list<ImportResult> $results */
- private static function summariseResults(array $results): string
- {
- $parts = [];
- foreach ($results as $r) {
- $bits = ["{$r->workerCount} workers", "{$r->taskCount} tasks", "{$r->assignmentCellCount} cells"];
- if ($r->createdWorkers !== []) {
- $bits[] = count($r->createdWorkers) . ' new workers';
- }
- $parts[] = "“{$r->sprintName}” imported (" . implode(', ', $bits) . ').';
- }
- return implode(' ', $parts);
- }
- /**
- * R01-N14: serialised-byte estimate for a `ParsedSheet[]::toArray()`
- * payload. Pure, so the cap can be unit-tested without a real
- * upload + session round-trip.
- *
- * Encoding mirrors `AuditLogger::encodeJson`: UTF-8 plain, no
- * escaping of slashes — what the session bag *would* serialise to
- * if we were JSON-encoding it. PHP's session serialiser is
- * different (PHP-serialized format) but the JSON byte length is a
- * reliable proxy and matches the contract recorded in REVIEW_01.
- *
- * @param array<int,array<string,mixed>> $sheetsArr
- */
- public static function encodedPayloadBytes(array $sheetsArr): int
- {
- $json = json_encode(
- $sheetsArr,
- JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
- );
- return $json === false ? 0 : strlen($json);
- }
- /**
- * R01-N14: payload for the IMPORT_PREVIEW_ABANDONED audit row.
- * Keeps just enough metadata to reconstruct what was lost without
- * persisting any user task content.
- *
- * @param array<string,mixed> $entry the session entry as stashed
- * by `upload()`
- * @return array<string,mixed>
- */
- public static function abandonedAuditPayload(array $entry, int $now): array
- {
- $createdAt = (int) ($entry['created_at'] ?? 0);
- $sheets = (array) ($entry['sheets'] ?? []);
- // Missing `created_at` reads as 0; without this guard the
- // computed age would be the full unix-epoch offset (~50 yr),
- // which is alarming noise in `/audit` for what is just a
- // malformed entry. Same shape clamps clock-skew "future" rows
- // to 0 instead of negative.
- $age = $createdAt > 0 ? max(0, $now - $createdAt) : 0;
- return [
- 'file_name' => (string) ($entry['file_name'] ?? ''),
- 'sheet_count' => count($sheets),
- 'payload_bytes' => (int) ($entry['payload_bytes'] ?? 0),
- 'age_seconds' => $age,
- 'created_at' => $createdAt > 0 ? gmdate('Y-m-d\TH:i:s\Z', $createdAt) : '',
- ];
- }
- private static function looksLikeXlsx(string $origName, string $tmpPath): bool
- {
- if (!preg_match('/\.xlsx$/i', $origName)) {
- return false;
- }
- // XLSX is a ZIP container; the file's first 4 bytes start with PK\x03\x04 or PK\x05\x06.
- $fh = @fopen($tmpPath, 'rb');
- if ($fh === false) {
- return false;
- }
- $head = (string) fread($fh, 4);
- fclose($fh);
- if (strlen($head) !== 4) {
- return false;
- }
- return $head[0] === 'P' && $head[1] === 'K'
- && (($head[2] === "\x03" && $head[3] === "\x04")
- || ($head[2] === "\x05" && $head[3] === "\x06"));
- }
- private static function uploadErrorCode(int $code): string
- {
- return match ($code) {
- UPLOAD_ERR_INI_SIZE,
- UPLOAD_ERR_FORM_SIZE => 'too_big',
- UPLOAD_ERR_PARTIAL => 'partial',
- UPLOAD_ERR_NO_FILE => 'no_file',
- UPLOAD_ERR_NO_TMP_DIR,
- UPLOAD_ERR_CANT_WRITE,
- UPLOAD_ERR_EXTENSION => 'server',
- default => 'unknown',
- };
- }
- }
|