mirror of
https://github.com/inventree/InvenTree.git
synced 2026-05-22 01:06:50 +00:00
Remove image download support (#11962)
* Remove image download support - Helper function remains (it is used in the supplier plugin mixin) - No longer available to user - Close massive security hole entirely - Will be defunct soon anyway (moving to generic attachments) * Update CHANGELOG.md * Bump API version * Fix for unit tests
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'.
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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') && (
|
||||
<ActionButton
|
||||
icon={
|
||||
<InvenTreeIcon
|
||||
icon='download'
|
||||
iconProps={{ color: 'white' }}
|
||||
/>
|
||||
}
|
||||
tooltip={t`Download remote image`}
|
||||
variant='outline'
|
||||
size='lg'
|
||||
tooltipAlignment='top'
|
||||
onClick={(event: any) => {
|
||||
cancelEvent(event);
|
||||
downloadImage();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{actions.uploadFile && (
|
||||
<ActionButton
|
||||
icon={
|
||||
@@ -439,21 +414,6 @@ export function DetailsImage(props: Readonly<DetailImageProps>) {
|
||||
|
||||
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<DetailImageProps>) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{downloadImage.modal}
|
||||
<Grid.Col span={{ base: 12, sm: 4 }}>
|
||||
<AspectRatio
|
||||
ref={ref}
|
||||
@@ -502,7 +461,6 @@ export function DetailsImage(props: Readonly<DetailImageProps>) {
|
||||
hasImage={!!props.src}
|
||||
pk={props.pk}
|
||||
setImage={setAndRefresh}
|
||||
downloadImage={downloadImage.open}
|
||||
/>
|
||||
</Overlay>
|
||||
)}
|
||||
|
||||
@@ -59,15 +59,7 @@ export default function SystemSettings() {
|
||||
'INVENTREE_RESTRICT_ABOUT',
|
||||
'DISPLAY_FULL_NAMES',
|
||||
'DISPLAY_PROFILE_INFO',
|
||||
'WEEK_STARTS_ON'
|
||||
]}
|
||||
/>
|
||||
<GlobalSettingList
|
||||
heading={t`Image Download Settings`}
|
||||
keys={[
|
||||
'INVENTREE_DOWNLOAD_FROM_URL',
|
||||
'INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE',
|
||||
'INVENTREE_DOWNLOAD_FROM_URL_USER_AGENT',
|
||||
'WEEK_STARTS_ON',
|
||||
'INVENTREE_STRICT_URLS'
|
||||
]}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user