From 77828295f89cfb9e167b492b0c9edfe39e06f4e3 Mon Sep 17 00:00:00 2001 From: jedi Date: Sun, 7 Jan 2024 21:06:06 +0100 Subject: [PATCH] create file API v2 and X-Accel-Redirect endpoint for images --- core/core/urls.py | 2 + core/files/api_v1.py | 2 + core/files/api_v2.py | 24 ++++++ core/files/media_v2.py | 80 +++++++++++++++++++ core/files/migrations/0001_initial.py | 2 +- core/files/tests/__init__.py | 0 core/files/tests/v1/__init__.py | 0 .../{tests.py => tests/v1/test_files.py} | 0 core/files/tests/v2/__init__.py | 0 core/files/tests/v2/test_files.py | 55 +++++++++++++ 10 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 core/files/api_v2.py create mode 100644 core/files/media_v2.py create mode 100644 core/files/tests/__init__.py create mode 100644 core/files/tests/v1/__init__.py rename core/files/{tests.py => tests/v1/test_files.py} (100%) create mode 100644 core/files/tests/v2/__init__.py create mode 100644 core/files/tests/v2/test_files.py diff --git a/core/core/urls.py b/core/core/urls.py index 7d37ec0..a432cce 100644 --- a/core/core/urls.py +++ b/core/core/urls.py @@ -24,6 +24,8 @@ urlpatterns = [ path('api/1/', include('inventory.api_v1')), path('api/1/', include('files.api_v1')), path('api/1/', include('files.media_v1')), + path('api/2/', include('files.api_v2')), + path('media/2/', include('files.media_v2')), path('api/2/', include('authentication.api_v2')), path('api/', get_info), ] diff --git a/core/files/api_v1.py b/core/files/api_v1.py index b0386da..ce9730f 100644 --- a/core/files/api_v1.py +++ b/core/files/api_v1.py @@ -16,6 +16,8 @@ class FileViewSet(viewsets.ModelViewSet): serializer_class = FileSerializer queryset = File.objects.all() lookup_field = 'hash' + permission_classes = [] + authentication_classes = [] router = routers.SimpleRouter(trailing_slash=False) diff --git a/core/files/api_v2.py b/core/files/api_v2.py new file mode 100644 index 0000000..a0962f0 --- /dev/null +++ b/core/files/api_v2.py @@ -0,0 +1,24 @@ +from rest_framework import serializers, viewsets, routers + +from files.models import File + + +class FileSerializer(serializers.ModelSerializer): + data = serializers.CharField(max_length=1000000, write_only=True) + + class Meta: + model = File + fields = ['hash', 'data'] + read_only_fields = ['hash'] + + +class FileViewSet(viewsets.ModelViewSet): + serializer_class = FileSerializer + queryset = File.objects.all() + lookup_field = 'hash' + + +router = routers.SimpleRouter() +router.register(r'files', FileViewSet, basename='files') + +urlpatterns = router.urls diff --git a/core/files/media_v2.py b/core/files/media_v2.py new file mode 100644 index 0000000..7cbf865 --- /dev/null +++ b/core/files/media_v2.py @@ -0,0 +1,80 @@ +from datetime import datetime, timedelta + +import os +from django.http import HttpResponse +from django.urls import path +from drf_yasg.utils import swagger_auto_schema +from rest_framework import status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from core.settings import MEDIA_ROOT +from files.models import File + + +@swagger_auto_schema(method='GET', auto_schema=None) +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def media_urls(request, hash): + try: + if request.META.get('HTTP_IF_NONE_MATCH') and request.META.get('HTTP_IF_NONE_MATCH') == hash: + return HttpResponse(status=status.HTTP_304_NOT_MODIFIED) + + file = File.objects.get(hash=hash) + hash_path = file.file + return HttpResponse(status=status.HTTP_200_OK, + content_type=file.mime_type, + headers={ + 'X-Accel-Redirect': f'/redirect_media/{hash_path}', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'max-age=31536000, private', + 'Expires': datetime.utcnow() + timedelta(days=365), + 'Age': 0, + 'ETag': file.hash, + }) + + except File.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + +@swagger_auto_schema(method='GET', auto_schema=None) +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def thumbnail_urls(request, size, hash): + if size not in [32, 64, 256]: + return Response(status=status.HTTP_404_NOT_FOUND) + if request.META.get('HTTP_IF_NONE_MATCH') and request.META.get('HTTP_IF_NONE_MATCH') == hash + "_" + str(size): + return HttpResponse(status=status.HTTP_304_NOT_MODIFIED) + try: + file = File.objects.get(hash=hash) + hash_path = file.file + if not os.path.exists(MEDIA_ROOT + f'/thumbnails/{size}/{hash_path}'): + from PIL import Image + image = Image.open(file.file) + image.thumbnail((size, size)) + rgb_image = image.convert('RGB') + thumb_dir = os.path.dirname(MEDIA_ROOT + f'/thumbnails/{size}/{hash_path}') + if not os.path.exists(thumb_dir): + os.makedirs(thumb_dir) + rgb_image.save(MEDIA_ROOT + f'/thumbnails/{size}/{hash_path}', 'jpeg', quality=90) + + return HttpResponse(status=status.HTTP_200_OK, + content_type="image/jpeg", + headers={ + 'X-Accel-Redirect': f'/redirect_thumbnail/{size}/{hash_path}', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'max-age=31536000, private', + 'Expires': datetime.utcnow() + timedelta(days=365), + 'Age': 0, + 'ETag': file.hash + "_" + str(size), + }) + + except File.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + +urlpatterns = [ + path('//', thumbnail_urls), + path('/', media_urls), +] diff --git a/core/files/migrations/0001_initial.py b/core/files/migrations/0001_initial.py index fa20ba8..2c15fff 100644 --- a/core/files/migrations/0001_initial.py +++ b/core/files/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.7 on 2023-11-18 11:28 +# Generated by Django 4.2.7 on 2023-12-09 02:13 from django.db import migrations, models import django.db.models.deletion diff --git a/core/files/tests/__init__.py b/core/files/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/files/tests/v1/__init__.py b/core/files/tests/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/files/tests.py b/core/files/tests/v1/test_files.py similarity index 100% rename from core/files/tests.py rename to core/files/tests/v1/test_files.py diff --git a/core/files/tests/v2/__init__.py b/core/files/tests/v2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/files/tests/v2/test_files.py b/core/files/tests/v2/test_files.py new file mode 100644 index 0000000..dedd647 --- /dev/null +++ b/core/files/tests/v2/test_files.py @@ -0,0 +1,55 @@ +from django.test import TestCase, Client +from django.contrib.auth.models import Permission + +from authentication.models import ExtendedUser +from files.models import File +from inventory.models import Event, Container, Item +from knox.models import AuthToken + + +class FileTestCase(TestCase): + + def setUp(self): + super().setUp() + self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test') + self.user.user_permissions.add(*Permission.objects.all()) + self.user.save() + self.token = AuthToken.objects.create(user=self.user) + self.client = Client(headers={'Authorization': 'Token ' + self.token[1]}) + self.event = Event.objects.create(slug='EVENT', name='Event') + self.box = Container.objects.create(name='BOX') + + def test_list_files(self): + import base64 + item = File.objects.create(data="data:text/plain;base64," + base64.b64encode(b"foo").decode('utf-8')) + response = self.client.get('/api/2/files/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()[0]['hash'], item.hash) + self.assertEqual(len(response.json()[0]['hash']), 64) + + def test_one_file(self): + import base64 + item = File.objects.create(data="data:text/plain;base64," + base64.b64encode(b"foo").decode('utf-8')) + response = self.client.get(f'/api/2/files/{item.hash}/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['hash'], item.hash) + self.assertEqual(len(response.json()['hash']), 64) + + def test_create_file(self): + import base64 + Item.objects.create(container=self.box, event=self.event, description='1') + item = Item.objects.create(container=self.box, event=self.event, description='2') + response = self.client.post('/api/2/files/', + {'data': "data:text/plain;base64," + base64.b64encode(b"foo").decode('utf-8')}, + content_type='application/json') + self.assertEqual(response.status_code, 201) + self.assertEqual(len(response.json()['hash']), 64) + + def test_delete_file(self): + import base64 + item = Item.objects.create(container=self.box, event=self.event, description='1') + File.objects.create(item=item, data="data:text/plain;base64," + base64.b64encode(b"foo").decode('utf-8')) + file = File.objects.create(item=item, data="data:text/plain;base64," + base64.b64encode(b"bar").decode('utf-8')) + self.assertEqual(len(File.objects.all()), 2) + response = self.client.delete(f'/api/2/files/{file.hash}/') + self.assertEqual(response.status_code, 204)