From 1406252fafe17dc306d7343c43bdf3a59dbd9408 Mon Sep 17 00:00:00 2001 From: killecaptron <203002577+killecaptron@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:09:50 +0200 Subject: [PATCH 1/2] Fix broken preview images for extension-less external files If no file extension can be extracted from the URL of an external attachment, Part-DB assumed that it is a picture. That is a sensible guess for image URLs, but it also applies to URLs which redirect to the actual file, like the datasheet links of the TrustedParts provider (https://www.trustedparts.com/productredirect?id=...). Such datasheets were rendered as an in the attachment lists (showing a broken image) and the first one was automatically used as the preview picture of the part by the AttachmentSubmitHandler, so the part page showed a broken main image too. The attachment type already knows which filetypes it may contain (the "Datasheet" type created by the info provider system is restricted to application/pdf), so use that information for the guess. Attachments whose URL has a known picture extension are unaffected, as are attachment types without a filetype filter. Existing parts heal themselves: the AttachmentSubmitHandler already removes a master picture attachment which is not a picture anymore, as soon as the part is saved the next time. --- src/Entity/Attachments/Attachment.php | 37 +++++++++++++++++++-- tests/Entity/Attachments/AttachmentTest.php | 25 ++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/Entity/Attachments/Attachment.php b/src/Entity/Attachments/Attachment.php index 1f8ad2972..f3c561be9 100644 --- a/src/Entity/Attachments/Attachment.php +++ b/src/Entity/Attachments/Attachment.php @@ -289,13 +289,46 @@ public function isPicture(): bool //Check if we can extract a file extension from the URL $extension = pathinfo(parse_url($this->getExternalPath(), PHP_URL_PATH) ?? '', PATHINFO_EXTENSION); - //If no extension is found or it is known picture extension, we assume that this is a picture extension - return $extension === '' || in_array(strtolower($extension), static::PICTURE_EXTS, true); + if (in_array(strtolower($extension), static::PICTURE_EXTS, true)) { + return true; + } + + //If no extension is found (e.g. for URLs which redirect to the actual file), we can only guess. We assume + //that it is a picture, unless the attachment type rules pictures out (like the "Datasheet" type, which only + //allows PDFs), as we would show a broken image otherwise. + return $extension === '' && $this->attachmentTypeAllowsPictures(); } //File doesn't have an internal, nor an external copy. This shouldn't happen, but it certainly isn't a picture... return false; } + /** + * Checks whether the filetype filter of the attachment type allows pictures. + * If no attachment type is set, or it does not restrict the filetypes, true is returned, as we can not tell. + */ + private function attachmentTypeAllowsPictures(): bool + { + $filter = trim($this->getAttachmentType()?->getFiletypeFilter() ?? ''); + + if ($filter === '') { + return true; + } + + foreach (explode(',', $filter) as $allowed) { + $allowed = trim(strtolower($allowed)); + + if ($allowed === '*' || $allowed === '*/*' || str_starts_with($allowed, 'image/')) { + return true; + } + + if (in_array(ltrim($allowed, '.'), static::PICTURE_EXTS, true)) { + return true; + } + } + + return false; + } + /** * Check if this attachment is a 3D model and therefore can be directly shown to user. * If no internal copy exists, false is returned (3D Models must be internal). diff --git a/tests/Entity/Attachments/AttachmentTest.php b/tests/Entity/Attachments/AttachmentTest.php index bef2df168..7d4173ac4 100644 --- a/tests/Entity/Attachments/AttachmentTest.php +++ b/tests/Entity/Attachments/AttachmentTest.php @@ -171,6 +171,31 @@ public function testIsPicture(?string $internal_path, ?string $external_path, bo $this->assertSame($expected, $attachment->isPicture()); } + public static function pictureFiletypeFilterDataProvider(): \Iterator + { + //An URL without a file extension is only assumed to be a picture, if the attachment type allows pictures + yield ['https://invalid.com/redirect?id=1234', '', true]; + yield ['https://invalid.com/redirect?id=1234', 'image/*', true]; + yield ['https://invalid.com/redirect?id=1234', '.jpg,.png', true]; + yield ['https://invalid.com/redirect?id=1234', 'application/pdf', false]; + yield ['https://invalid.com/redirect?id=1234', '.pdf,.txt', false]; + //An URL with a picture extension is always a picture, no matter what the attachment type says + yield ['https://invalid.com/picture.jpeg', 'application/pdf', true]; + } + + #[DataProvider('pictureFiletypeFilterDataProvider')] + public function testIsPictureRespectsFiletypeFilter(string $external_path, string $filter, bool $expected): void + { + $attachment_type = new AttachmentType(); + $attachment_type->setFiletypeFilter($filter); + + $attachment = new PartAttachment(); + $attachment->setAttachmentType($attachment_type); + $this->setProtectedProperty($attachment, 'external_path', $external_path); + + $this->assertSame($expected, $attachment->isPicture()); + } + public static function builtinDataProvider(): \Iterator { yield ['', false]; From c08fa3381f68430741ddda157ca5c51203f803bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 6 Sep 2026 21:51:41 +0200 Subject: [PATCH 2/2] Moved allowsPicture logic to attachmentType --- src/Entity/Attachments/Attachment.php | 31 ++----------------- src/Entity/Attachments/AttachmentType.php | 26 ++++++++++++++++ .../Entity/Attachments/AttachmentTypeTest.php | 30 ++++++++++++++++++ 3 files changed, 59 insertions(+), 28 deletions(-) diff --git a/src/Entity/Attachments/Attachment.php b/src/Entity/Attachments/Attachment.php index f3c561be9..7a4a170a5 100644 --- a/src/Entity/Attachments/Attachment.php +++ b/src/Entity/Attachments/Attachment.php @@ -285,6 +285,7 @@ public function isPicture(): bool return in_array(strtolower($extension), static::PICTURE_EXTS, true); } + if ($this->hasExternal()) { //Check if we can extract a file extension from the URL $extension = pathinfo(parse_url($this->getExternalPath(), PHP_URL_PATH) ?? '', PATHINFO_EXTENSION); @@ -296,36 +297,10 @@ public function isPicture(): bool //If no extension is found (e.g. for URLs which redirect to the actual file), we can only guess. We assume //that it is a picture, unless the attachment type rules pictures out (like the "Datasheet" type, which only //allows PDFs), as we would show a broken image otherwise. - return $extension === '' && $this->attachmentTypeAllowsPictures(); - } - //File doesn't have an internal, nor an external copy. This shouldn't happen, but it certainly isn't a picture... - return false; - } - - /** - * Checks whether the filetype filter of the attachment type allows pictures. - * If no attachment type is set, or it does not restrict the filetypes, true is returned, as we can not tell. - */ - private function attachmentTypeAllowsPictures(): bool - { - $filter = trim($this->getAttachmentType()?->getFiletypeFilter() ?? ''); - - if ($filter === '') { - return true; - } - - foreach (explode(',', $filter) as $allowed) { - $allowed = trim(strtolower($allowed)); - - if ($allowed === '*' || $allowed === '*/*' || str_starts_with($allowed, 'image/')) { - return true; - } - - if (in_array(ltrim($allowed, '.'), static::PICTURE_EXTS, true)) { - return true; - } + return $extension === '' && ($this->getAttachmentType()?->allowsPictures() ?? true); } + //File doesn't have an internal, nor an external copy. This shouldn't happen, but it certainly isn't a picture... return false; } diff --git a/src/Entity/Attachments/AttachmentType.php b/src/Entity/Attachments/AttachmentType.php index 03bb8031a..454ed67ff 100644 --- a/src/Entity/Attachments/AttachmentType.php +++ b/src/Entity/Attachments/AttachmentType.php @@ -191,6 +191,32 @@ public function setFiletypeFilter(string $filetype_filter): self return $this; } + /** + * Checks if this attachment type allows pictures (i.e. if the filetype filter allows image files). + * If no filetype filter is set, this method returns true, as it is assumed that all file types are allowed. + * @return bool + */ + public function allowsPictures(): bool + { + if ($this->filetype_filter === '') { + return true; + } + + foreach (explode(',', $this->filetype_filter) as $allowed) { + $allowed = strtolower(trim($allowed)); + + if ($allowed === '*' || $allowed === '*/*' || str_starts_with($allowed, 'image/')) { + return true; + } + + if (in_array(ltrim($allowed, '.'), Attachment::PICTURE_EXTS, true)) { + return true; + } + } + + return false; + } + /** * Returns a list of allowed targets as class names (e.g. PartAttachment::class), where this attachment type can be assigned to. If null, there are no restrictions. * @return class-string[]|null diff --git a/tests/Entity/Attachments/AttachmentTypeTest.php b/tests/Entity/Attachments/AttachmentTypeTest.php index c966d23f8..dad479981 100644 --- a/tests/Entity/Attachments/AttachmentTypeTest.php +++ b/tests/Entity/Attachments/AttachmentTypeTest.php @@ -26,6 +26,7 @@ use App\Entity\Attachments\PartAttachment; use App\Entity\Attachments\UserAttachment; use Doctrine\Common\Collections\Collection; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class AttachmentTypeTest extends TestCase @@ -83,4 +84,33 @@ public function testIsAllowedForTarget(): void $this->assertTrue($attachmentType->isAllowedForTarget(PartAttachment::class)); $this->assertTrue($attachmentType->isAllowedForTarget(UserAttachment::class)); } + + public static function allowsPictureDataProvider(): array + { + return [ + ['', true], + ['image/*', true], + ['image/png,image/jpeg', true], + ['application/pdf', false], + ['text/plain', false], + ['image/gif,application/pdf', true], + ['audio/mpeg', false], + ['video/mp4', false], + ['image/svg+xml', true], + ['application/zip', false], + ['image/webp,text/html', true], + ['*', true], + ['.pdf,.png', true], + ['.jpeg,.gif', true], + ['.pdf,.xml', false], + ]; + } + + #[DataProvider('allowsPictureDataProvider')] + public function testAllowsPicture(string $filetype_filter, bool $expected): void + { + $attachmentType = new AttachmentType(); + $attachmentType->setFiletypeFilter($filetype_filter); + $this->assertSame($expected, $attachmentType->allowsPictures()); + } }