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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions clients/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### New Features ✨

- (Python) Add a content disposition option to object URLs and pre-signed URLs (FS-570).

## 0.2.10

### New Features ✨
Expand Down
16 changes: 16 additions & 0 deletions clients/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,22 @@ with urllib.request.urlopen(url) as resp:
content = resp.read()
```

### Download response options

Both `Session.object_url` and `Session.presigned_object_url` accept a
`response_content_disposition` option:

```python
url = session.presigned_object_url(
"GET",
"my-key",
response_content_disposition="inline",
)
```

Disposition accepts `inline` or `attachment`, defaulting to `attachment` when omitted.
The stored filename is retained.

## Configuration

In production, store the `Client` and `Usecase` at module level and reuse them.
Expand Down
44 changes: 39 additions & 5 deletions clients/python/src/objectstore_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@
USER_AGENT = f"objectstore-client/{version('objectstore-client')}"


def _response_options_query(
disposition: Literal["inline", "attachment"],
) -> str:
"""Validate and encode the response-content-disposition query parameter."""
if disposition not in ("inline", "attachment"):
raise ValueError(f"unsupported response content disposition {disposition!r}")
return utils.encode_query(f"response-content-disposition={disposition}")


class GetResponse(NamedTuple):
metadata: Metadata
payload: IO[bytes]
Expand Down Expand Up @@ -94,7 +103,7 @@ def __init__(
"usecase name must contain only ASCII letters, digits, '_' or '-'"
)
self._name = name

if resumable_threshold_bytes != "none" and (
type(resumable_threshold_bytes) is not int or resumable_threshold_bytes < 0
):
Expand Down Expand Up @@ -709,7 +718,12 @@ def get(
span.set_attribute("objectstore.decompressed", decompressed)
return GetResponse(metadata, stream)

def object_url(self, key: str, token_validity: timedelta | None = None) -> str:
def object_url(
self,
key: str,
token_validity: timedelta | None = None,
response_content_disposition: Literal["inline", "attachment"] | None = None,
) -> str:
"""
Generates a GET url to the object with the given `key`.

Expand All @@ -721,10 +735,18 @@ def object_url(self, key: str, token_validity: timedelta | None = None) -> str:
information is embedded in the returned URL's query string, valid
for the given duration.

``response_content_disposition`` selects ``attachment`` (default) or ``inline``
for successful GET/HEAD responses, retaining the stored filename.

These options are not bound by the auth token and can be changed by recipients.
Use :meth:`presigned_object_url` to bind the options to a signature.

Raises ``ValueError`` if ``token_validity`` is provided but no
``SecretKey`` is configured on this session.
``SecretKey`` is configured on this session, or if disposition is invalid.
"""
url = self._make_url(key, full=True)
if response_content_disposition is not None:
url = f"{url}?{_response_options_query(response_content_disposition)}"
if token_validity is None:
return url

Expand All @@ -734,13 +756,15 @@ def object_url(self, key: str, token_validity: timedelta | None = None) -> str:
)
# A JWT's compact serialization is base64url, whose alphabet is URL-safe,
# so it can go in the query string as-is with no further encoding.
return f"{url}?{PARAM_AUTH}={token}"
separator = "&" if response_content_disposition is not None else "?"
return f"{url}{separator}{PARAM_AUTH}={token}"

def presigned_object_url(
self,
method: Literal["GET", "HEAD"],
key: str,
duration: timedelta = timedelta(hours=1),
response_content_disposition: Literal["inline", "attachment"] | None = None,
) -> str:
"""
Generates a pre-signed URL authorizing a single ``method`` request on the
Expand All @@ -752,12 +776,17 @@ def presigned_object_url(

Raises ``ValueError`` if no ``SecretKey`` is configured on this
session's client, if ``method`` is not supported, or if ``duration``
is above the one-week maximum.
is above the one-week maximum, or if disposition is invalid.

The returned URL carries a signature that allows the recipient to perform
a request without an auth token. It can be handed to any HTTP client;
the URL is already percent-encoded, and must be transmitted verbatim.

``response_content_disposition`` selects ``attachment`` (default) or ``inline``
for successful GET/HEAD responses, retaining the stored filename.

Response options are included in the signature when supplied.

Note that `HEAD` and `GET` are considered equivalent from the point of view
of signatures, meaning that a signature for `GET` can also be used for
`HEAD`, and viceversa.
Expand Down Expand Up @@ -795,6 +824,11 @@ def presigned_object_url(
f"&{presign.PARAM_DURATION}={duration_secs}"
)

if response_content_disposition is not None:
encoded_query += (
f"&{_response_options_query(response_content_disposition)}"
)

canonical = presign.build_canonical_form(
method, encoded_path, encoded_query
)
Expand Down
59 changes: 59 additions & 0 deletions clients/python/tests/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -1094,6 +1094,65 @@ def test_presigned_get_encoding_corner_cases(server_url: str, key: str) -> None:
assert body == payload


def test_presigned_response_options(server_url: str) -> None:
session = _presign_session(server_url)
key = "inline-example"
session.put(b"<html>example</html>", key=key, content_type="text/html")
url = session.presigned_object_url(
"GET",
key,
response_content_disposition="inline",
)
with urllib.request.urlopen(url) as response:
assert response.status == 200
assert response.headers["content-disposition"] == "inline"
assert response.headers["content-type"] == "text/html"
assert response.headers["content-security-policy"] == "sandbox"
assert response.headers["x-content-type-options"] == "nosniff"
assert response.read() == b"<html>example</html>"

status, _ = _fetch(url.replace("=inline", "=attachment"))
assert status == 401


def test_jwt_response_options_remain_editable(server_url: str) -> None:
session = _presign_session(server_url)
session.put(b"example", key="jwt-options")
url = session.object_url(
"jwt-options",
token_validity=timedelta(minutes=5),
response_content_disposition="inline",
)
for target, expected in [
(url, "inline"),
(url.replace("=inline", "=attachment"), "attachment"),
]:
with urllib.request.urlopen(target) as response:
assert response.status == 200
assert response.headers["content-disposition"] == expected


def test_inline_preserves_stored_mime_type_and_compression(server_url: str) -> None:
session = Client(server_url, token=TestSecretKey.get()).session(
Usecase("test-usecase", compression="zstd"), org=42, project=1337
)
payload = b'<svg xmlns="http://www.w3.org/2000/svg"><script>example</script></svg>'
session.put(payload, key="svg-options", content_type="image/svg+xml")
url = session.object_url(
"svg-options",
token_validity=timedelta(minutes=5),
response_content_disposition="inline",
)
with urllib.request.urlopen(url) as response:
assert response.headers["content-type"] == "image/svg+xml"
assert response.headers["content-encoding"] == "zstd"
assert response.headers["content-security-policy"] == "sandbox"
with zstandard.ZstdDecompressor().stream_reader(
BytesIO(response.read())
) as reader:
assert reader.read() == payload


def test_object_url_read_only_token_succeeds(server_url: str) -> None:
session = _presign_session(server_url)
session.put(b"read only hello", key="read-only-url")
Expand Down
54 changes: 53 additions & 1 deletion clients/python/tests/test_presign.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

import base64
import os
from datetime import UTC, datetime, timedelta
from unittest.mock import patch
from urllib.parse import parse_qs, urlsplit

from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.hazmat.primitives.serialization import load_pem_public_key
from objectstore_client import presign, utils
from objectstore_client import Client, Usecase, presign, utils
from objectstore_client.auth import SecretKey

TEST_EDDSA_PRIVKEY_PATH = os.path.join(os.path.dirname(__file__), "ed25519.private.pem")
Expand Down Expand Up @@ -73,3 +76,52 @@ def test_sign_canonical_form_roundtrips_with_public_key() -> None:
signature = base64.urlsafe_b64decode(signature_b64 + padding)
assert len(signature) == 64
public_key.verify(signature, canonical.encode()) # raises on mismatch


def test_presigned_url_response_options() -> None:
with open(TEST_EDDSA_PRIVKEY_PATH) as f:
secret = SecretKey("test_kid", f.read())
session = Client("https://example.com/prefix", token=secret).session(
Usecase("testing"), org=1
)
with patch("objectstore_client.client.datetime") as clock:
clock.now.return_value = datetime(2026, 4, 20, 13, 37, tzinfo=UTC)
legacy = session.presigned_object_url("GET", "key")
explicit_none = session.presigned_object_url(
"GET", "key", response_content_disposition=None
)
url = session.presigned_object_url(
"GET",
"key",
response_content_disposition="inline",
)
assert legacy == explicit_none
assert "response-content-disposition" not in parse_qs(urlsplit(legacy).query)
parsed = urlsplit(url)
assert parsed.path == urlsplit(legacy).path
query = parse_qs(parsed.query)
assert query["response-content-disposition"] == ["inline"]
assert len(query) == 5


def test_jwt_url_options_roundtrip_without_binding_token() -> None:
with open(TEST_EDDSA_PRIVKEY_PATH) as f:
secret = SecretKey("test_kid", f.read())
session = Client("https://example.com/prefix", token=secret).session(
Usecase("testing")
)
with patch.object(session, "mint_token", return_value="same.jwt.token"):
legacy = session.object_url("key", token_validity=timedelta(minutes=5))
url = session.object_url(
"key",
token_validity=timedelta(minutes=5),
response_content_disposition="inline",
)
assert parse_qs(urlsplit(url).query) == {
"os_auth": ["same.jwt.token"],
"response-content-disposition": ["inline"],
}
assert legacy.endswith("?os_auth=same.jwt.token")
assert session.object_url(
"key", response_content_disposition=None
) == session.object_url("key")
1 change: 1 addition & 0 deletions objectstore-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ thread_local = { workspace = true }
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
tower = { workspace = true }
tower-http = { workspace = true, features = ["catch-panic", "metrics", "set-header", "trace"] }
url = { workspace = true }

[target.'cfg(target_os = "linux")'.dependencies]
jemalloc_pprof = { workspace = true, optional = true }
Expand Down
5 changes: 5 additions & 0 deletions objectstore-server/src/endpoints/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@
//! Object metadata travels in request and response headers; see
//! [`objectstore_types::metadata`] for the mapping and the separate JSON update contract.
//!
//! GET and HEAD accept `response-content-disposition=inline|attachment` (default attachment).
//! Response options apply only to successful object responses, including ranged GETs. See
//! [`crate::extractors::response_options`] for validation. The object handlers construct
//! download headers with browser safeguards and filename formatting.
//!
//! # Resumable Upload Endpoints
//!
//! A resumable upload transfers a single object across several requests.
Expand Down
Loading
Loading