mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-25 16:03:47 +00:00
`quick-xml` < 0.41 is affected by RUSTSEC-2026-0194 and RUSTSEC-2026-0195 (both CVSS 7.5). The only `object_store` releases that depend on `quick-xml` >= 0.41 are 0.14.1+, and no published `datafusion` release accepts `object_store` 0.14 yet, so `datafusion` is pinned to the apache/datafusion commit that moved to arrow/parquet 60 and `object_store` 0.14.2. That set also drops `thrift` (parquet >= 59 no longer uses it), closing GHSA-2f9f-gq7v-9h6m. - datafusion 47.0.0 -> git 2cac7132 (arrow/parquet 60, object_store 0.14.2) - object_store git 36752c97 (0.12.0) -> 0.14.2; the pinned rev's Azure query-signing fix (arrow-rs-object-store#334) shipped in 0.12.1, so the `[patch.crates-io]` entry and the git dep are both dropped - parquet 55.2.0 -> 60.0.0, arrow 55.2.0 -> 60.0.0 - quick-xml 0.37 -> 0.41; samael pinned forward to the rev that did the same, since it was the other source of a 0.37 copy - tokio =1.46.1 -> =1.53.1, forced by datafusion's `tokio ^1.52` - `reqwest_object_store` (reqwest 0.12) folded into the workspace `reqwest` 0.13, which object_store 0.14 now builds its clients with Code adaptations: `get`/`get_range`/`head`/`delete`/`copy`/`put`/ `put_multipart` moved to `ObjectStoreExt`, `copy`/`rename` to `copy_opts`/`rename_opts`, `delete` to `delete_stream` with a `'static` stream, `Error::NotImplemented` gained fields, `NdJsonReadOptions` became `JsonReadOptions`, `DFSchema: Into<Schema>` is gone, and `set_max_row_group_size` became `set_max_row_group_row_count`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kv72vdCDggCZEjJSmNCwnX
900 lines
34 KiB
Rust
900 lines
34 KiB
Rust
use super::{
|
|
effective_allowed_origins, http_trigger_args::RawHttpTriggerArgs, match_origin,
|
|
refresh_routers, AuthenticationMethod, HttpMethod, RequestType, TriggerRoute,
|
|
HTTP_ACCESS_CACHE, HTTP_AUTH_CACHE, HTTP_ROUTERS_CACHE,
|
|
};
|
|
use crate::{
|
|
auth::{AuthCache, OptTokened},
|
|
db::{ApiAuthed, DB},
|
|
jobs::start_job_update_sse_stream,
|
|
triggers::trigger_helpers::{
|
|
get_runnable_format, trigger_runnable, trigger_runnable_and_wait_for_result,
|
|
trigger_runnable_inner, RunnableId,
|
|
},
|
|
utils::{check_scopes, ExpiringCacheEntry},
|
|
};
|
|
use axum::{
|
|
extract::Path,
|
|
response::{IntoResponse, Response},
|
|
routing::get,
|
|
Extension, Router,
|
|
};
|
|
use futures::StreamExt;
|
|
use http::{HeaderMap, StatusCode};
|
|
use std::{collections::HashMap, sync::Arc};
|
|
use windmill_common::{
|
|
db::UserDB,
|
|
error::{Error, Result},
|
|
global_settings::HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS,
|
|
jobs::JobTriggerKind,
|
|
triggers::{TriggerKind, TriggerMetadata},
|
|
utils::{not_found_if_none, StripPath},
|
|
};
|
|
use windmill_store::resources::try_get_resource_from_db_as;
|
|
use windmill_trigger::TriggerMode;
|
|
|
|
#[cfg(feature = "parquet")]
|
|
use {
|
|
crate::job_helpers_oss::get_workspace_s3_resource,
|
|
windmill_object_store::build_object_store_client,
|
|
};
|
|
|
|
/// Which router a request's CORS decision must be looked up in.
|
|
///
|
|
/// A preflight names the method it is asking about in
|
|
/// `Access-Control-Request-Method`; the routers are keyed by method, so without
|
|
/// that header there is nothing to look up.
|
|
fn cors_lookup_method(req: &axum::extract::Request) -> Option<HttpMethod> {
|
|
let method = req.method();
|
|
if method == http::Method::OPTIONS {
|
|
req.headers()
|
|
.get(http::header::ACCESS_CONTROL_REQUEST_METHOD)
|
|
.and_then(|method| method.to_str().ok())
|
|
.and_then(|method| http::Method::try_from(method).ok())
|
|
.as_ref()
|
|
.and_then(routable_method)
|
|
} else {
|
|
routable_method(method)
|
|
}
|
|
}
|
|
|
|
/// The router key a request method maps to. `HEAD` resolves the `GET` route it
|
|
/// mirrors, and does so for a preflight naming it too: browsers send
|
|
/// `Access-Control-Request-Method: HEAD` when the HEAD carries a non-safelisted
|
|
/// header, and answering that preflight from a different route than the request
|
|
/// itself resolves is how the two come to disagree.
|
|
fn routable_method(method: &http::Method) -> Option<HttpMethod> {
|
|
if method == http::Method::HEAD {
|
|
Some(HttpMethod::Get)
|
|
} else {
|
|
HttpMethod::try_from(method).ok()
|
|
}
|
|
}
|
|
|
|
/// The key to look a request up by, matching what `route_job` resolves it to.
|
|
///
|
|
/// `Path<StripPath>` percent-decodes before `get_http_route_trigger` builds its
|
|
/// lookup key, so decoding here is what keeps the two agreeing: on the raw path,
|
|
/// `/us%65rs` misses the trigger registered at `/users` that goes on to serve the
|
|
/// request, and the response would carry the permissive default instead of that
|
|
/// trigger's allowlist.
|
|
fn cors_lookup_path(raw_path: &str) -> Option<String> {
|
|
let decoded = urlencoding::decode(raw_path).ok()?;
|
|
// `StripPath::to_path` strips one leading slash and the handler trims
|
|
// trailing ones, before a single `/` is prefixed back on.
|
|
let stripped = decoded.strip_prefix('/').unwrap_or(&decoded);
|
|
Some(format!("/{}", stripped.trim_end_matches('/')))
|
|
}
|
|
|
|
/// What the middleware should stamp, decided while the routers guard is held.
|
|
///
|
|
/// Deliberately small and owned: the allowlist itself never leaves the guard,
|
|
/// so a large one is scanned in place instead of being copied per request onto
|
|
/// a path an unauthenticated preflight can reach.
|
|
#[derive(Clone)]
|
|
enum CorsDecision {
|
|
/// No allowlist applies, so the permissive default stands.
|
|
Unrestricted,
|
|
/// An allowlist applies. `allow_origin` is the value to echo, present only
|
|
/// when the request's own `Origin` is on the list.
|
|
Restricted { route_method: Option<HttpMethod>, allow_origin: Option<http::HeaderValue> },
|
|
/// The routers could not be read, so nothing is known about this path.
|
|
Unavailable,
|
|
}
|
|
|
|
/// Whether a route actually serves a static website, rather than merely saying
|
|
/// it does.
|
|
///
|
|
/// `is_static_website` is a caller-set flag that validation ties to nothing: a
|
|
/// route can carry it while having no assets configured and a `script_path`
|
|
/// that `route_job` runs regardless. Keying the exemption off the flag alone
|
|
/// would let one boolean disable a route's allowlist and hand its runnable back
|
|
/// the `wm_headers` escape hatch, so the assets have to be there too.
|
|
fn serves_a_static_website(trigger: &TriggerRoute) -> bool {
|
|
trigger.is_static_website && trigger.static_asset_config.is_some()
|
|
}
|
|
|
|
/// A static website is never subject to an allowlist, its own included. It has
|
|
/// no authentication of its own — the editor does not offer any — so it hands
|
|
/// out public files that any non-browser client can already fetch, and
|
|
/// restricting which browsers may read them protects nothing while breaking the
|
|
/// cross-origin uses that do consult CORS: a webfont, a `crossorigin` asset, a
|
|
/// `fetch`.
|
|
///
|
|
/// A single-file static asset is not exempt. That one can carry an
|
|
/// `authentication_method`, so its content need not be public, and an allowlist
|
|
/// is what keeps another origin from reading a response its own credentials
|
|
/// would not have obtained.
|
|
///
|
|
/// The CORS verdict for a request, published by whoever resolved its trigger.
|
|
///
|
|
/// The middleware stamps headers after the handler returns, but only the
|
|
/// handler knows which trigger it actually served. Re-deriving that from the
|
|
/// routers cache is a second lookup which can disagree with the first when a
|
|
/// route is edited, deleted or widened mid-request, and every ordering of the
|
|
/// two is wrong in some case. So the verdict travels with the request instead
|
|
/// of being worked out twice.
|
|
#[derive(Clone, Default)]
|
|
struct ResolvedCorsPolicy(std::sync::Arc<std::sync::OnceLock<CorsDecision>>);
|
|
|
|
impl ResolvedCorsPolicy {
|
|
/// Record what the trigger being served allows. Called once, where the
|
|
/// route is resolved, so the answer cannot drift from the response.
|
|
fn publish(&self, trigger: &TriggerRoute, method: Option<HttpMethod>, headers: &HeaderMap) {
|
|
let decision = if serves_a_static_website(trigger) {
|
|
CorsDecision::Unrestricted
|
|
} else {
|
|
let instance_default = HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS.load();
|
|
match effective_allowed_origins(
|
|
trigger.allowed_origins.as_deref(),
|
|
instance_default.as_slice(),
|
|
) {
|
|
None => CorsDecision::Unrestricted,
|
|
Some(allowed_origins) => CorsDecision::Restricted {
|
|
route_method: method,
|
|
allow_origin: match_origin(allowed_origins, headers.get(http::header::ORIGIN)),
|
|
},
|
|
}
|
|
};
|
|
let _ = self.0.set(decision);
|
|
}
|
|
|
|
fn published(&self) -> Option<CorsDecision> {
|
|
self.0.get().cloned()
|
|
}
|
|
}
|
|
|
|
/// Decide the CORS answer from the routers cache, for a request no handler
|
|
/// published a verdict for: a preflight, an unknown path, or any failure ahead
|
|
/// of the publish — authentication included, which runs after the route itself
|
|
/// resolves.
|
|
///
|
|
/// Loads the routers when the cache is cold, the way `get_http_route_trigger`
|
|
/// does, so a preflight is answered from the same view of the routes as the
|
|
/// request that follows it.
|
|
async fn resolve_cors_decision(
|
|
db: &DB,
|
|
http_method: HttpMethod,
|
|
requested_path: &str,
|
|
origin: Option<&http::HeaderValue>,
|
|
) -> CorsDecision {
|
|
let routers_cache = HTTP_ROUTERS_CACHE.read().await;
|
|
|
|
let routers_cache = if routers_cache.routers.is_empty() {
|
|
drop(routers_cache);
|
|
match refresh_routers(db, false).await {
|
|
Ok((_, routers_cache)) => routers_cache,
|
|
Err(err) => {
|
|
tracing::error!("Could not load HTTP routers to resolve CORS: {err:#}");
|
|
return CorsDecision::Unavailable;
|
|
}
|
|
}
|
|
} else {
|
|
routers_cache
|
|
};
|
|
|
|
let Some(router) = routers_cache.routers.get(&http_method) else {
|
|
return CorsDecision::Unavailable;
|
|
};
|
|
|
|
let route = router.at(requested_path).ok();
|
|
if route
|
|
.as_ref()
|
|
.is_some_and(|trigger| serves_a_static_website(trigger.value))
|
|
{
|
|
return CorsDecision::Unrestricted;
|
|
}
|
|
let route_allowed_origins = route
|
|
.as_ref()
|
|
.and_then(|trigger| trigger.value.allowed_origins.as_deref());
|
|
|
|
let instance_default = HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS.load();
|
|
match effective_allowed_origins(route_allowed_origins, instance_default.as_slice()) {
|
|
None => CorsDecision::Unrestricted,
|
|
Some(allowed_origins) => CorsDecision::Restricted {
|
|
route_method: route.map(|_| http_method),
|
|
allow_origin: match_origin(allowed_origins, origin),
|
|
},
|
|
}
|
|
}
|
|
|
|
async fn conditional_cors_middleware(
|
|
Extension(db): Extension<DB>,
|
|
mut req: axum::extract::Request,
|
|
next: axum::middleware::Next,
|
|
) -> Response {
|
|
let origin = req.headers().get(http::header::ORIGIN).cloned();
|
|
// Owned before `next.run` consumes the request. `&Request` is not `Send`
|
|
// (`Body` is not `Sync`), so nothing borrowed from it can cross the await.
|
|
// The URI is carried rather than the decoded path: cloning it is a refcount
|
|
// bump, while decoding allocates, and only the fallback below ever needs it.
|
|
let lookup_method = cors_lookup_method(&req);
|
|
let uri = req.uri().clone();
|
|
|
|
let resolved = ResolvedCorsPolicy::default();
|
|
req.extensions_mut().insert(resolved.clone());
|
|
|
|
let mut response = next.run(req).await;
|
|
|
|
let decision = match resolved.published() {
|
|
// The handler resolved a trigger and said what it served under. That is
|
|
// the policy this response was produced with, so nothing else can be
|
|
// more authoritative.
|
|
Some(decision) => decision,
|
|
// No verdict was published: a preflight, an unknown path, or a request
|
|
// that failed before reaching the publish, authentication included. No
|
|
// runnable produced this body, so reading the cache cannot contradict
|
|
// anything.
|
|
None => match lookup_method.zip(cors_lookup_path(uri.path())) {
|
|
Some((method, path)) => {
|
|
resolve_cors_decision(&db, method, &path, origin.as_ref()).await
|
|
}
|
|
// Not a preflight, not a routable method, or a path that does not
|
|
// decode.
|
|
None => CorsDecision::Unrestricted,
|
|
},
|
|
};
|
|
|
|
let headers = response.headers_mut();
|
|
|
|
// Check existing headers first to determine what not to insert
|
|
let mut not_insert_origin = false;
|
|
let mut not_insert_methods = false;
|
|
let mut not_insert_headers = false;
|
|
|
|
for key in headers.keys() {
|
|
if !not_insert_origin && key == http::header::ACCESS_CONTROL_ALLOW_ORIGIN {
|
|
not_insert_origin = true;
|
|
}
|
|
if !not_insert_methods && key == http::header::ACCESS_CONTROL_ALLOW_METHODS {
|
|
not_insert_methods = true;
|
|
}
|
|
if !not_insert_headers && key == http::header::ACCESS_CONTROL_ALLOW_HEADERS {
|
|
not_insert_headers = true;
|
|
}
|
|
|
|
// Early exit if all headers are already present
|
|
if not_insert_origin && not_insert_methods && not_insert_headers {
|
|
break;
|
|
}
|
|
}
|
|
|
|
match &decision {
|
|
CorsDecision::Restricted { allow_origin, .. } => {
|
|
// A configured allowlist decides, overriding any `wm_headers` value
|
|
// the runnable set. The preflight is answered before any code runs,
|
|
// so config is the only thing it can consult; letting the response
|
|
// widen what the preflight advertised would make the two disagree
|
|
// and leave the allowlist bounding nothing. A route escapes a
|
|
// stricter instance default — `wm_headers` included — by setting
|
|
// its own list to `*`.
|
|
match allow_origin {
|
|
Some(value) => {
|
|
headers.insert(http::header::ACCESS_CONTROL_ALLOW_ORIGIN, value.clone())
|
|
}
|
|
// No match: omit the header entirely so the browser blocks the
|
|
// read, and drop any value the runnable set.
|
|
None => headers.remove(http::header::ACCESS_CONTROL_ALLOW_ORIGIN),
|
|
};
|
|
// Appended, not inserted: the answer now depends on the request's
|
|
// Origin, and a shared cache that ignores it would hand one
|
|
// origin's response to another.
|
|
headers.append(http::header::VARY, http::HeaderValue::from_static("origin"));
|
|
}
|
|
// The routers could not be read, so nothing is known about this path;
|
|
// only a preflight or an unresolved request reaches here. Answering a
|
|
// preflight permissively would let a disallowed origin go on to invoke
|
|
// a runnable whose purpose may be a side effect.
|
|
CorsDecision::Unavailable => {
|
|
headers.remove(http::header::ACCESS_CONTROL_ALLOW_ORIGIN);
|
|
}
|
|
CorsDecision::Unrestricted => {
|
|
if !not_insert_origin {
|
|
headers.insert(
|
|
http::header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
|
http::HeaderValue::from_static("*"),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if !not_insert_methods {
|
|
// A route accepts exactly one method, so advertising all seven
|
|
// overstates it. Only a route under an allowlist gets the narrower
|
|
// answer; an unrestricted one advertises the full supported set, since
|
|
// narrowing it would say something about a route the response is not
|
|
// otherwise willing to disclose.
|
|
let restricted_method = match &decision {
|
|
CorsDecision::Restricted { route_method, .. } => *route_method,
|
|
_ => None,
|
|
};
|
|
headers.insert(
|
|
http::header::ACCESS_CONTROL_ALLOW_METHODS,
|
|
http::HeaderValue::from_static(match restricted_method {
|
|
Some(HttpMethod::Get) => "GET, OPTIONS",
|
|
Some(HttpMethod::Post) => "POST, OPTIONS",
|
|
Some(HttpMethod::Put) => "PUT, OPTIONS",
|
|
Some(HttpMethod::Delete) => "DELETE, OPTIONS",
|
|
Some(HttpMethod::Patch) => "PATCH, OPTIONS",
|
|
None => "GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS",
|
|
}),
|
|
);
|
|
}
|
|
|
|
if !not_insert_headers {
|
|
headers.insert(
|
|
http::header::ACCESS_CONTROL_ALLOW_HEADERS,
|
|
http::HeaderValue::from_static("content-type, authorization"),
|
|
);
|
|
}
|
|
|
|
response
|
|
}
|
|
|
|
pub fn http_route_trigger_handler() -> Router {
|
|
Router::new()
|
|
.route(
|
|
"/{*path}",
|
|
get(route_job)
|
|
.post(route_job)
|
|
.delete(route_job)
|
|
.put(route_job)
|
|
.patch(route_job)
|
|
.head(|| async { "" })
|
|
.options(|| async { "" }),
|
|
)
|
|
.layer(axum::middleware::from_fn(conditional_cors_middleware))
|
|
}
|
|
|
|
async fn get_http_route_trigger(
|
|
route_path: &str,
|
|
auth_cache: &Arc<AuthCache>,
|
|
token: Option<&String>,
|
|
db: &DB,
|
|
user_db: UserDB,
|
|
method: &http::Method,
|
|
) -> Result<(TriggerRoute, String, HashMap<String, String>, ApiAuthed)> {
|
|
let http_method: HttpMethod = method.try_into()?;
|
|
|
|
let requested_path = format!("/{}", route_path);
|
|
|
|
let routers_cache = HTTP_ROUTERS_CACHE.read().await;
|
|
|
|
let routers_cache = if routers_cache.routers.is_empty() {
|
|
tracing::info!("HTTP routers are not loaded, loading from db");
|
|
// refresh_routers takes the write lock, so holding this read guard across it deadlocks.
|
|
drop(routers_cache);
|
|
let (_, routers_cache) = refresh_routers(db, false).await?;
|
|
routers_cache
|
|
} else {
|
|
routers_cache
|
|
};
|
|
|
|
let router = routers_cache
|
|
.routers
|
|
.get(&http_method)
|
|
.ok_or(Error::internal_err(
|
|
"HTTP routers could not be loaded".to_string(),
|
|
))?;
|
|
|
|
let trigger_match = router.at(requested_path.as_str()).ok();
|
|
|
|
let matchit::Match { value: trigger, params } =
|
|
not_found_if_none(trigger_match, "Trigger", requested_path.as_str())?;
|
|
|
|
let params: HashMap<String, String> = params
|
|
.iter()
|
|
.map(|(k, v)| (k.to_string(), v.to_string()))
|
|
.collect();
|
|
|
|
let username_override = if let AuthenticationMethod::Windmill = trigger.authentication_method {
|
|
let opt_authed = if let Some(token) = token {
|
|
auth_cache
|
|
.get_authed(Some(trigger.workspace_id.clone()), token)
|
|
.await
|
|
} else {
|
|
None
|
|
};
|
|
if let Some(authed) = opt_authed {
|
|
check_scopes(&authed, || format!("http_triggers:read:{}", &trigger.path))?;
|
|
|
|
// check that the user has access to the trigger
|
|
let cache_key = (
|
|
trigger.workspace_id.clone(),
|
|
trigger.path.clone(),
|
|
authed.clone(),
|
|
);
|
|
let exists = match HTTP_ACCESS_CACHE.get(&cache_key) {
|
|
Some(cache_entry) if cache_entry.expiry > std::time::Instant::now() => {
|
|
tracing::debug!("HTTP access cache hit for route {}", trigger.path);
|
|
true
|
|
}
|
|
_ => {
|
|
tracing::debug!("HTTP access cache miss for route {}", trigger.path);
|
|
let mut tx = user_db.begin(&authed).await?;
|
|
let exists = sqlx::query_scalar!(
|
|
r#"
|
|
SELECT EXISTS(
|
|
SELECT 1
|
|
FROM
|
|
http_trigger
|
|
WHERE
|
|
workspace_id = $1 AND
|
|
path = $2
|
|
)
|
|
"#,
|
|
trigger.workspace_id,
|
|
trigger.path
|
|
)
|
|
.fetch_one(&mut *tx)
|
|
.await?
|
|
.unwrap_or(false);
|
|
if exists {
|
|
HTTP_ACCESS_CACHE.insert(
|
|
cache_key,
|
|
ExpiringCacheEntry {
|
|
value: (),
|
|
expiry: std::time::Instant::now()
|
|
+ std::time::Duration::from_secs(10),
|
|
},
|
|
);
|
|
}
|
|
exists
|
|
}
|
|
};
|
|
if exists {
|
|
Some(authed.display_username().to_owned())
|
|
} else {
|
|
return Err(Error::NotAuthorized("Unauthorized".to_string()));
|
|
}
|
|
} else {
|
|
return Err(Error::NotAuthorized("Requires authentication".to_string()));
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let email = windmill_common::users::get_email_from_permissioned_as(
|
|
&trigger.permissioned_as,
|
|
&trigger.workspace_id,
|
|
db,
|
|
)
|
|
.await?;
|
|
let authed = windmill_api_auth::fetch_api_authed_from_permissioned_as(
|
|
trigger.permissioned_as.clone(),
|
|
email,
|
|
&trigger.workspace_id,
|
|
&db,
|
|
Some(username_override.unwrap_or(format!("HTTP-{}", trigger.path))),
|
|
)
|
|
.await?;
|
|
|
|
Ok((trigger.clone(), route_path.to_string(), params, authed))
|
|
}
|
|
|
|
async fn route_job(
|
|
Extension(db): Extension<DB>,
|
|
Extension(user_db): Extension<UserDB>,
|
|
Extension(auth_cache): Extension<Arc<AuthCache>>,
|
|
Extension(cors_policy): Extension<ResolvedCorsPolicy>,
|
|
OptTokened { token }: OptTokened,
|
|
Path(route_path): Path<StripPath>,
|
|
headers: HeaderMap,
|
|
args: RawHttpTriggerArgs,
|
|
) -> std::result::Result<impl IntoResponse, Response> {
|
|
let route_path = route_path.to_path().trim_end_matches("/");
|
|
|
|
let (trigger, called_path, params, authed) = get_http_route_trigger(
|
|
route_path,
|
|
&auth_cache,
|
|
token.as_ref(),
|
|
&db,
|
|
user_db.clone(),
|
|
&args.0.metadata.method,
|
|
)
|
|
.await
|
|
.map_err(|e| e.into_response())?;
|
|
|
|
// Publish before anything else can fail: the CORS middleware stamps this
|
|
// response either way, and it must reflect the trigger actually served.
|
|
cors_policy.publish(&trigger, routable_method(&args.0.metadata.method), &headers);
|
|
|
|
if trigger.script_path.is_empty() && trigger.static_asset_config.is_none() {
|
|
return Err(Error::NotFound(format!(
|
|
"Runnable path of HTTP route at path: {}",
|
|
trigger.path
|
|
))
|
|
.into_response());
|
|
}
|
|
|
|
let args = args
|
|
.process_args(
|
|
&authed,
|
|
&db,
|
|
&trigger.workspace_id,
|
|
match trigger.authentication_method {
|
|
AuthenticationMethod::CustomScript | AuthenticationMethod::Signature => true,
|
|
_ => trigger.raw_string,
|
|
},
|
|
)
|
|
.await
|
|
.map_err(|e| e.into_response())?;
|
|
|
|
match trigger.authentication_method {
|
|
AuthenticationMethod::None
|
|
| AuthenticationMethod::Windmill
|
|
| AuthenticationMethod::CustomScript => {}
|
|
_ => {
|
|
let resource_path = match trigger.authentication_resource_path {
|
|
Some(resource_path) => resource_path,
|
|
None => {
|
|
return Err(Error::BadRequest(
|
|
"Missing authentication resource path".to_string(),
|
|
)
|
|
.into_response())
|
|
}
|
|
};
|
|
|
|
let cache_key = (
|
|
trigger.workspace_id.clone(),
|
|
resource_path.clone(),
|
|
authed.clone(),
|
|
);
|
|
|
|
let authentication_method = match HTTP_AUTH_CACHE.get(&cache_key) {
|
|
Some(cache_entry) if cache_entry.expiry > std::time::Instant::now() => {
|
|
tracing::debug!("HTTP auth method cache hit for route {}", trigger.path);
|
|
cache_entry.value
|
|
}
|
|
_ => {
|
|
tracing::debug!("HTTP auth method cache miss for route {}", trigger.path);
|
|
let auth_method = try_get_resource_from_db_as::<
|
|
super::http_trigger_auth::AuthenticationMethod,
|
|
>(
|
|
&authed,
|
|
Some(user_db.clone()),
|
|
&db,
|
|
&resource_path,
|
|
&trigger.workspace_id,
|
|
)
|
|
.await
|
|
.map_err(|e| e.into_response())?;
|
|
HTTP_AUTH_CACHE.insert(
|
|
cache_key,
|
|
ExpiringCacheEntry {
|
|
value: auth_method.clone(),
|
|
expiry: std::time::Instant::now() + std::time::Duration::from_secs(60),
|
|
},
|
|
);
|
|
auth_method
|
|
}
|
|
};
|
|
|
|
let raw_payload = args.0.metadata.raw_string.as_ref();
|
|
|
|
let response = authentication_method
|
|
.authenticate_http_request(&headers, raw_payload)
|
|
.map_err(|e| e.into_response())?;
|
|
|
|
if let Some(response) = response {
|
|
return Ok(response);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(not(feature = "parquet"))]
|
|
if trigger.static_asset_config.is_some() {
|
|
return Err(Error::internal_err(
|
|
"Static asset configuration is not supported in this build".to_string(),
|
|
)
|
|
.into_response());
|
|
}
|
|
|
|
#[cfg(feature = "parquet")]
|
|
if let Some(sqlx::types::Json(config)) = trigger.static_asset_config {
|
|
let build_static_response_f = async {
|
|
use windmill_object_store::object_store_reexports::ObjectStoreExt;
|
|
|
|
let (_, s3_resource_opt) = get_workspace_s3_resource(
|
|
&authed,
|
|
&db,
|
|
None,
|
|
&trigger.workspace_id,
|
|
config.storage.clone(),
|
|
)
|
|
.await?;
|
|
let s3_resource = s3_resource_opt.ok_or_else(|| {
|
|
windmill_object_store::workspace_storage_misconfigured(config.storage.as_deref())
|
|
})?;
|
|
let s3_client = build_object_store_client(&s3_resource).await?;
|
|
|
|
let path = if trigger.is_static_website {
|
|
let subpath = params
|
|
.get("wm_subpath")
|
|
.cloned()
|
|
.unwrap_or("index.html".to_string());
|
|
tracing::info!("subpath: {}", subpath);
|
|
format!("{}/{}", config.s3.trim_end_matches('/'), subpath)
|
|
} else {
|
|
config.s3.clone()
|
|
};
|
|
let path = windmill_object_store::object_store_reexports::Path::from(path);
|
|
let s3_object = s3_client.get(&path).await;
|
|
|
|
let s3_object = match s3_object {
|
|
Err(
|
|
windmill_object_store::object_store_reexports::ObjectStoreError::NotFound {
|
|
..
|
|
},
|
|
) if trigger.is_static_website => {
|
|
// fallback to index.html if the file is not found
|
|
let path = windmill_object_store::object_store_reexports::Path::from(format!(
|
|
"{}/index.html",
|
|
config.s3.trim_end_matches('/')
|
|
));
|
|
s3_client.get(&path).await
|
|
}
|
|
r => r,
|
|
};
|
|
|
|
let s3_object = s3_object.map_err(|err| {
|
|
tracing::warn!("Error retrieving file from S3: {:?}", err);
|
|
let mut msg = format!("Error retrieving file: {err}");
|
|
let mut source = std::error::Error::source(&err);
|
|
while let Some(e) = source {
|
|
msg.push_str(&format!("\n caused by: {e}"));
|
|
source = e.source();
|
|
}
|
|
Error::internal_err(msg)
|
|
})?;
|
|
|
|
let mut response_headers = http::HeaderMap::new();
|
|
if let Some(ref e_tag) = s3_object.meta.e_tag {
|
|
if let Some(if_none_match) = headers.get(http::header::IF_NONE_MATCH) {
|
|
if if_none_match == e_tag {
|
|
return Ok::<_, Error>((
|
|
StatusCode::NOT_MODIFIED,
|
|
response_headers,
|
|
axum::body::Body::empty(),
|
|
));
|
|
}
|
|
}
|
|
if let Ok(e_tag) = e_tag.parse() {
|
|
response_headers.insert("etag", e_tag);
|
|
}
|
|
}
|
|
response_headers.insert(
|
|
"content-type",
|
|
s3_object
|
|
.attributes
|
|
.get(&windmill_object_store::object_store_reexports::Attribute::ContentType)
|
|
.map(|s| s.parse().ok())
|
|
.flatten()
|
|
.unwrap_or("application/octet-stream".parse().unwrap()),
|
|
);
|
|
response_headers.insert("x-content-type-options", "nosniff".parse().unwrap());
|
|
if !trigger.is_static_website {
|
|
response_headers.insert(
|
|
"content-disposition",
|
|
config.filename.as_ref().map_or_else(
|
|
|| {
|
|
s3_object
|
|
.attributes
|
|
.get(&windmill_object_store::object_store_reexports::Attribute::ContentDisposition)
|
|
.map(|s| s.parse().ok())
|
|
.flatten()
|
|
.unwrap_or("inline".parse().unwrap())
|
|
},
|
|
|filename| {
|
|
format!("inline; filename=\"{}\"", filename)
|
|
.parse()
|
|
.unwrap_or("inline".parse().unwrap())
|
|
},
|
|
),
|
|
);
|
|
// For single-file triggers, sandbox any HTML/SVG so it can't
|
|
// reach the viewer's session cookie. Allow-scripts/forms/etc.
|
|
// keep the opaque origin (cookies still blocked) while
|
|
// preserving JS for legitimate HTML payloads. Static-website
|
|
// triggers intentionally serve a live web app and cannot be
|
|
// sandboxed; restrict write access to those buckets at the
|
|
// workspace level.
|
|
response_headers.insert(
|
|
"content-security-policy",
|
|
"sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads"
|
|
.parse()
|
|
.unwrap(),
|
|
);
|
|
}
|
|
|
|
let body_stream = axum::body::Body::from_stream(s3_object.into_stream());
|
|
Ok::<_, Error>((StatusCode::OK, response_headers, body_stream))
|
|
};
|
|
match build_static_response_f.await {
|
|
Ok((status, headers, body_stream)) => {
|
|
return Ok((status, headers, body_stream).into_response())
|
|
}
|
|
Err(e) => return Err(e.into_response()),
|
|
}
|
|
}
|
|
|
|
let runnable_format = get_runnable_format(
|
|
if trigger.is_flow {
|
|
RunnableId::from_flow_path(&trigger.script_path)
|
|
} else {
|
|
RunnableId::from_script_path(&trigger.script_path)
|
|
},
|
|
&trigger.workspace_id,
|
|
&db,
|
|
&TriggerKind::Http,
|
|
)
|
|
.await
|
|
.map_err(|e| e.into_response())?;
|
|
|
|
let args = args
|
|
.to_args_from_format(
|
|
&trigger.route_path,
|
|
&called_path,
|
|
&trigger.path,
|
|
¶ms,
|
|
runnable_format,
|
|
trigger.wrap_body,
|
|
)
|
|
.map_err(|e| e.into_response())?;
|
|
|
|
let trigger_info = TriggerMetadata::new(Some(trigger.path.clone()), JobTriggerKind::Http);
|
|
if trigger.mode == TriggerMode::Suspended {
|
|
let _ = trigger_runnable(
|
|
&db,
|
|
Some(user_db),
|
|
authed,
|
|
&trigger.workspace_id,
|
|
&trigger.script_path,
|
|
trigger.is_flow,
|
|
args,
|
|
trigger.retry.as_ref(),
|
|
trigger.error_handler_path.as_deref(),
|
|
trigger.error_handler_args.as_ref(),
|
|
format!("http_trigger/{}", trigger.path),
|
|
None,
|
|
true,
|
|
trigger_info,
|
|
)
|
|
.await
|
|
.map_err(|e| e.into_response())?;
|
|
|
|
return Ok((
|
|
StatusCode::OK,
|
|
format!(
|
|
"Trigger {} is in suspended mode, jobs are added to the queue but suspended",
|
|
&trigger.path
|
|
),
|
|
)
|
|
.into_response());
|
|
}
|
|
|
|
// Handle execution based on the execution mode
|
|
match trigger.request_type {
|
|
RequestType::SyncSse => {
|
|
// Trigger the job (always async when streaming)
|
|
let (uuid, _, early_return, has_failure_module, _) = trigger_runnable_inner(
|
|
&db,
|
|
None,
|
|
Some(user_db.clone()),
|
|
authed.clone(),
|
|
&trigger.workspace_id,
|
|
&trigger.script_path,
|
|
trigger.is_flow,
|
|
args,
|
|
trigger.retry.as_ref(),
|
|
trigger.error_handler_path.as_deref(),
|
|
trigger.error_handler_args.as_ref(),
|
|
format!("http_trigger/{}", trigger.path),
|
|
None,
|
|
trigger_info,
|
|
None,
|
|
)
|
|
.await
|
|
.map_err(|e| e.into_response())?;
|
|
|
|
// Set up SSE stream
|
|
let opt_authed = Some(authed.clone());
|
|
let opt_tokened = OptTokened { token: None };
|
|
let (tx, rx) = tokio::sync::mpsc::channel(32);
|
|
|
|
let stream = tokio_stream::wrappers::ReceiverStream::new(rx).map(|x| {
|
|
format!(
|
|
"data: {}\n\n",
|
|
serde_json::to_string(&x).unwrap_or_default()
|
|
)
|
|
});
|
|
|
|
start_job_update_sse_stream(
|
|
opt_authed,
|
|
opt_tokened,
|
|
db.clone(),
|
|
trigger.workspace_id.clone(),
|
|
uuid,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(true),
|
|
Some(true),
|
|
None,
|
|
None,
|
|
tx,
|
|
None,
|
|
early_return,
|
|
has_failure_module,
|
|
false,
|
|
);
|
|
|
|
let body = axum::body::Body::from_stream(
|
|
stream.map(std::result::Result::<_, std::convert::Infallible>::Ok),
|
|
);
|
|
|
|
Ok(Response::builder()
|
|
.status(200)
|
|
.header("Content-Type", "text/event-stream")
|
|
.header("Cache-Control", "no-cache")
|
|
.body(body)
|
|
.map_err(|e| Error::internal_err(e.to_string()).into_response())?)
|
|
}
|
|
RequestType::Async => trigger_runnable(
|
|
&db,
|
|
Some(user_db),
|
|
authed,
|
|
&trigger.workspace_id,
|
|
&trigger.script_path,
|
|
trigger.is_flow,
|
|
args,
|
|
trigger.retry.as_ref(),
|
|
trigger.error_handler_path.as_deref(),
|
|
trigger.error_handler_args.as_ref(),
|
|
format!("http_trigger/{}", trigger.path),
|
|
None,
|
|
false,
|
|
trigger_info,
|
|
)
|
|
.await
|
|
.map_err(|e| e.into_response()),
|
|
RequestType::Sync => trigger_runnable_and_wait_for_result(
|
|
&db,
|
|
Some(user_db),
|
|
authed,
|
|
&trigger.workspace_id,
|
|
&trigger.script_path,
|
|
trigger.is_flow,
|
|
args,
|
|
trigger.retry.as_ref(),
|
|
trigger.error_handler_path.as_deref(),
|
|
trigger.error_handler_args.as_ref(),
|
|
format!("http_trigger/{}", trigger.path),
|
|
trigger_info,
|
|
)
|
|
.await
|
|
.map_err(|e| e.into_response()),
|
|
}
|
|
}
|