⚠ This page is served via a proxy. Original site: https://github.com
This service does not collect credentials or authentication data.
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions config/services/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/Domain/Messaging/Exception/AttachmentFileNotFoundException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace PhpList\Core\Domain\Messaging\Exception;

use RuntimeException;

class AttachmentFileNotFoundException extends RuntimeException
{
}
2 changes: 2 additions & 0 deletions src/Domain/Messaging/Model/Attachment.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
#[ORM\Table(name: 'phplist_attachment')]
class Attachment implements DomainModel, Identity
{
public const FORWARD = 'forwarded';

#[ORM\Id]
#[ORM\Column(type: 'integer')]
#[ORM\GeneratedValue]
Expand Down
18 changes: 18 additions & 0 deletions src/Domain/Messaging/Model/Dto/DownloadableAttachment.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace PhpList\Core\Domain\Messaging\Model\Dto;

use Psr\Http\Message\StreamInterface;

final class DownloadableAttachment
{
public function __construct(
public readonly string $filename,
public readonly string $mimeType,
public readonly ?int $size,
public readonly StreamInterface $content,
) {
}
}
5 changes: 2 additions & 3 deletions src/Domain/Messaging/Service/AttachmentAdder.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,8 @@ public function add(Email $email, int $campaignId, OutputFormat $format, bool $f
break;

case OutputFormat::Text:
$hash = $forwarded ? 'forwarded' : $email->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()
Expand Down
90 changes: 90 additions & 0 deletions src/Domain/Messaging/Service/AttachmentDownloadService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

declare(strict_types=1);

namespace PhpList\Core\Domain\Messaging\Service;

use GuzzleHttp\Psr7\Utils;
use PhpList\Core\Domain\Messaging\Exception\AttachmentFileNotFoundException;
use PhpList\Core\Domain\Messaging\Exception\SubscriberNotFoundException;
use PhpList\Core\Domain\Messaging\Model\Attachment;
use PhpList\Core\Domain\Messaging\Model\Dto\DownloadableAttachment;
use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository;
use Psr\Http\Message\StreamInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Mime\MimeTypes;

class AttachmentDownloadService
{
public function __construct(
private readonly SubscriberRepository $subscriberRepository,
#[Autowire('%phplist.attachment_repository_path%')] private readonly string $attachmentRepositoryPath = '/tmp',
) {
}

public function getDownloadable(Attachment $attachment, string $uid): DownloadableAttachment
{
$this->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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
109 changes: 109 additions & 0 deletions tests/Unit/Domain/Messaging/Service/AttachmentDownloadServiceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php

declare(strict_types=1);

namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service;

use PhpList\Core\Domain\Messaging\Exception\AttachmentFileNotFoundException;
use PhpList\Core\Domain\Messaging\Model\Attachment;
use PhpList\Core\Domain\Messaging\Service\AttachmentDownloadService;
use PhpList\Core\Domain\Subscription\Model\Subscriber;
use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository;
use PHPUnit\Framework\TestCase;

final class AttachmentDownloadServiceTest extends TestCase
{
private string $tempDir;

protected function setUp(): void
{
$this->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);
}
}
Loading