221 lines
8.1 KiB
PHP
221 lines
8.1 KiB
PHP
<?php declare(strict_types=1);
|
|
/*
|
|
* This file is part of the SplendidBear Websites' projects.
|
|
*
|
|
* Copyright (c) 2026 @ www.splendidbear.org
|
|
*
|
|
* For the full copyright and license information, please view the LICENSE
|
|
* file that was distributed with this source code.
|
|
*/
|
|
|
|
namespace App\Controller;
|
|
|
|
use App\Dto\BattleShareDto;
|
|
use App\Dto\ProfileChartDataFactory;
|
|
use App\Dto\ProfileGameDto;
|
|
use App\Dto\ProfileGameDtoFactory;
|
|
use App\Dto\ProfileStatsDto;
|
|
use App\Dto\ProfileViewDto;
|
|
use App\Entity\User;
|
|
use App\Entity\RecentBattle;
|
|
use App\Repository\PlayedGameRepository;
|
|
use App\Repository\RecentBattleRepository;
|
|
use App\Repository\UserStatsRepository;
|
|
use App\Service\BattleCardGenerator;
|
|
use App\Service\WebAuthnService;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use League\Flysystem\FilesystemException;
|
|
use League\Flysystem\FilesystemOperator;
|
|
use Liip\ImagineBundle\Imagine\Cache\CacheManager;
|
|
use Psr\Log\LoggerInterface;
|
|
use RuntimeException;
|
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
|
use Symfony\Component\HttpKernel\Attribute\AsController;
|
|
use Symfony\Component\Routing\Attribute\Route;
|
|
use Symfony\Component\Uid\Uuid;
|
|
use Throwable;
|
|
use function count;
|
|
|
|
/**
|
|
* Class ProfileController
|
|
*
|
|
* @package App\Controller
|
|
* @author Lang <https://www.splendidbear.org>
|
|
* @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,
|
|
#[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->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());
|
|
throw new RuntimeException('Unable to write new avatar: ' . $e->getMessage());
|
|
}
|
|
fclose($stream);
|
|
|
|
$user->avatarPath = $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->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),
|
|
]);
|
|
}
|
|
}
|