Skip to content

Commit 2d6b3d1

Browse files
authored
Fixes #20236: Improve file naming and upload handling (#20315)
1 parent 103939a commit 2d6b3d1

File tree

4 files changed

+273
-22
lines changed

4 files changed

+273
-22
lines changed

netbox/extras/models/models.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import json
2-
import os
32
import urllib.parse
3+
from pathlib import Path
44

55
from django.conf import settings
66
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
@@ -728,7 +728,9 @@ def delete(self, *args, **kwargs):
728728

729729
@property
730730
def filename(self):
731-
return os.path.basename(self.image.name).split('_', 2)[2]
731+
base_name = Path(self.image.name).name
732+
prefix = f"{self.object_type.model}_{self.object_id}_"
733+
return base_name.removeprefix(prefix)
732734

733735
@property
734736
def html_tag(self):

netbox/extras/tests/test_models.py

Lines changed: 85 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,95 @@
11
import tempfile
22
from pathlib import Path
33

4+
from django.contrib.contenttypes.models import ContentType
5+
from django.core.files.uploadedfile import SimpleUploadedFile
46
from django.forms import ValidationError
57
from django.test import tag, TestCase
68

79
from core.models import DataSource, ObjectType
810
from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
9-
from extras.models import ConfigContext, ConfigContextProfile, ConfigTemplate, Tag
11+
from extras.models import ConfigContext, ConfigContextProfile, ConfigTemplate, ImageAttachment, Tag
1012
from tenancy.models import Tenant, TenantGroup
1113
from utilities.exceptions import AbortRequest
1214
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
1315

1416

17+
class ImageAttachmentTests(TestCase):
18+
@classmethod
19+
def setUpTestData(cls):
20+
cls.ct_rack = ContentType.objects.get(app_label='dcim', model='rack')
21+
cls.image_content = b''
22+
23+
def _stub_image_attachment(self, object_id, image_filename, name=None):
24+
"""
25+
Creates an instance of ImageAttachment with the provided object_id and image_name.
26+
27+
This method prepares a stubbed image attachment to test functionalities that
28+
require an ImageAttachment object.
29+
The function initializes the attachment with a specified file name and
30+
pre-defined image content.
31+
"""
32+
ia = ImageAttachment(
33+
object_type=self.ct_rack,
34+
object_id=object_id,
35+
name=name,
36+
image=SimpleUploadedFile(
37+
name=image_filename,
38+
content=self.image_content,
39+
content_type='image/jpeg',
40+
),
41+
)
42+
return ia
43+
44+
def test_filename_strips_expected_prefix(self):
45+
"""
46+
Tests that the filename of the image attachment is stripped of the expected
47+
prefix.
48+
"""
49+
ia = self._stub_image_attachment(12, 'image-attachments/rack_12_My_File.png')
50+
self.assertEqual(ia.filename, 'My_File.png')
51+
52+
def test_filename_legacy_nested_path_returns_basename(self):
53+
"""
54+
Tests if the filename of a legacy-nested path correctly returns only the basename.
55+
"""
56+
# e.g. "image-attachments/rack_12_5/31/23.jpg" -> "23.jpg"
57+
ia = self._stub_image_attachment(12, 'image-attachments/rack_12_5/31/23.jpg')
58+
self.assertEqual(ia.filename, '23.jpg')
59+
60+
def test_filename_no_prefix_returns_basename(self):
61+
"""
62+
Tests that the filename property correctly returns the basename for an image
63+
attachment that has no leading prefix in its path.
64+
"""
65+
ia = self._stub_image_attachment(42, 'image-attachments/just_name.webp')
66+
self.assertEqual(ia.filename, 'just_name.webp')
67+
68+
def test_mismatched_prefix_is_not_stripped(self):
69+
"""
70+
Tests that a mismatched prefix in the filename is not stripped.
71+
"""
72+
# Prefix does not match object_id -> leave as-is (basename only)
73+
ia = self._stub_image_attachment(12, 'image-attachments/rack_13_other.png')
74+
self.assertEqual('rack_13_other.png', ia.filename)
75+
76+
def test_str_uses_name_when_present(self):
77+
"""
78+
Tests that the `str` representation of the object uses the
79+
`name` attribute when provided.
80+
"""
81+
ia = self._stub_image_attachment(12, 'image-attachments/rack_12_file.png', name='Human title')
82+
self.assertEqual('Human title', str(ia))
83+
84+
def test_str_falls_back_to_filename(self):
85+
"""
86+
Tests that the `str` representation of the object falls back to
87+
the filename if the name attribute is not set.
88+
"""
89+
ia = self._stub_image_attachment(12, 'image-attachments/rack_12_file.png', name='')
90+
self.assertEqual('file.png', str(ia))
91+
92+
1593
class TagTest(TestCase):
1694

1795
def test_default_ordering_weight_then_name_is_set(self):
@@ -445,7 +523,7 @@ def test_virtualmachine_site_context(self):
445523
vm1 = VirtualMachine.objects.create(name="VM 1", site=site, role=vm_role)
446524
vm2 = VirtualMachine.objects.create(name="VM 2", cluster=cluster, role=vm_role)
447525

448-
# Check that their individually-rendered config contexts are identical
526+
# Check that their individually rendered config contexts are identical
449527
self.assertEqual(
450528
vm1.get_config_context(),
451529
vm2.get_config_context()
@@ -462,7 +540,7 @@ def test_multiple_tags_return_distinct_objects(self):
462540
"""
463541
Tagged items use a generic relationship, which results in duplicate rows being returned when queried.
464542
This is combated by appending distinct() to the config context querysets. This test creates a config
465-
context assigned to two tags and ensures objects related by those same two tags result in only a single
543+
context assigned to two tags and ensures objects related to those same two tags result in only a single
466544
config context record being returned.
467545
468546
See https://github.com/netbox-community/netbox/issues/5314
@@ -495,14 +573,14 @@ def test_multiple_tags_return_distinct_objects(self):
495573
self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 1)
496574
self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
497575

498-
def test_multiple_tags_return_distinct_objects_with_seperate_config_contexts(self):
576+
def test_multiple_tags_return_distinct_objects_with_separate_config_contexts(self):
499577
"""
500578
Tagged items use a generic relationship, which results in duplicate rows being returned when queried.
501-
This is combatted by by appending distinct() to the config context querysets. This test creates a config
502-
context assigned to two tags and ensures objects related by those same two tags result in only a single
579+
This is combated by appending distinct() to the config context querysets. This test creates a config
580+
context assigned to two tags and ensures objects related to those same two tags result in only a single
503581
config context record being returned.
504582
505-
This test case is seperate from the above in that it deals with multiple config context objects in play.
583+
This test case is separate from the above in that it deals with multiple config context objects in play.
506584
507585
See https://github.com/netbox-community/netbox/issues/5387
508586
"""

netbox/extras/tests/test_utils.py

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
from types import SimpleNamespace
2+
3+
from django.contrib.contenttypes.models import ContentType
14
from django.test import TestCase
25

36
from extras.models import ExportTemplate
4-
from extras.utils import filename_from_model
7+
from extras.utils import filename_from_model, image_upload
58
from tenancy.models import ContactGroup, TenantGroup
69
from wireless.models import WirelessLANGroup
710

@@ -17,3 +20,141 @@ def test_expected_output(self):
1720

1821
for model, expected in cases:
1922
self.assertEqual(filename_from_model(model), expected)
23+
24+
25+
class ImageUploadTests(TestCase):
26+
@classmethod
27+
def setUpTestData(cls):
28+
# We only need a ContentType with model="rack" for the prefix;
29+
# this doesn't require creating a Rack object.
30+
cls.ct_rack = ContentType.objects.get(app_label='dcim', model='rack')
31+
32+
def _stub_instance(self, object_id=12, name=None):
33+
"""
34+
Creates a minimal stub for use with the `image_upload()` function.
35+
36+
This method generates an instance of `SimpleNamespace` containing a set
37+
of attributes required to simulate the expected input for the
38+
`image_upload()` method.
39+
It is designed to simplify testing or processing by providing a
40+
lightweight representation of an object.
41+
"""
42+
return SimpleNamespace(object_type=self.ct_rack, object_id=object_id, name=name)
43+
44+
def _second_segment(self, path: str):
45+
"""
46+
Extracts and returns the portion of the input string after the
47+
first '/' character.
48+
"""
49+
return path.split('/', 1)[1]
50+
51+
def test_windows_fake_path_and_extension_lowercased(self):
52+
"""
53+
Tests handling of a Windows file path with a fake directory and extension.
54+
"""
55+
inst = self._stub_instance(name=None)
56+
path = image_upload(inst, r'C:\fake_path\MyPhoto.JPG')
57+
# Base directory and single-level path
58+
seg2 = self._second_segment(path)
59+
self.assertTrue(path.startswith('image-attachments/rack_12_'))
60+
self.assertNotIn('/', seg2, 'should not create nested directories')
61+
# Extension from the uploaded file, lowercased
62+
self.assertTrue(seg2.endswith('.jpg'))
63+
64+
def test_name_with_slashes_is_flattened_no_subdirectories(self):
65+
"""
66+
Tests that a name with slashes is flattened and does not
67+
create subdirectories.
68+
"""
69+
inst = self._stub_instance(name='5/31/23')
70+
path = image_upload(inst, 'image.png')
71+
seg2 = self._second_segment(path)
72+
self.assertTrue(seg2.startswith('rack_12_'))
73+
self.assertNotIn('/', seg2)
74+
self.assertNotIn('\\', seg2)
75+
self.assertTrue(seg2.endswith('.png'))
76+
77+
def test_name_with_backslashes_is_flattened_no_subdirectories(self):
78+
"""
79+
Tests that a name including backslashes is correctly flattened
80+
into a single directory name without creating subdirectories.
81+
"""
82+
inst = self._stub_instance(name=r'5\31\23')
83+
path = image_upload(inst, 'image_name.png')
84+
85+
seg2 = self._second_segment(path)
86+
self.assertTrue(seg2.startswith('rack_12_'))
87+
self.assertNotIn('/', seg2)
88+
self.assertNotIn('\\', seg2)
89+
self.assertTrue(seg2.endswith('.png'))
90+
91+
def test_prefix_format_is_as_expected(self):
92+
"""
93+
Tests the output path format generated by the `image_upload` function.
94+
"""
95+
inst = self._stub_instance(object_id=99, name='label')
96+
path = image_upload(inst, 'a.webp')
97+
# The second segment must begin with "rack_99_"
98+
seg2 = self._second_segment(path)
99+
self.assertTrue(seg2.startswith('rack_99_'))
100+
self.assertTrue(seg2.endswith('.webp'))
101+
102+
def test_unsupported_file_extension(self):
103+
"""
104+
Test that when the file extension is not allowed, the extension
105+
is omitted.
106+
"""
107+
inst = self._stub_instance(name='test')
108+
path = image_upload(inst, 'document.txt')
109+
110+
seg2 = self._second_segment(path)
111+
self.assertTrue(seg2.startswith('rack_12_test'))
112+
self.assertFalse(seg2.endswith('.txt'))
113+
# When not allowed, no extension should be appended
114+
self.assertNotRegex(seg2, r'\.txt$')
115+
116+
def test_instance_name_with_whitespace_and_special_chars(self):
117+
"""
118+
Test that an instance name with leading/trailing whitespace and
119+
special characters is sanitized properly.
120+
"""
121+
# Suppose the instance name has surrounding whitespace and
122+
# extra slashes.
123+
inst = self._stub_instance(name=' my/complex\\name ')
124+
path = image_upload(inst, 'irrelevant.png')
125+
126+
# The output should be flattened and sanitized.
127+
# We expect the name to be transformed into a valid filename without
128+
# path separators.
129+
seg2 = self._second_segment(path)
130+
self.assertNotIn(' ', seg2)
131+
self.assertNotIn('/', seg2)
132+
self.assertNotIn('\\', seg2)
133+
self.assertTrue(seg2.endswith('.png'))
134+
135+
def test_separator_variants_with_subTest(self):
136+
"""
137+
Tests that both forward slash and backslash in file paths are
138+
handled consistently by the `image_upload` function and
139+
processed into a sanitized uniform format.
140+
"""
141+
for name in ['2025/09/12', r'2025\09\12']:
142+
with self.subTest(name=name):
143+
inst = self._stub_instance(name=name)
144+
path = image_upload(inst, 'x.jpeg')
145+
seg2 = self._second_segment(path)
146+
self.assertTrue(seg2.startswith('rack_12_'))
147+
self.assertNotIn('/', seg2)
148+
self.assertNotIn('\\', seg2)
149+
self.assertTrue(seg2.endswith('.jpeg') or seg2.endswith('.jpg'))
150+
151+
def test_fallback_on_suspicious_file_operation(self):
152+
"""
153+
Test that when default_storage.get_valid_name() raises a
154+
SuspiciousFileOperation, the fallback default is used.
155+
"""
156+
inst = self._stub_instance(name=' ')
157+
path = image_upload(inst, 'sample.png')
158+
# Expect the fallback name 'unnamed' to be used.
159+
self.assertIn('unnamed', path)
160+
self.assertTrue(path.startswith('image-attachments/rack_12_'))

0 commit comments

Comments
 (0)