feat: serve static websites (#5218)

* feat: serve static websites

* nit

* sqlx

* nit

* nits

* nits

* nits

* nits
This commit is contained in:
HugoCasa
2025-02-06 17:38:12 +01:00
committed by GitHub
parent 2478df52a5
commit 48e4ae6561
21 changed files with 731 additions and 425 deletions
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path, script_path, is_flow, route_path, workspace_id, is_async, requires_auth, edited_by, email, static_asset_config as \"static_asset_config: _\" FROM http_trigger WHERE workspace_id = $1 AND http_method = $2",
"query": "SELECT path, script_path, is_flow, route_path, workspace_id, is_async, requires_auth, edited_by, email, static_asset_config as \"static_asset_config: _\", is_static_website FROM http_trigger WHERE http_method = $1",
"describe": {
"columns": [
{
@@ -52,11 +52,15 @@
"ordinal": 9,
"name": "static_asset_config: _",
"type_info": "Jsonb"
},
{
"ordinal": 10,
"name": "is_static_website",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
{
"Custom": {
"name": "http_method",
@@ -83,8 +87,9 @@
false,
false,
false,
true
true,
false
]
},
"hash": "a76be7f4e9e8b8c81afe50fcbf1f3d393ed0bb73314e5c430f32541c43bcac52"
"hash": "1961d15ae075072bd5f677c95f9b4dac7f747585d98aa8bcf60dcfb4c8124028"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id, path, route_path, route_path_key, script_path, is_flow, http_method as \"http_method: _\", edited_by, email, edited_at, extra_perms, is_async, requires_auth, static_asset_config as \"static_asset_config: _\"\n FROM http_trigger\n WHERE workspace_id = $1 AND path = $2",
"query": "SELECT workspace_id, path, route_path, route_path_key, script_path, is_flow, http_method as \"http_method: _\", edited_by, email, edited_at, extra_perms, is_async, requires_auth, static_asset_config as \"static_asset_config: _\", is_static_website\n FROM http_trigger\n WHERE workspace_id = $1 AND path = $2",
"describe": {
"columns": [
{
@@ -85,6 +85,11 @@
"ordinal": 13,
"name": "static_asset_config: _",
"type_info": "Jsonb"
},
{
"ordinal": 14,
"name": "is_static_website",
"type_info": "Bool"
}
],
"parameters": {
@@ -107,8 +112,9 @@
false,
false,
false,
true
true,
false
]
},
"hash": "f904702536c106b0e5da8facae119c6af887c49a29ae44b3a95350ff27fb1ccf"
"hash": "197321abfe4667256761884970334f58c1b3edfcc1e863ec4316c9742a1ac7c8"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM http_trigger WHERE route_path_key = $1 AND http_method = $2)",
"query": "SELECT EXISTS(SELECT 1 FROM http_trigger WHERE route_path_key = $1 AND http_method = $2 AND ($3::TEXT IS NULL OR path != $3))",
"describe": {
"columns": [
{
@@ -25,12 +25,13 @@
]
}
}
}
},
"Text"
]
},
"nullable": [
null
]
},
"hash": "16c4fb122ceb30722f54c92db97427b0ddd36f495d549f8cd23d659d2c866a63"
"hash": "63b5f03741be97d0e8763dd070649ebb6ec02aa083d7e175c1a02a38935a4024"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE http_trigger SET script_path = $1, path = $2, is_flow = $3, http_method = $4, static_asset_config = $5, edited_by = $6, email = $7, is_async = $8, requires_auth = $9, edited_at = now() \n WHERE workspace_id = $10 AND path = $11",
"query": "UPDATE http_trigger SET script_path = $1, path = $2, is_flow = $3, http_method = $4, static_asset_config = $5, edited_by = $6, email = $7, is_async = $8, requires_auth = $9, edited_at = now(), is_static_website = $10\n WHERE workspace_id = $11 AND path = $12",
"describe": {
"columns": [],
"parameters": {
@@ -27,11 +27,12 @@
"Varchar",
"Bool",
"Bool",
"Bool",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "7113d7cc72e44e4b7e01b69cc18cbe7b0399cf8ec0e9e6d2b05ceef589c432df"
"hash": "89c08575afb31b70984f6b2b7dd4297af93b5b83ffdfb3ec91eba5df7ad3fd95"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path, script_path, is_flow, route_path, workspace_id, is_async, requires_auth, edited_by, email, static_asset_config as \"static_asset_config: _\" FROM http_trigger WHERE http_method = $1",
"query": "SELECT path, script_path, is_flow, route_path, workspace_id, is_async, requires_auth, edited_by, email, static_asset_config as \"static_asset_config: _\", is_static_website FROM http_trigger WHERE workspace_id = $1 AND http_method = $2",
"describe": {
"columns": [
{
@@ -52,10 +52,16 @@
"ordinal": 9,
"name": "static_asset_config: _",
"type_info": "Jsonb"
},
{
"ordinal": 10,
"name": "is_static_website",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
{
"Custom": {
"name": "http_method",
@@ -82,8 +88,9 @@
false,
false,
false,
true
true,
false
]
},
"hash": "28a389a93a3d2472b13d956ec55eb357c6147e186e00e371f7374d166903ef64"
"hash": "8ff25d890d3f7019c5c6f2b47f29f4c7b2c91094036d9b5037ef9a4ac986ee53"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM http_trigger WHERE route_path_key = $1 AND workspace_id = $2 AND http_method = $3)",
"query": "SELECT EXISTS(SELECT 1 FROM http_trigger WHERE route_path_key = $1 AND workspace_id = $2 AND http_method = $3 AND ($4::TEXT IS NULL OR path != $4))",
"describe": {
"columns": [
{
@@ -26,12 +26,13 @@
]
}
}
}
},
"Text"
]
},
"nullable": [
null
]
},
"hash": "867e0f8299e22a6b8e02d21adeb0281b1a10593715db823a5adeeac3ab1058cd"
"hash": "a96ff22bc78b74d7234550a12d9fb5c555c1187b276f1353dae1b2ac0670a92a"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO http_trigger (workspace_id, path, route_path, route_path_key, script_path, is_flow, is_async, requires_auth, http_method, static_asset_config, edited_by, email, edited_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now())",
"query": "INSERT INTO http_trigger (workspace_id, path, route_path, route_path_key, script_path, is_flow, is_async, requires_auth, http_method, static_asset_config, edited_by, email, edited_at, is_static_website) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now(), $13)",
"describe": {
"columns": [],
"parameters": {
@@ -29,10 +29,11 @@
},
"Jsonb",
"Varchar",
"Varchar"
"Varchar",
"Bool"
]
},
"nullable": []
},
"hash": "333b484ffa030dee08e7b1161fcbc48af411377d2d9f58f92fc9d5eacdf0fba1"
"hash": "b41cef713e822bbd89b49b1f35cc662539d5e4af1dd0b5923d8ee17b772c5677"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE http_trigger \n SET route_path = $1, route_path_key = $2, script_path = $3, path = $4, is_flow = $5, http_method = $6, static_asset_config = $7, edited_by = $8, email = $9, is_async = $10, requires_auth = $11, edited_at = now() \n WHERE workspace_id = $12 AND path = $13",
"query": "UPDATE http_trigger \n SET route_path = $1, route_path_key = $2, script_path = $3, path = $4, is_flow = $5, http_method = $6, static_asset_config = $7, edited_by = $8, email = $9, is_async = $10, requires_auth = $11, edited_at = now(), is_static_website = $12\n WHERE workspace_id = $13 AND path = $14",
"describe": {
"columns": [],
"parameters": {
@@ -29,11 +29,12 @@
"Varchar",
"Bool",
"Bool",
"Bool",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "487d377e2df67fc3ea39d183ba9f99d45828d7c8e0ff10c5d74c454472e0493c"
"hash": "ccb7aea162fa8781675d547fb82f2996dede4de49c5bd9cca2928209dc40b8f1"
}
@@ -0,0 +1,11 @@
ALTER TABLE http_trigger DROP COLUMN is_static_website;
CREATE OR REPLACE FUNCTION prevent_route_path_change()
RETURNS TRIGGER AS $$
BEGIN
IF CURRENT_USER <> 'windmill_admin' AND NEW.route_path <> OLD.route_path THEN
RAISE EXCEPTION 'Modification of route_path is only allowed by admins';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
@@ -0,0 +1,12 @@
ALTER TABLE http_trigger ADD COLUMN is_static_website BOOLEAN NOT NULL DEFAULT FALSE;
CREATE OR REPLACE FUNCTION prevent_route_path_change()
RETURNS TRIGGER AS $$
BEGIN
IF CURRENT_USER = 'windmill_user' AND NEW.route_path <> OLD.route_path THEN
RAISE EXCEPTION 'Modification of route_path is only allowed by admins';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
+11 -1
View File
@@ -7781,8 +7781,9 @@ paths:
http_method:
type: string
enum: ["get", "post", "put", "delete", "patch"]
trigger_path:
type: string
required:
- kind
- route_path
- http_method
responses:
@@ -12998,6 +12999,8 @@ components:
type: boolean
requires_auth:
type: boolean
is_static_website:
type: boolean
required:
- path
@@ -13012,6 +13015,7 @@ components:
- is_async
- requires_auth
- http_method
- is_static_website
NewHttpTrigger:
type: object
@@ -13047,6 +13051,8 @@ components:
type: boolean
requires_auth:
type: boolean
is_static_website:
type: boolean
required:
- path
@@ -13056,6 +13062,7 @@ components:
- is_async
- requires_auth
- http_method
- is_static_website
EditHttpTrigger:
type: object
@@ -13091,6 +13098,8 @@ components:
type: boolean
requires_auth:
type: boolean
is_static_website:
type: boolean
required:
- path
- script_path
@@ -13099,6 +13108,7 @@ components:
- is_async
- requires_auth
- http_method
- is_static_website
TriggersCount:
type: object
+175 -62
View File
@@ -36,7 +36,8 @@ use windmill_common::{
};
lazy_static::lazy_static! {
static ref ROUTE_PATH_KEY_RE: regex::Regex = regex::Regex::new(r"/:\w+").unwrap();
static ref ROUTE_PATH_KEY_RE: regex::Regex = regex::Regex::new(r"/?:[-\w]+").unwrap();
static ref VALID_ROUTE_PATH_RE: regex::Regex = regex::Regex::new(r"^:?[-\w]+(/:?[-\w]+)*$").unwrap();
}
pub fn routes_global_service() -> Router {
@@ -109,10 +110,11 @@ struct NewTrigger {
requires_auth: bool,
http_method: HttpMethod,
static_asset_config: Option<sqlx::types::Json<S3Object>>,
is_static_website: bool,
}
#[derive(FromRow, Serialize)]
struct Trigger {
struct HttpTrigger {
workspace_id: String,
path: String,
route_path: String,
@@ -127,6 +129,7 @@ struct Trigger {
requires_auth: bool,
http_method: HttpMethod,
static_asset_config: Option<sqlx::types::Json<S3Object>>,
is_static_website: bool,
}
#[derive(Deserialize)]
@@ -139,6 +142,7 @@ struct EditTrigger {
requires_auth: bool,
http_method: HttpMethod,
static_asset_config: Option<sqlx::types::Json<S3Object>>,
is_static_website: bool,
}
#[derive(Deserialize)]
@@ -155,7 +159,7 @@ async fn list_triggers(
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(lst): Query<ListTriggerQuery>,
) -> error::JsonResult<Vec<Trigger>> {
) -> error::JsonResult<Vec<HttpTrigger>> {
let mut tx = user_db.begin(&authed).await?;
let (per_page, offset) = paginate(Pagination { per_page: lst.per_page, page: lst.page });
let mut sqlb = SqlBuilder::select_from("http_trigger")
@@ -177,7 +181,7 @@ async fn list_triggers(
let sql = sqlb
.sql()
.map_err(|e| error::Error::internal_err(e.to_string()))?;
let rows = sqlx::query_as::<_, Trigger>(&sql)
let rows = sqlx::query_as::<_, HttpTrigger>(&sql)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
@@ -189,12 +193,12 @@ async fn get_trigger(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> error::JsonResult<Trigger> {
) -> error::JsonResult<HttpTrigger> {
let mut tx = user_db.begin(&authed).await?;
let path = path.to_path();
let trigger = sqlx::query_as!(
Trigger,
r#"SELECT workspace_id, path, route_path, route_path_key, script_path, is_flow, http_method as "http_method: _", edited_by, email, edited_at, extra_perms, is_async, requires_auth, static_asset_config as "static_asset_config: _"
HttpTrigger,
r#"SELECT workspace_id, path, route_path, route_path_key, script_path, is_flow, http_method as "http_method: _", edited_by, email, edited_at, extra_perms, is_async, requires_auth, static_asset_config as "static_asset_config: _", is_static_website
FROM http_trigger
WHERE workspace_id = $1 AND path = $2"#,
w_id,
@@ -211,17 +215,38 @@ async fn get_trigger(
async fn create_trigger(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(ct): Json<NewTrigger>,
) -> error::Result<(StatusCode, String)> {
require_admin(authed.is_admin, &authed.username)?;
if !VALID_ROUTE_PATH_RE.is_match(&ct.route_path) {
return Err(error::Error::BadRequest("Invalid route path".to_string()));
}
// route path key is extracted from the route path to check for uniqueness
// it replaces /?:{key} with :key
// it will also remove the leading / if present, not an issue as we only allow : after slashes
let route_path_key = ROUTE_PATH_KEY_RE.replace_all(ct.route_path.as_str(), ":key");
let exists = route_path_key_exists(&route_path_key, &ct.http_method, &w_id, None, &db).await?;
if exists {
return Err(error::Error::BadRequest(
"A route already exists with this path".to_string(),
));
}
if *CLOUD_HOSTED && (ct.is_static_website || ct.static_asset_config.is_some()) {
return Err(error::Error::BadRequest(
"Static website and static asset are not supported on cloud".to_string(),
));
}
let mut tx = user_db.begin(&authed).await?;
sqlx::query!(
"INSERT INTO http_trigger (workspace_id, path, route_path, route_path_key, script_path, is_flow, is_async, requires_auth, http_method, static_asset_config, edited_by, email, edited_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now())",
"INSERT INTO http_trigger (workspace_id, path, route_path, route_path_key, script_path, is_flow, is_async, requires_auth, http_method, static_asset_config, edited_by, email, edited_at, is_static_website) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now(), $13)",
w_id,
ct.path,
ct.route_path,
@@ -233,7 +258,8 @@ async fn create_trigger(
ct.http_method as _,
ct.static_asset_config as _,
&authed.username,
&authed.email
&authed.email,
ct.is_static_website,
)
.execute(&mut *tx).await?;
@@ -256,27 +282,48 @@ async fn create_trigger(
async fn update_trigger(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
Json(ct): Json<EditTrigger>,
) -> error::Result<String> {
let path = path.to_path();
let mut tx = user_db.begin(&authed).await?;
if *CLOUD_HOSTED && (ct.is_static_website || ct.static_asset_config.is_some()) {
return Err(error::Error::BadRequest(
"Static website and static asset are not supported on cloud".to_string(),
));
}
let mut tx;
if authed.is_admin {
if ct.route_path.is_none() {
let Some(route_path) = ct.route_path else {
return Err(error::Error::BadRequest(
"route_path is required".to_string(),
));
};
if !VALID_ROUTE_PATH_RE.is_match(&route_path) {
return Err(error::Error::BadRequest("Invalid route path".to_string()));
}
let route_path_key =
ROUTE_PATH_KEY_RE.replace_all(ct.route_path.as_ref().unwrap().as_str(), ":key");
let route_path_key = ROUTE_PATH_KEY_RE.replace_all(&route_path, ":key");
let exists =
route_path_key_exists(&route_path_key, &ct.http_method, &w_id, Some(&path), &db)
.await?;
if exists {
return Err(error::Error::BadRequest(
"A route already exists with this path".to_string(),
));
}
tx = user_db.begin(&authed).await?;
sqlx::query!(
"UPDATE http_trigger
SET route_path = $1, route_path_key = $2, script_path = $3, path = $4, is_flow = $5, http_method = $6, static_asset_config = $7, edited_by = $8, email = $9, is_async = $10, requires_auth = $11, edited_at = now()
WHERE workspace_id = $12 AND path = $13",
ct.route_path,
SET route_path = $1, route_path_key = $2, script_path = $3, path = $4, is_flow = $5, http_method = $6, static_asset_config = $7, edited_by = $8, email = $9, is_async = $10, requires_auth = $11, edited_at = now(), is_static_website = $12
WHERE workspace_id = $13 AND path = $14",
route_path,
&route_path_key,
ct.script_path,
ct.path,
@@ -287,14 +334,16 @@ async fn update_trigger(
&authed.email,
ct.is_async,
ct.requires_auth,
ct.is_static_website,
w_id,
path,
)
.execute(&mut *tx).await?;
} else {
tx = user_db.begin(&authed).await?;
sqlx::query!(
"UPDATE http_trigger SET script_path = $1, path = $2, is_flow = $3, http_method = $4, static_asset_config = $5, edited_by = $6, email = $7, is_async = $8, requires_auth = $9, edited_at = now()
WHERE workspace_id = $10 AND path = $11",
"UPDATE http_trigger SET script_path = $1, path = $2, is_flow = $3, http_method = $4, static_asset_config = $5, edited_by = $6, email = $7, is_async = $8, requires_auth = $9, edited_at = now(), is_static_website = $10
WHERE workspace_id = $11 AND path = $12",
ct.script_path,
ct.path,
ct.is_flow,
@@ -304,6 +353,7 @@ async fn update_trigger(
&authed.email,
ct.is_async,
ct.requires_auth,
ct.is_static_website,
w_id,
path,
)
@@ -378,34 +428,57 @@ async fn exists_trigger(
struct RouteExists {
route_path: String,
http_method: HttpMethod,
trigger_path: Option<String>,
}
async fn route_path_key_exists(
route_path_key: &str,
http_method: &HttpMethod,
w_id: &str,
trigger_path: Option<&str>,
db: &DB,
) -> error::Result<bool> {
let exists = if *CLOUD_HOSTED {
sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM http_trigger WHERE route_path_key = $1 AND workspace_id = $2 AND http_method = $3 AND ($4::TEXT IS NULL OR path != $4))",
&route_path_key,
w_id,
http_method as &HttpMethod,
trigger_path
)
.fetch_one(db)
.await?
.unwrap_or(false)
} else {
sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM http_trigger WHERE route_path_key = $1 AND http_method = $2 AND ($3::TEXT IS NULL OR path != $3))",
&route_path_key,
http_method as &HttpMethod,
trigger_path
)
.fetch_one(db)
.await?
.unwrap_or(false)
};
Ok(exists)
}
async fn exists_route(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(RouteExists { route_path, http_method }): Json<RouteExists>,
Json(RouteExists { route_path, http_method, trigger_path }): Json<RouteExists>,
) -> JsonResult<bool> {
let route_path_key = ROUTE_PATH_KEY_RE.replace_all(route_path.as_str(), ":key");
let exists = if *CLOUD_HOSTED {
sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM http_trigger WHERE route_path_key = $1 AND workspace_id = $2 AND http_method = $3)",
&route_path_key,
w_id,
http_method as HttpMethod
)
.fetch_one(&db)
.await?
.unwrap_or(false)
} else {
sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM http_trigger WHERE route_path_key = $1 AND http_method = $2)",
&route_path_key,
http_method as HttpMethod
)
.fetch_one(&db)
.await?
.unwrap_or(false)
};
let exists = route_path_key_exists(
&route_path_key,
&http_method,
&w_id,
trigger_path.as_deref(),
&db,
)
.await?;
Ok(Json(exists))
}
@@ -420,6 +493,7 @@ struct TriggerRoute {
edited_by: String,
email: String,
static_asset_config: Option<sqlx::types::Json<S3Object>>,
is_static_website: bool,
}
async fn get_http_route_trigger(
@@ -439,7 +513,7 @@ async fn get_http_route_trigger(
let route_path = StripPath(splitted.collect::<Vec<_>>().join("/"));
let triggers = sqlx::query_as!(
TriggerRoute,
r#"SELECT path, script_path, is_flow, route_path, workspace_id, is_async, requires_auth, edited_by, email, static_asset_config as "static_asset_config: _" FROM http_trigger WHERE workspace_id = $1 AND http_method = $2"#,
r#"SELECT path, script_path, is_flow, route_path, workspace_id, is_async, requires_auth, edited_by, email, static_asset_config as "static_asset_config: _", is_static_website FROM http_trigger WHERE workspace_id = $1 AND http_method = $2"#,
w_id,
http_method as HttpMethod
)
@@ -449,7 +523,7 @@ async fn get_http_route_trigger(
} else {
let triggers = sqlx::query_as!(
TriggerRoute,
r#"SELECT path, script_path, is_flow, route_path, workspace_id, is_async, requires_auth, edited_by, email, static_asset_config as "static_asset_config: _" FROM http_trigger WHERE http_method = $1"#,
r#"SELECT path, script_path, is_flow, route_path, workspace_id, is_async, requires_auth, edited_by, email, static_asset_config as "static_asset_config: _", is_static_website FROM http_trigger WHERE http_method = $1"#,
http_method as HttpMethod
)
.fetch_all(db)
@@ -461,6 +535,17 @@ async fn get_http_route_trigger(
for (idx, trigger) in triggers.iter().enumerate() {
let route_path = trigger.route_path.clone();
if trigger.is_static_website {
router
.insert(format!("{}/*wm_subpath", route_path), idx)
.unwrap_or_else(|e| {
tracing::warn!(
"Failed to consider http trigger route {}: {:?}",
route_path,
e,
);
});
}
router.insert(route_path.as_str(), idx).unwrap_or_else(|e| {
tracing::warn!(
"Failed to consider http trigger route {}: {:?}",
@@ -565,7 +650,7 @@ async fn route_job(
headers: HeaderMap,
args: WebhookArgs,
) -> impl IntoResponse {
let route_path = route_path.to_path();
let route_path = route_path.to_path().trim_end_matches("/");
let (trigger, called_path, params, authed) = match get_http_route_trigger(
route_path,
&auth_cache,
@@ -612,11 +697,37 @@ async fn route_job(
"No files storage resource defined at the workspace level".to_string(),
))?;
let s3_client = build_object_store_client(&s3_resource).await?;
let path = object_store::path::Path::from(config.s3);
let s3_object = s3_client.get(&path).await.map_err(|err| {
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 = object_store::path::Path::from(path);
let s3_object = s3_client.get(&path).await;
let s3_object = match s3_object {
Err(object_store::Error::NotFound { .. }) if trigger.is_static_website => {
// fallback to index.html if the file is not found
let path = object_store::path::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);
error::Error::internal_err(format!("Error retrieving file: {}", err.to_string()))
})?;
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(IF_NONE_MATCH) {
@@ -641,24 +752,26 @@ async fn route_job(
.flatten()
.unwrap_or("application/octet-stream".parse().unwrap()),
);
response_headers.insert(
"content-disposition",
config.filename.as_ref().map_or_else(
|| {
s3_object
.attributes
.get(&object_store::Attribute::ContentDisposition)
.map(|s| s.parse().ok())
.flatten()
.unwrap_or("inline".parse().unwrap())
},
|filename| {
format!("inline; filename=\"{}\"", filename)
.parse()
.unwrap_or("inline".parse().unwrap())
},
),
);
if !trigger.is_static_website {
response_headers.insert(
"content-disposition",
config.filename.as_ref().map_or_else(
|| {
s3_object
.attributes
.get(&object_store::Attribute::ContentDisposition)
.map(|s| s.parse().ok())
.flatten()
.unwrap_or("inline".parse().unwrap())
},
|filename| {
format!("inline; filename=\"{}\"", filename)
.parse()
.unwrap_or("inline".parse().unwrap())
},
),
);
}
let body_stream = axum::body::Body::from_stream(s3_object.into_stream());
Ok::<_, error::Error>((StatusCode::OK, response_headers, body_stream))
+1 -1
View File
@@ -31,7 +31,7 @@ impl<B> OnResponse<B> for MyOnResponse {
if *LOG_REQUESTS {
let latency = latency.as_millis();
let status = response.status().as_u16();
if response.status().is_success() {
if response.status().is_success() || response.status().is_redirection() {
tracing::info!(latency = latency, status = status, "response")
} else {
tracing::error!(latency = latency, status = status, "response")
@@ -43,6 +43,7 @@
export let initialFileKey: { s3: string } | undefined = undefined
let initialFileKeyInternalCopy: { s3: string }
export let selectedFileKey: { s3: string } | undefined = undefined
export let folderOnly = false
let csvSeparatorChar: string = ','
let csvHasHeader: boolean = true
@@ -369,6 +370,11 @@
let item_key = displayedFileKeys[index]
let item = allFilesByKey[item_key]
if (item.type === 'folder') {
if (folderOnly) {
selectedFileKey = {
s3: item_key
}
}
if (toggleCollapsed) {
item.collapsed = !item.collapsed
}
@@ -709,7 +715,9 @@
>
{#if !fromWorkspaceSettings}
<Button
disable={selectedFileKey === undefined || emptyString(selectedFileKey.s3)}
disabled={selectedFileKey === undefined ||
emptyString(selectedFileKey.s3) ||
(folderOnly && allFilesByKey[selectedFileKey.s3]?.type !== 'folder')}
on:click={selectAndClose}>Select</Button
>
{/if}
@@ -20,30 +20,24 @@
export let submittedText: string | undefined = undefined
export let defaultFile: string | undefined = undefined
export let disabled: boolean | undefined = undefined
export let folderOnly = false
const dispatch = createEventDispatcher()
let input: HTMLInputElement
export let files: File[] | undefined = undefined
type FileWithPath = File & { path?: string }
export let files: FileWithPath[] | undefined = undefined
async function onChange(fileList: FileList | null) {
async function onChange(fileList: FileWithPath[] | null) {
if (!fileList || !fileList.length) {
files = undefined
dispatch('change', files)
return
}
if (!multiple || !files) {
files = []
}
for (let i = 0; i < fileList.length; i++) {
const file = fileList.item(i)
if (file) {
files.push(file)
}
}
if (!files.length) {
files = undefined
if (multiple && files) {
files = [...files, ...fileList]
} else {
files = fileList
}
// Needs to be reset so the same file can be selected
@@ -86,15 +80,70 @@
}
}
function handleDrop(event: DragEvent) {
async function handleFile(fileEntry: FileSystemFileEntry): Promise<FileWithPath> {
return new Promise((resolve, reject) => {
fileEntry.file(resolve, reject)
})
}
async function handleDirectory(
dirEntry: FileSystemDirectoryEntry,
path: string
): Promise<FileWithPath[]> {
const files: FileWithPath[] = []
const dirReader = dirEntry.createReader()
async function readEntries() {
return new Promise<FileWithPath[]>((resolve) => {
dirReader.readEntries(async (entries) => {
if (entries.length === 0) {
resolve(files)
return
}
const filePromises = entries.map(async (entry) => {
return traverseFileTree(entry, path + dirEntry.name + '/')
})
const nestedFiles = await Promise.all(filePromises)
files.push(...nestedFiles.flat())
// readEntries only return up to 100 files
// continue reading if more files exist
resolve(await readEntries())
})
})
}
return readEntries()
}
async function traverseFileTree(entry: FileSystemEntry, path = ''): Promise<FileWithPath[]> {
if (entry.isFile) {
const file = await handleFile(entry as FileSystemFileEntry)
file.path = path + file.name
return [file]
} else if (entry.isDirectory) {
return handleDirectory(entry as FileSystemDirectoryEntry, path)
}
return []
}
async function handleDrop(event: DragEvent) {
event.preventDefault()
if (event.dataTransfer) {
if (event.dataTransfer.files && event.dataTransfer.files.length) {
if (!multiple && event.dataTransfer.files.length > 1) {
sendUserToast('Only one file can be uploaded at a time')
return
} else {
onChange(event.dataTransfer.files)
if (folderOnly) {
const item = event.dataTransfer.items[0]?.webkitGetAsEntry()
if (item) {
const files = await traverseFileTree(item, '')
onChange(files)
}
} else {
if (event.dataTransfer.files && event.dataTransfer.files.length) {
if (!multiple && event.dataTransfer.files.length > 1) {
sendUserToast('Only one file can be uploaded at a time')
return
} else {
onChange(Array.from(event.dataTransfer.files))
}
}
}
}
@@ -174,22 +223,22 @@
</div>
{:else}
<slot>
<span>Drag and drop {multiple ? 'files' : 'a file'}</span>
<span>Drag and drop {folderOnly ? 'a folder' : multiple ? 'files' : 'a file'}</span>
</slot>
{/if}
<input
class="!absolute !inset-0 !z-10 !opacity-0 !cursor-pointer"
type="file"
{...{ webkitdirectory: folderOnly }}
title={files ? `${files.length} file${files.length > 1 ? 's' : ''} chosen` : 'No file chosen'}
bind:this={input}
on:change={({ currentTarget }) => {
onChange(currentTarget.files)
onChange(currentTarget.files ? Array.from(currentTarget.files) : null)
}}
{accept}
{multiple}
{...$$restProps}
/>
{#if defaultFile}
<div class="w-full border-dashed border-t-2 text-2xs pt-1 text-tertiary mt-2">
Default file: <span class="text-blue-500">{defaultFile}</span>
@@ -14,7 +14,10 @@
export let acceptedFileTypes: string[] | undefined = ['*']
export let allowMultiple: boolean = true
export let containerText: string = allowMultiple
export let folderOnly = false
export let containerText: string = folderOnly
? 'Drag and drop a folder here or click to browse'
: allowMultiple
? 'Drag and drop files here or click to browse'
: 'Drag and drop a file here or click to browse'
export let customResourcePath: string | undefined = undefined
@@ -53,20 +56,61 @@
}
async function handleChange(files: File[] | undefined) {
for (const file of files ?? []) {
uploadFileToS3(file, file.name)
if (folderOnly) {
uniqueFolderPrefix = getRandomFolderPrefix()
uploadedFolderRoot = undefined
await Promise.all(
files?.map(async (file) => {
await uploadFileToS3(file, file.name)
}) ?? []
)
if (uploadedFolderRoot) {
dispatch('addition', {
path: uniqueFolderPrefix + uploadedFolderRoot,
filename: undefined
})
sendUserToast('Folder uploaded!')
}
} else {
for (const file of files ?? []) {
uploadFileToS3(file, file.name)
}
}
}
let activeUploads: { xhr: XMLHttpRequest; fileName: string }[] = []
async function uploadFileToS3(fileToUpload: File, fileToUploadKey: string) {
function getRandomFolderPrefix(): string {
const now = Date.now()
const randomNum = Math.floor(Math.random() * 65536)
return `windmill_uploads/upload_${now}_${randomNum}/`
}
let uniqueFolderPrefix = getRandomFolderPrefix()
let uploadedFolderRoot: string | undefined = undefined
async function uploadFileToS3(fileToUpload: File & { path?: string }, fileToUploadKey: string) {
if (fileToUpload === undefined || fileToUploadKey === undefined) {
return
}
let path: string | undefined = undefined
let fileExtension: string | undefined = undefined
if (randomFileKey) {
if (folderOnly) {
const relativePath = fileToUpload.webkitRelativePath || fileToUpload.path
if (!relativePath) {
throw new Error('Missing file relative path')
}
const rootFolder = relativePath.split('/')[0]
if (!rootFolder) {
throw new Error('Missing root folder')
}
if (uploadedFolderRoot && rootFolder !== uploadedFolderRoot) {
throw new Error('Uploading a file in a different root folder than the previous files')
} else {
uploadedFolderRoot = rootFolder
}
path = uniqueFolderPrefix + relativePath
} else if (randomFileKey) {
fileExtension = fileToUpload.name.split('.').pop()
if (emptyString(fileExtension)) {
fileExtension = undefined
@@ -219,8 +263,10 @@
})
return
}
dispatch('addition', { path: uploadData.path, filename: fileToUpload.name })
sendUserToast('File upload finished!')
if (!folderOnly) {
dispatch('addition', { path: uploadData.path, filename: fileToUpload.name })
sendUserToast('File uploaded!')
}
uploadData.progress = 100
$fileUploads = $fileUploads.map((fileUpload) => {
@@ -442,6 +488,7 @@
</div>
{:else}
<FileInput
{folderOnly}
{disabled}
accept={acceptedFileTypes?.join(',')}
multiple={allowMultiple}
@@ -200,8 +200,6 @@
/>
{:else if captureType === 'http'}
<RouteEditorConfigSection
{isFlow}
{path}
{showCapture}
can_write={true}
runnableArgs={data?.args}
@@ -47,8 +47,6 @@
bind:route_path={args.route_path}
bind:http_method={args.http_method}
headless
{isFlow}
{path}
/>
{:else if triggerType === 'email'}
<EmailTriggerConfigSection
@@ -10,15 +10,14 @@
import { bash } from 'svelte-highlight/languages'
import { HttpTriggerService } from '$lib/gen'
// import { page } from '$app/stores'
import { isCloudHosted } from '$lib/cloud'
import { base } from '$lib/base'
import type { CaptureInfo } from '../CaptureSection.svelte'
import CaptureSection from '../CaptureSection.svelte'
import CaptureTable from '../CaptureTable.svelte'
import ClipboardPanel from '../../details/ClipboardPanel.svelte'
import { isCloudHosted } from '$lib/cloud'
export let isFlow: boolean
export let path: string
export let initialTriggerPath: string | undefined = undefined
export let dirtyRoutePath: boolean = false
export let route_path: string | undefined
export let http_method: 'get' | 'post' | 'put' | 'patch' | 'delete' | undefined
@@ -26,7 +25,6 @@
export let static_asset_config: { s3: string; storage?: string; filename?: string } | undefined =
undefined
export let showCapture = false
export let initialRoutePath: string = ''
export let headless: boolean = false
export let captureInfo: CaptureInfo | undefined = undefined
export let captureTable: CaptureTable | undefined = undefined
@@ -36,16 +34,16 @@
let routeError: string = ''
async function validateRoute(
path: string | undefined,
routePath: string | undefined,
method: typeof http_method
): Promise<void> {
if (validateTimeout) {
clearTimeout(validateTimeout)
}
validateTimeout = setTimeout(async () => {
if (!path || !method || !/^[\w-:]+(\/[\w-:]+)*$/.test(path)) {
if (!routePath || !method || !/^:?[-\w]+(\/:?[-\w]+)*$/.test(routePath)) {
routeError = 'Endpoint not valid'
} else if (initialRoutePath !== path && (await routeExists(path, method))) {
} else if (await routeExists(routePath, method)) {
routeError = 'Endpoint already taken'
} else {
routeError = ''
@@ -59,14 +57,15 @@
workspace: $workspaceStore!,
requestBody: {
route_path,
http_method: method
http_method: method,
trigger_path: initialTriggerPath
}
})
}
$: captureURL = `${location.origin}${base}/api/w/${$workspaceStore}/capture_u/http/${
isFlow ? 'flow' : 'script'
}/${path.replaceAll('/', '.')}/${route_path}`
$: validateRoute(route_path, http_method)
$: isValid = routeError === ''
function getHttpRoute(route_path: string | undefined) {
return `${location.origin}${base}/api/r/${isCloudHosted() ? $workspaceStore + '/' : ''}${
@@ -74,10 +73,6 @@
}`
}
$: validateRoute(route_path, http_method)
$: isValid = routeError === ''
$: fullRoute = getHttpRoute(route_path)
$: !http_method && (http_method = 'post')
@@ -86,6 +81,9 @@
<div>
{#if showCapture && captureInfo}
{@const captureURL = `${location.origin}${base}/api/w/${$workspaceStore}/capture_u/http/${
captureInfo.isFlow ? 'flow' : 'script'
}/${captureInfo.path.replaceAll('/', '.')}/${route_path}`}
<CaptureSection
captureType="http"
disabled={!isValid}
@@ -22,7 +22,7 @@
import Toggle from '$lib/components/Toggle.svelte'
import RouteEditorConfigSection from './RouteEditorConfigSection.svelte'
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
import { isCloudHosted } from '$lib/cloud'
let is_flow: boolean = false
let initialPath = ''
let edit = true
@@ -66,7 +66,6 @@
itemKind = nis_flow ? 'flow' : 'script'
is_async = false
requires_auth = false
initialRoutePath = ''
route_path = defaultValues?.route_path ?? ''
dirtyRoutePath = false
http_method = defaultValues?.http_method ?? 'post'
@@ -78,6 +77,7 @@
path = ''
initialPath = ''
dirtyPath = false
is_static_website = false
} finally {
drawerLoading = false
}
@@ -89,11 +89,11 @@
let dirtyRoutePath = false
let is_async = false
let requires_auth = false
let initialRoutePath = ''
let route_path = ''
let http_method: 'get' | 'post' | 'put' | 'patch' | 'delete' = 'post'
let static_asset_config: { s3: string; storage?: string; filename?: string } | undefined =
undefined
let is_static_website = false
let s3FilePicker: S3FilePicker
let s3FileUploadRawMode = false
@@ -113,12 +113,14 @@
is_flow = s.is_flow
path = s.path
route_path = s.route_path
initialRoutePath = s.route_path
http_method = s.http_method ?? 'post'
is_async = s.is_async
requires_auth = s.requires_auth
static_asset_config = s.static_asset_config
s3FileUploadRawMode = !!static_asset_config
if (!isCloudHosted()) {
static_asset_config = s.static_asset_config
s3FileUploadRawMode = !!static_asset_config
is_static_website = s.is_static_website
}
can_write = canWrite(s.path, s.extra_perms, $userStore)
}
@@ -136,7 +138,8 @@
requires_auth,
route_path: $userStore?.is_admin || $userStore?.is_super_admin ? route_path : undefined,
http_method,
static_asset_config
static_asset_config,
is_static_website
}
})
sendUserToast(`Route ${path} updated`)
@@ -151,7 +154,8 @@
requires_auth,
route_path,
http_method,
static_asset_config
static_asset_config,
is_static_website
}
})
sendUserToast(`Route ${path} created`)
@@ -166,11 +170,14 @@
let drawer: Drawer
let dirtyPath = false
$: !static_asset_config && (is_static_website = false)
</script>
{#if static_asset_config}
<S3FilePicker
bind:this={s3FilePicker}
folderOnly={is_static_website}
bind:selectedFileKey={static_asset_config}
on:close={() => {
s3Editor?.setCode(JSON.stringify(static_asset_config, null, 2))
@@ -220,102 +227,125 @@
</div>
<RouteEditorConfigSection
isFlow={is_flow}
{path}
initialTriggerPath={initialPath}
bind:route_path
bind:isValid
bind:dirtyRoutePath
bind:http_method
{can_write}
bind:static_asset_config
{initialRoutePath}
/>
<Section label="Target">
<ToggleButtonGroup
disabled={fixedScriptPath != '' || !can_write}
selected={static_asset_config ? 'static_asset' : 'runnable'}
on:selected={(ev) => {
if (ev.detail === 'static_asset') {
static_asset_config = { s3: '' }
script_path = ''
initialScriptPath = ''
is_flow = false
http_method = 'get'
} else {
static_asset_config = undefined
}
}}
>
<ToggleButton label="Runnable" value="runnable" />
<ToggleButton label="Static asset" value="static_asset" />
</ToggleButtonGroup>
{#if !isCloudHosted()}
<ToggleButtonGroup
disabled={fixedScriptPath != '' || !can_write}
selected={static_asset_config
? is_static_website
? 'static_website'
: 'static_asset'
: 'runnable'}
on:selected={(ev) => {
if (ev.detail === 'static_asset' || ev.detail === 'static_website') {
static_asset_config = { s3: '' }
s3Editor?.setCode(JSON.stringify(static_asset_config, null, 2))
script_path = ''
initialScriptPath = ''
is_flow = false
http_method = 'get'
is_async = false
is_static_website = ev.detail === 'static_website'
if (is_static_website) {
requires_auth = false
}
} else if (ev.detail === 'runnable') {
static_asset_config = undefined
}
}}
>
<ToggleButton label="Runnable" value="runnable" />
<ToggleButton label="Static asset" value="static_asset" />
<ToggleButton label="Static website" value="static_website" />
</ToggleButtonGroup>
{/if}
{#if static_asset_config}
<div class="flex flex-col w-full gap-1">
{#if can_write}
<Toggle
class="flex justify-end"
bind:checked={s3FileUploadRawMode}
size="xs"
options={{ left: 'Existing file' }}
disabled={!can_write}
/>
{/if}
{#if s3FileUploadRawMode}
{#if is_static_website}
<p class="text-xs my-1 text-tertiary">
Upload or specify a <b>folder</b> on S3. All its files will be served under the path
above. Use this full path as the base URL of your website to ensure relative imports
work correctly.
</p>
{/if}
<div class="flex flex-col gap-4">
<div class="flex flex-col w-full gap-1">
{#if can_write}
<JsonEditor
bind:editor={s3Editor}
on:focus={(e) => {
dispatch('focus')
}}
on:blur={(e) => {
dispatch('blur')
}}
code={JSON.stringify(static_asset_config ?? { s3: '' }, null, 2)}
bind:value={static_asset_config}
/>
{:else}
<Highlight
language={json}
code={JSON.stringify(static_asset_config ?? { s3: '' }, null, 2)}
/>
{/if}
{#if can_write}
<Button
variant="border"
color="light"
<Toggle
class="flex justify-end"
bind:checked={s3FileUploadRawMode}
size="xs"
btnClasses="mt-1"
on:click={() => {
s3FilePicker?.open?.(static_asset_config)
}}
startIcon={{ icon: Pipette }}
>
Choose an object from the catalog
</Button>
options={{ left: 'Existing file' }}
disabled={!can_write}
/>
{/if}
{:else}
<FileUpload
allowMultiple={false}
randomFileKey={true}
on:addition={(evt) => {
static_asset_config = {
s3: evt.detail?.path ?? '',
filename: evt.detail?.filename ?? undefined
}
s3FileUploadRawMode = true
}}
on:deletion={(evt) => {
static_asset_config = {
s3: ''
}
}}
/>
{/if}
{#if s3FileUploadRawMode}
{#if can_write}
<JsonEditor
bind:editor={s3Editor}
on:focus={(e) => {
dispatch('focus')
}}
on:blur={(e) => {
dispatch('blur')
}}
code={JSON.stringify(static_asset_config ?? { s3: '' }, null, 2)}
bind:value={static_asset_config}
/>
{:else}
<Highlight
language={json}
code={JSON.stringify(static_asset_config ?? { s3: '' }, null, 2)}
/>
{/if}
{#if can_write}
<Button
variant="border"
color="light"
size="xs"
btnClasses="mt-1"
on:click={() => {
s3FilePicker?.open?.(!is_static_website ? static_asset_config : undefined)
}}
startIcon={{ icon: Pipette }}
>
Choose an object from the catalog
</Button>
{/if}
{:else}
{#key is_static_website}
<FileUpload
folderOnly={is_static_website}
allowMultiple={false}
randomFileKey={true}
on:addition={(evt) => {
static_asset_config = {
s3: evt.detail?.path ?? '',
filename: evt.detail?.filename ?? undefined
}
s3FileUploadRawMode = true
}}
on:deletion={(evt) => {
static_asset_config = {
s3: ''
}
}}
/>
{/key}
{/if}
</div>
</div>
{:else}
<p class="text-xs mb-1 text-tertiary">
<p class="text-xs mt-0.5 mb-1 text-tertiary">
Pick a script or flow to be triggered<Required required={true} /><br />
To handle headers, query or path parameters, add a preprocessor to your runnable.
</p>
@@ -346,48 +376,52 @@
{/if}
</Section>
<Section label="Advanced">
<div class="flex flex-col gap-4">
<div class="flex flex-row justify-between">
<Label label="Request type" class="w-full">
{#if !is_static_website}
<Section label="Advanced">
<div class="flex flex-col gap-4">
{#if !static_asset_config}
<div class="flex flex-row justify-between">
<Label label="Request type" class="w-full">
<svelte:fragment slot="action">
<ToggleButtonGroup
class="w-auto h-full"
bind:selected={is_async}
disabled={!can_write || !!static_asset_config}
>
<ToggleButton
label="Async"
value={true}
tooltip="The returning value is the uuid of the job assigned to execute the job."
/>
<ToggleButton
label="Sync"
value={false}
tooltip="Triggers the execution, wait for the job to complete and return it as a response."
/>
</ToggleButtonGroup>
</svelte:fragment>
</Label>
</div>
{/if}
<Label label="Authentication" class="w-full">
<svelte:fragment slot="action">
<ToggleButtonGroup
class="w-auto h-full"
bind:selected={is_async}
bind:selected={requires_auth}
disabled={!can_write}
>
<ToggleButton label="None" value={false} />
<ToggleButton
label="Async"
label="Required"
value={true}
tooltip="The returning value is the uuid of the job assigned to execute the job."
/>
<ToggleButton
label="Sync"
value={false}
tooltip="Triggers the execution, wait for the job to complete and return it as a response."
tooltip="Requires authentication with read access on the route"
/>
</ToggleButtonGroup>
</svelte:fragment>
</Label>
</div>
<Label label="Authentication" class="w-full">
<svelte:fragment slot="action">
<ToggleButtonGroup
class="w-auto h-full"
bind:selected={requires_auth}
disabled={!can_write}
>
<ToggleButton label="None" value={false} />
<ToggleButton
label="Required"
value={true}
tooltip="Requires authentication with read access on the route"
/>
</ToggleButtonGroup>
</svelte:fragment>
</Label>
</div>
</Section>
</Section>
{/if}
</div>
{/if}
</DrawerContent>
@@ -109,7 +109,11 @@
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
: Array.from(
new Set(filteredItems?.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? [])
new Set(
filteredItems
?.filter((x) => x.script_path)
.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? []
)
).sort()
$: items = filter !== '' ? filteredItems : preFilteredItems
@@ -156,176 +160,177 @@
f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
/>
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find(_ => _.id === $workspaceStore)?.operator_settings?.triggers}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
<p class="font-bold">Unauthorized</p>
<p>Page not available for operators</p>
</div>
{:else}
<CenteredPage>
<PageHeader
title="Custom HTTP routes"
tooltip="Every script and flow already has a canonical HTTP API endpoint/webhook attached to it, this is to create additional parametrizable ones."
documentationLink="https://www.windmill.dev/docs/core_concepts/http_routing"
>
{#if $userStore?.is_admin || $userStore?.is_super_admin}
<Button size="md" startIcon={{ icon: Plus }} on:click={() => routeEditor.openNew(false)}>
New&nbsp;route
</Button>
{/if}
</PageHeader>
<div class="w-full h-full flex flex-col">
<div class="w-full pb-4 pt-6">
<input type="text" placeholder="Search routes" bind:value={filter} class="search-item" />
<div class="flex flex-row items-center gap-2 mt-6">
<div class="text-sm shrink-0"> Filter by path of </div>
<ToggleButtonGroup bind:selected={selectedFilterKind}>
<ToggleButton small value="trigger" label="Route" icon={Route} />
<ToggleButton small value="script_flow" label="Script/Flow" icon={Code} />
</ToggleButtonGroup>
</div>
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
<div class="flex flex-row items-center justify-end gap-4">
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
<Toggle
size="xs"
bind:checked={filterUserFolders}
options={{ right: `Only u/${$userStore.username} and f/*` }}
/>
{/if}
</div>
</div>
{#if loading}
{#each new Array(6) as _}
<Skeleton layout={[[6], 0.4]} />
{/each}
{:else if !triggers?.length}
<div class="text-center text-sm text-tertiary mt-2"> No routes </div>
{:else if items?.length}
<div class="border rounded-md divide-y">
{#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, route_path, is_flow, extra_perms, canWrite, marked, http_method, static_asset_config } (path)}
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
<div
class="hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
>
<div class="w-full flex gap-5 items-center">
<RowIcon kind={is_flow ? 'flow' : 'script'} />
<a
href="#{path}"
on:click={() => routeEditor?.openEdit(path, is_flow)}
class="min-w-0 grow hover:underline decoration-gray-400"
>
<div class="text-primary flex-wrap text-left text-md font-semibold mb-1 truncate">
{#if marked}
<span class="text-xs">
{@html marked}
</span>
{:else}
{http_method.toUpperCase()} /{route_path}
{/if}
</div>
<div class="text-secondary text-xs truncate text-left font-light">
{path}
</div>
<div class="text-secondary text-xs truncate text-left font-light">
{#if static_asset_config}
file: {static_asset_config.s3}
{:else}
runnable: {script_path}
{/if}
</div>
</a>
<div class="hidden lg:flex flex-row gap-1 items-center">
<SharedBadge {canWrite} extraPerms={extra_perms} />
</div>
<div class="flex gap-2 items-center justify-end">
<Button
on:click={() => routeEditor?.openEdit(path, is_flow)}
size="xs"
startIcon={canWrite
? { icon: Pen }
: {
icon: Eye
}}
color="dark"
>
{canWrite ? 'Edit' : 'View'}
</Button>
<Dropdown
items={[
{
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
icon: Eye,
action: () => {
goto(href)
}
},
{
displayName: 'Delete',
type: 'delete',
icon: Trash,
disabled: !canWrite || !($userStore?.is_admin || $userStore?.is_super_admin),
action: async () => {
await HttpTriggerService.deleteHttpTrigger({
workspace: $workspaceStore ?? '',
path
})
loadTriggers()
}
},
{
displayName: canWrite ? 'Edit' : 'View',
icon: canWrite ? Pen : Eye,
action: () => {
routeEditor?.openEdit(path, is_flow)
}
},
{
displayName: 'Audit logs',
icon: Eye,
href: `${base}/audit_logs?resource=${path}`
},
{
displayName: canWrite ? 'Share' : 'See Permissions',
icon: Share,
action: () => {
shareModal.openDrawer(path, 'http_trigger')
}
}
]}
/>
</div>
</div>
<div class="w-full flex justify-between items-baseline">
<div
class="flex flex-wrap text-[0.7em] text-tertiary gap-1 items-center justify-end truncate pr-2"
>
<div class="truncate">edited by {edited_by}</div>
<div class="truncate">at {displayDate(edited_at)}</div>
</div>
</div>
</div>
{/each}
</div>
{:else}
<NoItemFound />
{/if}
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
<p class="font-bold">Unauthorized</p>
<p>Page not available for operators</p>
</div>
{#if items && items?.length > 15 && nbDisplayed < items.length}
<span class="text-xs"
>{nbDisplayed} items out of {items.length}
<button class="ml-4" on:click={() => (nbDisplayed += 30)}>load 30 more</button></span
{:else}
<CenteredPage>
<PageHeader
title="Custom HTTP routes"
tooltip="Every script and flow already has a canonical HTTP API endpoint/webhook attached to it, this is to create additional parametrizable ones."
documentationLink="https://www.windmill.dev/docs/core_concepts/http_routing"
>
{/if}
</CenteredPage>
{#if $userStore?.is_admin || $userStore?.is_super_admin}
<Button size="md" startIcon={{ icon: Plus }} on:click={() => routeEditor.openNew(false)}>
New&nbsp;route
</Button>
{/if}
</PageHeader>
<div class="w-full h-full flex flex-col">
<div class="w-full pb-4 pt-6">
<input type="text" placeholder="Search routes" bind:value={filter} class="search-item" />
<div class="flex flex-row items-center gap-2 mt-6">
<div class="text-sm shrink-0"> Filter by path of </div>
<ToggleButtonGroup bind:selected={selectedFilterKind}>
<ToggleButton small value="trigger" label="Route" icon={Route} />
<ToggleButton small value="script_flow" label="Script/Flow" icon={Code} />
</ToggleButtonGroup>
</div>
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
<div class="flex flex-row items-center justify-end gap-4">
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
<Toggle
size="xs"
bind:checked={filterUserFolders}
options={{ right: `Only u/${$userStore.username} and f/*` }}
/>
{/if}
</div>
</div>
{#if loading}
{#each new Array(6) as _}
<Skeleton layout={[[6], 0.4]} />
{/each}
{:else if !triggers?.length}
<div class="text-center text-sm text-tertiary mt-2"> No routes </div>
{:else if items?.length}
<div class="border rounded-md divide-y">
{#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, route_path, is_flow, extra_perms, canWrite, marked, http_method, static_asset_config } (path)}
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
<div
class="hover:bg-surface-hover w-full items-center px-4 py-2 gap-4 first-of-type:!border-t-0
first-of-type:rounded-t-md last-of-type:rounded-b-md flex flex-col"
>
<div class="w-full flex gap-5 items-center">
<RowIcon kind={is_flow ? 'flow' : 'script'} />
<a
href="#{path}"
on:click={() => routeEditor?.openEdit(path, is_flow)}
class="min-w-0 grow hover:underline decoration-gray-400"
>
<div class="text-primary flex-wrap text-left text-md font-semibold mb-1 truncate">
{#if marked}
<span class="text-xs">
{@html marked}
</span>
{:else}
{http_method.toUpperCase()} /{route_path}
{/if}
</div>
<div class="text-secondary text-xs truncate text-left font-light">
{path}
</div>
<div class="text-secondary text-xs truncate text-left font-light">
{#if static_asset_config}
file: {static_asset_config.s3}
{:else}
runnable: {script_path}
{/if}
</div>
</a>
<div class="hidden lg:flex flex-row gap-1 items-center">
<SharedBadge {canWrite} extraPerms={extra_perms} />
</div>
<div class="flex gap-2 items-center justify-end">
<Button
on:click={() => routeEditor?.openEdit(path, is_flow)}
size="xs"
startIcon={canWrite
? { icon: Pen }
: {
icon: Eye
}}
color="dark"
>
{canWrite ? 'Edit' : 'View'}
</Button>
<Dropdown
items={[
{
displayName: `View ${is_flow ? 'Flow' : 'Script'}`,
icon: Eye,
action: () => {
goto(href)
}
},
{
displayName: 'Delete',
type: 'delete',
icon: Trash,
disabled:
!canWrite || !($userStore?.is_admin || $userStore?.is_super_admin),
action: async () => {
await HttpTriggerService.deleteHttpTrigger({
workspace: $workspaceStore ?? '',
path
})
loadTriggers()
}
},
{
displayName: canWrite ? 'Edit' : 'View',
icon: canWrite ? Pen : Eye,
action: () => {
routeEditor?.openEdit(path, is_flow)
}
},
{
displayName: 'Audit logs',
icon: Eye,
href: `${base}/audit_logs?resource=${path}`
},
{
displayName: canWrite ? 'Share' : 'See Permissions',
icon: Share,
action: () => {
shareModal.openDrawer(path, 'http_trigger')
}
}
]}
/>
</div>
</div>
<div class="w-full flex justify-between items-baseline">
<div
class="flex flex-wrap text-[0.7em] text-tertiary gap-1 items-center justify-end truncate pr-2"
>
<div class="truncate">edited by {edited_by}</div>
<div class="truncate">at {displayDate(edited_at)}</div>
</div>
</div>
</div>
{/each}
</div>
{:else}
<NoItemFound />
{/if}
</div>
{#if items && items?.length > 15 && nbDisplayed < items.length}
<span class="text-xs"
>{nbDisplayed} items out of {items.length}
<button class="ml-4" on:click={() => (nbDisplayed += 30)}>load 30 more</button></span
>
{/if}
</CenteredPage>
{/if}
<ShareModal