Files
Keith Solomon 44199ec0a8
CI / php (push) Failing after 1m11s
chore: address Plan 4 deferred-minors (stale-cookie cleanup pass)
- Rename $secret to $csrfSecret in public/index.php to make
  intent clear at every call site; the value is the CSRF/turn-token
  HMAC key, not just 'the secret'.
- Drop the stale 'Configure the router with the eight routes' comment
  in public/index.php; the router now has sixteen routes.
- Drop the duplicate .bf-toast rule in public/assets/styles.css; the
  second declaration (the new yellow box) is the active one, the
  first (legacy green pill) is dead code from the editor era.
- Add a trailing newline to src/Views/home.php.
- Tighten the matchId regex in two tests from {16,} to {16} since
  bin2hex(random_bytes(8)) always produces exactly 16 hex chars; the
  spec's {16,} stays in production TurnToken.

No behavior changes; addresses the deferred-minors parked during
per-task review.
2026-07-26 16:38:05 -05:00

211 lines
9.0 KiB
PHP

<?php
declare(strict_types=1);
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Handlers\GetAssets;
use BattleForge\Http\Handlers\GetBattlefieldEditor;
use BattleForge\Http\Handlers\GetBundledScenario;
use BattleForge\Http\Handlers\GetBundledScenarios;
use BattleForge\Http\Handlers\GetHomePage;
use BattleForge\Http\Handlers\GetMatchView;
use BattleForge\Http\Handlers\GetTeamEditor;
use BattleForge\Http\Handlers\PostBattlefieldEditor;
use BattleForge\Http\Handlers\PostImageUpload;
use BattleForge\Http\Handlers\PostMatchAbility;
use BattleForge\Http\Handlers\PostMatchAttack;
use BattleForge\Http\Handlers\PostMatchEndTurn;
use BattleForge\Http\Handlers\PostMatchMove;
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.
// The app secret reads as the CSRF/turn-token HMAC key. The name below
// (kept for historical reasons) is just the variable label; the same
// value is used for both the CSRF cookie HMAC and the turn-token HMAC.
$csrfSecret = getenv('BATTLEFORGE_SECRET');
if ($csrfSecret === false || $csrfSecret === '') {
$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);
}
$csrfSecret = file_get_contents($secretFile);
if ($csrfSecret === false) {
http_response_code(500);
echo "Failed to read app secret.\n";
return;
}
}
// 2. Ensure the __csrf cookie is set. If absent OR stale, issue a fresh
// token. A cookie pair is "stale" when both cookies are present but
// the HMAC (__csrf) does not match the raw token (__csrf_token) under
// the current secret. This can happen when a browser session predates
// a server restart with a fresh secret.key: the browser still has the
// old __csrf cookie, the new server's secret does not match it, and
// every CSRF check fails. By re-minting on first request, we recover
// the session without requiring the user to clear cookies manually.
// The double-submit pattern stores the raw token in a separate
// readable cookie so client code (meta tag, JS) can echo it back as
// the X-CSRF-Token header. The HMAC cookie is the verification
// oracle; the raw-token cookie rides parallel and is readable by
// document.cookie or the equivalent server-side read.
$cookies = $_COOKIE;
$existingCookie = (string) ($cookies['__csrf'] ?? '');
$existingRawToken = (string) ($cookies['__csrf_token'] ?? '');
$pairIsFresh = false;
if ($existingCookie !== '' && $existingRawToken !== '') {
$expectedHmac = hash_hmac('sha256', $existingRawToken, $csrfSecret);
$pairIsFresh = hash_equals($expectedHmac, $existingCookie);
}
if ($existingCookie === '' || $existingRawToken === '' || !$pairIsFresh) {
[$token, $cookie] = CsrfToken::issue($csrfSecret);
setcookie('__csrf', $cookie, [
'expires' => time() + 86400,
'path' => '/',
'secure' => isset($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Lax',
]);
// The raw token rides in a readable cookie so server-side templates
// can echo it into the <meta name="csrf-token"> tag without keeping
// the raw token in `index.php` per-request state. HttpOnly off so the
// server can read it via the request cookie jar; security comes from
// the HMAC verification, not from hiding the raw token (which the
// browser would already see in the meta tag anyway).
setcookie('__csrf_token', $token, [
'expires' => time() + 86400,
'path' => '/',
'secure' => isset($_SERVER['HTTPS']),
'httponly' => false,
'samesite' => 'Lax',
]);
$existingCookie = $cookie;
$existingRawToken = $token;
$cookies['__csrf'] = $cookie;
$cookies['__csrf_token'] = $token;
}
// 3. Compute the upload-endpoint user token (derived from the same secret).
$uploadsToken = hash_hmac('sha256', $csrfSecret, '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'] = $csrfSecret;
}
$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.
$uploadsRoot = __DIR__ . '/../var/uploads';
$placeholderDir = __DIR__ . '/assets/placeholders';
$scenariosDir = __DIR__ . '/assets/scenarios';
$homePage = static function (Request $r, array $p) use ($scenariosDir): Response {
return (new GetHomePage($scenariosDir))->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'] ?? ''],
);
};
$getBundledList = static function (Request $r, array $p) use ($scenariosDir): Response {
return (new GetBundledScenarios($scenariosDir))->handle($r, $p);
};
$getBundledOne = static function (Request $r, array $p) use ($scenariosDir): Response {
return (new GetBundledScenario($scenariosDir))->handle($r, $p);
};
$getMatchView = static fn (Request $r, array $p): Response => (new GetMatchView())->handle($r, $p);
$postMatchMove = static function (Request $r, array $p) use ($csrfSecret): Response {
return (new PostMatchMove($csrfSecret))->handle($r, $p);
};
$postMatchAttack = static function (Request $r, array $p) use ($csrfSecret): Response {
return (new PostMatchAttack($csrfSecret))->handle($r, $p);
};
$postMatchAbility = static function (Request $r, array $p) use ($csrfSecret): Response {
return (new PostMatchAbility($csrfSecret))->handle($r, $p);
};
$postMatchEndTurn = static function (Request $r, array $p) use ($csrfSecret): Response {
return (new PostMatchEndTurn($csrfSecret))->handle($r, $p);
};
$router = new Router();
$router->add('GET', '/', $homePage);
$router->add('GET', '/scenarios/bundled', $getBundledList);
$router->add('GET', '/scenarios/bundled/{id}', $getBundledOne);
$router->add('GET', '/matches/current', $getMatchView);
$router->add('POST', '/matches/current/move', $postMatchMove);
$router->add('POST', '/matches/current/attack', $postMatchAttack);
$router->add('POST', '/matches/current/ability', $postMatchAbility);
$router->add('POST', '/matches/current/end-turn', $postMatchEndTurn);
$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;