diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c8884168..a6f64467 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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) ------------------------------------------------------------------- diff --git a/cloudbridge/interfaces/resources.py b/cloudbridge/interfaces/resources.py index 9f89da3c..4f0346b8 100644 --- a/cloudbridge/interfaces/resources.py +++ b/cloudbridge/interfaces/resources.py @@ -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. @@ -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. diff --git a/cloudbridge/providers/aws/resources.py b/cloudbridge/providers/aws/resources.py index 926668be..e5994167 100644 --- a/cloudbridge/providers/aws/resources.py +++ b/cloudbridge/providers/aws/resources.py @@ -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: diff --git a/cloudbridge/providers/azure/azure_client.py b/cloudbridge/providers/azure/azure_client.py index c0d008f8..dddd6e38 100644 --- a/cloudbridge/providers/azure/azure_client.py +++ b/cloudbridge/providers/azure/azure_client.py @@ -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) @@ -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 diff --git a/cloudbridge/providers/azure/resources.py b/cloudbridge/providers/azure/resources.py index 27a651d3..aa7ce9ef 100644 --- a/cloudbridge/providers/azure/resources.py +++ b/cloudbridge/providers/azure/resources.py @@ -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 diff --git a/cloudbridge/providers/gcp/resources.py b/cloudbridge/providers/gcp/resources.py index acbe99c1..5dbd06a8 100644 --- a/cloudbridge/providers/gcp/resources.py +++ b/cloudbridge/providers/gcp/resources.py @@ -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. @@ -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 diff --git a/cloudbridge/providers/openstack/resources.py b/cloudbridge/providers/openstack/resources.py index 72fa56b7..d2f58419 100644 --- a/cloudbridge/providers/openstack/resources.py +++ b/cloudbridge/providers/openstack/resources.py @@ -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 @@ -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" @@ -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 diff --git a/tests/test_object_store_service.py b/tests/test_object_store_service.py index e6bb5cf8..9fc6e31a 100644 --- a/tests/test_object_store_service.py +++ b/tests/test_object_store_service.py @@ -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())