diff --git a/src/backend/InvenTree/InvenTree/helpers.py b/src/backend/InvenTree/InvenTree/helpers.py index 7b7cef24d5..792fd1b002 100644 --- a/src/backend/InvenTree/InvenTree/helpers.py +++ b/src/backend/InvenTree/InvenTree/helpers.py @@ -262,27 +262,42 @@ def checkStaticFile(*args) -> bool: return static_storage.exists(str(fn)) -def getLogoImage(as_file=False, custom=True): - """Return the InvenTree logo image, or a custom logo if available.""" +def getLogoImage(as_file: bool = False, custom: bool = True) -> str: + """Return the InvenTree logo image, or a custom logo if available. + + Arguments: + as_file: If True, return a base64-encoded data URI of the image contents, + suitable for embedding directly into a generated report (default = False) + custom: If True, return a custom logo if one has been provided (default = True) + """ + # Note: Imported here to avoid circular imports, as the 'report' app also imports from this module + import report.helpers + from report.templatetags.report import ( + get_media_file_contents, + get_static_file_contents, + ) + if custom and settings.CUSTOM_LOGO: static_storage = StaticFilesStorage() if static_storage.exists(settings.CUSTOM_LOGO): - storage = static_storage - elif default_storage.exists(settings.CUSTOM_LOGO): - storage = default_storage - else: - storage = None - - if storage is not None: if as_file: - return f'file://{storage.path(settings.CUSTOM_LOGO)}' - return storage.url(settings.CUSTOM_LOGO) + return report.helpers.encode_file_base64( + settings.CUSTOM_LOGO, get_static_file_contents(settings.CUSTOM_LOGO) + ) + return static_storage.url(settings.CUSTOM_LOGO) + elif default_storage.exists(settings.CUSTOM_LOGO): + if as_file: + return report.helpers.encode_file_base64( + settings.CUSTOM_LOGO, get_media_file_contents(settings.CUSTOM_LOGO) + ) + return default_storage.url(settings.CUSTOM_LOGO) # If we have got to this point, return the default logo if as_file: - path = settings.STATIC_ROOT.joinpath('img/inventree.png') - return f'file://{path}' + return report.helpers.encode_file_base64( + 'img/inventree.png', get_static_file_contents('img/inventree.png') + ) return getStaticUrl('img/inventree.png') diff --git a/src/backend/InvenTree/InvenTree/tests.py b/src/backend/InvenTree/InvenTree/tests.py index f8ef27a75e..52251ea1cd 100644 --- a/src/backend/InvenTree/InvenTree/tests.py +++ b/src/backend/InvenTree/InvenTree/tests.py @@ -1,9 +1,11 @@ """Test general functions and helpers.""" +import base64 import os import time from datetime import datetime, timedelta from decimal import Decimal +from io import BytesIO from pathlib import Path from unittest import mock from zoneinfo import ZoneInfo @@ -13,6 +15,8 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.core import mail from django.core.exceptions import ValidationError +from django.core.files.base import ContentFile +from django.core.files.storage import default_storage from django.test import TestCase, override_settings from django.urls import reverse from django.utils import timezone @@ -22,6 +26,7 @@ from djmoney.contrib.exchange.exceptions import MissingRate from djmoney.contrib.exchange.models import Rate, convert_money from djmoney.money import Money from maintenance_mode.core import get_maintenance_mode, set_maintenance_mode +from PIL import Image from rest_framework import serializers from sesame.utils import get_user from stdimage.models import StdImageFieldFile @@ -703,12 +708,59 @@ class TestHelpers(TestCase): def test_logo_image(self): """Test for retrieving logo image.""" - # By default, there is no custom logo provided + # By default, there is no custom logo provided - return the default InvenTree logo logo = helpers.getLogoImage() self.assertEqual(logo, '/static/img/inventree.png') + # When requested 'as_file', the logo must be returned as an embeddable + # base64 data URI - file:// URIs are no longer permitted in reports logo = helpers.getLogoImage(as_file=True) - self.assertEqual(logo, f'file://{settings.STATIC_ROOT}/img/inventree.png') + self.assertNotIn('file://', logo) + self.assertTrue(logo.startswith('data:image/png;base64,')) + + # Ensure the encoded data actually represents a valid image + decoded = base64.b64decode(logo.removeprefix('data:image/png;base64,')) + Image.open(BytesIO(decoded)).verify() + + def test_logo_image_custom_static(self): + """Test retrieval of a custom logo which lives in the static storage backend.""" + with override_settings(CUSTOM_LOGO='img/inventree.png'): + logo = helpers.getLogoImage() + self.assertEqual(logo, '/static/img/inventree.png') + + logo = helpers.getLogoImage(as_file=True) + self.assertNotIn('file://', logo) + self.assertTrue(logo.startswith('data:image/png;base64,')) + + # Disabling 'custom' must fall back to the default logo, even if set + logo = helpers.getLogoImage(custom=False) + self.assertEqual(logo, '/static/img/inventree.png') + + def test_logo_image_custom_media(self): + """Test retrieval of a custom logo which lives in the media (uploaded) storage backend.""" + custom_logo_path = 'custom/test_logo.png' + + img = Image.new('RGB', (16, 16), color='blue') + buffer = BytesIO() + img.save(buffer, 'PNG') + image_data = buffer.getvalue() + + default_storage.save(custom_logo_path, ContentFile(image_data)) + + try: + with override_settings(CUSTOM_LOGO=custom_logo_path): + logo = helpers.getLogoImage() + self.assertEqual(logo, default_storage.url(custom_logo_path)) + + logo = helpers.getLogoImage(as_file=True) + self.assertNotIn('file://', logo) + self.assertTrue(logo.startswith('data:image/png;base64,')) + + # The decoded data must exactly match the uploaded image + decoded = base64.b64decode(logo.removeprefix('data:image/png;base64,')) + self.assertEqual(decoded, image_data) + finally: + default_storage.delete(custom_logo_path) def test_download_image(self): """Test function for downloading image from remote URL.""" diff --git a/src/backend/InvenTree/report/helpers.py b/src/backend/InvenTree/report/helpers.py index 58e3b11843..0b174fb023 100644 --- a/src/backend/InvenTree/report/helpers.py +++ b/src/backend/InvenTree/report/helpers.py @@ -3,6 +3,7 @@ import base64 import io import logging +import mimetypes from django.utils.translation import gettext_lazy as _ @@ -78,6 +79,33 @@ def report_page_size_default(): return page_size +def encode_file_base64(filename: str, data: bytes | None) -> str: + """Return a base-64 encoded data URI for raw file data. + + Unlike encode_image_base64, this preserves the original file bytes and + format as-is (no PIL decode/re-encode), so it works for any file type + (e.g. SVG logos) and does not alter the source data. + + Arguments: + filename: The filename associated with the data (used to guess the mime type) + data: The raw file data to encode + + Returns: + str -- Base64 encoded data URI e.g. 'data:image/png;base64,xxxxxxxxx', or an + empty string if no data is provided + """ + if not data: + return '' + + mime_type, _encoding = mimetypes.guess_type(str(filename)) + + if not mime_type: + mime_type = 'application/octet-stream' + + encoded = base64.b64encode(data).decode('ascii') + return f'data:{mime_type};base64,{encoded}' + + def encode_image_base64(image, img_format: str = 'PNG') -> str: """Return a base-64 encoded image which can be rendered in an tag. diff --git a/src/backend/InvenTree/report/templatetags/report.py b/src/backend/InvenTree/report/templatetags/report.py index f5d0856dd1..591eae7a33 100644 --- a/src/backend/InvenTree/report/templatetags/report.py +++ b/src/backend/InvenTree/report/templatetags/report.py @@ -3,7 +3,6 @@ import base64 import copy import logging -import mimetypes from datetime import date, datetime from decimal import Decimal, InvalidOperation from io import BytesIO @@ -323,12 +322,7 @@ def asset(filename: str, raise_error: bool = False) -> str | None: if not file_data: return None - mime_type, _encoding = mimetypes.guess_type(str(filename)) - if not mime_type: - mime_type = 'application/octet-stream' - - encoded = base64.b64encode(file_data).decode('ascii') - return f'data:{mime_type};base64,{encoded}' + return report.helpers.encode_file_base64(filename, file_data) @register.simple_tag() diff --git a/src/backend/InvenTree/report/test_tags.py b/src/backend/InvenTree/report/test_tags.py index 52ed61adff..f2762a8660 100644 --- a/src/backend/InvenTree/report/test_tags.py +++ b/src/backend/InvenTree/report/test_tags.py @@ -228,11 +228,18 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase): def test_logo_image(self): """Unit tests for the 'logo_image' tag.""" - # By default, should return the core InvenTree logo - for b in [True, False]: - self.debug_mode(b) - logo = report_tags.logo_image() - self.assertIn('inventree.png', logo) + # In debug mode, a web-accessible URL to the logo is returned + self.debug_mode(True) + logo = report_tags.logo_image() + self.assertIn('inventree.png', logo) + self.assertNotIn('file://', logo) + + # Outside of debug mode, an embeddable base64 data URI is returned instead + # of a file:// URL, which is no longer permitted in report templates + self.debug_mode(False) + logo = report_tags.logo_image() + self.assertNotIn('file://', logo) + self.assertTrue(logo.startswith('data:image/png;base64,')) def test_string_tags(self): """Simple tests for the string manipulation tags."""