From 20b8e5d2a984ff2631ab3090437dde466f1b37cd Mon Sep 17 00:00:00 2001 From: Roni Choudhury Date: Tue, 14 Mar 2023 08:34:38 -0400 Subject: [PATCH 01/12] Add inline disposition URL functions --- dandiapi/api/storage.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/dandiapi/api/storage.py b/dandiapi/api/storage.py index e833e3538..d117c644b 100644 --- a/dandiapi/api/storage.py +++ b/dandiapi/api/storage.py @@ -171,6 +171,17 @@ def generate_presigned_download_url(self, key: str, path: str) -> str: }, ) + def generate_presigned_inline_url(self, key: str) -> str: + return self.connection.meta.client.generate_presigned_url( + 'get_object', + Params={ + 'Bucket': self.bucket_name, + 'Key': key, + 'ResponseContentDisposition': 'inline', + }, + ) + + def sha256_checksum(self, key: str) -> str: calculator = ChecksumCalculatorFile() obj = self.bucket.Object(key) @@ -213,6 +224,13 @@ def generate_presigned_download_url(self, key: str, path: str) -> str: response_headers={'response-content-disposition': f'attachment; filename="{path}"'}, ) + def generate_presigned_inline_url(self, key: str) -> str: + return self.base_url_client.presigned_get_object( + self.bucket_name, + key, + response_headers={'response-content-disposition': 'inline'}, + ) + def sha256_checksum(self, key: str) -> str: calculator = ChecksumCalculatorFile() obj = self.client.get_object(self.bucket_name, key) From 1767af0f9788497d8395ac9ef147ed0f90916b0b Mon Sep 17 00:00:00 2001 From: Roni Choudhury Date: Tue, 14 Mar 2023 08:38:55 -0400 Subject: [PATCH 02/12] Add content_disposition parameter to asset download endpoint --- dandiapi/api/views/asset.py | 14 ++++++++++++-- dandiapi/api/views/common.py | 9 +++++++++ dandiapi/api/views/serializers.py | 13 +++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/dandiapi/api/views/asset.py b/dandiapi/api/views/asset.py index 14e81cf4c..95abd5854 100644 --- a/dandiapi/api/views/asset.py +++ b/dandiapi/api/views/asset.py @@ -44,12 +44,14 @@ from dandiapi.api.models.asset import EmbargoedAssetBlob, validate_asset_path from dandiapi.api.views.common import ( ASSET_ID_PARAM, + CONTENT_DISPOSITION_PARAM, VERSIONS_DANDISET_PK_PARAM, VERSIONS_VERSION_PARAM, DandiPagination, ) from dandiapi.api.views.serializers import ( AssetDetailSerializer, + AssetDownloadQueryParameterSerializer, AssetListSerializer, AssetPathsQueryParameterSerializer, AssetPathsSerializer, @@ -111,14 +113,14 @@ def retrieve(self, request, **kwargs): method='GET', operation_summary='Get the download link for an asset.', operation_description='', - manual_parameters=[ASSET_ID_PARAM], + manual_parameters=[ASSET_ID_PARAM, CONTENT_DISPOSITION_PARAM], responses={ 200: None, # This disables the auto-generated 200 response 301: 'Redirect to object store', }, ) @action(methods=['GET', 'HEAD'], detail=True) - def download(self, *args, **kwargs): + def download(self, request, *args, **kwargs): asset = self.get_object() # Raise error if zarr @@ -137,9 +139,17 @@ def download(self, *args, **kwargs): # Redirect to correct presigned URL storage = asset_blob.blob.storage + + serializer = AssetDownloadQueryParameterSerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + content_disposition = serializer.validated_data['content_disposition'] + return HttpResponseRedirect( storage.generate_presigned_download_url( asset_blob.blob.name, os.path.basename(asset.path) + ) if content_disposition == 'attachment' else + storage.generate_presigned_inline_url( + asset_blob.blob.name ) ) diff --git a/dandiapi/api/views/common.py b/dandiapi/api/views/common.py index 44c8996fe..666c3d7e4 100644 --- a/dandiapi/api/views/common.py +++ b/dandiapi/api/views/common.py @@ -23,6 +23,15 @@ class DandiPagination(PageNumberPagination): pattern=r'[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}', ) +CONTENT_DISPOSITION_PARAM = openapi.Parameter( + 'content_disposition', + openapi.IN_QUERY, + 'Content Disposition', + type=openapi.TYPE_STRING, + required=False, + pattern='inline|attachment', +) + DANDISET_PK_PARAM = openapi.Parameter( 'dandiset__pk', openapi.IN_PATH, diff --git a/dandiapi/api/views/serializers.py b/dandiapi/api/views/serializers.py index 831d2d743..29b2f1b3e 100644 --- a/dandiapi/api/views/serializers.py +++ b/dandiapi/api/views/serializers.py @@ -1,6 +1,7 @@ from django.conf import settings from django.contrib.auth.validators import UnicodeUsernameValidator from rest_framework import serializers +from rest_framework.exceptions import ValidationError from dandiapi.api.models import Asset, AssetBlob, AssetPath, Dandiset, Version @@ -199,6 +200,18 @@ class Meta: fields = ['status', 'validation_errors'] +class AssetDownloadQueryParameterSerializer(serializers.Serializer): + content_disposition = serializers.CharField(default='attachment') + + def validate(self, data): + value = data['content_disposition'] + + if value not in ['attachment', 'inline']: + raise ValidationError(f'Illegal value {value} for parameter "content_disposition"', code=400) + + return super().validate(data) + + class EmbargoedSlugRelatedField(serializers.SlugRelatedField): """ A Field for cleanly serializing embargoed model fields. From 77cfa7ec34bf707e8b91533bfbafc596757f1070 Mon Sep 17 00:00:00 2001 From: Roni Choudhury Date: Tue, 14 Mar 2023 08:39:30 -0400 Subject: [PATCH 03/12] Add "open file inline" action to file browser --- web/src/rest.ts | 3 +++ web/src/views/FileBrowserView/FileBrowser.vue | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/web/src/rest.ts b/web/src/rest.ts index 7b81fa1e6..bbd3457c2 100644 --- a/web/src/rest.ts +++ b/web/src/rest.ts @@ -242,6 +242,9 @@ const dandiRest = new Vue({ assetDownloadURI(identifier: string, version: string, uuid: string) { return `${dandiApiRoot}assets/${uuid}/download/`; }, + assetInlineURI(identifier: string, version: string, uuid: string) { + return `${dandiApiRoot}assets/${uuid}/download?content_disposition=inline`; + }, assetMetadataURI(identifier: string, version: string, uuid: string) { return `${dandiApiRoot}dandisets/${identifier}/versions/${version}/assets/${uuid}`; }, diff --git a/web/src/views/FileBrowserView/FileBrowser.vue b/web/src/views/FileBrowserView/FileBrowser.vue index e2e4b813a..8d893b64d 100644 --- a/web/src/views/FileBrowserView/FileBrowser.vue +++ b/web/src/views/FileBrowserView/FileBrowser.vue @@ -144,6 +144,18 @@ + + + + mdi-eye + + + + Date: Tue, 14 Mar 2023 14:12:11 -0400 Subject: [PATCH 04/12] Supply a special-cased HTML response for playing MKV files --- dandiapi/api/views/asset.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/dandiapi/api/views/asset.py b/dandiapi/api/views/asset.py index 95abd5854..4aae09699 100644 --- a/dandiapi/api/views/asset.py +++ b/dandiapi/api/views/asset.py @@ -27,7 +27,7 @@ from django.conf import settings from django.db import transaction from django.db.models import QuerySet -from django.http import HttpResponseRedirect +from django.http import HttpResponse, HttpResponseRedirect from django.utils.decorators import method_decorator from django_filters import rest_framework as filters from drf_yasg.utils import swagger_auto_schema @@ -143,15 +143,28 @@ def download(self, request, *args, **kwargs): serializer = AssetDownloadQueryParameterSerializer(data=request.query_params) serializer.is_valid(raise_exception=True) content_disposition = serializer.validated_data['content_disposition'] + asset_basename = os.path.basename(asset.path) - return HttpResponseRedirect( - storage.generate_presigned_download_url( - asset_blob.blob.name, os.path.basename(asset.path) - ) if content_disposition == 'attachment' else - storage.generate_presigned_inline_url( + if content_disposition == 'attachment': + return HttpResponseRedirect( + storage.generate_presigned_download_url( + asset_blob.blob.name, asset_basename + ) + ) + elif content_disposition == 'inline': + url = storage.generate_presigned_inline_url( asset_blob.blob.name ) - ) + + + if asset_basename.endswith('.mkv'): + return HttpResponse(f''' +