From 4c9040c92306b99cc9877533e7ad247648fba08f Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 7 Apr 2026 10:56:57 +0200 Subject: [PATCH] feat : init project Coltura (CRM/ERP) Symfony 8 + API Platform 4 + Nuxt 4 monorepo. Backend: User entity, JWT auth, fixtures. Frontend: login, dashboard, auth middleware, i18n, @malio/layer-ui. Docker: dev (ports 8083/3003/5436) + prod multi-stage. Co-Authored-By: Claude Opus 4.6 (1M context) --- .env | 21 ++ .gitignore | 38 ++++ .php-cs-fixer.dist.php | 56 +++++ CLAUDE.md | 114 ++++++++++ README.md | 26 +++ bin/console | 21 ++ commit-msg | 31 +++ composer.json | 95 ++++++++ config/bundles.php | 25 +++ config/packages/api_platform.yaml | 12 + config/packages/cache.yaml | 7 + config/packages/doctrine.yaml | 42 ++++ config/packages/doctrine_migrations.yaml | 4 + config/packages/framework.yaml | 12 + config/packages/http_discovery.yaml | 10 + config/packages/lexik_jwt_authentication.yaml | 25 +++ config/packages/monolog.yaml | 56 +++++ config/packages/nelmio_cors.yaml | 11 + config/packages/property_info.yaml | 3 + config/packages/routing.yaml | 8 + config/packages/security.yaml | 57 +++++ config/packages/validator.yaml | 9 + config/preload.php | 7 + config/routes.yaml | 4 + config/routes/api_platform.yaml | 4 + config/services.yaml | 17 ++ config/version.yaml | 2 + docker-compose.yml | 60 +++++ frontend/app.vue | 13 ++ frontend/assets/css/main.css | 1 + frontend/composables/useApi.ts | 206 ++++++++++++++++++ frontend/composables/useAppVersion.ts | 15 ++ frontend/i18n.config.ts | 5 + frontend/i18n/locales/fr.json | 48 ++++ frontend/layouts/default.vue | 111 ++++++++++ frontend/middleware/auth.global.ts | 16 ++ frontend/nuxt.config.ts | 59 +++++ frontend/package.json | 25 +++ frontend/pages/index.vue | 12 + frontend/pages/login.vue | 68 ++++++ frontend/services/auth.ts | 22 ++ frontend/services/dto/user-data.ts | 5 + frontend/stores/auth.ts | 71 ++++++ frontend/stores/ui.ts | 19 ++ frontend/tailwind.config.ts | 12 + frontend/tsconfig.json | 17 ++ frontend/utils/api.ts | 8 + infra/dev/.env.docker | 9 + infra/dev/Dockerfile | 102 +++++++++ infra/dev/nginx.conf | 54 +++++ infra/dev/php.ini | 8 + infra/dev/xdebug.ini | 9 + infra/prod/.env.prod.example | 16 ++ infra/prod/Dockerfile | 86 ++++++++ infra/prod/docker-compose.prod.yml | 42 ++++ infra/prod/nginx.conf | 44 ++++ infra/prod/php-prod.ini | 18 ++ makefile | 125 +++++++++++ phpunit.dist.xml | 44 ++++ pre-commit | 38 ++++ public/index.php | 11 + src/ApiResource/AppVersion.php | 24 ++ src/DataFixtures/AppFixtures.php | 40 ++++ src/Entity/User.php | 153 +++++++++++++ src/Kernel.php | 13 ++ src/Repository/UserRepository.php | 20 ++ src/State/AppVersionProvider.php | 25 +++ src/State/MeProvider.php | 24 ++ src/State/UserPasswordHasherProcessor.php | 34 +++ tests/bootstrap.php | 15 ++ 70 files changed, 2464 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 .php-cs-fixer.dist.php create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100755 bin/console create mode 100755 commit-msg create mode 100644 composer.json create mode 100644 config/bundles.php create mode 100644 config/packages/api_platform.yaml create mode 100644 config/packages/cache.yaml create mode 100644 config/packages/doctrine.yaml create mode 100644 config/packages/doctrine_migrations.yaml create mode 100644 config/packages/framework.yaml create mode 100644 config/packages/http_discovery.yaml create mode 100644 config/packages/lexik_jwt_authentication.yaml create mode 100644 config/packages/monolog.yaml create mode 100644 config/packages/nelmio_cors.yaml create mode 100644 config/packages/property_info.yaml create mode 100644 config/packages/routing.yaml create mode 100644 config/packages/security.yaml create mode 100644 config/packages/validator.yaml create mode 100644 config/preload.php create mode 100644 config/routes.yaml create mode 100644 config/routes/api_platform.yaml create mode 100644 config/services.yaml create mode 100644 config/version.yaml create mode 100644 docker-compose.yml create mode 100644 frontend/app.vue create mode 100644 frontend/assets/css/main.css create mode 100644 frontend/composables/useApi.ts create mode 100644 frontend/composables/useAppVersion.ts create mode 100644 frontend/i18n.config.ts create mode 100644 frontend/i18n/locales/fr.json create mode 100644 frontend/layouts/default.vue create mode 100644 frontend/middleware/auth.global.ts create mode 100644 frontend/nuxt.config.ts create mode 100644 frontend/package.json create mode 100644 frontend/pages/index.vue create mode 100644 frontend/pages/login.vue create mode 100644 frontend/services/auth.ts create mode 100644 frontend/services/dto/user-data.ts create mode 100644 frontend/stores/auth.ts create mode 100644 frontend/stores/ui.ts create mode 100644 frontend/tailwind.config.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/utils/api.ts create mode 100644 infra/dev/.env.docker create mode 100644 infra/dev/Dockerfile create mode 100644 infra/dev/nginx.conf create mode 100644 infra/dev/php.ini create mode 100644 infra/dev/xdebug.ini create mode 100644 infra/prod/.env.prod.example create mode 100644 infra/prod/Dockerfile create mode 100644 infra/prod/docker-compose.prod.yml create mode 100644 infra/prod/nginx.conf create mode 100644 infra/prod/php-prod.ini create mode 100644 makefile create mode 100644 phpunit.dist.xml create mode 100755 pre-commit create mode 100644 public/index.php create mode 100644 src/ApiResource/AppVersion.php create mode 100644 src/DataFixtures/AppFixtures.php create mode 100644 src/Entity/User.php create mode 100644 src/Kernel.php create mode 100644 src/Repository/UserRepository.php create mode 100644 src/State/AppVersionProvider.php create mode 100644 src/State/MeProvider.php create mode 100644 src/State/UserPasswordHasherProcessor.php create mode 100644 tests/bootstrap.php diff --git a/.env b/.env new file mode 100644 index 0000000..4a306a1 --- /dev/null +++ b/.env @@ -0,0 +1,21 @@ +APP_ENV=dev +APP_SECRET="change_me_in_env_local" +APP_DEBUG=1 + +DEFAULT_URI=http://localhost/ + +###> nelmio/cors-bundle ### +CORS_ALLOW_ORIGIN='^https?://(localhost|127.0.0.1)(:[0-9]+)?$' +###< nelmio/cors-bundle ### + +###> lexik/jwt-authentication-bundle ### +JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem +JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem +JWT_PASSPHRASE=change_me_in_env_local +JWT_COOKIE_SECURE=0 +JWT_TOKEN_TTL=86400 +JWT_COOKIE_TTL=86400 +###< lexik/jwt-authentication-bundle ### + + +DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..86f5863 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +###> symfony/framework-bundle ### +/.env.local +/.env.local.php +/.env.*.local +/config/secrets/dev/dev.decrypt.private.php +/public/bundles/ +/var/ +/vendor/ +###< symfony/framework-bundle ### + +###> phpunit/phpunit ### +/phpunit.xml +.phpunit.result.cache +.phpunit.cache +###< phpunit/phpunit ### + +###> php-cs-fixer ### +.php-cs-fixer.cache +###< php-cs-fixer ### + +###> lexik/jwt-authentication-bundle ### +/config/jwt/*.pem +###< lexik/jwt-authentication-bundle ### + +###> frontend ### +frontend/node_modules/ +frontend/.nuxt/ +frontend/.output/ +frontend/dist/ +###< frontend ### + +###> docker ### +infra/dev/.env.docker.local +###< docker ### + +###> logs ### +LOG/ +###< logs ### diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..0b01813 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,56 @@ +in('src') + ->notName('Kernel.php') +; + +$rules = [ + '@Symfony' => true, + '@PSR12' => true, + '@PHP84Migration' => true, + '@PER-CS' => true, + '@PhpCsFixer' => true, + 'strict_param' => true, + 'strict_comparison' => true, + 'no_useless_else' => true, + 'no_useless_return' => true, + 'binary_operator_spaces' => [ + 'operators' => [ + '=' => 'align_single_space_minimal', + '||' => 'align_single_space_minimal', + '=>' => 'align_single_space_minimal', + ], + ], + 'global_namespace_import' => [ + 'import_classes' => true, + 'import_constants' => true, + 'import_functions' => true, + ], + 'modernize_strpos' => true, + 'no_superfluous_phpdoc_tags' => true, + 'echo_tag_syntax' => true, + 'semicolon_after_instruction' => true, + 'combine_consecutive_unsets' => true, + 'ternary_to_null_coalescing' => true, + 'declare_strict_types' => true, + 'operator_linebreak' => [ + 'position' => 'beginning', + ], + 'no_unused_imports' => true, + 'single_line_throw' => false, + 'php_unit_test_class_requires_covers' => false, +]; + +$config = new Config(); + +return $config + ->setRiskyAllowed(true) + ->setRules($rules) + ->setFinder($finder) +; diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..352e31a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,114 @@ +# Coltura + +CRM/ERP. Monorepo Symfony 8 (API Platform 4) + Nuxt 4. + +## Stack + +- **Backend** : PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16 +- **Frontend** : Nuxt 4 (SSR off / SPA), Vue 3, Pinia, Tailwind CSS, @malio/layer-ui, nuxt-toast, @nuxtjs/i18n, @nuxt/icon +- **Auth** : JWT HTTP-only cookie (lexik/jwt-authentication-bundle), login a `/login_check`, cookie `BEARER` +- **Docker** : PHP-FPM + Node 24, Nginx (port 8083), PostgreSQL (port 5436) + +## Structure + +``` +src/Entity/ # Entites Doctrine (User) +src/ApiResource/ # Ressources API Platform decouples (AppVersion) +src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, UserPasswordHasherProcessor) +src/Service/ # Services metier +src/Repository/ # Repositories Doctrine +src/DataFixtures/ # Fixtures +config/ # Config Symfony (security, api_platform, lexik_jwt, nelmio_cors, doctrine) +config/jwt/ # Cles JWT (private.pem, public.pem) +migrations/ # Migrations Doctrine +infra/dev/ # Config Docker dev (Dockerfile, nginx, php.ini, xdebug) +infra/prod/ # Config Docker prod (Dockerfile multi-stage, nginx, php-prod.ini) +frontend/ # App Nuxt 4 +frontend/pages/ # Pages (index, login) +frontend/layouts/ # Layouts (default) +frontend/components/ # Composants Vue +frontend/composables/# Composables (useApi, useAppVersion) +frontend/stores/ # Stores Pinia (auth, ui) +frontend/services/ # Services API (auth) +frontend/services/dto/ # Types TypeScript +frontend/i18n/locales/ # Fichiers de traduction +``` + +## Commandes + +```bash +make start # Demarrer les containers +make stop # Arreter les containers +make restart # Redemarrer les containers +make install # Install complet (composer, migrations, fixtures, build Nuxt) +make reset # Tout supprimer et reinstaller (supprime la BDD) +make dev-nuxt # Dev server Nuxt (hot reload, port 3003) +make shell # Shell dans le container PHP +make shell-root # Shell root dans le container PHP +make cache-clear # Vider le cache Symfony +make migration-migrate # Lancer les migrations +make fixtures # Charger les fixtures +make db-reset # Reset BDD + migrations + fixtures +make test # PHPUnit +make php-cs-fixer-allow-risky # Fix code style PHP +make logs-dev # Tail logs Symfony +``` + +## Conventions + +### Commits + +Format : `() : ` (espace avant et apres `:`) + +Types autorises (minuscules) : `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test` + +Exemples : `feat : add login page`, `fix(auth) : prevent null token crash` + +### Tags & Versioning + +- La version de l'app est dans `config/version.yaml` (parametre `app.version`) +- A chaque creation de tag, **toujours** mettre a jour `config/version.yaml` avec la meme version +- Faire un commit separe de bump : `chore : bump version to v` +- Puis creer le tag et pusher : `git tag v && git push origin develop --tags` + +### Backend + +- Toujours `declare(strict_types=1)` en haut des fichiers PHP +- API Platform : utiliser ApiResource, Providers (`src/State/`), Processors — pas de controllers +- Routes API prefixees `/api` (via `config/routes/api_platform.yaml`) +- Le login (`/login_check`) est hors prefix `/api`, nginx reecrit `REQUEST_URI` vers `/login_check` +- PHP CS Fixer : regles Symfony + PSR-12 + strict types +- Roles : `ROLE_ADMIN`, `ROLE_USER` — hierarchie dans `security.yaml` +- PostgreSQL : noms de colonnes toujours en **minuscules** dans le SQL brut +- Controllers custom sous `/api/` : ajouter `priority: 1` sur `#[Route]` pour eviter le conflit avec API Platform `{id}` +- Serialization : pour embarquer une relation (pas IRI), ajouter le groupe du parent aux proprietes de l'entite cible +- Upload fichiers : utiliser `$file->getMimeType()` (pas `getClientMimeType()`) pour valider cote serveur + +### Frontend + +- TypeScript strict +- Composable `useApi()` pour tous les appels API (gere cookies, erreurs, toasts, i18n) +- Stores Pinia : `useAuthStore` (auth), `useUiStore` (ui) +- Middleware global `auth.global.ts` protege les routes +- Traductions dans `frontend/i18n/locales/` +- 4 espaces d'indentation + +### Nginx + +- `/api/*` -> Symfony (via try_files + index.php) +- `/api/login_check` -> location exact match, fastcgi direct avec REQUEST_URI reecrit en `/login_check` +- `/` -> SPA frontend (`frontend/dist/`) + +## Docker + +- Container PHP : `php-coltura-fpm` +- Container Nginx : `nginx-coltura` +- Container DB : PostgreSQL sur port **5436** (interne et externe) +- Config Docker dev : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`) +- Config Docker prod : `infra/prod/` (Dockerfile multi-stage, docker-compose.prod.yml) +- Apres modif nginx : `docker restart nginx-coltura` + +## Fixtures + +- User admin : `admin` / `admin` (ROLE_ADMIN) +- Users internes : `alice` / `alice`, `bob` / `bob` (ROLE_USER) diff --git a/README.md b/README.md new file mode 100644 index 0000000..040f84c --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# Coltura + +CRM/ERP - Symfony 8 + API Platform 4 + Nuxt 4 + +## Quick Start + +```bash +make start # Start Docker containers +make install # Install dependencies, run migrations, build frontend +``` + +Dev frontend: `make dev-nuxt` (hot reload on port 3003) + +## Ports + +| Service | Port | +|----------|------| +| API | 8083 | +| Frontend | 3003 | +| PostgreSQL | 5436 | + +## Credentials (dev) + +- admin / admin (ROLE_ADMIN) +- alice / alice (ROLE_USER) +- bob / bob (ROLE_USER) diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..d8d530e --- /dev/null +++ b/bin/console @@ -0,0 +1,21 @@ +#!/usr/bin/env php + feat(auth) : ... +REGEX='^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9._-]+\))?\ :\ .+' + +if [[ ! "$FIRST_LINE" =~ $REGEX ]]; then + echo "❌ Message de commit invalide." + echo "" + echo "➡️ Format attendu : () : " + echo "➡️ Types autorisés (minuscules uniquement) :" + echo " build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test" + echo "" + echo "✅ Exemples :" + echo " feat : add login page" + echo " fix(auth) : prevent null token crash" + echo " docs : update README" + echo "" + echo "❌ Exemple refusé :" + echo " Feat : add login page" + exit 1 +fi diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..de7bace --- /dev/null +++ b/composer.json @@ -0,0 +1,95 @@ +{ + "type": "project", + "license": "proprietary", + "minimum-stability": "stable", + "prefer-stable": true, + "require": { + "php": ">=8.4", + "ext-ctype": "*", + "ext-iconv": "*", + "api-platform/doctrine-orm": "^4.2", + "api-platform/symfony": "^4.2", + "doctrine/doctrine-bundle": "^3.2", + "doctrine/doctrine-migrations-bundle": "^4.0", + "doctrine/orm": "^3.6", + "lexik/jwt-authentication-bundle": "^3.2", + "nelmio/cors-bundle": "^2.6", + "nyholm/psr7": "^1.8", + "phpdocumentor/reflection-docblock": "^5.6|^6.0", + "phpstan/phpdoc-parser": "^2.3", + "symfony/asset": "8.0.*", + "symfony/console": "8.0.*", + "symfony/dotenv": "8.0.*", + "symfony/expression-language": "8.0.*", + "symfony/flex": "^2", + "symfony/framework-bundle": "8.0.*", + "symfony/http-client": "8.0.*", + "symfony/mime": "8.0.*", + "symfony/monolog-bundle": "^4.0", + "symfony/property-access": "8.0.*", + "symfony/property-info": "8.0.*", + "symfony/rate-limiter": "8.0.*", + "symfony/runtime": "8.0.*", + "symfony/security-bundle": "8.0.*", + "symfony/serializer": "8.0.*", + "symfony/validator": "8.0.*", + "symfony/yaml": "8.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": "*", + "symfony/polyfill-php83": "*", + "symfony/polyfill-php84": "*" + }, + "scripts": { + "auto-scripts": { + "cache:clear": "symfony-cmd", + "assets:install %PUBLIC_DIR%": "symfony-cmd" + }, + "post-install-cmd": [ + "@auto-scripts" + ], + "post-update-cmd": [ + "@auto-scripts" + ] + }, + "conflict": { + "symfony/symfony": "*" + }, + "extra": { + "symfony": { + "allow-contrib": false, + "require": "8.0.*" + } + }, + "require-dev": { + "doctrine/doctrine-fixtures-bundle": "^4.3", + "friendsofphp/php-cs-fixer": "^3.94", + "phpunit/phpunit": "^13.0" + } +} diff --git a/config/bundles.php b/config/bundles.php new file mode 100644 index 0000000..a7c2c43 --- /dev/null +++ b/config/bundles.php @@ -0,0 +1,25 @@ + ['all' => true], + SecurityBundle::class => ['all' => true], + DoctrineBundle::class => ['all' => true], + DoctrineMigrationsBundle::class => ['all' => true], + NelmioCorsBundle::class => ['all' => true], + ApiPlatformBundle::class => ['all' => true], + DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], + LexikJWTAuthenticationBundle::class => ['all' => true], + MonologBundle::class => ['all' => true], +]; diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml new file mode 100644 index 0000000..4f32b21 --- /dev/null +++ b/config/packages/api_platform.yaml @@ -0,0 +1,12 @@ +api_platform: + title: Coltura API + version: 1.0.0 + formats: + jsonld: ['application/ld+json'] + json: ['application/json'] + patch_formats: + json: ['application/merge-patch+json'] + defaults: + stateless: true + cache_headers: + vary: ['Content-Type', 'Authorization', 'Origin'] diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml new file mode 100644 index 0000000..a06ce63 --- /dev/null +++ b/config/packages/cache.yaml @@ -0,0 +1,7 @@ +framework: + cache: + #prefix_seed: your_vendor_name/app_name + + # Redis + #app: cache.adapter.redis + #default_redis_provider: redis://localhost diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml new file mode 100644 index 0000000..4998cf6 --- /dev/null +++ b/config/packages/doctrine.yaml @@ -0,0 +1,42 @@ +doctrine: + dbal: + url: '%env(resolve:DATABASE_URL)%' + profiling_collect_backtrace: '%kernel.debug%' + orm: + validate_xml_mapping: true + naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware + identity_generation_preferences: + Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity + auto_mapping: true + mappings: + App: + type: attribute + is_bundle: false + dir: '%kernel.project_dir%/src/Entity' + prefix: 'App\Entity' + alias: App + controller_resolver: + auto_mapping: false + +when@test: + doctrine: + dbal: + dbname_suffix: '_test%env(default::TEST_TOKEN)%' + +when@prod: + doctrine: + orm: + query_cache_driver: + type: pool + pool: doctrine.system_cache_pool + result_cache_driver: + type: pool + pool: doctrine.result_cache_pool + + framework: + cache: + pools: + doctrine.result_cache_pool: + adapter: cache.app + doctrine.system_cache_pool: + adapter: cache.system diff --git a/config/packages/doctrine_migrations.yaml b/config/packages/doctrine_migrations.yaml new file mode 100644 index 0000000..c4a193b --- /dev/null +++ b/config/packages/doctrine_migrations.yaml @@ -0,0 +1,4 @@ +doctrine_migrations: + migrations_paths: + 'DoctrineMigrations': '%kernel.project_dir%/migrations' + enable_profiler: false diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml new file mode 100644 index 0000000..f92c233 --- /dev/null +++ b/config/packages/framework.yaml @@ -0,0 +1,12 @@ +# see https://symfony.com/doc/current/reference/configuration/framework.html +framework: + secret: '%env(APP_SECRET)%' + + # Note that the session will be started ONLY if you read or write from it. + session: true + +when@test: + framework: + test: true + session: + storage_factory_id: session.storage.factory.mock_file diff --git a/config/packages/http_discovery.yaml b/config/packages/http_discovery.yaml new file mode 100644 index 0000000..2a789e7 --- /dev/null +++ b/config/packages/http_discovery.yaml @@ -0,0 +1,10 @@ +services: + Psr\Http\Message\RequestFactoryInterface: '@http_discovery.psr17_factory' + Psr\Http\Message\ResponseFactoryInterface: '@http_discovery.psr17_factory' + Psr\Http\Message\ServerRequestFactoryInterface: '@http_discovery.psr17_factory' + Psr\Http\Message\StreamFactoryInterface: '@http_discovery.psr17_factory' + Psr\Http\Message\UploadedFileFactoryInterface: '@http_discovery.psr17_factory' + Psr\Http\Message\UriFactoryInterface: '@http_discovery.psr17_factory' + + http_discovery.psr17_factory: + class: Http\Discovery\Psr17Factory diff --git a/config/packages/lexik_jwt_authentication.yaml b/config/packages/lexik_jwt_authentication.yaml new file mode 100644 index 0000000..ade6ecd --- /dev/null +++ b/config/packages/lexik_jwt_authentication.yaml @@ -0,0 +1,25 @@ +lexik_jwt_authentication: + secret_key: '%env(resolve:JWT_SECRET_KEY)%' + public_key: '%env(resolve:JWT_PUBLIC_KEY)%' + pass_phrase: '%env(JWT_PASSPHRASE)%' + token_ttl: '%env(int:JWT_TOKEN_TTL)%' + remove_token_from_body_when_cookies_used: true + token_extractors: + authorization_header: + enabled: false + cookie: + enabled: true + name: BEARER + query_parameter: + enabled: false + set_cookies: + BEARER: + lifetime: '%env(int:JWT_COOKIE_TTL)%' + samesite: lax + path: / + secure: '%env(bool:JWT_COOKIE_SECURE)%' + httpOnly: true + api_platform: + check_path: /api/login_check + username_path: username + password_path: password diff --git a/config/packages/monolog.yaml b/config/packages/monolog.yaml new file mode 100644 index 0000000..9a00158 --- /dev/null +++ b/config/packages/monolog.yaml @@ -0,0 +1,56 @@ +monolog: + channels: + - deprecation + +when@dev: + monolog: + handlers: + main: + type: rotating_file + path: "%kernel.logs_dir%/%kernel.environment%.log" + level: debug + max_files: 7 + channels: ["!event"] + console: + type: console + process_psr_3_messages: false + channels: ["!event", "!doctrine", "!console"] + +when@test: + monolog: + handlers: + main: + type: fingers_crossed + action_level: error + handler: nested + excluded_http_codes: [404, 405] + channels: ["!event"] + nested: + type: stream + path: "%kernel.logs_dir%/%kernel.environment%.log" + level: debug + +when@prod: + monolog: + handlers: + main: + type: fingers_crossed + action_level: error + handler: nested + excluded_http_codes: [404, 405] + channels: ["!deprecation"] + buffer_size: 50 + nested: + type: rotating_file + path: "%kernel.logs_dir%/%kernel.environment%.log" + level: debug + max_files: 30 + console: + type: console + process_psr_3_messages: false + channels: ["!event", "!doctrine"] + deprecation: + type: rotating_file + channels: [deprecation] + path: "%kernel.logs_dir%/deprecations.log" + max_files: 7 diff --git a/config/packages/nelmio_cors.yaml b/config/packages/nelmio_cors.yaml new file mode 100644 index 0000000..ff85e9e --- /dev/null +++ b/config/packages/nelmio_cors.yaml @@ -0,0 +1,11 @@ +nelmio_cors: + defaults: + origin_regex: true + allow_origin: ['%env(CORS_ALLOW_ORIGIN)%'] + allow_credentials: true + allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE'] + allow_headers: ['Content-Type', 'Authorization'] + expose_headers: ['Link'] + max_age: 3600 + paths: + '^/': null diff --git a/config/packages/property_info.yaml b/config/packages/property_info.yaml new file mode 100644 index 0000000..dd31b9d --- /dev/null +++ b/config/packages/property_info.yaml @@ -0,0 +1,3 @@ +framework: + property_info: + with_constructor_extractor: true diff --git a/config/packages/routing.yaml b/config/packages/routing.yaml new file mode 100644 index 0000000..339523a --- /dev/null +++ b/config/packages/routing.yaml @@ -0,0 +1,8 @@ +framework: + router: + default_uri: '%env(DEFAULT_URI)%' + +when@prod: + framework: + router: + strict_requirements: null diff --git a/config/packages/security.yaml b/config/packages/security.yaml new file mode 100644 index 0000000..b8f0530 --- /dev/null +++ b/config/packages/security.yaml @@ -0,0 +1,57 @@ +security: + role_hierarchy: + ROLE_ADMIN: [ROLE_USER] + + password_hashers: + Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' + + providers: + app_user_provider: + entity: + class: App\Entity\User + property: username + + firewalls: + dev: + pattern: ^/(_profiler|_wdt|assets|build)/ + security: false + login: + pattern: ^/login_check + stateless: true + provider: app_user_provider + login_throttling: + max_attempts: 5 + interval: '1 minute' + json_login: + check_path: /login_check + username_path: username + password_path: password + success_handler: lexik_jwt_authentication.handler.authentication_success + failure_handler: lexik_jwt_authentication.handler.authentication_failure + api: + pattern: ^/api + stateless: true + provider: app_user_provider + jwt: ~ + logout: + path: /api/logout + target: /login + enable_csrf: false + delete_cookies: + BEARER: + path: / + + access_control: + - { path: ^/login_check, roles: PUBLIC_ACCESS } + - { path: ^/api/docs, roles: PUBLIC_ACCESS } + - { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] } + - { path: ^/api, roles: IS_AUTHENTICATED_FULLY } + +when@test: + security: + password_hashers: + Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: + algorithm: auto + cost: 4 + time_cost: 3 + memory_cost: 10 diff --git a/config/packages/validator.yaml b/config/packages/validator.yaml new file mode 100644 index 0000000..c590a14 --- /dev/null +++ b/config/packages/validator.yaml @@ -0,0 +1,9 @@ +framework: + validation: + #auto_mapping: + # App\Entity\: [] + +when@test: + framework: + validation: + not_compromised_password: false diff --git a/config/preload.php b/config/preload.php new file mode 100644 index 0000000..7cbe578 --- /dev/null +++ b/config/preload.php @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/assets/css/main.css b/frontend/assets/css/main.css new file mode 100644 index 0000000..c727348 --- /dev/null +++ b/frontend/assets/css/main.css @@ -0,0 +1 @@ +/* Coltura - Custom styles */ diff --git a/frontend/composables/useApi.ts b/frontend/composables/useApi.ts new file mode 100644 index 0000000..212fe53 --- /dev/null +++ b/frontend/composables/useApi.ts @@ -0,0 +1,206 @@ +import type { FetchOptions } from 'ofetch' +import { $fetch, FetchError } from 'ofetch' +import { useAuthStore } from '~/stores/auth' + +export type AnyObject = Record + +export type ApiClient = { + get(url: string, query?: AnyObject, options?: ApiFetchOptions<'json'>): Promise + post(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise + put(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise + patch(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise + delete(url: string, query?: AnyObject, options?: ApiFetchOptions<'json'>): Promise +} + +export type ApiFetchOptions = + FetchOptions & { + toast?: boolean + toastOn401?: boolean + toastTitle?: string + toastErrorMessage?: string + toastSuccessMessage?: string + toastErrorKey?: string + toastSuccessKey?: string + } + +let isHandlingUnauthorized = false + +export function useApi(): ApiClient { + const config = useRuntimeConfig() + const baseURL = config.public.apiBase || '/api' + const toast = useToast() + const auth = useAuthStore() + const nuxtApp = useNuxtApp() + const i18n = nuxtApp.$i18n as + | { + t: (key: string) => string + te?: (key: string) => boolean + } + | undefined + const t = (key: string) => (i18n?.t ? String(i18n.t(key)) : key) + const te = (key: string) => (i18n?.te ? i18n.te(key) : false) + + function extractErrorMessage(error: unknown, responseData?: unknown): string { + const data = responseData ?? (error as FetchError)?.data + + if (typeof data === 'string') { + return data + } + + if (data && typeof data === 'object') { + const record = data as Record + return ( + (record['hydra:description'] as string) || + (record.detail as string) || + (record.message as string) || + (record.error as string) || + (record.title as string) || + (record['hydra:title'] as string) || + '' + ) + } + + return (error as FetchError)?.message ?? 'Erreur inconnue.' + } + + const methodErrorKeys: Record = { + GET: 'errors.http.get', + POST: 'errors.http.post', + PUT: 'errors.http.put', + PATCH: 'errors.http.patch', + DELETE: 'errors.http.delete' + } + + const client = $fetch.create({ + baseURL, + retry: 0, + credentials: 'include', + onResponse({ options, response }) { + const apiOptions = options as ApiFetchOptions<'json'> + if (apiOptions?.toast === false) { + return + } + + if (response?.status && response.status >= 400) { + return + } + + const successKey = apiOptions?.toastSuccessKey + const successMessage = + apiOptions?.toastSuccessMessage || + (successKey ? (te(successKey) ? t(successKey) : successKey) : '') + + if (successMessage) { + toast.success({ + title: 'Succes', + message: successMessage + }) + } + }, + async onResponseError({ response, error, options }) { + const apiOptions = options as ApiFetchOptions<'json'> + if (response?.status === 401) { + const requestUrl = typeof options?.url === 'string' ? options.url : '' + const isLoginCheck = requestUrl.includes('/login_check') + const isLogout = requestUrl.includes('/logout') + const shouldToast401 = apiOptions?.toastOn401 === true && apiOptions?.toast !== false + + if (shouldToast401) { + const errorKey = apiOptions?.toastErrorKey + const errorMessage = + errorKey ? (te(errorKey) ? t(errorKey) : errorKey) : '' + const extractedMessage = extractErrorMessage(error, response?._data) + const message = + apiOptions?.toastErrorMessage || + errorMessage || + extractedMessage || + 'Une erreur est survenue.' + + toast.error({ + title: apiOptions?.toastTitle ?? 'Erreur', + message + }) + } + + if (!isLoginCheck && !isLogout) { + if (!isHandlingUnauthorized) { + isHandlingUnauthorized = true + auth.clearSession() + const route = useRoute() + if (route.path !== '/login') { + await navigateTo('/login') + } + isHandlingUnauthorized = false + } + } + + return + } + + if (apiOptions?.toast === false) { + return + } + + const method = + typeof options?.method === 'string' ? options.method.toUpperCase() : 'GET' + const defaultKey = methodErrorKeys[method] + const defaultMessage = + defaultKey && te(defaultKey) ? t(defaultKey) : '' + const errorKey = apiOptions?.toastErrorKey + const errorMessage = + errorKey ? (te(errorKey) ? t(errorKey) : errorKey) : '' + const extractedMessage = extractErrorMessage(error, response?._data) + const message = + apiOptions?.toastErrorMessage || + errorMessage || + defaultMessage || + extractedMessage || + 'Une erreur est survenue.' + + toast.error({ + title: apiOptions?.toastTitle ?? 'Erreur', + message + }) + } + }) + + function request( + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', + url: string, + options: ApiFetchOptions<'json'> = {} + ) { + const needsJsonBody = method === 'POST' || method === 'PUT' + const needsMergePatch = method === 'PATCH' + const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData + + const headers = new Headers(options.headers as HeadersInit | undefined) + + if (!isFormData) { + if (needsMergePatch && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/merge-patch+json') + } else if (needsJsonBody && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json') + } + } + + return client(url, { ...options, method, headers }) + } + + return { + get(url: string, query: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) { + return request('GET', url, { ...options, query }) + }, + post(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) { + return request('POST', url, { ...options, body }) + }, + put(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) { + return request('PUT', url, { ...options, body }) + }, + patch(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) { + return request('PATCH', url, { ...options, body }) + }, + delete(url: string, query: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) { + return request('DELETE', url, { ...options, query }) + } + } +} diff --git a/frontend/composables/useAppVersion.ts b/frontend/composables/useAppVersion.ts new file mode 100644 index 0000000..d1348e3 --- /dev/null +++ b/frontend/composables/useAppVersion.ts @@ -0,0 +1,15 @@ +const version = ref('') + +export function useAppVersion() { + async function load() { + try { + const api = useApi() + const data = await api.get<{ version: string }>('/version', {}, { toast: false }) + version.value = data.version + } catch { + version.value = '?' + } + } + + return { version, load } +} diff --git a/frontend/i18n.config.ts b/frontend/i18n.config.ts new file mode 100644 index 0000000..4dda6aa --- /dev/null +++ b/frontend/i18n.config.ts @@ -0,0 +1,5 @@ +export default defineI18nConfig(() => ({ + legacy: false, + locale: 'fr', + fallbackLocale: 'fr', +})) diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json new file mode 100644 index 0000000..2803c40 --- /dev/null +++ b/frontend/i18n/locales/fr.json @@ -0,0 +1,48 @@ +{ + "common": { + "loading": "Chargement...", + "save": "Enregistrer", + "cancel": "Annuler", + "delete": "Supprimer", + "edit": "Modifier", + "create": "Creer", + "search": "Rechercher", + "confirm": "Confirmer", + "yes": "Oui", + "no": "Non", + "actions": "Actions" + }, + "nav": { + "dashboard": "Tableau de bord", + "admin": "Administration" + }, + "dashboard": { + "title": "Tableau de bord", + "welcome": "Bienvenue sur Coltura" + }, + "auth": { + "login": "Connexion", + "logout": "Deconnexion", + "username": "Nom d'utilisateur", + "password": "Mot de passe" + }, + "errors": { + "auth": { + "login": "Identifiants invalides", + "session": "Session expir\u00e9e", + "logout": "Erreur lors de la deconnexion" + }, + "http": { + "get": "Erreur lors du chargement", + "post": "Erreur lors de la creation", + "put": "Erreur lors de la mise a jour", + "patch": "Erreur lors de la modification", + "delete": "Erreur lors de la suppression" + } + }, + "success": { + "auth": { + "logout": "Deconnexion reussie" + } + } +} diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue new file mode 100644 index 0000000..bb5240d --- /dev/null +++ b/frontend/layouts/default.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/frontend/middleware/auth.global.ts b/frontend/middleware/auth.global.ts new file mode 100644 index 0000000..18799ca --- /dev/null +++ b/frontend/middleware/auth.global.ts @@ -0,0 +1,16 @@ +export default defineNuxtRouteMiddleware(async (to) => { + const auth = useAuthStore() + const isLogin = to.path === '/login' + + if (!auth.checked) { + await auth.ensureSession() + } + + if (!isLogin && !auth.isAuthenticated) { + return navigateTo('/login') + } + + if (isLogin && auth.isAuthenticated) { + return navigateTo('/') + } +}) diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts new file mode 100644 index 0000000..157b545 --- /dev/null +++ b/frontend/nuxt.config.ts @@ -0,0 +1,59 @@ +export default defineNuxtConfig({ + compatibilityDate: '2025-07-15', + devtools: {enabled: false}, + ssr: false, + css: ['~/assets/css/main.css'], + app: { + baseURL: process.env.NODE_ENV === 'production' + ? (process.env.NUXT_PUBLIC_APP_BASE || '/') + : '/' + }, + extends: ['@malio/layer-ui'], + modules: [ + '@nuxtjs/tailwindcss', + '@pinia/nuxt', + 'nuxt-toast', + '@nuxtjs/i18n', + '@nuxt/icon', + ], + runtimeConfig: { + public: { + apiBase: process.env.NUXT_PUBLIC_API_BASE + } + }, + devServer: { + port: 3003, + }, + components: [ + {path: '~/components', pathPrefix: false}, + ], + vite: { + server: { + allowedHosts: true, + proxy: { + '/api': { + target: 'http://nginx', + changeOrigin: true, + }, + }, + }, + }, + toast: { + settings: { + timeout: 2000, + closeOnClick: true, + progressBar: false + } + }, + i18n: { + strategy: 'no_prefix', + defaultLocale: 'fr', + langDir: 'locales', + locales: [ + {code: 'fr', file: 'fr.json', name: 'Français'} + ], + }, + typescript: { + strict: true + }, +}) diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..3cf341a --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "coltura-frontend", + "type": "module", + "private": true, + "scripts": { + "build": "nuxt build", + "dev": "nuxt dev", + "generate": "nuxt generate", + "preview": "nuxt preview", + "postinstall": "nuxt prepare", + "build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist" + }, + "dependencies": { + "@malio/layer-ui": "^1.2.0", + "@nuxt/icon": "^2.2.1", + "@nuxtjs/i18n": "^10.2.3", + "@nuxtjs/tailwindcss": "^6.14.0", + "@pinia/nuxt": "^0.11.3", + "nuxt": "^4.3.1", + "nuxt-toast": "^1.4.0", + "pinia": "^3.0.4", + "vue": "^3.5.29", + "vue-router": "^4.6.4" + } +} diff --git a/frontend/pages/index.vue b/frontend/pages/index.vue new file mode 100644 index 0000000..429ff9a --- /dev/null +++ b/frontend/pages/index.vue @@ -0,0 +1,12 @@ + + + diff --git a/frontend/pages/login.vue b/frontend/pages/login.vue new file mode 100644 index 0000000..2572974 --- /dev/null +++ b/frontend/pages/login.vue @@ -0,0 +1,68 @@ + + + diff --git a/frontend/services/auth.ts b/frontend/services/auth.ts new file mode 100644 index 0000000..b09cfb9 --- /dev/null +++ b/frontend/services/auth.ts @@ -0,0 +1,22 @@ +import type { UserData } from './dto/user-data' + +export function getCurrentUser() { + const api = useApi() + return api.get('/me', {}, { toastErrorKey: 'errors.auth.session' }) +} + +export function login(username: string, password: string) { + const api = useApi() + return api.post('/login_check', { username, password }, { + toastOn401: true, + toastErrorKey: 'errors.auth.login' + }) +} + +export function logout() { + const api = useApi() + return api.post('/logout', {}, { + toastErrorKey: 'errors.auth.logout', + toastSuccessKey: 'success.auth.logout' + }) +} diff --git a/frontend/services/dto/user-data.ts b/frontend/services/dto/user-data.ts new file mode 100644 index 0000000..25d5c2b --- /dev/null +++ b/frontend/services/dto/user-data.ts @@ -0,0 +1,5 @@ +export interface UserData { + id: number + username: string + roles: string[] +} diff --git a/frontend/stores/auth.ts b/frontend/stores/auth.ts new file mode 100644 index 0000000..ad2f727 --- /dev/null +++ b/frontend/stores/auth.ts @@ -0,0 +1,71 @@ +import { defineStore } from 'pinia' +import type { UserData } from '~/services/dto/user-data' +import { getCurrentUser, login, logout } from '~/services/auth' + +export const useAuthStore = defineStore('auth', { + state: () => ({ + user: null as UserData | null, + isLoading: false, + checked: false + }), + getters: { + isAuthenticated: (state) => Boolean(state.user) + }, + actions: { + clearSession() { + this.user = null + this.checked = true + this.isLoading = false + }, + async ensureSession() { + if (this.checked) { + return this.user + } + + this.checked = true + + try { + const me = await getCurrentUser() + this.user = me + return me + } catch { + this.user = null + return null + } + }, + async login(username: string, password: string) { + this.isLoading = true + + try { + await login(username, password) + const me = await getCurrentUser() + this.user = me + this.checked = true + return me + } finally { + this.isLoading = false + } + }, + async logout() { + this.isLoading = true + + try { + await logout() + } catch { + // Ignore logout errors so we can still clear local auth state. + } finally { + this.user = null + this.checked = true + this.isLoading = false + } + }, + async refreshUser() { + try { + const me = await getCurrentUser() + this.user = me + } catch { + // Silently fail — user session might have expired + } + } + } +}) diff --git a/frontend/stores/ui.ts b/frontend/stores/ui.ts new file mode 100644 index 0000000..dfce285 --- /dev/null +++ b/frontend/stores/ui.ts @@ -0,0 +1,19 @@ +import { defineStore } from 'pinia' + +export const useUiStore = defineStore('ui', { + state: () => ({ + sidebarCollapsed: false, + sidebarOpen: false, + }), + actions: { + toggleSidebar() { + this.sidebarCollapsed = !this.sidebarCollapsed + }, + openMobileSidebar() { + this.sidebarOpen = true + }, + closeMobileSidebar() { + this.sidebarOpen = false + }, + }, +}) diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..eb47687 --- /dev/null +++ b/frontend/tailwind.config.ts @@ -0,0 +1,12 @@ +import type { Config } from 'tailwindcss' + +export default >{ + content: [ + './components/**/*.{vue,js,ts}', + './layouts/**/*.vue', + './pages/**/*.vue', + './composables/**/*.{js,ts}', + './plugins/**/*.{js,ts}', + './app.vue', + ], +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..6ae5970 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "files": [], + "references": [ + { + "path": "./.nuxt/tsconfig.app.json" + }, + { + "path": "./.nuxt/tsconfig.server.json" + }, + { + "path": "./.nuxt/tsconfig.shared.json" + }, + { + "path": "./.nuxt/tsconfig.node.json" + } + ] +} diff --git a/frontend/utils/api.ts b/frontend/utils/api.ts new file mode 100644 index 0000000..36affd1 --- /dev/null +++ b/frontend/utils/api.ts @@ -0,0 +1,8 @@ +export interface HydraCollection { + 'hydra:member': T[] + 'hydra:totalItems': number +} + +export function extractHydraMembers(collection: HydraCollection): T[] { + return collection['hydra:member'] ?? [] +} diff --git a/infra/dev/.env.docker b/infra/dev/.env.docker new file mode 100644 index 0000000..a3ef80f --- /dev/null +++ b/infra/dev/.env.docker @@ -0,0 +1,9 @@ +DOCKER_APP_NAME=coltura +DOCKER_PHP_VERSION=8.4.6 +DOCKER_NODE_VERSION=24.12.0 +APP_USER=www-data +POSTGRES_DB=coltura +POSTGRES_USER=root +POSTGRES_PASSWORD=root +POSTGRES_PORT=5436 +XDEBUG_CLIENT_HOST=host.docker.internal diff --git a/infra/dev/Dockerfile b/infra/dev/Dockerfile new file mode 100644 index 0000000..b250909 --- /dev/null +++ b/infra/dev/Dockerfile @@ -0,0 +1,102 @@ +ARG DOCKER_PHP_VERSION + +FROM php:${DOCKER_PHP_VERSION}-fpm-bullseye + +ARG DOCKER_NODE_VERSION +ENV DOCKER_NODE_VERSION="${DOCKER_NODE_VERSION}" + +# Installer les dépendances et extensions PHP nécessaires +RUN apt-get update && apt-get install -y \ + libicu-dev \ + libpq-dev \ + libpng-dev \ + libzip-dev \ + libxml2-dev \ + ca-certificates \ + gnupg \ + libbz2-dev \ + libgmp-dev \ + libldap2-dev \ + libonig-dev \ + libsodium-dev \ + libxslt1-dev \ + unixodbc-dev \ + libsqlite3-dev \ + zlib1g-dev \ + libssl-dev \ + libc-client-dev \ + libkrb5-dev \ + freetds-dev \ + vim \ + tcpdump \ + dnsutils \ + wget \ + git \ + unzip \ + && docker-php-ext-install -j$(nproc) \ + intl \ + zip \ + bcmath \ + bz2 \ + calendar \ + exif \ + gd \ + gettext \ + gmp \ + ldap \ + pcntl \ + pdo_pgsql \ + soap \ + sockets \ + sysvsem \ + xsl + + +# Installation de node +RUN wget -qO- "https://nodejs.org/dist/v${DOCKER_NODE_VERSION}/node-v${DOCKER_NODE_VERSION}-linux-x64.tar.xz" | tar xJC /tmp/ && \ + cp -r /tmp/node-v${DOCKER_NODE_VERSION}-linux-x64/bin /usr/ && \ + cp -r /tmp/node-v${DOCKER_NODE_VERSION}-linux-x64/include /usr/ && \ + cp -r /tmp/node-v${DOCKER_NODE_VERSION}-linux-x64/lib /usr/ && \ + cp -r /tmp/node-v${DOCKER_NODE_VERSION}-linux-x64/share /usr/ && \ + npm install --global yarn + +# installation/activation d'extensions php +RUN pecl install xdebug +RUN docker-php-ext-enable xdebug && \ + docker-php-ext-install zip && \ + docker-php-ext-install gd && \ + docker-php-ext-install soap && \ + docker-php-ext-configure intl && \ + docker-php-ext-install intl + +RUN docker-php-ext-enable opcache + +# installation de composer +RUN rm -rf /var/cache/apk/* && rm -rf /tmp/* && \ + curl --insecure https://getcomposer.org/composer.phar -o /usr/bin/composer && chmod +x /usr/bin/composer + +# cache Composer pour www-data +RUN mkdir -p /var/www/.composer/cache/vcs \ +&& chown -R www-data:www-data /var/www/.composer +ENV COMPOSER_HOME=/var/www/.composer + +# Création de la structure du projet +RUN mkdir /var/www/html/LOG + +###> User ### +ARG CURRENT_UID +ARG CURRENT_GID +# mapping du user host avec www-data +RUN usermod -o -u ${CURRENT_UID} www-data && groupmod -o -g ${CURRENT_GID} www-data +RUN chown www-data:www-data -R /var/www/* +RUN chown www-data:www-data -R /var/www/.* +###< User ### + +RUN rm -rf \ + /var/lib/apt/lists/* \ + /tmp/* \ + /var/tmp/* + +WORKDIR /var/www/html + +EXPOSE 80 diff --git a/infra/dev/nginx.conf b/infra/dev/nginx.conf new file mode 100644 index 0000000..d5e1b00 --- /dev/null +++ b/infra/dev/nginx.conf @@ -0,0 +1,54 @@ +server { + listen 80; + server_name localhost; + + root /var/www/html/frontend/dist; + index index.html; + + client_max_body_size 55m; + + location ^~ /api/ { + root /var/www/html/public; + try_files $uri /index.php?$query_string; + } + + location ^~ /_wdt/ { + root /var/www/html/public; + try_files $uri /index.php?$query_string; + } + + location ^~ /_profiler/ { + root /var/www/html/public; + try_files $uri /index.php?$query_string; + } + + location ^~ /bundles/ { + root /var/www/html/public; + try_files $uri =404; + } + + location = /api/login_check { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php; + fastcgi_param DOCUMENT_ROOT /var/www/html/public; + fastcgi_param SCRIPT_NAME /index.php; + fastcgi_param PATH_INFO /login_check; + fastcgi_param REQUEST_URI /login_check; + fastcgi_pass php:9000; + } + + location ~ ^/index\.php(/|$) { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php; + fastcgi_param DOCUMENT_ROOT /var/www/html/public; + fastcgi_pass php:9000; + } + + location ~ \.php$ { + return 404; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/infra/dev/php.ini b/infra/dev/php.ini new file mode 100644 index 0000000..482519d --- /dev/null +++ b/infra/dev/php.ini @@ -0,0 +1,8 @@ +[Date] +; Defines the default timezone used by the date functions +; http://php.net/date.timezone +date.timezone = Europe/Paris + +[Upload] +upload_max_filesize = 50M +post_max_size = 55M diff --git a/infra/dev/xdebug.ini b/infra/dev/xdebug.ini new file mode 100644 index 0000000..e09a954 --- /dev/null +++ b/infra/dev/xdebug.ini @@ -0,0 +1,9 @@ +zend_extension = /usr/local/lib/php/extensions/no-debug-non-zts-20240924/xdebug.so +xdebug.mode=debug +xdebug.idekey=PHPSTORM +xdebug.start_with_request=yes +xdebug.discover_client_host=1 +xdebug.client_port=9003 +xdebug.log="/var/www/html/LOG/xdebug.log" +xdebug.log_level=0 +xdebug.connect_timeout_ms=2 diff --git a/infra/prod/.env.prod.example b/infra/prod/.env.prod.example new file mode 100644 index 0000000..6113dd8 --- /dev/null +++ b/infra/prod/.env.prod.example @@ -0,0 +1,16 @@ +APP_ENV=prod +APP_DEBUG=0 +APP_SECRET=CHANGE_ME_IN_PRODUCTION + +POSTGRES_DB=coltura +POSTGRES_USER=coltura +POSTGRES_PASSWORD=CHANGE_ME_IN_PRODUCTION + +APP_PORT=80 + +JWT_PASSPHRASE=CHANGE_ME_IN_PRODUCTION +JWT_COOKIE_SECURE=1 +JWT_TOKEN_TTL=86400 +JWT_COOKIE_TTL=86400 + +CORS_ALLOW_ORIGIN='^https://coltura\.malio-dev\.fr$' diff --git a/infra/prod/Dockerfile b/infra/prod/Dockerfile new file mode 100644 index 0000000..3c07389 --- /dev/null +++ b/infra/prod/Dockerfile @@ -0,0 +1,86 @@ +ARG DOCKER_PHP_VERSION=8.4.6 + +FROM php:${DOCKER_PHP_VERSION}-fpm-bullseye AS php-base + +ARG DOCKER_NODE_VERSION=24.12.0 +ENV DOCKER_NODE_VERSION="${DOCKER_NODE_VERSION}" + +# Installer les dépendances et extensions PHP nécessaires +RUN apt-get update && apt-get install -y \ + libicu-dev \ + libpq-dev \ + libpng-dev \ + libzip-dev \ + libxml2-dev \ + ca-certificates \ + gnupg \ + libbz2-dev \ + libgmp-dev \ + libldap2-dev \ + libonig-dev \ + libsodium-dev \ + libxslt1-dev \ + zlib1g-dev \ + libssl-dev \ + wget \ + git \ + unzip \ + && docker-php-ext-install -j$(nproc) \ + intl \ + zip \ + bcmath \ + bz2 \ + calendar \ + exif \ + gd \ + gettext \ + gmp \ + ldap \ + pcntl \ + pdo_pgsql \ + soap \ + sockets \ + sysvsem \ + xsl \ + && docker-php-ext-enable opcache \ + && rm -rf /var/lib/apt/lists/* /tmp/* + +# Installation de node +RUN wget -qO- "https://nodejs.org/dist/v${DOCKER_NODE_VERSION}/node-v${DOCKER_NODE_VERSION}-linux-x64.tar.xz" | tar xJC /tmp/ && \ + cp -r /tmp/node-v${DOCKER_NODE_VERSION}-linux-x64/bin /usr/ && \ + cp -r /tmp/node-v${DOCKER_NODE_VERSION}-linux-x64/include /usr/ && \ + cp -r /tmp/node-v${DOCKER_NODE_VERSION}-linux-x64/lib /usr/ && \ + cp -r /tmp/node-v${DOCKER_NODE_VERSION}-linux-x64/share /usr/ && \ + rm -rf /tmp/* + +# Installation de composer +RUN curl --insecure https://getcomposer.org/composer.phar -o /usr/bin/composer && chmod +x /usr/bin/composer + +WORKDIR /var/www/html + +# Copier les fichiers projet +COPY . /var/www/html + +# Installation des dépendances PHP (prod) +RUN composer install --no-dev --optimize-autoloader --no-interaction + +# Génération des clés JWT si absentes +RUN php bin/console lexik:jwt:generate-keypair --skip-if-exists + +# Build du frontend +RUN cd frontend && npm ci && npm run build:dist && rm -rf node_modules + +# Permissions +RUN chown -R www-data:www-data /var/www/html/var /var/www/html/frontend/dist + +# PHP prod config +COPY infra/deploy/php-prod.ini /usr/local/etc/php/php.ini + +EXPOSE 9000 + +# ── Nginx stage ── +FROM nginx:1.27-alpine AS nginx + +COPY infra/deploy/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=php-base /var/www/html/public /var/www/html/public +COPY --from=php-base /var/www/html/frontend/dist /var/www/html/frontend/dist diff --git a/infra/prod/docker-compose.prod.yml b/infra/prod/docker-compose.prod.yml new file mode 100644 index 0000000..bc7f2b8 --- /dev/null +++ b/infra/prod/docker-compose.prod.yml @@ -0,0 +1,42 @@ +services: + php: + container_name: php-coltura-fpm + build: + context: ../../ + dockerfile: infra/deploy/Dockerfile + target: php-base + environment: + APP_ENV: prod + APP_DEBUG: 0 + DATABASE_URL: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?serverVersion=16&charset=utf8" + volumes: + - uploads_data:/var/www/html/var/uploads + depends_on: + - db + restart: unless-stopped + + nginx: + container_name: nginx-coltura + build: + context: ../../ + dockerfile: infra/deploy/Dockerfile + target: nginx + depends_on: + - php + ports: + - "${APP_PORT:-80}:80" + restart: unless-stopped + + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - pg_data:/var/lib/postgresql/data + restart: unless-stopped + +volumes: + pg_data: + uploads_data: diff --git a/infra/prod/nginx.conf b/infra/prod/nginx.conf new file mode 100644 index 0000000..0ba41b0 --- /dev/null +++ b/infra/prod/nginx.conf @@ -0,0 +1,44 @@ +server { + listen 80; + server_name _; + + root /var/www/html/frontend/dist; + index index.html; + + client_max_body_size 55m; + + location ^~ /api/ { + root /var/www/html/public; + try_files $uri /index.php?$query_string; + } + + location = /api/login_check { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php; + fastcgi_param DOCUMENT_ROOT /var/www/html/public; + fastcgi_param SCRIPT_NAME /index.php; + fastcgi_param PATH_INFO /login_check; + fastcgi_param REQUEST_URI /login_check; + fastcgi_pass php:9000; + } + + location ^~ /bundles/ { + root /var/www/html/public; + try_files $uri =404; + } + + location ~ ^/index\.php(/|$) { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php; + fastcgi_param DOCUMENT_ROOT /var/www/html/public; + fastcgi_pass php:9000; + } + + location ~ \.php$ { + return 404; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/infra/prod/php-prod.ini b/infra/prod/php-prod.ini new file mode 100644 index 0000000..9823635 --- /dev/null +++ b/infra/prod/php-prod.ini @@ -0,0 +1,18 @@ +[Date] +date.timezone = Europe/Paris + +[Upload] +upload_max_filesize = 50M +post_max_size = 55M + +[opcache] +opcache.enable = 1 +opcache.memory_consumption = 256 +opcache.max_accelerated_files = 20000 +opcache.validate_timestamps = 0 +opcache.preload = /var/www/html/config/preload.php +opcache.preload_user = www-data + +[Performance] +realpath_cache_size = 4096K +realpath_cache_ttl = 600 diff --git a/makefile b/makefile new file mode 100644 index 0000000..cd29cc5 --- /dev/null +++ b/makefile @@ -0,0 +1,125 @@ +# Permet d'utiliser un .env.docker.local pour override +ENV_DEFAULT = infra/dev/.env.docker +ENV_LOCAL = infra/dev/.env.docker.local +ENV_FILE := $(if $(wildcard $(ENV_LOCAL)),$(ENV_LOCAL),$(ENV_DEFAULT)) + +# Permet d'avoir les variables du fichier .env.docker.local +include $(ENV_DEFAULT) +-include $(ENV_LOCAL) + +PHP_CONTAINER = php-$(DOCKER_APP_NAME)-fpm +SYMFONY_CONSOLE = $(EXEC_PHP) php bin/console + +DOCKER_COMPOSE = docker compose --env-file $(ENV_FILE) +DOCKER = docker + +EXEC_PHP = $(DOCKER) exec -t -u $(APP_USER) $(PHP_CONTAINER) +EXEC_PHP_CS_FIXER = $(EXEC_PHP) php vendor/bin/php-cs-fixer +EXEC_PHP_ROOT = $(DOCKER) exec -t -u root $(PHP_CONTAINER) +EXEC_PHP_INTERACTIVE = $(DOCKER) exec -it -u $(APP_USER) $(PHP_CONTAINER) +EXEC_PHP_INTERACTIVE_ROOT = $(DOCKER) exec -it -u root $(PHP_CONTAINER) +FILES = + +#======================================================================================== + +env-init: + @cp --update=none $(ENV_DEFAULT) $(ENV_LOCAL) + +# Lance le container +start: env-init + @echo "**** START CONTAINERS ****" + CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d + +# Éteint le container +stop: + $(DOCKER_COMPOSE) stop + +restart: env-init + $(DOCKER_COMPOSE) down + CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d + +install: copy-git-hook composer-install cache-clear node-use build-nuxtJS migration-migrate + +# Supprime tout est réinstalle tout (Attention ça supprime la bdd aussi) +reset: delete_built_dir remove_orphans build-without-cache start wait install + +composer-install: + $(EXEC_PHP) composer install + $(SYMFONY_CONSOLE) lexik:jwt:generate-keypair --skip-if-exists + +build-nuxtJS: + $(EXEC_PHP) sh -lc "cd frontend && npm install && npm run build:dist" + +dev-nuxt: + $(EXEC_PHP) sh -c "cd frontend && npm run dev" + +delete_built_dir: + CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d + $(DOCKER) exec -u root $(PHP_CONTAINER) rm -rf vendor/ + $(DOCKER) exec -u root $(PHP_CONTAINER) rm -rf frontend/node_modules + +remove_orphans: + $(DOCKER_COMPOSE) kill + $(DOCKER_COMPOSE) down --volumes --remove-orphans + +build-without-cache: + $(DOCKER_COMPOSE) build \ + --build-arg="DOCKER_PHP_VERSION=$(DOCKER_PHP_VERSION)" \ + --build-arg="DOCKER_NODE_VERSION=$(DOCKER_NODE_VERSION)" \ + --build-arg="CURRENT_UID=$(shell id -u)" \ + --build-arg="CURRENT_GID=$(shell id -g)" \ + --no-cache + +migration-migrate: + $(SYMFONY_CONSOLE) doctrine:migrations:migrate --no-interaction + +fixtures: + $(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load + +# Attention, supprime votre bdd local +db-reset: + $(DOCKER_COMPOSE) down -v + $(DOCKER_COMPOSE) up -d + $(MAKE) wait + $(SYMFONY_CONSOLE) doctrine:database:create --if-not-exists + $(MAKE) migration-migrate + $(MAKE) fixtures + +# Restart la bdd +db-restart: + $(DOCKER_COMPOSE) down + $(DOCKER_COMPOSE) up -d + +cache-clear: + $(SYMFONY_CONSOLE) cache:clear + +copy-git-hook: + $(EXEC_PHP) cp pre-commit .git/hooks/ + $(EXEC_PHP) cp commit-msg .git/hooks/ + $(EXEC_PHP) chmod a+x .git/hooks/pre-commit + $(EXEC_PHP) chmod a+x .git/hooks/commit-msg + +shell: + $(EXEC_PHP_INTERACTIVE) bash + +shell-root: + $(EXEC_PHP_INTERACTIVE_ROOT) bash + +# Suivi temps réel des logs dev +logs-dev: + $(EXEC_PHP_INTERACTIVE) sh -lc "tail -f var/log/dev.log" + +# Force la version node +node-use: + bash -lc 'source "$$HOME/.nvm/nvm.sh" && nvm install && nvm use' + +# Utilisé par le pre-commit pour fix les fichiers modifiés +php-cs-fixer-allow-risky: + @echo "Fixing files: $(FILES)" + $(EXEC_PHP_CS_FIXER) fix --config=.php-cs-fixer.dist.php --allow-risky=yes $(FILES) + +test: + $(EXEC_PHP) php -d memory_limit="512M" vendor/bin/phpunit $(FILES) + +wait: + sleep 10 diff --git a/phpunit.dist.xml b/phpunit.dist.xml new file mode 100644 index 0000000..22bd879 --- /dev/null +++ b/phpunit.dist.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + tests + + + + + + src + + + + Doctrine\Deprecations\Deprecation::trigger + Doctrine\Deprecations\Deprecation::delegateTriggerToBackend + trigger_deprecation + + + + + + diff --git a/pre-commit b/pre-commit new file mode 100755 index 0000000..21c1f12 --- /dev/null +++ b/pre-commit @@ -0,0 +1,38 @@ +#!/bin/sh + +echo "######### Pre-commit hook start #############" +echo "--- php-cs-fixer pre commit hook start ---" + +FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.php$') +# Vérifier s'il y a des fichiers PHP modifiés +if [ -n "$FILES" ]; then + echo "Running PHP CS Fixer on staged PHP files..." + + # Convertir la liste des fichiers en une chaîne séparée par des espaces + FILES_LIST="" + for FILE in $FILES; do + FILES_LIST="$FILES_LIST $FILE" + done + + # Exécuter la cible make pour PHP CS Fixer + make php-cs-fixer-allow-risky FILES="$FILES_LIST" + + # Ajouter les fichiers corrigés au commit + git add $FILES +else + echo "No PHP files to fix." +fi +echo "--- php-cs-fixer pre commit hook finish---" + +echo "--- phpunit pre commit hook start ---" +make test +PHPUNIT_RESULT=$? + +if [ $PHPUNIT_RESULT -ne 0 ]; then + echo "PHPUnit tests failed. Aborting commit." + exit 1 +fi +echo "--- phpunit pre commit hook finished ---" + +echo "All checks passed. Proceeding with commit." +exit 0 diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..97e228d --- /dev/null +++ b/public/index.php @@ -0,0 +1,11 @@ +setUsername('admin'); + $admin->setRoles(['ROLE_ADMIN']); + $admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin')); + $manager->persist($admin); + + $alice = new User(); + $alice->setUsername('alice'); + $alice->setRoles(['ROLE_USER']); + $alice->setPassword($this->passwordHasher->hashPassword($alice, 'alice')); + $manager->persist($alice); + + $bob = new User(); + $bob->setUsername('bob'); + $bob->setRoles(['ROLE_USER']); + $bob->setPassword($this->passwordHasher->hashPassword($bob, 'bob')); + $manager->persist($bob); + + $manager->flush(); + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php new file mode 100644 index 0000000..85054d0 --- /dev/null +++ b/src/Entity/User.php @@ -0,0 +1,153 @@ + ['me:read']], + ), + new Get( + normalizationContext: ['groups' => ['user:list']], + ), + new GetCollection( + normalizationContext: ['groups' => ['user:list']], + ), + new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class), + new Patch(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class), + new Delete(security: "is_granted('ROLE_ADMIN')"), + ], + denormalizationContext: ['groups' => ['user:write']], +)] +#[ORM\Entity(repositoryClass: UserRepository::class)] +#[ORM\Table(name: '`user`')] +class User implements UserInterface, PasswordAuthenticatedUserInterface +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['me:read', 'user:list'])] + private ?int $id = null; + + #[ORM\Column(length: 180, unique: true)] + #[Groups(['me:read', 'user:list', 'user:write'])] + private ?string $username = null; + + /** @var list */ + #[ORM\Column] + #[Groups(['me:read', 'user:list', 'user:write'])] + private array $roles = []; + + #[ORM\Column] + private ?string $password = null; + + #[Groups(['user:write'])] + private ?string $plainPassword = null; + + #[ORM\Column(type: 'datetime_immutable')] + private ?DateTimeImmutable $createdAt = null; + + public function __construct() + { + $this->createdAt = new DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getUsername(): ?string + { + return $this->username; + } + + public function setUsername(string $username): static + { + $this->username = $username; + + return $this; + } + + public function getUserIdentifier(): string + { + return (string) $this->username; + } + + /** @return list */ + public function getRoles(): array + { + $roles = $this->roles; + $roles[] = 'ROLE_USER'; + + return array_values(array_unique($roles)); + } + + /** @param list $roles */ + public function setRoles(array $roles): static + { + $this->roles = $roles; + + return $this; + } + + public function getPassword(): ?string + { + return $this->password; + } + + public function setPassword(string $password): static + { + $this->password = $password; + + return $this; + } + + public function getCreatedAt(): ?DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(DateTimeImmutable $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getPlainPassword(): ?string + { + return $this->plainPassword; + } + + public function setPlainPassword(?string $plainPassword): static + { + $this->plainPassword = $plainPassword; + + return $this; + } + + public function eraseCredentials(): void + { + $this->plainPassword = null; + } +} diff --git a/src/Kernel.php b/src/Kernel.php new file mode 100644 index 0000000..ad0fb48 --- /dev/null +++ b/src/Kernel.php @@ -0,0 +1,13 @@ + + */ +class UserRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, User::class); + } +} diff --git a/src/State/AppVersionProvider.php b/src/State/AppVersionProvider.php new file mode 100644 index 0000000..1d573db --- /dev/null +++ b/src/State/AppVersionProvider.php @@ -0,0 +1,25 @@ + + */ +class AppVersionProvider implements ProviderInterface +{ + public function __construct( + #[Autowire(param: 'app.version')] + private readonly string $appVersion, + ) {} + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object + { + return new \App\ApiResource\AppVersion($this->appVersion); + } +} diff --git a/src/State/MeProvider.php b/src/State/MeProvider.php new file mode 100644 index 0000000..972d33a --- /dev/null +++ b/src/State/MeProvider.php @@ -0,0 +1,24 @@ + + */ +class MeProvider implements ProviderInterface +{ + public function __construct( + private readonly Security $security, + ) {} + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?object + { + return $this->security->getUser(); + } +} diff --git a/src/State/UserPasswordHasherProcessor.php b/src/State/UserPasswordHasherProcessor.php new file mode 100644 index 0000000..d73294c --- /dev/null +++ b/src/State/UserPasswordHasherProcessor.php @@ -0,0 +1,34 @@ + + */ +class UserPasswordHasherProcessor implements ProcessorInterface +{ + public function __construct( + /** @var ProcessorInterface */ + private readonly ProcessorInterface $persistProcessor, + private readonly UserPasswordHasherInterface $passwordHasher, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if ($data instanceof User && null !== $data->getPlainPassword()) { + $data->setPassword( + $this->passwordHasher->hashPassword($data, $data->getPlainPassword()) + ); + $data->eraseCredentials(); + } + + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..c4f2193 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,15 @@ +bootEnv(dirname(__DIR__).'/.env'); +} + +if ($_SERVER['APP_DEBUG']) { + umask(0o000); +}