feat(mail) : MailAttachmentDownloadController - GET /api/mail/attachments/{id} (stream, disposition: attachment)

- downloadId = base64url(messageDbId:partNumber)
- Content-Disposition: attachment systematique (jamais inline pour eviter XSS via HTML attachments)
- X-Content-Type-Options: nosniff
- filename sanitise via basename pour eviter path traversal

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 00:12:38 +02:00
parent 117175d4b1
commit f7f7a07162

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Controller\Mail;
use App\Mail\Exception\MailProviderException;
use App\Mail\MailProviderInterface;
use App\Repository\MailMessageRepository;
use App\Security\MailAccessChecker;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/mail/attachments/{downloadId}', name: 'mail_attachment_download', methods: ['GET'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class MailAttachmentDownloadController extends AbstractController
{
public function __construct(
private readonly MailMessageRepository $messageRepository,
private readonly MailProviderInterface $mailProvider,
private readonly MailAccessChecker $accessChecker,
) {}
public function __invoke(string $downloadId): Response
{
$this->accessChecker->ensureCanAccessMail($this->getUser());
$decoded = base64_decode(strtr($downloadId, '-_', '+/'), true);
if (false === $decoded || !str_contains($decoded, ':')) {
throw new BadRequestHttpException('Invalid attachment ID format');
}
[$messageDbIdStr, $partNumber] = explode(':', $decoded, 2);
$messageDbId = (int) $messageDbIdStr;
$message = $this->messageRepository->find($messageDbId);
if (null === $message) {
throw new NotFoundHttpException('Message not found');
}
try {
$detail = $this->mailProvider->fetchMessage(
$message->getFolder()->getPath(),
$message->getUid()
);
} catch (MailProviderException) {
throw new NotFoundHttpException('Could not fetch message from IMAP server');
}
$targetAttachment = null;
foreach ($detail->attachments as $att) {
if ($att->partNumber === $partNumber) {
$targetAttachment = $att;
break;
}
}
if (null === $targetAttachment) {
throw new NotFoundHttpException(sprintf('Attachment part "%s" not found', $partNumber));
}
try {
$content = $this->mailProvider->fetchAttachment(
$message->getFolder()->getPath(),
$message->getUid(),
$partNumber
);
} catch (MailProviderException) {
throw new NotFoundHttpException('Could not fetch attachment content');
}
$filename = basename($targetAttachment->filename);
if ('' === $filename || '.' === $filename) {
$filename = 'attachment';
}
$response = new Response($content);
$response->headers->set('Content-Type', $targetAttachment->mimeType);
$response->headers->set(
'Content-Disposition',
sprintf('attachment; filename="%s"', addslashes($filename))
);
$response->headers->set('Content-Length', (string) strlen($content));
$response->headers->set('X-Content-Type-Options', 'nosniff');
return $response;
}
}