Compare commits

...

21 Commits

Author SHA1 Message Date
391ecf1732 Update Monolog action level to info, adjust Dockerfile permissions and logging configuration, and set supervisord user to root
All checks were successful
CI (Gitea) / php-tests (push) Successful in 10m4s
CI (Gitea) / docker-image (push) Successful in 2m23s
2025-11-28 10:08:36 +00:00
4ae7a44881 Update trusted proxies configuration to use default environment variable and add importmap block in base template
All checks were successful
CI (Gitea) / php-tests (push) Successful in 10m16s
CI (Gitea) / docker-image (push) Successful in 2m29s
2025-11-28 08:59:28 +00:00
fa54cb4167 Enhance CI workflow by adding PHP setup step and caching for vendor directory
Some checks failed
CI (Gitea) / php-tests (push) Failing after 6m22s
CI (Gitea) / docker-image (push) Has been skipped
2025-11-28 08:49:59 +00:00
f109c933c1 add trusted proxies configuration
Some checks failed
CI (Gitea) / docker-image (push) Has been cancelled
CI (Gitea) / php-tests (push) Has been cancelled
2025-11-28 08:47:17 +00:00
d52eb6bd81 documentation and env changes
All checks were successful
CI (Gitea) / php-tests (push) Successful in 10m8s
CI (Gitea) / docker-image (push) Successful in 2m18s
2025-11-28 08:14:13 +00:00
f77f3a9e40 its 7am i havent slept i have no idea
All checks were successful
CI (Gitea) / php-tests (push) Successful in 10m5s
CI (Gitea) / docker-image (push) Successful in 2m22s
2025-11-28 06:40:10 +00:00
336dcc4d3a erm
All checks were successful
CI (Gitea) / php-tests (push) Successful in 10m23s
CI (Gitea) / docker-image (push) Successful in 3m3s
2025-11-28 03:23:52 +00:00
54b1908793 Add APP_SECRET to Dockerfile
Some checks failed
CI (Gitea) / php-tests (push) Successful in 10m11s
CI (Gitea) / docker-image (push) Failing after 2m17s
2025-11-28 02:59:01 +00:00
dda9ff06b5 add temp build secret
Some checks failed
CI (Gitea) / php-tests (push) Successful in 10m7s
CI (Gitea) / docker-image (push) Failing after 1m38s
2025-11-28 02:37:54 +00:00
796acaa9c0 changed project name
Some checks failed
CI (Gitea) / php-tests (push) Successful in 10m6s
CI (Gitea) / docker-image (push) Failing after 2m34s
2025-11-28 02:16:00 +00:00
3879c6c312 update composer.json
Some checks failed
CI (Gitea) / docker-image (push) Has been cancelled
CI (Gitea) / php-tests (push) Has been cancelled
2025-11-28 02:14:49 +00:00
da9af888c0 Attempt to be prod ready
Some checks failed
CI (Gitea) / php-tests (push) Failing after 1m30s
CI (Gitea) / docker-image (push) Has been skipped
2025-11-28 02:11:23 +00:00
dae8f3d999 wtf
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 2m0s
2025-11-28 02:00:11 +00:00
1c98a634c3 eerrrrrr
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 1m57s
2025-11-27 23:42:17 +00:00
054e970df9 what the fuck
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 1m55s
2025-11-27 20:03:12 +00:00
f15d9a9cfd Added admin dashboard, refactored user dashboard. Removed old reviews route.
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 1m53s
2025-11-20 20:40:49 +00:00
cd04fa5212 CRUD Albums + Spotify API requests into DB.
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 2m17s
2025-11-20 19:54:31 +00:00
cd13f1478a Update .gitea/workflows/ci.yml
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 2m1s
2025-11-07 12:15:13 +00:00
0cd77f8b30 Updated composer.json to remove the translation package and add the stopwatch package. Cleaned up configuration files by removing unused mailer, notifier, translation, and ux_turbo settings, and adjusted CSRF protection settings. Modified messenger.yaml to remove email message routing.
Some checks failed
CI - Build Tonehaus Docker image / build (push) Failing after 2m6s
2025-11-07 12:08:58 +00:00
6cccc3746d Merge remote-tracking branch 'tonehaus-gitea/main'
Some checks failed
CI - Build Tonehaus Docker image / build (push) Failing after 1m36s
2025-11-07 11:56:07 +00:00
5d7cc1666b Removed unused bundles from bundles.php 2025-11-07 11:56:01 +00:00
135 changed files with 12736 additions and 1068 deletions

BIN
.DS_Store vendored

Binary file not shown.

19
.dockerignore Normal file
View File

@@ -0,0 +1,19 @@
.DS_Store
.cursor
.env.local
.git
.gitea
.github
.idea
.public
backup_manifests/
docs/
tests/
var/
# Symfony cache/logs generated inside the container should not come from the host
var/cache/
var/log/
# Uploaded files stay on the host, not baked into images
public/uploads/

View File

@@ -1,16 +1,12 @@
SPOTIFY_CLIENT_ID= # Uncomment to override stored setting.
SPOTIFY_CLIENT_SECRET= #SPOTIFY_CLIENT_ID=
#SPOTIFY_CLIENT_SECRET=
APP_ENV=prod
APP_SECRET=changeme APP_SECRET=changeme
DEFAULT_URI=http://localhost:8000 # APP_ALLOW_REGISTRATION=1 # Uncomment to override administration setting
DATABASE_URL=postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony}@db:5432/${POSTGRES_DB:-symfony}?serverVersion=16&charset=utf8 DEFAULT_URI=http://localhost:8085
ALBUM_SEARCH_LIMIT=30 # Amount of albums shown on page. Do not set too high, may be rate limited by Spotify.
# POSTGRES_DB= DATABASE_DRIVER=sqlite # postgres | sqlite. Untested support for postgres since migration to SQLite.
# POSTGRES_USER= # DATABASE_URL=postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony}@db:5432/${POSTGRES_DB:-symfony}?serverVersion=16&charset=utf8
# POSTGRES_PASSWORD=
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

View File

@@ -1,20 +1,74 @@
name: CI - Build Tonehaus Docker image name: CI (Gitea)
on: on:
push: push:
branches: [ main ] branches:
- main
- prod
pull_request: pull_request:
branches:
- main
- prod
workflow_dispatch: workflow_dispatch:
env: env:
IMAGE_NAME: tonehaus APP_ENV: test
APP_SECRET: ci-secret
DATABASE_DRIVER: sqlite
DATABASE_SQLITE_PATH: ${{ gitea.workspace }}/var/data/database.test.sqlite
DOCKERFILE: docker/php/Dockerfile DOCKERFILE: docker/php/Dockerfile
BUILD_TARGET: prod BUILD_TARGET: prod
PLATFORMS: linux/amd64,linux/arm64 IMAGE_NAME: tonehaus-app
jobs: jobs:
build: php-tests:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: intl, mbstring, pdo_pgsql, pdo_sqlite, zip, gd
coverage: none
ini-values: memory_limit=512M
tools: composer:v2
- name: Validate Composer manifest
run: composer validate --strict
- name: Cache Composer downloads
uses: actions/cache@v4
with:
path: |
~/.cache/composer/files
~/.cache/composer/vcs
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
restore-keys: |
composer-${{ runner.os }}-
- name: Install Composer dependencies
run: composer install --prefer-dist --no-interaction --no-progress
- name: Prepare SQLite database
run: |
mkdir -p "$(dirname "$DATABASE_SQLITE_PATH")"
touch "$DATABASE_SQLITE_PATH"
php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
- name: Run PHPUnit
run: vendor/bin/phpunit --colors=always
docker-image:
needs: php-tests
runs-on: ubuntu-latest
env:
REGISTRY: ${{ secrets.REGISTRY }}
REGISTRY_IMAGE: ${{ secrets.REGISTRY_IMAGE }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -22,44 +76,40 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Compute tags - name: Build prod image (local)
id: meta uses: docker/build-push-action@v6
run: | with:
SHA="${GITHUB_SHA:-${GITEA_SHA:-unknown}}" context: .
SHORT_SHA="${SHA:0:7}" file: ${{ env.DOCKERFILE }}
echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT" target: ${{ env.BUILD_TARGET }}
tags: ${{ env.IMAGE_NAME }}:ci
load: true
- name: Optional registry login - name: Verify baked APP_ENV
run: docker run --rm --entrypoint sh ${{ env.IMAGE_NAME }}:ci -c 'test "$APP_ENV" = "prod"'
- name: Verify Symfony artifacts exist
run: |
docker run --rm --entrypoint sh ${{ env.IMAGE_NAME }}:ci -c 'test -f /var/www/html/public/index.php'
docker run --rm --entrypoint sh ${{ env.IMAGE_NAME }}:ci -c 'test -f /var/www/html/bin/console'
- name: Smoke-test entrypoint & migrations
run: docker run --rm -e APP_SECRET=test-secret --entrypoint /entrypoint.sh ${{ env.IMAGE_NAME }}:ci true
- name: Login to registry
if: ${{ env.REGISTRY != '' && env.REGISTRY_USERNAME != '' && env.REGISTRY_PASSWORD != '' }} if: ${{ env.REGISTRY != '' && env.REGISTRY_USERNAME != '' && env.REGISTRY_PASSWORD != '' }}
env:
REGISTRY: ${{ secrets.REGISTRY }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: | run: |
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USERNAME" --password-stdin echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USERNAME" --password-stdin
- name: Build single-arch images for artifacts (no registry) - name: Push prod image
if: ${{ env.REGISTRY == '' }} if: ${{ env.REGISTRY != '' && env.REGISTRY_IMAGE != '' && env.REGISTRY_USERNAME != '' && env.REGISTRY_PASSWORD != '' }}
run: | uses: docker/build-push-action@v6
TAG_SHA=${{ steps.meta.outputs.short_sha }}
for P in linux/amd64; do \
ARCH=${P#linux/}; \
docker buildx build \
--platform "$P" \
--file "$DOCKERFILE" \
--target "$BUILD_TARGET" \
--build-arg APP_ENV=prod \
--output type=docker \
--tag "$IMAGE_NAME:$TAG_SHA-$ARCH" \
. ; \
docker save "$IMAGE_NAME:$TAG_SHA-$ARCH" -o "tonehaus-image-$ARCH.tar" ; \
done
- name: Upload artifacts
if: ${{ env.REGISTRY == '' }}
uses: actions/upload-artifact@v4
with: with:
name: tonehaus-images context: .
path: | file: ${{ env.DOCKERFILE }}
tonehaus-image-amd64.tar target: ${{ env.BUILD_TARGET }}
push: true
tags: |
${{ env.REGISTRY }}/${{ env.REGISTRY_IMAGE }}:ci
${{ env.REGISTRY }}/${{ env.REGISTRY_IMAGE }}:${{ github.sha }}

134
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,134 @@
name: CI
on:
push:
branches:
- main
- prod
pull_request:
branches:
- main
- prod
permissions:
contents: read
packages: write
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
APP_ENV: test
APP_SECRET: ci-secret
DATABASE_DRIVER: sqlite
DATABASE_SQLITE_PATH: ${{ github.workspace }}/var/data/database.test.sqlite
jobs:
php-tests:
name: PHPUnit + migrations
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
id: setup-php
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: intl, mbstring, pdo_pgsql, pdo_sqlite, zip, gd
coverage: none
ini-values: memory_limit=512M
tools: composer:v2
- name: Validate Composer manifest
run: composer validate --strict
- name: Cache Composer downloads
uses: actions/cache@v4
with:
path: |
~/.cache/composer/files
~/.cache/composer/vcs
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
restore-keys: |
composer-${{ runner.os }}-
- name: Cache vendor directory
id: cache-vendor
uses: actions/cache@v4
with:
path: vendor
key: vendor-${{ runner.os }}-${{ steps.setup-php.outputs.php-version }}-${{ hashFiles('**/composer.lock') }}
restore-keys: |
vendor-${{ runner.os }}-${{ steps.setup-php.outputs.php-version }}-
- name: Install Composer dependencies
run: composer install --prefer-dist --no-interaction --no-progress
env:
COMPOSER_NO_INTERACTION: 1
COMPOSER_MEMORY_LIMIT: -1
- name: Prepare SQLite database
run: |
mkdir -p "$(dirname "$DATABASE_SQLITE_PATH")"
touch "$DATABASE_SQLITE_PATH"
php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
- name: Run PHPUnit
run: vendor/bin/phpunit --colors=always
docker-image:
name: Build production image
needs: php-tests
runs-on: ubuntu-latest
env:
IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/tonehaus-app
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build prod image
id: build-prod
uses: docker/build-push-action@v6
with:
context: .
file: docker/php/Dockerfile
target: prod
load: true
push: false
tags: tonehaus-app:ci
- name: Verify baked APP_ENV
run: docker run --rm --entrypoint sh tonehaus-app:ci -c 'test "$APP_ENV" = "prod"'
- name: Verify Symfony artifacts exist
run: |
docker run --rm --entrypoint sh tonehaus-app:ci -c 'test -f /var/www/html/public/index.php'
docker run --rm --entrypoint sh tonehaus-app:ci -c 'test -f /var/www/html/bin/console'
- name: Smoke-test entrypoint & migrations
run: docker run --rm -e APP_SECRET=test-secret --entrypoint /entrypoint.sh tonehaus-app:ci true
- name: Log in to GHCR
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Tag latest image
if: github.event_name == 'push'
run: docker tag tonehaus-app:ci $IMAGE_NAME:latest
- name: Push latest image
if: github.event_name == 'push'
run: docker push $IMAGE_NAME:latest

1
.gitignore vendored
View File

@@ -15,6 +15,7 @@
!var/cache/.gitkeep !var/cache/.gitkeep
!var/logs/.gitkeep !var/logs/.gitkeep
!var/sessions/.gitkeep !var/sessions/.gitkeep
/var/data/
# Logs (Symfony4) # Logs (Symfony4)
/var/log/* /var/log/*

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema attributeFormDefault="unqualified" elementFormDefault="qualified"
xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="framework" type="frameworkType"/>
<xs:complexType name="commandType">
<xs:all>
<xs:element type="xs:string" name="name" minOccurs="1" maxOccurs="1"/>
<xs:element type="xs:string" name="params" minOccurs="0" maxOccurs="1"/>
<xs:element type="xs:string" name="help" minOccurs="0" maxOccurs="1"/>
<xs:element type="optionsBeforeType" name="optionsBefore" minOccurs="0" maxOccurs="1"/>
</xs:all>
</xs:complexType>
<xs:complexType name="frameworkType">
<xs:sequence>
<xs:element type="xs:string" name="extraData" minOccurs="0" maxOccurs="1"/>
<xs:element type="commandType" name="command" maxOccurs="unbounded" minOccurs="0"/>
<xs:element type="xs:string" name="help" minOccurs="0" maxOccurs="1"/>
</xs:sequence>
<xs:attribute type="xs:string" name="name" use="required"/>
<xs:attribute type="xs:string" name="invoke" use="required"/>
<xs:attribute type="xs:string" name="alias" use="required"/>
<xs:attribute type="xs:boolean" name="enabled" use="required"/>
<xs:attribute type="xs:integer" name="version" use="required"/>
<xs:attribute type="xs:string" name="frameworkId" use="optional"/>
</xs:complexType>
<xs:complexType name="optionsBeforeType">
<xs:sequence>
<xs:element type="optionType" name="option" maxOccurs="unbounded" minOccurs="0"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="optionType">
<xs:sequence>
<xs:element type="xs:string" name="help" minOccurs="0" maxOccurs="1"/>
</xs:sequence>
<xs:attribute type="xs:string" name="name" use="required"/>
<xs:attribute type="xs:string" name="shortcut" use="optional"/>
<xs:attribute name="pattern" use="optional">
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:enumeration value="space"/>
<xs:enumeration value="equals"/>
<xs:enumeration value="unknown"/>
</xs:restriction>
</xs:simpleType>
</xs:attribute>
</xs:complexType>
</xs:schema>

16
.idea/musicratings.iml generated
View File

@@ -18,17 +18,12 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/orm" /> <excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/orm" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/persistence" /> <excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/persistence" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/sql-formatter" /> <excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/sql-formatter" />
<excludeFolder url="file://$MODULE_DIR$/vendor/egulias/email-validator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/masterminds/html5" /> <excludeFolder url="file://$MODULE_DIR$/vendor/masterminds/html5" />
<excludeFolder url="file://$MODULE_DIR$/vendor/monolog/monolog" /> <excludeFolder url="file://$MODULE_DIR$/vendor/monolog/monolog" />
<excludeFolder url="file://$MODULE_DIR$/vendor/myclabs/deep-copy" /> <excludeFolder url="file://$MODULE_DIR$/vendor/myclabs/deep-copy" />
<excludeFolder url="file://$MODULE_DIR$/vendor/nikic/php-parser" /> <excludeFolder url="file://$MODULE_DIR$/vendor/nikic/php-parser" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/manifest" /> <excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/manifest" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/version" /> <excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/version" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/reflection-common" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/reflection-docblock" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/type-resolver" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpstan/phpdoc-parser" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-code-coverage" /> <excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-code-coverage" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-file-iterator" /> <excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-file-iterator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-invoker" /> <excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-invoker" />
@@ -39,7 +34,6 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/clock" /> <excludeFolder url="file://$MODULE_DIR$/vendor/psr/clock" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/container" /> <excludeFolder url="file://$MODULE_DIR$/vendor/psr/container" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/event-dispatcher" /> <excludeFolder url="file://$MODULE_DIR$/vendor/psr/event-dispatcher" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/link" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/log" /> <excludeFolder url="file://$MODULE_DIR$/vendor/psr/log" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/cli-parser" /> <excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/cli-parser" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/code-unit" /> <excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/code-unit" />
@@ -70,13 +64,11 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dependency-injection" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dependency-injection" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/deprecation-contracts" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/deprecation-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/doctrine-bridge" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/doctrine-bridge" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/doctrine-messenger" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dom-crawler" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dom-crawler" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dotenv" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dotenv" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/error-handler" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/error-handler" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/event-dispatcher" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/event-dispatcher" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/event-dispatcher-contracts" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/event-dispatcher-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/expression-language" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/filesystem" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/filesystem" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/finder" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/finder" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/flex" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/flex" />
@@ -87,13 +79,10 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-foundation" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-foundation" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-kernel" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-kernel" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/intl" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/intl" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mailer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/maker-bundle" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/maker-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/messenger" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mime" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mime" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bridge" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bridge" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bundle" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/notifier" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/options-resolver" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/options-resolver" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/password-hasher" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/password-hasher" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-intl-grapheme" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-intl-grapheme" />
@@ -114,26 +103,21 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/security-http" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/security-http" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/serializer" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/serializer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/service-contracts" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/service-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/stimulus-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/stopwatch" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/stopwatch" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/string" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/string" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/translation" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/translation-contracts" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/translation-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/twig-bridge" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/twig-bridge" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/twig-bundle" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/twig-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/type-info" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/type-info" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/ux-turbo" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/validator" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/validator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-dumper" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-dumper" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-exporter" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-exporter" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/web-link" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/web-profiler-bundle" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/web-profiler-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/yaml" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/yaml" />
<excludeFolder url="file://$MODULE_DIR$/vendor/theseer/tokenizer" /> <excludeFolder url="file://$MODULE_DIR$/vendor/theseer/tokenizer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/extra-bundle" /> <excludeFolder url="file://$MODULE_DIR$/vendor/twig/extra-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/string-extra" /> <excludeFolder url="file://$MODULE_DIR$/vendor/twig/string-extra" />
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/twig" /> <excludeFolder url="file://$MODULE_DIR$/vendor/twig/twig" />
<excludeFolder url="file://$MODULE_DIR$/vendor/webmozart/assert" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />

16
.idea/php.xml generated
View File

@@ -12,11 +12,9 @@
</component> </component>
<component name="PhpIncludePathManager"> <component name="PhpIncludePathManager">
<include_path> <include_path>
<path value="$PROJECT_DIR$/vendor/phpstan/phpdoc-parser" />
<path value="$PROJECT_DIR$/vendor/twig/twig" /> <path value="$PROJECT_DIR$/vendor/twig/twig" />
<path value="$PROJECT_DIR$/vendor/twig/string-extra" /> <path value="$PROJECT_DIR$/vendor/twig/string-extra" />
<path value="$PROJECT_DIR$/vendor/twig/extra-bundle" /> <path value="$PROJECT_DIR$/vendor/twig/extra-bundle" />
<path value="$PROJECT_DIR$/vendor/webmozart/assert" />
<path value="$PROJECT_DIR$/vendor/masterminds/html5" /> <path value="$PROJECT_DIR$/vendor/masterminds/html5" />
<path value="$PROJECT_DIR$/vendor/sebastian/type" /> <path value="$PROJECT_DIR$/vendor/sebastian/type" />
<path value="$PROJECT_DIR$/vendor/sebastian/diff" /> <path value="$PROJECT_DIR$/vendor/sebastian/diff" />
@@ -31,11 +29,8 @@
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" /> <path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
<path value="$PROJECT_DIR$/vendor/sebastian/code-unit-reverse-lookup" /> <path value="$PROJECT_DIR$/vendor/sebastian/code-unit-reverse-lookup" />
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" /> <path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-docblock" />
<path value="$PROJECT_DIR$/vendor/sebastian/environment" /> <path value="$PROJECT_DIR$/vendor/sebastian/environment" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" />
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" /> <path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-common" />
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" /> <path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" /> <path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" /> <path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
@@ -43,7 +38,6 @@
<path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" /> <path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" /> <path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" /> <path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
<path value="$PROJECT_DIR$/vendor/symfony/expression-language" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" /> <path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
<path value="$PROJECT_DIR$/vendor/symfony/clock" /> <path value="$PROJECT_DIR$/vendor/symfony/clock" />
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" /> <path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
@@ -79,7 +73,6 @@
<path value="$PROJECT_DIR$/vendor/doctrine/persistence" /> <path value="$PROJECT_DIR$/vendor/doctrine/persistence" />
<path value="$PROJECT_DIR$/vendor/symfony/http-foundation" /> <path value="$PROJECT_DIR$/vendor/symfony/http-foundation" />
<path value="$PROJECT_DIR$/vendor/doctrine/sql-formatter" /> <path value="$PROJECT_DIR$/vendor/doctrine/sql-formatter" />
<path value="$PROJECT_DIR$/vendor/egulias/email-validator" />
<path value="$PROJECT_DIR$/vendor/symfony/framework-bundle" /> <path value="$PROJECT_DIR$/vendor/symfony/framework-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/inflector" /> <path value="$PROJECT_DIR$/vendor/doctrine/inflector" />
<path value="$PROJECT_DIR$/vendor/symfony/validator" /> <path value="$PROJECT_DIR$/vendor/symfony/validator" />
@@ -89,10 +82,7 @@
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" /> <path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" /> <path value="$PROJECT_DIR$/vendor/symfony/service-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" /> <path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/web-link" />
<path value="$PROJECT_DIR$/vendor/symfony/type-info" /> <path value="$PROJECT_DIR$/vendor/symfony/type-info" />
<path value="$PROJECT_DIR$/vendor/symfony/translation" />
<path value="$PROJECT_DIR$/vendor/symfony/stimulus-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/finder" /> <path value="$PROJECT_DIR$/vendor/symfony/finder" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php84" /> <path value="$PROJECT_DIR$/vendor/symfony/polyfill-php84" />
<path value="$PROJECT_DIR$/vendor/symfony/intl" /> <path value="$PROJECT_DIR$/vendor/symfony/intl" />
@@ -106,7 +96,6 @@
<path value="$PROJECT_DIR$/vendor/symfony/dependency-injection" /> <path value="$PROJECT_DIR$/vendor/symfony/dependency-injection" />
<path value="$PROJECT_DIR$/vendor/symfony/config" /> <path value="$PROJECT_DIR$/vendor/symfony/config" />
<path value="$PROJECT_DIR$/vendor/symfony/security-http" /> <path value="$PROJECT_DIR$/vendor/symfony/security-http" />
<path value="$PROJECT_DIR$/vendor/symfony/mailer" />
<path value="$PROJECT_DIR$/vendor/phar-io/version" /> <path value="$PROJECT_DIR$/vendor/phar-io/version" />
<path value="$PROJECT_DIR$/vendor/symfony/mime" /> <path value="$PROJECT_DIR$/vendor/symfony/mime" />
<path value="$PROJECT_DIR$/vendor/phar-io/manifest" /> <path value="$PROJECT_DIR$/vendor/phar-io/manifest" />
@@ -116,17 +105,13 @@
<path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" /> <path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-idn" /> <path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-idn" />
<path value="$PROJECT_DIR$/vendor/symfony/web-profiler-bundle" /> <path value="$PROJECT_DIR$/vendor/symfony/web-profiler-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/doctrine-messenger" />
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" /> <path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/console" /> <path value="$PROJECT_DIR$/vendor/symfony/console" />
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" /> <path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
<path value="$PROJECT_DIR$/vendor/symfony/browser-kit" /> <path value="$PROJECT_DIR$/vendor/symfony/browser-kit" />
<path value="$PROJECT_DIR$/vendor/symfony/yaml" /> <path value="$PROJECT_DIR$/vendor/symfony/yaml" />
<path value="$PROJECT_DIR$/vendor/symfony/notifier" />
<path value="$PROJECT_DIR$/vendor/symfony/ux-turbo" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-icu" /> <path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-icu" />
<path value="$PROJECT_DIR$/vendor/symfony/string" /> <path value="$PROJECT_DIR$/vendor/symfony/string" />
<path value="$PROJECT_DIR$/vendor/symfony/messenger" />
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" /> <path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
<path value="$PROJECT_DIR$/vendor/composer" /> <path value="$PROJECT_DIR$/vendor/composer" />
<path value="$PROJECT_DIR$/vendor/symfony/serializer" /> <path value="$PROJECT_DIR$/vendor/symfony/serializer" />
@@ -135,7 +120,6 @@
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" /> <path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
<path value="$PROJECT_DIR$/vendor/symfony/process" /> <path value="$PROJECT_DIR$/vendor/symfony/process" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-mbstring" /> <path value="$PROJECT_DIR$/vendor/symfony/polyfill-mbstring" />
<path value="$PROJECT_DIR$/vendor/psr/link" />
<path value="$PROJECT_DIR$/vendor/psr/clock" /> <path value="$PROJECT_DIR$/vendor/psr/clock" />
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" /> <path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
<path value="$PROJECT_DIR$/vendor/psr/cache" /> <path value="$PROJECT_DIR$/vendor/psr/cache" />

View File

@@ -1,8 +1,8 @@
# Tonehaus — Music Ratings # Tonehaus — Music Ratings (Symfony 7)
Discover albums from Spotify, read and write reviews, and manage your account. Built with Symfony 7, Twig, Doctrine, and Bootstrap. Discover albums via Spotify, write and manage reviews, and administer your site. Built with Symfony 7, Twig, Doctrine, and Bootstrap.
## Quick start ## Quick start (Docker Compose)
1) Start the stack 1) Start the stack
@@ -10,65 +10,66 @@ Discover albums from Spotify, read and write reviews, and manage your account. B
docker compose up -d --build docker compose up -d --build
``` ```
2) Create the database schema 2) Open the app
- App URL: `http://localhost:8085`
- Health: `http://localhost:8085/healthz`
3) Create your first admin
- Sign Up through Tonehaus
```bash ```bash
docker compose exec php php bin/console doctrine:database:create --if-not-exists docker compose exec tonehaus php bin/console app:promote-admin you@example.com
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) 4) Configure Spotify
- Go to `http://localhost:8085/admin/settings` and enter your Spotify Client ID/Secret, or
- Set env vars in `.env`: `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`
5) (Optional) Seed demo data
```bash ```bash
docker compose exec php php bin/console app:promote-admin you@example.com docker compose exec tonehaus php bin/console app:seed-demo-users --count=50
docker compose exec tonehaus php bin/console app:seed-demo-albums --count=40 --attach-users
docker compose exec tonehaus php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8
``` ```
4) Configure Spotify API credentials (admin only) Notes:
- The packaged image uses SQLite by default and runs Doctrine migrations on start (idempotent).
- Open `http://localhost:8000/admin/settings` and enter your Spotify Client ID/Secret. - To switch to Postgres, set `DATABASE_DRIVER=postgres` and provide `DATABASE_URL`.
- Alternatively, set env vars for the PHP container: `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`.
5) Visit `http://localhost:8000` to search for albums.
## Features ## Features
- Spotify search with Advanced filters (album, artist, year range) and per-album aggregates (avg/count) - Spotify search with advanced filters (album, artist, year range) and peralbum aggregates (avg/count)
- Album page with details, reviews list, and inline new review (logged in) - Album page with details, tracklist, reviews list, and inline new review (logged-in)
- Auth modal (Login/Sign up) with remember-me cookie, no separate pages - Auth modal (Login/Sign up) with rememberme; no separate pages
- Role-based access: authors manage their own reviews, admins can manage any - Role-based access: authors manage their own reviews; moderators/admins can moderate content
- Admin Site Settings to manage Spotify credentials in DB - Admin Site Settings: manage Spotify credentials and public registration toggle
- User Dashboard to update profile and change password (requires current password) - User Dashboard: profile updates and password change
- Light/Dark theme toggle in Settings (cookie-backed) - Light/Dark theme toggle (cookie-backed)
- Bootstrap UI
## Rate limiting & caching ## Documentation
- Server-side Client Credentials; access tokens are cached. - Setup and configuration: `docs/setup.md`
- Requests pass through a throttle and 429 Retry-After backoff. GET responses are cached. - Feature overview: `docs/features.md`
- Tunables (optional): - Authentication and users: `docs/auth-and-users.md`
- Spotify integration: `docs/spotify-integration.md`
- Reviews and albums: `docs/reviews-and-albums.md`
- Admin & site settings: `docs/admin-and-settings.md`
- Troubleshooting: `docs/troubleshooting.md`
- Architecture: `docs/architecture.md`
- Deployment: `docs/deployment.md`
```bash ## Environment overview
# 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 - `APP_ENV` (dev|prod), `APP_SECRET` (required)
- `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`
See `/docs` for how-tos and deeper notes: - `APP_ALLOW_REGISTRATION` (1|0) — DB setting can be overridden by env
- `DATABASE_DRIVER` (sqlite|postgres), `DATABASE_URL` (when using Postgres)
- Setup and configuration: `docs/01-setup.md` - `DATABASE_SQLITE_PATH` (optional, defaults to `var/data/database.sqlite`)
- Features and UX: `docs/02-features.md` - `RUN_MIGRATIONS_ON_START` (1|0, defaults to 1)
- 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 ## License

5
assets/app.js Normal file
View File

@@ -0,0 +1,5 @@
// Placeholder front-end entrypoint to satisfy importmap & asset mapper.
// You can add real JS behavior here; for now we only register a noop.
console.debug('Tonehaus assets initialized');

View File

@@ -1,4 +1,6 @@
{ {
"name": "tonehaus/tonehaus",
"description": "Tonehaus — discover albums, manage reviews, and administer site settings with a Symfony 7 stack.",
"type": "project", "type": "project",
"license": "proprietary", "license": "proprietary",
"minimum-stability": "stable", "minimum-stability": "stable",
@@ -11,14 +13,10 @@
"doctrine/doctrine-bundle": "^2.18", "doctrine/doctrine-bundle": "^2.18",
"doctrine/doctrine-migrations-bundle": "^3.5", "doctrine/doctrine-migrations-bundle": "^3.5",
"doctrine/orm": "^3.5", "doctrine/orm": "^3.5",
"phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.3",
"symfony/asset": "7.3.*", "symfony/asset": "7.3.*",
"symfony/asset-mapper": "7.3.*", "symfony/asset-mapper": "7.3.*",
"symfony/console": "7.3.*", "symfony/console": "7.3.*",
"symfony/doctrine-messenger": "7.3.*",
"symfony/dotenv": "7.3.*", "symfony/dotenv": "7.3.*",
"symfony/expression-language": "7.3.*",
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/form": "7.3.*", "symfony/form": "7.3.*",
"symfony/framework-bundle": "7.3.*", "symfony/framework-bundle": "7.3.*",
@@ -33,10 +31,8 @@
"symfony/security-bundle": "7.3.*", "symfony/security-bundle": "7.3.*",
"symfony/serializer": "7.3.*", "symfony/serializer": "7.3.*",
"symfony/string": "7.3.*", "symfony/string": "7.3.*",
"symfony/translation": "7.3.*",
"symfony/twig-bundle": "7.3.*", "symfony/twig-bundle": "7.3.*",
"symfony/validator": "7.3.*", "symfony/validator": "7.3.*",
"symfony/web-link": "7.3.*",
"symfony/yaml": "7.3.*", "symfony/yaml": "7.3.*",
"twig/extra-bundle": "^2.12|^3.0", "twig/extra-bundle": "^2.12|^3.0",
"twig/string-extra": "^3.22", "twig/string-extra": "^3.22",
@@ -99,6 +95,7 @@
"symfony/css-selector": "7.3.*", "symfony/css-selector": "7.3.*",
"symfony/debug-bundle": "7.3.*", "symfony/debug-bundle": "7.3.*",
"symfony/maker-bundle": "^1.64", "symfony/maker-bundle": "^1.64",
"symfony/stopwatch": "7.3.*",
"symfony/web-profiler-bundle": "7.3.*" "symfony/web-profiler-bundle": "7.3.*"
} }
} }

View File

@@ -1,109 +0,0 @@
{
"type": "project",
"license": "proprietary",
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.2",
"ext-ctype": "*",
"ext-iconv": "*",
"doctrine/dbal": "^3",
"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.*",
"symfony/http-client": "7.3.*",
"symfony/intl": "7.3.*",
"symfony/mailer": "7.3.*",
"symfony/mime": "7.3.*",
"symfony/monolog-bundle": "^3.0",
"symfony/notifier": "7.3.*",
"symfony/process": "7.3.*",
"symfony/property-access": "7.3.*",
"symfony/property-info": "7.3.*",
"symfony/runtime": "7.3.*",
"symfony/security-bundle": "7.3.*",
"symfony/serializer": "7.3.*",
"symfony/stimulus-bundle": "^2.31",
"symfony/string": "7.3.*",
"symfony/translation": "7.3.*",
"symfony/twig-bundle": "7.3.*",
"symfony/ux-turbo": "^2.31",
"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",
"twig/twig": "^2.12|^3.0"
},
"config": {
"allow-plugins": {
"php-http/discovery": true,
"symfony/flex": true,
"symfony/runtime": true
},
"bump-after-update": true,
"sort-packages": true
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"replace": {
"symfony/polyfill-ctype": "*",
"symfony/polyfill-iconv": "*",
"symfony/polyfill-php72": "*",
"symfony/polyfill-php73": "*",
"symfony/polyfill-php74": "*",
"symfony/polyfill-php80": "*",
"symfony/polyfill-php81": "*",
"symfony/polyfill-php82": "*"
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd",
"importmap:install": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts"
],
"post-update-cmd": [
"@auto-scripts"
]
},
"conflict": {
"symfony/symfony": "*"
},
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.3.*"
}
},
"require-dev": {
"phpunit/phpunit": "^11.5",
"symfony/browser-kit": "7.3.*",
"symfony/css-selector": "7.3.*",
"symfony/debug-bundle": "7.3.*",
"symfony/maker-bundle": "^1.64",
"symfony/stopwatch": "7.3.*",
"symfony/web-profiler-bundle": "7.3.*"
}
}

View File

@@ -7,8 +7,6 @@ return [
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true], Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true],
Symfony\UX\Turbo\TurboBundle::class => ['all' => true],
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],

View File

@@ -5,6 +5,7 @@ framework:
token_id: submit token_id: submit
csrf_protection: csrf_protection:
check_header: true
stateless_token_ids: stateless_token_ids:
- submit - submit
- authenticate - authenticate

View File

@@ -0,0 +1,84 @@
<?php
/**
* Dynamic Doctrine DBAL configuration.
*
* This file complements `config/packages/doctrine.yaml`, not replacing it!:
* - YAML handles ORM mappings, naming strategy, caches, and env-specific tweaks.
* - This PHP config focuses on DBAL and runtime driver selection.
*
* Behavior:
* - Chooses the database driver from `DATABASE_DRIVER` (`postgres` or `sqlite`).
* - For Postgres:
* - Uses `DATABASE_URL` (e.g. `postgresql://user:pass@host:5432/dbname`).
* - Pins `serverVersion` (currently `16`) to avoid auto-detection issues.
* - For SQLite:
* - Uses `DATABASE_SQLITE_PATH` when provided.
* - Otherwise, defaults to `<projectDir>/var/data/database.sqlite`, creating the
* directory and file if they do not already exist. (Recommended)
*
* This split keeps the mapping/caching config in YAML while allowing
* DBAL to adapt between Docker/postgres and local sqlite setups.
*/
declare(strict_types=1);
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Config\DoctrineConfig;
use function Symfony\Component\DependencyInjection\Loader\Configurator\param;
return static function (DoctrineConfig $doctrine): void {
// Normalize DATABASE_DRIVER and validate allowed values up front.
$driver = strtolower((string) ($_ENV['DATABASE_DRIVER'] ?? $_SERVER['DATABASE_DRIVER'] ?? 'postgres'));
$supportedDrivers = ['postgres', 'sqlite'];
if (!in_array($driver, $supportedDrivers, true)) {
throw new \InvalidArgumentException(sprintf(
'Unsupported DATABASE_DRIVER "%s". Allowed values: %s',
$driver,
implode(', ', $supportedDrivers)
));
}
// Configure the default DBAL connection.
$dbal = $doctrine->dbal();
$dbal->defaultConnection('default');
$connection = $dbal->connection('default');
$connection->profilingCollectBacktrace(param('kernel.debug'));
$connection->useSavepoints(true);
if ('sqlite' === $driver) {
// SQLite: use a file-backed database by default.
$connection->driver('pdo_sqlite');
$hasCustomPath = array_key_exists('DATABASE_SQLITE_PATH', $_ENV)
|| array_key_exists('DATABASE_SQLITE_PATH', $_SERVER);
if ($hasCustomPath) {
// Allow explicit database path via env overrides.
$connection->path('%env(resolve:DATABASE_SQLITE_PATH)%');
} else {
$projectDir = dirname(__DIR__, 2);
$databasePath = sprintf('%s/var/data/database.sqlite', $projectDir);
$databaseDir = dirname($databasePath);
$filesystem = new Filesystem();
if (!$filesystem->exists($databaseDir)) {
$filesystem->mkdir($databaseDir, 0o775);
}
if (!$filesystem->exists($databasePath)) {
$filesystem->touch($databasePath);
}
$connection->path('%kernel.project_dir%/var/data/database.sqlite');
}
} else {
// Postgres (or other server-based driver) via DATABASE_URL.
$connection->url('%env(resolve:DATABASE_URL)%');
// Keep the server version explicit so Doctrine does not need network calls to detect it.
$connection->serverVersion('16');
$connection->charset('utf8');
}
};

View File

@@ -1,13 +1,4 @@
doctrine: 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: orm:
auto_generate_proxy_classes: true auto_generate_proxy_classes: true
enable_lazy_ghost_objects: true enable_lazy_ghost_objects: true

View File

@@ -1,6 +1,8 @@
# see https://symfony.com/doc/current/reference/configuration/framework.html # see https://symfony.com/doc/current/reference/configuration/framework.html
framework: framework:
secret: '%env(APP_SECRET)%' secret: '%env(APP_SECRET)%'
trusted_proxies: '%env(default::TRUSTED_PROXIES)%'
trusted_headers: ['x-forwarded-for','x-forwarded-proto','x-forwarded-port','x-forwarded-host','x-forwarded-prefix']
# Note that the session will be started ONLY if you read or write from it. # Note that the session will be started ONLY if you read or write from it.
session: true session: true

View File

@@ -1,3 +0,0 @@
framework:
mailer:
dsn: '%env(MAILER_DSN)%'

View File

@@ -1,30 +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:
Symfony\Component\Mailer\Messenger\SendEmailMessage: async
Symfony\Component\Notifier\Message\ChatMessage: async
Symfony\Component\Notifier\Message\SmsMessage: async
# Route your messages to the transports
# 'App\Message\YourMessage': async

View File

@@ -42,7 +42,7 @@ when@prod:
handlers: handlers:
main: main:
type: fingers_crossed type: fingers_crossed
action_level: error action_level: info
handler: nested handler: nested
excluded_http_codes: [404, 405] excluded_http_codes: [404, 405]
buffer_size: 50 # How many messages should be saved? Prevent memory leaks buffer_size: 50 # How many messages should be saved? Prevent memory leaks

View File

@@ -1,12 +0,0 @@
framework:
notifier:
chatter_transports:
texter_transports:
channel_policy:
# use chat/slack, chat/telegram, sms/twilio or sms/nexmo
urgent: ['email']
high: ['email']
medium: ['email']
low: ['email']
admin_recipients:
- { email: admin@example.com }

View File

@@ -1,4 +1,7 @@
security: security:
role_hierarchy:
ROLE_ADMIN: ['ROLE_MODERATOR']
ROLE_MODERATOR: ['ROLE_USER']
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers: password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
@@ -17,11 +20,11 @@ security:
provider: app_user_provider provider: app_user_provider
form_login: form_login:
login_path: album_search login_path: app_login
check_path: app_login check_path: app_login
enable_csrf: true enable_csrf: true
default_target_path: album_search default_target_path: album_search
failure_path: album_search failure_path: app_login
username_parameter: _username username_parameter: _username
password_parameter: _password password_parameter: _password
csrf_parameter: _csrf_token csrf_parameter: _csrf_token
@@ -45,8 +48,9 @@ security:
# Easy way to control access for large sections of your site # Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used # Note: Only the *first* access control that matches will be used
access_control: access_control:
# - { path: ^/admin, roles: ROLE_ADMIN } - { path: ^/admin/settings, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER } - { path: ^/admin/users, roles: ROLE_MODERATOR }
- { path: ^/admin/dashboard, roles: ROLE_MODERATOR }
when@test: when@test:
security: security:

View File

@@ -1,5 +0,0 @@
framework:
default_locale: en
translator:
default_path: '%kernel.project_dir%/translations'
providers:

View File

@@ -1,4 +0,0 @@
# Enable stateless CSRF protection for forms and logins/logouts
framework:
csrf_protection:
check_header: true

View File

@@ -4,6 +4,7 @@
# Put parameters here that don't need to change on each machine where the app is deployed # Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters: parameters:
album_search_limit: '%env(int:ALBUM_SEARCH_LIMIT)%'
services: services:
# default configuration for services in *this* file # default configuration for services in *this* file
@@ -21,5 +22,14 @@ services:
App\Service\SpotifyClient: App\Service\SpotifyClient:
arguments: arguments:
$clientId: '%env(SPOTIFY_CLIENT_ID)%' $clientId: '%env(default::SPOTIFY_CLIENT_ID)%'
$clientSecret: '%env(SPOTIFY_CLIENT_SECRET)%' $clientSecret: '%env(default::SPOTIFY_CLIENT_SECRET)%'
App\Service\UploadStorage:
arguments:
$storageRoot: '%kernel.project_dir%/public/uploads'
$publicPrefix: '/uploads'
App\Controller\AlbumController:
arguments:
$searchLimit: '%album_search_limit%'

View File

@@ -1,98 +1,21 @@
## Docker Compose stack for Symfony app
## - php: PHP-FPM container that runs Symfony and Composer
## - nginx: Frontend web server proxying PHP to php-fpm
## - db: Postgres database for Doctrine
## - pgadmin: Optional UI to inspect the database
services: services:
php: tonehaus:
# Build multi-stage image defined in docker/php/Dockerfile image: git.ntbx.io/boris/tonehaus:latest-arm64
build: container_name: tonehaus
context: .
dockerfile: docker/php/Dockerfile
target: dev # change to "prod" for production build
args:
- APP_ENV=dev
container_name: app-php
restart: unless-stopped restart: unless-stopped
environment:
# 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: volumes:
# Mount only source and config; vendors are installed in-container - uploads:/var/www/html/public/uploads
- ./bin:/var/www/html/bin - sqlite_data:/var/www/html/var/data
- ./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
- ./symfony.lock:/var/www/html/symfony.lock
# Speed up composer installs by caching download artifacts
- composer_cache:/tmp/composer
healthcheck:
test: ["CMD-SHELL", "php -v || exit 1"]
interval: 10s
timeout: 3s
retries: 5
depends_on:
- db
nginx:
image: nginx:alpine
container_name: nginx
ports: ports:
- "8000:80" - "8085:80"
volumes: env_file:
# Serve built assets and front controller from Symfony public dir - .env
- ./public:/var/www/html/public
# Custom vhost with PHP FastCGI proxy
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
depends_on:
- php
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80/healthz"] test: ["CMD", "curl", "-f", "http://localhost:80/healthz"]
interval: 10s interval: 10s
timeout: 3s timeout: 3s
retries: 5 retries: 5
db:
image: postgres:16-alpine
container_name: postgres
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-symfony}
POSTGRES_USER: ${POSTGRES_USER:-symfony}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-symfony}
ports:
- 5432:5432
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-symfony} -d ${POSTGRES_DB:-symfony}"]
interval: 10s
timeout: 5s
retries: 10
pgadmin:
image: dpage/pgadmin4
container_name: pgadmin
environment:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-admin@example.com}
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:-password}
ports:
- "8081:80"
volumes:
- pgadmin_data:/var/lib/pgadmin
depends_on:
- db
volumes: volumes:
db_data: sqlite_data:
composer_cache: uploads:
pgadmin_data:

View File

@@ -1,6 +1,6 @@
server { server {
listen 80; listen 80;
server_name localhost; # host header (not used locally) server_name _; # host header (not used locally)
root /var/www/html/public; # Symfony's public/ dir (front controller) root /var/www/html/public; # Symfony's public/ dir (front controller)
location / { location / {
@@ -11,7 +11,7 @@ server {
location ~ \.php$ { location ~ \.php$ {
# Forward PHP requests to php-fpm service # Forward PHP requests to php-fpm service
include fastcgi_params; include fastcgi_params;
fastcgi_pass php:9000; fastcgi_pass tonehaus:9000;
# Use resolved path to avoid path traversal issues # Use resolved path to avoid path traversal issues
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root; fastcgi_param DOCUMENT_ROOT $realpath_root;

View File

@@ -1,118 +1,136 @@
# FROM php:8.2-fpm
# # Install dependencies
# RUN apt-get update && apt-get install -y \
# git \
# unzip \
# libzip-dev \
# libpng-dev \
# libjpeg-dev \
# libonig-dev \
# libxml2-dev \
# zip \
# && docker-php-ext-install pdo pdo_mysql zip gd mbstring exif pcntl bcmath intl opcache
# # Install Composer
# COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# # Copy PHP config
# COPY docker/php/php.ini /usr/local/etc/php/
# WORKDIR /var/www/html
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Base PHP-FPM with Composer + Symfony-friendly extensions # Base PHP-FPM with Composer + Symfony-friendly extensions
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
FROM php:8.2-fpm-alpine AS base FROM php:8.2-fpm-alpine AS base
WORKDIR /var/www/html
ARG APP_ENV=dev
# System dependencies ENV APP_ENV=${APP_ENV}
RUN apk add --no-cache \
bash git unzip icu-dev libpng-dev libjpeg-turbo-dev libwebp-dev \ WORKDIR /var/www/html
libzip-dev oniguruma-dev libxml2-dev postgresql-dev zlib-dev
# System dependencies shared across images
# PHP extensions commonly used by Symfony RUN apk add --no-cache \
RUN docker-php-ext-configure gd --with-jpeg --with-webp \ bash \
&& docker-php-ext-install -j"$(nproc)" \ git \
intl \ unzip \
gd \ icu-dev \
pdo_pgsql \ libpng-dev \
opcache \ libjpeg-turbo-dev \
mbstring \ libwebp-dev \
zip \ libzip-dev \
xml oniguruma-dev \
libxml2-dev \
# Composer available in the running container (dev + prod) postgresql-dev \
COPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer sqlite-dev \
zlib-dev \
# Recommended PHP settings (tweak as needed) su-exec
RUN { \
echo "memory_limit=512M"; \ # PHP extensions commonly used by Symfony (plus both Postgres + SQLite)
echo "upload_max_filesize=50M"; \ RUN docker-php-ext-configure gd --with-jpeg --with-webp \
echo "post_max_size=50M"; \ && docker-php-ext-install -j"$(nproc)" \
echo "date.timezone=UTC"; \ intl \
} > /usr/local/etc/php/conf.d/php-recommended.ini \ gd \
&& { \ pdo_pgsql \
echo "opcache.enable=1"; \ pdo_sqlite \
echo "opcache.enable_cli=1"; \ opcache \
echo "opcache.memory_consumption=256"; \ mbstring \
echo "opcache.interned_strings_buffer=16"; \ zip \
echo "opcache.max_accelerated_files=20000"; \ xml
echo "opcache.validate_timestamps=0"; \
echo "opcache.jit=tracing"; \ # Composer available in every stage (dev + prod)
echo "opcache.jit_buffer_size=128M"; \ COPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer
} > /usr/local/etc/php/conf.d/opcache-recommended.ini
# Recommended PHP settings (tweak as needed)
# Small healthcheck file for Nginx RUN { \
RUN mkdir -p public && printf "OK" > public/healthz echo "memory_limit=512M"; \
echo "upload_max_filesize=50M"; \
# Ensure correct user echo "post_max_size=50M"; \
RUN addgroup -g 1000 app && adduser -D -G app -u 1000 app echo "date.timezone=UTC"; \
# php-fpm uses www-data; keep both available } > /usr/local/etc/php/conf.d/php-recommended.ini \
RUN chown -R www-data:www-data /var/www && { \
echo "opcache.enable=1"; \
# ----------------------------------------------------------------------------- echo "opcache.enable_cli=1"; \
# Development image (mount your code via docker-compose volumes) echo "opcache.memory_consumption=256"; \
# ----------------------------------------------------------------------------- echo "opcache.interned_strings_buffer=16"; \
FROM base AS dev echo "opcache.max_accelerated_files=20000"; \
ENV APP_ENV=dev echo "opcache.validate_timestamps=0"; \
# Optional: enable Xdebug (uncomment to use) echo "opcache.jit=tracing"; \
# RUN apk add --no-cache $PHPIZE_DEPS \ echo "opcache.jit_buffer_size=128M"; \
# && pecl install xdebug \ } > /usr/local/etc/php/conf.d/opcache-recommended.ini
# && docker-php-ext-enable xdebug \
# && { \ # Small healthcheck file for HTTP probes
# echo "xdebug.mode=debug,develop"; \ RUN mkdir -p public && printf "OK" > public/healthz
# echo "xdebug.client_host=host.docker.internal"; \
# } > /usr/local/etc/php/conf.d/xdebug.ini # Ensure unprivileged app user exists
# Composer cache directory (faster installs inside container) RUN addgroup -g 1000 app && adduser -D -G app -u 1000 app \
ENV COMPOSER_CACHE_DIR=/tmp/composer && chown -R www-data:www-data /var/www
CMD ["php-fpm"]
# -----------------------------------------------------------------------------
# ----------------------------------------------------------------------------- # Development image (mount your code via docker-compose volumes)
# Production image (copies your app + installs deps + warms cache) # -----------------------------------------------------------------------------
# ----------------------------------------------------------------------------- FROM base AS dev
FROM base AS prod ARG APP_ENV=dev
ENV APP_ENV=prod ENV APP_ENV=${APP_ENV}
# Copy only manifests first (better layer caching); ignore if missing ENV APP_DEBUG=1
COPY composer.json composer.lock* symfony.lock* ./
# Install vendors (no scripts here; run later with console if needed) # Optional: enable Xdebug by uncommenting below
RUN --mount=type=cache,target=/tmp/composer \ # RUN apk add --no-cache $PHPIZE_DEPS \
if [ -f composer.json ]; then \ # && pecl install xdebug \
composer install --no-dev --prefer-dist --no-interaction --no-progress --no-scripts; \ # && docker-php-ext-enable xdebug \
fi # && { \
# echo "xdebug.mode=debug,develop"; \
# Copy the rest of the app # echo "xdebug.client_host=host.docker.internal"; \
COPY . /var/www/html # } > /usr/local/etc/php/conf.d/xdebug.ini
# If Symfony console exists, finalize install & warm cache ENV COMPOSER_CACHE_DIR=/tmp/composer
RUN if [ -f bin/console ]; then \ CMD ["php-fpm"]
set -ex; \
composer dump-autoload --no-dev --optimize; \ # -----------------------------------------------------------------------------
php bin/console cache:clear --no-warmup; \ # Production image (copies your app + installs deps + warms cache)
php bin/console cache:warmup; \ # -----------------------------------------------------------------------------
mkdir -p var && chown -R www-data:www-data var; \ FROM base AS prod
fi ARG APP_ENV=prod
ENV APP_ENV=${APP_ENV}
USER www-data ENV APP_DEBUG=0 \
CMD ["php-fpm"] DATABASE_DRIVER=sqlite \
DATABASE_SQLITE_PATH=/var/www/html/var/data/database.sqlite \
RUN_MIGRATIONS_ON_START=1
# Copy only composer manifests for layer caching
COPY composer.json composer.lock* symfony.lock* ./
# Install vendors (cached)
RUN --mount=type=cache,target=/tmp/composer \
if [ -f composer.json ]; then \
composer install --no-dev --prefer-dist --no-interaction --no-progress --no-scripts; \
fi
# Copy the rest of the app
COPY . /var/www/html
# Finalize install & warm cache
RUN if [ -f bin/console ]; then \
set -ex; \
composer dump-autoload --no-dev --optimize; \
mkdir -p var var/data public/uploads; \
chown -R www-data:www-data var public/uploads; \
fi
# Runtime web stack (nginx + supervisor) for a single immutable container
RUN apk add --no-cache nginx supervisor curl
COPY docker/prod/nginx.conf /etc/nginx/http.d/default.conf
COPY docker/prod/supervisord.conf /etc/supervisor/conf.d/app.conf
COPY docker/prod/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh \
&& mkdir -p /run/nginx /var/log/supervisor \
&& chown -R www-data:www-data /run/nginx /var/log/supervisor /var/www/html
RUN mkdir -p /var/lib/nginx /var/log/nginx && chown -R www-data:www-data /var/lib/nginx /var/log/nginx
RUN sed -i 's|^error_log =.*|error_log = /proc/self/fd/2|' /usr/local/etc/php-fpm.conf
EXPOSE 8080
ENTRYPOINT ["/entrypoint.sh"]
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/app.conf"]

51
docker/prod/entrypoint.sh Executable file
View File

@@ -0,0 +1,51 @@
#!/bin/sh
set -eu
require_app_secret() {
if [ -z "${APP_SECRET:-}" ]; then
echo "APP_SECRET environment variable is required at runtime" >&2
exit 1
fi
}
install_runtime() {
if [ -f vendor/autoload_runtime.php ] && [ "${FORCE_COMPOSER_INSTALL:-0}" != "1" ]; then
return
fi
echo "Installing Composer dependencies..."
su-exec www-data composer install \
--no-dev \
--prefer-dist \
--no-interaction \
--no-progress
}
install_runtime
if [ -f bin/console ]; then
require_app_secret
fi
if [ "${RUN_MIGRATIONS_ON_START:-1}" = "1" ] && [ -f bin/console ]; then
if [ "${DATABASE_DRIVER:-sqlite}" = "sqlite" ]; then
SQLITE_PATH="${DATABASE_SQLITE_PATH:-/var/www/html/var/data/database.sqlite}"
SQLITE_DIR=$(dirname "${SQLITE_PATH}")
mkdir -p "${SQLITE_DIR}"
if [ ! -f "${SQLITE_PATH}" ]; then
touch "${SQLITE_PATH}"
fi
chown -R www-data:www-data "${SQLITE_DIR}"
fi
su-exec www-data php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
fi
if [ -f bin/console ]; then
su-exec www-data php bin/console cache:clear --no-warmup
su-exec www-data php bin/console cache:warmup
chown -R www-data:www-data var
fi
exec "$@"

29
docker/prod/nginx.conf Normal file
View File

@@ -0,0 +1,29 @@
server {
listen 80;
server_name _;
root /var/www/html/public;
index index.php;
location / {
try_files $uri /index.php$is_args$args;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass 127.0.0.1:9000;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
internal;
}
location = /healthz {
access_log off;
add_header Content-Type text/plain;
return 200 "OK";
}
error_log /var/log/nginx/error.log;
access_log /var/log/nginx/access.log;
}

View File

@@ -0,0 +1,22 @@
[supervisord]
nodaemon=true
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
user=root
[program:php-fpm]
command=/usr/local/sbin/php-fpm --nodaemonize
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:nginx]
command=/usr/sbin/nginx -g "daemon off;"
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

View File

@@ -1,32 +0,0 @@
# Setup
## Prerequisites
- Docker + Docker Compose
- Spotify Developer account (for a Client ID/Secret)
## Start services
```bash
docker compose up -d --build
```
## Database
```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
```
## Admin user
```bash
docker compose exec php php bin/console app:promote-admin you@example.com
```
## Spotify credentials
- Prefer admin UI: open `/admin/settings` and enter Client ID/Secret.
- Fallback to env vars:
```bash
export SPOTIFY_CLIENT_ID=your_client_id
export SPOTIFY_CLIENT_SECRET=your_client_secret
```

View File

@@ -1,14 +0,0 @@
# Features
- Spotify album search with Advanced filters (album, artist, year range)
- Album page with details, list of reviews, and inline new review
- Review rating slider (110) 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)
- Admin Site Settings to manage Spotify credentials
- User Dashboard for profile changes (email, display name, password)
- Light/Dark theme toggle (cookie-backed)
- Bootstrap UI

View File

@@ -1,19 +0,0 @@
# Authentication & Users
## Modal auth
- Login and registration happen in a Bootstrap modal.
- AJAX submits keep users on the same page; state updates after reload.
- Remember-me cookie keeps users logged in across sessions.
## Roles
- `ROLE_USER`: default for registered users.
- `ROLE_ADMIN`: promoted via console `app:promote-admin`.
## Password changes
- On `/dashboard`, users can change email/display name.
- To set a new password, the current password must be provided.
## Logout
- `/logout` (link in user menu).

View File

@@ -1,19 +0,0 @@
# Spotify Integration
## Credentials
- Prefer configuring via `/admin/settings` (stored in DB).
- Fallback to environment variables `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET`.
## API client
- `src/Service/SpotifyClient.php`
- 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:
- `album:"..."`, `artist:"..."`, `year:YYYY` or `year:YYYY-YYYY`
- Optional free-text added to the query

View File

@@ -1,16 +0,0 @@
# Reviews & Albums
## Album page
- Shows album artwork, metadata, average rating and review count.
- Lists reviews newest-first.
- Logged-in users can submit a review inline.
## Permissions
- Anyone can view.
- Authors can edit/delete their own reviews.
- Admins can edit/delete any review.
## UI
- Rating uses a slider (110) with ticks; badge shows current value.

View File

@@ -1,17 +0,0 @@
# Admin & Settings
## Site settings (ROLE_ADMIN)
- URL: `/admin/settings`
- Manage Spotify credentials stored in DB.
## User management
- Promote an admin:
```bash
docker compose exec php php bin/console app:promote-admin user@example.com
```
## Appearance
- `/settings` provides a dark/light mode toggle.
- Preference saved in a cookie; applied via `data-bs-theme`.

View File

@@ -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.

View File

@@ -1,19 +0,0 @@
# Troubleshooting
## Cannot find template or routes
- Clear cache: `docker compose exec php php bin/console cache:clear`
- List routes: `docker compose exec php php bin/console debug:router`
## Missing vendors
- Install: `docker compose exec php composer install --no-interaction --prefer-dist`
## .env not read in container
- Ensure we mount `.env` or set env vars in compose; we mount `.env` in `docker-compose.yml`.
## 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.

View File

@@ -0,0 +1,48 @@
# 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`
- 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.
- Preference saved in a cookie; applied via `data-bs-theme`.
## Useful tips
- Registration toggle can be locked by environment (`APP_ALLOW_REGISTRATION`), in which case the UI explains that the value is immutable.
- Changing Spotify credentials in settings is effective immediately; no restart is required.
- Admin UI actions are CSRFprotected and rolechecked; if a button appears disabled, hover for a tooltip explanation.

90
docs/architecture.md Normal file
View File

@@ -0,0 +1,90 @@
# Architecture
This project follows a conventional Symfony architecture with clear separation of concerns across controllers, entities, repositories, services, security, forms, and templates.
## Naming & reusability standards (PHP)
- **Classes**
- **Controllers** end with `Controller` (e.g. `AlbumController`) and expose HTTPoriented actions with verbbased method names (`search`, `show`, `edit`, `delete`).
- **Services** are named by capability, not by caller, using nouns or nounphrases (e.g. `AlbumSearchService`, `ConsoleCommandRunner`, `RegistrationToggle`). When a service is tightly scoped to a thirdparty, the integration appears in the name (e.g. `SpotifyClient`, `SpotifyMetadataRefresher`).
- **Entities** are singular domain nouns (`Album`, `Review`, `User`) and avoid transport or UI details.
- **Commands** describe what they do and the environment they are meant for (e.g. `SeedDemoUsersCommand`, `PromoteAdminCommand`).
- **Methods**
- Use **verbbased, intentionrevealing names** that describe *what* the method does, not *how* it is used (e.g. `refreshAllSpotifyAlbums()`, `resetCatalog()`, `runConsoleCommand()`, `isEnabled()`, `findAlbumByPublicId()`).
- Accessors start with `get*`, `set*`, `is*` / `has*` for booleans (e.g. `getEnvOverride()`, `isSpotifyConfigured()`).
- Avoid ambiguous names like `run()`, `handle()`, or `process()` without a clear domain object; prefer `runConsoleCommand()`, `handleAlbumCoverUpload()`, etc.
- **Variables & parameters**
- Use **descriptive, domainlevel names** (e.g. `$albumRepository`, `$reviewCount`, `$spotifyAlbumPayload`) and avoid unclear abbreviations (`$em` is acceptable for `EntityManagerInterface` in local scope, but prefer full names for properties).
- Booleans read naturally (`$isEnabled`, `$shouldQuerySpotify`, `$needsSync`).
- Collections are pluralized (`$albums`, `$userReviews`, `$spotifyIds`).
- **Files & namespaces**
- File names match their primary class name and follow PSR4 (e.g. `src/Service/AlbumSearchService.php` for `App\Service\AlbumSearchService`).
- Helper classes that are not tied to HTTP or persistence live under `src/Service` or `src/Dto` with names that describe the abstraction, not the caller.
These conventions should be followed for all new PHP code and when refactoring existing classes to keep the codebase reusable and selfdocumenting.
## High-level flow
1. Visitors search for albums (Spotify) and view an album page
2. Loggedin users can write, edit, and delete reviews
3. Moderators and admins can moderate content and manage users
4. Admins configure site settings (Spotify credentials, registration toggle)
## Layers & components
### Controllers (`src/Controller/*`)
- `AlbumController` — search, album detail, inline review creation
- `ReviewController` — view, edit, and delete reviews
- `AccountController` — profile, password, and user settings pages
- `Admin/*` — site dashboard, user management, and settings
- `RegistrationController`, `SecurityController` — signup and login/logout routes
### Entities (`src/Entity/*`)
- `User` — authentication principal and roles
- `Album`, `AlbumTrack` — normalized album metadata and track list
- `Review` — userauthored review with rating and timestamps
- `Setting` — key/value store for site configuration (e.g., Spotify credentials)
### Repositories (`src/Repository/*`)
- Doctrine repositories for querying by domain (albums, tracks, reviews, settings, users)
### Forms (`src/Form/*`)
- `RegistrationFormType`, `ReviewType`, `ChangePasswordFormType`, `ProfileFormType`, `SiteSettingsType`, etc.
- Leverage Symfony validation constraints for robust serverside validation
### Services (`src/Service/*`)
- `SpotifyClient` — Client Credentials token management (cached) and API calls
- `SpotifyMetadataRefresher`, `SpotifyGenreResolver` — helpers for richer album data
- `CatalogResetService` — admin action to reset/sync catalog state safely
- `ImageStorage` — avatar uploads and related image handling
- `RegistrationToggle` — DBbacked registration flag with env override
### Security (`config/packages/security.yaml`, `src/Security/*`)
- Role hierarchy: `ROLE_ADMIN``ROLE_MODERATOR``ROLE_USER`
- `ReviewVoter` — edit/delete permissions for review owners and privileged roles
- Access control for `/admin/*` enforced via routes and controllers
### Views (`templates/*`)
- Twig templates for pages and partials (`base.html.twig`, `album/*`, `review/*`, `account/*`, `admin/*`)
- Auth modal in `templates/_partials/auth_modal.html.twig`
- Navbar with roleaware links in `templates/_partials/navbar.html.twig`
### DTOs (`src/Dto/*`)
- Simple data transfer objects for admin tables and search results
## Data & persistence
- SQLite by default for local/packaged deployments; Postgres supported via `DATABASE_URL`
- Migrations run on startup by default (`RUN_MIGRATIONS_ON_START=1`)
## Error handling & UX
- 404 for missing albums
- Flash messages for success/error on actions
- Disabled/tooltip states in admin UI for protected actions (e.g., cannot delete an admin)
## Testing & tooling
- PHPUnit setup in `composer.json` (`phpunit/phpunit`), BrowserKit & CSS Selector for functional coverage
- Web Profiler enabled in dev

48
docs/auth-and-users.md Normal file
View File

@@ -0,0 +1,48 @@
# Authentication & Users
## Login & Registration (modal)
- Login and signup are handled in a Bootstrap modal.
- AJAX submits keep users on the page; a successful login refreshes state.
- Rememberme cookie keeps users logged in across sessions.
## Roles & Permissions
- `ROLE_USER` — default for registered users
- `ROLE_MODERATOR` — can access dashboard and user management, and moderate content
- `ROLE_ADMIN` — adds Site Settings access and moderator promotion/demotion
Promotion (from your host):
```bash
docker compose exec tonehaus php bin/console app:promote-moderator mod@example.com
docker compose exec tonehaus php bin/console app:promote-admin admin@example.com
```
### Access flow
- Visiting `/admin/*` while unauthenticated redirects through `/login`, which reopens the modal.
- Role hierarchy applies: Admin ⊇ Moderator ⊇ User.
- Controllers, templates, and voters enforce privilege boundaries (e.g., site settings are adminonly).
## Public registration toggle
- Toggle in UI: `/admin/settings` (stored in DB)
- Env override: `APP_ALLOW_REGISTRATION=0|1` (env has priority on each boot)
- When disabled, the modal replaces “Sign up” with a tooltip explaining registration is closed. Staff can still create users via `/admin/users`.
## User management (moderator+)
- `/admin/users` lists accounts with album/review counts and actions:
- Create accounts inline (does not affect the current session)
- Delete users (guards prevent deleting self or administrators)
- Admins can Promote/Demote Moderator on nonadmins
## Profiles & Passwords
- `/account/profile`: update email and display name
- `/account/password`: change password (requires current password)
## Demo accounts & avatars
```bash
docker compose exec tonehaus php bin/console app:seed-demo-users --count=50
docker compose exec tonehaus php bin/console app:seed-user-avatars --overwrite
```
## Logout
- Link in the user menu calls `/logout` (handled by Symfony security).

70
docs/deployment.md Normal file
View File

@@ -0,0 +1,70 @@
# Deployment
This application ships with an immutable, singlecontainer image that includes PHPFPM, Nginx, and your code. By default it uses SQLite and autoruns migrations on start.
## Build (locally)
```bash
docker build \
--target=prod \
-t tonehaus-app:latest \
-f docker/php/Dockerfile \
.
```
## Run
```bash
docker run -d \
--name tonehaus \
-p 8080:8080 \
-e APP_ENV=prod \
-e APP_SECRET=change_me \
-e SPOTIFY_CLIENT_ID=your_client_id \
-e SPOTIFY_CLIENT_SECRET=your_client_secret \
tonehaus-app:latest
```
### Notes
- Health endpoint: `GET /healthz` (e.g., `curl http://localhost:8080/healthz`)
- Migrations: `RUN_MIGRATIONS_ON_START=1` by default (safe to rerun)
- Cache warmup is executed on boot; `APP_SECRET` is required
## Persistence options
### SQLite (default)
- Data file at `var/data/database.sqlite`
- Use a volume for durability:
```bash
docker run -d \
-v tonehaus_sqlite:/var/www/html/var/data \
...
```
### Postgres
Provide `DATABASE_DRIVER=postgres` and a `DATABASE_URL`, e.g.:
```
postgresql://user:password@host:5432/dbname?serverVersion=16&charset=utf8
```
You can disable automatic migrations with `RUN_MIGRATIONS_ON_START=0` and run them manually:
```bash
docker exec tonehaus php bin/console doctrine:migrations:migrate --no-interaction
```
## Environment variables
- `APP_ENV` (`prod` recommended in production)
- `APP_SECRET` (required; random string)
- `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`
- `APP_ALLOW_REGISTRATION` (env override for public registration)
- `DATABASE_DRIVER` (`sqlite` default, or `postgres`)
- `DATABASE_URL` (when using Postgres)
- `DATABASE_SQLITE_PATH` (optional)
- `RUN_MIGRATIONS_ON_START` (default `1`)
## Reverse proxy / TLS
- Place behind your ingress/proxy (e.g., Nginx, Traefik, or a cloud load balancer)
- Terminate TLS at the proxy and forward to the containers port 8080
- Ensure proxy sends `X-Forwarded-*` headers
## Zerodowntime tips
- Build then run a new container alongside the old one, switch traffic at the proxy
- Keep SQLite on a named volume, or use Postgres for shared state across replicas

31
docs/features.md Normal file
View File

@@ -0,0 +1,31 @@
# Features
## Albums & Reviews
- Spotify album search with advanced filters (album, artist, year range)
- Album page: cover art, metadata, full tracklist (when available)
- Reviews list (newest first) and inline new review form (logged-in)
- Rating slider (110) with live badge
- Peralbum aggregates: average rating and total review count
## Authentication & Users
- Bootstrap auth modal for login/sign-up with AJAX submits
- Rememberme cookie keeps users signed in
- Roles: User, Moderator, Admin (see `docs/auth-and-users.md`)
- Profile: update email, display name, and password (requires current password)
## Administration
- Dashboard: latest reviews/albums and key counts (moderator+)
- Users: create/delete users, promote/demote moderators (admin constraints)
- Settings: manage Spotify credentials, toggle public registration (admin)
## Design & UX
- Responsive Bootstrap UI
- Light/Dark theme toggle (cookie-backed)
- CSRF protection on forms
- Access control via role hierarchy and security voters
## Screenshots (placeholders)
- Search page — `docs/img/search.png` (optional)
- Album page — `docs/img/album.png` (optional)
- Admin dashboard — `docs/img/admin-dashboard.png` (optional)

View File

@@ -0,0 +1,31 @@
# Reviews & Albums
## Album page
- Artwork, metadata, average rating, and review count
- Full Spotify tracklist when available
- Reviews list (newest first)
- Inline new review form for loggedin users
## Writing a review
- Rating slider from 110
- Title (max 160 chars) and body (205000 chars)
- Server-side validation provides inline errors on failure
- Successful submissions persist, flash a success message, and reload the album page
## Editing & deleting reviews
- Authors can edit/delete their own reviews
- Moderators/Admins can edit/delete any review
- CSRF protection is required for deletion
## Aggregates
- The album page computes:
- Total number of reviews for the album
- Average rating rounded to one decimal
## Demo data
```bash
docker compose exec tonehaus php bin/console app:seed-demo-albums --count=40 --attach-users
docker compose exec tonehaus php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8
```
- Use `--only-empty` to focus on albums that currently have no reviews.

63
docs/setup.md Normal file
View File

@@ -0,0 +1,63 @@
# Setup
## Prerequisites
- Docker + Docker Compose
- Spotify Developer account (Client ID/Secret)
- A unique `APP_SECRET` value in your environment (for prod builds)
## 1) Start the stack
```bash
docker compose up -d --build
```
App: `http://localhost:8085`
Health: `http://localhost:8085/healthz`
## 2) Create an admin
```bash
docker compose exec tonehaus php bin/console app:promote-admin you@example.com
```
## 3) Configure Spotify
- Preferred: open `/admin/settings` and enter your Client ID/Secret (stored in DB)
- Env fallback (in `.env` or your shell):
```bash
SPOTIFY_CLIENT_ID=your_client_id
SPOTIFY_CLIENT_SECRET=your_client_secret
```
## 4) (Optional) Seed demo data
```bash
docker compose exec tonehaus php bin/console app:seed-demo-users --count=50
docker compose exec tonehaus php bin/console app:seed-demo-albums --count=40 --attach-users
docker compose exec tonehaus php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8
```
## Database drivers
- SQLite (default): set `DATABASE_DRIVER=sqlite` (default) — data stored at `var/data/database.sqlite`
- Postgres: set `DATABASE_DRIVER=postgres` and provide `DATABASE_URL`
- If you enable the commented `db` service in `docker-compose.yml`, a typical URL is:
```
postgresql://symfony:symfony@db:5432/symfony?serverVersion=16&charset=utf8
```
## Environment variables
- `APP_ENV=dev|prod`
- `APP_SECRET=<random_string>`
- `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`
- `APP_ALLOW_REGISTRATION=1|0` (env can override DB setting)
- `DATABASE_DRIVER=sqlite|postgres`
- `DATABASE_SQLITE_PATH` (optional)
- `RUN_MIGRATIONS_ON_START=1|0` (default 1)
## Useful commands
```bash
# Symfony cache
docker compose exec tonehaus php bin/console cache:clear
# Inspect routes
docker compose exec tonehaus php bin/console debug:router
# Promote moderator
docker compose exec tonehaus php bin/console app:promote-moderator mod@example.com
```

View File

@@ -0,0 +1,30 @@
# Spotify Integration
## Credentials
- Preferred: Manage in `/admin/settings` (persisted in DB; no restart required)
- Env fallback: `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`
## API Client
- `src/Service/SpotifyClient.php`
- Client Credentials token fetch with caching
- `searchAlbums(q, limit)` — album search endpoint
- `getAlbum(id)` / `getAlbums([ids])` — metadata fetch
- `getAlbumWithTracks(id)` — metadata + hydrated tracklist
- `getAlbumTracks(id)` — raw paginated tracks (when needed)
### Caching & Rate Limits
- Access tokens are cached until expiry to avoid unnecessary auth calls.
- Downstream requests should be mindful of Spotify rate limits; user actions are debounced in the UI and server calls are focused on album/track data needed by the current page.
## Advanced search syntax
- Fielded queries are composed as:
- `album:"..."`, `artist:"..."`, `year:YYYY` or `year:YYYY-YYYY`
- Optional free text is appended to the query
- Examples:
- `album:"in rainbows" artist:"radiohead"`
- `year:1999-2004 post rock`
## Admin settings
- Update credentials in `/admin/settings`
- Settings are stored in the database; `APP_ENV` reload or container restart is not required

46
docs/troubleshooting.md Normal file
View File

@@ -0,0 +1,46 @@
# Troubleshooting
## Cannot find template or routes
- Clear cache: `docker compose exec tonehaus php bin/console cache:clear`
- List routes: `docker compose exec tonehaus php bin/console debug:router`
## Missing vendors
- Install: `docker compose exec tonehaus composer install --no-interaction --prefer-dist`
## .env not read in container
- Ensure we mount `.env` or set env vars in compose; we mount `.env` in `docker-compose.yml`.
## Login modal shows blank
- Make sure Bootstrap JS loads before the modal script (handled in `base.html.twig`).
## 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 `admin-and-settings.md` to grant roles.
## SQLite file permissions
- The default SQLite path is `var/data/database.sqlite`.
- If migrations fail at startup: ensure the `sqlite_data` volume is attached and the path is writable by the container user.
## Postgres connection issues
- If you enable the `db` service in `docker-compose.yml`, verify `DATABASE_URL` matches the service name and credentials.
- Example URL:
```
postgresql://symfony:symfony@db:5432/symfony?serverVersion=16&charset=utf8
```
## Spotify errors
- Verify credentials in `/admin/settings` or env vars `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET`.
- Client Credentials tokens are cached; if revoked, wait for expiry or restart the container.
## ARM64 Build
```bash
sudo docker buildx build \
--platform linux/arm64 \
--target prod \
-t tonehaus/tonehaus:dev-arm64 \
-f docker/php/Dockerfile \
. \
--load
```

14
importmap.php Normal file
View File

@@ -0,0 +1,14 @@
<?php
/**
* Importmap configuration for the asset mapper.
*
* The single "app" entrypoint is enough for Bootstrap + custom JS tweaks.
*/
return [
'app' => [
'path' => './assets/app.js',
'entrypoint' => true,
],
];

View File

@@ -19,7 +19,16 @@ final class Version20251031224841 extends AbstractMigration
public function up(Schema $schema): void public function up(Schema $schema): void
{ {
// this up() migration is auto-generated, please modify it to your needs if ($this->isSqlite()) {
// SQLite uses the dedicated schema bootstrap migration later in the chain.
return;
}
// Skip if the tables somehow already exist to keep reruns idempotent.
if ($schema->hasTable('reviews') || $schema->hasTable('users') || $schema->hasTable('messenger_messages')) {
return;
}
$this->addSql('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 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('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.created_at IS \'(DC2Type:datetime_immutable)\'');
@@ -46,11 +55,17 @@ final class Version20251031224841 extends AbstractMigration
public function down(Schema $schema): void public function down(Schema $schema): void
{ {
// this down() migration is auto-generated, please modify it to your needs if ($this->isSqlite()) {
$this->addSql('CREATE SCHEMA public'); return;
}
$this->addSql('ALTER TABLE reviews DROP CONSTRAINT FK_6970EB0FF675F31B'); $this->addSql('ALTER TABLE reviews DROP CONSTRAINT FK_6970EB0FF675F31B');
$this->addSql('DROP TABLE reviews'); $this->addSql('DROP TABLE reviews');
$this->addSql('DROP TABLE users'); $this->addSql('DROP TABLE users');
$this->addSql('DROP TABLE messenger_messages'); $this->addSql('DROP TABLE messenger_messages');
} }
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
} }

View File

@@ -25,7 +25,14 @@ final class Version20251031231033 extends AbstractMigration
public function down(Schema $schema): void public function down(Schema $schema): void
{ {
// this down() migration is auto-generated, please modify it to your needs if ($this->isSqlite()) {
return;
}
$this->addSql('CREATE SCHEMA public'); $this->addSql('CREATE SCHEMA public');
} }
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
} }

View File

@@ -25,7 +25,14 @@ final class Version20251031231715 extends AbstractMigration
public function down(Schema $schema): void public function down(Schema $schema): void
{ {
// this down() migration is auto-generated, please modify it to your needs if ($this->isSqlite()) {
return;
}
$this->addSql('CREATE SCHEMA public'); $this->addSql('CREATE SCHEMA public');
} }
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
} }

View File

@@ -19,15 +19,27 @@ final class Version20251101001514 extends AbstractMigration
public function up(Schema $schema): void public function up(Schema $schema): void
{ {
// this up() migration is auto-generated, please modify it to your needs if ($this->isSqlite()) {
// SQLite bootstraps settings in Version20251127235840.
return;
}
if ($schema->hasTable('settings')) {
return;
}
$this->addSql('CREATE TABLE settings (id SERIAL NOT NULL, name VARCHAR(100) NOT NULL, value TEXT DEFAULT NULL, PRIMARY KEY(id))'); $this->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)'); $this->addSql('CREATE UNIQUE INDEX uniq_setting_name ON settings (name)');
} }
public function down(Schema $schema): void public function down(Schema $schema): void
{ {
// this down() migration is auto-generated, please modify it to your needs if ($this->isSqlite()) {
$this->addSql('CREATE SCHEMA public'); return;
}
$this->addSql('DROP TABLE settings'); $this->addSql('DROP TABLE settings');
} }
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
} }

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251114111853 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
if ($this->isSqlite()) {
// SQLite installs get albums from Version20251127235840.
return;
}
// Idempotent guard: if table already exists (from previous migration), skip
if ($schema->hasTable('albums')) {
return;
}
$this->addSql('CREATE TABLE albums (id SERIAL NOT NULL, spotify_id VARCHAR(64) NOT NULL, name VARCHAR(255) NOT NULL, artists JSON NOT NULL, release_date VARCHAR(20) DEFAULT NULL, total_tracks INT NOT NULL, cover_url VARCHAR(1024) DEFAULT NULL, external_url VARCHAR(1024) DEFAULT 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 UNIQUE INDEX UNIQ_F4E2474FA905FC5C ON albums (spotify_id)');
$this->addSql('COMMENT ON COLUMN albums.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN albums.updated_at IS \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
if ($this->isSqlite()) {
return;
}
// Be defensive: only drop the table if it exists
if ($schema->hasTable('albums')) {
$this->addSql('DROP TABLE albums');
}
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251114112016 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
if ($this->connection->getDatabasePlatform()->getName() === 'sqlite') {
return;
}
$this->addSql('ALTER TABLE albums ALTER created_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
$this->addSql('ALTER TABLE albums ALTER updated_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
$this->addSql('COMMENT ON COLUMN albums.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN albums.updated_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER INDEX uniq_album_spotify_id RENAME TO UNIQ_F4E2474FA905FC5C');
}
public function down(Schema $schema): void
{
if ($this->connection->getDatabasePlatform()->getName() === 'sqlite') {
return;
}
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE albums ALTER created_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
$this->addSql('ALTER TABLE albums ALTER updated_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
$this->addSql('COMMENT ON COLUMN albums.created_at IS NULL');
$this->addSql('COMMENT ON COLUMN albums.updated_at IS NULL');
$this->addSql('ALTER INDEX uniq_f4e2474fa905fc5c RENAME TO uniq_album_spotify_id');
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251114113000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Normalize reviews: add album_id FK, backfill from albums.spotify_id';
}
public function up(Schema $schema): void
{
if (!$schema->hasTable('reviews')) {
return;
}
$reviews = $schema->getTable('reviews');
if ($reviews->hasColumn('album_id')) {
// Already migrated (common for SQLite dev DBs)
return;
}
if ($this->connection->getDatabasePlatform()->getName() === 'sqlite') {
// SQLite cannot add FK constraints after table creation; add the column + index and rely on app-level validation.
$this->addSql('ALTER TABLE reviews ADD COLUMN album_id INTEGER DEFAULT NULL');
$this->addSql('CREATE INDEX IF NOT EXISTS IDX_6970EF78E0C31AF9 ON reviews (album_id)');
$this->addSql('UPDATE reviews SET album_id = (SELECT a.id FROM albums a WHERE a.spotify_id = reviews.spotify_album_id) WHERE album_id IS NULL');
return;
}
// Add nullable album_id first (PostgreSQL / others that support full DDL)
$this->addSql('ALTER TABLE reviews ADD album_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE reviews ADD CONSTRAINT FK_6970EF78E0C31AF9 FOREIGN KEY (album_id) REFERENCES albums (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_6970EF78E0C31AF9 ON reviews (album_id)');
// Backfill using existing spotify_album_id if both columns exist
// Some environments may not have the legacy column; guard with DO blocks
$this->addSql(<<<'SQL'
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name='reviews' AND column_name='spotify_album_id'
) THEN
UPDATE reviews r
SET album_id = a.id
FROM albums a
WHERE a.spotify_id = r.spotify_album_id
AND r.album_id IS NULL;
END IF;
END $$;
SQL);
// Optionally set NOT NULL if all rows are linked
$this->addSql(<<<'SQL'
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM reviews WHERE album_id IS NULL) THEN
ALTER TABLE reviews ALTER COLUMN album_id SET NOT NULL;
END IF;
END $$;
SQL);
}
public function down(Schema $schema): void
{
if (!$schema->hasTable('reviews')) {
return;
}
$reviews = $schema->getTable('reviews');
if (!$reviews->hasColumn('album_id')) {
return;
}
if ($this->connection->getDatabasePlatform()->getName() === 'sqlite') {
$this->addSql('DROP INDEX IF EXISTS IDX_6970EF78E0C31AF9');
$this->addSql('ALTER TABLE reviews DROP COLUMN album_id');
return;
}
$this->addSql('ALTER TABLE reviews DROP CONSTRAINT FK_6970EF78E0C31AF9');
$this->addSql('DROP INDEX IF EXISTS IDX_6970EF78E0C31AF9');
$this->addSql('ALTER TABLE reviews DROP COLUMN album_id');
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251114114000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Drop legacy duplicated review columns: spotify_album_id, album_name, album_artist';
}
public function up(Schema $schema): void
{
if ($this->isSqlite()) {
// SQLite schema never created the legacy columns.
return;
}
// Guard: drop columns only if they exist
$this->addSql(<<<'SQL'
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='reviews' AND column_name='spotify_album_id') THEN
ALTER TABLE reviews DROP COLUMN spotify_album_id;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='reviews' AND column_name='album_name') THEN
ALTER TABLE reviews DROP COLUMN album_name;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='reviews' AND column_name='album_artist') THEN
ALTER TABLE reviews DROP COLUMN album_artist;
END IF;
END $$;
SQL);
}
public function down(Schema $schema): void
{
if ($this->isSqlite()) {
return;
}
// Recreate columns as nullable in down migration
$this->addSql('ALTER TABLE reviews ADD spotify_album_id VARCHAR(64) DEFAULT NULL');
$this->addSql('ALTER TABLE reviews ADD album_name VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE reviews ADD album_artist VARCHAR(255) DEFAULT NULL');
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251114120500 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add user-created album fields: local_id, source, created_by_id; make spotify_id nullable';
}
public function up(Schema $schema): void
{
if ($this->isSqlite()) {
// SQLite schema already ships with these columns/defaults.
return;
}
$this->addSql("ALTER TABLE albums ADD local_id VARCHAR(64) DEFAULT NULL");
$this->addSql("ALTER TABLE albums ADD source VARCHAR(16) NOT NULL DEFAULT 'spotify'");
$this->addSql("ALTER TABLE albums ADD created_by_id INT DEFAULT NULL");
$this->addSql("ALTER TABLE albums ALTER spotify_id DROP NOT NULL");
$this->addSql("CREATE UNIQUE INDEX uniq_album_local_id ON albums (local_id)");
$this->addSql("ALTER TABLE albums ADD CONSTRAINT FK_F4E2474FB03A8386 FOREIGN KEY (created_by_id) REFERENCES users (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE");
$this->addSql("CREATE INDEX IDX_F4E2474FB03A8386 ON albums (created_by_id)");
$this->addSql("UPDATE albums SET source = 'spotify' WHERE source IS NULL");
}
public function down(Schema $schema): void
{
if ($this->isSqlite()) {
return;
}
$this->addSql("ALTER TABLE albums DROP CONSTRAINT FK_F4E2474FB03A8386");
$this->addSql("DROP INDEX IF EXISTS uniq_album_local_id");
$this->addSql("DROP INDEX IF EXISTS IDX_F4E2474FB03A8386");
$this->addSql("ALTER TABLE albums DROP COLUMN local_id");
$this->addSql("ALTER TABLE albums DROP COLUMN source");
$this->addSql("ALTER TABLE albums DROP COLUMN created_by_id");
$this->addSql("ALTER TABLE albums ALTER spotify_id SET NOT NULL");
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251120174722 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
if ($this->isSqlite()) {
return;
}
$this->addSql('ALTER TABLE reviews ALTER album_id SET NOT NULL');
$this->addSql('ALTER INDEX idx_6970ef78e0c31af9 RENAME TO IDX_6970EB0F1137ABCF');
}
public function down(Schema $schema): void
{
if ($this->isSqlite()) {
return;
}
$this->addSql('ALTER TABLE reviews ALTER album_id DROP NOT NULL');
$this->addSql('ALTER INDEX idx_6970eb0f1137abcf RENAME TO idx_6970ef78e0c31af9');
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251120175034 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
}
public function down(Schema $schema): void
{
if ($this->isSqlite()) {
return;
}
$this->addSql('CREATE SCHEMA public');
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251127191813 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
if ($this->isSqlite()) {
return;
}
$this->addSql('ALTER TABLE albums ALTER source DROP DEFAULT');
$this->addSql('ALTER INDEX uniq_album_local_id RENAME TO UNIQ_F4E2474F5D5A2101');
}
public function down(Schema $schema): void
{
if ($this->isSqlite()) {
return;
}
$this->addSql('ALTER TABLE albums ALTER source SET DEFAULT \'spotify\'');
$this->addSql('ALTER INDEX uniq_f4e2474f5d5a2101 RENAME TO uniq_album_local_id');
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251127235840 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
if (!$this->isSqlite()) {
return;
}
if ($schema->hasTable('users')) {
// Already initialized.
return;
}
$this->addSql('CREATE TABLE albums (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, created_by_id INTEGER DEFAULT NULL, spotify_id VARCHAR(64) DEFAULT NULL, local_id VARCHAR(64) DEFAULT NULL, source VARCHAR(16) NOT NULL, name VARCHAR(255) NOT NULL, artists CLOB NOT NULL --(DC2Type:json)
, release_date VARCHAR(20) DEFAULT NULL, total_tracks INTEGER NOT NULL, cover_url VARCHAR(1024) DEFAULT NULL, cover_image_path VARCHAR(255) DEFAULT NULL, external_url VARCHAR(1024) DEFAULT NULL, created_at DATETIME NOT NULL --(DC2Type:datetime_immutable)
, updated_at DATETIME NOT NULL --(DC2Type:datetime_immutable)
, CONSTRAINT FK_F4E2474FB03A8386 FOREIGN KEY (created_by_id) REFERENCES users (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_F4E2474FA905FC5C ON albums (spotify_id)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_F4E2474F5D5A2101 ON albums (local_id)');
$this->addSql('CREATE INDEX IDX_F4E2474FB03A8386 ON albums (created_by_id)');
$this->addSql('CREATE TABLE reviews (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, author_id INTEGER NOT NULL, album_id INTEGER NOT NULL, title VARCHAR(160) NOT NULL, content CLOB NOT NULL, rating SMALLINT NOT NULL, created_at DATETIME NOT NULL --(DC2Type:datetime_immutable)
, updated_at DATETIME NOT NULL --(DC2Type:datetime_immutable)
, CONSTRAINT FK_6970EB0FF675F31B FOREIGN KEY (author_id) REFERENCES users (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6970EB0F1137ABCF FOREIGN KEY (album_id) REFERENCES albums (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('CREATE INDEX IDX_6970EB0FF675F31B ON reviews (author_id)');
$this->addSql('CREATE INDEX IDX_6970EB0F1137ABCF ON reviews (album_id)');
$this->addSql('CREATE TABLE settings (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(100) NOT NULL, value CLOB DEFAULT NULL)');
$this->addSql('CREATE UNIQUE INDEX uniq_setting_name ON settings (name)');
$this->addSql('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, email VARCHAR(180) NOT NULL, roles CLOB NOT NULL --(DC2Type:json)
, password VARCHAR(255) NOT NULL, display_name VARCHAR(120) DEFAULT NULL, profile_image_path VARCHAR(255) DEFAULT NULL)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E9E7927C74 ON users (email)');
}
public function down(Schema $schema): void
{
if (!$this->isSqlite()) {
return;
}
$this->addSql('DROP TABLE albums');
$this->addSql('DROP TABLE reviews');
$this->addSql('DROP TABLE settings');
$this->addSql('DROP TABLE users');
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251205123000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add profile image path to users and cover image path to albums';
}
public function up(Schema $schema): void
{
if ($this->shouldAddColumn($schema, 'users', 'profile_image_path')) {
$this->addSql('ALTER TABLE users ADD profile_image_path VARCHAR(255) DEFAULT NULL');
}
if ($this->shouldAddColumn($schema, 'albums', 'cover_image_path')) {
$this->addSql('ALTER TABLE albums ADD cover_image_path VARCHAR(255) DEFAULT NULL');
}
}
public function down(Schema $schema): void
{
if ($this->isSqlite()) {
// SQLite cannot drop columns; leave them in place.
return;
}
if ($schema->hasTable('users') && $schema->getTable('users')->hasColumn('profile_image_path')) {
$this->addSql('ALTER TABLE users DROP profile_image_path');
}
if ($schema->hasTable('albums') && $schema->getTable('albums')->hasColumn('cover_image_path')) {
$this->addSql('ALTER TABLE albums DROP cover_image_path');
}
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
private function shouldAddColumn(Schema $schema, string $tableName, string $column): bool
{
if (!$schema->hasTable($tableName)) {
return false;
}
return !$schema->getTable($tableName)->hasColumn($column);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251205133000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create album_tracks table to persist Spotify tracklists';
}
public function up(Schema $schema): void
{
if ($this->isSqlite()) {
if ($schema->hasTable('album_tracks')) {
return;
}
$this->addSql('CREATE TABLE album_tracks (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, album_id INTEGER NOT NULL, spotify_track_id VARCHAR(64) DEFAULT NULL, disc_number INTEGER NOT NULL, track_number INTEGER NOT NULL, name VARCHAR(512) NOT NULL, duration_ms INTEGER NOT NULL, preview_url VARCHAR(1024) DEFAULT NULL, CONSTRAINT FK_5E4A3B3B1137ABCF FOREIGN KEY (album_id) REFERENCES albums (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('CREATE INDEX IDX_5E4A3B3B1137ABCF ON album_tracks (album_id)');
$this->addSql('CREATE UNIQUE INDEX uniq_album_disc_track ON album_tracks (album_id, disc_number, track_number)');
return;
}
$this->addSql('CREATE TABLE album_tracks (id SERIAL NOT NULL, album_id INT NOT NULL, spotify_track_id VARCHAR(64) DEFAULT NULL, disc_number INT NOT NULL, track_number INT NOT NULL, name VARCHAR(512) NOT NULL, duration_ms INT NOT NULL, preview_url VARCHAR(1024) DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_5E4A3B3B1137ABCF ON album_tracks (album_id)');
$this->addSql('CREATE UNIQUE INDEX uniq_album_disc_track ON album_tracks (album_id, disc_number, track_number)');
$this->addSql('ALTER TABLE album_tracks ADD CONSTRAINT FK_5E4A3B3B1137ABCF FOREIGN KEY (album_id) REFERENCES albums (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
if ($this->isSqlite()) {
$this->addSql('DROP TABLE IF EXISTS album_tracks');
return;
}
$this->addSql('ALTER TABLE album_tracks DROP CONSTRAINT FK_5E4A3B3B1137ABCF');
$this->addSql('DROP TABLE album_tracks');
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251205134500 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add genres JSON column to albums for advanced search filters';
}
public function up(Schema $schema): void
{
if (!$this->shouldAddColumn($schema, 'albums', 'genres')) {
return;
}
if ($this->isSqlite()) {
$this->addSql("ALTER TABLE albums ADD genres CLOB NOT NULL DEFAULT '[]'");
return;
}
$this->addSql("ALTER TABLE albums ADD genres JSON NOT NULL DEFAULT '[]'");
}
public function down(Schema $schema): void
{
if ($this->isSqlite()) {
// SQLite cannot drop columns without rebuilding the table; leave as-is.
return;
}
if ($schema->hasTable('albums') && $schema->getTable('albums')->hasColumn('genres')) {
$this->addSql('ALTER TABLE albums DROP genres');
}
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
private function shouldAddColumn(Schema $schema, string $tableName, string $column): bool
{
if (!$schema->hasTable($tableName)) {
return false;
}
return !$schema->getTable($tableName)->hasColumn($column);
}
}

303
public/css/app.css Normal file
View File

@@ -0,0 +1,303 @@
: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);
}
/* Material-inspired data tables */
.mui-table-wrapper {
border: 1px solid var(--md-card-border);
border-radius: 20px;
background-color: var(--md-card);
box-shadow: var(--md-shadow-ambient);
overflow-x: auto;
}
.mui-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
min-width: 620px;
}
.mui-table thead th {
font-size: 0.74rem;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 600;
color: var(--md-text-secondary);
padding: 0.85rem 1.25rem;
background-color: var(--md-surface-variant);
border-bottom: 1px solid var(--md-outline);
}
.mui-table tbody td {
padding: 1rem 1.25rem;
border-bottom: 1px solid color-mix(in srgb, var(--md-outline) 75%, transparent);
color: var(--md-text-primary);
}
.mui-table--compact tbody td {
padding: 0.65rem 1rem;
}
.mui-table tbody tr:last-child td {
border-bottom: none;
}
.mui-table tbody tr {
transition: background-color 0.15s ease, transform 0.15s ease;
}
.mui-table tbody tr:hover {
background-color: color-mix(in srgb, var(--accent-color) 6%, transparent);
}
.mui-table--striped tbody tr:nth-child(even) {
background-color: color-mix(in srgb, var(--accent-color) 4%, transparent);
}
.mui-table--striped tbody tr:nth-child(even):hover {
background-color: color-mix(in srgb, var(--accent-color) 9%, transparent);
}
.mui-table__number {
font-variant-numeric: tabular-nums;
color: var(--md-text-secondary);
width: 72px;
}
.mui-table__metric {
font-variant-numeric: tabular-nums;
text-align: center;
color: var(--md-text-secondary);
}
.mui-table__title {
font-weight: 600;
}
.mui-table__title-avatar {
display: flex;
align-items: center;
gap: 0.85rem;
}
.mui-table__title-avatar img {
width: 40px;
height: 40px;
border-radius: 12px;
object-fit: cover;
border: 1px solid var(--md-card-border);
}
.mui-table__subtitle {
font-size: 0.85rem;
color: var(--md-text-secondary);
}
.mui-icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 999px;
border: 1px solid var(--md-outline);
color: var(--accent-color);
background-color: transparent;
transition: background-color 0.15s ease, box-shadow 0.15s ease;
}
.mui-icon-button:hover {
background-color: color-mix(in srgb, var(--accent-color) 12%, transparent);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
.mui-icon-button svg,
.mui-icon-button span {
font-size: 1rem;
line-height: 1;
}

3
public/uploads/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*
!.gitignore

View File

@@ -13,20 +13,32 @@ use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'app:promote-admin', description: 'Grant ROLE_ADMIN to a user by email')] #[AsCommand(name: 'app:promote-admin', description: 'Grant ROLE_ADMIN to a user by email')]
class PromoteAdminCommand extends Command class PromoteAdminCommand extends Command
{ {
public function __construct(private readonly UserRepository $users, private readonly EntityManagerInterface $em) /**
* Stores injected dependencies for later use.
*/
public function __construct(
private readonly UserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
)
{ {
parent::__construct(); parent::__construct();
} }
/**
* Declares the required email argument.
*/
protected function configure(): void protected function configure(): void
{ {
$this->addArgument('email', InputArgument::REQUIRED, 'Email of the user to promote'); $this->addArgument('email', InputArgument::REQUIRED, 'Email of the user to promote');
} }
/**
* Promotes the provided account to administrator if found.
*/
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
{ {
$email = (string) $input->getArgument('email'); $email = (string) $input->getArgument('email');
$user = $this->users->findOneByEmail($email); $user = $this->userRepository->findOneByEmail($email);
if (!$user) { if (!$user) {
$output->writeln('<error>User not found: ' . $email . '</error>'); $output->writeln('<error>User not found: ' . $email . '</error>');
return Command::FAILURE; return Command::FAILURE;
@@ -36,7 +48,7 @@ class PromoteAdminCommand extends Command
if (!in_array('ROLE_ADMIN', $roles, true)) { if (!in_array('ROLE_ADMIN', $roles, true)) {
$roles[] = 'ROLE_ADMIN'; $roles[] = 'ROLE_ADMIN';
$user->setRoles($roles); $user->setRoles($roles);
$this->em->flush(); $this->entityManager->flush();
} }
$output->writeln('<info>Granted ROLE_ADMIN to ' . $email . '</info>'); $output->writeln('<info>Granted ROLE_ADMIN to ' . $email . '</info>');

View File

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

View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Album;
use App\Entity\User;
use App\Repository\AlbumRepository;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:seed-demo-albums',
description: 'Create demo albums with randomized metadata for local development.'
)]
/**
* Seeds the database with synthetic user-sourced albums.
*
* - Always marked as "user" source with a unique localId.
* - Include randomized names, artists, genres, release dates, and cover URLs.
* - Optionally link to existing users as creators when --attach-users is set.
*/
class SeedDemoAlbumsCommand extends Command
{
private const GENRES = [
'Dreamwave', 'Synth Pop', 'Lo-Fi', 'Indie Rock', 'Chillhop', 'Neo Jazz',
'Electro Funk', 'Ambient', 'Future Soul', 'Post Folk', 'Shoegaze', 'Hyperpop',
];
private const ADJECTIVES = [
'Electric', 'Velvet', 'Crimson', 'Solar', 'Golden', 'Neon', 'Silent', 'Liquid', 'Violet', 'Paper',
];
private const NOUNS = [
'Echoes', 'Horizons', 'Magnets', 'Parades', 'Cities', 'Signals', 'Fragments', 'Constellations',
'Gardens', 'Drifters', 'Reflections', 'Blueprints',
];
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly AlbumRepository $albumRepository,
private readonly UserRepository $userRepository,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('count', null, InputOption::VALUE_OPTIONAL, 'Number of demo albums to create', 40)
->addOption('attach-users', null, InputOption::VALUE_NONE, 'If set, randomly assigns existing users as creators');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$count = max(1, (int) $input->getOption('count'));
$attachUsers = (bool) $input->getOption('attach-users');
$users = $attachUsers ? $this->userRepository->findAll() : [];
$created = 0;
// Track generated localIds so we never attempt to persist obvious duplicates.
$seenLocalIds = [];
while ($created < $count) {
// Generate a localId that is unique in-memory and in the database to avoid constraint violations.
$localId = $this->generateLocalId();
if (isset($seenLocalIds[$localId]) || $this->albumRepository->findOneBy(['localId' => $localId]) !== null) {
// Only accept IDs that are unique both in-memory and in the DB to avoid constraint errors.
continue;
}
$album = new Album();
$album->setSource('user');
$album->setLocalId($localId);
$album->setName($this->generateAlbumName());
$album->setArtists($this->generateArtists());
$album->setGenres($this->generateGenres());
$album->setReleaseDate($this->generateReleaseDate());
$album->setTotalTracks(random_int(6, 16));
$album->setCoverUrl($this->generateCoverUrl($localId));
$album->setExternalUrl(sprintf('https://example.com/demo-albums/%s', $localId));
if ($attachUsers && $users !== []) {
/** @var User $user */
$user = $users[array_rand($users)];
$album->setCreatedBy($user);
}
$this->entityManager->persist($album);
$seenLocalIds[$localId] = true;
$created++;
}
$this->entityManager->flush();
$io->success(sprintf('Created %d demo albums%s.', $created, $attachUsers ? ' with random owners' : ''));
return Command::SUCCESS;
}
private function generateLocalId(): string
{
return 'demo_' . bin2hex(random_bytes(4));
}
private function generateAlbumName(): string
{
$adj = self::ADJECTIVES[random_int(0, count(self::ADJECTIVES) - 1)];
$noun = self::NOUNS[random_int(0, count(self::NOUNS) - 1)];
$genre = self::GENRES[random_int(0, count(self::GENRES) - 1)];
return sprintf('%s %s of %s', $adj, $noun, $genre);
}
/**
* @return list<string>
*/
private function generateArtists(): array
{
$artists = [];
$artistCount = random_int(1, 3);
for ($i = 0; $i < $artistCount; $i++) {
$artists[] = sprintf(
'%s %s',
self::ADJECTIVES[random_int(0, count(self::ADJECTIVES) - 1)],
self::NOUNS[random_int(0, count(self::NOUNS) - 1)]
);
}
return array_values(array_unique($artists));
}
private function generateReleaseDate(): string
{
$year = random_int(1990, (int) date('Y'));
$month = random_int(1, 12);
$day = random_int(1, 28);
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
/**
* @return list<string>
*/
private function generateGenres(): array
{
$count = random_int(1, 3);
$genres = [];
for ($i = 0; $i < $count; $i++) {
$genres[] = self::GENRES[random_int(0, count(self::GENRES) - 1)];
}
return array_values(array_unique($genres));
}
private function generateCoverUrl(string $seed): string
{
return sprintf('https://picsum.photos/seed/%s/640/640', $seed);
}
}

View File

@@ -0,0 +1,232 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Album;
use App\Entity\Review;
use App\Entity\User;
use App\Repository\AlbumRepository;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:seed-demo-reviews',
description: 'Generate demo reviews across existing albums.'
)]
/**
* Seeds the database with demo reviews attached to existing albums and users.
*
* Controls:
* - --cover-percent: roughly what percentage of albums receive reviews.
* - --min-per-album / --max-per-album: bounds for randomly chosen review counts.
* - --only-empty: restricts seeding to albums that currently have no reviews.
*
* The command avoids:
* - Creating multiple reviews from the same user on a single album.
* - Touching albums/users when there is no suitable data to seed.
*/
class SeedDemoReviewsCommand extends Command
{
private const SUBJECTS = [
'Textures', 'Melodies', 'Lyrics', 'Drums', 'Synths', 'Vocals', 'Atmosphere', 'Production',
'Hooks', 'Transitions', 'Energy', 'Dynamics', 'Story', 'Beats', 'Guitars',
];
private const VERBS = [
'ignite', 'carry', 'elevate', 'anchor', 'transform', 'frame', 'redefine', 'ground', 'highlight',
'soften', 'energize', 'contrast', 'bend', 'reshape', 'underline',
];
private const QUALIFIERS = [
'beautifully', 'with surprising restraint', 'like neon waves', 'with cinematic flair',
'through dusty speakers', 'in unexpected directions', 'along a familiar path', 'with swagger',
'with delicate pulses', 'through midnight haze', 'under fluorescent skies', 'with raw urgency',
];
public function __construct(
private readonly AlbumRepository $albumRepository,
private readonly UserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('max-per-album', null, InputOption::VALUE_OPTIONAL, 'Maximum reviews per album', 10)
->addOption('min-per-album', null, InputOption::VALUE_OPTIONAL, 'Minimum reviews per selected album', 1)
->addOption('cover-percent', null, InputOption::VALUE_OPTIONAL, 'Percent of albums that should receive reviews (0-100)', 60)
->addOption('only-empty', null, InputOption::VALUE_NONE, 'Only seed albums that currently have no reviews');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
// Pull all albums/users once up front so downstream helpers filter as needed.
$albums = $this->albumRepository->findAll();
$users = $this->userRepository->findAll();
if ($albums === [] || $users === []) {
$io->warning('Need at least one album and one user to seed reviews.');
return Command::FAILURE;
}
// Normalize and clamp CLI options so downstream math is always safe. (min/max/clamp)
$minPerAlbum = max(0, (int) $input->getOption('min-per-album'));
$maxPerAlbum = max($minPerAlbum, (int) $input->getOption('max-per-album'));
$coverPercent = max(0, min(100, (int) $input->getOption('cover-percent')));
// Apply coverage and "only empty" filters before creating any Review entities. (filter)
$selectedAlbums = $this->selectAlbums($albums, $coverPercent);
$onlyEmpty = (bool) $input->getOption('only-empty');
$created = 0;
// Count how many albums actually received new reviews for clearer operator feedback. (count)
$processedAlbums = 0;
foreach ($selectedAlbums as $album) {
if ($onlyEmpty && $this->albumHasReviews($album)) {
continue;
}
$targetReviews = random_int($minPerAlbum, max($minPerAlbum, $maxPerAlbum));
$created += $this->seedForAlbum($album, $users, $targetReviews);
$processedAlbums++;
}
$this->entityManager->flush();
if ($created === 0) {
$io->warning('No reviews were created. Try relaxing the filters or ensure there are albums without reviews.');
return Command::SUCCESS;
}
$io->success(sprintf('Created %d demo reviews across %d albums.', $created, max($processedAlbums, 1)));
return Command::SUCCESS;
}
/**
* @param list<Album> $albums
* @return list<Album>
*/
private function selectAlbums(array $albums, int $coverPercent): array
{
if ($coverPercent >= 100) {
return $albums;
}
// Randomly sample albums until the requested coverage threshold is met.
$selected = [];
foreach ($albums as $album) {
if (random_int(1, 100) <= $coverPercent) {
$selected[] = $album;
}
}
// Ensure we always seed at least one album when any albums exist.
return $selected === [] ? [$albums[array_rand($albums)]] : $selected;
}
/**
* @param list<User> $users
*/
private function seedForAlbum(Album $album, array $users, int $targetReviews): int
{
$created = 0;
$existingAuthors = $this->fetchExistingAuthors($album);
// Filter out users who have already reviewed this album so we only ever
// create one review per (album, author) pair.
$availableUsers = array_filter($users, fn(User $user) => !isset($existingAuthors[$user->getId() ?? -1]));
if ($availableUsers === []) {
return 0;
}
// Limit requested reviews to the number of eligible authors, then randomly
// choose a stable subset for this run.
$targetReviews = min($targetReviews, count($availableUsers));
shuffle($availableUsers);
$selectedUsers = array_slice($availableUsers, 0, $targetReviews);
foreach ($selectedUsers as $user) {
// Prevent duplicate reviews per author by only iterating over filtered unique users.
$review = new Review();
$review->setAlbum($album);
$review->setAuthor($user);
$review->setRating(random_int(4, 10));
$review->setTitle($this->generateTitle());
$review->setContent($this->generateContent($album));
$this->entityManager->persist($review);
$created++;
}
return $created;
}
/**
* @return array<int,bool>
*/
private function fetchExistingAuthors(Album $album): array
{
// Fetch all distinct author IDs that have already reviewed this album so we
// can cheaply check for duplicates in PHP without loading full Review objects.
$qb = $this->entityManager->createQueryBuilder()
->select('IDENTITY(r.author) AS authorId')
->from(Review::class, 'r')
->where('r.album = :album')
->setParameter('album', $album);
$rows = $qb->getQuery()->getScalarResult();
$out = [];
foreach ($rows as $row) {
$out[(int) $row['authorId']] = true;
}
return $out;
}
private function albumHasReviews(Album $album): bool
{
$count = (int) $this->entityManager->createQueryBuilder()
->select('COUNT(r.id)')
->from(Review::class, 'r')
->where('r.album = :album')
->setParameter('album', $album)
->getQuery()
->getSingleScalarResult();
return $count > 0;
}
private function generateTitle(): string
{
$subject = self::SUBJECTS[random_int(0, count(self::SUBJECTS) - 1)];
$verb = self::VERBS[random_int(0, count(self::VERBS) - 1)];
return sprintf('%s %s the vibe', $subject, $verb);
}
private function generateContent(Album $album): string
{
$qualifier = self::QUALIFIERS[random_int(0, count(self::QUALIFIERS) - 1)];
return sprintf(
'Listening to "%s" feels like %s. %s %s %s, and by the end it lingers far longer than expected.',
$album->getName(),
$qualifier,
self::SUBJECTS[random_int(0, count(self::SUBJECTS) - 1)],
self::VERBS[random_int(0, count(self::VERBS) - 1)],
self::QUALIFIERS[random_int(0, count(self::QUALIFIERS) - 1)]
);
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\User;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
#[AsCommand(
name: 'app:seed-demo-users',
description: 'Create demo users with random emails and display names.'
)]
/**
* Seeds the database with demo users for local development and testing.
*
* - Generates unique, non-conflicting demo email addresses.
* - Assigns a predictable default password (overridable via --password).
* - Creates users with a single ROLE_USER role.
*/
class SeedDemoUsersCommand extends Command
{
private const FIRST_NAMES = [
'Alex', 'Jamie', 'Taylor', 'Jordan', 'Morgan', 'Casey', 'Riley', 'Parker', 'Robin', 'Avery',
'Charlie', 'Dakota', 'Emerson', 'Finley', 'Harper', 'Jules', 'Kai', 'Logan', 'Quinn', 'Rowan',
];
private const LAST_NAMES = [
'Rivera', 'Nguyen', 'Patel', 'Khan', 'Smith', 'Garcia', 'Fernandez', 'Kim', 'Singh', 'Williams',
'Hughes', 'Silva', 'Bennett', 'Wright', 'Clark', 'Murphy', 'Price', 'Reid', 'Gallagher', 'Foster',
];
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly UserRepository $userRepository,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('count', null, InputOption::VALUE_OPTIONAL, 'Number of demo users to create', 50)
->addOption('password', null, InputOption::VALUE_OPTIONAL, 'Plain password assigned to every demo user', 'password');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$count = (int) $input->getOption('count');
$count = $count > 0 ? $count : 50;
$plainPassword = (string) $input->getOption('password');
$created = 0;
// Track generated emails so we never attempt to persist obvious duplicates.
$seenEmails = [];
while ($created < $count) {
// Keep generating new tokens until we find an email that is unique
// for both this run and the existing database.
$email = $this->generateEmail();
if (isset($seenEmails[$email]) || $this->userRepository->findOneBy(['email' => $email]) !== null) {
// Collisions are rare but possible because we only randomize 8 hex chars; try again.
continue;
}
$user = new User();
$user->setEmail($email);
$user->setDisplayName($this->generateDisplayName());
$user->setPassword($this->passwordHasher->hashPassword($user, $plainPassword));
$user->setRoles(['ROLE_USER']);
$this->entityManager->persist($user);
$seenEmails[$email] = true;
$created++;
}
$this->entityManager->flush();
$io->success(sprintf('Created %d demo users. Default password: %s', $created, $plainPassword));
return Command::SUCCESS;
}
private function generateEmail(): string
{
$token = bin2hex(random_bytes(4));
return sprintf('demo+%s@example.com', $token);
}
private function generateDisplayName(): string
{
$first = self::FIRST_NAMES[random_int(0, count(self::FIRST_NAMES) - 1)];
$last = self::LAST_NAMES[random_int(0, count(self::LAST_NAMES) - 1)];
return sprintf('%s %s', $first, $last);
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\User;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:seed-user-avatars',
description: 'Assign generated profile images to existing users.'
)]
/**
* Seeds or refreshes user profile images using the DiceBear avatar API.
*
* - Skips users that already have an image unless --overwrite is provided.
* - Builds deterministic avatar URLs based on user identity and an optional seed prefix.
* - Does not download or cache the avatars locally; URLs are stored directly.
*/
class SeedUserAvatarsCommand extends Command
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('overwrite', null, InputOption::VALUE_NONE, 'Overwrite users that already have a profile image set')
->addOption('style', null, InputOption::VALUE_OPTIONAL, 'DiceBear style to use for avatars', 'thumbs')
->addOption('seed-prefix', null, InputOption::VALUE_OPTIONAL, 'Prefix added to the avatar seed for variety', 'musicratings');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$overwrite = (bool) $input->getOption('overwrite');
$style = (string) $input->getOption('style');
$seedPrefix = (string) $input->getOption('seed-prefix');
$users = $this->userRepository->findAll();
if ($users === []) {
$io->warning('No users found.');
return Command::FAILURE;
}
$updated = 0;
foreach ($users as $user) {
if (!$user instanceof User) {
continue;
}
if (!$overwrite && $user->getProfileImagePath()) {
// Respect existing uploads unless the operator explicitly allows clobbering them.
continue;
}
$user->setProfileImagePath($this->buildAvatarUrl($user, $style, $seedPrefix));
$updated++;
}
if ($updated === 0) {
$io->info('No avatars needed updating.');
return Command::SUCCESS;
}
$this->entityManager->flush();
$io->success(sprintf('Assigned avatars to %d user(s).', $updated));
return Command::SUCCESS;
}
private function buildAvatarUrl(User $user, string $style, string $seedPrefix): string
{
// Use a stable identifier (display name when present, email as fallback)
// so the same user is always mapped to the same avatar for a given prefix.
$identifier = trim((string) ($user->getDisplayName() ?? $user->getEmail()));
// Combine prefix, identifier, and primary key into a deterministic hash
// and trim it to a shorter seed value accepted by DiceBear.
$seed = substr(hash('sha256', $seedPrefix . '|' . strtolower($identifier) . '|' . (string) $user->getId()), 0, 32);
return sprintf('https://api.dicebear.com/7.x/%s/svg?seed=%s', rawurlencode($style), $seed);
}
}

View File

@@ -4,48 +4,119 @@ namespace App\Controller;
use App\Entity\User; use App\Entity\User;
use App\Form\ProfileFormType; use App\Form\ProfileFormType;
use App\Repository\ReviewRepository;
use App\Repository\AlbumRepository;
use App\Service\UploadStorage;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Form\FormError; use Symfony\Component\Form\FormError;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
/**
* AccountController hosts authenticated self-service pages.
*/
#[IsGranted('ROLE_USER')] #[IsGranted('ROLE_USER')]
class AccountController extends AbstractController class AccountController extends AbstractController
{ {
#[Route('/dashboard', name: 'account_dashboard', methods: ['GET', 'POST'])] /**
public function dashboard(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $hasher): Response * Summarizes the signed-in user's recent activity.
*/
#[Route('/dashboard', name: 'account_dashboard', methods: ['GET'])]
public function dashboard(ReviewRepository $reviews, AlbumRepository $albums): Response
{ {
/** @var User $user */ /** @var User $user */
$user = $this->getUser(); $user = $this->getUser();
$reviewCount = (int) $reviews->createQueryBuilder('r')
->select('COUNT(r.id)')
->where('r.author = :u')->setParameter('u', $user)
->getQuery()->getSingleScalarResult();
$albumCount = (int) $albums->createQueryBuilder('a')
->select('COUNT(a.id)')
->where('a.source = :src')->setParameter('src', 'user')
->andWhere('a.createdBy = :u')->setParameter('u', $user)
->getQuery()->getSingleScalarResult();
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')
->setMaxResults(10)
->getQuery()->getResult();
$userAlbums = $albums->createQueryBuilder('a')
->where('a.source = :src')->setParameter('src', 'user')
->andWhere('a.createdBy = :u')->setParameter('u', $user)
->orderBy('a.createdAt', 'DESC')
->setMaxResults(10)
->getQuery()->getResult();
return $this->render('account/dashboard.html.twig', [
'email' => $user->getEmail(),
'displayName' => $user->getDisplayName(),
'profileImage' => $user->getProfileImagePath(),
'reviewCount' => $reviewCount,
'albumCount' => $albumCount,
'userType' => $userType,
'userReviews' => $userReviews,
'userAlbums' => $userAlbums,
]);
}
/**
* Allows users to update profile details and avatar.
*/
#[Route('/account/profile', name: 'account_profile', methods: ['GET', 'POST'])]
public function profile(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $hasher, UploadStorage $uploadStorage): Response
{
/** @var User $user */
$user = $this->getUser();
$form = $this->createForm(ProfileFormType::class, $user); $form = $this->createForm(ProfileFormType::class, $user);
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if ($form->isSubmitted()) {
$newPassword = (string) $form->get('newPassword')->getData(); $newPassword = (string) $form->get('newPassword')->getData();
if ($newPassword !== '') { if ($newPassword !== '') {
$current = (string) $form->get('currentPassword')->getData(); $current = (string) $form->get('currentPassword')->getData();
if ($current === '' || !$hasher->isPasswordValid($user, $current)) { if ($current === '' || !$hasher->isPasswordValid($user, $current)) {
$form->get('currentPassword')->addError(new FormError('Current password is incorrect.')); $form->get('currentPassword')->addError(new FormError('Current password is incorrect.'));
return $this->render('account/dashboard.html.twig', [ } else {
'form' => $form->createView(), // Allow password updates inside the same form submission instead of forcing a separate flow.
]); $user->setPassword($hasher->hashPassword($user, $newPassword));
} }
$user->setPassword($hasher->hashPassword($user, $newPassword));
} }
$em->flush();
$this->addFlash('success', 'Profile updated.'); if ($form->isValid()) {
return $this->redirectToRoute('account_dashboard'); $upload = $form->get('profileImage')->getData();
if ($upload instanceof UploadedFile) {
$uploadStorage->remove($user->getProfileImagePath());
$user->setProfileImagePath($uploadStorage->storeProfileImage($upload));
}
$em->flush();
$this->addFlash('success', 'Profile updated.');
return $this->redirectToRoute('account_profile');
}
} }
return $this->render('account/dashboard.html.twig', [ return $this->render('account/profile.html.twig', [
'form' => $form->createView(), 'form' => $form->createView(),
'profileImage' => $user->getProfileImagePath(),
]); ]);
} }
/**
* Shows account-level settings options.
*/
#[Route('/settings', name: 'account_settings', methods: ['GET'])] #[Route('/settings', name: 'account_settings', methods: ['GET'])]
public function settings(): Response public function settings(): Response
{ {

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Controller\Admin;
use App\Repository\AlbumRepository;
use App\Repository\ReviewRepository;
use App\Repository\UserRepository;
use App\Service\SpotifyMetadataRefresher;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* DashboardController shows high-level site activity to admins.
*/
#[IsGranted('ROLE_MODERATOR')]
class DashboardController extends AbstractController
{
/**
* Renders overall activity metrics for administrators.
*/
#[Route('/admin/dashboard', name: 'admin_dashboard', methods: ['GET'])]
public function dashboard(ReviewRepository $reviews, AlbumRepository $albums, UserRepository $users): Response
{
// Raw COUNT(*) queries are cheaper than hydrating entities just to compute totals.
$totalReviews = (int) $reviews->createQueryBuilder('r')
->select('COUNT(r.id)')
->getQuery()->getSingleScalarResult();
$totalAlbums = (int) $albums->createQueryBuilder('a')
->select('COUNT(a.id)')
->getQuery()->getSingleScalarResult();
$totalUsers = (int) $users->createQueryBuilder('u')
->select('COUNT(u.id)')
->getQuery()->getSingleScalarResult();
// Latest rows are pulled separately so the dashboard can show concrete activity.
$recentReviews = $reviews->findLatest(50);
$recentAlbums = $albums->createQueryBuilder('a')
->orderBy('a.createdAt', 'DESC')
->setMaxResults(50)
->getQuery()->getResult();
return $this->render('admin/site_dashboard.html.twig', [
'totalReviews' => $totalReviews,
'totalAlbums' => $totalAlbums,
'totalUsers' => $totalUsers,
'recentReviews' => $recentReviews,
'recentAlbums' => $recentAlbums,
]);
}
#[Route('/admin/dashboard/refresh-spotify', name: 'admin_dashboard_refresh_spotify', methods: ['POST'])]
public function refreshSpotify(
Request $request,
SpotifyMetadataRefresher $refresher
): Response {
$token = (string) $request->request->get('_token');
if (!$this->isCsrfTokenValid('dashboard_refresh_spotify', $token)) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
// Refresh runs synchronously; keep user feedback short so the POST remains snappy.
$updated = $refresher->refreshAllSpotifyAlbums();
if ($updated === 0) {
$this->addFlash('info', 'No Spotify albums needed refresh or none are saved.');
} else {
$this->addFlash('success', sprintf('Refreshed %d Spotify albums.', $updated));
}
return $this->redirectToRoute('admin_dashboard');
}
}

View File

@@ -0,0 +1,171 @@
<?php
namespace App\Controller\Admin;
use App\Form\SiteSettingsType;
use App\Repository\SettingRepository;
use App\Service\CatalogResetService;
use App\Service\CommandRunner;
use App\Service\RegistrationToggle;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* SettingsController lets admins adjust key integration settings.
*/
#[IsGranted('ROLE_ADMIN')]
class SettingsController extends AbstractController
{
// Metadata for demo seeding actions; drives both the UI form and CLI invocation options.
private const DEMO_COMMANDS = [
'users' => [
'command' => 'app:seed-demo-users',
'label' => 'Demo users',
'description' => 'Creates demo accounts with randomized emails.',
'fields' => [
['name' => 'count', 'label' => 'Count', 'type' => 'number', 'placeholder' => '50', 'default' => 50],
['name' => 'password', 'label' => 'Password', 'type' => 'text', 'placeholder' => 'password', 'default' => 'password'],
],
],
'albums' => [
'command' => 'app:seed-demo-albums',
'label' => 'Demo albums',
'description' => 'Creates user albums with randomized metadata.',
'fields' => [
['name' => 'count', 'label' => 'Count', 'type' => 'number', 'placeholder' => '40', 'default' => 40],
['name' => 'attach-users', 'label' => 'Attach existing users', 'type' => 'checkbox', 'default' => true],
],
],
'reviews' => [
'command' => 'app:seed-demo-reviews',
'label' => 'Demo reviews',
'description' => 'Adds sample reviews for existing albums.',
'fields' => [
['name' => 'cover-percent', 'label' => 'Album coverage %', 'type' => 'number', 'placeholder' => '50', 'default' => 50],
['name' => 'min-per-album', 'label' => 'Min per album', 'type' => 'number', 'placeholder' => '1', 'default' => 1],
['name' => 'max-per-album', 'label' => 'Max per album', 'type' => 'number', 'placeholder' => '3', 'default' => 3],
['name' => 'only-empty', 'label' => 'Only albums without reviews', 'type' => 'checkbox'],
],
],
'avatars' => [
'command' => 'app:seed-user-avatars',
'label' => 'Profile pictures',
'description' => 'Assigns generated avatars to users (skips existing).',
'fields' => [
['name' => 'overwrite', 'label' => 'Overwrite existing avatars', 'type' => 'checkbox'],
['name' => 'style', 'label' => 'DiceBear style', 'type' => 'text', 'placeholder' => 'thumbs', 'default' => 'thumbs'],
],
],
];
/**
* Displays and persists Spotify credential settings.
*/
#[Route('/admin/settings', name: 'admin_settings', methods: ['GET', 'POST'])]
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->getEnvOverride();
$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) {
// Persist only when the flag is not locked by APP_ALLOW_REGISTRATION.
$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,
'demoCommands' => self::DEMO_COMMANDS,
]);
}
#[Route('/admin/settings/reset-catalog', name: 'admin_settings_reset_catalog', methods: ['POST'])]
public function resetCatalog(Request $request, CatalogResetService $resetService): Response
{
$token = (string) $request->request->get('_token');
if (!$this->isCsrfTokenValid('admin_settings_reset_catalog', $token)) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$result = $resetService->resetCatalog();
$this->addFlash('success', sprintf(
'Reset catalog: deleted %d reviews and %d albums.',
$result['reviews'],
$result['albums']
));
return $this->redirectToRoute('admin_settings');
}
#[Route('/admin/settings/generate-demo/{type}', name: 'admin_settings_generate_demo', methods: ['POST'])]
public function generateDemo(
string $type,
Request $request,
CommandRunner $runner
): Response {
$config = self::DEMO_COMMANDS[$type] ?? null;
if ($config === null) {
throw $this->createNotFoundException('Unknown demo data type.');
}
$token = (string) $request->request->get('_token');
if (!$this->isCsrfTokenValid('admin_settings_generate_' . $type, $token)) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
try {
$options = $this->buildCommandOptions($config, $request);
$runner->runConsoleCommand($config['command'], $options);
$this->addFlash('success', sprintf('%s generation complete.', $config['label']));
} catch (\Throwable $e) {
$this->addFlash('danger', sprintf(
'%s failed: %s',
$config['label'],
$e->getMessage()
));
}
return $this->redirectToRoute('admin_settings');
}
/**
* @param array<string,mixed> $config
* @return array<string,mixed>
*/
private function buildCommandOptions(array $config, Request $request): array
{
$options = [];
foreach (($config['fields'] ?? []) as $field) {
$name = (string) $field['name'];
$type = $field['type'] ?? 'text';
$value = $request->request->get($name);
if ($type === 'checkbox') {
if ($value) {
// Symfony console options expect "--flag" style boolean toggles.
$options['--' . $name] = true;
}
continue;
}
if ($value === null || $value === '') {
continue;
}
$options['--' . $name] = $value;
}
return $options;
}
}

View File

@@ -1,37 +0,0 @@
<?php
namespace App\Controller\Admin;
use App\Form\SiteSettingsType;
use App\Repository\SettingRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[IsGranted('ROLE_ADMIN')]
class SiteSettingsController extends AbstractController
{
#[Route('/admin/settings', name: 'admin_settings', methods: ['GET', 'POST'])]
public function settings(Request $request, SettingRepository $settings): Response
{
$form = $this->createForm(SiteSettingsType::class);
$form->get('SPOTIFY_CLIENT_ID')->setData($settings->getValue('SPOTIFY_CLIENT_ID'));
$form->get('SPOTIFY_CLIENT_SECRET')->setData($settings->getValue('SPOTIFY_CLIENT_SECRET'));
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$settings->setValue('SPOTIFY_CLIENT_ID', (string) $form->get('SPOTIFY_CLIENT_ID')->getData());
$settings->setValue('SPOTIFY_CLIENT_SECRET', (string) $form->get('SPOTIFY_CLIENT_SECRET')->getData());
$this->addFlash('success', 'Settings saved.');
return $this->redirectToRoute('admin_settings');
}
return $this->render('admin/settings.html.twig', [
'form' => $form->createView(),
]);
}
}

View File

@@ -0,0 +1,124 @@
<?php
namespace App\Controller\Admin;
use App\Dto\AdminUserData;
use App\Entity\User;
use App\Form\AdminUserType;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
/**
* UserController exposes moderator/admin user management tools.
*/
#[IsGranted('ROLE_MODERATOR')]
class UserController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly UserPasswordHasherInterface $passwordHasher,
) {
}
/**
* Lists all users and handles manual account creation.
*/
#[Route('/admin/users', name: 'admin_users', methods: ['GET', 'POST'])]
public function index(Request $request, UserRepository $users): Response
{
$formData = new AdminUserData();
$form = $this->createForm(AdminUserType::class, $formData);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// Form collects only high-level metadata; everything else is defaulted here.
$plainPassword = (string) $form->get('plainPassword')->getData();
$newUser = new User();
$newUser->setEmail($formData->email);
$newUser->setDisplayName($formData->displayName);
$newUser->setPassword($this->passwordHasher->hashPassword($newUser, $plainPassword));
$this->entityManager->persist($newUser);
$this->entityManager->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()) {
// Protect against accidental lockouts by blocking self-deletes.
$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->entityManager->remove($target);
$this->entityManager->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) {
// Toggle-style UX: hitting the endpoint again demotes the moderator.
$filtered = array_values(array_filter($roles, static fn(string $role) => $role !== 'ROLE_MODERATOR'));
$target->setRoles($filtered);
$this->entityManager->flush();
$this->addFlash('success', 'Moderator privileges removed.');
} else {
$roles[] = 'ROLE_MODERATOR';
$target->setRoles(array_values(array_unique($roles)));
$this->entityManager->flush();
$this->addFlash('success', 'User promoted to moderator.');
}
return $this->redirectToRoute('admin_users');
}
}

View File

@@ -2,84 +2,121 @@
namespace App\Controller; namespace App\Controller;
use App\Service\SpotifyClient; use App\Dto\AlbumSearchCriteria;
use App\Entity\Album;
use App\Entity\Review; use App\Entity\Review;
use App\Entity\User;
use App\Form\ReviewType; use App\Form\ReviewType;
use App\Form\AlbumType;
use App\Repository\AlbumRepository;
use App\Repository\AlbumTrackRepository;
use App\Repository\ReviewRepository; use App\Repository\ReviewRepository;
use App\Service\AlbumSearchService;
use App\Service\UploadStorage;
use App\Service\SpotifyClient;
use App\Service\SpotifyGenreResolver;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* AlbumController orchestrates search, CRUD, and review entry on albums.
*/
class AlbumController extends AbstractController class AlbumController extends AbstractController
{ {
public function __construct(
private readonly UploadStorage $uploadStorage,
private readonly AlbumSearchService $albumSearch,
private readonly SpotifyGenreResolver $genreResolver,
private readonly int $searchLimit = 20
) {
}
/**
* Searches Spotify plus local albums and decorates results with review stats.
*/
#[Route('/', name: 'album_search', methods: ['GET'])] #[Route('/', name: 'album_search', methods: ['GET'])]
public function search(Request $request, SpotifyClient $spotifyClient, ReviewRepository $reviewRepository): Response public function search(Request $request): Response
{ {
$query = trim((string) $request->query->get('q', '')); $criteria = AlbumSearchCriteria::fromRequest($request, $this->searchLimit);
$albumName = trim($request->query->getString('album', '')); $result = $this->albumSearch->search($criteria);
$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', [ return $this->render('album/search.html.twig', [
'query' => $query, 'query' => $criteria->query,
'album' => $albumName, 'album' => $criteria->albumName,
'artist' => $artist, 'artist' => $criteria->artist,
'year_from' => $yearFrom ?: '', 'genre' => $criteria->genre,
'year_to' => $yearTo ?: '', 'year_from' => $criteria->yearFrom ?? '',
'albums' => $albums, 'year_to' => $criteria->yearTo ?? '',
'stats' => $stats, 'albums' => $result->albums,
'stats' => $result->stats,
'savedIds' => $result->savedIds,
'source' => $criteria->source,
'spotifyConfigured' => $this->albumSearch->isSpotifyConfigured(),
]); ]);
} }
#[Route('/albums/{id}', name: 'album_show', methods: ['GET', 'POST'])] /**
public function show(string $id, Request $request, SpotifyClient $spotifyClient, ReviewRepository $reviews, EntityManagerInterface $em): Response * Creates a user-authored album entry.
*/
#[IsGranted('ROLE_USER')]
#[Route('/albums/new', name: 'album_new', methods: ['GET', 'POST'])]
public function create(Request $request, AlbumRepository $albumRepo, EntityManagerInterface $em): Response
{ {
$album = $spotifyClient->getAlbum($id); $album = new Album();
if ($album === null) { $album->setSource('user');
throw $this->createNotFoundException('Album not found'); $form = $this->createForm(AlbumType::class, $album);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->normalizeAlbumFormData($album);
$user = $this->getUser();
if ($user instanceof User) {
$album->setCreatedBy($user);
}
$this->handleAlbumCoverUpload($album, $form);
$album->setLocalId($this->generateLocalId($albumRepo));
$em->persist($album);
$em->flush();
$this->addFlash('success', 'Album created.');
return $this->redirectToRoute('album_show', ['id' => $album->getLocalId()]);
} }
return $this->render('album/new.html.twig', [
'form' => $form->createView(),
]);
}
$existing = $reviews->findBy(['spotifyAlbumId' => $id], ['createdAt' => 'DESC']); /**
* Renders a detailed album view plus inline review form.
*/
#[Route('/albums/{id}', name: 'album_show', methods: ['GET', 'POST'])]
public function show(string $id, Request $request, SpotifyClient $spotify, ReviewRepository $reviewRepo, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, EntityManagerInterface $em): Response
{
$albumEntity = $this->findAlbum($id, $albumRepo);
$isSaved = $albumEntity !== null;
if (!$albumEntity) {
// Album has never been saved locally, so hydrate it via Spotify before rendering.
$spotifyAlbum = $spotify->getAlbumWithTracks($id);
if ($spotifyAlbum === null) {
throw $this->createNotFoundException('Album not found');
}
$albumEntity = $this->persistSpotifyAlbumPayload($spotifyAlbum, $albumRepo, $trackRepo);
$em->flush();
} else {
if ($this->syncSpotifyTracklistIfNeeded($albumEntity, $albumRepo, $trackRepo, $spotify)) {
// Track sync mutated the entity: persist before we build template arrays.
$em->flush();
}
}
$albumCard = $albumEntity->toTemplateArray();
$canManage = $this->canManageAlbum($albumEntity);
$trackRows = array_map(static fn($track) => $track->toTemplateArray(), $albumEntity->getTracks()->toArray());
$existing = $reviewRepo->findBy(['album' => $albumEntity], ['createdAt' => 'DESC']);
$count = count($existing); $count = count($existing);
$avg = 0.0; $avg = 0.0;
if ($count > 0) { if ($count > 0) {
@@ -88,11 +125,8 @@ class AlbumController extends AbstractController
$avg = round($sum / $count, 1); $avg = round($sum / $count, 1);
} }
// Pre-populate required album metadata before validation so entity constraints pass
$review = new Review(); $review = new Review();
$review->setSpotifyAlbumId($id); $review->setAlbum($albumEntity);
$review->setAlbumName($album['name'] ?? '');
$review->setAlbumArtist(implode(', ', array_map(fn($a) => $a['name'], $album['artists'] ?? [])));
$form = $this->createForm(ReviewType::class, $review); $form = $this->createForm(ReviewType::class, $review);
$form->handleRequest($request); $form->handleRequest($request);
@@ -106,14 +140,258 @@ class AlbumController extends AbstractController
} }
return $this->render('album/show.html.twig', [ return $this->render('album/show.html.twig', [
'album' => $album, 'album' => $albumCard,
'albumId' => $id, 'albumId' => $id,
'isSaved' => $isSaved,
'allowedEdit' => $canManage,
'allowedDelete' => $canManage,
'reviews' => $existing, 'reviews' => $existing,
'avg' => $avg, 'avg' => $avg,
'count' => $count, 'count' => $count,
'form' => $form->createView(), 'form' => $form->createView(),
'albumOwner' => $albumEntity->getCreatedBy(),
'albumCreatedAt' => $albumEntity->getCreatedAt(),
'tracks' => $trackRows,
]); ]);
} }
/**
* Saves a Spotify album locally for quicker access.
*/
#[IsGranted('ROLE_USER')]
#[Route('/albums/{id}/save', name: 'album_save', methods: ['POST'])]
public function save(string $id, Request $request, SpotifyClient $spotify, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, EntityManagerInterface $em): Response
{
$token = (string) $request->request->get('_token');
if (!$this->isCsrfTokenValid('save-album-' . $id, $token)) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$existing = $albumRepo->findOneBySpotifyId($id);
if (!$existing) {
$spotifyAlbum = $spotify->getAlbumWithTracks($id);
if ($spotifyAlbum === null) {
throw $this->createNotFoundException('Album not found');
}
$this->persistSpotifyAlbumPayload($spotifyAlbum, $albumRepo, $trackRepo);
$em->flush();
$this->addFlash('success', 'Album saved.');
} else {
$this->addFlash('info', 'Album already saved.');
}
return $this->redirectToRoute('album_show', ['id' => $id]);
}
/**
* Deletes a user-created album when authorized.
*/
#[IsGranted('ROLE_USER')]
#[Route('/albums/{id}/delete', name: 'album_delete', methods: ['POST'])]
public function delete(string $id, Request $request, AlbumRepository $albumRepo, EntityManagerInterface $em): Response
{
$token = (string) $request->request->get('_token');
if (!$this->isCsrfTokenValid('delete-album-' . $id, $token)) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$album = $this->findAlbum($id, $albumRepo);
if ($album) {
$this->ensureCanManageAlbum($album);
if ($album->getSource() === 'user') {
$this->uploadStorage->remove($album->getCoverImagePath());
}
$em->remove($album);
$em->flush();
$this->addFlash('success', 'Album deleted.');
} else {
$this->addFlash('info', 'Album not found.');
}
return $this->redirectToRoute('album_search');
}
/**
* Generates a unique user album identifier.
*/
private function generateLocalId(AlbumRepository $albumRepo): string
{
do {
$id = 'u_' . bin2hex(random_bytes(6));
} while ($albumRepo->findOneByLocalId($id) !== null);
return $id;
}
/**
* Normalizes a human-entered release date (YYYY[-MM[-DD]]).
*/
private function normalizeReleaseDate(?string $input): ?string
{
if ($input === null || trim($input) === '') {
return null;
}
$s = trim($input);
// YYYY
if (preg_match('/^\d{4}$/', $s)) {
return $s . '-01-01';
}
// YYYY-MM
if (preg_match('/^\d{4}-\d{2}$/', $s)) {
return $s . '-01';
}
// YYYY-MM-DD
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $s)) {
return $s;
}
// Fallback: attempt to parse
try {
// Trust PHP's parser only as a last resort (it accepts many human formats).
$dt = new \DateTimeImmutable($s);
return $dt->format('Y-m-d');
} catch (\Throwable) {
return null;
}
}
/**
* Edits a saved album when the current user may manage it.
*/
#[IsGranted('ROLE_USER')]
#[Route('/albums/{id}/edit', name: 'album_edit', methods: ['GET', 'POST'])]
public function edit(string $id, Request $request, AlbumRepository $albumRepo, EntityManagerInterface $em): Response
{
$album = $this->findAlbum($id, $albumRepo);
if (!$album) {
throw $this->createNotFoundException('Album not found');
}
$this->ensureCanManageAlbum($album);
$form = $this->createForm(AlbumType::class, $album);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->normalizeAlbumFormData($album);
$this->handleAlbumCoverUpload($album, $form);
$em->flush();
$this->addFlash('success', 'Album updated.');
return $this->redirectToRoute('album_show', ['id' => $id]);
}
return $this->render('album/edit.html.twig', [
'form' => $form->createView(),
'albumId' => $id,
]);
}
/**
* Looks up an album by either local or Spotify identifier.
*/
private function findAlbum(string $id, AlbumRepository $albumRepo): ?Album
{
$local = $albumRepo->findOneByLocalId($id);
if ($local instanceof Album) {
return $local;
}
return $albumRepo->findOneBySpotifyId($id);
}
/**
* Returns true when the authenticated user can manage the album.
*/
private function canManageAlbum(Album $album): bool
{
if ($this->isGranted('ROLE_MODERATOR')) {
return true;
}
return $album->getSource() === 'user' && $this->isAlbumOwner($album);
}
/**
* Throws if the authenticated user cannot manage the album.
*/
private function ensureCanManageAlbum(Album $album): void
{
if (!$this->canManageAlbum($album)) {
throw $this->createAccessDeniedException();
}
}
/**
* Checks whether the current user created the album.
*/
private function isAlbumOwner(Album $album): bool
{
$user = $this->getUser();
return $user instanceof User && $album->getCreatedBy()?->getId() === $user->getId();
}
private function normalizeAlbumFormData(Album $album): void
{
$album->setReleaseDate($this->normalizeReleaseDate($album->getReleaseDate()));
}
private function handleAlbumCoverUpload(Album $album, FormInterface $form): void
{
if ($album->getSource() !== 'user' || !$form->has('coverUpload')) {
return;
}
$file = $form->get('coverUpload')->getData();
if ($file instanceof UploadedFile) {
$this->uploadStorage->remove($album->getCoverImagePath());
$album->setCoverImagePath($this->uploadStorage->storeAlbumCover($file));
}
}
/**
* @param array<string,mixed> $spotifyAlbum
*/
private function persistSpotifyAlbumPayload(array $spotifyAlbum, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo): Album
{
// Bring genres along when we persist Spotify albums so templates can display them immediately.
$genresMap = $this->genreResolver->resolveGenresForAlbums([$spotifyAlbum]);
$albumId = (string) ($spotifyAlbum['id'] ?? '');
$album = $albumRepo->upsertFromSpotifyAlbum(
$spotifyAlbum,
$albumId !== '' ? ($genresMap[$albumId] ?? []) : []
);
$tracks = $spotifyAlbum['tracks']['items'] ?? [];
if (is_array($tracks) && $tracks !== []) {
$trackRepo->replaceAlbumTracks($album, $tracks);
$album->setTotalTracks(count($tracks));
}
return $album;
}
private function syncSpotifyTracklistIfNeeded(Album $album, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, SpotifyClient $spotify): bool
{
if ($album->getSource() !== 'spotify') {
return false;
}
$spotifyId = $album->getSpotifyId();
if ($spotifyId === null) {
return false;
}
$storedCount = $album->getTracks()->count();
$needsSync = $storedCount === 0;
if (!$needsSync && $album->getTotalTracks() > 0 && $storedCount !== $album->getTotalTracks()) {
// Spotify track counts do not match what we have stored; re-sync to avoid stale data.
$needsSync = true;
}
if (!$needsSync) {
return false;
}
$spotifyAlbum = $spotify->getAlbumWithTracks($spotifyId);
if ($spotifyAlbum === null) {
return false;
}
// Rehydrate genres during syncs as well, in case Spotify has updated the metadata.
$genresMap = $this->genreResolver->resolveGenresForAlbums([$spotifyAlbum]);
$albumGenres = $genresMap[$spotifyId] ?? [];
$albumRepo->upsertFromSpotifyAlbum($spotifyAlbum, $albumGenres);
$tracks = $spotifyAlbum['tracks']['items'] ?? [];
if (!is_array($tracks) || $tracks === []) {
return false;
}
$trackRepo->replaceAlbumTracks($album, $tracks);
$album->setTotalTracks(count($tracks));
return true;
}
} }

View File

@@ -4,6 +4,7 @@ namespace App\Controller;
use App\Entity\User; use App\Entity\User;
use App\Form\RegistrationFormType; use App\Form\RegistrationFormType;
use App\Service\RegistrationToggle;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -12,11 +13,26 @@ use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
/**
* RegistrationController handles signup workflows (HTML + XHR).
*/
class RegistrationController extends AbstractController class RegistrationController extends AbstractController
{ {
/**
* Processes registration submissions or serves the form modal.
*/
#[Route('/register', name: 'app_register')] #[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 // For GET (non-XHR), redirect to home and let the modal open
if ($request->isMethod('GET') && !$request->isXmlHttpRequest()) { if ($request->isMethod('GET') && !$request->isXmlHttpRequest()) {
return $this->redirectToRoute('album_search', ['auth' => 'register']); return $this->redirectToRoute('album_search', ['auth' => 'register']);

View File

@@ -4,31 +4,35 @@ namespace App\Controller;
use App\Entity\Review; use App\Entity\Review;
use App\Form\ReviewType; use App\Form\ReviewType;
use App\Repository\ReviewRepository;
use App\Service\SpotifyClient;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
/**
* ReviewController wires up CRUD routes for reviews and keeps users inside the album-focused flow.
*/
#[Route('/reviews')] #[Route('/reviews')]
class ReviewController extends AbstractController class ReviewController extends AbstractController
{ {
/**
* Maintains backwards compatibility by redirecting to the dashboard.
*/
#[Route('', name: 'review_index', methods: ['GET'])] #[Route('', name: 'review_index', methods: ['GET'])]
public function index(ReviewRepository $reviewRepository): Response public function index(): Response
{ {
$reviews = $reviewRepository->findLatest(50); return $this->redirectToRoute('account_dashboard');
return $this->render('review/index.html.twig', [
'reviews' => $reviews,
]);
} }
/**
* Nudges users back to the album flow so new reviews always start from a specific album.
*/
#[Route('/new', name: 'review_new', methods: ['GET', 'POST'])] #[Route('/new', name: 'review_new', methods: ['GET', 'POST'])]
#[IsGranted('ROLE_USER')] #[IsGranted('ROLE_USER')]
public function new(Request $request): Response public function create(Request $request): Response
{ {
$albumId = (string) $request->query->get('album_id', ''); $albumId = (string) $request->query->get('album_id', '');
if ($albumId !== '') { if ($albumId !== '') {
@@ -38,6 +42,9 @@ class ReviewController extends AbstractController
return $this->redirectToRoute('album_search'); return $this->redirectToRoute('album_search');
} }
/**
* Shows a read-only page for a single review.
*/
#[Route('/{id}', name: 'review_show', requirements: ['id' => '\\d+'], methods: ['GET'])] #[Route('/{id}', name: 'review_show', requirements: ['id' => '\\d+'], methods: ['GET'])]
public function show(Review $review): Response public function show(Review $review): Response
{ {
@@ -46,6 +53,9 @@ class ReviewController extends AbstractController
]); ]);
} }
/**
* Handles review edits by running the voter, binding the form, and saving valid updates.
*/
#[Route('/{id}/edit', name: 'review_edit', requirements: ['id' => '\\d+'], methods: ['GET', 'POST'])] #[Route('/{id}/edit', name: 'review_edit', requirements: ['id' => '\\d+'], methods: ['GET', 'POST'])]
#[IsGranted('ROLE_USER')] #[IsGranted('ROLE_USER')]
public function edit(Request $request, Review $review, EntityManagerInterface $em): Response public function edit(Request $request, Review $review, EntityManagerInterface $em): Response
@@ -67,6 +77,9 @@ class ReviewController extends AbstractController
]); ]);
} }
/**
* Deletes a review after both the CSRF token and voter checks pass.
*/
#[Route('/{id}/delete', name: 'review_delete', requirements: ['id' => '\\d+'], methods: ['POST'])] #[Route('/{id}/delete', name: 'review_delete', requirements: ['id' => '\\d+'], methods: ['POST'])]
#[IsGranted('ROLE_USER')] #[IsGranted('ROLE_USER')]
public function delete(Request $request, Review $review, EntityManagerInterface $em): RedirectResponse public function delete(Request $request, Review $review, EntityManagerInterface $em): RedirectResponse
@@ -77,10 +90,9 @@ class ReviewController extends AbstractController
$em->flush(); $em->flush();
$this->addFlash('success', 'Review deleted.'); $this->addFlash('success', 'Review deleted.');
} }
return $this->redirectToRoute('review_index'); return $this->redirectToRoute('account_dashboard');
} }
// fetchAlbumById no longer needed; album view handles retrieval and creation
} }

View File

@@ -7,12 +7,17 @@ use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
/**
* SecurityController keeps login/logout routes alive for the firewall.
*/
class SecurityController extends AbstractController class SecurityController extends AbstractController
{ {
/**
* Redirects GET requests to the SPA and lets Symfony handle POST auth.
*/
#[Route('/login', name: 'app_login')] #[Route('/login', name: 'app_login')]
public function login(Request $request, AuthenticationUtils $authenticationUtils): Response public function login(Request $request): Response
{ {
// Keep this route so the firewall can use it as check_path for POST. // Keep this route so the firewall can use it as check_path for POST.
// For GET requests, redirect to the main page and let the modal handle UI. // For GET requests, redirect to the main page and let the modal handle UI.
@@ -22,6 +27,9 @@ class SecurityController extends AbstractController
return new Response(status: 204); return new Response(status: 204);
} }
/**
* Symfony intercepts this route to log the user out.
*/
#[Route('/logout', name: 'app_logout')] #[Route('/logout', name: 'app_logout')]
public function logout(): void public function logout(): void
{ {

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

@@ -0,0 +1,31 @@
<?php
namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert;
/**
* AdminUserData carries the lightweight fields needed when admins create or edit
* users from the back office without touching the `User` entity directly.
* Used to allow user creation in the user management panel without invalidating active token.
* (This took too long to figure out)
*/
class AdminUserData
{
/**
* Email address for the managed user.
*/
#[Assert\NotBlank]
#[Assert\Email]
public string $email = '';
/**
* Optional public display name.
*/
#[Assert\Length(max: 120)]
public ?string $displayName = null;
}

View File

@@ -0,0 +1,107 @@
<?php
namespace App\Dto;
use Symfony\Component\HttpFoundation\Request;
/**
* AlbumSearchCriteria captures normalized filters for album discovery.
*/
final class AlbumSearchCriteria
{
/** Free-form query that mixes album, artist, and keyword matches. */
public readonly string $query;
/** Explicit album title filter supplied via the advanced panel. */
public readonly string $albumName;
/** Explicit artist filter supplied via the advanced panel. */
public readonly string $artist;
/** Genre substring to match within stored Spotify/user genres. */
public readonly string $genre;
/** Lower bound (inclusive) of the release year filter, if any. */
public readonly ?int $yearFrom;
/** Upper bound (inclusive) of the release year filter, if any. */
public readonly ?int $yearTo;
/** Requested source scope: `all`, `spotify`, or `user`. */
public readonly string $source;
/** Maximum number of results the search should return. */
public readonly int $limit;
public function __construct(
string $query,
string $albumName,
string $artist,
string $genre,
?int $yearFrom,
?int $yearTo,
string $source,
int $limit
) {
$this->query = $query;
$this->albumName = $albumName;
$this->artist = $artist;
$this->genre = $genre;
$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', '')),
genre: trim($request->query->getString('genre', '')),
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
);
}
/**
* Determines whether the search should include Spotify-sourced albums.
*/
public function shouldUseSpotify(): bool
{
return $this->source === 'all' || $this->source === 'spotify';
}
/**
* Determines whether the search should include user-created albums.
*/
public function shouldUseUserCatalog(): bool
{
return $this->source === 'all' || $this->source === 'user';
}
private static function normalizeYear(mixed $value): ?int
{
if ($value === null) {
return null;
}
$raw = trim((string) $value);
if ($raw === '') {
return null;
}
return preg_match('/^\d{4}$/', $raw) ? (int) $raw : null;
}
private static function normalizeSource(string $source): string
{
$source = strtolower(trim($source));
return in_array($source, ['all', 'spotify', 'user'], true) ? $source : 'all';
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Dto;
/**
* AlbumSearchResult is the value object returned by AlbumSearchService.
* It keeps the original criteria alongside the merged album payloads,
* per-album review stats, and a list of Spotify IDs stored locally.
*/
final class AlbumSearchResult
{
/**
* @param array<int,array<mixed>> $albums
* @param array<string,array{count:int,avg:float}> $stats
* @param array<int,string> $savedIds
*/
public function __construct(
/** Filters that produced this result set. */
public readonly AlbumSearchCriteria $criteria,
/** Album payloads ready for Twig rendering. */
public readonly array $albums,
/** Per-album review aggregates keyed by album ID. */
public readonly array $stats,
/** List of Spotify IDs saved locally for quick lookup. */
public readonly array $savedIds
) {
}
}

393
src/Entity/Album.php Normal file
View File

@@ -0,0 +1,393 @@
<?php
namespace App\Entity;
use App\Entity\AlbumTrack;
use App\Entity\User;
use App\Repository\AlbumRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Album aggregates Spotify or user-submitted metadata persisted in the catalog.
*/
#[ORM\Entity(repositoryClass: AlbumRepository::class)]
#[ORM\Table(name: 'albums')]
#[ORM\HasLifecycleCallbacks]
class Album
{
#[ORM\OneToMany(mappedBy: 'album', targetEntity: AlbumTrack::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['discNumber' => 'ASC', 'trackNumber' => 'ASC'])]
private Collection $tracks;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
// For Spotify-sourced albums; null for user-created
#[ORM\Column(type: 'string', length: 64, unique: true, nullable: true)]
private ?string $spotifyId = null;
// Public identifier for user-created albums (e.g., "u_abc123"); null for Spotify
#[ORM\Column(type: 'string', length: 64, unique: true, nullable: true)]
private ?string $localId = null;
// 'spotify' or 'user'
#[ORM\Column(type: 'string', length: 16)]
private string $source = 'spotify';
#[ORM\Column(type: 'string', length: 255)]
#[Assert\NotBlank]
private string $name = '';
/**
* @var list<string>
*/
#[ORM\Column(type: 'json')]
private array $artists = [];
/**
* @var list<string>
*/
#[ORM\Column(type: 'json')]
private array $genres = [];
// Stored as given by Spotify: YYYY or YYYY-MM or YYYY-MM-DD
#[ORM\Column(type: 'string', length: 20, nullable: true)]
private ?string $releaseDate = null;
#[ORM\Column(type: 'integer')]
private int $totalTracks = 0;
#[ORM\Column(type: 'string', length: 1024, nullable: true)]
private ?string $coverUrl = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $coverImagePath = null;
#[ORM\Column(type: 'string', length: 1024, nullable: true)]
private ?string $externalUrl = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?User $createdBy = null;
#[ORM\Column(type: 'datetime_immutable')]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(type: 'datetime_immutable')]
private ?\DateTimeImmutable $updatedAt = null;
public function __construct()
{
$this->tracks = new ArrayCollection();
}
/**
* Initializes timestamps right before first persistence.
*/
#[ORM\PrePersist]
public function onPrePersist(): void
{
$now = new \DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
}
/**
* Refreshes the updated timestamp prior to every update.
*/
#[ORM\PreUpdate]
public function onPreUpdate(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
/**
* Returns the database identifier.
*/
public function getId(): ?int
{
return $this->id;
}
/**
* Gets the Spotify album identifier when sourced from Spotify.
*/
public function getSpotifyId(): ?string
{
return $this->spotifyId;
}
/**
* Stores the Spotify album identifier.
*/
public function setSpotifyId(?string $spotifyId): void
{
$this->spotifyId = $spotifyId;
}
/**
* Gets the local unique identifier for user-created albums.
*/
public function getLocalId(): ?string
{
return $this->localId;
}
/**
* Assigns the local identifier for user-created albums.
*/
public function setLocalId(?string $localId): void
{
$this->localId = $localId;
}
/**
* Returns the album source flag ("spotify" or "user").
*/
public function getSource(): string
{
return $this->source;
}
/**
* Sets the album source flag ("spotify" or "user").
*/
public function setSource(string $source): void
{
$this->source = $source;
}
/**
* Returns the human readable album title.
*/
public function getName(): string
{
return $this->name;
}
/**
* Updates the human readable album title.
*/
public function setName(string $name): void
{
$this->name = $name;
}
/**
* @return list<string> Ordered performer names.
*/
public function getArtists(): array
{
return $this->artists;
}
/**
* @param list<string> $artists Ordered performer names.
*/
public function setArtists(array $artists): void
{
$this->artists = array_values($artists);
}
/**
* @return list<string>
*/
public function getGenres(): array
{
return $this->genres;
}
/**
* @param list<string> $genres
*/
public function setGenres(array $genres): void
{
$normalized = array_map(
static fn($genre) => trim((string) $genre),
$genres
);
$filtered = array_values(array_filter($normalized, static fn($genre) => $genre !== ''));
$this->genres = $filtered;
}
/**
* Returns the stored release date string.
*/
public function getReleaseDate(): ?string
{
return $this->releaseDate;
}
/**
* Sets the release date string (YYYY[-MM[-DD]]).
*/
public function setReleaseDate(?string $releaseDate): void
{
$this->releaseDate = $releaseDate;
}
/**
* Returns the total number of tracks.
*/
public function getTotalTracks(): int
{
return $this->totalTracks;
}
/**
* Sets the track count.
*/
public function setTotalTracks(int $totalTracks): void
{
$this->totalTracks = $totalTracks;
}
/**
* Returns the preferred cover art URL.
*/
public function getCoverUrl(): ?string
{
return $this->coverUrl;
}
/**
* Sets the preferred cover art URL.
*/
public function setCoverUrl(?string $coverUrl): void
{
$this->coverUrl = $coverUrl;
}
/**
* Returns an external link (defaults to Spotify).
*/
public function getExternalUrl(): ?string
{
return $this->externalUrl;
}
/**
* Sets the external reference link.
*/
public function setExternalUrl(?string $externalUrl): void
{
$this->externalUrl = $externalUrl;
}
/**
* Returns the user that created the album, when applicable.
*/
public function getCreatedBy(): ?User
{
return $this->createdBy;
}
/**
* Returns the locally stored cover image path for user albums.
*/
public function getCoverImagePath(): ?string
{
return $this->coverImagePath;
}
/**
* Updates the locally stored cover image path for user albums.
*/
public function setCoverImagePath(?string $coverImagePath): void
{
$this->coverImagePath = $coverImagePath;
}
/**
* Sets the owner responsible for the album.
*/
public function setCreatedBy(?User $user): void
{
$this->createdBy = $user;
}
/**
* Returns the creation timestamp.
*/
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
/**
* Returns the last update timestamp.
*/
public function getUpdatedAt(): ?\DateTimeImmutable
{
return $this->updatedAt;
}
/**
* Shapes the entity to the payload Twig templates expect.
*
* @return array<string,mixed>
*/
public function toTemplateArray(): array
{
$images = [];
$imageUrl = $this->coverUrl;
if ($this->source === 'user' && $this->coverImagePath) {
$imageUrl = $this->coverImagePath;
}
if ($imageUrl) {
$images = [
['url' => $imageUrl],
['url' => $imageUrl],
];
}
$artists = array_map(static fn(string $n) => ['name' => $n], $this->artists);
$external = $this->externalUrl;
if ($external === null && $this->source === 'spotify' && $this->spotifyId) {
$external = 'https://open.spotify.com/album/' . $this->spotifyId;
}
$publicId = $this->source === 'user' ? (string) $this->localId : (string) $this->spotifyId;
$genres = array_slice($this->genres, 0, 5);
return [
'id' => $publicId,
'name' => $this->name,
'images' => $images,
'artists' => $artists,
'genres' => $genres,
'release_date' => $this->releaseDate,
'total_tracks' => $this->totalTracks,
'external_urls' => [ 'spotify' => $external ],
'source' => $this->source,
];
}
/**
* @return Collection<int, AlbumTrack>
*/
public function getTracks(): Collection
{
return $this->tracks;
}
public function addTrack(AlbumTrack $track): void
{
if (!$this->tracks->contains($track)) {
$this->tracks->add($track);
$track->setAlbum($this);
}
}
public function removeTrack(AlbumTrack $track): void
{
if ($this->tracks->removeElement($track) && $track->getAlbum() === $this) {
$track->setAlbum(null);
}
}
}

140
src/Entity/AlbumTrack.php Normal file
View File

@@ -0,0 +1,140 @@
<?php
namespace App\Entity;
use App\Repository\AlbumTrackRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* AlbumTrack persists individual tracks fetched from Spotify.
*/
#[ORM\Entity(repositoryClass: AlbumTrackRepository::class)]
#[ORM\Table(name: 'album_tracks')]
#[ORM\UniqueConstraint(name: 'uniq_album_disc_track', columns: ['album_id', 'disc_number', 'track_number'])]
class AlbumTrack
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Album::class, inversedBy: 'tracks')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?Album $album = null;
#[ORM\Column(type: 'string', length: 64, nullable: true)]
private ?string $spotifyTrackId = null;
#[ORM\Column(type: 'integer')]
private int $discNumber = 1;
#[ORM\Column(type: 'integer')]
private int $trackNumber = 1;
#[ORM\Column(type: 'string', length: 512)]
private string $name = '';
#[ORM\Column(type: 'integer')]
private int $durationMs = 0;
#[ORM\Column(type: 'string', length: 1024, nullable: true)]
private ?string $previewUrl = null;
public function getId(): ?int
{
return $this->id;
}
public function getAlbum(): ?Album
{
return $this->album;
}
public function setAlbum(?Album $album): void
{
$this->album = $album;
}
public function getSpotifyTrackId(): ?string
{
return $this->spotifyTrackId;
}
public function setSpotifyTrackId(?string $spotifyTrackId): void
{
$this->spotifyTrackId = $spotifyTrackId;
}
public function getDiscNumber(): int
{
return $this->discNumber;
}
public function setDiscNumber(int $discNumber): void
{
$this->discNumber = max(1, $discNumber);
}
public function getTrackNumber(): int
{
return $this->trackNumber;
}
public function setTrackNumber(int $trackNumber): void
{
$this->trackNumber = max(1, $trackNumber);
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): void
{
$this->name = $name;
}
public function getDurationMs(): int
{
return $this->durationMs;
}
public function setDurationMs(int $durationMs): void
{
$this->durationMs = max(0, $durationMs);
}
public function getPreviewUrl(): ?string
{
return $this->previewUrl;
}
public function setPreviewUrl(?string $previewUrl): void
{
$this->previewUrl = $previewUrl;
}
/**
* Normalizes the track for template rendering.
*
* @return array{disc:int,track:int,name:string,duration_label:string,duration_seconds:int,preview_url:?string}
*/
public function toTemplateArray(): array
{
$seconds = (int) floor($this->durationMs / 1000);
$minutes = intdiv($seconds, 60);
$remainingSeconds = $seconds % 60;
return [
'disc' => $this->discNumber,
'track' => $this->trackNumber,
'name' => $this->name,
'duration_label' => sprintf('%d:%02d', $minutes, $remainingSeconds),
'duration_seconds' => $seconds,
'preview_url' => $this->previewUrl,
];
}
}

View File

@@ -3,9 +3,13 @@
namespace App\Entity; namespace App\Entity;
use App\Repository\ReviewRepository; use App\Repository\ReviewRepository;
use App\Entity\Album;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
/**
* Review captures a user-authored rating and narrative about an album.
*/
#[ORM\Entity(repositoryClass: ReviewRepository::class)] #[ORM\Entity(repositoryClass: ReviewRepository::class)]
#[ORM\Table(name: 'reviews')] #[ORM\Table(name: 'reviews')]
#[ORM\HasLifecycleCallbacks] #[ORM\HasLifecycleCallbacks]
@@ -20,17 +24,9 @@ class Review
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?User $author = null; private ?User $author = null;
#[ORM\Column(type: 'string', length: 64)] #[ORM\ManyToOne(targetEntity: Album::class)]
#[Assert\NotBlank] #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private string $spotifyAlbumId = ''; private ?Album $album = null;
#[ORM\Column(type: 'string', length: 255)]
#[Assert\NotBlank]
private string $albumName = '';
#[ORM\Column(type: 'string', length: 255)]
#[Assert\NotBlank]
private string $albumArtist = '';
#[ORM\Column(type: 'string', length: 160)] #[ORM\Column(type: 'string', length: 160)]
#[Assert\NotBlank] #[Assert\NotBlank]
@@ -52,6 +48,9 @@ class Review
#[ORM\Column(type: 'datetime_immutable')] #[ORM\Column(type: 'datetime_immutable')]
private ?\DateTimeImmutable $updatedAt = null; private ?\DateTimeImmutable $updatedAt = null;
/**
* Sets timestamps prior to the first persist.
*/
#[ORM\PrePersist] #[ORM\PrePersist]
public function onPrePersist(): void public function onPrePersist(): void
{ {
@@ -60,29 +59,118 @@ class Review
$this->updatedAt = $now; $this->updatedAt = $now;
} }
/**
* Updates the modified timestamp before every update.
*/
#[ORM\PreUpdate] #[ORM\PreUpdate]
public function onPreUpdate(): void public function onPreUpdate(): void
{ {
$this->updatedAt = new \DateTimeImmutable(); $this->updatedAt = new \DateTimeImmutable();
} }
public function getId(): ?int { return $this->id; } /**
public function getAuthor(): ?User { return $this->author; } * Returns the database identifier.
public function setAuthor(User $author): void { $this->author = $author; } */
public function getSpotifyAlbumId(): string { return $this->spotifyAlbumId; } public function getId(): ?int
public function setSpotifyAlbumId(string $spotifyAlbumId): void { $this->spotifyAlbumId = $spotifyAlbumId; } {
public function getAlbumName(): string { return $this->albumName; } return $this->id;
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; } * Returns the authoring user.
public function setTitle(string $title): void { $this->title = $title; } */
public function getContent(): string { return $this->content; } public function getAuthor(): ?User
public function setContent(string $content): void { $this->content = $content; } {
public function getRating(): int { return $this->rating; } return $this->author;
public function setRating(int $rating): void { $this->rating = $rating; } }
public function getCreatedAt(): ?\DateTimeImmutable { return $this->createdAt; }
public function getUpdatedAt(): ?\DateTimeImmutable { return $this->updatedAt; } /**
* Assigns the authoring user.
*/
public function setAuthor(User $author): void
{
$this->author = $author;
}
/**
* Returns the reviewed album.
*/
public function getAlbum(): ?Album
{
return $this->album;
}
/**
* Assigns the reviewed album.
*/
public function setAlbum(Album $album): void
{
$this->album = $album;
}
/**
* Returns the short review title.
*/
public function getTitle(): string
{
return $this->title;
}
/**
* Sets the short review title.
*/
public function setTitle(string $title): void
{
$this->title = $title;
}
/**
* Returns the long-form review content.
*/
public function getContent(): string
{
return $this->content;
}
/**
* Sets the review content body.
*/
public function setContent(string $content): void
{
$this->content = $content;
}
/**
* Returns the 1-10 numeric rating.
*/
public function getRating(): int
{
return $this->rating;
}
/**
* Assigns the 1-10 numeric rating.
*/
public function setRating(int $rating): void
{
$this->rating = $rating;
}
/**
* Returns the creation timestamp.
*/
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
/**
* Returns the last updated timestamp.
*/
public function getUpdatedAt(): ?\DateTimeImmutable
{
return $this->updatedAt;
}
} }

View File

@@ -6,6 +6,9 @@ use App\Repository\SettingRepository;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
/**
* Setting stores lightweight key/value configuration entries.
*/
#[ORM\Entity(repositoryClass: SettingRepository::class)] #[ORM\Entity(repositoryClass: SettingRepository::class)]
#[ORM\Table(name: 'settings')] #[ORM\Table(name: 'settings')]
#[ORM\UniqueConstraint(name: 'uniq_setting_name', columns: ['name'])] #[ORM\UniqueConstraint(name: 'uniq_setting_name', columns: ['name'])]
@@ -23,11 +26,45 @@ class Setting
#[ORM\Column(type: 'text', nullable: true)] #[ORM\Column(type: 'text', nullable: true)]
private ?string $value = null; private ?string $value = null;
public function getId(): ?int { return $this->id; } /**
public function getName(): string { return $this->name; } * Returns the unique identifier.
public function setName(string $name): void { $this->name = $name; } */
public function getValue(): ?string { return $this->value; } public function getId(): ?int
public function setValue(?string $value): void { $this->value = $value; } {
return $this->id;
}
/**
* Returns the configuration key.
*/
public function getName(): string
{
return $this->name;
}
/**
* Sets the configuration key.
*/
public function setName(string $name): void
{
$this->name = $name;
}
/**
* Returns the stored configuration value.
*/
public function getValue(): ?string
{
return $this->value;
}
/**
* Sets the stored configuration value.
*/
public function setValue(?string $value): void
{
$this->value = $value;
}
} }

View File

@@ -9,6 +9,9 @@ use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
/**
* User models an authenticated account that can create reviews and albums.
*/
#[ORM\Entity(repositoryClass: UserRepository::class)] #[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: 'users')] #[ORM\Table(name: 'users')]
#[UniqueEntity(fields: ['email'], message: 'This email is already registered.')] #[UniqueEntity(fields: ['email'], message: 'This email is already registered.')]
@@ -40,30 +43,49 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[Assert\Length(max: 120)] #[Assert\Length(max: 120)]
private ?string $displayName = null; private ?string $displayName = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $profileImagePath = null;
/**
* Returns the database identifier.
*/
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;
} }
/**
* Returns the normalized email address.
*/
public function getEmail(): string public function getEmail(): string
{ {
return $this->email; return $this->email;
} }
/**
* Sets and normalizes the email address.
*/
public function setEmail(string $email): void public function setEmail(string $email): void
{ {
$this->email = strtolower($email); $this->email = strtolower($email);
} }
/**
* Symfony security identifier alias for the email.
*/
public function getUserIdentifier(): string public function getUserIdentifier(): string
{ {
return $this->email; return $this->email;
} }
/**
* Returns the unique role list plus the implicit ROLE_USER.
*
* @return list<string>
*/
public function getRoles(): array public function getRoles(): array
{ {
$roles = $this->roles; $roles = $this->roles;
// guarantee every user at least has ROLE_USER
if (!in_array('ROLE_USER', $roles, true)) { if (!in_array('ROLE_USER', $roles, true)) {
$roles[] = 'ROLE_USER'; $roles[] = 'ROLE_USER';
} }
@@ -71,6 +93,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
} }
/** /**
* Replaces the granted role list.
*
* @param list<string> $roles * @param list<string> $roles
*/ */
public function setRoles(array $roles): void public function setRoles(array $roles): void
@@ -78,6 +102,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
$this->roles = array_values(array_unique($roles)); $this->roles = array_values(array_unique($roles));
} }
/**
* Adds a single role if not already present.
*/
public function addRole(string $role): void public function addRole(string $role): void
{ {
$roles = $this->getRoles(); $roles = $this->getRoles();
@@ -87,30 +114,56 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
$this->roles = $roles; $this->roles = $roles;
} }
/**
* Returns the hashed password string.
*/
public function getPassword(): string public function getPassword(): string
{ {
return $this->password; return $this->password;
} }
/**
* Stores the hashed password string.
*/
public function setPassword(string $hashedPassword): void public function setPassword(string $hashedPassword): void
{ {
$this->password = $hashedPassword; $this->password = $hashedPassword;
} }
/**
* Removes any sensitive transient data (no-op here).
*/
#[\Deprecated(reason: 'No transient credentials stored; method retained for BC.')]
public function eraseCredentials(): void public function eraseCredentials(): void
{ {
// no-op // no-op
} }
/**
* Returns the optional display name.
*/
public function getDisplayName(): ?string public function getDisplayName(): ?string
{ {
return $this->displayName; return $this->displayName;
} }
/**
* Sets the optional display name.
*/
public function setDisplayName(?string $displayName): void public function setDisplayName(?string $displayName): void
{ {
$this->displayName = $displayName; $this->displayName = $displayName;
} }
public function getProfileImagePath(): ?string
{
return $this->profileImagePath;
}
public function setProfileImagePath(?string $profileImagePath): void
{
$this->profileImagePath = $profileImagePath;
}
} }

View File

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

133
src/Form/AlbumType.php Normal file
View File

@@ -0,0 +1,133 @@
<?php
namespace App\Form;
use App\Entity\Album;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
/**
* AlbumType powers the user-facing album CRUD form, including CSV-style artist/genre helpers.
*/
class AlbumType extends AbstractType
{
/**
* Defines the album creation/editing fields.
*/
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, [
'constraints' => [new Assert\NotBlank(), new Assert\Length(max: 255)],
])
->add('artistsCsv', TextType::class, [
'mapped' => false,
'label' => 'Artists (comma-separated)',
'constraints' => [new Assert\NotBlank()],
])
->add('genresCsv', TextType::class, [
'mapped' => false,
'required' => false,
'label' => 'Genres (comma-separated)',
'help' => 'Optional: e.g. Dream pop, Shoegaze',
])
->add('releaseDate', TextType::class, [
'required' => false,
'help' => 'YYYY or YYYY-MM or YYYY-MM-DD',
])
->add('totalTracks', IntegerType::class, [
'constraints' => [new Assert\Range(min: 0, max: 500)],
])
->add('coverUpload', FileType::class, [
'mapped' => false,
'required' => false,
'label' => 'Album cover',
'help' => 'JPEG or PNG up to 5MB.',
'constraints' => [new Assert\Image(maxSize: '5M')],
])
->add('externalUrl', TextType::class, [
'required' => false,
'label' => 'External link',
]);
// Seed the CSV helper fields with existing entity values before rendering.
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void {
$album = $event->getData();
if (!$album instanceof Album) {
return;
}
$form = $event->getForm();
if ($form->has('artistsCsv')) {
$form->get('artistsCsv')->setData($this->implodeCsv($album->getArtists()));
}
if ($form->has('genresCsv')) {
$form->get('genresCsv')->setData($this->implodeCsv($album->getGenres()));
}
});
// Convert the CSV helper fields back into normalized arrays when saving.
$builder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event): void {
$album = $event->getData();
if (!$album instanceof Album) {
return;
}
$form = $event->getForm();
if ($form->has('artistsCsv')) {
$album->setArtists($this->splitCsv((string) $form->get('artistsCsv')->getData()));
}
if ($form->has('genresCsv')) {
$album->setGenres($this->splitCsv((string) $form->get('genresCsv')->getData()));
}
});
}
/**
* Points the form to the Album entity.
*/
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Album::class,
]);
}
/**
* Converts a comma-separated string into a normalized, de-duplicated list.
*
* @return list<string>
*/
private function splitCsv(string $input): array
{
if ($input === '') {
return [];
}
$normalized = str_replace(["\n", "\r", ';'], ',', $input);
$parts = array_map(static fn(string $value) => trim($value), explode(',', $normalized));
$filtered = array_values(array_filter($parts, static fn(string $value) => $value !== ''));
return array_values(array_unique($filtered));
}
/**
* Joins a list back into a user-friendly CSV string for display.
*
* @param list<string> $items
*/
private function implodeCsv(array $items): string
{
if ($items === []) {
return '';
}
return implode(', ', array_map(static fn(string $item) => trim($item), $items));
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
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
{
/**
* Builds the password change fields with validation.
*/
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('currentPassword', PasswordType::class, [
'label' => 'Current password',
])
->add('newPassword', RepeatedType::class, [
'type' => PasswordType::class,
'first_options' => ['label' => 'New password'],
'second_options' => ['label' => 'Repeat new password'],
'invalid_message' => 'The password fields must match.',
'constraints' => [new Assert\Length(min: 8)],
]);
}
/**
* Leaves default form options untouched.
*/
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([]);
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Form;
use App\Entity\User; use App\Entity\User;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType; use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType; use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
@@ -12,8 +13,14 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
/**
* ProfileFormType lets authenticated users edit their account details and password.
*/
class ProfileFormType extends AbstractType class ProfileFormType extends AbstractType
{ {
/**
* Defines profile fields including optional password updates.
*/
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void
{ {
$builder $builder
@@ -24,6 +31,12 @@ class ProfileFormType extends AbstractType
'required' => false, 'required' => false,
'constraints' => [new Assert\Length(max: 120)], 'constraints' => [new Assert\Length(max: 120)],
]) ])
->add('profileImage', FileType::class, [
'mapped' => false,
'required' => false,
'label' => 'Profile picture',
'constraints' => [new Assert\Image(maxSize: '4M')],
])
->add('currentPassword', PasswordType::class, [ ->add('currentPassword', PasswordType::class, [
'mapped' => false, 'mapped' => false,
'required' => false, 'required' => false,
@@ -40,6 +53,9 @@ class ProfileFormType extends AbstractType
]); ]);
} }
/**
* Binds the form to the User entity.
*/
public function configureOptions(OptionsResolver $resolver): void public function configureOptions(OptionsResolver $resolver): void
{ {
$resolver->setDefaults([ $resolver->setDefaults([

View File

@@ -13,8 +13,14 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
/**
* RegistrationFormType defines the public signup form and its validation rules.
*/
class RegistrationFormType extends AbstractType class RegistrationFormType extends AbstractType
{ {
/**
* Configures the registration form fields and validation rules.
*/
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void
{ {
$builder $builder
@@ -42,6 +48,9 @@ class RegistrationFormType extends AbstractType
]); ]);
} }
/**
* Binds the form to the User entity.
*/
public function configureOptions(OptionsResolver $resolver): void public function configureOptions(OptionsResolver $resolver): void
{ {
$resolver->setDefaults([ $resolver->setDefaults([

View File

@@ -12,8 +12,14 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
/**
* ReviewType captures the fields needed to author or edit a review.
*/
class ReviewType extends AbstractType class ReviewType extends AbstractType
{ {
/**
* Declares the review submission fields and validation.
*/
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void
{ {
$builder $builder
@@ -35,6 +41,9 @@ class ReviewType extends AbstractType
]); ]);
} }
/**
* Associates the form with the Review entity.
*/
public function configureOptions(OptionsResolver $resolver): void public function configureOptions(OptionsResolver $resolver): void
{ {
$resolver->setDefaults([ $resolver->setDefaults([

View File

@@ -3,12 +3,19 @@
namespace App\Form; namespace App\Form;
use Symfony\Component\Form\AbstractType; 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\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* SiteSettingsType exposes toggles for operations staff (Spotify creds, registration).
*/
class SiteSettingsType extends AbstractType class SiteSettingsType extends AbstractType
{ {
/**
* Exposes Spotify credential inputs for administrators.
*/
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void
{ {
$builder $builder
@@ -21,9 +28,17 @@ class SiteSettingsType extends AbstractType
'required' => false, 'required' => false,
'label' => 'Spotify Client Secret', 'label' => 'Spotify Client Secret',
'mapped' => false, 'mapped' => false,
])
->add('REGISTRATION_ENABLED', CheckboxType::class, [
'required' => false,
'label' => 'Allow self-service registration',
'mapped' => false,
]); ]);
} }
/**
* Leaves default options unchanged.
*/
public function configureOptions(OptionsResolver $resolver): void public function configureOptions(OptionsResolver $resolver): void
{ {
$resolver->setDefaults([]); $resolver->setDefaults([]);

View File

@@ -5,6 +5,9 @@
namespace App; namespace App;
/**
* MicroKernelTrait used over KernelTrait for smaller footprint; full HttpKernel is not needed.
*/
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel; use Symfony\Component\HttpKernel\Kernel as BaseKernel;

Some files were not shown because too many files have changed in this diff Show More