Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
23 changes: 23 additions & 0 deletions docs/runtime/s3.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,29 @@ Response (0 KB) {
}
```

The URL is signed for `GET` and is valid for 15 minutes.

S3 checks the method of a presigned URL. When a `Bun.serve()` handler returns this response for a `HEAD` request, Bun signs the URL for `HEAD`. Every other request gets the `GET` URL. A copy made with `response.clone()` or `new Response(null, response)` behaves the same.

```ts server.ts icon="/icons/typescript.svg"
import { s3, serve } from "bun";

serve({
routes: {
"/report.pdf": () => new Response(s3.file("report.pdf")),
},
});
```

The `location` header that your code reads from the `Response` is always the `GET` URL. Code that sends the redirect in another way, for example through `server.fetch()` or with a response built from these headers, must sign for the method of the request:

```ts s3.ts icon="/icons/typescript.svg"
function download(request: Request) {
const method = request.method === "HEAD" ? "HEAD" : "GET";
return Response.redirect(s3file.presign({ method, expiresIn: 15 * 60 }));
}
```

---

## Support for S3-Compatible Services
Expand Down
76 changes: 48 additions & 28 deletions src/runtime/server/RequestContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -867,13 +867,8 @@ where
self.response_root.set_rooted(value, global_this);

if self.method == Method::HEAD {
if let Some(resp) = self.resp.get() {
let mut pair = HeaderResponsePair {
this: self,
response,
};
resp.run_corked_with_type(Self::do_render_head_response, &raw mut pair);
}
// SAFETY: `response` is the live, protect()'d cell pointer.
unsafe { self.render_head(response) };
return;
}

Expand Down Expand Up @@ -2773,13 +2768,8 @@ where
ctx.response_root.clear();
response_value.ensure_still_alive();
if ctx.method == Method::HEAD {
if let Some(resp) = ctx.resp.get() {
let mut pair = HeaderResponsePair {
this: ctx,
response,
};
resp.run_corked_with_type(Self::do_render_head_response, &raw mut pair);
}
// SAFETY: `response` is the live, rooted cell pointer.
unsafe { ctx.render_head(response) };
return;
} else {
// SAFETY: `response` is the live, rooted cell pointer.
Expand Down Expand Up @@ -2831,13 +2821,8 @@ where
ctx.response_root.clear();
fulfilled_value.ensure_still_alive();
if ctx.method == Method::HEAD {
if let Some(resp) = ctx.resp.get() {
let mut pair = HeaderResponsePair {
this: ctx,
response,
};
resp.run_corked_with_type(Self::do_render_head_response, &raw mut pair);
}
// SAFETY: `response` is the live, rooted cell pointer.
unsafe { ctx.render_head(response) };
return;
}
// SAFETY: `response` is the live, rooted cell pointer.
Expand Down Expand Up @@ -3981,6 +3966,46 @@ where
.set(unsafe { response::WeakRef::init_ref(response) });
}

/// Safety: the contract of [`Self::set_response`].
#[inline(always)]
unsafe fn render_head(&self, response: *mut Response) {
// SAFETY: caller contract: `response` is live.
if unsafe { (*response).has_s3_redirect() } {
// SAFETY: caller contract.
return unsafe { self.render_s3_redirect_head(response) };
}
if let Some(resp) = self.resp.get() {
let mut pair = HeaderResponsePair {
this: self,
response,
};
resp.run_corked_with_type(Self::do_render_head_response, &raw mut pair);
}
}

/// Safety: the contract of [`Self::set_response`].
#[cold]
#[inline(never)]
unsafe fn render_s3_redirect_head(&self, response: *mut Response) {
let Some(resp) = self.resp.get() else { return };
if let Some(server) = self.server.get() {
// SAFETY: caller contract: `response` is live. The borrow ends before the error handler runs.
let signed = unsafe { (*response).sign_s3_redirect_for_head(server.global_this()) };
if let Err(err) = signed {
self.run_error_handler(err);
return;
}
}
let mut pair = HeaderResponsePair {
this: self,
response,
};
resp.run_corked_with_type(Self::do_render_head_response, &raw mut pair);
// The send took the headers, so the HEAD URL cannot reach a later GET of this Response.
// SAFETY: caller contract: `response` is live.
Comment thread
robobun marked this conversation as resolved.
Resolved
debug_assert!(unsafe { (*response).get_init_headers().is_none() });
}

/// # Safety
/// Same contract as [`Self::set_response`].
pub(crate) unsafe fn render(&self, response: *mut Response) {
Expand All @@ -3990,13 +4015,8 @@ where
// handler path branches to `do_render_head_response` before reaching
// here, but the `error()` handler paths call `render()` directly.
if self.method == Method::HEAD {
if let Some(resp) = self.resp.get() {
let mut pair = HeaderResponsePair {
this: self,
response,
};
resp.run_corked_with_type(Self::do_render_head_response, &raw mut pair);
}
// SAFETY: caller contract.
unsafe { self.render_head(response) };
return;
}

Expand Down
109 changes: 77 additions & 32 deletions src/runtime/webcore/Response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,10 @@ pub(crate) struct Response {
abort_listener: JsCell<Option<Box<BodyAbortListener>>>,
}

// A larger Response leaves the 320-byte size class of mimalloc for the 384-byte one.
#[cfg(not(debug_assertions))]
const _: () = assert!(mem::size_of::<Response>() <= 320);

impl Default for Response {
fn default() -> Self {
Self {
Expand Down Expand Up @@ -404,6 +408,48 @@ impl Response {
self.init.with_mut(|init| init.headers.take())
}

#[inline]
pub(crate) fn has_s3_redirect(&self) -> bool {
self.init.get().s3_redirect.is_some()
}

/// Signs the `Location` of a `new Response(s3file)` for HEAD, while it is still the URL that Bun wrote.
#[cold]
#[inline(never)]
pub(crate) fn sign_s3_redirect_for_head(&self, global: &JSGlobalObject) -> Result<(), JSValue> {
let store = self.init.with_mut(|init| {
let state = init.s3_redirect.as_deref()?;
let location = init
.headers
.as_deref_mut()?
.fast_get(HTTPHeaderName::Location)?;
let written = state.location.latin1();
let unchanged = if location.is_16bit() {
let units = location.utf16_slice();
units.len() == written.len()
&& units.iter().zip(written).all(|(&u, &b)| u == u16::from(b))
} else {
location.slice() == written
};
unchanged.then(|| state.store.clone())
});
let Some(store) = store else {
return Ok(());
};
let result = super::blob::store::presign_redirect(store.data.as_s3(), Method::HEAD)
.map_err(|err| crate::webcore::s3::client::get_js_sign_error(err.into(), global))?;
let Some(headers) = self.get_init_headers_mut() else {
return Ok(());
};
headers
.put(
HTTPHeaderName::Location,
&BunString::ascii(&result.url),
global,
)
.map_err(|err| global.take_exception(err))
Comment thread
robobun marked this conversation as resolved.
Resolved
}

#[inline]
pub(crate) fn get_method(&self) -> Method {
self.init.get().method
Expand Down Expand Up @@ -1070,41 +1116,29 @@ impl Response {
..Default::default()
};

let s3 = blob.store.get().as_ref().unwrap().data.as_s3();
let credentials = s3.get_credentials();

let result = match credentials.sign_request::<false>(
&bun_s3_signing::SignOptions {
path: s3.path(),
method: Method::GET,
content_hash: None,
content_md5: None,
search_params: None,
content_disposition: None,
content_type: None,
content_encoding: None,
acl: None,
storage_class: None,
request_payer: false,
},
Some(bun_s3_signing::SignQueryOptions { expires: 15 * 60 }),
) {
Ok(r) => r,
Err(sign_err) => {
return Err(crate::webcore::s3::client::throw_sign_error(
sign_err.into(),
global_this,
));
}
};
let store = blob.store.get().as_ref().unwrap();
let result =
match super::blob::store::presign_redirect(store.data.as_s3(), Method::GET)
{
Ok(r) => r,
Err(sign_err) => {
return Err(crate::webcore::s3::client::throw_sign_error(
sign_err.into(),
global_this,
));
}
};
// `defer result.deinit()` — SignResult: Drop frees owned buffers at scope exit.
response.redirected.set(true);
let headers = response.get_or_create_headers(global_this)?;
headers.put(
HTTPHeaderName::Location,
&BunString::ascii(&result.url),
global_this,
)?;
let location = BunString::clone_latin1(&result.url);
headers.put(HTTPHeaderName::Location, &location, global_this)?;
response.init.with_mut(|init| {
init.s3_redirect = Some(Box::new(S3Redirect {
store: store.clone(),
location,
}));
});
return Ok(bun_core::heap::into_raw(Box::new(response)));
}
}
Expand Down Expand Up @@ -1187,6 +1221,15 @@ pub(crate) struct Init {
pub(crate) status_code: u16,
pub(crate) status_text: BunString,
pub method: Method,
/// Set by `new Response(s3file)`: Bun.serve signs that redirect for HEAD when the request is HEAD.
pub(crate) s3_redirect: Option<Box<S3Redirect>>,
}

#[derive(Clone)]
pub(crate) struct S3Redirect {
store: RefPtr<super::blob::Store>,
/// The `Location` value that Bun wrote. Any other value is the user's and is sent as it is.
location: BunString,
}

impl Default for Init {
Expand All @@ -1196,6 +1239,7 @@ impl Default for Init {
status_code: 0,
status_text: BunString::EMPTY,
method: Method::GET,
s3_redirect: None,
}
}
}
Expand All @@ -1214,6 +1258,7 @@ impl Init {
status_code: self.status_code,
status_text: self.status_text.clone(),
method: self.method,
s3_redirect: self.s3_redirect.clone(),
})
}

Expand Down
29 changes: 29 additions & 0 deletions src/runtime/webcore/blob/Store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use crate::webcore::s3::client::{
S3ListObjectsResult,
};
use bun_core::strings;
use bun_http_types::Method::Method;
use bun_http_types::MimeType::MimeType;
use bun_ptr::RefPtr;

Expand Down Expand Up @@ -426,6 +427,34 @@ impl S3Ext for S3 {
}
}

/// Signs the URL of the `new Response(s3file)` redirect: for HEAD when the request is HEAD, for GET in each other case.
#[inline(never)]
pub(crate) fn presign_redirect(
s3: &S3,
request_method: Method,
) -> Result<bun_s3_signing::SignResult, bun_s3_signing::SignError> {
s3.get_credentials().sign_request::<false>(
&bun_s3_signing::SignOptions {
path: s3.path(),
method: if request_method == Method::HEAD {
Method::HEAD
} else {
Method::GET
},
Comment thread
robobun marked this conversation as resolved.
Resolved
content_hash: None,
content_md5: None,
search_params: None,
content_disposition: None,
content_type: None,
content_encoding: None,
acl: None,
storage_class: None,
request_payer: false,
Comment thread
robobun marked this conversation as resolved.
Resolved
},
Some(bun_s3_signing::SignQueryOptions { expires: 15 * 60 }),
)
}

impl BytesExt for Bytes {
/// Adopt an mmap'd region. `Drop` (`allocator.free`) will `munmap` it.
#[cfg(unix)]
Expand Down
Loading
Loading