diff --git a/src/Common/EventListener/ExceptionListener.php b/src/Common/EventListener/ExceptionListener.php index d66b852..f272462 100644 --- a/src/Common/EventListener/ExceptionListener.php +++ b/src/Common/EventListener/ExceptionListener.php @@ -6,7 +6,9 @@ use Exception; use PhpList\Core\Domain\Identity\Exception\AdminAttributeCreationException; +use PhpList\Core\Domain\Messaging\Exception\AttachmentFileNotFoundException; use PhpList\Core\Domain\Messaging\Exception\MessageNotReceivedException; +use PhpList\Core\Domain\Messaging\Exception\SubscriberNotFoundException; use PhpList\Core\Domain\Subscription\Exception\AttributeDefinitionCreationException; use PhpList\Core\Domain\Subscription\Exception\SubscriptionCreationException; use Symfony\Component\HttpFoundation\JsonResponse; @@ -18,61 +20,49 @@ class ExceptionListener { - /** - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - */ + private const EXCEPTION_STATUS_MAP = [ + SubscriptionCreationException::class => null, + AttributeDefinitionCreationException::class => null, + AdminAttributeCreationException::class => null, + ValidatorException::class => 400, + AccessDeniedException::class => 403, + AccessDeniedHttpException::class => 403, + AttachmentFileNotFoundException::class => 404, + SubscriberNotFoundException::class => 404, + MessageNotReceivedException::class => 422, + ]; + public function onKernelException(ExceptionEvent $event): void { $exception = $event->getThrowable(); - if ($exception instanceof AccessDeniedHttpException) { - $response = new JsonResponse([ - 'message' => $exception->getMessage(), - ], 403); - - $event->setResponse($response); - } elseif ($exception instanceof HttpExceptionInterface) { - $response = new JsonResponse([ - 'message' => $exception->getMessage(), - ], $exception->getStatusCode()); + foreach (self::EXCEPTION_STATUS_MAP as $class => $statusCode) { + if ($exception instanceof $class) { + $status = $statusCode ?? $exception->getStatusCode(); + $event->setResponse( + new JsonResponse([ + 'message' => $exception->getMessage() + ], $status) + ); + return; + } + } - $event->setResponse($response); - } elseif ($exception instanceof SubscriptionCreationException) { - $response = new JsonResponse([ - 'message' => $exception->getMessage(), - ], $exception->getStatusCode()); - $event->setResponse($response); - } elseif ($exception instanceof AdminAttributeCreationException) { - $response = new JsonResponse([ - 'message' => $exception->getMessage(), - ], $exception->getStatusCode()); - $event->setResponse($response); - } elseif ($exception instanceof AttributeDefinitionCreationException) { - $response = new JsonResponse([ - 'message' => $exception->getMessage(), - ], $exception->getStatusCode()); - $event->setResponse($response); - } elseif ($exception instanceof ValidatorException) { - $response = new JsonResponse([ - 'message' => $exception->getMessage(), - ], 400); - $event->setResponse($response); - } elseif ($exception instanceof AccessDeniedException) { - $response = new JsonResponse([ - 'message' => $exception->getMessage(), - ], 403); - $event->setResponse($response); - } elseif ($exception instanceof MessageNotReceivedException) { - $response = new JsonResponse([ - 'message' => $exception->getMessage(), - ], 422); - $event->setResponse($response); - } elseif ($exception instanceof Exception) { - $response = new JsonResponse([ - 'message' => $exception->getMessage(), - ], 500); + if ($exception instanceof HttpExceptionInterface) { + $event->setResponse( + new JsonResponse([ + 'message' => $exception->getMessage() + ], $exception->getStatusCode()) + ); + return; + } - $event->setResponse($response); + if ($exception instanceof Exception) { + $event->setResponse( + new JsonResponse([ + 'message' => $exception->getMessage() + ], 500) + ); } } } diff --git a/src/Messaging/Controller/AttachmentController.php b/src/Messaging/Controller/AttachmentController.php new file mode 100644 index 0000000..b7e90fe --- /dev/null +++ b/src/Messaging/Controller/AttachmentController.php @@ -0,0 +1,94 @@ + '\\d+'], methods: ['GET'])] + #[OA\Get( + path: '/api/v2/attachments/download/{id}', + description: 'Download an attachment by ID. `uid` query parameter is required.', + summary: 'Download attachment', + tags: ['attachments'], + parameters: [ + new OA\Parameter( + name: 'id', + description: 'Attachment ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'integer') + ), + new OA\Parameter( + name: 'uid', + description: 'Download token (subscriber email or word "forwarded")', + in: 'query', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response(response: 200, description: 'File stream'), + new OA\Response(response: 403, description: 'Unauthorized'), + new OA\Response(response: 404, description: 'Not found'), + ] + )] + public function download( + #[MapEntity(mapping: ['id' => 'id'])] Attachment $attachment, + #[MapQueryParameter] string $uid + ): StreamedResponse { + $downloadable = $this->attachmentDownloadService->getDownloadable($attachment, $uid); + + $headers = [ + 'Content-Type' => $downloadable->mimeType, + 'Content-Disposition' => HeaderUtils::makeDisposition( + disposition: ResponseHeaderBag::DISPOSITION_ATTACHMENT, + filename: $downloadable->filename + ), + ]; + + if ($downloadable->size !== null) { + $headers['Content-Length'] = (string) $downloadable->size; + } + + return new StreamedResponse( + callback: function () use ($downloadable) { + $stream = $downloadable->content; + + if ($stream->isSeekable()) { + $stream->rewind(); + } + + while (!$stream->eof()) { + echo $stream->read(8192); + flush(); + } + }, + status: 200, + headers: $headers + ); + } +} diff --git a/tests/Integration/Messaging/Controller/AttachmentControllerTest.php b/tests/Integration/Messaging/Controller/AttachmentControllerTest.php new file mode 100644 index 0000000..e00bc7c --- /dev/null +++ b/tests/Integration/Messaging/Controller/AttachmentControllerTest.php @@ -0,0 +1,125 @@ +repoPath = (string) self::getContainer()->getParameter('phplist.attachment_repository_path'); + if (!is_dir($this->repoPath)) { + mkdir($this->repoPath, 0777, true); + } + } + + protected function tearDown(): void + { + // Clean up any test file we might have created + $file = $this->repoPath . DIRECTORY_SEPARATOR . AttachmentFixture::FILENAME; + if (is_file($file)) { + unlink($file); + } + + parent::tearDown(); + } + + public function testControllerIsAvailableViaContainer(): void + { + self::assertInstanceOf( + AttachmentController::class, + self::getContainer()->get(AttachmentController::class) + ); + } + + public function testDownloadReturnsFileStreamWithHeaders(): void + { + $this->loadFixtures([AttachmentFixture::class]); + + // Prepare the actual file in the repository path + $content = 'Hello Attachment'; + $file = $this->repoPath . DIRECTORY_SEPARATOR . AttachmentFixture::FILENAME; + file_put_contents($file, $content); + + self::getClient()->request( + 'GET', + sprintf( + '/api/v2/attachments/download/%d?uid=%s', + AttachmentFixture::ATTACHMENT_ID, + Attachment::FORWARD + ) + ); + + $response = self::getClient()->getResponse(); + + // StreamedResponse should be 200 with correct headers + self::assertSame(200, $response->getStatusCode()); + self::assertSame('text/plain; charset=UTF-8', $response->headers->get('Content-Type')); + self::assertStringContainsString( + 'attachment; filename=' . AttachmentFixture::FILENAME, + (string) $response->headers->get('Content-Disposition') + ); + self::assertSame((string) strlen($content), $response->headers->get('Content-Length')); + + $callback = $response->getCallback(); + ob_start(); + $callback(); + $body = ob_get_clean(); + + self::assertSame($content, $body); + } + + public function testDownloadReturnsNotFoundWhenAttachmentEntityMissing(): void + { + self::getClient()->request('GET', '/api/v2/attachments/download/999999?uid=' . Attachment::FORWARD); + $this->assertHttpNotFound(); + } + + public function testDownloadReturnsNotFoundWhenUidEmailNotFound(): void + { + $this->loadFixtures([AttachmentFixture::class]); + + // Do not create the file; the uid validation happens first and should 404 + self::getClient()->request( + 'GET', + sprintf( + '/api/v2/attachments/download/%d?uid=%s', + AttachmentFixture::ATTACHMENT_ID, + 'does-not-exist@example.com' + ) + ); + + $this->assertHttpNotFound(); + } + + public function testDownloadReturnsNotFoundWhenFileMissing(): void + { + $this->loadFixtures([AttachmentFixture::class]); + + // Ensure no file exists + $file = $this->repoPath . DIRECTORY_SEPARATOR . AttachmentFixture::FILENAME; + if (is_file($file)) { + unlink($file); + } + + self::getClient()->request( + 'GET', + sprintf( + '/api/v2/attachments/download/%d?uid=%s', + AttachmentFixture::ATTACHMENT_ID, + Attachment::FORWARD + ) + ); + + $this->assertHttpNotFound(); + } +} diff --git a/tests/Integration/Messaging/Fixtures/AttachmentFixture.php b/tests/Integration/Messaging/Fixtures/AttachmentFixture.php new file mode 100644 index 0000000..7d23319 --- /dev/null +++ b/tests/Integration/Messaging/Fixtures/AttachmentFixture.php @@ -0,0 +1,33 @@ +setSubjectId($attachment, self::ATTACHMENT_ID); + $manager->persist($attachment); + $manager->flush(); + } +}