Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle Content-Type and Content-Disposition headers #90

Merged
merged 8 commits into from
May 14, 2021
Merged
8 changes: 8 additions & 0 deletions giftless/storage/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import mimetypes
from abc import ABC
from typing import Any, BinaryIO, Dict, Iterable, Optional

Expand Down Expand Up @@ -32,6 +33,9 @@ def exists(self, prefix: str, oid: str) -> bool:
def get_size(self, prefix: str, oid: str) -> int:
pass

def get_mime_type(self, prefix: str, oid: str) -> Optional[str]:
return "application/octet-stream"

def verify_object(self, prefix: str, oid: str, size: int):
"""Verify that an object exists
"""
Expand Down Expand Up @@ -85,3 +89,7 @@ def verify_object(self, prefix: str, oid: str, size: int) -> bool:
return self.get_size(prefix, oid) == size
except exc.ObjectNotFound:
return False


def guess_mime_type_from_filename(filename: str) -> Optional[str]:
return mimetypes.guess_type(filename)[0]
11 changes: 9 additions & 2 deletions giftless/storage/amazon_s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,16 @@ def get_download_action(self, prefix: str, oid: str, size: int, expires_in: int,
'Bucket': self.bucket_name,
'Key': self._get_blob_path(prefix, oid)
}
if extra and 'filename' in extra:
filename = safe_filename(extra['filename'])

filename = extra.get('filename') if extra else None
disposition = extra.get('disposition', 'attachment') if extra else 'attachment'

if filename and disposition:
filename = safe_filename(filename)
params['ResponseContentDisposition'] = f'attachment; filename="{filename}"'
elif disposition:
params['ResponseContentDisposition'] = disposition

response = self.s3_client.generate_presigned_url('get_object',
Params=params,
ExpiresIn=expires_in
Expand Down
47 changes: 37 additions & 10 deletions giftless/storage/azure.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from azure.core.exceptions import ResourceNotFoundError
from azure.storage.blob import BlobClient, BlobSasPermissions, BlobServiceClient, generate_blob_sas # type: ignore

from giftless.storage import ExternalStorage, MultipartStorage, StreamingStorage
from giftless.storage import ExternalStorage, MultipartStorage, StreamingStorage, guess_mime_type_from_filename

from .exc import ObjectNotFound

Expand Down Expand Up @@ -63,28 +63,50 @@ def get_size(self, prefix: str, oid: str) -> int:
except ResourceNotFoundError:
raise ObjectNotFound("Object does not exist")

def get_mime_type(self, prefix: str, oid: str) -> Optional[str]:
try:
blob_client = self.blob_svc_client.get_blob_client(container=self.container_name,
blob=self._get_blob_path(prefix, oid))
props = blob_client.get_blob_properties()
mime_type = props.content_settings.get(
"content_type", "application/octet-stream")
return mime_type # type: ignore
except ResourceNotFoundError:
raise ObjectNotFound("Object does not exist")

def get_upload_action(self, prefix: str, oid: str, size: int, expires_in: int,
extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
filename = extra.get('filename') if extra else None
return {
headers = {
"x-ms-blob-type": "BlockBlob",
}
reply = {
"actions": {
"upload": {
"href": self._get_signed_url(prefix, oid, expires_in, filename, create=True),
"header": {
"x-ms-blob-type": "BlockBlob",
},
"expires_in": expires_in
}
}
}

if filename:
mime_type = guess_mime_type_from_filename(filename)
if mime_type:
headers["x-ms-blob-content-type"] = mime_type

reply["actions"]["upload"]["header"] = headers

return reply

def get_download_action(self, prefix: str, oid: str, size: int, expires_in: int,
extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
filename = extra.get('filename') if extra else None
disposition = extra.get('disposition', 'attachment') if extra else 'attachment'

return {
"actions": {
"download": {
"href": self._get_signed_url(prefix, oid, expires_in, filename, read=True),
"href": self._get_signed_url(prefix, oid, expires_in, filename, disposition=disposition, read=True),
"header": {},
"expires_in": expires_in
}
Expand All @@ -104,7 +126,6 @@ def get_multipart_actions(self, prefix: str, oid: str, size: int, part_size: int
_log.info("There are %d uncommitted blocks pre-uploaded; %d parts still need to be uploaded",
len(uncommitted), len(parts))
commit_body = self._create_commit_body(blocks)

reply: Dict[str, Any] = {
"actions": {
"commit": {
Expand All @@ -123,6 +144,10 @@ def get_multipart_actions(self, prefix: str, oid: str, size: int, part_size: int
}
}
}
if filename:
mime_type = guess_mime_type_from_filename(filename)
if mime_type:
reply["actions"]["commit"]["header"]["x-ms-blob-content-type"] = mime_type

if parts:
reply['actions']['parts'] = parts
Expand All @@ -141,14 +166,16 @@ def _get_blob_path(self, prefix: str, oid: str) -> str:
return os.path.join(storage_prefix, prefix, oid)

def _get_signed_url(self, prefix: str, oid: str, expires_in: int, filename: Optional[str] = None,
**permissions: bool) -> str:
disposition: Optional[str] = None, **permissions: bool) -> str:
blob_name = self._get_blob_path(prefix, oid)
permissions = BlobSasPermissions(**permissions)
token_expires = (datetime.now(tz=timezone.utc) + timedelta(seconds=expires_in))

extra_args = {}
if filename:
extra_args['content_disposition'] = f'attachment; filename="{filename}"'
if filename and disposition:
extra_args['content_disposition'] = f'{disposition}; filename="{filename}"'
elif disposition:
extra_args['content_disposition'] = f'{disposition};"'

sas_token = generate_blob_sas(account_name=self.blob_svc_client.account_name,
account_key=self.blob_svc_client.credential.account_key,
Expand Down
10 changes: 8 additions & 2 deletions giftless/storage/google_cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,13 @@ def get_upload_action(self, prefix: str, oid: str, size: int, expires_in: int,
def get_download_action(self, prefix: str, oid: str, size: int, expires_in: int,
extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
filename = extra.get('filename') if extra else None
disposition = extra.get('disposition', 'attachment') if extra else 'attachment'

return {
"actions": {
"download": {
"href": self._get_signed_url(prefix, oid, expires_in=expires_in, filename=filename),
"href": self._get_signed_url(
prefix, oid, expires_in=expires_in, filename=filename, disposition=disposition),
"header": {},
"expires_in": expires_in
}
Expand All @@ -90,10 +93,13 @@ def _get_blob_path(self, prefix: str, oid: str) -> str:
return os.path.join(storage_prefix, prefix, oid)

def _get_signed_url(self, prefix: str, oid: str, expires_in: int, http_method: str = 'GET',
filename: Optional[str] = None) -> str:
filename: Optional[str] = None, disposition: Optional[str] = None) -> str:
bucket = self.storage_client.bucket(self.bucket_name)
blob = bucket.blob(self._get_blob_path(prefix, oid))
disposition = f'attachment; filename={filename}' if filename else None
if filename and disposition:
disposition = f'{disposition}; filename="{filename}"'

url: str = blob.generate_signed_url(expiration=timedelta(seconds=expires_in), method=http_method, version='v4',
response_disposition=disposition, credentials=self.credentials)
return url
Expand Down
5 changes: 5 additions & 0 deletions giftless/storage/local_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ def get_size(self, prefix: str, oid: str) -> int:
return os.path.getsize(self._get_path(prefix, oid))
raise exc.ObjectNotFound("Object was not found")

def get_mime_type(self, prefix: str, oid: str) -> str:
amercader marked this conversation as resolved.
Show resolved Hide resolved
if self.exists(prefix, oid):
return "application/octet-stream"
raise exc.ObjectNotFound("Object was not found")

def get_multipart_actions(self, prefix: str, oid: str, size: int, part_size: int, expires_in: int,
extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return super().get_multipart_actions(prefix, oid, size, part_size, expires_in, extra)
Expand Down
10 changes: 9 additions & 1 deletion giftless/transfer/basic_streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,18 @@ def get(self, organization, repo, oid):

filename = request.args.get('filename')
filename = safe_filename(filename)
headers = {'Content-Disposition': f'attachment; filename="{filename}"'} if filename else None
disposition = request.args.get('disposition')

headers = {}
if filename and disposition:
headers = {'Content-Disposition': f'attachment; filename="{filename}"'}
elif disposition:
headers = {'Content-Disposition': disposition}

if self.storage.exists(path, oid):
file = self.storage.get(path, oid)
mime_type = self.storage.get_mime_type(path, oid)
shevron marked this conversation as resolved.
Show resolved Hide resolved
headers['Content-Type'] = mime_type
return Response(file, direct_passthrough=True, status=200, headers=headers)
else:
raise NotFound("The object was not found")
Expand Down
6 changes: 5 additions & 1 deletion tests/test_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,15 @@ def test_batch_request_default_transfer():


def test_object_schema_accepts_x_fields():
payload = {"oid": "123abc", "size": 1212, "x-filename": "foobarbaz", "x-mtime": 123123123123}
payload = {
"oid": "123abc", "size": 1212, "x-filename": "foobarbaz",
"x-mtime": 123123123123, "x-disposition": "inline"
}
parsed = schema.ObjectSchema().load(payload)
assert "foobarbaz" == parsed['extra']['filename']
assert 123123123123 == parsed['extra']['mtime']
assert "123abc" == parsed['oid']
assert "inline" == parsed['extra']['disposition']


def test_object_schema_rejects_unknown_fields():
Expand Down