```
[+] chore(root): Initialise le projet avec une structure de base Crée la structure de base du projet Symfony, incluant les entités, services, formulaires, et templates nécessaires pour la gestion des comptes utilisateurs, la sécurité, et la gestion des mots de passe oubliés. Ajoute également la configuration pour la gestion des assets avec Vite, la gestion des fichiers avec Flysystem, et la génération de sitemaps. ```
This commit is contained in:
41
.env
41
.env
@@ -15,17 +15,10 @@
|
||||
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
|
||||
|
||||
###> symfony/framework-bundle ###
|
||||
APP_ENV=dev
|
||||
APP_ENV=prod
|
||||
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 ###
|
||||
|
||||
###> 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
|
||||
@@ -33,7 +26,7 @@ DEFAULT_URI=http://localhost
|
||||
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.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://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
|
||||
DATABASE_URL="postgresql://symfony_user:ChangeMeInProd!@db:5432/app_db?serverVersion=16&charset=utf8"
|
||||
###< doctrine/doctrine-bundle ###
|
||||
|
||||
###> symfony/messenger ###
|
||||
@@ -44,5 +37,33 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
|
||||
###< symfony/messenger ###
|
||||
|
||||
###> symfony/mailer ###
|
||||
MAILER_DSN=null://null
|
||||
MAILER_DSN='smtp://mailhog:1025'
|
||||
###< symfony/mailer ###
|
||||
|
||||
###> nelmio/cors-bundle ###
|
||||
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
|
||||
###< nelmio/cors-bundle ###
|
||||
|
||||
VITE_LOAD=0
|
||||
REDIS_DSN="redis://redis:6379"
|
||||
REAL_MAIL=0
|
||||
PATH_URL=https://esyweb.local
|
||||
|
||||
DEV_URL=https://240fba7426df.ngrok-free.app
|
||||
VAPID_PK=DsOg7jToRSD-VpNSV1Gt3YAhSwz4l-nqeu7yFvzbSxg
|
||||
VAPID_PC=BKz0kdcsG6kk9KxciPpkfP8kEDAd408inZecij5kBDbQ1ZGZSNwS4KZ8FerC28LFXvgSqpDXtor3ePo0zBCdNqo
|
||||
|
||||
CLOUDFLARE_ZONE_ID=
|
||||
CLOUDFLARE_API_TOKEN=-h
|
||||
###> google/apiclient ###
|
||||
GOOGLE_API_KEY=
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_AUTH_CONFIG=%kernel.project_dir%/google.json
|
||||
GOOGLE_APPLICATION_CREDENTIALS=%kernel.project_dir%/google.json
|
||||
###< google/apiclient ###
|
||||
|
||||
###> sentry/sentry-symfony ###
|
||||
SENTRY_DSN=""
|
||||
###< sentry/sentry-symfony ###
|
||||
DEFAULT_URI=https://esyweb.local
|
||||
|
||||
23
.gitignore
vendored
23
.gitignore
vendored
@@ -1,12 +1,13 @@
|
||||
|
||||
###> symfony/framework-bundle ###
|
||||
/.env.local
|
||||
/.env.local.php
|
||||
/.env.*.local
|
||||
/.env.test
|
||||
/config/secrets/prod/prod.decrypt.private.php
|
||||
/public/bundles/
|
||||
/var/
|
||||
/vendor/
|
||||
/public/storage/
|
||||
/public/tmp/*.pdf
|
||||
###< symfony/framework-bundle ###
|
||||
|
||||
###> phpunit/phpunit ###
|
||||
@@ -15,6 +16,22 @@
|
||||
###< phpunit/phpunit ###
|
||||
|
||||
###> symfony/asset-mapper ###
|
||||
/public/assets/
|
||||
/assets/vendor/
|
||||
###< symfony/asset-mapper ###
|
||||
|
||||
.idea
|
||||
node_modules
|
||||
|
||||
###> phpstan/phpstan ###
|
||||
phpstan.neon
|
||||
###< phpstan/phpstan ###
|
||||
|
||||
###> liip/imagine-bundle ###
|
||||
/public/media/cache/
|
||||
###< liip/imagine-bundle ###
|
||||
.coverage
|
||||
coverage/
|
||||
.phpunit.cache
|
||||
/public/build
|
||||
bun.lock
|
||||
bun.lockd
|
||||
|
||||
1
CODEOWNERS
Normal file
1
CODEOWNERS
Normal file
@@ -0,0 +1 @@
|
||||
* jovann@siteconseil.fr
|
||||
12
CONTRIBUTING.md
Normal file
12
CONTRIBUTING.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# 🤐 Politique de Contribution
|
||||
|
||||
Ce projet est strictement confidentiel et **n'accepte aucune contribution externe**.
|
||||
|
||||
Aucune demande d'accès, pull request ou proposition de modification ne sera acceptée.
|
||||
L'accès au code source est limité aux membres autorisés explicitement par SARL SITECONSEIL.
|
||||
|
||||
Toute tentative d'accès non autorisé ou de diffusion du contenu de ce projet pourra faire l'objet de poursuites légales.
|
||||
|
||||
---
|
||||
|
||||
Pour toute question, veuillez contacter : [s.com@siteconseil.fr]
|
||||
2
assets/admin.js
Normal file
2
assets/admin.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import './admin.scss'
|
||||
import * as Turbo from "@hotwired/turbo"
|
||||
1
assets/admin.scss
Normal file
1
assets/admin.scss
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
602
assets/app.js
602
assets/app.js
@@ -1,10 +1,594 @@
|
||||
import './stimulus_bootstrap.js';
|
||||
/*
|
||||
* Welcome to your app's main JavaScript file!
|
||||
*
|
||||
* This file will be included onto the page via the importmap() Twig function,
|
||||
* which should already be in your base.html.twig.
|
||||
*/
|
||||
import './styles/app.css';
|
||||
import './app.scss'
|
||||
import * as Turbo from "@hotwired/turbo"
|
||||
|
||||
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');
|
||||
|
||||
// --- CLÉS DE STOCKAGE ET VAPID ---
|
||||
const VAPID_PUBLIC_KEY = "BKz0kdcsG6kk9KxciPpkfP8kEDAd408inZecij5kBDbQ1ZGZSNwS4KZ8FerC28LFXvgSqpDXtor3ePo0zBCdNqo";
|
||||
const COOKIE_STORAGE_KEY = 'cookies_accepted'; // Clé pour le consentement des cookies
|
||||
|
||||
|
||||
// --- MESSAGES ET TRADUCTIONS (fr et en) ---
|
||||
const MESSAGES = {
|
||||
fr: {
|
||||
// Notifications
|
||||
notificationTitle: "🔔 Activer les notifications",
|
||||
notificationText: "Recevez les nouvelles, les promotions et les événements de l'association.",
|
||||
notificationButton: "Activer",
|
||||
notificationClose: "Fermer la notification",
|
||||
|
||||
// Cookies
|
||||
cookieText: 'Ce site utilise uniquement des cookies de <strong>fonctionnement</strong> et de <strong>sécurité</strong> (e.g. Cloudflare) essentiels pour son bon fonctionnement. Aucune donnée personnelle n\'est collectée.',
|
||||
cookieLink: "Politique de cookies",
|
||||
cookieButton: "Accepter",
|
||||
},
|
||||
en: {
|
||||
// Notifications
|
||||
notificationTitle: "🔔 Enable Notifications",
|
||||
notificationText: "Receive news, promotions, and association events.",
|
||||
notificationButton: "Enable",
|
||||
notificationClose: "Close notification",
|
||||
|
||||
// Cookies
|
||||
cookieText: 'This website only uses <strong>functional</strong> and <strong>security</strong> cookies (e.g. Cloudflare) essential for its proper operation. No personal data is collected.',
|
||||
cookieLink: "Cookie Policy",
|
||||
cookieButton: "Accept",
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Détermine la langue à utiliser, en privilégiant l'attribut 'lang' du document HTML,
|
||||
* puis la langue du navigateur. Retourne l'ensemble de messages correspondant.
|
||||
* @returns {object} L'objet de messages pour la langue sélectionnée.
|
||||
*/
|
||||
function getLanguageMessages() {
|
||||
let langCode = 'fr'; // Français par défaut
|
||||
|
||||
// 1. Tente de lire la langue depuis l'attribut 'lang' du tag <html>
|
||||
const htmlLang = document.documentElement.lang;
|
||||
if (htmlLang) {
|
||||
// On prend les deux premiers caractères (ex: 'en-US' devient 'en')
|
||||
const normalizedHtmlLang = htmlLang.toLowerCase().substring(0, 2);
|
||||
if (normalizedHtmlLang === 'en') {
|
||||
langCode = 'en';
|
||||
}
|
||||
} else {
|
||||
// 2. Si l'attribut 'lang' est manquant, utilise la langue du navigateur
|
||||
const userLang = navigator.language || navigator.userLanguage;
|
||||
if (userLang && userLang.toLowerCase().startsWith('en')) {
|
||||
langCode = 'en';
|
||||
}
|
||||
}
|
||||
|
||||
return MESSAGES[langCode];
|
||||
}
|
||||
// --- FIN MESSAGES ET TRADUCTIONS ---
|
||||
|
||||
|
||||
/**
|
||||
* Convertit une chaîne Base64 URL Safe en Uint8Array.
|
||||
* Nécessaire pour passer la clé VAPID publique à pushManager.subscribe().
|
||||
* @param {string} base64String
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/\-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Fonction générique pour basculer la visibilité d'un menu déroulant.
|
||||
* @param {HTMLElement} button - Le bouton qui déclenche l'action.
|
||||
* @param {HTMLElement} menu - Le menu à afficher/masquer.
|
||||
*/
|
||||
function toggleMenu(button, menu) {
|
||||
if (!button || !menu) return;
|
||||
const isExpanded = button.getAttribute('aria-expanded') === 'true' || false;
|
||||
button.setAttribute('aria-expanded', !isExpanded);
|
||||
menu.classList.toggle('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fonction d'initialisation pour les composants qui DOIVENT être réinitialisés
|
||||
* après un chargement Turbo (comme les compteurs d'articles, les états initiaux).
|
||||
* Le menu mobile et le panier sont gérés par délégation d'événements.
|
||||
*/
|
||||
function initializeUI() {
|
||||
// Réinitialisation des états des menus cachés après un chargement Turbo
|
||||
document.querySelectorAll('#mobile-menu, #userMenuDesktop, #userMenuMobile').forEach(menu => {
|
||||
if (!menu.classList.contains('hidden')) {
|
||||
menu.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
document.querySelectorAll('#mobileMenuButton, #userMenuButtonDesktop, #userMenuButtonMobile').forEach(button => {
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
|
||||
// --- 2. Gestion du Panier Latéral (Off-Canvas) ---
|
||||
const cartSidebar = document.getElementById('cartSidebar');
|
||||
const cartBackdrop = document.getElementById('cartBackdrop');
|
||||
const closeCartButton = document.getElementById('closeCartButton');
|
||||
|
||||
// Mettez les fonctions ici pour qu'elles soient toujours définies si les éléments existent
|
||||
if (cartSidebar && cartBackdrop && closeCartButton) {
|
||||
function openCart() {
|
||||
document.body.style.overflow = 'hidden';
|
||||
cartBackdrop.classList.remove('hidden');
|
||||
cartSidebar.classList.remove('translate-x-full');
|
||||
cartSidebar.classList.add('translate-x-0');
|
||||
}
|
||||
|
||||
function closeCart() {
|
||||
document.body.style.overflow = '';
|
||||
cartSidebar.classList.remove('translate-x-0');
|
||||
cartSidebar.classList.add('translate-x-full');
|
||||
setTimeout(() => {
|
||||
cartBackdrop.classList.add('hidden');
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Stocker les fonctions dans une variable globale accessible par l'écouteur du document
|
||||
window.openCart = openCart;
|
||||
window.closeCart = closeCart;
|
||||
} else {
|
||||
// Sécurité si les éléments du panier n'existent pas
|
||||
window.openCart = null;
|
||||
window.closeCart = null;
|
||||
}
|
||||
|
||||
|
||||
// --- 3. Logique Panier Mock (Affichage du compteur) ---
|
||||
function updateCartDisplay(count) {
|
||||
const desktopCounter = document.getElementById('cartCountDesktop');
|
||||
const mobileCounter = document.getElementById('cartCountMobile');
|
||||
|
||||
if (desktopCounter) desktopCounter.textContent = count;
|
||||
if (mobileCounter) mobileCounter.textContent = count;
|
||||
}
|
||||
|
||||
// Simuler un panier non-vide au chargement (Mettre 0 pour un panier vide réel)
|
||||
updateCartDisplay(0);
|
||||
|
||||
// --- 4. Vérification de l'abonnement push au chargement ---
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
subscribeAndSave();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tente d'abonner l'utilisateur aux notifications push via le Service Worker
|
||||
* et envoie l'objet d'abonnement au backend (/notificationSub).
|
||||
*/
|
||||
async function subscribeAndSave() {
|
||||
if (!('Notification' in window) || Notification.permission !== 'granted') {
|
||||
console.log("Les notifications ne sont pas supportées ou la permission n'est pas accordée.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
console.error("Le Service Worker ou PushManager n'est pas disponible pour l'abonnement.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
|
||||
// 1. Si aucun abonnement n'existe, on essaie d'en créer un.
|
||||
if (!subscription) {
|
||||
console.log("Aucun abonnement existant trouvé. Tentative de nouvel abonnement...");
|
||||
|
||||
const applicationServerKey = urlBase64ToUint8Array(VAPID_PUBLIC_KEY);
|
||||
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: applicationServerKey
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
console.error("Échec de la création de l'abonnement. Le Service Worker est-il correctement enregistré et la clé VAPID valide ?");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
console.log("Abonnement push existant trouvé. Vérification/Mise à jour auprès du serveur.");
|
||||
}
|
||||
|
||||
// 2. Envoi (ou mise à jour) de l'abonnement au backend
|
||||
const payload = { subscription: subscription.toJSON() };
|
||||
|
||||
const response = await fetch('/notificationSub', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log("-> Abonnement aux notifications sauvegardé (ou vérifié) avec succès sur le serveur. <-");
|
||||
} else {
|
||||
console.error("-> Erreur lors de la sauvegarde de l'abonnement:", response.status, response.statusText);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de l'obtention de l'abonnement ou de l'enregistrement:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tente de demander la permission si nécessaire et d'appeler l'abonnement.
|
||||
* (Utilisée par le clic du bandeau)
|
||||
*/
|
||||
async function promptForPermissionAndSubscribe() {
|
||||
if (!('Notification' in window)) {
|
||||
console.error("Les Notifications ne sont pas supportées par ce navigateur.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Demander la permission
|
||||
try {
|
||||
const permission = await Notification.requestPermission();
|
||||
|
||||
if (permission === 'granted') {
|
||||
console.log("-> Permission de notification accordée. Lancement de l'abonnement. <-");
|
||||
await subscribeAndSave();
|
||||
} else {
|
||||
console.log(`-> Permission de notification refusée ou ignorée (${permission}). <-`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de la demande de permission:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// --- NOUVELLE FONCTION DE DÉTECTION POUR PAGESPEED/LIGHTHOUSE ---
|
||||
// ====================================================================
|
||||
|
||||
/**
|
||||
* Vérifie si le script est exécuté par un User Agent de test (e.g., Lighthouse/PageSpeed)
|
||||
* @returns {boolean} True si c'est un User Agent de test.
|
||||
*/
|
||||
function isPerformanceTestAgent() {
|
||||
const ua = navigator.userAgent;
|
||||
// Vérifie les User Agents connus des outils de performance
|
||||
return ua.includes('Lighthouse') || ua.includes('Chrome-Lighthouse') || ua.includes('PageSpeed') || ua.includes('moto g power') || ua.includes('Intel Mac OS X 10_15_7');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Affiche une petite carte de notification push temporaire en bas à gauche.
|
||||
* N'affiche QUE si la permission n'est PAS accordée.
|
||||
*/
|
||||
function handleNotificationBanner() {
|
||||
// 0. MASQUAGE POUR PAGESPEED
|
||||
if (isPerformanceTestAgent()) {
|
||||
console.log("Notification Banner skipped for performance test agent.");
|
||||
return;
|
||||
}
|
||||
// --- FIN MASQUAGE ---
|
||||
|
||||
const BANNER_ID = 'notification-prompt-banner';
|
||||
const DURATION_MS = 15000; // 15 secondes d'affichage
|
||||
const M = getLanguageMessages(); // Récupère les messages traduits
|
||||
|
||||
// 1. NE PAS AFFICHER si la permission est déjà accordée
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Si le bandeau existe déjà, on quitte.
|
||||
if (document.getElementById(BANNER_ID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Créer le conteneur du message
|
||||
const banner = document.createElement('div');
|
||||
banner.id = BANNER_ID;
|
||||
banner.className = `fixed bottom-4 left-4 z-50 p-4 max-w-xs
|
||||
bg-indigo-600 text-white rounded-xl shadow-2xl
|
||||
transition-all duration-500 transform
|
||||
opacity-0 translate-y-full
|
||||
md:left-8 md:bottom-8`; // Style initial (masqué)
|
||||
|
||||
banner.innerHTML = `
|
||||
<div class="flex items-start justify-between">
|
||||
<p class="font-semibold text-sm leading-snug">
|
||||
${M.notificationTitle}
|
||||
</p>
|
||||
<button id="closeNotificationBanner"
|
||||
aria-label="${M.notificationClose}"
|
||||
class="ml-3 -mt-1 p-1 rounded-full text-indigo-200 hover:text-white hover:bg-indigo-700 transition">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x"><path d="M18 6L6 18"/><path d="M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-indigo-200">
|
||||
${M.notificationText}
|
||||
</p>
|
||||
<button id="activateNotifications"
|
||||
class="mt-3 w-full text-center py-2 bg-white text-indigo-600 font-bold text-sm rounded-lg
|
||||
shadow hover:bg-gray-100 transition transform hover:scale-[1.02]">
|
||||
${M.notificationButton}
|
||||
</button>
|
||||
`;
|
||||
|
||||
document.body.appendChild(banner);
|
||||
|
||||
// 4. Fonctions d'animation et de gestion
|
||||
const hideBanner = () => {
|
||||
// Déclenche l'animation de disparition
|
||||
banner.classList.remove('opacity-100', 'translate-y-0');
|
||||
banner.classList.add('opacity-0', 'translate-y-full');
|
||||
// Supprime après l'animation pour nettoyer le DOM
|
||||
setTimeout(() => {
|
||||
if (document.body.contains(banner)) {
|
||||
document.body.removeChild(banner);
|
||||
}
|
||||
}, 600);
|
||||
};
|
||||
|
||||
// Clic sur le bouton de fermeture
|
||||
document.getElementById('closeNotificationBanner').addEventListener('click', () => {
|
||||
hideBanner();
|
||||
});
|
||||
|
||||
// Clic sur le bouton d'activation -> Logique Push
|
||||
document.getElementById('activateNotifications').addEventListener('click', async () => {
|
||||
await promptForPermissionAndSubscribe();
|
||||
// Fermer le bandeau après l'interaction (que ce soit accordé ou refusé)
|
||||
hideBanner();
|
||||
});
|
||||
|
||||
// 5. Affichage et Timer
|
||||
|
||||
// Montre le bandeau (déclencher l'animation)
|
||||
setTimeout(() => {
|
||||
banner.classList.remove('opacity-0', 'translate-y-full');
|
||||
banner.classList.add('opacity-100', 'translate-y-0');
|
||||
}, 100);
|
||||
|
||||
// Cache le bandeau automatiquement après la durée définie
|
||||
setTimeout(hideBanner, DURATION_MS);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Affiche le bandeau de consentement aux cookies en bas à droite s'il n'a jamais été accepté.
|
||||
*/
|
||||
function handleCookieBanner() {
|
||||
// 0. MASQUAGE POUR PAGESPEED
|
||||
if (isPerformanceTestAgent()) {
|
||||
console.log("Cookie Banner skipped for performance test agent.");
|
||||
return;
|
||||
}
|
||||
// --- FIN MASQUAGE ---
|
||||
|
||||
const BANNER_ID = 'cookie-banner';
|
||||
const M = getLanguageMessages(); // Récupère les messages traduits
|
||||
|
||||
// 1. NE PAS AFFICHER si le consentement est déjà enregistré
|
||||
if (localStorage.getItem(COOKIE_STORAGE_KEY) === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Si le bandeau existe déjà, on quitte.
|
||||
if (document.getElementById(BANNER_ID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Créer le conteneur du message (position bottom-right)
|
||||
const banner = document.createElement('div');
|
||||
banner.id = BANNER_ID;
|
||||
banner.className = `fixed bottom-4 right-4 z-50 p-4 max-w-sm
|
||||
bg-white text-gray-800 rounded-xl shadow-2xl
|
||||
transition-all duration-500 transform
|
||||
opacity-0 translate-y-full
|
||||
border border-gray-100
|
||||
md:right-8 md:bottom-8`; // Style initial (masqué)
|
||||
|
||||
// Utilisation de M.cookieText, M.cookieLink et M.cookieButton
|
||||
// MODIFICATION APPORTÉE ICI : Ajout de py-2 (padding vertical) au lien "En savoir plus"
|
||||
banner.innerHTML = `
|
||||
<p class="text-sm leading-relaxed">
|
||||
${M.cookieText}
|
||||
</p>
|
||||
<div class="mt-4 flex justify-end items-center">
|
||||
<a href="/cookies" class="py-2 font-medium text-indigo-600 hover:text-indigo-800 transition mr-4">
|
||||
${M.cookieLink}
|
||||
</a>
|
||||
<button id="acceptCookies"
|
||||
class="py-2 px-4 bg-indigo-600 text-white font-bold text-sm rounded-lg
|
||||
shadow-md hover:bg-indigo-700 transition transform hover:scale-[1.02]">
|
||||
${M.cookieButton}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(banner);
|
||||
|
||||
// 4. Fonctions d'animation et de gestion
|
||||
const hideBanner = () => {
|
||||
// Déclenche l'animation de disparition
|
||||
banner.classList.remove('opacity-100', 'translate-y-0');
|
||||
banner.classList.add('opacity-0', 'translate-y-full');
|
||||
// Supprime après l'animation
|
||||
setTimeout(() => {
|
||||
if (document.body.contains(banner)) {
|
||||
document.body.removeChild(banner);
|
||||
}
|
||||
}, 600);
|
||||
};
|
||||
|
||||
// Clic sur le bouton d'acceptation
|
||||
document.getElementById('acceptCookies').addEventListener('click', () => {
|
||||
localStorage.setItem(COOKIE_STORAGE_KEY, 'true');
|
||||
hideBanner();
|
||||
});
|
||||
|
||||
// 5. Affichage
|
||||
// Montre le bandeau (déclencher l'animation)
|
||||
setTimeout(() => {
|
||||
banner.classList.remove('opacity-0', 'translate-y-full');
|
||||
banner.classList.add('opacity-100', 'translate-y-0');
|
||||
}, 100);
|
||||
}
|
||||
|
||||
|
||||
// --- INITIALISATION DES COMPOSANTS APRÈS TURBO/CHARGEMENT ---
|
||||
document.addEventListener('DOMContentLoaded', ()=>{
|
||||
// initializeUI appelle subscribeAndSave si la permission est accordée
|
||||
initializeUI()
|
||||
if (!isPerformanceTestAgent()) {
|
||||
|
||||
// Gère le bandeau de cookies (bottom-right)
|
||||
handleCookieBanner()
|
||||
}
|
||||
/*if (!isPerformanceTestAgent()) {
|
||||
var BASE_URL_WOOT = "https://app.chatwoot.com";
|
||||
let script = document.createElement('script');
|
||||
script.setAttribute('src', BASE_URL_WOOT + "/packs/js/sdk.js")
|
||||
script.setAttribute('sync', true)
|
||||
document.head.append(script)
|
||||
script.onload = function () {
|
||||
window.chatwootSDK.run({
|
||||
websiteToken: '8SXvcdoWJVA77hug4mT5JhAP',
|
||||
baseUrl: BASE_URL_WOOT
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const env = document.querySelector('meta[name="env"]')
|
||||
if(env.getAttribute('content') == "prod") {
|
||||
if (typeof navigator.serviceWorker !== 'undefined') {
|
||||
// Assurez-vous que le Service Worker est bien enregistré en mode prod
|
||||
navigator.serviceWorker.register('sw.js')
|
||||
}
|
||||
}*/
|
||||
});
|
||||
document.addEventListener('turbo:load', () => {
|
||||
initializeUI();
|
||||
handleNotificationBanner();
|
||||
handleCookieBanner(); // Doit être appelé à chaque chargement Turbo
|
||||
});
|
||||
|
||||
|
||||
// ====================================================================
|
||||
// --- DÉLÉGATION D'ÉVÉNEMENTS (Gestion des clics une seule fois) ---
|
||||
// ====================================================================
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const target = event.target;
|
||||
|
||||
// --- 1. GESTION DU MENU MOBILE (Burger) ---
|
||||
const mobileMenuButton = document.getElementById('mobileMenuButton');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
|
||||
// On vérifie si la cible cliquée est le bouton ou un de ses enfants
|
||||
if (mobileMenuButton && mobileMenu && (target === mobileMenuButton || mobileMenuButton.contains(target))) {
|
||||
event.preventDefault();
|
||||
toggleMenu(mobileMenuButton, mobileMenu);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- 2. GESTION DU MENU UTILISATEUR (Dropdown) ---
|
||||
const userMenuButtonDesktop = document.getElementById('userMenuButtonDesktop');
|
||||
const userMenuDesktop = document.getElementById('userMenuDesktop');
|
||||
const userMenuButtonMobile = document.getElementById('userMenuButtonMobile');
|
||||
const userMenuMobile = document.getElementById('userMenuMobile');
|
||||
|
||||
// Ouverture/Fermeture du menu utilisateur Desktop
|
||||
if (userMenuButtonDesktop && userMenuDesktop && (target === userMenuButtonDesktop || userMenuButtonDesktop.contains(target))) {
|
||||
event.preventDefault();
|
||||
// S'assurer que les autres menus sont fermés
|
||||
userMenuMobile.classList.add('hidden');
|
||||
toggleMenu(userMenuButtonDesktop, userMenuDesktop);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ouverture/Fermeture du menu utilisateur Mobile
|
||||
if (userMenuButtonMobile && userMenuMobile && (target === userMenuButtonMobile || userMenuButtonMobile.contains(target))) {
|
||||
event.preventDefault();
|
||||
// S'assurer que les autres menus sont fermés
|
||||
userMenuDesktop.classList.add('hidden');
|
||||
toggleMenu(userMenuButtonMobile, userMenuMobile);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fermeture des menus s'il y a un clic en dehors
|
||||
const isClickInsideDesktopMenu = userMenuDesktop && (userMenuDesktop.contains(target) || userMenuButtonDesktop.contains(target));
|
||||
const isClickInsideMobileMenu = userMenuMobile && (userMenuMobile.contains(target) || userMenuButtonMobile.contains(target));
|
||||
|
||||
if (userMenuDesktop && userMenuButtonDesktop && !isClickInsideDesktopMenu) {
|
||||
userMenuDesktop.classList.add('hidden');
|
||||
userMenuButtonDesktop.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
if (userMenuMobile && userMenuButtonMobile && !isClickInsideMobileMenu) {
|
||||
userMenuMobile.classList.add('hidden');
|
||||
userMenuButtonMobile.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
|
||||
// --- 3. GESTION DE L'OUVERTURE ET FERMETURE DU PANIER ---
|
||||
const openCartDesktop = document.getElementById('openCartDesktop');
|
||||
const openCartMobile = document.getElementById('openCartMobile');
|
||||
const closeCartButton = document.getElementById('closeCartButton');
|
||||
const cartBackdrop = document.getElementById('cartBackdrop');
|
||||
|
||||
// Ouverture (Desktop ou Mobile)
|
||||
if (window.openCart && (
|
||||
(openCartDesktop && (target === openCartDesktop || openCartDesktop.contains(target))) ||
|
||||
(openCartMobile && (target === openCartMobile || openCartMobile.contains(target)))
|
||||
)) {
|
||||
event.preventDefault();
|
||||
window.openCart();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fermeture (Bouton interne)
|
||||
if (window.closeCart && closeCartButton && (target === closeCartButton || closeCartButton.contains(target))) {
|
||||
event.preventDefault();
|
||||
window.closeCart();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fermeture (Cliquer sur le fond/backdrop)
|
||||
if (window.closeCart && target === cartBackdrop) {
|
||||
window.closeCart();
|
||||
return;
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
// --- GESTION GLOBALE DE LA TOUCHE ESC (Une seule fois) ---
|
||||
document.addEventListener('keydown', (event) => {
|
||||
const cartSidebar = document.getElementById('cartSidebar');
|
||||
// Fermer le panier
|
||||
if (cartSidebar && window.closeCart && event.key === 'Escape' && !cartSidebar.classList.contains('translate-x-full')) {
|
||||
window.closeCart();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fermer les menus utilisateur
|
||||
const userMenuDesktop = document.getElementById('userMenuDesktop');
|
||||
const userMenuButtonDesktop = document.getElementById('userMenuButtonDesktop');
|
||||
const userMenuMobile = document.getElementById('userMenuMobile');
|
||||
const userMenuButtonMobile = document.getElementById('userMenuButtonMobile');
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
if (userMenuDesktop && !userMenuDesktop.classList.contains('hidden')) {
|
||||
toggleMenu(userMenuButtonDesktop, userMenuDesktop);
|
||||
return;
|
||||
}
|
||||
if (userMenuMobile && !userMenuMobile.classList.contains('hidden')) {
|
||||
toggleMenu(userMenuButtonMobile, userMenuMobile);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
1
assets/app.scss
Normal file
1
assets/app.scss
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"controllers": {
|
||||
"@symfony/ux-turbo": {
|
||||
"turbo-core": {
|
||||
"enabled": true,
|
||||
"fetch": "eager"
|
||||
},
|
||||
"mercure-turbo-stream": {
|
||||
"enabled": false,
|
||||
"fetch": "eager"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entrypoints": []
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/;
|
||||
const tokenCheck = /^[-_/+a-zA-Z0-9]{24,}$/;
|
||||
|
||||
// Generate and double-submit a CSRF token in a form field and a cookie, as defined by Symfony's SameOriginCsrfTokenManager
|
||||
// Use `form.requestSubmit()` to ensure that the submit event is triggered. Using `form.submit()` will not trigger the event
|
||||
// and thus this event-listener will not be executed.
|
||||
document.addEventListener('submit', function (event) {
|
||||
generateCsrfToken(event.target);
|
||||
}, true);
|
||||
|
||||
// When @hotwired/turbo handles form submissions, send the CSRF token in a header in addition to a cookie
|
||||
// The `framework.csrf_protection.check_header` config option needs to be enabled for the header to be checked
|
||||
document.addEventListener('turbo:submit-start', function (event) {
|
||||
const h = generateCsrfHeaders(event.detail.formSubmission.formElement);
|
||||
Object.keys(h).map(function (k) {
|
||||
event.detail.formSubmission.fetchRequest.headers[k] = h[k];
|
||||
});
|
||||
});
|
||||
|
||||
// When @hotwired/turbo handles form submissions, remove the CSRF cookie once a form has been submitted
|
||||
document.addEventListener('turbo:submit-end', function (event) {
|
||||
removeCsrfToken(event.detail.formSubmission.formElement);
|
||||
});
|
||||
|
||||
export function generateCsrfToken (formElement) {
|
||||
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
|
||||
|
||||
if (!csrfField) {
|
||||
return;
|
||||
}
|
||||
|
||||
let csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
|
||||
let csrfToken = csrfField.value;
|
||||
|
||||
if (!csrfCookie && nameCheck.test(csrfToken)) {
|
||||
csrfField.setAttribute('data-csrf-protection-cookie-value', csrfCookie = csrfToken);
|
||||
csrfField.defaultValue = csrfToken = btoa(String.fromCharCode.apply(null, (window.crypto || window.msCrypto).getRandomValues(new Uint8Array(18))));
|
||||
}
|
||||
csrfField.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
if (csrfCookie && tokenCheck.test(csrfToken)) {
|
||||
const cookie = csrfCookie + '_' + csrfToken + '=' + csrfCookie + '; path=/; samesite=strict';
|
||||
document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie;
|
||||
}
|
||||
}
|
||||
|
||||
export function generateCsrfHeaders (formElement) {
|
||||
const headers = {};
|
||||
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
|
||||
|
||||
if (!csrfField) {
|
||||
return headers;
|
||||
}
|
||||
|
||||
const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
|
||||
|
||||
if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
|
||||
headers[csrfCookie] = csrfField.value;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function removeCsrfToken (formElement) {
|
||||
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
|
||||
|
||||
if (!csrfField) {
|
||||
return;
|
||||
}
|
||||
|
||||
const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
|
||||
|
||||
if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
|
||||
const cookie = csrfCookie + '_' + csrfField.value + '=0; path=/; samesite=strict; max-age=0';
|
||||
|
||||
document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie;
|
||||
}
|
||||
}
|
||||
|
||||
/* stimulusFetch: 'lazy' */
|
||||
export default 'csrf-protection-controller';
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
/*
|
||||
* This is an example Stimulus controller!
|
||||
*
|
||||
* Any element with a data-controller="hello" attribute will cause
|
||||
* this controller to be executed. The name "hello" comes from the filename:
|
||||
* hello_controller.js -> "hello"
|
||||
*
|
||||
* Delete this file or adapt it for your use!
|
||||
*/
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
this.element.textContent = 'Hello Stimulus! Edit me in assets/controllers/hello_controller.js';
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { startStimulusApp } from '@symfony/stimulus-bundle';
|
||||
|
||||
const app = startStimulusApp();
|
||||
// register any custom, 3rd party controllers here
|
||||
// app.register('some_controller_name', SomeImportedController);
|
||||
@@ -1,3 +0,0 @@
|
||||
body {
|
||||
background-color: skyblue;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
|
||||
services:
|
||||
###> doctrine/doctrine-bundle ###
|
||||
database:
|
||||
ports:
|
||||
- "5432"
|
||||
###< doctrine/doctrine-bundle ###
|
||||
|
||||
###> symfony/mailer ###
|
||||
mailer:
|
||||
image: axllent/mailpit
|
||||
ports:
|
||||
- "1025"
|
||||
- "8025"
|
||||
environment:
|
||||
MP_SMTP_AUTH_ACCEPT_ANY: 1
|
||||
MP_SMTP_AUTH_ALLOW_INSECURE: 1
|
||||
###< symfony/mailer ###
|
||||
25
compose.yaml
25
compose.yaml
@@ -1,25 +0,0 @@
|
||||
|
||||
services:
|
||||
###> 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}
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-d", "${POSTGRES_DB:-app}", "-U", "${POSTGRES_USER:-app}"]
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
volumes:
|
||||
- database_data:/var/lib/postgresql/data:rw
|
||||
# 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 ###
|
||||
|
||||
volumes:
|
||||
###> doctrine/doctrine-bundle ###
|
||||
database_data:
|
||||
###< doctrine/doctrine-bundle ###
|
||||
128
composer.json
128
composer.json
@@ -6,43 +6,81 @@
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"ext-ctype": "*",
|
||||
"ext-dom": "*",
|
||||
"ext-iconv": "*",
|
||||
"doctrine/doctrine-bundle": "^2.18",
|
||||
"doctrine/doctrine-migrations-bundle": "^3.7",
|
||||
"doctrine/orm": "^3.5",
|
||||
"phpdocumentor/reflection-docblock": "^5.6",
|
||||
"ext-libxml": "*",
|
||||
"ext-zip": "*",
|
||||
"chillerlan/php-qrcode": ">=5.0.5",
|
||||
"cocur/slugify": ">=4.6",
|
||||
"doctrine/dbal": "^3.10.3",
|
||||
"doctrine/doctrine-bundle": "^2.18.1",
|
||||
"doctrine/doctrine-migrations-bundle": "^3.7.0",
|
||||
"doctrine/orm": "^3.5.7",
|
||||
"docusealco/docuseal-php": "^1.0.5",
|
||||
"endroid/qr-code": ">=6.0.9",
|
||||
"exbil/mailcow-php-api": ">=0.15.0",
|
||||
"fpdf/fpdf": ">=1.86",
|
||||
"google/apiclient": "^2.18.4",
|
||||
"google/cloud": "^0.296.0",
|
||||
"healey/robots": "^1.0.1",
|
||||
"imagine/imagine": "^1.5",
|
||||
"io-developer/php-whois": ">=4.1.10",
|
||||
"knplabs/knp-paginator-bundle": "^6.9.1",
|
||||
"lasserafn/php-initial-avatar-generator": "^4.5",
|
||||
"league/flysystem-aws-s3-v3": "^3.30.1",
|
||||
"league/flysystem-bundle": "^3.6",
|
||||
"liip/imagine-bundle": "^2.15",
|
||||
"lufiipe/insee-sierene": ">=1",
|
||||
"minishlink/web-push": "^9.0.3",
|
||||
"mittwald/vault-php": "^3.0.2",
|
||||
"mobiledetect/mobiledetectlib": "^4.8.09",
|
||||
"nelmio/cors-bundle": "^2.6",
|
||||
"ovh/ovh": ">=3.5",
|
||||
"pear/net_dns2": ">=2.0.7",
|
||||
"phpdocumentor/reflection-docblock": "^5.6.4",
|
||||
"phpoffice/phpspreadsheet": ">=5.3",
|
||||
"phpstan/phpdoc-parser": "^2.3",
|
||||
"symfony/asset": "7.4.*",
|
||||
"symfony/asset-mapper": "7.4.*",
|
||||
"symfony/console": "7.4.*",
|
||||
"symfony/doctrine-messenger": "7.4.*",
|
||||
"symfony/dotenv": "7.4.*",
|
||||
"symfony/expression-language": "7.4.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/form": "7.4.*",
|
||||
"symfony/framework-bundle": "7.4.*",
|
||||
"symfony/http-client": "7.4.*",
|
||||
"symfony/intl": "7.4.*",
|
||||
"symfony/mailer": "7.4.*",
|
||||
"symfony/mime": "7.4.*",
|
||||
"symfony/monolog-bundle": "^3.0|^4.0",
|
||||
"symfony/notifier": "7.4.*",
|
||||
"symfony/process": "7.4.*",
|
||||
"symfony/property-access": "7.4.*",
|
||||
"symfony/property-info": "7.4.*",
|
||||
"symfony/runtime": "7.4.*",
|
||||
"symfony/security-bundle": "7.4.*",
|
||||
"symfony/serializer": "7.4.*",
|
||||
"symfony/stimulus-bundle": "^2.31",
|
||||
"symfony/string": "7.4.*",
|
||||
"symfony/translation": "7.4.*",
|
||||
"symfony/twig-bundle": "7.4.*",
|
||||
"symfony/ux-turbo": "^2.31",
|
||||
"symfony/validator": "7.4.*",
|
||||
"symfony/web-link": "7.4.*",
|
||||
"symfony/yaml": "7.4.*",
|
||||
"twig/extra-bundle": "^2.12|^3.0",
|
||||
"twig/twig": "^2.12|^3.0"
|
||||
"presta/sitemap-bundle": "^4.2",
|
||||
"sentry/sentry-symfony": "^5.6",
|
||||
"setasign/fpdi": "^2.6.4",
|
||||
"spatie/mjml-php": "^1.2.5",
|
||||
"stancer/stancer": ">=2.0.1",
|
||||
"symfony/amazon-mailer": "7.3.*",
|
||||
"symfony/asset": "7.3.*",
|
||||
"symfony/asset-mapper": "7.3.*",
|
||||
"symfony/console": "7.3.*",
|
||||
"symfony/doctrine-messenger": "7.3.*",
|
||||
"symfony/dotenv": "7.3.*",
|
||||
"symfony/expression-language": "7.3.*",
|
||||
"symfony/flex": "^2.10.0",
|
||||
"symfony/form": "7.3.*",
|
||||
"symfony/framework-bundle": "7.3.*",
|
||||
"symfony/http-client": "7.3.*",
|
||||
"symfony/intl": "7.3.*",
|
||||
"symfony/mailer": "7.3.*",
|
||||
"symfony/mime": "7.3.*",
|
||||
"symfony/monolog-bundle": "^3.10",
|
||||
"symfony/notifier": "7.3.*",
|
||||
"symfony/process": "7.3.*",
|
||||
"symfony/property-access": "7.3.*",
|
||||
"symfony/property-info": "7.3.*",
|
||||
"symfony/redis-messenger": "7.3.*",
|
||||
"symfony/runtime": "7.3.*",
|
||||
"symfony/security-bundle": "7.3.*",
|
||||
"symfony/serializer": "7.3.*",
|
||||
"symfony/string": "7.3.*",
|
||||
"symfony/translation": "7.3.*",
|
||||
"symfony/twig-bundle": "7.3.*",
|
||||
"symfony/uid": "7.3.*",
|
||||
"symfony/validator": "7.3.*",
|
||||
"symfony/web-link": "7.3.*",
|
||||
"symfony/yaml": "7.3.*",
|
||||
"tecnickcom/tcpdf": "^6.10.1",
|
||||
"twig/extra-bundle": "^3.22.1",
|
||||
"twig/intl-extra": "^3.22.1",
|
||||
"twig/twig": "^3.22",
|
||||
"vich/uploader-bundle": "^2.8.1",
|
||||
"web-auth/webauthn-lib": ">=5.2.2"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
@@ -91,17 +129,19 @@
|
||||
},
|
||||
"extra": {
|
||||
"symfony": {
|
||||
"allow-contrib": false,
|
||||
"require": "7.4.*"
|
||||
"allow-contrib": true,
|
||||
"require": "7.3.*"
|
||||
}
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^12.5",
|
||||
"symfony/browser-kit": "7.4.*",
|
||||
"symfony/css-selector": "7.4.*",
|
||||
"symfony/debug-bundle": "7.4.*",
|
||||
"symfony/maker-bundle": "^1.0",
|
||||
"symfony/stopwatch": "7.4.*",
|
||||
"symfony/web-profiler-bundle": "7.4.*"
|
||||
"fakerphp/faker": "^1.24.1",
|
||||
"phpunit/phpunit": "^12.4.4",
|
||||
"rector/rector": "^2.2.8",
|
||||
"symfony/browser-kit": "7.3.*",
|
||||
"symfony/css-selector": "7.3.*",
|
||||
"symfony/debug-bundle": "7.3.*",
|
||||
"symfony/maker-bundle": "^1.65",
|
||||
"symfony/stopwatch": "7.3.*",
|
||||
"symfony/web-profiler-bundle": "7.3.*"
|
||||
}
|
||||
}
|
||||
|
||||
20604
composer.lock
generated
20604
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -7,10 +7,15 @@ return [
|
||||
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
|
||||
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
||||
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
|
||||
Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true],
|
||||
Symfony\UX\Turbo\TurboBundle::class => ['all' => true],
|
||||
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
|
||||
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
|
||||
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
|
||||
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
|
||||
Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true],
|
||||
League\FlysystemBundle\FlysystemBundle::class => ['all' => true],
|
||||
Liip\ImagineBundle\LiipImagineBundle::class => ['all' => true],
|
||||
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
|
||||
Presta\SitemapBundle\PrestaSitemapBundle::class => ['all' => true],
|
||||
Sentry\SentryBundle\SentryBundle::class => ['prod' => true],
|
||||
Vich\UploaderBundle\VichUploaderBundle::class => ['all' => true],
|
||||
];
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
framework:
|
||||
cache:
|
||||
# Unique name of your app: used to compute stable namespaces for cache keys.
|
||||
#prefix_seed: your_vendor_name/app_name
|
||||
# Nom unique de votre application : utilisé pour calculer des espaces de noms stables pour les clés de cache.
|
||||
# Ceci est CRUCIAL pour éviter les collisions de clés si plusieurs applications partagent le même serveur de cache (ex: Redis).
|
||||
# Décommentez et remplacez par une valeur unique à votre projet (ex: "mon_entreprise/mon_app")
|
||||
prefix_seed: 'e-page' # <-- REMPLACEZ CECI PAR UN NOM UNIQUE À VOTRE PROJET
|
||||
|
||||
# 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
|
||||
# En production, utilisez un adaptateur de cache rapide et performant comme Redis.
|
||||
# Assurez-vous que votre serveur Redis est accessible.
|
||||
app: cache.adapter.redis
|
||||
default_redis_provider: '%env(REDIS_DSN)%'
|
||||
# Vous pouvez également optimiser les pools personnalisés pour la production si besoin.
|
||||
pools:
|
||||
my.user_data_cache:
|
||||
adapter: cache.adapter.redis
|
||||
my.api_data_cache:
|
||||
adapter: cache.adapter.redis
|
||||
vite_cache_pool:
|
||||
adapter: cache.adapter.redis
|
||||
|
||||
7
config/packages/flysystem.yaml
Normal file
7
config/packages/flysystem.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
# Read the documentation at https://github.com/thephpleague/flysystem-bundle/blob/master/docs/1-getting-started.md
|
||||
flysystem:
|
||||
storages:
|
||||
default.storage:
|
||||
adapter: 'local'
|
||||
options:
|
||||
directory: '%kernel.project_dir%/var/storage/default'
|
||||
@@ -2,11 +2,20 @@
|
||||
framework:
|
||||
secret: '%env(APP_SECRET)%'
|
||||
|
||||
# Note that the session will be started ONLY if you read or write from it.
|
||||
session: true
|
||||
http_cache:
|
||||
enabled: true
|
||||
default_ttl: 3600
|
||||
stale_while_revalidate: 3600
|
||||
stale_if_error: 3600
|
||||
session:
|
||||
name: crm_session
|
||||
cookie_lifetime: 3600
|
||||
cookie_secure: true
|
||||
|
||||
#esi: true
|
||||
#fragments: true
|
||||
trusted_proxies: '103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,104.16.0.0/13,104.24.0.0/14,108.162.192.0/18,131.0.72.0/22,141.101.64.0/18,162.158.0.0/15,172.64.0.0/13,173.245.48.0/20,188.114.96.0/20,190.93.240.0/20,197.234.240.0/22,198.41.128.0/17'
|
||||
trusted_headers: [ 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port', 'x-forwarded-prefix' ]
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
|
||||
11
config/packages/google_apiclient.yaml
Normal file
11
config/packages/google_apiclient.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
services:
|
||||
Google\Client:
|
||||
class: Google\Client
|
||||
calls:
|
||||
# Authentication with "API key"
|
||||
- [setDeveloperKey, ['%env(GOOGLE_API_KEY)%']]
|
||||
# Authentication with "OAuth 2.0" using Client ID & Secret
|
||||
- [setClientId, ['%env(GOOGLE_CLIENT_ID)%']]
|
||||
- [setClientSecret, ['%env(GOOGLE_CLIENT_SECRET)%']]
|
||||
# Authentication with "OAuth 2.0" or "Service account" using JSON
|
||||
- [setAuthConfig, ['%env(resolve:GOOGLE_AUTH_CONFIG)%']]
|
||||
16
config/packages/liip_imagine.yaml
Normal file
16
config/packages/liip_imagine.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
liip_imagine:
|
||||
driver: "gd"
|
||||
twig:
|
||||
mode: lazy
|
||||
filter_sets:
|
||||
webp:
|
||||
quality: 85
|
||||
format: 'webp'
|
||||
filters:
|
||||
strip: ~
|
||||
logo:
|
||||
quality: 85
|
||||
format: 'webp'
|
||||
filters:
|
||||
strip: ~
|
||||
thumbnail: { size: [ 99, 56 ], mode: inset }
|
||||
@@ -1,29 +1,8 @@
|
||||
# config/packages/messenger.yaml
|
||||
framework:
|
||||
messenger:
|
||||
failure_transport: failed
|
||||
|
||||
transports:
|
||||
# https://symfony.com/doc/current/messenger.html#transport-configuration
|
||||
async:
|
||||
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
|
||||
options:
|
||||
use_notify: true
|
||||
check_delayed_interval: 60000
|
||||
retry_strategy:
|
||||
max_retries: 3
|
||||
multiplier: 2
|
||||
failed: 'doctrine://default?queue_name=failed'
|
||||
# sync: 'sync://'
|
||||
|
||||
default_bus: messenger.bus.default
|
||||
|
||||
buses:
|
||||
messenger.bus.default: []
|
||||
async: "%env(MESSENGER_TRANSPORT_DSN)%"
|
||||
|
||||
routing:
|
||||
Symfony\Component\Mailer\Messenger\SendEmailMessage: async
|
||||
Symfony\Component\Notifier\Message\ChatMessage: async
|
||||
Symfony\Component\Notifier\Message\SmsMessage: async
|
||||
|
||||
# Route your messages to the transports
|
||||
# 'App\Message\YourMessage': async
|
||||
|
||||
@@ -10,6 +10,14 @@ when@dev:
|
||||
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
|
||||
@@ -33,23 +41,11 @@ when@prod:
|
||||
monolog:
|
||||
handlers:
|
||||
main:
|
||||
type: fingers_crossed
|
||||
action_level: error
|
||||
handler: nested
|
||||
excluded_http_codes: [404, 405]
|
||||
channels: ["!deprecation"]
|
||||
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
|
||||
nested:
|
||||
type: stream
|
||||
path: php://stderr
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
level: debug
|
||||
formatter: monolog.formatter.json
|
||||
channels: ["!event"]
|
||||
console:
|
||||
type: console
|
||||
process_psr_3_messages: false
|
||||
channels: ["!event", "!doctrine"]
|
||||
deprecation:
|
||||
type: stream
|
||||
channels: [deprecation]
|
||||
path: php://stderr
|
||||
formatter: monolog.formatter.json
|
||||
channels: ["!event", "!doctrine", "!console"]
|
||||
|
||||
10
config/packages/nelmio_cors.yaml
Normal file
10
config/packages/nelmio_cors.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
nelmio_cors:
|
||||
defaults:
|
||||
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']
|
||||
max_age: 3600
|
||||
paths:
|
||||
'^/': null
|
||||
3
config/packages/presta_sitemap.yaml
Normal file
3
config/packages/presta_sitemap.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
presta_sitemap:
|
||||
sitemap_file_prefix: 'sitemap'
|
||||
timetolive: 3600
|
||||
@@ -1,39 +1,48 @@
|
||||
# config/packages/security.yaml
|
||||
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
|
||||
# https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
|
||||
providers:
|
||||
users_in_memory: { memory: null }
|
||||
# Appelle votre provider d'utilisateurs.
|
||||
# Ici, nous configurons un provider d'entité pour notre classe Account,
|
||||
# en spécifiant 'username' comme propriété d'identification.
|
||||
app_account_provider:
|
||||
entity:
|
||||
class: App\Entity\Account
|
||||
property: email # Utilise le champ 'username' de votre entité Account pour l'authentification
|
||||
|
||||
firewalls:
|
||||
dev:
|
||||
# Ensure dev tools and static assets are always allowed
|
||||
pattern: ^/(_profiler|_wdt|assets|build)/
|
||||
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
||||
security: false
|
||||
|
||||
main:
|
||||
lazy: true
|
||||
provider: users_in_memory
|
||||
provider: app_account_provider # Utilise le provider que nous avons défini ci-dessus
|
||||
user_checker: App\Security\UserChecker
|
||||
form_login:
|
||||
login_path: app_home # La route vers votre formulaire de connexion (GET)
|
||||
check_path: app_home # L'URL où le formulaire POST sera soumis
|
||||
enable_csrf: true # Active la protection CSRF
|
||||
csrf_token_id: authenticate # ID du jeton CSRF (doit correspondre à celui dans votre Twig)
|
||||
entry_point: App\Security\AuthenticationEntryPoint
|
||||
custom_authenticator:
|
||||
- App\Security\LoginFormAuthenticator
|
||||
logout:
|
||||
target: app_logout
|
||||
|
||||
# Activate different ways to authenticate:
|
||||
# https://symfony.com/doc/current/security.html#the-firewall
|
||||
# Configuration des algorithmes de hachage des mots de passe.
|
||||
# Symfony choisira automatiquement le meilleur algorithme par défaut si non spécifié,
|
||||
# mais vous pouvez le configurer explicitement.
|
||||
password_hashers:
|
||||
App\Entity\Account: 'auto' # 'auto' sélectionne le meilleur algorithme disponible (recommandé)
|
||||
# Ou pour spécifier bcrypt explicitement :
|
||||
# App\Entity\Account:
|
||||
# algorithm: bcrypt
|
||||
|
||||
# https://symfony.com/doc/current/security/impersonating_user.html
|
||||
# switch_user: true
|
||||
role_hierarchy:
|
||||
ROLE_ROOT: [ROLE_ADMIN] # ROLE_ROOT inclut ROLE_ADMIN, qui à son tour inclut ROLE_ARTEMIS
|
||||
|
||||
# Note: Only the *first* matching rule is applied
|
||||
access_control:
|
||||
# - { path: ^/admin, roles: ROLE_ADMIN }
|
||||
# - { path: ^/profile, roles: ROLE_USER }
|
||||
|
||||
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
|
||||
- { path: ^/admin, roles: [ROLE_ADMIN] }
|
||||
- { path: ^/console, roles: [ROLE_COSPLAY] }
|
||||
- { path: ^/, roles: PUBLIC_ACCESS } # Toutes les autres pages nécessitent une authentification complète
|
||||
|
||||
36
config/packages/sentry.yaml
Normal file
36
config/packages/sentry.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
when@prod:
|
||||
sentry:
|
||||
dsn: '%env(SENTRY_DSN)%'
|
||||
options:
|
||||
# Add request headers, cookies, IP address and the authenticated user
|
||||
# see https://docs.sentry.io/platforms/php/data-management/data-collected/ for more info
|
||||
# send_default_pii: true
|
||||
ignore_exceptions:
|
||||
- 'Symfony\Component\ErrorHandler\Error\FatalError'
|
||||
- 'Symfony\Component\Debug\Exception\FatalErrorException'
|
||||
#
|
||||
# # If you are using Monolog, you also need this additional configuration to log the errors correctly:
|
||||
# # https://docs.sentry.io/platforms/php/guides/symfony/#monolog-integration
|
||||
# register_error_listener: false
|
||||
# register_error_handler: false
|
||||
#
|
||||
# monolog:
|
||||
# handlers:
|
||||
# # Use this only if you don't want to use structured logging and instead receive
|
||||
# # certain log levels as errors.
|
||||
# sentry:
|
||||
# type: sentry
|
||||
# level: !php/const Monolog\Logger::ERROR
|
||||
# hub_id: Sentry\State\HubInterface
|
||||
# fill_extra_context: true # Enables sending monolog context to Sentry
|
||||
# process_psr_3_messages: false # Disables the resolution of PSR-3 placeholders
|
||||
#
|
||||
# # Use this for structured log integration
|
||||
# sentry_logs:
|
||||
# type: service
|
||||
# id: Sentry\SentryBundle\Monolog\LogsHandler
|
||||
#
|
||||
# services:
|
||||
# Sentry\SentryBundle\Monolog\LogsHandler:
|
||||
# arguments:
|
||||
# - !php/const Monolog\Logger::INFO
|
||||
@@ -1,5 +1,5 @@
|
||||
framework:
|
||||
default_locale: en
|
||||
default_locale: fr
|
||||
translator:
|
||||
default_path: '%kernel.project_dir%/translations'
|
||||
providers:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
twig:
|
||||
file_name_pattern: '*.twig'
|
||||
|
||||
form_themes:
|
||||
- 'form_tailwind.twig'
|
||||
when@test:
|
||||
twig:
|
||||
strict_variables: true
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# Enable stateless CSRF protection for forms and logins/logouts
|
||||
framework:
|
||||
csrf_protection:
|
||||
check_header: true
|
||||
4
config/packages/vich_uploader.yaml
Normal file
4
config/packages/vich_uploader.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
vich_uploader:
|
||||
db_driver: orm
|
||||
mappings:
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
# 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
|
||||
|
||||
resource:
|
||||
path: ../src/Controller/
|
||||
namespace: App\Controller
|
||||
type: attribute
|
||||
|
||||
presta_sitemap:
|
||||
resource: "@PrestaSitemapBundle/config/routing.yml"
|
||||
|
||||
2
config/routes/liip_imagine.yaml
Normal file
2
config/routes/liip_imagine.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
_liip_imagine:
|
||||
resource: "@LiipImagineBundle/Resources/config/routing.yaml"
|
||||
2
config/routes/presta_sitemap.yml
Normal file
2
config/routes/presta_sitemap.yml
Normal file
@@ -0,0 +1,2 @@
|
||||
presta_sitemap:
|
||||
resource: "@PrestaSitemapBundle/config/routing.yml"
|
||||
@@ -1,8 +1,5 @@
|
||||
# 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
|
||||
@@ -19,5 +16,7 @@ services:
|
||||
App\:
|
||||
resource: '../src/'
|
||||
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
App\Twig\ViteAssetExtension:
|
||||
arguments:
|
||||
$manifest: '%kernel.project_dir%/public/build/.vite/manifest.json'
|
||||
$cache: '@vite_cache_pool'
|
||||
|
||||
177
docker-compose.yml
Normal file
177
docker-compose.yml
Normal file
@@ -0,0 +1,177 @@
|
||||
services:
|
||||
|
||||
# --- Service PHP ---
|
||||
# Conteneur pour exécuter l'application Symfony (requêtes web)
|
||||
php:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./docker/php/Dockerfile
|
||||
args:
|
||||
# Utilise l'UID/GID de l'hôte pour éviter les problèmes de permissions
|
||||
UID: ${UID:-1000}
|
||||
GID: ${GID:-1000}
|
||||
container_name: crm_php
|
||||
environment:
|
||||
- XDEBUG_MODE=coverage
|
||||
volumes:
|
||||
# Monte le code source de l'application dans le conteneur
|
||||
- ./:/srv/app
|
||||
# Monte le socket Docker pour permettre l'exécution de commandes docker depuis le conteneur
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./coverage:/opt/phpstorm-coverage
|
||||
- ./.coverage:/opt/phpstorm-coverage
|
||||
depends_on:
|
||||
# S'assure que la base de données et Redis sont démarrés avant PHP
|
||||
- db
|
||||
- redis
|
||||
networks:
|
||||
- crm_network # Assignation au réseau commun
|
||||
|
||||
# --- Service Worker pour Symfony Messenger ---
|
||||
# Conteneur dédié à l'exécution des messages en arrière-plan
|
||||
messenger-worker:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./docker/php/Dockerfile
|
||||
args:
|
||||
UID: ${UID:-1000}
|
||||
GID: ${GID:-1000}
|
||||
container_name: crm_messenger_worker
|
||||
# Commande pour lancer le worker. 'async' est le nom du transport par défaut.
|
||||
command: php bin/console messenger:consume async --memory-limit=128M --time-limit=3600
|
||||
volumes:
|
||||
- ./:/srv/app
|
||||
# Monte aussi le socket Docker ici pour la cohérence
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
networks:
|
||||
- crm_network # Assignation au réseau commun
|
||||
|
||||
# --- Service Frontend (Bun + Vite) ---
|
||||
# Conteneur pour compiler les assets JS/CSS en développement
|
||||
bun:
|
||||
image: oven/bun:1-slim
|
||||
container_name: crm_bun
|
||||
# Exécute les commandes avec l'utilisateur de l'hôte pour éviter les problèmes de permissions sur node_modules
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
working_dir: /usr/src/app
|
||||
volumes:
|
||||
- ./:/usr/src/app
|
||||
ports:
|
||||
# Port par défaut du serveur de développement Vite
|
||||
- "5173:5173"
|
||||
# Lance le serveur de développement Vite défini dans package.json
|
||||
command: bun run dev
|
||||
networks:
|
||||
- crm_network # Assignation au réseau commun
|
||||
|
||||
# --- Service Serveur Web (Caddy) ---
|
||||
# Serveur web moderne qui sert l'application et gère le PHP-FPM
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
container_name: crm_caddy
|
||||
ports:
|
||||
# Mappe le port 8000 de l'hôte au port 80 du conteneur
|
||||
- "8000:80"
|
||||
volumes:
|
||||
# Monte le fichier de configuration de Caddy
|
||||
- ./docker/caddy/Caddyfile:/etc/caddy/Caddyfile
|
||||
# Monte le code source pour servir les fichiers statiques
|
||||
- ./:/srv/app
|
||||
depends_on:
|
||||
- php
|
||||
networks:
|
||||
- crm_network # Assignation au réseau commun
|
||||
|
||||
# --- Service Base de Données principale (PostgreSQL) ---
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: crm_db
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_DB: app_db
|
||||
POSTGRES_USER: symfony_user
|
||||
POSTGRES_PASSWORD: ChangeMeInProd!
|
||||
volumes:
|
||||
# Volume nommé pour la persistance des données
|
||||
- db_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- crm_network # Assignation au réseau commun
|
||||
|
||||
# --- Service Cache/Messenger (Redis) ---
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: crm_redis
|
||||
networks:
|
||||
- crm_network # Assignation au réseau commun
|
||||
|
||||
# --- Service de Test d'Emails (MailHog) ---
|
||||
# Intercepte tous les emails envoyés en développement
|
||||
mailhog:
|
||||
image: mailhog/mailhog:latest
|
||||
container_name: crm_mailhog
|
||||
ports:
|
||||
# Port 1025 pour le serveur SMTP factice
|
||||
- "1025:1025"
|
||||
# Port 8025 pour l'interface web de MailHog
|
||||
- "8025:8025"
|
||||
networks:
|
||||
- crm_network # Assignation au réseau commun
|
||||
|
||||
# --- Service de Stockage Fichiers (MinIO) ---
|
||||
# Fournit une API compatible S3 pour le stockage de fichiers
|
||||
minio:
|
||||
image: minio/minio:RELEASE.2025-02-03T21-03-04Z
|
||||
container_name: crm_minio
|
||||
ports:
|
||||
# Port 9000 pour l'API S3
|
||||
- "9000:9000"
|
||||
# Port 9001 pour la console web de MinIO
|
||||
- "9001:9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: minio_user
|
||||
MINIO_ROOT_PASSWORD: ChangeMeInProd!
|
||||
volumes:
|
||||
# Volume nommé pour la persistance des fichiers
|
||||
- minio_data:/data
|
||||
# Commande pour démarrer MinIO et lancer la console sur le bon port
|
||||
command: server /data --console-address ":9001"
|
||||
networks:
|
||||
- crm_network # Assignation au réseau commun
|
||||
|
||||
# --- Service de Gestion des Secrets (HashiCorp Vault) ---
|
||||
vault:
|
||||
image: hashicorp/vault:latest
|
||||
container_name: crm_vault
|
||||
ports:
|
||||
- "8210:8200" # Mappe le port 8210 de l'hôte au port 8200 du conteneur Vault
|
||||
- "8211:8201" # Mappe le port 8210 de l'hôte au port 8200 du conteneur Vault
|
||||
- "8212:8202" # Mappe le port 8212 de l'hôte au port 8200 du conteneur Vault
|
||||
volumes:
|
||||
# Volume pour la persistance des données
|
||||
- vault_data:/vault
|
||||
# Volume pour monter notre fichier de configuration
|
||||
environment:
|
||||
VAULT_DEV_ROOT_TOKEN_ID: myroot
|
||||
VAULT_DEV_LISTEN_ADDRESS: 0.0.0.0:8201
|
||||
VAULT_LOCAL_CONFIG: '{"storage": {"file": {"path": "/vault/file"}}, "listener": [{"tcp": { "address": "0.0.0.0:8200", "tls_disable": true}}], "default_lease_ttl": "168h", "max_lease_ttl": "720h", "ui": true,"disable_mlock": false}'
|
||||
# Lance Vault en mode serveur avec notre fichier de configuration
|
||||
cap_add:
|
||||
- IPC_LOCK
|
||||
command: "server -dev"
|
||||
networks:
|
||||
- crm_network # Assignation au réseau commun
|
||||
|
||||
# Définition des volumes pour la persistance des données
|
||||
volumes:
|
||||
db_data: # Pour la base de données principale de Symfony
|
||||
minio_data: # Pour le stockage de fichiers MinIO
|
||||
vault_data: # Pour les données de HashiCorp Vault
|
||||
|
||||
# Définition des réseaux
|
||||
networks:
|
||||
crm_network: # Définition du réseau commun
|
||||
driver: bridge # Le type de driver le plus courant
|
||||
123
docker/actions/Dockerfile
Normal file
123
docker/actions/Dockerfile
Normal file
@@ -0,0 +1,123 @@
|
||||
# Use the official Debian 12.11 (Bookworm) image as the base
|
||||
FROM debian:12.11
|
||||
|
||||
# Set environment variables to prevent interactive prompts during apt operations
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Update the package list and install necessary dependencies for adding Node.js and PHP repositories
|
||||
# curl is needed to download the NodeSource setup script, Bun install script, and Composer installer
|
||||
# gnupg is needed to handle GPG keys for apt repositories
|
||||
# ca-certificates is needed for secure connections
|
||||
# apt-transport-https is needed for apt to fetch packages over HTTPS
|
||||
# unzip and tar are often required for Bun's installation process
|
||||
# lsb-release is needed for add-apt-repository (which is not used directly, but useful for detecting distro)
|
||||
# dirmngr is needed for adding GPG keys
|
||||
# wget is needed to download the PHP repository GPG key
|
||||
RUN apt-get update && \
|
||||
apt-get install -y curl gnupg ca-certificates apt-transport-https unzip tar lsb-release dirmngr wget && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Add NodeSource GPG key for Node.js 23.x repository
|
||||
# The NodeSource setup script adds the repository and imports the GPG key.
|
||||
# We're specifically targeting Node.js 23.x.
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_23.x | bash -
|
||||
|
||||
# Install Node.js and npm from the NodeSource repository
|
||||
# nodejs package includes both Node.js runtime and npm (Node Package Manager)
|
||||
RUN apt-get update && \
|
||||
apt-get install -y nodejs && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Bun
|
||||
# This command downloads and executes the official Bun installation script.
|
||||
# It installs Bun globally.
|
||||
RUN curl -fsSL https://bun.sh/install | bash
|
||||
|
||||
# Add Bun to the PATH for non-interactive shells and subsequent commands
|
||||
# The Bun installer typically adds it to ~/.bashrc or similar, but for Docker,
|
||||
# we need to ensure it's in the system-wide PATH or explicitly sourced.
|
||||
# This line appends the Bun binary directory to the PATH environment variable.
|
||||
ENV PATH="/root/.bun/bin:$PATH"
|
||||
|
||||
# Add Ondrej's PHP repository for Debian 12 (Bookworm)
|
||||
# This repository provides up-to-date PHP versions.
|
||||
RUN echo "deb https://packages.sury.org/php/ $(lsb_release -sc) main" > /etc/apt/sources.list.d/sury-php.list && \
|
||||
wget -qO - https://packages.sury.org/php/apt.gpg | apt-key add -
|
||||
|
||||
# Install PHP 8.3 and common extensions
|
||||
# php8.3-cli: Command Line Interface
|
||||
# php8.3-fpm: FastCGI Process Manager (for web servers like Nginx/Apache)
|
||||
# php8.3-mysql: MySQL database extension
|
||||
# php8.3-curl: cURL extension for making HTTP requests
|
||||
# php8.3-mbstring: Multibyte string functions
|
||||
# php8.3-xml: XML extension
|
||||
# php8.3-zip: Zip archive extension (already present, but good to ensure)
|
||||
# php8.3-gd: GD extension (for JPEG, WebP, etc.)
|
||||
# php8.3-pdo: PDO (PHP Data Objects) extension
|
||||
# php8.3-pgsql: PostgreSQL PDO driver
|
||||
# php8.3-gmp: GNU Multiple Precision arithmetic functions
|
||||
# php8.3-bcmath: Arbitrary precision mathematics
|
||||
# php8.3-intl: Internationalization extension
|
||||
# php8.3-redis: Redis extension
|
||||
# php8.3-excimer: Excimer extension (for profiling)
|
||||
# php8.3-xdebug: Xdebug extension (for debugging and profiling)
|
||||
RUN apt-get update && \
|
||||
apt-get install -y php8.3 php8.3-cli php8.3-fpm php8.3-mysql php8.3-curl php8.3-mbstring php8.3-xml php8.3-zip php8.3-gd php8.3-pdo php8.3-pgsql php8.3-gmp php8.3-bcmath php8.3-intl php8.3-redis php8.3-excimer php8.3-xdebug && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Composer
|
||||
# Download the Composer installer script
|
||||
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
|
||||
|
||||
# Configure Xdebug
|
||||
# Create a new INI file for Xdebug configuration.
|
||||
# Set xdebug.mode to 'develop,debug' for development and debugging features.
|
||||
# Set xdebug.start_with_request to 'yes' to always start Xdebug on every request.
|
||||
# Set xdebug.client_host to 'host.docker.internal' for Docker Desktop compatibility
|
||||
# This allows Xdebug to connect back to the host machine's IDE.
|
||||
RUN echo "zend_extension=xdebug" > /etc/php/8.3/mods-available/xdebug.ini && \
|
||||
echo "xdebug.mode=develop,debug" >> /etc/php/8.3/mods-available/xdebug.ini && \
|
||||
echo "xdebug.start_with_request=yes" >> /etc/php/8.3/mods-available/xdebug.ini && \
|
||||
echo "xdebug.client_host=host.docker.internal" >> /etc/php/8.3/mods-available/xdebug.ini
|
||||
|
||||
# --- Install Docker into the image ---
|
||||
# Add Docker's official GPG key
|
||||
RUN install -m 0755 -d /etc/apt/keyrings && \
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg && \
|
||||
chmod a+r /etc/apt/keyrings/docker.gpg
|
||||
|
||||
# Add the Docker repository to Apt sources
|
||||
RUN echo \
|
||||
"deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \
|
||||
"$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | \
|
||||
tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
|
||||
# Update apt package index and install Docker Engine, CLI, containerd, and Docker Compose plugin
|
||||
RUN apt-get update && \
|
||||
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Verify Node.js, npm, Bun, PHP, Composer, and Docker installations
|
||||
RUN node -v
|
||||
RUN npm -v
|
||||
RUN bun -v
|
||||
RUN php -v
|
||||
RUN composer -v
|
||||
|
||||
# Set a working directory (optional, but good practice for applications)
|
||||
WORKDIR /app
|
||||
|
||||
# You can add your application code here, for example:
|
||||
# COPY . /app
|
||||
|
||||
# Install dependencies using Bun
|
||||
# This command assumes you have a package.json or bun.lockb file in your /app directory.
|
||||
# If you don't have one, this command will likely fail or do nothing.
|
||||
# RUN bun install
|
||||
|
||||
# EXPOSE 3000
|
||||
# CMD ["node", "your-app.js"]
|
||||
|
||||
# Default command if no other command is specified when running the container
|
||||
# This will keep the container running and allow you to exec into it.
|
||||
CMD ["node"]
|
||||
46
docker/caddy/Caddyfile
Normal file
46
docker/caddy/Caddyfile
Normal file
@@ -0,0 +1,46 @@
|
||||
# ./docker/caddy/Caddyfile
|
||||
|
||||
# Nous écoutons sur le port 80 (qui est mappé depuis le port 8000 de l'hôte)
|
||||
:80 {
|
||||
# La racine de votre application Symfony
|
||||
root * /srv/app/public
|
||||
|
||||
# Activation de la compression (optionnel)
|
||||
encode gzip zstd
|
||||
|
||||
# Gère les assets statiques directement (améliore les performances)
|
||||
file_server
|
||||
|
||||
# Passe toutes les requêtes non résolues par file_server à Symfony (index.php)
|
||||
php_fastcgi php:9000 { # 'php' est le nom de votre service PHP dans docker-compose
|
||||
# Transmet les en-têtes X-Forwarded-* reçus de Nginx à PHP-FPM
|
||||
# C'est CRUCIAL pour que Symfony détecte HTTPS
|
||||
env HTTPS on # Simule que la connexion est HTTPS pour PHP
|
||||
env HTTP_X_FORWARDED_PROTO {header.X-Forwarded-Proto} # Récupère le proto original de Nginx
|
||||
env HTTP_X_FORWARDED_HOST {header.X-Forwarded-Host} # Récupère l'hôte original de Nginx
|
||||
env HTTP_X_FORWARDED_PORT {header.X-Forwarded-Port} # Récupère le port original de Nginx
|
||||
env HTTP_X_REAL_IP {header.X-Real-IP} # Récupère l'IP réelle du client (si Nginx la passe)
|
||||
|
||||
# Assurez-vous que l'adresse IP de Nginx est aussi trustée par Symfony
|
||||
# via framework.trusted_proxies dans Symfony.
|
||||
}
|
||||
|
||||
# Journalisation des accès (utile pour le débogage)
|
||||
log {
|
||||
output stdout
|
||||
format json
|
||||
}
|
||||
handle_path /ts.js {
|
||||
redir https://widget.trustpilot.com/bootstrap/v5/tp.widget.bootstrap.min.js
|
||||
}
|
||||
handle_path /tp.widget.bootstrap.min.js.map {
|
||||
redir https://widget.trustpilot.com/bootstrap/v5/tp.widget.bootstrap.min.js.map
|
||||
}
|
||||
# Configuration des en-têtes CORS pour les requêtes OPTIONS du profiler
|
||||
# Note: Caddy a une directive 'header' mais c'est pour les réponses.
|
||||
# Pour gérer spécifiquement les OPTIONS en tant que preflight, c'est plus direct
|
||||
# de le faire via php_fastcgi si Symfony peut le gérer, ou avec une directive
|
||||
# 'handle' ou 'route' spécifique si Caddy doit répondre directement.
|
||||
# Pour l'instant, faisons confiance à Symfony pour gérer les CORS via NelmioCorsBundle
|
||||
# une fois que le protocole est correct.
|
||||
}
|
||||
91
docker/php/Dockerfile
Normal file
91
docker/php/Dockerfile
Normal file
@@ -0,0 +1,91 @@
|
||||
# Utiliser une image PHP 8.3 FPM (Ubuntu-based)
|
||||
FROM php:8.3-fpm
|
||||
|
||||
# Arguments pour les permissions de fichiers
|
||||
ARG UID
|
||||
ARG GID
|
||||
|
||||
# Mettre à jour et installer les dépendances système requises
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# Outils de compilation et dépendances pour PHP extensions
|
||||
build-essential \
|
||||
libpq-dev \
|
||||
libzip-dev \
|
||||
libpng-dev \
|
||||
libjpeg-dev \
|
||||
libfreetype6-dev \
|
||||
libwebp-dev \
|
||||
libicu-dev \
|
||||
# Dépendance pour GMP (bibliothèque de développement)
|
||||
libgmp-dev \
|
||||
# Outils généraux
|
||||
zip \
|
||||
unzip \
|
||||
ffmpeg \
|
||||
jq \
|
||||
wget \
|
||||
nodejs \
|
||||
npm \
|
||||
postgresql-client \
|
||||
git \
|
||||
# Nettoyage des listes de paquets pour réduire la taille de l'image
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# Installer le client HashiCorp Vault
|
||||
ENV VAULT_VERSION=1.17.1
|
||||
RUN wget https://releases.hashicorp.com/vault/${VAULT_VERSION}/vault_${VAULT_VERSION}_linux_amd64.zip -O vault.zip && \
|
||||
unzip vault.zip -d /usr/local/bin && \
|
||||
rm vault.zip
|
||||
|
||||
# Installer le client MinIO (mc)
|
||||
RUN wget https://dl.min.io/client/mc/release/linux-amd64/mc -O /usr/local/bin/mc && \
|
||||
chmod +x /usr/local/bin/mc
|
||||
|
||||
RUN npm install -g mjml
|
||||
# Configurer et installer les extensions PHP
|
||||
# Utilisation de -j$(nproc) pour paralléliser la compilation et accélérer le build
|
||||
RUN docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
|
||||
&& docker-php-ext-install -j$(nproc) gd pdo pdo_pgsql zip gmp bcmath intl exif
|
||||
|
||||
# Installer Redis via pecl
|
||||
RUN pecl install redis && docker-php-ext-enable redis
|
||||
|
||||
# Installer Excimer via pecl
|
||||
RUN pecl install excimer && docker-php-ext-enable excimer
|
||||
|
||||
# Configuration et installation de Xdebug
|
||||
# Utilisation de --no-install-recommends pour éviter l'installation de paquets inutiles
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git \
|
||||
&& mkdir -p /usr/src/php/ext/xdebug \
|
||||
&& git clone https://github.com/xdebug/xdebug.git /usr/src/php/ext/xdebug \
|
||||
&& cd /usr/src/php/ext/xdebug \
|
||||
&& phpize \
|
||||
&& ./configure --enable-xdebug \
|
||||
&& make && make install \
|
||||
&& rm -rf /usr/src/php/ext/xdebug \
|
||||
&& apt-get autoremove -y build-essential git \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
COPY ./docker/php/custom.ini /usr/local/etc/php/conf.d/custom.ini
|
||||
|
||||
RUN echo "zend_extension=xdebug" > /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
|
||||
RUN echo "xdebug.mode=develop,debug" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
|
||||
&& echo "xdebug.start_with_request=yes" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
|
||||
&& echo "xdebug.client_host=host.docker.internal" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
|
||||
RUN mkdir -p /opt/phpstorm-coverage && \
|
||||
chmod -R 777 /opt/phpstorm-coverage
|
||||
|
||||
# Installer Composer globalement
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
RUN mkdir -p /opt/phpstorm-coverage/ && chmod -R 777 /opt/phpstorm-coverage/
|
||||
|
||||
# Créer un utilisateur et un groupe non-root pour l'application
|
||||
# Utilisation de useradd/groupadd pour les systèmes basés sur Debian/Ubuntu
|
||||
RUN groupadd -g $GID appuser && \
|
||||
useradd -u $UID -g appuser -ms /bin/bash appuser
|
||||
|
||||
# Changer pour l'utilisateur non-root
|
||||
USER appuser
|
||||
# Définir le répertoire de travail
|
||||
WORKDIR /srv/app
|
||||
2
docker/php/custom.ini
Normal file
2
docker/php/custom.ini
Normal file
@@ -0,0 +1,2 @@
|
||||
upload_max_filesize=128M
|
||||
post_max_size=128M
|
||||
1
docker/vault/config.json
Normal file
1
docker/vault/config.json
Normal file
@@ -0,0 +1 @@
|
||||
{"storage": {"file": {"path": "/vault/file"}}, "listener": [{"tcp": { "address": "0.0.0.0:8200", "tls_disable": true}}], "default_lease_ttl": "168h", "max_lease_ttl": "720h", "ui": true}
|
||||
4
docker/vault/entrypoint.sh
Normal file
4
docker/vault/entrypoint.sh
Normal file
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
|
||||
# 'exec' remplace le processus du shell par celui de vault, ce qui est une bonne pratique.
|
||||
exec vault server -dev
|
||||
13
google.json
Normal file
13
google.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"type": "service_account",
|
||||
"project_id": "smiling-gasket-478816-n2",
|
||||
"private_key_id": "50be7ef1c73c674d110c849d70acc8b1ed5904b0",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDAiPtoiN8Dpdw0\n44dxFBIMx1+9bwiL8PXxQhMYHkhRk1GGIhd+hUkpcQn/87FqH6M2fTDq1U8BEmwb\nWznpSt0rx0JLwfPwdh2CLJ8iZ/ZieUBGg1bQITOlXwN8w4BS4jfEZ57jwjjIOO+3\neA1ib3Y4o0NOQCEjoOdw9XZgzSBKopjYtCx7ELKTzuK7FQjfxBhUeETA2ts0Qi6l\nA6psximWu+ZKvTX0wZC4CCECqKM0CuaKdKrotoysx2bKQLlTclLU60LSqUJX2JZE\nTcXT8NRRt6B58rEOoxcP2l5bJSySw5PTceRC1wbihihDazefBKbqZh/N3eMueY2C\nuN1nTdWjAgMBAAECggEANF6OE7b3AiUBKpmg448UAcnRLtOevYHDQ+Y3D15dSWMK\nz7NCNLXIWq9WivocDcOhP1b6EUYIAUCjiyVbMSud0SSX0cdltMhL6nZ8qn0gtVRJ\nuWRMHryJMbgQWMVMiD7j8FZHD6hqjXt/KKZM7bNnsqwlyIkE+o3vpff+RQJGgEYV\nBLkj81wdW1DMJI3LQoau9jKdMf5mLrDCWRDyEugLMnvAA9acNWoYiBMB6lxvCa3G\n12nPjeCRJXJf+/VT7M2mwHjWgajG8AyFECYRe8cNmvIAfH3GC1uQfyLw8kReoCBL\n1jAUNAUQAQbmGz1Z87/k20skZIkXsW4eG2W6C4+L0QKBgQDiWfXdT+78aPCFopQ0\nFtOwYQvqjwPjeMa5z0T736ZNX115MAbl4DlfyUEb0cvospOLUXTg6cVtJzWkMK0/\nrkh/i8EQ8OyyStZ/K/wFxnR4Vu+AeSpop4aOqNl/meW7bflITUt8dIs+PLjK+vLa\nQPxQoNDSlGrejPz7lEG8DR1nrQKBgQDZwRbSMQTOj4mOGtv/bsCmCQ6ctNF6sH80\nPdCsmaygLfaPWNQ9lTPdJd993Id2Fo0Itzkwy/Vb+0eFj4iBSIpskuZ7mrA7eypJ\nAR/WJGX3BYAvMKEvMn+KOwJczHzsU1083moKV73ldGMiQdfZOtmnqzWpXpgnCEki\nq0lpp/IcjwKBgEI4OVKuwOb9OGiQILWAfBvcuGS9xFB3FARmG/NoAbofDTSYFVyJ\nFZ/tO+wMm5APNlUK1pu6KHT/hJTtXLIFpdYSp7/yC/05IbmAv7Fc1tQh8t1uFTca\n06XGxiKrfmcwDD7Xxh655camcxWHBydM3cQk2BLTMtS7AIQFYpnGaHTdAoGAKkfA\nm51i9oyOQ+ZZMxaZF2QIz0qYpf7hJA6glvLbvtpN1nWD+FUhFd6Fr5WDQ92LEtco\npp3jjTGUKI2/DoM8RWqckAFwGIyIoFY0jUrR9Y2+3urNUTG36+obQlN+KhDhuLDi\n3BE/UO8xVHR+abJwkoq+x50TY/jK4o1pmrc+XmcCgYApPsls/sai1QbD6pQwQcwJ\nZtLBvFbykc79nZ8/5XHbrtsqWtCAju3RnxRZArIE3rCh2PWbPhWRq2q0sQFVSd4v\n1JLPD7OD9dLw63HG4ODD8E6Ih+xUDJuCOl9bYYJiBpXUx91tOdBFrJeB2HRWHNsH\nNnXhRIljkbVF/FFkvGxa3g==\n-----END PRIVATE KEY-----\n",
|
||||
"client_email": "shop-api@smiling-gasket-478816-n2.iam.gserviceaccount.com",
|
||||
"client_id": "101205257048462610951",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/shop-api%40smiling-gasket-478816-n2.iam.gserviceaccount.com",
|
||||
"universe_domain": "googleapis.com"
|
||||
}
|
||||
69
makefile
Normal file
69
makefile
Normal file
@@ -0,0 +1,69 @@
|
||||
# --- Configuration ---
|
||||
PHP_EXEC = docker compose exec php
|
||||
CONSOLE = $(PHP_EXEC) php bin/console
|
||||
|
||||
# --- Scripts d'automatisation ---
|
||||
# Note: Ces scripts sont complexes et utilisent '$$' pour échapper le '$' pour Make.
|
||||
|
||||
# --- Commandes Docker ---
|
||||
.PHONY: pull build start stop restart logs logs-php
|
||||
pull: ## Récupère les dernières images docker
|
||||
@docker compose pull
|
||||
build: ## Construit (ou reconstruit) les images docker du projet
|
||||
@docker compose build
|
||||
start: ## Démarre tous les services en mode détaché
|
||||
@docker compose up -d # Ajout de -d pour le mode détaché par défaut
|
||||
stop: ## Arrête tous les services
|
||||
@docker compose down
|
||||
restart: stop start ## Redémarre tous les services
|
||||
logs: ## Affiche les logs de tous les services
|
||||
@docker compose logs -f
|
||||
logs-php: ## Affiche les logs du service PHP
|
||||
@docker compose logs -f php
|
||||
|
||||
|
||||
# --- Commandes d'Initialisation ---
|
||||
init: ## Installation initiale du projet (sans Vault/MinIO)
|
||||
@make pull
|
||||
@make build
|
||||
@make start
|
||||
@make composer-install
|
||||
@sleep 5
|
||||
@make db-create
|
||||
@make migrate
|
||||
|
||||
|
||||
# --- Commandes Symfony & DB ---
|
||||
.PHONY: db-create entity migration migrate composer-install deps
|
||||
db-create: ## Crée la base de données
|
||||
@$(CONSOLE) doctrine:database:create --if-not-exists
|
||||
db-drop: ## Crée la base de données
|
||||
@$(CONSOLE) doctrine:database:drop --force
|
||||
entity: ## Lance la création/mise à jour d'une entité
|
||||
@$(CONSOLE) make:entity
|
||||
migration: ## Génère une nouvelle migration
|
||||
@$(CONSOLE) make:migration
|
||||
migrate: ## Applique les migrations
|
||||
@$(CONSOLE) doctrine:migrations:migrate --no-interaction
|
||||
composer-install: ## Installe les dépendances Composer
|
||||
@$(PHP_EXEC) composer install
|
||||
deps: composer-install ## Alias pour composer-install
|
||||
|
||||
dbtest_add: ## Crée la base de données
|
||||
@$(CONSOLE) doctrine:database:create --env=test
|
||||
dbtest_migrate: ## Crée la base de données
|
||||
@$(CONSOLE) doctrine:migrations:migrate --env=test --no-interaction
|
||||
dbtest_remove: ## Crée la base de données
|
||||
@$(CONSOLE) doctrine:database:drop --env=test --force
|
||||
|
||||
# --- Aide ---
|
||||
.PHONY: help
|
||||
help: ## Affiche cet écran d'aide
|
||||
@echo "Commandes disponibles mainframe :"
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
gitlab_build:
|
||||
docker build -t tools-registry.esy-web.dev/mainframe/mainframe:php docker/php
|
||||
docker push tools-registry.esy-web.dev/mainframe/mainframe:php
|
||||
63
migrations/Version20251209163956.php
Normal file
63
migrations/Version20251209163956.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20251209163956 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 "account" (id SERIAL NOT NULL, username VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, uuid UUID NOT NULL, is_first_login BOOLEAN DEFAULT NULL, update_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, is_actif BOOLEAN DEFAULT NULL, PRIMARY KEY(id))');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_IDENTIFIER_USERNAME ON "account" (username)');
|
||||
$this->addSql('COMMENT ON COLUMN "account".update_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
$this->addSql('CREATE TABLE account_login_register (id SERIAL NOT NULL, account_id INT DEFAULT NULL, login_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, user_agent VARCHAR(255) NOT NULL, ip VARCHAR(255) NOT NULL, PRIMARY KEY(id))');
|
||||
$this->addSql('CREATE INDEX IDX_23AAA4819B6B5FBA ON account_login_register (account_id)');
|
||||
$this->addSql('COMMENT ON COLUMN account_login_register.login_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
$this->addSql('CREATE TABLE account_reset_password_request (id SERIAL NOT NULL, account_id INT DEFAULT NULL, token VARCHAR(255) NOT NULL, requested_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, expires_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
|
||||
$this->addSql('CREATE INDEX IDX_E41866D09B6B5FBA ON account_reset_password_request (account_id)');
|
||||
$this->addSql('COMMENT ON COLUMN account_reset_password_request.requested_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
$this->addSql('COMMENT ON COLUMN account_reset_password_request.expires_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
$this->addSql('CREATE TABLE messenger_messages (id BIGSERIAL NOT NULL, body TEXT NOT NULL, headers TEXT NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, available_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, delivered_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))');
|
||||
$this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0 ON messenger_messages (queue_name)');
|
||||
$this->addSql('CREATE INDEX IDX_75EA56E0E3BD61CE ON messenger_messages (available_at)');
|
||||
$this->addSql('CREATE INDEX IDX_75EA56E016BA31DB ON messenger_messages (delivered_at)');
|
||||
$this->addSql('COMMENT ON COLUMN messenger_messages.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
$this->addSql('COMMENT ON COLUMN messenger_messages.available_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
$this->addSql('COMMENT ON COLUMN messenger_messages.delivered_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
$this->addSql('CREATE OR REPLACE FUNCTION notify_messenger_messages() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify(\'messenger_messages\', NEW.queue_name::text);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;');
|
||||
$this->addSql('DROP TRIGGER IF EXISTS notify_trigger ON messenger_messages;');
|
||||
$this->addSql('CREATE TRIGGER notify_trigger AFTER INSERT OR UPDATE ON messenger_messages FOR EACH ROW EXECUTE PROCEDURE notify_messenger_messages();');
|
||||
$this->addSql('ALTER TABLE account_login_register ADD CONSTRAINT FK_23AAA4819B6B5FBA FOREIGN KEY (account_id) REFERENCES "account" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||
$this->addSql('ALTER TABLE account_reset_password_request ADD CONSTRAINT FK_E41866D09B6B5FBA FOREIGN KEY (account_id) REFERENCES "account" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE SCHEMA public');
|
||||
$this->addSql('ALTER TABLE account_login_register DROP CONSTRAINT FK_23AAA4819B6B5FBA');
|
||||
$this->addSql('ALTER TABLE account_reset_password_request DROP CONSTRAINT FK_E41866D09B6B5FBA');
|
||||
$this->addSql('DROP TABLE "account"');
|
||||
$this->addSql('DROP TABLE account_login_register');
|
||||
$this->addSql('DROP TABLE account_reset_password_request');
|
||||
$this->addSql('DROP TABLE messenger_messages');
|
||||
}
|
||||
}
|
||||
35
package.json
Normal file
35
package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "e-cosplay",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hotwired/stimulus": "^3.2.2",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-scss": "^4.0.9",
|
||||
"rollup-plugin-javascript-obfuscator": "^1.0.4",
|
||||
"sass": "^1.94.2",
|
||||
"vite": "^7.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grafikart/drop-files-element": "^1.0.9",
|
||||
"@hotwired/turbo": "^8.0.20",
|
||||
"@preact/preset-vite": "^2.10.2",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"body-scroll-lock": "^4.0.0-beta.0",
|
||||
"react-email-editor": "^1.7.11",
|
||||
"sortablejs": "^1.15.6",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tom-select": "^2.4.3",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-favicon": "^1.0.8"
|
||||
}
|
||||
}
|
||||
8
phpstan.dist.neon
Normal file
8
phpstan.dist.neon
Normal file
@@ -0,0 +1,8 @@
|
||||
parameters:
|
||||
level: 6
|
||||
paths:
|
||||
- bin/
|
||||
- config/
|
||||
- public/
|
||||
- src/
|
||||
- tests/
|
||||
7
postcss.config.cjs
Normal file
7
postcss.config.cjs
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
parser: 'postcss-scss',
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
'autoprefixer': {},
|
||||
},
|
||||
}
|
||||
66
src/Command/AccountCommand.php
Normal file
66
src/Command/AccountCommand.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Account;
|
||||
use App\Service\Generator\TempPasswordGenerator;
|
||||
use App\Service\Mailer\Event\CreatedAdminEvent;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Question\Question;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
#[AsCommand(name: 'crm:admin')]
|
||||
class AccountCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EventDispatcherInterface $eventDispatcher,
|
||||
private readonly UserPasswordHasherInterface $userPasswordHasher,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
?string $name = null
|
||||
) {
|
||||
parent::__construct($name);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$io->title("Création d'un utilisateur administrateur");
|
||||
|
||||
|
||||
|
||||
$existingUser = $this->entityManager->getRepository(Account::class)->findOneBy(['email' => "jovann@siteconseil.fr"]);
|
||||
if (!$existingUser instanceof Account) {
|
||||
$password = TempPasswordGenerator::generate();
|
||||
$newUser = new Account();
|
||||
$newUser->setRoles(['ROLE_ROOT']);
|
||||
$newUser->setUuid(Uuid::v4());
|
||||
$newUser->setIsActif(true);
|
||||
$newUser->setIsFirstLogin(true);
|
||||
$newUser->setEmail("jovann@siteconseil.fr");
|
||||
$newUser->setUsername("Jovann");
|
||||
|
||||
|
||||
|
||||
$hashedPassword = $this->userPasswordHasher->hashPassword($newUser, $password);
|
||||
$newUser->setPassword($hashedPassword);
|
||||
$this->eventDispatcher->dispatch(new CreatedAdminEvent($newUser,$password));
|
||||
|
||||
$this->entityManager->persist($newUser);
|
||||
$this->entityManager->flush();
|
||||
|
||||
|
||||
$io->success("Utilisateur administrateur créé avec succès.");
|
||||
} else {
|
||||
$io->warning("Un utilisateur avec l'email existe déjà.");
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
181
src/Controller/HomeController.php
Normal file
181
src/Controller/HomeController.php
Normal file
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Account;
|
||||
use App\Entity\AccountResetPasswordRequest;
|
||||
use App\Form\RequestPasswordConfirmType;
|
||||
use App\Form\RequestPasswordRequestType;
|
||||
use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent;
|
||||
use App\Service\ResetPassword\Event\ResetPasswordEvent;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
|
||||
class HomeController extends AbstractController
|
||||
{
|
||||
#[Route(path: '/', name: 'app_home', options: ['sitemap' => false], methods: ['GET'])]
|
||||
public function index(AuthenticationUtils $authenticationUtils): Response
|
||||
{
|
||||
return $this->render('home.twig',[
|
||||
'last_username' => $authenticationUtils->getLastUsername(),
|
||||
'error' => $authenticationUtils->getLastAuthenticationError(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/logout', name: 'app_logout', options: ['sitemap' => false], methods: ['GET','POST'])]
|
||||
public function logout(): Response
|
||||
{
|
||||
|
||||
}
|
||||
#[Route(path: '/mot-de-passe-oublie', name: 'app_forgot_password', options: ['sitemap' => false], methods: ['GET','POST'])]
|
||||
public function forgotPassword(Request $request,EventDispatcherInterface $eventDispatcher): Response
|
||||
{
|
||||
$requestPasswordRequest = new ResetPasswordEvent();
|
||||
$form = $this->createForm(RequestPasswordRequestType::class,$requestPasswordRequest);
|
||||
$form->handleRequest($request);
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$eventDispatcher->dispatch($requestPasswordRequest);
|
||||
return $this->redirectToRoute('app_forgot_password_sent');
|
||||
}
|
||||
return $this->render('security/forgot_password.twig', [
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/mot-de-passe-oublie/sent', name: 'app_forgot_password_sent', options: ['sitemap' => false], methods: ['GET','POST'])]
|
||||
public function forgotPasswordSent(Request $request,EventDispatcherInterface $eventDispatcher): Response
|
||||
{
|
||||
return $this->render('security/forgot_password_success.twig', [
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/mot-de-passe-oublie/{id}/{token}', name: 'app_forgot_password_confirm', options: ['sitemap' => false], methods: ['GET','POST'])]
|
||||
public function forgotPasswordConfirm(UserPasswordHasherInterface $userPasswordHasher,EventDispatcherInterface $eventDispatcher,Request $request,EntityManagerInterface $entityManager,string $id,string $token): Response
|
||||
{
|
||||
$errorMessage = "Requête non valide.";
|
||||
if (!is_numeric($id)) {
|
||||
$this->addFlash("error", $errorMessage);
|
||||
return $this->redirectToRoute('app_forgot_password');
|
||||
}
|
||||
$account = $entityManager->getRepository(Account::class)->find((int)$id);
|
||||
if (!$account instanceof Account) {
|
||||
$this->addFlash("error", $errorMessage);
|
||||
return $this->redirectToRoute('app_forgot_password');
|
||||
}
|
||||
$requestToken = $entityManager->getRepository(AccountResetPasswordRequest::class)->findOneBy([
|
||||
'Account' => $account, // Assurez-vous que 'Account' est le nom correct de la propriété/colonne dans votre entité AccountResetPasswordRequest.
|
||||
'token' => $token
|
||||
]);
|
||||
if (!$requestToken instanceof AccountResetPasswordRequest) {
|
||||
$this->addFlash("error", $errorMessage);
|
||||
return $this->redirectToRoute('app_forgot_password');
|
||||
}
|
||||
$now = new \DateTimeImmutable();
|
||||
if ($requestToken->getExpiresAt() < $now) {
|
||||
$this->addFlash("error", "Le lien de réinitialisation de mot de passe a expiré.");
|
||||
return $this->redirectToRoute('app_forgot_password');
|
||||
}
|
||||
|
||||
$event = new ResetPasswordConfirmEvent();
|
||||
$form = $this->createForm(RequestPasswordConfirmType::class,$event);
|
||||
$form->handleRequest($request);
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$account->setPassword($userPasswordHasher->hashPassword($account,$event->getPassword()));
|
||||
$entityManager->persist($account);
|
||||
$entityManager->flush();
|
||||
$this->addFlash("success", "Votre mot de passe a été mis à jour avec succès.");
|
||||
return $this->redirectToRoute('app_home');
|
||||
}
|
||||
return $this->render('security/forgot-password-confirm.twig', [
|
||||
'form' => $form->createView(),
|
||||
'noIndex' => true,
|
||||
'id' => $id,
|
||||
'token' => $token,
|
||||
'account' => $account,
|
||||
]);
|
||||
}
|
||||
const SENTRY_HOST = '';
|
||||
const SENTRY_PROJECT_IDS = [''];
|
||||
|
||||
#[Route('/uptime',name: 'app_uptime',options: ['sitemap' => false], methods: ['GET'])]
|
||||
public function app_uptime(Request $request,HttpClientInterface $httpClient): Response
|
||||
{
|
||||
return $this->json([]);
|
||||
}
|
||||
|
||||
#[Route('/tunnel',name: 'app_tunnel',options: ['sitemap' => false], methods: ['POST'])]
|
||||
public function tunnel(Request $request,HttpClientInterface $httpClient): Response
|
||||
{
|
||||
$envelope = $request->getContent();
|
||||
if (empty($envelope)) {
|
||||
return $this->json([]);
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. Extract the header piece (first line)
|
||||
$pieces = explode("\n", $envelope, 2);
|
||||
$piece = $pieces[0];
|
||||
|
||||
// 3. Parse the header (which is JSON)
|
||||
$header = json_decode($piece, true);
|
||||
|
||||
if (!isset($header['dsn'])) {
|
||||
throw new \Exception("Missing DSN in envelope header.");
|
||||
}
|
||||
|
||||
// 4. Extract and validate DSN and Project ID
|
||||
$dsnUrl = parse_url($header['dsn']);
|
||||
$dsnHostname = $dsnUrl['host'] ?? null;
|
||||
$dsnPath = $dsnUrl['path'] ?? '/';
|
||||
|
||||
// Remove leading/trailing slashes from the path to get the project_id
|
||||
$projectId = trim($dsnPath, '/');
|
||||
|
||||
|
||||
if ($dsnHostname !== self::SENTRY_HOST) {
|
||||
throw new \Exception("Invalid sentry hostname: {$dsnHostname}");
|
||||
}
|
||||
|
||||
if (empty($projectId) || !in_array($projectId, self::SENTRY_PROJECT_IDS)) {
|
||||
throw new \Exception("Invalid sentry project id: {$projectId}");
|
||||
}
|
||||
|
||||
// 5. Construct the upstream Sentry URL
|
||||
$upstreamSentryUrl = "https://" . self::SENTRY_HOST . "/api/" . $projectId . "/envelope/";
|
||||
|
||||
// 6. Forward the request using an HTTP client (e.g., Guzzle)
|
||||
|
||||
$response = $httpClient->request("POST",$upstreamSentryUrl, [
|
||||
'body' => $envelope,
|
||||
'headers' => [
|
||||
// Sentry expects this content type
|
||||
'Content-Type' => 'application/x-sentry-envelope',
|
||||
// Forward the content encoding if present, though often not needed
|
||||
// 'Content-Encoding' => $request->headers->get('Content-Encoding'),
|
||||
],
|
||||
]);
|
||||
|
||||
// 7. Return the status from the upstream Sentry response
|
||||
return new JsonResponse([], $response->getStatusCode());
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Log the error for server-side debugging
|
||||
error_log("Error tunneling to Sentry: " . $e->getMessage());
|
||||
|
||||
// Return a success status (200/202) or a non-specific 500 to the client.
|
||||
// Returning a non-error status (like 200) is often preferred for tunnels
|
||||
// to avoid triggering ad-blockers on failures.
|
||||
return new JsonResponse([
|
||||
'error' => 'An error occurred during tunneling.'
|
||||
], JsonResponse::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
248
src/Entity/Account.php
Normal file
248
src/Entity/Account.php
Normal file
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use AllowDynamicProperties;
|
||||
use App\Repository\AccountRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\HttpFoundation\File\File;
|
||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
#[AllowDynamicProperties] #[ORM\Entity(repositoryClass: AccountRepository::class)]
|
||||
#[ORM\Table(name: '`account`')]
|
||||
#[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_USERNAME', fields: ['username'])]
|
||||
#[UniqueEntity(fields: ['email'], message: 'Cette adresse e-mail est déjà utilisée.')]
|
||||
#[UniqueEntity(fields: ['uuid'], message: 'Cet identifiant unique (UUID) est déjà utilisé.')]
|
||||
class Account implements UserInterface, PasswordAuthenticatedUserInterface, \Serializable
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 180)]
|
||||
private ?string $username = null;
|
||||
|
||||
/**
|
||||
* @var list<string> The user roles
|
||||
*/
|
||||
#[ORM\Column]
|
||||
private array $roles = [];
|
||||
|
||||
#[ORM\Column]
|
||||
private ?string $password = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private ?string $email = null;
|
||||
|
||||
#[ORM\Column(type: Types::GUID)]
|
||||
private ?string $uuid = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?bool $isFirstLogin = null;
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?\DateTimeImmutable $updateAt;
|
||||
|
||||
/**
|
||||
* @var Collection<int, AccountLoginRegister>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: AccountLoginRegister::class, mappedBy: 'account')]
|
||||
private Collection $accountLoginRegisters;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?bool $isActif = null;
|
||||
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->accountLoginRegisters = new ArrayCollection();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* A visual identifier that represents this user.
|
||||
*
|
||||
* @see UserInterface
|
||||
*/
|
||||
public function getUserIdentifier(): string
|
||||
{
|
||||
return (string) $this->username;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see UserInterface
|
||||
*/
|
||||
public function getRoles(): array
|
||||
{
|
||||
$roles = $this->roles;
|
||||
// guarantee every user at least has ROLE_USER
|
||||
$roles[] = 'ROLE_USER';
|
||||
|
||||
return array_unique($roles);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the session doesn't contain actual password hashes by CRC32C-hashing them, as supported since Symfony 7.3.
|
||||
*/
|
||||
|
||||
#[\Deprecated]
|
||||
public function eraseCredentials(): void
|
||||
{
|
||||
// @deprecated, to be removed when upgrading to Symfony 8
|
||||
}
|
||||
|
||||
public function getEmail(): ?string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function setEmail(string $email): static
|
||||
{
|
||||
$this->email = $email;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUuid(): ?string
|
||||
{
|
||||
return $this->uuid;
|
||||
}
|
||||
|
||||
public function setUuid(string $uuid): static
|
||||
{
|
||||
$this->uuid = $uuid;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isFirstLogin(): ?bool
|
||||
{
|
||||
return $this->isFirstLogin;
|
||||
}
|
||||
|
||||
public function setIsFirstLogin(?bool $isFirstLogin): static
|
||||
{
|
||||
$this->isFirstLogin = $isFirstLogin;
|
||||
|
||||
return $this;
|
||||
}
|
||||
public function getUpdateAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->updateAt;
|
||||
}
|
||||
|
||||
public function setUpdateAt(?\DateTimeImmutable $updateAt): static
|
||||
{
|
||||
$this->updateAt = $updateAt;
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
public function serialize()
|
||||
{
|
||||
return serialize(array(
|
||||
$this->id,
|
||||
$this->email,
|
||||
$this->username,
|
||||
$this->avatarFileName,
|
||||
));
|
||||
}
|
||||
|
||||
public function unserialize(string $data)
|
||||
{
|
||||
list (
|
||||
$this->id,
|
||||
$this->email,
|
||||
$this->username,
|
||||
$this->avatarFileName,
|
||||
) = unserialize($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, AccountLoginRegister>
|
||||
*/
|
||||
public function getAccountLoginRegisters(): Collection
|
||||
{
|
||||
return $this->accountLoginRegisters;
|
||||
}
|
||||
|
||||
public function addAccountLoginRegister(AccountLoginRegister $accountLoginRegister): static
|
||||
{
|
||||
if (!$this->accountLoginRegisters->contains($accountLoginRegister)) {
|
||||
$this->accountLoginRegisters->add($accountLoginRegister);
|
||||
$accountLoginRegister->setAccount($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeAccountLoginRegister(AccountLoginRegister $accountLoginRegister): static
|
||||
{
|
||||
if ($this->accountLoginRegisters->removeElement($accountLoginRegister)) {
|
||||
// set the owning side to null (unless already changed)
|
||||
if ($accountLoginRegister->getAccount() === $this) {
|
||||
$accountLoginRegister->setAccount(null);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isActif(): ?bool
|
||||
{
|
||||
return $this->isActif;
|
||||
}
|
||||
|
||||
public function setIsActif(?bool $isActif): static
|
||||
{
|
||||
$this->isActif = $isActif;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
80
src/Entity/AccountLoginRegister.php
Normal file
80
src/Entity/AccountLoginRegister.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\AccountLoginRegisterRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: AccountLoginRegisterRepository::class)]
|
||||
class AccountLoginRegister
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'accountLoginRegisters')]
|
||||
private ?Account $account = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private ?\DateTimeImmutable $loginAt = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private ?string $userAgent = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private ?string $ip = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getAccount(): ?Account
|
||||
{
|
||||
return $this->account;
|
||||
}
|
||||
|
||||
public function setAccount(?Account $account): static
|
||||
{
|
||||
$this->account = $account;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLoginAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->loginAt;
|
||||
}
|
||||
|
||||
public function setLoginAt(\DateTimeImmutable $loginAt): static
|
||||
{
|
||||
$this->loginAt = $loginAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUserAgent(): ?string
|
||||
{
|
||||
return $this->userAgent;
|
||||
}
|
||||
|
||||
public function setUserAgent(string $userAgent): static
|
||||
{
|
||||
$this->userAgent = $userAgent;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getIp(): ?string
|
||||
{
|
||||
return $this->ip;
|
||||
}
|
||||
|
||||
public function setIp(string $ip): static
|
||||
{
|
||||
$this->ip = $ip;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
82
src/Entity/AccountResetPasswordRequest.php
Normal file
82
src/Entity/AccountResetPasswordRequest.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\AccountResetPasswordRequestRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: AccountResetPasswordRequestRepository::class)]
|
||||
class AccountResetPasswordRequest
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
private ?Account $Account = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private ?string $token = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private ?\DateTimeImmutable $requestedAt = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private ?\DateTimeImmutable $expiresAt = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getAccount(): ?Account
|
||||
{
|
||||
return $this->Account;
|
||||
}
|
||||
|
||||
public function setAccount(?Account $Account): static
|
||||
{
|
||||
$this->Account = $Account;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|null
|
||||
*/
|
||||
public function getToken(): ?string
|
||||
{
|
||||
return $this->token;
|
||||
}
|
||||
|
||||
public function setToken(?string $token): static
|
||||
{
|
||||
$this->token = $token;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRequestedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->requestedAt;
|
||||
}
|
||||
|
||||
public function setRequestedAt(\DateTimeImmutable $requestedAt): static
|
||||
{
|
||||
$this->requestedAt = $requestedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getExpiresAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->expiresAt;
|
||||
}
|
||||
|
||||
public function setExpiresAt(\DateTimeImmutable $expiresAt): static
|
||||
{
|
||||
$this->expiresAt = $expiresAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
35
src/Form/RequestPasswordConfirmType.php
Normal file
35
src/Form/RequestPasswordConfirmType.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class RequestPasswordConfirmType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->add('password', RepeatedType::class, [
|
||||
'required' => true,
|
||||
'type' => PasswordType::class,
|
||||
'first_options' => [
|
||||
'label' => 'Mot de passe',
|
||||
],
|
||||
'second_options' => [
|
||||
'label' => 'Confirmer le mot de passe',
|
||||
],
|
||||
'invalid_message' => 'Les deux mots de passe doivent correspondre.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => ResetPasswordConfirmEvent::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
27
src/Form/RequestPasswordRequestType.php
Normal file
27
src/Form/RequestPasswordRequestType.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Service\ResetPassword\Event\ResetPasswordEvent;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class RequestPasswordRequestType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->add('email', EmailType::class, [
|
||||
'required' => true,
|
||||
'label' => 'Email de votre compte'
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => ResetPasswordEvent::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
52
src/Repository/AccountLoginRegisterRepository.php
Normal file
52
src/Repository/AccountLoginRegisterRepository.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Account;
|
||||
use App\Entity\AccountLoginRegister;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<AccountLoginRegister>
|
||||
*/
|
||||
class AccountLoginRegisterRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, AccountLoginRegister::class);
|
||||
}
|
||||
|
||||
// /**
|
||||
// * @return AccountLoginRegister[] Returns an array of AccountLoginRegister 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): ?AccountLoginRegister
|
||||
// {
|
||||
// return $this->createQueryBuilder('a')
|
||||
// ->andWhere('a.exampleField = :val')
|
||||
// ->setParameter('val', $value)
|
||||
// ->getQuery()
|
||||
// ->getOneOrNullResult()
|
||||
// ;
|
||||
// }
|
||||
public function lastLogin(Account $account)
|
||||
{
|
||||
return $this->createQueryBuilder('a')
|
||||
->andWhere('a.account = :acc')
|
||||
->setParameter('acc',$account)
|
||||
->orderBy('a.loginAt','DESC')
|
||||
->getQuery()->getResult();
|
||||
}
|
||||
}
|
||||
39
src/Repository/AccountRepository.php
Normal file
39
src/Repository/AccountRepository.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Account;
|
||||
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<Account>
|
||||
*/
|
||||
class AccountRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Account::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to upgrade (rehash) the user's password automatically over time.
|
||||
*/
|
||||
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
|
||||
{
|
||||
if (!$user instanceof Account) {
|
||||
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class));
|
||||
}
|
||||
|
||||
$user->setPassword($newHashedPassword);
|
||||
$this->getEntityManager()->persist($user);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
18
src/Repository/AccountResetPasswordRequestRepository.php
Normal file
18
src/Repository/AccountResetPasswordRequestRepository.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\AccountResetPasswordRequest;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<AccountResetPasswordRequest>
|
||||
*/
|
||||
class AccountResetPasswordRequestRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, AccountResetPasswordRequest::class);
|
||||
}
|
||||
}
|
||||
35
src/Security/AccessDeniedHandler.php
Normal file
35
src/Security/AccessDeniedHandler.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\Session\Session;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
use Symfony\Component\Security\Http\Authorization\AccessDeniedHandlerInterface;
|
||||
use Twig\Environment;
|
||||
|
||||
class AccessDeniedHandler implements AccessDeniedHandlerInterface
|
||||
{
|
||||
public function __construct(private readonly UrlGeneratorInterface $urlGenerator, private readonly Environment $twig)
|
||||
{
|
||||
}
|
||||
|
||||
public function handle(Request $request, AccessDeniedException $accessDeniedException): Response
|
||||
{
|
||||
$attributes = $accessDeniedException->getAttributes();
|
||||
|
||||
$pathInfo = $request->getPathInfo();
|
||||
if (str_contains($pathInfo, "/admin")) {
|
||||
return new RedirectResponse($this->urlGenerator->generate("app_home"));
|
||||
}
|
||||
if (in_array('application/json', $request->getAcceptableContentTypes())) {
|
||||
return new JsonResponse(null, Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
return new JsonResponse(null, Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
56
src/Security/AuthenticationEntryPoint.php
Normal file
56
src/Security/AuthenticationEntryPoint.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\RememberMeToken;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
use Symfony\Component\Security\Core\Exception\InsufficientAuthenticationException;
|
||||
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
|
||||
|
||||
class AuthenticationEntryPoint implements AuthenticationEntryPointInterface
|
||||
{
|
||||
/**
|
||||
* @var \Symfony\Component\Routing\Generator\UrlGeneratorInterface|mixed
|
||||
*/
|
||||
public $urlGenerator;
|
||||
/**
|
||||
* @var AccessDeniedHandler|mixed
|
||||
*/
|
||||
public $accessDeniedHandler;
|
||||
public function __construct(
|
||||
UrlGeneratorInterface $urlGenerator,
|
||||
AccessDeniedHandler $accessDeniedHandler
|
||||
) {
|
||||
$this->urlGenerator = $urlGenerator;
|
||||
$this->accessDeniedHandler = $accessDeniedHandler;
|
||||
}
|
||||
|
||||
public function start(Request $request, AuthenticationException $authException = null): Response
|
||||
{
|
||||
$previous = $authException !== null ? $authException->getPrevious() : null;
|
||||
|
||||
// Parque le composant security est un peu bête et ne renvoie pas un AccessDenied pour les utilisateur connecté avec un cookie
|
||||
// On redirige le traitement de cette situation vers le AccessDeniedHandler
|
||||
if ($authException instanceof InsufficientAuthenticationException &&
|
||||
$previous instanceof AccessDeniedException &&
|
||||
$authException->getToken() instanceof RememberMeToken
|
||||
) {
|
||||
return $this->accessDeniedHandler->handle($request, $previous);
|
||||
}
|
||||
|
||||
if (in_array('application/json', $request->getAcceptableContentTypes())) {
|
||||
return new JsonResponse(
|
||||
['title' => "Vous n'avez pas les permissions suffisantes pour effectuer cette action"],
|
||||
Response::HTTP_FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return new RedirectResponse($this->urlGenerator->generate('app_login'));
|
||||
}
|
||||
}
|
||||
96
src/Security/LoginFormAuthenticator.php
Normal file
96
src/Security/LoginFormAuthenticator.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use App\Entity\Account;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security; // L'objet moderne pour les opérations de sécurité
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
|
||||
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||
use Symfony\Component\Security\Http\SecurityRequestAttributes;
|
||||
use Symfony\Component\Security\Http\Util\TargetPathTrait;
|
||||
|
||||
class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
|
||||
{
|
||||
use TargetPathTrait;
|
||||
|
||||
public const LOGIN_ROUTE = 'app_login';
|
||||
|
||||
// Les propriétés typées sont la norme en Symfony 7 / PHP 8.2+
|
||||
private EntityManagerInterface $entityManager;
|
||||
private UrlGeneratorInterface $urlGenerator;
|
||||
private Security $security;
|
||||
|
||||
public function __construct(EntityManagerInterface $entityManager, UrlGeneratorInterface $urlGenerator, Security $security)
|
||||
{
|
||||
$this->entityManager = $entityManager;
|
||||
$this->urlGenerator = $urlGenerator;
|
||||
$this->security = $security;
|
||||
}
|
||||
|
||||
public function supports(Request $request): bool
|
||||
{
|
||||
return ($request->attributes->get('_route') === self::LOGIN_ROUTE) && $request->isMethod('POST');
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Request $request La requête HTTP entrante
|
||||
* @return Passport Le passeport contenant l'utilisateur et les informations d'authentification
|
||||
*/
|
||||
public function authenticate(Request $request): Passport
|
||||
{
|
||||
|
||||
|
||||
$email = (string) $request->request->get('_username', '');
|
||||
$request->getSession()->set(SecurityRequestAttributes::LAST_USERNAME, $email);
|
||||
|
||||
return new Passport(
|
||||
// 1. UserBadge: Charge l'utilisateur par l'email
|
||||
new UserBadge($email, function(string $userIdentifier): Account {
|
||||
$user = $this->entityManager->getRepository(Account::class)->findOneBy(['email' => $userIdentifier]);
|
||||
|
||||
if (!$user) {
|
||||
throw new CustomUserMessageAuthenticationException('Email ou mot de passe invalide.');
|
||||
}
|
||||
|
||||
return $user;
|
||||
}),
|
||||
// 2. Credentials: Vérifie le mot de passe
|
||||
new PasswordCredentials($request->request->get('_password', '')),
|
||||
[
|
||||
new CsrfTokenBadge('authenticate', $request->request->get('_csrf_token')),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Request $request La requête actuelle
|
||||
* @param TokenInterface $token Le jeton d'authentification
|
||||
* @param string $firewallName Le nom du pare-feu utilisé ('main' dans votre cas)
|
||||
* @return Response|null
|
||||
*/
|
||||
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||
{
|
||||
return new RedirectResponse($this->urlGenerator->generate('app_home'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Request $request
|
||||
* @return string L'URL de la page de connexion
|
||||
*/
|
||||
protected function getLoginUrl(Request $request): string
|
||||
{
|
||||
return $this->urlGenerator->generate(self::LOGIN_ROUTE);
|
||||
}
|
||||
}
|
||||
46
src/Security/PasswordGenerator.php
Normal file
46
src/Security/PasswordGenerator.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
class PasswordGenerator
|
||||
{
|
||||
private int $length;
|
||||
private string $charsLower = 'abcdefghijklmnopqrstuvwxyz';
|
||||
private string $charsUpper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
private string $digits = '0123456789';
|
||||
private string $specials = '@#_-'; // Caractères spéciaux limités
|
||||
|
||||
public function __construct(int $length = 12)
|
||||
{
|
||||
if ($length < 4) {
|
||||
throw new \InvalidArgumentException('La longueur du mot de passe doit être au moins 4.');
|
||||
}
|
||||
$this->length = $length;
|
||||
}
|
||||
|
||||
public function generate(): string
|
||||
{
|
||||
// Garantir la présence d'au moins un caractère de chaque type
|
||||
$password = [
|
||||
$this->randomChar($this->charsLower),
|
||||
$this->randomChar($this->charsUpper),
|
||||
$this->randomChar($this->digits),
|
||||
$this->randomChar($this->specials),
|
||||
];
|
||||
|
||||
$allChars = $this->charsLower . $this->charsUpper . $this->digits . $this->specials;
|
||||
|
||||
for ($i = 4; $i < $this->length; $i++) {
|
||||
$password[] = $this->randomChar($allChars);
|
||||
}
|
||||
|
||||
shuffle($password);
|
||||
|
||||
return implode('', $password);
|
||||
}
|
||||
|
||||
private function randomChar(string $chars): string
|
||||
{
|
||||
return $chars[random_int(0, strlen($chars) - 1)];
|
||||
}
|
||||
}
|
||||
27
src/Security/UserChecker.php
Normal file
27
src/Security/UserChecker.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use App\Entity\Account;
|
||||
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
|
||||
use Symfony\Component\Security\Core\User\UserCheckerInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
class UserChecker implements UserCheckerInterface
|
||||
{
|
||||
public function checkPreAuth(UserInterface $user): void
|
||||
{
|
||||
if (!$user instanceof Account) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$user->isActif()) {
|
||||
throw new CustomUserMessageAccountStatusException('Votre compte a été désactivé.');
|
||||
}
|
||||
}
|
||||
|
||||
public function checkPostAuth(UserInterface $user): void
|
||||
{
|
||||
// Pas de vérifications post-authentification pour l’instant
|
||||
}
|
||||
}
|
||||
52
src/Service/Generator/TempPasswordGenerator.php
Normal file
52
src/Service/Generator/TempPasswordGenerator.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Generator;
|
||||
|
||||
/**
|
||||
* Class TempPasswordGenerator
|
||||
*
|
||||
* Fournit des fonctionnalités pour générer des mots de passe temporaires sécurisés.
|
||||
*/
|
||||
class TempPasswordGenerator
|
||||
{
|
||||
private const DEFAULT_LENGTH = 12;
|
||||
private const DEFAULT_CHARACTERS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()-_=+[]{}|;:,.<>?';
|
||||
|
||||
/**
|
||||
* Génère un mot de passe temporaire aléatoire.
|
||||
*
|
||||
* @param int $length Longueur désirée du mot de passe (par défaut 12).
|
||||
* @param string $characters Jeu de caractères à utiliser dans le mot de passe.
|
||||
* @return string Mot de passe généré.
|
||||
*/
|
||||
public static function generate(int $length = self::DEFAULT_LENGTH, string $characters = self::DEFAULT_CHARACTERS): string
|
||||
{
|
||||
if ($length <= 0) {
|
||||
$length = self::DEFAULT_LENGTH;
|
||||
}
|
||||
|
||||
$password = '';
|
||||
$maxIndex = strlen($characters) - 1;
|
||||
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$password .= $characters[random_int(0, $maxIndex)];
|
||||
}
|
||||
|
||||
return $password;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un mot de passe remplit certaines exigences de complexité.
|
||||
*
|
||||
* @param string $password Mot de passe à vérifier.
|
||||
* @return bool True si les critères sont respectés, false sinon.
|
||||
*/
|
||||
public static function isComplex(string $password): bool
|
||||
{
|
||||
return strlen($password) >= 8
|
||||
&& preg_match('/[A-Z]/', $password)
|
||||
&& preg_match('/[a-z]/', $password)
|
||||
&& preg_match('/[0-9]/', $password)
|
||||
&& preg_match('/[!@#$%^&*()\-_=+\[\]{}|;:,.<>?]/', $password);
|
||||
}
|
||||
}
|
||||
24
src/Service/Mailer/Event/CreatedAdminEvent.php
Normal file
24
src/Service/Mailer/Event/CreatedAdminEvent.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Mailer\Event;
|
||||
|
||||
use App\Entity\Account;
|
||||
|
||||
class CreatedAdminEvent
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Account $account,
|
||||
private readonly string $password
|
||||
) {
|
||||
}
|
||||
|
||||
public function getAccount(): Account
|
||||
{
|
||||
return $this->account;
|
||||
}
|
||||
|
||||
public function getPassword(): string
|
||||
{
|
||||
return $this->password;
|
||||
}
|
||||
}
|
||||
100
src/Service/Mailer/Mailer.php
Normal file
100
src/Service/Mailer/Mailer.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Mailer;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Exception;
|
||||
use Symfony\Component\HttpKernel\Profiler\Profiler;
|
||||
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
|
||||
use Symfony\Component\Mime\Address;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use Symfony\Component\Mime\Header\IdentificationHeader;
|
||||
use Symfony\Component\Process\Exception\ProcessFailedException;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Twig\Environment;
|
||||
|
||||
class Mailer
|
||||
{
|
||||
private readonly MailerInterface $mailer;
|
||||
|
||||
public function __construct(
|
||||
MailerInterface $mailer,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
private readonly ?Profiler $profiler,
|
||||
private readonly Environment $environment,
|
||||
) {
|
||||
$this->mailer = $mailer;
|
||||
}
|
||||
|
||||
private function convertMjmlToHtml(string $mjmlContent): string
|
||||
{
|
||||
$process = new Process(['mjml', '--stdin']);
|
||||
$process->setInput($mjmlContent);
|
||||
|
||||
try {
|
||||
$process->run();
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
throw new ProcessFailedException($process);
|
||||
}
|
||||
|
||||
return $process->getOutput();
|
||||
} catch (ProcessFailedException|Exception) {
|
||||
return ''; // Retourne vide en cas d'échec
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param string $address
|
||||
* @param string $addressName
|
||||
* @param string $subject
|
||||
* @param string $template
|
||||
* @param array<string, mixed> $data
|
||||
* @param array<\Symfony\Component\Mime\Part\DataPart> $files
|
||||
*/
|
||||
public function send(
|
||||
string $address,
|
||||
string $addressName,
|
||||
string $subject,
|
||||
string $template,
|
||||
array $data,
|
||||
array $files = []
|
||||
): void {
|
||||
$dest = new Address($address, $addressName);
|
||||
$src = new Address("contact@e-page.fr", "E-PAGE");
|
||||
|
||||
$mail = (new Email())
|
||||
->subject($subject)
|
||||
->to($dest)
|
||||
->from($src);
|
||||
|
||||
$mjmlGenerator = $this->environment->render($template, [
|
||||
'system' => [
|
||||
'subject' => $subject,
|
||||
'path' => $_ENV['PATH_URL'],
|
||||
],
|
||||
'datas' => $data,
|
||||
]);
|
||||
|
||||
$htmlContent = $this->convertMjmlToHtml($mjmlGenerator);
|
||||
$txtContent = $this->environment->render('txt-'.$template,[
|
||||
'system' => [
|
||||
'subject' => $subject,
|
||||
'path' => $_ENV['PATH_URL'],
|
||||
],
|
||||
'datas' => $data,
|
||||
]);
|
||||
foreach ($files as $file) {
|
||||
$mail->addPart($file);
|
||||
}
|
||||
$mail->html($htmlContent);
|
||||
$mail->text($txtContent);
|
||||
$this->mailer->send($mail);
|
||||
}
|
||||
|
||||
}
|
||||
35
src/Service/Mailer/MailerSubscriber.php
Normal file
35
src/Service/Mailer/MailerSubscriber.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Mailer;
|
||||
|
||||
use App\Service\Mailer\Event\CreatedAdminEvent;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
#[AsEventListener(event: CreatedAdminEvent::class, method: 'onAdminEvent')]
|
||||
class MailerSubscriber
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
private readonly Mailer $mailer
|
||||
) {
|
||||
}
|
||||
|
||||
public function onAdminEvent(CreatedAdminEvent $createdAdminEvent): void
|
||||
{
|
||||
$account = $createdAdminEvent->getAccount();
|
||||
$password = $createdAdminEvent->getPassword();
|
||||
|
||||
$this->mailer->send(
|
||||
$account->getEmail(),
|
||||
$account->getUsername(),
|
||||
"[CRM] - Création d'un compte administrateur",
|
||||
"mails/new_admin.twig",
|
||||
[
|
||||
'username' => $account->getUsername(),
|
||||
'password' => $password,
|
||||
'url' => $this->urlGenerator->generate('app_home'),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\ResetPassword\Event;
|
||||
|
||||
class ResetPasswordConfirmEvent
|
||||
{
|
||||
private $password;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function getPassword(): string
|
||||
{
|
||||
return $this->password;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $password
|
||||
*/
|
||||
public function setPassword($password): void
|
||||
{
|
||||
$this->password = $password;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
25
src/Service/ResetPassword/Event/ResetPasswordEvent.php
Normal file
25
src/Service/ResetPassword/Event/ResetPasswordEvent.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\ResetPassword\Event;
|
||||
|
||||
class ResetPasswordEvent
|
||||
{
|
||||
private ?string $email;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function getEmail(): string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|null $email
|
||||
*/
|
||||
public function setEmail(?string $email): void
|
||||
{
|
||||
$this->email = $email;
|
||||
}
|
||||
}
|
||||
83
src/Service/ResetPassword/ResetPasswordSubscriber.php
Normal file
83
src/Service/ResetPassword/ResetPasswordSubscriber.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\ResetPassword;
|
||||
|
||||
use App\Entity\Account;
|
||||
use App\Entity\AccountResetPasswordRequest;
|
||||
use App\Service\Generator\TempPasswordGenerator;
|
||||
use App\Service\Mailer\Mailer;
|
||||
use App\Service\ResetPassword\Event\ResetPasswordEvent;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
#[AsEventListener(event: ResetPasswordEvent::class, method: 'onResetPassword')]
|
||||
class ResetPasswordSubscriber
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Mailer $mailer,
|
||||
private readonly RequestStack $requestStack,
|
||||
) {
|
||||
}
|
||||
|
||||
public function onResetPassword(ResetPasswordEvent $resetPasswordEvent): void
|
||||
{
|
||||
$email = $resetPasswordEvent->getEmail();
|
||||
$account = $this->entityManager->getRepository(Account::class)->findOneBy(['email' => $email]);
|
||||
|
||||
if (!$account instanceof Account) {
|
||||
return;
|
||||
}
|
||||
|
||||
$existingRequest = $this->entityManager->getRepository(AccountResetPasswordRequest::class)
|
||||
->findOneBy(['Account' => $account]);
|
||||
|
||||
$now = new \DateTimeImmutable();
|
||||
$sendNewRequest = true;
|
||||
$request = null;
|
||||
|
||||
if ($existingRequest instanceof AccountResetPasswordRequest) {
|
||||
$expiredAt = $existingRequest->getExpiresAt();
|
||||
if ($expiredAt < $now) {
|
||||
$this->entityManager->remove($existingRequest);
|
||||
} else {
|
||||
$sendNewRequest = false;
|
||||
$request = $existingRequest;
|
||||
}
|
||||
}
|
||||
|
||||
if ($sendNewRequest) {
|
||||
$expiredAt = $now->modify('+1 hour');
|
||||
|
||||
$request = new AccountResetPasswordRequest();
|
||||
$request->setAccount($account);
|
||||
$request->setToken(TempPasswordGenerator::generate(50, '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'));
|
||||
$request->setRequestedAt($now);
|
||||
$request->setExpiresAt($expiredAt);
|
||||
|
||||
$this->entityManager->persist($request);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
$resetLink = $this->urlGenerator->generate(
|
||||
'app_forgot_password_confirm',
|
||||
['id' => $account->getId(), 'token' => $request->getToken()],
|
||||
UrlGeneratorInterface::ABSOLUTE_URL
|
||||
);
|
||||
|
||||
$this->mailer->send(
|
||||
$account->getEmail(),
|
||||
$account->getUsername(),
|
||||
'[CRM] - Lien pour réinitialiser votre mot de passe',
|
||||
'mails/reset.twig',
|
||||
[
|
||||
'account' => $account,
|
||||
'request' => $request,
|
||||
'resetLink' => $resetLink,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
160
src/Twig/ViteAssetExtension.php
Normal file
160
src/Twig/ViteAssetExtension.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace App\Twig;
|
||||
|
||||
use Detection\MobileDetect;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
class ViteAssetExtension extends AbstractExtension
|
||||
{
|
||||
// Clé réservée dans le manifest Vite pour le HTML généré des favicons.
|
||||
const FAVICON_MANIFEST_KEY = '_FAVICONS_HTML_';
|
||||
|
||||
private ?array $manifestData = null;
|
||||
|
||||
const CACHE_KEY = 'vite_manifest';
|
||||
|
||||
private readonly bool $isDev;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $manifest,
|
||||
private readonly CacheItemPoolInterface $cache,
|
||||
) {
|
||||
// Respecte la logique existante : VITE_LOAD == "0" est considéré comme DEV.
|
||||
$this->isDev = $_ENV['VITE_LOAD'] == "0";
|
||||
}
|
||||
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [
|
||||
new TwigFunction('vite_asset', $this->asset(...), ['is_safe' => ['html']]),
|
||||
new TwigFunction('isMobile', $this->isMobile(...), ['is_safe' => ['html']]),
|
||||
// Nouvelle fonction Twig pour inclure les liens de favicons
|
||||
new TwigFunction('vite_favicons', $this->favicons(...), ['is_safe' => ['html']])
|
||||
];
|
||||
}
|
||||
|
||||
public function isMobile()
|
||||
{
|
||||
$detect = new MobileDetect();
|
||||
return $detect->isMobile() || $detect->isTablet();
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge le manifeste s'il n'est pas déjà chargé et met en cache.
|
||||
*/
|
||||
private function loadManifest(): void
|
||||
{
|
||||
if ($this->manifestData === null) {
|
||||
$item = $this->cache->getItem(self::CACHE_KEY);
|
||||
if ($item->isHit()) {
|
||||
$this->manifestData = $item->get();
|
||||
} else {
|
||||
if (!file_exists($this->manifest)) {
|
||||
// En cas d'erreur de fichier, initialise à un tableau vide
|
||||
$this->manifestData = [];
|
||||
return;
|
||||
}
|
||||
|
||||
$this->manifestData = json_decode((string)file_get_contents($this->manifest), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$this->manifestData = [];
|
||||
}
|
||||
|
||||
$item->set($this->manifestData);
|
||||
$this->cache->save($item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Gestion des assets JS/CSS (non modifiée) ---
|
||||
|
||||
public function asset(string $entry, array $deps): string
|
||||
{
|
||||
if ($this->isDev) {
|
||||
return $this->assetDev($entry, $deps);
|
||||
}
|
||||
|
||||
return $this->assetProd($entry);
|
||||
}
|
||||
|
||||
public function assetDev(string $entry, array $deps): string
|
||||
{
|
||||
$html = <<<HTML
|
||||
<script type="module" src="http://localhost:5173/assets/@vite/client"></script>
|
||||
HTML;
|
||||
return $html . <<<HTML
|
||||
<script type="module" src="http://localhost:5173/assets/{$entry}" defer></script>
|
||||
HTML;
|
||||
}
|
||||
|
||||
public function assetProd(string $entry): string
|
||||
{
|
||||
$this->loadManifest();
|
||||
|
||||
$file = $this->manifestData[$entry]['file'] ?? '';
|
||||
$css = $this->manifestData[$entry]['css'] ?? [];
|
||||
$imports = $this->manifestData[$entry]['imports'] ?? [];
|
||||
|
||||
$html = <<<HTML
|
||||
<script type="module" src="/build/{$file}" crossorigin="anonymous" defer></script>
|
||||
HTML;
|
||||
|
||||
foreach ($css as $cssFile) {
|
||||
$html .= <<<HTML
|
||||
<link rel="stylesheet" rel="preload" media="screen" href="/build/{$cssFile}" crossorigin="anonymous"/>
|
||||
HTML;
|
||||
}
|
||||
|
||||
foreach ($imports as $import) {
|
||||
$import = str_replace("_vendor","vendor",$import);
|
||||
$import = str_replace("_turbo","turbo",$import);
|
||||
$html .= <<<HTML
|
||||
<link rel="modulepreload" href="/build/{$import}" crossorigin="anonymous"/>
|
||||
HTML;
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
// --- Nouvelle Gestion des Favicons ---
|
||||
|
||||
public function favicons(): string
|
||||
{
|
||||
if ($this->isDev) {
|
||||
return $this->faviconsDev();
|
||||
}
|
||||
|
||||
return $this->faviconsProd();
|
||||
}
|
||||
|
||||
public function faviconsDev(): string
|
||||
{
|
||||
// En mode dev, on assume qu'un fichier favicon.ico ou favicon.png
|
||||
// standard est présent dans le répertoire public.
|
||||
return <<<HTML
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
HTML;
|
||||
}
|
||||
|
||||
public function faviconsProd(): string
|
||||
{
|
||||
$this->loadManifest();
|
||||
|
||||
// Récupère le bloc HTML complet généré par le plugin dans le manifest.
|
||||
// On suppose que l'entrée est un tableau associatif avec la clé 'html'.
|
||||
$faviconData = $this->manifestData;
|
||||
$faviconHtml = "";
|
||||
foreach ($faviconData as $key =>$favicon) {
|
||||
if(!str_contains($key,".js")) {
|
||||
$faviconHtml .= <<<HTML
|
||||
<link rel="icon" href="/build/{$favicon['file']}" type="image/x-icon">
|
||||
HTML;
|
||||
}
|
||||
}
|
||||
return $faviconHtml;
|
||||
}
|
||||
}
|
||||
129
symfony.lock
129
symfony.lock
@@ -35,6 +35,71 @@
|
||||
"migrations/.gitignore"
|
||||
]
|
||||
},
|
||||
"google/apiclient": {
|
||||
"version": "2.18",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes-contrib",
|
||||
"branch": "main",
|
||||
"version": "2.10",
|
||||
"ref": "07a97ec434d43b7903c78069a04c92adb6442e52"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/google_apiclient.yaml"
|
||||
]
|
||||
},
|
||||
"knplabs/knp-paginator-bundle": {
|
||||
"version": "v6.10.0"
|
||||
},
|
||||
"league/flysystem-bundle": {
|
||||
"version": "3.6",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "1.0",
|
||||
"ref": "913dc3d7a5a1af0d2b044c5ac3a16e2f851d7380"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/flysystem.yaml",
|
||||
"var/storage/.gitignore"
|
||||
]
|
||||
},
|
||||
"liip/imagine-bundle": {
|
||||
"version": "2.15",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes-contrib",
|
||||
"branch": "main",
|
||||
"version": "1.8",
|
||||
"ref": "d1227d002b70d1a1f941d91845fcd7ac7fbfc929"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/liip_imagine.yaml",
|
||||
"config/routes/liip_imagine.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"
|
||||
]
|
||||
},
|
||||
"phpstan/phpstan": {
|
||||
"version": "2.1",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes-contrib",
|
||||
"branch": "main",
|
||||
"version": "1.0",
|
||||
"ref": "5e490cc197fb6bb1ae22e5abbc531ddc633b6767"
|
||||
},
|
||||
"files": [
|
||||
"phpstan.dist.neon"
|
||||
]
|
||||
},
|
||||
"phpunit/phpunit": {
|
||||
"version": "12.5",
|
||||
"recipe": {
|
||||
@@ -50,6 +115,30 @@
|
||||
"bin/phpunit"
|
||||
]
|
||||
},
|
||||
"presta/sitemap-bundle": {
|
||||
"version": "v4.2.0"
|
||||
},
|
||||
"sentry/sentry-symfony": {
|
||||
"version": "5.8",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes-contrib",
|
||||
"branch": "main",
|
||||
"version": "5.0",
|
||||
"ref": "b6cb4b34429dadecd7187852123be19d628fa37a"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/sentry.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/amazon-mailer": {
|
||||
"version": "7.3",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "4.4",
|
||||
"ref": "9648db3ecae5c8a6b1a5f74715d3907124348815"
|
||||
}
|
||||
},
|
||||
"symfony/asset-mapper": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
@@ -229,21 +318,6 @@
|
||||
"config/routes/security.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/stimulus-bundle": {
|
||||
"version": "2.31",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "2.24",
|
||||
"ref": "3357f2fa6627b93658d8e13baa416b2a94a50c5f"
|
||||
},
|
||||
"files": [
|
||||
"assets/controllers.json",
|
||||
"assets/controllers/csrf_protection_controller.js",
|
||||
"assets/controllers/hello_controller.js",
|
||||
"assets/stimulus_bootstrap.js"
|
||||
]
|
||||
},
|
||||
"symfony/translation": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
@@ -270,17 +344,14 @@
|
||||
"templates/base.html.twig"
|
||||
]
|
||||
},
|
||||
"symfony/ux-turbo": {
|
||||
"version": "2.31",
|
||||
"symfony/uid": {
|
||||
"version": "7.3",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "2.20",
|
||||
"ref": "287f7c6eb6e9b65e422d34c00795b360a787380b"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/ux_turbo.yaml"
|
||||
]
|
||||
"version": "7.0",
|
||||
"ref": "0df5844274d871b37fc3816c57a768ffc60a43a5"
|
||||
}
|
||||
},
|
||||
"symfony/validator": {
|
||||
"version": "7.4",
|
||||
@@ -321,5 +392,17 @@
|
||||
},
|
||||
"twig/extra-bundle": {
|
||||
"version": "v3.22.1"
|
||||
},
|
||||
"vich/uploader-bundle": {
|
||||
"version": "2.9",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes-contrib",
|
||||
"branch": "main",
|
||||
"version": "1.13",
|
||||
"ref": "1b3064c2f6b255c2bc2f56461aaeb76b11e07e36"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/vich_uploader.yaml"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<!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 %}
|
||||
{% block importmap %}{{ importmap('app') }}{% endblock %}
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{% block body %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
18
templates/base.twig
Normal file
18
templates/base.twig
Normal file
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="{{ app.request.locale }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<title>{% block title %}Accueil{% endblock %}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
{{ vite_asset('app.js', []) }}
|
||||
|
||||
</head>
|
||||
{# Le corps aura un fond gris clair pour correspondre au fond du logo #}
|
||||
<body class="flex flex-col min-h-screen bg-gray-100">
|
||||
{% block body %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
141
templates/form_tailwind.twig
Normal file
141
templates/form_tailwind.twig
Normal file
@@ -0,0 +1,141 @@
|
||||
{% use 'form_div_layout.html.twig' %}
|
||||
|
||||
{# ---------- FORM START / END ---------- #}
|
||||
{% block form_start -%}
|
||||
{{ parent() }}
|
||||
{%- endblock %}
|
||||
{% block form_end -%}
|
||||
{{ parent() }}
|
||||
{%- endblock %}
|
||||
|
||||
{# ---------- ROW ---------- #}
|
||||
{% block form_row %}
|
||||
<div class="mb-5">
|
||||
{{ form_label(form) }}
|
||||
<div class="mt-1">
|
||||
{{ form_widget(form) }}
|
||||
</div>
|
||||
{% if not compound and not form.vars.valid %}
|
||||
{# Affiche l'erreur en bas du champ simple #}
|
||||
<p class="text-sm text-red-600 mt-1">{{ form_errors(form) }}</p>
|
||||
{% else %}
|
||||
{# Affiche l'erreur pour les champs composés (si form_errors n'est pas déjà dans le widget) #}
|
||||
{{ form_errors(form) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{# ---------- LABEL ---------- #}
|
||||
{% block form_label %}
|
||||
|
||||
{% if label is not same as(false) %}
|
||||
<label for="{{ id }}" class="block text-sm font-medium text-gray-700">
|
||||
{{ label|trans({}, translation_domain) }}
|
||||
{% if required %}
|
||||
<span class="text-red-500">*</span>
|
||||
{% endif %}
|
||||
</label>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{# ---------- ERRORS ---------- #}
|
||||
{% block form_errors %}
|
||||
{% if errors|length > 0 %}
|
||||
<ul class="mt-1 text-sm text-red-600 list-disc list-inside">
|
||||
{% for error in errors %}
|
||||
<li>{{ error.message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{# ---------- WIDGET DISPATCH ---------- #}
|
||||
{% block form_widget %}
|
||||
{% if compound %}
|
||||
{{ block('form_widget_compound') }}
|
||||
{% else %}
|
||||
{{ block('form_widget_simple') }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{# --- STYLE COMMUN POUR WIDGETS (Light Mode) --- #}
|
||||
|
||||
{# ---------- SIMPLE INPUTS (text, email, number...) ---------- #}
|
||||
{% block form_widget_simple %}
|
||||
{% set type = type|default('text') %}
|
||||
<input
|
||||
type="{{ type }}"
|
||||
{{ block('widget_attributes') }}
|
||||
value="{{ value }}"
|
||||
class="form-input mt-1 block w-full px-3 py-2 bg-white border border-gray-300 text-gray-900 placeholder-gray-400 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm transition duration-150"
|
||||
/>
|
||||
{% endblock %}
|
||||
|
||||
{# ---------- TEXTAREA ---------- #}
|
||||
{% block textarea_widget %}
|
||||
<textarea
|
||||
{{ block('widget_attributes') }}
|
||||
class="form-textarea form-input mt-1 block w-full px-3 py-2 bg-white border border-gray-300 text-gray-900 placeholder-gray-400 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm transition duration-150"
|
||||
>{{ value }}</textarea>
|
||||
{% endblock %}
|
||||
|
||||
{# ---------- SELECT ---------- #}
|
||||
{% block choice_widget_collapsed %}
|
||||
<select
|
||||
{{ block('widget_attributes') }}
|
||||
class="form-select form-input mt-1 block w-full px-3 py-2 bg-white border border-gray-300 text-gray-900 placeholder-gray-400 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm transition duration-150"
|
||||
>
|
||||
{% if placeholder is not none %}
|
||||
<option value="" {% if required and value is empty %}selected{% endif %}>
|
||||
{{ placeholder != '' ? (placeholder|trans({}, translation_domain)) : '' }}
|
||||
</option>
|
||||
{% endif %}
|
||||
{% for group_label, choice in choices %}
|
||||
{% if choice is iterable %}
|
||||
<optgroup label="{{ group_label|trans({}, translation_domain) }}">
|
||||
{% for nested_choice in choice %}
|
||||
<option value="{{ nested_choice.value }}" {% if nested_choice is selectedchoice(value) %}selected{% endif %}>
|
||||
{{ nested_choice.label|trans({}, translation_domain) }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% else %}
|
||||
<option value="{{ choice.value }}" {% if choice is selectedchoice(value) %}selected{% endif %}>
|
||||
{{ choice.label|trans({}, translation_domain) }}
|
||||
</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endblock %}
|
||||
|
||||
{# ---------- CHECKBOX ---------- #}
|
||||
{% block checkbox_widget %}
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox"
|
||||
{{ block('widget_attributes') }}
|
||||
{% if value not in ['', null] %} value="{{ value }}"{% endif %}
|
||||
{% if checked %}checked="checked"{% endif %}
|
||||
class="form-checkbox h-5 w-5 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{# ---------- RADIO ---------- #}
|
||||
{% block radio_widget %}
|
||||
<input type="radio"
|
||||
{{ block('widget_attributes') }}
|
||||
value="{{ value }}"
|
||||
{% if checked %}checked="checked"{% endif %}
|
||||
class="form-radio h-5 w-5 text-indigo-600 border-gray-300 focus:ring-indigo-500">
|
||||
{% endblock %}
|
||||
|
||||
{# ---------- FILE ---------- #}
|
||||
{% block file_widget %}
|
||||
<input type="file"
|
||||
{{ block('widget_attributes') }}
|
||||
class="block w-full text-sm text-gray-800 file:mr-4 file:py-2 file:px-4
|
||||
file:rounded-md file:border-0
|
||||
file:text-sm file:font-semibold
|
||||
file:bg-indigo-600 file:text-white
|
||||
hover:file:bg-indigo-700
|
||||
bg-white border border-gray-300 rounded-md shadow-sm">
|
||||
{% endblock %}
|
||||
67
templates/home.twig
Normal file
67
templates/home.twig
Normal file
@@ -0,0 +1,67 @@
|
||||
{% extends 'base.twig' %}
|
||||
|
||||
{% block body %}
|
||||
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full space-y-8 p-10 bg-white rounded-xl shadow-lg">
|
||||
|
||||
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
{{ 'security.login'|trans }}
|
||||
</h2>
|
||||
|
||||
{# Display error messages if login fails #}
|
||||
{% if error %}
|
||||
<div class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg" role="alert">
|
||||
{{ error.messageKey|trans(error.messageData, 'security') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for message in app.flashes('success') %}
|
||||
<div class="p-4 text-sm text-green-700 bg-green-100 rounded-lg" role="alert">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{# The actual login form #}
|
||||
<form class="mt-8 space-y-6" action="{{ path('app_home') }}" method="post">
|
||||
<input type="hidden" name="remember" value="true">
|
||||
|
||||
{# Username Field (Email) #}
|
||||
<div class="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label for="username" class="sr-only">{{ 'label.email'|trans }}</label>
|
||||
<input id="username" name="_username" type="email" autocomplete="email" required
|
||||
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
placeholder="{{ 'label.email'|trans }}" value="{{ last_username }}" autofocus>
|
||||
</div>
|
||||
|
||||
{# Password Field #}
|
||||
<div>
|
||||
<label for="password" class="sr-only">{{ 'label.password'|trans }}</label>
|
||||
<input id="password" name="_password" type="password" autocomplete="current-password" required
|
||||
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
placeholder="{{ 'label.password'|trans }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Remember Me & Forgot Password (Optional) #}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm">
|
||||
<a href="{{ path('app_forgot_password') }}" class="font-medium text-indigo-600 hover:text-indigo-500">
|
||||
{{ 'link.forgot_password'|trans }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# CSRF Token (Important for security) #}
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
|
||||
|
||||
{# Submit Button #}
|
||||
<div>
|
||||
<button type="submit"
|
||||
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
{{ 'button.sign_in'|trans }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
28
templates/mails/base.twig
Normal file
28
templates/mails/base.twig
Normal file
@@ -0,0 +1,28 @@
|
||||
{% extends 'mails/base.twig' %}
|
||||
|
||||
{% block content %}
|
||||
<mj-text>Bonjour, </mj-text>
|
||||
|
||||
{% if 'ROLE_CUSTOMER' in datas.account.roles %}
|
||||
<mj-text>Nous avons reçu une demande de réinitialisation de mot de passe pour votre espace client.</mj-text>
|
||||
{% else %}
|
||||
<mj-text>Nous avons reçu une demande de réinitialisation de mot de passe pour votre compte E-Cosplay.</mj-text>
|
||||
{% endif %}
|
||||
|
||||
<mj-text>Pour réinitialiser votre mot de passe, veuillez cliquer sur le bouton ci-dessous. Ce lien est valable pour une durée limitée.</mj-text>
|
||||
|
||||
<mj-button href="{{ datas.resetLink }}">
|
||||
Réinitialiser mon mot de passe
|
||||
</mj-button>
|
||||
|
||||
<mj-text padding-top="20px">
|
||||
Ce lien expirera le {{ datas.request.expiresAt|date('d/m/Y à H:i') }}.
|
||||
<br/>
|
||||
Veuillez l'utiliser avant cette date et heure.
|
||||
</mj-text>
|
||||
|
||||
<mj-text>Si vous n'avez pas demandé cette réinitialisation de mot de passe, veuillez ignorer cet e-mail. Votre mot de passe actuel restera inchangé.</mj-text>
|
||||
|
||||
<mj-text padding-top="20px">Cordialement,</mj-text>
|
||||
<mj-text>L'équipe E-Cosplay</mj-text>
|
||||
{% endblock %}
|
||||
30
templates/mails/new_admin.twig
Normal file
30
templates/mails/new_admin.twig
Normal file
@@ -0,0 +1,30 @@
|
||||
{% extends 'mails/base.twig' %}
|
||||
|
||||
{% block content %}
|
||||
<mj-text font-size="16px" line-height="24px">
|
||||
Bonjour,
|
||||
<br/><br/>
|
||||
Nous avons le plaisir de vous informer que votre compte administrateur a été créé.
|
||||
<br/><br/>
|
||||
Voici vos identifiants de connexion temporaires :
|
||||
<br/>
|
||||
<strong>Nom d'utilisateur :</strong> `{{ datas.username }}`
|
||||
<br/>
|
||||
<strong>Mot de passe :</strong> `{{ datas.password }}`
|
||||
<br/><br/>
|
||||
Pour des raisons de sécurité, nous vous demandons de bien vouloir modifier votre mot de passe lors de votre première connexion.
|
||||
<br/><br/>
|
||||
Vous pouvez vous connecter à votre compte en cliquant sur le lien ci-dessous :
|
||||
</mj-text>
|
||||
<mj-button href="{{ system.path }}{{ datas.url }}" background-color="#4A90E2" color="#ffffff" font-size="16px" border-radius="5px">
|
||||
Se connecter
|
||||
</mj-button>
|
||||
<mj-text font-size="16px" line-height="24px">
|
||||
<br/><br/>
|
||||
Si vous avez des questions ou rencontrez des difficultés, n'hésitez pas à nous contacter.
|
||||
<br/><br/>
|
||||
Cordialement,
|
||||
<br/>
|
||||
L'équipe CRM
|
||||
</mj-text>
|
||||
{% endblock %}
|
||||
29
templates/mails/reset.twig
Normal file
29
templates/mails/reset.twig
Normal file
@@ -0,0 +1,29 @@
|
||||
reset.twig Thu Dec 11 17:18:45 2025
|
||||
{% extends 'mails/base.twig' %}
|
||||
|
||||
{% block content %}
|
||||
<mj-text>Bonjour, </mj-text>
|
||||
|
||||
{% if 'ROLE_CUSTOMER' in datas.account.roles %}
|
||||
<mj-text>Nous avons reçu une demande de réinitialisation de mot de passe pour votre espace client.</mj-text>
|
||||
{% else %}
|
||||
<mj-text>Nous avons reçu une demande de réinitialisation de mot de passe pour votre compte E-Cosplay.</mj-text>
|
||||
{% endif %}
|
||||
|
||||
<mj-text>Pour réinitialiser votre mot de passe, veuillez cliquer sur le bouton ci-dessous. Ce lien est valable pour une durée limitée.</mj-text>
|
||||
|
||||
<mj-button href="{{ datas.resetLink }}">
|
||||
Réinitialiser mon mot de passe
|
||||
</mj-button>
|
||||
|
||||
<mj-text padding-top="20px">
|
||||
Ce lien expirera le {{ datas.request.expiresAt|date('d/m/Y à H:i') }}.
|
||||
<br/>
|
||||
Veuillez l'utiliser avant cette date et heure.
|
||||
</mj-text>
|
||||
|
||||
<mj-text>Si vous n'avez pas demandé cette réinitialisation de mot de passe, veuillez ignorer cet e-mail. Votre mot de passe actuel restera inchangé.</mj-text>
|
||||
|
||||
<mj-text padding-top="20px">Cordialement,</mj-text>
|
||||
<mj-text>L'équipe CRM</mj-text>
|
||||
{% endblock %}
|
||||
74
templates/security/forgot-password-confirm.twig
Normal file
74
templates/security/forgot-password-confirm.twig
Normal file
@@ -0,0 +1,74 @@
|
||||
{% extends 'base.twig' %}
|
||||
|
||||
{% block title %}{{ 'events.reset_password'|trans }}{% endblock %}
|
||||
{% block body %}
|
||||
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full space-y-8 p-10 bg-white rounded-xl shadow-lg">
|
||||
|
||||
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
{{ 'events.reset_password'|trans }}
|
||||
</h2>
|
||||
|
||||
<p class="mt-2 text-center text-sm text-gray-600">
|
||||
{{ 'text.enter_new_password'|trans }}
|
||||
</p>
|
||||
|
||||
{# Affichage des messages flash (ex: token expiré ou invalide) #}
|
||||
{% for flash_error in app.flashes('reset_password_error') %}
|
||||
<div class="p-4 text-sm text-red-700 bg-red-100 rounded-lg" role="alert">
|
||||
{{ flash_error }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{# Le formulaire Symfony #}
|
||||
{{ form_start(form, {'attr': {'class': 'mt-8 space-y-6'}}) }}
|
||||
|
||||
<div class="rounded-md shadow-sm -space-y-px">
|
||||
{# Champ Nouveau Mot de Passe (first) #}
|
||||
{# On suppose que form.plainPassword est un RepeatedType avec un champ 'first' et 'second' #}
|
||||
|
||||
<div>
|
||||
{{ form_label(form.password.first, 'label.new_password'|trans, {'label_attr': {'class': 'sr-only'}}) }}
|
||||
{{ form_widget(form.password.first, {
|
||||
'attr': {
|
||||
'class': 'appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm',
|
||||
'placeholder': 'label.new_password'|trans,
|
||||
'autocomplete': 'new-password',
|
||||
'required': 'required'
|
||||
}
|
||||
}) }}
|
||||
{{ form_errors(form.password.first) }}
|
||||
</div>
|
||||
|
||||
{# Champ Confirmation Mot de Passe (second) #}
|
||||
<div>
|
||||
{{ form_label(form.password.second, 'label.confirm_password'|trans, {'label_attr': {'class': 'sr-only'}}) }}
|
||||
{{ form_widget(form.password.second, {
|
||||
'attr': {
|
||||
'class': 'appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm',
|
||||
'placeholder': 'label.confirm_password'|trans,
|
||||
'autocomplete': 'new-password',
|
||||
'required': 'required'
|
||||
}
|
||||
}) }}
|
||||
{{ form_errors(form.password.second) }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{# Affichage des erreurs globales du formulaire #}
|
||||
{{ form_errors(form) }}
|
||||
|
||||
{# Bouton Soumettre #}
|
||||
<div>
|
||||
<button type="submit"
|
||||
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
{{ 'button.reset_password'|trans }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{ form_end(form) }}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %
|
||||
70
templates/security/forgot_password.twig
Normal file
70
templates/security/forgot_password.twig
Normal file
@@ -0,0 +1,70 @@
|
||||
{% extends 'base.twig' %}
|
||||
|
||||
{% block title %}{{ 'events.forgot_password'|trans }}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full space-y-8 p-10 bg-white rounded-xl shadow-lg">
|
||||
|
||||
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
{{ 'events.forgot_password'|trans }}
|
||||
</h2>
|
||||
|
||||
<p class="mt-2 text-center text-sm text-gray-600">
|
||||
{{ 'text.enter_email_for_reset'|trans }}
|
||||
</p>
|
||||
|
||||
{# Affichage des messages flash (succès ou erreur) #}
|
||||
{% for flash_error in app.flashes('reset_password_error') %}
|
||||
<div class="p-4 text-sm text-red-700 bg-red-100 rounded-lg" role="alert">
|
||||
{{ flash_error }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% for message in app.flashes('success') %}
|
||||
<div class="p-4 text-sm text-green-700 bg-green-100 rounded-lg" role="alert">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
|
||||
{# Le formulaire Symfony #}
|
||||
{{ form_start(form, {'attr': {'class': 'mt-8 space-y-6'}}) }}
|
||||
|
||||
<div class="rounded-md shadow-sm -space-y-px">
|
||||
{# Champ Email #}
|
||||
<div>
|
||||
{{ form_label(form.email, 'label.email'|trans, {'label_attr': {'class': 'sr-only'}}) }}
|
||||
{{ form_widget(form.email, {
|
||||
'attr': {
|
||||
'class': 'appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm',
|
||||
'placeholder': 'label.email'|trans,
|
||||
'autocomplete': 'email',
|
||||
'required': 'required'
|
||||
}
|
||||
}) }}
|
||||
|
||||
{# Affichage des erreurs de champ spécifiques #}
|
||||
{{ form_errors(form.email) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Bouton Soumettre #}
|
||||
<div>
|
||||
<button type="submit"
|
||||
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
{{ 'button.send_reset_link'|trans }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{ form_end(form) }}
|
||||
|
||||
<div class="text-center text-sm">
|
||||
<a href="{{ path('app_home') }}" class="font-medium text-indigo-600 hover:text-indigo-500">
|
||||
{{ 'link.back_to_login'|trans }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
37
templates/security/forgot_password_success.twig
Normal file
37
templates/security/forgot_password_success.twig
Normal file
@@ -0,0 +1,37 @@
|
||||
{% extends 'base.twig' %}
|
||||
|
||||
{% block title %}{{ 'events.reset_email_sent'|trans }}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full space-y-8 p-10 bg-white rounded-xl shadow-lg text-center">
|
||||
|
||||
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
{{ 'events.reset_email_sent'|trans }}
|
||||
</h2>
|
||||
|
||||
{# Message de Sécurité IMPORTANT #}
|
||||
<div class="p-6 bg-blue-50 border border-blue-200 text-blue-700 rounded-lg">
|
||||
<p class="font-medium mb-2">
|
||||
{{ 'text.check_inbox_title'|trans }}
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
{{ 'text.check_inbox_description'|trans }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-sm text-gray-500">
|
||||
{{ 'text.spam_folder_tip'|trans }}
|
||||
</p>
|
||||
|
||||
{# Lien de Retour à la Connexion #}
|
||||
<div class="mt-8">
|
||||
<a href="{{ path('app_login') }}"
|
||||
class="font-medium text-indigo-600 hover:text-indigo-500">
|
||||
{{ 'link.back_to_login'|trans }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
30
translations/messages.fr.yaml
Normal file
30
translations/messages.fr.yaml
Normal file
@@ -0,0 +1,30 @@
|
||||
login_link: Connexion
|
||||
register_link: Inscription
|
||||
breadcrumb.login: Connexion
|
||||
label.email: Adresse e-mail
|
||||
label.password: Mot de passe
|
||||
label.remember_me: Se souvenir de moi
|
||||
button.sign_in: Se connecter
|
||||
link.forgot_password: Mot de passe oublié ?
|
||||
error.login_failed: Échec de la connexion.
|
||||
security.login: Connexion à votre compte
|
||||
events.forgot_password: Mot de passe oublié
|
||||
breadcrumb.forgot_password: Mot de passe oublié
|
||||
text.enter_email_for_reset: Veuillez entrer votre adresse e-mail pour recevoir un lien de réinitialisation.
|
||||
button.send_reset_link: Envoyer le lien de réinitialisation
|
||||
link.back_to_login: Retour à la connexion
|
||||
events.reset_email_sent: E-mail de réinitialisation envoyé
|
||||
text.check_inbox_title: Vérifiez votre boîte de réception 📥
|
||||
text.check_inbox_description: Un e-mail a été envoyé avec un lien pour réinitialiser votre mot de passe. Il se peut qu'il arrive dans quelques minutes.
|
||||
text.spam_folder_tip: Si vous ne le voyez pas, vérifiez votre dossier de courriers indésirables (spam).
|
||||
events.reset_password: Réinitialiser le mot de passe
|
||||
breadcrumb.reset_password: Réinitialisation du mot de passe
|
||||
label.new_password: Nouveau mot de passe
|
||||
label.confirm_password: Confirmer le nouveau mot de passe
|
||||
text.enter_new_password: Veuillez saisir et confirmer votre nouveau mot de passe.
|
||||
button.reset_password: Réinitialiser le mot de passe
|
||||
open_user_menu_sr: Ouvrir le menu utilisateur
|
||||
logged_in_as: Connecté en tant que
|
||||
logout_link: Déconnexion
|
||||
page.login: Connexion
|
||||
logged_admin: Administration
|
||||
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Enable latest features
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
38
umami-docker.yaml
Normal file
38
umami-docker.yaml
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
services:
|
||||
datas_umami:
|
||||
image: ghcr.io/umami-software/umami:latest
|
||||
ports:
|
||||
- "27503:3000"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://umami:umami@datas_db:5432/umami
|
||||
DATABASE_TYPE: postgresql
|
||||
APP_SECRET: replace-me-with-a-random-string
|
||||
TRACKER_SCRIPT_NAME: 'vs.js'
|
||||
COLLECT_API_ENDPOINT: '/vs'
|
||||
depends_on:
|
||||
datas_db:
|
||||
condition: service_healthy
|
||||
init: true
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl http://localhost:3000/api/heartbeat"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
datas_db:
|
||||
image: postgres:15-alpine
|
||||
environment:
|
||||
POSTGRES_DB: umami
|
||||
POSTGRES_USER: umami
|
||||
POSTGRES_PASSWORD: umami
|
||||
volumes:
|
||||
- umami-db-data:/var/lib/postgresql/data
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
volumes:
|
||||
umami-db-data:
|
||||
14
update.sh
Normal file
14
update.sh
Normal file
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
RED='\033[0;31m'
|
||||
ORANGE='\033[0;33m'
|
||||
GREEN='\033[0;32m'
|
||||
CYAN='\033[0;36m'
|
||||
RESET='\033[0m' # Reset color to default
|
||||
|
||||
echo "${CYAN}#######################${RESET}"
|
||||
echo "${CYAN}# E-PAGE UPDATE START #${RESET}"
|
||||
echo "${CYAN}#######################${RESET}"
|
||||
ansible-playbook -i ansible/hosts.ini ansible/playbook.yml
|
||||
echo "${CYAN}##############${RESET}"
|
||||
echo "${CYAN}# END UPDATE #${RESET}"
|
||||
echo "${CYAN}##############${RESET}"
|
||||
96
vite.config.js
Normal file
96
vite.config.js
Normal file
@@ -0,0 +1,96 @@
|
||||
// vite.config.js
|
||||
|
||||
import { defineConfig } from 'vite';
|
||||
import { resolve } from 'path';
|
||||
import JavaScriptObfuscator from 'rollup-plugin-javascript-obfuscator';
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import viteCompression from 'vite-plugin-compression';
|
||||
|
||||
export default defineConfig({
|
||||
|
||||
root: './assets',
|
||||
base: '/assets/',
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
allowedHosts: ["esyweb.local"],
|
||||
|
||||
port: 5173,
|
||||
|
||||
open: false,
|
||||
|
||||
cors: {
|
||||
origin: ['https://esyweb.local']
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'assets'),
|
||||
},
|
||||
// Extensions de fichiers à résoudre automatiquement
|
||||
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue', '.scss', '.css'],
|
||||
},
|
||||
build: {
|
||||
outDir: resolve(__dirname, 'public/build'),
|
||||
assetsDir: '',
|
||||
emptyOutDir: true,
|
||||
manifest: true,
|
||||
sourcemap: false,
|
||||
// Minification par défaut : esbuild est déjà très rapide et efficace
|
||||
minify: 'esbuild',
|
||||
cssMinify: 'esbuild',
|
||||
// NOUVEAU : Stratégie de découpage du code pour améliorer le cache client
|
||||
rollupOptions: {
|
||||
input: {
|
||||
app: resolve(__dirname, 'assets/app.js'),
|
||||
admin: resolve(__dirname, 'assets/admin.js'),
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// --- Plugins Vite ---
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
// NOUVEAU: Ajoutez Preact/React/Vue si vous utilisez ces frameworks
|
||||
// preact(),
|
||||
|
||||
// 1. Compression des Assets (Gzip et Brotli)
|
||||
// Crée des fichiers .gz et .br à côté des assets originaux.
|
||||
// Votre serveur web (Nginx/Apache) doit être configuré pour servir ces versions compressées.
|
||||
viteCompression({
|
||||
verbose: true,
|
||||
disable: false,
|
||||
threshold: 10240, // Compresse seulement les fichiers > 10KB
|
||||
algorithm: 'gzip',
|
||||
ext: '.gz',
|
||||
}),
|
||||
viteCompression({
|
||||
verbose: true,
|
||||
disable: false,
|
||||
threshold: 10240,
|
||||
algorithm: 'brotliCompress', // Brotli est plus efficace que Gzip
|
||||
ext: '.br',
|
||||
// Ne génère le brotli que si l'algorithme est disponible (par défaut)
|
||||
deleteOriginFile: false // Important : gardez les fichiers originaux
|
||||
}),
|
||||
|
||||
JavaScriptObfuscator({
|
||||
// Options recommandées pour un bon équilibre entre sécurité et performance
|
||||
compact: true,
|
||||
sourceMap: false,
|
||||
controlFlowFlattening: true, // Très efficace mais coûteux en performance à l'exécution
|
||||
deadCodeInjection: false,
|
||||
debugProtection: false,
|
||||
identifierNamesGenerator: 'hexadecimal', // Rend le code illisible
|
||||
log: false,
|
||||
numbersToExpressions: true,
|
||||
simplify: true,
|
||||
splitStrings: true,
|
||||
stringArray: true,
|
||||
stringArrayThreshold: 0.75,
|
||||
transformObjectKeys: true,
|
||||
unicodeEscapeSequence: false,
|
||||
}),
|
||||
|
||||
],
|
||||
define: {},
|
||||
});
|
||||
Reference in New Issue
Block a user