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