diff --git a/compose.yaml b/compose.yaml index 6e02160..7735d04 100644 --- a/compose.yaml +++ b/compose.yaml @@ -33,7 +33,11 @@ services: MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} MINIO_ENDPOINT: http://minio:9000 MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9000} - TRUSTED_PROXIES: ${TRUSTED_PROXIES} + # IMPORTANT: Set TRUSTED_PROXIES to your reverse proxy IP in production. + # For Docker on same host, use: 172.17.0.1 (default bridge) or 172.16.0.0/12 (overlay network) + # For Kubernetes or external proxy, use the proxy's IP address. + # WARNING: Using 0.0.0.0/0 is insecure in production environments! + TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1} volumes: - app_var:/app/var - caddy_data:/data diff --git a/config/packages/security.yaml b/config/packages/security.yaml index d730a69..dea6dc7 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -23,12 +23,14 @@ security: auth_code_parameter_name: _auth_code post_only: true default_target_path: MineSeekerBundle_homepage + always_use_default_target_path: false prepare_on_login: true prepare_on_access_denied: true form_login: login_path: MineSeekerBundle_login check_path: MineSeekerBundle_login default_target_path: MineSeekerBundle_homepage + always_use_default_target_path: false username_parameter: _username password_parameter: _password enable_csrf: true diff --git a/src/Controller/MercureController.php b/src/Controller/MercureController.php index 328c0cc..c77993d 100644 --- a/src/Controller/MercureController.php +++ b/src/Controller/MercureController.php @@ -64,7 +64,7 @@ class MercureController extends AbstractController #[Route('/api/game/join/{gameAssoc}', name: 'MineSeekerBundle_api_game_join', methods: ['POST'])] public function join(string $gameAssoc, Request $request): JsonResponse { - $this->topicManager->subscribe($gameAssoc, $this->resolveUserName($request), $this->getUser()); + $this->topicManager->subscribe($gameAssoc, $this->resolveUserName($request), $this->getUser(), $request); return $this->json(['success' => true]); } diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index 9b3fd54..4b244eb 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -17,6 +17,7 @@ use App\Form\ResetPasswordFormType; use App\Repository\UserRepository; use DateTime; use Doctrine\ORM\EntityManagerInterface; +use LogicException; use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\DependencyInjection\Attribute\Autowire; @@ -64,7 +65,7 @@ class SecurityController extends AbstractController #[Route('/logout', name: 'MineSeekerBundle_logout', methods: ['POST'])] public function logout(): never { - throw new \LogicException('This action is intercepted by the security firewall.'); + throw new LogicException('This action is intercepted by the security firewall.'); } #[Route('/register', name: 'MineSeekerBundle_register')] diff --git a/src/Interfaces/TopicManagerInterface.php b/src/Interfaces/TopicManagerInterface.php index 76e1c98..065f382 100644 --- a/src/Interfaces/TopicManagerInterface.php +++ b/src/Interfaces/TopicManagerInterface.php @@ -10,6 +10,7 @@ namespace App\Interfaces; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Security\Core\User\UserInterface; /** @@ -24,7 +25,7 @@ use Symfony\Component\Security\Core\User\UserInterface; */ interface TopicManagerInterface { - public function subscribe(string $gameAssoc, string $userName, ?UserInterface $user): void; + public function subscribe(string $gameAssoc, string $userName, ?UserInterface $user, Request $request): void; public function unSubscribe(string $gameAssoc, string $userName): void; diff --git a/src/Util/TopicManager.php b/src/Util/TopicManager.php index f3caf04..2b492c9 100644 --- a/src/Util/TopicManager.php +++ b/src/Util/TopicManager.php @@ -18,14 +18,15 @@ use App\Entity\User; use App\Interfaces\TopicManagerInterface; use App\Repository\PlayedGameRepository; use App\Repository\UserRepository; -use Liip\ImagineBundle\Imagine\Cache\CacheManager; -use DateTimeInterface; use DateTime; +use DateTimeInterface; use Doctrine\ORM\EntityManagerInterface; use Exception; use JsonException; +use Liip\ImagineBundle\Imagine\Cache\CacheManager; use Psr\Log\LoggerInterface; use RuntimeException; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Mercure\HubInterface; use Symfony\Component\Mercure\Update; use Symfony\Component\Security\Core\User\UserInterface; @@ -52,7 +53,7 @@ readonly class TopicManager implements TopicManagerInterface ) { } - public function subscribe(string $gameAssoc, string $userName, ?UserInterface $user): void + public function subscribe(string $gameAssoc, string $userName, ?UserInterface $user, Request $request): void { $playedGame = $this->getPlayedGame($gameAssoc); if (null === $playedGame) { @@ -70,7 +71,7 @@ readonly class TopicManager implements TopicManagerInterface /** Save the player to the database on a fresh join */ if (!$isKnown && $count < 2) { - $users = $this->saveUserToDb($gameAssoc, $userName, $user, $count + 1); + $users = $this->saveUserToDb($gameAssoc, $userName, $user, $count + 1, $request); $count = $this->getPlayerCount($users); } @@ -168,9 +169,6 @@ readonly class TopicManager implements TopicManagerInterface return $data; } - // ------------------------------------------------------------------ // - // Normal move - // ------------------------------------------------------------------ // $coords = $event['coords']; $player = $event['player']; // 'red' | 'blue' $isBomb = (bool)$event['bomb']; @@ -243,10 +241,6 @@ readonly class TopicManager implements TopicManagerInterface return $data; } - // ------------------------------------------------------------------ // - // Grid helpers - // ------------------------------------------------------------------ // - /** Load the grid rows from the database as a 2-D array. */ private function loadGrid(string $gameAssoc): array { @@ -403,10 +397,6 @@ readonly class TopicManager implements TopicManagerInterface return $mines; } - // ------------------------------------------------------------------ // - // Database helpers - // ------------------------------------------------------------------ // - private function getPlayedGame(string $gameAssoc): ?PlayedGame { return $this->playedGameRepository->findOneByGameAssoc($gameAssoc); @@ -462,13 +452,18 @@ readonly class TopicManager implements TopicManagerInterface } } - private function saveUserToDb(string $gameAssoc, string $userName, ?UserInterface $user, int $count): array - { + private function saveUserToDb( + string $gameAssoc, + string $userName, + ?UserInterface $user, + int $count, + Request $request + ): array { $playedGame = $this->getPlayedGame($gameAssoc); null !== $user ? $this->saveRegisteredUser($userName, $count, $playedGame) - : $this->saveAnonUser($userName, $count, $playedGame); + : $this->saveAnonUser($userName, $count, $playedGame, $request); $this->entityManager->persist($playedGame); $this->entityManager->flush(); @@ -495,11 +490,14 @@ readonly class TopicManager implements TopicManagerInterface } } - private function saveAnonUser(string $userName, int $count, PlayedGame $playedGame): void + private function saveAnonUser(string $userName, int $count, PlayedGame $playedGame, Request $request): void { try { $anon = new Gamer(); - $anon->setUsername($userName); + $anon->setUserName($userName); + $anon->setIp($request->getClientIp()); + $anon->setCountry($this->extractCountry($request)); + $anon->setUserAgent($request->headers->get('User-Agent')); $anon->setConnTimestamp(new DateTime()); $this->entityManager->persist($anon); @@ -518,8 +516,8 @@ readonly class TopicManager implements TopicManagerInterface private function getUserCollection(PlayedGame $playedGame): array { - $redUser = $playedGame->getRed(); - $blueUser = $playedGame->getBlue(); + $redUser = $playedGame->getRed(); + $blueUser = $playedGame->getBlue(); return [ 'red' => null !== $redUser ? $redUser->getUsername() : '', @@ -527,11 +525,11 @@ readonly class TopicManager implements TopicManagerInterface 'redAnon' => null !== $playedGame->getRedAnon() ? $playedGame->getRedAnon()->getUserName() : '', 'blueAnon' => null !== $playedGame->getBlueAnon() ? $playedGame->getBlueAnon()->getUserName() : '', 'redAvatar' => null !== $redUser && null !== $redUser->getAvatarPath() - ? $this->cacheManager->generateUrl($redUser->getAvatarPath(), 'avatar_thumb') - : null, + ? $this->cacheManager->generateUrl($redUser->getAvatarPath(), 'avatar_thumb') + : null, 'blueAvatar' => null !== $blueUser && null !== $blueUser->getAvatarPath() - ? $this->cacheManager->generateUrl($blueUser->getAvatarPath(), 'avatar_thumb') - : null, + ? $this->cacheManager->generateUrl($blueUser->getAvatarPath(), 'avatar_thumb') + : null, ]; } @@ -585,4 +583,27 @@ readonly class TopicManager implements TopicManagerInterface $this->logger->error('Lobby publish error: ' . $e->getMessage()); } } + + private function extractCountry(Request $request): ?string + { + /** Common headers used by CDNs and proxies to pass country information */ + $countryHeaders = [ + 'CF-IPCountry', // Cloudflare + 'CloudFront-Viewer-Country', // AWS CloudFront + 'X-Country-Code', // Custom header + 'X-Geoip-Country', // Generic GeoIP header + ]; + + foreach ($countryHeaders as $header) { + $country = $request->headers->get($header); + + if (empty($country)) { + continue; + } + + return substr($country, 0, 100); + } + + return null; + } }