Skip to content

Commit 4a643c5

Browse files
committed
allow served filename to be different
fixes #52
1 parent a2ffdbb commit 4a643c5

File tree

7 files changed

+75
-4
lines changed

7 files changed

+75
-4
lines changed

keg_storage/backends/azure.py

+2
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,7 @@ def link_to(
313313
path: str,
314314
operation: typing.Union[base.ShareLinkOperation, str],
315315
expire: typing.Union[arrow.Arrow, datetime],
316+
output_path: typing.Optional[str] = None,
316317
) -> str:
317318
if not self.account_url or not self.key:
318319
raise ValueError('Cannot create a SAS URL without account credentials')
@@ -336,6 +337,7 @@ def link_to(
336337
account_key=self.key,
337338
permission=perms,
338339
expiry=expire,
340+
content_disposition=f'attachment;filename={output_path}' if output_path else None,
339341
)
340342
escaped_path = urllib.parse.quote(path, safe="")
341343
url = urllib.parse.urljoin(self.account_url, '{}/{}'.format(self.bucket, escaped_path))

keg_storage/backends/base.py

+7-3
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,8 @@ def link_to(
170170
self,
171171
path: str,
172172
operation: typing.Union[ShareLinkOperation, str],
173-
expire: typing.Union[arrow.Arrow, datetime]
173+
expire: typing.Union[arrow.Arrow, datetime],
174+
output_path: typing.Optional[str] = None,
174175
) -> str:
175176
"""
176177
Returns a URL allowing direct the specified operations to be performed on the given path
@@ -351,7 +352,8 @@ def link_to(
351352
self,
352353
path: str,
353354
operation: typing.Union[ShareLinkOperation, str],
354-
expire: typing.Union[arrow.Arrow, datetime]
355+
expire: typing.Union[arrow.Arrow, datetime],
356+
output_path: typing.Optional[str] = None,
355357
) -> str:
356358
"""
357359
Create a URL pointing to the given `linked_endpoint` containing a JWT authorizing the user
@@ -380,7 +382,9 @@ def link_to(
380382
operation=operation,
381383
expire=expire
382384
)
383-
return flask.url_for(self.linked_endpoint, token=token.decode(), _external=True)
385+
return flask.url_for(
386+
self.linked_endpoint, token=token.decode(), output_path=output_path, _external=True
387+
)
384388

385389

386390
class FileNotFoundInStorageError(Exception):

keg_storage/backends/s3.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,8 @@ def link_to(
268268
self,
269269
path: str,
270270
operation: typing.Union[ShareLinkOperation, str],
271-
expire: typing.Union[arrow.Arrow, datetime]
271+
expire: typing.Union[arrow.Arrow, datetime],
272+
output_path: typing.Optional[str] = None,
272273
) -> str:
273274
operation = ShareLinkOperation.as_operation(operation)
274275
if operation.name is None:
@@ -278,6 +279,8 @@ def link_to(
278279
extra_params = {}
279280
if operation == ShareLinkOperation.download:
280281
method = 'get_object'
282+
if output_path:
283+
extra_params['ResponseContentDisposition'] = f'attachment;filename={output_path}'
281284
elif operation == ShareLinkOperation.upload:
282285
method = 'put_object'
283286
extra_params = {'ContentType': 'application/octet-stream'}

keg_storage/plugin.py

+6
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,16 @@ def get(self):
106106
if not token_data.allow_download:
107107
flask.abort(403)
108108

109+
headers = {}
110+
output_path = flask.request.args.get('output_path')
111+
if output_path:
112+
headers['Content-Disposition'] = f'attachment; filename={output_path}'
113+
109114
fp = storage.open(token_data.path, backends.FileMode.read)
110115
return flask.Response(
111116
fp.iter_chunks(),
112117
mimetype='application/octet-stream',
118+
headers=headers,
113119
)
114120

115121
def on_upload_success(self, token_data: backends.InternalLinkTokenData):

keg_storage/tests/test_backend_azure.py

+15
Original file line numberDiff line numberDiff line change
@@ -323,8 +323,23 @@ def test_link_to(self, expire: Union[arrow.Arrow, datetime.datetime],
323323
assert qs['se'] == ['2019-01-02T03:04:05Z']
324324
assert qs['sp'] == [perms]
325325
assert qs['sig']
326+
assert 'rscd' not in qs
326327
assert 'sip' not in qs
327328

329+
def test_link_to_with_output_path(self):
330+
expire = arrow.get(2019, 1, 2, 3, 4, 5)
331+
ops = base.ShareLinkOperation.download
332+
storage = create_storage()
333+
url = storage.link_to(
334+
'abc/def.txt',
335+
operation=ops,
336+
expire=expire,
337+
output_path='myfile.txt',
338+
)
339+
parsed = urlparse.urlparse(url)
340+
qs = urlparse.parse_qs(parsed.query)
341+
assert qs['rscd'] == ['attachment;filename=myfile.txt']
342+
328343
def test_sas_create_container_url(self):
329344
storage = backends.AzureStorage(
330345
**{"sas_container_url": "https://foo.blob.core.windows.net/test?sp=rwdl"}

keg_storage/tests/test_backend_s3.py

+22
Original file line numberDiff line numberDiff line change
@@ -362,3 +362,25 @@ def test_link_to_success(self, m_boto, op, method, extra_params):
362362
ExpiresIn=3600,
363363
Params={'Bucket': 'bucket', 'Key': 'foo/bar', **extra_params}
364364
)
365+
366+
@freezegun.freeze_time('2020-04-27')
367+
def test_link_to_download_output_path(self, m_boto):
368+
op = ShareLinkOperation.download
369+
method = 'get_object'
370+
extra_params = {'ResponseContentDisposition': 'attachment;filename=myfile.txt'}
371+
s3 = backends.S3Storage('bucket', aws_region='us-east-1')
372+
s3.client.generate_presigned_url.return_value = 'https://localhost/foo'
373+
374+
result = s3.link_to(
375+
path='foo/bar',
376+
operation=op,
377+
expire=arrow.get(2020, 4, 27, 1),
378+
output_path='myfile.txt'
379+
)
380+
assert result == 'https://localhost/foo'
381+
382+
s3.client.generate_presigned_url.assert_called_once_with(
383+
ClientMethod=method,
384+
ExpiresIn=3600,
385+
Params={'Bucket': 'bucket', 'Key': 'foo/bar', **extra_params}
386+
)

keg_storage/tests/test_views.py

+19
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,31 @@ def test_get_success(self, tmp_path: pathlib.Path):
7474
operation=ShareLinkOperation.download,
7575
expire=arrow.utcnow().shift(hours=1)
7676
)
77+
assert 'output_path' not in url
7778

7879
resp = self.client.get(url, headers={'StorageRoot': str(tmp_path)})
7980
assert resp.status_code == 200
8081
assert resp.content_type == 'application/octet-stream'
8182
assert resp.body == b'foo'
8283

84+
def test_get_with_output_path(self, tmp_path: pathlib.Path):
85+
storage = create_local_storage(tmp_path)
86+
storage.upload(io.BytesIO(b'foo'), 'abc.txt')
87+
assert tmp_path.joinpath('abc.txt').exists()
88+
89+
url = storage.link_to(
90+
path='abc.txt',
91+
operation=ShareLinkOperation.download,
92+
expire=arrow.utcnow().shift(hours=1),
93+
output_path='myfile.txt',
94+
)
95+
96+
resp = self.client.get(url, headers={'StorageRoot': str(tmp_path)})
97+
assert resp.status_code == 200
98+
assert resp.content_type == 'application/octet-stream'
99+
assert resp.content_disposition == 'attachment; filename=myfile.txt'
100+
assert resp.body == b'foo'
101+
83102
@pytest.mark.parametrize('method', ['post', 'put'])
84103
def test_post_put_operation_not_allowed(self, tmp_path: pathlib.Path, method: str):
85104
storage = create_local_storage(tmp_path)

0 commit comments

Comments
 (0)