eerrrrrr
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 1m57s

This commit is contained in:
2025-11-27 23:42:17 +00:00
parent 054e970df9
commit 1c98a634c3
50 changed files with 1666 additions and 593 deletions

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Command;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'app:promote-moderator', description: 'Grant ROLE_MODERATOR to a user by email')]
class PromoteModeratorCommand extends Command
{
/**
* Stores dependencies for the console handler.
*/
public function __construct(private readonly UserRepository $users, private readonly EntityManagerInterface $em)
{
parent::__construct();
}
/**
* Declares the required email argument.
*/
protected function configure(): void
{
$this->addArgument('email', InputArgument::REQUIRED, 'Email of the user to promote');
}
/**
* Grants the moderator role if the user exists.
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$email = (string) $input->getArgument('email');
$user = $this->users->findOneByEmail($email);
if (!$user) {
$output->writeln('<error>User not found: ' . $email . '</error>');
return Command::FAILURE;
}
$roles = $user->getRoles();
if (!in_array('ROLE_MODERATOR', $roles, true)) {
$roles[] = 'ROLE_MODERATOR';
$user->setRoles($roles);
$this->em->flush();
}
$output->writeln('<info>Granted ROLE_MODERATOR to ' . $email . '</info>');
return Command::SUCCESS;
}
}

View File

@@ -4,7 +4,6 @@ namespace App\Controller;
use App\Entity\User;
use App\Form\ProfileFormType;
use App\Form\ChangePasswordFormType;
use App\Repository\ReviewRepository;
use App\Repository\AlbumRepository;
use App\Service\ImageStorage;
@@ -42,7 +41,13 @@ class AccountController extends AbstractController
->where('a.source = :src')->setParameter('src', 'user')
->andWhere('a.createdBy = :u')->setParameter('u', $user)
->getQuery()->getSingleScalarResult();
$userType = $this->isGranted('ROLE_ADMIN') ? 'Admin' : 'User';
if ($this->isGranted('ROLE_ADMIN')) {
$userType = 'Admin';
} elseif ($this->isGranted('ROLE_MODERATOR')) {
$userType = 'Moderator';
} else {
$userType = 'User';
}
$userReviews = $reviews->createQueryBuilder('r')
->where('r.author = :u')->setParameter('u', $user)
->orderBy('r.createdAt', 'DESC')
@@ -84,9 +89,10 @@ class AccountController extends AbstractController
$current = (string) $form->get('currentPassword')->getData();
if ($current === '' || !$hasher->isPasswordValid($user, $current)) {
$form->get('currentPassword')->addError(new FormError('Current password is incorrect.'));
return $this->render('account/profile.html.twig', [
'form' => $form->createView(),
]);
return $this->render('account/profile.html.twig', [
'form' => $form->createView(),
'profileImage' => $user->getProfileImagePath(),
]);
}
$user->setPassword($hasher->hashPassword($user, $newPassword));
}
@@ -116,35 +122,6 @@ class AccountController extends AbstractController
{
return $this->render('account/settings.html.twig');
}
/**
* Validates the password change form and updates the hash.
*/
#[Route('/account/password', name: 'account_password', methods: ['GET', 'POST'])]
public function changePassword(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $hasher): Response
{
/** @var User $user */
$user = $this->getUser();
$form = $this->createForm(ChangePasswordFormType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$current = (string) $form->get('currentPassword')->getData();
if ($current === '' || !$hasher->isPasswordValid($user, $current)) {
$form->get('currentPassword')->addError(new FormError('Current password is incorrect.'));
return $this->render('account/password.html.twig', [
'form' => $form->createView(),
]);
}
$newPassword = (string) $form->get('newPassword')->getData();
$user->setPassword($hasher->hashPassword($user, $newPassword));
$em->flush();
$this->addFlash('success', 'Password updated.');
return $this->redirectToRoute('account_dashboard');
}
return $this->render('account/password.html.twig', [
'form' => $form->createView(),
]);
}
}

View File

@@ -13,7 +13,7 @@ use Symfony\Component\Routing\Attribute\Route;
/**
* DashboardController shows high-level site activity to admins.
*/
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_MODERATOR')]
class DashboardController extends AbstractController
{
/**

View File

@@ -4,6 +4,7 @@ namespace App\Controller\Admin;
use App\Form\SiteSettingsType;
use App\Repository\SettingRepository;
use App\Service\RegistrationToggle;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
use Symfony\Component\HttpFoundation\Request;
@@ -20,22 +21,31 @@ class SettingsController extends AbstractController
* Displays and persists Spotify credential settings.
*/
#[Route('/admin/settings', name: 'admin_settings', methods: ['GET', 'POST'])]
public function settings(Request $request, SettingRepository $settings): Response
public function settings(Request $request, SettingRepository $settings, RegistrationToggle $registrationToggle): Response
{
$form = $this->createForm(SiteSettingsType::class);
$form->get('SPOTIFY_CLIENT_ID')->setData($settings->getValue('SPOTIFY_CLIENT_ID'));
$form->get('SPOTIFY_CLIENT_SECRET')->setData($settings->getValue('SPOTIFY_CLIENT_SECRET'));
$registrationOverride = $registrationToggle->envOverride();
$form->get('REGISTRATION_ENABLED')->setData($registrationToggle->isEnabled());
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$settings->setValue('SPOTIFY_CLIENT_ID', (string) $form->get('SPOTIFY_CLIENT_ID')->getData());
$settings->setValue('SPOTIFY_CLIENT_SECRET', (string) $form->get('SPOTIFY_CLIENT_SECRET')->getData());
if ($registrationOverride === null) {
$registrationToggle->persist((bool) $form->get('REGISTRATION_ENABLED')->getData());
} else {
$this->addFlash('info', 'Registration is locked by APP_ALLOW_REGISTRATION and cannot be changed.');
}
$this->addFlash('success', 'Settings saved.');
return $this->redirectToRoute('admin_settings');
}
return $this->render('admin/settings.html.twig', [
'form' => $form->createView(),
'registrationImmutable' => $registrationOverride !== null,
'registrationOverrideValue' => $registrationOverride,
]);
}
}

View File

@@ -0,0 +1,121 @@
<?php
namespace App\Controller\Admin;
use App\Dto\AdminUserData;
use App\Entity\User;
use App\Form\AdminUserType;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
/**
* UserController exposes moderator/admin user management tools.
*/
#[IsGranted('ROLE_MODERATOR')]
class UserController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly UserPasswordHasherInterface $hasher,
) {
}
/**
* Lists all users and handles manual account creation.
*/
#[Route('/admin/users', name: 'admin_users', methods: ['GET', 'POST'])]
public function index(Request $request, UserRepository $users): Response
{
$formData = new AdminUserData();
$form = $this->createForm(AdminUserType::class, $formData);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$plainPassword = (string) $form->get('plainPassword')->getData();
$newUser = new User();
$newUser->setEmail($formData->email);
$newUser->setDisplayName($formData->displayName);
$newUser->setPassword($this->hasher->hashPassword($newUser, $plainPassword));
$this->em->persist($newUser);
$this->em->flush();
$this->addFlash('success', 'User account created.');
return $this->redirectToRoute('admin_users');
}
return $this->render('admin/users.html.twig', [
'form' => $form->createView(),
'rows' => $users->findAllWithStats(),
]);
}
/**
* Deletes a user account (moderators cannot delete admins).
*/
#[Route('/admin/users/{id}/delete', name: 'admin_users_delete', methods: ['POST'])]
public function delete(User $target, Request $request): Response
{
if (!$this->isCsrfTokenValid('delete-user-' . $target->getId(), (string) $request->request->get('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
/** @var User|null $current */
$current = $this->getUser();
if ($current && $target->getId() === $current->getId()) {
$this->addFlash('danger', 'You cannot delete your own account.');
return $this->redirectToRoute('admin_users');
}
if (in_array('ROLE_ADMIN', $target->getRoles(), true)) {
$this->addFlash('danger', 'Administrators cannot delete other administrators.');
return $this->redirectToRoute('admin_users');
}
$this->em->remove($target);
$this->em->flush();
$this->addFlash('success', 'User deleted.');
return $this->redirectToRoute('admin_users');
}
/**
* Promotes a user to moderator (admins only).
*/
#[Route('/admin/users/{id}/promote', name: 'admin_users_promote', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function promote(User $target, Request $request): Response
{
if (!$this->isCsrfTokenValid('promote-user-' . $target->getId(), (string) $request->request->get('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$roles = $target->getRoles();
if (in_array('ROLE_ADMIN', $roles, true)) {
$this->addFlash('danger', 'Administrators already include moderator permissions.');
return $this->redirectToRoute('admin_users');
}
$isModerator = in_array('ROLE_MODERATOR', $roles, true);
if ($isModerator) {
$filtered = array_values(array_filter($roles, static fn(string $role) => $role !== 'ROLE_MODERATOR'));
$target->setRoles($filtered);
$this->em->flush();
$this->addFlash('success', 'Moderator privileges removed.');
} else {
$roles[] = 'ROLE_MODERATOR';
$target->setRoles(array_values(array_unique($roles)));
$this->em->flush();
$this->addFlash('success', 'User promoted to moderator.');
}
return $this->redirectToRoute('admin_users');
}
}

View File

@@ -2,15 +2,17 @@
namespace App\Controller;
use App\Service\SpotifyClient;
use App\Service\ImageStorage;
use App\Repository\AlbumRepository;
use App\Dto\AlbumSearchCriteria;
use App\Entity\Album;
use App\Entity\Review;
use App\Entity\User;
use App\Form\ReviewType;
use App\Form\AlbumType;
use App\Repository\AlbumRepository;
use App\Repository\ReviewRepository;
use App\Service\AlbumSearchService;
use App\Service\ImageStorage;
use App\Service\SpotifyClient;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\UploadedFile;
@@ -19,7 +21,6 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
use Psr\Log\LoggerInterface;
/**
* AlbumController orchestrates search, CRUD, and review entry on albums.
@@ -28,6 +29,7 @@ class AlbumController extends AbstractController
{
public function __construct(
private readonly ImageStorage $imageStorage,
private readonly AlbumSearchService $albumSearch,
private readonly int $searchLimit = 20
) {
}
@@ -36,37 +38,21 @@ class AlbumController extends AbstractController
* Searches Spotify plus local albums and decorates results with review stats.
*/
#[Route('/', name: 'album_search', methods: ['GET'])]
public function search(Request $request, SpotifyClient $spotify, ReviewRepository $reviewRepo, AlbumRepository $albumRepo, EntityManagerInterface $em, LoggerInterface $logger): Response
public function search(Request $request): Response
{
$filters = $this->buildSearchFilters($request);
$stats = [];
$savedIds = [];
$spotifyData = $this->resolveSpotifyAlbums($filters, $spotify, $albumRepo, $reviewRepo, $em, $logger);
$stats = $this->mergeStats($stats, $spotifyData['stats']);
$savedIds = $this->mergeSavedIds($savedIds, $spotifyData['savedIds']);
$userData = $this->resolveUserAlbums($filters, $albumRepo, $reviewRepo);
$stats = $this->mergeStats($stats, $userData['stats']);
$albums = $this->composeAlbumList(
$filters['source'],
$userData['payloads'],
$spotifyData['payloads'],
$filters['limit']
);
$savedIds = $this->mergeSavedIds($savedIds, []);
$criteria = AlbumSearchCriteria::fromRequest($request, $this->searchLimit);
$result = $this->albumSearch->search($criteria);
return $this->render('album/search.html.twig', [
'query' => $filters['query'],
'album' => $filters['albumName'],
'artist' => $filters['artist'],
'year_from' => $filters['yearFrom'] ?: '',
'year_to' => $filters['yearTo'] ?: '',
'albums' => $albums,
'stats' => $stats,
'savedIds' => $savedIds,
'source' => $filters['source'],
'query' => $criteria->query,
'album' => $criteria->albumName,
'artist' => $criteria->artist,
'year_from' => $criteria->yearFrom ?? '',
'year_to' => $criteria->yearTo ?? '',
'albums' => $result->albums,
'stats' => $result->stats,
'savedIds' => $result->savedIds,
'source' => $criteria->source,
]);
}
@@ -291,7 +277,7 @@ class AlbumController extends AbstractController
*/
private function canManageAlbum(Album $album): bool
{
if ($this->isGranted('ROLE_ADMIN')) {
if ($this->isGranted('ROLE_MODERATOR')) {
return true;
}
return $album->getSource() === 'user' && $this->isAlbumOwner($album);
@@ -348,309 +334,6 @@ class AlbumController extends AbstractController
}
}
/**
* @return array{
* query:string,
* albumName:string,
* artist:string,
* source:string,
* yearFrom:int,
* yearTo:int,
* limit:int,
* spotifyQuery:string,
* hasUserFilters:bool,
* useSpotify:bool
* }
*/
private function buildSearchFilters(Request $request): array
{
$query = trim((string) $request->query->get('q', ''));
$albumName = trim($request->query->getString('album', ''));
$artist = trim($request->query->getString('artist', ''));
$source = $request->query->getString('source', 'all');
$yearFromRaw = trim((string) $request->query->get('year_from', ''));
$yearToRaw = trim((string) $request->query->get('year_to', ''));
$yearFrom = (preg_match('/^\d{4}$/', $yearFromRaw)) ? (int) $yearFromRaw : 0;
$yearTo = (preg_match('/^\d{4}$/', $yearToRaw)) ? (int) $yearToRaw : 0;
$spotifyQuery = $this->buildSpotifyQuery($query, $albumName, $artist, $yearFrom, $yearTo);
$hasUserFilters = ($spotifyQuery !== '' || $albumName !== '' || $artist !== '' || $yearFrom > 0 || $yearTo > 0);
$useSpotify = ($source === 'all' || $source === 'spotify');
return [
'query' => $query,
'albumName' => $albumName,
'artist' => $artist,
'source' => $source,
'yearFrom' => $yearFrom,
'yearTo' => $yearTo,
'limit' => $this->searchLimit,
'spotifyQuery' => $spotifyQuery,
'hasUserFilters' => $hasUserFilters,
'useSpotify' => $useSpotify,
];
}
private function buildSpotifyQuery(string $query, string $albumName, string $artist, int $yearFrom, int $yearTo): string
{
$parts = [];
if ($albumName !== '') { $parts[] = 'album:' . $albumName; }
if ($artist !== '') { $parts[] = 'artist:' . $artist; }
if ($yearFrom > 0 || $yearTo > 0) {
if ($yearFrom > 0 && $yearTo > 0 && $yearTo >= $yearFrom) {
$parts[] = 'year:' . $yearFrom . '-' . $yearTo;
} else {
$y = $yearFrom > 0 ? $yearFrom : $yearTo;
$parts[] = 'year:' . $y;
}
}
if ($query !== '') { $parts[] = $query; }
return implode(' ', $parts);
}
/**
* @return array{payloads:array<int,array>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
*/
private function resolveSpotifyAlbums(
array $filters,
SpotifyClient $spotify,
AlbumRepository $albumRepo,
ReviewRepository $reviewRepo,
EntityManagerInterface $em,
LoggerInterface $logger
): array {
if (!$filters['useSpotify'] || $filters['spotifyQuery'] === '') {
return ['payloads' => [], 'stats' => [], 'savedIds' => []];
}
$stored = $albumRepo->searchSpotifyAlbums(
$filters['spotifyQuery'],
$filters['albumName'],
$filters['artist'],
$filters['yearFrom'],
$filters['yearTo'],
$filters['limit']
);
$storedPayloads = array_map(static fn($a) => $a->toTemplateArray(), $stored);
$storedIds = $this->collectSpotifyIds($stored);
$stats = $storedIds ? $reviewRepo->getAggregatesForAlbumIds($storedIds) : [];
$savedIds = $storedIds;
if (count($stored) >= $filters['limit']) {
return [
'payloads' => array_slice($storedPayloads, 0, $filters['limit']),
'stats' => $stats,
'savedIds' => $savedIds,
];
}
$apiPayloads = $this->fetchSpotifyPayloads(
$filters,
$spotify,
$albumRepo,
$reviewRepo,
$em,
$logger
);
$payloads = $this->mergePayloadLists($apiPayloads['payloads'], $storedPayloads, $filters['limit']);
$stats = $this->mergeStats($stats, $apiPayloads['stats']);
$savedIds = $this->mergeSavedIds($savedIds, $apiPayloads['savedIds']);
return ['payloads' => $payloads, 'stats' => $stats, 'savedIds' => $savedIds];
}
/**
* @return array{payloads:array<int,array>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
*/
private function fetchSpotifyPayloads(
array $filters,
SpotifyClient $spotify,
AlbumRepository $albumRepo,
ReviewRepository $reviewRepo,
EntityManagerInterface $em,
LoggerInterface $logger
): array {
$result = $spotify->searchAlbums($filters['spotifyQuery'], $filters['limit']);
$searchItems = $result['albums']['items'] ?? [];
$logger->info('Album search results received', ['query' => $filters['spotifyQuery'], 'items' => is_countable($searchItems) ? count($searchItems) : 0]);
if (!$searchItems) {
return ['payloads' => [], 'stats' => [], 'savedIds' => []];
}
$ids = $this->extractSpotifyIds($searchItems);
if ($ids === []) {
return ['payloads' => [], 'stats' => [], 'savedIds' => []];
}
$full = $spotify->getAlbums($ids);
$albumsPayload = is_array($full) ? ($full['albums'] ?? []) : [];
if ($albumsPayload === [] && $searchItems !== []) {
$albumsPayload = $searchItems;
$logger->warning('Spotify getAlbums returned empty; falling back to search items', ['count' => count($albumsPayload)]);
}
$upserted = 0;
foreach ($albumsPayload as $sa) {
$albumRepo->upsertFromSpotifyAlbum((array) $sa);
$upserted++;
}
$em->flush();
$logger->info('Albums upserted to DB', ['upserted' => $upserted]);
$existing = $albumRepo->findBySpotifyIdsKeyed($ids);
$payloads = [];
foreach ($ids as $sid) {
if (isset($existing[$sid])) {
$payloads[] = $existing[$sid]->toTemplateArray();
}
}
$stats = $reviewRepo->getAggregatesForAlbumIds($ids);
return [
'payloads' => $payloads,
'stats' => $stats,
'savedIds' => array_keys($existing),
];
}
/**
* @return array{payloads:array<int,array>,stats:array<string,array{count:int,avg:float}>}
*/
private function resolveUserAlbums(array $filters, AlbumRepository $albumRepo, ReviewRepository $reviewRepo): array
{
if (!$filters['hasUserFilters'] || ($filters['source'] !== 'user' && $filters['source'] !== 'all')) {
return ['payloads' => [], 'stats' => []];
}
$userAlbums = $albumRepo->searchUserAlbums(
$filters['query'],
$filters['albumName'],
$filters['artist'],
$filters['yearFrom'],
$filters['yearTo'],
$filters['limit']
);
if ($userAlbums === []) {
return ['payloads' => [], 'stats' => []];
}
$entityIds = array_values(array_map(static fn($a) => $a->getId(), $userAlbums));
$userStats = $reviewRepo->getAggregatesForAlbumEntityIds($entityIds);
$payloads = array_map(static fn($a) => $a->toTemplateArray(), $userAlbums);
return ['payloads' => $payloads, 'stats' => $this->mapUserStatsToLocalIds($userAlbums, $userStats)];
}
/**
* @param list<Album> $userAlbums
* @param array<int,array{count:int,avg:float}> $userStats
* @return array<string,array{count:int,avg:float}>
*/
private function mapUserStatsToLocalIds(array $userAlbums, array $userStats): array
{
$mapped = [];
foreach ($userAlbums as $album) {
$entityId = (int) $album->getId();
$localId = (string) $album->getLocalId();
if ($localId !== '' && isset($userStats[$entityId])) {
$mapped[$localId] = $userStats[$entityId];
}
}
return $mapped;
}
private function composeAlbumList(string $source, array $userPayloads, array $spotifyPayloads, int $limit): array
{
if ($source === 'user') {
return array_slice($userPayloads, 0, $limit);
}
if ($source === 'spotify') {
return array_slice($spotifyPayloads, 0, $limit);
}
return array_slice(array_merge($userPayloads, $spotifyPayloads), 0, $limit);
}
/**
* @param list<Album> $albums
* @return list<string>
*/
private function collectSpotifyIds(array $albums): array
{
$ids = [];
foreach ($albums as $album) {
$sid = (string) $album->getSpotifyId();
if ($sid !== '') {
$ids[] = $sid;
}
}
return array_values(array_unique($ids));
}
/**
* @param array<int,mixed> $searchItems
* @return list<string>
*/
private function extractSpotifyIds(array $searchItems): array
{
$ids = [];
foreach ($searchItems as $item) {
$id = isset($item['id']) ? (string) $item['id'] : '';
if ($id !== '') {
$ids[] = $id;
}
}
return array_values(array_unique($ids));
}
private function mergeStats(array $current, array $updates): array
{
foreach ($updates as $key => $value) {
$current[$key] = $value;
}
return $current;
}
private function mergeSavedIds(array $current, array $updates): array
{
$merged = array_merge($current, array_filter($updates, static fn($id) => $id !== ''));
return array_values(array_unique($merged));
}
/**
* @param array<int,array<mixed>> $primary
* @param array<int,array<mixed>> $secondary
* @return array<int,array<mixed>>
*/
private function mergePayloadLists(array $primary, array $secondary, int $limit): array
{
$seen = [];
$merged = [];
foreach ($primary as $payload) {
$merged[] = $payload;
if (isset($payload['id'])) {
$seen[$payload['id']] = true;
}
if (count($merged) >= $limit) {
return array_slice($merged, 0, $limit);
}
}
foreach ($secondary as $payload) {
$id = $payload['id'] ?? null;
if ($id !== null && isset($seen[$id])) {
continue;
}
$merged[] = $payload;
if ($id !== null) {
$seen[$id] = true;
}
if (count($merged) >= $limit) {
break;
}
}
return array_slice($merged, 0, $limit);
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Controller;
use App\Entity\User;
use App\Form\RegistrationFormType;
use App\Service\RegistrationToggle;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -21,8 +22,17 @@ class RegistrationController extends AbstractController
* Processes registration submissions or serves the form modal.
*/
#[Route('/register', name: 'app_register')]
public function register(Request $request, EntityManagerInterface $entityManager, UserPasswordHasherInterface $passwordHasher): Response
public function register(Request $request, EntityManagerInterface $entityManager, UserPasswordHasherInterface $passwordHasher, RegistrationToggle $registrationToggle): Response
{
$registrationEnabled = $registrationToggle->isEnabled();
if (!$registrationEnabled && !$this->isGranted('ROLE_ADMIN')) {
if ($request->isXmlHttpRequest()) {
return new JsonResponse(['ok' => false, 'errors' => ['registration' => ['Registration is currently disabled.']]], 403);
}
$this->addFlash('info', 'Registration is currently disabled.');
return $this->redirectToRoute('album_search');
}
// For GET (non-XHR), redirect to home and let the modal open
if ($request->isMethod('GET') && !$request->isXmlHttpRequest()) {
return $this->redirectToRoute('album_search', ['auth' => 'register']);

24
src/Dto/AdminUserData.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert;
/**
* AdminUserData transports user creation input from the admin form.
* This is a Data Transfer Object to avoid direct entity manipulation.
* Used to allow user creation in the user management panel without invalidating active token.
* (This took too long to figure out)
*/
class AdminUserData
{
#[Assert\NotBlank]
#[Assert\Email]
public string $email = '';
#[Assert\Length(max: 120)]
public ?string $displayName = null;
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Dto;
use Symfony\Component\HttpFoundation\Request;
/**
* AlbumSearchCriteria captures normalized filters for album discovery.
*/
final class AlbumSearchCriteria
{
public readonly string $query;
public readonly string $albumName;
public readonly string $artist;
public readonly ?int $yearFrom;
public readonly ?int $yearTo;
public readonly string $source;
public readonly int $limit;
public function __construct(
string $query,
string $albumName,
string $artist,
?int $yearFrom,
?int $yearTo,
string $source,
int $limit
) {
$this->query = $query;
$this->albumName = $albumName;
$this->artist = $artist;
$this->yearFrom = $yearFrom;
$this->yearTo = $yearTo;
$this->source = in_array($source, ['all', 'spotify', 'user'], true) ? $source : 'all';
$this->limit = max(1, $limit);
}
/**
* Builds criteria from an incoming HTTP request.
*/
public static function fromRequest(Request $request, int $defaultLimit = 20): self
{
return new self(
query: trim((string) $request->query->get('q', '')),
albumName: trim($request->query->getString('album', '')),
artist: trim($request->query->getString('artist', '')),
yearFrom: self::normalizeYear($request->query->get('year_from')),
yearTo: self::normalizeYear($request->query->get('year_to')),
source: self::normalizeSource($request->query->getString('source', 'all')),
limit: $defaultLimit
);
}
public function useSpotify(): bool
{
return $this->source === 'all' || $this->source === 'spotify';
}
public function useUser(): bool
{
return $this->source === 'all' || $this->source === 'user';
}
private static function normalizeYear(mixed $value): ?int
{
if ($value === null) {
return null;
}
$raw = trim((string) $value);
if ($raw === '') {
return null;
}
return preg_match('/^\d{4}$/', $raw) ? (int) $raw : null;
}
private static function normalizeSource(string $source): string
{
$source = strtolower(trim($source));
return in_array($source, ['all', 'spotify', 'user'], true) ? $source : 'all';
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Dto;
/**
* AlbumSearchResult encapsulates merged payloads for presentation layers.
*/
final class AlbumSearchResult
{
/**
* @param array<int,array<mixed>> $albums
* @param array<string,array{count:int,avg:float}> $stats
* @param array<int,string> $savedIds
*/
public function __construct(
public readonly AlbumSearchCriteria $criteria,
public readonly array $albums,
public readonly array $stats,
public readonly array $savedIds
) {
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Form;
use App\Dto\AdminUserData;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
/**
* AdminUserType lets moderators manually create accounts.
*/
class AdminUserType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('email', EmailType::class, [
'required' => true,
'constraints' => [
new Assert\NotBlank(),
new Assert\Email(),
],
])
->add('displayName', TextType::class, [
'required' => false,
'constraints' => [
new Assert\Length(max: 120),
],
])
->add('plainPassword', RepeatedType::class, [
'type' => PasswordType::class,
'mapped' => false,
'first_options' => ['label' => 'Password'],
'second_options' => ['label' => 'Repeat password'],
'invalid_message' => 'Passwords must match.',
'constraints' => [
new Assert\NotBlank(),
new Assert\Length(min: 8),
],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => AdminUserData::class,
]);
}
}

View File

@@ -39,6 +39,7 @@ class AlbumType extends AbstractType
'mapped' => false,
'required' => false,
'label' => 'Album cover',
'help' => 'JPEG or PNG up to 5MB.',
'constraints' => [new Assert\Image(maxSize: '5M')],
])
->add('externalUrl', TextType::class, [

View File

@@ -9,6 +9,9 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Retained for compatibility; password updates now live on the profile page.
*/
class ChangePasswordFormType extends AbstractType
{
/**
@@ -38,4 +41,3 @@ class ChangePasswordFormType extends AbstractType
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -24,6 +25,11 @@ class SiteSettingsType extends AbstractType
'required' => false,
'label' => 'Spotify Client Secret',
'mapped' => false,
])
->add('REGISTRATION_ENABLED', CheckboxType::class, [
'required' => false,
'label' => 'Allow self-service registration',
'mapped' => false,
]);
}

View File

@@ -34,7 +34,11 @@ class SettingRepository extends ServiceEntityRepository
public function setValue(string $name, ?string $value): void
{
$em = $this->getEntityManager();
$setting = $this->findOneBy(['name' => $name]) ?? (new Setting())->setName($name);
$setting = $this->findOneBy(['name' => $name]);
if ($setting === null) {
$setting = new Setting();
$setting->setName($name);
}
$setting->setValue($value);
$em->persist($setting);
$em->flush();

View File

@@ -2,6 +2,8 @@
namespace App\Repository;
use App\Entity\Album;
use App\Entity\Review;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -32,6 +34,36 @@ class UserRepository extends ServiceEntityRepository
->getQuery()
->getOneOrNullResult();
}
/**
* Returns every user with aggregated album/review counts.
*
* @return list<array{user: User, albumCount: int, reviewCount: int}>
*/
public function findAllWithStats(): array
{
$rows = $this->createQueryBuilder('u')
->select('u', 'COUNT(DISTINCT a.id) AS albumCount', 'COUNT(DISTINCT r.id) AS reviewCount')
->leftJoin(Album::class, 'a', 'WITH', 'a.createdBy = u')
->leftJoin(Review::class, 'r', 'WITH', 'r.author = u')
->groupBy('u.id')
->orderBy('u.email', 'ASC')
->getQuery()
->getResult();
return array_map(static function (array $row): array {
/** @var User $user */
$user = $row[0] ?? $row['user'] ?? null;
if (!$user instanceof User) {
throw new \RuntimeException('Unexpected result row; expected User entity.');
}
return [
'user' => $user,
'albumCount' => (int) ($row['albumCount'] ?? 0),
'reviewCount' => (int) ($row['reviewCount'] ?? 0),
];
}, $rows);
}
}

View File

@@ -33,7 +33,8 @@ class ReviewVoter extends Voter
return false;
}
if (in_array('ROLE_ADMIN', $user->getRoles(), true)) {
$roles = $user->getRoles();
if (in_array('ROLE_ADMIN', $roles, true) || in_array('ROLE_MODERATOR', $roles, true)) {
return true;
}

View File

@@ -0,0 +1,313 @@
<?php
namespace App\Service;
use App\Dto\AlbumSearchCriteria;
use App\Dto\AlbumSearchResult;
use App\Entity\Album;
use App\Repository\AlbumRepository;
use App\Repository\ReviewRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* AlbumSearchService composes Spotify and user albums into reusable payloads.
*/
class AlbumSearchService
{
public function __construct(
private readonly SpotifyClient $spotify,
private readonly AlbumRepository $albumRepository,
private readonly ReviewRepository $reviewRepository,
private readonly EntityManagerInterface $em,
private readonly LoggerInterface $logger,
) {
}
public function search(AlbumSearchCriteria $criteria): AlbumSearchResult
{
$spotifyQuery = $this->buildSpotifyQuery($criteria);
$hasUserFilters = $this->hasUserFilters($criteria, $spotifyQuery);
$stats = [];
$savedIds = [];
$spotifyPayloads = [];
$userPayloads = [];
if ($criteria->useSpotify() && $spotifyQuery !== '') {
$spotifyData = $this->resolveSpotifyAlbums($criteria, $spotifyQuery);
$spotifyPayloads = $spotifyData['payloads'];
$stats = $this->mergeStats($stats, $spotifyData['stats']);
$savedIds = $this->mergeSavedIds($savedIds, $spotifyData['savedIds']);
}
if ($criteria->useUser() && $hasUserFilters) {
$userData = $this->resolveUserAlbums($criteria);
$userPayloads = $userData['payloads'];
$stats = $this->mergeStats($stats, $userData['stats']);
}
$albums = $this->composeAlbumList($criteria->source, $userPayloads, $spotifyPayloads, $criteria->limit);
return new AlbumSearchResult($criteria, $albums, $stats, $savedIds);
}
private function buildSpotifyQuery(AlbumSearchCriteria $criteria): string
{
$parts = [];
if ($criteria->albumName !== '') {
$parts[] = 'album:' . $criteria->albumName;
}
if ($criteria->artist !== '') {
$parts[] = 'artist:' . $criteria->artist;
}
if ($criteria->yearFrom !== null || $criteria->yearTo !== null) {
if ($criteria->yearFrom !== null && $criteria->yearTo !== null && $criteria->yearTo >= $criteria->yearFrom) {
$parts[] = 'year:' . $criteria->yearFrom . '-' . $criteria->yearTo;
} else {
$year = $criteria->yearFrom ?? $criteria->yearTo;
if ($year !== null) {
$parts[] = 'year:' . $year;
}
}
}
if ($criteria->query !== '') {
$parts[] = $criteria->query;
}
return implode(' ', $parts);
}
private function hasUserFilters(AlbumSearchCriteria $criteria, string $spotifyQuery): bool
{
return $spotifyQuery !== ''
|| $criteria->albumName !== ''
|| $criteria->artist !== ''
|| $criteria->yearFrom !== null
|| $criteria->yearTo !== null;
}
/**
* @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
*/
private function resolveSpotifyAlbums(AlbumSearchCriteria $criteria, string $spotifyQuery): array
{
$stored = $this->albumRepository->searchSpotifyAlbums(
$spotifyQuery,
$criteria->albumName,
$criteria->artist,
$criteria->yearFrom ?? 0,
$criteria->yearTo ?? 0,
$criteria->limit
);
$storedPayloads = array_map(static fn(Album $album) => $album->toTemplateArray(), $stored);
$storedIds = $this->collectSpotifyIds($stored);
$stats = $storedIds ? $this->reviewRepository->getAggregatesForAlbumIds($storedIds) : [];
$savedIds = $storedIds;
if (count($stored) >= $criteria->limit) {
return [
'payloads' => array_slice($storedPayloads, 0, $criteria->limit),
'stats' => $stats,
'savedIds' => $savedIds,
];
}
$apiPayloads = $this->fetchSpotifyPayloads($criteria, $spotifyQuery, $storedPayloads);
$payloads = $this->mergePayloadLists($apiPayloads['payloads'], $storedPayloads, $criteria->limit);
$stats = $this->mergeStats($stats, $apiPayloads['stats']);
$savedIds = $this->mergeSavedIds($savedIds, $apiPayloads['savedIds']);
return ['payloads' => $payloads, 'stats' => $stats, 'savedIds' => $savedIds];
}
/**
* @param array<int,array<mixed>> $storedPayloads
* @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
*/
private function fetchSpotifyPayloads(AlbumSearchCriteria $criteria, string $spotifyQuery, array $storedPayloads): array
{
$result = $this->spotify->searchAlbums($spotifyQuery, $criteria->limit);
$searchItems = $result['albums']['items'] ?? [];
$this->logger->info('Album search results received', [
'query' => $spotifyQuery,
'items' => is_countable($searchItems) ? count($searchItems) : 0,
]);
if (!$searchItems) {
return ['payloads' => [], 'stats' => [], 'savedIds' => []];
}
$ids = $this->extractSpotifyIds($searchItems);
if ($ids === []) {
return ['payloads' => [], 'stats' => [], 'savedIds' => []];
}
$full = $this->spotify->getAlbums($ids);
$albumsPayload = is_array($full) ? ($full['albums'] ?? []) : [];
if ($albumsPayload === [] && $searchItems !== []) {
$albumsPayload = $searchItems;
$this->logger->warning('Spotify getAlbums returned empty; falling back to search items', ['count' => count($albumsPayload)]);
}
$upserted = 0;
foreach ($albumsPayload as $payload) {
$this->albumRepository->upsertFromSpotifyAlbum((array) $payload);
$upserted++;
}
$this->em->flush();
$this->logger->info('Albums upserted to DB', ['upserted' => $upserted]);
$existing = $this->albumRepository->findBySpotifyIdsKeyed($ids);
$payloads = [];
foreach ($ids as $sid) {
if (isset($existing[$sid])) {
$payloads[] = $existing[$sid]->toTemplateArray();
}
}
$stats = $this->reviewRepository->getAggregatesForAlbumIds($ids);
return [
'payloads' => $payloads,
'stats' => $stats,
'savedIds' => array_keys($existing),
];
}
/**
* @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>}
*/
private function resolveUserAlbums(AlbumSearchCriteria $criteria): array
{
$userAlbums = $this->albumRepository->searchUserAlbums(
$criteria->query,
$criteria->albumName,
$criteria->artist,
$criteria->yearFrom ?? 0,
$criteria->yearTo ?? 0,
$criteria->limit
);
if ($userAlbums === []) {
return ['payloads' => [], 'stats' => []];
}
$entityIds = array_values(array_map(static fn(Album $album) => (int) $album->getId(), $userAlbums));
$userStats = $this->reviewRepository->getAggregatesForAlbumEntityIds($entityIds);
$payloads = array_map(static fn(Album $album) => $album->toTemplateArray(), $userAlbums);
return [
'payloads' => $payloads,
'stats' => $this->mapUserStatsToLocalIds($userAlbums, $userStats),
];
}
/**
* @param list<Album> $userAlbums
* @param array<int,array{count:int,avg:float}> $userStats
* @return array<string,array{count:int,avg:float}>
*/
private function mapUserStatsToLocalIds(array $userAlbums, array $userStats): array
{
$mapped = [];
foreach ($userAlbums as $album) {
$entityId = (int) $album->getId();
$localId = (string) $album->getLocalId();
if ($localId !== '' && isset($userStats[$entityId])) {
$mapped[$localId] = $userStats[$entityId];
}
}
return $mapped;
}
private function composeAlbumList(string $source, array $userPayloads, array $spotifyPayloads, int $limit): array
{
if ($source === 'user') {
return array_slice($userPayloads, 0, $limit);
}
if ($source === 'spotify') {
return array_slice($spotifyPayloads, 0, $limit);
}
return array_slice(array_merge($userPayloads, $spotifyPayloads), 0, $limit);
}
/**
* @param list<Album> $albums
* @return list<string>
*/
private function collectSpotifyIds(array $albums): array
{
$ids = [];
foreach ($albums as $album) {
$sid = (string) $album->getSpotifyId();
if ($sid !== '') {
$ids[] = $sid;
}
}
return array_values(array_unique($ids));
}
/**
* @param array<int,mixed> $searchItems
* @return list<string>
*/
private function extractSpotifyIds(array $searchItems): array
{
$ids = [];
foreach ($searchItems as $item) {
$id = isset($item['id']) ? (string) $item['id'] : '';
if ($id !== '') {
$ids[] = $id;
}
}
return array_values(array_unique($ids));
}
private function mergeStats(array $current, array $updates): array
{
foreach ($updates as $key => $value) {
$current[$key] = $value;
}
return $current;
}
private function mergeSavedIds(array $current, array $updates): array
{
$merged = array_merge($current, array_filter($updates, static fn($id) => $id !== ''));
return array_values(array_unique($merged));
}
/**
* @param array<int,array<mixed>> $primary
* @param array<int,array<mixed>> $secondary
* @return array<int,array<mixed>>
*/
private function mergePayloadLists(array $primary, array $secondary, int $limit): array
{
$seen = [];
$merged = [];
foreach ($primary as $payload) {
$merged[] = $payload;
if (isset($payload['id'])) {
$seen[$payload['id']] = true;
}
if (count($merged) >= $limit) {
return array_slice($merged, 0, $limit);
}
}
foreach ($secondary as $payload) {
$id = $payload['id'] ?? null;
if ($id !== null && isset($seen[$id])) {
continue;
}
$merged[] = $payload;
if ($id !== null) {
$seen[$id] = true;
}
if (count($merged) >= $limit) {
break;
}
}
return array_slice($merged, 0, $limit);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Service;
use App\Repository\SettingRepository;
/**
* RegistrationToggle centralizes the logic around the registration switch.
*/
final class RegistrationToggle
{
private ?bool $envOverride;
public function __construct(private readonly SettingRepository $settings)
{
$this->envOverride = $this->detectEnvOverride();
}
/**
* Returns the environment-provided override, or null when unset.
*/
public function envOverride(): ?bool
{
return $this->envOverride;
}
/**
* Resolves whether registration should currently be enabled.
*/
public function isEnabled(): bool
{
if ($this->envOverride !== null) {
return $this->envOverride;
}
return $this->settings->getValue('REGISTRATION_ENABLED', '1') !== '0';
}
/**
* Persists a new database-backed toggle value.
*/
public function persist(bool $enabled): void
{
$this->settings->setValue('REGISTRATION_ENABLED', $enabled ? '1' : '0');
}
private function detectEnvOverride(): ?bool
{
$raw = $_ENV['APP_ALLOW_REGISTRATION'] ?? $_SERVER['APP_ALLOW_REGISTRATION'] ?? null;
if ($raw === null) {
return null;
}
if (is_bool($raw)) {
return $raw;
}
$normalized = strtolower(trim((string) $raw));
if ($normalized === '') {
return null;
}
if (in_array($normalized, ['0', 'false', 'off', 'no'], true)) {
return false;
}
if (in_array($normalized, ['1', 'true', 'on', 'yes'], true)) {
return true;
}
return null;
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Twig;
use App\Service\RegistrationToggle;
use Twig\Extension\AbstractExtension;
use Twig\Extension\GlobalsInterface;
/**
* Exposes frequently used configuration values to Twig templates.
*/
class AppSettingsExtension extends AbstractExtension implements GlobalsInterface
{
public function __construct(private readonly RegistrationToggle $registrationToggle)
{
}
public function getGlobals(): array
{
return [
'registration_enabled' => $this->registrationToggle->isEnabled(),
];
}
}