[bug] Fix getLogoImage helper (#12577)

* [bug] Fix getLogoImage helper

- Do not return a file:// URI

* cleanup code
This commit is contained in:
Oliver
2026-08-08 19:11:32 +10:00
committed by GitHub
parent f1e6ec249d
commit cf2f64c83a
5 changed files with 123 additions and 27 deletions
+28 -13
View File
@@ -262,27 +262,42 @@ def checkStaticFile(*args) -> bool:
return static_storage.exists(str(fn)) return static_storage.exists(str(fn))
def getLogoImage(as_file=False, custom=True): def getLogoImage(as_file: bool = False, custom: bool = True) -> str:
"""Return the InvenTree logo image, or a custom logo if available.""" """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: if custom and settings.CUSTOM_LOGO:
static_storage = StaticFilesStorage() static_storage = StaticFilesStorage()
if static_storage.exists(settings.CUSTOM_LOGO): 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: if as_file:
return f'file://{storage.path(settings.CUSTOM_LOGO)}' return report.helpers.encode_file_base64(
return storage.url(settings.CUSTOM_LOGO) 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 we have got to this point, return the default logo
if as_file: if as_file:
path = settings.STATIC_ROOT.joinpath('img/inventree.png') return report.helpers.encode_file_base64(
return f'file://{path}' 'img/inventree.png', get_static_file_contents('img/inventree.png')
)
return getStaticUrl('img/inventree.png') return getStaticUrl('img/inventree.png')
+54 -2
View File
@@ -1,9 +1,11 @@
"""Test general functions and helpers.""" """Test general functions and helpers."""
import base64
import os import os
import time import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from decimal import Decimal from decimal import Decimal
from io import BytesIO
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -13,6 +15,8 @@ from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core import mail from django.core import mail
from django.core.exceptions import ValidationError 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.test import TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from django.utils import timezone 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.contrib.exchange.models import Rate, convert_money
from djmoney.money import Money from djmoney.money import Money
from maintenance_mode.core import get_maintenance_mode, set_maintenance_mode from maintenance_mode.core import get_maintenance_mode, set_maintenance_mode
from PIL import Image
from rest_framework import serializers from rest_framework import serializers
from sesame.utils import get_user from sesame.utils import get_user
from stdimage.models import StdImageFieldFile from stdimage.models import StdImageFieldFile
@@ -703,12 +708,59 @@ class TestHelpers(TestCase):
def test_logo_image(self): def test_logo_image(self):
"""Test for retrieving logo image.""" """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.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() logo = helpers.getLogoImage()
self.assertEqual(logo, '/static/img/inventree.png') self.assertEqual(logo, '/static/img/inventree.png')
logo = helpers.getLogoImage(as_file=True) 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,'))
# 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): def test_download_image(self):
"""Test function for downloading image from remote URL.""" """Test function for downloading image from remote URL."""
+28
View File
@@ -3,6 +3,7 @@
import base64 import base64
import io import io
import logging import logging
import mimetypes
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -78,6 +79,33 @@ def report_page_size_default():
return page_size 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: def encode_image_base64(image, img_format: str = 'PNG') -> str:
"""Return a base-64 encoded image which can be rendered in an <img> tag. """Return a base-64 encoded image which can be rendered in an <img> tag.
@@ -3,7 +3,6 @@
import base64 import base64
import copy import copy
import logging import logging
import mimetypes
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from io import BytesIO from io import BytesIO
@@ -323,12 +322,7 @@ def asset(filename: str, raise_error: bool = False) -> str | None:
if not file_data: if not file_data:
return None return None
mime_type, _encoding = mimetypes.guess_type(str(filename)) return report.helpers.encode_file_base64(filename, file_data)
if not mime_type:
mime_type = 'application/octet-stream'
encoded = base64.b64encode(file_data).decode('ascii')
return f'data:{mime_type};base64,{encoded}'
@register.simple_tag() @register.simple_tag()
+10 -3
View File
@@ -228,11 +228,18 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
def test_logo_image(self): def test_logo_image(self):
"""Unit tests for the 'logo_image' tag.""" """Unit tests for the 'logo_image' tag."""
# By default, should return the core InvenTree logo # In debug mode, a web-accessible URL to the logo is returned
for b in [True, False]: self.debug_mode(True)
self.debug_mode(b)
logo = report_tags.logo_image() logo = report_tags.logo_image()
self.assertIn('inventree.png', logo) 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): def test_string_tags(self):
"""Simple tests for the string manipulation tags.""" """Simple tests for the string manipulation tags."""