diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cf482cf7b..a8724e2ec4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed +- [#11962](https://github.com/inventree/InvenTree/pull/11962) removes the "remote_image" field from the Part API endpoint, which (previously) allowed the user to specify a remote URL for an image to be downloaded and associated with the part. This field was removed due to security concerns around downloading images from arbitrary URLs. If you were using this field in an external client application, you will need to update your application to use the new "download_image_from_url" API endpoint instead. + ## 1.3.0 - 2026-04-11 ### Breaking Changes diff --git a/docs/docs/settings/global.md b/docs/docs/settings/global.md index 5773e1f4cb..d936c62244 100644 --- a/docs/docs/settings/global.md +++ b/docs/docs/settings/global.md @@ -33,14 +33,6 @@ Configuration of basic server settings: {{ globalsetting("DISPLAY_FULL_NAMES") }} {{ globalsetting("DISPLAY_PROFILE_INFO") }} {{ globalsetting("WEEK_STARTS_ON") }} - -Configuration of image download settings: - -| Name | Description | Default | Units | -| ---- | ----------- | ------- | ----- | -{{ globalsetting("INVENTREE_DOWNLOAD_FROM_URL") }} -{{ globalsetting("INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE") }} -{{ globalsetting("INVENTREE_DOWNLOAD_FROM_URL_USER_AGENT") }} {{ globalsetting("INVENTREE_STRICT_URLS") }} Configuration of various scheduled tasks: diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index efed87c3a5..dc17ef357a 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,15 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 488 +INVENTREE_API_VERSION = 489 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v489 -> 2026-05-18 : https://github.com/inventree/InvenTree/pull/11962 + - Removes the "remote_image" field from the Part API endpoint + - Removes the "remote_image" field from the Company API endpoint + v488 -> 2026-05-17 : https://github.com/inventree/InvenTree/pull/11920 - Allow renaming of attachments after upload via the API diff --git a/src/backend/InvenTree/InvenTree/helpers_model.py b/src/backend/InvenTree/InvenTree/helpers_model.py index 455c8e000d..ccd1cfd963 100644 --- a/src/backend/InvenTree/InvenTree/helpers_model.py +++ b/src/backend/InvenTree/InvenTree/helpers_model.py @@ -120,7 +120,12 @@ def validate_url_no_ssrf(url): raise ValueError(_('URL points to a private or reserved IP address')) -def download_image_from_url(remote_url, timeout=2.5): +def download_image_from_url( + remote_url: str, + timeout: float = 2.5, + user_agent: str = '', + max_size: Optional[int] = None, +): """Download an image file from a remote URL. This is a potentially dangerous operation, so we must perform some checks: @@ -130,8 +135,9 @@ def download_image_from_url(remote_url, timeout=2.5): Arguments: remote_url: The remote URL to retrieve image - max_size: Maximum allowed image size (default = 1MB) timeout: Connection timeout in seconds (default = 5) + user_agent: User-Agent string to use for the request (optional) + max_size: Maximum allowed image size (in bytes) (default = 1MB) Returns: An in-memory PIL image file, if the download was successful @@ -151,13 +157,9 @@ def download_image_from_url(remote_url, timeout=2.5): validate_url_no_ssrf(remote_url) # Calculate maximum allowable image size (in bytes) - max_size = ( - int(get_global_setting('INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE')) * 1024 * 1024 - ) + max_size = max_size or 1 * 1024 * 1024 # Default to 1MB if not provided # Add user specified user-agent to request (if specified) - user_agent = get_global_setting('INVENTREE_DOWNLOAD_FROM_URL_USER_AGENT') - headers = {'User-Agent': user_agent} if user_agent else None try: diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py index 7e0216ef38..3df76e9e3c 100644 --- a/src/backend/InvenTree/InvenTree/serializers.py +++ b/src/backend/InvenTree/InvenTree/serializers.py @@ -26,7 +26,6 @@ from rest_framework.serializers import DecimalField, Serializer from rest_framework.utils import model_meta from taggit.serializers import TaggitSerializer -import common.models as common_models import InvenTree.ready from common.currency import currency_code_default, currency_code_mappings from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField @@ -785,51 +784,6 @@ class NotesFieldMixin: self.fields.pop('notes', None) -class RemoteImageMixin(metaclass=serializers.SerializerMetaclass): - """Mixin class which allows downloading an 'image' from a remote URL. - - Adds the optional, write-only `remote_image` field to the serializer - """ - - def skip_create_fields(self): - """Ensure the 'remote_image' field is skipped when creating a new instance.""" - return ['remote_image'] - - remote_image = serializers.URLField( - required=False, - allow_blank=True, - write_only=True, - label=_('Remote Image'), - help_text=_('URL of remote image file'), - ) - - def validate_remote_image(self, url): - """Perform custom validation for the remote image URL. - - - Attempt to download the image and store it against this object instance - - Catches and re-throws any errors - """ - from InvenTree.helpers_model import download_image_from_url - - if not url: - return - - if not common_models.InvenTreeSetting.get_setting( - 'INVENTREE_DOWNLOAD_FROM_URL' - ): - raise ValidationError( - _('Downloading images from remote URL is not enabled') - ) - - try: - self.remote_image_file = download_image_from_url(url) - except Exception: - self.remote_image_file = None - raise ValidationError(_('Failed to download image from remote URL')) - - return url - - class ContentTypeField(serializers.ChoiceField): """Serializer field which represents a ContentType as 'app_label.model_name'. diff --git a/src/backend/InvenTree/InvenTree/tests.py b/src/backend/InvenTree/InvenTree/tests.py index 5a303d264d..977834b450 100644 --- a/src/backend/InvenTree/InvenTree/tests.py +++ b/src/backend/InvenTree/InvenTree/tests.py @@ -738,21 +738,10 @@ class TestHelpers(TestCase): large_img = 'https://github.com/inventree/InvenTree/raw/master/src/backend/InvenTree/InvenTree/static/img/paper_splash_large.jpg' - InvenTreeSetting.set_setting( - 'INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE', 1, change_user=None - ) - - # Attempt to download an image which is too large - with self.assertRaises(ValueError): - InvenTree.helpers_model.download_image_from_url(large_img, timeout=10) - - # Increase allowable download size - InvenTreeSetting.set_setting( - 'INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE', 5, change_user=None - ) - # Download a valid image (should not throw an error) - InvenTree.helpers_model.download_image_from_url(large_img, timeout=10) + InvenTree.helpers_model.download_image_from_url( + large_img, timeout=10, max_size=10 * 1024 * 1024 + ) def test_model_mixin(self): """Test the getModelsWithMixin function.""" diff --git a/src/backend/InvenTree/common/setting/system.py b/src/backend/InvenTree/common/setting/system.py index 880464d971..8c6c15fea2 100644 --- a/src/backend/InvenTree/common/setting/system.py +++ b/src/backend/InvenTree/common/setting/system.py @@ -286,26 +286,6 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'choices': common.currency.currency_exchange_plugins, 'default': 'inventreecurrencyexchange', }, - 'INVENTREE_DOWNLOAD_FROM_URL': { - 'name': _('Download from URL'), - 'description': _('Allow download of remote images and files from external URL'), - 'validator': bool, - 'default': False, - }, - 'INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE': { - 'name': _('Download Size Limit'), - 'description': _('Maximum allowable download size for remote image'), - 'units': 'MB', - 'default': 1, - 'validator': [int, MinValueValidator(1), MaxValueValidator(25)], - }, - 'INVENTREE_DOWNLOAD_FROM_URL_USER_AGENT': { - 'name': _('User-agent used to download from URL'), - 'description': _( - 'Allow to override the user-agent used to download images and files from external URL (leave blank for the default)' - ), - 'default': '', - }, 'INVENTREE_STRICT_URLS': { 'name': _('Strict URL Validation'), 'description': _('Require schema specification when validating URLs'), diff --git a/src/backend/InvenTree/company/serializers.py b/src/backend/InvenTree/company/serializers.py index 616941eda5..0ff71a39bc 100644 --- a/src/backend/InvenTree/company/serializers.py +++ b/src/backend/InvenTree/company/serializers.py @@ -1,8 +1,5 @@ """JSON serializers for Company app.""" -import io - -from django.core.files.base import ContentFile from django.db.models import Prefetch from django.utils.translation import gettext_lazy as _ @@ -26,7 +23,6 @@ from InvenTree.serializers import ( InvenTreeTagModelSerializer, NotesFieldMixin, OptionalField, - RemoteImageMixin, ) from .models import ( @@ -113,7 +109,6 @@ class CompanySerializer( FilterableSerializerMixin, DataImportExportSerializerMixin, NotesFieldMixin, - RemoteImageMixin, InvenTreeModelSerializer, ): """Serializer for Company object (full detail).""" @@ -145,7 +140,6 @@ class CompanySerializer( 'notes', 'parts_supplied', 'parts_manufactured', - 'remote_image', 'primary_address', 'tax_id', 'parameters', @@ -193,27 +187,6 @@ class CompanySerializer( parameters = common.filters.enable_parameters_filter() - def save(self): - """Save the Company instance.""" - super().save() - - company = self.instance - - # Check if an image was downloaded from a remote URL - remote_img = getattr(self, 'remote_image_file', None) - - if remote_img and company: - fmt = remote_img.format or 'PNG' - buffer = io.BytesIO() - remote_img.save(buffer, format=fmt) - - # Construct a simplified name for the image - filename = f'company_{company.pk}_image.{fmt.lower()}' - - company.image.save(filename, ContentFile(buffer.getvalue())) - - return self.instance - @register_importer() class ContactSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer): diff --git a/src/backend/InvenTree/importer/tests.py b/src/backend/InvenTree/importer/tests.py index b4b7b99a6f..17490b9283 100644 --- a/src/backend/InvenTree/importer/tests.py +++ b/src/backend/InvenTree/importer/tests.py @@ -39,7 +39,7 @@ class ImporterTest(ImporterMixin, InvenTreeTestCase): session.extract_columns() - self.assertEqual(session.column_mappings.count(), 15) + self.assertEqual(session.column_mappings.count(), 14) # Check some of the field mappings for field, col in [ diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 3826f7407f..dbb5605a43 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -1,11 +1,9 @@ """DRF data serializers for Part app.""" -import io import os from decimal import Decimal from django.core.exceptions import ValidationError -from django.core.files.base import ContentFile from django.core.validators import MinValueValidator from django.db import models, transaction from django.db.models import ExpressionWrapper, F, Q @@ -559,7 +557,6 @@ class PartSerializer( InvenTree.serializers.FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTree.serializers.NotesFieldMixin, - InvenTree.serializers.RemoteImageMixin, InvenTree.serializers.InvenTreeTaggitSerializer, InvenTree.serializers.InvenTreeModelSerializer, ): @@ -592,7 +589,6 @@ class PartSerializer( 'description', 'full_name', 'image', - 'remote_image', 'existing_image', 'IPN', 'is_template', @@ -662,7 +658,7 @@ class PartSerializer( # These fields are only used for the LIST API endpoint for f in self.skip_create_fields(): # Fields required for certain operations, but are not part of the model - if f in ['remote_image', 'existing_image']: + if f in ['existing_image']: continue self.fields.pop(f, None) @@ -1108,6 +1104,7 @@ class PartSerializer( part = self.instance data = self.validated_data + # TODO: Remove the existing_image field entirely! existing_image = data.pop('existing_image', None) if existing_image: @@ -1116,19 +1113,6 @@ class PartSerializer( part.image = img_path part.save() - # Check if an image was downloaded from a remote URL - remote_img = getattr(self, 'remote_image_file', None) - - if remote_img and part: - fmt = remote_img.format or 'PNG' - buffer = io.BytesIO() - remote_img.save(buffer, format=fmt) - - # Construct a simplified name for the image - filename = f'part_{part.pk}_image.{fmt.lower()}' - - part.image.save(filename, ContentFile(buffer.getvalue())) - return self.instance diff --git a/src/frontend/src/components/details/DetailsImage.tsx b/src/frontend/src/components/details/DetailsImage.tsx index 044406e5c7..9941ee87de 100644 --- a/src/frontend/src/components/details/DetailsImage.tsx +++ b/src/frontend/src/components/details/DetailsImage.tsx @@ -29,8 +29,6 @@ import { showNotification } from '@mantine/notifications'; import { api } from '../../App'; import { InvenTreeIcon } from '../../functions/icons'; import { showApiErrorMessage } from '../../functions/notifications'; -import { useEditApiFormModal } from '../../hooks/UseForm'; -import { useGlobalSettingsState } from '../../states/SettingsStates'; import { useUserState } from '../../states/UserState'; import { PartThumbTable } from '../../tables/part/PartThumbTable'; import { vars } from '../../theme'; @@ -320,8 +318,7 @@ function ImageActionButtons({ apiPath, hasImage, pk, - setImage, - downloadImage + setImage }: Readonly<{ actions?: DetailImageButtonProps; visible: boolean; @@ -329,10 +326,7 @@ function ImageActionButtons({ hasImage: boolean; pk: string; setImage: (image: string) => void; - downloadImage: () => void; }>) { - const globalSettings = useGlobalSettingsState(); - return ( <> {visible && ( @@ -363,25 +357,6 @@ function ImageActionButtons({ }} /> )} - {actions.downloadImage && - globalSettings.isSet('INVENTREE_DOWNLOAD_FROM_URL') && ( - - } - tooltip={t`Download remote image`} - variant='outline' - size='lg' - tooltipAlignment='top' - onClick={(event: any) => { - cancelEvent(event); - downloadImage(); - }} - /> - )} {actions.uploadFile && ( ) { const permissions = useUserState(); - const downloadImage = useEditApiFormModal({ - url: props.apiPath, - title: t`Download Image`, - fields: { - remote_image: {} - }, - timeout: 10000, - successMessage: t`Image downloaded successfully`, - onFormSuccess: (response: any) => { - if (response.image) { - setAndRefresh(response.image); - } - } - }); - const hasOverlay: boolean = useMemo(() => { return ( props.imageActions?.selectExisting || @@ -473,7 +433,6 @@ export function DetailsImage(props: Readonly) { return ( <> - {downloadImage.modal} ) { hasImage={!!props.src} pk={props.pk} setImage={setAndRefresh} - downloadImage={downloadImage.open} /> )} diff --git a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx index 042d5e1f40..c3720a49ea 100644 --- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx +++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx @@ -59,15 +59,7 @@ export default function SystemSettings() { 'INVENTREE_RESTRICT_ABOUT', 'DISPLAY_FULL_NAMES', 'DISPLAY_PROFILE_INFO', - 'WEEK_STARTS_ON' - ]} - /> -