Просмотр исходного кода

Add response-header overrides to BucketObject.generate_url

generate_url gains optional content_disposition/content_type parameters
that ask the backing store to serve the object with those response
headers on GET — needed by consumers (e.g. Galaxy) that hand out
presigned download URLs with a forced filename and MIME type.

The signature is uniform across all four providers:
- AWS: ResponseContentDisposition/ResponseContentType presigned params
- Azure: content_disposition/content_type on the blob SAS
- GCP: response-content-disposition/response-content-type signed query
  parameters
- OpenStack Swift: the filename portion of the disposition is honored
  via the tempurl filename/inline query parameters (appended after
  signing — the tempurl signature covers only method, path and expiry);
  the content type cannot be overridden

Both parameters are ignored for writable URLs.
Nuwan Goonasekera 1 месяц назад
Родитель
Сommit
56b82d8248

+ 6 - 0
CHANGELOG.rst

@@ -8,6 +8,12 @@
   3.10–3.13, and CI runs the mock-provider suite on both the lowest and highest
   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
   supported versions. ``mypy`` type-checks against 3.10 so newer-stdlib usage
   is caught at lint time.
   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)
 4.2.0 - July 9, 2026 (sha 60dbe305f0af46a7ce3eadd093756d31b8131b2e)
 -------------------------------------------------------------------
 -------------------------------------------------------------------

+ 17 - 1
cloudbridge/interfaces/resources.py

@@ -2435,7 +2435,9 @@ class BucketObject(CloudResource):
         pass
         pass
 
 
     @abstractmethod
     @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.
         Generate a signed URL to this object.
 
 
@@ -2449,6 +2451,20 @@ class BucketObject(CloudResource):
         :param writable: Write permission for this signed URL. Users with the URL
         :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
             will be able to upload to this object, but they will NOT be able to
             read from it.
             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``
         :rtype: ``str``
         :return: A URL to access the object.
         :return: A URL to access the object.

+ 10 - 2
cloudbridge/providers/aws/resources.py

@@ -947,11 +947,19 @@ class AWSBucketObject(BaseBucketObject):
     def delete(self) -> None:
     def delete(self) -> None:
         self._obj.delete()
         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 \
         return cast("AWSCloudProvider", self._provider).s3_conn.meta.client \
             .generate_presigned_url(
             .generate_presigned_url(
                 'put_object' if writable else 'get_object',
                 'put_object' if writable else 'get_object',
-                Params={'Bucket': self._obj.bucket_name, 'Key': self.id},
+                Params=params,
                 ExpiresIn=expires_in)
                 ExpiresIn=expires_in)
 
 
     def refresh(self) -> None:
     def refresh(self) -> None:

+ 6 - 2
cloudbridge/providers/azure/azure_client.py

@@ -508,7 +508,9 @@ class AzureClient(object):
         blob_client.delete_blob(delete_snapshots)
         blob_client.delete_blob(delete_snapshots)
 
 
     def get_blob_url(self, container_name: Any, blob_name: str,
     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()
         now = datetime.datetime.utcnow()
         expiry = now + datetime.timedelta(
         expiry = now + datetime.timedelta(
             seconds=expiry_time)
             seconds=expiry_time)
@@ -520,7 +522,9 @@ class AzureClient(object):
         sas = generate_blob_sas(
         sas = generate_blob_sas(
             self.storage_account, container_name, blob_name,
             self.storage_account, container_name, blob_name,
             permission=BlobSasPermissions(read=True, write=writable), expiry=expiry,
             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}"
         url = f"https://{self.storage_account}.blob.core.windows.net/{container_name}/{blob_name}?{sas}"
         return url
         return url

+ 6 - 2
cloudbridge/providers/azure/resources.py

@@ -311,13 +311,17 @@ class AzureBucketObject(BaseBucketObject):
         """
         """
         self._blob_client.delete_blob()
         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.
         Generate a URL to this object.
         """
         """
         return cast(
         return cast(
             "AzureCloudProvider", self._provider).azure_client.get_blob_url(
             "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:
     def refresh(self) -> None:
         pass
         pass

+ 13 - 2
cloudbridge/providers/gcp/resources.py

@@ -2198,7 +2198,9 @@ class GCPBucketObject(BaseBucketObject):
          .delete(bucket=self._obj['bucket'], object=self.name)
          .delete(bucket=self._obj['bucket'], object=self.name)
          .execute())
          .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.
         Generates a signed URL accessible to everyone.
 
 
@@ -2210,10 +2212,19 @@ class GCPBucketObject(BaseBucketObject):
         provider = cast("GCPCloudProvider", self._provider)
         provider = cast("GCPCloudProvider", self._provider)
         http_method = "PUT" if writable else "GET"
         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
         # pylint:disable=protected-access
         return helpers.generate_signed_url(
         return helpers.generate_signed_url(
             provider._credentials, self._obj['bucket'], self.name,
             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:
     def refresh(self) -> None:
         # pylint:disable=protected-access
         # pylint:disable=protected-access

+ 21 - 3
cloudbridge/providers/openstack/resources.py

@@ -14,6 +14,7 @@ from typing import IO
 from typing import Iterable
 from typing import Iterable
 from typing import TYPE_CHECKING
 from typing import TYPE_CHECKING
 from typing import cast
 from typing import cast
+from urllib.parse import quote
 from urllib.parse import urljoin
 from urllib.parse import urljoin
 from urllib.parse import urlparse
 from urllib.parse import urlparse
 
 
@@ -1474,7 +1475,9 @@ class OpenStackBucketObject(BaseBucketObject):
             for del_res in swift.delete(self.cbcontainer.name, [self.name, ]):
             for del_res in swift.delete(self.cbcontainer.name, [self.name, ]):
                 del_res['success']
                 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"
         http_method = "PUT" if writable else "GET"
         # Set a temp url key on the object (http://bit.ly/2NBiXGD)
         # Set a temp url key on the object (http://bit.ly/2NBiXGD)
         temp_url_key = "cloudbridge-tmp-url-key"
         temp_url_key = "cloudbridge-tmp-url-key"
@@ -1484,8 +1487,23 @@ class OpenStackBucketObject(BaseBucketObject):
         base_url = urlparse(swift.get_service_auth()[0])
         base_url = urlparse(swift.get_service_auth()[0])
         access_point = "{0}://{1}".format(base_url.scheme, base_url.netloc)
         access_point = "{0}://{1}".format(base_url.scheme, base_url.netloc)
         url_path = "/".join([base_url.path, self.cbcontainer.name, self.name])
         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:
     def refresh(self) -> None:
         self._obj = self.cbcontainer.objects.get(self.id)._obj
         self._obj = self.cbcontainer.objects.get(self.id)._obj

+ 63 - 0
tests/test_object_store_service.py

@@ -229,6 +229,69 @@ class CloudObjectStoreServiceTestCase(ProviderTestBase):
                 obj.save_content(target_stream)
                 obj.save_content(target_stream)
                 self.assertEqual(target_stream.getvalue(), content)
                 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'])
     @helpers.skipIfNoService(['storage.buckets'])
     def test_upload_download_bucket_content_from_file(self):
     def test_upload_download_bucket_content_from_file(self):
         name = "cbtestbucketobjs-{0}".format(helpers.get_uuid())
         name = "cbtestbucketobjs-{0}".format(helpers.get_uuid())