|
|
@@ -7,11 +7,13 @@ 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;
|
|
|
@@ -36,6 +38,19 @@ final class ImportController
|
|
|
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,
|
|
|
@@ -43,6 +58,7 @@ final class ImportController
|
|
|
private readonly XlsxSprintImporter $parser,
|
|
|
private readonly SprintImporter $committer,
|
|
|
private readonly View $view,
|
|
|
+ private readonly AuditLogger $audit,
|
|
|
) {
|
|
|
}
|
|
|
|
|
|
@@ -99,17 +115,28 @@ final class ImportController
|
|
|
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' => array_map(fn(ParsedSheet $s) => $s->toArray(), $sheets),
|
|
|
- 'file_name' => basename($orig),
|
|
|
+ 'created_at' => time(),
|
|
|
+ 'sheets' => $sheetsArr,
|
|
|
+ 'file_name' => basename($orig),
|
|
|
+ 'payload_bytes' => $payloadBytes,
|
|
|
];
|
|
|
- $this->pruneSessionImports();
|
|
|
+ $this->pruneSessionImports($req, $actor);
|
|
|
|
|
|
return Response::redirect('/sprints/import/' . $token);
|
|
|
}
|
|
|
@@ -121,7 +148,7 @@ final class ImportController
|
|
|
return $actor;
|
|
|
}
|
|
|
$token = (string) ($params['token'] ?? '');
|
|
|
- $entry = $this->loadSessionEntry($token);
|
|
|
+ $entry = $this->loadSessionEntry($token, $req, $actor);
|
|
|
if ($entry === null) {
|
|
|
return Response::redirect('/sprints/import?error=expired');
|
|
|
}
|
|
|
@@ -165,7 +192,7 @@ final class ImportController
|
|
|
return Response::text('CSRF token invalid', 403);
|
|
|
}
|
|
|
$token = (string) ($params['token'] ?? '');
|
|
|
- $entry = $this->loadSessionEntry($token);
|
|
|
+ $entry = $this->loadSessionEntry($token, $req, $actor);
|
|
|
if ($entry === null) {
|
|
|
return Response::redirect('/sprints/import?error=expired');
|
|
|
}
|
|
|
@@ -241,7 +268,7 @@ final class ImportController
|
|
|
// ------------------------------------------------------------------ utils
|
|
|
|
|
|
/** @return array{sheets: list<array<string,mixed>>, file_name: string, created_at: int}|null */
|
|
|
- private function loadSessionEntry(string $token): ?array
|
|
|
+ private function loadSessionEntry(string $token, Request $req, User $actor): ?array
|
|
|
{
|
|
|
if (!preg_match('/^[0-9a-f]{32}$/', $token)) {
|
|
|
return null;
|
|
|
@@ -254,13 +281,19 @@ final class ImportController
|
|
|
$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(): void
|
|
|
+ private function pruneSessionImports(Request $req, User $actor): void
|
|
|
{
|
|
|
SessionGuard::start();
|
|
|
$bag = $_SESSION[self::SESSION_KEY] ?? [];
|
|
|
@@ -269,12 +302,42 @@ final class ImportController
|
|
|
}
|
|
|
$cutoff = time() - self::TTL_SECONDS;
|
|
|
foreach ($bag as $tok => $row) {
|
|
|
- if (!is_array($row) || (int) ($row['created_at'] ?? 0) < $cutoff) {
|
|
|
+ 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
|
|
|
{
|
|
|
@@ -360,6 +423,56 @@ final class ImportController
|
|
|
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)) {
|