From d387caca865fefe4e944055aff4b730448d4a3ea Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Mon, 6 Jul 2026 22:36:34 -0500 Subject: [PATCH] feat: wire the front controller and harden userToken path --- public/index.php | 126 ++++++++++++++++++++++++++++++-- src/Http/Handlers/GetAssets.php | 6 +- 2 files changed, 126 insertions(+), 6 deletions(-) diff --git a/public/index.php b/public/index.php index 0845a87..c4d07cc 100644 --- a/public/index.php +++ b/public/index.php @@ -2,8 +2,124 @@ 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) file_get_contents('php://input'); + +// 6. Build a Request with the request data, the CSRF secret, and the upload token. +$server = $_SERVER; +$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); +}; + +$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/{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; diff --git a/src/Http/Handlers/GetAssets.php b/src/Http/Handlers/GetAssets.php index ec6b35d..b495284 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'] ?? ''; + if (!preg_match('/^[a-f0-9]{32,}$/', $userToken)) { + return Response::html(404, '

Not found

'); + } + $requestToken = $request->cookies['__uploads_token'] ?? ''; - if ($userToken === '' || $userToken !== $requestToken) { + if ($userToken !== $requestToken) { return Response::html(404, '

Not found

'); }