mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
feat: serve static websites (#5218)
* feat: serve static websites * nit * sqlx * nit * nits * nits * nits * nits
This commit is contained in:
+9
-4
@@ -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"
|
||||
}
|
||||
+9
-3
@@ -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"
|
||||
}
|
||||
+4
-3
@@ -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"
|
||||
}
|
||||
+3
-2
@@ -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"
|
||||
}
|
||||
+10
-3
@@ -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"
|
||||
}
|
||||
+4
-3
@@ -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"
|
||||
}
|
||||
+4
-3
@@ -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"
|
||||
}
|
||||
+3
-2
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 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 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
|
||||
|
||||
Reference in New Issue
Block a user