diff --git a/.github/workflows/build-publish-rh-image.yml b/.github/workflows/build-publish-rh-image.yml index 010888d57d..f467cd432f 100644 --- a/.github/workflows/build-publish-rh-image.yml +++ b/.github/workflows/build-publish-rh-image.yml @@ -62,7 +62,7 @@ jobs: platforms: linux/amd64,linux/arm64 push: true build-args: | - features=enterprise,enterprise_saml,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc,license,otel,http_trigger,zip,oauth2,kafka,sqs_trigger,nats,postgres_trigger,gcp_trigger,mqtt_trigger,websocket,smtp,static_frontend,all_languages,deno_core,mcp,private + features=enterprise,enterprise_saml,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc,license,otel,http_trigger,zip,oauth2,kafka,sqs_trigger,nats,postgres_trigger,gcp_trigger,mqtt_trigger,websocket,smtp,native_trigger,static_frontend,all_languages,deno_core,mcp,private secrets: | rh_username=${{ secrets.RH_USERNAME }} rh_password=${{ secrets.RH_PASSWORD }} diff --git a/.github/workflows/build-publish-rh8-image.yml b/.github/workflows/build-publish-rh8-image.yml index aabb17a592..fc10b6bcac 100644 --- a/.github/workflows/build-publish-rh8-image.yml +++ b/.github/workflows/build-publish-rh8-image.yml @@ -64,7 +64,7 @@ jobs: platforms: linux/amd64 push: true build-args: | - features=enterprise,enterprise_saml,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc,license,otel,http_trigger,zip,oauth2,kafka,sqs_trigger,nats,postgres_trigger,gcp_trigger,mqtt_trigger,websocket,smtp,static_frontend,all_languages,deno_core,mcp,private + features=enterprise,enterprise_saml,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc,license,otel,http_trigger,zip,oauth2,kafka,sqs_trigger,nats,postgres_trigger,gcp_trigger,mqtt_trigger,websocket,smtp,native_trigger,static_frontend,all_languages,deno_core,mcp,private secrets: | rh_username=${{ secrets.RH_USERNAME }} rh_password=${{ secrets.RH_PASSWORD }} @@ -81,7 +81,7 @@ jobs: platforms: linux/arm64 push: true build-args: | - features=enterprise,enterprise_saml,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc,license,otel,http_trigger,zip,oauth2,kafka,sqs_trigger,nats,postgres_trigger,gcp_trigger,mqtt_trigger,websocket,smtp,static_frontend,all_languages,deno_core,mcp,private + features=enterprise,enterprise_saml,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc,license,otel,http_trigger,zip,oauth2,kafka,sqs_trigger,nats,postgres_trigger,gcp_trigger,mqtt_trigger,websocket,smtp,native_trigger,static_frontend,all_languages,deno_core,mcp,private secrets: | rh_username=${{ secrets.RH_USERNAME }} rh_password=${{ secrets.RH_PASSWORD }} diff --git a/.github/workflows/build_windows_worker_.yml b/.github/workflows/build_windows_worker_.yml index 79cde5003e..985e4d94fe 100644 --- a/.github/workflows/build_windows_worker_.yml +++ b/.github/workflows/build_windows_worker_.yml @@ -57,7 +57,7 @@ jobs: $env:OPENSSL_DIR="${Env:VCPKG_INSTALLATION_ROOT}\installed\x64-windows-static" mkdir frontend/build && cd backend New-Item -Path . -Name "windmill-api/openapi-deref.yaml" -ItemType "File" -Force - cargo build --release --features=enterprise,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc,tantivy,license,http_trigger,zip,oauth2,kafka,nats,sqs_trigger,postgres_trigger,gcp_trigger,mqtt_trigger,websocket,smtp,static_frontend,all_languages_windows,mcp,bedrock,private + cargo build --release --features=enterprise,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc,tantivy,license,http_trigger,zip,oauth2,kafka,nats,sqs_trigger,postgres_trigger,gcp_trigger,mqtt_trigger,websocket,smtp,native_trigger,static_frontend,all_languages_windows,mcp,bedrock,private - name: Rename binary with corresponding architecture run: | Rename-Item -Path ".\backend\target\release\windmill.exe" -NewName "windmill-ee.exe" diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index cad751582f..6d28ad4953 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -159,7 +159,7 @@ jobs: platforms: linux/amd64,linux/arm64 push: true build-args: | - features=enterprise,enterprise_saml,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc,agent_worker_server,tantivy,license,http_trigger,zip,oauth2,kafka,sqs_trigger,nats,otel,dind,postgres_trigger,mqtt_trigger,gcp_trigger,websocket,smtp,static_frontend,all_languages,private,deno_core,mcp,bedrock + features=enterprise,enterprise_saml,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc,agent_worker_server,tantivy,license,http_trigger,zip,oauth2,kafka,sqs_trigger,nats,otel,dind,postgres_trigger,mqtt_trigger,gcp_trigger,websocket,smtp,native_trigger,static_frontend,all_languages,private,deno_core,mcp,bedrock tags: | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:${{ env.DEV_SHA }} ${{ steps.meta-ee-public.outputs.tags }} diff --git a/.github/workflows/publish_windows_worker.yml b/.github/workflows/publish_windows_worker.yml index 059f299331..aac8b178e9 100644 --- a/.github/workflows/publish_windows_worker.yml +++ b/.github/workflows/publish_windows_worker.yml @@ -59,7 +59,7 @@ jobs: $env:OPENSSL_DIR="${Env:VCPKG_INSTALLATION_ROOT}\installed\x64-windows-static" mkdir frontend/build && cd backend New-Item -Path . -Name "windmill-api/openapi-deref.yaml" -ItemType "File" -Force - cargo build --release --features=enterprise,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc,tantivy,license,http_trigger,zip,oauth2,kafka,sqs_trigger,nats,postgres_trigger,mqtt_trigger,gcp_trigger,websocket,smtp,static_frontend,all_languages_windows,mcp,bedrock,private + cargo build --release --features=enterprise,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc,tantivy,license,http_trigger,zip,oauth2,kafka,sqs_trigger,nats,postgres_trigger,mqtt_trigger,gcp_trigger,websocket,smtp,native_trigger,static_frontend,all_languages_windows,mcp,bedrock,private - name: Rename binary with corresponding architecture run: | Rename-Item -Path ".\backend\target\release\windmill.exe" -NewName "windmill-ee.exe" diff --git a/backend/.sqlx/query-089d7bc7acdbb97cf477159e111bc7e9ee85289ff5c52af43166928337c257e7.json b/backend/.sqlx/query-089d7bc7acdbb97cf477159e111bc7e9ee85289ff5c52af43166928337c257e7.json index c22d884730..a032a87239 100644 --- a/backend/.sqlx/query-089d7bc7acdbb97cf477159e111bc7e9ee85289ff5c52af43166928337c257e7.json +++ b/backend/.sqlx/query-089d7bc7acdbb97cf477159e111bc7e9ee85289ff5c52af43166928337c257e7.json @@ -29,7 +29,8 @@ "postgres", "sqs", "gcp", - "mqtt" + "mqtt", + "nextcloud" ] } } diff --git a/backend/.sqlx/query-14276a040cb4db88d71fccdc3579e8c0bb132b70668301b535872d1632753e30.json b/backend/.sqlx/query-14276a040cb4db88d71fccdc3579e8c0bb132b70668301b535872d1632753e30.json index c607060231..d4f7afa966 100644 --- a/backend/.sqlx/query-14276a040cb4db88d71fccdc3579e8c0bb132b70668301b535872d1632753e30.json +++ b/backend/.sqlx/query-14276a040cb4db88d71fccdc3579e8c0bb132b70668301b535872d1632753e30.json @@ -121,7 +121,8 @@ "postgres", "sqs", "gcp", - "mqtt" + "mqtt", + "nextcloud" ] } } diff --git a/backend/.sqlx/query-19b59c478744d029c6006b01f04243ad2e0aef485a780daea5d76b0be2bb2ea2.json b/backend/.sqlx/query-19b59c478744d029c6006b01f04243ad2e0aef485a780daea5d76b0be2bb2ea2.json index f52739a9b0..43c68f8c5a 100644 --- a/backend/.sqlx/query-19b59c478744d029c6006b01f04243ad2e0aef485a780daea5d76b0be2bb2ea2.json +++ b/backend/.sqlx/query-19b59c478744d029c6006b01f04243ad2e0aef485a780daea5d76b0be2bb2ea2.json @@ -39,7 +39,8 @@ "postgres", "sqs", "gcp", - "mqtt" + "mqtt", + "nextcloud" ] } } diff --git a/backend/.sqlx/query-23419adcd74c326d716527293eff518b42f4cdb33e034441015494bd26c172d2.json b/backend/.sqlx/query-23419adcd74c326d716527293eff518b42f4cdb33e034441015494bd26c172d2.json index 1cb68e3acb..242b358ff5 100644 --- a/backend/.sqlx/query-23419adcd74c326d716527293eff518b42f4cdb33e034441015494bd26c172d2.json +++ b/backend/.sqlx/query-23419adcd74c326d716527293eff518b42f4cdb33e034441015494bd26c172d2.json @@ -39,7 +39,8 @@ "sqs", "mqtt", "gcp", - "default_email" + "default_email", + "nextcloud" ] } } diff --git a/backend/.sqlx/query-42b4b73e9d60348e2d90fcade9dcad6d8995242dc20a4e14c1a8fae4fc6a9fd2.json b/backend/.sqlx/query-42b4b73e9d60348e2d90fcade9dcad6d8995242dc20a4e14c1a8fae4fc6a9fd2.json index 52a2d901ec..e6d71f386f 100644 --- a/backend/.sqlx/query-42b4b73e9d60348e2d90fcade9dcad6d8995242dc20a4e14c1a8fae4fc6a9fd2.json +++ b/backend/.sqlx/query-42b4b73e9d60348e2d90fcade9dcad6d8995242dc20a4e14c1a8fae4fc6a9fd2.json @@ -23,7 +23,8 @@ "sqs", "mqtt", "gcp", - "default_email" + "default_email", + "nextcloud" ] } } diff --git a/backend/.sqlx/query-4f547c0fd54f3bc57212ce87810e35adf640d44d607e62a1fb296e38ac3fdd36.json b/backend/.sqlx/query-4f547c0fd54f3bc57212ce87810e35adf640d44d607e62a1fb296e38ac3fdd36.json index cb53599789..789c0334d7 100644 --- a/backend/.sqlx/query-4f547c0fd54f3bc57212ce87810e35adf640d44d607e62a1fb296e38ac3fdd36.json +++ b/backend/.sqlx/query-4f547c0fd54f3bc57212ce87810e35adf640d44d607e62a1fb296e38ac3fdd36.json @@ -31,7 +31,8 @@ "sqs", "mqtt", "gcp", - "default_email" + "default_email", + "nextcloud" ] } } @@ -68,7 +69,8 @@ "sqs", "mqtt", "gcp", - "default_email" + "default_email", + "nextcloud" ] } } diff --git a/backend/.sqlx/query-5bf200f2c8db25ddf231b564503c6c70f7f3958564a79bb0c6b3863b1ebb0cbf.json b/backend/.sqlx/query-5bf200f2c8db25ddf231b564503c6c70f7f3958564a79bb0c6b3863b1ebb0cbf.json index 903d9baf17..6d3941ad69 100644 --- a/backend/.sqlx/query-5bf200f2c8db25ddf231b564503c6c70f7f3958564a79bb0c6b3863b1ebb0cbf.json +++ b/backend/.sqlx/query-5bf200f2c8db25ddf231b564503c6c70f7f3958564a79bb0c6b3863b1ebb0cbf.json @@ -244,7 +244,8 @@ "postgres", "sqs", "gcp", - "mqtt" + "mqtt", + "nextcloud" ] } } diff --git a/backend/.sqlx/query-615d832a452a6c64de50cd0efada0be238fb16daacb3464bbcf47ca2e21bdaae.json b/backend/.sqlx/query-615d832a452a6c64de50cd0efada0be238fb16daacb3464bbcf47ca2e21bdaae.json index da8704fe30..b0da0f0e69 100644 --- a/backend/.sqlx/query-615d832a452a6c64de50cd0efada0be238fb16daacb3464bbcf47ca2e21bdaae.json +++ b/backend/.sqlx/query-615d832a452a6c64de50cd0efada0be238fb16daacb3464bbcf47ca2e21bdaae.json @@ -30,7 +30,8 @@ "sqs", "mqtt", "gcp", - "default_email" + "default_email", + "nextcloud" ] } } diff --git a/backend/.sqlx/query-757ef6215d3d385cb3a69e26ee4ca846dd5e7fe7ceb1aa8b3fcd26a2bd30eb2c.json b/backend/.sqlx/query-757ef6215d3d385cb3a69e26ee4ca846dd5e7fe7ceb1aa8b3fcd26a2bd30eb2c.json index e1ca614dec..cd795e6fec 100644 --- a/backend/.sqlx/query-757ef6215d3d385cb3a69e26ee4ca846dd5e7fe7ceb1aa8b3fcd26a2bd30eb2c.json +++ b/backend/.sqlx/query-757ef6215d3d385cb3a69e26ee4ca846dd5e7fe7ceb1aa8b3fcd26a2bd30eb2c.json @@ -39,7 +39,8 @@ "postgres", "sqs", "gcp", - "mqtt" + "mqtt", + "nextcloud" ] } } diff --git a/backend/.sqlx/query-7fbf72d9059fcd77e4c1112fa4fa22e4276c1da653475628889ce17dc904fbaa.json b/backend/.sqlx/query-7fbf72d9059fcd77e4c1112fa4fa22e4276c1da653475628889ce17dc904fbaa.json index 693785ebd0..393a920b7c 100644 --- a/backend/.sqlx/query-7fbf72d9059fcd77e4c1112fa4fa22e4276c1da653475628889ce17dc904fbaa.json +++ b/backend/.sqlx/query-7fbf72d9059fcd77e4c1112fa4fa22e4276c1da653475628889ce17dc904fbaa.json @@ -26,7 +26,8 @@ "sqs", "mqtt", "gcp", - "default_email" + "default_email", + "nextcloud" ] } } diff --git a/backend/.sqlx/query-87564a196a1662f524407d853db506bf08c28efe82b68b3d44bafbd3d0e91c29.json b/backend/.sqlx/query-87564a196a1662f524407d853db506bf08c28efe82b68b3d44bafbd3d0e91c29.json index 36927a7fd7..9350442134 100644 --- a/backend/.sqlx/query-87564a196a1662f524407d853db506bf08c28efe82b68b3d44bafbd3d0e91c29.json +++ b/backend/.sqlx/query-87564a196a1662f524407d853db506bf08c28efe82b68b3d44bafbd3d0e91c29.json @@ -34,7 +34,8 @@ "sqs", "mqtt", "gcp", - "default_email" + "default_email", + "nextcloud" ] } } diff --git a/backend/.sqlx/query-9c50e3a136a8ee3ec56e083f26d3a960b89e02ec40b292f3b5198baf2a1d3dbf.json b/backend/.sqlx/query-9c50e3a136a8ee3ec56e083f26d3a960b89e02ec40b292f3b5198baf2a1d3dbf.json index 94bffa26d0..9759bad4d4 100644 --- a/backend/.sqlx/query-9c50e3a136a8ee3ec56e083f26d3a960b89e02ec40b292f3b5198baf2a1d3dbf.json +++ b/backend/.sqlx/query-9c50e3a136a8ee3ec56e083f26d3a960b89e02ec40b292f3b5198baf2a1d3dbf.json @@ -31,7 +31,8 @@ "sqs", "mqtt", "gcp", - "default_email" + "default_email", + "nextcloud" ] } } diff --git a/backend/.sqlx/query-9ecb404e46a4eac55f977f05a3afbafe5dc3cdecc17a3d5a7476b160c1b6e7e1.json b/backend/.sqlx/query-9ecb404e46a4eac55f977f05a3afbafe5dc3cdecc17a3d5a7476b160c1b6e7e1.json index 7d00a6f2c7..79c8f0b45d 100644 --- a/backend/.sqlx/query-9ecb404e46a4eac55f977f05a3afbafe5dc3cdecc17a3d5a7476b160c1b6e7e1.json +++ b/backend/.sqlx/query-9ecb404e46a4eac55f977f05a3afbafe5dc3cdecc17a3d5a7476b160c1b6e7e1.json @@ -29,7 +29,8 @@ "postgres", "sqs", "gcp", - "mqtt" + "mqtt", + "nextcloud" ] } } diff --git a/backend/.sqlx/query-a1745a4f525b251d2f5a602ab2b2ede46b4471e21b11f607573a844013911abe.json b/backend/.sqlx/query-a1745a4f525b251d2f5a602ab2b2ede46b4471e21b11f607573a844013911abe.json index f787efc97b..a25f845f91 100644 --- a/backend/.sqlx/query-a1745a4f525b251d2f5a602ab2b2ede46b4471e21b11f607573a844013911abe.json +++ b/backend/.sqlx/query-a1745a4f525b251d2f5a602ab2b2ede46b4471e21b11f607573a844013911abe.json @@ -154,7 +154,8 @@ "postgres", "sqs", "gcp", - "mqtt" + "mqtt", + "nextcloud" ] } } diff --git a/backend/.sqlx/query-a4b6371d33206010b2f3ffd2b09e33244fe8ab9a803248fc23f334034d24aad4.json b/backend/.sqlx/query-a4b6371d33206010b2f3ffd2b09e33244fe8ab9a803248fc23f334034d24aad4.json index af115b6095..da6d213748 100644 --- a/backend/.sqlx/query-a4b6371d33206010b2f3ffd2b09e33244fe8ab9a803248fc23f334034d24aad4.json +++ b/backend/.sqlx/query-a4b6371d33206010b2f3ffd2b09e33244fe8ab9a803248fc23f334034d24aad4.json @@ -184,7 +184,8 @@ "postgres", "sqs", "gcp", - "mqtt" + "mqtt", + "nextcloud" ] } } diff --git a/backend/.sqlx/query-ac9037b8adce156b95390a0ffac04e38ab8474849e0cacb3be1443d7f3265d30.json b/backend/.sqlx/query-ac9037b8adce156b95390a0ffac04e38ab8474849e0cacb3be1443d7f3265d30.json index df39f8f868..7587b063d9 100644 --- a/backend/.sqlx/query-ac9037b8adce156b95390a0ffac04e38ab8474849e0cacb3be1443d7f3265d30.json +++ b/backend/.sqlx/query-ac9037b8adce156b95390a0ffac04e38ab8474849e0cacb3be1443d7f3265d30.json @@ -30,7 +30,8 @@ "sqs", "mqtt", "gcp", - "default_email" + "default_email", + "nextcloud" ] } } diff --git a/backend/.sqlx/query-b1a11db5617e8282f5d8256f193be955155b7d0344dfa908451faf2a0ba9269b.json b/backend/.sqlx/query-b1a11db5617e8282f5d8256f193be955155b7d0344dfa908451faf2a0ba9269b.json index 1b70af508e..94acdfb2d8 100644 --- a/backend/.sqlx/query-b1a11db5617e8282f5d8256f193be955155b7d0344dfa908451faf2a0ba9269b.json +++ b/backend/.sqlx/query-b1a11db5617e8282f5d8256f193be955155b7d0344dfa908451faf2a0ba9269b.json @@ -104,7 +104,8 @@ "postgres", "sqs", "gcp", - "mqtt" + "mqtt", + "nextcloud" ] } } diff --git a/backend/.sqlx/query-b3771b690c5966272b1f42c9965bb6a8f961c119516e4c33dc928cd3b4f4edbc.json b/backend/.sqlx/query-b3771b690c5966272b1f42c9965bb6a8f961c119516e4c33dc928cd3b4f4edbc.json index d5084e373e..d08af6ffdd 100644 --- a/backend/.sqlx/query-b3771b690c5966272b1f42c9965bb6a8f961c119516e4c33dc928cd3b4f4edbc.json +++ b/backend/.sqlx/query-b3771b690c5966272b1f42c9965bb6a8f961c119516e4c33dc928cd3b4f4edbc.json @@ -159,7 +159,8 @@ "postgres", "sqs", "gcp", - "mqtt" + "mqtt", + "nextcloud" ] } } diff --git a/backend/.sqlx/query-b3f0595cacba194e08b9a3e244d9e637e9e156cd85b69126c87dfff89a47711d.json b/backend/.sqlx/query-b3f0595cacba194e08b9a3e244d9e637e9e156cd85b69126c87dfff89a47711d.json index d55d9a0a43..54a4e3cd93 100644 --- a/backend/.sqlx/query-b3f0595cacba194e08b9a3e244d9e637e9e156cd85b69126c87dfff89a47711d.json +++ b/backend/.sqlx/query-b3f0595cacba194e08b9a3e244d9e637e9e156cd85b69126c87dfff89a47711d.json @@ -23,7 +23,8 @@ "sqs", "mqtt", "gcp", - "default_email" + "default_email", + "nextcloud" ] } } diff --git a/backend/.sqlx/query-d1d9a3184f3470949840cc07414d08641527a4fe329204c65c49ab82d9ee4afb.json b/backend/.sqlx/query-d14e982c3d74499ec4bc62118e0edf065799eec5cf16f439b5f7568f392e60c3.json similarity index 83% rename from backend/.sqlx/query-d1d9a3184f3470949840cc07414d08641527a4fe329204c65c49ab82d9ee4afb.json rename to backend/.sqlx/query-d14e982c3d74499ec4bc62118e0edf065799eec5cf16f439b5f7568f392e60c3.json index 2d3fc8b5b7..c41f61c70d 100644 --- a/backend/.sqlx/query-d1d9a3184f3470949840cc07414d08641527a4fe329204c65c49ab82d9ee4afb.json +++ b/backend/.sqlx/query-d14e982c3d74499ec4bc62118e0edf065799eec5cf16f439b5f7568f392e60c3.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n EXISTS(SELECT 1 FROM websocket_trigger WHERE workspace_id = $1) AS \"websocket_used!\",\n EXISTS(SELECT 1 FROM http_trigger WHERE workspace_id = $1) AS \"http_routes_used!\",\n EXISTS(SELECT 1 FROM kafka_trigger WHERE workspace_id = $1) as \"kafka_used!\",\n EXISTS(SELECT 1 FROM nats_trigger WHERE workspace_id = $1) as \"nats_used!\",\n EXISTS(SELECT 1 FROM postgres_trigger WHERE workspace_id = $1) AS \"postgres_used!\",\n EXISTS(SELECT 1 FROM mqtt_trigger WHERE workspace_id = $1) AS \"mqtt_used!\",\n EXISTS(SELECT 1 FROM sqs_trigger WHERE workspace_id = $1) AS \"sqs_used!\",\n EXISTS(SELECT 1 FROM gcp_trigger WHERE workspace_id = $1) AS \"gcp_used!\",\n EXISTS(SELECT 1 FROM email_trigger WHERE workspace_id = $1) AS \"email_used!\"\n ", + "query": "\n SELECT\n EXISTS(SELECT 1 FROM websocket_trigger WHERE workspace_id = $1) AS \"websocket_used!\",\n EXISTS(SELECT 1 FROM http_trigger WHERE workspace_id = $1) AS \"http_routes_used!\",\n EXISTS(SELECT 1 FROM kafka_trigger WHERE workspace_id = $1) as \"kafka_used!\",\n EXISTS(SELECT 1 FROM nats_trigger WHERE workspace_id = $1) as \"nats_used!\",\n EXISTS(SELECT 1 FROM postgres_trigger WHERE workspace_id = $1) AS \"postgres_used!\",\n EXISTS(SELECT 1 FROM mqtt_trigger WHERE workspace_id = $1) AS \"mqtt_used!\",\n EXISTS(SELECT 1 FROM sqs_trigger WHERE workspace_id = $1) AS \"sqs_used!\",\n EXISTS(SELECT 1 FROM gcp_trigger WHERE workspace_id = $1) AS \"gcp_used!\",\n EXISTS(SELECT 1 FROM email_trigger WHERE workspace_id = $1) AS \"email_used!\",\n EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'nextcloud'::native_trigger_service) AS \"nextcloud_used!\"\n ", "describe": { "columns": [ { @@ -47,6 +47,11 @@ "ordinal": 8, "name": "email_used!", "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "nextcloud_used!", + "type_info": "Bool" } ], "parameters": { @@ -63,8 +68,9 @@ null, null, null, + null, null ] }, - "hash": "d1d9a3184f3470949840cc07414d08641527a4fe329204c65c49ab82d9ee4afb" + "hash": "d14e982c3d74499ec4bc62118e0edf065799eec5cf16f439b5f7568f392e60c3" } diff --git a/backend/.sqlx/query-db7b39335049f7b5fbb1ba2b99618eeeccdd4b7e14a0c0077af9d978f99ae899.json b/backend/.sqlx/query-db7b39335049f7b5fbb1ba2b99618eeeccdd4b7e14a0c0077af9d978f99ae899.json index 1b48864951..7ba4c70a26 100644 --- a/backend/.sqlx/query-db7b39335049f7b5fbb1ba2b99618eeeccdd4b7e14a0c0077af9d978f99ae899.json +++ b/backend/.sqlx/query-db7b39335049f7b5fbb1ba2b99618eeeccdd4b7e14a0c0077af9d978f99ae899.json @@ -24,7 +24,8 @@ "sqs", "mqtt", "gcp", - "default_email" + "default_email", + "nextcloud" ] } } diff --git a/backend/.sqlx/query-dcaf17a826e8f4cba4145abcf72bf749ad1d4381fa3b9df8b5bf534f9c13692e.json b/backend/.sqlx/query-dcaf17a826e8f4cba4145abcf72bf749ad1d4381fa3b9df8b5bf534f9c13692e.json index 95b23e62bf..5f7957c57f 100644 --- a/backend/.sqlx/query-dcaf17a826e8f4cba4145abcf72bf749ad1d4381fa3b9df8b5bf534f9c13692e.json +++ b/backend/.sqlx/query-dcaf17a826e8f4cba4145abcf72bf749ad1d4381fa3b9df8b5bf534f9c13692e.json @@ -23,7 +23,8 @@ "sqs", "mqtt", "gcp", - "default_email" + "default_email", + "nextcloud" ] } } diff --git a/backend/.sqlx/query-e4d71278fb80126a7a9da73f1889352d4d1e3cb3a8a08f1c9c03055a1cab1235.json b/backend/.sqlx/query-e4d71278fb80126a7a9da73f1889352d4d1e3cb3a8a08f1c9c03055a1cab1235.json index 33ea7709ab..5b07bcd9c9 100644 --- a/backend/.sqlx/query-e4d71278fb80126a7a9da73f1889352d4d1e3cb3a8a08f1c9c03055a1cab1235.json +++ b/backend/.sqlx/query-e4d71278fb80126a7a9da73f1889352d4d1e3cb3a8a08f1c9c03055a1cab1235.json @@ -184,7 +184,8 @@ "postgres", "sqs", "gcp", - "mqtt" + "mqtt", + "nextcloud" ] } } diff --git a/backend/.sqlx/query-eac595e19e5c8e70f1514ef29dec35c7342ac9a814c73f6290e1d6ebd3a55423.json b/backend/.sqlx/query-eac595e19e5c8e70f1514ef29dec35c7342ac9a814c73f6290e1d6ebd3a55423.json index c4ee4c1a48..57607ae052 100644 --- a/backend/.sqlx/query-eac595e19e5c8e70f1514ef29dec35c7342ac9a814c73f6290e1d6ebd3a55423.json +++ b/backend/.sqlx/query-eac595e19e5c8e70f1514ef29dec35c7342ac9a814c73f6290e1d6ebd3a55423.json @@ -23,7 +23,8 @@ "sqs", "mqtt", "gcp", - "default_email" + "default_email", + "nextcloud" ] } } diff --git a/backend/.sqlx/query-f43a5c3eb04c04eb7d5f58e27d3fb6b979319a6507f3e317835e08a5ae33c5f3.json b/backend/.sqlx/query-f43a5c3eb04c04eb7d5f58e27d3fb6b979319a6507f3e317835e08a5ae33c5f3.json new file mode 100644 index 0000000000..6901e94b14 --- /dev/null +++ b/backend/.sqlx/query-f43a5c3eb04c04eb7d5f58e27d3fb6b979319a6507f3e317835e08a5ae33c5f3.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM native_trigger WHERE workspace_id = $1 AND script_path = $2 AND is_flow = $3 AND service_name = 'nextcloud'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Bool" + ] + }, + "nullable": [ + null + ] + }, + "hash": "f43a5c3eb04c04eb7d5f58e27d3fb6b979319a6507f3e317835e08a5ae33c5f3" +} diff --git a/backend/.sqlx/query-fa9c2c75b622b23008ef1cdba3cb691ef7d1b0ad9eb2596ccfa7227721a0784f.json b/backend/.sqlx/query-fa9c2c75b622b23008ef1cdba3cb691ef7d1b0ad9eb2596ccfa7227721a0784f.json index 8308fdb273..7f581135ed 100644 --- a/backend/.sqlx/query-fa9c2c75b622b23008ef1cdba3cb691ef7d1b0ad9eb2596ccfa7227721a0784f.json +++ b/backend/.sqlx/query-fa9c2c75b622b23008ef1cdba3cb691ef7d1b0ad9eb2596ccfa7227721a0784f.json @@ -104,7 +104,8 @@ "postgres", "sqs", "gcp", - "mqtt" + "mqtt", + "nextcloud" ] } } diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 2d3a07a540..8d6d8e8348 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -7,6 +7,15 @@ - The API routes prefixes are all listed in windmill-api/src/lib.rs - This repository is the open source side of the project. The enterprise files (\*\_ee.rs) are in the `windmill-ee-private` folder (a sibling directory). Those files are symlinked into their corresponding locations within each crate's `src/` directory. +## JSON Handling + +- **Prefer `Box` over `serde_json::Value`** when possible, especially: + - When storing JSON in the database (JSONB columns) + - When passing JSON through without modification + - When the JSON structure doesn't need to be inspected or manipulated +- This avoids unnecessary parsing/serialization overhead and preserves the original JSON format +- Use `serde_json::Value` only when you need to inspect, modify, or construct JSON programmatically + ## Adding New Features 1. Update database schema with migration if necessary diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 50385be0c0..04243ca738 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -15555,6 +15555,7 @@ dependencies = [ "sha2 0.10.9", "sql-builder", "sqlx", + "strum 0.27.2", "tar", "tempfile", "thiserror 2.0.18", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index d07ac5cce2..a412be58d2 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -79,6 +79,7 @@ postgres_trigger = ["windmill-api/postgres_trigger"] mcp = ["windmill-api/mcp", "windmill-worker/mcp"] bedrock = ["windmill-api/bedrock", "windmill-worker/bedrock", "windmill-common/bedrock"] mqtt_trigger = ["windmill-api/mqtt_trigger"] +native_trigger = ["windmill-api/native_trigger"] sqs_trigger = ["windmill-api/sqs_trigger", "windmill-common/aws_auth", "windmill-api/openidconnect"] gcp_trigger = ["windmill-api/gcp_trigger"] smtp = ["windmill-api/smtp", "windmill-common/smtp", "windmill-queue/smtp"] diff --git a/backend/migrations/20260112100000_native_triggers.down.sql b/backend/migrations/20260112100000_native_triggers.down.sql new file mode 100644 index 0000000000..ccf13757a5 --- /dev/null +++ b/backend/migrations/20260112100000_native_triggers.down.sql @@ -0,0 +1,7 @@ +-- Add down migration script here + +DROP TABLE IF EXISTS native_trigger; + +DROP TABLE IF EXISTS workspace_integrations; + +DROP TYPE IF EXISTS native_trigger_service; diff --git a/backend/migrations/20260112100000_native_triggers.up.sql b/backend/migrations/20260112100000_native_triggers.up.sql new file mode 100644 index 0000000000..ad29eebebd --- /dev/null +++ b/backend/migrations/20260112100000_native_triggers.up.sql @@ -0,0 +1,52 @@ +-- Add up migration script here + +CREATE TYPE native_trigger_service AS ENUM ('nextcloud'); +ALTER TYPE TRIGGER_KIND ADD VALUE IF NOT EXISTS 'nextcloud'; +ALTER TYPE job_trigger_kind ADD VALUE IF NOT EXISTS 'nextcloud'; + +CREATE TABLE native_trigger ( + external_id VARCHAR(255) NOT NULL, + workspace_id VARCHAR(50) NOT NULL, + service_name native_trigger_service NOT NULL, + script_path VARCHAR(255) NOT NULL, + is_flow BOOLEAN NOT NULL, + webhook_token_prefix VARCHAR(10) NOT NULL, + service_config JSONB, + error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (external_id, workspace_id, service_name), + CONSTRAINT fk_native_trigger_workspace FOREIGN KEY (workspace_id) + REFERENCES workspace(id) ON DELETE CASCADE +); + +CREATE INDEX idx_native_trigger_workspace + ON native_trigger (workspace_id); + +CREATE INDEX idx_native_trigger_script_path + ON native_trigger (workspace_id, script_path, is_flow); + +GRANT ALL ON native_trigger TO windmill_user; +GRANT ALL ON native_trigger TO windmill_admin; + + +CREATE TABLE workspace_integrations ( + workspace_id VARCHAR(50) NOT NULL, + service_name native_trigger_service NOT NULL, + oauth_data JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by VARCHAR(50) NOT NULL, + PRIMARY KEY (workspace_id, service_name), + CONSTRAINT fk_workspace_integrations_workspace FOREIGN KEY (workspace_id) + REFERENCES workspace(id) ON DELETE CASCADE +); + +CREATE INDEX idx_workspace_integrations_workspace + ON workspace_integrations (workspace_id); + +CREATE INDEX idx_workspace_integrations_service + ON workspace_integrations (service_name); + +GRANT ALL ON workspace_integrations TO windmill_user; +GRANT ALL ON workspace_integrations TO windmill_admin; diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 0d65daaa89..9039ea7393 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -29,6 +29,9 @@ use windmill_api::{ SCIM_TOKEN, }; +#[cfg(feature = "native_trigger")] +use windmill_api::native_triggers::sync::sync_all_triggers; + #[cfg(feature = "enterprise")] use windmill_common::ee_oss::low_disk_alerts; #[cfg(feature = "enterprise")] @@ -1867,6 +1870,34 @@ pub async fn monitor_db( windmill_common::min_version::update_min_version(conn, _worker_mode, WORKERS_NAMES.read().await.clone(), initial_load).await; }; + // Run every 5 minutes (10 iterations * 30s = 5 minutes) + let native_triggers_sync_f = async { + #[cfg(feature = "native_trigger")] + if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(10) { + if let Some(db) = conn.as_sql() { + match sync_all_triggers(db).await { + Ok(result) => { + tracing::debug!( + "Native triggers sync completed: {} workspaces, {} synced, {} errors", + result.workspaces_processed, + result.total_synced, + result.total_errors + ); + if result.total_errors > 0 { + tracing::warn!( + "Native triggers sync encountered {} errors", + result.total_errors + ); + } + } + Err(e) => { + tracing::error!("Error during native triggers sync: {:#}", e); + } + } + } + } + }; + join!( expired_items_f, zombie_jobs_f, @@ -1885,6 +1916,7 @@ pub async fn monitor_db( cleanup_debounce_keys_completed_f, cleanup_flow_iterator_data_f, cleanup_worker_group_stats_f, + native_triggers_sync_f, ); } @@ -2099,7 +2131,6 @@ pub async fn load_base_url(conn: &Connection) -> error::Result { } else { std_base_url }; - { let mut l = BASE_URL.write().await; *l = base_url.clone(); diff --git a/backend/windmill-api/Cargo.toml b/backend/windmill-api/Cargo.toml index e8231efb57..757612ec20 100644 --- a/backend/windmill-api/Cargo.toml +++ b/backend/windmill-api/Cargo.toml @@ -32,6 +32,7 @@ http_trigger = ["dep:matchit", "dep:thiserror", "dep:sha1", "dep:constant_time_e static_frontend = ["dep:rust-embed"] postgres_trigger = ["dep:rust-postgres", "dep:pg_escape", "dep:byteorder", "dep:thiserror", "dep:rust_decimal", "dep:rust-postgres-native-tls"] mqtt_trigger = ["dep:thiserror", "dep:rumqttc"] +native_trigger = ["dep:strum", "dep:backon", "oauth2"] sqs_trigger = ["dep:aws-sdk-sqs", "dep:aws-sdk-sts", "dep:aws-sdk-sso", "dep:aws-sdk-ssooidc", "dep:thiserror", "dep:backon", "dep:aws-config"] deno_core = ["dep:deno_core", "dep:deno_error"] gcp_trigger = ["dep:thiserror", "dep:google-cloud-pubsub", "dep:google-cloud-googleapis", "dep:tonic"] @@ -166,6 +167,7 @@ deno_core = { workspace = true, optional = true } tar.workspace = true flate2.workspace = true backon = {workspace = true, optional = true} +strum = { workspace = true, optional = true } [build-dependencies] deno_core = { workspace = true, optional = true } diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index ccd0b1be3b..138c798556 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -3548,6 +3548,8 @@ paths: type: boolean email_used: type: boolean + nextcloud_used: + type: boolean required: - http_routes_used - websocket_used @@ -3558,6 +3560,7 @@ paths: - gcp_used - sqs_used - email_used + - nextcloud_used /w/{workspace}/users/list: get: summary: list users @@ -11946,6 +11949,424 @@ paths: schema: type: string + /w/{workspace}/native_triggers/integrations/list: + get: + summary: list available native trigger services + operationId: listNativeTriggerServices + tags: + - workspace_integration + parameters: + - $ref: "#/components/parameters/WorkspaceId" + responses: + "200": + description: native trigger services list + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/WorkspaceIntegrations" + + /w/{workspace}/native_triggers/integrations/{service_name}/exists: + get: + summary: check if integrations for a particular service exists + operationId: checkIfNativeTriggersServiceExists + tags: + - workspace_integration + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: service_name + in: path + required: true + schema: + $ref: "#/components/schemas/NativeServiceName" + responses: + "200": + description: integration exists + content: + application/json: + schema: + type: boolean + + /w/{workspace}/native_triggers/integrations/{service_name}/create: + post: + summary: create native trigger service + operationId: createNativeTriggerService + tags: + - workspace_integration + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: service_name + in: path + required: true + schema: + $ref: "#/components/schemas/NativeServiceName" + requestBody: + description: new native trigger service + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/WorkspaceOAuthConfig" + responses: + "201": + description: native trigger service created + content: + text/plain: + schema: + type: string + + /w/{workspace}/native_triggers/integrations/{service_name}/generate_connect_url: + post: + summary: generate connect url for native trigger service + operationId: generateNativeTriggerServiceConnectUrl + tags: + - workspace_integration + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: service_name + in: path + required: true + schema: + $ref: "#/components/schemas/NativeServiceName" + requestBody: + description: redirect_uri + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RedirectUri" + responses: + "200": + description: native trigger service connect url + content: + application/json: + schema: + type: string + + /w/{workspace}/native_triggers/integrations/{service_name}/delete: + delete: + summary: delete native trigger service + operationId: deleteNativeTriggerService + tags: + - workspace_integration + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: service_name + in: path + required: true + schema: + $ref: "#/components/schemas/NativeServiceName" + responses: + "200": + description: native trigger service deleted + content: + text/plain: + schema: + type: string + + /w/{workspace}/native_triggers/integrations/{service_name}/callback/{code}/{state}: + post: + summary: native trigger service oauth callback + operationId: nativeTriggerServiceCallback + tags: + - workspace_integration + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: service_name + in: path + required: true + schema: + $ref: "#/components/schemas/NativeServiceName" + - name: code + in: path + required: true + schema: + type: string + - name: state + in: path + required: true + schema: + type: string + requestBody: + description: redirect_uri + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RedirectUri" + responses: + "200": + description: native trigger service oauth completed + content: + text/plain: + schema: + type: string + + /w/{workspace}/native_triggers/{service_name}/create: + post: + summary: create native trigger + description: | + Creates a new native trigger for the specified service. + Requires write access to the script or flow that the trigger will be associated with. + operationId: createNativeTrigger + tags: + - native_trigger + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: service_name + in: path + required: true + schema: + $ref: "#/components/schemas/NativeServiceName" + requestBody: + description: new native trigger configuration + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NativeTriggerData" + responses: + "201": + description: native trigger created + content: + application/json: + schema: + $ref: "#/components/schemas/CreateTriggerResponse" + + /w/{workspace}/native_triggers/{service_name}/update/{external_id}: + post: + summary: update native trigger + description: | + Updates an existing native trigger. + Requires write access to the script or flow that the trigger is associated with. + operationId: updateNativeTrigger + tags: + - native_trigger + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: service_name + in: path + required: true + schema: + $ref: "#/components/schemas/NativeServiceName" + - name: external_id + in: path + required: true + description: The external ID of the trigger from the external service + schema: + type: string + requestBody: + description: updated native trigger configuration + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NativeTriggerData" + responses: + "200": + description: native trigger updated + content: + text/plain: + schema: + type: string + + /w/{workspace}/native_triggers/{service_name}/get/{external_id}: + get: + summary: get native trigger + description: | + Retrieves a native trigger by its external ID. + Requires write access to the script or flow that the trigger is associated with. + operationId: getNativeTrigger + tags: + - native_trigger + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: service_name + in: path + required: true + schema: + $ref: "#/components/schemas/NativeServiceName" + - name: external_id + in: path + required: true + description: The external ID of the trigger from the external service + schema: + type: string + responses: + "200": + description: native trigger with external configuration + content: + application/json: + schema: + $ref: "#/components/schemas/NativeTriggerWithExternal" + + /w/{workspace}/native_triggers/{service_name}/delete/{external_id}: + delete: + summary: delete native trigger + description: | + Deletes a native trigger by its external ID. + Requires write access to the script or flow that the trigger is associated with. + operationId: deleteNativeTrigger + tags: + - native_trigger + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: service_name + in: path + required: true + schema: + $ref: "#/components/schemas/NativeServiceName" + - name: external_id + in: path + required: true + description: The external ID of the trigger from the external service + schema: + type: string + responses: + "200": + description: native trigger deleted + content: + text/plain: + schema: + type: string + + /w/{workspace}/native_triggers/{service_name}/list: + get: + summary: list native triggers + description: Lists all native triggers for the specified service in the workspace. + operationId: listNativeTriggers + tags: + - native_trigger + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: service_name + in: path + required: true + schema: + $ref: "#/components/schemas/NativeServiceName" + - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/PerPage" + responses: + "200": + description: native triggers list + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/NativeTrigger" + + /w/{workspace}/native_triggers/{service_name}/exists/{external_id}: + get: + summary: check if native trigger exists + description: Checks if a native trigger with the given external ID exists. + operationId: existsNativeTrigger + tags: + - native_trigger + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: service_name + in: path + required: true + schema: + $ref: "#/components/schemas/NativeServiceName" + - name: external_id + in: path + required: true + description: The external ID of the trigger from the external service + schema: + type: string + responses: + "200": + description: whether the native trigger exists + content: + application/json: + schema: + type: boolean + + /w/{workspace}/native_triggers/{service_name}/sync: + post: + summary: sync native triggers with external service + operationId: syncNativeTriggers + tags: + - native_trigger + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: service_name + in: path + required: true + schema: + $ref: "#/components/schemas/NativeServiceName" + responses: + "200": + description: sync completed successfully + + /w/{workspace}/native_triggers/nextcloud/events: + get: + summary: list available NextCloud events + operationId: listNextCloudEvents + tags: + - native_trigger + parameters: + - name: workspace + in: path + required: true + schema: + type: string + responses: + "200": + description: list of available NextCloud events + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/NextCloudEventType" + + /native_triggers/{service_name}/w/{workspace_id}/webhook/{internal_id}: + post: + summary: receive webhook from external native trigger service + operationId: nativeTriggerWebhook + tags: + - native_trigger + parameters: + - name: service_name + in: path + required: true + schema: + $ref: "#/components/schemas/NativeServiceName" + - name: workspace_id + in: path + required: true + schema: + type: string + - name: internal_id + in: path + required: true + schema: + type: integer + format: int64 + description: The internal database ID of the trigger + requestBody: + description: webhook payload from external service + required: false + content: + application/json: + schema: + type: object + additionalProperties: true + text/plain: + schema: + type: string + responses: + "200": + description: webhook received successfully + content: + text/plain: + schema: + type: string + /w/{workspace}/mqtt_triggers/create: post: summary: create mqtt trigger @@ -19398,6 +19819,8 @@ components: type: number sqs_count: type: number + nextcloud_count: + type: number WebsocketTrigger: allOf: @@ -22045,3 +22468,214 @@ components: kind: $ref: "#/components/schemas/AssetKind" required: [path, kind] + + NativeServiceName: + type: string + enum: + - nextcloud + + NativeTrigger: + type: object + description: A native trigger stored in Windmill + properties: + external_id: + type: string + description: The unique identifier from the external service + workspace_id: + type: string + description: The workspace this trigger belongs to + service_name: + $ref: "#/components/schemas/NativeServiceName" + script_path: + type: string + description: The path to the script or flow that will be triggered + is_flow: + type: boolean + description: Whether the trigger targets a flow (true) or a script (false) + service_config: + type: object + description: Configuration for the trigger including event_type and service_config + additionalProperties: true + error: + type: string + nullable: true + description: Error message if the trigger is in an error state + required: + - external_id + - workspace_id + - service_name + - script_path + - is_flow + - service_config + + NativeTriggerWithExternal: + type: object + description: Full trigger response containing both Windmill data and external service data + properties: + external_id: + type: string + description: The unique identifier from the external service + workspace_id: + type: string + description: The workspace this trigger belongs to + service_name: + $ref: "#/components/schemas/NativeServiceName" + script_path: + type: string + description: The path to the script or flow that will be triggered + is_flow: + type: boolean + description: Whether the trigger targets a flow (true) or a script (false) + service_config: + type: object + description: Configuration for the trigger including event_type and service_config + additionalProperties: true + error: + type: string + nullable: true + description: Error message if the trigger is in an error state + external_data: + type: object + description: Configuration data from the external service + additionalProperties: true + required: + - external_id + - workspace_id + - service_name + - script_path + - is_flow + - service_config + - external_data + + WorkspaceIntegrations: + type: object + properties: + service_name: + $ref: '#/components/schemas/NativeServiceName' + oauth_data: + nullable: true + $ref: '#/components/schemas/WorkspaceOAuthConfig' + required: + - service_name + + + WorkspaceOAuthConfig: + type: object + properties: + client_id: + type: string + description: The OAuth client ID for the workspace + client_secret: + type: string + description: The OAuth client secret for the workspace + base_url: + type: string + format: uri + description: The base URL of the workspace + redirect_uri: + type: string + format: uri + description: The OAuth redirect URI + required: + - client_id + - client_secret + - base_url + - redirect_uri + + WebhookEvent: + type: object + properties: + type: + type: string + enum: [webhook] + request_type: + $ref: '#/components/schemas/WebhookRequestType' + required: + - type + - request_type + + WebhookRequestType: + type: string + description: The type of webhook request (define possible values here) + enum: + - async + - sync + + RedirectUri: + type: object + properties: + redirect_uri: + type: + string + required: + - redirect_uri + + + NativeTriggerData: + type: object + description: Data for creating or updating a native trigger + properties: + script_path: + type: string + description: The path to the script or flow that will be triggered + is_flow: + type: boolean + description: Whether the trigger targets a flow (true) or a script (false) + service_config: + type: object + description: Service-specific configuration (e.g., event types, filters) + additionalProperties: true + required: + - script_path + - is_flow + - service_config + + CreateTriggerResponse: + type: object + description: Response returned when a native trigger is created + properties: + external_id: + type: string + description: The external ID of the created trigger from the external service + required: + - external_id + + SyncResult: + type: object + properties: + already_in_sync: + type: boolean + added_count: + type: integer + added_triggers: + type: array + items: + type: string + total_external: + type: integer + total_windmill: + type: integer + required: + - already_in_sync + - added_count + - added_triggers + - total_external + - total_windmill + + NextCloudEventType: + type: object + properties: + id: + type: string + name: + type: string + description: + type: string + category: + type: string + path: + type: string + required: + - id + - name + - path \ No newline at end of file diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index a749b8cec8..33100668aa 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -1789,6 +1789,8 @@ pub struct RunJobQuery { pub skip_preprocessor: Option, pub poll_delay_ms: Option, pub memory_id: Option, + pub trigger_external_id: Option, + pub service_name: Option, pub suspended_mode: Option, } @@ -2089,7 +2091,11 @@ async fn list_queue_jobs( )); } - let args_field = if include_args { "v2_job.args" } else { "null as args" }; + let args_field = if include_args { + "v2_job.args" + } else { + "null as args" + }; let sql = list_queue_jobs_query( &w_id, @@ -2460,11 +2466,23 @@ async fn list_jobs( if include_args { cj_fields = UnifiedJob::completed_job_fields() .iter() - .map(|f| if *f == "null as args" { "v2_job.args" } else { *f }) + .map(|f| { + if *f == "null as args" { + "v2_job.args" + } else { + *f + } + }) .collect(); qj_fields = UnifiedJob::queued_job_fields() .iter() - .map(|f| if *f == "null as args" { "v2_job.args" } else { *f }) + .map(|f| { + if *f == "null as args" { + "v2_job.args" + } else { + *f + } + }) .collect(); cj_fields_ref = &cj_fields; qj_fields_ref = &qj_fields; @@ -4245,18 +4263,26 @@ pub async fn run_flow_by_path( Query(run_query): Query, args: RawWebhookArgs, ) -> error::Result<(StatusCode, String)> { - let args = args - .to_args_from_runnable( - &authed, - &db, - &w_id, - RunnableId::from_flow_path(flow_path.to_path()), - run_query.skip_preprocessor, - ) - .await?; + let (args, trigger_metadata) = get_args_and_trigger_metadata( + &db, + &authed, + RunnableId::from_flow_path(flow_path.to_path()), + &run_query, + &w_id, + args, + ) + .await?; let (uuid, _, _) = push_flow_job_by_path_into_queue( - authed, db, None, user_db, w_id, flow_path, run_query, args, None, + authed, + db, + None, + user_db, + w_id, + flow_path, + run_query, + args, + trigger_metadata, ) .await?; @@ -4650,15 +4676,15 @@ pub async fn run_script_by_path( Query(run_query): Query, args: RawWebhookArgs, ) -> error::Result<(StatusCode, String)> { - let args = args - .to_args_from_runnable( - &authed, - &db, - &w_id, - RunnableId::from_script_path(script_path.to_path()), - run_query.skip_preprocessor, - ) - .await?; + let (args, trigger_metadata) = get_args_and_trigger_metadata( + &db, + &authed, + RunnableId::from_script_path(script_path.to_path()), + &run_query, + &w_id, + args, + ) + .await?; let (uuid, _, _) = push_script_job_by_path_into_queue( authed, @@ -4669,13 +4695,53 @@ pub async fn run_script_by_path( script_path, run_query, args, - None, + trigger_metadata, ) .await?; Ok((StatusCode::CREATED, uuid.to_string())) } +#[allow(unused)] +pub async fn get_args_and_trigger_metadata( + db: &DB, + authed: &ApiAuthed, + runnable_id: RunnableId, + run_query: &RunJobQuery, + w_id: &str, + args: RawWebhookArgs, +) -> error::Result<(PushArgsOwned, Option)> { + use windmill_common::triggers::TriggerMetadata; + + // Build trigger metadata if this is a native trigger request + #[cfg(feature = "native_trigger")] + let trigger_metadata = if let Some(service_name_str) = &run_query.service_name { + use crate::native_triggers::ServiceName; + let service_name = ServiceName::try_from(service_name_str.to_owned())?; + Some(TriggerMetadata::new( + run_query.trigger_external_id.clone(), + service_name.as_job_trigger_kind(), + )) + } else { + None + }; + + #[cfg(not(feature = "native_trigger"))] + let trigger_metadata: Option = None; + + let args = args + .to_args_from_runnable( + &authed, + &db, + &w_id, + runnable_id, + run_query.skip_preprocessor, + ) + .await?; + + Ok((args, trigger_metadata)) +} + pub async fn push_script_job_by_path_into_queue<'c>( authed: ApiAuthed, db: DB, @@ -8413,7 +8479,11 @@ async fn list_completed_jobs( let (per_page, offset) = paginate(pagination); - let args_field = if include_args { "v2_job.args" } else { "null as args" }; + let args_field = if include_args { + "v2_job.args" + } else { + "null as args" + }; let sql = list_completed_jobs_query( &w_id, diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index c18c077880..f329bdcc3b 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -164,6 +164,8 @@ mod smtp_server_oss; pub mod teams_approvals_ee; mod teams_approvals_oss; +#[cfg(feature = "native_trigger")] +pub mod native_triggers; mod public_app_layer; mod static_assets; #[cfg(all(feature = "stripe", feature = "enterprise", feature = "private"))] @@ -494,6 +496,18 @@ pub async fn run_server( .nest("/job_helpers", job_helpers_service) .nest("/jobs", jobs::workspaced_service()) .nest("/debug", debug::workspaced_service()) + .nest("/native_triggers", { + #[cfg(feature = "native_trigger")] + { + native_triggers::handler::generate_native_trigger_routers().merge( + native_triggers::workspace_integrations::workspaced_service(), + ) + } + #[cfg(not(feature = "native_trigger"))] + { + axum::Router::new() + } + }) .nest("/oauth", { #[cfg(feature = "oauth2")] { diff --git a/backend/windmill-api/src/native_triggers/handler.rs b/backend/windmill-api/src/native_triggers/handler.rs new file mode 100644 index 0000000000..930739e244 --- /dev/null +++ b/backend/windmill-api/src/native_triggers/handler.rs @@ -0,0 +1,562 @@ +use crate::{ + db::ApiAuthed, + native_triggers::{ + delete_native_trigger, delete_token_by_prefix, get_native_trigger, get_token_by_prefix, + get_workspace_integration, list_native_triggers, store_native_trigger, + update_native_trigger_error, External, NativeTrigger, NativeTriggerConfig, + NativeTriggerData, ServiceName, + }, + users::{create_token_internal, NewToken}, + utils::check_scopes, +}; +use axum::{ + extract::{Path, Query}, + routing::{delete, get, post}, + Extension, Json, Router, +}; +use serde::{Deserialize, Serialize}; +use sqlx::PgConnection; +use std::sync::Arc; +use windmill_audit::{audit_oss::audit_log, ActionKind}; +use windmill_common::{ + db::UserDB, + error::{Error, JsonResult, Result}, + utils::rd_string, + DB, +}; + +async fn require_is_writer_on_runnable( + authed: &ApiAuthed, + path: &str, + is_flow: bool, + w_id: &str, + db: DB, +) -> Result<()> { + if is_flow { + crate::flows::require_is_writer(authed, path, w_id, db).await + } else { + crate::scripts::require_is_writer(authed, path, w_id, db).await + } +} + +#[derive(Debug, Deserialize)] +pub struct ListQuery { + pub page: Option, + pub per_page: Option, +} + +#[derive(Debug, Serialize)] +pub struct FullTriggerResponse { + #[serde(flatten)] + pub windmill_data: NativeTrigger, + pub external_data: T, +} + +#[derive(Debug, Serialize)] +pub struct CreateTriggerResponse { + pub external_id: String, +} + +async fn new_webhook_token( + tx: &mut PgConnection, + db: &DB, + authed: &ApiAuthed, + script_path: &str, + is_flow: bool, + workspace_id: &str, + service_name: ServiceName, +) -> Result { + let kind = if is_flow { "flows" } else { "scripts" }; + + let scopes = vec![format!("jobs:run:{kind}:{script_path}")]; + let label = format!("webhook-{}-{}", service_name.as_str(), rd_string(5)); + let token_config = NewToken::new( + Some(label), + None, + None, + Some(scopes), + Some(workspace_id.to_owned()), + ); + let token = create_token_internal(&mut *tx, &db, &authed, token_config).await?; + + Ok(token) +} + +async fn create_native_trigger( + Extension(handler): Extension>, + Extension(service_name): Extension, + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path(workspace_id): Path, + Json(data): Json>, +) -> JsonResult { + check_scopes(&authed, || { + format!("native_triggers:write:{}", &data.script_path) + })?; + require_is_writer_on_runnable( + &authed, + &data.script_path, + data.is_flow, + &workspace_id, + db.clone(), + ) + .await?; + + let mut tx = user_db.begin(&authed).await?; + + let webhook_token = new_webhook_token( + &mut *tx, + &db, + &authed, + &data.script_path, + data.is_flow, + &workspace_id, + service_name, + ) + .await?; + + let integration = get_workspace_integration(&mut *tx, &workspace_id, service_name).await?; + + let oauth_data: T::OAuthData = serde_json::from_value(integration.oauth_data).map_err(|e| { + Error::InternalErr(format!( + "Failed to parse {} OAuth data: {}", + T::DISPLAY_NAME, + e + )) + })?; + + let resp = handler + .create( + &workspace_id, + &oauth_data, + &webhook_token, + &data, + &db, + &mut tx, + ) + .await?; + + let (external_id, _) = handler.external_id_and_metadata_from_response(&resp); + + // update the created external trigger with a new uri containing the external_id + handler + .update( + &workspace_id, + &oauth_data, + &external_id, + &webhook_token, + &data, + &db, + &mut tx, + ) + .await?; + + // Fetch the updated trigger data from the external service and extract service_config + let trigger_data = handler + .get(&workspace_id, &oauth_data, &external_id, &db, &mut tx) + .await?; + let service_config = handler.extract_service_config_from_trigger_data(&trigger_data)?; + + let config = NativeTriggerConfig { + script_path: data.script_path.clone(), + is_flow: data.is_flow, + webhook_token, + }; + + store_native_trigger( + &mut *tx, + &workspace_id, + service_name, + &external_id, + &config, + service_config, + ) + .await?; + + audit_log( + &mut *tx, + &authed, + &format!("native_triggers.{}.create", service_name), + ActionKind::Create, + &workspace_id, + Some(&external_id), + None, + ) + .await?; + + tx.commit().await?; + + Ok(Json(CreateTriggerResponse { external_id })) +} + +async fn update_native_trigger_handler( + Extension(handler): Extension>, + Extension(service_name): Extension, + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((workspace_id, external_id)): Path<(String, String)>, + Json(data): Json>, +) -> Result { + check_scopes(&authed, || { + format!("native_triggers:write:{}", &data.script_path) + })?; + require_is_writer_on_runnable( + &authed, + &data.script_path, + data.is_flow, + &workspace_id, + db.clone(), + ) + .await?; + + let mut tx = user_db.begin(&authed).await?; + + let existing = get_native_trigger(&mut *tx, &workspace_id, service_name, &external_id) + .await? + .ok_or_else(|| Error::NotFound(format!("Native trigger not found: {}", external_id)))?; + + // Look up the full token using the stored prefix (use db, not tx, for token table) + let webhook_token = match get_token_by_prefix(&db, &existing.webhook_token_prefix).await? { + Some(token) => token, + None => { + tracing::warn!( + "Webhook token not found for trigger {} (prefix: {}), recreating token", + external_id, + existing.webhook_token_prefix + ); + new_webhook_token( + &mut *tx, + &db, + &authed, + &data.script_path, + data.is_flow, + &workspace_id, + service_name, + ) + .await? + } + }; + + let integration = get_workspace_integration(&mut *tx, &workspace_id, service_name).await?; + + let oauth_data: T::OAuthData = serde_json::from_value(integration.oauth_data).map_err(|e| { + Error::InternalErr(format!( + "Failed to parse {} OAuth data: {}", + T::DISPLAY_NAME, + e + )) + })?; + + handler + .update( + &workspace_id, + &oauth_data, + &external_id, + &webhook_token, + &data, + &db, + &mut tx, + ) + .await?; + + // Fetch the updated trigger data from the external service and extract service_config + let trigger_data = handler + .get(&workspace_id, &oauth_data, &external_id, &db, &mut tx) + .await?; + let service_config = handler.extract_service_config_from_trigger_data(&trigger_data)?; + + let config = NativeTriggerConfig { + script_path: data.script_path.clone(), + is_flow: data.is_flow, + webhook_token, + }; + + store_native_trigger( + &mut *tx, + &workspace_id, + service_name, + &external_id, + &config, + service_config, + ) + .await?; + + audit_log( + &mut *tx, + &authed, + &format!("native_triggers.{}.update", service_name), + ActionKind::Update, + &workspace_id, + Some(&external_id), + None, + ) + .await?; + + tx.commit().await?; + + Ok(format!("Native trigger updated")) +} + +async fn get_native_trigger_handler( + Extension(handler): Extension>, + Extension(service_name): Extension, + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((workspace_id, external_id)): Path<(String, String)>, +) -> JsonResult> { + let mut tx = user_db.begin(&authed).await?; + + let windmill_trigger = get_native_trigger(&mut *tx, &workspace_id, service_name, &external_id) + .await? + .ok_or_else(|| Error::NotFound(format!("Native trigger not found: {}", external_id)))?; + + check_scopes(&authed, || { + format!("native_triggers:read:{}", &windmill_trigger.script_path) + })?; + require_is_writer_on_runnable( + &authed, + &windmill_trigger.script_path, + windmill_trigger.is_flow, + &workspace_id, + db.clone(), + ) + .await?; + + let integration = get_workspace_integration(&mut *tx, &workspace_id, service_name).await?; + + let oauth_data: T::OAuthData = serde_json::from_value(integration.oauth_data).map_err(|e| { + Error::InternalErr(format!( + "Failed to parse {} OAuth data: {}", + T::DISPLAY_NAME, + e + )) + })?; + + let native_trigger = handler + .get(&workspace_id, &oauth_data, &external_id, &db, &mut tx) + .await; + + let native_trigger_config = match native_trigger { + Ok(native_cfg) => { + // Clear error if it was set + if windmill_trigger.error.is_some() { + update_native_trigger_error( + &mut *tx, + &workspace_id, + service_name, + &external_id, + None, + ) + .await?; + } + native_cfg + } + Err(Error::NotFound(_)) => { + let error_msg = "Trigger no longer exists on external service".to_string(); + tracing::warn!( + "Native trigger no longer exists on external service {}, setting error", + service_name + ); + + update_native_trigger_error( + &mut *tx, + &workspace_id, + service_name, + &external_id, + Some(&error_msg), + ) + .await?; + + tx.commit().await?; + + return Err(Error::NotFound(format!( + "Trigger '{}' no longer exists on external service {}", + external_id, service_name + ))); + } + Err(e) => return Err(e), + }; + + let full_resp = Json(FullTriggerResponse { + windmill_data: windmill_trigger, + external_data: native_trigger_config, + }); + + Ok(full_resp) +} + +async fn delete_native_trigger_handler( + Extension(handler): Extension>, + Extension(service_name): Extension, + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((workspace_id, external_id)): Path<(String, String)>, +) -> Result { + let mut tx = user_db.begin(&authed).await?; + + let existing = get_native_trigger(&mut *tx, &workspace_id, service_name, &external_id) + .await? + .ok_or_else(|| Error::NotFound(format!("Native trigger not found: {}", external_id)))?; + + check_scopes(&authed, || { + format!("native_triggers:write:{}", &existing.script_path) + })?; + require_is_writer_on_runnable( + &authed, + &existing.script_path, + existing.is_flow, + &workspace_id, + db.clone(), + ) + .await?; + + let integration = get_workspace_integration(&mut *tx, &workspace_id, service_name).await?; + + let oauth_data: T::OAuthData = serde_json::from_value(integration.oauth_data).map_err(|e| { + Error::InternalErr(format!( + "Failed to parse {} OAuth data: {}", + T::DISPLAY_NAME, + e + )) + })?; + + handler + .delete(&workspace_id, &oauth_data, &external_id, &db, &mut tx) + .await?; + + let deleted = + delete_native_trigger(&mut *tx, &workspace_id, service_name, &external_id).await?; + + if !deleted { + return Err(Error::NotFound(format!("Native trigger not found"))); + } + + // Delete the webhook token using its prefix + if !delete_token_by_prefix(&db, &existing.webhook_token_prefix).await? { + tracing::warn!( + "Webhook token not found when deleting trigger {} (prefix: {})", + external_id, + existing.webhook_token_prefix + ); + } + + audit_log( + &mut *tx, + &authed, + &format!("native_triggers.{}.delete", service_name), + ActionKind::Delete, + &workspace_id, + Some(&external_id), + None, + ) + .await?; + + tx.commit().await?; + + Ok(format!("Native trigger deleted")) +} + +async fn exists_native_trigger_handler( + Extension(service_name): Extension, + _authed: ApiAuthed, + Extension(db): Extension, + Path((workspace_id, external_id)): Path<(String, String)>, +) -> JsonResult { + let exists = sqlx::query_scalar!( + r#" + SELECT EXISTS( + SELECT 1 + FROM native_trigger + WHERE + workspace_id = $1 AND + service_name = $2 AND + external_id = $3 + ) + "#, + workspace_id, + service_name as ServiceName, + external_id + ) + .fetch_one(&db) + .await? + .unwrap_or(false); + + Ok(Json(exists)) +} + +async fn list_native_triggers_handler( + Extension(service_name): Extension, + authed: ApiAuthed, + Extension(user_db): Extension, + Path(workspace_id): Path, + Query(query): Query, +) -> JsonResult> { + let mut tx = user_db.begin(&authed).await?; + let triggers = list_native_triggers( + &mut *tx, + &workspace_id, + service_name, + query.page, + query.per_page, + ) + .await?; + tx.commit().await?; + Ok(Json(triggers)) +} + +pub fn service_routes(handler: T) -> Router { + let additional_routes = handler.additional_routes(); + let service_name = T::SERVICE_NAME; + + let handler_arc = Arc::new(handler); + + let standard_routes = Router::new() + .route("/create", post(create_native_trigger::)) + .route("/list", get(list_native_triggers_handler::)) + .route("/get/:external_id", get(get_native_trigger_handler::)) + .route( + "/update/:external_id", + post(update_native_trigger_handler::), + ) + .route( + "/delete/:external_id", + delete(delete_native_trigger_handler::), + ) + .route( + "/exists/:external_id", + get(exists_native_trigger_handler::), + ); + + standard_routes + .merge(additional_routes) + .layer(Extension(handler_arc)) + .layer(Extension(service_name)) +} + +/// Generates routes for all registered native trigger services. +/// When adding a new service, add a new `.nest()` call here. +pub fn generate_native_trigger_routers() -> Router { + let router = Router::new(); + + #[cfg(feature = "native_trigger")] + { + use crate::native_triggers::nextcloud::NextCloud; + + // Register all service routes here + // When adding a new service: + // 1. Import the handler: use crate::native_triggers::newservice::NewServiceHandler; + // 2. Add the route: .nest("/newservice", service_routes(NewServiceHandler)) + return router.nest("/nextcloud", service_routes(NextCloud)); + // Add new services here: + // .nest("/newservice", service_routes(NewServiceHandler)) + } + + #[cfg(not(feature = "native_trigger"))] + { + router + } +} diff --git a/backend/windmill-api/src/native_triggers/mod.rs b/backend/windmill-api/src/native_triggers/mod.rs new file mode 100644 index 0000000000..029fe9f488 --- /dev/null +++ b/backend/windmill-api/src/native_triggers/mod.rs @@ -0,0 +1,1047 @@ +//! Native Triggers Module +//! +//! This module provides integration with external services (like Nextcloud) that can +//! trigger Windmill scripts/flows via webhooks. +//! +//! ## Adding a New Native Trigger Service +//! +//! When adding a new service (e.g., "NewService"), you need to update the following locations: +//! +//! ### 1. This file (mod.rs): +//! - Add `pub mod newservice;` under the `#[cfg(feature = "native_trigger")]` block +//! - Add `NewService` variant to `ServiceName` enum +//! - Update `ServiceName::as_str()` - add match arm returning `"newservice"` +//! - Update `TryFrom for ServiceName` - add match arm for `"newservice"` +//! - Update `ServiceName::as_trigger_kind()` - add match arm (requires TriggerKind::NewService in windmill_common) +//! - Update `ServiceName::as_job_trigger_kind()` - add match arm (requires JobTriggerKind::NewService in windmill_common) +//! - Update `ServiceName::fmt()` (Display impl) - add match arm +//! +//! ### 2. sync.rs: +//! - Add `sync_service!()` macro call in `sync_all_triggers()` +//! +//! ### 3. handler.rs: +//! - Add `.nest("/newservice", service_routes(NewServiceHandler))` in `generate_native_trigger_routers()` +//! +//! ### 4. Database migration: +//! - Add `'newservice'` to the `native_trigger_service` enum type +//! +//! ### 5. windmill_common (if needed): +//! - Add `NewService` variant to `TriggerKind` enum +//! - Add `'newservice'` to `job_trigger_kind` enum type in migration +//! +//! The generic code (trait definitions, route handlers, database operations) does NOT +//! need modification when adding new services. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use http::StatusCode; +use itertools::Itertools; +use reqwest::{Client, Method}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde_json::json; +use serde_json::value::RawValue; +use sqlx::{FromRow, PgConnection, Postgres}; +use std::{collections::HashMap, fmt::Debug}; +use strum::{EnumIter, IntoEnumIterator}; +use tokio::task; +use windmill_common::{ + error::{to_anyhow, Error, Result}, + triggers::TriggerKind, + variables::{build_crypt, decrypt, encrypt}, + DB, +}; +use windmill_queue::PushArgsOwned; + +#[cfg(feature = "native_trigger")] +use windmill_oauth::{OClient, RefreshToken, Url, OAUTH_HTTP_CLIENT}; + +use crate::db::ApiAuthed; +pub mod handler; +pub mod sync; +pub mod workspace_integrations; + +// Service modules - add new services here: +#[cfg(feature = "native_trigger")] +pub mod nextcloud; +// #[cfg(feature = "native_trigger")] +// pub mod newservice; + +/// Enum of all supported native trigger services. +/// When adding a new service, add a variant here (e.g., `NewService`). +#[derive(EnumIter, sqlx::Type, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[sqlx(type_name = "native_trigger_service", rename_all = "lowercase")] +#[serde(rename_all = "lowercase")] +pub enum ServiceName { + Nextcloud, + // Add new services here: + // NewService, +} + +impl TryFrom for ServiceName { + type Error = Error; + fn try_from(value: String) -> std::result::Result { + // Add new service match arms here: + let service = match value.as_str() { + "nextcloud" => ServiceName::Nextcloud, + // "newservice" => ServiceName::NewService, + _ => { + return Err(anyhow::anyhow!( + "Unknown service, currently supported services are: [{}]", + ServiceName::iter().join(",") + ) + .into()) + } + }; + + Ok(service) + } +} + +impl ServiceName { + /// Returns the lowercase string identifier for this service. + /// Add new service match arms here. + pub fn as_str(&self) -> &'static str { + match self { + ServiceName::Nextcloud => "nextcloud", + // ServiceName::NewService => "newservice", + } + } + + /// Returns the corresponding TriggerKind for this service. + /// Requires adding the variant to TriggerKind in windmill_common. + pub fn as_trigger_kind(&self) -> TriggerKind { + match self { + ServiceName::Nextcloud => TriggerKind::Nextcloud, + // ServiceName::NewService => TriggerKind::NewService, + } + } + + /// Returns the corresponding JobTriggerKind for this service. + /// Requires adding the variant to JobTriggerKind in windmill_common. + pub fn as_job_trigger_kind(&self) -> windmill_common::jobs::JobTriggerKind { + match self { + ServiceName::Nextcloud => windmill_common::jobs::JobTriggerKind::Nextcloud, + // ServiceName::NewService => windmill_common::jobs::JobTriggerKind::NewService, + } + } + + /// Returns the OAuth token endpoint path for this service. + /// Used for building OAuth clients dynamically. + pub fn token_endpoint(&self) -> &'static str { + match self { + ServiceName::Nextcloud => "/apps/oauth2/api/v1/token", + // ServiceName::NewService => "/oauth/token", + } + } + + /// Returns the OAuth authorization endpoint path for this service. + /// Used for building OAuth authorization URLs. + pub fn auth_endpoint(&self) -> &'static str { + match self { + ServiceName::Nextcloud => "/apps/oauth2/authorize", + // ServiceName::NewService => "/oauth/authorize", + } + } +} + +impl std::fmt::Display for ServiceName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct NativeTrigger { + pub external_id: String, + pub workspace_id: String, + pub service_name: ServiceName, + pub script_path: String, + pub is_flow: bool, + pub webhook_token_prefix: String, + pub service_config: Option, + pub error: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NativeTriggerConfig { + pub script_path: String, + pub is_flow: bool, + pub webhook_token: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NativeTriggerData { + pub script_path: String, + pub is_flow: bool, + pub service_config: C, +} + +#[derive(Debug, Clone, FromRow, Serialize, Deserialize)] +pub struct WorkspaceIntegration { + pub workspace_id: String, + pub service_name: ServiceName, + pub oauth_data: serde_json::Value, + pub created_at: DateTime, + pub updated_at: DateTime, + pub created_by: String, +} + +#[async_trait] +pub trait External: Send + Sync + 'static { + type ServiceConfig: Debug + DeserializeOwned + Serialize + Send + Sync; + type TriggerData: Debug + Serialize + Send + Sync; + type OAuthData: DeserializeOwned + Serialize + Clone + Send + Sync; + type CreateResponse: DeserializeOwned + Send + Sync; + + const SUPPORT_WEBHOOK: bool; + const SERVICE_NAME: ServiceName; + const DISPLAY_NAME: &'static str; + const TOKEN_ENDPOINT: &'static str; + const REFRESH_ENDPOINT: &'static str; + + async fn create( + &self, + w_id: &str, + oauth_data: &Self::OAuthData, + webhook_token: &str, + data: &NativeTriggerData, + db: &DB, + tx: &mut PgConnection, + ) -> Result; + + async fn update( + &self, + w_id: &str, + oauth_data: &Self::OAuthData, + external_id: &str, + webhook_token: &str, + data: &NativeTriggerData, + db: &DB, + tx: &mut PgConnection, + ) -> Result<()>; + + async fn get( + &self, + w_id: &str, + oauth_data: &Self::OAuthData, + external_id: &str, + db: &DB, + tx: &mut PgConnection, + ) -> Result; + + async fn delete( + &self, + w_id: &str, + oauth_data: &Self::OAuthData, + external_id: &str, + db: &DB, + tx: &mut PgConnection, + ) -> Result<()>; + + #[allow(unused)] + async fn exists( + &self, + w_id: &str, + oauth_data: &Self::OAuthData, + external_id: &str, + db: &DB, + tx: &mut PgConnection, + ) -> Result; + + async fn list_all( + &self, + w_id: &str, + oauth_data: &Self::OAuthData, + db: &DB, + tx: &mut PgConnection, + ) -> Result>; + + async fn prepare_webhook( + &self, + _db: &DB, + _w_id: &str, + _header: HashMap, + _body: String, + _script_path: &str, + _is_flow: bool, + ) -> Result { + Ok(PushArgsOwned { extra: None, args: HashMap::new() }) + } + + fn external_id_and_metadata_from_response( + &self, + resp: &Self::CreateResponse, + ) -> (String, Option); + + fn get_external_id_from_trigger_data(&self, data: &Self::TriggerData) -> String; + + /// Extracts the service-specific config from trigger data (from external service). + /// Used for comparison during sync to detect config drift. + /// Default implementation converts the trigger data to a JSON value + /// If you need to exclude some fields, skip serializing attributes on the TriggerData struct or override this method. + fn extract_service_config_from_trigger_data( + &self, + data: &Self::TriggerData, + ) -> Result { + serde_json::to_value(data).map_err(|e| { + Error::internal_err(format!("Failed to convert trigger data to JSON: {}", e)) + }) + } + + fn additional_routes(&self) -> axum::Router { + axum::Router::new() + } + + async fn http_client_request( + &self, + url: &str, + method: Method, + workspace_id: &str, + tx: &mut PgConnection, + db: &DB, + headers: Option>, + body: Option<&B>, + ) -> Result { + let oauth_config: OAuthConfig = + decrypt_oauth_data(tx, db, workspace_id, Self::SERVICE_NAME).await?; + + let result = make_http_request( + url, + method.clone(), + headers.clone(), + body.as_ref(), + &oauth_config.access_token, + ) + .await; + + match result { + Ok(response) => Ok(response), + Err(err) + if err.status() == Some(StatusCode::UNAUTHORIZED) + || err.status() == Some(StatusCode::FORBIDDEN) => + { + tracing::info!( + "HTTP auth error ({}), attempting token refresh", + err.status().unwrap() + ); + + let refreshed_oauth_config = + refresh_oauth_tokens(&oauth_config, Self::REFRESH_ENDPOINT).await?; + + task::spawn({ + let db_clone = db.clone(); + let workspace_id_clone = workspace_id.to_string(); + let refreshed_json = oauth_config_to_json(&refreshed_oauth_config); + async move { + update_workspace_integration_tokens_helper( + db_clone, + workspace_id_clone, + Self::SERVICE_NAME, + refreshed_json, + ) + .await; + } + }); + + let response = make_http_request( + url, + method, + headers, + body.as_ref(), + &refreshed_oauth_config.access_token, + ) + .await + .map_err(to_anyhow)?; + Ok(response) + } + Err(e) => Err(to_anyhow(e).into()), + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct OAuthConfig { + pub base_url: String, + pub access_token: String, + pub refresh_token: Option, + pub client_id: String, + pub client_secret: String, +} + +pub async fn make_http_request( + url: &str, + method: Method, + headers: Option>, + body: Option<&B>, + access_token: &str, +) -> std::result::Result { + let client = Client::new(); + let mut request = client.request(method, url); + + request = request + .header("Accept", "application/json") + .header("Authorization", format!("Bearer {}", access_token)); + + if body.is_some() { + request = request.header("Content-Type", "application/json"); + } + + if let Some(custom_headers) = headers { + for (key, value) in custom_headers { + request = request.header(key, value); + } + } + + if let Some(body_content) = body { + request = request.json(body_content); + } + + let response = request.send().await?.error_for_status()?; + + let response_json = response.json().await?; + + Ok(response_json) +} + +pub async fn decrypt_oauth_data< + 'c, + E: sqlx::Executor<'c, Database = Postgres>, + T: DeserializeOwned, +>( + tx: E, + db: &DB, + workspace_id: &str, + service_name: ServiceName, +) -> Result { + let integration = get_workspace_integration(tx, workspace_id, service_name).await?; + + let mc = build_crypt(db, workspace_id).await?; + let mut oauth_data: serde_json::Value = integration.oauth_data; + + if let Some(encrypted_access_token) = oauth_data.get("access_token").and_then(|v| v.as_str()) { + let decrypted_access_token = decrypt(&mc, encrypted_access_token.to_string()) + .map_err(|e| Error::InternalErr(format!("Failed to decrypt access token: {}", e)))?; + oauth_data["access_token"] = serde_json::Value::String(decrypted_access_token); + } + + if let Some(encrypted_refresh_token) = oauth_data.get("refresh_token").and_then(|v| v.as_str()) + { + let decrypted_refresh_token = decrypt(&mc, encrypted_refresh_token.to_string()) + .map_err(|e| Error::InternalErr(format!("Failed to decrypt refresh token: {}", e)))?; + oauth_data["refresh_token"] = serde_json::Value::String(decrypted_refresh_token); + } + + serde_json::from_value(oauth_data) + .map_err(|e| Error::InternalErr(format!("Failed to deserialize OAuth data: {}", e))) +} + +#[allow(unused)] +pub fn oauth_data_to_config(oauth_data: &serde_json::Value) -> Result { + let base_url = oauth_data + .get("base_url") + .and_then(|v| v.as_str()) + .ok_or_else(|| Error::InternalErr("No base_url in OAuth data".to_string()))? + .to_string(); + + let access_token = oauth_data + .get("access_token") + .and_then(|v| v.as_str()) + .ok_or_else(|| Error::InternalErr("No access_token in OAuth data".to_string()))? + .to_string(); + + let refresh_token = oauth_data + .get("refresh_token") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let client_id = oauth_data + .get("client_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| Error::InternalErr("No client_id in OAuth data".to_string()))? + .to_string(); + + let client_secret = oauth_data + .get("client_secret") + .and_then(|v| v.as_str()) + .ok_or_else(|| Error::InternalErr("No client_secret in OAuth data".to_string()))? + .to_string(); + + Ok(OAuthConfig { base_url, access_token, refresh_token, client_id, client_secret }) +} + +#[inline] +pub fn oauth_config_to_json(config: &OAuthConfig) -> serde_json::Value { + let mut json = json!({ + "base_url": config.base_url, + "access_token": config.access_token, + "client_id": config.client_id, + "client_secret": config.client_secret, + }); + + if let Some(refresh_token) = &config.refresh_token { + json["refresh_token"] = serde_json::Value::String(refresh_token.clone()); + } + + json +} + +/// Token refresh response +#[cfg(feature = "native_trigger")] +#[derive(Debug, Deserialize)] +struct RefreshTokenResponse { + access_token: String, + refresh_token: Option, +} + +/// Refresh OAuth tokens using windmill-oauth. +#[cfg(feature = "native_trigger")] +pub async fn refresh_oauth_tokens( + oauth_config: &OAuthConfig, + refresh_endpoint: &str, +) -> Result { + let refresh_token_str = oauth_config + .refresh_token + .as_ref() + .ok_or_else(|| Error::InternalErr("No refresh token available".to_string()))?; + + // Build OAuth client for token refresh + // Auth URL is not used for refresh, but required by the client constructor + let auth_url = Url::parse(&format!("{}/oauth/authorize", oauth_config.base_url)) + .map_err(|e| Error::InternalErr(format!("Invalid auth URL: {}", e)))?; + let token_url = Url::parse(&format!("{}{}", oauth_config.base_url, refresh_endpoint)) + .map_err(|e| Error::InternalErr(format!("Invalid token URL: {}", e)))?; + + let mut client = OClient::new(oauth_config.client_id.clone(), auth_url, token_url); + client.set_client_secret(oauth_config.client_secret.clone()); + + let token_response: RefreshTokenResponse = client + .exchange_refresh_token(&RefreshToken::from(refresh_token_str.as_str())) + .with_client(&*OAUTH_HTTP_CLIENT) + .execute() + .await + .map_err(|e| Error::InternalErr(format!("Failed to refresh token: {:?}", e)))?; + + Ok(OAuthConfig { + base_url: oauth_config.base_url.clone(), + access_token: token_response.access_token, + refresh_token: token_response + .refresh_token + .or_else(|| oauth_config.refresh_token.clone()), + client_id: oauth_config.client_id.clone(), + client_secret: oauth_config.client_secret.clone(), + }) +} + +/// Fallback refresh without native_triggers feature +#[cfg(not(feature = "native_trigger"))] +pub async fn refresh_oauth_tokens( + _oauth_config: &OAuthConfig, + _refresh_endpoint: &str, +) -> Result { + Err(Error::InternalErr( + "Native triggers feature is not enabled".to_string(), + )) +} + +async fn update_workspace_integration_tokens_helper( + db: DB, + workspace_id: String, + service_name: ServiceName, + oauth_data: serde_json::Value, +) { + let result = async { + let mut tx = db.begin().await?; + let mc = build_crypt(&db, &workspace_id).await?; + let mut encrypted_oauth_data = oauth_data; + + if let Some(access_token) = encrypted_oauth_data + .get("access_token") + .and_then(|v| v.as_str()) + { + let encrypted_access_token = encrypt(&mc, access_token); + encrypted_oauth_data["access_token"] = + serde_json::Value::String(encrypted_access_token); + } + + if let Some(refresh_token) = encrypted_oauth_data + .get("refresh_token") + .and_then(|v| v.as_str()) + { + let encrypted_refresh_token = encrypt(&mc, refresh_token); + encrypted_oauth_data["refresh_token"] = + serde_json::Value::String(encrypted_refresh_token); + } + + sqlx::query!( + r#" + UPDATE workspace_integrations + SET oauth_data = $1, updated_at = now() + WHERE workspace_id = $2 AND service_name = $3 + "#, + encrypted_oauth_data, + workspace_id, + service_name as ServiceName, + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok::<(), Error>(()) + } + .await; + + if let Err(e) = result { + tracing::error!("Critical error: Failed to update workspace integration tokens for {} in workspace {}: {}", + service_name, workspace_id, e); + } +} + +/// Look up the full token from the token table using its prefix +pub async fn get_token_by_prefix<'c, E: sqlx::Executor<'c, Database = Postgres>>( + db: E, + token_prefix: &str, +) -> Result> { + let token = sqlx::query_scalar!( + r#" + SELECT token + FROM token + WHERE token LIKE concat($1::text, '%') + LIMIT 1 + "#, + token_prefix + ) + .fetch_optional(db) + .await?; + + Ok(token) +} + +/// Delete a token from the token table using its prefix +pub async fn delete_token_by_prefix<'c, E: sqlx::Executor<'c, Database = Postgres>>( + db: E, + token_prefix: &str, +) -> Result { + let deleted = sqlx::query!( + r#" + DELETE FROM token + WHERE token LIKE concat($1::text, '%') + "#, + token_prefix + ) + .execute(db) + .await? + .rows_affected(); + + Ok(deleted > 0) +} + +pub async fn store_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres>, C: Serialize>( + db: E, + workspace_id: &str, + service_name: ServiceName, + external_id: &str, + config: &NativeTriggerConfig, + service_config: C, +) -> Result<()> { + // Store only the first 10 characters of the webhook token as a prefix + let webhook_token_prefix: String = config.webhook_token.chars().take(10).collect(); + + sqlx::query!( + r#" + INSERT INTO native_trigger ( + external_id, + workspace_id, + service_name, + script_path, + is_flow, + webhook_token_prefix, + service_config + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7 + ) + ON CONFLICT (external_id, workspace_id, service_name) + DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_prefix = $6, service_config = $7, error = NULL, updated_at = NOW() + "#, + external_id, + workspace_id, + service_name as ServiceName, + config.script_path, + config.is_flow, + webhook_token_prefix, + sqlx::types::Json(service_config) as _, + ) + .execute(db) + .await?; + + Ok(()) +} + +pub async fn update_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres>>( + db: E, + workspace_id: &str, + service_name: ServiceName, + external_id: &str, + config: &NativeTriggerConfig, + service_config: Option<&RawValue>, +) -> Result<()> { + // Store only the first 10 characters of the webhook token as a prefix + let webhook_token_prefix: String = config.webhook_token.chars().take(10).collect(); + + sqlx::query!( + r#" + UPDATE native_trigger + SET script_path = $1, is_flow = $2, webhook_token_prefix = $3, service_config = $4, error = NULL, updated_at = NOW() + WHERE + workspace_id = $5 + AND service_name = $6 + AND external_id = $7 + "#, + config.script_path, + config.is_flow, + webhook_token_prefix, + service_config.map(sqlx::types::Json) as _, + workspace_id, + service_name as ServiceName, + external_id, + ) + .execute(db) + .await?; + + Ok(()) +} + +pub async fn delete_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres>>( + db: E, + workspace_id: &str, + service_name: ServiceName, + external_id: &str, +) -> Result { + let deleted = sqlx::query!( + r#" + DELETE FROM native_trigger + WHERE + workspace_id = $1 + AND service_name = $2 + AND external_id = $3 + "#, + workspace_id, + service_name as ServiceName, + external_id, + ) + .execute(db) + .await? + .rows_affected(); + + Ok(deleted > 0) +} +pub async fn get_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres>>( + db: E, + workspace_id: &str, + service_name: ServiceName, + external_id: &str, +) -> Result> { + let trigger = sqlx::query_as!( + NativeTrigger, + r#" + SELECT + external_id, + workspace_id, + service_name AS "service_name!: ServiceName", + script_path, + is_flow, + webhook_token_prefix, + service_config, + error, + created_at, + updated_at + FROM + native_trigger + WHERE + workspace_id = $1 + AND service_name = $2 + AND external_id = $3 + "#, + workspace_id, + service_name as ServiceName, + external_id + ) + .fetch_optional(db) + .await?; + + Ok(trigger) +} + +pub async fn get_native_trigger_by_script<'c, E: sqlx::Executor<'c, Database = Postgres>>( + db: E, + workspace_id: &str, + service_name: ServiceName, + script_path: &str, + is_flow: bool, +) -> Result> { + let trigger = sqlx::query_as!( + NativeTrigger, + r#" + SELECT + external_id, + workspace_id, + service_name AS "service_name!: ServiceName", + script_path, + is_flow, + webhook_token_prefix, + service_config, + error, + created_at, + updated_at + FROM + native_trigger + WHERE + workspace_id = $1 + AND service_name = $2 + AND script_path = $3 + AND is_flow = $4 + LIMIT 1 + "#, + workspace_id, + service_name as ServiceName, + script_path, + is_flow + ) + .fetch_optional(db) + .await?; + + Ok(trigger) +} + +pub async fn list_native_triggers<'c, E: sqlx::Executor<'c, Database = Postgres>>( + db: E, + workspace_id: &str, + service_name: ServiceName, + page: Option, + per_page: Option, +) -> Result> { + let offset = (page.unwrap_or(0) * per_page.unwrap_or(100)) as i64; + let limit = per_page.unwrap_or(100) as i64; + + let triggers = sqlx::query_as!( + NativeTrigger, + r#" + SELECT + nt.external_id, + nt.workspace_id, + nt.service_name AS "service_name!: ServiceName", + nt.script_path, + nt.is_flow, + nt.webhook_token_prefix, + nt.service_config, + nt.error, + nt.created_at, + nt.updated_at + FROM + native_trigger nt + WHERE + nt.workspace_id = $1 AND + nt.service_name = $2 AND + ( + (nt.is_flow = false AND EXISTS ( + SELECT 1 FROM script s + WHERE s.workspace_id = nt.workspace_id + AND s.path = nt.script_path + )) + OR + (nt.is_flow = true AND EXISTS ( + SELECT 1 FROM flow f + WHERE f.workspace_id = nt.workspace_id + AND f.path = nt.script_path + )) + ) + LIMIT $3 + OFFSET $4 + "#, + workspace_id, + service_name as ServiceName, + limit, + offset + ) + .fetch_all(db) + .await?; + + Ok(triggers) +} + +pub async fn update_native_trigger_error<'c, E: sqlx::Executor<'c, Database = Postgres>>( + db: E, + workspace_id: &str, + service_name: ServiceName, + external_id: &str, + error: Option<&str>, +) -> Result<()> { + sqlx::query!( + r#" + UPDATE native_trigger + SET error = $1 + WHERE + workspace_id = $2 + AND service_name = $3 + AND external_id = $4 + "#, + error, + workspace_id, + service_name as ServiceName, + external_id, + ) + .execute(db) + .await?; + + Ok(()) +} + +pub async fn update_native_trigger_service_config< + 'c, + E: sqlx::Executor<'c, Database = Postgres>, +>( + db: E, + workspace_id: &str, + service_name: ServiceName, + external_id: &str, + service_config: &serde_json::Value, +) -> Result<()> { + sqlx::query!( + r#" + UPDATE native_trigger + SET service_config = $1, updated_at = NOW() + WHERE + workspace_id = $2 + AND service_name = $3 + AND external_id = $4 + "#, + service_config, + workspace_id, + service_name as ServiceName, + external_id, + ) + .execute(db) + .await?; + + Ok(()) +} + +pub async fn store_workspace_integration( + tx: &mut PgConnection, + authed: &ApiAuthed, + workspace_id: &str, + service_name: ServiceName, + oauth_data: serde_json::Value, +) -> Result<()> { + sqlx::query!( + r#" + INSERT INTO workspace_integrations ( + workspace_id, + service_name, + oauth_data, + created_by, + created_at, + updated_at + ) VALUES ( + $1, $2, $3, $4, now(), now() + ) + ON CONFLICT (workspace_id, service_name) + DO UPDATE SET + oauth_data = $3, + updated_at = now() + "#, + workspace_id, + service_name as ServiceName, + oauth_data, + authed.username, + ) + .execute(&mut *tx) + .await?; + + Ok(()) +} + +pub async fn get_workspace_integration<'c, E: sqlx::Executor<'c, Database = Postgres>>( + db: E, + workspace_id: &str, + service_name: ServiceName, +) -> Result { + let integration = sqlx::query_as!( + WorkspaceIntegration, + r#" + SELECT + workspace_id, + service_name AS "service_name!: ServiceName", + oauth_data, + created_at, + updated_at, + created_by + FROM + workspace_integrations + WHERE + workspace_id = $1 + AND service_name = $2 + "#, + workspace_id, + service_name as ServiceName, + ) + .fetch_one(db) + .await?; + + Ok(integration) +} + +pub async fn delete_workspace_integration( + tx: &mut PgConnection, + workspace_id: &str, + service_name: ServiceName, +) -> Result { + let deleted = sqlx::query!( + r#" + DELETE FROM workspace_integrations + WHERE + workspace_id = $1 + AND service_name = $2 + "#, + workspace_id, + service_name as ServiceName, + ) + .execute(&mut *tx) + .await? + .rows_affected(); + + Ok(deleted > 0) +} + +/// Generates the webhook URL that external services will call. +/// +/// `external_id` is optional because during CREATE we don't have it yet +/// (it's returned by the external service). During UPDATE, we have it. +pub fn generate_webhook_service_url( + base_url: &str, + w_id: &str, + script_path: &str, + is_flow: bool, + external_id: Option<&str>, + service_name: ServiceName, + webhook_token: &str, +) -> String { + let runnable_prefix = if is_flow { "f" } else { "p" }; + + let mut url = format!( + "{}/api/w/{}/jobs/run/{}/{}?token={}&service_name={}", + base_url, + w_id, + runnable_prefix, + script_path, + &webhook_token, + service_name.as_str(), + ); + + if let Some(id) = external_id { + url.push_str(&format!("&trigger_external_id={}", id)); + } + + url +} diff --git a/backend/windmill-api/src/native_triggers/nextcloud/external.rs b/backend/windmill-api/src/native_triggers/nextcloud/external.rs new file mode 100644 index 0000000000..028ba131c3 --- /dev/null +++ b/backend/windmill-api/src/native_triggers/nextcloud/external.rs @@ -0,0 +1,304 @@ +use reqwest::Method; +use serde::{Deserialize, Serialize}; +use serde_json::value::to_raw_value; +use sqlx::PgConnection; +use std::collections::HashMap; +use windmill_common::{ + error::{Error, Result}, + BASE_URL, DB, +}; + +use crate::native_triggers::{ + generate_webhook_service_url, + nextcloud::{ + routes, NextCloud, NextCloudOAuthData, NextCloudTriggerData, NextcloudServiceConfig, + OcsResponse, + }, + External, NativeTriggerData, ServiceName, +}; + +lazy_static::lazy_static! { + pub static ref TOKEN_NEEDED: Box = to_raw_value(&serde_json::json!({ + "user_roles": ["owner", "trigger"] + })).unwrap(); +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct FullNextcloudPayload { + pub http_method: String, + pub uri: String, + pub token_needed: Box, + #[serde(flatten)] + service_config: NextcloudServiceConfig, +} + +impl FullNextcloudPayload { + async fn new( + w_id: &str, + external_id: Option<&str>, + webhook_token: &str, + data: &NativeTriggerData, + ) -> FullNextcloudPayload { + let base_url = &*BASE_URL.read().await; + let uri = generate_webhook_service_url( + base_url, + w_id, + &data.script_path, + data.is_flow, + external_id, + ServiceName::Nextcloud, + webhook_token, + ); + + FullNextcloudPayload { + http_method: http::Method::POST.to_string().to_uppercase(), + uri, + token_needed: TOKEN_NEEDED.clone(), + service_config: data.service_config.clone(), + } + } +} + +#[derive(Debug, Deserialize)] +pub struct RegisterWebhookResponse { + pub id: i64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct User { + pub uid: String, + #[serde(rename = "displayName")] + pub display_name: Option, +} +#[derive(Debug, Serialize, Deserialize)] +pub struct WebhookPayload { + pub event: EventPayload, + pub user: User, + pub time: i64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct EventPayload { + pub node: Node, + #[serde(rename = "class")] + pub class_name: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Node { + pub id: i64, + pub path: String, +} + +#[async_trait::async_trait] +impl External for NextCloud { + type ServiceConfig = NextcloudServiceConfig; + type TriggerData = NextCloudTriggerData; + type OAuthData = NextCloudOAuthData; + type CreateResponse = RegisterWebhookResponse; + const SERVICE_NAME: ServiceName = ServiceName::Nextcloud; + const DISPLAY_NAME: &'static str = "Nextcloud"; + const SUPPORT_WEBHOOK: bool = true; + const TOKEN_ENDPOINT: &'static str = "/apps/oauth2/api/v1/token"; + const REFRESH_ENDPOINT: &'static str = "/apps/oauth2/api/v1/token"; + + async fn create( + &self, + w_id: &str, + oauth_data: &Self::OAuthData, + webhook_token: &str, + data: &NativeTriggerData, + db: &DB, + tx: &mut PgConnection, + ) -> Result { + // During create, we don't have external_id yet (it comes from NextCloud's response) + let full_nextcloud_payload = + FullNextcloudPayload::new(w_id, None, webhook_token, data).await; + + let url = format!( + "{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks", + oauth_data.base_url + ); + + let mut headers = HashMap::new(); + headers.insert("OCS-APIRequest".to_string(), "true".to_string()); + + let ocs_response = self + .http_client_request::, _>( + &url, + Method::POST, + w_id, + tx, + db, + Some(headers), + Some(&full_nextcloud_payload), + ) + .await?; + + Ok(ocs_response.ocs.data) + } + + async fn update( + &self, + w_id: &str, + oauth_data: &Self::OAuthData, + external_id: &str, + webhook_token: &str, + data: &NativeTriggerData, + db: &DB, + tx: &mut PgConnection, + ) -> Result<()> { + // During update, we have the external_id so include it in the webhook URL + let full_nextcloud_payload = + FullNextcloudPayload::new(w_id, Some(external_id), webhook_token, data).await; + + let url = format!( + "{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/{}", + oauth_data.base_url, external_id + ); + + let mut headers = HashMap::new(); + headers.insert("OCS-APIRequest".to_string(), "true".to_string()); + + let _ = self + .http_client_request::( + &url, + Method::POST, + w_id, + tx, + db, + Some(headers), + Some(&full_nextcloud_payload), + ) + .await?; + + Ok(()) + } + + async fn get( + &self, + w_id: &str, + oauth_data: &Self::OAuthData, + external_id: &str, + db: &DB, + tx: &mut PgConnection, + ) -> Result { + let url = format!( + "{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/{}", + oauth_data.base_url, external_id + ); + + let mut headers = HashMap::new(); + headers.insert("OCS-APIRequest".to_string(), "true".to_string()); + + let ocs_response: OcsResponse = self + .http_client_request::<_, ()>(&url, Method::GET, w_id, tx, db, Some(headers), None) + .await?; + + Ok(ocs_response.ocs.data) + } + + async fn delete( + &self, + w_id: &str, + oauth_data: &Self::OAuthData, + external_id: &str, + db: &DB, + tx: &mut PgConnection, + ) -> Result<()> { + let url = format!( + "{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/{}", + oauth_data.base_url, external_id + ); + + let mut headers = HashMap::new(); + headers.insert("OCS-APIRequest".to_string(), "true".to_string()); + + let _: serde_json::Value = self + .http_client_request::<_, ()>(&url, Method::DELETE, w_id, tx, db, Some(headers), None) + .await + .or_else(|e| match &e { + Error::InternalErr(msg) if msg.contains("404") => Ok(serde_json::Value::Null), + _ => Err(e), + })?; + + Ok(()) + } + + async fn exists( + &self, + w_id: &str, + oauth_data: &Self::OAuthData, + external_id: &str, + db: &DB, + tx: &mut PgConnection, + ) -> Result { + let url = format!( + "{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks/{}", + oauth_data.base_url, external_id + ); + + let mut headers = HashMap::new(); + headers.insert("OCS-APIRequest".to_string(), "true".to_string()); + + let _ = self + .http_client_request::( + &url, + Method::GET, + w_id, + tx, + db, + Some(headers), + None, + ) + .await?; + + Ok(true) + } + + async fn list_all( + &self, + w_id: &str, + oauth_data: &Self::OAuthData, + db: &DB, + tx: &mut PgConnection, + ) -> Result> { + let url = format!( + "{}/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks", + oauth_data.base_url + ); + + let mut headers = HashMap::new(); + headers.insert("OCS-APIRequest".to_string(), "true".to_string()); + + let ocs_response = self + .http_client_request::>, ()>( + &url, + Method::GET, + w_id, + tx, + db, + Some(headers), + None, + ) + .await?; + + Ok(ocs_response.ocs.data) + } + + fn external_id_and_metadata_from_response( + &self, + resp: &Self::CreateResponse, + ) -> (String, Option) { + (resp.id.to_string(), None) + } + + fn get_external_id_from_trigger_data(&self, data: &Self::TriggerData) -> String { + data.id.to_string() + } + + fn additional_routes(&self) -> axum::Router { + routes::nextcloud_routes(self.clone()) + } +} diff --git a/backend/windmill-api/src/native_triggers/nextcloud/mod.rs b/backend/windmill-api/src/native_triggers/nextcloud/mod.rs new file mode 100644 index 0000000000..fda8ad9f6e --- /dev/null +++ b/backend/windmill-api/src/native_triggers/nextcloud/mod.rs @@ -0,0 +1,64 @@ +use serde::{Deserialize, Serialize}; + +pub mod external; +mod routes; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct NextCloudOAuthData { + pub base_url: String, + pub access_token: String, + pub refresh_token: Option, + pub token_expires_at: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct OcsResponse { + pub ocs: OcsData, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Meta { + status: String, + #[serde(rename = "statuscode")] + status_code: u16, + message: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct OcsData { + pub meta: Meta, + pub data: T, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NextCloudEventType { + pub name: String, + pub description: Option, + pub path: Option, + pub parameters: Option, +} + +#[derive(Copy, Clone)] +pub struct NextCloud; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NextcloudServiceConfig { + pub event: String, + pub event_filter: Option>, + pub user_id_filter: Option, + pub headers: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NextCloudTriggerData { + #[serde(skip_serializing)] + pub id: i64, + #[serde(skip_serializing)] + pub uri: String, + pub event: String, + pub event_filter: Option>, + pub user_id_filter: Option, + pub headers: Option>, +} diff --git a/backend/windmill-api/src/native_triggers/nextcloud/routes.rs b/backend/windmill-api/src/native_triggers/nextcloud/routes.rs new file mode 100644 index 0000000000..40633dc248 --- /dev/null +++ b/backend/windmill-api/src/native_triggers/nextcloud/routes.rs @@ -0,0 +1,66 @@ +use std::{collections::HashMap, sync::Arc}; + +use axum::{extract::Path, routing::get, Extension, Json, Router}; +use http::Method; +use windmill_common::{ + db::UserDB, + error::{Error, JsonResult}, + DB, +}; + +use crate::{ + db::ApiAuthed, + native_triggers::{ + get_workspace_integration, + nextcloud::{NextCloudEventType, OcsResponse}, + External, OAuthConfig, ServiceName, + }, +}; + +async fn list_available_events( + authed: ApiAuthed, + Extension(handler): Extension>, + Extension(db): Extension, + Extension(user_db): Extension, + Path(workspace_id): Path, +) -> JsonResult> { + let mut tx = user_db.clone().begin(&authed).await?; + let integration = + get_workspace_integration(&mut *tx, &workspace_id, ServiceName::Nextcloud).await?; + + let auth = serde_json::from_value::(integration.oauth_data) + .map_err(|e| Error::InternalErr(format!("Failed to parse NextCloud OAuth data: {}", e)))?; + + let url = format!( + "{}/ocs/v2.php/apps/integration_windmill/api/v1/list/events", + &auth.base_url, + ); + + let mut headers = HashMap::new(); + headers.insert("OCS-APIRequest".to_string(), "true".to_string()); + + let ocs_response = handler + .http_client_request::( + &url, + Method::GET, + &workspace_id, + &mut *tx, + &db, + Some(headers), + None, + ) + .await?; + tx.commit().await?; + + let events = serde_json::from_str(&ocs_response.ocs.data) + .map_err(|e| Error::InternalErr(format!("Failed to parse NextCloud events data: {}", e)))?; + + Ok(Json(events)) +} + +pub fn nextcloud_routes(service: T) -> Router { + let service = Arc::new(service); + Router::new() + .route("/events", get(list_available_events::)) + .layer(Extension(service)) +} diff --git a/backend/windmill-api/src/native_triggers/sync.rs b/backend/windmill-api/src/native_triggers/sync.rs new file mode 100644 index 0000000000..da3feb7f33 --- /dev/null +++ b/backend/windmill-api/src/native_triggers/sync.rs @@ -0,0 +1,416 @@ +use std::collections::HashMap; +use windmill_common::error::Result; +use windmill_common::DB; + +use serde::Serialize; + +use crate::native_triggers::{ + decrypt_oauth_data, list_native_triggers, update_native_trigger_error, + update_native_trigger_service_config, External, ServiceName, +}; + +#[derive(Debug, Serialize)] +pub struct TriggerSyncInfo { + pub external_id: String, + pub script_path: String, + pub action: SyncAction, +} + +#[derive(Debug, Serialize)] +pub enum SyncAction { + ErrorSet(String), + ErrorCleared, + ConfigUpdated, +} + +#[derive(Debug, Serialize)] +pub struct SyncError { + pub resource_path: String, + pub error_message: String, + pub error_type: String, +} + +#[derive(Debug)] +pub struct BackgroundSyncResult { + pub workspaces_processed: usize, + pub total_synced: usize, + pub total_errors: usize, + pub service_results: HashMap, +} + +#[derive(Debug)] +pub struct ServiceSyncResult { + pub synced_triggers: Vec, + pub errors: Vec, +} + +pub async fn sync_all_triggers(db: &DB) -> Result { + tracing::info!("Starting native triggers sync"); + + let mut service_results: HashMap = HashMap::new(); + let mut total_synced = 0; + let mut total_errors = 0; + let mut workspaces_processed = 0; + + // Sync all registered services + // Each service only syncs workspaces that have the corresponding integration configured + #[cfg(feature = "native_trigger")] + { + use crate::native_triggers::nextcloud::NextCloud; + + let (service_name, result) = sync_service_triggers(db, NextCloud).await; + total_synced += result.synced_triggers.len(); + total_errors += result.errors.len(); + service_results.insert(service_name, result); + + // Add new services here: + // use crate::native_triggers::newservice::NewService; + // let (service_name, result) = sync_service_triggers(db, NewService).await; + // total_synced += result.synced_triggers.len(); + // total_errors += result.errors.len(); + // service_results.insert(service_name, result); + } + + // Count unique workspaces processed across all services + for result in service_results.values() { + workspaces_processed += result + .synced_triggers + .iter() + .map(|t| &t.external_id) + .collect::>() + .len(); + } + + let result = + BackgroundSyncResult { workspaces_processed, total_synced, total_errors, service_results }; + + tracing::info!( + "Completed native triggers sync: {} updated, {} errors", + result.total_synced, + result.total_errors + ); + + Ok(result) +} + +async fn sync_service_triggers( + db: &DB, + handler: T, +) -> (ServiceName, ServiceSyncResult) { + let mut all_synced_triggers = Vec::new(); + let mut all_errors = Vec::new(); + + // Only sync workspaces that have the corresponding integration configured + let workspaces_with_integration = match sqlx::query_scalar!( + r#" + SELECT wi.workspace_id + FROM workspace_integrations wi + JOIN workspace w ON w.id = wi.workspace_id + WHERE wi.service_name = $1 + AND wi.oauth_data IS NOT NULL + AND w.deleted = false + "#, + T::SERVICE_NAME as ServiceName + ) + .fetch_all(db) + .await + { + Ok(workspaces) => workspaces, + Err(e) => { + tracing::error!( + "Error querying workspaces with {} integration: {:#}", + T::SERVICE_NAME.as_str(), + e + ); + all_errors.push(SyncError { + resource_path: "database".to_string(), + error_message: format!("Failed to query workspaces: {}", e), + error_type: "database_error".to_string(), + }); + return ( + T::SERVICE_NAME, + ServiceSyncResult { synced_triggers: Vec::new(), errors: all_errors }, + ); + } + }; + + if workspaces_with_integration.is_empty() { + tracing::debug!( + "No workspaces with {} integration configured, skipping sync", + T::SERVICE_NAME.as_str() + ); + return ( + T::SERVICE_NAME, + ServiceSyncResult { synced_triggers: Vec::new(), errors: Vec::new() }, + ); + } + + tracing::info!( + "Found {} workspaces with {} integration configured", + workspaces_with_integration.len(), + T::SERVICE_NAME.as_str() + ); + + for workspace_id in workspaces_with_integration { + let sync_result = sync_workspace_triggers::(db, &workspace_id, &handler).await; + + match sync_result { + Ok((synced_triggers, errors)) => { + all_synced_triggers.extend(synced_triggers); + all_errors.extend(errors); + } + Err(e) => { + tracing::error!( + "Error syncing {} triggers for workspace {}: {:#}", + T::SERVICE_NAME.as_str(), + workspace_id, + e + ); + all_errors.push(SyncError { + resource_path: format!("workspace:{}", workspace_id), + error_message: format!("Failed to sync workspace: {}", e), + error_type: "workspace_sync_error".to_string(), + }); + } + } + } + + ( + T::SERVICE_NAME, + ServiceSyncResult { synced_triggers: all_synced_triggers, errors: all_errors }, + ) +} + +#[cfg(feature = "native_trigger")] +pub async fn sync_workspace_triggers( + db: &DB, + workspace_id: &str, + handler: &T, +) -> Result<(Vec, Vec)> { + tracing::info!( + "Syncing {} triggers for workspace '{}'", + T::SERVICE_NAME.as_str(), + workspace_id + ); + + let windmill_triggers = + list_native_triggers(db, workspace_id, T::SERVICE_NAME, None, None).await?; + + if windmill_triggers.is_empty() { + tracing::info!( + "No {} triggers found for workspace '{}'", + T::SERVICE_NAME.as_str(), + workspace_id + ); + return Ok((Vec::new(), Vec::new())); + } + + let mut all_synced_triggers = Vec::new(); + let mut all_sync_errors = Vec::new(); + + let oauth_data = { + match decrypt_oauth_data(db, db, workspace_id, T::SERVICE_NAME).await { + Ok(oauth_data) => oauth_data, + Err(e) => { + tracing::error!( + "Failed to get workspace integration OAuth data for {}: {}", + workspace_id, + e + ); + all_sync_errors.push(SyncError { + resource_path: format!("workspace:{}", workspace_id), + error_message: format!("Failed to get workspace integration OAuth data: {}", e), + error_type: "oauth_error".to_string(), + }); + return Ok((Vec::new(), all_sync_errors)); + } + } + }; + + let mut tx = db.begin().await?; + let external_triggers = match handler + .list_all(workspace_id, &oauth_data, db, &mut tx) + .await + { + Ok(triggers) => triggers, + Err(e) => { + tracing::error!( + "Failed to fetch external triggers for {}: {}", + workspace_id, + e + ); + all_sync_errors.push(SyncError { + resource_path: format!("workspace:{}", workspace_id), + error_message: format!("Failed to fetch external triggers: {}", e), + error_type: "external_service_error".to_string(), + }); + return Ok((Vec::new(), all_sync_errors)); + } + }; + tx.commit().await?; + + // Build a map of external trigger IDs to their data + let mut external_trigger_map: HashMap = HashMap::new(); + for external_trigger in &external_triggers { + let external_id = handler.get_external_id_from_trigger_data(external_trigger); + external_trigger_map.insert(external_id, external_trigger); + } + + for trigger in &windmill_triggers { + if !external_trigger_map.contains_key(&trigger.external_id) { + // Trigger no longer exists on external service - set error + let error_msg = "Trigger no longer exists on external service".to_string(); + + if trigger.error.as_deref() != Some(&error_msg) { + tracing::info!( + "Trigger (external_id: '{}', script_path: '{}') no longer exists in external service, setting error", + trigger.external_id, + trigger.script_path + ); + + match update_native_trigger_error( + db, + workspace_id, + T::SERVICE_NAME, + &trigger.external_id, + Some(&error_msg), + ) + .await + { + Ok(()) => { + all_synced_triggers.push(TriggerSyncInfo { + external_id: trigger.external_id.clone(), + script_path: trigger.script_path.clone(), + action: SyncAction::ErrorSet(error_msg), + }); + } + Err(e) => { + tracing::error!( + "Failed to update error for trigger (external_id: '{}'): {}", + trigger.external_id, + e + ); + all_sync_errors.push(SyncError { + resource_path: format!("workspace:{}", workspace_id), + error_message: format!( + "Failed to update error for trigger (external_id: '{}'): {}", + trigger.external_id, e + ), + error_type: "database_update_error".to_string(), + }); + } + } + } + } else { + // Trigger exists on external service + let external_trigger_data = external_trigger_map.get(&trigger.external_id).unwrap(); + + // Clear error if it was set + if trigger.error.is_some() { + tracing::info!( + "Trigger (external_id: '{}', script_path: '{}') exists on external service, clearing error", + trigger.external_id, + trigger.script_path + ); + + match update_native_trigger_error( + db, + workspace_id, + T::SERVICE_NAME, + &trigger.external_id, + None, + ) + .await + { + Ok(()) => { + all_synced_triggers.push(TriggerSyncInfo { + external_id: trigger.external_id.clone(), + script_path: trigger.script_path.clone(), + action: SyncAction::ErrorCleared, + }); + } + Err(e) => { + tracing::error!( + "Failed to clear error for trigger (external_id: '{}'): {}", + trigger.external_id, + e + ); + all_sync_errors.push(SyncError { + resource_path: format!("workspace:{}", workspace_id), + error_message: format!( + "Failed to clear error for trigger (external_id: '{}'): {}", + trigger.external_id, e + ), + error_type: "database_update_error".to_string(), + }); + } + } + } + + // Compare service_config and update if different + let external_service_config = + handler.extract_service_config_from_trigger_data(external_trigger_data)?; + let stored_service_config = trigger + .service_config + .clone() + .unwrap_or(serde_json::Value::Null); + + if external_service_config != stored_service_config { + tracing::info!( + "Trigger (external_id: '{}', script_path: '{}') config differs from external service, updating local config", + trigger.external_id, + trigger.script_path + ); + + match update_native_trigger_service_config( + db, + workspace_id, + T::SERVICE_NAME, + &trigger.external_id, + &external_service_config, + ) + .await + { + Ok(()) => { + all_synced_triggers.push(TriggerSyncInfo { + external_id: trigger.external_id.clone(), + script_path: trigger.script_path.clone(), + action: SyncAction::ConfigUpdated, + }); + } + Err(e) => { + tracing::error!( + "Failed to update config for trigger (external_id: '{}'): {}", + trigger.external_id, + e + ); + all_sync_errors.push(SyncError { + resource_path: format!("workspace:{}", workspace_id), + error_message: format!( + "Failed to update config for trigger (external_id: '{}'): {}", + trigger.external_id, e + ), + error_type: "database_update_error".to_string(), + }); + } + } + } else { + tracing::info!( + "Trigger (external_id: '{}', script_path: '{}') config is the same as external service, no update needed", + trigger.external_id, + trigger.script_path + ); + } + } + } + + tracing::info!( + "Sync completed for {} in workspace '{}'. Updated: {}, Errors: {}", + T::SERVICE_NAME.as_str(), + workspace_id, + all_synced_triggers.len(), + all_sync_errors.len() + ); + + Ok((all_synced_triggers, all_sync_errors)) +} diff --git a/backend/windmill-api/src/native_triggers/workspace_integrations.rs b/backend/windmill-api/src/native_triggers/workspace_integrations.rs new file mode 100644 index 0000000000..dd823b1344 --- /dev/null +++ b/backend/windmill-api/src/native_triggers/workspace_integrations.rs @@ -0,0 +1,522 @@ +use axum::{ + extract::Path, + routing::{delete, get, post}, + Extension, Json, Router, +}; + +#[cfg(feature = "native_trigger")] +use serde_json::to_value; +use sqlx::prelude::FromRow; +use strum::IntoEnumIterator; + +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +#[cfg(feature = "native_trigger")] +use windmill_audit::{audit_oss::audit_log, ActionKind}; +use windmill_common::{ + db::UserDB, + error::{Error, JsonResult, Result}, + utils::require_admin, + variables::{build_crypt, encrypt}, + DB, +}; + +#[cfg(feature = "native_trigger")] +use crate::{ + db::ApiAuthed, + native_triggers::{delete_workspace_integration, store_workspace_integration, ServiceName}, +}; + +#[cfg(feature = "native_trigger")] +use windmill_oauth::{OClient, Url, OAUTH_HTTP_CLIENT}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use hmac::{Hmac, Mac}; +use sha2::Sha256; + +type HmacSha256 = Hmac; + +const STATE_EXPIRATION_SECONDS: i64 = 600; // 10 minutes + +/// Generate a signed OAuth state that is cluster-safe. +/// The state contains: workspace_id, service_name, timestamp, and nonce. +/// It's signed with HMAC-SHA256 using the workspace key. +#[cfg(feature = "native_trigger")] +async fn generate_signed_state( + db: &DB, + workspace_id: &str, + service_name: ServiceName, +) -> Result { + use windmill_common::variables::get_workspace_key; + + let nonce = uuid::Uuid::new_v4().to_string(); + let timestamp = chrono::Utc::now().timestamp(); + let payload = format!( + "{}:{}:{}:{}", + workspace_id, + service_name.as_str(), + timestamp, + nonce + ); + + // Get workspace key for signing + let key = get_workspace_key(workspace_id, db).await?; + let mut mac = + HmacSha256::new_from_slice(key.as_bytes()).map_err(|e| Error::InternalErr(e.to_string()))?; + mac.update(payload.as_bytes()); + let signature = mac.finalize().into_bytes(); + + // Encode as: base64(payload):base64(signature) + let encoded_payload = URL_SAFE_NO_PAD.encode(payload.as_bytes()); + let encoded_signature = URL_SAFE_NO_PAD.encode(signature); + + Ok(format!("{}:{}", encoded_payload, encoded_signature)) +} + +/// Validate a signed OAuth state. +/// Returns true if the state is valid (correct signature and not expired). +#[cfg(feature = "native_trigger")] +async fn validate_signed_state(db: &DB, state: &str, workspace_id: &str) -> Result { + use windmill_common::variables::get_workspace_key; + + let parts: Vec<&str> = state.split(':').collect(); + if parts.len() != 2 { + return Ok(false); + } + + let encoded_payload = parts[0]; + let encoded_signature = parts[1]; + + // Decode payload + let payload_bytes = match URL_SAFE_NO_PAD.decode(encoded_payload) { + Ok(bytes) => bytes, + Err(_) => return Ok(false), + }; + let payload = match String::from_utf8(payload_bytes) { + Ok(s) => s, + Err(_) => return Ok(false), + }; + + // Parse payload: workspace_id:service_name:timestamp:nonce + let payload_parts: Vec<&str> = payload.split(':').collect(); + if payload_parts.len() != 4 { + return Ok(false); + } + + let state_workspace_id = payload_parts[0]; + let timestamp: i64 = match payload_parts[2].parse() { + Ok(ts) => ts, + Err(_) => return Ok(false), + }; + + // Verify workspace_id matches + if state_workspace_id != workspace_id { + return Ok(false); + } + + // Check expiration + let now = chrono::Utc::now().timestamp(); + if now - timestamp > STATE_EXPIRATION_SECONDS { + return Ok(false); + } + + // Verify signature + let key = get_workspace_key(workspace_id, db).await?; + let mut mac = + HmacSha256::new_from_slice(key.as_bytes()).map_err(|e| Error::InternalErr(e.to_string()))?; + mac.update(payload.as_bytes()); + + let received_signature = match URL_SAFE_NO_PAD.decode(encoded_signature) { + Ok(bytes) => bytes, + Err(_) => return Ok(false), + }; + + Ok(mac.verify_slice(&received_signature).is_ok()) +} + +#[derive(Debug, Serialize)] +pub struct IntegrationStatusResponse { + pub connected: bool, + pub service_name: ServiceName, + pub created_at: Option>, + pub created_by: Option, +} + +#[derive(Debug, Serialize)] +pub struct ListIntegrationsResponse { + pub integrations: Vec, +} + +#[derive(Debug, Serialize)] +pub struct ConnectIntegrationResponse { + pub auth_url: String, +} + +#[derive(FromRow, Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceOAuthConfig { + pub client_id: String, + pub client_secret: String, + pub base_url: String, + pub access_token: Option +} + +#[derive(Debug, Serialize)] +pub struct OAuthConfigResponse { + pub configured: bool, + pub base_url: Option, + pub redirect_uri: Option, +} + +#[cfg(feature = "native_trigger")] +async fn generate_connect_url( + authed: ApiAuthed, + Extension(db): Extension, + Path((workspace_id, service_name)): Path<(String, ServiceName)>, + Json(RedirectUri { redirect_uri }): Json, +) -> JsonResult { + require_admin(authed.is_admin, &workspace_id)?; + + let oauth_config = + get_workspace_oauth_config_as_oauth_config(&db, &workspace_id, service_name).await?; + + // Generate a signed state that is cluster-safe + let state = generate_signed_state(&db, &workspace_id, service_name).await?; + let auth_url = build_authorization_url(&oauth_config, &state, &redirect_uri); + Ok(Json(auth_url)) +} + +#[cfg(feature = "native_trigger")] +async fn delete_integration( + authed: ApiAuthed, + Extension(user_db): Extension, + Path((workspace_id, service_name)): Path<(String, ServiceName)>, +) -> JsonResult { + require_admin(authed.is_admin, &workspace_id)?; + + let mut tx = user_db.begin(&authed).await?; + + let deleted = delete_workspace_integration(&mut *tx, &workspace_id, service_name).await?; + + if !deleted { + return Err(Error::NotFound(format!( + "{} integration not found for workspace", + service_name + ))); + } + + audit_log( + &mut *tx, + &authed, + &format!("workspace_integrations.{}.disconnect", service_name), + ActionKind::Delete, + &workspace_id, + Some(&format!("Disconnected {} integration", service_name)), + None, + ) + .await?; + + tx.commit().await?; + + Ok(Json(format!( + "{} integration disconnected successfully", + service_name + ))) +} + +#[derive(FromRow, Debug, Deserialize, Serialize)] +struct WorkspaceIntegrations { + service_name: ServiceName, + oauth_data: Option>, +} + +#[cfg(feature = "native_trigger")] +async fn list_integrations( + authed: ApiAuthed, + Extension(db): Extension, + Extension(_user_db): Extension, + Path(workspace_id): Path, +) -> JsonResult> { + require_admin(authed.is_admin, &workspace_id)?; + let mut tx = db.begin().await?; + let integrations = sqlx::query_as!( + WorkspaceIntegrations, + r#" + SELECT + oauth_data as "oauth_data!: sqlx::types::Json", + service_name as "service_name!: ServiceName" + FROM + workspace_integrations + WHERE + workspace_id = $1 + "#, + workspace_id + ) + .fetch_all(&mut *tx) + .await?; + + let key_value = integrations + .into_iter() + .map(|integration| (integration.service_name, integration.oauth_data)) + .collect::>(); + + let integrations = ServiceName::iter() + .map(|service_name| WorkspaceIntegrations { + service_name: service_name, + oauth_data: key_value.get(&service_name).cloned().flatten(), + }) + .collect::>(); + + tx.commit().await?; + + Ok(Json(integrations)) +} + +async fn integration_exist( + authed: ApiAuthed, + Extension(user_db): Extension, + Path((workspace_id, service_name)): Path<(String, ServiceName)>, +) -> JsonResult { + let mut tx = user_db.begin(&authed).await?; + let exists = sqlx::query_scalar!( + r#" + SELECT EXISTS ( + SELECT 1 + FROM workspace_integrations + WHERE workspace_id = $1 + AND service_name = $2 + AND oauth_data IS NOT NULL + ) + "#, + workspace_id, + service_name as ServiceName + ) + .fetch_one(&mut *tx) + .await? + .unwrap_or(false); + + Ok(Json(exists)) +} + +#[derive(Debug, Deserialize)] +struct RedirectUri { + redirect_uri: String, +} + +#[cfg(feature = "native_trigger")] +async fn oauth_callback( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((workspace_id, service_name, code, state)): Path<(String, ServiceName, String, String)>, + Json(RedirectUri { redirect_uri }): Json, +) -> JsonResult { + require_admin(authed.is_admin, &workspace_id)?; + + // Validate the signed state (cluster-safe, no DB storage needed) + let state_was_valid = validate_signed_state(&db, &state, &workspace_id).await?; + + if !state_was_valid { + return Err(Error::BadRequest( + "Invalid or expired state parameter".to_string(), + )); + } + + let oauth_config = + get_workspace_oauth_config::(&db, &workspace_id, service_name) + .await?; + + let token_response = + exchange_code_for_token(&oauth_config, service_name, &code, &redirect_uri).await?; + + let mut tx = user_db.begin(&authed).await?; + + let mc = build_crypt(&db, &workspace_id).await?; + let mut oauth_data = serde_json::to_value(oauth_config).unwrap(); + + let encrypted_access_token = encrypt(&mc, &token_response.access_token); + oauth_data["access_token"] = serde_json::Value::String(encrypted_access_token); + + if let Some(refresh_token) = token_response.refresh_token { + let encrypted_refresh_token = encrypt(&mc, &refresh_token); + oauth_data["refresh_token"] = serde_json::Value::String(encrypted_refresh_token); + } + if let Some(expires_in) = token_response.expires_in { + let expires_at = chrono::Utc::now() + chrono::Duration::seconds(expires_in as i64); + oauth_data["token_expires_at"] = serde_json::Value::String(expires_at.to_rfc3339()); + } + + store_workspace_integration(&mut *tx, &authed, &workspace_id, service_name, oauth_data).await?; + + audit_log( + &mut *tx, + &authed, + &format!("workspace_integrations.{}.connect", service_name), + ActionKind::Create, + &workspace_id, + Some(&format!("Connected {} integration via OAuth", service_name)), + None, + ) + .await?; + + tx.commit().await?; + + Ok(Json(format!( + "{} integration connected successfully via OAuth", + service_name + ))) +} + +/// Token response from OAuth token exchange +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, + refresh_token: Option, + #[serde(default)] + expires_in: Option, +} + +/// Build an OAuth client for native trigger services using windmill-oauth. +#[cfg(feature = "native_trigger")] +fn build_native_oauth_client( + config: &WorkspaceOAuthConfig, + service_name: ServiceName, + redirect_uri: &str, +) -> Result { + let auth_url = Url::parse(&format!("{}{}", config.base_url, service_name.auth_endpoint())) + .map_err(|e| Error::InternalErr(format!("Invalid auth URL: {}", e)))?; + let token_url = Url::parse(&format!("{}{}", config.base_url, service_name.token_endpoint())) + .map_err(|e| Error::InternalErr(format!("Invalid token URL: {}", e)))?; + let redirect = Url::parse(redirect_uri).map_err(|e| { + Error::BadRequest(format!( + "Invalid redirect URI '{}': {}. The redirect URI must be an absolute URL (e.g., https://example.com/callback)", + redirect_uri, e + )) + })?; + + let mut client = OClient::new(config.client_id.clone(), auth_url, token_url); + client.set_client_secret(config.client_secret.clone()); + client.set_redirect_url(redirect); + + Ok(client) +} + +/// Exchange authorization code for tokens using windmill-oauth. +#[cfg(feature = "native_trigger")] +async fn exchange_code_for_token( + config: &WorkspaceOAuthConfig, + service_name: ServiceName, + code: &str, + redirect_uri: &str, +) -> Result { + let client = build_native_oauth_client(config, service_name, redirect_uri)?; + + let token_response: TokenResponse = client + .exchange_code(code.to_string()) + .with_client(&*OAUTH_HTTP_CLIENT) + .execute() + .await + .map_err(|e| Error::InternalErr(format!("Failed to exchange code for token: {:?}", e)))?; + + Ok(token_response) +} + +async fn get_workspace_oauth_config( + db: &DB, + workspace_id: &str, + service_name: ServiceName, +) -> Result { + let oauth_configs = sqlx::query_scalar!( + r#" + SELECT + oauth_data + FROM + workspace_integrations + WHERE + workspace_id = $1 AND + service_name = $2 + "#, + workspace_id, + service_name as ServiceName + ) + .fetch_optional(db) + .await? + .ok_or(Error::NotFound(format!( + "Integration for service {} not found", + service_name.as_str() + )))?; + + let config = serde_json::from_value::(oauth_configs) + .map_err(|e| Error::InternalErr(format!("Failed to parse OAuth config: {}", e)))?; + + Ok(config) +} + +#[cfg(feature = "native_trigger")] +pub async fn create_workspace_integration( + authed: ApiAuthed, + Extension(user_db): Extension, + Path((workspace_id, service_name)): Path<(String, ServiceName)>, + Json(oauth_data): Json, +) -> Result<()> { + require_admin(authed.is_admin, &workspace_id)?; + + let mut tx = user_db.begin(&authed).await?; + + store_workspace_integration( + &mut tx, + &authed, + &workspace_id, + service_name, + to_value(oauth_data).unwrap(), + ) + .await?; + + tx.commit().await?; + + Ok(()) +} + +#[inline] +async fn get_workspace_oauth_config_as_oauth_config( + db: &DB, + workspace_id: &str, + service_name: ServiceName, +) -> Result { + get_workspace_oauth_config::(db, workspace_id, service_name).await +} + +fn build_authorization_url( + config: &WorkspaceOAuthConfig, + state: &str, + redirect_uri: &str, +) -> String { + let params = [ + ("response_type", "code"), + ("client_id", &config.client_id), + ("redirect_uri", redirect_uri), + ("state", state), + ("scope", "read write"), + ]; + + let query_string = params + .iter() + .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v))) + .collect::>() + .join("&"); + + format!("{}/apps/oauth2/authorize?{}", config.base_url, query_string) +} + +pub fn workspaced_service() -> Router { + let router = Router::new() + .route("/list", get(list_integrations)) + .route("/:service_name/exists", get(integration_exist)) + .route("/:service_name/create", post(create_workspace_integration)) + .route( + "/:service_name/generate_connect_url", + post(generate_connect_url), + ) + .route("/:service_name/delete", delete(delete_integration)) + .route("/:service_name/callback/:code/:state", post(oauth_callback)); + + Router::new().nest("/integrations", router) +} diff --git a/backend/windmill-api/src/scopes.rs b/backend/windmill-api/src/scopes.rs index 7984e4b627..1c1bdd4f26 100644 --- a/backend/windmill-api/src/scopes.rs +++ b/backend/windmill-api/src/scopes.rs @@ -257,6 +257,9 @@ pub enum ScopeDomain { PostgresTriggers, EmailTriggers, + // Native trigger domains + NativeTriggers, + // System domains Audit, Settings, @@ -310,6 +313,7 @@ impl ScopeDomain { Self::GcpTriggers => "gcp_triggers", Self::PostgresTriggers => "postgres_triggers", Self::EmailTriggers => "email_triggers", + Self::NativeTriggers => "native_triggers", Self::Audit => "audit", Self::Settings => "settings", Self::Workers => "workers", @@ -366,6 +370,7 @@ impl ScopeDomain { "ai" => Some(Self::AI), "indexer" | "srch" => Some(Self::Indexer), "teams" => Some(Self::Teams), + "native_triggers" => Some(Self::NativeTriggers), "git_sync" | "github_app" => Some(Self::GitSync), "capture" => Some(Self::Capture), "drafts" => Some(Self::Drafts), diff --git a/backend/windmill-api/src/token.rs b/backend/windmill-api/src/token.rs index f38ce642e6..25978eb873 100644 --- a/backend/windmill-api/src/token.rs +++ b/backend/windmill-api/src/token.rs @@ -124,6 +124,12 @@ fn build_standard_scope_domains() -> Vec { "Git synchronization management", false, ), + ( + "native_triggers", + "Native Triggers", + "Native triggers management", + true, + ), ]; STANDARD_DOMAINS diff --git a/backend/windmill-api/src/triggers/handler.rs b/backend/windmill-api/src/triggers/handler.rs index 6e970fe347..85df62f82e 100644 --- a/backend/windmill-api/src/triggers/handler.rs +++ b/backend/windmill-api/src/triggers/handler.rs @@ -811,6 +811,7 @@ pub struct TriggersCount { mqtt_count: i64, sqs_count: i64, gcp_count: i64, + nextcloud_count: i64, } pub async fn get_triggers_count_internal( @@ -970,6 +971,16 @@ pub async fn get_triggers_count_internal( .await? .unwrap_or(0); + let nextcloud_count = sqlx::query_scalar!( + "SELECT COUNT(*) FROM native_trigger WHERE workspace_id = $1 AND script_path = $2 AND is_flow = $3 AND service_name = 'nextcloud'", + w_id, + path, + is_flow, + ) + .fetch_one(db) + .await? + .unwrap_or(0); + Ok(Json(TriggersCount { primary_schedule: primary_schedule.map(|s| TriggerPrimarySchedule { schedule: s }), schedule_count, @@ -984,5 +995,6 @@ pub async fn get_triggers_count_internal( mqtt_count, gcp_count, sqs_count, + nextcloud_count, })) } diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index 405a92de0b..9300dc623c 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -9,7 +9,7 @@ #![allow(non_snake_case)] use quick_cache::sync::Cache; -use sqlx::{Postgres, Transaction}; +use sqlx::{PgConnection, Postgres, Transaction}; use std::sync::atomic::AtomicBool; use std::sync::Arc; @@ -389,6 +389,19 @@ pub struct NewToken { pub workspace_id: Option, } +#[cfg(feature = "native_trigger")] +impl NewToken { + pub fn new( + label: Option, + expiration: Option>, + impersonate_email: Option, + scopes: Option>, + workspace_id: Option, + ) -> NewToken { + NewToken { label, expiration, impersonate_email, scopes, workspace_id } + } +} + #[derive(Deserialize)] pub struct Login { pub email: String, @@ -2122,13 +2135,13 @@ pub async fn create_session_token<'c>( Ok(token) } -async fn create_token( - Extension(db): Extension, - authed: ApiAuthed, - Json(new_token): Json, -) -> Result<(StatusCode, String)> { +pub async fn create_token_internal( + tx: &mut PgConnection, + db: &DB, + authed: &ApiAuthed, + token_config: NewToken, +) -> Result { let token = rd_string(32); - let mut tx = db.begin().await?; let is_super_admin = sqlx::query_scalar!( "SELECT super_admin FROM password WHERE email = $1", @@ -2140,7 +2153,7 @@ async fn create_token( if *CLOUD_HOSTED { let nb_tokens = sqlx::query_scalar!("SELECT COUNT(*) FROM token WHERE email = $1", &authed.email) - .fetch_one(&db) + .fetch_one(db) .await?; if nb_tokens.unwrap_or(0) >= 10000 { return Err(Error::BadRequest( @@ -2155,18 +2168,18 @@ async fn create_token( VALUES ($1, $2, $3, $4, $5, $6, $7)", token, authed.email, - new_token.label, - new_token.expiration, + token_config.label, + token_config.expiration, is_super_admin, - new_token.scopes.as_ref().map(|x| x.as_slice()), - new_token.workspace_id, + token_config.scopes.as_ref().map(|x| x.as_slice()), + token_config.workspace_id, ) .execute(&mut *tx) .await?; audit_log( &mut *tx, - &authed, + authed, "users.token.create", ActionKind::Create, &"global", @@ -2175,6 +2188,19 @@ async fn create_token( ) .instrument(tracing::info_span!("token", email = &authed.email)) .await?; + + Ok(token) +} + +async fn create_token( + Extension(db): Extension, + authed: ApiAuthed, + Json(token_config): Json, +) -> Result<(StatusCode, String)> { + let mut tx = db.begin().await?; + + let token = create_token_internal(&mut *tx, &db, &authed, token_config).await?; + tx.commit().await?; Ok((StatusCode::CREATED, token)) } diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index 27d10aaf0e..0818ecdaa8 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -2568,6 +2568,7 @@ struct UsedTriggers { pub sqs_used: bool, pub gcp_used: bool, pub email_used: bool, + pub nextcloud_used: bool, } async fn get_used_triggers( @@ -2576,7 +2577,7 @@ async fn get_used_triggers( Path(w_id): Path, ) -> JsonResult { let mut tx = user_db.begin(&authed).await?; - let websocket_used = sqlx::query_as!( + let triggers_used = sqlx::query_as!( UsedTriggers, r#" SELECT @@ -2588,7 +2589,8 @@ async fn get_used_triggers( EXISTS(SELECT 1 FROM mqtt_trigger WHERE workspace_id = $1) AS "mqtt_used!", EXISTS(SELECT 1 FROM sqs_trigger WHERE workspace_id = $1) AS "sqs_used!", EXISTS(SELECT 1 FROM gcp_trigger WHERE workspace_id = $1) AS "gcp_used!", - EXISTS(SELECT 1 FROM email_trigger WHERE workspace_id = $1) AS "email_used!" + EXISTS(SELECT 1 FROM email_trigger WHERE workspace_id = $1) AS "email_used!", + EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = 'nextcloud'::native_trigger_service) AS "nextcloud_used!" "#, w_id ) @@ -2596,7 +2598,7 @@ async fn get_used_triggers( .await?; tx.commit().await?; - Ok(Json(websocket_used)) + Ok(Json(triggers_used)) } async fn get_workspace_as_superadmin( diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index 9a730d1d44..8f14d81ac1 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -802,6 +802,38 @@ pub(crate) async fn tarball_workspace( .await?; } } + + #[cfg(feature = "native_trigger")] + { + use crate::native_triggers::{list_native_triggers, ServiceName}; + use strum::IntoEnumIterator; + + for service_name in ServiceName::iter() { + let native_triggers = + list_native_triggers(&mut *tx, &w_id, service_name, None, None).await?; + + for trigger in native_triggers { + let trigger_str = &to_string_without_metadata( + &trigger, + false, + Some(vec!["webhook_token_prefix"]), + ) + .unwrap(); + archive + .write_to_archive( + &trigger_str, + &format!( + "{}.{}.{}.{}_native_trigger.json", + trigger.script_path, + if trigger.is_flow { "flow" } else { "script" }, + trigger.external_id, + service_name.as_str() + ), + ) + .await?; + } + } + } } if include_users.unwrap_or(false) { diff --git a/backend/windmill-common/src/jobs.rs b/backend/windmill-common/src/jobs.rs index 9eaa9b790d..b1497c3d3e 100644 --- a/backend/windmill-common/src/jobs.rs +++ b/backend/windmill-common/src/jobs.rs @@ -55,6 +55,7 @@ pub enum JobTriggerKind { Postgres, Schedule, Gcp, + Nextcloud, } impl std::fmt::Display for JobTriggerKind { @@ -71,6 +72,7 @@ impl std::fmt::Display for JobTriggerKind { JobTriggerKind::Postgres => "postgres", JobTriggerKind::Schedule => "schedule", JobTriggerKind::Gcp => "gcp", + JobTriggerKind::Nextcloud => "nextcloud", }; write!(f, "{}", kind) } diff --git a/backend/windmill-common/src/triggers.rs b/backend/windmill-common/src/triggers.rs index 51c5ea41a2..6a5d2e4f45 100644 --- a/backend/windmill-common/src/triggers.rs +++ b/backend/windmill-common/src/triggers.rs @@ -21,6 +21,7 @@ pub enum TriggerKind { Sqs, Postgres, Gcp, + Nextcloud, } impl TriggerKind { @@ -37,6 +38,7 @@ impl TriggerKind { TriggerKind::Sqs => "sqs".to_string(), TriggerKind::Postgres => "postgres".to_string(), TriggerKind::Gcp => "gcp".to_string(), + TriggerKind::Nextcloud => "nextcloud".to_string(), } } } @@ -55,6 +57,7 @@ impl fmt::Display for TriggerKind { TriggerKind::Sqs => "sqs", TriggerKind::Postgres => "postgres", TriggerKind::Gcp => "gcp", + TriggerKind::Nextcloud => "nextcloud", }; write!(f, "{}", s) } diff --git a/backend/windmill-common/src/utils.rs b/backend/windmill-common/src/utils.rs index 6099a2ddf7..1ac3f6c1bb 100644 --- a/backend/windmill-common/src/utils.rs +++ b/backend/windmill-common/src/utils.rs @@ -925,7 +925,8 @@ impl Future for WarnAfterFuture { } } -#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(sqlx::Type, Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)] +#[sqlx(type_name = "runnable_kind", rename_all = "lowercase")] #[serde(rename_all = "lowercase")] pub enum RunnableKind { Script, diff --git a/backend/windmill-common/src/variables.rs b/backend/windmill-common/src/variables.rs index 1edd292cbe..8129f39b3f 100644 --- a/backend/windmill-common/src/variables.rs +++ b/backend/windmill-common/src/variables.rs @@ -93,9 +93,9 @@ pub async fn build_crypt(db: &DB, w_id: &str) -> crate::error::Result( } } +type NativeTriggerFile = Omit< + NativeTrigger, + "external_id" | "workspace_id" | "error" +>; + +export async function pushNativeTrigger( + workspace: string, + filePath: string, + _remoteTrigger: NativeTrigger | undefined, + localTrigger: NativeTriggerFile +): Promise { + const triggerInfo = extractNativeTriggerInfo(filePath); + if (!triggerInfo) { + throw new Error( + `Invalid native trigger file path: ${filePath}. Expected format: {script_path}.{flow|script}.{external_id}.{service}_native_trigger.json` + ); + } + + const { externalId, serviceName } = triggerInfo; + log.debug( + `Processing local native trigger: service=${serviceName}, external_id=${externalId}` + ); + + let remoteTrigger: NativeTrigger | undefined; + try { + const result = await wmill.getNativeTrigger({ + workspace, + serviceName: serviceName as NativeServiceName, + externalId, + }); + // getNativeTrigger returns NativeTriggerWithExternal, extract NativeTrigger fields + remoteTrigger = { + external_id: result.external_id, + workspace_id: result.workspace_id, + service_name: result.service_name, + script_path: result.script_path, + is_flow: result.is_flow, + service_config: result.service_config, + error: result.error, + }; + log.debug(`Native trigger ${serviceName}/${externalId} exists on remote`); + } catch { + log.debug( + `Native trigger ${serviceName}/${externalId} does not exist on remote` + ); + } + + const triggerData: NativeTriggerData = { + script_path: localTrigger.script_path, + is_flow: localTrigger.is_flow, + service_config: localTrigger.service_config, + }; + + if (remoteTrigger) { + // Compare relevant fields + const localCompare = { + script_path: localTrigger.script_path, + is_flow: localTrigger.is_flow, + service_config: localTrigger.service_config, + }; + const remoteCompare = { + script_path: remoteTrigger.script_path, + is_flow: remoteTrigger.is_flow, + service_config: remoteTrigger.service_config, + }; + + if (isSuperset(localCompare, remoteCompare)) { + log.debug(`Native trigger ${serviceName}/${externalId} is up to date`); + return; + } + + log.debug( + `Native trigger ${serviceName}/${externalId} is not up-to-date, updating...` + ); + try { + await wmill.updateNativeTrigger({ + workspace, + serviceName: serviceName as NativeServiceName, + externalId, + requestBody: triggerData, + }); + } catch (e) { + console.error((e as any).body); + throw e; + } + } else { + console.log( + colors.bold.yellow( + `Creating new native trigger: ${serviceName}/${externalId}` + ) + ); + try { + await wmill.createNativeTrigger({ + workspace, + serviceName: serviceName as NativeServiceName, + requestBody: triggerData, + }); + } catch (e) { + console.error((e as any).body); + throw e; + } + } +} + async function list(opts: GlobalOptions) { const workspace = await resolveWorkspace(opts); await requireLogin(opts); diff --git a/cli/src/types.ts b/cli/src/types.ts index 504a0986f4..22cc1a2b33 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -23,7 +23,7 @@ import { pushWorkspaceUser } from "./commands/user/user.ts"; import { pushGroup } from "./commands/user/user.ts"; import { pushWorkspaceDependencies } from "./commands/dependencies/dependencies.ts"; import { pushWorkspaceSettings, pushWorkspaceKey } from "./core/settings.ts"; -import { pushTrigger } from "./commands/trigger/trigger.ts"; +import { pushTrigger, pushNativeTrigger } from "./commands/trigger/trigger.ts"; import { pushRawApp } from "./commands/app/raw_apps.ts"; import { isFlowPath, @@ -66,6 +66,9 @@ export const TRIGGER_TYPES = [ "email", ] as const; +export const NATIVE_TRIGGER_SERVICES = ["nextcloud"] as const; +export type NativeTriggerService = (typeof NATIVE_TRIGGER_SERVICES)[number]; + export type GlobalOptions = { baseUrl: string | undefined; workspace: string | undefined; @@ -195,6 +198,8 @@ export async function pushObj( await pushTrigger("gcp", workspace, p, befObj, newObj); } else if (typeEnding === "email_trigger") { await pushTrigger("email", workspace, p, befObj, newObj); + } else if (typeEnding === "native_trigger") { + await pushNativeTrigger(workspace, p, befObj, newObj); } else if (typeEnding === "user") { await pushWorkspaceUser(workspace, p, befObj, newObj); } else if (typeEnding === "group") { @@ -251,6 +256,7 @@ export function getTypeStrFromPath( | "sqs_trigger" | "gcp_trigger" | "email_trigger" + | "native_trigger" | "user" | "group" | "settings" @@ -303,6 +309,10 @@ export function getTypeStrFromPath( } const typeEnding = parsed.name.split(".").at(-1); + // Check for native trigger: {service}_native_trigger pattern + if (typeEnding?.endsWith("_native_trigger")) { + return "native_trigger"; + } if ( typeEnding === "script" || typeEnding === "variable" || @@ -346,6 +356,35 @@ export function removeType(str: string, type: string) { return normalizedStr.slice(0, normalizedStr.length - type.length - 6); } +/** + * Extracts native trigger info from a path like: + * u/admin/script.flow.12345.nextcloud_native_trigger.json + * Returns { scriptPath: "u/admin/script", isFlow: true, externalId: "12345", serviceName: "nextcloud" } + */ +export function extractNativeTriggerInfo(p: string): { + scriptPath: string; + isFlow: boolean; + externalId: string; + serviceName: string; +} | null { + // Remove extension (.json or .yaml) + const normalizedPath = path.normalize(p).replaceAll(SEP, "/"); + const withoutExt = normalizedPath.replace(/\.(json|yaml)$/, ""); + + // Match pattern: {script_path}.{flow|script}.{external_id}.{service}_native_trigger + const match = withoutExt.match(/^(.+)\.(flow|script)\.([^.]+)\.(\w+)_native_trigger$/); + if (!match) { + return null; + } + + return { + scriptPath: match[1], + isFlow: match[2] === "flow", + externalId: match[3], + serviceName: match[4], + }; +} + export function removePathPrefix(str: string, prefix: string) { // Normalize paths for cross-platform compatibility and convert to forward slashes for API consistency const normalizedStr = path.normalize(str).replaceAll(SEP, "/"); diff --git a/frontend/src/lib/components/NextcloudSetting.svelte b/frontend/src/lib/components/NextcloudSetting.svelte index 6118b12294..b8054b6f61 100644 --- a/frontend/src/lib/components/NextcloudSetting.svelte +++ b/frontend/src/lib/components/NextcloudSetting.svelte @@ -90,7 +90,7 @@ >Client Secret from your Nextcloud OAuth2 app configuration diff --git a/frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte b/frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte index d064cedf77..1aa74f9914 100644 --- a/frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte +++ b/frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte @@ -10,8 +10,10 @@ import { Menu, Menubar, MeltButton, MenuItem, Tooltip } from '$lib/components/meltComponents' import { twMerge } from 'tailwind-merge' import SchedulePollIcon from '$lib/components/icons/SchedulePollIcon.svelte' + import type { NativeServiceName } from '$lib/gen/types.gen' import TriggerLabel from '$lib/components/triggers/TriggerLabel.svelte' import CountBadge from '$lib/components/common/badge/CountBadge.svelte' + import NextcloudIcon from '$lib/components/icons/NextcloudIcon.svelte' const { triggersState, triggersCount } = getContext('TriggerContext') @@ -42,31 +44,50 @@ }: Props = $props() let menuOpen = $state(false) + let availableNativeServices = $state< + Array<{ service: NativeServiceName; icon: any; config: any }> + >([]) - const triggerTypeConfig: { - [key in TriggerType]: { - icon: ComponentType | Component - countKey?: string - disabled?: boolean + let triggerTypeConfig = $derived(() => { + const baseConfig: { + [key in TriggerType]?: { + icon: ComponentType | Component + countKey?: string + disabled?: boolean + } + } = { + webhook: { icon: Webhook, countKey: 'webhook_count' }, + schedule: { icon: Calendar, countKey: 'schedule_count' }, + http: { icon: Route, countKey: 'http_routes_count' }, + websocket: { icon: Unplug, countKey: 'websocket_count' }, + postgres: { icon: Database, countKey: 'postgres_count' }, + kafka: { icon: KafkaIcon, countKey: 'kafka_count', disabled: !$enterpriseLicense }, + default_email: { icon: Mail, countKey: 'default_email_count' }, + email: { icon: Mail, countKey: 'email_count' }, + nats: { icon: NatsIcon, countKey: 'nats_count', disabled: !$enterpriseLicense }, + mqtt: { icon: MqttIcon, countKey: 'mqtt_count', disabled: !$enterpriseLicense }, + sqs: { icon: AwsIcon, countKey: 'sqs_count', disabled: !$enterpriseLicense }, + gcp: { icon: GoogleCloudIcon, countKey: 'gcp_count', disabled: !$enterpriseLicense }, + poll: { icon: SchedulePollIcon }, + cli: { icon: Terminal }, + nextcloud: { icon: NextcloudIcon, countKey: 'nextcloud_count' } } - } = { - webhook: { icon: Webhook, countKey: 'webhook_count' }, - schedule: { icon: Calendar, countKey: 'schedule_count' }, - http: { icon: Route, countKey: 'http_routes_count' }, - websocket: { icon: Unplug, countKey: 'websocket_count' }, - postgres: { icon: Database, countKey: 'postgres_count' }, - kafka: { icon: KafkaIcon, countKey: 'kafka_count', disabled: !$enterpriseLicense }, - default_email: { icon: Mail, countKey: 'default_email_count' }, - email: { icon: Mail, countKey: 'email_count' }, - nats: { icon: NatsIcon, countKey: 'nats_count', disabled: !$enterpriseLicense }, - mqtt: { icon: MqttIcon, countKey: 'mqtt_count', disabled: !$enterpriseLicense }, - sqs: { icon: AwsIcon, countKey: 'sqs_count', disabled: !$enterpriseLicense }, - gcp: { icon: GoogleCloudIcon, countKey: 'gcp_count', disabled: !$enterpriseLicense }, - poll: { icon: SchedulePollIcon }, - cli: { icon: Terminal } - } - const allTypes = [ + // Add native trigger services that are available + for (const { service, icon } of availableNativeServices) { + baseConfig[service as TriggerType] = { icon } + } + + return baseConfig as { + [key in TriggerType]: { + icon: ComponentType | Component + countKey?: string + disabled?: boolean + } + } + }) + + let allTypes = $derived([ 'webhook', 'schedule', 'http', @@ -80,8 +101,9 @@ 'gcp', 'email', 'poll', - 'cli' - ] + 'cli', + 'nextcloud' + ]) function camelCaseToWords(s: string) { const result = s.replace(/([A-Z])/g, ' $1') @@ -124,10 +146,10 @@ return types.filter( (type) => (!showOnlyTriggersWithCount || - ((triggerTypeConfig[type].countKey && - ($triggersCount?.[triggerTypeConfig[type].countKey] ?? 0)) || + ((triggerTypeConfig()[type]?.countKey && + ($triggersCount?.[triggerTypeConfig()[type].countKey] ?? 0)) || 0) > 0) && - !triggerTypeConfig[type].disabled + !triggerTypeConfig()[type]?.disabled ) }) let triggersToDisplay = $derived(limit ? allTriggerTypes.slice(0, limit) : allTriggerTypes) @@ -240,7 +262,10 @@ {#snippet triggerButton({ type, isSelected, meltElement = undefined, singleItem = false })} - {@const { icon: SvelteComponent, countKey } = triggerTypeConfig[type]} + {@const { icon: SvelteComponent, countKey } = triggerTypeConfig()[type] || { + icon: Database, + countKey: undefined + }}
diff --git a/frontend/src/lib/components/icons/NextcloudIcon.svelte b/frontend/src/lib/components/icons/NextcloudIcon.svelte index b46775dbc7..749f855bf3 100644 --- a/frontend/src/lib/components/icons/NextcloudIcon.svelte +++ b/frontend/src/lib/components/icons/NextcloudIcon.svelte @@ -3,9 +3,17 @@ size?: number height?: number width?: number + color?: string | undefined + class?: string } - let { size = undefined, height: heightProp = 24, width: widthProp = 24 }: Props = $props() + let { + size = undefined, + height: heightProp = 24, + width: widthProp = 24, + color = undefined, + class: clazz = '' + }: Props = $props() const { width, height } = $derived( size ? { width: size, height: size } : { width: widthProp, height: heightProp } @@ -18,7 +26,8 @@ {width} {height} xmlns="http://www.w3.org/2000/svg" - style="fill:currentcolor;" + class={clazz} + fill={color ?? 'currentColor'} >
- {#if newToken} + {#if newToken && displayCreateToken} {/if} - {#if newMcpToken} + {#if newMcpToken && displayCreateToken} {/if} diff --git a/frontend/src/lib/components/sidebar/SidebarContent.svelte b/frontend/src/lib/components/sidebar/SidebarContent.svelte index 98ca5a21bc..7c985135ba 100644 --- a/frontend/src/lib/components/sidebar/SidebarContent.svelte +++ b/frontend/src/lib/components/sidebar/SidebarContent.svelte @@ -59,6 +59,12 @@ import NatsIcon from '../icons/NatsIcon.svelte' import MqttIcon from '../icons/MqttIcon.svelte' import AwsIcon from '../icons/AwsIcon.svelte' + import { + getAvailableNativeTriggerServices, + getServiceConfig, + getServiceIcon + } from '../triggers/native/utils' + import type { NativeServiceName } from '$lib/gen/types.gen' import { Menubar, Menu, @@ -86,6 +92,29 @@ let hasNewChangelogs = $state(false) let recentChangelogs: Changelog[] = $state([]) let lastOpened = localStorage.getItem('changelogsLastOpened') + let availableNativeServices = $state< + Array<{ service: NativeServiceName; icon: any; config: any }> + >([]) + + async function loadAvailableNativeTriggers() { + try { + const services = await getAvailableNativeTriggerServices($workspaceStore!) + console.log({ services }) + const serviceData = await Promise.all( + services.map(async (service) => ({ + service, + icon: await getServiceIcon(service), + config: getServiceConfig(service) + })) + ) + availableNativeServices = serviceData + } catch (err) { + console.error('Failed to load available native trigger services:', err) + availableNativeServices = [] + } + } + + loadAvailableNativeTriggers() onMount(async () => { if (lastOpened) { @@ -317,6 +346,21 @@ aiDescription: 'Button to navigate to Email triggers' } ]) + + let nativeTriggerLinks = $derived( + availableNativeServices.map(({ service, icon, config }) => ({ + label: config?.serviceDisplayName || service, + href: `/native_triggers/${service}`, + icon: icon, + disabled: $userStore?.operator, + kind: service, + aiId: `sidebar-menu-link-${service}`, + aiDescription: `Button to navigate to ${config?.serviceDisplayName || service} triggers` + })) + ) + + let allTriggerLinks = $derived([...defaultExtraTriggerLinks, ...nativeTriggerLinks]) + let triggerMenuLinks = $derived([ { label: 'Schedules', @@ -326,12 +370,12 @@ aiId: 'sidebar-menu-link-schedules', aiDescription: 'Button to navigate to schedules' }, - ...defaultExtraTriggerLinks.filter( + ...allTriggerLinks.filter( (link) => $usedTriggerKinds.includes(link.kind) || $page.url.pathname.includes(link.href) ) ]) let extraTriggerLinks = $derived( - defaultExtraTriggerLinks.filter((link) => { + allTriggerLinks.filter((link) => { return !$page.url.pathname.includes(link.href) && !$usedTriggerKinds.includes(link.kind) }) ) diff --git a/frontend/src/lib/components/triggers.ts b/frontend/src/lib/components/triggers.ts index eee4f37e8b..7483b0c76b 100644 --- a/frontend/src/lib/components/triggers.ts +++ b/frontend/src/lib/components/triggers.ts @@ -56,6 +56,7 @@ export type TriggerKind = | 'mqtt' | 'sqs' | 'gcp' + | 'nextcloud' export function captureTriggerKindToTriggerKind(kind: CaptureTriggerKind): TriggerKind { switch (kind) { case 'webhook': diff --git a/frontend/src/lib/components/triggers/AddTriggersButton.svelte b/frontend/src/lib/components/triggers/AddTriggersButton.svelte index d6ba346c08..ee4c106324 100644 --- a/frontend/src/lib/components/triggers/AddTriggersButton.svelte +++ b/frontend/src/lib/components/triggers/AddTriggersButton.svelte @@ -5,7 +5,8 @@ import type { Placement } from '@floating-ui/core' import { isCloudHosted } from '$lib/cloud' import { CloudOff } from 'lucide-svelte' - import type { Item } from '$lib/utils' + import { isServiceAvailable } from './native/utils' + import { workspaceStore } from '$lib/stores' interface Props { setDropdownWidthToButtonWidth?: boolean @@ -33,72 +34,87 @@ let dropdown: DropdownV2 | undefined const cloudHosted = isCloudHosted() + let nextcloudAvailable = $state(false) - // Dropdown items for adding new triggers - const addTriggerItems: Item[] = [ - { - displayName: 'Schedule', - action: () => onAddDraftTrigger?.('schedule'), - icon: triggerIconMap.schedule - }, - { displayName: 'HTTP', action: () => onAddDraftTrigger?.('http'), icon: triggerIconMap.http }, - { - displayName: 'WebSocket', - action: () => onAddDraftTrigger?.('websocket'), - icon: triggerIconMap.websocket, - extra: cloudHosted ? extra : undefined - }, - { - displayName: 'Postgres', - action: () => onAddDraftTrigger?.('postgres'), - icon: triggerIconMap.postgres, - extra: cloudHosted ? extra : undefined - }, - { - displayName: 'Kafka', - action: () => onAddDraftTrigger?.('kafka'), - icon: triggerIconMap.kafka, - extra: cloudHosted ? extra : undefined - }, - { - displayName: 'NATS', - action: () => onAddDraftTrigger?.('nats'), - icon: triggerIconMap.nats, - extra: cloudHosted ? extra : undefined - }, - { - displayName: 'MQTT', - action: () => onAddDraftTrigger?.('mqtt'), - icon: triggerIconMap.mqtt, - extra: cloudHosted ? extra : undefined - }, - { - displayName: 'SQS', - action: () => onAddDraftTrigger?.('sqs'), - icon: triggerIconMap.sqs, - extra: cloudHosted ? extra : undefined - }, - { - displayName: 'GCP Pub/Sub', - action: () => onAddDraftTrigger?.('gcp'), - icon: triggerIconMap.gcp, - extra: cloudHosted ? extra : undefined - }, - { - displayName: 'Email', - action: () => onAddDraftTrigger?.('email'), - icon: triggerIconMap.email - }, - { - displayName: 'Scheduled Poll', - action: (e) => { - onAddDraftTrigger?.('poll') - onAddScheduledPoll?.() + async function setNextcloudState() { + nextcloudAvailable = await isServiceAvailable('nextcloud', $workspaceStore!) + } + + setNextcloudState() + + const addTriggerItems = $derived( + [ + { + displayName: 'Schedule', + action: () => onAddDraftTrigger?.('schedule'), + icon: triggerIconMap.schedule }, - icon: SchedulePollIcon, - hidden: !isEditor - } - ].filter((item) => !item.hidden) + { displayName: 'HTTP', action: () => onAddDraftTrigger?.('http'), icon: triggerIconMap.http }, + { + displayName: 'WebSocket', + action: () => onAddDraftTrigger?.('websocket'), + icon: triggerIconMap.websocket, + extra: cloudHosted ? extra : undefined + }, + { + displayName: 'Postgres', + action: () => onAddDraftTrigger?.('postgres'), + icon: triggerIconMap.postgres, + extra: cloudHosted ? extra : undefined + }, + { + displayName: 'Kafka', + action: () => onAddDraftTrigger?.('kafka'), + icon: triggerIconMap.kafka, + extra: cloudHosted ? extra : undefined + }, + { + displayName: 'NATS', + action: () => onAddDraftTrigger?.('nats'), + icon: triggerIconMap.nats, + extra: cloudHosted ? extra : undefined + }, + { + displayName: 'MQTT', + action: () => onAddDraftTrigger?.('mqtt'), + icon: triggerIconMap.mqtt, + extra: cloudHosted ? extra : undefined + }, + { + displayName: 'SQS', + action: () => onAddDraftTrigger?.('sqs'), + icon: triggerIconMap.sqs, + extra: cloudHosted ? extra : undefined + }, + { + displayName: 'GCP Pub/Sub', + action: () => onAddDraftTrigger?.('gcp'), + icon: triggerIconMap.gcp, + extra: cloudHosted ? extra : undefined + }, + { + displayName: 'Email', + action: () => onAddDraftTrigger?.('email'), + icon: triggerIconMap.email, + extra: cloudHosted ? extra : undefined + }, + { + displayName: 'Scheduled Poll', + action: (e) => { + onAddDraftTrigger?.('poll') + onAddScheduledPoll?.() + }, + icon: SchedulePollIcon, + hidden: !isEditor + }, + { + displayName: 'Nextcloud', + action: () => onAddDraftTrigger?.('nextcloud'), + icon: triggerIconMap.nextcloud, + hidden: !nextcloudAvailable + } + ].filter((item) => !item.hidden) + ) let triggersButtonWidth = $state(0) diff --git a/frontend/src/lib/components/triggers/TriggersEditor.svelte b/frontend/src/lib/components/triggers/TriggersEditor.svelte index 082dc94baf..0897725f06 100644 --- a/frontend/src/lib/components/triggers/TriggersEditor.svelte +++ b/frontend/src/lib/components/triggers/TriggersEditor.svelte @@ -241,6 +241,14 @@ isFlow, $userStore ) + } else if (triggerType === 'nextcloud') { + await triggersState.fetchNativeTriggers( + 'nextcloud', + $workspaceStore, + currentPath, + isFlow, + $userStore + ) } triggersState.selectedTriggerIndex = triggersState.triggers.findIndex( @@ -256,6 +264,7 @@ newConfig: Record, saveDisabled: boolean ) { + console.log('handleUpdateDraftConfig', triggerIndex, newConfig, saveDisabled) if (triggerIndex && triggerIndex !== -1 && newConfig) { triggersState.setDraftConfig(triggerIndex, { ...newConfig, canSave: !saveDisabled }) } diff --git a/frontend/src/lib/components/triggers/TriggersWrapper.svelte b/frontend/src/lib/components/triggers/TriggersWrapper.svelte index 973931ceb2..213d8361fe 100644 --- a/frontend/src/lib/components/triggers/TriggersWrapper.svelte +++ b/frontend/src/lib/components/triggers/TriggersWrapper.svelte @@ -16,6 +16,7 @@ import ClipboardPanel from '../details/ClipboardPanel.svelte' import CliHelpBox from '../CliHelpBox.svelte' import TriggerLabel from './TriggerLabel.svelte' + import NativeTriggersPanel from './native/NativeTriggersPanel.svelte' interface Props { selectedTrigger: Trigger @@ -52,6 +53,10 @@ onEmailDomain, ...props }: Props = $props() + + $effect(() => { + console.log('selectedTrigger', selectedTrigger) + }) {#if selectedTrigger.type === 'http'} @@ -167,6 +172,16 @@ /> {:else if selectedTrigger.type === 'poll'} +{:else if selectedTrigger.type === 'nextcloud'} + {:else if selectedTrigger.type === 'cli'}
diff --git a/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte b/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte new file mode 100644 index 0000000000..8401e3da2c --- /dev/null +++ b/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte @@ -0,0 +1,459 @@ + + +{#if useDrawer} + + + {#snippet actions()} + {@render drawerActions()} + {/snippet} + {@render content()} + + +{:else} +
+ {#snippet header()} + {#if customLabel} + {@render customLabel()} + {/if} + {/snippet} + {#snippet action()} + {@render inlineActions()} + {/snippet} + {@render content()} +
+{/if} + +{#snippet drawerActions()} + +{/snippet} + +{#snippet inlineActions()} + {#if !loadingConfig} + {}} + disableSuspendedMode={true} + /> + {/if} +{/snippet} + +{#snippet content()} + {#if loadingConfig && showLoading} + + {:else} +
+ {#if description} + {@render description()} + {/if} +
+
+ {#if !hideTarget} +
+

+ Pick a script or flow to be triggered +

+
+ { + isFlow = e.detail.itemKind === 'flow' + }} + kinds={['script']} + allowFlow={true} + allowEdit={!$userStore?.operator} + clearable + /> + {#if emptyString(scriptPath)} + + {/if} +
+ {#if errors.runnable_path} +
{errors.runnable_path}
+ {/if} +
+ {/if} + + {#if loadingConfig} +
+
+ + Loading configuration from {serviceInfo?.serviceDisplayName}... +
+
+ {:else if ServiceFormComponent} + + {:else} +
+
+
Failed to load service configuration component for {service}.
+
+ Ensure your workspace has a connected {serviceInfo?.serviceDisplayName} integration. +
+ +
+
+ {/if} +
+ {/if} +{/snippet} diff --git a/frontend/src/lib/components/triggers/native/NativeTriggerTable.svelte b/frontend/src/lib/components/triggers/native/NativeTriggerTable.svelte new file mode 100644 index 0000000000..e6dcbf47d5 --- /dev/null +++ b/frontend/src/lib/components/triggers/native/NativeTriggerTable.svelte @@ -0,0 +1,175 @@ + + +
+ {#if loading} + {#each new Array(6) as _} + + {/each} + {:else if !triggers?.length} +
+ No {serviceConfig?.serviceDisplayName} triggers +
+ {:else} +
+ {#each triggers as trigger (trigger.external_id)} + {@const isFlow = trigger.is_flow} + {@const href = `${isFlow ? '/flows/get' : '/scripts/get'}/${trigger.script_path}`} +
+
+ + + onEdit?.(trigger)} + class="min-w-0 grow hover:underline decoration-gray-400" + > +
+ {#if trigger.marked} + + {@html trigger.marked} + + {:else} + {trigger.script_path} + {/if} +
+
+ external ID: {trigger.external_id} +
+
+ +
+ + { + goto(href) + } + }, + { + displayName: 'Delete', + type: 'delete' as const, + icon: Trash, + action: () => openDeleteConfirmation(trigger) + } + ]} + /> +
+
+ {#if trigger.error} +
+
+ Error: {trigger.error} +
+ +
+ {/if} +
+ {/each} +
+ {/if} +
+ + +
+ Are you sure you want to delete this trigger? + {#if triggerToDelete?.external_id} + External ID: {triggerToDelete.external_id} + {/if} + + This will permanently delete the trigger from both Windmill and {serviceConfig?.serviceDisplayName}. + This action cannot be undone. + +
+
diff --git a/frontend/src/lib/components/triggers/native/NativeTriggersPanel.svelte b/frontend/src/lib/components/triggers/native/NativeTriggersPanel.svelte new file mode 100644 index 0000000000..fb55529414 --- /dev/null +++ b/frontend/src/lib/components/triggers/native/NativeTriggersPanel.svelte @@ -0,0 +1,66 @@ + + +
+ + {#snippet description()} + + {serviceDisplayName} triggers execute scripts and flows in response to events in {serviceDisplayName}. + + {/snippet} + +
diff --git a/frontend/src/lib/components/triggers/native/services/nextcloud/NextcloudTriggerForm.svelte b/frontend/src/lib/components/triggers/native/services/nextcloud/NextcloudTriggerForm.svelte new file mode 100644 index 0000000000..cee2980b27 --- /dev/null +++ b/frontend/src/lib/components/triggers/native/services/nextcloud/NextcloudTriggerForm.svelte @@ -0,0 +1,138 @@ + + +
+ {#if loading} +
+ + Loading available events... +
+ {:else if availableEvents.length === 0} +
+
No events available. Please ensure your workspace has a connected Nextcloud integration.
+
+ + +
+
+ {:else if serviceSchema} + + {:else} +
+ Please ensure Nextcloud workspace integration is connected to load available configuration + options. +
+ {/if} +
diff --git a/frontend/src/lib/components/triggers/native/utils.ts b/frontend/src/lib/components/triggers/native/utils.ts new file mode 100644 index 0000000000..ab78f490e6 --- /dev/null +++ b/frontend/src/lib/components/triggers/native/utils.ts @@ -0,0 +1,221 @@ +import type { NativeServiceName, NativeTrigger, NativeTriggerData } from '$lib/gen/types.gen' +import { isCloudHosted } from '$lib/cloud' +import { NativeTriggerService, WorkspaceIntegrationService } from '$lib/gen' +import { get, type Writable } from 'svelte/store' +import { sendUserToast } from '$lib/toast' + +export interface NativeTriggerConfig { + readonly serviceDisplayName: string + readonly serviceKey: NativeServiceName + readonly supportsSync: boolean + readonly supportsFetchConfig: boolean + readonly isCloudCompatible: boolean + readonly templates?: { + script?: string + flow?: string + } +} + +export const NATIVE_TRIGGER_SERVICES: Record = { + nextcloud: { + serviceDisplayName: 'Nextcloud', + serviceKey: 'nextcloud', + supportsSync: true, + supportsFetchConfig: true, + isCloudCompatible: true, + templates: { + script: '/scripts/add?hub=hub%2F28115', + flow: '/flows/add?hub=73' + } + } +} + +export async function isServiceAvailable( + service: NativeServiceName, + workspace: string +): Promise { + const config = NATIVE_TRIGGER_SERVICES[service] + if (!config) return false + + if (isCloudHosted() && !config.isCloudCompatible) { + return false + } + + try { + const response = await WorkspaceIntegrationService.checkIfNativeTriggersServiceExists({ + workspace, + serviceName: service + }) + + return response + } catch (workspaceErr) { + console.debug(`Workspace integration check failed for ${service}:`, workspaceErr) + return false + } +} + +export function getAvailableServices(): NativeServiceName[] { + return Object.keys(NATIVE_TRIGGER_SERVICES) as NativeServiceName[] +} + +export async function getAvailableNativeTriggerServices( + workspace: string +): Promise { + const services = getAvailableServices() + const availableServices: NativeServiceName[] = [] + + for (const service of services) { + const available = await isServiceAvailable(service, workspace) + if (available) { + availableServices.push(service) + } + } + + return availableServices +} + +export function getServiceConfig(service: NativeServiceName): NativeTriggerConfig | undefined { + return NATIVE_TRIGGER_SERVICES[service] +} + +// NativeTrigger now has script_path and is_flow directly +// This type adds the marked property for search highlighting +export type ExtendedNativeTrigger = NativeTrigger & { marked?: string } + +export interface ServiceFormProps { + config: Record + errors: Record + resources: Array<{ path: string; description?: string }> + onConfigChange: (newConfig: Record) => void + onTest?: () => Promise + disabled?: boolean +} + +export function validateCommonFields(config: Record): Record { + const errors: Record = {} + + if (!config.script_path?.trim()) { + errors.script_path = 'Script/Flow path is required' + } + + return errors +} + +export function formatTriggerDisplayName(trigger: NativeTrigger): string { + return `${trigger.script_path} (external id: ${trigger.external_id})` +} + +export function getTriggerIconName(service: NativeServiceName): string { + switch (service) { + case 'nextcloud': + return 'NextcloudIcon' + default: + return 'NextcloudIcon' + } +} + +export async function getServiceIcon(service: NativeServiceName): Promise { + switch (service) { + case 'nextcloud': + return (await import('$lib/components/icons/NextcloudIcon.svelte')).default + } +} + +export function getServiceTemplates( + service: NativeServiceName +): { script?: string; flow?: string } | undefined { + const config = getServiceConfig(service) + return config?.templates +} + +export function getTemplatePath( + service: NativeServiceName, + type: 'script' | 'flow' +): string | undefined { + const templates = getServiceTemplates(service) + return templates?.[type] +} + +export interface NextcloudEvent { + path: string + description?: string +} + +export function getNextcloudSchema(availableEvents: NextcloudEvent[]) { + return { + type: 'object', + properties: { + event: { + type: 'string', + title: 'Event', + description: 'The type of Nextcloud event to listen for', + enum: availableEvents.map((e) => e.path), + enumLabels: availableEvents.reduce( + (acc, cur) => ({ ...acc, [cur.path]: cur.description ?? cur.path }), + {} as Record + ) + }, + eventFilter: { + type: 'object', + title: 'Event filter', + description: 'Optional filter criteria for the event (JSON object)' + }, + userIdFilter: { + type: 'string', + title: 'User ID filter', + description: 'Filter events by specific user ID' + }, + headers: { + type: 'object', + title: 'Headers', + description: 'Optional HTTP headers to include (JSON object)' + } + }, + required: ['event'] + } +} + +export async function saveNativeTriggerFromCfg( + service: NativeServiceName, + initialExternalId: string, + triggerCfg: Record, + edit: boolean, + workspace: string, + usedTriggerKinds: Writable +): Promise { + const requestBody: NativeTriggerData = { + script_path: triggerCfg.script_path, + is_flow: triggerCfg.is_flow, + service_config: triggerCfg.service_config + } + + const serviceName = NATIVE_TRIGGER_SERVICES[service].serviceDisplayName + + try { + let externalId = initialExternalId + if (edit) { + await NativeTriggerService.updateNativeTrigger({ + workspace: workspace, + serviceName: service, + externalId: initialExternalId, + requestBody + }) + sendUserToast(`${serviceName} trigger ${externalId} updated`) + } else { + const response = await NativeTriggerService.createNativeTrigger({ + workspace: workspace, + serviceName: service, + requestBody + }) + externalId = response.external_id + sendUserToast(`${serviceName} trigger ${externalId} created`) + } + if (!get(usedTriggerKinds).includes(service)) { + usedTriggerKinds.update((t) => [...t, service]) + } + return externalId + } catch (error) { + sendUserToast(error.body || error.message, true) + return null + } +} diff --git a/frontend/src/lib/components/triggers/triggers.svelte.ts b/frontend/src/lib/components/triggers/triggers.svelte.ts index 6f99c38221..4f7fc508a1 100644 --- a/frontend/src/lib/components/triggers/triggers.svelte.ts +++ b/frontend/src/lib/components/triggers/triggers.svelte.ts @@ -6,6 +6,7 @@ import { ScheduleService, SqsTriggerService, WebsocketTriggerService, + NativeTriggerService, type GcpTrigger, type KafkaTrigger, type PostgresTrigger, @@ -15,7 +16,9 @@ import { HttpTriggerService, GcpTriggerService, type EmailTrigger, - EmailTriggerService + EmailTriggerService, + type NativeTrigger, + type NativeServiceName } from '$lib/gen' import { enterpriseLicense } from '$lib/stores' @@ -73,6 +76,7 @@ export class Triggers { } setDraftConfig(triggerIndex: number, draftConfig: Record | undefined) { + console.log('setDraftConfig', triggerIndex, draftConfig) if (triggerIndex === undefined || triggerIndex < 0 || triggerIndex >= this.#triggers.length) { return } @@ -460,6 +464,37 @@ export class Triggers { } } + async fetchNativeTriggers( + serviceName: NativeServiceName, + workspaceId: string | undefined, + path: string, + isFlow: boolean, + user: UserExt | undefined = undefined + ): Promise { + if (!workspaceId) return + try { + const allTriggers: NativeTrigger[] = await NativeTriggerService.listNativeTriggers({ + workspace: workspaceId, + serviceName + }) + // Filter triggers for this specific script/flow path + const filteredTriggers = allTriggers.filter( + (t) => t.script_path === path && t.is_flow === isFlow + ) + // Convert to the trigger format used by updateTriggers + const triggerData = filteredTriggers.map((t) => ({ + path: t.external_id, + script_path: t.script_path, + is_flow: t.is_flow, + extra_perms: {}, + service_config: t.service_config + })) + this.updateTriggers(triggerData, serviceName, user) + } catch (error) { + console.debug(`Failed to fetch ${serviceName} triggers:`, error) + } + } + async fetchTriggers( triggersCountStore: Writable, workspaceId: string | undefined, @@ -479,10 +514,14 @@ export class Triggers { this.fetchNatsTriggers(triggersCountStore, workspaceId, path, isFlow, user), this.fetchMqttTriggers(triggersCountStore, workspaceId, path, isFlow, user), this.fetchEmailTriggers(triggersCountStore, workspaceId, path, isFlow, user), - ...(get(enterpriseLicense) ? [ - this.fetchKafkaTriggers(triggersCountStore, workspaceId, path, isFlow, user), - this.fetchSqsTriggers(triggersCountStore, workspaceId, path, isFlow, user), - this.fetchGcpTriggers(triggersCountStore, workspaceId, path, isFlow, user)] : []) + this.fetchNativeTriggers('nextcloud', workspaceId, path, isFlow, user), + ...(get(enterpriseLicense) + ? [ + this.fetchKafkaTriggers(triggersCountStore, workspaceId, path, isFlow, user), + this.fetchSqsTriggers(triggersCountStore, workspaceId, path, isFlow, user), + this.fetchGcpTriggers(triggersCountStore, workspaceId, path, isFlow, user) + ] + : []) ]) } } diff --git a/frontend/src/lib/components/triggers/utils.ts b/frontend/src/lib/components/triggers/utils.ts index 92a6041597..0256824205 100644 --- a/frontend/src/lib/components/triggers/utils.ts +++ b/frontend/src/lib/components/triggers/utils.ts @@ -27,6 +27,8 @@ import { saveGcpTriggerFromCfg } from './gcp/utils' import type { Triggers } from './triggers.svelte' import { emptyString } from '$lib/utils' import { saveEmailTriggerFromCfg } from './email/utils' +import NextcloudIcon from '$lib/components/icons/NextcloudIcon.svelte' +import { saveNativeTriggerFromCfg } from './native/utils' export const CLOUD_DISABLED_TRIGGER_TYPES = [ 'nats', @@ -54,6 +56,7 @@ export type TriggerType = | 'email' | 'poll' | 'cli' + | 'nextcloud' export const jobTriggerKinds: JobTriggerKind[] = [ 'webhook', @@ -99,7 +102,8 @@ export const triggerIconMap = { gcp: GoogleCloudIcon, primary_schedule: Calendar, poll: SchedulePollIcon, - cli: Terminal + cli: Terminal, + nextcloud: NextcloudIcon } export const triggerDisplayNamesMap = { @@ -116,7 +120,8 @@ export const triggerDisplayNamesMap = { poll: 'Scheduled Poll', webhook: 'Webhook', default_email: 'Default Email', - cli: 'CLI' + cli: 'CLI', + nextcloud: 'Nextcloud' } as const satisfies Record /** @@ -170,7 +175,8 @@ export function updateTriggersCount( gcp: 'gcp_count', email: 'email_count', poll: undefined, - cli: undefined + cli: undefined, + nextcloud: undefined } const countProperty = countPropertyMap[type] @@ -352,7 +358,16 @@ export async function deployTriggers( usedTriggerKinds ), poll: undefined, - cli: undefined + cli: undefined, + nextcloud: (trigger: Trigger) => + saveNativeTriggerFromCfg( + 'nextcloud', + trigger.path ?? '', + trigger.draftConfig ?? {}, + !trigger.isDraft, + workspaceId, + usedTriggerKinds + ) } await Promise.all( @@ -450,6 +465,8 @@ export function getLightConfig( return { gcp_resource_path: trigger.gcp_resource_path, topic: trigger.topic } } else if (triggerType === 'email') { return { local_part: trigger.local_part } + } else if (triggerType === 'nextcloud') { + return { event: trigger.service_config?.event ?? trigger.event } } else { return undefined } @@ -484,6 +501,8 @@ export function getTriggerLabel(trigger: Trigger): string { return `${config?.url}` } else if (type === 'email' && config?.local_part) { return `${config?.local_part}` + } else if (type === 'nextcloud' && path) { + return `${path}` } else if (isDraft && draftConfig?.path) { return `${draftConfig?.path}` } else if (isDraft) { @@ -508,7 +527,8 @@ export function sortTriggers(triggers: Trigger[]): Trigger[] { 'mqtt', 'sqs', 'gcp', - 'email' + 'email', + 'nextcloud' ] return triggers.sort((a, b) => { diff --git a/frontend/src/lib/components/workspaceSettings/OAuthClientConfig.svelte b/frontend/src/lib/components/workspaceSettings/OAuthClientConfig.svelte new file mode 100644 index 0000000000..471b3c0de9 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/OAuthClientConfig.svelte @@ -0,0 +1,204 @@ + + +
+
+

{serviceDisplayName} OAuth Client Configuration

+
+ + {#if !existingConfig} + +

+ Before you can connect to {serviceDisplayName}, you need to configure an OAuth client. This + requires: +

+
    +
  1. Create an OAuth2 application in your {serviceDisplayName} instance (Administration settings + -> Security -> OAuth 2.0 clients)
  2. +
  3. Configure the redirect URI: {redirectUri || + `${window.location.origin}/workspace_settings?tab=integrations&service=${serviceName}`}
  4. +
  5. Enter the client credentials below
  6. +
+
+ {/if} + +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+
+ + {#if existingConfig} + +

+ OAuth client is configured for {serviceDisplayName}. You can now connect this workspace to + your {serviceDisplayName} instance. +

+
+ {/if} +
diff --git a/frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte b/frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte new file mode 100644 index 0000000000..dffb51f8e4 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte @@ -0,0 +1,329 @@ + + +
+
+
Native Triggers
+ + Connect your workspace to external services for native triggers and enhanced functionality. + These connections are shared across all workspace members and are required for native triggers + to work. + + + Learn more about native triggers and workspace integrations. + +
+ + {#if processingCallback} + +

Completing your OAuth connection, please wait...

+
+ {:else if loading} +
+ {#each new Array(3) as _} + + {/each} +
+ {:else} +
+ {#each Object.entries(supportedServices) as [serviceName, config]} + {@const integration = getIntegrationByService(serviceName)} + {@const isConnecting = connecting === serviceName} + {@const isOAuthConfigured = integration && isConfigured(integration)} + {@const isServiceConnected = integration && isConnected(integration)} + {@const isShowingConfig = showingConfig === serviceName} + +
+
+
+
+ +
+
+
{config.displayName}
+
{config.description}
+
+
+ +
+ {#if isServiceConnected} +
+ + Connected +
+ + + {:else if isOAuthConfigured} + + + {:else} + + {/if} + + {#if config.docsUrl} + + {/if} +
+
+ + {#if isShowingConfig} +
+ { + await createOrUpdateIntegration(serviceName, oauthData) + showingConfig = null + }} + /> +
+ {/if} +
+ {/each} +
+ + {#if integrations.length === 0} + + Connect to external services above to enable native triggers for your workspace. + + {/if} + {/if} +
diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 7b7026ab17..c6bd465aca 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -220,7 +220,8 @@ sqs_used, mqtt_used, gcp_used, - email_used + email_used, + nextcloud_used } = await WorkspaceService.getUsedTriggers({ workspace: $workspaceStore ?? '' }) @@ -251,6 +252,9 @@ if (email_used) { usedKinds.push('email') } + if (nextcloud_used) { + usedKinds.push('nextcloud') + } $usedTriggerKinds = usedKinds } diff --git a/frontend/src/routes/(root)/(logged)/native_triggers/[service_name]/+page.svelte b/frontend/src/routes/(root)/(logged)/native_triggers/[service_name]/+page.svelte new file mode 100644 index 0000000000..e7c42baa89 --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/native_triggers/[service_name]/+page.svelte @@ -0,0 +1,292 @@ + + + + + `${formatTriggerDisplayName(trigger)}`} +/> + +{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers} + +{:else if !serviceSupported} + +
+

Service not supported

+

+ The service "{serviceName}" is not supported for native triggers. +

+

Supported services: nextcloud

+
+
+{:else} + + + + + + {#if serviceAvailable === false} + +
+ The workspace doesn't have a {serviceConfig?.serviceDisplayName || serviceName} integration + connected +
+ + +
+
+
+ {:else if serviceAvailable} +
+
+ + +
+ {#if $userStore?.is_super_admin && $userStore.username.includes('@')} + + {:else if $userStore?.is_admin || $userStore?.is_super_admin} + + {/if} +
+
+ + {#if loading} + {#each new Array(6) as _} + + {/each} + {:else if !triggers?.length} +
+ No {serviceConfig?.serviceDisplayName || serviceName} triggers +
+ {:else if items?.length} + editor?.openEdit(trigger.external_id, trigger.is_flow)} + onRecreate={(trigger) => editor?.openRecreate(trigger)} + onSync={syncTriggers} + /> + {:else} + + {/if} +
+ {#if items && items?.length > 15 && nbDisplayed < items.length} + {nbDisplayed} items out of {items.length} + + {/if} + {:else} +
+ +
Checking {serviceConfig?.serviceDisplayName || serviceName} availability...
+
+ {/if} +
+{/if} diff --git a/frontend/src/routes/(root)/(logged)/native_triggers/[service_name]/+page.ts b/frontend/src/routes/(root)/(logged)/native_triggers/[service_name]/+page.ts new file mode 100644 index 0000000000..e0ae01cc55 --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/native_triggers/[service_name]/+page.ts @@ -0,0 +1,22 @@ +import { getServiceConfig } from '$lib/components/triggers/native/utils' +import { error } from '@sveltejs/kit' +import type { PageLoad } from './$types' +import type { NativeServiceName } from '$lib/gen' + +export const load: PageLoad = async ({ params }) => { + const serviceName = params.service_name as NativeServiceName + + const serviceConfig = getServiceConfig(serviceName) + + if (!serviceConfig) { + throw error(404, { + message: `Service "${serviceName}" is not supported for native triggers.` + }) + } + + return { + serviceName, + serviceConfig, + stuff: { title: `${serviceConfig.serviceDisplayName} triggers` } + } +} diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index 81219f13ab..e8546d6859 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -156,6 +156,7 @@ | 'windmill_lfs' | 'git_sync' | 'default_app' + | 'native_triggers' | 'encryption' | 'dependencies' // Both 'slack' and 'teams' URLs map to 'slack' tab @@ -753,6 +754,13 @@ aiDescription="Data tables workspace settings" label="Data Tables" /> + {/key}
+ {:else if tab == 'native_triggers'} + {#if $workspaceStore} + {#await import('$lib/components/workspaceSettings/WorkspaceIntegrations.svelte') then { default: WorkspaceIntegrations }} + + {/await} + {:else} +
+
Loading workspace...
+
+ {/if} {:else if tab == 'encryption'}