diff --git a/public/index.php b/public/index.php
index 0845a87..37a9d30 100644
--- a/public/index.php
+++ b/public/index.php
@@ -2,8 +2,136 @@
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) ($_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;
+if (!isset($server['__csrf_secret'])) {
+ $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);
+};
+$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);
+$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/uploads/{userToken}/{filename}', $getUploadedAsset);
+$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;
+
+return $response->status;
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 ec6b35d..1696085 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'] ?? '';
- $requestToken = $request->cookies['__uploads_token'] ?? '';
+ if (!preg_match('/^[a-f0-9]{32,}$/', $userToken)) {
+ return Response::html(404, '
Not found
');
+ }
- if ($userToken === '' || $userToken !== $requestToken) {
+ $requestToken = (string) ($request->server['__uploads_token'] ?? '');
+
+ if ($userToken !== $requestToken) {
return Response::html(404, 'Not found
');
}
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/GetHomePage.php b/src/Http/Handlers/GetHomePage.php
index 82cd9be..32275ef 100644
--- a/src/Http/Handlers/GetHomePage.php
+++ b/src/Http/Handlers/GetHomePage.php
@@ -12,30 +12,12 @@ final class GetHomePage
/** @param array $params */
public function handle(Request $request, array $params): Response
{
- $body = <<<'HTML'
-
-
-
-
- BattleForge
-
-
-
-
- BattleForge
- New scenario
- Recent scenarios
-
-
-
-
-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);
}
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/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/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/src/Http/Handlers/PostTeamEditor.php b/src/Http/Handlers/PostTeamEditor.php
new file mode 100644
index 0000000..3b39eb3
--- /dev/null
+++ b/src/Http/Handlers/PostTeamEditor.php
@@ -0,0 +1,87 @@
+ $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);
+ $safeUrl = json_encode($url, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
+ $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/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: = $e($scenarioId) ?>
+
+
+ Escape::html($v);
+ $a = static fn (string $v): string => Escape::attr($v);
+ ?>
+ BattleForge
+ New scenario
+ Recent scenarios
+
+
+ ` 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) ?>
+
+
+ = $extraHead /* trusted raw HTML for the optional extra-head block */ ?>
+
+
+
+
+
+ $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
+
+
+
+
+ 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'] ?? []);
+
+ // 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);
+ }
+ foreach (glob($uploadsRoot . '/*') as $dir) {
+ @rmdir($dir);
+ }
+ }
+ }
+ }
+
+ 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;
+
+ 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;
+ }
+}
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/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/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/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,
+ ];
+ }
+}
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,
+ ];
+ }
+}
diff --git a/tests/Integration/PostTeamEditorTest.php b/tests/Integration/PostTeamEditorTest.php
new file mode 100644
index 0000000..9a6fb0c
--- /dev/null
+++ b/tests/Integration/PostTeamEditorTest.php
@@ -0,0 +1,127 @@
+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);
+ }
+
+ 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 tag escaped to <\/.
+ self::assertStringContainsString('<\\/script>