I lowkey forgot to commit

This commit is contained in:
2025-11-01 00:28:29 +00:00
parent f9e747633f
commit c0528310c1
54 changed files with 2154 additions and 7 deletions

BIN
src/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,47 @@
<?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-admin', description: 'Grant ROLE_ADMIN to a user by email')]
class PromoteAdminCommand extends Command
{
public function __construct(private readonly UserRepository $users, private readonly EntityManagerInterface $em)
{
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('email', InputArgument::REQUIRED, 'Email of the user to promote');
}
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_ADMIN', $roles, true)) {
$roles[] = 'ROLE_ADMIN';
$user->setRoles($roles);
$this->em->flush();
}
$output->writeln('<info>Granted ROLE_ADMIN to ' . $email . '</info>');
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Controller;
use App\Entity\User;
use App\Form\ProfileFormType;
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\Form\FormError;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
#[IsGranted('ROLE_USER')]
class AccountController extends AbstractController
{
#[Route('/dashboard', name: 'account_dashboard', methods: ['GET', 'POST'])]
public function dashboard(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $hasher): Response
{
/** @var User $user */
$user = $this->getUser();
$form = $this->createForm(ProfileFormType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$newPassword = (string) $form->get('newPassword')->getData();
if ($newPassword !== '') {
$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/dashboard.html.twig', [
'form' => $form->createView(),
]);
}
$user->setPassword($hasher->hashPassword($user, $newPassword));
}
$em->flush();
$this->addFlash('success', 'Profile updated.');
return $this->redirectToRoute('account_dashboard');
}
return $this->render('account/dashboard.html.twig', [
'form' => $form->createView(),
]);
}
#[Route('/settings', name: 'account_settings', methods: ['GET'])]
public function settings(): Response
{
return $this->render('account/settings.html.twig');
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Controller\Admin;
use App\Form\SiteSettingsType;
use App\Repository\SettingRepository;
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\Routing\Attribute\Route;
#[IsGranted('ROLE_ADMIN')]
class SiteSettingsController extends AbstractController
{
#[Route('/admin/settings', name: 'admin_settings', methods: ['GET', 'POST'])]
public function settings(Request $request, SettingRepository $settings): 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'));
$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());
$this->addFlash('success', 'Settings saved.');
return $this->redirectToRoute('admin_settings');
}
return $this->render('admin/settings.html.twig', [
'form' => $form->createView(),
]);
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Controller;
use App\Service\SpotifyClient;
use App\Entity\Review;
use App\Form\ReviewType;
use App\Repository\ReviewRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class AlbumController extends AbstractController
{
#[Route('/', name: 'album_search', methods: ['GET'])]
public function search(Request $request, SpotifyClient $spotifyClient, ReviewRepository $reviewRepository): Response
{
$query = trim((string) $request->query->get('q', ''));
$albumName = trim($request->query->getString('album', ''));
$artist = trim($request->query->getString('artist', ''));
// Accept empty strings and validate manually to avoid FILTER_NULL_ON_FAILURE issues
$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;
$albums = [];
$stats = [];
// Build Spotify fielded search if advanced inputs are supplied
$advancedUsed = ($albumName !== '' || $artist !== '' || $yearFrom > 0 || $yearTo > 0);
$q = $query;
if ($advancedUsed) {
$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;
}
}
// also include free-text if provided
if ($query !== '') { $parts[] = $query; }
$q = implode(' ', $parts);
}
if ($q !== '') {
$result = $spotifyClient->searchAlbums($q, 20);
$albums = $result['albums']['items'] ?? [];
if ($albums) {
$ids = array_values(array_map(static fn($a) => $a['id'] ?? null, $albums));
$ids = array_filter($ids, static fn($v) => is_string($v) && $v !== '');
if ($ids) {
$stats = $reviewRepository->getAggregatesForAlbumIds($ids);
}
}
}
return $this->render('album/search.html.twig', [
'query' => $query,
'album' => $albumName,
'artist' => $artist,
'year_from' => $yearFrom ?: '',
'year_to' => $yearTo ?: '',
'albums' => $albums,
'stats' => $stats,
]);
}
#[Route('/albums/{id}', name: 'album_show', methods: ['GET', 'POST'])]
public function show(string $id, Request $request, SpotifyClient $spotifyClient, ReviewRepository $reviews, EntityManagerInterface $em): Response
{
$album = $spotifyClient->getAlbum($id);
if ($album === null) {
throw $this->createNotFoundException('Album not found');
}
$existing = $reviews->findBy(['spotifyAlbumId' => $id], ['createdAt' => 'DESC']);
$count = count($existing);
$avg = 0.0;
if ($count > 0) {
$sum = 0;
foreach ($existing as $rev) { $sum += (int) $rev->getRating(); }
$avg = round($sum / $count, 1);
}
// Pre-populate required album metadata before validation so entity constraints pass
$review = new Review();
$review->setSpotifyAlbumId($id);
$review->setAlbumName($album['name'] ?? '');
$review->setAlbumArtist(implode(', ', array_map(fn($a) => $a['name'], $album['artists'] ?? [])));
$form = $this->createForm(ReviewType::class, $review);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->denyAccessUnlessGranted('ROLE_USER');
$review->setAuthor($this->getUser());
$em->persist($review);
$em->flush();
$this->addFlash('success', 'Review added.');
return $this->redirectToRoute('album_show', ['id' => $id]);
}
return $this->render('album/show.html.twig', [
'album' => $album,
'albumId' => $id,
'reviews' => $existing,
'avg' => $avg,
'count' => $count,
'form' => $form->createView(),
]);
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Controller;
use App\Entity\User;
use App\Form\RegistrationFormType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
class RegistrationController extends AbstractController
{
#[Route('/register', name: 'app_register')]
public function register(Request $request, EntityManagerInterface $entityManager, UserPasswordHasherInterface $passwordHasher): Response
{
// 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']);
}
$user = new User();
$form = $this->createForm(RegistrationFormType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$plainPassword = (string) $form->get('plainPassword')->getData();
$hashed = $passwordHasher->hashPassword($user, $plainPassword);
$user->setPassword($hashed);
$entityManager->persist($user);
$entityManager->flush();
if ($request->isXmlHttpRequest()) {
return new JsonResponse(['ok' => true]);
}
$this->addFlash('success', 'Account created. You can now sign in.');
return $this->redirectToRoute('app_login');
}
if ($request->isXmlHttpRequest()) {
// Flatten form errors for the modal
$errors = [];
foreach ($form->getErrors(true) as $error) {
$origin = $error->getOrigin();
$name = $origin ? $origin->getName() : 'form';
$errors[$name][] = $error->getMessage();
}
return new JsonResponse(['ok' => false, 'errors' => $errors], 422);
}
return $this->render('registration/register.html.twig', [
'registrationForm' => $form->createView(),
]);
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Controller;
use App\Entity\Review;
use App\Form\ReviewType;
use App\Repository\ReviewRepository;
use App\Service\SpotifyClient;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/reviews')]
class ReviewController extends AbstractController
{
#[Route('', name: 'review_index', methods: ['GET'])]
public function index(ReviewRepository $reviewRepository): Response
{
$reviews = $reviewRepository->findLatest(50);
return $this->render('review/index.html.twig', [
'reviews' => $reviews,
]);
}
#[Route('/new', name: 'review_new', methods: ['GET', 'POST'])]
#[IsGranted('ROLE_USER')]
public function new(Request $request): Response
{
$albumId = (string) $request->query->get('album_id', '');
if ($albumId !== '') {
return $this->redirectToRoute('album_show', ['id' => $albumId]);
}
$this->addFlash('info', 'Select an album first.');
return $this->redirectToRoute('album_search');
}
#[Route('/{id}', name: 'review_show', requirements: ['id' => '\\d+'], methods: ['GET'])]
public function show(Review $review): Response
{
return $this->render('review/show.html.twig', [
'review' => $review,
]);
}
#[Route('/{id}/edit', name: 'review_edit', requirements: ['id' => '\\d+'], methods: ['GET', 'POST'])]
#[IsGranted('ROLE_USER')]
public function edit(Request $request, Review $review, EntityManagerInterface $em): Response
{
$this->denyAccessUnlessGranted('REVIEW_EDIT', $review);
$form = $this->createForm(ReviewType::class, $review);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->flush();
$this->addFlash('success', 'Review updated.');
return $this->redirectToRoute('review_show', ['id' => $review->getId()]);
}
return $this->render('review/edit.html.twig', [
'form' => $form->createView(),
'review' => $review,
]);
}
#[Route('/{id}/delete', name: 'review_delete', requirements: ['id' => '\\d+'], methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function delete(Request $request, Review $review, EntityManagerInterface $em): RedirectResponse
{
$this->denyAccessUnlessGranted('REVIEW_DELETE', $review);
if ($this->isCsrfTokenValid('delete_review_' . $review->getId(), (string) $request->request->get('_token'))) {
$em->remove($review);
$em->flush();
$this->addFlash('success', 'Review deleted.');
}
return $this->redirectToRoute('review_index');
}
// fetchAlbumById no longer needed; album view handles retrieval and creation
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class SecurityController extends AbstractController
{
#[Route('/login', name: 'app_login')]
public function login(Request $request, AuthenticationUtils $authenticationUtils): Response
{
// Keep this route so the firewall can use it as check_path for POST.
// For GET requests, redirect to the main page and let the modal handle UI.
if ($request->isMethod('GET')) {
return new RedirectResponse($this->generateUrl('album_search', ['auth' => 'login']));
}
return new Response(status: 204);
}
#[Route('/logout', name: 'app_logout')]
public function logout(): void
{
// Controller can be blank: it will be intercepted by the logout key on your firewall
}
}

88
src/Entity/Review.php Normal file
View File

@@ -0,0 +1,88 @@
<?php
namespace App\Entity;
use App\Repository\ReviewRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: ReviewRepository::class)]
#[ORM\Table(name: 'reviews')]
#[ORM\HasLifecycleCallbacks]
class Review
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?User $author = null;
#[ORM\Column(type: 'string', length: 64)]
#[Assert\NotBlank]
private string $spotifyAlbumId = '';
#[ORM\Column(type: 'string', length: 255)]
#[Assert\NotBlank]
private string $albumName = '';
#[ORM\Column(type: 'string', length: 255)]
#[Assert\NotBlank]
private string $albumArtist = '';
#[ORM\Column(type: 'string', length: 160)]
#[Assert\NotBlank]
#[Assert\Length(max: 160)]
private string $title = '';
#[ORM\Column(type: 'text')]
#[Assert\NotBlank]
#[Assert\Length(min: 20, max: 5000)]
private string $content = '';
#[ORM\Column(type: 'smallint')]
#[Assert\Range(min: 1, max: 10)]
private int $rating = 5;
#[ORM\Column(type: 'datetime_immutable')]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(type: 'datetime_immutable')]
private ?\DateTimeImmutable $updatedAt = null;
#[ORM\PrePersist]
public function onPrePersist(): void
{
$now = new \DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
}
#[ORM\PreUpdate]
public function onPreUpdate(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
public function getId(): ?int { return $this->id; }
public function getAuthor(): ?User { return $this->author; }
public function setAuthor(User $author): void { $this->author = $author; }
public function getSpotifyAlbumId(): string { return $this->spotifyAlbumId; }
public function setSpotifyAlbumId(string $spotifyAlbumId): void { $this->spotifyAlbumId = $spotifyAlbumId; }
public function getAlbumName(): string { return $this->albumName; }
public function setAlbumName(string $albumName): void { $this->albumName = $albumName; }
public function getAlbumArtist(): string { return $this->albumArtist; }
public function setAlbumArtist(string $albumArtist): void { $this->albumArtist = $albumArtist; }
public function getTitle(): string { return $this->title; }
public function setTitle(string $title): void { $this->title = $title; }
public function getContent(): string { return $this->content; }
public function setContent(string $content): void { $this->content = $content; }
public function getRating(): int { return $this->rating; }
public function setRating(int $rating): void { $this->rating = $rating; }
public function getCreatedAt(): ?\DateTimeImmutable { return $this->createdAt; }
public function getUpdatedAt(): ?\DateTimeImmutable { return $this->updatedAt; }
}

33
src/Entity/Setting.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
namespace App\Entity;
use App\Repository\SettingRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: SettingRepository::class)]
#[ORM\Table(name: 'settings')]
#[ORM\UniqueConstraint(name: 'uniq_setting_name', columns: ['name'])]
class Setting
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 100)]
#[Assert\NotBlank]
private string $name = '';
#[ORM\Column(type: 'text', nullable: true)]
private ?string $value = null;
public function getId(): ?int { return $this->id; }
public function getName(): string { return $this->name; }
public function setName(string $name): void { $this->name = $name; }
public function getValue(): ?string { return $this->value; }
public function setValue(?string $value): void { $this->value = $value; }
}

116
src/Entity/User.php Normal file
View File

@@ -0,0 +1,116 @@
<?php
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: 'users')]
#[UniqueEntity(fields: ['email'], message: 'This email is already registered.')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 180, unique: true)]
#[Assert\NotBlank]
#[Assert\Email]
private string $email = '';
/**
* @var list<string>
*/
#[ORM\Column(type: 'json')]
private array $roles = [];
/**
* @var string The hashed password
*/
#[ORM\Column(type: 'string')]
private string $password = '';
#[ORM\Column(type: 'string', length: 120, nullable: true)]
#[Assert\Length(max: 120)]
private ?string $displayName = null;
public function getId(): ?int
{
return $this->id;
}
public function getEmail(): string
{
return $this->email;
}
public function setEmail(string $email): void
{
$this->email = strtolower($email);
}
public function getUserIdentifier(): string
{
return $this->email;
}
public function getRoles(): array
{
$roles = $this->roles;
// guarantee every user at least has ROLE_USER
if (!in_array('ROLE_USER', $roles, true)) {
$roles[] = 'ROLE_USER';
}
return array_values(array_unique($roles));
}
/**
* @param list<string> $roles
*/
public function setRoles(array $roles): void
{
$this->roles = array_values(array_unique($roles));
}
public function addRole(string $role): void
{
$roles = $this->getRoles();
if (!in_array($role, $roles, true)) {
$roles[] = $role;
}
$this->roles = $roles;
}
public function getPassword(): string
{
return $this->password;
}
public function setPassword(string $hashedPassword): void
{
$this->password = $hashedPassword;
}
public function eraseCredentials(): void
{
// no-op
}
public function getDisplayName(): ?string
{
return $this->displayName;
}
public function setDisplayName(?string $displayName): void
{
$this->displayName = $displayName;
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Form;
use App\Entity\User;
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;
class ProfileFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('email', EmailType::class, [
'constraints' => [new Assert\NotBlank(), new Assert\Email()],
])
->add('displayName', TextType::class, [
'required' => false,
'constraints' => [new Assert\Length(max: 120)],
])
->add('currentPassword', PasswordType::class, [
'mapped' => false,
'required' => false,
'label' => 'Current password (required to change password)'
])
->add('newPassword', RepeatedType::class, [
'type' => PasswordType::class,
'mapped' => false,
'required' => false,
'first_options' => ['label' => 'New password (optional)'],
'second_options' => ['label' => 'Repeat new password'],
'invalid_message' => 'The password fields must match.',
'constraints' => [new Assert\Length(min: 8)],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => User::class,
]);
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Form;
use App\Entity\User;
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\SubmitType;
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;
class RegistrationFormType 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' => 'The password fields must match.',
'constraints' => [
new Assert\NotBlank(groups: ['registration']),
new Assert\Length(min: 8, groups: ['registration']),
],
])
->add('register', SubmitType::class, [
'label' => 'Create account',
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => User::class,
]);
}
}

46
src/Form/ReviewType.php Normal file
View File

@@ -0,0 +1,46 @@
<?php
namespace App\Form;
use App\Entity\Review;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\RangeType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
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;
class ReviewType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', TextType::class, [
'constraints' => [new Assert\NotBlank(), new Assert\Length(max: 160)],
])
->add('content', TextareaType::class, [
'constraints' => [new Assert\NotBlank(), new Assert\Length(min: 20, max: 5000)],
'attr' => ['rows' => 8],
])
->add('rating', RangeType::class, [
'constraints' => [new Assert\Range(min: 1, max: 10)],
'attr' => [
'min' => 1,
'max' => 10,
'step' => 1,
'class' => 'form-range',
],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Review::class,
]);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class SiteSettingsType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('SPOTIFY_CLIENT_ID', TextType::class, [
'required' => false,
'label' => 'Spotify Client ID',
'mapped' => false,
])
->add('SPOTIFY_CLIENT_SECRET', TextType::class, [
'required' => false,
'label' => 'Spotify Client Secret',
'mapped' => false,
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([]);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Repository;
use App\Entity\Review;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Review>
*/
class ReviewRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Review::class);
}
/**
* @return list<Review>
*/
public function findLatest(int $limit = 20): array
{
return $this->createQueryBuilder('r')
->orderBy('r.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
/**
* Return aggregates for albums: [albumId => ['count' => int, 'avg' => float]].
*
* @param list<string> $albumIds
* @return array<string,array{count:int,avg:float}>
*/
public function getAggregatesForAlbumIds(array $albumIds): array
{
if ($albumIds === []) {
return [];
}
$rows = $this->createQueryBuilder('r')
->select('r.spotifyAlbumId AS albumId, COUNT(r.id) AS cnt, AVG(r.rating) AS avgRating')
->where('r.spotifyAlbumId IN (:ids)')
->setParameter('ids', $albumIds)
->groupBy('r.spotifyAlbumId')
->getQuery()
->getArrayResult();
$out = [];
foreach ($rows as $row) {
$avg = isset($row['avgRating']) ? round((float) $row['avgRating'], 1) : 0.0;
$out[$row['albumId']] = ['count' => (int) $row['cnt'], 'avg' => $avg];
}
return $out;
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Repository;
use App\Entity\Setting;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class SettingRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Setting::class);
}
public function getValue(string $name, ?string $default = null): ?string
{
$setting = $this->findOneBy(['name' => $name]);
return $setting?->getValue() ?? $default;
}
public function setValue(string $name, ?string $value): void
{
$em = $this->getEntityManager();
$setting = $this->findOneBy(['name' => $name]) ?? (new Setting())->setName($name);
$setting->setValue($value);
$em->persist($setting);
$em->flush();
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<User>
*/
class UserRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
public function findOneByEmail(string $email): ?User
{
return $this->createQueryBuilder('u')
->andWhere('LOWER(u.email) = :email')
->setParameter('email', strtolower($email))
->getQuery()
->getOneOrNullResult();
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Security;
use App\Entity\Review;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class ReviewVoter extends Voter
{
public const EDIT = 'REVIEW_EDIT';
public const DELETE = 'REVIEW_DELETE';
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::EDIT, self::DELETE], true) && $subject instanceof Review;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
if (in_array('ROLE_ADMIN', $user->getRoles(), true)) {
return true;
}
/** @var Review $review */
$review = $subject;
return $review->getAuthor()?->getId() === $user->getId();
}
}

View File

@@ -0,0 +1,220 @@
<?php
namespace App\Service;
use Psr\Cache\CacheItemPoolInterface;
use App\Repository\SettingRepository;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class SpotifyClient
{
private HttpClientInterface $httpClient;
private CacheInterface $cache;
private ?string $clientId;
private ?string $clientSecret;
private SettingRepository $settings;
private int $rateWindowSeconds;
private int $rateMaxRequests;
private int $rateMaxRequestsSensitive;
public function __construct(
HttpClientInterface $httpClient,
CacheInterface $cache,
string $clientId,
string $clientSecret,
SettingRepository $settings
) {
$this->httpClient = $httpClient;
$this->cache = $cache;
$this->clientId = $clientId;
$this->clientSecret = $clientSecret;
$this->settings = $settings;
// Allow tuning via env vars; fallback to conservative defaults
$this->rateWindowSeconds = (int) (getenv('SPOTIFY_RATE_WINDOW_SECONDS') ?: 30);
$this->rateMaxRequests = (int) (getenv('SPOTIFY_RATE_MAX_REQUESTS') ?: 50);
$this->rateMaxRequestsSensitive = (int) (getenv('SPOTIFY_RATE_MAX_REQUESTS_SENSITIVE') ?: 20);
}
/**
* Search Spotify albums by query string.
*
* @param string $query
* @param int $limit
* @return array<mixed>
*/
public function searchAlbums(string $query, int $limit = 12): array
{
$accessToken = $this->getAccessToken();
if ($accessToken === null) {
return ['albums' => ['items' => []]];
}
$url = 'https://api.spotify.com/v1/search';
$options = [
'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ],
'query' => [ 'q' => $query, 'type' => 'album', 'limit' => $limit ],
];
return $this->sendRequest('GET', $url, $options, 600, false);
}
/**
* Fetch a single album by Spotify ID.
*
* @return array<mixed>|null
*/
public function getAlbum(string $albumId): ?array
{
$accessToken = $this->getAccessToken();
if ($accessToken === null) {
return null;
}
$url = 'https://api.spotify.com/v1/albums/' . urlencode($albumId);
$options = [ 'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ] ];
try {
return $this->sendRequest('GET', $url, $options, 3600, false);
} catch (\Throwable) {
return null;
}
}
/**
* Fetch multiple albums with one call.
*
* @param list<string> $albumIds
* @return array<mixed>|null
*/
public function getAlbums(array $albumIds): ?array
{
if ($albumIds === []) { return []; }
$accessToken = $this->getAccessToken();
if ($accessToken === null) { return null; }
$url = 'https://api.spotify.com/v1/albums';
$options = [
'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ],
'query' => [ 'ids' => implode(',', $albumIds) ],
];
try {
return $this->sendRequest('GET', $url, $options, 3600, false);
} catch (\Throwable) {
return null;
}
}
/**
* Centralized request with basic throttling, caching and 429 handling.
*
* @param array<string,mixed> $options
* @return array<mixed>
*/
private function sendRequest(string $method, string $url, array $options, int $cacheTtlSeconds = 0, bool $sensitive = false): array
{
$cacheKey = null;
if ($cacheTtlSeconds > 0 && strtoupper($method) === 'GET') {
$cacheKey = 'spotify_resp_' . sha1($url . '|' . json_encode($options['query'] ?? []));
$cached = $this->cache->get($cacheKey, function($item) use ($cacheTtlSeconds) {
// placeholder; we'll set item value explicitly below on miss
$item->expiresAfter(1);
return null;
});
if (is_array($cached) && !empty($cached)) {
return $cached;
}
}
$this->throttle($sensitive);
$attempts = 0;
while (true) {
++$attempts;
$response = $this->httpClient->request($method, $url, $options);
$status = $response->getStatusCode();
if ($status === 429) {
$retryAfter = (int) ($response->getHeaders()['retry-after'][0] ?? 1);
$retryAfter = max(1, min(30, $retryAfter));
sleep($retryAfter);
if ($attempts < 3) { continue; }
}
$data = $response->toArray(false);
if ($cacheKey && $cacheTtlSeconds > 0 && is_array($data)) {
$this->cache->get($cacheKey, function($item) use ($data, $cacheTtlSeconds) {
$item->expiresAfter($cacheTtlSeconds);
return $data;
});
}
return $data;
}
}
private function throttle(bool $sensitive): void
{
$windowKey = $sensitive ? 'spotify_rate_sensitive' : 'spotify_rate';
$max = $sensitive ? $this->rateMaxRequestsSensitive : $this->rateMaxRequests;
$now = time();
$entry = $this->cache->get($windowKey, function($item) use ($now) {
$item->expiresAfter($this->rateWindowSeconds);
return ['start' => $now, 'count' => 0];
});
if (!is_array($entry) || !isset($entry['start'], $entry['count'])) {
$entry = ['start' => $now, 'count' => 0];
}
$start = (int) $entry['start'];
$count = (int) $entry['count'];
$elapsed = $now - $start;
if ($elapsed >= $this->rateWindowSeconds) {
$start = $now; $count = 0;
}
if ($count >= $max) {
$sleep = max(1, $this->rateWindowSeconds - $elapsed);
sleep($sleep);
$start = time(); $count = 0;
}
$count++;
$newEntry = ['start' => $start, 'count' => $count];
$this->cache->get($windowKey, function($item) use ($newEntry) {
$item->expiresAfter($this->rateWindowSeconds);
return $newEntry;
});
}
private function getAccessToken(): ?string
{
return $this->cache->get('spotify_client_credentials_token', function ($item) {
// Default to 1 hour, will adjust based on response
$item->expiresAfter(3500);
$clientId = $this->settings->getValue('SPOTIFY_CLIENT_ID', $this->clientId ?? '');
$clientSecret = $this->settings->getValue('SPOTIFY_CLIENT_SECRET', $this->clientSecret ?? '');
if ($clientId === '' || $clientSecret === '') {
return null;
}
$response = $this->httpClient->request('POST', 'https://accounts.spotify.com/api/token', [
'headers' => [
'Authorization' => 'Basic ' . base64_encode($clientId . ':' . $clientSecret),
'Content-Type' => 'application/x-www-form-urlencoded',
],
'body' => 'grant_type=client_credentials',
]);
$data = $response->toArray(false);
if (!isset($data['access_token'])) {
return null;
}
if (isset($data['expires_in']) && is_int($data['expires_in'])) {
$ttl = max(60, $data['expires_in'] - 60);
$item->expiresAfter($ttl);
}
return $data['access_token'];
});
}
}