refactor: stocker les secrets webhook Stripe en BDD au lieu de .env.local
src/Entity/StripeWebhookSecret.php (nouveau): - Constantes TYPE_MAIN_LIGHT, TYPE_MAIN_INSTANT, TYPE_CONNECT_LIGHT, TYPE_CONNECT_INSTANT pour les 4 types de webhook - type: string(30) unique, identifie le webhook (main_light, etc.) - secret: string(255), le signing secret retourne par Stripe (whsec_xxx) - endpointId: string nullable, l'ID de l'endpoint Stripe (we_xxx) - createdAt: DateTimeImmutable src/Repository/StripeWebhookSecretRepository.php (nouveau): - findByType(): trouve un secret par type - getSecret(): retourne directement la valeur du secret ou null src/Controller/WebhookStripeController.php (reecrit): - Les 4 routes lisent le secret depuis la BDD via StripeWebhookSecretRepository::getSecret() au lieu de variables d'env - Retourne HTTP 503 si le secret n'est pas encore configure - Plus besoin des variables STRIPE_WH_*_SECRET dans .env src/Controller/Admin/SyncController.php: - syncStripeWebhooks(): sauvegarde les secrets en BDD (cree ou met a jour StripeWebhookSecret par type) - Suppression de saveSecretsToEnvLocal() (plus de modification .env.local) - URL de base lue depuis WEBHOOK_BASE_URL (env) .env: - Suppression des 4 variables STRIPE_WH_*_SECRET (stockees en BDD) - Ajout WEBHOOK_BASE_URL (vide par defaut) docker/ngrok/sync.sh: - Ecrit aussi WEBHOOK_BASE_URL en plus de OUTSIDE_URL ansible/env.local.j2: - WEBHOOK_BASE_URL=https://stripe.siteconseil.fr pour la prod migrations/Version20260402205935.php: - Table stripe_webhook_secret avec type unique, secret, endpoint_id Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
4
.env
4
.env
@@ -102,6 +102,10 @@ MAILCOW_URL=https://mail.esy-web.dev
|
||||
MAILCOW_API_KEY=
|
||||
###< mailcow ###
|
||||
|
||||
###> webhooks ###
|
||||
WEBHOOK_BASE_URL=
|
||||
###< webhooks ###
|
||||
|
||||
###> docuseal ###
|
||||
DOCUSEAL_URL=https://signature.esy-web.dev
|
||||
DOCUSEAL_API=
|
||||
|
||||
@@ -33,6 +33,7 @@ AWS_REGION=eu-west-3
|
||||
CLOUDFLARE_KEY={{ cloudflare_key }}
|
||||
MAILCOW_URL=https://mail.esy-web.dev
|
||||
MAILCOW_API_KEY={{ mailcow_api_key }}
|
||||
WEBHOOK_BASE_URL=https://stripe.siteconseil.fr
|
||||
DOCUSEAL_URL=https://signature.esy-web.dev
|
||||
DOCUSEAL_API={{ docuseal_api }}
|
||||
DOCUSEAL_WEBHOOKS_SECRET_HEADER=X-Sign
|
||||
|
||||
@@ -23,7 +23,9 @@ fi
|
||||
|
||||
touch /app/.env.local
|
||||
sed -i '/^OUTSIDE_URL=/d' /app/.env.local
|
||||
sed -i '/^WEBHOOK_BASE_URL=/d' /app/.env.local
|
||||
echo "OUTSIDE_URL=$NGROK_URL" >> /app/.env.local
|
||||
echo "WEBHOOK_BASE_URL=$NGROK_URL" >> /app/.env.local
|
||||
|
||||
echo "Ngrok URL: $NGROK_URL"
|
||||
echo "Written to .env.local"
|
||||
echo "Written OUTSIDE_URL and WEBHOOK_BASE_URL to .env.local"
|
||||
|
||||
32
migrations/Version20260402205935.php
Normal file
32
migrations/Version20260402205935.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260402205935 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 stripe_webhook_secret (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, type VARCHAR(30) NOT NULL, secret VARCHAR(255) NOT NULL, endpoint_id VARCHAR(255) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_D9BDCBA48CDE5729 ON stripe_webhook_secret (type)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('DROP TABLE stripe_webhook_secret');
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,17 @@
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Entity\StripeWebhookSecret;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Repository\PriceAutomaticRepository;
|
||||
use App\Repository\RevendeurRepository;
|
||||
use App\Repository\StripeWebhookSecretRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use App\Service\MeilisearchService;
|
||||
use App\Service\StripePriceService;
|
||||
use App\Service\StripeWebhookService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -80,17 +84,26 @@ class SyncController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/stripe/webhooks', name: 'stripe_webhooks', methods: ['POST'])]
|
||||
public function syncStripeWebhooks(Request $request, StripeWebhookService $webhookService): Response
|
||||
{
|
||||
$baseUrl = trim($request->request->getString('base_url'));
|
||||
|
||||
if ('' === $baseUrl) {
|
||||
$this->addFlash('error', 'URL de base requise.');
|
||||
public function syncStripeWebhooks(
|
||||
StripeWebhookService $webhookService,
|
||||
StripeWebhookSecretRepository $secretRepository,
|
||||
EntityManagerInterface $em,
|
||||
#[Autowire(env: 'WEBHOOK_BASE_URL')] string $webhookBaseUrl,
|
||||
): Response {
|
||||
if ('' === $webhookBaseUrl) {
|
||||
$this->addFlash('error', 'WEBHOOK_BASE_URL non configuree dans .env.local');
|
||||
|
||||
return $this->redirectToRoute('app_admin_sync_index');
|
||||
}
|
||||
|
||||
$result = $webhookService->createAllWebhooks(rtrim($baseUrl, '/'));
|
||||
$result = $webhookService->createAllWebhooks(rtrim($webhookBaseUrl, '/'));
|
||||
|
||||
$typeMap = [
|
||||
'Main Light' => StripeWebhookSecret::TYPE_MAIN_LIGHT,
|
||||
'Main Instant' => StripeWebhookSecret::TYPE_MAIN_INSTANT,
|
||||
'Connect Light' => StripeWebhookSecret::TYPE_CONNECT_LIGHT,
|
||||
'Connect Instant' => StripeWebhookSecret::TYPE_CONNECT_INSTANT,
|
||||
];
|
||||
|
||||
foreach ($result['created'] as $wh) {
|
||||
$status = $wh['status'] ?? 'created';
|
||||
@@ -98,9 +111,24 @@ class SyncController extends AbstractController
|
||||
$this->addFlash('success', $wh['type'].' : deja configure ('.$wh['id'].')');
|
||||
} else {
|
||||
$this->addFlash('success', $wh['type'].' : cree ('.$wh['id'].')');
|
||||
|
||||
if (isset($wh['secret'], $typeMap[$wh['type']])) {
|
||||
$dbType = $typeMap[$wh['type']];
|
||||
$existing = $secretRepository->findByType($dbType);
|
||||
|
||||
if (null !== $existing) {
|
||||
$existing->setSecret($wh['secret']);
|
||||
$existing->setEndpointId($wh['id']);
|
||||
} else {
|
||||
$entity = new StripeWebhookSecret($dbType, $wh['secret'], $wh['id']);
|
||||
$em->persist($entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$em->flush();
|
||||
|
||||
foreach ($result['errors'] as $error) {
|
||||
$this->addFlash('error', 'Stripe Webhook : '.$error);
|
||||
}
|
||||
@@ -153,4 +181,5 @@ class SyncController extends AbstractController
|
||||
|
||||
return $this->redirectToRoute('app_admin_sync_index');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\StripeWebhookSecret;
|
||||
use App\Repository\StripeWebhookSecretRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -14,43 +15,44 @@ class WebhookStripeController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private LoggerInterface $logger,
|
||||
private StripeWebhookSecretRepository $secretRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/webhooks/stripe/main/light', name: 'app_webhook_stripe_main_light', methods: ['POST'])]
|
||||
public function mainLight(
|
||||
Request $request,
|
||||
#[Autowire(env: 'STRIPE_WEBHOOK_SECRET')] string $secret,
|
||||
): Response {
|
||||
return $this->handleWebhook($request, $secret, 'main_light');
|
||||
public function mainLight(Request $request): Response
|
||||
{
|
||||
return $this->handleWebhook($request, StripeWebhookSecret::TYPE_MAIN_LIGHT);
|
||||
}
|
||||
|
||||
#[Route('/webhooks/stripe/main/instant', name: 'app_webhook_stripe_main_instant', methods: ['POST'])]
|
||||
public function mainInstant(
|
||||
Request $request,
|
||||
#[Autowire(env: 'STRIPE_WEBHOOK_SECRET')] string $secret,
|
||||
): Response {
|
||||
return $this->handleWebhook($request, $secret, 'main_instant');
|
||||
public function mainInstant(Request $request): Response
|
||||
{
|
||||
return $this->handleWebhook($request, StripeWebhookSecret::TYPE_MAIN_INSTANT);
|
||||
}
|
||||
|
||||
#[Route('/webhooks/stripe/connect/light', name: 'app_webhook_stripe_connect_light', methods: ['POST'])]
|
||||
public function connectLight(
|
||||
Request $request,
|
||||
#[Autowire(env: 'STRIPE_WEBHOOK_SECRET_CONNECT')] string $secret,
|
||||
): Response {
|
||||
return $this->handleWebhook($request, $secret, 'connect_light');
|
||||
public function connectLight(Request $request): Response
|
||||
{
|
||||
return $this->handleWebhook($request, StripeWebhookSecret::TYPE_CONNECT_LIGHT);
|
||||
}
|
||||
|
||||
#[Route('/webhooks/stripe/connect/instant', name: 'app_webhook_stripe_connect_instant', methods: ['POST'])]
|
||||
public function connectInstant(
|
||||
Request $request,
|
||||
#[Autowire(env: 'STRIPE_WEBHOOK_SECRET_CONNECT')] string $secret,
|
||||
): Response {
|
||||
return $this->handleWebhook($request, $secret, 'connect_instant');
|
||||
public function connectInstant(Request $request): Response
|
||||
{
|
||||
return $this->handleWebhook($request, StripeWebhookSecret::TYPE_CONNECT_INSTANT);
|
||||
}
|
||||
|
||||
private function handleWebhook(Request $request, string $secret, string $channel): Response
|
||||
private function handleWebhook(Request $request, string $channel): Response
|
||||
{
|
||||
$secret = $this->secretRepository->getSecret($channel);
|
||||
|
||||
if (null === $secret) {
|
||||
$this->logger->warning('Stripe webhook ['.$channel.']: secret non configure');
|
||||
|
||||
return new JsonResponse(['error' => 'Webhook not configured'], Response::HTTP_SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
$payload = $request->getContent();
|
||||
$sigHeader = $request->headers->get('Stripe-Signature', '');
|
||||
|
||||
@@ -69,7 +71,6 @@ class WebhookStripeController extends AbstractController
|
||||
]);
|
||||
|
||||
// TODO: dispatcher les evenements vers les handlers specifiques
|
||||
// Ex: match ($event->type) { 'invoice.paid' => ..., }
|
||||
|
||||
return new JsonResponse(['status' => 'ok', 'channel' => $channel, 'event' => $event->type]);
|
||||
}
|
||||
|
||||
75
src/Entity/StripeWebhookSecret.php
Normal file
75
src/Entity/StripeWebhookSecret.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\StripeWebhookSecretRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: StripeWebhookSecretRepository::class)]
|
||||
class StripeWebhookSecret
|
||||
{
|
||||
public const TYPE_MAIN_LIGHT = 'main_light';
|
||||
public const TYPE_MAIN_INSTANT = 'main_instant';
|
||||
public const TYPE_CONNECT_LIGHT = 'connect_light';
|
||||
public const TYPE_CONNECT_INSTANT = 'connect_instant';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 30, unique: true)]
|
||||
private string $type;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private string $secret;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $endpointId = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
public function __construct(string $type, string $secret, ?string $endpointId = null)
|
||||
{
|
||||
$this->type = $type;
|
||||
$this->secret = $secret;
|
||||
$this->endpointId = $endpointId;
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function getSecret(): string
|
||||
{
|
||||
return $this->secret;
|
||||
}
|
||||
|
||||
public function setSecret(string $secret): void
|
||||
{
|
||||
$this->secret = $secret;
|
||||
}
|
||||
|
||||
public function getEndpointId(): ?string
|
||||
{
|
||||
return $this->endpointId;
|
||||
}
|
||||
|
||||
public function setEndpointId(?string $endpointId): void
|
||||
{
|
||||
$this->endpointId = $endpointId;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
}
|
||||
30
src/Repository/StripeWebhookSecretRepository.php
Normal file
30
src/Repository/StripeWebhookSecretRepository.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\StripeWebhookSecret;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<StripeWebhookSecret>
|
||||
*/
|
||||
class StripeWebhookSecretRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, StripeWebhookSecret::class);
|
||||
}
|
||||
|
||||
public function findByType(string $type): ?StripeWebhookSecret
|
||||
{
|
||||
return $this->findOneBy(['type' => $type]);
|
||||
}
|
||||
|
||||
public function getSecret(string $type): ?string
|
||||
{
|
||||
$entity = $this->findByType($type);
|
||||
|
||||
return $entity?->getSecret();
|
||||
}
|
||||
}
|
||||
@@ -131,11 +131,7 @@
|
||||
<p class="text-xs text-gray-500">Cree les 4 endpoints : main light/instant + connect light/instant</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ path('app_admin_sync_stripe_webhooks') }}" class="flex items-end gap-3" data-confirm="Creer les webhooks Stripe ? Les endpoints existants ne seront pas dupliques.">
|
||||
<div>
|
||||
<label class="block text-xs font-bold uppercase tracking-wider mb-1 text-gray-600">URL de base</label>
|
||||
<input type="text" name="base_url" value="{{ app.request.schemeAndHttpHost }}" required class="input-glass px-3 py-2 text-sm font-medium font-mono w-64" placeholder="https://crm.siteconseil.fr">
|
||||
</div>
|
||||
<form method="post" action="{{ path('app_admin_sync_stripe_webhooks') }}" data-confirm="Creer les webhooks Stripe ? Les secrets seront sauvegardes dans .env.local">
|
||||
<button type="submit" class="px-4 py-2 btn-glass text-blue-600 font-bold uppercase text-[10px] tracking-wider">
|
||||
Creer les webhooks
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user