From c0528310c1510c4224aeadc83018287cf5269277 Mon Sep 17 00:00:00 2001 From: boris Date: Sat, 1 Nov 2025 00:28:29 +0000 Subject: [PATCH] I lowkey forgot to commit --- .DS_Store | Bin 0 -> 8196 bytes README.md | 76 +++++- composer.json | 1 + config/.DS_Store | Bin 0 -> 6148 bytes config/packages/security.yaml | 27 ++- config/services.yaml | 5 + docker-compose.yml | 9 +- docker/.DS_Store | Bin 0 -> 6148 bytes docs/01-setup.md | 32 +++ docs/02-features.md | 14 ++ docs/03-auth-and-users.md | 19 ++ docs/04-spotify-integration.md | 19 ++ docs/05-reviews-and-albums.md | 16 ++ docs/06-admin-and-settings.md | 17 ++ docs/07-rate-limits-and-caching.md | 23 ++ docs/08-troubleshooting.md | 19 ++ migrations/Version20251031224841.php | 56 +++++ migrations/Version20251031231033.php | 31 +++ migrations/Version20251031231715.php | 31 +++ migrations/Version20251101001514.php | 33 +++ src/.DS_Store | Bin 0 -> 8196 bytes src/Command/PromoteAdminCommand.php | 47 ++++ src/Controller/AccountController.php | 56 +++++ .../Admin/SiteSettingsController.php | 37 +++ src/Controller/AlbumController.php | 119 ++++++++++ src/Controller/RegistrationController.php | 62 +++++ src/Controller/ReviewController.php | 86 +++++++ src/Controller/SecurityController.php | 32 +++ src/Entity/Review.php | 88 +++++++ src/Entity/Setting.php | 33 +++ src/Entity/User.php | 116 +++++++++ src/Form/ProfileFormType.php | 51 ++++ src/Form/RegistrationFormType.php | 53 +++++ src/Form/ReviewType.php | 46 ++++ src/Form/SiteSettingsType.php | 33 +++ src/Repository/ReviewRepository.php | 60 +++++ src/Repository/SettingRepository.php | 32 +++ src/Repository/UserRepository.php | 29 +++ src/Security/ReviewVoter.php | 37 +++ src/Service/SpotifyClient.php | 220 ++++++++++++++++++ templates/.DS_Store | Bin 0 -> 6148 bytes templates/_partials/auth_modal.html.twig | 124 ++++++++++ templates/_partials/navbar.html.twig | 40 ++++ templates/account/dashboard.html.twig | 17 ++ templates/account/settings.html.twig | 35 +++ templates/admin/settings.html.twig | 26 +++ templates/album/search.html.twig | 68 ++++++ templates/album/show.html.twig | 68 ++++++ templates/base.html.twig | 20 ++ templates/review/edit.html.twig | 17 ++ templates/review/index.html.twig | 29 +++ templates/review/new.html.twig | 30 +++ templates/review/show.html.twig | 22 ++ var/.DS_Store | Bin 0 -> 6148 bytes 54 files changed, 2154 insertions(+), 7 deletions(-) create mode 100644 .DS_Store create mode 100644 config/.DS_Store create mode 100644 docker/.DS_Store create mode 100644 docs/01-setup.md create mode 100644 docs/02-features.md create mode 100644 docs/03-auth-and-users.md create mode 100644 docs/04-spotify-integration.md create mode 100644 docs/05-reviews-and-albums.md create mode 100644 docs/06-admin-and-settings.md create mode 100644 docs/07-rate-limits-and-caching.md create mode 100644 docs/08-troubleshooting.md create mode 100644 migrations/Version20251031224841.php create mode 100644 migrations/Version20251031231033.php create mode 100644 migrations/Version20251031231715.php create mode 100644 migrations/Version20251101001514.php create mode 100644 src/.DS_Store create mode 100644 src/Command/PromoteAdminCommand.php create mode 100644 src/Controller/AccountController.php create mode 100644 src/Controller/Admin/SiteSettingsController.php create mode 100644 src/Controller/AlbumController.php create mode 100644 src/Controller/RegistrationController.php create mode 100644 src/Controller/ReviewController.php create mode 100644 src/Controller/SecurityController.php create mode 100644 src/Entity/Review.php create mode 100644 src/Entity/Setting.php create mode 100644 src/Entity/User.php create mode 100644 src/Form/ProfileFormType.php create mode 100644 src/Form/RegistrationFormType.php create mode 100644 src/Form/ReviewType.php create mode 100644 src/Form/SiteSettingsType.php create mode 100644 src/Repository/ReviewRepository.php create mode 100644 src/Repository/SettingRepository.php create mode 100644 src/Repository/UserRepository.php create mode 100644 src/Security/ReviewVoter.php create mode 100644 src/Service/SpotifyClient.php create mode 100644 templates/.DS_Store create mode 100644 templates/_partials/auth_modal.html.twig create mode 100644 templates/_partials/navbar.html.twig create mode 100644 templates/account/dashboard.html.twig create mode 100644 templates/account/settings.html.twig create mode 100644 templates/admin/settings.html.twig create mode 100644 templates/album/search.html.twig create mode 100644 templates/album/show.html.twig create mode 100644 templates/base.html.twig create mode 100644 templates/review/edit.html.twig create mode 100644 templates/review/index.html.twig create mode 100644 templates/review/new.html.twig create mode 100644 templates/review/show.html.twig create mode 100644 var/.DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..2e6d3ac3fc9398d6fce31591c6ed7d3d2c9d9a8a GIT binary patch literal 8196 zcmeHMJ!lj`6n=9*@zlbDSj0kEJ1a>bV0(tM(n69(Y&7?4@Gf^;E&-9iH8GW-*rkeA zDhNslqM(J1SPFJguvCylD_g%eGr4_pyPIeun#{n?+syagzHh#n*}2&ek(!vVj1i3x zQ4SZ|{9#lvg>k+jC1qR2VHM)3Q6HNsx0>yUr*H5Icm=!yUIDLwSKwb!0DCrr>Rae)OF79!<+Xc z)@&7vrADiSw%kJP;@uaQ-=1~x%8q#Zn)o(Qc#2RufV^#*rxG=gz3gOXJmMiaeEhO> z?3v*(@SJ8VWRmRV@G;r)U@}c*x(-flxFX)cE0t}&AH{rvHzHN*Kg z;=*41NnA|7(&*O=&ARNfi|DjPB*!1mA1<#Oj(s#_5=Gs70V_EK4O>vIhOBir>-hUY zT;?vGdbFL}d4?R(Bp-)|S~WZ>;KAcrrH;$v=%nT$aLHrkjNsw;gi0j&I6U;WNry0# zpagX=pSrkXZ3gDj@CopV^^?3F-+s6F%8X5dZ^&;qUzg{o(JOH^s*2W2;F8zs1)-PY z6Y@#&ad`yHBaPM88GIj1e+m(qdu8k7XZtR)adNgqQ8(Xr&UX-_QpFgx@gCw4!fJPC z%lY+qFUjN5w>Ljuo3V*`p5(N%r_1jS^UE4es0Vm4C{f90AIT%LtaWEDd%2`d=SKMae=hm^|6Z2B_suJ??-fv);#6@0GT5_qL=Zk} z2e_`{;=+DCV|55BcHwq?9Ea`y!w~m@#R}H;j0J-7*FOZva|rqUU!UT{F9f*{>H+HS P^71#4-~X*P|E>82ckw}) literal 0 HcmV?d00001 diff --git a/README.md b/README.md index 7f7e8e0..8f09378 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,76 @@ -# tonehaus +# Tonehaus — Music Ratings + +Discover albums from Spotify, read and write reviews, and manage your account. Built with Symfony 7, Twig, Doctrine, and Bootstrap. + +## Quick start + +1) Start the stack + +```bash +docker compose up -d --build +``` + +2) Create the database schema + +```bash +docker compose exec php php bin/console doctrine:database:create --if-not-exists +docker compose exec php php bin/console doctrine:migrations:diff --no-interaction +docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction +``` + +3) Promote an admin (to access Site Settings) + +```bash +docker compose exec php php bin/console app:promote-admin you@example.com +``` + +4) Configure Spotify API credentials (admin only) + +- Open `http://localhost:8000/admin/settings` and enter your Spotify Client ID/Secret. +- Alternatively, set env vars for the PHP container: `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`. + +5) Visit `http://localhost:8000` to search for albums. + +## Features + +- Spotify search with Advanced filters (album, artist, year range) and per-album aggregates (avg/count) +- Album page with details, reviews list, and inline new review (logged in) +- Auth modal (Login/Sign up) with remember-me cookie, no separate pages +- Role-based access: authors manage their own reviews, admins can manage any +- Admin Site Settings to manage Spotify credentials in DB +- User Dashboard to update profile and change password (requires current password) +- Light/Dark theme toggle in Settings (cookie-backed) +- Bootstrap UI + +## Rate limiting & caching + +- Server-side Client Credentials; access tokens are cached. +- Requests pass through a throttle and 429 Retry-After backoff. GET responses are cached. +- Tunables (optional): + +```bash +# seconds per window (default 30) +SPOTIFY_RATE_WINDOW_SECONDS=30 +# max requests per window (default 50) +SPOTIFY_RATE_MAX_REQUESTS=50 +# max requests for sensitive endpoints (default 20) +SPOTIFY_RATE_MAX_REQUESTS_SENSITIVE=20 +``` + +## Docs + +See `/docs` for how-tos and deeper notes: + +- Setup and configuration: `docs/01-setup.md` +- Features and UX: `docs/02-features.md` +- Authentication and users: `docs/03-auth-and-users.md` +- Spotify integration: `docs/04-spotify-integration.md` +- Reviews and albums: `docs/05-reviews-and-albums.md` +- Admin & site settings: `docs/06-admin-and-settings.md` +- Rate limits & caching: `docs/07-rate-limits-and-caching.md` +- Troubleshooting: `docs/08-troubleshooting.md` + +## License + +MIT diff --git a/composer.json b/composer.json index 3f8569c..8e21133 100644 --- a/composer.json +++ b/composer.json @@ -43,6 +43,7 @@ "symfony/web-link": "7.3.*", "symfony/yaml": "7.3.*", "twig/extra-bundle": "^2.12|^3.0", + "twig/string-extra": "^3.22", "twig/twig": "^2.12|^3.0" }, "config": { diff --git a/config/.DS_Store b/config/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..960bcf9d310957e625538dcce72cff70105d3b50 GIT binary patch literal 6148 zcmeHKy-veG47N*!idZ@@bi9RyiR=ua3Qy3DiT(I^qnJsZ!$1Mm!px8n2J9HeO_ zrV3TI`hh)`TyxxA*`ye9EdQW~*u@w$a(!>-+7;^>)9-pZw~FY?9XG zOIiAuo~WWVy?lRD$xi&aY5$$i)p^RNHAh+fGVlew C+)Xe5 literal 0 HcmV?d00001 diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 367af25..30cbf4a 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -4,14 +4,37 @@ security: Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider providers: - users_in_memory: { memory: null } + app_user_provider: + entity: + class: App\Entity\User + property: email firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false main: lazy: true - provider: users_in_memory + provider: app_user_provider + + form_login: + login_path: album_search + check_path: app_login + enable_csrf: true + default_target_path: album_search + failure_path: album_search + username_parameter: _username + password_parameter: _password + csrf_parameter: _csrf_token + remember_me: + secret: '%env(APP_SECRET)%' + lifetime: 1209600 # 14 days + path: '/' + secure: auto + samesite: lax + remember_me_parameter: _remember_me + logout: + path: app_logout + target: album_search # activate different ways to authenticate # https://symfony.com/doc/current/security.html#the-firewall diff --git a/config/services.yaml b/config/services.yaml index 6bbad87..5c4ce08 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -18,3 +18,8 @@ services: # add more service definitions when explicit configuration is needed # please note that last definitions always *replace* previous ones + + App\Service\SpotifyClient: + arguments: + $clientId: '%env(SPOTIFY_CLIENT_ID)%' + $clientSecret: '%env(SPOTIFY_CLIENT_SECRET)%' diff --git a/docker-compose.yml b/docker-compose.yml index f822867..809d075 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,10 +15,8 @@ services: container_name: app-php restart: unless-stopped environment: - # App base URL (used by some tools) - DEFAULT_URI: ${DEFAULT_URI:-http://localhost:8000} - APP_ENV: ${APP_ENV:-dev} - APP_SECRET: ${APP_SECRET:-change_me} + # Symfony Messenger (dev-safe default so CLI commands don't fail) + MESSENGER_TRANSPORT_DSN: ${MESSENGER_TRANSPORT_DSN:-sync://} # Doctrine DATABASE_URL consumed by Symfony/Doctrine DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony}@db:5432/${POSTGRES_DB:-symfony}?serverVersion=16&charset=utf8} volumes: @@ -27,8 +25,11 @@ services: - ./config:/var/www/html/config - ./migrations:/var/www/html/migrations - ./public:/var/www/html/public + - ./templates:/var/www/html/templates - ./src:/var/www/html/src - ./var:/var/www/html/var + - ./.env:/var/www/html/.env:ro + - ./vendor:/var/www/html/vendor # Keep composer manifests on host for version control - ./composer.json:/var/www/html/composer.json - ./composer.lock:/var/www/html/composer.lock diff --git a/docker/.DS_Store b/docker/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..bd1217cd5d7e4a4c27b8945b6f7c883eb2afef23 GIT binary patch literal 6148 zcmeHKy-EW?5T5Z8I7kya%Y6g`b|S3dEUnU6Nem=H&bua%ayy^J2k;quEx(yrlU#CO zC!#a3`_0bJ&fN#Mb4x@#-7F?VVJR*Y}5y+vAAEHxcdXkRjon3TkOZZI`?A`@QF| z?)GL^H|N#OzUHa7Tklfpxi|yPfHU9>{BH)(vqj30q0i2MGvEyD7?ATJUaAO>K}m<(Zou%!YmmGc#YEgkld<6_2SXz9dS8T;hQUzZow>aYh5 zCys_bI|I%@mx02TW9k2Q_+)x7`P~#>IRnnXA7g+A)vTK0rE+gQc|Ga10pk%vMBaddSql('CREATE TABLE reviews (id SERIAL NOT NULL, author_id INT NOT NULL, spotify_album_id VARCHAR(64) NOT NULL, album_name VARCHAR(255) NOT NULL, album_artist VARCHAR(255) NOT NULL, title VARCHAR(160) NOT NULL, content TEXT NOT NULL, rating SMALLINT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_6970EB0FF675F31B ON reviews (author_id)'); + $this->addSql('COMMENT ON COLUMN reviews.created_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN reviews.updated_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('CREATE TABLE users (id SERIAL NOT NULL, email VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, display_name VARCHAR(120) DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E9E7927C74 ON users (email)'); + $this->addSql('CREATE TABLE messenger_messages (id BIGSERIAL NOT NULL, body TEXT NOT NULL, headers TEXT NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, available_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, delivered_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0 ON messenger_messages (queue_name)'); + $this->addSql('CREATE INDEX IDX_75EA56E0E3BD61CE ON messenger_messages (available_at)'); + $this->addSql('CREATE INDEX IDX_75EA56E016BA31DB ON messenger_messages (delivered_at)'); + $this->addSql('COMMENT ON COLUMN messenger_messages.created_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN messenger_messages.available_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN messenger_messages.delivered_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('CREATE OR REPLACE FUNCTION notify_messenger_messages() RETURNS TRIGGER AS $$ + BEGIN + PERFORM pg_notify(\'messenger_messages\', NEW.queue_name::text); + RETURN NEW; + END; + $$ LANGUAGE plpgsql;'); + $this->addSql('DROP TRIGGER IF EXISTS notify_trigger ON messenger_messages;'); + $this->addSql('CREATE TRIGGER notify_trigger AFTER INSERT OR UPDATE ON messenger_messages FOR EACH ROW EXECUTE PROCEDURE notify_messenger_messages();'); + $this->addSql('ALTER TABLE reviews ADD CONSTRAINT FK_6970EB0FF675F31B FOREIGN KEY (author_id) REFERENCES users (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE reviews DROP CONSTRAINT FK_6970EB0FF675F31B'); + $this->addSql('DROP TABLE reviews'); + $this->addSql('DROP TABLE users'); + $this->addSql('DROP TABLE messenger_messages'); + } +} diff --git a/migrations/Version20251031231033.php b/migrations/Version20251031231033.php new file mode 100644 index 0000000..d6ad6c7 --- /dev/null +++ b/migrations/Version20251031231033.php @@ -0,0 +1,31 @@ +addSql('CREATE SCHEMA public'); + } +} diff --git a/migrations/Version20251031231715.php b/migrations/Version20251031231715.php new file mode 100644 index 0000000..d61bb7a --- /dev/null +++ b/migrations/Version20251031231715.php @@ -0,0 +1,31 @@ +addSql('CREATE SCHEMA public'); + } +} diff --git a/migrations/Version20251101001514.php b/migrations/Version20251101001514.php new file mode 100644 index 0000000..ed97a60 --- /dev/null +++ b/migrations/Version20251101001514.php @@ -0,0 +1,33 @@ +addSql('CREATE TABLE settings (id SERIAL NOT NULL, name VARCHAR(100) NOT NULL, value TEXT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX uniq_setting_name ON settings (name)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('DROP TABLE settings'); + } +} diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..d56834b92796a2e71e635435a0b04b5cf15bd8e3 GIT binary patch literal 8196 zcmeHMF;5#Y82wyANdp5DVqmyR?NX^j_yq?f9V`~igM&*)OM@3Wo6&iNX;prU?0*)Or*ci+pq+&OlLNUiR-szi%KR7Pc- zpG6ZWJkB*&9@w5Gph7&+E9z3K^Sat-ra88Bhy&t)I3Ny)1LDBH-~i@qF6DxG-;7Et z4u}K)r33tYh)@}0ipT@^sUGa7fh*HP?p^HxYZ&ukx(y9Mo~tK) z{_EK4Dd3KLnZym~EK>tIo3syofonB&35_|9tM72RduH_ocq46p;`zGt_k2d{>eD-@ zYSNB>^&Xu=)fmIobhO_7a5A{>`11|V<6g;PRzMFDdWEOj2$eCmm>J|Q?$@(0Q09L& zM2a|Yj~tjXS{Hf!UoYPOzek@)q>2OLz~4HcO0`C96;XOt4@C1UAE9oca$#O(P=}!L hehA?G@DD@0jzHy@*kWc7Jt(pfply&w9Jo^legn8k|DON= literal 0 HcmV?d00001 diff --git a/src/Command/PromoteAdminCommand.php b/src/Command/PromoteAdminCommand.php new file mode 100644 index 0000000..27f6b65 --- /dev/null +++ b/src/Command/PromoteAdminCommand.php @@ -0,0 +1,47 @@ +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('User not found: ' . $email . ''); + return Command::FAILURE; + } + + $roles = $user->getRoles(); + if (!in_array('ROLE_ADMIN', $roles, true)) { + $roles[] = 'ROLE_ADMIN'; + $user->setRoles($roles); + $this->em->flush(); + } + + $output->writeln('Granted ROLE_ADMIN to ' . $email . ''); + return Command::SUCCESS; + } +} + + diff --git a/src/Controller/AccountController.php b/src/Controller/AccountController.php new file mode 100644 index 0000000..bb1f90a --- /dev/null +++ b/src/Controller/AccountController.php @@ -0,0 +1,56 @@ +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'); + } +} + + diff --git a/src/Controller/Admin/SiteSettingsController.php b/src/Controller/Admin/SiteSettingsController.php new file mode 100644 index 0000000..cec2ff2 --- /dev/null +++ b/src/Controller/Admin/SiteSettingsController.php @@ -0,0 +1,37 @@ +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(), + ]); + } +} + + diff --git a/src/Controller/AlbumController.php b/src/Controller/AlbumController.php new file mode 100644 index 0000000..2aa0317 --- /dev/null +++ b/src/Controller/AlbumController.php @@ -0,0 +1,119 @@ +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(), + ]); + } +} + + diff --git a/src/Controller/RegistrationController.php b/src/Controller/RegistrationController.php new file mode 100644 index 0000000..c91c083 --- /dev/null +++ b/src/Controller/RegistrationController.php @@ -0,0 +1,62 @@ +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(), + ]); + } +} + + diff --git a/src/Controller/ReviewController.php b/src/Controller/ReviewController.php new file mode 100644 index 0000000..0794ccb --- /dev/null +++ b/src/Controller/ReviewController.php @@ -0,0 +1,86 @@ +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 +} + + diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php new file mode 100644 index 0000000..60098e3 --- /dev/null +++ b/src/Controller/SecurityController.php @@ -0,0 +1,32 @@ +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 + } +} + + diff --git a/src/Entity/Review.php b/src/Entity/Review.php new file mode 100644 index 0000000..5935ca0 --- /dev/null +++ b/src/Entity/Review.php @@ -0,0 +1,88 @@ +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; } +} + + diff --git a/src/Entity/Setting.php b/src/Entity/Setting.php new file mode 100644 index 0000000..8d91b0e --- /dev/null +++ b/src/Entity/Setting.php @@ -0,0 +1,33 @@ +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; } +} + + diff --git a/src/Entity/User.php b/src/Entity/User.php new file mode 100644 index 0000000..8bfc27d --- /dev/null +++ b/src/Entity/User.php @@ -0,0 +1,116 @@ + + */ + #[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 $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; + } +} + + diff --git a/src/Form/ProfileFormType.php b/src/Form/ProfileFormType.php new file mode 100644 index 0000000..e87b473 --- /dev/null +++ b/src/Form/ProfileFormType.php @@ -0,0 +1,51 @@ +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, + ]); + } +} + + diff --git a/src/Form/RegistrationFormType.php b/src/Form/RegistrationFormType.php new file mode 100644 index 0000000..7c41e1d --- /dev/null +++ b/src/Form/RegistrationFormType.php @@ -0,0 +1,53 @@ +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, + ]); + } +} + + diff --git a/src/Form/ReviewType.php b/src/Form/ReviewType.php new file mode 100644 index 0000000..6c1f139 --- /dev/null +++ b/src/Form/ReviewType.php @@ -0,0 +1,46 @@ +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, + ]); + } +} + + diff --git a/src/Form/SiteSettingsType.php b/src/Form/SiteSettingsType.php new file mode 100644 index 0000000..605cf39 --- /dev/null +++ b/src/Form/SiteSettingsType.php @@ -0,0 +1,33 @@ +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([]); + } +} + + diff --git a/src/Repository/ReviewRepository.php b/src/Repository/ReviewRepository.php new file mode 100644 index 0000000..8341952 --- /dev/null +++ b/src/Repository/ReviewRepository.php @@ -0,0 +1,60 @@ + + */ +class ReviewRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Review::class); + } + + /** + * @return list + */ + 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 $albumIds + * @return array + */ + 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; + } +} + + diff --git a/src/Repository/SettingRepository.php b/src/Repository/SettingRepository.php new file mode 100644 index 0000000..9247aca --- /dev/null +++ b/src/Repository/SettingRepository.php @@ -0,0 +1,32 @@ +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(); + } +} + + diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php new file mode 100644 index 0000000..a91b986 --- /dev/null +++ b/src/Repository/UserRepository.php @@ -0,0 +1,29 @@ + + */ +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(); + } +} + + diff --git a/src/Security/ReviewVoter.php b/src/Security/ReviewVoter.php new file mode 100644 index 0000000..d199db1 --- /dev/null +++ b/src/Security/ReviewVoter.php @@ -0,0 +1,37 @@ +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(); + } +} + + diff --git a/src/Service/SpotifyClient.php b/src/Service/SpotifyClient.php new file mode 100644 index 0000000..673bb21 --- /dev/null +++ b/src/Service/SpotifyClient.php @@ -0,0 +1,220 @@ +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 + */ + 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|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 $albumIds + * @return array|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 $options + * @return array + */ + 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']; + }); + } +} + + diff --git a/templates/.DS_Store b/templates/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..4632d4e58223dccc4b21517c9dd7875cb428c907 GIT binary patch literal 6148 zcmeHKyH3L}6upLmQs~fup<^U|fc`){+iB8Qa@V(KvywCnqd1Mw>P3_5 z+ezzrvxwQ5li5EsXWn1|OL9+%XiQ1mGg~WTyVcZlU%t3}P%}JMzBf1sXGjsyhieGse*~3w=Woa(3i>Nhc8~bhUH9Ibb`ms9vjl{%;lE z|Lsoh%{ky4_)`vuQrHX|_#}O{=01+kS|4Q}g@bvSLfHkCK91!BAH_8k8EEtQ0T^h^ S6e5Fge*~lru5u3ir~@BaXuW^{ literal 0 HcmV?d00001 diff --git a/templates/_partials/auth_modal.html.twig b/templates/_partials/auth_modal.html.twig new file mode 100644 index 0000000..6d65cd6 --- /dev/null +++ b/templates/_partials/auth_modal.html.twig @@ -0,0 +1,124 @@ + + + + + diff --git a/templates/_partials/navbar.html.twig b/templates/_partials/navbar.html.twig new file mode 100644 index 0000000..34948bb --- /dev/null +++ b/templates/_partials/navbar.html.twig @@ -0,0 +1,40 @@ + + + diff --git a/templates/account/dashboard.html.twig b/templates/account/dashboard.html.twig new file mode 100644 index 0000000..bcd9669 --- /dev/null +++ b/templates/account/dashboard.html.twig @@ -0,0 +1,17 @@ +{% extends 'base.html.twig' %} +{% block title %}Dashboard{% endblock %} +{% block body %} +

Your profile

+ {% for msg in app.flashes('success') %}
{{ msg }}
{% endfor %} + + {{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }} +
{{ form_label(form.email) }}{{ form_widget(form.email, {attr: {class: 'form-control'}}) }}{{ form_errors(form.email) }}
+
{{ form_label(form.displayName) }}{{ form_widget(form.displayName, {attr: {class: 'form-control'}}) }}{{ form_errors(form.displayName) }}
+
{{ form_label(form.currentPassword) }}{{ form_widget(form.currentPassword, {attr: {class: 'form-control'}}) }}{{ form_errors(form.currentPassword) }}
+
{{ form_label(form.newPassword.first) }}{{ form_widget(form.newPassword.first, {attr: {class: 'form-control'}}) }}{{ form_errors(form.newPassword.first) }}
+
{{ form_label(form.newPassword.second) }}{{ form_widget(form.newPassword.second, {attr: {class: 'form-control'}}) }}{{ form_errors(form.newPassword.second) }}
+ + {{ form_end(form) }} +{% endblock %} + + diff --git a/templates/account/settings.html.twig b/templates/account/settings.html.twig new file mode 100644 index 0000000..30f9459 --- /dev/null +++ b/templates/account/settings.html.twig @@ -0,0 +1,35 @@ +{% extends 'base.html.twig' %} +{% block title %}Settings{% endblock %} +{% block body %} +

Settings

+ +
+
+

Appearance

+
+ + +
+ Your choice is saved in a cookie. +
+
+ + +{% endblock %} + + diff --git a/templates/admin/settings.html.twig b/templates/admin/settings.html.twig new file mode 100644 index 0000000..8761527 --- /dev/null +++ b/templates/admin/settings.html.twig @@ -0,0 +1,26 @@ +{% extends 'base.html.twig' %} +{% block title %}Site Settings{% endblock %} +{% block body %} +

Site Settings

+ {% for msg in app.flashes('success') %}
{{ msg }}
{% endfor %} + +
+
+ {{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }} +
+ {{ form_label(form.SPOTIFY_CLIENT_ID) }} + {{ form_widget(form.SPOTIFY_CLIENT_ID, {attr: {class: 'form-control'}}) }} +
+
+ {{ form_label(form.SPOTIFY_CLIENT_SECRET) }} + {{ form_widget(form.SPOTIFY_CLIENT_SECRET, {attr: {class: 'form-control'}}) }} +
+
+ +
+ {{ form_end(form) }} +
+
+{% endblock %} + + diff --git a/templates/album/search.html.twig b/templates/album/search.html.twig new file mode 100644 index 0000000..e2b282d --- /dev/null +++ b/templates/album/search.html.twig @@ -0,0 +1,68 @@ +{% extends 'base.html.twig' %} +{% block title %}Album Search{% endblock %} +{% block body %} +

Search Albums

+
+
+ +
+
+ +
+ +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ + {% if query is empty and (album is empty) and (artist is empty) and (year_from is empty) and (year_to is empty) %} +

Tip: Use the Advanced search to filter by album, artist, or year range.

+ {% endif %} + + {% if albums is defined and albums|length > 0 %} +
+ {% for album in albums %} +
+
+ {% set image = (album.images[1] ?? album.images[0] ?? null) %} + {% if image %} + + {{ album.name }} cover + + {% endif %} +
+
{{ album.name }}
+

{{ album.artists|map(a => a.name)|join(', ') }}

+

Released {{ album.release_date }} • {{ album.total_tracks }} tracks

+ {% set s = stats[album.id] ?? { 'avg': 0, 'count': 0 } %} +

User score: {{ s.avg }}/10 ({{ s.count }})

+ +
+
+
+ {% endfor %} +
+ {% elseif query or album or artist or year_from or year_to %} +

No albums found.

+ {% endif %} +{% endblock %} + + diff --git a/templates/album/show.html.twig b/templates/album/show.html.twig new file mode 100644 index 0000000..2683162 --- /dev/null +++ b/templates/album/show.html.twig @@ -0,0 +1,68 @@ +{% extends 'base.html.twig' %} +{% block title %}{{ album.name }} — Reviews{% endblock %} +{% block body %} +
+
+
+ {% set image = (album.images[1] ?? album.images[0] ?? null) %} + {% if image %} + {{ album.name }} cover + {% endif %} +
+
{{ album.name }}
+
{{ album.artists|map(a => a.name)|join(', ') }}
+

Released {{ album.release_date }} • {{ album.total_tracks }} tracks

+

User score: {{ avg }}/10 ({{ count }})

+ Open in Spotify +
+
+
+
+
+

Reviews

+
+
+ {% for r in reviews %} +
+
+
{{ r.title }} (Rating {{ r.rating }}/10)
+
by {{ r.author.displayName ?? r.author.userIdentifier }} • {{ r.createdAt|date('Y-m-d H:i') }}
+

{{ r.content|u.truncate(300, '…', false) }}

+ Read more +
+
+ {% else %} +

No reviews yet for this album.

+ {% endfor %} +
+ + {% if app.user %} +
+
+

Leave a review

+ {{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }} +
{{ form_label(form.title) }}{{ form_widget(form.title, {attr: {class: 'form-control'}}) }}{{ form_errors(form.title) }}
+
{{ form_label(form.content) }}{{ form_widget(form.content, {attr: {class: 'form-control', rows: 6}}) }}{{ form_errors(form.content) }}
+
+ {{ form_label(form.rating) }} +
+ {{ form_widget(form.rating, {attr: {class: 'form-range', min:1, max:10, step:1, list:'rating-ticks', oninput:'document.getElementById("rating-value").textContent=this.value;'}}) }} + {{ form.rating.vars.value ?? 5 }} +
+ + {% for i in 1..10 %}{% endfor %} + + {{ form_errors(form.rating) }} +
+ + {{ form_end(form) }} +
+
+ {% else %} +
Sign in to leave a review.
+ {% endif %} +
+
+{% endblock %} + + diff --git a/templates/base.html.twig b/templates/base.html.twig new file mode 100644 index 0000000..6d86eef --- /dev/null +++ b/templates/base.html.twig @@ -0,0 +1,20 @@ + + + + + + {% block title %}Music Ratings{% endblock %} + + + + {% include '_partials/navbar.html.twig' %} +
+ {% block body %}{% endblock %} +
+ + + {% include '_partials/auth_modal.html.twig' %} + + + + diff --git a/templates/review/edit.html.twig b/templates/review/edit.html.twig new file mode 100644 index 0000000..ef74d9f --- /dev/null +++ b/templates/review/edit.html.twig @@ -0,0 +1,17 @@ +{% extends 'base.html.twig' %} +{% block title %}Edit Review{% endblock %} +{% block body %} +

Edit review

+

{{ review.albumName }} — {{ review.albumArtist }} ({{ review.spotifyAlbumId }})

+ + {{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }} +
{{ form_label(form.title) }}{{ form_widget(form.title, {attr: {class: 'form-control'}}) }}{{ form_errors(form.title) }}
+
{{ form_label(form.content) }}{{ form_widget(form.content, {attr: {class: 'form-control', rows: 8}}) }}{{ form_errors(form.content) }}
+
{{ form_label(form.rating) }}{{ form_widget(form.rating, {attr: {class: 'form-control'}}) }}{{ form_errors(form.rating) }}
+ + {{ form_end(form) }} + +

Back to review

+{% endblock %} + + diff --git a/templates/review/index.html.twig b/templates/review/index.html.twig new file mode 100644 index 0000000..e65efb9 --- /dev/null +++ b/templates/review/index.html.twig @@ -0,0 +1,29 @@ +{% extends 'base.html.twig' %} +{% block title %}Album Reviews{% endblock %} +{% block body %} +
+

Album reviews

+ {% if app.user %} + New review + {% endif %} +
+ +
+ {% for r in reviews %} +
+
+
+
{{ r.title }} (Rating {{ r.rating }}/10)
+
{{ r.albumName }} — {{ r.albumArtist }}
+

{{ r.content|u.truncate(220, '…', false) }}

+ Read more +
+
+
+ {% else %} +

No reviews yet.

+ {% endfor %} +
+{% endblock %} + + diff --git a/templates/review/new.html.twig b/templates/review/new.html.twig new file mode 100644 index 0000000..7c2aaee --- /dev/null +++ b/templates/review/new.html.twig @@ -0,0 +1,30 @@ +{% extends 'base.html.twig' %} +{% block title %}New Review{% endblock %} +{% block body %} +

Write a review

+ +
+
+ + +
+
+ + +
+
+
+ + +
+ + {{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }} +
{{ form_label(form.title) }}{{ form_widget(form.title, {attr: {class: 'form-control'}}) }}{{ form_errors(form.title) }}
+
{{ form_label(form.content) }}{{ form_widget(form.content, {attr: {class: 'form-control', rows: 8}}) }}{{ form_errors(form.content) }}
+
{{ form_label(form.rating) }}{{ form_widget(form.rating, {attr: {class: 'form-control'}}) }}{{ form_errors(form.rating) }}
+ + {{ form_end(form) }} + +{% endblock %} + + diff --git a/templates/review/show.html.twig b/templates/review/show.html.twig new file mode 100644 index 0000000..2955f8b --- /dev/null +++ b/templates/review/show.html.twig @@ -0,0 +1,22 @@ +{% extends 'base.html.twig' %} +{% block title %}{{ review.title }}{% endblock %} +{% block body %} +

← Back

+

{{ review.title }} (Rating {{ review.rating }}/10)

+

{{ review.albumName }} — {{ review.albumArtist }} ({{ review.spotifyAlbumId }})

+
+

{{ review.content|nl2br }}

+
+ + {% if is_granted('REVIEW_EDIT', review) %} +
+ Edit +
+ + +
+
+ {% endif %} +{% endblock %} + + diff --git a/var/.DS_Store b/var/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..b06c0d51d86dd2102e5b7786c30efd52fc0f167b GIT binary patch literal 6148 zcmeHKy-veG47O>JBC(W>@m`?=d#J(_bYm)QQ9C3`7xohi&%nmW1`=Z71(;ZQ40r$* zKA#O~Xe71@fi3yIJO7-MPs&|PL_9j54~YgultBeqCLJQ?PS=hFi<8qHXXRozp4HW= zW1HV;l4swg8J$9x)OvotxqWVXtGXzrWj%#8d%3zed^~wN`W`X=A!4;`7~UZa-c(p7px#Z}*oETG#Tbx~aM8U9U%Bf=ykV0cXG&a0dQ41GuwAvOPr~odIXS z8TevA&W8XM%nhSrIyx|<7690XISJ;{OGr*I%nhR=JP_7Ypr*357_8~A2aC%MqoSr0 zTl2wI`LlW9R2}<=6i%Eg`sfTe18oL+x*W*;e~eG2v&nC#_{te@2L2cW+%Lw(2rp%K z>&ENJT^pbisEEWxnLsda{RChk=g2{Fsz1maak*hslwHK0(t-XXkO=X?8Q1~?9}pW$ AzyJUM literal 0 HcmV?d00001