From 46464f65446f90d26e5ec7f335f1c1e6c85d407f Mon Sep 17 00:00:00 2001 From: Jochen Klar Date: Tue, 21 Jan 2025 16:36:57 +0100 Subject: [PATCH 1/3] Add throttling to contact api endpoint --- rdmo/core/assets/js/api/BaseApi.js | 14 ++++++++++++++ rdmo/core/settings.py | 9 ++++++--- rdmo/core/throttling.py | 8 ++++++++ .../assets/js/interview/components/main/Contact.js | 9 ++++++++- rdmo/projects/viewsets.py | 4 +++- 5 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 rdmo/core/throttling.py diff --git a/rdmo/core/assets/js/api/BaseApi.js b/rdmo/core/assets/js/api/BaseApi.js index c7d6d7651..7f2c37111 100644 --- a/rdmo/core/assets/js/api/BaseApi.js +++ b/rdmo/core/assets/js/api/BaseApi.js @@ -19,6 +19,12 @@ function BadRequestError(errors) { this.errors = errors } +function ThrottlingError(errors) { + this.errors = { + throttling: errors.detail + } +} + class BaseApi { static get(url) { @@ -31,6 +37,10 @@ class BaseApi { return response.json().then(errors => { throw new BadRequestError(errors) }) + } else if (response.status === 429) { + return response.json().then(errors => { + throw new ThrottlingError(errors) + }) } else { throw new ApiError(response.statusText, response.status) } @@ -59,6 +69,10 @@ class BaseApi { return response.json().then(errors => { throw new ValidationError(errors) }) + } else if (response.status === 429) { + return response.json().then(errors => { + throw new ThrottlingError(errors) + }) } else { throw new ApiError(response.statusText, response.status) } diff --git a/rdmo/core/settings.py b/rdmo/core/settings.py index 63d3b1745..87d6c0f2c 100644 --- a/rdmo/core/settings.py +++ b/rdmo/core/settings.py @@ -164,8 +164,8 @@ CACHES = { 'default': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'LOCATION': 'rdmo_default' + 'BACKEND': 'django.core.cache.backends.db.DatabaseCache', + 'LOCATION': 'django_cache' } } @@ -180,7 +180,10 @@ ), 'DEFAULT_RENDERER_CLASSES': ( 'rest_framework.renderers.JSONRenderer', - ) + ), + 'DEFAULT_THROTTLE_RATES': { + 'email': '1/minute' + } } SETTINGS_EXPORT = [ diff --git a/rdmo/core/throttling.py b/rdmo/core/throttling.py new file mode 100644 index 000000000..215ac552c --- /dev/null +++ b/rdmo/core/throttling.py @@ -0,0 +1,8 @@ +from rest_framework.throttling import UserRateThrottle + + +class EmailThrottle(UserRateThrottle): + scope = 'email' + + def allow_request(self, request, view): + return request.method == 'GET' or super().allow_request(request, view) diff --git a/rdmo/projects/assets/js/interview/components/main/Contact.js b/rdmo/projects/assets/js/interview/components/main/Contact.js index 84d88109d..7bb1369eb 100644 --- a/rdmo/projects/assets/js/interview/components/main/Contact.js +++ b/rdmo/projects/assets/js/interview/components/main/Contact.js @@ -31,7 +31,7 @@ const Contact = ({ templates, contact, sendContact, closeContact }) => { bsSize: 'lg' }}> -
+
{ }
+ { + errors && errors.throttling && ( + + ) + }
diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index 0527882b2..efd28c1b2 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -22,6 +22,7 @@ from rdmo.conditions.models import Condition from rdmo.core.permissions import HasModelPermission +from rdmo.core.throttling import EmailThrottle from rdmo.core.utils import human2bytes, is_truthy, return_file_response from rdmo.options.models import OptionSet from rdmo.questions.models import Catalog, Page, Question, QuestionSet @@ -329,7 +330,8 @@ def visibility(self, request, pk=None): raise Http404 @action(detail=True, methods=['get', 'post'], - permission_classes=(HasModelPermission | HasProjectPermission, )) + permission_classes=(HasModelPermission | HasProjectPermission, ), + throttle_classes=[EmailThrottle]) def contact(self, request, pk): if settings.PROJECT_CONTACT: if request.method == 'POST': From bcd512e94543d7ec5227248c796de749e22c33ec Mon Sep 17 00:00:00 2001 From: David Wallace Date: Wed, 22 Jan 2025 12:00:55 +0100 Subject: [PATCH 2/3] test(projects,contact): assert PROJECT_CONTACT setting Signed-off-by: David Wallace --- rdmo/projects/tests/test_viewset_project_contact.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/rdmo/projects/tests/test_viewset_project_contact.py b/rdmo/projects/tests/test_viewset_project_contact.py index 97526fafc..a219f22a9 100644 --- a/rdmo/projects/tests/test_viewset_project_contact.py +++ b/rdmo/projects/tests/test_viewset_project_contact.py @@ -151,7 +151,8 @@ def test_contact_get_set(db, client): @pytest.mark.parametrize('username,password', users) @pytest.mark.parametrize('project_id', projects) -def test_contact_post(db, client, username, password, project_id): +def test_contact_post(db, client,settings, username, password, project_id): + assert settings.PROJECT_CONTACT client.login(username=username, password=password) url = reverse(urlnames['contact'], args=[project_id]) @@ -176,7 +177,8 @@ def test_contact_post(db, client, username, password, project_id): @pytest.mark.parametrize('username,password', users) @pytest.mark.parametrize('project_id', projects) -def test_contact_post_error(db, client, username, password, project_id): +def test_contact_post_error(db, client,settings, username, password, project_id): + assert settings.PROJECT_CONTACT client.login(username=username, password=password) url = reverse(urlnames['contact'], args=[project_id]) From 99f98f6c3d165783e3492c025d6925de5d15d54c Mon Sep 17 00:00:00 2001 From: David Wallace Date: Wed, 22 Jan 2025 12:03:25 +0100 Subject: [PATCH 3/3] fix(projects,contact): catch 404 exception when getting object Signed-off-by: David Wallace --- rdmo/projects/viewsets.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index efd28c1b2..0afd3c815 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -334,6 +334,11 @@ def visibility(self, request, pk=None): throttle_classes=[EmailThrottle]) def contact(self, request, pk): if settings.PROJECT_CONTACT: + try: + project = self.get_object() + except Http404: + return Response(status=status.HTTP_404_NOT_FOUND) + if request.method == 'POST': subject = request.data.get('subject') message = request.data.get('message') @@ -347,11 +352,10 @@ def contact(self, request, pk): 'message': [_('This field may not be blank.')] if not message else [] }) else: - project = self.get_object() project.catalog.prefetch_elements() return Response(get_contact_message(request, project)) else: - return 404 + return Response(status=status.HTTP_404_NOT_FOUND) @action(detail=False, url_path='upload-accept', permission_classes=(IsAuthenticated, )) def upload_accept(self, request):