From 8b08b43e77cc8c11b2ddb60fe8d32c68dfb50656 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Tue, 5 Jan 2021 12:49:58 +0100 Subject: [PATCH] API: File upload infrastructure --- doc/api/fundamentals.rst | 47 ++++++++++++++++++++++++++++++ src/pretix/api/urls.py | 3 +- src/pretix/api/views/upload.py | 53 ++++++++++++++++++++++++++++++++++ src/pretix/settings.py | 5 ++++ src/pretix/testutils/api.py | 11 +++++++ src/tests/api/test_upload.py | 31 ++++++++++++++++++++ 6 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/pretix/api/views/upload.py create mode 100644 src/pretix/testutils/api.py create mode 100644 src/tests/api/test_upload.py diff --git a/doc/api/fundamentals.rst b/doc/api/fundamentals.rst index 8cfa55223..9e8cd323b 100644 --- a/doc/api/fundamentals.rst +++ b/doc/api/fundamentals.rst @@ -183,6 +183,9 @@ Relative date *either* String in ISO 8601 ``"2017-12-27"``, constructed from a number of days before the base point and the base point. +File URL in responses, ``file:`` ``"https://…"``, ``"file:…"`` + specifiers in requests + (see below). ===================== ============================ =================================== Query parameters @@ -227,4 +230,48 @@ We store idempotency keys for 24 hours, so you should never retry a request afte All ``POST``, ``PUT``, ``PATCH``, or ``DELETE`` api calls support idempotency keys. Adding an idempotency key to a ``GET``, ``HEAD``, or ``OPTIONS`` request has no effect. + +File upload +----------- + +In some places, the API supports working with files, for example when setting the picture of a product. In this case, +you will first need to make a separate request to our file upload endpoint: + +.. sourcecode:: http + + POST /api/v1/upload HTTP/1.1 + Host: pretix.eu + Authorization: Token e1l6gq2ye72thbwkacj7jbri7a7tvxe614ojv8ybureain92ocub46t5gab5966k + Content-Type: image/png + Content-Disposition: attachment; filename="logo.png" + Content-Length: 1234 + + + +Note that the ``Content-Type`` and ``Content-Disposition`` headers are required. If the upload was successful, you will +receive a JSON response with the ID of the file: + +.. sourcecode:: http + + HTTP/1.1 201 Created + Content-Type: application/json + + { + "id": "file:1cd99455-1ebd-4cda-b1a2-7a7d2a969ad1" + } + +You can then use this file ID in the request you want to use it in. File IDs are currently valid for 24 hours and can only +be used using the same authorization method and user that was used to upload them. + +.. sourcecode:: http + + PATCH /api/v1/organizers/test/events/test/items/3/ HTTP/1.1 + Host: pretix.eu + Content-Type: application/json + + { + "picture": "file:1cd99455-1ebd-4cda-b1a2-7a7d2a969ad1" + } + + .. _CSRF policies: https://docs.djangoproject.com/en/1.11/ref/csrf/#ajax diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index d2d49e69a..6737a90ba 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -8,7 +8,7 @@ from pretix.api.views import cart from .views import ( checkin, device, event, exporters, item, oauth, order, organizer, user, - version, voucher, waitinglist, webhooks, + upload, version, voucher, waitinglist, webhooks, ) router = routers.DefaultRouter() @@ -95,6 +95,7 @@ urlpatterns = [ url(r"^device/roll$", device.RollKeyView.as_view(), name="device.roll"), url(r"^device/revoke$", device.RevokeKeyView.as_view(), name="device.revoke"), url(r"^device/eventselection$", device.EventSelectionView.as_view(), name="device.eventselection"), + url(r"^upload$", upload.UploadView.as_view(), name="user.me"), url(r"^me$", user.MeView.as_view(), name="user.me"), url(r"^version$", version.VersionView.as_view(), name="version"), ] diff --git a/src/pretix/api/views/upload.py b/src/pretix/api/views/upload.py new file mode 100644 index 000000000..35fe855f7 --- /dev/null +++ b/src/pretix/api/views/upload.py @@ -0,0 +1,53 @@ +import datetime + +from django.utils.timezone import now +from oauth2_provider.contrib.rest_framework import OAuth2Authentication +from rest_framework.authentication import SessionAuthentication +from rest_framework.exceptions import ValidationError +from rest_framework.parsers import FileUploadParser +from rest_framework.response import Response +from rest_framework.views import APIView + +from pretix.api.auth.device import DeviceTokenAuthentication +from pretix.api.auth.token import TeamTokenAuthentication +from pretix.base.models import CachedFile + +ALLOWED_TYPES = { + 'image/gif': {'.gif'}, + 'image/jpeg': {'.jpg', '.jpeg'}, + 'image/png': {'.png'}, + 'application/pdf': {'.pdf'}, +} + + +class UploadView(APIView): + authentication_classes = ( + SessionAuthentication, OAuth2Authentication, DeviceTokenAuthentication, TeamTokenAuthentication + ) + parser_classes = [FileUploadParser] + + def post(self, request): + if 'file' not in request.data: + raise ValidationError('No file has been submitted.') + file_obj = request.data['file'] + content_type = file_obj.content_type.split(";")[0] # ignore e.g. "; charset=…" + if content_type not in ALLOWED_TYPES: + raise ValidationError('Content type "{type}" is not allowed'.format(type=content_type)) + if not any(file_obj.name.endswith(ext) for ext in ALLOWED_TYPES[content_type]): + raise ValidationError('File name "{name}" has an invalid extension for type "{type}"'.format( + name=file_obj.name, + type=content_type + )) + cf = CachedFile.objects.create( + expires=now() + datetime.timedelta(days=1), + date=now(), + web_download=False, + filename=file_obj.name, + type=content_type, + session_key=f'api-upload-{str(type(request.user or request.auth))}-{(request.user or request.auth).pk}' + ) + cf.file.save(file_obj.name, file_obj) + cf.save() + return Response({ + 'id': f'file:{cf.pk}' + }, status=201) diff --git a/src/pretix/settings.py b/src/pretix/settings.py index 252de9a45..a81a053e3 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -344,6 +344,11 @@ REST_FRAMEWORK = { 'DEFAULT_RENDERER_CLASSES': ( 'rest_framework.renderers.JSONRenderer', ), + 'TEST_REQUEST_RENDERER_CLASSES': [ + 'rest_framework.renderers.MultiPartRenderer', + 'rest_framework.renderers.JSONRenderer', + 'pretix.testutils.api.UploadRenderer', + ], 'EXCEPTION_HANDLER': 'pretix.api.exception.custom_exception_handler', 'UNICODE_JSON': False } diff --git a/src/pretix/testutils/api.py b/src/pretix/testutils/api.py new file mode 100644 index 000000000..109ba0689 --- /dev/null +++ b/src/pretix/testutils/api.py @@ -0,0 +1,11 @@ +from rest_framework.renderers import BaseRenderer + + +class UploadRenderer(BaseRenderer): + media_type = None + format = 'upload' + charset = 'utf-8' + + def render(self, data, accepted_media_type=None, renderer_context=None): + self.media_type = data['media_type'] + return data['file'] diff --git a/src/tests/api/test_upload.py b/src/tests/api/test_upload.py new file mode 100644 index 000000000..a44c2cb75 --- /dev/null +++ b/src/tests/api/test_upload.py @@ -0,0 +1,31 @@ +import pytest +from django.core.files.base import ContentFile + + +@pytest.mark.django_db +def test_upload_file(token_client): + r = token_client.post( + '/api/v1/upload', + data={ + 'media_type': 'application/pdf', + 'file': ContentFile('file.pdf', 'invalid pdf content') + }, + format='upload', + HTTP_CONTENT_DISPOSITION='attachment; filename="file.pdf"', + ) + assert r.status_code == 201 + assert r.data['id'].startswith('file:') + + +@pytest.mark.django_db +def test_upload_file_extension_mismatch(token_client): + r = token_client.post( + '/api/v1/upload', + data={ + 'media_type': 'application/pdf', + 'file': ContentFile('file.png', 'invalid pdf content') + }, + format='upload', + HTTP_CONTENT_DISPOSITION='attachment; filename="file.png"', + ) + assert r.status_code == 400