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:
@@ -20,8 +20,5 @@ jobs:
|
|||||||
chmod 600 ~/.ssh/id_ed25519
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
ssh-keyscan 34.90.187.4 >> ~/.ssh/known_hosts
|
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
|
- 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 }}")
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
- name: Configure Cloudflare for ticket.e-cosplay.fr
|
# --- Cloudflare configuration ---
|
||||||
|
- name: Configure Cloudflare
|
||||||
hosts: localhost
|
hosts: localhost
|
||||||
connection: local
|
connection: local
|
||||||
vars_files:
|
vars_files:
|
||||||
@@ -156,7 +157,7 @@
|
|||||||
ignore_errors: true
|
ignore_errors: true
|
||||||
|
|
||||||
# --- Allow SEO bots ---
|
# --- Allow SEO bots ---
|
||||||
- name: Allow Googlebot
|
- name: Allow SEO and social media bots
|
||||||
uri:
|
uri:
|
||||||
url: "https://api.cloudflare.com/client/v4/zones/{{ zone_id }}/firewall/rules"
|
url: "https://api.cloudflare.com/client/v4/zones/{{ zone_id }}/firewall/rules"
|
||||||
method: POST
|
method: POST
|
||||||
@@ -171,3 +172,91 @@
|
|||||||
description: "Allow SEO and social media bots"
|
description: "Allow SEO and social media bots"
|
||||||
status_code: [200, 409]
|
status_code: [200, 409]
|
||||||
ignore_errors: true
|
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
19
ansible/env.local.j2
Normal 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 }}'
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
$ANSIBLE_VAULT;1.1;AES256
|
cloudflare_api_token: Kq_hpaH_ng-hAeGsJo6KhQb2TxYW1v6lRGE84aOR
|
||||||
64616263316537643530626465343665623830646361623061333265373065353535643435333632
|
cloudflare_zone_id: a26d2ecd33d18c984f348eeb060ed5b3
|
||||||
6639663636363630376437323232633662643430643865630a636431653266353930306231383031
|
app_secret: c6fd784e6bc77d66721bdfb142f58dc4ff9be3fe66244290e303a802a14c4f06
|
||||||
34393965623762356632633262303632316439333464313161383638366331623833666534653930
|
mailer_dsn: ses+smtp://AKIAWTT2T22CWBRBBDYN:BBdgb6KxRQ8mNcpWFJsZCJxbSGNdgLhKFiITMErfBlQP@default?region=eu-west-3
|
||||||
6435656537306566630a333332663632343030643664626261373536393232666262623466643934
|
s3_access_key: CHANGE_ME
|
||||||
35636534656530623865663737336139386137353738623362393933376563323463346432313562
|
s3_secret_key: CHANGE_ME
|
||||||
36383933306237363936383230303830643030336338353466323933356231343865663663666633
|
stripe_pk: pk_live_51SUA1rP4ub49xK2ThoRH8efqGYNi1hrcWMzrqmDtJpMv12cmTzLa8ncJLUKLbOQNZTkm1jgptLfwt4hxEGqkVsHB00AK3ieZNl
|
||||||
34386361303033663366356639353933356466333637653436363261613833373664363861633236
|
stripe_sk: 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
|
||||||
65656162643837633336363836663635626164323763323832396236633131393864363064636463
|
stripe_webhook_secret: CHANGE_ME
|
||||||
61303636346661373362656561356532646364613937663261333939303865326534616237336335
|
smime_passphrase: 'KLreLnyR07x5h#3$AC'
|
||||||
3737616162653034666634653736393833356331343430653637
|
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ services:
|
|||||||
ngrok-sync:
|
ngrok-sync:
|
||||||
image: curlimages/curl:latest
|
image: curlimages/curl:latest
|
||||||
container_name: e_ticket_ngrok_sync
|
container_name: e_ticket_ngrok_sync
|
||||||
|
user: "0:0"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- ./docker/ngrok/sync.sh:/sync.sh
|
- ./docker/ngrok/sync.sh:/sync.sh
|
||||||
|
|||||||
@@ -4,73 +4,112 @@ namespace App\Service;
|
|||||||
|
|
||||||
use Symfony\Component\Mime\Crypto\SMimeSigner;
|
use Symfony\Component\Mime\Crypto\SMimeSigner;
|
||||||
use Symfony\Component\Mime\Email;
|
use Symfony\Component\Mime\Email;
|
||||||
use Symfony\Component\Mime\Header\Headers;
|
use Symfony\Component\Mailer\MailerInterface;
|
||||||
use Symfony\Component\Messenger\MessageBusInterface;
|
|
||||||
use Symfony\Component\Mailer\Messenger\SendEmailMessage;
|
|
||||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
use Symfony\Component\Uid\Uuid;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
use Twig\Environment;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
class MailerService
|
class MailerService
|
||||||
{
|
{
|
||||||
private readonly SMimeSigner $signer;
|
private const UNSUBSCRIBE_WHITELIST = [
|
||||||
|
'contact@e-cosplay.fr',
|
||||||
|
];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly MessageBusInterface $bus,
|
private MailerInterface $mailer,
|
||||||
private readonly Environment $twig,
|
#[Autowire('%kernel.project_dir%')] private string $projectDir,
|
||||||
private readonly UrlGeneratorInterface $urlGenerator,
|
#[Autowire(env: 'SMIME_PASSPHRASE')] private string $smimePassphrase,
|
||||||
private readonly string $smimeCertificate,
|
private UrlGeneratorInterface $urlGenerator,
|
||||||
private readonly string $smimePrivateKey,
|
private UnsubscribeManager $unsubscribeManager,
|
||||||
private readonly string $smimePassphrase,
|
private EntityManagerInterface $em,
|
||||||
private readonly string $fromEmail,
|
) {}
|
||||||
private readonly bool $realMail,
|
|
||||||
) {
|
|
||||||
$this->signer = new SMimeSigner(
|
|
||||||
$this->smimeCertificate,
|
|
||||||
$this->smimePrivateKey,
|
|
||||||
$this->smimePassphrase,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function send(
|
public function send(Email $email): void
|
||||||
string $to,
|
{
|
||||||
string $subject,
|
$publicKeyPath = $this->projectDir . '/public/key.asc';
|
||||||
string $template,
|
|
||||||
array $context = [],
|
if (file_exists($publicKeyPath)) {
|
||||||
?string $replyTo = null,
|
$email->attachFromPath($publicKeyPath, 'public_key.asc', 'application/pgp-keys');
|
||||||
): void {
|
|
||||||
if (!$this->realMail) {
|
|
||||||
$to = $this->fromEmail;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$html = $this->twig->render("emails/{$template}.html.twig", $context);
|
$certificate = $this->projectDir . '/config/cert/certificate.pem';
|
||||||
|
$privateKey = $this->projectDir . '/config/cert/private-key.pem';
|
||||||
|
|
||||||
$messageId = Uuid::v4()->toRfc4122() . '@e-cosplay.fr';
|
if (file_exists($certificate) && file_exists($privateKey)) {
|
||||||
|
$signer = new SMimeSigner($certificate, $privateKey, $this->smimePassphrase);
|
||||||
|
$email = $signer->sign($email);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->mailer->send($email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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())
|
$email = (new Email())
|
||||||
->from($this->fromEmail)
|
->from($from)
|
||||||
->to($to)
|
->to($to)
|
||||||
->subject($subject)
|
->subject($subject)
|
||||||
->html($html);
|
->html($content);
|
||||||
|
|
||||||
$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');
|
|
||||||
|
|
||||||
if ($replyTo) {
|
if ($replyTo) {
|
||||||
$email->replyTo($replyTo);
|
$email->replyTo($replyTo);
|
||||||
}
|
}
|
||||||
|
|
||||||
$signed = $this->signer->sign($email);
|
if ($attachments) {
|
||||||
|
foreach ($attachments as $attachment) {
|
||||||
|
$email->attachFromPath($attachment['path'], $attachment['name'] ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$this->bus->dispatch(new SendEmailMessage($signed));
|
$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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user