* @category Class * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License * @link www.splendidbear.org * @since 2026. 04. 11. */ #[AsController] class ProfileController extends AbstractController { public function __construct( private readonly PlayedGameRepository $repo, private readonly WebAuthnService $webAuthnService, private readonly LoggerInterface $logger, ) { } #[Route('/profile', name: 'MineSeekerBundle_profile')] public function index(CacheManager $cacheManager): Response { /** @var User $user */ $user = $this->getUser(); $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); $total = $this->repo->countFinishedForUser($user); $wins = $this->repo->countWinsForUser($user); $losses = $this->repo->countLossesForUser($user); $draws = $this->repo->countDrawsForUser($user); /** Build monthly buckets for the last 6 months */ $monthlyData = []; for ($i = 5; $i >= 0; $i--) { $dt = new DateTime("first day of -$i months midnight"); $key = $dt->format('Y-m'); $monthlyData[$key] = ['label' => $dt->format('M'), 'wins' => 0, 'losses' => 0, 'draws' => 0]; } $since = new DateTime('first day of -5 months midnight'); $recentGames = $this->repo->findFinishedForUserSince($user, $since); $userId = $user->getId(); foreach ($recentGames as $game) { if (!$game->getUpdated()) { continue; } $month = $game->getUpdated()->format('Y-m'); if (!isset($monthlyData[$month])) { continue; } $isRed = $game->getRed()?->getId() === $userId; $myPts = $isRed ? $game->getRedPoints() : $game->getBluePoints(); $oppPts = $isRed ? $game->getBluePoints() : $game->getRedPoints(); $resign = $game->getResign(); $myColor = $isRed ? 'red' : 'blue'; $oppColor = $isRed ? 'blue' : 'red'; $result = 'draws'; if ($resign === $myColor) { $result = 'losses'; } elseif ($resign === $oppColor) { $result = 'wins'; } elseif ($myPts !== null && $oppPts !== null) { if ($myPts > $oppPts) $result = 'wins'; elseif ($myPts < $oppPts) $result = 'losses'; } $monthlyData[$month][$result]++; } $months = array_column(array_values($monthlyData), 'label'); return $this->render('Security/profile.html.twig', [ 'stats' => [ 'total' => $total, 'wins' => $wins, 'losses' => $losses, 'draws' => $draws, 'bombs' => $this->repo->countBombsForUser($user), 'winRate' => $total > 0 ? (int)round($wins / $total * 100) : 0, 'avgScore' => $this->repo->findAvgScoreForUser($user), 'bestScore' => $this->repo->findBestScoreForUser($user), ], 'recent' => ($recent = $this->repo->findRecentFinishedForUser($user)), 'gamesData' => array_map(function (PlayedGame $game) use ($userId, $cacheManager): array { $isRed = $game->getRed()?->getId() === $userId; $resign = $game->getResign(); $myColor = $isRed ? 'red' : 'blue'; $oppColor = $isRed ? 'blue' : 'red'; $myPts = $isRed ? $game->getRedPoints() : $game->getBluePoints(); $oppPts = $isRed ? $game->getBluePoints() : $game->getRedPoints(); $result = 'draw'; if ($resign === $myColor) $result = 'loss'; elseif ($resign === $oppColor) $result = 'win'; elseif ($myPts !== null && $oppPts !== null) { if ($myPts > $oppPts) $result = 'win'; elseif ($myPts < $oppPts) $result = 'loss'; } $redAvatarPath = $game->getRed()?->getAvatarPath(); $blueAvatarPath = $game->getBlue()?->getAvatarPath(); return [ 'id' => $game->getId(), 'uuid' => $game->getUuid()?->toRfc4122(), 'redName' => $game->getRed()?->getUsername() ?? $game->getRedAnon()?->getUserName() ?? 'Guest', 'blueName' => $game->getBlue()?->getUsername() ?? $game->getBlueAnon()?->getUserName() ?? 'Guest', 'redAvatar' => $redAvatarPath ? $cacheManager->generateUrl($redAvatarPath, 'avatar_thumb') : null, 'blueAvatar' => $blueAvatarPath ? $cacheManager->generateUrl($blueAvatarPath, 'avatar_thumb') : null, 'redPoints' => $game->getRedPoints(), 'bluePoints' => $game->getBluePoints(), 'redExplodedBomb' => $game->getRedExplodedBomb(), 'blueExplodedBomb' => $game->getBlueExplodedBomb(), 'resign' => $resign, 'created' => $game->getCreated()?->format('Y-m-d H:i'), 'date' => $game->getUpdated()?->format('Y-m-d H:i'), 'isRed' => $isRed, 'result' => $result, 'myPoints' => $myPts, 'oppPoints' => $oppPts, 'redBonusPoints' => $game->getRedBonusPoints() ?? 0, 'blueBonusPoints' => $game->getBlueBonusPoints() ?? 0, 'redBonusStats' => $game->getRedBonusStats() ?? [], 'blueBonusStats' => $game->getBlueBonusStats() ?? [], ]; }, $recent), 'chartData' => [ 'months' => $months, 'wins' => array_column(array_values($monthlyData), 'wins'), 'losses' => array_column(array_values($monthlyData), 'losses'), 'draws' => array_column(array_values($monthlyData), 'draws'), 'pieWins' => $wins, 'pieLosses' => $losses, 'pieDraws' => $draws, ], ]); } #[Route( '/battle/{uuid}', name: 'MineSeekerBundle_battle_share', requirements: ['uuid' => '[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'], methods: ['GET'], )] public function battleShare(Uuid $uuid): Response { $game = $this->repo->findOneBy(['uuid' => $uuid]); if (!$game) { throw $this->createNotFoundException('Battle not found.'); } $redName = $game->getRed()?->getUsername() ?? ($game->getRedAnon() !== null ? 'Anonymous' : 'Guest'); $blueName = $game->getBlue()?->getUsername() ?? ($game->getBlueAnon() !== null ? 'Anonymous' : 'Guest'); $redPts = $game->getRedPoints(); $bluePts = $game->getBluePoints(); $resign = $game->getResign(); $redAvatar = $game->getRed()?->getAvatarPath(); $blueAvatar = $game->getBlue()?->getAvatarPath(); $redBonusPoints = $game->getRedBonusPoints() ?? 0; $blueBonusPoints = $game->getBlueBonusPoints() ?? 0; $redBonusStats = $game->getRedBonusStats() ?? []; $blueBonusStats = $game->getBlueBonusStats() ?? []; if ($resign === 'red') { $summary = "$redName resigned — $blueName wins"; } elseif ($resign === 'blue') { $summary = "$blueName resigned — $redName wins"; } elseif ($redPts !== null && $bluePts !== null) { if ($redPts > $bluePts) { $summary = "$redName defeated $blueName ($redPts – $bluePts)"; } elseif ($bluePts > $redPts) { $summary = "$blueName defeated $redName ($bluePts – $redPts)"; } else { $summary = "$redName and $blueName drew ($redPts – $bluePts)"; } } else { $summary = "$redName vs $blueName"; } return $this->render('Game/battle_share.html.twig', [ 'game' => $game, 'redName' => $redName, 'blueName' => $blueName, 'redPts' => $redPts, 'bluePts' => $bluePts, 'resign' => $resign, 'redAvatar' => $redAvatar, 'blueAvatar' => $blueAvatar, 'redBonusPoints' => $redBonusPoints, 'blueBonusPoints' => $blueBonusPoints, 'redBonusStats' => $redBonusStats, 'blueBonusStats' => $blueBonusStats, 'ogTitle' => "MineSeeker · $summary", 'ogDesc' => "Watch the battle replay: $summary — played on MineSeeker, the multiplayer minesweeper.", ]); } #[Route( '/og/battle/{uuid}.png', name: 'MineSeekerBundle_og_battle', requirements: ['uuid' => '[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'], methods: ['GET'], )] public function battleOgImage(Uuid $uuid, BattleCardGenerator $generator): BinaryFileResponse { $game = $this->repo->findOneBy(['uuid' => $uuid]); if (!$game) { throw $this->createNotFoundException(); } $path = $generator->generate($game); $response = new BinaryFileResponse($path); $response->headers->set('Content-Type', 'image/png'); $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE); $response->setMaxAge(86400 * 30); $response->setPublic(); return $response; } #[Route('/profile/avatar', name: 'MineSeekerBundle_profile_avatar', methods: ['POST'])] public function uploadAvatar( Request $request, EntityManagerInterface $em, CacheManager $cacheManager, #[Autowire(service: 'mineseeker.media.storage')] FilesystemOperator $mediaStorage, ): JsonResponse { $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); /** @var User $user */ $user = $this->getUser(); $file = $request->files->get('avatar'); if (!$file instanceof UploadedFile) { return $this->json(['error' => 'No file uploaded.'], 400); } if ($file->getSize() > 2 * 1024 * 1024) { return $this->json(['error' => 'File is too large. Maximum 2 MB.'], 400); } $allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; if (!in_array($file->getMimeType(), $allowed, true)) { return $this->json(['error' => 'Invalid type. Allowed: JPEG, PNG, GIF, WEBP.'], 400); } $ext = $file->guessExtension() ?? 'jpg'; $newPath = sprintf('avatar/%s.%s', Uuid::v4()->toRfc4122(), $ext); $oldPath = $user->getAvatarPath(); /** Remove old file and any cached thumbnails */ if ($oldPath) { try { $mediaStorage->delete($oldPath); } catch (Throwable) { $this->logger->error('Unable to delete old avatar: ' . $oldPath); } $cacheManager->remove($oldPath, 'avatar_thumb'); } /** Upload original to MinIO media/avatar/ */ $stream = fopen($file->getPathname(), 'rb'); try { $mediaStorage->writeStream($newPath, $stream); } catch (FilesystemException $e) { $this->logger->error('Unable to write new avatar: ' . $e->getMessage()); throw new RuntimeException('Unable to write new avatar: ' . $e->getMessage()); } fclose($stream); $user->setAvatarPath($newPath); $em->flush(); return $this->json([ 'thumbUrl' => $cacheManager->generateUrl($newPath, 'avatar_thumb'), ]); } #[Route('/profile/security', name: 'MineSeekerBundle_profile_security')] public function security(): Response { /** @var User $user */ $user = $this->getUser(); $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); $credentials = $this->webAuthnService->getCredentialsForUser($user); $credentialsData = array_map(fn($cred) => [ 'id' => $cred->getId(), 'credentialName' => $cred->getCredentialName(), 'createdAt' => $cred->getCreatedAt()?->format('Y-m-d H:i:s'), 'lastUsedAt' => $cred->getLastUsedAt()?->format('Y-m-d H:i:s'), 'isBackupEligible' => $cred->isBackupEligible(), 'isBackupAuthenticated' => $cred->isBackupAuthenticated(), ], $credentials); return $this->render('Security/profile_security.html.twig', [ 'credentials' => $credentialsData, 'isTotpEnabled' => $user->isTotpAuthenticationEnabled(), 'backupCodesCount' => count($user->getBackupCodes()), ]); } }