feat: add home page and asset-serving handlers
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Http\Handlers;
|
||||
|
||||
use BattleForge\Http\Request;
|
||||
use BattleForge\Http\Response;
|
||||
|
||||
final class GetAssets
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $placeholderDir,
|
||||
private readonly string $uploadsRoot,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @param array<string, string> $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, '<h1>Not found</h1>');
|
||||
}
|
||||
|
||||
return $this->serveFrom($this->uploadsRoot . '/' . $userToken . '/' . $filename);
|
||||
}
|
||||
|
||||
return Response::html(404, '<h1>Not found</h1>');
|
||||
}
|
||||
|
||||
private function serveFrom(string $path): Response
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
return Response::html(404, '<h1>Not found</h1>');
|
||||
}
|
||||
|
||||
$body = file_get_contents($path);
|
||||
if ($body === false) {
|
||||
return Response::html(500, '<h1>Read error</h1>');
|
||||
}
|
||||
|
||||
$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<string, string> */
|
||||
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'",
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Http\Handlers;
|
||||
|
||||
use BattleForge\Http\Request;
|
||||
use BattleForge\Http\Response;
|
||||
|
||||
final class GetHomePage
|
||||
{
|
||||
/** @param array<string, string> $params */
|
||||
public function handle(Request $request, array $params): Response
|
||||
{
|
||||
$body = <<<'HTML'
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>BattleForge</title>
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
<meta name="csrf-token" content="{{ csrf }}">
|
||||
</head>
|
||||
<body>
|
||||
<h1>BattleForge</h1>
|
||||
<p><a href="/scenarios/new/edit/team">New scenario</a></p>
|
||||
<h2>Recent scenarios</h2>
|
||||
<div id="recent"><p class="bf-empty">No saved scenarios yet.</p></div>
|
||||
<script type="module" src="/js/storage.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Tests\Integration;
|
||||
|
||||
use BattleForge\Http\Request;
|
||||
use BattleForge\Http\Handlers\GetAssets;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GetAssetsTest extends TestCase
|
||||
{
|
||||
private string $placeholderDir;
|
||||
private string $uploadsDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BattleForge\Tests\Integration;
|
||||
|
||||
use BattleForge\Http\Request;
|
||||
use BattleForge\Http\Response;
|
||||
use BattleForge\Http\Handlers\GetHomePage;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GetHomePageTest extends TestCase
|
||||
{
|
||||
public function testItReturnsTheHomePageWithSecurityHeaders(): void
|
||||
{
|
||||
$handler = new GetHomePage();
|
||||
$request = new Request([], [], [], [], [], '', 'GET', '/', 'text/html', '');
|
||||
$response = $handler->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('<a href="/scenarios/new/edit/team">', $response->body);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user