diff --git a/.env.example b/.env.example index 5ea494a..2479b3f 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,13 @@ SPOTIFY_CLIENT_ID= SPOTIFY_CLIENT_SECRET= -APP_SECRET=changeme -DEFAULT_URI=http://localhost:8000 +APP_ENV=dev +APP_SECRET=changeme # Arbitrary secret. Ideally a long random string. +APP_ALLOW_REGISTRATION=1 # +DEFAULT_URI=http://localhost:8000 # Should match external URI of application. +DATABASE_DRIVER=postgres # Allowed values: postgres, sqlite DATABASE_URL=postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony}@db:5432/${POSTGRES_DB:-symfony}?serverVersion=16&charset=utf8 +#DATABASE_SQLITE_PATH=/absolute/path/to/database.sqlite # Optional override when DATABASE_DRIVER=sqlite +ALBUM_SEARCH_LIMIT=30 # Amount of albums to be displayed at once. WARNING: Setting this number too high may cause rate limits. # POSTGRES_DB= # POSTGRES_USER= @@ -10,7 +15,3 @@ DATABASE_URL=postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony PGADMIN_DEFAULT_EMAIL=admin@example.com PGADMIN_DEFAULT_PASSWORD=password - -SPOTIFY_RATE_WINDOW_SECONDS=30 -SPOTIFY_RATE_MAX_REQUESTS=50 -SPOTIFY_RATE_MAX_REQUESTS_SENSITIVE=20 \ No newline at end of file diff --git a/.gitignore b/.gitignore index e7136ac..fd9fb0e 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ !var/cache/.gitkeep !var/logs/.gitkeep !var/sessions/.gitkeep +/var/data/ # Logs (Symfony4) /var/log/* diff --git a/README.md b/README.md index 8f09378..3904119 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,12 @@ docker compose exec php php bin/console app:promote-admin you@example.com 5) Visit `http://localhost:8000` to search for albums. +## Database driver + +- Set `DATABASE_DRIVER=postgres` (default) to keep using the Postgres 16 container defined in `docker-compose.yml`. +- Set `DATABASE_DRIVER=sqlite` to run against a self-contained SQLite file stored at `var/data/database.sqlite`. +- When `DATABASE_DRIVER=sqlite`, the `DATABASE_URL` env var is ignored. Doctrine will automatically create and use the SQLite file; override the default location with `DATABASE_SQLITE_PATH` if needed. + ## Features - Spotify search with Advanced filters (album, artist, year range) and per-album aggregates (avg/count) @@ -45,17 +51,6 @@ docker compose exec php php bin/console app:promote-admin you@example.com ## 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 @@ -67,8 +62,7 @@ See `/docs` for how-tos and deeper notes: - 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` +- Troubleshooting: `docs/07-troubleshooting.md` ## License diff --git a/composer.json b/composer.json index 3ed6916..d690e94 100644 --- a/composer.json +++ b/composer.json @@ -11,14 +11,10 @@ "doctrine/doctrine-bundle": "^2.18", "doctrine/doctrine-migrations-bundle": "^3.5", "doctrine/orm": "^3.5", - "phpdocumentor/reflection-docblock": "^5.6", - "phpstan/phpdoc-parser": "^2.3", "symfony/asset": "7.3.*", "symfony/asset-mapper": "7.3.*", "symfony/console": "7.3.*", - "symfony/doctrine-messenger": "7.3.*", "symfony/dotenv": "7.3.*", - "symfony/expression-language": "7.3.*", "symfony/flex": "^2", "symfony/form": "7.3.*", "symfony/framework-bundle": "7.3.*", @@ -35,7 +31,6 @@ "symfony/string": "7.3.*", "symfony/twig-bundle": "7.3.*", "symfony/validator": "7.3.*", - "symfony/web-link": "7.3.*", "symfony/yaml": "7.3.*", "twig/extra-bundle": "^2.12|^3.0", "twig/string-extra": "^3.22", diff --git a/config/packages/doctrine.php b/config/packages/doctrine.php new file mode 100644 index 0000000..f560c7d --- /dev/null +++ b/config/packages/doctrine.php @@ -0,0 +1,43 @@ +dbal(); + $dbal->defaultConnection('default'); + + $connection = $dbal->connection('default'); + $connection->profilingCollectBacktrace('%kernel.debug%'); + $connection->useSavepoints(true); + + if ('sqlite' === $driver) { + $connection->driver('pdo_sqlite'); + + $hasCustomPath = array_key_exists('DATABASE_SQLITE_PATH', $_ENV) + || array_key_exists('DATABASE_SQLITE_PATH', $_SERVER); + + if ($hasCustomPath) { + $connection->path('%env(resolve:DATABASE_SQLITE_PATH)%'); + } else { + $connection->path('%kernel.project_dir%/var/data/database.sqlite'); + } + } else { + $connection->url('%env(resolve:DATABASE_URL)%'); + $connection->serverVersion('16'); + $connection->charset('utf8'); + } +}; + diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 25138b9..8ccd241 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -1,13 +1,4 @@ doctrine: - dbal: - url: '%env(resolve:DATABASE_URL)%' - - # IMPORTANT: You MUST configure your server version, - # either here or in the DATABASE_URL env var (see .env file) - #server_version: '16' - - profiling_collect_backtrace: '%kernel.debug%' - use_savepoints: true orm: auto_generate_proxy_classes: true enable_lazy_ghost_objects: true diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml deleted file mode 100644 index 0502156..0000000 --- a/config/packages/messenger.yaml +++ /dev/null @@ -1,27 +0,0 @@ -framework: - messenger: - failure_transport: failed - - transports: - # https://symfony.com/doc/current/messenger.html#transport-configuration - async: - #dsn: '%env(MESSENGER_TRANSPORT_DSN)%' - dsn: 'sync://' # meh - options: - use_notify: true - check_delayed_interval: 60000 - retry_strategy: - max_retries: 3 - multiplier: 2 - failed: 'doctrine://default?queue_name=failed' - # sync: 'sync://' - - default_bus: messenger.bus.default - - buses: - messenger.bus.default: [] - - routing: - - # Route your messages to the transports - # 'App\Message\YourMessage': async diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 30cbf4a..1bbb43b 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -1,4 +1,7 @@ security: + role_hierarchy: + ROLE_ADMIN: ['ROLE_MODERATOR'] + ROLE_MODERATOR: ['ROLE_USER'] # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords password_hashers: Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' @@ -17,11 +20,11 @@ security: provider: app_user_provider form_login: - login_path: album_search + login_path: app_login check_path: app_login enable_csrf: true default_target_path: album_search - failure_path: album_search + failure_path: app_login username_parameter: _username password_parameter: _password csrf_parameter: _csrf_token @@ -45,8 +48,9 @@ security: # Easy way to control access for large sections of your site # Note: Only the *first* access control that matches will be used access_control: - # - { path: ^/admin, roles: ROLE_ADMIN } - # - { path: ^/profile, roles: ROLE_USER } + - { path: ^/admin/settings, roles: ROLE_ADMIN } + - { path: ^/admin/users, roles: ROLE_MODERATOR } + - { path: ^/admin/dashboard, roles: ROLE_MODERATOR } when@test: security: diff --git a/docker-compose.yml b/docker-compose.yml index f872111..a0a7f1f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,7 @@ services: build: context: . dockerfile: docker/php/Dockerfile - target: dev # change to "prod" for production build + target: dev args: - APP_ENV=dev container_name: php diff --git a/docs/01-setup.md b/docs/01-setup.md index c75a7fa..56b7f15 100644 --- a/docs/01-setup.md +++ b/docs/01-setup.md @@ -21,12 +21,23 @@ docker compose exec php php bin/console doctrine:migrations:migrate --no-interac docker compose exec php php bin/console app:promote-admin you@example.com ``` +## Moderator (optional) +```bash +docker compose exec php php bin/console app:promote-moderator mod@example.com +``` + ## Spotify credentials -- Prefer admin UI: open `/admin/settings` and enter Client ID/Secret. +- Prefer admin UI: open `/admin/settings` and enter Client ID/Secret. (Stored in DB) - Fallback to env vars: ```bash export SPOTIFY_CLIENT_ID=your_client_id export SPOTIFY_CLIENT_SECRET=your_client_secret ``` +## Optional feature flags +- Disable public registration by setting an env variable before starting Symfony: +```bash +export APP_ALLOW_REGISTRATION=0 # set to 1 (default) to re-enable +``` + diff --git a/docs/02-features.md b/docs/02-features.md index 6ff6938..e77683e 100644 --- a/docs/02-features.md +++ b/docs/02-features.md @@ -5,8 +5,10 @@ - Review rating slider (1–10) with live badge - Per-album aggregates: average rating and total review count - Auth modal (Login/Sign up) with remember-me cookie -- Role-based access (author vs admin) +- Role-based access (user, moderator, admin) with protected admin routes - Admin Site Settings to manage Spotify credentials +- Moderator/Admin dashboard with latest activity snapshots +- User management table (create/delete accounts, promote/demote moderators) - User Dashboard for profile changes (email, display name, password) - Light/Dark theme toggle (cookie-backed) - Bootstrap UI diff --git a/docs/03-auth-and-users.md b/docs/03-auth-and-users.md index d0ad452..4b99ea7 100644 --- a/docs/03-auth-and-users.md +++ b/docs/03-auth-and-users.md @@ -7,10 +7,24 @@ ## Roles - `ROLE_USER`: default for registered users. -- `ROLE_ADMIN`: promoted via console `app:promote-admin`. +- `ROLE_MODERATOR`: promoted via console `app:promote-moderator`, or via webUI; can manage users and all reviews/albums but not site settings. +- `ROLE_ADMIN`: promoted via console `app:promote-admin`; includes moderator abilities plus site settings access. + +### Access flow +- Visiting `/admin/dashboard`, `/admin/users`, or `/admin/settings` while unauthenticated forces a redirect through `/login`, which re-opens the modal automatically. +- Moderators inherit all `ROLE_USER` permissions; admins inherit both moderator and user permissions via the role hierarchy. +- Admin-only actions (site settings, moderator toggling, deleting other admins) are additionally guarded in controllers/templates to avoid accidental misuse. + +### User management UI +- `/admin/users` (moderator+) lists every account along with album/review counts. + - Moderators can create new accounts (without affecting their own login session.. ). +- Delete buttons are disabled (with tooltip hints) for protected rows such as the current user or any admin. +- Admins see a Promote/Demote toggle: promoting grants `ROLE_MODERATOR`; demoting removes that role unless the target is an admin (admins always outrank moderators). +- Admins can disable public registration from `/admin/settings`; when disabled, the “Sign up” button in the auth modal is replaced with a tooltip explaining that registration is closed, but `/admin/users` remains fully functional. +- Registration can also be enforced via `APP_ALLOW_REGISTRATION=0/1` in the environment; the DB setting syncs on each Symfony boot, so flips take effect after the next restart. ## Password changes -- On `/dashboard`, users can change email/display name. +- On `/profile`, users can change email/display name. - To set a new password, the current password must be provided. ## Logout diff --git a/docs/04-spotify-integration.md b/docs/04-spotify-integration.md index 96c4a91..49d2bc1 100644 --- a/docs/04-spotify-integration.md +++ b/docs/04-spotify-integration.md @@ -9,7 +9,6 @@ - Client Credentials token fetch (cached) - `searchAlbums(q, limit)` - `getAlbum(id)` and `getAlbums([ids])` - - Centralized request pipeline: throttling, 429 backoff, response caching ## Advanced search - The search page builds Spotify fielded queries: diff --git a/docs/05-reviews-and-albums.md b/docs/05-reviews-and-albums.md index 7c11045..460ba6a 100644 --- a/docs/05-reviews-and-albums.md +++ b/docs/05-reviews-and-albums.md @@ -8,7 +8,7 @@ ## Permissions - Anyone can view. - Authors can edit/delete their own reviews. -- Admins can edit/delete any review. +- Moderators and admins can edit/delete any review or user-created album. ## UI - Rating uses a slider (1–10) with ticks; badge shows current value. diff --git a/docs/06-admin-and-settings.md b/docs/06-admin-and-settings.md index 3ed6cef..a892a0b 100644 --- a/docs/06-admin-and-settings.md +++ b/docs/06-admin-and-settings.md @@ -1,14 +1,42 @@ # Admin & Settings +## Access control +- All `/admin/*` pages require authentication; unauthorized visitors get redirected through `/login`, which opens the auth modal automatically. +- `ROLE_MODERATOR` grants dashboard + user list access. +- `ROLE_ADMIN` adds settings access and moderator promotion/demotion abilities. + +## Site dashboard (ROLE_MODERATOR) +- URL: `/admin/dashboard` +- Shows total counts plus the most recent reviews and albums so staff can moderate activity quickly. + +## User management (ROLE_MODERATOR) +- URL: `/admin/users` +- Table columns: + - Name/email/roles + album/review counts (queried via aggregates). + - Action buttons always render; disabled buttons show tooltips describing why (e.g., "Administrators cannot be deleted"). +- Moderators: + - Create new accounts via the inline form without logging themselves out. + - Delete standard users or other moderators (except themselves). +- Admins: + - Toggle moderator role (Promote/Demote) for non-admin accounts. + - Cannot delete or demote other admins—admin privileges supersede moderator status. + ## Site settings (ROLE_ADMIN) - URL: `/admin/settings` -- Manage Spotify credentials stored in DB. +- Form persists Spotify Client ID/Secret in the DB (no restart needed). +- Toggle “Allow self-service registration” to pause public sign-ups while keeping `/admin/users` creation available to staff. +- The setting syncs with the `APP_ALLOW_REGISTRATION` environment variable each time Symfony boots (change the env value and restart to enforce). UI changes persist while the process runs. +- CSRF + role guards prevent unauthorized updates. ## User management - Promote an admin: ```bash docker compose exec php php bin/console app:promote-admin user@example.com ``` +- Promote a moderator: +```bash +docker compose exec php php bin/console app:promote-moderator user@example.com +``` ## Appearance - `/settings` provides a dark/light mode toggle. diff --git a/docs/07-rate-limits-and-caching.md b/docs/07-rate-limits-and-caching.md deleted file mode 100644 index 45af1df..0000000 --- a/docs/07-rate-limits-and-caching.md +++ /dev/null @@ -1,23 +0,0 @@ -# Rate Limits & Caching - -## Throttling -- Requests are throttled per window (default 30s) to avoid bursts. -- Separate caps for sensitive endpoints. -- Configure via env: -```bash -SPOTIFY_RATE_WINDOW_SECONDS=30 -SPOTIFY_RATE_MAX_REQUESTS=50 -SPOTIFY_RATE_MAX_REQUESTS_SENSITIVE=20 -``` - -## 429 handling -- If Spotify returns 429, respects `Retry-After` and retries (up to 3 attempts). - -## Response caching -- GET responses cached: search ~10 minutes, album ~1 hour. -- Token responses are cached separately. - -## Batching -- `getAlbums([ids])` provided for batch lookups. - - diff --git a/docs/08-troubleshooting.md b/docs/07-troubleshooting.md similarity index 64% rename from docs/08-troubleshooting.md rename to docs/07-troubleshooting.md index 06dc479..604baa0 100644 --- a/docs/08-troubleshooting.md +++ b/docs/07-troubleshooting.md @@ -13,7 +13,8 @@ ## Login modal shows blank - Make sure Bootstrap JS loads before the modal script (handled in `base.html.twig`). -## Rate limits / 429 -- Client backs off using `Retry-After`. Reduce concurrent requests; increase window env vars if needed. - +## Hitting admin routes redirects to home +- Expected when not logged in or lacking the required role. +- Ensure your user has `ROLE_MODERATOR` for `/admin/dashboard` or `/admin/users`, and `ROLE_ADMIN` for `/admin/settings`. +- Use the console commands in `06-admin-and-settings.md` to grant roles. diff --git a/public/css/app.css b/public/css/app.css new file mode 100644 index 0000000..8e89ae2 --- /dev/null +++ b/public/css/app.css @@ -0,0 +1,187 @@ +:root { + color-scheme: light; + --accent-color: #6750a4; + --accent-on-color: #ffffff; + --md-surface: color-mix(in srgb, var(--accent-color) 6%, #ffffff); + --md-surface-variant: color-mix(in srgb, var(--accent-color) 14%, #f5f4fa); + --md-card: #ffffff; + --md-card-border: color-mix(in srgb, var(--accent-color) 24%, transparent); + --md-outline: color-mix(in srgb, var(--accent-color) 18%, #d9d5ea); + --md-text-primary: #1c1b20; + --md-text-secondary: color-mix(in srgb, var(--accent-color) 30%, #4a4458); + --md-muted-bg: color-mix(in srgb, var(--accent-color) 18%, transparent); + --md-focus-ring: color-mix(in srgb, var(--accent-color) 45%, transparent); + --md-shadow-ambient: 0 12px 32px color-mix(in srgb, rgba(15, 13, 33, 0.2) 70%, var(--accent-color) 10%); +} + +[data-bs-theme='dark'] { + color-scheme: dark; + --md-surface: color-mix(in srgb, var(--accent-color) 5%, #131217); + --md-surface-variant: color-mix(in srgb, var(--accent-color) 14%, #1f1e25); + --md-card: color-mix(in srgb, var(--accent-color) 8%, #1f1e25); + --md-card-border: color-mix(in srgb, var(--accent-color) 35%, transparent); + --md-outline: color-mix(in srgb, var(--accent-color) 35%, #6c6772); + --md-text-primary: color-mix(in srgb, var(--accent-color) 6%, #f5f2ff); + --md-text-secondary: color-mix(in srgb, var(--accent-color) 24%, #cfc6dc); + --md-muted-bg: color-mix(in srgb, var(--accent-color) 22%, transparent); + --md-shadow-ambient: 0 16px 40px rgba(0, 0, 0, 0.55); +} + +body { + background-color: var(--md-surface); + color: var(--md-text-primary); + font-family: 'Inter', 'Segoe UI', -apple-system, BlinkMacSystemFont, 'Roboto', sans-serif; + transition: background-color 0.2s ease, color 0.2s ease; +} + +main.container { + max-width: 1100px; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + color: var(--md-text-primary); + font-weight: 600; +} + +.text-secondary { + color: var(--md-text-secondary) !important; +} + +.card { + background-color: var(--md-card); + border: 1px solid var(--md-card-border); + border-radius: 20px; + box-shadow: var(--md-shadow-ambient); + overflow: hidden; +} + +.navbar.bg-body-tertiary { + background-color: color-mix(in srgb, var(--accent-color) 6%, rgba(255, 255, 255, 0.92)) !important; + backdrop-filter: blur(16px); + border: 1px solid var(--md-card-border); + border-radius: 0 0 28px 28px; + box-shadow: 0 12px 32px rgba(15, 13, 33, 0.1); + margin: 0 auto 2rem auto; + max-width: 1100px; + width: calc(100% - 2rem); + position: relative; + z-index: 1040; +} + +@media (max-width: 1199px) { + .navbar.bg-body-tertiary { + border-radius: 0; + width: 100%; + margin: 0 0 1.5rem 0; + border-left: none; + border-right: none; + } +} + +[data-bs-theme='dark'] .navbar.bg-body-tertiary { + background-color: color-mix(in srgb, var(--accent-color) 8%, rgba(26, 25, 32, 0.96)) !important; + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.55); +} + +.dropdown-menu { + border-radius: 16px; + border: 1px solid var(--md-card-border); + background-color: var(--md-card); +} + +.btn-success, +.btn-primary, +.btn-accent { + background-color: var(--accent-color); + border-color: var(--accent-color); + color: var(--accent-on-color); + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.btn-success:hover, +.btn-primary:hover, +.btn-accent:hover { + background-color: var(--accent-color); + border-color: var(--accent-color); + color: var(--accent-on-color); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); + transform: translateY(-1px); +} + +.btn-outline-secondary, +.btn-outline-success, +.btn-outline-primary { + color: var(--accent-color); + border-color: var(--accent-color); +} + +.btn-outline-secondary:hover, +.btn-outline-success:hover, +.btn-outline-primary:hover { + background-color: var(--accent-color); + border-color: var(--accent-color); + color: var(--accent-on-color); +} + +a { + color: var(--accent-color); + text-decoration: none; +} + +a:hover { + color: var(--accent-color); + opacity: 0.9; + text-decoration: underline; +} + +.form-control, +.form-select { + border-radius: 14px; + border-color: var(--md-outline); + background-color: var(--md-card); + color: var(--md-text-primary); +} + +.form-control:focus, +.form-select:focus { + border-color: var(--accent-color); + box-shadow: 0 0 0 0.2rem var(--md-focus-ring); +} + +.form-check-input:checked { + background-color: var(--accent-color); + border-color: var(--accent-color); +} + +.badge.text-bg-primary, +.badge.text-bg-secondary { + background-color: var(--accent-color) !important; + color: var(--accent-on-color) !important; +} + +.landing-search-input { + border: 1px solid var(--md-card-border); +} + +.card .btn.btn-outline-primary, +.card .btn.btn-outline-success { + border-radius: 999px; +} + +.card .btn.btn-success, +.card .btn.btn-outline-primary, +.card .btn.btn-outline-success { + font-weight: 600; +} + +.alert { + border-radius: 16px; + border: none; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08); +} + diff --git a/src/Command/PromoteModeratorCommand.php b/src/Command/PromoteModeratorCommand.php new file mode 100644 index 0000000..505f7d3 --- /dev/null +++ b/src/Command/PromoteModeratorCommand.php @@ -0,0 +1,57 @@ +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('User not found: ' . $email . ''); + return Command::FAILURE; + } + + $roles = $user->getRoles(); + if (!in_array('ROLE_MODERATOR', $roles, true)) { + $roles[] = 'ROLE_MODERATOR'; + $user->setRoles($roles); + $this->em->flush(); + } + + $output->writeln('Granted ROLE_MODERATOR to ' . $email . ''); + return Command::SUCCESS; + } +} + + + diff --git a/src/Controller/AccountController.php b/src/Controller/AccountController.php index 047a030..d5bae7d 100644 --- a/src/Controller/AccountController.php +++ b/src/Controller/AccountController.php @@ -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(), - ]); - } } diff --git a/src/Controller/Admin/DashboardController.php b/src/Controller/Admin/DashboardController.php index 798d25c..a55147b 100644 --- a/src/Controller/Admin/DashboardController.php +++ b/src/Controller/Admin/DashboardController.php @@ -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 { /** diff --git a/src/Controller/Admin/SettingsController.php b/src/Controller/Admin/SettingsController.php index ae04384..81cd9b3 100644 --- a/src/Controller/Admin/SettingsController.php +++ b/src/Controller/Admin/SettingsController.php @@ -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, ]); } } \ No newline at end of file diff --git a/src/Controller/Admin/UserController.php b/src/Controller/Admin/UserController.php new file mode 100644 index 0000000..aea99a6 --- /dev/null +++ b/src/Controller/Admin/UserController.php @@ -0,0 +1,121 @@ +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'); + } +} + + + diff --git a/src/Controller/AlbumController.php b/src/Controller/AlbumController.php index 3621360..bef61b0 100644 --- a/src/Controller/AlbumController.php +++ b/src/Controller/AlbumController.php @@ -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,stats:array,savedIds:array} - */ - 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,stats:array,savedIds:array} - */ - 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,stats:array} - */ - 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 $userAlbums - * @param array $userStats - * @return array - */ - 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 $albums - * @return list - */ - 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 $searchItems - * @return list - */ - 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> $primary - * @param array> $secondary - * @return array> - */ - 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); - } } diff --git a/src/Controller/RegistrationController.php b/src/Controller/RegistrationController.php index 888c29f..556d99d 100644 --- a/src/Controller/RegistrationController.php +++ b/src/Controller/RegistrationController.php @@ -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']); diff --git a/src/Dto/AdminUserData.php b/src/Dto/AdminUserData.php new file mode 100644 index 0000000..1532b78 --- /dev/null +++ b/src/Dto/AdminUserData.php @@ -0,0 +1,24 @@ +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'; + } +} + diff --git a/src/Dto/AlbumSearchResult.php b/src/Dto/AlbumSearchResult.php new file mode 100644 index 0000000..3a97f7c --- /dev/null +++ b/src/Dto/AlbumSearchResult.php @@ -0,0 +1,23 @@ +> $albums + * @param array $stats + * @param array $savedIds + */ + public function __construct( + public readonly AlbumSearchCriteria $criteria, + public readonly array $albums, + public readonly array $stats, + public readonly array $savedIds + ) { + } +} + diff --git a/src/Form/AdminUserType.php b/src/Form/AdminUserType.php new file mode 100644 index 0000000..e48ad91 --- /dev/null +++ b/src/Form/AdminUserType.php @@ -0,0 +1,57 @@ +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, + ]); + } +} + + diff --git a/src/Form/AlbumType.php b/src/Form/AlbumType.php index 41fbc8e..3c4159e 100644 --- a/src/Form/AlbumType.php +++ b/src/Form/AlbumType.php @@ -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, [ diff --git a/src/Form/ChangePasswordFormType.php b/src/Form/ChangePasswordFormType.php index 7326c50..b72f8a3 100644 --- a/src/Form/ChangePasswordFormType.php +++ b/src/Form/ChangePasswordFormType.php @@ -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 } } - diff --git a/src/Form/SiteSettingsType.php b/src/Form/SiteSettingsType.php index 1a492c3..26b39fc 100644 --- a/src/Form/SiteSettingsType.php +++ b/src/Form/SiteSettingsType.php @@ -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, ]); } diff --git a/src/Repository/SettingRepository.php b/src/Repository/SettingRepository.php index dcbdd3d..97bc452 100644 --- a/src/Repository/SettingRepository.php +++ b/src/Repository/SettingRepository.php @@ -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(); diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 464a29a..167243b 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -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 + */ + 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); + } } diff --git a/src/Security/ReviewVoter.php b/src/Security/ReviewVoter.php index 745f113..6b194a9 100644 --- a/src/Security/ReviewVoter.php +++ b/src/Security/ReviewVoter.php @@ -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; } diff --git a/src/Service/AlbumSearchService.php b/src/Service/AlbumSearchService.php new file mode 100644 index 0000000..07286e9 --- /dev/null +++ b/src/Service/AlbumSearchService.php @@ -0,0 +1,313 @@ +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>,stats:array,savedIds:array} + */ + 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> $storedPayloads + * @return array{payloads:array>,stats:array,savedIds:array} + */ + 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>,stats:array} + */ + 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 $userAlbums + * @param array $userStats + * @return array + */ + 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 $albums + * @return list + */ + 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 $searchItems + * @return list + */ + 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> $primary + * @param array> $secondary + * @return array> + */ + 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); + } +} + diff --git a/src/Service/RegistrationToggle.php b/src/Service/RegistrationToggle.php new file mode 100644 index 0000000..2289a01 --- /dev/null +++ b/src/Service/RegistrationToggle.php @@ -0,0 +1,69 @@ +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; + } +} + diff --git a/src/Twig/AppSettingsExtension.php b/src/Twig/AppSettingsExtension.php new file mode 100644 index 0000000..e38608c --- /dev/null +++ b/src/Twig/AppSettingsExtension.php @@ -0,0 +1,27 @@ + $this->registrationToggle->isEnabled(), + ]; + } +} + + + diff --git a/templates/.DS_Store b/templates/.DS_Store index 4632d4e..7c2d398 100644 Binary files a/templates/.DS_Store and b/templates/.DS_Store differ diff --git a/templates/_partials/auth_modal.html.twig b/templates/_partials/auth_modal.html.twig index 6d65cd6..89efa2f 100644 --- a/templates/_partials/auth_modal.html.twig +++ b/templates/_partials/auth_modal.html.twig @@ -1,4 +1,5 @@ -