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

+

No saved scenarios yet.

+ + + +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); + } +}