1010from io import BytesIO
1111from os import PathLike
1212from typing import IO , Any , Literal , NamedTuple , cast
13- from urllib .parse import urlparse
13+ from urllib .parse import quote , urlencode , urlparse
1414
1515import sentry_sdk
1616import urllib3
5252USER_AGENT = f"objectstore-client/{ version ('objectstore-client' )} "
5353
5454
55+ def _response_options_query (
56+ disposition : Literal ["inline" , "attachment" ] | None ,
57+ cache_control : str | None ,
58+ ) -> str :
59+ """Encode individual response options without changing legacy URLs."""
60+ pairs : list [tuple [str , str ]] = []
61+ if disposition is not None :
62+ if disposition not in ("inline" , "attachment" ):
63+ raise ValueError (
64+ f"unsupported response content disposition { disposition !r} "
65+ )
66+ pairs .append (("response-content-disposition" , disposition ))
67+ if cache_control is not None :
68+ pairs .append (("response-cache-control" , cache_control ))
69+ return urlencode (pairs , quote_via = quote )
70+
71+
5572class GetResponse (NamedTuple ):
5673 metadata : Metadata
5774 payload : IO [bytes ]
@@ -94,7 +111,7 @@ def __init__(
94111 "usecase name must contain only ASCII letters, digits, '_' or '-'"
95112 )
96113 self ._name = name
97-
114+
98115 if resumable_threshold_bytes != "none" and (
99116 type (resumable_threshold_bytes ) is not int or resumable_threshold_bytes < 0
100117 ):
@@ -709,7 +726,14 @@ def get(
709726 span .set_attribute ("objectstore.decompressed" , decompressed )
710727 return GetResponse (metadata , stream )
711728
712- def object_url (self , key : str , token_validity : timedelta | None = None ) -> str :
729+ def object_url (
730+ self ,
731+ key : str ,
732+ token_validity : timedelta | None = None ,
733+ * ,
734+ response_content_disposition : Literal ["inline" , "attachment" ] | None = None ,
735+ response_cache_control : str | None = None ,
736+ ) -> str :
713737 """
714738 Generates a GET url to the object with the given `key`.
715739
@@ -721,10 +745,23 @@ def object_url(self, key: str, token_validity: timedelta | None = None) -> str:
721745 information is embedded in the returned URL's query string, valid
722746 for the given duration.
723747
748+ Response options apply only to successful GET/HEAD responses. Disposition
749+ defaults to attachment; inline responses include browser sandboxing without
750+ restricting MIME types. Cache control is passed through independently of
751+ authorization expiry, so cached responses can outlive the URL's validity.
752+
753+ These options are not bound by the JWT and can be changed by recipients.
754+ Use :meth:`presigned_object_url` to bind the options to a signature.
755+
724756 Raises ``ValueError`` if ``token_validity`` is provided but no
725- ``SecretKey`` is configured on this session.
757+ ``SecretKey`` is configured on this session, or if disposition is invalid .
726758 """
727759 url = self ._make_url (key , full = True )
760+ query = _response_options_query (
761+ response_content_disposition , response_cache_control
762+ )
763+ if query :
764+ url = f"{ url } ?{ query } "
728765 if token_validity is None :
729766 return url
730767
@@ -734,13 +771,17 @@ def object_url(self, key: str, token_validity: timedelta | None = None) -> str:
734771 )
735772 # A JWT's compact serialization is base64url, whose alphabet is URL-safe,
736773 # so it can go in the query string as-is with no further encoding.
737- return f"{ url } ?{ PARAM_AUTH } ={ token } "
774+ separator = "&" if query else "?"
775+ return f"{ url } { separator } { PARAM_AUTH } ={ token } "
738776
739777 def presigned_object_url (
740778 self ,
741779 method : Literal ["GET" , "HEAD" ],
742780 key : str ,
743781 duration : timedelta = timedelta (hours = 1 ),
782+ * ,
783+ response_content_disposition : Literal ["inline" , "attachment" ] | None = None ,
784+ response_cache_control : str | None = None ,
744785 ) -> str :
745786 """
746787 Generates a pre-signed URL authorizing a single ``method`` request on the
@@ -752,12 +793,19 @@ def presigned_object_url(
752793
753794 Raises ``ValueError`` if no ``SecretKey`` is configured on this
754795 session's client, if ``method`` is not supported, or if ``duration``
755- is above the one-week maximum.
796+ is above the one-week maximum, or if disposition is invalid .
756797
757798 The returned URL carries a signature that allows the recipient to perform
758799 a request without an auth token. It can be handed to any HTTP client;
759800 the URL is already percent-encoded, and must be transmitted verbatim.
760801
802+ Response options apply only to successful GET/HEAD responses. Disposition
803+ defaults to attachment; inline responses include browser sandboxing without
804+ restricting MIME types. Cache control is passed through independently of
805+ authorization expiry, so cached responses can outlive the URL's validity.
806+
807+ Both response options are included in the signature when supplied.
808+
761809 Note that `HEAD` and `GET` are considered equivalent from the point of view
762810 of signatures, meaning that a signature for `GET` can also be used for
763811 `HEAD`, and viceversa.
@@ -795,6 +843,12 @@ def presigned_object_url(
795843 f"&{ presign .PARAM_DURATION } ={ duration_secs } "
796844 )
797845
846+ response_query = _response_options_query (
847+ response_content_disposition , response_cache_control
848+ )
849+ if response_query :
850+ encoded_query = f"{ encoded_query } &{ response_query } "
851+
798852 canonical = presign .build_canonical_form (
799853 method , encoded_path , encoded_query
800854 )
0 commit comments