This commit is contained in:
Jérémy Guillot
2024-06-03 17:36:22 +02:00
commit bddcdd6823
73 changed files with 13150 additions and 0 deletions

32
.dockerignore Normal file
View File

@@ -0,0 +1,32 @@
**/*.log
**/*.md
**/*.php~
**/*.dist.php
**/*.dist
**/*.cache
**/._*
**/.dockerignore
**/.DS_Store
**/.git/
**/.gitattributes
**/.gitignore
**/.gitmodules
**/compose.*.yaml
**/compose.*.yml
**/compose.yaml
**/compose.yml
**/docker-compose.*.yaml
**/docker-compose.*.yml
**/docker-compose.yaml
**/docker-compose.yml
**/Dockerfile
**/Thumbs.db
public/bundles/
tests/
var/
vendor/
.editorconfig
.env.*.local
.env.local
.env.local.php
.env.test

58
.editorconfig Normal file
View File

@@ -0,0 +1,58 @@
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# editorconfig.org
root = true
[*]
# Change these settings to your own preference
indent_style = space
indent_size = 4
# We recommend you to keep these unchanged
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{js,html,ts,tsx}]
indent_size = 2
[*.json]
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[*.sh]
indent_style = tab
[*.xml{,.dist}]
indent_style = space
indent_size = 4
[*.{yaml,yml}]
trim_trailing_whitespace = false
[.github/workflows/*.yml]
indent_size = 2
[.gitmodules]
indent_style = tab
[.php_cs{,.dist}]
indent_style = space
indent_size = 4
[composer.json]
indent_size = 4
[{,docker-}compose{,.*}.{yaml,yml}]
indent_style = space
indent_size = 2
[{,*.*}Dockerfile]
indent_style = tab
[{,*.*}Caddyfile]
indent_style = tab

29
.env Normal file
View File

@@ -0,0 +1,29 @@
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=b55256ade5b349fd83c703f098b219fe
###< symfony/framework-bundle ###
POSTGRES_DB=app
POSTGRES_USER=user
POSTGRES_PASSWORD=password
POSTGRES_VERSION=16
POSTGRES_HOST=database
POSTGRES_PORT=5432
###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
#
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
# DATABASE_URL="postgresql://user:password@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
#DATABASE_URL="postgresql://%env(resolve:POSTGRES_USER)%:%env(resolve:POSTGRES_PASSWORD)%@%env(resolve:POSTGRES_HOST)%/%env(resolve:POSTGRES_DB)%?serverVersion=%env(resolve:POSTGRES_VERSION)%&charset=utf8"
###< doctrine/doctrine-bundle ###
###> nelmio/cors-bundle ###
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
###< nelmio/cors-bundle ###

24
.env.example Normal file
View File

@@ -0,0 +1,24 @@
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=b55256ade5b349fd83c703f098b219fe
###< symfony/framework-bundle ###
POSTGRES_DB=app
POSTGRES_USER=user
POSTGRES_PASSWORD=password
POSTGRES_VERSION=16
#POSTGRES_HOST=database
###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
#
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
# DATABASE_URL="postgresql://user:password@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
DATABASE_URL="postgresql://%env(resolve:POSTGRES_USER)%:%env(resolve:POSTGRES_PASSWORD)%@%env(resolve:POSTGRES_HOST)%/%env(resolve:POSTGRES_DB)%?serverVersion=%env(resolve:POSTGRES_VERSION)%&charset=utf8"
###< doctrine/doctrine-bundle ###

9
.env.test Normal file
View File

@@ -0,0 +1,9 @@
# define your env variables for the test env here
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'
SYMFONY_DEPRECATIONS_HELPER=999999
PANTHER_APP_ENV=panther
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
POSTGRES_DB=app
POSTGRE_VERSION=16

17
.gitattributes vendored Normal file
View File

@@ -0,0 +1,17 @@
* text=auto eol=lf
*.conf text eol=lf
*.html text eol=lf
*.ini text eol=lf
*.js text eol=lf
*.json text eol=lf
*.md text eol=lf
*.php text eol=lf
*.sh text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
bin/console text eol=lf
composer.lock text eol=lf merge=ours
*.ico binary
*.png binary

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
###> symfony/framework-bundle ###
/.env.local
/.env.local.php
/.env.*.local
/config/secrets/prod/prod.decrypt.private.php
/public/bundles/
/var/*
/vendor/
.idea/
###< symfony/framework-bundle ###
###> friendsofphp/php-cs-fixer ###
/.php-cs-fixer.php
/.php-cs-fixer.cache
###< friendsofphp/php-cs-fixer ###
###> symfony/phpunit-bridge ###
.phpunit.result.cache
/phpunit.xml
###< symfony/phpunit-bridge ###
###> phpunit/phpunit ###
/phpunit.xml
.phpunit.result.cache
###< phpunit/phpunit ###

13
.php-cs-fixer.dist.php Normal file
View File

@@ -0,0 +1,13 @@
<?php
$finder = (new PhpCsFixer\Finder())
->in(__DIR__)
->exclude('var')
;
return (new PhpCsFixer\Config())
->setRules([
'@Symfony' => true,
])
->setFinder($finder)
;

94
Dockerfile Normal file
View File

@@ -0,0 +1,94 @@
#syntax=docker/dockerfile:1.4
# Versions
FROM dunglas/frankenphp:1-alpine AS frankenphp_upstream
# The different stages of this Dockerfile are meant to be built into separate images
# https://docs.docker.com/develop/develop-images/multistage-build/#stop-at-a-specific-build-stage
# https://docs.docker.com/compose/compose-file/#target
# Base FrankenPHP image
FROM frankenphp_upstream AS frankenphp_base
WORKDIR /app
# persistent / runtime deps
# hadolint ignore=DL3018
RUN apk add --no-cache \
acl \
file \
gettext \
git \
;
RUN set -eux; \
install-php-extensions \
@composer \
apcu \
intl \
opcache \
zip \
;
# https://getcomposer.org/doc/03-cli.md#composer-allow-superuser
ENV COMPOSER_ALLOW_SUPERUSER=1
###> recipes ###
###> doctrine/doctrine-bundle ###
RUN install-php-extensions pdo_pgsql
###< doctrine/doctrine-bundle ###
###< recipes ###
COPY --link frankenphp/conf.d/app.ini $PHP_INI_DIR/conf.d/
COPY --link --chmod=755 frankenphp/docker-entrypoint.sh /usr/local/bin/docker-entrypoint
COPY --link frankenphp/Caddyfile /etc/caddy/Caddyfile
ENTRYPOINT ["docker-entrypoint"]
HEALTHCHECK --start-period=60s CMD curl -f http://localhost:2019/metrics || exit 1
CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile" ]
# Dev FrankenPHP image
FROM frankenphp_base AS frankenphp_dev
ENV APP_ENV=dev XDEBUG_MODE=off
VOLUME /app/var/
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
RUN set -eux; \
install-php-extensions \
xdebug \
;
COPY --link frankenphp/conf.d/app.dev.ini $PHP_INI_DIR/conf.d/
CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile", "--watch" ]
# Prod FrankenPHP image
FROM frankenphp_base AS frankenphp_prod
ENV APP_ENV=prod
ENV FRANKENPHP_CONFIG="import worker.Caddyfile"
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY --link frankenphp/conf.d/app.prod.ini $PHP_INI_DIR/conf.d/
COPY --link frankenphp/worker.Caddyfile /etc/caddy/worker.Caddyfile
# prevent the reinstallation of vendors at every changes in the source code
COPY --link composer.* symfony.* ./
RUN set -eux; \
composer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scripts --no-progress
# copy sources
COPY --link . ./
RUN rm -Rf frankenphp/
RUN set -eux; \
mkdir -p var/cache var/log; \
composer dump-autoload --classmap-authoritative --no-dev; \
composer dump-env prod; \
composer run-script --no-dev post-install-cmd; \
chmod +x bin/console; sync;

125
Makefile Normal file
View File

@@ -0,0 +1,125 @@
# Executables (local)
DOCKER_COMP = docker compose
# Docker containers
PHP_CONT = $(DOCKER_COMP) exec php
# Executables
PHP = $(PHP_CONT) php
COMPOSER = $(PHP_CONT) composer
SYMFONY = $(PHP) bin/console
SF_MEMORY = $(PHP) -d memory_limit=256M bin/console
# Misc
.DEFAULT_GOAL = help
.PHONY : help build start install down stop logs sh composer vendor sf cc test phpmd phpcs quality fix-permissions controller entity migration migration-diff migration-migrate form crud fixtures command auth subscriber state-processor state-provider
## —— 🎵 🐳 The Symfony Docker Makefile 🐳 🎵 ——————————————————————————————————
help: ## Outputs this help screen
@grep -E '(^[a-zA-Z0-9\./_-]+:.*?##.*$$)|(^##)' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}{printf "\033[32m%-30s\033[0m %s\n", $$1, $$2}' | sed -e 's/\[32m##/[33m/'
## —— Docker 🐳 ————————————————————————————————————————————————————————————————
build: ## Builds the Docker images
@$(DOCKER_COMP) build --pull --no-cache
start: ## Start the docker hub in detached mode (no logs)
@$(DOCKER_COMP) up --pull always -d --wait
install: build start vendor## Build and start the containers
down: ## Stop and remove the docker hub
@$(DOCKER_COMP) down --remove-orphans
stop: ## Stop the docker hub
@$(DOCKER_COMP) stop
logs: ## Show live logs
@$(DOCKER_COMP) logs --tail=0 --follow
sh: ## Connect to the FrankenPHP container
@$(PHP_CONT) sh
test: ## Start tests with phpunit, pass the parameter "c=" to add options to phpunit, example: make test c="--group e2e --stop-on-failure"
@$(eval c ?=)
@$(DOCKER_COMP) exec -e APP_ENV=test php bin/phpunit $(c)
phpmd: ## Start PHP Mess Detector
@if ! $(DOCKER_COMP) exec php vendor/bin/phpmd src/ text phpmd.xml -v; then \
echo "Done"; \
fi
phpcs: ## Start PHP Code Sniffer
@$(DOCKER_COMP) exec php vendor/bin/php-cs-fixer fix src/ --rules=@PSR12
quality: phpmd phpcs ## Start PHP Mess Detector and PHP Code Sniffer
fix-permissions: ## Fix permissions
@$(DOCKER_COMP) run --rm --user="root" php chown -R $$(id -u):$$(id -g) .
tail-logs: ## Tail the logs
@$(DOCKER_COMP) logs -f
## —— Composer 🧙 ——————————————————————————————————————————————————————————————
composer: ## Run composer, pass the parameter "c=" to run a given command, example: make composer c='req symfony/orm-pack'
@$(eval c ?=)
@$(COMPOSER) $(c)
vendor: ## Install vendors according to the current composer.lock file
vendor: c=install --prefer-dist --no-progress --no-scripts --no-interaction
vendor: composer
## —— Symfony 🎵 ———————————————————————————————————————————————————————————————
sf: ## List all Symfony commands or pass the parameter "c=" to run a given command, example: make sf c=about
@$(eval c ?=)
@$(SYMFONY) $(c)
cc: c=c:c ## Clear the cache
cc: sf
controller: ## Create a new controller
@$(SYMFONY) make:controller
entity: ## Create a new entity
@$(SYMFONY) make:entity
migration: ## Create a new migration
@$(SYMFONY) make:migration
migration-diff: ## Create a new migration diff
@$(SYMFONY) make:migration --no-interaction --allow-empty-diff
migration-migrate: ## Migrate the database
@$(SYMFONY) doctrine:migrations:migrate --no-interaction
migration-revert: ## Revert the last migration
@$(SYMFONY) doctrine:migrations:migrate prev --no-interaction
form: ## Create a new form
@$(SYMFONY) make:form
crud: ## Create CRUD for an existing entity
@$(SYMFONY) make:crud
factory: ## Create a new factory
@$(SYMFONY) make:factory
fixtures: ## Make fixtures
@$(SYMFONY) make:fixtures
fixtures-load: ## Load fixtures
@$(SF_MEMORY) doctrine:fixtures:load --no-interaction
command: ## Create a new command
@$(SYMFONY) make:command
auth: ## Create a new authenticator
@$(SYMFONY) make:auth
subscriber: ## Create a new event subscriber
@$(SYMFONY) make:subscriber
state-processor: ## Create a new state processor
@$(SYMFONY) make:state-processor
state-provider: ## Create a new state provider
@$(SYMFONY) make:state-provider

53
README.md Normal file
View File

@@ -0,0 +1,53 @@
# Projet TKM Symfony
Ce projet est un fork du template Symfony disponible à [dunglas/symfony-docker](https://github.com/dunglas/symfony-docker), adapté aux besoins spécifiques de TKM. Il intègre Symfony 7.0.2 et est configuré pour fonctionner avec Docker, offrant une mise en place rapide et efficace pour le développement.
## Prérequis
Avant de commencer, assurez-vous que les outils suivants sont installés sur votre machine :
- Docker
- Makefile
## Installation
Pour mettre en place le projet, suivez ces étapes :
1. Clonez le dépôt du projet :
```git clone git@bitbucket.org:tkm_rd/tkm-symfony.git```
2. Copiez le fichier `.env.example` en `.env` :
```cp .env.example .env```
3. Modifiez les credentials dans le fichier `.env` selon vos besoins.
4. Lancez l'installation des dépendances et la configuration du projet :
```make install```
Si vous rencontrez des problèmes de droits sur le projet, exécutez :
```make fix-permissions```
## Utilisation
Pour démarrer le projet, utilisez :
```make start```
Pour arrêter le projet, utilisez :
```make stop```
Pour voir d'autres commandes utiles, vous pouvez lancer :
```make help```
## Composants
Le projet comprend les technologies et outils suivants :
- **Symfony 7.0.2** : Framework PHP pour la construction d'applications web.
- **PostgreSQL 16** : Système de gestion de base de données relationnelle.
- **Caddy** : Serveur web moderne, sécurisé et facile à utiliser.
- **FrankenPhp** : Environnement d'exécution pour applications PHP.
- **phpmd (PHP Mess Detector)** : Outil d'analyse statique de code PHP.
- **php-cs (PHP CodeSniffer)** : Outil pour détecter les violations de standards de codage PHP.
## Administration de la Base de Données
Admirer, équivalent de PhpMyAdmin, est disponible sur le port **8080** pour la gestion de la base de données. Les credentials sont spécifiés dans le fichier `.env`. Utilisez `database` comme nom de serveur, qui correspond au nom du conteneur Docker de la base de données.

17
bin/console Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env php
<?php
use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application;
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
}
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
return new Application($kernel);
};

23
bin/phpunit Executable file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env php
<?php
if (!ini_get('date.timezone')) {
ini_set('date.timezone', 'UTC');
}
if (is_file(dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit')) {
if (PHP_VERSION_ID >= 80000) {
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
} else {
define('PHPUNIT_COMPOSER_INSTALL', dirname(__DIR__).'/vendor/autoload.php');
require PHPUNIT_COMPOSER_INSTALL;
PHPUnit\TextUI\Command::main();
}
} else {
if (!is_file(dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php')) {
echo "Unable to find the `simple-phpunit.php` script in `vendor/symfony/phpunit-bridge/bin/`.\n";
exit(1);
}
require dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php';
}

30
compose.override.yaml Normal file
View File

@@ -0,0 +1,30 @@
# Development environment override
services:
php:
build:
context: .
target: frankenphp_dev
volumes:
- ./:/app
- ./frankenphp/Caddyfile:/etc/caddy/Caddyfile:ro
- ./frankenphp/conf.d/app.dev.ini:/usr/local/etc/php/conf.d/app.dev.ini:ro
# If you develop on Mac or Windows you can remove the vendor/ directory
# from the bind-mount for better performance by enabling the next line:
#- /app/vendor
environment:
MERCURE_EXTRA_DIRECTIVES: demo
# See https://xdebug.org/docs/all_settings#mode
XDEBUG_MODE: "${XDEBUG_MODE:-off}"
extra_hosts:
# Ensure that host.docker.internal is correctly defined on Linux
- host.docker.internal:host-gateway
tty: true
###> symfony/mercure-bundle ###
###< symfony/mercure-bundle ###
###> doctrine/doctrine-bundle ###
database:
ports:
- "5432"
###< doctrine/doctrine-bundle ###

10
compose.prod.yaml Normal file
View File

@@ -0,0 +1,10 @@
# Production environment override
services:
php:
build:
context: .
target: frankenphp_prod
environment:
APP_SECRET: ${APP_SECRET}
MERCURE_PUBLISHER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET}
MERCURE_SUBSCRIBER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET}

71
compose.yaml Normal file
View File

@@ -0,0 +1,71 @@
services:
php:
image: ${IMAGES_PREFIX:-}app-php
restart: unless-stopped
environment:
SERVER_NAME: ${SERVER_NAME:-localhost}, php:80
MERCURE_PUBLISHER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!}
MERCURE_SUBSCRIBER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}
TRUSTED_HOSTS: ^${SERVER_NAME:-example\.com|localhost}|php$$
# Run "composer require symfony/orm-pack" to install and configure Doctrine ORM
DATABASE_URL: postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@database:5432/${POSTGRES_DB:-app}?serverVersion=${POSTGRES_VERSION:-15}&charset=${POSTGRES_CHARSET:-utf8}
# Run "composer require symfony/mercure-bundle" to install and configure the Mercure integration
MERCURE_URL: ${CADDY_MERCURE_URL:-http://php/.well-known/mercure}
MERCURE_PUBLIC_URL: https://${SERVER_NAME:-localhost}/.well-known/mercure
MERCURE_JWT_SECRET: ${CADDY_MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!}
# The two next lines can be removed after initial installation
SYMFONY_VERSION: ${SYMFONY_VERSION:-}
STABILITY: ${STABILITY:-stable}
volumes:
- caddy_data:/data
- caddy_config:/config
ports:
# HTTP
- target: 80
published: ${HTTP_PORT:-80}
protocol: tcp
# HTTPS
- target: 443
published: ${HTTPS_PORT:-443}
protocol: tcp
# HTTP/3
- target: 443
published: ${HTTP3_PORT:-443}
protocol: udp
# Mercure is installed as a Caddy module, prevent the Flex recipe from installing another service
###> symfony/mercure-bundle ###
###< symfony/mercure-bundle ###
###> doctrine/doctrine-bundle ###
database:
image: postgres:${POSTGRES_VERSION:-16}-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-app}
# You should definitely change the password in production
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!}
POSTGRES_USER: ${POSTGRES_USER:-app}
volumes:
- database_data:/var/lib/postgresql/data:rw
ports:
- '5432:5432'
# You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data!
# - ./docker/db/data:/var/lib/postgresql/data:rw
###< doctrine/doctrine-bundle ###
adminer:
image: adminer
ports:
- '8080:8080'
depends_on:
- database
volumes:
caddy_data:
caddy_config:
###> symfony/mercure-bundle ###
###< symfony/mercure-bundle ###
###> doctrine/doctrine-bundle ###
database_data:
###< doctrine/doctrine-bundle ###

104
composer.json Normal file
View File

@@ -0,0 +1,104 @@
{
"name": "symfony/skeleton",
"type": "project",
"license": "MIT",
"description": "A minimal Symfony project recommended to create bare bones applications",
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.3.1",
"ext-ctype": "*",
"ext-curl": "*",
"ext-iconv": "*",
"api-platform/core": "^3.2",
"doctrine/doctrine-bundle": "^2.11",
"doctrine/doctrine-migrations-bundle": "^3.3",
"doctrine/orm": "^2.17",
"guzzlehttp/guzzle": "^7.8",
"nelmio/cors-bundle": "^2.4",
"phpdocumentor/reflection-docblock": "^5.3",
"phpstan/phpdoc-parser": "^1.25",
"runtime/frankenphp-symfony": "^0.2.0",
"symfony/asset": "7.0.*",
"symfony/console": "7.0.*",
"symfony/dotenv": "7.0.*",
"symfony/expression-language": "7.0.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "7.0.*",
"symfony/http-client": "7.0.*",
"symfony/monolog-bundle": "^3.10",
"symfony/property-access": "7.0.*",
"symfony/property-info": "7.0.*",
"symfony/runtime": "7.0.*",
"symfony/security-bundle": "7.0.*",
"symfony/serializer": "7.0.*",
"symfony/twig-bundle": "7.0.*",
"symfony/validator": "7.0.*",
"symfony/yaml": "7.0.*"
},
"config": {
"allow-plugins": {
"php-http/discovery": true,
"symfony/flex": true,
"symfony/runtime": 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"
},
"post-install-cmd": [
"@auto-scripts"
],
"post-update-cmd": [
"@auto-scripts"
]
},
"conflict": {
"symfony/symfony": "*"
},
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.0.*",
"docker": true
}
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.5",
"friendsofphp/php-cs-fixer": "^3.48",
"mtdowling/jmespath.php": "^2.7",
"phpmd/phpmd": "^2.15",
"phpunit/phpunit": "^10.5",
"symfony/browser-kit": "7.0.*",
"symfony/css-selector": "7.0.*",
"symfony/maker-bundle": "^1.52",
"symfony/phpunit-bridge": "^7.0",
"symfony/stopwatch": "7.0.*",
"symfony/web-profiler-bundle": "7.0.*",
"zenstruck/browser": "^1.8",
"zenstruck/foundry": "^1.36"
}
}

10359
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

16
config/bundles.php Normal file
View File

@@ -0,0 +1,16 @@
<?php
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true],
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['dev' => true, 'test' => true],
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
];

View File

@@ -0,0 +1,25 @@
api_platform:
title: Mangarr API
version: 1.0.0
formats:
jsonld: ['application/ld+json']
json: ['application/json']
html: ['text/html']
jsonhal: ['application/hal+json']
swagger:
api_keys:
access_token:
name: Authorization
type: header
docs_formats:
jsonld: ['application/ld+json']
jsonopenapi: ['application/vnd.openapi+json']
html: ['text/html']
defaults:
cache_headers:
vary: ['Content-Type', 'Authorization', 'Origin']
extra_properties:
standard_put: true
rfc_7807_compliant_errors: true
event_listeners_backward_compatibility_layer: false
keep_legacy_inflector: false

View File

@@ -0,0 +1,19 @@
framework:
cache:
# Unique name of your app: used to compute stable namespaces for cache keys.
#prefix_seed: your_vendor_name/app_name
# The "app" cache stores to the filesystem by default.
# The data in this cache should persist between deploys.
# Other options include:
# Redis
#app: cache.adapter.redis
#default_redis_provider: redis://localhost
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
#app: cache.adapter.apcu
# Namespaced pools use the above "app" backend by default
#pools:
#my.dedicated.cache: null

View File

@@ -0,0 +1,49 @@
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%'
orm:
auto_generate_proxy_classes: true
enable_lazy_ghost_objects: true
report_fields_where_declared: true
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true
mappings:
App:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
when@test:
doctrine:
dbal:
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
when@prod:
doctrine:
orm:
auto_generate_proxy_classes: false
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
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

View File

@@ -0,0 +1,6 @@
doctrine_migrations:
migrations_paths:
# namespace is arbitrary but should be different from App\Migrations
# as migrations classes should NOT be autoloaded
'DoctrineMigrations': '%kernel.project_dir%/migrations'
enable_profiler: false

View File

@@ -0,0 +1,18 @@
# see https://symfony.com/doc/current/reference/configuration/framework.html
framework:
secret: '%env(APP_SECRET)%'
#csrf_protection: true
# Note that the session will be started ONLY if you read or write from it.
session:
cookie_samesite: none
cookie_secure: true
#esi: true
#fragments: true
when@test:
framework:
test: true
session:
storage_factory_id: session.storage.factory.mock_file

View File

@@ -0,0 +1,62 @@
monolog:
channels:
- deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
when@dev:
monolog:
handlers:
main:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
channels: ["!event"]
# uncomment to get logging in your browser
# you may have to allow bigger header sizes in your Web server configuration
#firephp:
# type: firephp
# level: info
#chromephp:
# type: chromephp
# level: info
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]
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
nested:
type: stream
path: php://stderr
level: debug
formatter: monolog.formatter.json
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine"]
deprecation:
type: stream
channels: [deprecation]
path: php://stderr
formatter: monolog.formatter.json

View File

@@ -0,0 +1,11 @@
nelmio_cors:
defaults:
allow_credentials: true
origin_regex: true
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
allow_headers: ['Content-Type', 'Authorization']
expose_headers: ['Link', 'Location']
max_age: 3600
paths:
'^/': ~

View File

@@ -0,0 +1,10 @@
framework:
router:
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
#default_uri: http://localhost
when@prod:
framework:
router:
strict_requirements: null

View File

@@ -0,0 +1,55 @@
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
# used to reload user from session & other features (e.g. switch_user)
app_user_provider:
entity:
class: App\Entity\User
property: email
role_hierarchy:
ROLE_FRONT_USER: [ROLE_USER_EDIT, ROLE_PROJECT_CREATE, ROLE_PROJECT_EDIT]
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_user_provider
json_login:
check_path: app_login
username_path: email
password_path: password
logout:
path: app_logout
access_token:
token_handler: App\Security\ApiTokenHandler
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
when@test:
security:
password_hashers:
# By default, password hashers are resource intensive and take time. This is
# important to generate secure password hashes. In tests however, secure hashes
# are not important, waste resources and increase test times. The following
# reduces the work factor to the lowest possible values.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon

View File

@@ -0,0 +1,6 @@
twig:
file_name_pattern: '*.twig'
when@test:
twig:
strict_variables: true

View File

@@ -0,0 +1,11 @@
framework:
validation:
# Enables validator auto-mapping support.
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
#auto_mapping:
# App\Entity\: []
when@test:
framework:
validation:
not_compromised_password: false

View File

@@ -0,0 +1,17 @@
when@dev:
web_profiler:
toolbar: true
intercept_redirects: false
framework:
profiler:
only_exceptions: false
collect_serializer_data: true
when@test:
web_profiler:
toolbar: false
intercept_redirects: false
framework:
profiler: { collect: false }

View File

@@ -0,0 +1,7 @@
when@dev: &dev
# See full configuration: https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#full-default-bundle-configuration
zenstruck_foundry:
# Whether to auto-refresh proxies by default (https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#auto-refresh)
auto_refresh_proxies: true
when@test: *dev

5
config/preload.php Normal file
View File

@@ -0,0 +1,5 @@
<?php
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
}

5
config/routes.yaml Normal file
View File

@@ -0,0 +1,5 @@
controllers:
resource:
path: ../src/Controller/
namespace: App\Controller
type: attribute

View File

@@ -0,0 +1,4 @@
api_platform:
resource: .
type: api_platform
prefix: /api

View File

@@ -0,0 +1,4 @@
when@dev:
_errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.xml'
prefix: /_error

View File

@@ -0,0 +1,3 @@
_security_logout:
resource: security.route_loader.logout
type: service

View File

@@ -0,0 +1,8 @@
when@dev:
web_profiler_wdt:
resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml'
prefix: /_wdt
web_profiler_profiler:
resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml'
prefix: /_profiler

45
config/services.yaml Normal file
View File

@@ -0,0 +1,45 @@
# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.
# 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
parameters:
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
App\EventListener\ExceptionListener:
tags:
- { name: kernel.event_listener, event: kernel.exception, method: onKernelException }
App\Service\DataLakeClient:
arguments:
$baseUrl: '%env(DATA_LAKE_BASE_URL)%'
GuzzleHttp\Client:
class: GuzzleHttp\Client
arguments:
$config:
headers:
Content-Type: 'application/json'
allow_redirects:
max: 20
strict: true
referer: true
protocols: [ 'http', 'https' ]
track_redirects: true

55
frankenphp/Caddyfile Normal file
View File

@@ -0,0 +1,55 @@
{
{$CADDY_GLOBAL_OPTIONS}
frankenphp {
{$FRANKENPHP_CONFIG}
}
# https://caddyserver.com/docs/caddyfile/directives#sorting-algorithm
order mercure after encode
order vulcain after reverse_proxy
order php_server before file_server
}
{$CADDY_EXTRA_CONFIG}
{$SERVER_NAME:localhost} {
log {
# Redact the authorization query parameter that can be set by Mercure
format filter {
wrap console
fields {
uri query {
replace authorization REDACTED
}
}
}
}
root * /app/public
encode zstd gzip
mercure {
# Transport to use (default to Bolt)
transport_url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db}
# Publisher JWT key
publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG}
# Subscriber JWT key
subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} {env.MERCURE_SUBSCRIBER_JWT_ALG}
# Allow anonymous subscribers (double-check that it's what you want)
anonymous
# Enable the subscription API (double-check that it's what you want)
subscriptions
# Extra directives
{$MERCURE_EXTRA_DIRECTIVES}
}
vulcain
{$CADDY_SERVER_EXTRA_DIRECTIVES}
# Disable Topics tracking if not enabled explicitly: https://github.com/jkarlin/topics
header ?Permissions-Policy "browsing-topics=()"
php_server
}

View File

@@ -0,0 +1,5 @@
; See https://docs.docker.com/desktop/networking/#i-want-to-connect-from-a-container-to-a-service-on-the-host
; See https://github.com/docker/for-linux/issues/264
; The `client_host` below may optionally be replaced with `discover_client_host=yes`
; Add `start_with_request=yes` to start debug session on each request
xdebug.client_host = host.docker.internal

14
frankenphp/conf.d/app.ini Normal file
View File

@@ -0,0 +1,14 @@
variables_order = EGPCS
expose_php = 0
date.timezone = UTC
apc.enable_cli = 1
session.use_strict_mode = 1
zend.detect_unicode = 0
; https://symfony.com/doc/current/performance.html
realpath_cache_size = 4096K
realpath_cache_ttl = 600
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 20000
opcache.memory_consumption = 256
opcache.enable_file_override = 1

View File

@@ -0,0 +1,2 @@
opcache.preload_user = root
opcache.preload = /app/config/preload.php

60
frankenphp/docker-entrypoint.sh Executable file
View File

@@ -0,0 +1,60 @@
#!/bin/sh
set -e
if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
# Install the project the first time PHP is started
# After the installation, the following block can be deleted
if [ ! -f composer.json ]; then
rm -Rf tmp/
composer create-project "symfony/skeleton $SYMFONY_VERSION" tmp --stability="$STABILITY" --prefer-dist --no-progress --no-interaction --no-install
cd tmp
cp -Rp . ..
cd -
rm -Rf tmp/
composer require "php:>=$PHP_VERSION" runtime/frankenphp-symfony
composer config --json extra.symfony.docker 'true'
if grep -q ^DATABASE_URL= .env; then
echo "To finish the installation please press Ctrl+C to stop Docker Compose and run: docker compose up --build -d --wait"
sleep infinity
fi
fi
if [ -z "$(ls -A 'vendor/' 2>/dev/null)" ]; then
composer install --prefer-dist --no-progress --no-interaction
fi
if grep -q ^DATABASE_URL= .env; then
echo "Waiting for database to be ready..."
ATTEMPTS_LEFT_TO_REACH_DATABASE=60
until [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ] || DATABASE_ERROR=$(php bin/console dbal:run-sql -q "SELECT 1" 2>&1); do
if [ $? -eq 255 ]; then
# If the Doctrine command exits with 255, an unrecoverable error occurred
ATTEMPTS_LEFT_TO_REACH_DATABASE=0
break
fi
sleep 1
ATTEMPTS_LEFT_TO_REACH_DATABASE=$((ATTEMPTS_LEFT_TO_REACH_DATABASE - 1))
echo "Still waiting for database to be ready... Or maybe the database is not reachable. $ATTEMPTS_LEFT_TO_REACH_DATABASE attempts left."
done
if [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ]; then
echo "The database is not up or not reachable:"
echo "$DATABASE_ERROR"
exit 1
else
echo "The database is now ready and reachable"
fi
if [ "$( find ./migrations -iname '*.php' -print -quit )" ]; then
php bin/console doctrine:migrations:migrate --no-interaction
fi
fi
setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var
setfacl -dR -m u:www-data:rwX -m u:"$(whoami)":rwX var
fi
exec docker-php-entrypoint "$@"

View File

@@ -0,0 +1,4 @@
worker {
file ./public/index.php
env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime
}

0
migrations/.gitignore vendored Normal file
View File

24
phpmd.xml Normal file
View File

@@ -0,0 +1,24 @@
<?xml version="1.0"?>
<ruleset name="My first PHPMD rule set"
xmlns="http://pmd.sf.net/ruleset/1.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://pmd.sf.net/ruleset/1.0.0
http://pmd.sf.net/ruleset_xml_schema.xsd"
xsi:noNamespaceSchemaLocation="
http://pmd.sf.net/ruleset_xml_schema.xsd">
<description>
My custom rule set that checks my code...
</description>
<properties>
<property name="cache.enabled" value="true"/>
<property name="cache.location" value="var/cache/phpmd.cache"/>
</properties>
<rule ref="rulesets/codesize.xml"/>
<rule ref="rulesets/cleancode.xml"/>
<rule ref="rulesets/controversial.xml"/>
<rule ref="rulesets/design.xml"/>
<rule ref="rulesets/naming.xml"/>
<rule ref="rulesets/unusedcode.xml"/>
</ruleset>

26
phpunit.xml.dist Normal file
View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd" backupGlobals="false" colors="true" bootstrap="tests/bootstrap.php" cacheDirectory=".phpunit.cache">
<php>
<ini name="display_errors" value="1"/>
<ini name="error_reporting" value="-1"/>
<server name="APP_ENV" value="test" force="true"/>
<server name="SHELL_VERBOSITY" value="-1"/>
<server name="SYMFONY_PHPUNIT_REMOVE" value=""/>
<server name="SYMFONY_PHPUNIT_VERSION" value="9.6"/>
<server name="SYMFONY_DEPRECATIONS_HELPER" value="disabled"/>
</php>
<testsuites>
<testsuite name="Project Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<extensions>
<bootstrap class="Zenstruck\Browser\Test\BrowserExtension"/>
</extensions>
<source>
<include>
<directory suffix=".php">src</directory>
</include>
</source>
</phpunit>

0
public/.gitignore vendored Normal file
View File

9
public/index.php Normal file
View File

@@ -0,0 +1,9 @@
<?php
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};

View File

@@ -0,0 +1,30 @@
<?php
namespace App\ApiPlatform;
use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface;
use ApiPlatform\OpenApi\Model\SecurityScheme;
use ApiPlatform\OpenApi\OpenApi;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
#[AsDecorator('api_platform.openapi.factory')]
class OpenApiFactoryDecorator implements OpenApiFactoryInterface
{
public function __construct(private OpenApiFactoryInterface $decorated)
{
}
public function __invoke(array $context = []): OpenApi
{
$openApi = $this->decorated->__invoke($context);
$securitySchemes = $openApi->getComponents()->getSecuritySchemes() ?: new \ArrayObject();
$securitySchemes['access_token'] = new SecurityScheme(
type: 'http',
scheme: 'bearer',
);
return $openApi;
}
}

0
src/ApiResource/.gitignore vendored Normal file
View File

0
src/Controller/.gitignore vendored Normal file
View File

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Controller;
use ApiPlatform\Api\IriConverterInterface;
use App\Entity\User;
use Exception;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
class SecurityController extends AbstractController
{
#[Route('/login', name: 'app_login', methods: ['GET', 'POST'])]
public function login(IriConverterInterface $iriConverter, #[CurrentUser] User $user = null): Response
{
if(!$user){
return $this->json([
'error' => 'Invalid credentials'
], 401);
}
return new Response(null, 204, [
'Location' => $iriConverter->getIriFromResource($user),
]);
}
/**
* @throws Exception
*/
#[Route('/logout', name: 'app_logout', methods: ['GET'])]
public function logout(): void
{
throw new Exception('This method can be blank.');
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\DataFixtures;
use App\Factory\ApiTokenFactory;
use App\Factory\CommentFactory;
use App\Factory\CompanyFactory;
use App\Factory\DocumentFactory;
use App\Factory\FolderFactory;
use App\Factory\ProjectDocumentFactory;
use App\Factory\ProjectFactory;
use App\Factory\UserFactory;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
CompanyFactory::createMany(5);
UserFactory::createMany(20, function () {
return [
'company' => CompanyFactory::random()
];
});
ApiTokenFactory::createMany(60, function () {
return [
'ownedBy' => UserFactory::random()
];
});
$projects = ProjectFactory::createMany(100, function () {
return [
'ownedBy' => UserFactory::random()
];
});
$documents = DocumentFactory::createMany(100);
foreach ($documents as $document) {
ProjectDocumentFactory::createMany(3, function () use ($document) {
return [
'document' => $document,
'project' => ProjectFactory::random()
];
});
CommentFactory::createMany(1, function () use ($document) {
return [
'projectDocument' => ProjectDocumentFactory::random(),
'postedBy' => UserFactory::random()
];
});
}
foreach ($projects as $project) {
$projectFolder = FolderFactory::createOne([
'label' => 'Root Folder Project ' . $project->getId(),
'slug' => 'root-folder-project-' . $project->getId(),
'project' => $project,
'createdBy' => $project->getOwnedBy()
]);
$child = FolderFactory::createOne([
'label' => 'Subfolder - Parent Folder: ' . $projectFolder->getId(),
'slug' => 'subfolder-parent-folder-' . $projectFolder->getId(),
'createdBy' => $project->getOwnedBy()
]);
$grandChild = FolderFactory::createOne([
'label' => 'Subfolder - Parent Folder: ' . $child->getId(),
'slug' => 'subfolder-parent-folder-' . $child->getId(),
'createdBy' => $project->getOwnedBy()
]);
$grandChild->addDocument(DocumentFactory::createOne()->object());
$grandChild->addDocument(DocumentFactory::createOne()->object());
$grandChild->addDocument(DocumentFactory::createOne()->object());
$child->addChild(
$grandChild->object()
);
$projectFolder->addChild(
$child->object()
);
}
}
}

0
src/Entity/.gitignore vendored Normal file
View File

107
src/Entity/ApiToken.php Normal file
View File

@@ -0,0 +1,107 @@
<?php
namespace App\Entity;
use App\Repository\ApiTokenRepository;
use Doctrine\ORM\Mapping as ORM;
use Random\RandomException;
#[ORM\Entity(repositoryClass: ApiTokenRepository::class)]
class ApiToken
{
private const string PERSONAL_ACCESS_TOKEN_PREFIX = 'ipm_';
public const string SCOPE_USER_EDIT = 'ROLE_USER_EDIT';
public const string SCOPE_PROJECT_CREATE = 'ROLE_PROJECT_CREATE';
public const string SCOPE_PROJECT_EDIT = 'ROLE_PROJECT_EDIT';
public const array SCOPES = [
self::SCOPE_USER_EDIT => 'Edit user',
self::SCOPE_PROJECT_CREATE => 'Create project',
self::SCOPE_PROJECT_EDIT => 'Edit project',
];
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'apiTokens')]
#[ORM\JoinColumn(nullable: false)]
private ?User $ownedBy = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $expiresAt = null;
#[ORM\Column(length: 68)]
private ?string $token = null;
#[ORM\Column]
private array $scopes = [];
/**
* @throws RandomException
*/
public function __construct(string $tokenType = self::PERSONAL_ACCESS_TOKEN_PREFIX)
{
$this->token = $tokenType . bin2hex(random_bytes(32));
}
public function getId(): ?int
{
return $this->id;
}
public function getOwnedBy(): ?User
{
return $this->ownedBy;
}
public function setOwnedBy(?User $ownedBy): static
{
$this->ownedBy = $ownedBy;
return $this;
}
public function getExpiresAt(): ?\DateTimeImmutable
{
return $this->expiresAt;
}
public function setExpiresAt(?\DateTimeImmutable $expiresAt): static
{
$this->expiresAt = $expiresAt;
return $this;
}
public function getToken(): ?string
{
return $this->token;
}
public function setToken(string $token): static
{
$this->token = $token;
return $this;
}
public function getScopes(): array
{
return $this->scopes;
}
public function setScopes(array $scopes): static
{
$this->scopes = $scopes;
return $this;
}
public function isValid(): bool
{
return $this->expiresAt === null || $this->expiresAt > new \DateTimeImmutable();
}
}

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

@@ -0,0 +1,364 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
#[ApiResource(
operations: [
new Get(),
new GetCollection(),
new Post(
validationContext: ['groups' => ['Default', 'postValidation']]
),
new Put(),
new Patch(),
new Delete()
],
normalizationContext: ['groups' => ['user:read']],
denormalizationContext: ['groups' => ['user:write']],
security: "is_granted('ROLE_USER')"
)]
#[UniqueEntity(fields: ['email'], message: 'There is already an account with this email')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 180, unique: true)]
#[Groups(['user:read', 'user:write'])]
#[Assert\NotBlank]
#[Assert\Email]
private ?string $email = null;
#[ORM\Column]
private array $roles = [];
/**
* @var ?string The hashed password
*/
#[ORM\Column]
private ?string $password = null;
#[ORM\Column(length: 255)]
#[Groups(['user:read', 'user:write'])]
#[Assert\NotBlank]
private ?string $firstName = null;
#[ORM\Column(length: 255)]
#[Groups(['user:read', 'user:write'])]
#[Assert\NotBlank]
private ?string $lastName = null;
#[ORM\OneToMany(mappedBy: 'ownedBy', targetEntity: ApiToken::class)]
private Collection $apiTokens;
private ?array $accessTokenScopes = null;
#[ORM\ManyToOne(inversedBy: 'employees')]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['user:read'])]
private ?Company $company = null;
#[Groups(['user:write'])]
#[SerializedName('password')]
#[Assert\NotBlank(groups: ['postValidation'])]
private ?string $plainPassword = null;
#[ORM\OneToMany(mappedBy: 'ownedBy', targetEntity: Project::class)]
#[Groups(['user:read'])]
private Collection $projects;
#[ORM\OneToMany(mappedBy: 'postedBy', targetEntity: Comment::class)]
private Collection $comments;
#[ORM\OneToMany(mappedBy: 'createdBy', targetEntity: Folder::class)]
private Collection $folders;
public function __construct()
{
$this->apiTokens = new ArrayCollection();
$this->projects = new ArrayCollection();
$this->comments = new ArrayCollection();
$this->folders = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): static
{
$this->email = $email;
return $this;
}
/**
* A visual identifier that represents this user.
*
* @see UserInterface
*/
public function getUserIdentifier(): string
{
return (string) $this->email;
}
/**
* @see UserInterface
*/
public function getRoles(): array
{
if($this->accessTokenScopes !== null){
$roles = $this->roles;
$roles[] = 'ROLE_FRONT_USER';
}else{
$roles = $this->accessTokenScopes;
}
// guarantee every user at least has ROLE_USER
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
public function setRoles(array $roles): static
{
$this->roles = $roles;
return $this;
}
/**
* @see PasswordAuthenticatedUserInterface
*/
public function getPassword(): string
{
return $this->password;
}
public function setPassword(string $password): static
{
$this->password = $password;
return $this;
}
/**
* @see UserInterface
*/
public function eraseCredentials(): void
{
// If you store any temporary, sensitive data on the user, clear it here
$this->plainPassword = null;
}
public function getFirstName(): ?string
{
return $this->firstName;
}
public function setFirstName(string $firstName): static
{
$this->firstName = $firstName;
return $this;
}
public function getLastName(): ?string
{
return $this->lastName;
}
public function setLastName(string $lastName): static
{
$this->lastName = $lastName;
return $this;
}
/**
* @return Collection<int, ApiToken>
*/
public function getApiTokens(): Collection
{
return $this->apiTokens;
}
public function addApiToken(ApiToken $apiToken): static
{
if (!$this->apiTokens->contains($apiToken)) {
$this->apiTokens->add($apiToken);
$apiToken->setOwnedBy($this);
}
return $this;
}
public function removeApiToken(ApiToken $apiToken): static
{
if ($this->apiTokens->removeElement($apiToken)) {
// set the owning side to null (unless already changed)
if ($apiToken->getOwnedBy() === $this) {
$apiToken->setOwnedBy(null);
}
}
return $this;
}
/**
* @return string[]
*/
#[Groups(['user:read'])]
public function getValidTokenStrings(): array
{
return $this->getApiTokens()
->filter(fn (ApiToken $token) => $token->isValid())
->map(fn (ApiToken $token) => $token->getToken())
->toArray();
}
public function markAsTokenAuthenticated(array $scopes): void
{
$this->accessTokenScopes = $scopes;
}
public function getCompany(): ?Company
{
return $this->company;
}
public function setCompany(?Company $company): static
{
$this->company = $company;
return $this;
}
public function getPlainPassword(): ?string
{
return $this->plainPassword;
}
public function setPlainPassword(string $plainPassword): void
{
$this->plainPassword = $plainPassword;
}
/**
* @return Collection<int, Project>
*/
public function getProjects(): Collection
{
return $this->projects;
}
public function addProject(Project $project): static
{
if (!$this->projects->contains($project)) {
$this->projects->add($project);
$project->setOwnedBy($this);
}
return $this;
}
public function removeProject(Project $project): static
{
if ($this->projects->removeElement($project)) {
// set the owning side to null (unless already changed)
if ($project->getOwnedBy() === $this) {
$project->setOwnedBy(null);
}
}
return $this;
}
/**
* @return Collection<int, Comment>
*/
public function getComments(): Collection
{
return $this->comments;
}
public function addComment(Comment $comment): static
{
if (!$this->comments->contains($comment)) {
$this->comments->add($comment);
$comment->setPostedBy($this);
}
return $this;
}
public function removeComment(Comment $comment): static
{
if ($this->comments->removeElement($comment)) {
// set the owning side to null (unless already changed)
if ($comment->getPostedBy() === $this) {
$comment->setPostedBy(null);
}
}
return $this;
}
/**
* @return Collection<int, Folder>
*/
public function getFolders(): Collection
{
return $this->folders;
}
public function addFolder(Folder $folder): static
{
if (!$this->folders->contains($folder)) {
$this->folders->add($folder);
$folder->setCreatedBy($this);
}
return $this;
}
public function removeFolder(Folder $folder): static
{
if ($this->folders->removeElement($folder)) {
// set the owning side to null (unless already changed)
if ($folder->getCreatedBy() === $this) {
$folder->setCreatedBy(null);
}
}
return $this;
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\EventListener;
use ApiPlatform\Exception\ItemNotFoundException;
use ApiPlatform\Symfony\Validator\Exception\ValidationException;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use ApiPlatform\Exception\FilterValidationException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
class ExceptionListener
{
public function __construct(private LoggerInterface $logger)
{
}
public function onKernelException(ExceptionEvent $event): void
{
$exception = $event->getThrowable();
$response = match(true) {
$exception instanceof FilterValidationException,
$exception instanceof BadRequestException => $this->createResponse($exception, Response::HTTP_BAD_REQUEST),
$exception instanceof NotFoundHttpException,
$exception instanceof ItemNotFoundException => $this->createResponse($exception, Response::HTTP_NOT_FOUND),
$exception instanceof AccessDeniedHttpException => $this->createResponse($exception, Response::HTTP_FORBIDDEN),
$exception instanceof ValidationException,
$exception instanceof NotNormalizableValueException => $this->createResponse($exception, Response::HTTP_UNPROCESSABLE_ENTITY),
default => null,
};
if ($response) {
$event->setResponse($response);
}else{
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
}
}
private function createResponse(\Throwable $exception, int $statusCode): Response
{
$this->logger->info($exception->getMessage(), ['exception' => $exception]);
return new Response(json_encode(['message' => $exception->getMessage()]), $statusCode, ['Content-Type' => 'application/json']);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Http\Event\LogoutEvent;
class LogoutSubscriber implements EventSubscriberInterface
{
public function onLogoutEvent(LogoutEvent $event): void
{
$response = new Response(null, 204);
$event->setResponse($response);
}
public static function getSubscribedEvents(): array
{
return [
LogoutEvent::class => 'onLogoutEvent',
];
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Factory;
use App\Entity\ApiToken;
use App\Repository\ApiTokenRepository;
use Zenstruck\Foundry\ModelFactory;
use Zenstruck\Foundry\Proxy;
use Zenstruck\Foundry\RepositoryProxy;
/**
* @extends ModelFactory<ApiToken>
*
* @method ApiToken|Proxy create(array|callable $attributes = [])
* @method static ApiToken|Proxy createOne(array $attributes = [])
* @method static ApiToken|Proxy find(object|array|mixed $criteria)
* @method static ApiToken|Proxy findOrCreate(array $attributes)
* @method static ApiToken|Proxy first(string $sortedField = 'id')
* @method static ApiToken|Proxy last(string $sortedField = 'id')
* @method static ApiToken|Proxy random(array $attributes = [])
* @method static ApiToken|Proxy randomOrCreate(array $attributes = [])
* @method static ApiTokenRepository|RepositoryProxy repository()
* @method static ApiToken[]|Proxy[] all()
* @method static ApiToken[]|Proxy[] createMany(int $number, array|callable $attributes = [])
* @method static ApiToken[]|Proxy[] createSequence(iterable|callable $sequence)
* @method static ApiToken[]|Proxy[] findBy(array $attributes)
* @method static ApiToken[]|Proxy[] randomRange(int $min, int $max, array $attributes = [])
* @method static ApiToken[]|Proxy[] randomSet(int $number, array $attributes = [])
*/
final class ApiTokenFactory extends ModelFactory
{
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services
*
* @todo inject services if required
*/
public function __construct()
{
parent::__construct();
}
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories
*
* @todo add your default values here
*/
protected function getDefaults(): array
{
return [
'ownedBy' => UserFactory::new(),
'scopes' => [],
];
}
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization
*/
protected function initialize(): self
{
return $this
// ->afterInstantiate(function(ApiToken $apiToken): void {})
;
}
protected static function getClass(): string
{
return ApiToken::class;
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Factory;
use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Zenstruck\Foundry\ModelFactory;
use Zenstruck\Foundry\Proxy;
use Zenstruck\Foundry\RepositoryProxy;
/**
* @extends ModelFactory<User>
*
* @method User|Proxy create(array|callable $attributes = [])
* @method static User|Proxy createOne(array $attributes = [])
* @method static User|Proxy find(object|array|mixed $criteria)
* @method static User|Proxy findOrCreate(array $attributes)
* @method static User|Proxy first(string $sortedField = 'id')
* @method static User|Proxy last(string $sortedField = 'id')
* @method static User|Proxy random(array $attributes = [])
* @method static User|Proxy randomOrCreate(array $attributes = [])
* @method static UserRepository|RepositoryProxy repository()
* @method static User[]|Proxy[] all()
* @method static User[]|Proxy[] createMany(int $number, array|callable $attributes = [])
* @method static User[]|Proxy[] createSequence(iterable|callable $sequence)
* @method static User[]|Proxy[] findBy(array $attributes)
* @method static User[]|Proxy[] randomRange(int $min, int $max, array $attributes = [])
* @method static User[]|Proxy[] randomSet(int $number, array $attributes = [])
*/
final class UserFactory extends ModelFactory
{
const array FIRST_NAMES = ["ALAIN", "ALEXANDRE", "ANDRÉ", "ANNIE", "ANTHONY", "AUDREY", "AURÉLIE", "BERNARD", "BRIGITTE", "BRUNO", "CATHERINE", "CEDRIC", "CHANTAL", "CHRISTELLE", "CHRISTIAN", "CHRISTIANE", "CHRISTINE", "CHRISTOPHE", "CLAUDE", "CORINNE", "CÉLINE", "DANIEL", "DANIELLE", "DAVID", "DENISE", "DIDIER", "DOMINIQUE", "ELODIE", "EMILIE", "ENZO", "ERIC", "FABRICE", "FLORENCE", "FRANCK", "FRANÇOISE", "FRÉDÉRIC", "GEORGES", "GERMAINE", "GUILLAUME", "GUY", "GÉRARD", "HENRI", "ISABELLE", "JACQUELINE", "JACQUES", "JEAN", "JEAN-CLAUDE", "JEAN-PIERRE", "JEANNE", "JEANNINE", "JEREMY", "JEROME", "JONATHAN", "JOSEPH", "JULIE", "JULIEN", "KARINE", "KEVIN", "LAETITIA", "LAURA", "LAURENCE", "LAURENT", "LOUIS", "LUCAS", "LÉA", "MADELEINE", "MANON", "MARCEL", "MARCELLE", "MARGUERITE", "MARIE", "MARINE", "MARTINE", "MAURICE", "MAXIME", "MICHEL", "MICHÈLE", "MONIQUE", "NATHALIE", "NICOLAS", "NICOLE", "ODETTE", "OLIVIER", "PASCAL", "PASCALE", "PATRICIA", "PATRICK", "PAUL", "PAULETTE", "PHILIPPE", "PIERRE", "RENÉ", "ROBERT", "ROGER", "ROMAIN", "SANDRA", "SANDRINE", "SERGE", "SOPHIE", "STÉPHANE", "STÉPHANIE", "SUZANNE", "SYLVIE", "SÉBASTIEN", "THIERRY", "THOMAS", "THÉO", "VALÉRIE", "VIRGINIE", "VÉRONIQUE", "YVETTE", "YVONNE"];
const array LAST_NAMES = ["Adam", "Andre", "Antoine", "Arnaud", "Aubert", "Aubry", "Bailly", "Barbier", "Baron", "Barre", "Barthelemy", "Benard", "Benoit", "Berger", "Bernard", "Bertin", "Bertrand", "Besson", "Blanc", "Blanchard", "Bonnet", "Boucher", "Bouchet", "Boulanger", "Bourgeois", "Bouvier", "Boyer", "Breton", "Brun", "Brunet", "Carlier", "Caron", "Carpentier", "Carre", "Charles", "Charpentier", "Chauvin", "Chevalier", "Chevallier", "Clement", "Colin", "Collet", "Collin", "Cordier", "Cousin", "Da Silva", "Daniel", "David", "Delaunay", "Denis", "Deschamps", "Dubois", "Dufour", "Dumas", "Dumont", "Dupont", "Dupuis", "Dupuy", "Durand", "Duval", "Etienne", "Fabre", "Faure", "Fernandez", "Fleury", "Fontaine", "Fournier", "Francois", "Gaillard", "Garcia", "Garnier", "Gauthier", "Gautier", "Gay", "Gerard", "Germain", "Gilbert", "Gillet", "Girard", "Giraud", "Gonzalez", "Grondin", "Guerin", "Guichard", "Guillaume", "Guillot", "Guyot", "Hamon", "Henry", "Herve", "Hoarau", "Hubert", "Huet", "Humbert", "Jacob", "Jacquet", "Jean", "Joly", "Julien", "Klein", "Lacroix", "Lambert", "Lamy", "Langlois", "Laporte", "Laurent", "Le Gall", "Le Goff", "Le Roux", "Leblanc", "Lebrun", "Leclerc", "Leclercq", "Lecomte", "Lefebvre", "Lefevre", "Leger", "Legrand", "Lejeune", "Lemaire", "Lemaitre", "Lemoine", "Leroux", "Leroy", "Leveque", "Lopez", "Louis", "Lucas", "Maillard", "Mallet", "Marchal", "Marchand", "Marechal", "Marie", "Martin", "Martinez", "Marty", "Masson", "Mathieu", "Menard", "Mercier", "Meunier", "Meyer", "Michaud", "Michel", "Millet", "Monnier", "Moreau", "Morel", "Morin", "Moulin", "Muller", "Nicolas", "Noel", "Olivier", "Paris", "Pasquier", "Payet", "Pelletier", "Perez", "Perret", "Perrier", "Perrin", "Perrot", "Petit", "Philippe", "Picard", "Pichon", "Pierre", "Poirier", "Poulain", "Prevost", "Remy", "Renard", "Renaud", "Renault", "Rey", "Reynaud", "Richard", "Riviere", "Robert", "Robin", "Roche", "Rodriguez", "Roger", "Rolland", "Rousseau", "Roussel", "Roux", "Roy", "Royer", "Sanchez", "Schmitt", "Schneider", "Simon", "Tessier", "Thomas", "Vasseur", "Vidal", "Vincent", "Weber"];
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services
*
*/
public function __construct(private readonly UserPasswordHasherInterface $passwordHasher)
{
parent::__construct();
}
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories
*
*/
protected function getDefaults(): array
{
return [
'email' => self::faker()->unique()->email(),
'password' => 'password',
'roles' => [],
'firstName' => self::faker()->randomElement(self::FIRST_NAMES),
'lastName' => self::faker()->randomElement(self::LAST_NAMES),
'company' => CompanyFactory::randomOrCreate(),
];
}
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization
*/
protected function initialize(): self
{
return $this
->afterInstantiate(function(User $user): void {
$user->setPassword($this->passwordHasher->hashPassword(
$user,
$user->getPassword()
));
})
;
}
protected static function getClass(): string
{
return User::class;
}
}

35
src/Kernel.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
namespace App;
use Override;
use Doctrine\ORM\Mapping\ClassMetadataInfo;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
#[Override]
protected function build(ContainerBuilder $container): void
{
$container->addCompilerPass(new class() implements CompilerPassInterface {
public function process(ContainerBuilder $container): void
{
$container->getDefinition('doctrine.orm.default_configuration')
->addMethodCall(
'setIdentityGenerationPreferences',
[
[
PostgreSQLPlatform::class => ClassMetadataInfo::GENERATOR_TYPE_SEQUENCE,
],
]
);
}
});
}
}

0
src/Repository/.gitignore vendored Normal file
View File

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Repository;
use App\Entity\ApiToken;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ApiToken>
*
* @method ApiToken|null find($id, $lockMode = null, $lockVersion = null)
* @method ApiToken|null findOneBy(array $criteria, array $orderBy = null)
* @method ApiToken[] findAll()
* @method ApiToken[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class ApiTokenRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ApiToken::class);
}
// /**
// * @return ApiToken[] Returns an array of ApiToken objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('a')
// ->andWhere('a.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('a.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?ApiToken
// {
// return $this->createQueryBuilder('a')
// ->andWhere('a.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
/**
* @extends ServiceEntityRepository<User>
*
* @implements PasswordUpgraderInterface<User>
*
* @method User|null find($id, $lockMode = null, $lockVersion = null)
* @method User|null findOneBy(array $criteria, array $orderBy = null)
* @method User[] findAll()
* @method User[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
/**
* Used to upgrade (rehash) the user's password automatically over time.
*/
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
if (!$user instanceof User) {
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class));
}
$user->setPassword($newHashedPassword);
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
}
// /**
// * @return User[] Returns an array of User objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('u')
// ->andWhere('u.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('u.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?User
// {
// return $this->createQueryBuilder('u')
// ->andWhere('u.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Security;
use App\Repository\ApiTokenRepository;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
readonly class ApiTokenHandler implements AccessTokenHandlerInterface
{
public function __construct(private ApiTokenRepository $apiTokenRepository)
{
}
public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge
{
$token = $this->apiTokenRepository->findOneBy(['token' => $accessToken]);
if(!$token) {
throw new BadCredentialsException();
}
if(!$token->isValid()){
throw new CustomUserMessageAuthenticationException('Token expired');
}
$token->getOwnedBy()->markAsTokenAuthenticated($token->getScopes());
return new UserBadge($token->getOwnedBy()->getUserIdentifier());
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\User;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
#[AsDecorator('api_platform.doctrine.orm.state.persist_processor')]
readonly class UserHashPasswordProcessor implements ProcessorInterface
{
public function __construct(private ProcessorInterface $decoratedProcessor, private UserPasswordHasherInterface $passwordHasher)
{
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if($data instanceof User && $data->getPlainPassword() !== null) {
$data->setPassword($this->passwordHasher->hashPassword($data, $data->getPlainPassword()));
}
$this->decoratedProcessor->process($data, $operation, $uriVariables, $context);
return $data;
}
}

248
symfony.lock Normal file
View File

@@ -0,0 +1,248 @@
{
"api-platform/core": {
"version": "3.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.2",
"ref": "696d44adc3c0d4f5d25a2f1c4f3700dd8a5c6db9"
},
"files": [
"config/packages/api_platform.yaml",
"config/routes/api_platform.yaml",
"src/ApiResource/.gitignore"
]
},
"doctrine/doctrine-bundle": {
"version": "2.11",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.10",
"ref": "310a02a22033e35640468f48ff6bf31a25891537"
},
"files": [
"config/packages/doctrine.yaml",
"src/Entity/.gitignore",
"src/Repository/.gitignore"
]
},
"doctrine/doctrine-fixtures-bundle": {
"version": "3.5",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.0",
"ref": "1f5514cfa15b947298df4d771e694e578d4c204d"
},
"files": [
"src/DataFixtures/AppFixtures.php"
]
},
"doctrine/doctrine-migrations-bundle": {
"version": "3.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.1",
"ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33"
},
"files": [
"config/packages/doctrine_migrations.yaml",
"migrations/.gitignore"
]
},
"friendsofphp/php-cs-fixer": {
"version": "3.48",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.0",
"ref": "be2103eb4a20942e28a6dd87736669b757132435"
},
"files": [
".php-cs-fixer.dist.php"
]
},
"nelmio/cors-bundle": {
"version": "2.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.5",
"ref": "6bea22e6c564fba3a1391615cada1437d0bde39c"
},
"files": [
"config/packages/nelmio_cors.yaml"
]
},
"phpunit/phpunit": {
"version": "10.5",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "9.6",
"ref": "7364a21d87e658eb363c5020c072ecfdc12e2326"
},
"files": [
".env.test",
"phpunit.xml.dist",
"tests/bootstrap.php"
]
},
"symfony/console": {
"version": "7.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "5.3",
"ref": "da0c8be8157600ad34f10ff0c9cc91232522e047"
},
"files": [
"bin/console"
]
},
"symfony/flex": {
"version": "2.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "146251ae39e06a95be0fe3d13c807bcf3938b172"
},
"files": [
".env"
]
},
"symfony/framework-bundle": {
"version": "7.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "6356c19b9ae08e7763e4ba2d9ae63043efc75db5"
},
"files": [
"config/packages/cache.yaml",
"config/packages/framework.yaml",
"config/preload.php",
"config/routes/framework.yaml",
"config/services.yaml",
"public/index.php",
"src/Controller/.gitignore",
"src/Kernel.php"
]
},
"symfony/maker-bundle": {
"version": "1.52",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
}
},
"symfony/monolog-bundle": {
"version": "3.10",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.7",
"ref": "aff23899c4440dd995907613c1dd709b6f59503f"
},
"files": [
"config/packages/monolog.yaml"
]
},
"symfony/phpunit-bridge": {
"version": "7.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.3",
"ref": "a411a0480041243d97382cac7984f7dce7813c08"
},
"files": [
".env.test",
"bin/phpunit",
"phpunit.xml.dist",
"tests/bootstrap.php"
]
},
"symfony/routing": {
"version": "7.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "21b72649d5622d8f7da329ffb5afb232a023619d"
},
"files": [
"config/packages/routing.yaml",
"config/routes.yaml"
]
},
"symfony/security-bundle": {
"version": "7.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "2ae08430db28c8eb4476605894296c82a642028f"
},
"files": [
"config/packages/security.yaml",
"config/routes/security.yaml"
]
},
"symfony/twig-bundle": {
"version": "7.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "cab5fd2a13a45c266d45a7d9337e28dee6272877"
},
"files": [
"config/packages/twig.yaml",
"templates/base.html.twig"
]
},
"symfony/validator": {
"version": "7.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd"
},
"files": [
"config/packages/validator.yaml"
]
},
"symfony/web-profiler-bundle": {
"version": "7.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.1",
"ref": "e42b3f0177df239add25373083a564e5ead4e13a"
},
"files": [
"config/packages/web_profiler.yaml",
"config/routes/web_profiler.yaml"
]
},
"zenstruck/foundry": {
"version": "1.36",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.10",
"ref": "37c2f894cc098ab4c08874b80cccc8e2f8de7976"
},
"files": [
"config/packages/zenstruck_foundry.yaml"
]
}
}

16
templates/base.html.twig Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{% block title %}Welcome!{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
{% block stylesheets %}
{% endblock %}
{% block javascripts %}
{% endblock %}
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Tests\Functional;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Zenstruck\Browser\HttpOptions;
use Zenstruck\Browser\KernelBrowser;
use Zenstruck\Browser\Test\HasBrowser;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
abstract class ApiTestCase extends KernelTestCase
{
use ResetDatabase, Factories, HasBrowser {
browser as baseKernelBrowser;
}
protected function browser(array $options = [], array $server = []): KernelBrowser
{
return $this->baseKernelBrowser($options, $server)
->setDefaultHttpOptions(
HttpOptions::create()
->withHeader('Accept', 'application/ld+json')
);
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Tests\Functional;
use App\Factory\ApiTokenFactory;
use App\Factory\CompanyFactory;
use App\Factory\UserFactory;
class UserResourceTest extends ApiTestCase
{
public function testUserLoginHttp(): void
{
$company = CompanyFactory::createOne();
$user = UserFactory::createOne(['company' => $company]);
$this->browser()
->post('/login', [
'json' => [
'email' => $user->getEmail(),
'password' => 'password'
]
])
->assertStatus(204)
->assertHeaderContains('Location', '/api/users/' . $user->getId());
}
public function testUserLogoutHttp()
{
$user = UserFactory::createOne();
$this->browser()
->actingAs($user)
->get('/logout')
->assertStatus(204)
;
}
public function testUserLoginToken(): void
{
$token = ApiTokenFactory::createOne();
$this->browser()
->get('api/users', [
'headers' => [
'Authorization' => 'Bearer ' . $token->getToken()
]
])
->assertStatus(200);
}
public function testCanGetUser(): void
{
$user = UserFactory::createOne();
$this->browser()
->actingAs($user)
->get('/api/users/' . $user->getId())
->assertSuccessful()
->assertJson()
->assertJsonMatches('email', $user->getEmail())
->assertJsonMatches('firstName', $user->getFirstName())
->assertJsonMatches('lastName', $user->getLastName())
;
}
public function testCanPostToCreateUser(): void
{
$loggedUser = UserFactory::createOne();
$this->browser()
->actingAs($loggedUser)
->post('/api/users', [
'json' => [
'email' => 'john.doe@mail.com',
'firstName' => 'John',
'lastName' => 'Doe',
'password' => 'password',
],
])
->assertSuccessful()
->post('/login', [
'json' => [
'email' => 'john.doe@mail.com',
'password' => 'password',
],
])
->assertSuccessful();
}
public function testCanPatchToUpdateUser(): void
{
$loggedUser = UserFactory::createOne();
$this->browser()
->actingAs($loggedUser)
->patch('/api/users/' . $loggedUser->getId(), [
'json' => [
'firstName' => 'John',
'lastName' => 'Doe',
],
'headers' => [
'Content-Type' => 'application/merge-patch+json'
]
])
->assertSuccessful()
->get('/api/users/' . $loggedUser->getId())
->assertSuccessful()
->assertJson()
->assertJsonMatches('firstName', 'John')
->assertJsonMatches('lastName', 'Doe');
;
}
}

13
tests/bootstrap.php Normal file
View File

@@ -0,0 +1,13 @@
<?php
use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__).'/vendor/autoload.php';
if (method_exists(Dotenv::class, 'bootEnv')) {
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
}
if ($_SERVER['APP_DEBUG']) {
umask(0000);
}