diff --git a/composer.json b/composer.json index 0a39fd1b..f49193ab 100644 --- a/composer.json +++ b/composer.json @@ -86,11 +86,11 @@ "ext-curl": "*", "ext-fileinfo": "*", "setasign/fpdf": "^1.8", - "phpdocumentor/reflection-docblock": "^5.2" + "phpdocumentor/reflection-docblock": "^5.2", + "guzzlehttp/guzzle": "^6.3.0" }, "require-dev": { "phpunit/phpunit": "^9.5", - "guzzlehttp/guzzle": "^6.3.0", "squizlabs/php_codesniffer": "^3.2.0", "phpstan/phpstan": "^1.10", "nette/caching": "^3.0.0", diff --git a/config/services/services.yml b/config/services/services.yml index 93134c38..5e7db66b 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -87,6 +87,10 @@ services: autowire: true autoconfigure: true + PhpList\Core\Domain\Messaging\Service\AttachmentDownloadService: + autowire: true + autoconfigure: true + PhpList\Core\Domain\Configuration\Service\UserPersonalizer: autowire: true autoconfigure: true diff --git a/src/Domain/Messaging/Exception/AttachmentFileNotFoundException.php b/src/Domain/Messaging/Exception/AttachmentFileNotFoundException.php new file mode 100644 index 00000000..42510085 --- /dev/null +++ b/src/Domain/Messaging/Exception/AttachmentFileNotFoundException.php @@ -0,0 +1,11 @@ +getTo()[0]->getAddress(); - // todo: add endpoint in rest-api project - $viewUrl = $this->attachmentDownloadUrl . '/?id=' . $att->getId() . '&uid=' . $hash; + $hash = $forwarded ? Attachment::FORWARD : $email->getTo()[0]->getAddress(); + $viewUrl = $this->attachmentDownloadUrl . '/' . $att->getId() . '/?uid=' . urlencode($hash); $email->text( $email->getTextBody() diff --git a/src/Domain/Messaging/Service/AttachmentDownloadService.php b/src/Domain/Messaging/Service/AttachmentDownloadService.php new file mode 100644 index 00000000..00181df6 --- /dev/null +++ b/src/Domain/Messaging/Service/AttachmentDownloadService.php @@ -0,0 +1,90 @@ +validateUid($uid); + + $original = $attachment->getFilename(); + if ($original === null || $original === '') { + throw new AttachmentFileNotFoundException('Attachment has no filename.'); + } + $filename = basename($original); + $filePath = $this->validateFilePath($filename, $original); + + $mimeType = $attachment->getMimeType() + ?? MimeTypes::getDefault()->guessMimeType($filePath) + ?? 'application/octet-stream'; + + $size = filesize($filePath); + $size = $size === false ? null : $size; + + /** @var StreamInterface $stream */ + $stream = Utils::streamFor(Utils::tryFopen($filePath, 'rb')); + + return new DownloadableAttachment( + filename: $filename, + mimeType: $mimeType, + size: $size, + content: $stream, + ); + } + + private function validateUid(string $uid): void + { + if ($uid === Attachment::FORWARD) { + return; + } + + $subscriber = $this->subscriberRepository->findOneByEmail($uid); + if ($subscriber === null) { + throw new SubscriberNotFoundException(); + } + } + + private function validateFilePath(string $filename, ?string $original): string + { + if ($filename === '' || $filename !== $original) { + throw new AttachmentFileNotFoundException('Invalid attachment filename: ' . $original); + } + + $baseDir = realpath($this->attachmentRepositoryPath); + if ($baseDir === false) { + throw new AttachmentFileNotFoundException('Attachment repository path does not exist.'); + } + + $filePath = $baseDir . DIRECTORY_SEPARATOR . $filename; + $realPath = realpath($filePath); + + if ($realPath === false || + !str_starts_with($realPath, $baseDir . DIRECTORY_SEPARATOR) || + !is_file($realPath) || + !is_readable($realPath) + ) { + throw new AttachmentFileNotFoundException('Attachment file not available'); + } + + return $filePath; + } +} diff --git a/tests/Unit/Domain/Messaging/Service/AttachmentAdderTest.php b/tests/Unit/Domain/Messaging/Service/AttachmentAdderTest.php index 9356eb3d..50a91a51 100644 --- a/tests/Unit/Domain/Messaging/Service/AttachmentAdderTest.php +++ b/tests/Unit/Domain/Messaging/Service/AttachmentAdderTest.php @@ -91,7 +91,7 @@ public function testTextModePrependsNoticeAndLinks(): void ); $this->assertStringContainsString('Doc description', $body); $this->assertStringContainsString('Location', $body); - $this->assertStringContainsString('https://dl.example/?id=42&uid=user@example.com', $body); + $this->assertStringContainsString('https://dl.example/42/?uid=' . urlencode('user@example.com'), $body); } public function testHtmlUsesRepositoryFileIfExists(): void diff --git a/tests/Unit/Domain/Messaging/Service/AttachmentDownloadServiceTest.php b/tests/Unit/Domain/Messaging/Service/AttachmentDownloadServiceTest.php new file mode 100644 index 00000000..3d165f66 --- /dev/null +++ b/tests/Unit/Domain/Messaging/Service/AttachmentDownloadServiceTest.php @@ -0,0 +1,109 @@ +tempDir = sys_get_temp_dir() . '/phplist-att-dl-' . bin2hex(random_bytes(5)); + if (!is_dir($this->tempDir)) { + mkdir($this->tempDir, 0777, true); + } + } + + protected function tearDown(): void + { + // cleanup temp directory + if (is_dir($this->tempDir)) { + $files = scandir($this->tempDir) ?: []; + foreach ($files as $f) { + if ($f === '.' || $f === '..') { + continue; + } + unlink($this->tempDir . '/' . $f); + } + rmdir($this->tempDir); + } + } + + public function testThrowsWhenFilenameIsEmpty(): void + { + $subscriberRepo = $this->createMock(SubscriberRepository::class); + $service = new AttachmentDownloadService($subscriberRepo, $this->tempDir); + + $attachment = $this->createMock(Attachment::class); + $attachment->method('getFilename')->willReturn(''); + + $this->expectException(AttachmentFileNotFoundException::class); + $service->getDownloadable($attachment, 'forwarded'); + } + + public function testThrowsWhenFileDoesNotExist(): void + { + $subscriberRepo = $this->createMock(SubscriberRepository::class); + $service = new AttachmentDownloadService($subscriberRepo, $this->tempDir); + + $attachment = $this->createMock(Attachment::class); + $attachment->method('getFilename')->willReturn('missing-file.pdf'); + + $this->expectException(AttachmentFileNotFoundException::class); + $service->getDownloadable($attachment, 'forwarded'); + } + + public function testReturnsDownloadableWithExplicitMimeType(): void + { + $subscriberRepo = $this->createMock(SubscriberRepository::class); + $subscriberRepo->method('findOneByEmail')->with('user@example.com')->willReturn(new Subscriber()); + $service = new AttachmentDownloadService($subscriberRepo, $this->tempDir); + + $filename = 'doc.pdf'; + $content = '%PDF-1.4\n'; + file_put_contents($this->tempDir . '/' . $filename, $content); + + $attachment = $this->createMock(Attachment::class); + $attachment->method('getFilename')->willReturn($filename); + $attachment->method('getMimeType')->willReturn('application/pdf'); + + $dl = $service->getDownloadable($attachment, 'user@example.com'); + + $this->assertSame($filename, $dl->filename); + $this->assertSame('application/pdf', $dl->mimeType); + $this->assertSame(strlen($content), $dl->size); + $this->assertSame($content, (string)$dl->content); + } + + public function testGuessesMimeTypeAndProvidesStream(): void + { + $subscriberRepo = $this->createMock(SubscriberRepository::class); + $subscriberRepo->method('findOneByEmail')->with('user@example.com')->willReturn(new Subscriber()); + $service = new AttachmentDownloadService($subscriberRepo, $this->tempDir); + + $filename = 'note.txt'; + $content = "Hello, world!\n"; + file_put_contents($this->tempDir . '/' . $filename, $content); + + $attachment = $this->createMock(Attachment::class); + $attachment->method('getFilename')->willReturn($filename); + $attachment->method('getMimeType')->willReturn(null); + + $dl = $service->getDownloadable($attachment, 'user@example.com'); + + $this->assertSame($filename, $dl->filename); + // Symfony MimeTypes should detect text/plain for .txt + $this->assertSame('text/plain', $dl->mimeType); + $this->assertSame(strlen($content), $dl->size); + $this->assertSame($content, (string)$dl->content); + } +}