diff --git a/src/Http/Handlers/GetAssets.php b/src/Http/Handlers/GetAssets.php
new file mode 100644
index 0000000..ec6b35d
--- /dev/null
+++ b/src/Http/Handlers/GetAssets.php
@@ -0,0 +1,74 @@
+ $params */
+ public function handle(Request $request, array $params): Response
+ {
+ $kind = $params['kind'] ?? '';
+ $filename = $params['filename'] ?? '';
+
+ if ($kind === 'placeholders') {
+ return $this->serveFrom($this->placeholderDir . '/' . $filename);
+ }
+
+ if ($kind === 'uploads') {
+ $userToken = $params['userToken'] ?? '';
+ $requestToken = $request->cookies['__uploads_token'] ?? '';
+
+ if ($userToken === '' || $userToken !== $requestToken) {
+ return Response::html(404, '
Not found
');
+ }
+
+ return $this->serveFrom($this->uploadsRoot . '/' . $userToken . '/' . $filename);
+ }
+
+ return Response::html(404, 'Not found
');
+ }
+
+ private function serveFrom(string $path): Response
+ {
+ if (!is_file($path)) {
+ return Response::html(404, 'Not found
');
+ }
+
+ $body = file_get_contents($path);
+ if ($body === false) {
+ return Response::html(500, 'Read error
');
+ }
+
+ $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
+ $contentType = match ($ext) {
+ 'png' => 'image/png',
+ 'jpg', 'jpeg' => 'image/jpeg',
+ 'webp' => 'image/webp',
+ 'gif' => 'image/gif',
+ default => 'application/octet-stream',
+ };
+
+ return new Response(200, ['Content-Type' => $contentType] + self::securityHeaders(), $body);
+ }
+
+ /** @return array */
+ private static function securityHeaders(): array
+ {
+ return [
+ 'X-Content-Type-Options' => 'nosniff',
+ 'Referrer-Policy' => 'same-origin',
+ 'Content-Security-Policy' => "default-src 'self'; img-src 'self' data:; style-src 'self'",
+ ];
+ }
+}
diff --git a/src/Http/Handlers/GetHomePage.php b/src/Http/Handlers/GetHomePage.php
new file mode 100644
index 0000000..82cd9be
--- /dev/null
+++ b/src/Http/Handlers/GetHomePage.php
@@ -0,0 +1,42 @@
+ $params */
+ public function handle(Request $request, array $params): Response
+ {
+ $body = <<<'HTML'
+
+
+
+
+ BattleForge
+
+
+
+
+ BattleForge
+ New scenario
+ Recent scenarios
+
+
+
+
+HTML;
+
+ // 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);
+
+ return Response::html(200, $body);
+ }
+}
diff --git a/tests/Integration/GetAssetsTest.php b/tests/Integration/GetAssetsTest.php
new file mode 100644
index 0000000..31f6bf6
--- /dev/null
+++ b/tests/Integration/GetAssetsTest.php
@@ -0,0 +1,70 @@
+placeholderDir = sys_get_temp_dir() . '/bf-placeholders-' . bin2hex(random_bytes(4));
+ mkdir($this->placeholderDir, 0755, true);
+ // 1x1 red PNG
+ $png = base64_decode(
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==',
+ true,
+ );
+ file_put_contents($this->placeholderDir . '/defender.png', $png);
+
+ $this->uploadsDir = sys_get_temp_dir() . '/bf-assets-' . bin2hex(random_bytes(4));
+ mkdir($this->uploadsDir, 0700, true);
+ }
+
+ protected function tearDown(): void
+ {
+ if (is_dir($this->uploadsDir)) {
+ foreach (glob($this->uploadsDir . '/*/*') as $file) {
+ unlink($file);
+ }
+ foreach (glob($this->uploadsDir . '/*') as $dir) {
+ rmdir($dir);
+ }
+ rmdir($this->uploadsDir);
+ }
+
+ if (is_dir($this->placeholderDir)) {
+ foreach (glob($this->placeholderDir . '/*') as $file) {
+ unlink($file);
+ }
+ rmdir($this->placeholderDir);
+ }
+ }
+
+ public function testItServesAPlaceholderImageWithoutAuth(): void
+ {
+ $handler = new GetAssets($this->placeholderDir, $this->uploadsDir);
+ $request = new Request([], [], [], [], [], '', 'GET', '/assets/placeholders/defender.png', null, '');
+ $response = $handler->handle($request, ['kind' => 'placeholders', 'filename' => 'defender.png']);
+
+ self::assertSame(200, $response->status);
+ self::assertSame('image/png', $response->headers['Content-Type'] ?? '');
+ self::assertGreaterThan(0, strlen($response->body));
+ }
+
+ public function testItReturns404ForAMissingFile(): void
+ {
+ $handler = new GetAssets($this->placeholderDir, $this->uploadsDir);
+ $request = new Request([], [], [], [], [], '', 'GET', '/assets/placeholders/missing.png', null, '');
+ $response = $handler->handle($request, ['kind' => 'placeholders', 'filename' => 'missing.png']);
+
+ self::assertSame(404, $response->status);
+ }
+}
diff --git a/tests/Integration/GetHomePageTest.php b/tests/Integration/GetHomePageTest.php
new file mode 100644
index 0000000..ac85680
--- /dev/null
+++ b/tests/Integration/GetHomePageTest.php
@@ -0,0 +1,26 @@
+handle($request, []);
+
+ 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('', $response->body);
+ }
+}