Skip to content

Commit 43720b0

Browse files
committed
feat: Support download response options
1 parent 95649a1 commit 43720b0

16 files changed

Lines changed: 798 additions & 69 deletions

File tree

‎clients/CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### New Features ✨
6+
7+
- (py-client) Add disposition and cache-control options to object URLs and pre-signed URLs (FS-570).
8+
39
## 0.2.10
410

511
### New Features ✨

‎clients/python/README.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,40 @@ with urllib.request.urlopen(url) as resp:
254254
content = resp.read()
255255
```
256256

257+
### Download response options
258+
259+
Both `Session.object_url` and `Session.presigned_object_url` accept keyword-only
260+
`response_content_disposition` and `response_cache_control` options:
261+
262+
```python
263+
url = session.presigned_object_url(
264+
"GET",
265+
"my-key",
266+
response_content_disposition="inline",
267+
response_cache_control="private, max-age=300",
268+
)
269+
```
270+
271+
Disposition accepts `inline` or `attachment`, defaulting to `attachment` when omitted.
272+
The stored filename is retained. Cache control is passed through as an HTTP header value;
273+
it is not interpreted or limited by authorization expiry. A cached response can remain
274+
usable after the URL expires. Omitting cache control preserves the server's existing behavior.
275+
276+
These options apply only to successful GET/HEAD responses, including ranged downloads.
277+
404 and other error responses retain their existing headers and caching behavior.
278+
Successful downloads include `X-Content-Type-Options: nosniff`; inline responses also include
279+
`Content-Security-Policy: sandbox` without relaxation tokens. Inline MIME types are not
280+
restricted by an allowlist. Sandboxing is a browser safeguard, not content sanitization,
281+
and does not prevent all external resource loads.
282+
283+
The object's declared content type and compression encoding cannot be overridden.
284+
Content-language and expires overrides are not supported.
285+
286+
Pre-signed URLs bind both options into the signature. JWT-bearing `object_url` URLs
287+
only authenticate the JWT's claims, so recipients can edit the response options.
288+
Existing URLs and signatures are unchanged when the options are omitted.
289+
Deploy server support before callers rely on these options; older servers ignore them.
290+
257291
## Configuration
258292

259293
In production, store the `Client` and `Usecase` at module level and reuse them.

‎clients/python/src/objectstore_client/client.py‎

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from io import BytesIO
1111
from os import PathLike
1212
from typing import IO, Any, Literal, NamedTuple, cast
13-
from urllib.parse import urlparse
13+
from urllib.parse import quote, urlencode, urlparse
1414

1515
import sentry_sdk
1616
import urllib3
@@ -52,6 +52,23 @@
5252
USER_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+
5572
class 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
)

‎clients/python/tests/test_e2e.py‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1094,6 +1094,83 @@ def test_presigned_get_encoding_corner_cases(server_url: str, key: str) -> None:
10941094
assert body == payload
10951095

10961096

1097+
@pytest.mark.parametrize("method", ["GET", "HEAD"])
1098+
def test_presigned_response_options(server_url: str, method: str) -> None:
1099+
session = _presign_session(server_url)
1100+
key = "inline-café/looks%20encoded&+"
1101+
session.put(b"<html>example</html>", key=key, content_type="text/html")
1102+
cache_control = 'private, max-age=3600, x="a&b=+%?#"'
1103+
url = session.presigned_object_url(
1104+
"GET",
1105+
key,
1106+
response_content_disposition="inline",
1107+
response_cache_control=cache_control,
1108+
)
1109+
with urllib.request.urlopen(urllib.request.Request(url, method=method)) as response:
1110+
assert response.status == 200
1111+
assert response.headers["content-disposition"] == "inline"
1112+
assert response.headers["cache-control"] == cache_control
1113+
assert response.headers["content-type"] == "text/html"
1114+
assert response.headers["content-security-policy"] == "sandbox"
1115+
assert response.headers["x-content-type-options"] == "nosniff"
1116+
assert response.read() == (b"" if method == "HEAD" else b"<html>example</html>")
1117+
1118+
changed_case = url.replace(
1119+
"response-content-disposition", "RESPONSE-CONTENT-DISPOSITION"
1120+
)
1121+
status, _ = _fetch(changed_case, method=method)
1122+
assert status == 200
1123+
for tampered in [
1124+
url.replace("=inline", "=attachment"),
1125+
url.replace("max-age%3D3600", "max-age%3D60"),
1126+
url.replace("response-content-disposition=inline&", ""),
1127+
]:
1128+
status, _ = _fetch(tampered, method=method)
1129+
assert status == 401
1130+
1131+
1132+
def test_jwt_response_options_remain_editable(server_url: str) -> None:
1133+
session = _presign_session(server_url)
1134+
session.put(b"example", key="jwt-options")
1135+
url = session.object_url(
1136+
"jwt-options",
1137+
token_validity=timedelta(minutes=5),
1138+
response_content_disposition="inline",
1139+
response_cache_control="no-store",
1140+
)
1141+
for target, expected in [
1142+
(url, "inline"),
1143+
(url.replace("=inline", "=attachment"), "attachment"),
1144+
]:
1145+
with urllib.request.urlopen(target) as response:
1146+
assert response.status == 200
1147+
assert response.headers["content-disposition"] == expected
1148+
assert response.headers["cache-control"] == "no-store"
1149+
1150+
1151+
def test_inline_preserves_stored_mime_type_and_compression(server_url: str) -> None:
1152+
session = Client(server_url, token=TestSecretKey.get()).session(
1153+
Usecase("test-usecase", compression="zstd"), org=42, project=1337
1154+
)
1155+
payload = b'<svg xmlns="http://www.w3.org/2000/svg"><script>example</script></svg>'
1156+
session.put(payload, key="svg-options", content_type="image/svg+xml")
1157+
url = session.object_url(
1158+
"svg-options",
1159+
token_validity=timedelta(minutes=5),
1160+
response_content_disposition="inline",
1161+
)
1162+
# Unsupported overrides cannot change the stored metadata.
1163+
url += "&response-content-type=text/html&response-content-encoding=gzip"
1164+
with urllib.request.urlopen(url) as response:
1165+
assert response.headers["content-type"] == "image/svg+xml"
1166+
assert response.headers["content-encoding"] == "zstd"
1167+
assert response.headers["content-security-policy"] == "sandbox"
1168+
with zstandard.ZstdDecompressor().stream_reader(
1169+
BytesIO(response.read())
1170+
) as reader:
1171+
assert reader.read() == payload
1172+
1173+
10971174
def test_object_url_read_only_token_succeeds(server_url: str) -> None:
10981175
session = _presign_session(server_url)
10991176
session.put(b"read only hello", key="read-only-url")

‎clients/python/tests/test_presign.py‎

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,17 @@
22

33
import base64
44
import os
5+
from datetime import UTC, datetime, timedelta
6+
from unittest.mock import patch
7+
from urllib.parse import parse_qs, urlsplit
58

6-
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
9+
import pytest
10+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
11+
Ed25519PrivateKey,
12+
Ed25519PublicKey,
13+
)
714
from cryptography.hazmat.primitives.serialization import load_pem_public_key
8-
from objectstore_client import presign, utils
15+
from objectstore_client import Client, Usecase, presign, utils
916
from objectstore_client.auth import SecretKey
1017

1118
TEST_EDDSA_PRIVKEY_PATH = os.path.join(os.path.dirname(__file__), "ed25519.private.pem")
@@ -73,3 +80,96 @@ def test_sign_canonical_form_roundtrips_with_public_key() -> None:
7380
signature = base64.urlsafe_b64decode(signature_b64 + padding)
7481
assert len(signature) == 64
7582
public_key.verify(signature, canonical.encode()) # raises on mismatch
83+
84+
85+
@pytest.mark.parametrize("key", ["café/looks%20encoded&+", "nested/space key"])
86+
def test_response_options_are_encoded_and_signed(key: str) -> None:
87+
with open(TEST_EDDSA_PRIVKEY_PATH) as f:
88+
secret = SecretKey("test_kid", f.read())
89+
session = Client("https://example.com/prefix", token=secret).session(
90+
Usecase("testing"), org=1
91+
)
92+
cache_control = 'private, max-age=3600, x="a&b=+%?#"'
93+
with patch("objectstore_client.client.datetime") as clock:
94+
clock.now.return_value = datetime(2026, 4, 20, 13, 37, tzinfo=UTC)
95+
legacy = session.presigned_object_url("GET", key)
96+
explicit_none = session.presigned_object_url(
97+
"GET", key, response_content_disposition=None, response_cache_control=None
98+
)
99+
url = session.presigned_object_url(
100+
"GET",
101+
key,
102+
response_content_disposition="inline",
103+
response_cache_control=cache_control,
104+
)
105+
assert legacy == explicit_none
106+
legacy_path = utils.encode_path(f"/prefix/v1/objects/testing/org=1/{key}")
107+
legacy_query = "os_kid=test_kid&os_timestamp=2026-04-20T13:37:00Z&os_duration=3600"
108+
legacy_signature = secret.signature_for_canonical_form(
109+
presign.build_canonical_form("GET", legacy_path, legacy_query)
110+
)
111+
assert (
112+
legacy
113+
== f"https://example.com:443{legacy_path}?{legacy_query}&os_sig={legacy_signature}"
114+
)
115+
parsed = urlsplit(url)
116+
assert parsed.path == urlsplit(legacy).path
117+
query = parse_qs(parsed.query)
118+
assert query["response-content-disposition"] == ["inline"]
119+
assert query["response-cache-control"] == [cache_control]
120+
assert len(query) == 6
121+
assert "%26" in parsed.query and "%2B" in parsed.query
122+
canonical = presign.build_canonical_form("GET", parsed.path, parsed.query)
123+
assert secret.signature_for_canonical_form(canonical) == query["os_sig"][0]
124+
altered = parsed.query.replace("=inline", "=attachment")
125+
assert (
126+
secret.signature_for_canonical_form(
127+
presign.build_canonical_form("GET", parsed.path, altered)
128+
)
129+
!= query["os_sig"][0]
130+
)
131+
132+
133+
def test_legacy_canonical_vector() -> None:
134+
path = "/prefix/v1/objects/testing/org=1;project=2/caf%C3%A9/looks%2520encoded&+"
135+
query = (
136+
"unknown=b&os_sig=ignored&dup=b&&OS_KID=test&dup=a&flag"
137+
"&encoded=a%26b%3D%2B&unknown=a"
138+
)
139+
expected = (
140+
f"GET\n{path}\n"
141+
"dup=a&dup=b&encoded=a%26b%3D%2B&flag&os_kid=test&unknown=a&unknown=b"
142+
)
143+
assert presign.build_canonical_form("HEAD", path, query) == expected
144+
signature = Ed25519PrivateKey.from_private_bytes(bytes([7]) * 32).sign(
145+
expected.encode()
146+
)
147+
assert base64.urlsafe_b64encode(signature).decode().rstrip("=") == (
148+
"2_8Qv-7PKMDMd-ku1kF33Xh8aPXjLj3QaIE_nLJuaDUGxtWUDQzCiU6Uz0Mb1zgH"
149+
"tfsCTB6CcQoZitooeJgmBw"
150+
)
151+
152+
153+
def test_jwt_url_options_roundtrip_without_binding_token() -> None:
154+
with open(TEST_EDDSA_PRIVKEY_PATH) as f:
155+
secret = SecretKey("test_kid", f.read())
156+
session = Client("https://example.com/prefix", token=secret).session(
157+
Usecase("testing")
158+
)
159+
with patch.object(session, "mint_token", return_value="same.jwt.token"):
160+
legacy = session.object_url("key", token_validity=timedelta(minutes=5))
161+
url = session.object_url(
162+
"key",
163+
token_validity=timedelta(minutes=5),
164+
response_content_disposition="inline",
165+
response_cache_control='private, x="&=+"',
166+
)
167+
assert parse_qs(urlsplit(url).query) == {
168+
"os_auth": ["same.jwt.token"],
169+
"response-content-disposition": ["inline"],
170+
"response-cache-control": ['private, x="&=+"'],
171+
}
172+
assert legacy.endswith("?os_auth=same.jwt.token")
173+
assert session.object_url(
174+
"key", response_content_disposition=None
175+
) == session.object_url("key")

0 commit comments

Comments
 (0)