Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Single table for file attachments #7420

Merged
merged 96 commits into from
Jun 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
96 commits
Select commit Hold shift + click to select a range
0bb25b2
Add basic model for handling generic attachments
SchrodingersGat Jun 4, 2024
3fb7d18
Refactor migration
SchrodingersGat Jun 8, 2024
ae5c38c
Data migration to convert old files across
SchrodingersGat Jun 8, 2024
79d5bc1
Admin updates
SchrodingersGat Jun 8, 2024
9e34b67
Merge remote-tracking branch 'origin/master' into attachments-refactor
SchrodingersGat Jun 8, 2024
5250508
Increase comment field max_length
SchrodingersGat Jun 9, 2024
8031a49
Adjust field name
SchrodingersGat Jun 9, 2024
e50ab6e
Remove legacy serializer classes / endpoints
SchrodingersGat Jun 9, 2024
a4f4280
Expose new model to API
SchrodingersGat Jun 9, 2024
4ca4638
Admin site list filters
SchrodingersGat Jun 9, 2024
66dcee4
Remove legacy attachment models
SchrodingersGat Jun 9, 2024
3cc7dd0
Update data migration
SchrodingersGat Jun 9, 2024
b589ef0
Add migrations to remove legacy attachment tables
SchrodingersGat Jun 9, 2024
353f1dc
Fix for "rename_attachment" callback
SchrodingersGat Jun 9, 2024
04e978e
Refactor model_type field
SchrodingersGat Jun 9, 2024
1f6d4b0
Set allowed options for admin
SchrodingersGat Jun 9, 2024
def177d
Update model verbose names
SchrodingersGat Jun 9, 2024
ee59ca3
Fix logic for file upload
SchrodingersGat Jun 9, 2024
6c8f36b
Add choices for serializer
SchrodingersGat Jun 9, 2024
f390780
Add API filtering
SchrodingersGat Jun 9, 2024
8f3414f
Fix for API filter
SchrodingersGat Jun 9, 2024
5afc259
Fix for attachment tables in PUI
SchrodingersGat Jun 9, 2024
548dd0d
Bump API version
SchrodingersGat Jun 9, 2024
78da986
Record user when uploading attachment via API
SchrodingersGat Jun 9, 2024
0316241
Refactor <AttachmentTable /> for PUI
SchrodingersGat Jun 9, 2024
5ebb9dd
Display 'file_size' in PUI attachment table
SchrodingersGat Jun 9, 2024
b6f5398
Fix company migrations
SchrodingersGat Jun 9, 2024
d193945
Include permission informtion in roles API endpoint
SchrodingersGat Jun 9, 2024
bf82144
Read user permissions in PUI
SchrodingersGat Jun 9, 2024
a3460c3
Simplify permission checks for <AttachmentTable />
SchrodingersGat Jun 9, 2024
8127ba2
Automatically clean up old content types
SchrodingersGat Jun 9, 2024
daa5249
Cleanup PUI
SchrodingersGat Jun 9, 2024
6bdf372
Fix typo in data migration
SchrodingersGat Jun 9, 2024
7c4c004
Add reverse data migration
SchrodingersGat Jun 9, 2024
3b8aec5
Merge branch 'master' into attachments-refactor
SchrodingersGat Jun 9, 2024
35e6aad
Update unit tests
SchrodingersGat Jun 9, 2024
a426162
Use InMemoryStorage for media files in test mode
SchrodingersGat Jun 10, 2024
98f8c77
Data migration unit test
SchrodingersGat Jun 10, 2024
d2c0eae
Fix "model_type" field
SchrodingersGat Jun 10, 2024
9f09512
Add permission check for serializer
SchrodingersGat Jun 10, 2024
add38f4
Fix permission check for CUI
SchrodingersGat Jun 10, 2024
ddbffd8
Merge branch 'master' into attachments-refactor
SchrodingersGat Jun 10, 2024
241ec4d
Fix PUI import
SchrodingersGat Jun 10, 2024
a0cccaf
Test python lib against specific branch
SchrodingersGat Jun 10, 2024
471f17a
Revert STORAGES setting
SchrodingersGat Jun 10, 2024
0c27705
Fix part unit test
SchrodingersGat Jun 11, 2024
af79d26
Fix unit test for sales order
SchrodingersGat Jun 11, 2024
8e593fa
Merge branch 'master' into attachments-refactor
SchrodingersGat Jun 13, 2024
89336fe
Merge remote-tracking branch 'origin/master' into attachments-refactor
SchrodingersGat Jun 14, 2024
6b748fe
Use 'get_global_setting'
SchrodingersGat Jun 14, 2024
a587c85
Use 'get_global_setting'
SchrodingersGat Jun 14, 2024
4803751
Update setting getter
SchrodingersGat Jun 14, 2024
1788f96
Unit tests
SchrodingersGat Jun 14, 2024
a69713a
Tweaks
SchrodingersGat Jun 14, 2024
2389d70
Revert change to settings.py
SchrodingersGat Jun 14, 2024
5a6f9f5
More updates for get_global_setting
SchrodingersGat Jun 14, 2024
ac1acb0
Merge branch 'master' into attachments-refactor
SchrodingersGat Jun 14, 2024
0d73fbb
Relax API query count requirement
SchrodingersGat Jun 15, 2024
9064e49
Merge branch 'attachments-refactor' of github.com:SchrodingersGat/Inv…
SchrodingersGat Jun 15, 2024
5517b34
remove illegal chars and add unit tests
SchrodingersGat Jun 15, 2024
2decad3
Fix unit tests
SchrodingersGat Jun 15, 2024
80a93ea
Fix frontend unit tests
SchrodingersGat Jun 15, 2024
1c615a0
settings management updates
SchrodingersGat Jun 15, 2024
eb2716e
Prevent db write under more conditions
SchrodingersGat Jun 15, 2024
a46374f
Simplify settings code
SchrodingersGat Jun 15, 2024
f28f24a
Pop values before creating filters
SchrodingersGat Jun 15, 2024
c621b94
Prevent settings write under certain conditions
SchrodingersGat Jun 16, 2024
a89c2dc
Add debug msg
SchrodingersGat Jun 16, 2024
e882bf4
Clear db on record import
SchrodingersGat Jun 16, 2024
c8fb905
Refactor permissions checks
SchrodingersGat Jun 16, 2024
e3f34f6
Unit test updates
SchrodingersGat Jun 16, 2024
1c73b70
Merge branch 'master' into attachments-refactor
SchrodingersGat Jun 16, 2024
bc4d219
Prevent delete of attachment without correct permissions
SchrodingersGat Jun 16, 2024
80098cc
Adjust odcker.yaml
SchrodingersGat Jun 16, 2024
b833da1
Cleanup data migrations
SchrodingersGat Jun 16, 2024
4e30078
Tweak migration tests for build app
SchrodingersGat Jun 16, 2024
e3f22ef
Merge branch 'master' into attachments-refactor
SchrodingersGat Jun 16, 2024
5450e73
Update data migration
SchrodingersGat Jun 16, 2024
c6c06d6
Prevent debug shell in TESTING mode
SchrodingersGat Jun 17, 2024
f93d898
Merge branch 'master' into attachments-refactor
SchrodingersGat Jun 17, 2024
da258d9
Update migration dependencies
SchrodingersGat Jun 17, 2024
e164cd3
add file size test
SchrodingersGat Jun 17, 2024
80f06ad
Update migration tests
SchrodingersGat Jun 17, 2024
e09394d
Merge branch 'master' into attachments-refactor
SchrodingersGat Jun 17, 2024
0f867dd
Revert some settings caching changes
SchrodingersGat Jun 17, 2024
e1c424c
Fix incorrect logic in migration
SchrodingersGat Jun 17, 2024
b4767e9
Update unit tests
SchrodingersGat Jun 17, 2024
3d31689
Merge branch 'attachments-refactor' of github.com:SchrodingersGat/Inv…
SchrodingersGat Jun 17, 2024
b713d3d
Prevent create on CURRENCY_CODES
SchrodingersGat Jun 18, 2024
28d5ade
Fix unit test
SchrodingersGat Jun 18, 2024
a17caa6
Some refactoring
SchrodingersGat Jun 18, 2024
18e86ca
Fix typo
SchrodingersGat Jun 18, 2024
7dc5403
Revert change
SchrodingersGat Jun 18, 2024
90bad53
Add "tags" and "metadata"
SchrodingersGat Jun 19, 2024
fda2381
Include "tags" field in API serializer
SchrodingersGat Jun 19, 2024
c94d901
add "metadata" endpoint for attachments
SchrodingersGat Jun 19, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/actions/migration/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,5 @@ runs:
invoke export-records -f data.json
python3 ./src/backend/InvenTree/manage.py flush --noinput
invoke migrate
invoke import-records -f data.json
invoke import-records -f data.json
invoke import-records -c -f data.json
invoke import-records -c -f data.json
7 changes: 4 additions & 3 deletions .github/workflows/docker.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,10 @@ jobs:
- name: Run Unit Tests
run: |
echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> contrib/container/docker.dev.env
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run inventree-dev-server invoke test --disable-pty
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run inventree-dev-server invoke test --migrations --disable-pty
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml down
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run --rm inventree-dev-server invoke test --disable-pty
- name: Run Migration Tests
run: |
docker compose --project-directory . -f contrib/container/dev-docker-compose.yml run --rm inventree-dev-server invoke test --migrations
- name: Clean up test folder
run: |
rm -rf InvenTree/_testfolder
Expand Down
16 changes: 0 additions & 16 deletions src/backend/InvenTree/InvenTree/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,22 +419,6 @@ def download_queryset(self, queryset, export_format):
raise NotImplementedError('download_queryset method not implemented!')


class AttachmentMixin:
"""Mixin for creating attachment objects, and ensuring the user information is saved correctly."""

permission_classes = [permissions.IsAuthenticated, RolePermission]

filter_backends = SEARCH_ORDER_FILTER

search_fields = ['attachment', 'comment', 'link']

def perform_create(self, serializer):
"""Save the user information when a file is uploaded."""
attachment = serializer.save()
attachment.user = self.request.user
attachment.save()


class APISearchViewSerializer(serializers.Serializer):
"""Serializer for the APISearchView."""

Expand Down
7 changes: 6 additions & 1 deletion src/backend/InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
"""InvenTree API version information."""

# InvenTree API version
INVENTREE_API_VERSION = 206
INVENTREE_API_VERSION = 207

"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""

INVENTREE_API_TEXT = """
v207 - 2024-06-09 : https://github.com/inventree/InvenTree/pull/7420
- Moves all "Attachment" models into a single table
- All "Attachment" operations are now performed at /api/attachment/
- Add permissions information to /api/user/roles/ endpoint

v206 - 2024-06-08 : https://github.com/inventree/InvenTree/pull/7417
- Adds "choices" field to the PartTestTemplate model

Expand Down
4 changes: 2 additions & 2 deletions src/backend/InvenTree/InvenTree/exchange.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from djmoney.contrib.exchange.models import ExchangeBackend, Rate

from common.currency import currency_code_default, currency_codes
from common.settings import get_global_setting

logger = logging.getLogger('inventree')

Expand All @@ -22,14 +23,13 @@ class InvenTreeExchange(SimpleExchangeBackend):

def get_rates(self, **kwargs) -> dict:
"""Set the requested currency codes and get rates."""
from common.models import InvenTreeSetting
from plugin import registry

base_currency = kwargs.get('base_currency', currency_code_default())
symbols = kwargs.get('symbols', currency_codes())

# Find the selected exchange rate plugin
slug = InvenTreeSetting.get_setting('CURRENCY_UPDATE_PLUGIN', '', create=False)
slug = get_global_setting('CURRENCY_UPDATE_PLUGIN', create=False)

if slug:
plugin = registry.get_plugin(slug)
Expand Down
2 changes: 1 addition & 1 deletion src/backend/InvenTree/InvenTree/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def __init__(self, **kwargs):

def run_validation(self, data=empty):
"""Override default validation behaviour for this field type."""
strict_urls = get_global_setting('INVENTREE_STRICT_URLS', True, cache=False)
strict_urls = get_global_setting('INVENTREE_STRICT_URLS', cache=False)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: Why is this changed?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default value filters through from the definition of the setting itself, and does not need to be passed here.


if not strict_urls and data is not empty and '://' not in data:
# Validate as if there were a schema provided
Expand Down
5 changes: 2 additions & 3 deletions src/backend/InvenTree/InvenTree/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from allauth_2fa.middleware import AllauthTwoFactorMiddleware, BaseRequire2FAMiddleware
from error_report.middleware import ExceptionProcessor

from common.settings import get_global_setting
from InvenTree.urls import frontendpatterns
from users.models import ApiToken

Expand Down Expand Up @@ -153,11 +154,9 @@ class Check2FAMiddleware(BaseRequire2FAMiddleware):

def require_2fa(self, request):
"""Use setting to check if MFA should be enforced for frontend page."""
from common.models import InvenTreeSetting

try:
if url_matcher.resolve(request.path[1:]):
return InvenTreeSetting.get_setting('LOGIN_ENFORCE_MFA')
return get_global_setting('LOGIN_ENFORCE_MFA')
except Resolver404:
pass
return False
Expand Down
223 changes: 41 additions & 182 deletions src/backend/InvenTree/InvenTree/models.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
"""Generic models which provide extra functionality over base Django model types."""

import logging
import os
from datetime import datetime
from io import BytesIO

from django.conf import settings
from django.contrib.auth import get_user_model
Expand All @@ -20,11 +18,11 @@
from mptt.exceptions import InvalidMove
from mptt.models import MPTTModel, TreeForeignKey

import common.settings
import InvenTree.fields
import InvenTree.format
import InvenTree.helpers
import InvenTree.helpers_model
from InvenTree.sanitizer import sanitize_svg

logger = logging.getLogger('inventree')

Expand Down Expand Up @@ -304,10 +302,7 @@ def get_reference_pattern(cls):
if cls.REFERENCE_PATTERN_SETTING is None:
return ''

# import at function level to prevent cyclic imports
from common.models import InvenTreeSetting

return InvenTreeSetting.get_setting(
return common.settings.get_global_setting(
cls.REFERENCE_PATTERN_SETTING, create=False
).strip()

Expand Down Expand Up @@ -503,200 +498,64 @@ class Meta:
abstract = True


def rename_attachment(instance, filename):
"""Function for renaming an attachment file. The subdirectory for the uploaded file is determined by the implementing class.

Args:
instance: Instance of a PartAttachment object
filename: name of uploaded file

Returns:
path to store file, format: '<subdir>/<id>/filename'
"""
# Construct a path to store a file attachment for a given model type
return os.path.join(instance.getSubdir(), filename)


class InvenTreeAttachment(InvenTreeModel):
class InvenTreeAttachmentMixin:
"""Provides an abstracted class for managing file attachments.

An attachment can be either an uploaded file, or an external URL
Links the implementing model to the common.models.Attachment table,
and provides the following methods:

Attributes:
attachment: Upload file
link: External URL
comment: String descriptor for the attachment
user: User associated with file upload
upload_date: Date the file was uploaded
- attachments: Return a queryset containing all attachments for this model
"""

class Meta:
"""Metaclass options. Abstract ensures no database table is created."""

abstract = True

def getSubdir(self):
"""Return the subdirectory under which attachments should be stored.
def delete(self):
"""Handle the deletion of a model instance.

Note: Re-implement this for each subclass of InvenTreeAttachment
Before deleting the model instance, delete any associated attachments.
"""
return 'attachments'

def save(self, *args, **kwargs):
"""Provide better validation error."""
# Either 'attachment' or 'link' must be specified!
if not self.attachment and not self.link:
raise ValidationError({
'attachment': _('Missing file'),
'link': _('Missing external link'),
})

if self.attachment and self.attachment.name.lower().endswith('.svg'):
self.attachment.file.file = self.clean_svg(self.attachment)

super().save(*args, **kwargs)

def clean_svg(self, field):
"""Sanitize SVG file before saving."""
cleaned = sanitize_svg(field.file.read())
return BytesIO(bytes(cleaned, 'utf8'))

def __str__(self):
"""Human name for attachment."""
if self.attachment is not None:
return os.path.basename(self.attachment.name)
return str(self.link)

attachment = models.FileField(
upload_to=rename_attachment,
verbose_name=_('Attachment'),
help_text=_('Select file to attach'),
blank=True,
null=True,
)

link = InvenTree.fields.InvenTreeURLField(
blank=True,
null=True,
verbose_name=_('Link'),
help_text=_('Link to external URL'),
)

comment = models.CharField(
blank=True,
max_length=100,
verbose_name=_('Comment'),
help_text=_('File comment'),
)

user = models.ForeignKey(
User,
on_delete=models.SET_NULL,
blank=True,
null=True,
verbose_name=_('User'),
help_text=_('User'),
)

upload_date = models.DateField(
auto_now_add=True, null=True, blank=True, verbose_name=_('upload date')
)
self.attachments.all().delete()
super().delete()

@property
def basename(self):
"""Base name/path for attachment."""
if self.attachment:
return os.path.basename(self.attachment.name)
return None

@basename.setter
def basename(self, fn):
"""Function to rename the attachment file.

- Filename cannot be empty
- Filename cannot contain illegal characters
- Filename must specify an extension
- Filename cannot match an existing file
"""
fn = fn.strip()

if len(fn) == 0:
raise ValidationError(_('Filename must not be empty'))
def attachments(self):
"""Return a queryset containing all attachments for this model."""
return self.attachments_for_model().filter(model_id=self.pk)

attachment_dir = settings.MEDIA_ROOT.joinpath(self.getSubdir())
old_file = settings.MEDIA_ROOT.joinpath(self.attachment.name)
new_file = settings.MEDIA_ROOT.joinpath(self.getSubdir(), fn).resolve()
@classmethod
def check_attachment_permission(cls, permission, user) -> bool:
"""Check if the user has permission to perform the specified action on the attachment.

# Check that there are no directory tricks going on...
if new_file.parent != attachment_dir:
logger.error(
"Attempted to rename attachment outside valid directory: '%s'", new_file
)
raise ValidationError(_('Invalid attachment directory'))
The default implementation runs a permission check against *this* model class,
but this can be overridden in the implementing class if required.

# Ignore further checks if the filename is not actually being renamed
if new_file == old_file:
return
Arguments:
permission: The permission to check (add / change / view / delete)
user: The user to check against

forbidden = [
"'",
'"',
'#',
'@',
'!',
'&',
'^',
'<',
'>',
':',
';',
'/',
'\\',
'|',
'?',
'*',
'%',
'~',
'`',
]
Comment on lines -641 to -661
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: @SchrodingersGat where was this security feature moved? I am not able to find it in the diff

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are largely covered by django's built in file name validators.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure/have you verified this? We had a bunch of vulns in this specific area in the past with length overflows and target directory manipulation and now that huntr is basically dead we wont get a free security audit

Copy link
Member Author

@SchrodingersGat SchrodingersGat Jun 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will add some more checking in to cover this. I think the the existing code is never called anyway - it is certainly not hit in our coverage report.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We did not add checks when we fixed the original issues as there was some sever time pressure. I will try to add unit tests for unsafe uploads to my todo


for c in forbidden:
if c in fn:
raise ValidationError(_(f"Filename contains illegal character '{c}'"))

if len(fn.split('.')) < 2:
raise ValidationError(_('Filename missing extension'))

if not old_file.exists():
logger.error(
"Trying to rename attachment '%s' which does not exist", old_file
)
return
Returns:
bool: True if the user has permission, False otherwise
"""
perm = f'{cls._meta.app_label}.{permission}_{cls._meta.model_name}'
return user.has_perm(perm)

if new_file.exists():
raise ValidationError(_('Attachment with this filename already exists'))
def attachments_for_model(self):
"""Return all attachments for this model class."""
from common.models import Attachment

try:
os.rename(old_file, new_file)
self.attachment.name = os.path.join(self.getSubdir(), fn)
self.save()
except Exception:
raise ValidationError(_('Error renaming file'))
model_type = self.__class__.__name__.lower()

def fully_qualified_url(self):
"""Return a 'fully qualified' URL for this attachment.
return Attachment.objects.filter(model_type=model_type)

- If the attachment is a link to an external resource, return the link
- If the attachment is an uploaded file, return the fully qualified media URL
"""
if self.link:
return self.link
def create_attachment(self, attachment=None, link=None, comment='', **kwargs):
"""Create an attachment / link for this model."""
from common.models import Attachment

if self.attachment:
media_url = InvenTree.helpers.getMediaUrl(self.attachment.url)
return InvenTree.helpers_model.construct_absolute_url(media_url)
kwargs['attachment'] = attachment
kwargs['link'] = link
kwargs['comment'] = comment
kwargs['model_type'] = self.__class__.__name__.lower()
kwargs['model_id'] = self.pk

return ''
Attachment.objects.create(**kwargs)


class InvenTreeTree(MetadataMixin, PluginValidationMixin, MPTTModel):
Expand Down
Loading
Loading