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:
93
src/Controller/Mail/MailAttachmentDownloadController.php
Normal file
93
src/Controller/Mail/MailAttachmentDownloadController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user