From 6946abae1330e614b8cf19ef7e1e8b1aba6b165d Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 16 Mar 2021 16:42:33 +1100 Subject: [PATCH 01/14] CSS fix for modal error info dialog --- InvenTree/templates/modals.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/InvenTree/templates/modals.html b/InvenTree/templates/modals.html index 11166751f8..dea7a8bbfa 100644 --- a/InvenTree/templates/modals.html +++ b/InvenTree/templates/modals.html @@ -78,7 +78,9 @@ - \ No newline at end of file From 15678f789cb10f8b16331e75efed0287c14a14e3 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 17 Mar 2021 08:40:30 +1100 Subject: [PATCH 05/14] Add global setting to enable download of files / images from remote URL --- InvenTree/common/models.py | 7 +++++++ InvenTree/part/templates/part/part_base.html | 4 ++++ InvenTree/part/templates/part/part_thumb.html | 7 ++++++- InvenTree/templates/InvenTree/settings/global.html | 1 + 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 06c06bde05..73acb5113d 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -78,6 +78,13 @@ class InvenTreeSetting(models.Model): 'choices': djmoney.settings.CURRENCY_CHOICES, }, + 'INVENTREE_DOWNLOAD_FROM_URL': { + 'name': _('Download from URL'), + 'description': _('Allow download of remote images and files from external URL'), + 'validator': bool, + 'default': False, + }, + 'BARCODE_ENABLE': { 'name': _('Barcode Support'), 'description': _('Enable barcode scanner support'), diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index 58922e377c..6031ec69f8 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -296,11 +296,15 @@ } {% if roles.part.change %} + + {% settings_value "INVENTREE_DOWNLOAD_FROM_URL" as allow_download %} + {% if allow_download %} $("#part-image-url").click(function() { launchModalForm( '{% url "part-image-download" part.id %}', ); }); + {% endif %} $("#part-image-select").click(function() { launchModalForm("{% url 'part-image-select' part.id %}", diff --git a/InvenTree/part/templates/part/part_thumb.html b/InvenTree/part/templates/part/part_thumb.html index 5cef673bd7..9e1de6fc26 100644 --- a/InvenTree/part/templates/part/part_thumb.html +++ b/InvenTree/part/templates/part/part_thumb.html @@ -1,5 +1,8 @@ {% load static %} {% load i18n %} +{% load inventree_extras %} + +{% settings_value "INVENTREE_DOWNLOAD_FROM_URL" as allow_download %}
@@ -15,8 +18,10 @@
- + {% if allow_download %} + + {% endif %}
{% endif %} diff --git a/InvenTree/templates/InvenTree/settings/global.html b/InvenTree/templates/InvenTree/settings/global.html index d63593d866..c234bd1379 100644 --- a/InvenTree/templates/InvenTree/settings/global.html +++ b/InvenTree/templates/InvenTree/settings/global.html @@ -19,6 +19,7 @@ {% include "InvenTree/settings/setting.html" with key="INVENTREE_BASE_URL" icon="fa-globe" %} {% include "InvenTree/settings/setting.html" with key="INVENTREE_COMPANY_NAME" icon="fa-building" %} {% include "InvenTree/settings/setting.html" with key="INVENTREE_DEFAULT_CURRENCY" icon="fa-dollar-sign" %} + {% include "InvenTree/settings/setting.html" with key="INVENTREE_DOWNLOAD_FROM_URL" icon="fa-cloud-download-alt" %} From 5f19f534fce860be79e88a382ccfe4b7d2412759 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 17 Mar 2021 09:47:57 +1100 Subject: [PATCH 06/14] Catch error if invalid image is uploaded --- InvenTree/part/apps.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/InvenTree/part/apps.py b/InvenTree/part/apps.py index b1089cd57c..d08e7680fe 100644 --- a/InvenTree/part/apps.py +++ b/InvenTree/part/apps.py @@ -7,6 +7,7 @@ from django.db.utils import OperationalError, ProgrammingError from django.apps import AppConfig from django.conf import settings +from PIL import UnidentifiedImageError logger = logging.getLogger(__name__) @@ -44,9 +45,11 @@ class PartConfig(AppConfig): try: part.image.render_variations(replace=False) except FileNotFoundError: - logger.warning("Image file missing") + logger.warning(f"Image file '{part.image}' missing") part.image = None part.save() + except UnidentifiedImageError: + logger.warning(f"Image file '{part.image}' is invalid") except (OperationalError, ProgrammingError): # Exception if the database has not been migrated yet pass From 47a11435703ff9d36d4af6a73cee2c946d8c05ce Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 17 Mar 2021 11:55:51 +1100 Subject: [PATCH 07/14] Catch error when generating company thumbnail images --- InvenTree/company/apps.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/InvenTree/company/apps.py b/InvenTree/company/apps.py index 2777425ab4..3fa3197183 100644 --- a/InvenTree/company/apps.py +++ b/InvenTree/company/apps.py @@ -7,6 +7,7 @@ from django.apps import AppConfig from django.db.utils import OperationalError, ProgrammingError from django.conf import settings +from PIL import UnidentifiedImageError logger = logging.getLogger(__name__) @@ -38,9 +39,11 @@ class CompanyConfig(AppConfig): try: company.image.render_variations(replace=False) except FileNotFoundError: - logger.warning("Image file missing") + logger.warning(f"Image file '{company.image}' missing") company.image = None company.save() + except UnidentifiedImageError: + logger.warning(f"Image file '{company.image}' is invalid") except (OperationalError, ProgrammingError): # Getting here probably meant the database was in test mode pass From 9a710ca28f53cea35525e5ea1a5399016222bf2e Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 17 Mar 2021 23:02:32 +1100 Subject: [PATCH 08/14] Fix image download code --- InvenTree/part/templates/part/part_base.html | 3 ++ InvenTree/part/views.py | 51 +++++++++++--------- 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index 6031ec69f8..fdc5624741 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -302,6 +302,9 @@ $("#part-image-url").click(function() { launchModalForm( '{% url "part-image-download" part.id %}', + { + reload: true, + } ); }); {% endif %} diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index ec182920d5..7fe9f80216 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -5,7 +5,7 @@ Django views for interacting with Part app # -*- coding: utf-8 -*- from __future__ import unicode_literals -from django.core.files import File +from django.core.files.base import ContentFile from django.core.exceptions import ValidationError from django.db import transaction from django.db.utils import IntegrityError @@ -20,10 +20,13 @@ from django.conf import settings from moneyed import CURRENCIES +from PIL import Image + from urllib.parse import urlsplit from tempfile import TemporaryFile import requests import os +import io from rapidfuzz import fuzz from decimal import Decimal, InvalidOperation @@ -860,52 +863,52 @@ class PartImageDownloadFromURL(AjaxUpdateView): # We can now extract a valid URL from the form data url = form.cleaned_data.get('url', None) - response = requests.get(url, stream=True) - # Check that the URL "looks" like an image URL if not TestIfImageURL(url): form.add_error('url', _('Supplied URL is not one of the supported image formats')) return + # Download the file + response = requests.get(url, stream=True) + + self.response = response + # Check for valid response code if not response.status_code == 200: form.add_error('url', f"{_('Invalid response')}: {response.status_code}") return - # Validate that the returned object is a valid image file - if not TestIfImage(response.raw): - form.add_error('url', _('Supplied URL is not a valid image file')) - return + response.raw.decode_content = True - # Save the image object - self.image_response = response + try: + self.image = Image.open(response.raw).convert('RGB') + self.image.verify() + except: + form.add_error('url', _("Supplied URL is not a valid image file")) + return def save(self, part, form, **kwargs): """ Save the downloaded image to the part """ - - response = getattr(self, 'image_response', None) - if not response: - return + fmt = self.image.format - with TemporaryFile() as tf: + if not fmt: + fmt = 'PNG' - #for chunk in response.iter_content(chunk_size=2048): - # tf.write(chunk) + buffer = io.BytesIO() - img_data = response.raw.read() - tf.write(img_data) + self.image.save(buffer, format=fmt) - print("Saved to temp file:", tf.name) + # Construct a simplified name for the image + filename = f"part_{part.pk}_image.{fmt.lower()}" - tf.seek(0) + part.image.save( + filename, + ContentFile(buffer.getvalue()), + ) - part.image.save( - os.path.basename(urlsplit(response.url).path), - File(tf), - ) class PartImageUpload(AjaxUpdateView): """ View for uploading a new Part image """ From be30933bfae10390f34cbbb1fca88735b84c0b72 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 17 Mar 2021 23:06:56 +1100 Subject: [PATCH 09/14] Add custom form template --- .../part/templates/part/image_download.html | 16 ++++++++++++++++ InvenTree/part/views.py | 1 + 2 files changed, 17 insertions(+) create mode 100644 InvenTree/part/templates/part/image_download.html diff --git a/InvenTree/part/templates/part/image_download.html b/InvenTree/part/templates/part/image_download.html new file mode 100644 index 0000000000..1191886711 --- /dev/null +++ b/InvenTree/part/templates/part/image_download.html @@ -0,0 +1,16 @@ +{% extends "modal_form.html" %} + +{% load inventree_extras %} +{% load i18n %} + +{% block pre_form_content %} +
+ {% trans "Specify URL for downloading image" %}: + +
    +
  • {% trans "Must be a valid image URL" %}
  • +
  • {% trans "Remote server must be accessible" %}
  • +
  • {% trans "Remote image must not exceed maximum allowable file size" %}
  • +
+
+{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 7fe9f80216..a5b40124a6 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -846,6 +846,7 @@ class PartImageDownloadFromURL(AjaxUpdateView): model = Part + ajax_template_name = 'part/image_download.html' form_class = part_forms.PartImageDownloadForm ajax_form_title = _('Download Image') From 8b310d8e47542746ceda50f01555834b25f622c1 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 17 Mar 2021 23:11:38 +1100 Subject: [PATCH 10/14] Check length of response --- InvenTree/part/views.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index a5b40124a6..3e20a08714 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -872,6 +872,22 @@ class PartImageDownloadFromURL(AjaxUpdateView): # Download the file response = requests.get(url, stream=True) + # Look at response header, reject if too large + content_length = response.headers.get('Content-Length', '0') + + try: + content_length = int(content_length) + except (ValueError): + # If we cannot extract meaningful length, just assume it's "small enough" + content_length = 0 + + # TODO: Factor this out into a configurable setting + MAX_IMG_LENGTH = 10 * 1024 * 1024 + + if content_length > MAX_IMG_LENGTH: + form.add_error('url', _('Image size exceeds maximum allowable size for download')) + return + self.response = response # Check for valid response code From db4762986754b9d1064f86dd4f38e6b2a8532f9c Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 17 Mar 2021 23:15:48 +1100 Subject: [PATCH 11/14] Cleanup --- InvenTree/part/views.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 3e20a08714..3bee3eefa2 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -22,8 +22,6 @@ from moneyed import CURRENCIES from PIL import Image -from urllib.parse import urlsplit -from tempfile import TemporaryFile import requests import os import io @@ -54,7 +52,6 @@ from InvenTree.views import QRCodeView from InvenTree.views import InvenTreeRoleMixin from InvenTree.helpers import DownloadFile, str2bool -from InvenTree.helpers import TestIfImageURL, TestIfImage class PartIndex(InvenTreeRoleMixin, ListView): @@ -864,11 +861,6 @@ class PartImageDownloadFromURL(AjaxUpdateView): # We can now extract a valid URL from the form data url = form.cleaned_data.get('url', None) - # Check that the URL "looks" like an image URL - if not TestIfImageURL(url): - form.add_error('url', _('Supplied URL is not one of the supported image formats')) - return - # Download the file response = requests.get(url, stream=True) From e3a5a56371ab78a0f2a267a6ceefe5771b438b01 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 17 Mar 2021 23:44:47 +1100 Subject: [PATCH 12/14] Add "modal image" display for part thumbnails --- InvenTree/InvenTree/static/css/inventree.css | 47 ++++++++++++++++++++ InvenTree/part/templates/part/part_base.html | 6 +++ InvenTree/templates/js/modals.js | 39 ++++++++++++++++ InvenTree/templates/modals.html | 7 +++ 4 files changed, 99 insertions(+) diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index 3650aa5ddf..153931f974 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -626,6 +626,53 @@ z-index: 11000; } +.modal-close { + position: absolute; + top: 15px; + right: 35px; + color: #f1f1f1; + font-size: 40px; + font-weight: bold; + transition: 0.25s; +} + +.modal-close:hover, +.modal-close:focus { + color: #bbb; + text-decoration: none; + cursor: pointer; +} + +.modal-image-content { + margin: auto; + display: block; + width: 80%; + max-width: 700px; + text-align: center; + color: #ccc; + padding: 10px 0; +} + +@media only screen and (max-width: 700px){ + .modal-image-content { + width: 100%; + } +} + +.modal-image { + display: none; + position: fixed; + z-index: 10000; + padding-top: 100px; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgb(0,0,0); /* Fallback color */ + background-color: rgba(0,0,0,0.85); /* Black w/ opacity */ +} + .js-modal-form .checkbox { margin-left: 0px; } diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index fdc5624741..96c9636c88 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -206,6 +206,12 @@ toggleId: '#part-menu-toggle', }); + {% if part.image %} + $('#part-thumb').click(function() { + showModalImage('{{ part.image.url }}'); + }); + {% endif %} + enableDragAndDrop( '#part-thumb', "{% url 'part-image-upload' part.id %}", diff --git a/InvenTree/templates/js/modals.js b/InvenTree/templates/js/modals.js index 12639749f6..2c246d2a36 100644 --- a/InvenTree/templates/js/modals.js +++ b/InvenTree/templates/js/modals.js @@ -909,3 +909,42 @@ function launchModalForm(url, options = {}) { // Send the AJAX request $.ajax(ajax_data); } + + +function hideModalImage() { + + var modal = $('#modal-image-dialog'); + + modal.animate({ + opacity: 0.0, + }, 250, function() { + modal.hide(); + }); + +} + + +function showModalImage(image_url) { + // Display full-screen modal image + + console.log('showing modal image: ' + image_url); + + var modal = $('#modal-image-dialog'); + + // Set image content + $('#modal-image').attr('src', image_url); + + modal.show(); + + modal.animate({ + opacity: 1.0, + }, 250); + + $('#modal-image-close').click(function() { + hideModalImage(); + }); + + modal.click(function() { + hideModalImage(); + }); +} \ No newline at end of file diff --git a/InvenTree/templates/modals.html b/InvenTree/templates/modals.html index dea7a8bbfa..9850f482c5 100644 --- a/InvenTree/templates/modals.html +++ b/InvenTree/templates/modals.html @@ -1,5 +1,12 @@ {% load i18n %} + +