feat : config + login

This commit is contained in:
2026-03-08 19:47:19 +01:00
parent 307442b2b2
commit 47562fbdec
87 changed files with 28844 additions and 0 deletions

17
.editorconfig Normal file
View File

@@ -0,0 +1,17 @@
# editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[{compose.yaml,compose.*.yaml}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false

24
.env Normal file
View File

@@ -0,0 +1,24 @@
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=
APP_SHARE_DIR=var/share
###< symfony/framework-bundle ###
###> symfony/routing ###
# 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
###< symfony/routing ###
###> 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=
JWT_COOKIE_SECURE=0
JWT_TOKEN_TTL=86400
JWT_COOKIE_TTL=86400
###< lexik/jwt-authentication-bundle ###

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
###> symfony/framework-bundle ###
/.env.local
/.env.local.php
/.env.*.local
/config/secrets/prod/prod.decrypt.private.php
/public/bundles/
/var/
/vendor/
###< symfony/framework-bundle ###
###> friendsofphp/php-cs-fixer ###
/.php-cs-fixer.php
/.php-cs-fixer.cache
###< friendsofphp/php-cs-fixer ###
###> phpunit/phpunit ###
/phpunit.xml
/.phpunit.cache/
###< phpunit/phpunit ###
###> lexik/jwt-authentication-bundle ###
/config/jwt/*.pem
###< lexik/jwt-authentication-bundle ###

10
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

8
.idea/Lesstime.iml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/db-forest-config.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="db-tree-configuration">
<option name="data" value="----------------------------------------&#10;1:0:9cad43df-2147-4989-b7a4-443067034884&#10;2:0:ae622167-c834-4e7b-87a5-c1721036f5dc&#10;3:0:f407a514-c6b4-4b26-9555-445a85892502&#10;4:0:09e221b8-067a-488b-9c1d-4e155a333079&#10;" />
</component>
</project>

10
.idea/material_theme_project_new.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MaterialThemeProjectNewConfig">
<option name="metadata">
<MTProjectMetadataState>
<option name="userId" value="386cba74:19cc24e9181:-799b" />
</MTProjectMetadataState>
</option>
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Lesstime.iml" filepath="$PROJECT_DIR$/.idea/Lesstime.iml" />
</modules>
</component>
</project>

20
.idea/php.xml generated Normal file
View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MessDetectorOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCSFixerOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCodeSnifferOptionsConfiguration">
<option name="highlightLevel" value="WARNING" />
<option name="transferred" value="true" />
</component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
<component name="PhpStanOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PsalmOptionsConfiguration">
<option name="transferred" value="true" />
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
24.12.0

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

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
use PhpCsFixer\Config;
use PhpCsFixer\Finder;
$finder = Finder::create()
->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, // needs PHP 8+ or polyfill
'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)
;

21
bin/console Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env php
<?php
use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application;
if (!is_dir(dirname(__DIR__).'/vendor')) {
throw new LogicException('Dependencies are missing. Try running "composer install".');
}
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);
};

4
bin/phpunit Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env php
<?php
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';

31
commit-msg Normal file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
MSG_FILE="${1}"
FIRST_LINE="$(head -n 1 "$MSG_FILE" | tr -d '\r')"
# Autoriser commits auto-générés par git
if [[ "$FIRST_LINE" =~ ^Merge\ ]]; then
exit 0
fi
# Types autorisés (MINUSCULES uniquement)
# Optionnel: scope => 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 : <type>(<scope optionnel>) : <message>"
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

93
composer.json Normal file
View File

@@ -0,0 +1,93 @@
{
"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",
"phpdocumentor/reflection-docblock": "^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/property-access": "8.0.*",
"symfony/property-info": "8.0.*",
"symfony/runtime": "8.0.*",
"symfony/security-bundle": "8.0.*",
"symfony/serializer": "8.0.*",
"symfony/twig-bundle": "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",
"symfony/browser-kit": "8.0.*",
"symfony/css-selector": "8.0.*"
}
}

10704
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

25
config/bundles.php Normal file
View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use ApiPlatform\Symfony\Bundle\ApiPlatformBundle;
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle;
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
use Nelmio\CorsBundle\NelmioCorsBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\TwigBundle\TwigBundle;
return [
FrameworkBundle::class => ['all' => true],
TwigBundle::class => ['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],
];

View File

@@ -0,0 +1,7 @@
api_platform:
title: Hello API Platform
version: 1.0.0
defaults:
stateless: true
cache_headers:
vary: ['Content-Type', 'Authorization', 'Origin']

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,48 @@
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:
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:
# "TEST_TOKEN" is typically set by ParaTest
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

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,15 @@
# 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
#esi: true
#fragments: true
when@test:
framework:
test: true
session:
storage_factory_id: session.storage.factory.mock_file

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
framework:
property_info:
with_constructor_extractor: true

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: '%env(DEFAULT_URI)%'
when@prod:
framework:
router:
strict_requirements: null

View File

@@ -0,0 +1,64 @@
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:
app_user_provider:
entity:
class: App\Entity\User
property: username
firewalls:
dev:
# Ensure dev tools and static assets are always allowed
pattern: ^/(_profiler|_wdt|assets|build)/
security: false
login:
pattern: ^/login_check
stateless: true
provider: app_user_provider
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: /
# 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
# Note: Only the *first* matching rule is applied
access_control:
- { path: ^/login_check, roles: PUBLIC_ACCESS }
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
# Version de l'application en public
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
when@test:
security:
password_hashers:
# Password hashers are resource-intensive by design to ensure security.
# In tests, it's safe to reduce their cost to improve performance.
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

7
config/preload.php Normal file
View File

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

1761
config/reference.php Normal file

File diff suppressed because it is too large Load Diff

11
config/routes.yaml Normal file
View File

@@ -0,0 +1,11 @@
# yaml-language-server: $schema=../vendor/symfony/routing/Loader/schema/routing.schema.json
# This file is the entry point to configure the routes of your app.
# Methods with the #[Route] attribute are automatically imported.
# See also https://symfony.com/doc/current/routing.html
# To list all registered routes, run the following command:
# bin/console debug:router
controllers:
resource: routing.controllers

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.php'
prefix: /_error

View File

@@ -0,0 +1,7 @@
_security_logout:
resource: security.route_loader.logout
type: service
api_login:
path: /login_check
methods: [POST]

26
config/services.yaml Normal file
View File

@@ -0,0 +1,26 @@
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.
# See also https://symfony.com/doc/current/service_container/import.html
# 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:
imports:
- { resource: version.yaml }
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/'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones

2
config/version.yaml Normal file
View File

@@ -0,0 +1,2 @@
parameters:
app.version: '0.1.0'

58
docker-compose.yml Normal file
View File

@@ -0,0 +1,58 @@
services:
php:
container_name: php-${DOCKER_APP_NAME}-fpm
build:
context: ./docker/php
dockerfile: Dockerfile
args:
DOCKER_PHP_VERSION: ${DOCKER_PHP_VERSION}
DOCKER_NODE_VERSION: ${DOCKER_NODE_VERSION}
CURRENT_UID: ${CURRENT_UID}
CURRENT_GID: ${CURRENT_GID}
environment:
PHP_IDE_CONFIG: serverName=${DOCKER_APP_NAME}-docker
XDEBUG_CLIENT_HOST: ${XDEBUG_CLIENT_HOST:-host.docker.internal}
XDEBUG_CONFIG: client_host=${XDEBUG_CLIENT_HOST:-host.docker.internal} client_port=9003
DATABASE_URL: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
COMPOSER_HOME: /tmp/composer
COMPOSER_CACHE_DIR: /tmp/composer/cache
volumes:
- ./:/var/www/html
- ~/.cache:/var/www/.cache # Pour la cache de composer
- ~/.config:/var/www/.config # Pour la config de yarn
- ~/.composer:/var/www/.composer # Pour la config de composer
- ./docker/php/config/php.ini:/usr/local/etc/php/php.ini
- ./docker/php/config/docker-php-ext-xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
- ./LOG:/var/www/html/LOG
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
- db
ports:
- "3002:3002"
restart: unless-stopped
nginx:
image: nginx:1.27-alpine
container_name: nginx-${DOCKER_APP_NAME}
depends_on:
- php
ports:
- "8082:80"
volumes:
- ./:/var/www/html:ro
- ./docker/nginx/conf.d:/etc/nginx/conf.d:ro
restart: unless-stopped
db:
image: postgres:16-alpine
command: -p ${POSTGRES_PORT:-5435}
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pg_data:/var/lib/postgresql/data
ports:
- "${POSTGRES_PORT:-5435}:${POSTGRES_PORT:-5435}"
restart: unless-stopped
volumes:
pg_data:

9
docker/.env.docker Normal file
View File

@@ -0,0 +1,9 @@
DOCKER_APP_NAME=lesstime
DOCKER_PHP_VERSION=8.4.6
DOCKER_NODE_VERSION=24.12.0
APP_USER=www-data
POSTGRES_DB=lesstime
POSTGRES_USER=root
POSTGRES_PASSWORD=root
POSTGRES_PORT=5435
XDEBUG_CLIENT_HOST=host.docker.internal

9
docker/.env.docker.local Normal file
View File

@@ -0,0 +1,9 @@
DOCKER_APP_NAME=lesstime
DOCKER_PHP_VERSION=8.4.6
DOCKER_NODE_VERSION=24.12.0
APP_USER=www-data
POSTGRES_DB=lesstime
POSTGRES_USER=root
POSTGRES_PASSWORD=root
POSTGRES_PORT=5435
XDEBUG_CLIENT_HOST=192.168.0.124

View File

@@ -0,0 +1,52 @@
server {
listen 80;
server_name localhost;
root /var/www/html/frontend/dist;
index index.html;
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;
}
}

129
docker/php/Dockerfile Normal file
View File

@@ -0,0 +1,129 @@
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 \
# mysqli \
pcntl \
pdo_pgsql \
# pdo_mysql \
# pdo_sqlite \
# pdo_sqlsrv \
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
# Configuration spéciale pour quelques extensions
# RUN docker-php-ext-configure pdo_odbc --with-pdo-odbc=unixODBC,/usr && \
# docker-php-ext-install pdo_odbc \
RUN docker-php-ext-enable opcache
# Configurer Oracle OCI8 (nécessite le SDK Oracle, à installer manuellement ou à lier via les dépendances)
#RUN apt-get update && apt-get -y install wget unzip libaio1 && \
# wget https://download.oracle.com/otn_software/linux/instantclient/2340000/instantclient-basic-linux.x64-23.4.0.24.05.zip && \
# unzip -o instantclient-basic-linux.x64-23.4.0.24.05.zip -d /usr/local && \
# wget https://download.oracle.com/otn_software/linux/instantclient/2340000/instantclient-sdk-linux.x64-23.4.0.24.05.zip && \
# unzip -o instantclient-sdk-linux.x64-23.4.0.24.05.zip -d /usr/local
#
#RUN echo 'instantclient,/usr/local/instantclient_23_4' | pecl install oci8-3.4.0 \
# && docker-php-ext-enable oci8
#
#ENV ORACLE_BASE /usr/local/instantclient_23_4
#ENV LD_LIBRARY_PATH /usr/local/instantclient_23_4
#ENV TNS_ADMIN /usr/local/instantclient_23_4
#ENV ORACLE_HOME /usr/local/instantclient_23_4
# Configuration pour utiliser Kerberos avec IMAP (si nécessaire)
# RUN docker-php-ext-configure imap --with-kerberos --with-imap-ssl \
# && docker-php-ext-install imap
# 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

View File

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

View File

@@ -0,0 +1,4 @@
[Date]
; Defines the default timezone used by the date functions
; http://php.net/date.timezone
date.timezone = Europe/Paris

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

13
frontend/app.vue Normal file
View File

@@ -0,0 +1,13 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
<script setup lang="ts">
const { load } = useAppVersion()
onMounted(() => {
load()
})
</script>

View File

@@ -0,0 +1,42 @@
<template>
<header class="border-b border-neutral-200 bg-primary-500 p-5 text-white">
<div class="flex h-full items-center justify-end">
<div class="flex gap-12 text-xl text-white">
<div class="group relative flex gap-4">
<Icon name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
<p class="self-center cursor-pointer">{{ user?.username }}</p>
<div class="invisible absolute right-0 top-full z-20 mt-2 w-44 rounded-md border border-neutral-200 bg-white py-1 text-sm text-neutral-800 opacity-0 shadow-lg transition-all group-hover:visible group-hover:opacity-100">
<button
type="button"
class="block w-full px-3 py-2 text-left hover:bg-neutral-100"
>
Mon profil
</button>
<button
type="button"
class="block w-full px-3 py-2 text-left hover:bg-neutral-100"
@click="handleLogout"
>
Déconnexion
</button>
</div>
</div>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import type { UserData } from '~/services/dto/user-data'
defineProps<{
user?: UserData
}>()
const auth = useAuthStore()
const handleLogout = async () => {
await auth.logout()
await navigateTo('/login')
}
</script>

View File

@@ -0,0 +1,213 @@
import type { FetchOptions } from 'ofetch'
import { $fetch, FetchError } from 'ofetch'
import { useAuthStore } from '~/stores/auth'
export type AnyObject = Record<string, unknown>
export type BlobResponse = {
data: Blob
headers: Headers
}
export type ApiClient = {
get<T>(url: string, query?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
getBlob(url: string, query?: AnyObject, options?: ApiFetchOptions<'blob'>): Promise<BlobResponse>
post<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
put<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
patch<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
delete<T>(url: string, query?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
}
export type ApiFetchOptions<ResponseType extends 'json' | 'blob'> =
FetchOptions<ResponseType> & {
toast?: boolean
toastOn401?: boolean
toastTitle?: string
toastErrorMessage?: string
toastSuccessMessage?: string
toastErrorKey?: string
toastSuccessKey?: string
}
export const useApi = (): ApiClient => {
const config = useRuntimeConfig()
const baseURL = config.public.apiBase ?? '/api'
const toast = useToast()
const auth = useAuthStore()
const nuxtApp = useNuxtApp()
let isHandlingUnauthorized = false
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)
const 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<string, unknown>
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<string, string> = {
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: 'Succès',
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
})
}
})
const request = <T>(
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
url: string,
options: ApiFetchOptions<'json'> = {}
) => {
const needsJsonBody = method === 'POST' || method === 'PUT'
const needsMergePatch = method === 'PATCH'
const headers = new Headers(options.headers as HeadersInit | undefined)
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<T>(url, { ...options, method, headers })
}
return {
get<T>(url: string, query: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
return request<T>('GET', url, { ...options, query })
},
getBlob(url: string, query: AnyObject = {}, options: ApiFetchOptions<'blob'> = {}) {
return client
.raw(url, { ...options, method: 'GET', query, responseType: 'blob' })
.then((res) => ({ data: res._data as Blob, headers: res.headers }))
},
post<T>(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
return request<T>('POST', url, { ...options, body })
},
put<T>(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
return request<T>('PUT', url, { ...options, body })
},
patch<T>(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
return request<T>('PATCH', url, { ...options, body })
},
delete<T>(url: string, query: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
return request<T>('DELETE', url, { ...options, query })
}
}
}

View File

@@ -0,0 +1,17 @@
export const useAppVersion = () => {
const api = useApi()
const version = useState<string | null>('app-version', () => null)
const load = async () => {
if (version.value) {
return version.value
}
const response = await api.get<{ version: string }>('version', {}, {
toast: false
})
version.value = response.version
return version.value
}
return { version, load }
}

4
frontend/i18n.config.ts Normal file
View File

@@ -0,0 +1,4 @@
export default defineI18nConfig(() => ({
legacy: false,
locale: 'fr'
}))

View File

@@ -0,0 +1,22 @@
{
"errors": {
"http": {
"get": "Impossible de récupérer les données.",
"post": "Impossible de créer la ressource.",
"put": "Impossible de mettre à jour la ressource.",
"patch": "Impossible de mettre à jour la ressource.",
"delete": "Impossible de supprimer la ressource."
},
"auth": {
"login": "Identifiants invalides.",
"logout": "Impossible de se déconnecter.",
"session": "Session expirée"
}
},
"success": {
"auth": {
"login": "Connexion réussie.",
"logout": "Déconnexion réussie."
}
}
}

11
frontend/layouts/auth.vue Normal file
View File

@@ -0,0 +1,11 @@
<template>
<div class="min-h-screen bg-tertiary-500 from-tertiary-500 via-white to-neutral-100 text-neutral-900">
<main class="mx-auto flex min-h-screen w-full max-w-[720px] items-center px-6 py-12">
<slot />
</main>
</div>
</template>
<script setup lang="ts">
const { version } = useAppVersion()
</script>

View File

@@ -0,0 +1,52 @@
<template>
<div class="h-screen overflow-hidden">
<div class="flex h-full">
<aside class="flex h-full w-64 flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500">
<div>
<img src="/malio.png" alt="Logo" class="w-auto"/>
</div>
<nav class="flex-1 px-4 pb-6">
<NuxtLink
to="/"
class="flex items-center gap-3 px-4 pb-3 pt-6 text-md font-semibold text-black hover:bg-tertiary-500 hover:text-primary-500 border-t border-secondary-500"
active-class="bg-tertiary-500 text-primary-500"
>
<Icon name="mdi:question-mark" size="24"/>
<span class="self-baseline text-md">Tableau de bord</span>
</NuxtLink>
<NuxtLink
to="/project-list"
class="flex gap-3 px-4 py-3 text-md font-semibold text-black hover:bg-tertiary-500 hover:text-primary-500"
active-class="bg-tertiary-500 text-primary-500"
>
<Icon name="mdi:folder-outline" size="24"/>
<span class="self-baseline text-md">Projets</span>
</NuxtLink>
</nav>
<div class="flex flex-col gap-2 items-center p-4">
<p class="font-bold">v 0.0.0</p>
</div>
</aside>
<div class="h-full flex-1 overflow-hidden flex flex-col">
<AppTopNav :user="auth.user" />
<main class="flex-1 overflow-y-auto px-8 py-12">
<slot/>
</main>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {useAppVersion} from "~/composables/useAppVersion";
const auth = useAuthStore()
const {version} = useAppVersion()
const handleLogout = async () => {
await auth.logout()
await navigateTo('/login')
}
</script>

View File

@@ -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('/')
}
})

41
frontend/nuxt.config.ts Normal file
View File

@@ -0,0 +1,41 @@
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: {enabled: false},
ssr: false,
app: {
baseURL: process.env.NODE_ENV === 'production'
? (process.env.NUXT_PUBLIC_APP_BASE || '/')
: '/'
},
modules: [
'@nuxtjs/tailwindcss',
'@pinia/nuxt',
'nuxt-toast',
'@nuxtjs/i18n',
'@nuxt/icon'
],
runtimeConfig: {
public: {
apiBase: process.env.NUXT_PUBLIC_API_BASE
}
},
devServer: {port: 3002},
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
}
})

13987
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
frontend/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "nuxt-app",
"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": {
"@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"
}
}

7
frontend/pages/index.vue Normal file
View File

@@ -0,0 +1,7 @@
<template>
<h1 class="text-primary-500">Tableau de bord</h1>
</template>
<script setup lang="ts">
</script>

76
frontend/pages/login.vue Normal file
View File

@@ -0,0 +1,76 @@
<template>
<div class="mx-auto w-full max-w-lg">
<span
class="flex items-center justify-center bg-white text-xl font-bold uppercase text-primary-500 p-4"
>
<img src="/malio.png" alt="Logo" class="w-[150px]"/>
</span>
<form
class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
@submit.prevent="handleSubmit"
>
<div>
<label class="text-sm font-semibold text-neutral-700" for="username">
Nom d'utilisateur
</label>
<input
id="username"
v-model="username"
type="text"
autocomplete="username"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div>
<label class="text-sm font-semibold text-neutral-700" for="password">
Mot de passe
</label>
<input
id="password"
v-model="password"
type="password"
autocomplete="current-password"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<button
type="submit"
class="w-full rounded-md bg-primary-500 px-4 py-2 text-base font-semibold text-white transition hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="isSubmitting"
>
Se connecter
</button>
<p class="font-bold">v{{ version }}</p>
</form>
</div>
</template>
<script setup lang="ts">
definePageMeta({layout: 'auth'})
useHead({
title: 'Connexion'
})
const router = useRouter()
const auth = useAuthStore()
const { version } = useAppVersion()
const username = ref('')
const password = ref('')
const isSubmitting = ref(false)
const handleSubmit = async () => {
if (isSubmitting.value) return
isSubmitting.value = true
try {
await auth.login(username.value, password.value)
await router.push('/')
} finally {
isSubmitting.value = false
}
}
</script>

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
frontend/public/malio.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@@ -0,0 +1,2 @@
User-Agent: *
Disallow:

22
frontend/services/auth.ts Normal file
View File

@@ -0,0 +1,22 @@
import type { UserData } from './dto/user-data'
export const getCurrentUser = () => {
const api = useApi()
return api.get<UserData>('/me', {}, { toastErrorKey: 'errors.auth.session' })
}
export const login = (username: string, password: string) => {
const api = useApi()
return api.post('/login_check', { username, password }, {
toastOn401: true,
toastErrorKey: 'errors.auth.login'
})
}
export const logout = () => {
const api = useApi()
return api.post('/logout', {}, {
toastErrorKey: 'errors.auth.logout',
toastSuccessKey: 'success.auth.logout'
})
}

View File

@@ -0,0 +1,5 @@
export type UserData = {
id: number
username: string
roles: string[]
}

63
frontend/stores/auth.ts Normal file
View File

@@ -0,0 +1,63 @@
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
}
}
}
})

View File

@@ -0,0 +1,25 @@
import type {Config} from 'tailwindcss'
export default <Partial<Config>>{
theme: {
extend: {
fontFamily: {
sans: ['"Helvetica Neue"', 'Helvetica', 'Arial', 'sans-serif']
},
colors: {
primary: {
500: '#222783',
},
secondary: {
500: '#304998'
},
tertiary: {
500: '#F3F4F8'
},
blue: {
500: '#056CF2'
}
}
}
}
}

18
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"files": [],
"references": [
{
"path": "./.nuxt/tsconfig.app.json"
},
{
"path": "./.nuxt/tsconfig.server.json"
},
{
"path": "./.nuxt/tsconfig.shared.json"
},
{
"path": "./.nuxt/tsconfig.node.json"
}
]
}

128
makefile Normal file
View File

@@ -0,0 +1,128 @@
# Permet d'utiliser un .env.docker.local pour override
ENV_DEFAULT = docker/.env.docker
ENV_LOCAL = docker/.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:
@mkdir -p docker
@cp --update=none $(ENV_DEFAULT) $(ENV_LOCAL)
# Lance le container
start: env-init
@echo "**** START CONTAINERS ****"
@cp --update=none docker/.env.docker docker/.env.docker.local
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) cp -n frontend/.env.dist frontend/.env.local
$(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

0
migrations/.gitignore vendored Normal file
View File

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260308180201 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE "user" (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, username VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649F85E0677 ON "user" (username)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE "user"');
}
}

44
phpunit.dist.xml Normal file
View File

@@ -0,0 +1,44 @@
<?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="vendor/phpunit/phpunit/phpunit.xsd"
colors="true"
failOnDeprecation="true"
failOnNotice="true"
failOnWarning="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" />
</php>
<testsuites>
<testsuite name="Project Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<source ignoreSuppressionOfDeprecations="true"
ignoreIndirectDeprecations="true"
restrictNotices="true"
restrictWarnings="true"
>
<include>
<directory>src</directory>
</include>
<deprecationTrigger>
<method>Doctrine\Deprecations\Deprecation::trigger</method>
<method>Doctrine\Deprecations\Deprecation::delegateTriggerToBackend</method>
<function>trigger_deprecation</function>
</deprecationTrigger>
</source>
<extensions>
</extensions>
</phpunit>

38
pre-commit Normal file
View File

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

11
public/index.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
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']);
};

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

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\State\AppVersionProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/version',
normalizationContext: ['groups' => ['version:read']],
provider: AppVersionProvider::class,
),
],
)]
final class AppVersion
{
#[Groups(['version:read'])]
public string $version = '';
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\DataFixtures;
use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
class AppFixtures extends Fixture
{
public function __construct(
private readonly UserPasswordHasherInterface $passwordHasher,
) {}
public function load(ObjectManager $manager): void
{
$admin = new User();
$admin->setUsername('admin');
$admin->setRoles(['ROLE_ADMIN']);
$admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin'));
$manager->persist($admin);
$manager->flush();
}
}

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

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

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Repository\UserRepository;
use App\State\MeProvider;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/me',
provider: MeProvider::class,
normalizationContext: ['groups' => ['me:read']],
),
],
)]
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['me:read'])]
private ?int $id = null;
#[ORM\Column(length: 180, unique: true)]
#[Groups(['me:read'])]
private ?string $username = null;
/** @var list<string> */
#[ORM\Column]
#[Groups(['me:read'])]
private array $roles = [];
#[ORM\Column]
private ?string $password = null;
#[ORM\Column(type: Types::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<string> */
public function getRoles(): array
{
$roles = $this->roles;
$roles[] = 'ROLE_USER';
return array_values(array_unique($roles));
}
/** @param list<string> $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 eraseCredentials(): void {}
}

13
src/Kernel.php Normal file
View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
}

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

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<User>
*/
class UserRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\AppVersion;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class AppVersionProvider implements ProviderInterface
{
public function __construct(
#[Autowire('%app.version%')]
private string $version,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): AppVersion
{
$dto = new AppVersion();
$dto->version = $this->version;
return $dto;
}
}

26
src/State/MeProvider.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\User;
use Symfony\Bundle\SecurityBundle\Security;
/**
* @implements ProviderInterface<User>
*/
final readonly class MeProvider implements ProviderInterface
{
public function __construct(
private Security $security,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): User
{
/** @var User $user */
return $this->security->getUser();
}
}

232
symfony.lock Normal file
View File

@@ -0,0 +1,232 @@
{
"api-platform/symfony": {
"version": "4.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "4.0",
"ref": "e9952e9f393c2d048f10a78f272cd35e807d972b"
},
"files": [
"config/packages/api_platform.yaml",
"config/routes/api_platform.yaml",
"src/ApiResource/.gitignore"
]
},
"doctrine/deprecations": {
"version": "1.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "87424683adc81d7dc305eefec1fced883084aab9"
}
},
"doctrine/doctrine-bundle": {
"version": "3.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.0",
"ref": "18ee08e513ba0303fd09a01fc1c934870af06ffa"
},
"files": [
"config/packages/doctrine.yaml",
"src/Entity/.gitignore",
"src/Repository/.gitignore"
]
},
"doctrine/doctrine-fixtures-bundle": {
"version": "4.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.0",
"ref": "1f5514cfa15b947298df4d771e694e578d4c204d"
},
"files": [
"src/DataFixtures/AppFixtures.php"
]
},
"doctrine/doctrine-migrations-bundle": {
"version": "4.0",
"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.94",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.39",
"ref": "97aaf9026490db73b86c23d49e5774bc89d2b232"
},
"files": [
".php-cs-fixer.dist.php"
]
},
"lexik/jwt-authentication-bundle": {
"version": "3.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.5",
"ref": "e9481b233a11ef7e15fe055a2b21fd3ac1aa2bb7"
},
"files": [
"config/packages/lexik_jwt_authentication.yaml"
]
},
"nelmio/cors-bundle": {
"version": "2.6",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.5",
"ref": "6bea22e6c564fba3a1391615cada1437d0bde39c"
},
"files": [
"config/packages/nelmio_cors.yaml"
]
},
"phpunit/phpunit": {
"version": "13.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "11.1",
"ref": "1117deb12541f35793eec9fff7494d7aa12283fc"
},
"files": [
".env.test",
"phpunit.dist.xml",
"tests/bootstrap.php",
"bin/phpunit"
]
},
"symfony/console": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "5.3",
"ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461"
},
"files": [
"bin/console"
]
},
"symfony/flex": {
"version": "2.10",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.4",
"ref": "52e9754527a15e2b79d9a610f98185a1fe46622a"
},
"files": [
".env",
".env.dev"
]
},
"symfony/framework-bundle": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.4",
"ref": "09f6e081c763a206802674ce0cb34a022f0ffc6d"
},
"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",
".editorconfig"
]
},
"symfony/property-info": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.3",
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
},
"files": [
"config/packages/property_info.yaml"
]
},
"symfony/routing": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.4",
"ref": "bc94c4fd86f393f3ab3947c18b830ea343e51ded"
},
"files": [
"config/packages/routing.yaml",
"config/routes.yaml"
]
},
"symfony/security-bundle": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.4",
"ref": "c42fee7802181cdd50f61b8622715829f5d2335c"
},
"files": [
"config/packages/security.yaml",
"config/routes/security.yaml"
]
},
"symfony/twig-bundle": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "cab5fd2a13a45c266d45a7d9337e28dee6272877"
},
"files": [
"config/packages/twig.yaml",
"templates/base.html.twig"
]
},
"symfony/uid": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "0df5844274d871b37fc3816c57a768ffc60a43a5"
}
},
"symfony/validator": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd"
},
"files": [
"config/packages/validator.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>

15
tests/bootstrap.php Normal file
View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
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(0o000);
}