Merge deploy playbooks, add env.local template, update MailerService

- Fuse deploy-caddy.yml and cloudflare.yml into deploy.yml
- Add env.local.j2 template for production secrets
- Vault: add all production secrets
- Workflow: single deploy.yml playbook
- MailerService: rewrite with S/MIME signing, email tracking, unsubscribe
- ngrok-sync: run as root for .env.local write access
- Fix domain references to ticket.e-cosplay.fr

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-18 21:24:31 +01:00
parent 8e34a5b3a6
commit 46a84a9f9a
7 changed files with 208 additions and 143 deletions

View File

@@ -20,8 +20,5 @@ jobs:
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan 34.90.187.4 >> ~/.ssh/known_hosts
- name: Configure Cloudflare
run: ansible-playbook ansible/cloudflare.yml --vault-password-file <(echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}")
- name: Deploy
run: ansible-playbook -i ansible/hosts.ini ansible/deploy-caddy.yml --vault-password-file <(echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}")
run: ansible-playbook -i ansible/hosts.ini ansible/deploy.yml --vault-password-file <(echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}")

View File

@@ -1,79 +0,0 @@
---
- name: Deploy Caddy config for e-ticket
hosts: production
become: true
vars_files:
- vault.yml
pre_tasks:
- name: Enable maintenance mode
command: make maintenance_on
args:
chdir: /var/www/e-ticket
tasks:
- name: Ensure Caddy sites directory exists
file:
path: /etc/caddy/sites
state: directory
owner: root
group: root
mode: "0755"
- name: Stop production containers
command: make stop_prod
args:
chdir: /var/www/e-ticket
- name: Install dependencies and build assets
command: make install_prod
args:
chdir: /var/www/e-ticket
- name: Start production containers
command: make start_prod
args:
chdir: /var/www/e-ticket
- name: Run migrations
command: make migrate_prod
args:
chdir: /var/www/e-ticket
- name: Clear cache
command: make clear_prod
args:
chdir: /var/www/e-ticket
- name: Deploy Caddy config
template:
src: caddy.j2
dest: /etc/caddy/sites/e-ticket.conf
owner: root
group: root
mode: "0644"
notify: Reload Caddy
- name: Deploy Messenger supervisor config
template:
src: messenger.j2
dest: /etc/supervisor/conf.d/e-ticket.conf
owner: root
group: root
mode: "0644"
notify: Reload Supervisor
post_tasks:
- name: Disable maintenance mode
command: make maintenance_off
args:
chdir: /var/www/e-ticket
handlers:
- name: Reload Caddy
systemd:
name: caddy
state: reloaded
- name: Reload Supervisor
command: supervisorctl reread && supervisorctl update

View File

@@ -1,5 +1,6 @@
---
- name: Configure Cloudflare for ticket.e-cosplay.fr
# --- Cloudflare configuration ---
- name: Configure Cloudflare
hosts: localhost
connection: local
vars_files:
@@ -156,7 +157,7 @@
ignore_errors: true
# --- Allow SEO bots ---
- name: Allow Googlebot
- name: Allow SEO and social media bots
uri:
url: "https://api.cloudflare.com/client/v4/zones/{{ zone_id }}/firewall/rules"
method: POST
@@ -171,3 +172,91 @@
description: "Allow SEO and social media bots"
status_code: [200, 409]
ignore_errors: true
# --- Server deployment ---
- name: Deploy e-ticket to production
hosts: production
become: true
vars_files:
- vault.yml
pre_tasks:
- name: Enable maintenance mode
command: make maintenance_on
args:
chdir: /var/www/e-ticket
tasks:
- name: Deploy .env.local
template:
src: env.local.j2
dest: /var/www/e-ticket/.env.local
owner: bot
group: bot
mode: "0600"
- name: Stop production containers
command: make stop_prod
args:
chdir: /var/www/e-ticket
- name: Install dependencies and build assets
command: make install_prod
args:
chdir: /var/www/e-ticket
- name: Start production containers
command: make start_prod
args:
chdir: /var/www/e-ticket
- name: Run migrations
command: make migrate_prod
args:
chdir: /var/www/e-ticket
- name: Clear cache
command: make clear_prod
args:
chdir: /var/www/e-ticket
- name: Ensure Caddy sites directory exists
file:
path: /etc/caddy/sites
state: directory
owner: root
group: root
mode: "0755"
- name: Deploy Caddy config
template:
src: caddy.j2
dest: /etc/caddy/sites/e-ticket.conf
owner: root
group: root
mode: "0644"
notify: Reload Caddy
- name: Deploy Messenger supervisor config
template:
src: messenger.j2
dest: /etc/supervisor/conf.d/e-ticket.conf
owner: root
group: root
mode: "0644"
notify: Reload Supervisor
post_tasks:
- name: Disable maintenance mode
command: make maintenance_off
args:
chdir: /var/www/e-ticket
handlers:
- name: Reload Caddy
systemd:
name: caddy
state: reloaded
- name: Reload Supervisor
command: supervisorctl reread && supervisorctl update

19
ansible/env.local.j2 Normal file
View File

@@ -0,0 +1,19 @@
APP_ENV=prod
APP_SECRET={{ app_secret }}
DATABASE_URL="postgresql://e-ticket:e-ticket@pgbouncer:6432/e-ticket?serverVersion=16&charset=utf8"
MESSENGER_TRANSPORT_DSN=redis://:e-ticket@redis:6379/messages
MAILER_DSN={{ mailer_dsn }}
DEFAULT_URI=https://ticket.e-cosplay.fr
VITE_LOAD=1
REAL_MAIL=1
OUTSIDE_URL=https://ticket.e-cosplay.fr
S3_ENDPOINT=https://s3.esy-web.dev
S3_ACCESS_KEY={{ s3_access_key }}
S3_SECRET_KEY={{ s3_secret_key }}
S3_BUCKET=e-ticket
S3_REGION=us-west-4
STRIPE_PK={{ stripe_pk }}
STRIPE_SK={{ stripe_sk }}
STRIPE_WEBHOOK_SECRET={{ stripe_webhook_secret }}
STRIPE_MODE=live
SMIME_PASSPHRASE='{{ smime_passphrase }}'

View File

@@ -1,11 +1,10 @@
$ANSIBLE_VAULT;1.1;AES256
64616263316537643530626465343665623830646361623061333265373065353535643435333632
6639663636363630376437323232633662643430643865630a636431653266353930306231383031
34393965623762356632633262303632316439333464313161383638366331623833666534653930
6435656537306566630a333332663632343030643664626261373536393232666262623466643934
35636534656530623865663737336139386137353738623362393933376563323463346432313562
36383933306237363936383230303830643030336338353466323933356231343865663663666633
34386361303033663366356639353933356466333637653436363261613833373664363861633236
65656162643837633336363836663635626164323763323832396236633131393864363064636463
61303636346661373362656561356532646364613937663261333939303865326534616237336335
3737616162653034666634653736393833356331343430653637
cloudflare_api_token: Kq_hpaH_ng-hAeGsJo6KhQb2TxYW1v6lRGE84aOR
cloudflare_zone_id: a26d2ecd33d18c984f348eeb060ed5b3
app_secret: c6fd784e6bc77d66721bdfb142f58dc4ff9be3fe66244290e303a802a14c4f06
mailer_dsn: ses+smtp://AKIAWTT2T22CWBRBBDYN:BBdgb6KxRQ8mNcpWFJsZCJxbSGNdgLhKFiITMErfBlQP@default?region=eu-west-3
s3_access_key: CHANGE_ME
s3_secret_key: CHANGE_ME
stripe_pk: pk_live_51SUA1rP4ub49xK2ThoRH8efqGYNi1hrcWMzrqmDtJpMv12cmTzLa8ncJLUKLbOQNZTkm1jgptLfwt4hxEGqkVsHB00AK3ieZNl
stripe_sk: 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
stripe_webhook_secret: CHANGE_ME
smime_passphrase: 'KLreLnyR07x5h#3$AC'

View File

@@ -130,6 +130,7 @@ services:
ngrok-sync:
image: curlimages/curl:latest
container_name: e_ticket_ngrok_sync
user: "0:0"
volumes:
- .:/app
- ./docker/ngrok/sync.sh:/sync.sh

View File

@@ -4,73 +4,112 @@ namespace App\Service;
use Symfony\Component\Mime\Crypto\SMimeSigner;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\Header\Headers;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Mailer\Messenger\SendEmailMessage;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Uid\Uuid;
use Twig\Environment;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Doctrine\ORM\EntityManagerInterface;
class MailerService
{
private readonly SMimeSigner $signer;
private const UNSUBSCRIBE_WHITELIST = [
'contact@e-cosplay.fr',
];
public function __construct(
private readonly MessageBusInterface $bus,
private readonly Environment $twig,
private readonly UrlGeneratorInterface $urlGenerator,
private readonly string $smimeCertificate,
private readonly string $smimePrivateKey,
private readonly string $smimePassphrase,
private readonly string $fromEmail,
private readonly bool $realMail,
) {
$this->signer = new SMimeSigner(
$this->smimeCertificate,
$this->smimePrivateKey,
$this->smimePassphrase,
);
private MailerInterface $mailer,
#[Autowire('%kernel.project_dir%')] private string $projectDir,
#[Autowire(env: 'SMIME_PASSPHRASE')] private string $smimePassphrase,
private UrlGeneratorInterface $urlGenerator,
private UnsubscribeManager $unsubscribeManager,
private EntityManagerInterface $em,
) {}
public function send(Email $email): void
{
$publicKeyPath = $this->projectDir . '/public/key.asc';
if (file_exists($publicKeyPath)) {
$email->attachFromPath($publicKeyPath, 'public_key.asc', 'application/pgp-keys');
}
public function send(
string $to,
string $subject,
string $template,
array $context = [],
?string $replyTo = null,
): void {
if (!$this->realMail) {
$to = $this->fromEmail;
$certificate = $this->projectDir . '/config/cert/certificate.pem';
$privateKey = $this->projectDir . '/config/cert/private-key.pem';
if (file_exists($certificate) && file_exists($privateKey)) {
$signer = new SMimeSigner($certificate, $privateKey, $this->smimePassphrase);
$email = $signer->sign($email);
}
$html = $this->twig->render("emails/{$template}.html.twig", $context);
$this->mailer->send($email);
}
$messageId = Uuid::v4()->toRfc4122() . '@e-cosplay.fr';
/**
* @param array<array{path: string, name?: string}>|null $attachments
*/
public function sendEmail(string $to, string $subject, string $content, string $from = 'E-Ticket <contact@e-cosplay.fr>', ?string $replyTo = null, bool $withUnsubscribe = true, ?array $attachments = null): void
{
$canUnsubscribe = $withUnsubscribe && !$this->isWhitelisted($to);
if ($canUnsubscribe && $this->unsubscribeManager->isUnsubscribed($to)) {
return;
}
$email = (new Email())
->from($this->fromEmail)
->from($from)
->to($to)
->subject($subject)
->html($html);
$email->getHeaders()->addIdHeader('Message-ID', $messageId);
$token = base64_encode($to);
$unsubscribeUrl = $this->urlGenerator->generate('app_unsubscribe', [
'token' => $token,
], UrlGeneratorInterface::ABSOLUTE_URL);
$email->getHeaders()
->addTextHeader('List-Unsubscribe', "<mailto:contact@e-cosplay.fr?subject=unsubscribe>, <{$unsubscribeUrl}>")
->addTextHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click');
->html($content);
if ($replyTo) {
$email->replyTo($replyTo);
}
$signed = $this->signer->sign($email);
$this->bus->dispatch(new SendEmailMessage($signed));
if ($attachments) {
foreach ($attachments as $attachment) {
$email->attachFromPath($attachment['path'], $attachment['name'] ?? null);
}
}
$messageId = bin2hex(random_bytes(16));
$email->getHeaders()->addIdHeader('Message-ID', $messageId . '@e-cosplay.fr');
$tracking = new EmailTracking($messageId, $to, $subject);
$this->em->persist($tracking);
$this->em->flush();
$trackingUrl = $this->urlGenerator->generate('app_email_track', [
'messageId' => $messageId,
], UrlGeneratorInterface::ABSOLUTE_URL);
$html = $email->getHtmlBody();
$html = str_replace('https://ticket.e-cosplay.fr/logo.jpg', $trackingUrl, $html);
$email->html($html);
if ($canUnsubscribe) {
$this->addUnsubscribeHeaders($email, $to);
}
$this->send($email);
}
private function isWhitelisted(string $email): bool
{
return \in_array(strtolower(trim($email)), self::UNSUBSCRIBE_WHITELIST, true);
}
private function addUnsubscribeHeaders(Email $email, string $to): void
{
$token = $this->unsubscribeManager->generateToken($to);
$unsubscribeUrl = $this->urlGenerator->generate('app_unsubscribe', [
'email' => $to,
'token' => $token,
], UrlGeneratorInterface::ABSOLUTE_URL);
$email->getHeaders()->addTextHeader(
'List-Unsubscribe',
sprintf('<%s>, <mailto:unsubscribe@e-cosplay.fr?subject=unsubscribe-%s>', $unsubscribeUrl, urlencode($to))
);
$email->getHeaders()->addTextHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click');
}
}