* @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 LoggerInterface $logger, private readonly PlayedGameRepository $repo, private readonly UserStatsRepository $userStatsRepo, private readonly RecentBattleRepository $recentBattleRepo, private readonly WebAuthnService $webAuthnService, private readonly ProfileGameDtoFactory $profileGameDtoFactory, private readonly ProfileChartDataFactory $profileChartDataFactory, ) { } #[Route('/profile', name: 'MineSeekerBundle_profile')] public function index(): Response { /** @var User $user */ $user = $this->getUser(); $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); $userId = $user->id; $stats = ProfileStatsDto::fromUserStats($this->userStatsRepo->findByUserId($userId)); $recent = $this->recentBattleRepo->findRecentForUser($userId, 30); $gamesData = array_map( fn(RecentBattle $battle): ProfileGameDto => $this->profileGameDtoFactory->createFromRecentBattle($battle), $recent, ); $chartData = $this->profileChartDataFactory->buildChartData($user, $userId, $stats); $view = new ProfileViewDto( stats: $stats, recent: $recent, gamesData: $gamesData, chartData: $chartData, ); return $this->render('Security/profile.html.twig', $view->toTemplateContext()); } #[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->findOneByUuid($uuid); if (!$game) { throw $this->createNotFoundException('Battle not found.'); } return $this->render('Game/battle_share.html.twig', BattleShareDto::fromPlayedGame($game)->toTemplateContext()); } #[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, FilterService $filterService, #[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() > 10 * 1024 * 1024) { return $this->json(['error' => 'File is too large. Maximum 10 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->avatarPath; /** 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()); fclose($stream); throw new RuntimeException('Unable to write new avatar: ' . $e->getMessage()); } fclose($stream); $user->avatarPath = $newPath; $em->flush(); return $this->json([ 'thumbUrl' => $filterService->getUrlOfFilteredImage($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->id, 'credentialName' => $cred->credentialName, 'createdAt' => $cred->createdAt?->format('Y-m-d H:i:s'), 'lastUsedAt' => $cred->lastUsedAt?->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->backupCodes), ]); } }