Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
3.10–3.13, and CI runs the mock-provider suite on both the lowest and highest
supported versions. ``mypy`` type-checks against 3.10 so newer-stdlib usage
is caught at lint time.
* **Response-header overrides on signed URLs.** ``BucketObject.generate_url`` now
accepts optional ``content_disposition`` and ``content_type`` parameters that ask
the backing store to serve the object with those response headers on GET. Honored
fully by AWS, Azure and GCP; OpenStack Swift honors the filename portion of the
disposition via its tempurl ``filename`` parameter (the content type cannot be
overridden). Both are ignored for writable URLs.

4.2.0 - July 9, 2026 (sha 60dbe305f0af46a7ce3eadd093756d31b8131b2e)
-------------------------------------------------------------------
Expand Down
18 changes: 17 additions & 1 deletion cloudbridge/interfaces/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -2435,7 +2435,9 @@ def delete(self) -> None:
pass

@abstractmethod
def generate_url(self, expires_in: int, writable: bool = False) -> str:
def generate_url(self, expires_in: int, writable: bool = False,
content_disposition: str | None = None,
content_type: str | None = None) -> str:
"""
Generate a signed URL to this object.

Expand All @@ -2449,6 +2451,20 @@ def generate_url(self, expires_in: int, writable: bool = False) -> str:
:param writable: Write permission for this signed URL. Users with the URL
will be able to upload to this object, but they will NOT be able to
read from it.
:type content_disposition: ``str``
:param content_disposition: When set, ask the backing store to serve
the object with this ``Content-Disposition`` response header on
GET (e.g. ``attachment; filename="data.txt"``). This is a
response-serving hint: every provider accepts it and honors it
where the backing store supports response-header overrides (AWS,
Azure and GCP fully; OpenStack Swift honors the filename portion
via its tempurl ``filename`` parameter). Ignored when
``writable`` is ``True``.
:type content_type: ``str``
:param content_type: When set, ask the backing store to serve the
object with this ``Content-Type`` response header on GET. Honored
by AWS, Azure and GCP; OpenStack Swift cannot override the
content type. Ignored when ``writable`` is ``True``.

:rtype: ``str``
:return: A URL to access the object.
Expand Down
12 changes: 10 additions & 2 deletions cloudbridge/providers/aws/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -947,11 +947,19 @@ def upload_from_file(self, path: str,
def delete(self) -> None:
self._obj.delete()

def generate_url(self, expires_in: int, writable: bool = False) -> str:
def generate_url(self, expires_in: int, writable: bool = False,
content_disposition: str | None = None,
content_type: str | None = None) -> str:
params = {'Bucket': self._obj.bucket_name, 'Key': self.id}
if not writable:
if content_disposition:
params['ResponseContentDisposition'] = content_disposition
if content_type:
params['ResponseContentType'] = content_type
return cast("AWSCloudProvider", self._provider).s3_conn.meta.client \
.generate_presigned_url(
'put_object' if writable else 'get_object',
Params={'Bucket': self._obj.bucket_name, 'Key': self.id},
Params=params,
ExpiresIn=expires_in)

def refresh(self) -> None:
Expand Down
8 changes: 6 additions & 2 deletions cloudbridge/providers/azure/azure_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,9 @@ def delete_blob(self, container_name: str, blob_name: str,
blob_client.delete_blob(delete_snapshots)

def get_blob_url(self, container_name: Any, blob_name: str,
expiry_time: int, writable: bool) -> str:
expiry_time: int, writable: bool,
content_disposition: str | None = None,
content_type: str | None = None) -> str:
now = datetime.datetime.utcnow()
expiry = now + datetime.timedelta(
seconds=expiry_time)
Expand All @@ -520,7 +522,9 @@ def get_blob_url(self, container_name: Any, blob_name: str,
sas = generate_blob_sas(
self.storage_account, container_name, blob_name,
permission=BlobSasPermissions(read=True, write=writable), expiry=expiry,
user_delegation_key=delegation_key
user_delegation_key=delegation_key,
content_disposition=content_disposition,
content_type=content_type
)
url = f"https://{self.storage_account}.blob.core.windows.net/{container_name}/{blob_name}?{sas}"
return url
Expand Down
8 changes: 6 additions & 2 deletions cloudbridge/providers/azure/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,13 +311,17 @@ def delete(self) -> None:
"""
self._blob_client.delete_blob()

def generate_url(self, expires_in: int, writable: bool = False) -> str:
def generate_url(self, expires_in: int, writable: bool = False,
content_disposition: str | None = None,
content_type: str | None = None) -> str:
"""
Generate a URL to this object.
"""
return cast(
"AzureCloudProvider", self._provider).azure_client.get_blob_url(
self._container, self.name, expires_in, writable)
self._container, self.name, expires_in, writable,
content_disposition=None if writable else content_disposition,
content_type=None if writable else content_type)

def refresh(self) -> None:
pass
Expand Down
15 changes: 13 additions & 2 deletions cloudbridge/providers/gcp/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -2198,7 +2198,9 @@ def delete(self) -> None:
.delete(bucket=self._obj['bucket'], object=self.name)
.execute())

def generate_url(self, expires_in: int, writable: bool = False) -> str:
def generate_url(self, expires_in: int, writable: bool = False,
content_disposition: str | None = None,
content_type: str | None = None) -> str:
"""
Generates a signed URL accessible to everyone.

Expand All @@ -2210,10 +2212,19 @@ def generate_url(self, expires_in: int, writable: bool = False) -> str:
provider = cast("GCPCloudProvider", self._provider)
http_method = "PUT" if writable else "GET"

query_parameters: dict[str, Any] = {}
if not writable:
if content_disposition:
query_parameters[
'response-content-disposition'] = content_disposition
if content_type:
query_parameters['response-content-type'] = content_type

# pylint:disable=protected-access
return helpers.generate_signed_url(
provider._credentials, self._obj['bucket'], self.name,
expiration=expires_in, http_method=http_method)
expiration=expires_in, http_method=http_method,
query_parameters=query_parameters or None)

def refresh(self) -> None:
# pylint:disable=protected-access
Expand Down
24 changes: 21 additions & 3 deletions cloudbridge/providers/openstack/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from typing import Iterable
from typing import TYPE_CHECKING
from typing import cast
from urllib.parse import quote
from urllib.parse import urljoin
from urllib.parse import urlparse

Expand Down Expand Up @@ -1474,7 +1475,9 @@ def delete(self) -> None:
for del_res in swift.delete(self.cbcontainer.name, [self.name, ]):
del_res['success']

def generate_url(self, expires_in: int, writable: bool = False) -> str:
def generate_url(self, expires_in: int, writable: bool = False,
content_disposition: str | None = None,
content_type: str | None = None) -> str:
http_method = "PUT" if writable else "GET"
# Set a temp url key on the object (http://bit.ly/2NBiXGD)
temp_url_key = "cloudbridge-tmp-url-key"
Expand All @@ -1484,8 +1487,23 @@ def generate_url(self, expires_in: int, writable: bool = False) -> str:
base_url = urlparse(swift.get_service_auth()[0])
access_point = "{0}://{1}".format(base_url.scheme, base_url.netloc)
url_path = "/".join([base_url.path, self.cbcontainer.name, self.name])
return urljoin(access_point, generate_temp_url(url_path, expires_in,
temp_url_key, http_method))
url = urljoin(access_point, generate_temp_url(url_path, expires_in,
temp_url_key,
http_method))
if not writable and content_disposition:
# Swift's tempurl middleware serves Content-Disposition via the
# unsigned `filename`/`inline` query parameters (the signature
# covers only method, path and expiry); Content-Type cannot be
# overridden.
match = re.search(r'filename\*?=(?:"([^"]+)"|([^;]+))',
content_disposition)
filename = (match.group(1) or match.group(2)).strip() \
if match else None
if content_disposition.strip().lower().startswith('inline'):
url += '&inline'
if filename:
url += '&filename=' + quote(filename)
return url

def refresh(self) -> None:
self._obj = self.cbcontainer.objects.get(self.id)._obj
Expand Down
63 changes: 63 additions & 0 deletions tests/test_object_store_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,69 @@ def test_generate_url_write_permissions(self):
obj.save_content(target_stream)
self.assertEqual(target_stream.getvalue(), content)

@helpers.skipIfNoService(['storage.buckets'])
def test_generate_url_with_response_headers(self):
name = "cbtestbucketobjs-{0}".format(helpers.get_uuid())
test_bucket = self.provider.storage.buckets.create(name)

with cb_helpers.cleanup_action(lambda: test_bucket.delete()):
obj_name = "hello_response_headers.txt"
obj = test_bucket.objects.create(obj_name)

with cb_helpers.cleanup_action(lambda: obj.delete()):
content = b"Hello World. Serve me with response headers."
obj.upload(content)

disposition = 'attachment; filename="hello.txt"'
content_type = "application/octet-stream"
url = obj.generate_url(100,
content_disposition=disposition,
content_type=content_type)
if isinstance(self.provider, TestMockHelperMixin):
# Presigned URLs are constructed client-side, so the
# response-header overrides can be asserted on the query
# string without a network round trip.
self.assertIn('response-content-disposition', url)
self.assertIn('response-content-type', url)
raise self.skipTest(
"Skipping rest of test - mock providers can't"
" access generated url")
response = requests.get(url)
self.assertEqual(response.content, content)
got_disposition = response.headers.get(
'Content-Disposition', '')
self.assertTrue(got_disposition.startswith('attachment'),
"Expected attachment disposition, got: "
f"{got_disposition}")
self.assertIn('hello.txt', got_disposition)
if self.provider.PROVIDER_ID != 'openstack':
# Swift's tempurl middleware cannot override Content-Type
# (it only honors the filename portion of the disposition
# via its `filename` query parameter).
self.assertEqual(response.headers.get('Content-Type'),
content_type)

@helpers.skipIfNoService(['storage.buckets'])
def test_generate_url_writable_ignores_response_headers(self):
name = "cbtestbucketobjs-{0}".format(helpers.get_uuid())
test_bucket = self.provider.storage.buckets.create(name)

with cb_helpers.cleanup_action(lambda: test_bucket.delete()):
obj_name = "hello_response_headers.txt"
obj = test_bucket.objects.create(obj_name)

with cb_helpers.cleanup_action(lambda: obj.delete()):
url = obj.generate_url(
100, writable=True,
content_disposition='attachment; filename="hello.txt"',
content_type="application/octet-stream")
# Response-header overrides only make sense for reads; a
# writable URL must not carry them (across all providers:
# AWS/GCP query params, Azure SAS rscd/rsct, Swift filename).
self.assertNotIn('response-content-disposition', url)
self.assertNotIn('rscd=', url)
self.assertNotIn('filename=', url)

@helpers.skipIfNoService(['storage.buckets'])
def test_upload_download_bucket_content_from_file(self):
name = "cbtestbucketobjs-{0}".format(helpers.get_uuid())
Expand Down