mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 08:02:18 +00:00
feat(triggers): serve binary HTTP-route responses via base64 transfer encoding (#10058)
Add an opt-in `wm_content_transfer_encoding: "base64"` field to the composite result. When set (together with `wm_content_type`), result_to_response decodes the string result into raw bytes before sending it, so sync HTTP routes/webhooks can return arbitrary binary payloads (PDFs, images, ...) with any content type — not just as base64 text or via object storage. Explicit and safe: the encoding is never guessed, invalid base64 is a hard error (no silent fallback to the encoded text), an unsupported encoding is rejected, and a transfer encoding without a content type is rejected. Existing string responses are unchanged. Closes #5986
This commit is contained in:
@@ -12,6 +12,7 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use base64::Engine as _;
|
||||
use http::{HeaderMap, HeaderName, HeaderValue};
|
||||
use hyper::StatusCode;
|
||||
use serde::Deserialize;
|
||||
@@ -254,6 +255,8 @@ pub struct WindmillCompositeResult {
|
||||
windmill_content_type: Option<String>,
|
||||
#[serde(alias = "wm_headers")]
|
||||
windmill_headers: Option<HashMap<String, String>>,
|
||||
#[serde(alias = "wm_content_transfer_encoding")]
|
||||
windmill_content_transfer_encoding: Option<String>,
|
||||
result: Option<Box<RawValue>>,
|
||||
}
|
||||
|
||||
@@ -375,11 +378,13 @@ pub fn result_to_response(result: Box<RawValue>, success: bool) -> error::Result
|
||||
windmill_status_code,
|
||||
windmill_content_type,
|
||||
windmill_headers,
|
||||
windmill_content_transfer_encoding,
|
||||
result: result_value,
|
||||
}) => {
|
||||
if windmill_content_type.is_none()
|
||||
&& windmill_status_code.is_none()
|
||||
&& windmill_headers.is_none()
|
||||
&& windmill_content_transfer_encoding.is_none()
|
||||
{
|
||||
return Ok((
|
||||
if success {
|
||||
@@ -425,18 +430,54 @@ pub fn result_to_response(result: Box<RawValue>, success: bool) -> error::Result
|
||||
let serialized_json_result = result_value
|
||||
.map(|val| val.get().to_owned())
|
||||
.unwrap_or_else(String::new);
|
||||
let serialized_result =
|
||||
serde_json::from_str::<String>(serialized_json_result.as_str())
|
||||
.ok()
|
||||
.unwrap_or(serialized_json_result);
|
||||
let parsed_string =
|
||||
serde_json::from_str::<String>(serialized_json_result.as_str()).ok();
|
||||
let result_is_json_string = parsed_string.is_some();
|
||||
let serialized_result = parsed_string.unwrap_or(serialized_json_result);
|
||||
headers.insert(
|
||||
http::header::CONTENT_TYPE,
|
||||
HeaderValue::from_str(content_type.as_str()).map_err(|err| {
|
||||
Error::internal_err(format!("Invalid content type {content_type}: {err}"))
|
||||
})?,
|
||||
);
|
||||
// Invalid base64 is a hard error, never a silent fallback to the encoded text.
|
||||
match windmill_content_transfer_encoding.as_deref() {
|
||||
Some("base64") => {
|
||||
// Only a JSON string carries base64; a number/bool/null/array/object
|
||||
// must not have its raw JSON text decoded into arbitrary bytes.
|
||||
if !result_is_json_string {
|
||||
return Err(Error::ExecutionErr(
|
||||
"windmill_content_transfer_encoding \"base64\" requires result \
|
||||
to be a base64-encoded string"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(serialized_result.as_bytes())
|
||||
.map_err(|err| {
|
||||
Error::ExecutionErr(format!(
|
||||
"windmill_content_transfer_encoding is \"base64\" but the \
|
||||
result is not valid base64: {err}"
|
||||
))
|
||||
})?;
|
||||
return Ok((status_code_or_default, headers, decoded).into_response());
|
||||
}
|
||||
Some(other) => {
|
||||
return Err(Error::ExecutionErr(format!(
|
||||
"Unsupported windmill_content_transfer_encoding \"{other}\" \
|
||||
(only \"base64\" is supported)"
|
||||
)));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
return Ok((status_code_or_default, headers, serialized_result).into_response());
|
||||
}
|
||||
if windmill_content_transfer_encoding.is_some() {
|
||||
return Err(Error::ExecutionErr(
|
||||
"windmill_content_transfer_encoding requires windmill_content_type to be set"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(result_value) = result_value {
|
||||
return Ok((status_code_or_default, headers, Json(result_value)).into_response());
|
||||
} else {
|
||||
@@ -960,3 +1001,106 @@ pub async fn push_script_job_by_path_into_queue<'c>(
|
||||
Ok((uuid, resolved_delete_secs, None))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod result_to_response_tests {
|
||||
use super::*;
|
||||
|
||||
fn raw(json: &str) -> Box<RawValue> {
|
||||
serde_json::from_str(json).expect("valid json")
|
||||
}
|
||||
|
||||
async fn body_bytes(resp: Response) -> Vec<u8> {
|
||||
axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read body")
|
||||
.to_vec()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn base64_result_is_decoded_to_raw_bytes() {
|
||||
// 0x00 0x01 0x02 0xFF is not valid UTF-8, so it can only survive as bytes.
|
||||
let bytes = vec![0u8, 1, 2, 255];
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
let resp = result_to_response(
|
||||
raw(&format!(
|
||||
r#"{{"wm_content_type":"application/pdf","wm_content_transfer_encoding":"base64","result":"{b64}"}}"#
|
||||
)),
|
||||
true,
|
||||
)
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
resp.headers().get(http::header::CONTENT_TYPE).unwrap(),
|
||||
"application/pdf"
|
||||
);
|
||||
assert_eq!(body_bytes(resp).await, bytes);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_base64_is_a_hard_error() {
|
||||
let res = result_to_response(
|
||||
raw(
|
||||
r#"{"wm_content_type":"application/pdf","wm_content_transfer_encoding":"base64","result":"not valid base64!!"}"#,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert!(res.is_err(), "invalid base64 must not silently fall back");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn base64_mode_rejects_non_string_results() {
|
||||
// A number/bool whose raw JSON text happens to be valid base64 (right length,
|
||||
// base64 alphabet) must not be decoded into bytes — it must be a hard error.
|
||||
for result in ["12345678", "true", "null", "[1,2,3]"] {
|
||||
let res = result_to_response(
|
||||
raw(&format!(
|
||||
r#"{{"wm_content_type":"application/octet-stream","wm_content_transfer_encoding":"base64","result":{result}}}"#
|
||||
)),
|
||||
true,
|
||||
);
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"base64 mode must reject non-string result: {result}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unsupported_transfer_encoding_is_rejected() {
|
||||
let res = result_to_response(
|
||||
raw(
|
||||
r#"{"wm_content_type":"text/plain","wm_content_transfer_encoding":"gzip","result":"x"}"#,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert!(res.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transfer_encoding_without_content_type_is_rejected() {
|
||||
let res = result_to_response(
|
||||
raw(r#"{"wm_content_transfer_encoding":"base64","result":"aGk="}"#),
|
||||
true,
|
||||
);
|
||||
assert!(res.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn string_result_is_still_served_verbatim() {
|
||||
// Regression: without a transfer encoding, a string result is sent as-is
|
||||
// (quotes stripped), not base64-decoded.
|
||||
let resp = result_to_response(
|
||||
raw(r#"{"wm_content_type":"text/html","result":"<h1>hi</h1>"}"#),
|
||||
true,
|
||||
)
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(
|
||||
resp.headers().get(http::header::CONTENT_TYPE).unwrap(),
|
||||
"text/html"
|
||||
);
|
||||
assert_eq!(body_bytes(resp).await, b"<h1>hi</h1>");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user