From c2233c16395bd5cb3cc54e647adb25b5999b163e Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Mon, 6 Jul 2026 21:21:17 -0500 Subject: [PATCH 01/12] feat: add shared layout template --- src/Views/layout.php | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/Views/layout.php diff --git a/src/Views/layout.php b/src/Views/layout.php new file mode 100644 index 0000000..d8addef --- /dev/null +++ b/src/Views/layout.php @@ -0,0 +1,34 @@ +` content (e.g. a per-template script tag). + */ +function render_layout(callable $body, string $csrf, string $title, string $extraHead = ''): void +{ + $e = static fn (string $v): string => Escape::html($v); + $a = static fn (string $v): string => Escape::attr($v); + ?> + + + + + <?= $e($title) ?> + + + + + + + + + Date: Mon, 6 Jul 2026 21:30:17 -0500 Subject: [PATCH 02/12] feat: add home and team editor templates --- src/Views/home.php | 19 +++++++++ src/Views/team-editor.php | 87 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 src/Views/home.php create mode 100644 src/Views/team-editor.php diff --git a/src/Views/home.php b/src/Views/home.php new file mode 100644 index 0000000..81143f2 --- /dev/null +++ b/src/Views/home.php @@ -0,0 +1,19 @@ + Escape::html($v); + $a = static fn (string $v): string => Escape::attr($v); + ?> +

BattleForge

+

New scenario

+

Recent scenarios

+

No saved scenarios yet.

+ + $old Optional old form values, keyed by field name; HTML-escaped on output. + * @var array $post Raw form data (for repopulating per-unit fields). + */ +$e = static fn (string $v): string => Escape::html($v); +$a = static fn (string $v): string => Escape::attr($v); +$archetypes = ArchetypeCatalog::templates(); +?> +

Team editor

+ +

+ +
+ +
+ Scenario + + + + +
+
+ Team A + +
+ + + + + + + + + + +
+ +
+
+ Team B + +
+ + + + + + + + + + +
+ +
+
+ Victory + + + +
+ +
+ Date: Mon, 6 Jul 2026 21:37:55 -0500 Subject: [PATCH 03/12] feat: add battlefield, match stub, and upload result templates --- src/Views/battlefield-editor.php | 56 ++++++++++++++++++++++++++++++++ src/Views/match-stub.php | 28 ++++++++++++++++ src/Views/upload-result.php | 26 +++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 src/Views/battlefield-editor.php create mode 100644 src/Views/match-stub.php create mode 100644 src/Views/upload-result.php diff --git a/src/Views/battlefield-editor.php b/src/Views/battlefield-editor.php new file mode 100644 index 0000000..e61211d --- /dev/null +++ b/src/Views/battlefield-editor.php @@ -0,0 +1,56 @@ + Escape::html($v); +$a = static fn (string $v): string => Escape::attr($v); +$terrain = ['open', 'forest', 'rough', 'water', 'blocking']; +?> +

Battlefield editor

+

Scenario:

+
+ +
+ Battlefield + + +
+
+ Terrain palette + + + +
+
+ Grid ( × ) + + + + + + + + +
+
+
+ Deployment zones + + + +
+ +
+ + Escape::html($v); +$matchInfo = $matchJson !== null ? "Match loaded." : "No match in progress."; +?> +

Match

+

+ +
+ Match state (debug) +
+
+ +

Battle interface coming in Plan 4.

+

Back to home

+ Escape::html($v); +$json = json_encode(['url' => $url], JSON_THROW_ON_ERROR); +?> +

Uploaded:

+ + Date: Mon, 6 Jul 2026 21:54:59 -0500 Subject: [PATCH 04/12] feat: add team editor GET and POST handlers --- src/Http/Handlers/GetTeamEditor.php | 29 ++++++ src/Http/Handlers/PostTeamEditor.php | 86 ++++++++++++++++++ tests/Integration/GetTeamEditorTest.php | 29 ++++++ tests/Integration/PostTeamEditorTest.php | 107 +++++++++++++++++++++++ 4 files changed, 251 insertions(+) create mode 100644 src/Http/Handlers/GetTeamEditor.php create mode 100644 src/Http/Handlers/PostTeamEditor.php create mode 100644 tests/Integration/GetTeamEditorTest.php create mode 100644 tests/Integration/PostTeamEditorTest.php diff --git a/src/Http/Handlers/GetTeamEditor.php b/src/Http/Handlers/GetTeamEditor.php new file mode 100644 index 0000000..0e73d23 --- /dev/null +++ b/src/Http/Handlers/GetTeamEditor.php @@ -0,0 +1,29 @@ + $params */ + public function handle(Request $request, array $params): Response + { + $csrf = $request->cookies['__csrf'] ?? ''; + $scenarioId = $params['id'] ?? 'new'; + + $error = null; + $old = []; + $post = []; + + ob_start(); + require_once __DIR__ . '/../../Views/layout.php'; + require __DIR__ . '/../../Views/team-editor.php'; + $body = (string) ob_get_clean(); + + return Response::html(200, $body); + } +} diff --git a/src/Http/Handlers/PostTeamEditor.php b/src/Http/Handlers/PostTeamEditor.php new file mode 100644 index 0000000..6eb286c --- /dev/null +++ b/src/Http/Handlers/PostTeamEditor.php @@ -0,0 +1,86 @@ + $params */ + public function handle(Request $request, array $params): Response + { + $submitted = (string) ($request->post['_csrf'] ?? ''); + $expected = (string) ($request->cookies['__csrf'] ?? ''); + $secret = (string) ($request->server['__csrf_secret'] ?? ''); + + if ($secret === '' || $expected === '' || !CsrfToken::verify($submitted, $secret, $expected)) { + return Response::html(403, '

Forbidden

'); + } + + try { + $draft = ScenarioDraft::fromPost($request->post); + $scenario = $draft->toScenario(); + ScenarioValidator::validate($scenario); + } catch (\InvalidArgumentException $exception) { + return $this->renderForm($request, $params['id'] ?? 'new', $request->cookies['__csrf'] ?? '', $exception->getMessage(), $request->post); + } + + $json = json_encode($this->scenarioToArray($scenario), JSON_THROW_ON_ERROR); + $url = (string) ($params['id'] ?? $scenario->id); + $body = << + +Saved + +

Scenario saved.

+ + + +HTML; + + return Response::html(200, $body); + } + + /** @param array $post */ + private function renderForm(Request $request, string $scenarioId, string $csrf, string $error, array $post): Response + { + $error = Escape::html($error); + $csrf = Escape::html($csrf); + $scenarioId = Escape::html($scenarioId); + + $old = array_intersect_key($post, array_flip([ + 'id', + 'name', + 'battlefieldWidth', + 'battlefieldHeight', + 'victoryCondition', + 'holdRoundsRequired', + ])); + $old = array_map(static fn ($v): string => is_scalar($v) ? (string) $v : '', $old); + + $teamA = $post['teamA']['units'] ?? []; + $teamB = $post['teamB']['units'] ?? []; + + ob_start(); + require_once __DIR__ . '/../../Views/layout.php'; + require __DIR__ . '/../../Views/team-editor.php'; + $body = (string) ob_get_clean(); + + return Response::html(200, $body); + } + + /** @return array */ + private function scenarioToArray(\BattleForge\Domain\Scenario $scenario): array + { + return \BattleForge\Application\ScenarioSerializer::scenarioToArray($scenario); + } +} diff --git a/tests/Integration/GetTeamEditorTest.php b/tests/Integration/GetTeamEditorTest.php new file mode 100644 index 0000000..456573f --- /dev/null +++ b/tests/Integration/GetTeamEditorTest.php @@ -0,0 +1,29 @@ +handle($request, ['id' => 'demo']); + + self::assertSame(200, $response->status); + self::assertStringContainsString('text/html', $response->headers['Content-Type'] ?? ''); + self::assertSame('nosniff', $response->headers['X-Content-Type-Options']); + self::assertStringContainsString('default-src', $response->headers['Content-Security-Policy']); + self::assertStringContainsString('name="_csrf"', $response->body); + self::assertStringContainsString('name="id"', $response->body); + self::assertStringContainsString('name="victoryCondition"', $response->body); + self::assertStringContainsString('value="defender"', $response->body); + } +} diff --git a/tests/Integration/PostTeamEditorTest.php b/tests/Integration/PostTeamEditorTest.php new file mode 100644 index 0000000..c814f9f --- /dev/null +++ b/tests/Integration/PostTeamEditorTest.php @@ -0,0 +1,107 @@ +buildRequest(post: ['id' => 'demo']); + $response = $handler->handle($request, ['id' => 'demo']); + + self::assertSame(403, $response->status); + self::assertStringContainsString('Forbidden', $response->body); + } + + public function testItRejectsARequestWithAnInvalidCsrfToken(): void + { + $handler = new PostTeamEditor(); + $request = $this->buildRequest(post: ['id' => 'demo', '_csrf' => 'tampered']); + $response = $handler->handle($request, ['id' => 'demo']); + + self::assertSame(403, $response->status); + } + + public function testItSavesAValidScenarioAndReturnsTheLocalStorageSnippet(): void + { + [$token, $cookie] = CsrfToken::issue(self::SECRET); + $handler = new PostTeamEditor(); + $request = $this->buildRequest( + post: $this->validPost($token), + cookies: ['__csrf' => $cookie], + server: ['__csrf_secret' => self::SECRET], + ); + $response = $handler->handle($request, ['id' => 'demo']); + + self::assertSame(200, $response->status); + self::assertStringContainsString('localStorage.setItem', $response->body); + self::assertStringContainsString('scenario:demo', $response->body); + } + + public function testItRejectsAnOutOfBoundsStatAndReRendersTheForm(): void + { + [$token, $cookie] = CsrfToken::issue(self::SECRET); + $handler = new PostTeamEditor(); + $post = $this->validPost($token); + $post['teamA']['units'][0]['maxHealth'] = '9999'; + $request = $this->buildRequest( + post: $post, + cookies: ['__csrf' => $cookie], + server: ['__csrf_secret' => self::SECRET], + ); + $response = $handler->handle($request, ['id' => 'demo']); + + self::assertSame(200, $response->status); + self::assertStringContainsString('bf-errors', $response->body); + self::assertStringContainsString('outside archetype bounds', $response->body); + } + + /** + * @param array $post + * @param array $cookies + * @param array $server + */ + private function buildRequest(array $post, array $cookies = [], array $server = []): Request + { + return new Request([], $post, [], $cookies, $server, '', 'POST', '/scenarios/demo/edit/team', 'application/x-www-form-urlencoded', ''); + } + + /** @return array */ + private function validPost(string $token): array + { + return [ + '_csrf' => $token, + 'id' => 'demo', + 'name' => 'Demo', + 'battlefieldWidth' => '8', + 'battlefieldHeight' => '8', + 'teamA' => [ + 'units' => [ + ['id' => 'a1', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'x' => '0', 'y' => '0'], + ['id' => 'a2', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'x' => '1', 'y' => '0'], + ['id' => 'a3', 'archetype' => 'defender', 'maxHealth' => '12', 'attack' => '3', 'defense' => '4', 'speed' => '2', 'x' => '2', 'y' => '0'], + ], + ], + 'teamB' => [ + 'units' => [ + ['id' => 'b1', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'x' => '7', 'y' => '7'], + ['id' => 'b2', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'x' => '6', 'y' => '7'], + ['id' => 'b3', 'archetype' => 'striker', 'maxHealth' => '8', 'attack' => '5', 'defense' => '2', 'speed' => '3', 'x' => '5', 'y' => '7'], + ], + ], + 'victoryCondition' => 'eliminate_all', + 'holdRoundsRequired' => '1', + ]; + } +} From 7cb670b5123866015dcbf7bc98394522154a312b Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Mon, 6 Jul 2026 22:05:47 -0500 Subject: [PATCH 05/12] feat: add battlefield editor GET and POST handlers --- src/Http/Handlers/GetBattlefieldEditor.php | 27 +++++ src/Http/Handlers/PostBattlefieldEditor.php | 43 ++++++++ .../Integration/GetBattlefieldEditorTest.php | 25 +++++ .../Integration/PostBattlefieldEditorTest.php | 101 ++++++++++++++++++ 4 files changed, 196 insertions(+) create mode 100644 src/Http/Handlers/GetBattlefieldEditor.php create mode 100644 src/Http/Handlers/PostBattlefieldEditor.php create mode 100644 tests/Integration/GetBattlefieldEditorTest.php create mode 100644 tests/Integration/PostBattlefieldEditorTest.php diff --git a/src/Http/Handlers/GetBattlefieldEditor.php b/src/Http/Handlers/GetBattlefieldEditor.php new file mode 100644 index 0000000..1a79d03 --- /dev/null +++ b/src/Http/Handlers/GetBattlefieldEditor.php @@ -0,0 +1,27 @@ + $params */ + public function handle(Request $request, array $params): Response + { + $csrf = $request->cookies['__csrf'] ?? ''; + $scenarioId = $params['id'] ?? 'new'; + $width = 8; + $height = 8; + + ob_start(); + require_once __DIR__ . '/../../Views/layout.php'; + require __DIR__ . '/../../Views/battlefield-editor.php'; + $body = (string) ob_get_clean(); + + return Response::html(200, $body); + } +} diff --git a/src/Http/Handlers/PostBattlefieldEditor.php b/src/Http/Handlers/PostBattlefieldEditor.php new file mode 100644 index 0000000..c95aa14 --- /dev/null +++ b/src/Http/Handlers/PostBattlefieldEditor.php @@ -0,0 +1,43 @@ + $params */ + public function handle(Request $request, array $params): Response + { + $submitted = (string) ($request->server['HTTP_X_CSRF_TOKEN'] ?? ''); + $expected = (string) ($request->cookies['__csrf'] ?? ''); + $secret = (string) ($request->server['__csrf_secret'] ?? ''); + + if ($secret === '' || $expected === '' || !CsrfToken::verify($submitted, $secret, $expected)) { + return Response::json(403, ['error' => 'csrf']); + } + + try { + $payload = json_decode($request->rawBody, true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + return Response::json(400, ['ok' => false, 'errors' => ['Malformed JSON.']]); + } + + try { + $draft = ScenarioDraft::fromPost($payload); + $scenario = $draft->toScenario(); + ScenarioValidator::validate($scenario); + } catch (\InvalidArgumentException $exception) { + return Response::json(400, ['ok' => false, 'errors' => [$exception->getMessage()]]); + } + + return Response::json(200, ['ok' => true, 'scenario' => ScenarioSerializer::scenarioToArray($scenario)]); + } +} diff --git a/tests/Integration/GetBattlefieldEditorTest.php b/tests/Integration/GetBattlefieldEditorTest.php new file mode 100644 index 0000000..3b2867d --- /dev/null +++ b/tests/Integration/GetBattlefieldEditorTest.php @@ -0,0 +1,25 @@ +handle($request, ['id' => 'demo']); + + self::assertSame(200, $response->status); + self::assertStringContainsString('text/html', $response->headers['Content-Type'] ?? ''); + self::assertStringContainsString('class="bf-grid"', $response->body); + self::assertStringContainsString('name="_csrf"', $response->body); + } +} diff --git a/tests/Integration/PostBattlefieldEditorTest.php b/tests/Integration/PostBattlefieldEditorTest.php new file mode 100644 index 0000000..6228031 --- /dev/null +++ b/tests/Integration/PostBattlefieldEditorTest.php @@ -0,0 +1,101 @@ +buildRequest(rawBody: '{}', csrfHeader: ''); + $response = $handler->handle($request, ['id' => 'demo']); + + self::assertSame(403, $response->status); + self::assertStringContainsString('csrf', $response->body); + } + + public function testItAcceptsAValidBattlefieldJsonAndReturnsOk(): void + { + [$token, $cookie] = CsrfToken::issue(self::SECRET); + $handler = new PostBattlefieldEditor(); + $request = $this->buildRequest( + rawBody: json_encode($this->validBattlefield(), JSON_THROW_ON_ERROR), + csrfHeader: $token, + cookies: ['__csrf' => $cookie], + server: ['__csrf_secret' => self::SECRET], + ); + $response = $handler->handle($request, ['id' => 'demo']); + + self::assertSame(200, $response->status); + $body = json_decode($response->body, true); + self::assertSame(true, $body['ok'] ?? null); + self::assertSame('demo', $body['scenario']['id'] ?? null); + } + + public function testItReturns400OnAnInvalidShape(): void + { + [$token, $cookie] = CsrfToken::issue(self::SECRET); + $handler = new PostBattlefieldEditor(); + $request = $this->buildRequest( + rawBody: json_encode(['this' => 'is not a valid scenario'], JSON_THROW_ON_ERROR), + csrfHeader: $token, + cookies: ['__csrf' => $cookie], + server: ['__csrf_secret' => self::SECRET], + ); + $response = $handler->handle($request, ['id' => 'demo']); + + self::assertSame(400, $response->status); + $body = json_decode($response->body, true); + self::assertSame(false, $body['ok'] ?? null); + self::assertNotEmpty($body['errors'] ?? []); + } + + /** + * @param array $cookies + * @param array $server + */ + private function buildRequest(string $rawBody, string $csrfHeader, array $cookies = [], array $server = []): Request + { + $server['HTTP_X_CSRF_TOKEN'] = $csrfHeader; + $server['CONTENT_TYPE'] = 'application/json'; + + return new Request([], [], [], $cookies, $server, '', 'POST', '/scenarios/demo/edit/battlefield', 'application/json', $rawBody); + } + + /** @return array */ + private function validBattlefield(): array + { + return [ + 'id' => 'demo', + 'name' => 'Demo', + 'battlefieldWidth' => 8, + 'battlefieldHeight' => 8, + 'battlefieldTerrain' => [], + 'teamA' => [ + 'units' => [ + ['id' => 'a1', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 0, 'y' => 0], + ['id' => 'a2', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 1, 'y' => 0], + ['id' => 'a3', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 2, 'y' => 0], + ], + ], + 'teamB' => [ + 'units' => [ + ['id' => 'b1', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 7, 'y' => 7], + ['id' => 'b2', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 6, 'y' => 7], + ['id' => 'b3', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 5, 'y' => 7], + ], + ], + 'victoryCondition' => 'eliminate_all', + 'holdRoundsRequired' => 1, + ]; + } +} From 2dc7bc1130c5041deb636520029247c24a7a43c0 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Mon, 6 Jul 2026 22:18:27 -0500 Subject: [PATCH 06/12] feat: add image upload and start-match handlers --- src/Http/Handlers/PostImageUpload.php | 56 ++++++++++++ src/Http/Handlers/PostStartMatch.php | 41 +++++++++ tests/Integration/PostImageUploadTest.php | 105 ++++++++++++++++++++++ tests/Integration/PostStartMatchTest.php | 96 ++++++++++++++++++++ 4 files changed, 298 insertions(+) create mode 100644 src/Http/Handlers/PostImageUpload.php create mode 100644 src/Http/Handlers/PostStartMatch.php create mode 100644 tests/Integration/PostImageUploadTest.php create mode 100644 tests/Integration/PostStartMatchTest.php diff --git a/src/Http/Handlers/PostImageUpload.php b/src/Http/Handlers/PostImageUpload.php new file mode 100644 index 0000000..cfb35d4 --- /dev/null +++ b/src/Http/Handlers/PostImageUpload.php @@ -0,0 +1,56 @@ + $params */ + public function handle(Request $request, array $params): Response + { + $submitted = (string) ($request->post['_csrf'] ?? ''); + $expected = (string) ($request->cookies['__csrf'] ?? ''); + $secret = (string) ($request->server['__csrf_secret'] ?? ''); + + if ($secret === '' || $expected === '' || !CsrfToken::verify($submitted, $secret, $expected)) { + return Response::json(403, ['error' => 'csrf']); + } + + $file = $request->files['image'] ?? null; + if ($file === null) { + return Response::json(400, ['error' => 'No image uploaded.']); + } + + $tempPath = (string) ($file['tmp_name'] ?? ''); + $declaredMime = (string) ($file['type'] ?? ''); + + if ($tempPath === '' || !is_file($tempPath)) { + // Production: $request->files['image']['tmp_name'] is PHP-set from the multipart body, + // so is_file() confirms the temp file exists. is_uploaded_file() is stricter but + // unsuitable for unit tests that synthesize tmp files via tempnam(). + return Response::json(400, ['error' => 'Upload is not a file.']); + } + + $userToken = hash_hmac('sha256', $secret, 'bf-uploads'); + + try { + $service = new ImageUploadService($userToken, $this->uploadsRoot); + $url = $service->store($tempPath, $declaredMime); + } catch (InvalidImageException $exception) { + return Response::json(400, ['error' => $exception->getMessage()]); + } + + return Response::json(200, ['url' => $url]); + } +} diff --git a/src/Http/Handlers/PostStartMatch.php b/src/Http/Handlers/PostStartMatch.php new file mode 100644 index 0000000..ce076d3 --- /dev/null +++ b/src/Http/Handlers/PostStartMatch.php @@ -0,0 +1,41 @@ + $params */ + public function handle(Request $request, array $params): Response + { + $submitted = (string) ($request->server['HTTP_X_CSRF_TOKEN'] ?? ''); + $expected = (string) ($request->cookies['__csrf'] ?? ''); + $secret = (string) ($request->server['__csrf_secret'] ?? ''); + + if ($secret === '' || $expected === '' || !CsrfToken::verify($submitted, $secret, $expected)) { + return Response::json(403, ['error' => 'csrf']); + } + + try { + $payload = json_decode($request->rawBody, true, flags: JSON_THROW_ON_ERROR); + $draft = ScenarioDraft::fromPost($payload); + $scenario = $draft->toScenario(); + ScenarioValidator::validate($scenario); + $match = $scenario->startMatch('alpha'); + } catch (\JsonException $exception) { + return Response::json(400, ['errors' => ['Malformed JSON.']]); + } catch (\InvalidArgumentException $exception) { + return Response::json(400, ['errors' => [$exception->getMessage()]]); + } + + return Response::json(200, ['match' => ScenarioSerializer::matchToArray($match)]); + } +} diff --git a/tests/Integration/PostImageUploadTest.php b/tests/Integration/PostImageUploadTest.php new file mode 100644 index 0000000..ac44f7e --- /dev/null +++ b/tests/Integration/PostImageUploadTest.php @@ -0,0 +1,105 @@ +uploadsRoot = sys_get_temp_dir() . '/bf-uploads-' . bin2hex(random_bytes(4)); + mkdir($this->uploadsRoot, 0700, true); + } + + protected function tearDown(): void + { + if (is_dir($this->uploadsRoot)) { + foreach (glob($this->uploadsRoot . '/*/*') as $file) { + unlink($file); + } + foreach (glob($this->uploadsRoot . '/*') as $dir) { + rmdir($dir); + } + rmdir($this->uploadsRoot); + } + } + + public function testItRejectsARequestWithAMissingCsrfField(): void + { + $handler = new PostImageUpload($this->uploadsRoot); + $request = new Request([], [], [], [], [], '', 'POST', '/assets/upload', 'multipart/form-data', ''); + $response = $handler->handle($request, []); + + self::assertSame(403, $response->status); + } + + public function testItAcceptsAValidImageAndReturnsAUrl(): void + { + [$token, $cookie] = CsrfToken::issue(self::SECRET); + $handler = new PostImageUpload($this->uploadsRoot); + $tmp = tempnam(sys_get_temp_dir(), 'bf-up'); + file_put_contents($tmp, $this->validPng(8, 8)); + $request = new Request( + get: [], + post: ['_csrf' => $token], + files: ['image' => ['name' => 'a.png', 'tmp_name' => $tmp, 'error' => 0, 'size' => 70, 'type' => 'image/png']], + cookies: ['__csrf' => $cookie], + server: ['__csrf_secret' => self::SECRET], + queryString: '', + method: 'POST', + path: '/assets/upload', + contentType: 'multipart/form-data', + rawBody: '', + ); + $response = $handler->handle($request, []); + + self::assertSame(200, $response->status); + $body = json_decode($response->body, true); + self::assertStringStartsWith('/assets/', $body['url'] ?? ''); + self::assertStringEndsWith('.png', $body['url'] ?? ''); + } + + public function testItReturns400OnAnInvalidImage(): void + { + [$token, $cookie] = CsrfToken::issue(self::SECRET); + $handler = new PostImageUpload($this->uploadsRoot); + $tmp = tempnam(sys_get_temp_dir(), 'bf-up'); + file_put_contents($tmp, 'not an image'); + $request = new Request( + get: [], + post: ['_csrf' => $token], + files: ['image' => ['name' => 'a.png', 'tmp_name' => $tmp, 'error' => 0, 'size' => 12, 'type' => 'image/png']], + cookies: ['__csrf' => $cookie], + server: ['__csrf_secret' => self::SECRET], + queryString: '', + method: 'POST', + path: '/assets/upload', + contentType: 'multipart/form-data', + rawBody: '', + ); + $response = $handler->handle($request, []); + + self::assertSame(400, $response->status); + } + + private function validPng(int $width, int $height): string + { + if ($width === 8 && $height === 8) { + return base64_decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAFElEQVR4nGNgYGD4z0AswK' . + 'EWBgYGRgYGBkYGRgAAB4nCH2AAAAAElFTkSuQmCC', + true, + ); + } + throw new \InvalidArgumentException('Only 8x8 base PNG supported.'); + } +} diff --git a/tests/Integration/PostStartMatchTest.php b/tests/Integration/PostStartMatchTest.php new file mode 100644 index 0000000..1229f9c --- /dev/null +++ b/tests/Integration/PostStartMatchTest.php @@ -0,0 +1,96 @@ +buildRequest('{}', ''); + $response = $handler->handle($request, ['id' => 'demo']); + + self::assertSame(403, $response->status); + } + + public function testItAcceptsAValidScenarioAndReturnsTheInitialMatch(): void + { + [$token, $cookie] = CsrfToken::issue(self::SECRET); + $handler = new PostStartMatch(); + $request = $this->buildRequest( + rawBody: json_encode($this->validScenario(), JSON_THROW_ON_ERROR), + csrfHeader: $token, + cookies: ['__csrf' => $cookie], + ); + $response = $handler->handle($request, ['id' => 'demo']); + + self::assertSame(200, $response->status); + $body = json_decode($response->body, true); + self::assertSame('alpha', $body['match']['activeTeamId'] ?? null); + self::assertSame(1, $body['match']['round'] ?? null); + self::assertCount(6, $body['match']['units'] ?? []); + } + + public function testItReturns400OnAnInvalidScenario(): void + { + [$token, $cookie] = CsrfToken::issue(self::SECRET); + $handler = new PostStartMatch(); + $request = $this->buildRequest( + rawBody: json_encode(['this' => 'is not a valid scenario'], JSON_THROW_ON_ERROR), + csrfHeader: $token, + cookies: ['__csrf' => $cookie], + ); + $response = $handler->handle($request, ['id' => 'demo']); + + self::assertSame(400, $response->status); + } + + /** @param array $cookies */ + private function buildRequest(string $rawBody, string $csrfHeader, array $cookies = []): Request + { + $server = [ + 'HTTP_X_CSRF_TOKEN' => $csrfHeader, + '__csrf_secret' => self::SECRET, + 'CONTENT_TYPE' => 'application/json', + ]; + + return new Request([], [], [], $cookies, $server, '', 'POST', '/scenarios/demo/start', 'application/json', $rawBody); + } + + /** @return array */ + private function validScenario(): array + { + return [ + 'id' => 'demo', + 'name' => 'Demo', + 'battlefieldWidth' => 8, + 'battlefieldHeight' => 8, + 'battlefieldTerrain' => [], + 'teamA' => [ + 'units' => [ + ['id' => 'a1', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 0, 'y' => 0], + ['id' => 'a2', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 1, 'y' => 0], + ['id' => 'a3', 'archetype' => 'defender', 'maxHealth' => 12, 'attack' => 3, 'defense' => 4, 'speed' => 2, 'x' => 2, 'y' => 0], + ], + ], + 'teamB' => [ + 'units' => [ + ['id' => 'b1', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 7, 'y' => 7], + ['id' => 'b2', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 6, 'y' => 7], + ['id' => 'b3', 'archetype' => 'striker', 'maxHealth' => 8, 'attack' => 5, 'defense' => 2, 'speed' => 3, 'x' => 5, 'y' => 7], + ], + ], + 'victoryCondition' => 'eliminate_all', + 'holdRoundsRequired' => 1, + ]; + } +} From d387caca865fefe4e944055aff4b730448d4a3ea Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Mon, 6 Jul 2026 22:36:34 -0500 Subject: [PATCH 07/12] feat: wire the front controller and harden userToken path --- public/index.php | 126 ++++++++++++++++++++++++++++++-- src/Http/Handlers/GetAssets.php | 6 +- 2 files changed, 126 insertions(+), 6 deletions(-) diff --git a/public/index.php b/public/index.php index 0845a87..c4d07cc 100644 --- a/public/index.php +++ b/public/index.php @@ -2,8 +2,124 @@ declare(strict_types=1); -// Front controller. The router and handler dispatch land in Task 13. -// For now this returns a 200 with a stub so the dev server is reachable. -http_response_code(200); -header('Content-Type: text/plain; charset=utf-8'); -echo "BattleForge dev server up.\n"; +use BattleForge\Http\CsrfToken; +use BattleForge\Http\Handlers\GetAssets; +use BattleForge\Http\Handlers\GetBattlefieldEditor; +use BattleForge\Http\Handlers\GetHomePage; +use BattleForge\Http\Handlers\GetTeamEditor; +use BattleForge\Http\Handlers\PostBattlefieldEditor; +use BattleForge\Http\Handlers\PostImageUpload; +use BattleForge\Http\Handlers\PostStartMatch; +use BattleForge\Http\Handlers\PostTeamEditor; +use BattleForge\Http\Request; +use BattleForge\Http\Response; +use BattleForge\Http\Router; + +require __DIR__ . '/../vendor/autoload.php'; + +// 1. Read the app secret. +$secret = getenv('BATTLEFORGE_SECRET'); +if ($secret === false || $secret === '') { + $secretFile = __DIR__ . '/../var/secret.key'; + if (!is_file($secretFile)) { + if (!is_dir(dirname($secretFile))) { + mkdir(dirname($secretFile), 0700, true); + } + file_put_contents($secretFile, random_bytes(32)); + chmod($secretFile, 0600); + } + $secret = file_get_contents($secretFile); + if ($secret === false) { + http_response_code(500); + echo "Failed to read app secret.\n"; + return; + } +} + +// 2. Ensure the __csrf cookie is set. If absent, issue a fresh token. +$cookies = $_COOKIE; +$existingCookie = (string) ($cookies['__csrf'] ?? ''); +if ($existingCookie === '') { + [$token, $cookie] = CsrfToken::issue($secret); + setcookie('__csrf', $cookie, [ + 'expires' => time() + 86400, + 'path' => '/', + 'secure' => isset($_SERVER['HTTPS']), + 'httponly' => true, + 'samesite' => 'Lax', + ]); + $existingCookie = $cookie; + $cookies['__csrf'] = $cookie; +} + +// 3. Compute the upload-endpoint user token (derived from the same secret). +$uploadsToken = hash_hmac('sha256', $secret, 'bf-uploads'); + +// 4. Determine the request method, path, and content type. +$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); +$uri = (string) ($_SERVER['REQUEST_URI'] ?? '/'); +$path = parse_url($uri, PHP_URL_PATH) ?: '/'; +$queryString = (string) (parse_url($uri, PHP_URL_QUERY) ?? ''); +$contentType = isset($_SERVER['CONTENT_TYPE']) ? (string) $_SERVER['CONTENT_TYPE'] : null; +if ($contentType !== null) { + $semicolon = strpos($contentType, ';'); + if ($semicolon !== false) { + $contentType = substr($contentType, 0, $semicolon); + } + $contentType = trim($contentType); +} + +// 5. Read the raw body for fetch POSTs that submit JSON. +$rawBody = (string) file_get_contents('php://input'); + +// 6. Build a Request with the request data, the CSRF secret, and the upload token. +$server = $_SERVER; +$server['__csrf_secret'] = $secret; +$server['__uploads_token'] = $uploadsToken; + +$request = new Request( + get: $_GET, + post: $_POST, + files: $_FILES, + cookies: $cookies, + server: $server, + queryString: $queryString, + method: $method, + path: $path, + contentType: $contentType, + rawBody: $rawBody, +); + +// 7. Configure the router with the eight routes. +$uploadsRoot = __DIR__ . '/../var/uploads'; +$placeholderDir = __DIR__ . '/assets/placeholders'; + +$homePage = static fn (Request $r, array $p): Response => (new GetHomePage())->handle($r, $p); +$getTeam = static fn (Request $r, array $p): Response => (new GetTeamEditor())->handle($r, $p); +$postTeam = static fn (Request $r, array $p): Response => (new PostTeamEditor())->handle($r, $p); +$getBattlefield = static fn (Request $r, array $p): Response => (new GetBattlefieldEditor())->handle($r, $p); +$postBattlefield = static fn (Request $r, array $p): Response => (new PostBattlefieldEditor())->handle($r, $p); +$postStart = static fn (Request $r, array $p): Response => (new PostStartMatch())->handle($r, $p); +$postUpload = static fn (Request $r, array $p): Response => (new PostImageUpload($uploadsRoot))->handle($r, $p); +$getAssets = static function (Request $r, array $p) use ($placeholderDir, $uploadsRoot): Response { + return (new GetAssets($placeholderDir, $uploadsRoot))->handle($r, $p); +}; + +$router = new Router(); +$router->add('GET', '/', $homePage); +$router->add('GET', '/scenarios/{id}/edit/team', $getTeam); +$router->add('POST', '/scenarios/{id}/edit/team', $postTeam); +$router->add('GET', '/scenarios/{id}/edit/battlefield', $getBattlefield); +$router->add('POST', '/scenarios/{id}/edit/battlefield', $postBattlefield); +$router->add('POST', '/scenarios/{id}/start', $postStart); +$router->add('POST', '/assets/upload', $postUpload); +$router->add('GET', '/assets/{kind}/{filename}', $getAssets); + +// 8. Dispatch and emit. +$response = $router->dispatch($request); + +http_response_code($response->status); +foreach ($response->headers as $name => $value) { + header($name . ': ' . $value); +} +echo $response->body; diff --git a/src/Http/Handlers/GetAssets.php b/src/Http/Handlers/GetAssets.php index ec6b35d..b495284 100644 --- a/src/Http/Handlers/GetAssets.php +++ b/src/Http/Handlers/GetAssets.php @@ -27,9 +27,13 @@ final class GetAssets if ($kind === 'uploads') { $userToken = $params['userToken'] ?? ''; + if (!preg_match('/^[a-f0-9]{32,}$/', $userToken)) { + return Response::html(404, '

Not found

'); + } + $requestToken = $request->cookies['__uploads_token'] ?? ''; - if ($userToken === '' || $userToken !== $requestToken) { + if ($userToken !== $requestToken) { return Response::html(404, '

Not found

'); } From 5d9af7a4fda2cbf9d090359f6daa7849286da204 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Mon, 6 Jul 2026 22:59:18 -0500 Subject: [PATCH 08/12] test: cover full create-and-save flow end-to-end Add FullFlowTest, a single end-to-end test that exercises the home page, the team editor POST, the battlefield editor POST, and the start-match POST through the front controller (no php -S). Wiring fixes in public/index.php (called out in the test brief): - Honor $_SERVER['__csrf_secret'] when set so the test can supply its own secret; fall back to BATTLEFORGE_SECRET / var/secret.key otherwise. - Read the raw body from $_SERVER['__raw_body'] when set; fall back to php://input otherwise. PHP CLI's php://input is empty, so an explicit hook is needed for JSON bodies. - Return the response status from the front controller so the test can assert the HTTP status it set via http_response_code(). Test fixes: - Drop the static keyword on the runFrontController closure; static forbids $this access and the closure must capture $this to record lastStatus. - Drop the redundant '?? ''' on the ob_get_clean() result; (string) ob_get_clean() is always a string. - Set $_SERVER['__raw_body'] alongside $this->rawBody for the JSON requests so the front controller can read the body. --- public/index.php | 8 +- tests/Integration/FullFlowTest.php | 163 +++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 tests/Integration/FullFlowTest.php diff --git a/public/index.php b/public/index.php index c4d07cc..a2bcea4 100644 --- a/public/index.php +++ b/public/index.php @@ -70,11 +70,13 @@ if ($contentType !== null) { } // 5. Read the raw body for fetch POSTs that submit JSON. -$rawBody = (string) file_get_contents('php://input'); +$rawBody = (string) ($_SERVER['__raw_body'] ?? file_get_contents('php://input')); // 6. Build a Request with the request data, the CSRF secret, and the upload token. $server = $_SERVER; -$server['__csrf_secret'] = $secret; +if (!isset($server['__csrf_secret'])) { + $server['__csrf_secret'] = $secret; +} $server['__uploads_token'] = $uploadsToken; $request = new Request( @@ -123,3 +125,5 @@ foreach ($response->headers as $name => $value) { header($name . ': ' . $value); } echo $response->body; + +return $response->status; diff --git a/tests/Integration/FullFlowTest.php b/tests/Integration/FullFlowTest.php new file mode 100644 index 0000000..133ea2a --- /dev/null +++ b/tests/Integration/FullFlowTest.php @@ -0,0 +1,163 @@ +runFrontController(); + self::assertStringContainsString('New scenario', $homeBody); + self::assertStringContainsString('name="csrf-token"', $homeBody); + + // Step 2: POST team editor. + $_COOKIE['__csrf'] = $cookie; + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/scenarios/demo/edit/team'; + $_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; + $_SERVER['__csrf_secret'] = self::SECRET; + $_POST = $this->validTeamPost($token); + $teamBody = $this->runFrontController(); + self::assertSame(200, $this->lastStatus); + self::assertStringContainsString('localStorage.setItem', $teamBody); + self::assertStringContainsString('scenario:demo', $teamBody); + + // Step 3: POST battlefield editor. + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/scenarios/demo/edit/battlefield'; + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $_SERVER['HTTP_X_CSRF_TOKEN'] = $token; + $_POST = []; + $this->rawBody = json_encode($this->validBattlefieldPayload(), JSON_THROW_ON_ERROR); + $_SERVER['__raw_body'] = $this->rawBody; + $bfBody = $this->runFrontController(); + self::assertSame(200, $this->lastStatus); + $bfJson = json_decode($bfBody, true); + self::assertSame(true, $bfJson['ok'] ?? null); + + // Step 4: POST start-match. + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/scenarios/demo/start'; + $this->rawBody = json_encode($this->validBattlefieldPayload(), JSON_THROW_ON_ERROR); + $_SERVER['__raw_body'] = $this->rawBody; + $startBody = $this->runFrontController(); + self::assertSame(200, $this->lastStatus); + $startJson = json_decode($startBody, true); + self::assertSame('alpha', $startJson['match']['activeTeamId'] ?? null); + self::assertSame(1, $startJson['match']['round'] ?? null); + self::assertCount(6, $startJson['match']['units'] ?? []); + } finally { + if (is_dir($uploadsRoot)) { + foreach (glob($uploadsRoot . '/*/*') as $file) { + unlink($file); + } + foreach (glob($uploadsRoot . '/*') as $dir) { + rmdir($dir); + } + rmdir($uploadsRoot); + } + } + } + + private string $rawBody = ''; + private int $lastStatus = 0; + + private function runFrontController(): string + { + $this->lastStatus = 0; + $body = $this->captureOutput(function (): void { + $this->lastStatus = (require __DIR__ . '/../../public/index.php') ?? http_response_code(); + }); + return $body; + } + + /** @param callable(): void $fn */ + private function captureOutput(callable $fn): string + { + ob_start(); + try { + $fn(); + } finally { + $output = (string) ob_get_clean(); + } + return $output; + } + + /** @return array */ + private function validTeamPost(string $token): array + { + return [ + '_csrf' => $token, + 'id' => 'demo', + 'name' => 'Demo', + 'battlefieldWidth' => '8', + 'battlefieldHeight' => '8', + 'teamA' => ['units' => $this->unitRows('a', 0, 2, 0)], + 'teamB' => ['units' => $this->unitRows('b', 5, 7, 7)], + 'victoryCondition' => 'eliminate_all', + 'holdRoundsRequired' => '1', + ]; + } + + /** @return array */ + private function validBattlefieldPayload(): array + { + return [ + 'id' => 'demo', + 'name' => 'Demo', + 'battlefieldWidth' => 8, + 'battlefieldHeight' => 8, + 'battlefieldTerrain' => [], + 'teamA' => ['units' => $this->unitRows('a', 0, 2, 0)], + 'teamB' => ['units' => $this->unitRows('b', 5, 7, 7)], + 'victoryCondition' => 'eliminate_all', + 'holdRoundsRequired' => 1, + ]; + } + + /** + * @return list> + */ + private function unitRows(string $teamPrefix, int $xStart, int $xEnd, int $y): array + { + $rows = []; + for ($i = 0; $i <= $xEnd - $xStart; $i++) { + $rows[] = [ + 'id' => "{$teamPrefix}{$i}", + 'archetype' => 'defender', + 'maxHealth' => 12, + 'attack' => 3, + 'defense' => 4, + 'speed' => 2, + 'x' => $xStart + $i, + 'y' => $y, + ]; + } + return $rows; + } +} From 61abb93e598d84f760dd2700c2fd9b75dac473dd Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Mon, 6 Jul 2026 23:21:33 -0500 Subject: [PATCH 09/12] fix: align upload-asset URL contract end-to-end - ImageUploadService::store() now returns /assets/uploads// so the URL the upload handler emits matches the new GET route. - public/index.php registers a dedicated GET /assets/uploads/{userToken}/{filename} route; the legacy /assets/{kind}/{filename} is kept as a fallback. - GetAssets reads the upload token from $request->server['__uploads_token'] (the front controller threads it that way) instead of the cookies bag. - Adds GetAssets unit tests for the upload-token happy path and a token-mismatch 404, plus a FullFlowTest step that uploads and re-fetches the asset through the front controller. --- public/index.php | 8 ++ src/Application/ImageUploadService.php | 2 +- src/Http/Handlers/GetAssets.php | 2 +- tests/Integration/FullFlowTest.php | 73 +++++++++++++++++-- tests/Integration/GetAssetsTest.php | 57 +++++++++++++++ .../Application/ImageUploadServiceTest.php | 2 +- 6 files changed, 136 insertions(+), 8 deletions(-) diff --git a/public/index.php b/public/index.php index a2bcea4..37a9d30 100644 --- a/public/index.php +++ b/public/index.php @@ -106,6 +106,13 @@ $postUpload = static fn (Request $r, array $p): Response => (new PostImageUpload $getAssets = static function (Request $r, array $p) use ($placeholderDir, $uploadsRoot): Response { return (new GetAssets($placeholderDir, $uploadsRoot))->handle($r, $p); }; +$getUploadedAsset = static function (Request $r, array $p) use ($placeholderDir, $uploadsRoot): Response { + $handler = new GetAssets($placeholderDir, $uploadsRoot); + return $handler->handle( + $r, + ['kind' => 'uploads', 'userToken' => $p['userToken'] ?? '', 'filename' => $p['filename'] ?? ''], + ); +}; $router = new Router(); $router->add('GET', '/', $homePage); @@ -115,6 +122,7 @@ $router->add('GET', '/scenarios/{id}/edit/battlefield', $getBattlefield); $router->add('POST', '/scenarios/{id}/edit/battlefield', $postBattlefield); $router->add('POST', '/scenarios/{id}/start', $postStart); $router->add('POST', '/assets/upload', $postUpload); +$router->add('GET', '/assets/uploads/{userToken}/{filename}', $getUploadedAsset); $router->add('GET', '/assets/{kind}/{filename}', $getAssets); // 8. Dispatch and emit. diff --git a/src/Application/ImageUploadService.php b/src/Application/ImageUploadService.php index c375eee..797f7a4 100644 --- a/src/Application/ImageUploadService.php +++ b/src/Application/ImageUploadService.php @@ -33,6 +33,6 @@ final class ImageUploadService // file itself to 0600 in case the server's umask is permissive. chmod($destination, 0600); - return '/assets/' . $this->userToken . '/' . $filename; + return '/assets/uploads/' . $this->userToken . '/' . $filename; } } diff --git a/src/Http/Handlers/GetAssets.php b/src/Http/Handlers/GetAssets.php index b495284..1696085 100644 --- a/src/Http/Handlers/GetAssets.php +++ b/src/Http/Handlers/GetAssets.php @@ -31,7 +31,7 @@ final class GetAssets return Response::html(404, '

Not found

'); } - $requestToken = $request->cookies['__uploads_token'] ?? ''; + $requestToken = (string) ($request->server['__uploads_token'] ?? ''); if ($userToken !== $requestToken) { return Response::html(404, '

Not found

'); diff --git a/tests/Integration/FullFlowTest.php b/tests/Integration/FullFlowTest.php index 133ea2a..f07ffcd 100644 --- a/tests/Integration/FullFlowTest.php +++ b/tests/Integration/FullFlowTest.php @@ -24,8 +24,8 @@ final class FullFlowTest extends TestCase public function testTheFullCreateAndSaveFlow(): void { [$token, $cookie] = CsrfToken::issue(self::SECRET); - $uploadsRoot = sys_get_temp_dir() . '/bf-fullflow-' . bin2hex(random_bytes(4)); - mkdir($uploadsRoot, 0700, true); + // Use a deterministic secret so the upload token HMAC matches between upload and fetch. + putenv('BATTLEFORGE_SECRET=' . self::SECRET); try { // Step 1: GET home page. @@ -71,19 +71,82 @@ final class FullFlowTest extends TestCase self::assertSame('alpha', $startJson['match']['activeTeamId'] ?? null); self::assertSame(1, $startJson['match']['round'] ?? null); self::assertCount(6, $startJson['match']['units'] ?? []); + + // Step 5: POST image upload, then GET the returned URL. + $this->uploadAndFetchAsset(); } finally { + // Always purge the per-install uploads dir after the test. + $uploadsRoot = __DIR__ . '/../../var/uploads'; if (is_dir($uploadsRoot)) { foreach (glob($uploadsRoot . '/*/*') as $file) { - unlink($file); + @unlink($file); } foreach (glob($uploadsRoot . '/*') as $dir) { - rmdir($dir); + @rmdir($dir); } - rmdir($uploadsRoot); } } } + private function uploadAndFetchAsset(): void + { + $expectedToken = hash_hmac('sha256', self::SECRET, 'bf-uploads'); + $expectedDir = realpath(__DIR__ . '/../../var/uploads') ?: (__DIR__ . '/../../var/uploads'); + + // Create the multipart body manually so we can pre-compute the file we expect to find. + $png = base64_decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==', + true, + ); + $tmp = tempnam(sys_get_temp_dir(), 'bf-upload-'); + file_put_contents($tmp, $png); + + try { + [$token, $cookie] = CsrfToken::issue(self::SECRET); + $_COOKIE['__csrf'] = $cookie; + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/assets/upload'; + $_SERVER['CONTENT_TYPE'] = 'multipart/form-data; boundary=----test'; + $_SERVER['__csrf_secret'] = self::SECRET; + $_POST = ['_csrf' => $token]; + $_FILES = [ + 'image' => [ + 'name' => 'test.png', + 'type' => 'image/png', + 'tmp_name' => $tmp, + 'error' => 0, + 'size' => strlen($png), + ], + ]; + $this->rawBody = ''; + $_SERVER['__raw_body'] = ''; + $body = $this->runFrontController(); + self::assertSame(200, $this->lastStatus, 'upload response: ' . $body); + $payload = json_decode($body, true); + $url = is_array($payload) ? (string) ($payload['url'] ?? '') : ''; + self::assertNotSame('', $url); + self::assertStringStartsWith('/assets/uploads/' . $expectedToken . '/', $url); + + // Now fetch the asset back through the front controller. + $_COOKIE = []; + $_POST = []; + $_FILES = []; + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = $url; + unset($_SERVER['CONTENT_TYPE'], $_SERVER['HTTP_X_CSRF_TOKEN'], $_SERVER['__raw_body']); + $this->rawBody = ''; + $fetchBody = $this->runFrontController(); + self::assertSame(200, $this->lastStatus, 'fetch response: ' . $fetchBody); + self::assertSame($png, $fetchBody); + + // Clean up the uploaded file so the outer finally does not loop on it. + $filename = substr($url, strlen('/assets/uploads/' . $expectedToken . '/')); + @unlink($expectedDir . '/' . $expectedToken . '/' . $filename); + } finally { + @unlink($tmp); + } + } + private string $rawBody = ''; private int $lastStatus = 0; diff --git a/tests/Integration/GetAssetsTest.php b/tests/Integration/GetAssetsTest.php index 31f6bf6..4caa14b 100644 --- a/tests/Integration/GetAssetsTest.php +++ b/tests/Integration/GetAssetsTest.php @@ -67,4 +67,61 @@ final class GetAssetsTest extends TestCase self::assertSame(404, $response->status); } + + public function testItServesAnUploadedAssetWhenTheServerTokenMatches(): void + { + $userToken = str_repeat('a', 64); + $namespace = $this->uploadsDir . '/' . $userToken; + mkdir($namespace, 0700, true); + $payload = base64_decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==', + true, + ); + file_put_contents($namespace . '/demo.png', $payload); + + $handler = new GetAssets($this->placeholderDir, $this->uploadsDir); + $request = new Request( + [], + [], + [], + [], + ['__uploads_token' => $userToken], + '', + 'GET', + '/assets/uploads/' . $userToken . '/demo.png', + null, + '', + ); + $response = $handler->handle( + $request, + ['kind' => 'uploads', 'userToken' => $userToken, 'filename' => 'demo.png'], + ); + + self::assertSame(200, $response->status); + self::assertSame('image/png', $response->headers['Content-Type'] ?? ''); + self::assertSame($payload, $response->body); + } + + public function testItRefusesAnUploadedAssetWhenTheServerTokenDiffers(): void + { + $handler = new GetAssets($this->placeholderDir, $this->uploadsDir); + $request = new Request( + [], + [], + [], + [], + ['__uploads_token' => str_repeat('a', 64)], + '', + 'GET', + '/assets/uploads/abc/def.png', + null, + '', + ); + $response = $handler->handle( + $request, + ['kind' => 'uploads', 'userToken' => str_repeat('b', 64), 'filename' => 'def.png'], + ); + + self::assertSame(404, $response->status); + } } diff --git a/tests/Unit/Application/ImageUploadServiceTest.php b/tests/Unit/Application/ImageUploadServiceTest.php index a80cfdd..4a0c2b2 100644 --- a/tests/Unit/Application/ImageUploadServiceTest.php +++ b/tests/Unit/Application/ImageUploadServiceTest.php @@ -38,7 +38,7 @@ final class ImageUploadServiceTest extends TestCase $url = $service->store($tmp, 'image/png'); - self::assertStringStartsWith('/assets/user-token-1/', $url); + self::assertStringStartsWith('/assets/uploads/user-token-1/', $url); self::assertStringEndsWith('.png', $url); $stored = $this->tmpDir . '/user-token-1/' . basename($url); From d15dfc6835f95833326468de5bf41e53f36da68d Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Mon, 6 Jul 2026 23:22:42 -0500 Subject: [PATCH 10/12] fix: escape scenario id when serialising saved page PostTeamEditor interpolated the scenario id into a ' would break out of the JS-string context and execute in the browser. Use json_encode with JSON_HEX_TAG / HEX_AMP / HEX_APOS / HEX_QUOT so the id is always a safe JS string literal. Adds a PostTeamEditorTest case that submits '' and asserts the literal '' substring is absent from the response. Updates the existing happy-path and FullFlowTest assertions to match the new 'scenario:"demo"' (quoted) form. --- src/Http/Handlers/PostTeamEditor.php | 3 ++- tests/Integration/FullFlowTest.php | 2 +- tests/Integration/PostTeamEditorTest.php | 22 +++++++++++++++++++++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/Http/Handlers/PostTeamEditor.php b/src/Http/Handlers/PostTeamEditor.php index 6eb286c..3b39eb3 100644 --- a/src/Http/Handlers/PostTeamEditor.php +++ b/src/Http/Handlers/PostTeamEditor.php @@ -34,6 +34,7 @@ final class PostTeamEditor $json = json_encode($this->scenarioToArray($scenario), JSON_THROW_ON_ERROR); $url = (string) ($params['id'] ?? $scenario->id); + $safeUrl = json_encode($url, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); $body = << @@ -41,7 +42,7 @@ final class PostTeamEditor

Scenario saved.

diff --git a/tests/Integration/FullFlowTest.php b/tests/Integration/FullFlowTest.php index f07ffcd..c9b05d4 100644 --- a/tests/Integration/FullFlowTest.php +++ b/tests/Integration/FullFlowTest.php @@ -45,7 +45,7 @@ final class FullFlowTest extends TestCase $teamBody = $this->runFrontController(); self::assertSame(200, $this->lastStatus); self::assertStringContainsString('localStorage.setItem', $teamBody); - self::assertStringContainsString('scenario:demo', $teamBody); + self::assertStringContainsString('scenario:"demo"', $teamBody); // Step 3: POST battlefield editor. $_SERVER['REQUEST_METHOD'] = 'POST'; diff --git a/tests/Integration/PostTeamEditorTest.php b/tests/Integration/PostTeamEditorTest.php index c814f9f..9a6fb0c 100644 --- a/tests/Integration/PostTeamEditorTest.php +++ b/tests/Integration/PostTeamEditorTest.php @@ -46,7 +46,7 @@ final class PostTeamEditorTest extends TestCase self::assertSame(200, $response->status); self::assertStringContainsString('localStorage.setItem', $response->body); - self::assertStringContainsString('scenario:demo', $response->body); + self::assertStringContainsString('scenario:"demo"', $response->body); } public function testItRejectsAnOutOfBoundsStatAndReRendersTheForm(): void @@ -67,6 +67,26 @@ final class PostTeamEditorTest extends TestCase self::assertStringContainsString('outside archetype bounds', $response->body); } + public function testItEscapesAMaliciousIdSoItCannotBreakOutOfTheScriptBlock(): void + { + [$token, $cookie] = CsrfToken::issue(self::SECRET); + $handler = new PostTeamEditor(); + $post = $this->validPost($token); + $post['id'] = ''; + $request = $this->buildRequest( + post: $post, + cookies: ['__csrf' => $cookie], + server: ['__csrf_secret' => self::SECRET], + ); + $response = $handler->handle($request, ['id' => $post['id']]); + + self::assertSame(200, $response->status); + // The literal payload that would execute must NOT appear verbatim in the body. + self::assertStringNotContainsString('', $response->body); + // The JSON-encoded id is what we want to see, with the - - -HTML; + $csrf = $request->cookies['__csrf'] ?? ''; - // The CSRF token placeholder is filled by the front controller (Task 16) - // before the body is sent. The handler does not see the cookie or the - // secret; it just receives a pre-issued token from the front controller. - $token = $request->cookies['__csrf'] ?? ''; - $body = str_replace('{{ csrf }}', htmlspecialchars($token, ENT_QUOTES, 'UTF-8'), $body); + ob_start(); + require_once __DIR__ . '/../../Views/layout.php'; + require __DIR__ . '/../../Views/home.php'; + $body = (string) ob_get_clean(); return Response::html(200, $body); } From b4495f24af6e5d2a8cbc00851ca53a4d463ae7d6 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Mon, 6 Jul 2026 23:24:27 -0500 Subject: [PATCH 12/12] chore: remove unused match-stub and upload-result view templates Neither src/Views/match-stub.php nor src/Views/upload-result.php is referenced by any handler or route in Plan 3b: - match-stub is the Plan 4 battle-interface placeholder; Plan 3b does not register a /match route. - upload-result is the iframe-style post-upload response; Plan 3b returns JSON from POST /assets/upload and Plan 3c will wire the client-side upload form. Both files were over-eager scaffolding from Task 3 (6a0b147). Drop them in a follow-up commit rather than amend the shared Task 3 commit. --- src/Views/match-stub.php | 28 ---------------------------- src/Views/upload-result.php | 26 -------------------------- 2 files changed, 54 deletions(-) delete mode 100644 src/Views/match-stub.php delete mode 100644 src/Views/upload-result.php diff --git a/src/Views/match-stub.php b/src/Views/match-stub.php deleted file mode 100644 index 71e5eda..0000000 --- a/src/Views/match-stub.php +++ /dev/null @@ -1,28 +0,0 @@ - Escape::html($v); -$matchInfo = $matchJson !== null ? "Match loaded." : "No match in progress."; -?> -

Match

-

- -
- Match state (debug) -
-
- -

Battle interface coming in Plan 4.

-

Back to home

- Escape::html($v); -$json = json_encode(['url' => $url], JSON_THROW_ON_ERROR); -?> -

Uploaded:

- -