diff --git a/.eslintignore b/.eslintignore index 94984a446f..2bc00912d2 100644 --- a/.eslintignore +++ b/.eslintignore @@ -9,8 +9,5 @@ packages/server/client packages/server/coverage packages/builder/.routify packages/sdk/sdk -packages/account-portal/packages/server/build -packages/account-portal/packages/ui/.routify -packages/account-portal/packages/ui/build **/*.ivm.bundle.js packages/server/build/oldClientVersions/**/** diff --git a/.github/workflows/budibase_ci.yml b/.github/workflows/budibase_ci.yml index 2d725bf28a..1258bddcca 100644 --- a/.github/workflows/budibase_ci.yml +++ b/.github/workflows/budibase_ci.yml @@ -64,18 +64,15 @@ jobs: - run: yarn --frozen-lockfile # Run build all the projects - - name: Build OSS - run: yarn build:oss - - name: Build account portal - run: yarn build:account-portal - if: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }} + - name: Build + run: yarn build # Check the types of the projects built via esbuild - name: Check types run: | if ${{ env.ONLY_AFFECTED_TASKS }}; then - yarn check:types --since=${{ env.NX_BASE_BRANCH }} --ignore @budibase/account-portal-server + yarn check:types --since=${{ env.NX_BASE_BRANCH }} else - yarn check:types --ignore @budibase/account-portal-server + yarn check:types fi helm-lint: @@ -117,9 +114,11 @@ jobs: - name: Test run: | if ${{ env.ONLY_AFFECTED_TASKS }}; then - yarn test --ignore=@budibase/worker --ignore=@budibase/server --since=${{ env.NX_BASE_BRANCH }} + yarn test -- --ignore=@budibase/worker --ignore=@budibase/server --ignore=@budibase/builder --no-prefix --since=${{ env.NX_BASE_BRANCH }} -- --verbose --reporters=default --reporters=github-actions + yarn test -- --scope=@budibase/builder --since=${{ env.NX_BASE_BRANCH }} else - yarn test --ignore=@budibase/worker --ignore=@budibase/server + yarn test -- --ignore=@budibase/worker --ignore=@budibase/server --ignore=@budibase/builder --no-prefix -- --verbose --reporters=default --reporters=github-actions + yarn test -- --scope=@budibase/builder --no-prefix fi test-worker: @@ -141,13 +140,22 @@ jobs: - name: Test worker run: | if ${{ env.ONLY_AFFECTED_TASKS }}; then - node scripts/run-affected.js --task=test --scope=@budibase/worker --since=${{ env.NX_BASE_BRANCH }} - else - yarn test --scope=@budibase/worker + AFFECTED=$(yarn --silent nx show projects --affected -t test --base=${{ env.NX_BASE_BRANCH }} -p @budibase/worker) + if [ -z "$AFFECTED" ]; then + echo "No affected tests to run" + exit 0 + fi fi + cd packages/worker + yarn test --verbose --reporters=default --reporters=github-actions + test-server: - runs-on: budi-tubby-tornado-quad-core-300gb + runs-on: ubuntu-latest + strategy: + matrix: + datasource: + [mssql, mysql, postgres, mongodb, mariadb, oracle, sqs, none] steps: - name: Checkout repo uses: actions/checkout@v4 @@ -170,12 +178,19 @@ jobs: - name: Pull testcontainers images run: | - docker pull mcr.microsoft.com/mssql/server@${{ steps.dotenv.outputs.MSSQL_SHA }} & - docker pull mysql@${{ steps.dotenv.outputs.MYSQL_SHA }} & - docker pull postgres@${{ steps.dotenv.outputs.POSTGRES_SHA }} & - docker pull mongo@${{ steps.dotenv.outputs.MONGODB_SHA }} & - docker pull mariadb@${{ steps.dotenv.outputs.MARIADB_SHA }} & - docker pull budibase/oracle-database:23.2-slim-faststart & + if [ "${{ matrix.datasource }}" == "mssql" ]; then + docker pull mcr.microsoft.com/mssql/server@${{ steps.dotenv.outputs.MSSQL_SHA }} + elif [ "${{ matrix.datasource }}" == "mysql" ]; then + docker pull mysql@${{ steps.dotenv.outputs.MYSQL_SHA }} + elif [ "${{ matrix.datasource }}" == "postgres" ]; then + docker pull postgres@${{ steps.dotenv.outputs.POSTGRES_SHA }} + elif [ "${{ matrix.datasource }}" == "mongodb" ]; then + docker pull mongo@${{ steps.dotenv.outputs.MONGODB_SHA }} + elif [ "${{ matrix.datasource }}" == "mariadb" ]; then + docker pull mariadb@${{ steps.dotenv.outputs.MARIADB_SHA }} + elif [ "${{ matrix.datasource }}" == "oracle" ]; then + docker pull budibase/oracle-database:23.2-slim-faststart + fi docker pull minio/minio & docker pull redis & docker pull testcontainers/ryuk:0.5.1 & @@ -186,13 +201,25 @@ jobs: - run: yarn --frozen-lockfile - name: Test server + env: + DATASOURCE: ${{ matrix.datasource }} run: | if ${{ env.ONLY_AFFECTED_TASKS }}; then - node scripts/run-affected.js --task=test --scope=@budibase/server --since=${{ env.NX_BASE_BRANCH }} - else - yarn test --scope=@budibase/server + AFFECTED=$(yarn --silent nx show projects --affected -t test --base=${{ env.NX_BASE_BRANCH }} -p @budibase/server) + if [ -z "$AFFECTED" ]; then + echo "No affected tests to run" + exit 0 + fi fi + FILTER="./src/tests/filters/datasource-tests.js" + if [ "${{ matrix.datasource }}" == "none" ]; then + FILTER="./src/tests/filters/non-datasource-tests.js" + fi + + cd packages/server + yarn test --filter $FILTER --verbose --reporters=default --reporters=github-actions + check-pro-submodule: runs-on: ubuntu-latest if: inputs.run_as_oss != true && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase') @@ -252,64 +279,6 @@ jobs: echo 'All good, the submodule had been merged and setup correctly!' fi - check-accountportal-submodule: - runs-on: ubuntu-latest - if: inputs.run_as_oss != true && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase') - steps: - - name: Checkout repo and submodules - uses: actions/checkout@v4 - with: - submodules: true - token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} - fetch-depth: 0 - - - uses: dorny/paths-filter@v3 - id: changes - with: - filters: | - src: - - packages/account-portal/** - - - if: steps.changes.outputs.src == 'true' - name: Check account portal commit - id: get_accountportal_commits - run: | - cd packages/account-portal - accountportal_commit=$(git rev-parse HEAD) - - branch="${{ github.base_ref || github.ref_name }}" - echo "Running on branch '$branch' (base_ref=${{ github.base_ref }}, ref_name=${{ github.head_ref }})" - - base_commit=$(git rev-parse origin/master) - - if [[ ! -z $base_commit ]]; then - echo "target_branch=$branch" - echo "target_branch=$branch" >> "$GITHUB_OUTPUT" - echo "accountportal_commit=$accountportal_commit" - echo "accountportal_commit=$accountportal_commit" >> "$GITHUB_OUTPUT" - echo "base_commit=$base_commit" - echo "base_commit=$base_commit" >> "$GITHUB_OUTPUT" - else - echo "Nothing to do - branch to branch merge." - fi - - - name: Check submodule merged to base branch - if: ${{ steps.get_accountportal_commits.outputs.base_commit != '' }} - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const submoduleCommit = '${{ steps.get_accountportal_commits.outputs.accountportal_commit }}'; - const baseCommit = '${{ steps.get_accountportal_commits.outputs.base_commit }}'; - - if (submoduleCommit !== baseCommit) { - console.error('Submodule commit does not match the latest commit on the "${{ steps.get_accountportal_commits.outputs.target_branch }}" branch.'); - console.error('Refer to the account portal repo to merge your changes: https://github.com/Budibase/account-portal/blob/master/docs/index.md') - process.exit(1); - } else { - console.log('All good, the submodule had been merged and setup correctly!') - } - check-lockfile: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index bac643e5df..21637edfbe 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ packages/server/build/oldClientVersions/**/* packages/builder/src/components/deploy/clientVersions.json packages/server/src/integrations/tests/utils/*.lock packages/builder/vite.config.mjs.timestamp* +packages/account-portal # Logs logs @@ -110,4 +111,4 @@ budibase-component budibase-datasource *.iml -.nx \ No newline at end of file +.nx diff --git a/.gitmodules b/.gitmodules index cb6d1c5dc8..2dd6ea53f2 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ [submodule "packages/pro"] path = packages/pro url = git@github.com:Budibase/budibase-pro.git -[submodule "packages/account-portal"] - path = packages/account-portal - url = git@github.com:Budibase/account-portal.git diff --git a/.prettierignore b/.prettierignore index 72cdc75a23..b1ee287391 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,8 +9,4 @@ packages/backend-core/coverage packages/builder/.routify packages/sdk/sdk packages/pro/coverage -packages/account-portal/packages/ui/build -packages/account-portal/packages/ui/.routify -packages/account-portal/packages/server/build -packages/account-portal/packages/server/coverage **/*.ivm.bundle.js \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 543d1e6179..2fda61345b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -20,16 +20,6 @@ "args": ["${workspaceFolder}/packages/worker/src/index.ts"], "cwd": "${workspaceFolder}/packages/worker" }, - { - "name": "Camunda Worker", - "type": "node", - "request": "launch", - "runtimeArgs": ["--nolazy", "-r", "ts-node/register/transpile-only"], - "args": [ - "${workspaceFolder}/packages/account-portal/packages/server/src/v2/run.ts" - ], - "cwd": "${workspaceFolder}/packages/account-portal/packages/server" - }, { "type": "chrome", "request": "launch", diff --git a/examples/nextjs-api-sales/yarn.lock b/examples/nextjs-api-sales/yarn.lock index 9acbdfdeb6..867835a6b7 100644 --- a/examples/nextjs-api-sales/yarn.lock +++ b/examples/nextjs-api-sales/yarn.lock @@ -423,9 +423,9 @@ core-js-pure@^3.20.2: integrity sha512-12VZfFIu+wyVbBebyHmRTuEE/tZrB4tJToWcwAMcsp3h4+sHR+fMJWbKpYiCRWlhFBq+KNyO8rIV9rTkeVmznQ== cross-spawn@^7.0.2: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== dependencies: path-key "^3.1.0" shebang-command "^2.0.0" diff --git a/globalSetup.ts b/globalSetup.ts index 5d8b0381c0..07a0cec5e2 100644 --- a/globalSetup.ts +++ b/globalSetup.ts @@ -62,6 +62,7 @@ export default async function setup() { }, ]) .withLabels({ "com.budibase": "true" }) + .withTmpFs({ "/data": "rw" }) .withReuse() .withWaitStrategy( Wait.forSuccessfulCommand( @@ -72,6 +73,7 @@ export default async function setup() { const minio = new GenericContainer("minio/minio") .withExposedPorts(9000) .withCommand(["server", "/data"]) + .withTmpFs({ "/data": "rw" }) .withEnvironment({ MINIO_ACCESS_KEY: "budibase", MINIO_SECRET_KEY: "budibase", diff --git a/hosting/.env b/hosting/.env index 173d409d04..23681f1f57 100644 --- a/hosting/.env +++ b/hosting/.env @@ -19,7 +19,6 @@ MINIO_PORT=4004 COUCH_DB_PORT=4005 COUCH_DB_SQS_PORT=4006 REDIS_PORT=6379 -WATCHTOWER_PORT=6161 BUDIBASE_ENVIRONMENT=PRODUCTION SQL_MAX_ROWS= diff --git a/hosting/docker-compose.build.yaml b/hosting/docker-compose.build.yaml index 1f16baa9e2..057d51a887 100644 --- a/hosting/docker-compose.build.yaml +++ b/hosting/docker-compose.build.yaml @@ -74,7 +74,6 @@ services: - WORKER_UPSTREAM_URL=http://worker-service:4003 - MINIO_UPSTREAM_URL=http://minio-service:9000 - COUCHDB_UPSTREAM_URL=http://couchdb-service:5984 - - WATCHTOWER_UPSTREAM_URL=http://watchtower-service:8080 - RESOLVER=127.0.0.11 depends_on: - minio-service diff --git a/hosting/docker-compose.yaml b/hosting/docker-compose.yaml index c7a22eb2b3..ec24765149 100644 --- a/hosting/docker-compose.yaml +++ b/hosting/docker-compose.yaml @@ -87,7 +87,6 @@ services: - WORKER_UPSTREAM_URL=http://worker-service:4003 - MINIO_UPSTREAM_URL=http://minio-service:9000 - COUCHDB_UPSTREAM_URL=http://couchdb-service:5984 - - WATCHTOWER_UPSTREAM_URL=http://watchtower-service:8080 - RESOLVER=127.0.0.11 depends_on: - minio-service @@ -112,19 +111,6 @@ services: volumes: - redis_data:/data - watchtower-service: - restart: always - image: containrrr/watchtower - volumes: - - /var/run/docker.sock:/var/run/docker.sock - command: --debug --http-api-update bbapps bbworker bbproxy - environment: - - WATCHTOWER_HTTP_API=true - - WATCHTOWER_HTTP_API_TOKEN=budibase - - WATCHTOWER_CLEANUP=true - labels: - - "com.centurylinklabs.watchtower.enable=false" - volumes: couchdb3_data: driver: local diff --git a/hosting/envoy.yaml b/hosting/envoy.yaml deleted file mode 100644 index d9f8384688..0000000000 --- a/hosting/envoy.yaml +++ /dev/null @@ -1,152 +0,0 @@ -static_resources: - listeners: - - name: main_listener - address: - socket_address: { address: 0.0.0.0, port_value: 10000 } - filter_chains: - - filters: - - name: envoy.filters.network.http_connection_manager - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager - stat_prefix: ingress - codec_type: auto - route_config: - name: local_route - virtual_hosts: - - name: local_services - domains: ["*"] - routes: - - match: { prefix: "/app/" } - route: - cluster: app-service - prefix_rewrite: "/" - - - match: { path: "/v1/update" } - route: - cluster: watchtower-service - - - match: { prefix: "/builder/" } - route: - cluster: app-service - - - match: { prefix: "/builder" } - route: - cluster: app-service - - - match: { prefix: "/app_" } - route: - cluster: app-service - - # special cases for worker admin (deprecated), global and system API - - match: { prefix: "/api/global/" } - route: - cluster: worker-service - - - match: { prefix: "/api/admin/" } - route: - cluster: worker-service - - - match: { prefix: "/api/system/" } - route: - cluster: worker-service - - - match: { path: "/" } - route: - cluster: app-service - - # special case for when API requests are made, can just forward, not to minio - - match: { prefix: "/api/" } - route: - cluster: app-service - timeout: 120s - - - match: { prefix: "/worker/" } - route: - cluster: worker-service - prefix_rewrite: "/" - - - match: { prefix: "/db/" } - route: - cluster: couchdb-service - prefix_rewrite: "/" - - # minio is on the default route because this works - # best, minio + AWS SDK doesn't handle path proxy - - match: { prefix: "/" } - route: - cluster: minio-service - - http_filters: - - name: envoy.filters.http.router - - clusters: - - name: app-service - connect_timeout: 0.25s - type: strict_dns - lb_policy: round_robin - load_assignment: - cluster_name: app-service - endpoints: - - lb_endpoints: - - endpoint: - address: - socket_address: - address: app-service - port_value: 4002 - - - name: minio-service - connect_timeout: 0.25s - type: strict_dns - lb_policy: round_robin - load_assignment: - cluster_name: minio-service - endpoints: - - lb_endpoints: - - endpoint: - address: - socket_address: - address: minio-service - port_value: 9000 - - - name: worker-service - connect_timeout: 0.25s - type: strict_dns - lb_policy: round_robin - load_assignment: - cluster_name: worker-service - endpoints: - - lb_endpoints: - - endpoint: - address: - socket_address: - address: worker-service - port_value: 4003 - - - name: couchdb-service - connect_timeout: 0.25s - type: strict_dns - lb_policy: round_robin - load_assignment: - cluster_name: couchdb-service - endpoints: - - lb_endpoints: - - endpoint: - address: - socket_address: - address: couchdb-service - port_value: 5984 - - - name: watchtower-service - connect_timeout: 0.25s - type: strict_dns - lb_policy: round_robin - load_assignment: - cluster_name: watchtower-service - endpoints: - - lb_endpoints: - - endpoint: - address: - socket_address: - address: watchtower-service - port_value: 8080 - diff --git a/hosting/hosting.properties b/hosting/hosting.properties index 6c1d9e5dbd..f63bb1941a 100644 --- a/hosting/hosting.properties +++ b/hosting/hosting.properties @@ -18,7 +18,6 @@ WORKER_PORT=4003 MINIO_PORT=4004 COUCH_DB_PORT=4005 REDIS_PORT=6379 -WATCHTOWER_PORT=6161 BUDIBASE_ENVIRONMENT=PRODUCTION # An admin user can be automatically created initially if these are set @@ -26,4 +25,4 @@ BB_ADMIN_USER_EMAIL= BB_ADMIN_USER_PASSWORD= # A path that is watched for plugin bundles. Any bundles found are imported automatically/ -PLUGINS_DIR= \ No newline at end of file +PLUGINS_DIR= diff --git a/hosting/portainer/template.json b/hosting/portainer/template.json index 29107b674e..4ca5b5e94f 100644 --- a/hosting/portainer/template.json +++ b/hosting/portainer/template.json @@ -78,11 +78,6 @@ "default": "6379", "preset": true }, - { - "name": "WATCHTOWER_PORT", - "default": "6161", - "preset": true - }, { "name": "BUDIBASE_ENVIRONMENT", "default": "PRODUCTION", diff --git a/hosting/proxy/Dockerfile b/hosting/proxy/Dockerfile index 42327be087..9ec458a219 100644 --- a/hosting/proxy/Dockerfile +++ b/hosting/proxy/Dockerfile @@ -22,5 +22,4 @@ ENV APPS_UPSTREAM_URL=http://app-service:4002 ENV WORKER_UPSTREAM_URL=http://worker-service:4003 ENV MINIO_UPSTREAM_URL=http://minio-service:9000 ENV COUCHDB_UPSTREAM_URL=http://couchdb-service:5984 -ENV WATCHTOWER_UPSTREAM_URL=http://watchtower-service:8080 ENV RESOLVER=127.0.0.11 diff --git a/hosting/proxy/nginx.prod.conf b/hosting/proxy/nginx.prod.conf index 59722dac5c..c5d378afd8 100644 --- a/hosting/proxy/nginx.prod.conf +++ b/hosting/proxy/nginx.prod.conf @@ -50,19 +50,6 @@ http { ignore_invalid_headers off; proxy_buffering off; - set $csp_default "default-src 'self'"; - set $csp_script "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.budibase.net https://cdn.budi.live https://js.intercomcdn.com https://widget.intercom.io https://d2l5prqdbvm3op.cloudfront.net https://us-assets.i.posthog.com"; - set $csp_style "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com https://rsms.me https://maxcdn.bootstrapcdn.com"; - set $csp_object "object-src 'none'"; - set $csp_base_uri "base-uri 'self'"; - set $csp_connect "connect-src 'self' https://*.budibase.app https://*.budibaseqa.app https://*.budibase.net https://api-iam.intercom.io https://api-iam.intercom.io https://api-ping.intercom.io https://app.posthog.com https://us.i.posthog.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io https://nexus-websocket-a.intercom.io https://nexus-websocket-b.intercom.io https://uploads.intercomcdn.com https://uploads.intercomusercontent.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.s3.us-east-2.amazonaws.com https://*.s3.us-east-1.amazonaws.com https://*.s3.us-west-1.amazonaws.com https://*.s3.us-west-2.amazonaws.com https://*.s3.af-south-1.amazonaws.com https://*.s3.ap-east-1.amazonaws.com https://*.s3.ap-southeast-3.amazonaws.com https://*.s3.ap-south-1.amazonaws.com https://*.s3.ap-northeast-3.amazonaws.com https://*.s3.ap-northeast-2.amazonaws.com https://*.s3.ap-southeast-1.amazonaws.com https://*.s3.ap-southeast-2.amazonaws.com https://*.s3.ap-northeast-1.amazonaws.com https://*.s3.ca-central-1.amazonaws.com https://*.s3.cn-north-1.amazonaws.com https://*.s3.cn-northwest-1.amazonaws.com https://*.s3.eu-central-1.amazonaws.com https://*.s3.eu-west-1.amazonaws.com https://*.s3.eu-west-2.amazonaws.com https://*.s3.eu-south-1.amazonaws.com https://*.s3.eu-west-3.amazonaws.com https://*.s3.eu-north-1.amazonaws.com https://*.s3.sa-east-1.amazonaws.com https://*.s3.me-south-1.amazonaws.com https://*.s3.us-gov-east-1.amazonaws.com https://*.s3.us-gov-west-1.amazonaws.com https://api.github.com"; - set $csp_font "font-src 'self' data: https://cdn.jsdelivr.net https://fonts.gstatic.com https://rsms.me https://maxcdn.bootstrapcdn.com https://js.intercomcdn.com https://fonts.intercomcdn.com"; - set $csp_frame "frame-src 'self' https:"; - set $csp_img "img-src http: https: data: blob:"; - set $csp_manifest "manifest-src 'self'"; - set $csp_media "media-src 'self' https://js.intercomcdn.com https://cdn.budi.live"; - set $csp_worker "worker-src blob:"; - error_page 502 503 504 /error.html; location = /error.html { root /usr/share/nginx/html; @@ -73,7 +60,6 @@ http { add_header X-Frame-Options SAMEORIGIN always; add_header X-Content-Type-Options nosniff always; add_header X-XSS-Protection "1; mode=block" always; - add_header Content-Security-Policy "${csp_default}; ${csp_script}; ${csp_style}; ${csp_object}; ${csp_base_uri}; ${csp_connect}; ${csp_font}; ${csp_frame}; ${csp_img}; ${csp_manifest}; ${csp_media}; ${csp_worker};" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; # upstreams @@ -81,7 +67,6 @@ http { set $worker ${WORKER_UPSTREAM_URL}; set $minio ${MINIO_UPSTREAM_URL}; set $couchdb ${COUCHDB_UPSTREAM_URL}; - set $watchtower ${WATCHTOWER_UPSTREAM_URL}; location /health { access_log off; @@ -107,10 +92,6 @@ http { proxy_pass $apps; } - location = /v1/update { - proxy_pass $watchtower; - } - location ~ ^/(builder|app_) { proxy_http_version 1.1; @@ -125,6 +106,12 @@ http { location ~ ^/api/(system|admin|global)/ { proxy_set_header Host $host; + + # Enable buffering for potentially large OIDC configs + proxy_buffering on; + proxy_buffer_size 16k; + proxy_buffers 4 32k; + proxy_pass $worker; } diff --git a/hosting/scripts/airgapped/airgappedDockerBuild.js b/hosting/scripts/airgapped/airgappedDockerBuild.js index 58bc7c09a9..432ea9a370 100755 --- a/hosting/scripts/airgapped/airgappedDockerBuild.js +++ b/hosting/scripts/airgapped/airgappedDockerBuild.js @@ -12,7 +12,6 @@ let IMAGES = { couch: "ibmcom/couchdb3", curl: "curlimages/curl", redis: "redis", - watchtower: "containrrr/watchtower", } if (IS_SINGLE_IMAGE) { @@ -53,4 +52,4 @@ if (!IS_SINGLE_IMAGE) { copyFile(FILES.ENV) // compress -execSync(`tar -czf bb-airgapped.tar.gz hosting/scripts/bb-airgapped`) \ No newline at end of file +execSync(`tar -czf bb-airgapped.tar.gz hosting/scripts/bb-airgapped`) diff --git a/lerna.json b/lerna.json index 02471fb63d..dc238bb392 100644 --- a/lerna.json +++ b/lerna.json @@ -1,12 +1,7 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "3.1.0", + "version": "3.2.12", "npmClient": "yarn", - "packages": [ - "packages/*", - "!packages/account-portal", - "packages/account-portal/packages/*" - ], "concurrency": 20, "command": { "publish": { diff --git a/nx.json b/nx.json index fb05ea94d0..22b23a7874 100644 --- a/nx.json +++ b/nx.json @@ -2,7 +2,6 @@ "$schema": "./node_modules/nx/schemas/nx-schema.json", "tasksRunnerOptions": { "default": { - "runner": "nx-cloud", "options": { "cacheableOperations": ["build", "test", "check:types"] } diff --git a/package.json b/package.json index fc7e202e3d..e354f36d2a 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "@types/node": "20.10.0", "@types/proper-lockfile": "^4.1.4", "@typescript-eslint/parser": "6.9.0", + "depcheck": "^1.4.7", "esbuild": "^0.18.17", "esbuild-node-externals": "^1.14.0", "eslint": "^8.52.0", @@ -24,22 +25,22 @@ "prettier": "2.8.8", "prettier-plugin-svelte": "^2.3.0", "proper-lockfile": "^4.1.2", - "svelte": "^4.2.10", + "svelte": "4.2.19", "svelte-eslint-parser": "^0.33.1", "typescript": "5.5.2", "typescript-eslint": "^7.3.1", - "yargs": "^17.7.2" + "yargs": "^17.7.2", + "cross-spawn": "7.0.6" }, "scripts": { "get-past-client-version": "node scripts/getPastClientVersion.js", "setup": "git config submodule.recurse true && git submodule update && node ./hosting/scripts/setup.js && yarn && yarn build && yarn dev", "build": "DISABLE_V8_COMPILE_CACHE=1 NODE_OPTIONS=--max-old-space-size=1500 lerna run build --stream", "build:apps": "DISABLE_V8_COMPILE_CACHE=1 yarn build --scope @budibase/server --scope @budibase/worker", - "build:oss": "DISABLE_V8_COMPILE_CACHE=1 NODE_OPTIONS=--max-old-space-size=1500 lerna run build --stream --ignore @budibase/account-portal-server --ignore @budibase/account-portal-ui", "build:cli": "yarn build --scope @budibase/cli", - "build:account-portal": "NODE_OPTIONS=--max-old-space-size=1500 lerna run build --stream --scope @budibase/account-portal-server --scope @budibase/account-portal-ui", "build:dev": "lerna run --stream prebuild && yarn nx run-many --target=build --output-style=dynamic --watch --preserveWatchOutput", - "check:types": "lerna run --concurrency 2 check:types --ignore @budibase/account-portal-server", + "check:types": "yarn check:dependencies && lerna run --concurrency 2 check:types", + "check:dependencies": "lerna run --concurrency 2 check:dependencies", "build:sdk": "lerna run --stream build:sdk", "deps:circular": "madge packages/server/dist/index.js packages/worker/src/index.ts packages/backend-core/dist/src/index.js packages/cli/src/index.js --circular", "release": "lerna publish from-package --yes --force-publish --no-git-tag-version --no-push --no-git-reset", @@ -52,15 +53,12 @@ "kill-server": "kill-port 4001 4002", "kill-accountportal": "kill-port 3001 4003", "kill-all": "yarn run kill-builder && yarn run kill-server && yarn kill-accountportal", - "dev": "yarn run kill-all && lerna run --parallel prebuild && lerna run --stream dev --ignore=@budibase/account-portal-ui --ignore @budibase/account-portal-server", - "dev:noserver": "yarn run kill-builder && lerna run --stream dev:stack:up --ignore @budibase/account-portal-server && lerna run --stream dev --ignore @budibase/backend-core --ignore @budibase/server --ignore @budibase/worker --ignore=@budibase/account-portal-ui --ignore @budibase/account-portal-server", + "dev": "yarn run kill-all && lerna run --parallel prebuild && lerna run --stream dev", + "dev:noserver": "yarn run kill-builder && lerna run --stream dev:stack:up && lerna run --stream dev --ignore @budibase/backend-core --ignore @budibase/server --ignore @budibase/worker", "dev:server": "yarn run kill-server && lerna run --stream dev --scope @budibase/worker --scope @budibase/server", - "dev:accountportal": "yarn kill-accountportal && lerna run dev --stream --scope @budibase/account-portal-ui --scope @budibase/account-portal-server", - "dev:camunda": "./scripts/deploy-camunda.sh", - "dev:all": "yarn run kill-all && lerna run --stream dev", "dev:built": "yarn run kill-all && cd packages/server && yarn dev:stack:up && cd ../../ && lerna run --stream dev:built", "dev:docker": "./scripts/devDocker.sh", - "test": "lerna run --concurrency 1 --stream test --stream", + "test": "lerna run --concurrency 1 --stream test", "test:containers:kill": "./scripts/killTestcontainers.sh", "lint:eslint": "eslint packages --max-warnings=0", "lint:prettier": "prettier --check \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\"", @@ -98,9 +96,7 @@ }, "workspaces": { "packages": [ - "packages/*", - "!packages/account-portal", - "packages/account-portal/packages/*" + "packages/*" ] }, "resolutions": { @@ -114,7 +110,7 @@ "semver": "7.5.3", "http-cache-semantics": "4.1.1", "msgpackr": "1.10.1", - "axios": "1.6.3", + "axios": "1.7.7", "xml2js": "0.6.2", "unset-value": "2.0.1", "passport": "0.6.0", @@ -124,6 +120,5 @@ }, "engines": { "node": ">=20.0.0 <21.0.0" - }, - "dependencies": {} + } } diff --git a/packages/account-portal b/packages/account-portal deleted file mode 160000 index 9bef5d1656..0000000000 --- a/packages/account-portal +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 9bef5d1656b4f3c991447ded6d65b0eba393a140 diff --git a/packages/backend-core/.npmignore b/packages/backend-core/.npmignore index 30bba85ce8..fb547825eb 100644 --- a/packages/backend-core/.npmignore +++ b/packages/backend-core/.npmignore @@ -1,6 +1,4 @@ * !dist/**/* dist/tsconfig.build.tsbuildinfo -!package.json -!src/** -!tests/** \ No newline at end of file +!package.json \ No newline at end of file diff --git a/packages/backend-core/package.json b/packages/backend-core/package.json index b68cba5fd9..a4381b4200 100644 --- a/packages/backend-core/package.json +++ b/packages/backend-core/package.json @@ -9,6 +9,13 @@ "./tests": "./dist/tests/index.js", "./*": "./dist/*.js" }, + "typesVersions": { + "*": { + "tests": [ + "dist/tests/index.d.ts" + ] + } + }, "author": "Budibase", "license": "GPL-3.0", "scripts": { @@ -17,6 +24,7 @@ "build": "tsc -p tsconfig.build.json --paths null && node ./scripts/build.js", "build:dev": "yarn prebuild && tsc --build --watch --preserveWatchOutput", "check:types": "tsc -p tsconfig.json --noEmit --paths null --target es2020", + "check:dependencies": "node ../../scripts/depcheck.js", "test": "bash scripts/test.sh", "test:watch": "jest --watchAll" }, @@ -25,17 +33,21 @@ "@budibase/pouchdb-replication-stream": "1.2.11", "@budibase/shared-core": "0.0.0", "@budibase/types": "0.0.0", + "@techpass/passport-openidconnect": "0.3.3", "aws-cloudfront-sign": "3.0.2", - "aws-sdk": "2.1030.0", + "aws-sdk": "2.1692.0", "bcrypt": "5.1.0", "bcryptjs": "2.4.3", "bull": "4.10.1", "correlation-id": "4.0.0", - "dd-trace": "5.2.0", + "dd-trace": "5.26.0", "dotenv": "16.0.1", + "google-auth-library": "^8.0.1", + "google-spreadsheet": "npm:@budibase/google-spreadsheet@4.1.5", "ioredis": "5.3.2", "joi": "17.6.0", "jsonwebtoken": "9.0.2", + "knex": "2.4.2", "koa-passport": "^6.0.0", "koa-pino-logger": "4.0.0", "lodash": "4.17.21", @@ -46,17 +58,17 @@ "pino": "8.11.0", "pino-http": "8.3.3", "posthog-node": "4.0.1", - "pouchdb": "7.3.0", - "pouchdb-find": "7.2.2", + "pouchdb": "9.0.0", + "pouchdb-find": "9.0.0", "redlock": "4.2.0", "rotating-file-stream": "3.1.0", "sanitize-s3-objectkey": "0.0.1", "semver": "^7.5.4", "tar-fs": "2.1.1", - "uuid": "^8.3.2", - "knex": "2.4.2" + "uuid": "^8.3.2" }, "devDependencies": { + "@jest/types": "^29.6.3", "@shopify/jest-koa-mocks": "5.1.1", "@swc/core": "1.3.71", "@swc/jest": "0.2.27", @@ -64,8 +76,9 @@ "@types/cookies": "0.7.8", "@types/jest": "29.5.5", "@types/lodash": "4.14.200", + "@types/node": "^22.9.0", "@types/node-fetch": "2.6.4", - "@types/pouchdb": "6.4.0", + "@types/pouchdb": "6.4.2", "@types/redlock": "4.0.7", "@types/semver": "7.3.7", "@types/tar-fs": "2.0.1", @@ -74,6 +87,7 @@ "ioredis-mock": "8.9.0", "jest": "29.7.0", "jest-serial-runner": "1.2.1", + "nock": "^13.5.6", "pino-pretty": "10.0.0", "pouchdb-adapter-memory": "7.2.2", "testcontainers": "^10.7.2", diff --git a/packages/backend-core/src/cache/tests/docWritethrough.spec.ts b/packages/backend-core/src/cache/tests/docWritethrough.spec.ts index 47b3f0672f..b72651e21f 100644 --- a/packages/backend-core/src/cache/tests/docWritethrough.spec.ts +++ b/packages/backend-core/src/cache/tests/docWritethrough.spec.ts @@ -1,7 +1,12 @@ import tk from "timekeeper" import _ from "lodash" -import { DBTestConfiguration, generator, structures } from "../../../tests" +import { + DBTestConfiguration, + generator, + structures, + utils, +} from "../../../tests" import { getDB } from "../../db" import { @@ -10,15 +15,14 @@ import { init, } from "../docWritethrough" -import InMemoryQueue from "../../queue/inMemoryQueue" - const initialTime = Date.now() async function waitForQueueCompletion() { - const queue: InMemoryQueue = DocWritethroughProcessor.queue as never - await queue.waitForCompletion() + await utils.queue.processMessages(DocWritethroughProcessor.queue) } +beforeAll(() => utils.queue.useRealQueues()) + describe("docWritethrough", () => { beforeAll(() => { init() @@ -67,7 +71,7 @@ describe("docWritethrough", () => { const patch3 = generatePatchObject(3) await docWritethrough.patch(patch3) - expect(await db.get(documentId)).toEqual({ + expect(await db.tryGet(documentId)).toEqual({ _id: documentId, ...patch1, ...patch2, @@ -92,7 +96,7 @@ describe("docWritethrough", () => { await waitForQueueCompletion() - expect(await db.get(documentId)).toEqual( + expect(await db.tryGet(documentId)).toEqual( expect.objectContaining({ _id: documentId, ...patch1, @@ -117,7 +121,7 @@ describe("docWritethrough", () => { await waitForQueueCompletion() expect(date1).not.toEqual(date2) - expect(await db.get(documentId)).toEqual( + expect(await db.tryGet(documentId)).toEqual( expect.objectContaining({ createdAt: date1.toISOString(), updatedAt: date2.toISOString(), @@ -135,7 +139,7 @@ describe("docWritethrough", () => { await docWritethrough.patch(patch2) const keyToOverride = _.sample(Object.keys(patch1))! - expect(await db.get(documentId)).toEqual( + expect(await db.tryGet(documentId)).toEqual( expect.objectContaining({ [keyToOverride]: patch1[keyToOverride], }) @@ -150,7 +154,7 @@ describe("docWritethrough", () => { await docWritethrough.patch(patch3) await waitForQueueCompletion() - expect(await db.get(documentId)).toEqual( + expect(await db.tryGet(documentId)).toEqual( expect.objectContaining({ ...patch1, ...patch2, @@ -180,14 +184,14 @@ describe("docWritethrough", () => { await secondDocWritethrough.patch(doc2Patch2) await waitForQueueCompletion() - expect(await db.get(docWritethrough.docId)).toEqual( + expect(await db.tryGet(docWritethrough.docId)).toEqual( expect.objectContaining({ ...doc1Patch, ...doc1Patch2, }) ) - expect(await db.get(secondDocWritethrough.docId)).toEqual( + expect(await db.tryGet(secondDocWritethrough.docId)).toEqual( expect.objectContaining({ ...doc2Patch, ...doc2Patch2, @@ -203,7 +207,7 @@ describe("docWritethrough", () => { await docWritethrough.patch(initialPatch) await waitForQueueCompletion() - expect(await db.get(documentId)).toEqual( + expect(await db.tryGet(documentId)).toEqual( expect.objectContaining(initialPatch) ) @@ -214,10 +218,10 @@ describe("docWritethrough", () => { await docWritethrough.patch(extraPatch) await waitForQueueCompletion() - expect(await db.get(documentId)).toEqual( + expect(await db.tryGet(documentId)).toEqual( expect.objectContaining(extraPatch) ) - expect(await db.get(documentId)).not.toEqual( + expect(await db.tryGet(documentId)).not.toEqual( expect.objectContaining(initialPatch) ) }) @@ -242,7 +246,7 @@ describe("docWritethrough", () => { expect(queueMessageSpy).toHaveBeenCalledTimes(5) await waitForQueueCompletion() - expect(await db.get(documentId)).toEqual( + expect(await db.tryGet(documentId)).toEqual( expect.objectContaining(patches) ) @@ -250,7 +254,7 @@ describe("docWritethrough", () => { expect(queueMessageSpy).toHaveBeenCalledTimes(45) await waitForQueueCompletion() - expect(await db.get(documentId)).toEqual( + expect(await db.tryGet(documentId)).toEqual( expect.objectContaining(patches) ) @@ -258,20 +262,18 @@ describe("docWritethrough", () => { expect(queueMessageSpy).toHaveBeenCalledTimes(55) await waitForQueueCompletion() - expect(await db.get(documentId)).toEqual( + expect(await db.tryGet(documentId)).toEqual( expect.objectContaining(patches) ) }) }) - // This is not yet supported - // eslint-disable-next-line jest/no-disabled-tests - it.skip("patches will execute in order", async () => { + it("patches will execute in order", async () => { let incrementalValue = 0 const keyToOverride = generator.word() async function incrementalPatches(count: number) { for (let i = 0; i < count; i++) { - await docWritethrough.patch({ [keyToOverride]: incrementalValue++ }) + await docWritethrough.patch({ [keyToOverride]: ++incrementalValue }) } } @@ -279,13 +281,13 @@ describe("docWritethrough", () => { await incrementalPatches(5) await waitForQueueCompletion() - expect(await db.get(documentId)).toEqual( + expect(await db.tryGet(documentId)).toEqual( expect.objectContaining({ [keyToOverride]: 5 }) ) await incrementalPatches(40) await waitForQueueCompletion() - expect(await db.get(documentId)).toEqual( + expect(await db.tryGet(documentId)).toEqual( expect.objectContaining({ [keyToOverride]: 45 }) ) }) diff --git a/packages/backend-core/src/db/couch/DatabaseImpl.ts b/packages/backend-core/src/db/couch/DatabaseImpl.ts index b807db0ee3..371f3dc997 100644 --- a/packages/backend-core/src/db/couch/DatabaseImpl.ts +++ b/packages/backend-core/src/db/couch/DatabaseImpl.ts @@ -10,7 +10,6 @@ import { DatabaseQueryOpts, DBError, Document, - FeatureFlag, isDocument, RowResponse, RowValue, @@ -27,7 +26,6 @@ import { SQLITE_DESIGN_DOC_ID } from "../../constants" import { DDInstrumentedDatabase } from "../instrumentation" import { checkSlashesInUrl } from "../../helpers" import { sqlLog } from "../../sql/utils" -import { flags } from "../../features" const DATABASE_NOT_FOUND = "Database does not exist." @@ -192,7 +190,7 @@ export class DatabaseImpl implements Database { } } - private async performCall(call: DBCallback): Promise { + private async performCall(call: DBCallback): Promise { const db = this.getDb() const fnc = await call(db) try { @@ -456,10 +454,7 @@ export class DatabaseImpl implements Database { } async destroy() { - if ( - (await flags.isEnabled(FeatureFlag.SQS)) && - (await this.exists(SQLITE_DESIGN_DOC_ID)) - ) { + if (await this.exists(SQLITE_DESIGN_DOC_ID)) { // delete the design document, then run the cleanup operation const definition = await this.get(SQLITE_DESIGN_DOC_ID) // remove all tables - save the definition then trigger a cleanup @@ -472,7 +467,7 @@ export class DatabaseImpl implements Database { } catch (err: any) { // didn't exist, don't worry if (err.statusCode === 404) { - return + return { ok: true } } else { throw new CouchDBError(err.message, err) } diff --git a/packages/backend-core/src/db/instrumentation.ts b/packages/backend-core/src/db/instrumentation.ts index e08bfc0362..0c0056d6ed 100644 --- a/packages/backend-core/src/db/instrumentation.ts +++ b/packages/backend-core/src/db/instrumentation.ts @@ -27,7 +27,7 @@ export class DDInstrumentedDatabase implements Database { exists(docId?: string): Promise { return tracer.trace("db.exists", span => { - span?.addTags({ db_name: this.name, doc_id: docId }) + span.addTags({ db_name: this.name, doc_id: docId }) if (docId) { return this.db.exists(docId) } @@ -37,15 +37,17 @@ export class DDInstrumentedDatabase implements Database { get(id?: string | undefined): Promise { return tracer.trace("db.get", span => { - span?.addTags({ db_name: this.name, doc_id: id }) + span.addTags({ db_name: this.name, doc_id: id }) return this.db.get(id) }) } tryGet(id?: string | undefined): Promise { - return tracer.trace("db.tryGet", span => { - span?.addTags({ db_name: this.name, doc_id: id }) - return this.db.tryGet(id) + return tracer.trace("db.tryGet", async span => { + span.addTags({ db_name: this.name, doc_id: id }) + const doc = await this.db.tryGet(id) + span.addTags({ doc_found: doc !== undefined }) + return doc }) } @@ -53,13 +55,15 @@ export class DDInstrumentedDatabase implements Database { ids: string[], opts?: { allowMissing?: boolean | undefined } | undefined ): Promise { - return tracer.trace("db.getMultiple", span => { - span?.addTags({ + return tracer.trace("db.getMultiple", async span => { + span.addTags({ db_name: this.name, num_docs: ids.length, allow_missing: opts?.allowMissing, }) - return this.db.getMultiple(ids, opts) + const docs = await this.db.getMultiple(ids, opts) + span.addTags({ num_docs_found: docs.length }) + return docs }) } @@ -69,12 +73,14 @@ export class DDInstrumentedDatabase implements Database { idOrDoc: string | Document, rev?: string ): Promise { - return tracer.trace("db.remove", span => { - span?.addTags({ db_name: this.name, doc_id: idOrDoc }) + return tracer.trace("db.remove", async span => { + span.addTags({ db_name: this.name, doc_id: idOrDoc, rev }) const isDocument = typeof idOrDoc === "object" const id = isDocument ? idOrDoc._id! : idOrDoc rev = isDocument ? idOrDoc._rev : rev - return this.db.remove(id, rev) + const resp = await this.db.remove(id, rev) + span.addTags({ ok: resp.ok }) + return resp }) } @@ -83,7 +89,11 @@ export class DDInstrumentedDatabase implements Database { opts?: { silenceErrors?: boolean } ): Promise { return tracer.trace("db.bulkRemove", span => { - span?.addTags({ db_name: this.name, num_docs: documents.length }) + span.addTags({ + db_name: this.name, + num_docs: documents.length, + silence_errors: opts?.silenceErrors, + }) return this.db.bulkRemove(documents, opts) }) } @@ -92,15 +102,21 @@ export class DDInstrumentedDatabase implements Database { document: AnyDocument, opts?: DatabasePutOpts | undefined ): Promise { - return tracer.trace("db.put", span => { - span?.addTags({ db_name: this.name, doc_id: document._id }) - return this.db.put(document, opts) + return tracer.trace("db.put", async span => { + span.addTags({ + db_name: this.name, + doc_id: document._id, + force: opts?.force, + }) + const resp = await this.db.put(document, opts) + span.addTags({ ok: resp.ok }) + return resp }) } bulkDocs(documents: AnyDocument[]): Promise { return tracer.trace("db.bulkDocs", span => { - span?.addTags({ db_name: this.name, num_docs: documents.length }) + span.addTags({ db_name: this.name, num_docs: documents.length }) return this.db.bulkDocs(documents) }) } @@ -108,9 +124,15 @@ export class DDInstrumentedDatabase implements Database { allDocs( params: DatabaseQueryOpts ): Promise> { - return tracer.trace("db.allDocs", span => { - span?.addTags({ db_name: this.name }) - return this.db.allDocs(params) + return tracer.trace("db.allDocs", async span => { + span.addTags({ db_name: this.name, ...params }) + const resp = await this.db.allDocs(params) + span.addTags({ + total_rows: resp.total_rows, + rows_length: resp.rows.length, + offset: resp.offset, + }) + return resp }) } @@ -118,57 +140,75 @@ export class DDInstrumentedDatabase implements Database { viewName: string, params: DatabaseQueryOpts ): Promise> { - return tracer.trace("db.query", span => { - span?.addTags({ db_name: this.name, view_name: viewName }) - return this.db.query(viewName, params) + return tracer.trace("db.query", async span => { + span.addTags({ db_name: this.name, view_name: viewName, ...params }) + const resp = await this.db.query(viewName, params) + span.addTags({ + total_rows: resp.total_rows, + rows_length: resp.rows.length, + offset: resp.offset, + }) + return resp }) } - destroy(): Promise { - return tracer.trace("db.destroy", span => { - span?.addTags({ db_name: this.name }) - return this.db.destroy() + destroy(): Promise { + return tracer.trace("db.destroy", async span => { + span.addTags({ db_name: this.name }) + const resp = await this.db.destroy() + span.addTags({ ok: resp.ok }) + return resp }) } - compact(): Promise { - return tracer.trace("db.compact", span => { - span?.addTags({ db_name: this.name }) - return this.db.compact() + compact(): Promise { + return tracer.trace("db.compact", async span => { + span.addTags({ db_name: this.name }) + const resp = await this.db.compact() + span.addTags({ ok: resp.ok }) + return resp }) } dump(stream: Writable, opts?: DatabaseDumpOpts | undefined): Promise { return tracer.trace("db.dump", span => { - span?.addTags({ db_name: this.name }) + span.addTags({ + db_name: this.name, + batch_limit: opts?.batch_limit, + batch_size: opts?.batch_size, + style: opts?.style, + timeout: opts?.timeout, + num_doc_ids: opts?.doc_ids?.length, + view: opts?.view, + }) return this.db.dump(stream, opts) }) } load(...args: any[]): Promise { return tracer.trace("db.load", span => { - span?.addTags({ db_name: this.name }) + span.addTags({ db_name: this.name, num_args: args.length }) return this.db.load(...args) }) } createIndex(...args: any[]): Promise { return tracer.trace("db.createIndex", span => { - span?.addTags({ db_name: this.name }) + span.addTags({ db_name: this.name, num_args: args.length }) return this.db.createIndex(...args) }) } deleteIndex(...args: any[]): Promise { return tracer.trace("db.deleteIndex", span => { - span?.addTags({ db_name: this.name }) + span.addTags({ db_name: this.name, num_args: args.length }) return this.db.deleteIndex(...args) }) } getIndexes(...args: any[]): Promise { return tracer.trace("db.getIndexes", span => { - span?.addTags({ db_name: this.name }) + span.addTags({ db_name: this.name, num_args: args.length }) return this.db.getIndexes(...args) }) } @@ -177,22 +217,27 @@ export class DDInstrumentedDatabase implements Database { sql: string, parameters?: SqlQueryBinding ): Promise { - return tracer.trace("db.sql", span => { - span?.addTags({ db_name: this.name }) - return this.db.sql(sql, parameters) + return tracer.trace("db.sql", async span => { + span.addTags({ db_name: this.name, num_bindings: parameters?.length }) + const resp = await this.db.sql(sql, parameters) + span.addTags({ num_rows: resp.length }) + return resp }) } sqlPurgeDocument(docIds: string[] | string): Promise { return tracer.trace("db.sqlPurgeDocument", span => { - span?.addTags({ db_name: this.name }) + span.addTags({ + db_name: this.name, + num_docs: Array.isArray(docIds) ? docIds.length : 1, + }) return this.db.sqlPurgeDocument(docIds) }) } sqlDiskCleanup(): Promise { return tracer.trace("db.sqlDiskCleanup", span => { - span?.addTags({ db_name: this.name }) + span.addTags({ db_name: this.name }) return this.db.sqlDiskCleanup() }) } diff --git a/packages/backend-core/src/environment.ts b/packages/backend-core/src/environment.ts index 4cb0a9c731..56d9cd6e10 100644 --- a/packages/backend-core/src/environment.ts +++ b/packages/backend-core/src/environment.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from "fs" import { ServiceType } from "@budibase/types" import { cloneDeep } from "lodash" +import { createSecretKey } from "crypto" function isTest() { return isJest() @@ -18,6 +19,12 @@ function isDev() { return process.env.NODE_ENV !== "production" } +function parseIntSafe(number?: string) { + if (number) { + return parseInt(number) + } +} + let LOADED = false if (!LOADED && isDev() && !isTest()) { require("dotenv").config() @@ -126,8 +133,12 @@ const environment = { }, BUDIBASE_ENVIRONMENT: process.env.BUDIBASE_ENVIRONMENT, JS_BCRYPT: process.env.JS_BCRYPT, - JWT_SECRET: process.env.JWT_SECRET, - JWT_SECRET_FALLBACK: process.env.JWT_SECRET_FALLBACK, + JWT_SECRET: process.env.JWT_SECRET + ? createSecretKey(Buffer.from(process.env.JWT_SECRET)) + : undefined, + JWT_SECRET_FALLBACK: process.env.JWT_SECRET_FALLBACK + ? createSecretKey(Buffer.from(process.env.JWT_SECRET_FALLBACK)) + : undefined, ENCRYPTION_KEY: process.env.ENCRYPTION_KEY, API_ENCRYPTION_KEY: getAPIEncryptionKey(), COUCH_DB_URL: process.env.COUCH_DB_URL || "http://localhost:4005", @@ -225,6 +236,8 @@ const environment = { OPENAI_API_KEY: process.env.OPENAI_API_KEY, MIN_VERSION_WITHOUT_POWER_ROLE: process.env.MIN_VERSION_WITHOUT_POWER_ROLE || "3.0.0", + DISABLE_CONTENT_SECURITY_POLICY: process.env.DISABLE_CONTENT_SECURITY_POLICY, + BSON_BUFFER_SIZE: parseIntSafe(process.env.BSON_BUFFER_SIZE), } export function setEnv(newEnvVars: Partial): () => void { diff --git a/packages/backend-core/src/features/features.ts b/packages/backend-core/src/features/features.ts index b9302f9bce..b3f016e88a 100644 --- a/packages/backend-core/src/features/features.ts +++ b/packages/backend-core/src/features/features.ts @@ -267,12 +267,10 @@ export class FlagSet, T extends { [key: string]: V }> { // All of the machinery in this file is to make sure that flags have their // default values set correctly and their types flow through the system. export const flags = new FlagSet({ - [FeatureFlag.DEFAULT_VALUES]: Flag.boolean(env.isDev()), - [FeatureFlag.AUTOMATION_BRANCHING]: Flag.boolean(env.isDev()), - [FeatureFlag.SQS]: Flag.boolean(true), - [FeatureFlag.AI_CUSTOM_CONFIGS]: Flag.boolean(env.isDev()), - [FeatureFlag.ENRICHED_RELATIONSHIPS]: Flag.boolean(env.isDev()), - [FeatureFlag.BUDIBASE_AI]: Flag.boolean(env.isDev()), + [FeatureFlag.DEFAULT_VALUES]: Flag.boolean(true), + [FeatureFlag.AUTOMATION_BRANCHING]: Flag.boolean(true), + [FeatureFlag.AI_CUSTOM_CONFIGS]: Flag.boolean(true), + [FeatureFlag.BUDIBASE_AI]: Flag.boolean(true), }) type UnwrapPromise = T extends Promise ? U : T diff --git a/packages/backend-core/src/middleware/contentSecurityPolicy.ts b/packages/backend-core/src/middleware/contentSecurityPolicy.ts new file mode 100644 index 0000000000..d1668d3dd5 --- /dev/null +++ b/packages/backend-core/src/middleware/contentSecurityPolicy.ts @@ -0,0 +1,113 @@ +import crypto from "crypto" + +const CSP_DIRECTIVES = { + "default-src": ["'self'"], + "script-src": [ + "'self'", + "'unsafe-eval'", + "https://*.budibase.net", + "https://cdn.budi.live", + "https://js.intercomcdn.com", + "https://widget.intercom.io", + "https://d2l5prqdbvm3op.cloudfront.net", + "https://us-assets.i.posthog.com", + ], + "style-src": [ + "'self'", + "'unsafe-inline'", + "https://cdn.jsdelivr.net", + "https://fonts.googleapis.com", + "https://rsms.me", + "https://maxcdn.bootstrapcdn.com", + ], + "object-src": ["'none'"], + "base-uri": ["'self'"], + "connect-src": [ + "'self'", + "https://*.budibase.app", + "https://*.budibaseqa.app", + "https://*.budibase.net", + "https://api-iam.intercom.io", + "https://api-ping.intercom.io", + "https://app.posthog.com", + "https://us.i.posthog.com", + "wss://nexus-websocket-a.intercom.io", + "wss://nexus-websocket-b.intercom.io", + "https://nexus-websocket-a.intercom.io", + "https://nexus-websocket-b.intercom.io", + "https://uploads.intercomcdn.com", + "https://uploads.intercomusercontent.com", + "https://*.amazonaws.com", + "https://*.s3.amazonaws.com", + "https://*.s3.us-east-2.amazonaws.com", + "https://*.s3.us-east-1.amazonaws.com", + "https://*.s3.us-west-1.amazonaws.com", + "https://*.s3.us-west-2.amazonaws.com", + "https://*.s3.af-south-1.amazonaws.com", + "https://*.s3.ap-east-1.amazonaws.com", + "https://*.s3.ap-south-1.amazonaws.com", + "https://*.s3.ap-northeast-2.amazonaws.com", + "https://*.s3.ap-southeast-1.amazonaws.com", + "https://*.s3.ap-southeast-2.amazonaws.com", + "https://*.s3.ap-northeast-1.amazonaws.com", + "https://*.s3.ca-central-1.amazonaws.com", + "https://*.s3.cn-north-1.amazonaws.com", + "https://*.s3.cn-northwest-1.amazonaws.com", + "https://*.s3.eu-central-1.amazonaws.com", + "https://*.s3.eu-west-1.amazonaws.com", + "https://*.s3.eu-west-2.amazonaws.com", + "https://*.s3.eu-south-1.amazonaws.com", + "https://*.s3.eu-west-3.amazonaws.com", + "https://*.s3.eu-north-1.amazonaws.com", + "https://*.s3.sa-east-1.amazonaws.com", + "https://*.s3.me-south-1.amazonaws.com", + "https://*.s3.us-gov-east-1.amazonaws.com", + "https://*.s3.us-gov-west-1.amazonaws.com", + "https://api.github.com", + ], + "font-src": [ + "'self'", + "data:", + "https://cdn.jsdelivr.net", + "https://fonts.gstatic.com", + "https://rsms.me", + "https://maxcdn.bootstrapcdn.com", + "https://js.intercomcdn.com", + "https://fonts.intercomcdn.com", + ], + "frame-src": ["'self'", "https:"], + "img-src": ["http:", "https:", "data:", "blob:"], + "manifest-src": ["'self'"], + "media-src": [ + "'self'", + "https://js.intercomcdn.com", + "https://cdn.budi.live", + ], + "worker-src": ["blob:"], +} + +export async function contentSecurityPolicy(ctx: any, next: any) { + try { + const nonce = crypto.randomBytes(16).toString("base64") + + const directives = { ...CSP_DIRECTIVES } + directives["script-src"] = [ + ...CSP_DIRECTIVES["script-src"], + `'nonce-${nonce}'`, + ] + + ctx.state.nonce = nonce + + const cspHeader = Object.entries(directives) + .map(([key, sources]) => `${key} ${sources.join(" ")}`) + .join("; ") + ctx.set("Content-Security-Policy", cspHeader) + await next() + } catch (err: any) { + console.error( + `Error occurred in Content-Security-Policy middleware: ${err}` + ) + } +} + +export default contentSecurityPolicy diff --git a/packages/backend-core/src/middleware/index.ts b/packages/backend-core/src/middleware/index.ts index 20c2125b13..9ee51db45b 100644 --- a/packages/backend-core/src/middleware/index.ts +++ b/packages/backend-core/src/middleware/index.ts @@ -19,5 +19,6 @@ export { default as pino } from "../logging/pino/middleware" export { default as correlation } from "../logging/correlation/middleware" export { default as errorHandling } from "./errorHandling" export { default as querystringToBody } from "./querystringToBody" +export { default as csp } from "./contentSecurityPolicy" export * as joiValidator from "./joi-validator" export { default as ip } from "./ip" diff --git a/packages/backend-core/src/middleware/tests/contentSecurityPolicy.spec.ts b/packages/backend-core/src/middleware/tests/contentSecurityPolicy.spec.ts new file mode 100644 index 0000000000..0c5838e7fe --- /dev/null +++ b/packages/backend-core/src/middleware/tests/contentSecurityPolicy.spec.ts @@ -0,0 +1,75 @@ +import crypto from "crypto" +import contentSecurityPolicy from "../contentSecurityPolicy" + +jest.mock("crypto", () => ({ + randomBytes: jest.fn(), + randomUUID: jest.fn(), +})) + +describe("contentSecurityPolicy middleware", () => { + let ctx: any + let next: any + const mockNonce = "mocked/nonce" + + beforeEach(() => { + ctx = { + state: {}, + set: jest.fn(), + } + next = jest.fn() + // @ts-ignore + crypto.randomBytes.mockReturnValue(Buffer.from(mockNonce, "base64")) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it("should generate a nonce and set it in the script-src directive", async () => { + await contentSecurityPolicy(ctx, next) + + expect(ctx.state.nonce).toBe(mockNonce) + expect(ctx.set).toHaveBeenCalledWith( + "Content-Security-Policy", + expect.stringContaining( + `script-src 'self' 'unsafe-eval' https://*.budibase.net https://cdn.budi.live https://js.intercomcdn.com https://widget.intercom.io https://d2l5prqdbvm3op.cloudfront.net https://us-assets.i.posthog.com 'nonce-${mockNonce}'` + ) + ) + expect(next).toHaveBeenCalled() + }) + + it("should include all CSP directives in the header", async () => { + await contentSecurityPolicy(ctx, next) + + const cspHeader = ctx.set.mock.calls[0][1] + expect(cspHeader).toContain("default-src 'self'") + expect(cspHeader).toContain("script-src 'self' 'unsafe-eval'") + expect(cspHeader).toContain("style-src 'self' 'unsafe-inline'") + expect(cspHeader).toContain("object-src 'none'") + expect(cspHeader).toContain("base-uri 'self'") + expect(cspHeader).toContain("connect-src 'self'") + expect(cspHeader).toContain("font-src 'self'") + expect(cspHeader).toContain("frame-src 'self'") + expect(cspHeader).toContain("img-src http: https: data: blob:") + expect(cspHeader).toContain("manifest-src 'self'") + expect(cspHeader).toContain("media-src 'self'") + expect(cspHeader).toContain("worker-src blob:") + }) + + it("should handle errors and log an error message", async () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation() + const error = new Error("Test error") + // @ts-ignore + crypto.randomBytes.mockImplementation(() => { + throw error + }) + + await contentSecurityPolicy(ctx, next) + + expect(consoleSpy).toHaveBeenCalledWith( + `Error occurred in Content-Security-Policy middleware: ${error}` + ) + expect(next).not.toHaveBeenCalled() + consoleSpy.mockRestore() + }) +}) diff --git a/packages/backend-core/src/queue/inMemoryQueue.ts b/packages/backend-core/src/queue/inMemoryQueue.ts index 62b971f9f5..dd8d3daa37 100644 --- a/packages/backend-core/src/queue/inMemoryQueue.ts +++ b/packages/backend-core/src/queue/inMemoryQueue.ts @@ -1,5 +1,5 @@ import events from "events" -import { newid, timeout } from "../utils" +import { newid } from "../utils" import { Queue, QueueOptions, JobOptions } from "./queue" interface JobMessage { @@ -141,7 +141,7 @@ class InMemoryQueue implements Partial { } else { pushMessage() } - return {} as any + return { id: jobId } as any } /** @@ -184,16 +184,6 @@ class InMemoryQueue implements Partial { // do nothing return this as any } - - async waitForCompletion() { - do { - await timeout(50) - } while (this.hasRunningJobs()) - } - - hasRunningJobs() { - return this._addCount > this._runCount - } } export default InMemoryQueue diff --git a/packages/backend-core/src/queue/queue.ts b/packages/backend-core/src/queue/queue.ts index f633d0885e..f5d710f02d 100644 --- a/packages/backend-core/src/queue/queue.ts +++ b/packages/backend-core/src/queue/queue.ts @@ -15,7 +15,7 @@ const QUEUE_LOCK_MS = Duration.fromMinutes(5).toMs() const QUEUE_LOCK_RENEW_INTERNAL_MS = Duration.fromSeconds(30).toMs() // cleanup the queue every 60 seconds const CLEANUP_PERIOD_MS = Duration.fromSeconds(60).toMs() -let QUEUES: BullQueue.Queue[] | InMemoryQueue[] = [] +let QUEUES: BullQueue.Queue[] = [] let cleanupInterval: NodeJS.Timeout async function cleanup() { @@ -45,11 +45,18 @@ export function createQueue( if (opts.jobOptions) { queueConfig.defaultJobOptions = opts.jobOptions } - let queue: any + let queue: BullQueue.Queue if (!env.isTest()) { queue = new BullQueue(jobQueue, queueConfig) + } else if ( + process.env.BULL_TEST_REDIS_PORT && + !isNaN(+process.env.BULL_TEST_REDIS_PORT) + ) { + queue = new BullQueue(jobQueue, { + redis: { host: "localhost", port: +process.env.BULL_TEST_REDIS_PORT }, + }) } else { - queue = new InMemoryQueue(jobQueue, queueConfig) + queue = new InMemoryQueue(jobQueue, queueConfig) as any } addListeners(queue, jobQueue, opts?.removeStalledCb) QUEUES.push(queue) diff --git a/packages/backend-core/src/security/tests/encryption.spec.ts b/packages/backend-core/src/security/tests/encryption.spec.ts index 0b7eb96b68..8e0af846bd 100644 --- a/packages/backend-core/src/security/tests/encryption.spec.ts +++ b/packages/backend-core/src/security/tests/encryption.spec.ts @@ -4,7 +4,7 @@ import env from "../../environment" describe("encryption", () => { it("should throw an error if API encryption key is not set", () => { const jwt = getSecret(SecretOption.API) - expect(jwt).toBe(env.JWT_SECRET) + expect(jwt).toBe(env.JWT_SECRET?.export().toString()) }) it("should throw an error if encryption key is not set", () => { diff --git a/packages/backend-core/tests/core/utilities/index.ts b/packages/backend-core/tests/core/utilities/index.ts index 787d69be2c..c3d81784c8 100644 --- a/packages/backend-core/tests/core/utilities/index.ts +++ b/packages/backend-core/tests/core/utilities/index.ts @@ -4,3 +4,4 @@ export { generator } from "./structures" export * as testContainerUtils from "./testContainerUtils" export * as utils from "./utils" export * from "./jestUtils" +export * as queue from "./queue" diff --git a/packages/backend-core/tests/core/utilities/queue.ts b/packages/backend-core/tests/core/utilities/queue.ts new file mode 100644 index 0000000000..49dd33ca29 --- /dev/null +++ b/packages/backend-core/tests/core/utilities/queue.ts @@ -0,0 +1,9 @@ +import { Queue } from "bull" + +export async function processMessages(queue: Queue) { + do { + await queue.whenCurrentJobsFinished() + } while (await queue.count()) + + await queue.whenCurrentJobsFinished() +} diff --git a/packages/backend-core/tests/core/utilities/testContainerUtils.ts b/packages/backend-core/tests/core/utilities/testContainerUtils.ts index 1a25bb28f4..71d7fa32db 100644 --- a/packages/backend-core/tests/core/utilities/testContainerUtils.ts +++ b/packages/backend-core/tests/core/utilities/testContainerUtils.ts @@ -1,4 +1,6 @@ import { execSync } from "child_process" +import { cloneDeep } from "lodash" +import { GenericContainer, StartedTestContainer } from "testcontainers" const IPV4_PORT_REGEX = new RegExp(`0\\.0\\.0\\.0:(\\d+)->(\\d+)/tcp`, "g") @@ -106,3 +108,58 @@ export function setupEnv(...envs: any[]) { } } } + +export async function startContainer(container: GenericContainer) { + const imageName = (container as any).imageName.string as string + let key: string = imageName + if (imageName.includes("@sha256")) { + key = imageName.split("@")[0] + } + key = key.replace(/\//g, "-").replace(/:/g, "-") + + container = container + .withReuse() + .withLabels({ "com.budibase": "true" }) + .withName(`${key}_testcontainer`) + + let startedContainer: StartedTestContainer | undefined = undefined + let lastError = undefined + for (let i = 0; i < 10; i++) { + try { + // container.start() is not an idempotent operation, calling `start` + // modifies the internal state of a GenericContainer instance such that + // the hash it uses to determine reuse changes. We need to clone the + // container before calling start to ensure that we're using the same + // reuse hash every time. + const containerCopy = cloneDeep(container) + startedContainer = await containerCopy.start() + lastError = undefined + break + } catch (e: any) { + lastError = e + await new Promise(resolve => setTimeout(resolve, 1000)) + } + } + + if (!startedContainer) { + if (lastError) { + throw lastError + } + throw new Error(`failed to start container: ${imageName}`) + } + + const info = getContainerById(startedContainer.getId()) + if (!info) { + throw new Error("Container not found") + } + + // Some Docker runtimes, when you expose a port, will bind it to both + // 127.0.0.1 and ::1, so ipv4 and ipv6. The port spaces of ipv4 and ipv6 + // addresses are not shared, and testcontainers will sometimes give you back + // the ipv6 port. There's no way to know that this has happened, and if you + // try to then connect to `localhost:port` you may attempt to bind to the v4 + // address which could be unbound or even an entirely different container. For + // that reason, we don't use testcontainers' `getExposedPort` function, + // preferring instead our own method that guaranteed v4 ports. + return getExposedV4Ports(info) +} diff --git a/packages/backend-core/tests/core/utilities/utils/index.ts b/packages/backend-core/tests/core/utilities/utils/index.ts index 41a249c7e6..3d28189c53 100644 --- a/packages/backend-core/tests/core/utilities/utils/index.ts +++ b/packages/backend-core/tests/core/utilities/utils/index.ts @@ -1 +1,2 @@ export * as time from "./time" +export * as queue from "./queue" diff --git a/packages/backend-core/tests/core/utilities/utils/queue.ts b/packages/backend-core/tests/core/utilities/utils/queue.ts new file mode 100644 index 0000000000..3ad7d6b4b4 --- /dev/null +++ b/packages/backend-core/tests/core/utilities/utils/queue.ts @@ -0,0 +1,27 @@ +import { Queue } from "bull" +import { GenericContainer, Wait } from "testcontainers" +import { startContainer } from "../testContainerUtils" + +export async function useRealQueues() { + const ports = await startContainer( + new GenericContainer("redis") + .withExposedPorts(6379) + .withWaitStrategy( + Wait.forSuccessfulCommand(`redis-cli`).withStartupTimeout(10000) + ) + ) + + const port = ports.find(x => x.container === 6379)?.host + if (!port) { + throw new Error("Redis port not found") + } + process.env.BULL_TEST_REDIS_PORT = port.toString() +} + +export async function processMessages(queue: Queue) { + do { + await queue.whenCurrentJobsFinished() + } while (await queue.count()) + + await queue.whenCurrentJobsFinished() +} diff --git a/packages/bbui/package.json b/packages/bbui/package.json index 0830f8ab6f..aeb7418526 100644 --- a/packages/bbui/package.json +++ b/packages/bbui/package.json @@ -81,6 +81,7 @@ "@spectrum-css/typography": "3.0.1", "@spectrum-css/underlay": "2.0.9", "@spectrum-css/vars": "3.0.1", + "atrament": "^4.3.0", "dayjs": "^1.10.8", "easymde": "^2.16.1", "svelte-dnd-action": "^0.9.8", diff --git a/packages/bbui/src/Form/Core/Dropzone.svelte b/packages/bbui/src/Form/Core/Dropzone.svelte index 2922d88e7a..26f1dc86c6 100644 --- a/packages/bbui/src/Form/Core/Dropzone.svelte +++ b/packages/bbui/src/Form/Core/Dropzone.svelte @@ -8,6 +8,7 @@ import Link from "../../Link/Link.svelte" import Tag from "../../Tags/Tag.svelte" import Tags from "../../Tags/Tags.svelte" + import ProgressCircle from "../../ProgressCircle/ProgressCircle.svelte" const BYTES_IN_KB = 1000 const BYTES_IN_MB = 1000000 @@ -39,12 +40,14 @@ "jfif", "webp", ] - const fieldId = id || uuid() + let selectedImageIdx = 0 let fileDragged = false let selectedUrl let fileInput + let loading = false + $: selectedImage = value?.[selectedImageIdx] ?? null $: fileCount = value?.length ?? 0 $: isImage = @@ -86,10 +89,15 @@ } if (processFiles) { - const processedFiles = await processFiles(fileList) - const newValue = [...value, ...processedFiles] - dispatch("change", newValue) - selectedImageIdx = newValue.length - 1 + loading = true + try { + const processedFiles = await processFiles(fileList) + const newValue = [...value, ...processedFiles] + dispatch("change", newValue) + selectedImageIdx = newValue.length - 1 + } finally { + loading = false + } } else { dispatch("change", fileList) } @@ -227,7 +235,7 @@ {#if showDropzone}
+ + {#if loading} +
+ +
+ {/if}
{/if} @@ -464,6 +478,7 @@ .spectrum-Dropzone { height: 220px; + position: relative; } .compact .spectrum-Dropzone { height: 40px; @@ -488,4 +503,14 @@ .tag { margin-top: 8px; } + + .loading { + position: absolute; + display: grid; + place-items: center; + height: 100%; + width: 100%; + top: 0; + left: 0; + } diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/TestDataModal.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/TestDataModal.svelte index f134c787ca..5ec66870a8 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/TestDataModal.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/TestDataModal.svelte @@ -1,4 +1,5 @@ - +{#if responseType === FieldType.NUMBER} + +{:else if responseType === FieldType.BOOLEAN} + +{:else if responseType === FieldType.DATETIME} + +{:else} + +{/if} diff --git a/packages/frontend-core/src/components/grid/layout/NewColumnButton.svelte b/packages/frontend-core/src/components/grid/layout/NewColumnButton.svelte index 261954379b..215fdabd8d 100644 --- a/packages/frontend-core/src/components/grid/layout/NewColumnButton.svelte +++ b/packages/frontend-core/src/components/grid/layout/NewColumnButton.svelte @@ -53,6 +53,7 @@ on:close={close} maxHeight={null} resizable + minWidth={360} >
@@ -80,7 +81,6 @@ } .content { - width: 300px; padding: 20px; display: flex; flex-direction: column; diff --git a/packages/frontend-core/src/fetch/NestedProviderFetch.js b/packages/frontend-core/src/fetch/NestedProviderFetch.js index 01c22b6ba0..0a08b00cb4 100644 --- a/packages/frontend-core/src/fetch/NestedProviderFetch.js +++ b/packages/frontend-core/src/fetch/NestedProviderFetch.js @@ -5,6 +5,7 @@ export default class NestedProviderFetch extends DataFetch { // Nested providers should already have exposed their own schema return { schema: datasource?.value?.schema, + primaryDisplay: datasource?.value?.primaryDisplay, } } diff --git a/packages/pro b/packages/pro index 04bee88597..25dd40ee12 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit 04bee88597edb1edb88ed299d0597b587f0362ec +Subproject commit 25dd40ee12b048307b558ebcedb36548d6e042cd diff --git a/packages/server/package.json b/packages/server/package.json index 76dd03b5a8..d2a51b4453 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -13,6 +13,7 @@ "build": "node ./scripts/build.js", "postbuild": "copyfiles -f ../client/dist/budibase-client.js ../client/manifest.json client && copyfiles -f ../../yarn.lock ./dist/", "check:types": "tsc -p tsconfig.json --noEmit --paths null --target es2020", + "check:dependencies": "node ../../scripts/depcheck.js", "build:isolated-vm-lib:snippets": "esbuild --minify --bundle src/jsRunner/bundles/snippets.ts --outfile=src/jsRunner/bundles/snippets.ivm.bundle.js --platform=node --format=iife --global-name=snippets", "build:isolated-vm-lib:string-templates": "esbuild --minify --bundle src/jsRunner/bundles/index-helpers.ts --outfile=src/jsRunner/bundles/index-helpers.ivm.bundle.js --platform=node --format=iife --external:handlebars --global-name=helpers", "build:isolated-vm-lib:bson": "esbuild --minify --bundle src/jsRunner/bundles/bsonPackage.ts --outfile=src/jsRunner/bundles/bson.ivm.bundle.js --platform=node --format=iife --global-name=bson", @@ -49,9 +50,11 @@ "license": "GPL-3.0", "dependencies": { "@apidevtools/swagger-parser": "10.0.3", + "@azure/msal-node": "^2.5.1", "@budibase/backend-core": "0.0.0", "@budibase/client": "0.0.0", "@budibase/frontend-core": "0.0.0", + "@budibase/nano": "10.1.5", "@budibase/pro": "0.0.0", "@budibase/shared-core": "0.0.0", "@budibase/string-templates": "0.0.0", @@ -60,15 +63,17 @@ "@bull-board/koa": "5.10.2", "@elastic/elasticsearch": "7.10.0", "@google-cloud/firestore": "7.8.0", - "@koa/router": "8.0.8", + "@koa/cors": "5.0.0", + "@koa/router": "13.1.0", "@socket.io/redis-adapter": "^8.2.1", "@types/xml2js": "^0.4.14", "airtable": "0.12.2", "arangojs": "7.2.0", "archiver": "7.0.1", - "aws-sdk": "2.1030.0", + "aws-sdk": "2.1692.0", "bcrypt": "5.1.0", "bcryptjs": "2.4.3", + "bson": "^6.9.0", "buffer": "6.0.3", "bull": "4.10.1", "chokidar": "3.5.3", @@ -76,17 +81,20 @@ "cookies": "0.8.0", "csvtojson": "2.0.10", "curlconverter": "3.21.0", - "dd-trace": "5.2.0", + "dayjs": "^1.10.8", + "dd-trace": "5.26.0", "dotenv": "8.2.0", "form-data": "4.0.0", "global-agent": "3.0.0", + "google-auth-library": "^8.0.1", "google-spreadsheet": "npm:@budibase/google-spreadsheet@4.1.5", "ioredis": "5.3.2", "isolated-vm": "^4.7.2", - "jimp": "0.22.12", + "jimp": "1.1.4", "joi": "17.6.0", "js-yaml": "4.1.0", "jsonschema": "1.4.0", + "jsonwebtoken": "9.0.2", "knex": "2.4.2", "koa": "2.13.4", "koa-body": "4.2.0", @@ -97,7 +105,7 @@ "lodash": "4.17.21", "memorystream": "0.3.1", "mongodb": "6.7.0", - "mssql": "10.0.1", + "mssql": "11.0.1", "mysql2": "3.9.8", "node-fetch": "2.6.7", "object-sizeof": "2.6.1", @@ -105,24 +113,28 @@ "openapi-types": "9.3.1", "oracledb": "6.5.1", "pg": "8.10.0", - "pouchdb": "7.3.0", + "pouchdb": "9.0.0", "pouchdb-all-dbs": "1.1.1", - "pouchdb-find": "7.2.2", + "pouchdb-find": "9.0.0", "redis": "4", + "semver": "^7.5.4", "serialize-error": "^7.0.1", "server-destroy": "1.0.1", - "snowflake-promise": "^4.5.0", - "socket.io": "4.7.5", + "snowflake-sdk": "^1.15.0", + "socket.io": "4.8.1", + "svelte": "^4.2.10", "tar": "6.2.1", "tmp": "0.2.3", "to-json-schema": "0.2.5", "uuid": "^8.3.2", "validate.js": "0.13.1", "worker-farm": "1.7.0", - "xml2js": "0.5.0" + "xml2js": "0.6.2" }, "devDependencies": { + "@babel/core": "^7.22.5", "@babel/preset-env": "7.16.11", + "@jest/types": "^29.6.3", "@swc/core": "1.3.71", "@swc/jest": "0.2.27", "@types/archiver": "6.0.2", @@ -130,19 +142,24 @@ "@types/jest": "29.5.5", "@types/koa": "2.13.4", "@types/koa-send": "^4.1.6", - "@types/koa__router": "8.0.8", + "@types/koa__cors": "5.0.0", + "@types/koa__router": "12.0.4", "@types/lodash": "4.14.200", - "@types/mssql": "9.1.4", + "@types/mssql": "9.1.5", + "@types/node": "^22.9.0", "@types/node-fetch": "2.6.4", "@types/oracledb": "6.5.1", "@types/pg": "8.6.6", + "@types/pouchdb": "6.4.2", "@types/server-destroy": "1.0.1", "@types/supertest": "2.0.14", "@types/tar": "6.1.5", "@types/tmp": "0.2.6", "@types/uuid": "8.3.4", + "chance": "^1.1.12", "copyfiles": "2.4.1", "docker-compose": "0.23.17", + "ioredis-mock": "8.9.0", "jest": "29.7.0", "jest-extended": "^4.0.2", "jest-openapi": "0.14.2", diff --git a/packages/server/scripts/test.sh b/packages/server/scripts/test.sh index 4b456e4731..5a02628a26 100644 --- a/packages/server/scripts/test.sh +++ b/packages/server/scripts/test.sh @@ -1,12 +1,12 @@ #!/bin/bash -set -e +set -ex if [[ -n $CI ]] then export NODE_OPTIONS="--max-old-space-size=4096 --no-node-snapshot $NODE_OPTIONS" - jest --coverage --maxWorkers=4 --forceExit --workerIdleMemoryLimit=2000MB --bail $@ + jest --coverage --maxWorkers=4 --forceExit --workerIdleMemoryLimit=2000MB --bail "$@" else # --maxWorkers performs better in development export NODE_OPTIONS="--no-node-snapshot $NODE_OPTIONS" - jest --coverage --maxWorkers=2 --forceExit $@ + jest --coverage --maxWorkers=2 --forceExit "$@" fi \ No newline at end of file diff --git a/packages/server/specs/openapi.json b/packages/server/specs/openapi.json index f3091a1fc7..c47a14cf21 100644 --- a/packages/server/specs/openapi.json +++ b/packages/server/specs/openapi.json @@ -32,6 +32,15 @@ "type": "string" } }, + "viewId": { + "in": "path", + "name": "viewId", + "required": true, + "description": "The ID of the view which this request is targeting.", + "schema": { + "type": "string" + } + }, "rowId": { "in": "path", "name": "rowId", @@ -47,7 +56,7 @@ "required": true, "description": "The ID of the app which this request is targeting.", "schema": { - "default": "{{ appId }}", + "default": "{{appId}}", "type": "string" } }, @@ -57,7 +66,7 @@ "required": true, "description": "The ID of the app which this request is targeting.", "schema": { - "default": "{{ appId }}", + "default": "{{appId}}", "type": "string" } }, @@ -423,6 +432,110 @@ }, "metrics": { "value": "# HELP budibase_os_uptime Time in seconds that the host operating system has been up.\n# TYPE budibase_os_uptime counter\nbudibase_os_uptime 54958\n# HELP budibase_os_free_mem Bytes of memory free for usage on the host operating system.\n# TYPE budibase_os_free_mem gauge\nbudibase_os_free_mem 804507648\n# HELP budibase_os_total_mem Total bytes of memory on the host operating system.\n# TYPE budibase_os_total_mem gauge\nbudibase_os_total_mem 16742404096\n# HELP budibase_os_used_mem Total bytes of memory in use on the host operating system.\n# TYPE budibase_os_used_mem gauge\nbudibase_os_used_mem 15937896448\n# HELP budibase_os_load1 Host operating system load average.\n# TYPE budibase_os_load1 gauge\nbudibase_os_load1 1.91\n# HELP budibase_os_load5 Host operating system load average.\n# TYPE budibase_os_load5 gauge\nbudibase_os_load5 1.75\n# HELP budibase_os_load15 Host operating system load average.\n# TYPE budibase_os_load15 gauge\nbudibase_os_load15 1.56\n# HELP budibase_tenant_user_count The number of users created.\n# TYPE budibase_tenant_user_count gauge\nbudibase_tenant_user_count 1\n# HELP budibase_tenant_app_count The number of apps created by a user.\n# TYPE budibase_tenant_app_count gauge\nbudibase_tenant_app_count 2\n# HELP budibase_tenant_production_app_count The number of apps a user has published.\n# TYPE budibase_tenant_production_app_count gauge\nbudibase_tenant_production_app_count 1\n# HELP budibase_tenant_dev_app_count The number of apps a user has unpublished in development.\n# TYPE budibase_tenant_dev_app_count gauge\nbudibase_tenant_dev_app_count 1\n# HELP budibase_tenant_db_count The number of couchdb databases including global tables such as _users.\n# TYPE budibase_tenant_db_count gauge\nbudibase_tenant_db_count 3\n# HELP budibase_quota_usage_apps The number of apps created.\n# TYPE budibase_quota_usage_apps gauge\nbudibase_quota_usage_apps 1\n# HELP budibase_quota_limit_apps The limit on the number of apps that can be created.\n# TYPE budibase_quota_limit_apps gauge\nbudibase_quota_limit_apps 9007199254740991\n# HELP budibase_quota_usage_rows The number of database rows used from the quota.\n# TYPE budibase_quota_usage_rows gauge\nbudibase_quota_usage_rows 0\n# HELP budibase_quota_limit_rows The limit on the number of rows that can be created.\n# TYPE budibase_quota_limit_rows gauge\nbudibase_quota_limit_rows 9007199254740991\n# HELP budibase_quota_usage_plugins The number of plugins in use.\n# TYPE budibase_quota_usage_plugins gauge\nbudibase_quota_usage_plugins 0\n# HELP budibase_quota_limit_plugins The limit on the number of plugins that can be created.\n# TYPE budibase_quota_limit_plugins gauge\nbudibase_quota_limit_plugins 9007199254740991\n# HELP budibase_quota_usage_user_groups The number of user groups created.\n# TYPE budibase_quota_usage_user_groups gauge\nbudibase_quota_usage_user_groups 0\n# HELP budibase_quota_limit_user_groups The limit on the number of user groups that can be created.\n# TYPE budibase_quota_limit_user_groups gauge\nbudibase_quota_limit_user_groups 9007199254740991\n# HELP budibase_quota_usage_queries The number of queries used in the current month.\n# TYPE budibase_quota_usage_queries gauge\nbudibase_quota_usage_queries 0\n# HELP budibase_quota_limit_queries The limit on the number of queries for the current month.\n# TYPE budibase_quota_limit_queries gauge\nbudibase_quota_limit_queries 9007199254740991\n# HELP budibase_quota_usage_automations The number of automations used in the current month.\n# TYPE budibase_quota_usage_automations gauge\nbudibase_quota_usage_automations 0\n# HELP budibase_quota_limit_automations The limit on the number of automations that can be created.\n# TYPE budibase_quota_limit_automations gauge\nbudibase_quota_limit_automations 9007199254740991\n" + }, + "view": { + "value": { + "data": { + "name": "peopleView", + "tableId": "ta_896a325f7e8147d2a2cda93c5d236511", + "schema": { + "name": { + "visible": true, + "readonly": false, + "order": 1, + "width": 300 + }, + "age": { + "visible": true, + "readonly": true, + "order": 2, + "width": 200 + }, + "salary": { + "visible": false, + "readonly": false + } + }, + "query": { + "logicalOperator": "all", + "onEmptyFilter": "none", + "groups": [ + { + "logicalOperator": "any", + "filters": [ + { + "operator": "string", + "field": "name", + "value": "John" + }, + { + "operator": "range", + "field": "age", + "value": { + "low": 18, + "high": 100 + } + } + ] + } + ] + }, + "primaryDisplay": "name" + } + } + }, + "views": { + "value": { + "data": [ + { + "name": "peopleView", + "tableId": "ta_896a325f7e8147d2a2cda93c5d236511", + "schema": { + "name": { + "visible": true, + "readonly": false, + "order": 1, + "width": 300 + }, + "age": { + "visible": true, + "readonly": true, + "order": 2, + "width": 200 + }, + "salary": { + "visible": false, + "readonly": false + } + }, + "query": { + "logicalOperator": "all", + "onEmptyFilter": "none", + "groups": [ + { + "logicalOperator": "any", + "filters": [ + { + "operator": "string", + "field": "name", + "value": "John" + }, + { + "operator": "range", + "field": "age", + "value": { + "low": 18, + "high": 100 + } + } + ] + } + ] + }, + "primaryDisplay": "name" + } + ] + } } }, "securitySchemes": { @@ -831,8 +944,7 @@ "type": "string", "enum": [ "static", - "dynamic", - "ai" + "dynamic" ], "description": "Defines whether this is a static or dynamic formula." } @@ -1042,8 +1154,7 @@ "type": "string", "enum": [ "static", - "dynamic", - "ai" + "dynamic" ], "description": "Defines whether this is a static or dynamic formula." } @@ -1264,8 +1375,7 @@ "type": "string", "enum": [ "static", - "dynamic", - "ai" + "dynamic" ], "description": "Defines whether this is a static or dynamic formula." } @@ -2024,6 +2134,872 @@ "required": [ "data" ] + }, + "view": { + "description": "The view to be created/updated.", + "type": "object", + "required": [ + "name", + "schema", + "tableId" + ], + "properties": { + "name": { + "description": "The name of the view.", + "type": "string" + }, + "tableId": { + "description": "The ID of the table this view is based on.", + "type": "string" + }, + "type": { + "description": "The type of view - standard (empty value) or calculation.", + "type": "string", + "enum": [ + "calculation" + ] + }, + "primaryDisplay": { + "type": "string", + "description": "A column used to display rows from this view - usually used when rendered in tables." + }, + "query": { + "description": "Search parameters for view", + "type": "object", + "required": [], + "properties": { + "logicalOperator": { + "description": "When using groups this defines whether all of the filters must match, or only one of them.", + "type": "string", + "enum": [ + "all", + "any" + ] + }, + "onEmptyFilter": { + "description": "If no filters match, should the view return all rows, or no rows.", + "type": "string", + "enum": [ + "all", + "none" + ] + }, + "groups": { + "description": "A grouping of filters to be applied.", + "type": "array", + "items": { + "type": "object", + "properties": { + "logicalOperator": { + "description": "When using groups this defines whether all of the filters must match, or only one of them.", + "type": "string", + "enum": [ + "all", + "any" + ] + }, + "filters": { + "description": "A list of filters to apply", + "type": "array", + "items": { + "type": "object", + "properties": { + "operator": { + "type": "string", + "description": "The type of search operation which is being performed.", + "enum": [ + "equal", + "notEqual", + "empty", + "notEmpty", + "fuzzy", + "string", + "contains", + "notContains", + "containsAny", + "oneOf", + "range" + ] + }, + "field": { + "type": "string", + "description": "The field in the view to perform the search on." + }, + "value": { + "description": "The value to search for - the type will depend on the operator in use.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "object" + }, + { + "type": "array" + } + ] + } + } + } + }, + "groups": { + "description": "A grouping of filters to be applied.", + "type": "array", + "items": { + "type": "object", + "properties": { + "logicalOperator": { + "description": "When using groups this defines whether all of the filters must match, or only one of them.", + "type": "string", + "enum": [ + "all", + "any" + ] + }, + "filters": { + "description": "A list of filters to apply", + "type": "array", + "items": { + "type": "object", + "properties": { + "operator": { + "type": "string", + "description": "The type of search operation which is being performed.", + "enum": [ + "equal", + "notEqual", + "empty", + "notEmpty", + "fuzzy", + "string", + "contains", + "notContains", + "containsAny", + "oneOf", + "range" + ] + }, + "field": { + "type": "string", + "description": "The field in the view to perform the search on." + }, + "value": { + "description": "The value to search for - the type will depend on the operator in use.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "object" + }, + { + "type": "array" + } + ] + } + } + } + } + } + } + } + } + } + } + } + }, + "sort": { + "type": "object", + "required": [ + "field" + ], + "properties": { + "field": { + "type": "string", + "description": "The field from the table/view schema to sort on." + }, + "order": { + "type": "string", + "description": "The order in which to sort.", + "enum": [ + "ascending", + "descending" + ] + }, + "type": { + "type": "string", + "description": "The type of sort to perform (by number, or by alphabetically).", + "enum": [ + "string", + "number" + ] + } + } + }, + "schema": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "object", + "properties": { + "visible": { + "type": "boolean", + "description": "Defines whether the column is visible or not - rows retrieved/updated through this view will not be able to access it." + }, + "readonly": { + "type": "boolean", + "description": "When used in combination with 'visible: true' the column will be visible in row responses but cannot be updated." + }, + "order": { + "type": "integer", + "description": "A number defining where the column shows up in tables, lowest being first." + }, + "width": { + "type": "integer", + "description": "A width for the column, defined in pixels - this affects rendering in tables." + }, + "column": { + "type": "array", + "description": "If this is a relationship column, we can set the columns we wish to include", + "items": { + "type": "object", + "properties": { + "readonly": { + "type": "boolean" + } + } + } + } + } + }, + { + "type": "object", + "properties": { + "calculationType": { + "type": "string", + "description": "This column should be built from a calculation, specifying a type and field. It is important to note when a calculation is configured all non-calculation columns will be used for grouping.", + "enum": [ + "sum", + "avg", + "count", + "min", + "max" + ] + }, + "field": { + "type": "string", + "description": "The field from the table to perform the calculation on." + }, + "distinct": { + "type": "boolean", + "description": "Can be used in tandem with the count calculation type, to count unique entries." + } + } + } + ] + } + } + } + }, + "viewOutput": { + "type": "object", + "properties": { + "data": { + "description": "The view to be created/updated.", + "type": "object", + "required": [ + "name", + "schema", + "tableId", + "id" + ], + "properties": { + "name": { + "description": "The name of the view.", + "type": "string" + }, + "tableId": { + "description": "The ID of the table this view is based on.", + "type": "string" + }, + "type": { + "description": "The type of view - standard (empty value) or calculation.", + "type": "string", + "enum": [ + "calculation" + ] + }, + "primaryDisplay": { + "type": "string", + "description": "A column used to display rows from this view - usually used when rendered in tables." + }, + "query": { + "description": "Search parameters for view", + "type": "object", + "required": [], + "properties": { + "logicalOperator": { + "description": "When using groups this defines whether all of the filters must match, or only one of them.", + "type": "string", + "enum": [ + "all", + "any" + ] + }, + "onEmptyFilter": { + "description": "If no filters match, should the view return all rows, or no rows.", + "type": "string", + "enum": [ + "all", + "none" + ] + }, + "groups": { + "description": "A grouping of filters to be applied.", + "type": "array", + "items": { + "type": "object", + "properties": { + "logicalOperator": { + "description": "When using groups this defines whether all of the filters must match, or only one of them.", + "type": "string", + "enum": [ + "all", + "any" + ] + }, + "filters": { + "description": "A list of filters to apply", + "type": "array", + "items": { + "type": "object", + "properties": { + "operator": { + "type": "string", + "description": "The type of search operation which is being performed.", + "enum": [ + "equal", + "notEqual", + "empty", + "notEmpty", + "fuzzy", + "string", + "contains", + "notContains", + "containsAny", + "oneOf", + "range" + ] + }, + "field": { + "type": "string", + "description": "The field in the view to perform the search on." + }, + "value": { + "description": "The value to search for - the type will depend on the operator in use.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "object" + }, + { + "type": "array" + } + ] + } + } + } + }, + "groups": { + "description": "A grouping of filters to be applied.", + "type": "array", + "items": { + "type": "object", + "properties": { + "logicalOperator": { + "description": "When using groups this defines whether all of the filters must match, or only one of them.", + "type": "string", + "enum": [ + "all", + "any" + ] + }, + "filters": { + "description": "A list of filters to apply", + "type": "array", + "items": { + "type": "object", + "properties": { + "operator": { + "type": "string", + "description": "The type of search operation which is being performed.", + "enum": [ + "equal", + "notEqual", + "empty", + "notEmpty", + "fuzzy", + "string", + "contains", + "notContains", + "containsAny", + "oneOf", + "range" + ] + }, + "field": { + "type": "string", + "description": "The field in the view to perform the search on." + }, + "value": { + "description": "The value to search for - the type will depend on the operator in use.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "object" + }, + { + "type": "array" + } + ] + } + } + } + } + } + } + } + } + } + } + } + }, + "sort": { + "type": "object", + "required": [ + "field" + ], + "properties": { + "field": { + "type": "string", + "description": "The field from the table/view schema to sort on." + }, + "order": { + "type": "string", + "description": "The order in which to sort.", + "enum": [ + "ascending", + "descending" + ] + }, + "type": { + "type": "string", + "description": "The type of sort to perform (by number, or by alphabetically).", + "enum": [ + "string", + "number" + ] + } + } + }, + "schema": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "object", + "properties": { + "visible": { + "type": "boolean", + "description": "Defines whether the column is visible or not - rows retrieved/updated through this view will not be able to access it." + }, + "readonly": { + "type": "boolean", + "description": "When used in combination with 'visible: true' the column will be visible in row responses but cannot be updated." + }, + "order": { + "type": "integer", + "description": "A number defining where the column shows up in tables, lowest being first." + }, + "width": { + "type": "integer", + "description": "A width for the column, defined in pixels - this affects rendering in tables." + }, + "column": { + "type": "array", + "description": "If this is a relationship column, we can set the columns we wish to include", + "items": { + "type": "object", + "properties": { + "readonly": { + "type": "boolean" + } + } + } + } + } + }, + { + "type": "object", + "properties": { + "calculationType": { + "type": "string", + "description": "This column should be built from a calculation, specifying a type and field. It is important to note when a calculation is configured all non-calculation columns will be used for grouping.", + "enum": [ + "sum", + "avg", + "count", + "min", + "max" + ] + }, + "field": { + "type": "string", + "description": "The field from the table to perform the calculation on." + }, + "distinct": { + "type": "boolean", + "description": "Can be used in tandem with the count calculation type, to count unique entries." + } + } + } + ] + } + }, + "id": { + "description": "The ID of the view.", + "type": "string" + } + } + } + }, + "required": [ + "data" + ] + }, + "viewSearch": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "description": "The view to be created/updated.", + "type": "object", + "required": [ + "name", + "schema", + "tableId", + "id" + ], + "properties": { + "name": { + "description": "The name of the view.", + "type": "string" + }, + "tableId": { + "description": "The ID of the table this view is based on.", + "type": "string" + }, + "type": { + "description": "The type of view - standard (empty value) or calculation.", + "type": "string", + "enum": [ + "calculation" + ] + }, + "primaryDisplay": { + "type": "string", + "description": "A column used to display rows from this view - usually used when rendered in tables." + }, + "query": { + "description": "Search parameters for view", + "type": "object", + "required": [], + "properties": { + "logicalOperator": { + "description": "When using groups this defines whether all of the filters must match, or only one of them.", + "type": "string", + "enum": [ + "all", + "any" + ] + }, + "onEmptyFilter": { + "description": "If no filters match, should the view return all rows, or no rows.", + "type": "string", + "enum": [ + "all", + "none" + ] + }, + "groups": { + "description": "A grouping of filters to be applied.", + "type": "array", + "items": { + "type": "object", + "properties": { + "logicalOperator": { + "description": "When using groups this defines whether all of the filters must match, or only one of them.", + "type": "string", + "enum": [ + "all", + "any" + ] + }, + "filters": { + "description": "A list of filters to apply", + "type": "array", + "items": { + "type": "object", + "properties": { + "operator": { + "type": "string", + "description": "The type of search operation which is being performed.", + "enum": [ + "equal", + "notEqual", + "empty", + "notEmpty", + "fuzzy", + "string", + "contains", + "notContains", + "containsAny", + "oneOf", + "range" + ] + }, + "field": { + "type": "string", + "description": "The field in the view to perform the search on." + }, + "value": { + "description": "The value to search for - the type will depend on the operator in use.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "object" + }, + { + "type": "array" + } + ] + } + } + } + }, + "groups": { + "description": "A grouping of filters to be applied.", + "type": "array", + "items": { + "type": "object", + "properties": { + "logicalOperator": { + "description": "When using groups this defines whether all of the filters must match, or only one of them.", + "type": "string", + "enum": [ + "all", + "any" + ] + }, + "filters": { + "description": "A list of filters to apply", + "type": "array", + "items": { + "type": "object", + "properties": { + "operator": { + "type": "string", + "description": "The type of search operation which is being performed.", + "enum": [ + "equal", + "notEqual", + "empty", + "notEmpty", + "fuzzy", + "string", + "contains", + "notContains", + "containsAny", + "oneOf", + "range" + ] + }, + "field": { + "type": "string", + "description": "The field in the view to perform the search on." + }, + "value": { + "description": "The value to search for - the type will depend on the operator in use.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "object" + }, + { + "type": "array" + } + ] + } + } + } + } + } + } + } + } + } + } + } + }, + "sort": { + "type": "object", + "required": [ + "field" + ], + "properties": { + "field": { + "type": "string", + "description": "The field from the table/view schema to sort on." + }, + "order": { + "type": "string", + "description": "The order in which to sort.", + "enum": [ + "ascending", + "descending" + ] + }, + "type": { + "type": "string", + "description": "The type of sort to perform (by number, or by alphabetically).", + "enum": [ + "string", + "number" + ] + } + } + }, + "schema": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "object", + "properties": { + "visible": { + "type": "boolean", + "description": "Defines whether the column is visible or not - rows retrieved/updated through this view will not be able to access it." + }, + "readonly": { + "type": "boolean", + "description": "When used in combination with 'visible: true' the column will be visible in row responses but cannot be updated." + }, + "order": { + "type": "integer", + "description": "A number defining where the column shows up in tables, lowest being first." + }, + "width": { + "type": "integer", + "description": "A width for the column, defined in pixels - this affects rendering in tables." + }, + "column": { + "type": "array", + "description": "If this is a relationship column, we can set the columns we wish to include", + "items": { + "type": "object", + "properties": { + "readonly": { + "type": "boolean" + } + } + } + } + } + }, + { + "type": "object", + "properties": { + "calculationType": { + "type": "string", + "description": "This column should be built from a calculation, specifying a type and field. It is important to note when a calculation is configured all non-calculation columns will be used for grouping.", + "enum": [ + "sum", + "avg", + "count", + "min", + "max" + ] + }, + "field": { + "type": "string", + "description": "The field from the table to perform the calculation on." + }, + "distinct": { + "type": "boolean", + "description": "Can be used in tandem with the count calculation type, to count unique entries." + } + } + } + ] + } + }, + "id": { + "description": "The ID of the view.", + "type": "string" + } + } + } + } + }, + "required": [ + "data" + ] } } }, @@ -2741,6 +3717,50 @@ } } }, + "/views/{viewId}/rows/search": { + "post": { + "operationId": "rowViewSearch", + "summary": "Search for rows in a view", + "tags": [ + "rows" + ], + "parameters": [ + { + "$ref": "#/components/parameters/viewId" + }, + { + "$ref": "#/components/parameters/appId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/rowSearch" + } + } + } + }, + "responses": { + "200": { + "description": "The response will contain an array of rows that match the search parameters.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/searchOutput" + }, + "examples": { + "search": { + "$ref": "#/components/examples/rows" + } + } + } + } + } + } + } + }, "/tables": { "post": { "operationId": "tableCreate", @@ -3115,6 +4135,209 @@ } } } + }, + "/views": { + "post": { + "operationId": "viewCreate", + "summary": "Create a view", + "description": "Create a view, this can be against an internal or external table.", + "tags": [ + "views" + ], + "parameters": [ + { + "$ref": "#/components/parameters/appId" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/view" + }, + "examples": { + "view": { + "$ref": "#/components/examples/view" + } + } + } + } + }, + "responses": { + "200": { + "description": "Returns the created view, including the ID which has been generated for it.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/viewOutput" + }, + "examples": { + "view": { + "$ref": "#/components/examples/view" + } + } + } + } + } + } + } + }, + "/views/{viewId}": { + "put": { + "operationId": "viewUpdate", + "summary": "Update a view", + "description": "Update a view, this can be against an internal or external table.", + "tags": [ + "views" + ], + "parameters": [ + { + "$ref": "#/components/parameters/viewId" + }, + { + "$ref": "#/components/parameters/appId" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/view" + }, + "examples": { + "view": { + "$ref": "#/components/examples/view" + } + } + } + } + }, + "responses": { + "200": { + "description": "Returns the updated view.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/viewOutput" + }, + "examples": { + "view": { + "$ref": "#/components/examples/view" + } + } + } + } + } + } + }, + "delete": { + "operationId": "viewDestroy", + "summary": "Delete a view", + "description": "Delete a view, this can be against an internal or external table.", + "tags": [ + "views" + ], + "parameters": [ + { + "$ref": "#/components/parameters/viewId" + }, + { + "$ref": "#/components/parameters/appId" + } + ], + "responses": { + "200": { + "description": "Returns the deleted view.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/viewOutput" + }, + "examples": { + "view": { + "$ref": "#/components/examples/view" + } + } + } + } + } + } + }, + "get": { + "operationId": "viewGetById", + "summary": "Retrieve a view", + "description": "Lookup a view, this could be internal or external.", + "tags": [ + "views" + ], + "parameters": [ + { + "$ref": "#/components/parameters/viewId" + }, + { + "$ref": "#/components/parameters/appId" + } + ], + "responses": { + "200": { + "description": "Returns the retrieved view.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/viewOutput" + }, + "examples": { + "view": { + "$ref": "#/components/examples/view" + } + } + } + } + } + } + } + }, + "/views/search": { + "post": { + "operationId": "viewSearch", + "summary": "Search for views", + "description": "Based on view properties (currently only name) search for views.", + "tags": [ + "views" + ], + "parameters": [ + { + "$ref": "#/components/parameters/appId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/nameSearch" + } + } + } + }, + "responses": { + "200": { + "description": "Returns the found views, based on the search parameters.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/viewSearch" + }, + "examples": { + "views": { + "$ref": "#/components/examples/views" + } + } + } + } + } + } + } } }, "tags": [] diff --git a/packages/server/specs/openapi.yaml b/packages/server/specs/openapi.yaml index 1e9b9921cf..edfb29f432 100644 --- a/packages/server/specs/openapi.yaml +++ b/packages/server/specs/openapi.yaml @@ -23,6 +23,13 @@ components: description: The ID of the table which this request is targeting. schema: type: string + viewId: + in: path + name: viewId + required: true + description: The ID of the view which this request is targeting. + schema: + type: string rowId: in: path name: rowId @@ -36,7 +43,7 @@ components: required: true description: The ID of the app which this request is targeting. schema: - default: "{{ appId }}" + default: "{{appId}}" type: string appIdUrl: in: path @@ -44,7 +51,7 @@ components: required: true description: The ID of the app which this request is targeting. schema: - default: "{{ appId }}" + default: "{{appId}}" type: string queryId: in: path @@ -442,6 +449,74 @@ components: # TYPE budibase_quota_limit_automations gauge budibase_quota_limit_automations 9007199254740991 + view: + value: + data: + name: peopleView + tableId: ta_896a325f7e8147d2a2cda93c5d236511 + schema: + name: + visible: true + readonly: false + order: 1 + width: 300 + age: + visible: true + readonly: true + order: 2 + width: 200 + salary: + visible: false + readonly: false + query: + logicalOperator: all + onEmptyFilter: none + groups: + - logicalOperator: any + filters: + - operator: string + field: name + value: John + - operator: range + field: age + value: + low: 18 + high: 100 + primaryDisplay: name + views: + value: + data: + - name: peopleView + tableId: ta_896a325f7e8147d2a2cda93c5d236511 + schema: + name: + visible: true + readonly: false + order: 1 + width: 300 + age: + visible: true + readonly: true + order: 2 + width: 200 + salary: + visible: false + readonly: false + query: + logicalOperator: all + onEmptyFilter: none + groups: + - logicalOperator: any + filters: + - operator: string + field: name + value: John + - operator: range + field: age + value: + low: 18 + high: 100 + primaryDisplay: name securitySchemes: ApiKeyAuth: type: apiKey @@ -761,7 +836,6 @@ components: enum: - static - dynamic - - ai description: Defines whether this is a static or dynamic formula. - type: object properties: @@ -931,7 +1005,6 @@ components: enum: - static - dynamic - - ai description: Defines whether this is a static or dynamic formula. - type: object properties: @@ -1108,7 +1181,6 @@ components: enum: - static - dynamic - - ai description: Defines whether this is a static or dynamic formula. - type: object properties: @@ -1704,6 +1776,644 @@ components: - userIds required: - data + view: + description: The view to be created/updated. + type: object + required: + - name + - schema + - tableId + properties: + name: + description: The name of the view. + type: string + tableId: + description: The ID of the table this view is based on. + type: string + type: + description: The type of view - standard (empty value) or calculation. + type: string + enum: + - calculation + primaryDisplay: + type: string + description: A column used to display rows from this view - usually used when + rendered in tables. + query: + description: Search parameters for view + type: object + required: [] + properties: + logicalOperator: + description: When using groups this defines whether all of the filters must + match, or only one of them. + type: string + enum: + - all + - any + onEmptyFilter: + description: If no filters match, should the view return all rows, or no rows. + type: string + enum: + - all + - none + groups: + description: A grouping of filters to be applied. + type: array + items: + type: object + properties: + logicalOperator: + description: When using groups this defines whether all of the filters must + match, or only one of them. + type: string + enum: + - all + - any + filters: + description: A list of filters to apply + type: array + items: + type: object + properties: + operator: + type: string + description: The type of search operation which is being performed. + enum: + - equal + - notEqual + - empty + - notEmpty + - fuzzy + - string + - contains + - notContains + - containsAny + - oneOf + - range + field: + type: string + description: The field in the view to perform the search on. + value: + description: The value to search for - the type will depend on the operator in + use. + oneOf: + - type: string + - type: number + - type: boolean + - type: object + - type: array + groups: + description: A grouping of filters to be applied. + type: array + items: + type: object + properties: + logicalOperator: + description: When using groups this defines whether all of the filters must + match, or only one of them. + type: string + enum: + - all + - any + filters: + description: A list of filters to apply + type: array + items: + type: object + properties: + operator: + type: string + description: The type of search operation which is being performed. + enum: + - equal + - notEqual + - empty + - notEmpty + - fuzzy + - string + - contains + - notContains + - containsAny + - oneOf + - range + field: + type: string + description: The field in the view to perform the search on. + value: + description: The value to search for - the type will depend on the operator in + use. + oneOf: + - type: string + - type: number + - type: boolean + - type: object + - type: array + sort: + type: object + required: + - field + properties: + field: + type: string + description: The field from the table/view schema to sort on. + order: + type: string + description: The order in which to sort. + enum: + - ascending + - descending + type: + type: string + description: The type of sort to perform (by number, or by alphabetically). + enum: + - string + - number + schema: + type: object + additionalProperties: + oneOf: + - type: object + properties: + visible: + type: boolean + description: Defines whether the column is visible or not - rows + retrieved/updated through this view will not be able to + access it. + readonly: + type: boolean + description: "When used in combination with 'visible: true' the column will be + visible in row responses but cannot be updated." + order: + type: integer + description: A number defining where the column shows up in tables, lowest being + first. + width: + type: integer + description: A width for the column, defined in pixels - this affects rendering + in tables. + column: + type: array + description: If this is a relationship column, we can set the columns we wish to + include + items: + type: object + properties: + readonly: + type: boolean + - type: object + properties: + calculationType: + type: string + description: This column should be built from a calculation, specifying a type + and field. It is important to note when a calculation is + configured all non-calculation columns will be used for + grouping. + enum: + - sum + - avg + - count + - min + - max + field: + type: string + description: The field from the table to perform the calculation on. + distinct: + type: boolean + description: Can be used in tandem with the count calculation type, to count + unique entries. + viewOutput: + type: object + properties: + data: + description: The view to be created/updated. + type: object + required: + - name + - schema + - tableId + - id + properties: + name: + description: The name of the view. + type: string + tableId: + description: The ID of the table this view is based on. + type: string + type: + description: The type of view - standard (empty value) or calculation. + type: string + enum: + - calculation + primaryDisplay: + type: string + description: A column used to display rows from this view - usually used when + rendered in tables. + query: + description: Search parameters for view + type: object + required: [] + properties: + logicalOperator: + description: When using groups this defines whether all of the filters must + match, or only one of them. + type: string + enum: + - all + - any + onEmptyFilter: + description: If no filters match, should the view return all rows, or no rows. + type: string + enum: + - all + - none + groups: + description: A grouping of filters to be applied. + type: array + items: + type: object + properties: + logicalOperator: + description: When using groups this defines whether all of the filters must + match, or only one of them. + type: string + enum: + - all + - any + filters: + description: A list of filters to apply + type: array + items: + type: object + properties: + operator: + type: string + description: The type of search operation which is being performed. + enum: + - equal + - notEqual + - empty + - notEmpty + - fuzzy + - string + - contains + - notContains + - containsAny + - oneOf + - range + field: + type: string + description: The field in the view to perform the search on. + value: + description: The value to search for - the type will depend on the operator in + use. + oneOf: + - type: string + - type: number + - type: boolean + - type: object + - type: array + groups: + description: A grouping of filters to be applied. + type: array + items: + type: object + properties: + logicalOperator: + description: When using groups this defines whether all of the filters must + match, or only one of them. + type: string + enum: + - all + - any + filters: + description: A list of filters to apply + type: array + items: + type: object + properties: + operator: + type: string + description: The type of search operation which is being performed. + enum: + - equal + - notEqual + - empty + - notEmpty + - fuzzy + - string + - contains + - notContains + - containsAny + - oneOf + - range + field: + type: string + description: The field in the view to perform the search on. + value: + description: The value to search for - the type will depend on the operator in + use. + oneOf: + - type: string + - type: number + - type: boolean + - type: object + - type: array + sort: + type: object + required: + - field + properties: + field: + type: string + description: The field from the table/view schema to sort on. + order: + type: string + description: The order in which to sort. + enum: + - ascending + - descending + type: + type: string + description: The type of sort to perform (by number, or by alphabetically). + enum: + - string + - number + schema: + type: object + additionalProperties: + oneOf: + - type: object + properties: + visible: + type: boolean + description: Defines whether the column is visible or not - rows + retrieved/updated through this view will not be able + to access it. + readonly: + type: boolean + description: "When used in combination with 'visible: true' the column will be + visible in row responses but cannot be updated." + order: + type: integer + description: A number defining where the column shows up in tables, lowest being + first. + width: + type: integer + description: A width for the column, defined in pixels - this affects rendering + in tables. + column: + type: array + description: If this is a relationship column, we can set the columns we wish to + include + items: + type: object + properties: + readonly: + type: boolean + - type: object + properties: + calculationType: + type: string + description: This column should be built from a calculation, specifying a type + and field. It is important to note when a calculation + is configured all non-calculation columns will be used + for grouping. + enum: + - sum + - avg + - count + - min + - max + field: + type: string + description: The field from the table to perform the calculation on. + distinct: + type: boolean + description: Can be used in tandem with the count calculation type, to count + unique entries. + id: + description: The ID of the view. + type: string + required: + - data + viewSearch: + type: object + properties: + data: + type: array + items: + description: The view to be created/updated. + type: object + required: + - name + - schema + - tableId + - id + properties: + name: + description: The name of the view. + type: string + tableId: + description: The ID of the table this view is based on. + type: string + type: + description: The type of view - standard (empty value) or calculation. + type: string + enum: + - calculation + primaryDisplay: + type: string + description: A column used to display rows from this view - usually used when + rendered in tables. + query: + description: Search parameters for view + type: object + required: [] + properties: + logicalOperator: + description: When using groups this defines whether all of the filters must + match, or only one of them. + type: string + enum: + - all + - any + onEmptyFilter: + description: If no filters match, should the view return all rows, or no rows. + type: string + enum: + - all + - none + groups: + description: A grouping of filters to be applied. + type: array + items: + type: object + properties: + logicalOperator: + description: When using groups this defines whether all of the filters must + match, or only one of them. + type: string + enum: + - all + - any + filters: + description: A list of filters to apply + type: array + items: + type: object + properties: + operator: + type: string + description: The type of search operation which is being performed. + enum: + - equal + - notEqual + - empty + - notEmpty + - fuzzy + - string + - contains + - notContains + - containsAny + - oneOf + - range + field: + type: string + description: The field in the view to perform the search on. + value: + description: The value to search for - the type will depend on the operator in + use. + oneOf: + - type: string + - type: number + - type: boolean + - type: object + - type: array + groups: + description: A grouping of filters to be applied. + type: array + items: + type: object + properties: + logicalOperator: + description: When using groups this defines whether all of the filters must + match, or only one of them. + type: string + enum: + - all + - any + filters: + description: A list of filters to apply + type: array + items: + type: object + properties: + operator: + type: string + description: The type of search operation which is being performed. + enum: + - equal + - notEqual + - empty + - notEmpty + - fuzzy + - string + - contains + - notContains + - containsAny + - oneOf + - range + field: + type: string + description: The field in the view to perform the search on. + value: + description: The value to search for - the type will depend on the operator in + use. + oneOf: + - type: string + - type: number + - type: boolean + - type: object + - type: array + sort: + type: object + required: + - field + properties: + field: + type: string + description: The field from the table/view schema to sort on. + order: + type: string + description: The order in which to sort. + enum: + - ascending + - descending + type: + type: string + description: The type of sort to perform (by number, or by alphabetically). + enum: + - string + - number + schema: + type: object + additionalProperties: + oneOf: + - type: object + properties: + visible: + type: boolean + description: Defines whether the column is visible or not - rows + retrieved/updated through this view will not be able + to access it. + readonly: + type: boolean + description: "When used in combination with 'visible: true' the column will be + visible in row responses but cannot be updated." + order: + type: integer + description: A number defining where the column shows up in tables, lowest being + first. + width: + type: integer + description: A width for the column, defined in pixels - this affects rendering + in tables. + column: + type: array + description: If this is a relationship column, we can set the columns we wish to + include + items: + type: object + properties: + readonly: + type: boolean + - type: object + properties: + calculationType: + type: string + description: This column should be built from a calculation, specifying a type + and field. It is important to note when a + calculation is configured all non-calculation + columns will be used for grouping. + enum: + - sum + - avg + - count + - min + - max + field: + type: string + description: The field from the table to perform the calculation on. + distinct: + type: boolean + description: Can be used in tandem with the count calculation type, to count + unique entries. + id: + description: The ID of the view. + type: string + required: + - data security: - ApiKeyAuth: [] paths: @@ -2136,6 +2846,32 @@ paths: examples: search: $ref: "#/components/examples/rows" + "/views/{viewId}/rows/search": + post: + operationId: rowViewSearch + summary: Search for rows in a view + tags: + - rows + parameters: + - $ref: "#/components/parameters/viewId" + - $ref: "#/components/parameters/appId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/rowSearch" + responses: + "200": + description: The response will contain an array of rows that match the search + parameters. + content: + application/json: + schema: + $ref: "#/components/schemas/searchOutput" + examples: + search: + $ref: "#/components/examples/rows" /tables: post: operationId: tableCreate @@ -2359,4 +3095,123 @@ paths: examples: users: $ref: "#/components/examples/users" + /views: + post: + operationId: viewCreate + summary: Create a view + description: Create a view, this can be against an internal or external table. + tags: + - views + parameters: + - $ref: "#/components/parameters/appId" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/view" + examples: + view: + $ref: "#/components/examples/view" + responses: + "200": + description: Returns the created view, including the ID which has been generated + for it. + content: + application/json: + schema: + $ref: "#/components/schemas/viewOutput" + examples: + view: + $ref: "#/components/examples/view" + "/views/{viewId}": + put: + operationId: viewUpdate + summary: Update a view + description: Update a view, this can be against an internal or external table. + tags: + - views + parameters: + - $ref: "#/components/parameters/viewId" + - $ref: "#/components/parameters/appId" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/view" + examples: + view: + $ref: "#/components/examples/view" + responses: + "200": + description: Returns the updated view. + content: + application/json: + schema: + $ref: "#/components/schemas/viewOutput" + examples: + view: + $ref: "#/components/examples/view" + delete: + operationId: viewDestroy + summary: Delete a view + description: Delete a view, this can be against an internal or external table. + tags: + - views + parameters: + - $ref: "#/components/parameters/viewId" + - $ref: "#/components/parameters/appId" + responses: + "200": + description: Returns the deleted view. + content: + application/json: + schema: + $ref: "#/components/schemas/viewOutput" + examples: + view: + $ref: "#/components/examples/view" + get: + operationId: viewGetById + summary: Retrieve a view + description: Lookup a view, this could be internal or external. + tags: + - views + parameters: + - $ref: "#/components/parameters/viewId" + - $ref: "#/components/parameters/appId" + responses: + "200": + description: Returns the retrieved view. + content: + application/json: + schema: + $ref: "#/components/schemas/viewOutput" + examples: + view: + $ref: "#/components/examples/view" + /views/search: + post: + operationId: viewSearch + summary: Search for views + description: Based on view properties (currently only name) search for views. + tags: + - views + parameters: + - $ref: "#/components/parameters/appId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/nameSearch" + responses: + "200": + description: Returns the found views, based on the search parameters. + content: + application/json: + schema: + $ref: "#/components/schemas/viewSearch" + examples: + views: + $ref: "#/components/examples/views" tags: [] diff --git a/packages/server/specs/parameters.ts b/packages/server/specs/parameters.ts index 2726ca5064..b3fb274567 100644 --- a/packages/server/specs/parameters.ts +++ b/packages/server/specs/parameters.ts @@ -8,6 +8,16 @@ export const tableId = { }, } +export const viewId = { + in: "path", + name: "viewId", + required: true, + description: "The ID of the view which this request is targeting.", + schema: { + type: "string", + }, +} + export const rowId = { in: "path", name: "rowId", diff --git a/packages/server/specs/resources/index.ts b/packages/server/specs/resources/index.ts index 49508e2e4f..0d32f2a007 100644 --- a/packages/server/specs/resources/index.ts +++ b/packages/server/specs/resources/index.ts @@ -6,6 +6,7 @@ import user from "./user" import metrics from "./metrics" import misc from "./misc" import roles from "./roles" +import view from "./view" export const examples = { ...application.getExamples(), @@ -16,6 +17,7 @@ export const examples = { ...misc.getExamples(), ...metrics.getExamples(), ...roles.getExamples(), + ...view.getExamples(), } export const schemas = { @@ -26,4 +28,5 @@ export const schemas = { ...user.getSchemas(), ...misc.getSchemas(), ...roles.getSchemas(), + ...view.getSchemas(), } diff --git a/packages/server/specs/resources/misc.ts b/packages/server/specs/resources/misc.ts index f56dff3301..8f77d2b22a 100644 --- a/packages/server/specs/resources/misc.ts +++ b/packages/server/specs/resources/misc.ts @@ -1,99 +1,101 @@ import { object } from "./utils" import Resource from "./utils/Resource" +export const searchSchema = { + type: "object", + properties: { + allOr: { + type: "boolean", + description: + "Specifies that a row should be returned if it satisfies any of the specified options, rather than requiring it to fulfill all the search parameters. This defaults to false, meaning AND logic will be used.", + }, + string: { + type: "object", + example: { + columnName1: "value", + columnName2: "value", + }, + description: + "A map of field name to the string to search for, this will look for rows that have a value starting with the string value.", + additionalProperties: { + type: "string", + description: "The value to search for in the column.", + }, + }, + fuzzy: { + type: "object", + description: + "Searches for a sub-string within a string column, e.g. searching for 'dib' will match 'Budibase'.", + }, + range: { + type: "object", + description: + 'Searches within a range, the format of this must be in the format of an object with a "low" and "high" property.', + example: { + columnName1: { + low: 10, + high: 20, + }, + }, + }, + equal: { + type: "object", + description: + "Searches for rows that have a column value that is exactly the value set.", + }, + notEqual: { + type: "object", + description: + "Searches for any row which does not contain the specified column value.", + }, + empty: { + type: "object", + description: + "Searches for rows which do not contain the specified column. The object should simply contain keys of the column names, these can map to any value.", + example: { + columnName1: "", + }, + }, + notEmpty: { + type: "object", + description: "Searches for rows which have the specified column.", + }, + oneOf: { + type: "object", + description: + "Searches for rows which have a column value that is any of the specified values. The format of this must be columnName -> [value1, value2].", + }, + contains: { + type: "object", + description: + "Searches for a value, or set of values in array column types (such as a multi-select). If an array of search options is provided then it must match all.", + example: { + arrayColumn: ["a", "b"], + }, + }, + notContains: { + type: "object", + description: + "The logical inverse of contains. Only works on array column types. If an array of values is passed, the row must not match any of them to be returned in the response.", + example: { + arrayColumn: ["a", "b"], + }, + }, + containsAny: { + type: "object", + description: + "As with the contains search, only works on array column types and searches for any of the provided values when given an array.", + example: { + arrayColumn: ["a", "b"], + }, + }, + }, +} + export default new Resource().setSchemas({ rowSearch: object( { - query: { - type: "object", - properties: { - allOr: { - type: "boolean", - description: - "Specifies that a row should be returned if it satisfies any of the specified options, rather than requiring it to fulfill all the search parameters. This defaults to false, meaning AND logic will be used.", - }, - string: { - type: "object", - example: { - columnName1: "value", - columnName2: "value", - }, - description: - "A map of field name to the string to search for, this will look for rows that have a value starting with the string value.", - additionalProperties: { - type: "string", - description: "The value to search for in the column.", - }, - }, - fuzzy: { - type: "object", - description: - "Searches for a sub-string within a string column, e.g. searching for 'dib' will match 'Budibase'.", - }, - range: { - type: "object", - description: - 'Searches within a range, the format of this must be in the format of an object with a "low" and "high" property.', - example: { - columnName1: { - low: 10, - high: 20, - }, - }, - }, - equal: { - type: "object", - description: - "Searches for rows that have a column value that is exactly the value set.", - }, - notEqual: { - type: "object", - description: - "Searches for any row which does not contain the specified column value.", - }, - empty: { - type: "object", - description: - "Searches for rows which do not contain the specified column. The object should simply contain keys of the column names, these can map to any value.", - example: { - columnName1: "", - }, - }, - notEmpty: { - type: "object", - description: "Searches for rows which have the specified column.", - }, - oneOf: { - type: "object", - description: - "Searches for rows which have a column value that is any of the specified values. The format of this must be columnName -> [value1, value2].", - }, - contains: { - type: "object", - description: - "Searches for a value, or set of values in array column types (such as a multi-select). If an array of search options is provided then it must match all.", - example: { - arrayColumn: ["a", "b"], - }, - }, - notContains: { - type: "object", - description: - "The logical inverse of contains. Only works on array column types. If an array of values is passed, the row must not match any of them to be returned in the response.", - example: { - arrayColumn: ["a", "b"], - }, - }, - containsAny: { - type: "object", - description: - "As with the contains search, only works on array column types and searches for any of the provided values when given an array.", - example: { - arrayColumn: ["a", "b"], - }, - }, - }, - }, + query: searchSchema, paginate: { type: "boolean", description: "Enables pagination, by default this is disabled.", diff --git a/packages/server/specs/resources/view.ts b/packages/server/specs/resources/view.ts new file mode 100644 index 0000000000..aeb2b97aa9 --- /dev/null +++ b/packages/server/specs/resources/view.ts @@ -0,0 +1,274 @@ +import { object } from "./utils" +import Resource from "./utils/Resource" +import { + ArrayOperator, + BasicOperator, + CalculationType, + RangeOperator, + SortOrder, + SortType, +} from "@budibase/types" +import { cloneDeep } from "lodash" + +const view = { + name: "peopleView", + tableId: "ta_896a325f7e8147d2a2cda93c5d236511", + schema: { + name: { + visible: true, + readonly: false, + order: 1, + width: 300, + }, + age: { + visible: true, + readonly: true, + order: 2, + width: 200, + }, + salary: { + visible: false, + readonly: false, + }, + }, + query: { + logicalOperator: "all", + onEmptyFilter: "none", + groups: [ + { + logicalOperator: "any", + filters: [ + { operator: "string", field: "name", value: "John" }, + { operator: "range", field: "age", value: { low: 18, high: 100 } }, + ], + }, + ], + }, + primaryDisplay: "name", +} + +const baseColumnDef = { + visible: { + type: "boolean", + description: + "Defines whether the column is visible or not - rows retrieved/updated through this view will not be able to access it.", + }, + readonly: { + type: "boolean", + description: + "When used in combination with 'visible: true' the column will be visible in row responses but cannot be updated.", + }, + order: { + type: "integer", + description: + "A number defining where the column shows up in tables, lowest being first.", + }, + width: { + type: "integer", + description: + "A width for the column, defined in pixels - this affects rendering in tables.", + }, + column: { + type: "array", + description: + "If this is a relationship column, we can set the columns we wish to include", + items: { + type: "object", + properties: { + readonly: { + type: "boolean", + }, + }, + }, + }, +} + +const logicalOperator = { + description: + "When using groups this defines whether all of the filters must match, or only one of them.", + type: "string", + enum: ["all", "any"], +} + +const filterGroup = { + description: "A grouping of filters to be applied.", + type: "array", + items: { + type: "object", + properties: { + logicalOperator, + filters: { + description: "A list of filters to apply", + type: "array", + items: { + type: "object", + properties: { + operator: { + type: "string", + description: + "The type of search operation which is being performed.", + enum: [ + ...Object.values(BasicOperator), + ...Object.values(ArrayOperator), + ...Object.values(RangeOperator), + ], + }, + field: { + type: "string", + description: "The field in the view to perform the search on.", + }, + value: { + description: + "The value to search for - the type will depend on the operator in use.", + oneOf: [ + { type: "string" }, + { type: "number" }, + { type: "boolean" }, + { type: "object" }, + { type: "array" }, + ], + }, + }, + }, + }, + }, + }, +} + +// have to clone to avoid constantly recursive structure - we can't represent this easily +const layeredFilterGroup: any = cloneDeep(filterGroup) +layeredFilterGroup.items.properties.groups = filterGroup + +const viewQuerySchema = { + description: "Search parameters for view", + type: "object", + required: [], + properties: { + logicalOperator, + onEmptyFilter: { + description: + "If no filters match, should the view return all rows, or no rows.", + type: "string", + enum: ["all", "none"], + }, + groups: layeredFilterGroup, + }, +} + +const viewSchema = { + description: "The view to be created/updated.", + type: "object", + required: ["name", "schema", "tableId"], + properties: { + name: { + description: "The name of the view.", + type: "string", + }, + tableId: { + description: "The ID of the table this view is based on.", + type: "string", + }, + type: { + description: "The type of view - standard (empty value) or calculation.", + type: "string", + enum: ["calculation"], + }, + primaryDisplay: { + type: "string", + description: + "A column used to display rows from this view - usually used when rendered in tables.", + }, + query: viewQuerySchema, + sort: { + type: "object", + required: ["field"], + properties: { + field: { + type: "string", + description: "The field from the table/view schema to sort on.", + }, + order: { + type: "string", + description: "The order in which to sort.", + enum: Object.values(SortOrder), + }, + type: { + type: "string", + description: + "The type of sort to perform (by number, or by alphabetically).", + enum: Object.values(SortType), + }, + }, + }, + schema: { + type: "object", + additionalProperties: { + oneOf: [ + { + type: "object", + properties: baseColumnDef, + }, + { + type: "object", + properties: { + calculationType: { + type: "string", + description: + "This column should be built from a calculation, specifying a type and field. It is important to note when a calculation is configured all non-calculation columns will be used for grouping.", + enum: Object.values(CalculationType), + }, + field: { + type: "string", + description: + "The field from the table to perform the calculation on.", + }, + distinct: { + type: "boolean", + description: + "Can be used in tandem with the count calculation type, to count unique entries.", + }, + }, + }, + ], + }, + }, + }, +} + +const viewOutputSchema = { + ...viewSchema, + properties: { + ...viewSchema.properties, + id: { + description: "The ID of the view.", + type: "string", + }, + }, + required: [...viewSchema.required, "id"], +} + +export default new Resource() + .setExamples({ + view: { + value: { + data: view, + }, + }, + views: { + value: { + data: [view], + }, + }, + }) + .setSchemas({ + view: viewSchema, + viewOutput: object({ + data: viewOutputSchema, + }), + viewSearch: object({ + data: { + type: "array", + items: viewOutputSchema, + }, + }), + }) diff --git a/packages/server/src/api/controllers/application.ts b/packages/server/src/api/controllers/application.ts index e7d0ed7ba7..101257c321 100644 --- a/packages/server/src/api/controllers/application.ts +++ b/packages/server/src/api/controllers/application.ts @@ -153,7 +153,11 @@ async function createInstance(appId: string, template: AppTemplate) { await createAllSearchIndex() if (template && template.useTemplate) { - await sdk.backups.importApp(appId, db, template) + const opts = { + importObjStoreContents: true, + updateAttachmentColumns: !template.key, // preserve attachments when using Budibase templates + } + await sdk.backups.importApp(appId, db, template, opts) } else { // create the users table await db.put(USERS_TABLE_SCHEMA) diff --git a/packages/server/src/api/controllers/public/mapping/applications.ts b/packages/server/src/api/controllers/public/mapping/applications.ts index 0b729fc610..74c55e1c7b 100644 --- a/packages/server/src/api/controllers/public/mapping/applications.ts +++ b/packages/server/src/api/controllers/public/mapping/applications.ts @@ -1,6 +1,7 @@ import { Application } from "./types" +import { RequiredKeys } from "@budibase/types" -function application(body: any): Application { +function application(body: any): RequiredKeys { let app = body?.application ? body.application : body return { _id: app.appId, diff --git a/packages/server/src/api/controllers/public/mapping/index.ts b/packages/server/src/api/controllers/public/mapping/index.ts index 0cdcfbbe4b..b765f4bd76 100644 --- a/packages/server/src/api/controllers/public/mapping/index.ts +++ b/packages/server/src/api/controllers/public/mapping/index.ts @@ -3,6 +3,7 @@ import applications from "./applications" import users from "./users" import rows from "./rows" import queries from "./queries" +import views from "./views" export default { ...tables, @@ -10,4 +11,5 @@ export default { ...users, ...rows, ...queries, + ...views, } diff --git a/packages/server/src/api/controllers/public/mapping/queries.ts b/packages/server/src/api/controllers/public/mapping/queries.ts index 481b5f18c4..d0857453f6 100644 --- a/packages/server/src/api/controllers/public/mapping/queries.ts +++ b/packages/server/src/api/controllers/public/mapping/queries.ts @@ -1,6 +1,7 @@ import { Query, ExecuteQuery } from "./types" +import { RequiredKeys } from "@budibase/types" -function query(body: any): Query { +function query(body: any): RequiredKeys { return { _id: body._id, datasourceId: body.datasourceId, diff --git a/packages/server/src/api/controllers/public/mapping/rows.ts b/packages/server/src/api/controllers/public/mapping/rows.ts index c1cba43718..69f376bebf 100644 --- a/packages/server/src/api/controllers/public/mapping/rows.ts +++ b/packages/server/src/api/controllers/public/mapping/rows.ts @@ -1,6 +1,7 @@ import { Row, RowSearch } from "./types" +import { RequiredKeys } from "@budibase/types" -function row(body: any): Row { +function row(body: any): RequiredKeys { delete body._rev // have to input everything, since structure unknown return { diff --git a/packages/server/src/api/controllers/public/mapping/tables.ts b/packages/server/src/api/controllers/public/mapping/tables.ts index 72ed9f1a9a..857feb82ca 100644 --- a/packages/server/src/api/controllers/public/mapping/tables.ts +++ b/packages/server/src/api/controllers/public/mapping/tables.ts @@ -1,6 +1,7 @@ import { Table } from "./types" +import { RequiredKeys } from "@budibase/types" -function table(body: any): Table { +function table(body: any): RequiredKeys { return { _id: body._id, name: body.name, diff --git a/packages/server/src/api/controllers/public/mapping/types.ts b/packages/server/src/api/controllers/public/mapping/types.ts index 9fea9b7213..6cbcfddb92 100644 --- a/packages/server/src/api/controllers/public/mapping/types.ts +++ b/packages/server/src/api/controllers/public/mapping/types.ts @@ -9,6 +9,9 @@ export type CreateApplicationParams = components["schemas"]["application"] export type Table = components["schemas"]["tableOutput"]["data"] export type CreateTableParams = components["schemas"]["table"] +export type View = components["schemas"]["viewOutput"]["data"] +export type CreateViewParams = components["schemas"]["view"] + export type Row = components["schemas"]["rowOutput"]["data"] export type RowSearch = components["schemas"]["searchOutput"] export type CreateRowParams = components["schemas"]["row"] diff --git a/packages/server/src/api/controllers/public/mapping/users.ts b/packages/server/src/api/controllers/public/mapping/users.ts index 2a158bede9..232c81cec0 100644 --- a/packages/server/src/api/controllers/public/mapping/users.ts +++ b/packages/server/src/api/controllers/public/mapping/users.ts @@ -1,6 +1,7 @@ import { User } from "./types" +import { RequiredKeys } from "@budibase/types" -function user(body: any): User { +function user(body: any): RequiredKeys { return { _id: body._id, email: body.email, diff --git a/packages/server/src/api/controllers/public/mapping/views.ts b/packages/server/src/api/controllers/public/mapping/views.ts new file mode 100644 index 0000000000..9ee1fe42d5 --- /dev/null +++ b/packages/server/src/api/controllers/public/mapping/views.ts @@ -0,0 +1,32 @@ +import { View } from "./types" +import { ViewV2, Ctx, RequiredKeys } from "@budibase/types" +import { dataFilters } from "@budibase/shared-core" + +function view(body: ViewV2): RequiredKeys { + return { + id: body.id, + tableId: body.tableId, + type: body.type, + name: body.name, + schema: body.schema!, + primaryDisplay: body.primaryDisplay, + query: dataFilters.buildQuery(body.query), + sort: body.sort, + } +} + +function mapView(ctx: Ctx<{ data: ViewV2 }>): { data: View } { + return { + data: view(ctx.body.data), + } +} + +function mapViews(ctx: Ctx<{ data: ViewV2[] }>): { data: View[] } { + const views = ctx.body.data.map((body: ViewV2) => view(body)) + return { data: views } +} + +export default { + mapView, + mapViews, +} diff --git a/packages/server/src/api/controllers/public/rows.ts b/packages/server/src/api/controllers/public/rows.ts index 16403b06c9..3c9cbf0ddd 100644 --- a/packages/server/src/api/controllers/public/rows.ts +++ b/packages/server/src/api/controllers/public/rows.ts @@ -22,13 +22,13 @@ export function fixRow(row: Row, params: any) { return row } -export async function search(ctx: UserCtx, next: Next) { +function buildSearchRequestBody(ctx: UserCtx) { let { sort, paginate, bookmark, limit, query } = ctx.request.body // update the body to the correct format of the internal search if (!sort) { sort = {} } - ctx.request.body = { + return { sort: sort.column, sortType: sort.type, sortOrder: sort.order, @@ -37,10 +37,23 @@ export async function search(ctx: UserCtx, next: Next) { limit, query, } +} + +export async function search(ctx: UserCtx, next: Next) { + ctx.request.body = buildSearchRequestBody(ctx) await rowController.search(ctx) await next() } +export async function viewSearch(ctx: UserCtx, next: Next) { + ctx.request.body = buildSearchRequestBody(ctx) + ctx.params = { + viewId: ctx.params.viewId, + } + await rowController.views.searchView(ctx) + await next() +} + export async function create(ctx: UserCtx, next: Next) { ctx.request.body = fixRow(ctx.request.body, ctx.params) await rowController.save(ctx) @@ -79,4 +92,5 @@ export default { update, destroy, search, + viewSearch, } diff --git a/packages/server/src/api/controllers/public/views.ts b/packages/server/src/api/controllers/public/views.ts new file mode 100644 index 0000000000..5b08f39e36 --- /dev/null +++ b/packages/server/src/api/controllers/public/views.ts @@ -0,0 +1,95 @@ +import { search as stringSearch } from "./utils" +import * as controller from "../view" +import { ViewV2, UserCtx, UISearchFilter, PublicAPIView } from "@budibase/types" +import { Next } from "koa" +import { merge } from "lodash" + +function viewRequest(view: PublicAPIView, params?: { viewId: string }) { + const viewV2: ViewV2 = view + if (!viewV2) { + return viewV2 + } + if (params?.viewId) { + viewV2.id = params.viewId + } + if (!view.query) { + viewV2.query = {} + } else { + // public API only has one form of query + viewV2.queryUI = viewV2.query as UISearchFilter + } + viewV2.version = 2 + return viewV2 +} + +function viewResponse(view: ViewV2): PublicAPIView { + // remove our internal structure - always un-necessary + delete view.query + return { + ...view, + query: view.queryUI, + } +} + +function viewsResponse(views: ViewV2[]): PublicAPIView[] { + return views.map(viewResponse) +} + +export async function search(ctx: UserCtx, next: Next) { + const { name } = ctx.request.body + await controller.v2.fetch(ctx) + ctx.body.data = viewsResponse(stringSearch(ctx.body.data, name)) + await next() +} + +export async function create(ctx: UserCtx, next: Next) { + ctx = merge(ctx, { + request: { + body: viewRequest(ctx.request.body), + }, + }) + await controller.v2.create(ctx) + ctx.body.data = viewResponse(ctx.body.data) + await next() +} + +export async function read(ctx: UserCtx, next: Next) { + ctx = merge(ctx, { + params: { + viewId: ctx.params.viewId, + }, + }) + await controller.v2.get(ctx) + ctx.body.data = viewResponse(ctx.body.data) + await next() +} + +export async function update(ctx: UserCtx, next: Next) { + const viewId = ctx.params.viewId + ctx = merge(ctx, { + request: { + body: { + data: viewRequest(ctx.request.body, { viewId }), + }, + }, + params: { + viewId, + }, + }) + await controller.v2.update(ctx) + ctx.body.data = viewResponse(ctx.body.data) + await next() +} + +export async function destroy(ctx: UserCtx, next: Next) { + await controller.v2.remove(ctx) + await next() +} + +export default { + create, + read, + update, + destroy, + search, +} diff --git a/packages/server/src/api/controllers/query/import/sources/curl.ts b/packages/server/src/api/controllers/query/import/sources/curl.ts index ba85d82be0..5742d254af 100644 --- a/packages/server/src/api/controllers/query/import/sources/curl.ts +++ b/packages/server/src/api/controllers/query/import/sources/curl.ts @@ -4,7 +4,7 @@ import { URL } from "url" const curlconverter = require("curlconverter") -const parseCurl = (data: string): any => { +const parseCurl = (data: string): Promise => { const curlJson = curlconverter.toJsonString(data) return JSON.parse(curlJson) } @@ -53,8 +53,7 @@ export class Curl extends ImportSource { isSupported = async (data: string): Promise => { try { - const curl = parseCurl(data) - this.curl = curl + this.curl = parseCurl(data) } catch (err) { return false } diff --git a/packages/server/src/api/controllers/query/index.ts b/packages/server/src/api/controllers/query/index.ts index 54f672c3f3..15c60bcf47 100644 --- a/packages/server/src/api/controllers/query/index.ts +++ b/packages/server/src/api/controllers/query/index.ts @@ -23,6 +23,7 @@ import { } from "@budibase/types" import { ValidQueryNameRegex, utils as JsonUtils } from "@budibase/shared-core" import { findHBSBlocks } from "@budibase/string-templates" +import { ObjectId } from "mongodb" const Runner = new Thread(ThreadType.QUERY, { timeoutMs: env.QUERY_THREAD_TIMEOUT, @@ -223,6 +224,8 @@ export async function preview( } else { fieldMetadata = makeQuerySchema(FieldType.ARRAY, key) } + } else if (field instanceof ObjectId) { + fieldMetadata = makeQuerySchema(FieldType.STRING, key) } else { fieldMetadata = makeQuerySchema(FieldType.JSON, key) } diff --git a/packages/server/src/api/controllers/row/views.ts b/packages/server/src/api/controllers/row/views.ts index b8d01424f2..02ac871de0 100644 --- a/packages/server/src/api/controllers/row/views.ts +++ b/packages/server/src/api/controllers/row/views.ts @@ -50,6 +50,7 @@ export async function searchView( result.rows.forEach(r => (r._viewId = view.id)) ctx.body = result } + function getSortOptions(request: SearchViewRowRequest, view: ViewV2) { if (request.sort) { return { diff --git a/packages/server/src/api/controllers/static/index.ts b/packages/server/src/api/controllers/static/index.ts index daf7b9b25c..1bf04e94f0 100644 --- a/packages/server/src/api/controllers/static/index.ts +++ b/packages/server/src/api/controllers/static/index.ts @@ -209,6 +209,7 @@ export const serveApp = async function (ctx: UserCtx) { ? objectStore.getGlobalFileUrl("settings", "logoUrl") : "", appMigrating: needMigrations, + nonce: ctx.state.nonce, }) const appHbs = loadHandlebarsFile(appHbsPath) ctx.body = await processString(appHbs, { @@ -217,6 +218,7 @@ export const serveApp = async function (ctx: UserCtx) { css: `:root{${themeVariables}} ${css.code}`, appId, embedded: bbHeaderEmbed, + nonce: ctx.state.nonce, }) } else { // just return the app info for jest to assert on @@ -258,6 +260,7 @@ export const serveBuilderPreview = async function (ctx: Ctx) { const previewHbs = loadHandlebarsFile(join(previewLoc, "preview.hbs")) ctx.body = await processString(previewHbs, { clientLibPath: objectStore.clientLibraryUrl(appId!, appInfo.version), + nonce: ctx.state.nonce, }) } else { // just return the app info for jest to assert on diff --git a/packages/server/src/api/controllers/static/templates/BudibaseApp.svelte b/packages/server/src/api/controllers/static/templates/BudibaseApp.svelte index b4bfbe6660..b88b738f90 100644 --- a/packages/server/src/api/controllers/static/templates/BudibaseApp.svelte +++ b/packages/server/src/api/controllers/static/templates/BudibaseApp.svelte @@ -16,6 +16,8 @@ export let hideDevTools export let sideNav export let hideFooter + + export let nonce @@ -118,11 +120,11 @@

{/if} - {#if appMigrating} - {/if} @@ -135,7 +137,7 @@ {/each} {/if} - diff --git a/packages/server/src/api/controllers/static/templates/preview.hbs b/packages/server/src/api/controllers/static/templates/preview.hbs index 54b5b1a4e4..87b9ad6ea3 100644 --- a/packages/server/src/api/controllers/static/templates/preview.hbs +++ b/packages/server/src/api/controllers/static/templates/preview.hbs @@ -31,7 +31,7 @@ } -