diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index 6286576187..4bcbd66d5b 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -41,8 +41,8 @@ from rest_framework import routers from pretix.api.views import cart from .views import ( - checkin, device, discount, event, exporters, item, oauth, order, organizer, - upload, user, version, voucher, waitinglist, webhooks, + checkin, device, discount, event, exporters, idempotency, item, oauth, + order, organizer, upload, user, version, voucher, waitinglist, webhooks, ) router = routers.DefaultRouter() @@ -133,6 +133,7 @@ urlpatterns = [ re_path(r"^device/roll$", device.RollKeyView.as_view(), name="device.roll"), re_path(r"^device/revoke$", device.RevokeKeyView.as_view(), name="device.revoke"), re_path(r"^device/eventselection$", device.EventSelectionView.as_view(), name="device.eventselection"), + re_path(r"^idempotency_query$", idempotency.IdempotencyQueryView.as_view(), name="idempotency.query"), re_path(r"^upload$", upload.UploadView.as_view(), name="upload"), re_path(r"^me$", user.MeView.as_view(), name="user.me"), re_path(r"^version$", version.VersionView.as_view(), name="version"), diff --git a/src/pretix/api/views/idempotency.py b/src/pretix/api/views/idempotency.py new file mode 100644 index 0000000000..3ae560b01e --- /dev/null +++ b/src/pretix/api/views/idempotency.py @@ -0,0 +1,80 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +import json +import logging +from hashlib import sha1 + +from django.conf import settings +from django.http import HttpResponse, JsonResponse +from rest_framework import status +from rest_framework.views import APIView + +from pretix.api.models import ApiCall + +logger = logging.getLogger(__name__) + + +class IdempotencyQueryView(APIView): + # Experimental feature, therefore undocumented for now + authentication_classes = () + permission_classes = () + + def get(self, request, format=None): + idempotency_key = request.GET.get("key") + auth_hash_parts = '{}:{}'.format( + request.headers.get('Authorization', ''), + request.COOKIES.get(settings.SESSION_COOKIE_NAME, '') + ) + auth_hash = sha1(auth_hash_parts.encode()).hexdigest() + if not idempotency_key: + return JsonResponse({ + 'detail': 'No idempotency key given.' + }, status=status.HTTP_404_NOT_FOUND) + + try: + call = ApiCall.objects.get( + auth_hash=auth_hash, + idempotency_key=idempotency_key, + ) + except ApiCall.DoesNotExist: + return JsonResponse({ + 'detail': 'Idempotency key not seen before.' + }, status=status.HTTP_404_NOT_FOUND) + + if call.locked: + r = JsonResponse( + {'detail': 'Concurrent request with idempotency key.'}, + status=status.HTTP_409_CONFLICT, + ) + r['Retry-After'] = 5 + return r + + content = call.response_body + if isinstance(content, memoryview): + content = content.tobytes() + r = HttpResponse( + content=content, + status=call.response_code, + ) + for k, v in json.loads(call.response_headers).values(): + r[k] = v + return r diff --git a/src/tests/api/test_idempotency.py b/src/tests/api/test_idempotency.py index c7915523e2..baec6dda30 100644 --- a/src/tests/api/test_idempotency.py +++ b/src/tests/api/test_idempotency.py @@ -103,6 +103,19 @@ def test_ignore_path_method_body(token_client, organizer): assert resp.status_code == 201 +@pytest.mark.django_db +def test_query_key(token_client, organizer): + resp = token_client.post('/api/v1/organizers/{}/events/'.format(organizer.slug), + PAYLOAD, format='json', HTTP_X_IDEMPOTENCY_KEY='foo') + assert resp.status_code == 201 + data = resp.content + resp = token_client.get('/api/v1/idempotency_query?key=foo') + assert resp.content == data + assert resp.status_code == 201 + resp = token_client.get('/api/v1/idempotency_query?key=bar') + assert resp.status_code == 404 + + @pytest.mark.django_db def test_scoped_by_token(token_client, device, organizer): resp = token_client.post('/api/v1/organizers/{}/events/'.format(organizer.slug),