diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 95ff342..05e3e23 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -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 }}") diff --git a/ansible/deploy-caddy.yml b/ansible/deploy-caddy.yml deleted file mode 100644 index 16fbdca..0000000 --- a/ansible/deploy-caddy.yml +++ /dev/null @@ -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 diff --git a/ansible/cloudflare.yml b/ansible/deploy.yml similarity index 73% rename from ansible/cloudflare.yml rename to ansible/deploy.yml index 1590860..87f9add 100644 --- a/ansible/cloudflare.yml +++ b/ansible/deploy.yml @@ -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 diff --git a/ansible/env.local.j2 b/ansible/env.local.j2 new file mode 100644 index 0000000..f046797 --- /dev/null +++ b/ansible/env.local.j2 @@ -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 }}' diff --git a/ansible/vault.yml b/ansible/vault.yml index 527a0d3..4acaea4 100644 --- a/ansible/vault.yml +++ b/ansible/vault.yml @@ -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' diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 240beba..dd85cd2 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -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 diff --git a/src/Service/MailerService.php b/src/Service/MailerService.php index 71a655a..fb50b7c 100644 --- a/src/Service/MailerService.php +++ b/src/Service/MailerService.php @@ -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( - string $to, - string $subject, - string $template, - array $context = [], - ?string $replyTo = null, - ): void { - if (!$this->realMail) { - $to = $this->fromEmail; + 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'); } - $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|null $attachments + */ + public function sendEmail(string $to, string $subject, string $content, string $from = 'E-Ticket ', ?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', ", <{$unsubscribeUrl}>") - ->addTextHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click'); + ->html($content); if ($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>, ', $unsubscribeUrl, urlencode($to)) + ); + $email->getHeaders()->addTextHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click'); + } }