diff --git a/.eslintignore b/.eslintignore index 54824be5c7..579bd55947 100644 --- a/.eslintignore +++ b/.eslintignore @@ -7,4 +7,5 @@ packages/server/client packages/builder/.routify packages/builder/cypress/support/queryLevelTransformerFunction.js packages/builder/cypress/support/queryLevelTransformerFunctionWithData.js -packages/builder/cypress/reports \ No newline at end of file +packages/builder/cypress/reports +packages/sdk/sdk \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/epic.md b/.github/ISSUE_TEMPLATE/epic.md new file mode 100644 index 0000000000..b8cf652125 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/epic.md @@ -0,0 +1,24 @@ +--- +name: Epic +about: Plan a new project +title: '' +labels: epic +assignees: '' + +--- + +## Description +Brief summary of what this Epic is, whether it's a larger project, goal, or user story. Describe the job to be done, which persona this Epic is mainly for, or if more multiple, break it down by user and job story. + +## Spec +Link to confluence spec + +## Teams and Stakeholders +Describe who needs to be kept up-to-date about this Epic, included in discussions, or updated along the way. Stakeholders can be both in Product/Engineering, as well as other teams like Customer Success who might want to keep customers updated on the Epic project. + + +## Workflow +- [ ] Spec Created and pasted above +- [ ] Product Review +- [ ] Designs created +- [ ] Individual Tasks created and assigned to Epic diff --git a/.github/workflows/budibase_ci.yml b/.github/workflows/budibase_ci.yml index e940e6fa10..475bd4f66a 100644 --- a/.github/workflows/budibase_ci.yml +++ b/.github/workflows/budibase_ci.yml @@ -23,6 +23,15 @@ jobs: build: runs-on: ubuntu-latest + services: + couchdb: + image: ibmcom/couchdb3 + env: + COUCHDB_PASSWORD: budibase + COUCHDB_USER: budibase + ports: + - 4567:5984 + strategy: matrix: node-version: [14.x] @@ -53,9 +62,8 @@ jobs: name: codecov-umbrella verbose: true - # TODO: parallelise this - - name: Cypress run - uses: cypress-io/github-action@v2 - with: - install: false - command: yarn test:e2e:ci + - name: QA Core Integration Tests + run: | + cd qa-core + yarn + yarn api:test:ci \ No newline at end of file diff --git a/.github/workflows/deploy-single-image.yml b/.github/workflows/deploy-single-image.yml index 8bf8f232c5..cd16574eea 100644 --- a/.github/workflows/deploy-single-image.yml +++ b/.github/workflows/deploy-single-image.yml @@ -4,8 +4,6 @@ on: workflow_dispatch: env: - BASE_BRANCH: ${{ github.event.pull_request.base.ref}} - BRANCH: ${{ github.event.pull_request.head.ref }} CI: true PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }} REGISTRY_URL: registry.hub.docker.com @@ -17,6 +15,11 @@ jobs: matrix: node-version: [14.x] steps: + - name: Fail if branch is not master + if: github.ref != 'refs/heads/master' + run: | + echo "Ref is not master, you must run this job from master." + exit 1 - name: "Checkout" uses: actions/checkout@v2 - name: Use Node.js ${{ matrix.node-version }} @@ -28,8 +31,6 @@ jobs: - name: Setup Docker Buildx id: buildx uses: docker/setup-buildx-action@v1 - - name: Install Pro - run: yarn install:pro $BRANCH $BASE_BRANCH - name: Run Yarn run: yarn - name: Run Yarn Bootstrap diff --git a/.github/workflows/release-develop.yml b/.github/workflows/release-develop.yml index 57e65c734e..21c74851e1 100644 --- a/.github/workflows/release-develop.yml +++ b/.github/workflows/release-develop.yml @@ -46,7 +46,8 @@ jobs: - run: yarn - run: yarn bootstrap - run: yarn lint - - run: yarn build + - run: yarn build + - run: yarn build:sdk - run: yarn test - name: Configure AWS Credentials diff --git a/.github/workflows/release-selfhost.yml b/.github/workflows/release-selfhost.yml index da064f3e32..d78180fdc7 100644 --- a/.github/workflows/release-selfhost.yml +++ b/.github/workflows/release-selfhost.yml @@ -3,10 +3,6 @@ name: Budibase Release Selfhost on: workflow_dispatch: -env: - BRANCH: ${{ github.event.pull_request.head.ref }} - BASE_BRANCH: ${{ github.event.pull_request.base.ref}} - jobs: release: runs-on: ubuntu-latest @@ -54,9 +50,6 @@ jobs: DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }} SELFHOST_TAG: latest - - name: Install Pro - run: yarn install:pro $BRANCH $BASE_BRANCH - - name: Bootstrap and build (CLI) run: | yarn diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 961082e1ef..de288dd7db 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,6 +56,7 @@ jobs: - run: yarn bootstrap - run: yarn lint - run: yarn build + - run: yarn build:sdk - run: yarn test - name: Configure AWS Credentials diff --git a/.gitignore b/.gitignore index f063e2224f..e1d3e6db0e 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,7 @@ typings/ # dotenv environment variables file .env +!qa-core/.env !hosting/.env hosting/.generated-nginx.dev.conf hosting/proxy/.generated-nginx.prod.conf @@ -102,4 +103,6 @@ packages/builder/cypress/reports stats.html # TypeScript cache -*.tsbuildinfo \ No newline at end of file +*.tsbuildinfo +budibase-component +budibase-datasource diff --git a/.prettierignore b/.prettierignore index bbeff65da7..3a381d255e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,3 +9,4 @@ packages/server/src/definitions/openapi.ts packages/builder/.routify packages/builder/cypress/support/queryLevelTransformerFunction.js packages/builder/cypress/support/queryLevelTransformerFunctionWithData.js +packages/sdk/sdk \ No newline at end of file diff --git a/README.md b/README.md index 1dec1737da..bd38610566 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Budibase is open-source - licensed as GPL v3. This should fill you with confiden

### Load data or start from scratch -Budibase pulls in data from multiple sources, including MongoDB, CouchDB, PostgreSQL, MySQL, Airtable, S3, DynamoDB, or a REST API. And unlike other platforms, with Budibase you can start from scratch and create business apps with no data sources. [Request new data sources](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas). +Budibase pulls in data from multiple sources, including MongoDB, CouchDB, PostgreSQL, MySQL, Airtable, S3, DynamoDB, or a REST API. And unlike other platforms, with Budibase you can start from scratch and create business apps with no datasources. [Request new datasources](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas).

Budibase data diff --git a/charts/budibase/templates/app-service-deployment.yaml b/charts/budibase/templates/app-service-deployment.yaml index fd46e77647..d71ee6e178 100644 --- a/charts/budibase/templates/app-service-deployment.yaml +++ b/charts/budibase/templates/app-service-deployment.yaml @@ -78,6 +78,12 @@ spec: key: objectStoreSecret - name: MINIO_URL value: {{ .Values.services.objectStore.url }} + - name: PLUGIN_BUCKET_NAME + value: {{ .Values.services.objectStore.pluginBucketName | quote }} + - name: APPS_BUCKET_NAME + value: {{ .Values.services.objectStore.appsBucketName | quote }} + - name: GLOBAL_CLOUD_BUCKET_NAME + value: {{ .Values.services.objectStore.globalBucketName | quote }} - name: PORT value: {{ .Values.services.apps.port | quote }} {{ if .Values.services.worker.publicApiRateLimitPerSecond }} @@ -124,11 +130,31 @@ spec: value: {{ .Values.globals.tenantFeatureFlags | quote }} {{ if .Values.globals.bbAdminUserEmail }} - name: BB_ADMIN_USER_EMAIL - value: { { .Values.globals.bbAdminUserEmail | quote } } + value: {{ .Values.globals.bbAdminUserEmail | quote }} {{ end }} {{ if .Values.globals.bbAdminUserPassword }} - name: BB_ADMIN_USER_PASSWORD - value: { { .Values.globals.bbAdminUserPassword | quote } } + value: {{ .Values.globals.bbAdminUserPassword | quote }} + {{ end }} + {{ if .Values.globals.pluginsDir }} + - name: PLUGINS_DIR + value: {{ .Values.globals.pluginsDir | quote }} + {{ end }} + {{ if .Values.services.apps.nodeDebug }} + - name: NODE_DEBUG + value: {{ .Values.services.apps.nodeDebug | quote }} + {{ end }} + {{ if .Values.globals.elasticApmEnabled }} + - name: ELASTIC_APM_ENABLED + value: {{ .Values.globals.elasticApmEnabled | quote }} + {{ end }} + {{ if .Values.globals.elasticApmSecretToken }} + - name: ELASTIC_APM_SECRET_TOKEN + value: {{ .Values.globals.elasticApmSecretToken | quote }} + {{ end }} + {{ if .Values.globals.elasticApmServerUrl }} + - name: ELASTIC_APM_SERVER_URL + value: {{ .Values.globals.elasticApmServerUrl | quote }} {{ end }} image: budibase/apps:{{ .Values.globals.appVersion }} @@ -142,7 +168,10 @@ spec: name: bbapps ports: - containerPort: {{ .Values.services.apps.port }} - resources: {} + {{ with .Values.services.apps.resources }} + resources: + {{- toYaml . | nindent 10 }} + {{ end }} {{- with .Values.affinity }} affinity: {{- toYaml . | nindent 8 }} diff --git a/charts/budibase/templates/couchdb-backup.yaml b/charts/budibase/templates/couchdb-backup.yaml index ae062475ce..68e5eab617 100644 --- a/charts/budibase/templates/couchdb-backup.yaml +++ b/charts/budibase/templates/couchdb-backup.yaml @@ -38,7 +38,10 @@ spec: image: redgeoff/replicate-couchdb-cluster imagePullPolicy: Always name: couchdb-backup - resources: {} + {{ with .Values.services.couchdb.backup.resources }} + resources: + {{- toYaml . | nindent 10 }} + {{ end }} {{- with .Values.affinity }} affinity: {{- toYaml . | nindent 8 }} diff --git a/charts/budibase/templates/minio-service-deployment.yaml b/charts/budibase/templates/minio-service-deployment.yaml index 103f9e3ed2..144dbe539a 100644 --- a/charts/budibase/templates/minio-service-deployment.yaml +++ b/charts/budibase/templates/minio-service-deployment.yaml @@ -56,7 +56,10 @@ spec: name: minio-service ports: - containerPort: {{ .Values.services.objectStore.port }} - resources: {} + {{ with .Values.services.objectStore.resources }} + resources: + {{- toYaml . | nindent 10 }} + {{ end }} volumeMounts: - mountPath: /data name: minio-data diff --git a/charts/budibase/templates/proxy-service-deployment.yaml b/charts/budibase/templates/proxy-service-deployment.yaml index 505a46f1e8..5588022032 100644 --- a/charts/budibase/templates/proxy-service-deployment.yaml +++ b/charts/budibase/templates/proxy-service-deployment.yaml @@ -30,7 +30,10 @@ spec: name: proxy-service ports: - containerPort: {{ .Values.services.proxy.port }} - resources: {} + {{ with .Values.services.proxy.resources }} + resources: + {{- toYaml . | nindent 10 }} + {{ end }} volumeMounts: {{- with .Values.affinity }} affinity: diff --git a/charts/budibase/templates/redis-service-deployment.yaml b/charts/budibase/templates/redis-service-deployment.yaml index 6e09346cad..d94e4d70f8 100644 --- a/charts/budibase/templates/redis-service-deployment.yaml +++ b/charts/budibase/templates/redis-service-deployment.yaml @@ -35,7 +35,10 @@ spec: name: redis-service ports: - containerPort: {{ .Values.services.redis.port }} - resources: {} + {{ with .Values.services.redis.resources }} + resources: + {{- toYaml . | nindent 10 }} + {{ end }} volumeMounts: - mountPath: /data name: redis-data diff --git a/charts/budibase/templates/worker-service-deployment.yaml b/charts/budibase/templates/worker-service-deployment.yaml index 918dab427b..ffcda1ab72 100644 --- a/charts/budibase/templates/worker-service-deployment.yaml +++ b/charts/budibase/templates/worker-service-deployment.yaml @@ -27,6 +27,8 @@ spec: spec: containers: - env: + - name: BUDIBASE_ENVIRONMENT + value: {{ .Values.globals.budibaseEnv }} - name: DEPLOYMENT_ENVIRONMENT value: "kubernetes" - name: CLUSTER_PORT @@ -75,6 +77,12 @@ spec: key: objectStoreSecret - name: MINIO_URL value: {{ .Values.services.objectStore.url }} + - name: PLUGIN_BUCKET_NAME + value: {{ .Values.services.objectStore.pluginBucketName | quote }} + - name: APPS_BUCKET_NAME + value: {{ .Values.services.objectStore.appsBucketName | quote }} + - name: GLOBAL_CLOUD_BUCKET_NAME + value: {{ .Values.services.objectStore.globalBucketName | quote }} - name: PORT value: {{ .Values.services.worker.port | quote }} - name: MULTI_TENANCY @@ -125,6 +133,19 @@ spec: value: {{ .Values.globals.google.secret | quote }} - name: TENANT_FEATURE_FLAGS value: {{ .Values.globals.tenantFeatureFlags | quote }} + {{ if .Values.globals.elasticApmEnabled }} + - name: ELASTIC_APM_ENABLED + value: {{ .Values.globals.elasticApmEnabled | quote }} + {{ end }} + {{ if .Values.globals.elasticApmSecretToken }} + - name: ELASTIC_APM_SECRET_TOKEN + value: {{ .Values.globals.elasticApmSecretToken | quote }} + {{ end }} + {{ if .Values.globals.elasticApmServerUrl }} + - name: ELASTIC_APM_SERVER_URL + value: {{ .Values.globals.elasticApmServerUrl | quote }} + {{ end }} + image: budibase/worker:{{ .Values.globals.appVersion }} imagePullPolicy: Always livenessProbe: @@ -136,7 +157,10 @@ spec: name: bbworker ports: - containerPort: {{ .Values.services.worker.port }} - resources: {} + {{ with .Values.services.worker.resources }} + resources: + {{- toYaml . | nindent 10 }} + {{ end }} {{- with .Values.affinity }} affinity: {{- toYaml . | nindent 8 }} diff --git a/charts/budibase/values.yaml b/charts/budibase/values.yaml index 404e92c70f..5c4004cb57 100644 --- a/charts/budibase/values.yaml +++ b/charts/budibase/values.yaml @@ -60,19 +60,6 @@ ingress: port: number: 10000 -resources: - {} - # We usually recommend not to specify default resources and to leave this as a conscious - # choice for the user. This also increases chances charts run on environments with little - # resources, such as Minikube. If you do want to specify resources, uncomment the following - # lines, adjust them as necessary, and remove the curly braces after 'resources:'. - # limits: - # cpu: 100m - # memory: 128Mi - # requests: - # cpu: 100m - # memory: 128Mi - autoscaling: enabled: false minReplicas: 1 @@ -89,6 +76,7 @@ affinity: {} globals: appVersion: "latest" budibaseEnv: PRODUCTION + tenantFeatureFlags: "*:LICENSING,*:USER_GROUPS" enableAnalytics: "1" sentryDSN: "" posthogToken: "phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU" @@ -114,6 +102,10 @@ globals: smtp: enabled: false +# elasticApmEnabled: +# elasticApmSecretToken: +# elasticApmServerUrl: + services: budibaseVersion: latest dns: cluster.local @@ -121,15 +113,19 @@ services: proxy: port: 10000 replicaCount: 1 + resources: {} apps: port: 4002 replicaCount: 1 logLevel: info + resources: {} +# nodeDebug: "" # set the value of NODE_DEBUG worker: port: 4003 replicaCount: 1 + resources: {} couchdb: enabled: true @@ -143,6 +139,7 @@ services: target: "" # backup interval in seconds interval: "" + resources: {} redis: enabled: true # disable if using external redis @@ -156,6 +153,7 @@ services: ## If undefined (the default) or set to null, no storageClassName spec is ## set, choosing the default provisioner. storageClass: "" + resources: {} objectStore: minio: true @@ -172,6 +170,7 @@ services: ## If undefined (the default) or set to null, no storageClassName spec is ## set, choosing the default provisioner. storageClass: "" + resources: {} # Override values in couchDB subchart couchdb: diff --git a/docs/DEV-SETUP-DEBIAN.md b/docs/DEV-SETUP-DEBIAN.md index 88a124708c..9edd8286cb 100644 --- a/docs/DEV-SETUP-DEBIAN.md +++ b/docs/DEV-SETUP-DEBIAN.md @@ -1,12 +1,15 @@ ## Dev Environment on Debian 11 -### Install Node +### Install NVM & Node 14 +NVM documentation: https://github.com/nvm-sh/nvm#installing-and-updating -Budibase requires a recent version of node (14+): +Install NVM ``` -curl -sL https://deb.nodesource.com/setup_16.x | sudo bash - -apt -y install nodejs -node -v +curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash +``` +Install Node 14 +``` +nvm install 14 ``` ### Install npm requirements @@ -31,7 +34,7 @@ This setup process was tested on Debian 11 (bullseye) with version numbers show - Docker: 20.10.5 - Docker-Compose: 1.29.2 -- Node: v16.15.1 +- Node: v14.20.1 - Yarn: 1.22.19 - Lerna: 5.1.4 diff --git a/docs/DEV-SETUP-MACOSX.md b/docs/DEV-SETUP-MACOSX.md index c5990e58da..d9e2dcad6a 100644 --- a/docs/DEV-SETUP-MACOSX.md +++ b/docs/DEV-SETUP-MACOSX.md @@ -11,7 +11,7 @@ through brew. ### Install Node -Budibase requires a recent version of node (14+): +Budibase requires a recent version of node 14: ``` brew install node npm node -v @@ -38,7 +38,7 @@ This setup process was tested on Mac OSX 12 (Monterey) with version numbers show - Docker: 20.10.14 - Docker-Compose: 2.6.0 -- Node: 18.3.0 +- Node: 14.20.1 - Yarn: 1.22.19 - Lerna: 5.1.4 @@ -59,4 +59,7 @@ The dev version will be available on port 10000 i.e. http://127.0.0.1:10000/builder/admin | **NOTE**: If you are working on a M1 Apple Silicon, you will need to uncomment `# platform: linux/amd64` line in -[hosting/docker-compose-dev.yaml](../hosting/docker-compose.dev.yaml) \ No newline at end of file +[hosting/docker-compose-dev.yaml](../hosting/docker-compose.dev.yaml) + +### Troubleshooting +If there are errors with the `yarn setup` command, you can try installing nvm and node 14. This is the same as the instructions for Debian 11. diff --git a/docs/DEV-SETUP-WINDOWS.md b/docs/DEV-SETUP-WINDOWS.md new file mode 100644 index 0000000000..c5608b7567 --- /dev/null +++ b/docs/DEV-SETUP-WINDOWS.md @@ -0,0 +1,81 @@ +## Dev Environment on Windows 10/11 (WSL2) + + +### Install WSL with Ubuntu LTS + +Enable WSL 2 on Windows 10/11 for docker support. +``` +wsl --set-default-version 2 +``` +Install Ubuntu LTS. +``` +wsl --install Ubuntu +``` + +Or follow the instruction here: +https://learn.microsoft.com/en-us/windows/wsl/install + +### Install Docker in windows +Download the installer from docker and install it. + +Check this url for more detailed instructions: +https://docs.docker.com/desktop/install/windows-install/ + +You should follow the next steps from within the Ubuntu terminal. + +### Install NVM & Node 14 +NVM documentation: https://github.com/nvm-sh/nvm#installing-and-updating + +Install NVM +``` +curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash +``` +Install Node 14 +``` +nvm install 14 +``` + + +### Install npm requirements + +``` +npm install -g yarn jest lerna +``` + +### Clone the repo +``` +git clone https://github.com/Budibase/budibase.git +``` + +### Check Versions + +This setup process was tested on Windows 11 with version numbers show below. Your mileage may vary using anything else. + +- Docker: 20.10.7 +- Docker-Compose: 2.10.2 +- Node: v14.20.1 +- Yarn: 1.22.19 +- Lerna: 5.5.4 + +### Build + +``` +cd budibase +yarn setup +``` +The yarn setup command runs several build steps i.e. +``` +node ./hosting/scripts/setup.js && yarn && yarn bootstrap && yarn build && yarn dev +``` +So this command will actually run the application in dev mode. It creates .env files under `./packages/server` and `./packages/worker` and runs docker containers for each service via docker-compose. + +The dev version will be available on port 10000 i.e. + +http://127.0.0.1:10000/builder/admin + +### Working with the code +Here are the instructions to work on the application from within Visual Studio Code (in Windows) through the WSL. All the commands and files are within the Ubuntu system and it should run as if you were working on a Linux machine. + +https://code.visualstudio.com/docs/remote/wsl + +Note you will be able to run the application from within the WSL terminal and you will be able to access the application from the a browser in Windows. \ No newline at end of file diff --git a/examples/nextjs-api-sales/definitions/openapi.ts b/examples/nextjs-api-sales/definitions/openapi.ts index 4f4ad45fc6..7f7f6befec 100644 --- a/examples/nextjs-api-sales/definitions/openapi.ts +++ b/examples/nextjs-api-sales/definitions/openapi.ts @@ -348,7 +348,7 @@ export interface paths { } } responses: { - /** Returns the created table, including the ID which has been generated for it. This can be internal or external data sources. */ + /** Returns the created table, including the ID which has been generated for it. This can be internal or external datasources. */ 200: { content: { "application/json": components["schemas"]["tableOutput"] @@ -959,7 +959,7 @@ export interface components { query: { /** @description The ID of the query. */ _id: string - /** @description The ID of the data source the query belongs to. */ + /** @description The ID of the datasource the query belongs to. */ datasourceId?: string /** @description The bindings which are required to perform this query. */ parameters?: string[] @@ -983,7 +983,7 @@ export interface components { data: { /** @description The ID of the query. */ _id: string - /** @description The ID of the data source the query belongs to. */ + /** @description The ID of the datasource the query belongs to. */ datasourceId?: string /** @description The bindings which are required to perform this query. */ parameters?: string[] diff --git a/examples/nextjs-api-sales/package.json b/examples/nextjs-api-sales/package.json index 6d75c85f01..41ce52e952 100644 --- a/examples/nextjs-api-sales/package.json +++ b/examples/nextjs-api-sales/package.json @@ -11,8 +11,8 @@ "dependencies": { "bulma": "^0.9.3", "next": "12.1.0", - "node-fetch": "^3.2.2", - "node-sass": "^7.0.1", + "node-fetch": "^3.2.10", + "sass": "^1.52.3", "react": "17.0.2", "react-dom": "17.0.2", "react-notifications-component": "^3.4.1" @@ -24,4 +24,4 @@ "eslint-config-next": "12.1.0", "typescript": "4.6.2" } -} +} \ No newline at end of file diff --git a/examples/nextjs-api-sales/yarn.lock b/examples/nextjs-api-sales/yarn.lock index 52c89967b2..f47fb84e33 100644 --- a/examples/nextjs-api-sales/yarn.lock +++ b/examples/nextjs-api-sales/yarn.lock @@ -2020,10 +2020,10 @@ node-domexception@^1.0.0: resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== -node-fetch@^3.2.2: - version "3.2.2" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.2.2.tgz#16d33fbe32ca7c6ca1ca8ba5dfea1dd885c59f04" - integrity sha512-Cwhq1JFIoon15wcIkFzubVNFE5GvXGV82pKf4knXXjvGmn7RJKcypeuqcVNZMGDZsAFWyIRya/anwAJr7TWJ7w== +node-fetch@^3.2.10: + version "3.2.10" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.2.10.tgz#e8347f94b54ae18b57c9c049ef641cef398a85c8" + integrity sha512-MhuzNwdURnZ1Cp4XTazr69K0BTizsBroX7Zx3UgDSVcZYKF/6p0CBe4EUb/hLqmzVhl0UpYfgRljQ4yxE+iCxA== dependencies: data-uri-to-buffer "^4.0.0" fetch-blob "^3.1.4" diff --git a/hosting/.env b/hosting/.env index 11dd661bf1..c5638a266f 100644 --- a/hosting/.env +++ b/hosting/.env @@ -22,4 +22,7 @@ BUDIBASE_ENVIRONMENT=PRODUCTION # An admin user can be automatically created initially if these are set BB_ADMIN_USER_EMAIL= -BB_ADMIN_USER_PASSWORD= \ No newline at end of file +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 diff --git a/hosting/docker-compose.yaml b/hosting/docker-compose.yaml index 7d3e6960dc..5b2adc2665 100644 --- a/hosting/docker-compose.yaml +++ b/hosting/docker-compose.yaml @@ -25,9 +25,12 @@ services: REDIS_PASSWORD: ${REDIS_PASSWORD} BB_ADMIN_USER_EMAIL: ${BB_ADMIN_USER_EMAIL} BB_ADMIN_USER_PASSWORD: ${BB_ADMIN_USER_PASSWORD} + PLUGINS_DIR: ${PLUGINS_DIR} depends_on: - worker-service - redis-service +# volumes: +# - /some/path/to/plugins:/plugins worker-service: restart: unless-stopped @@ -78,6 +81,7 @@ services: image: budibase/proxy environment: - PROXY_RATE_LIMIT_WEBHOOKS_PER_SECOND=10 + - PROXY_RATE_LIMIT_API_PER_SECOND=20 depends_on: - minio-service - worker-service diff --git a/hosting/hosting.properties b/hosting/hosting.properties index 11dd661bf1..c5638a266f 100644 --- a/hosting/hosting.properties +++ b/hosting/hosting.properties @@ -22,4 +22,7 @@ BUDIBASE_ENVIRONMENT=PRODUCTION # An admin user can be automatically created initially if these are set BB_ADMIN_USER_EMAIL= -BB_ADMIN_USER_PASSWORD= \ No newline at end of file +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 diff --git a/hosting/nginx.dev.conf.hbs b/hosting/nginx.dev.conf.hbs index e08516c9d3..39a8dc52af 100644 --- a/hosting/nginx.dev.conf.hbs +++ b/hosting/nginx.dev.conf.hbs @@ -15,12 +15,30 @@ http { log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; + '"$http_user_agent" "$http_x_forwarded_for" ' + 'response_time=$upstream_response_time proxy_host=$proxy_host upstream_addr=$upstream_addr'; + + access_log /var/log/nginx/access.log main; map $http_upgrade $connection_upgrade { default "upgrade"; } + upstream app-service { + server {{address}}:4001; + keepalive 32; + } + + upstream worker-service { + server {{address}}:4002; + keepalive 32; + } + + upstream builder { + server {{address}}:3000; + keepalive 32; + } + server { listen 10000 default_server; server_name _; @@ -40,45 +58,88 @@ http { } location ~ ^/api/(system|admin|global)/ { - proxy_pass http://{{ address }}:4002; + proxy_pass http://worker-service; + proxy_read_timeout 120s; + proxy_connect_timeout 120s; + proxy_send_timeout 120s; + proxy_http_version 1.1; + proxy_set_header Connection ""; } location /api/ { proxy_read_timeout 120s; proxy_connect_timeout 120s; proxy_send_timeout 120s; - proxy_pass http://{{ address }}:4001; + proxy_pass http://app-service; + proxy_http_version 1.1; + proxy_set_header Connection ""; } location = / { - proxy_pass http://{{ address }}:4001; + proxy_pass http://app-service; + proxy_read_timeout 120s; + proxy_connect_timeout 120s; + proxy_send_timeout 120s; + proxy_http_version 1.1; + proxy_set_header Connection ""; } location /app_ { - proxy_pass http://{{ address }}:4001; + proxy_pass http://app-service; + proxy_read_timeout 120s; + proxy_connect_timeout 120s; + proxy_send_timeout 120s; + proxy_http_version 1.1; + proxy_set_header Connection ""; } location /app { - proxy_pass http://{{ address }}:4001; - } - - location /preview { - proxy_pass http://{{ address }}:4001; + proxy_pass http://app-service; + proxy_read_timeout 120s; + proxy_connect_timeout 120s; + proxy_send_timeout 120s; + proxy_http_version 1.1; + proxy_set_header Connection ""; } location /builder { - proxy_pass http://{{ address }}:3000; + proxy_pass http://builder; + proxy_read_timeout 120s; + proxy_connect_timeout 120s; + proxy_send_timeout 120s; + proxy_http_version 1.1; + proxy_set_header Connection ""; rewrite ^/builder(.*)$ /builder/$1 break; } location /builder/ { - proxy_pass http://{{ address }}:3000; + proxy_pass http://builder; proxy_http_version 1.1; proxy_set_header Connection $connection_upgrade; proxy_set_header Upgrade $http_upgrade; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 120s; + proxy_connect_timeout 120s; + proxy_send_timeout 120s; + } + + location /vite/ { + proxy_pass http://builder; + proxy_read_timeout 120s; + proxy_connect_timeout 120s; + proxy_send_timeout 120s; + rewrite ^/vite(.*)$ /$1 break; + } + + location /socket/ { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + proxy_pass http://app-service; } location / { diff --git a/hosting/nginx.prod.conf.hbs b/hosting/nginx.prod.conf.hbs index eaff214187..f3202ad4a4 100644 --- a/hosting/nginx.prod.conf.hbs +++ b/hosting/nginx.prod.conf.hbs @@ -11,7 +11,7 @@ events { http { # rate limiting limit_req_status 429; - limit_req_zone $binary_remote_addr zone=ratelimit:10m rate=20r/s; + limit_req_zone $binary_remote_addr zone=ratelimit:10m rate=${PROXY_RATE_LIMIT_API_PER_SECOND}r/s; limit_req_zone $binary_remote_addr zone=webhooks:10m rate=${PROXY_RATE_LIMIT_WEBHOOKS_PER_SECOND}r/s; include /etc/nginx/mime.types; @@ -33,7 +33,10 @@ http { log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; + '"$http_user_agent" "$http_x_forwarded_for" ' + 'response_time=$upstream_response_time proxy_host=$proxy_host upstream_addr=$upstream_addr'; + + access_log /var/log/nginx/access.log main; map $http_upgrade $connection_upgrade { default "upgrade"; @@ -85,10 +88,6 @@ http { proxy_pass http://$apps:4002; } - location /preview { - proxy_pass http://$apps:4002; - } - location = / { proxy_pass http://$apps:4002; } @@ -159,6 +158,15 @@ http { rewrite ^/db/(.*)$ /$1 break; } + location /socket/ { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + proxy_pass http://$apps:4002; + } + location / { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/hosting/proxy/10-listen-on-ipv6-by-default.sh b/hosting/proxy/10-listen-on-ipv6-by-default.sh new file mode 100644 index 0000000000..e2e89388a9 --- /dev/null +++ b/hosting/proxy/10-listen-on-ipv6-by-default.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# vim:sw=4:ts=4:et + +set -e + +ME=$(basename $0) +NGINX_CONF_FILE="/etc/nginx/nginx.conf" +DEFAULT_CONF_FILE="/etc/nginx/conf.d/default.conf" + +# check if we have ipv6 available +if [ ! -f "/proc/net/if_inet6" ]; then + # ipv6 not available so delete lines from nginx conf + if [ -f "$NGINX_CONF_FILE" ]; then + sed -i '/listen \[::\]/d' $NGINX_CONF_FILE + fi + if [ -f "$DEFAULT_CONF_FILE" ]; then + sed -i '/listen \[::\]/d' $DEFAULT_CONF_FILE + fi + echo "$ME: info: ipv6 not available so delete lines from nginx conf" +else + echo "$ME: info: ipv6 is available so no need to delete lines from nginx conf" +fi + +exit 0 diff --git a/hosting/proxy/80-listen-on-ipv6-by-default.sh b/hosting/proxy/80-listen-on-ipv6-by-default.sh new file mode 100644 index 0000000000..1d62732ea1 --- /dev/null +++ b/hosting/proxy/80-listen-on-ipv6-by-default.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# vim:sw=4:ts=4:et + +set -e + +ME=$(basename $0) +NGINX_CONF_FILE="/etc/nginx/nginx.conf" +DEFAULT_CONF_FILE="/etc/nginx/conf.d/default.conf" + +# check if we have ipv6 available +if [ ! -f "/proc/net/if_inet6" ]; then + # ipv6 not available so delete lines from nginx conf + if [ -f "$NGINX_CONF_FILE" ]; then + sed -i '/listen \[::\]/d' $NGINX_CONF_FILE + fi + if [ -f "$DEFAULT_CONF_FILE" ]; then + sed -i '/listen \[::\]/d' $DEFAULT_CONF_FILE + fi + echo "$ME: info: ipv6 not available so delete lines from nginx conf" +else + echo "$ME: info: ipv6 is available so no need to delete lines from nginx conf" +fi + +exit 0 \ No newline at end of file diff --git a/hosting/proxy/Dockerfile b/hosting/proxy/Dockerfile index d9b33e3e9a..68e8134750 100644 --- a/hosting/proxy/Dockerfile +++ b/hosting/proxy/Dockerfile @@ -6,8 +6,14 @@ FROM nginx:latest ENV NGINX_ENVSUBST_OUTPUT_DIR=/etc/nginx COPY .generated-nginx.prod.conf /etc/nginx/templates/nginx.conf.template +# IPv6 removal needs to happen after envsubst +RUN rm -rf /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh +COPY 80-listen-on-ipv6-by-default.sh /docker-entrypoint.d/80-listen-on-ipv6-by-default.sh +RUN chmod +x /docker-entrypoint.d/80-listen-on-ipv6-by-default.sh + # Error handling COPY error.html /usr/share/nginx/html/error.html # Default environment -ENV PROXY_RATE_LIMIT_WEBHOOKS_PER_SECOND=10 \ No newline at end of file +ENV PROXY_RATE_LIMIT_WEBHOOKS_PER_SECOND=10 +ENV PROXY_RATE_LIMIT_API_PER_SECOND=20 \ No newline at end of file diff --git a/hosting/scripts/build-target-paths.sh b/hosting/scripts/build-target-paths.sh index ee314c1ce4..67e1765ca8 100644 --- a/hosting/scripts/build-target-paths.sh +++ b/hosting/scripts/build-target-paths.sh @@ -4,17 +4,21 @@ echo ${TARGETBUILD} > /buildtarget.txt if [[ "${TARGETBUILD}" = "aas" ]]; then # Azure AppService uses /home for persisent data & SSH on port 2222 DATA_DIR=/home - mkdir -p $DATA_DIR/{search,minio,couchdb} - mkdir -p $DATA_DIR/couchdb/{dbs,views} - chown -R couchdb:couchdb $DATA_DIR/couchdb/ + WEBSITES_ENABLE_APP_SERVICE_STORAGE=true + mkdir -p $DATA_DIR/{search,minio,couch} + mkdir -p $DATA_DIR/couch/{dbs,views} + chown -R couchdb:couchdb $DATA_DIR/couch/ apt update apt-get install -y openssh-server - sed -i "s/#Port 22/Port 2222/" /etc/ssh/sshd_config + echo "root:Docker!" | chpasswd + mkdir -p /tmp + chmod +x /tmp/ssh_setup.sh \ + && (sleep 1;/tmp/ssh_setup.sh 2>&1 > /dev/null) + cp /etc/sshd_config /etc/ssh/sshd_config /etc/init.d/ssh restart sed -i "s#DATA_DIR#/home#g" /opt/clouseau/clouseau.ini sed -i "s#DATA_DIR#/home#g" /opt/couchdb/etc/local.ini else sed -i "s#DATA_DIR#/data#g" /opt/clouseau/clouseau.ini sed -i "s#DATA_DIR#/data#g" /opt/couchdb/etc/local.ini - fi \ No newline at end of file diff --git a/hosting/single/Dockerfile b/hosting/single/Dockerfile index 476a6e5e94..58796f0362 100644 --- a/hosting/single/Dockerfile +++ b/hosting/single/Dockerfile @@ -19,8 +19,8 @@ ADD packages/worker . RUN node /pinVersions.js && yarn && yarn build && /cleanup.sh FROM couchdb:3.2.1 -# TARGETARCH can be amd64 or arm e.g. docker build --build-arg TARGETARCH=amd64 -ARG TARGETARCH=amd64 +ARG TARGETARCH +ENV TARGETARCH $TARGETARCH #TARGETBUILD can be set to single (for single docker image) or aas (for azure app service) # e.g. docker build --build-arg TARGETBUILD=aas .... ARG TARGETBUILD=single @@ -29,23 +29,8 @@ ENV TARGETBUILD $TARGETBUILD COPY --from=build /app /app COPY --from=build /worker /worker -ENV \ - APP_PORT=4001 \ - ARCHITECTURE=amd \ - BUDIBASE_ENVIRONMENT=PRODUCTION \ - CLUSTER_PORT=80 \ - # CUSTOM_DOMAIN=budi001.custom.com \ - DATA_DIR=/data \ - DEPLOYMENT_ENVIRONMENT=docker \ - MINIO_URL=http://localhost:9000 \ - POSTHOG_TOKEN=phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU \ - REDIS_URL=localhost:6379 \ - SELF_HOSTED=1 \ - TARGETBUILD=$TARGETBUILD \ - WORKER_PORT=4002 \ - WORKER_URL=http://localhost:4002 \ - APPS_URL=http://localhost:4001 - +# ENV CUSTOM_DOMAIN=budi001.custom.com \ +# See runner.sh for Env Vars # These secret env variables are generated by the runner at startup # their values can be overriden by the user, they will be written # to the .env file in the /data directory for use later on @@ -117,6 +102,8 @@ RUN chmod +x ./build-target-paths.sh # Script below sets the path for storing data based on $DATA_DIR # For Azure App Service install SSH & point data locations to /home +ADD hosting/single/ssh/sshd_config /etc/ +ADD hosting/single/ssh/ssh_setup.sh /tmp RUN /build-target-paths.sh # cleanup cache @@ -124,6 +111,8 @@ RUN yarn cache clean -f EXPOSE 80 EXPOSE 443 +# Expose port 2222 for SSH on Azure App Service build +EXPOSE 2222 VOLUME /data # setup letsencrypt certificate diff --git a/hosting/single/couch/local.ini b/hosting/single/couch/local.ini index 35f0383dfc..266c0d4b60 100644 --- a/hosting/single/couch/local.ini +++ b/hosting/single/couch/local.ini @@ -1,5 +1,5 @@ ; CouchDB Configuration Settings [couchdb] -database_dir = DATA_DIR/couchdb/dbs -view_index_dir = DATA_DIR/couchdb/views +database_dir = DATA_DIR/couch/dbs +view_index_dir = DATA_DIR/couch/views diff --git a/hosting/single/nginx/nginx-default-site.conf b/hosting/single/nginx/nginx-default-site.conf index c0d80a0185..bd89e21251 100644 --- a/hosting/single/nginx/nginx-default-site.conf +++ b/hosting/single/nginx/nginx-default-site.conf @@ -66,6 +66,15 @@ server { rewrite ^/db/(.*)$ /$1 break; } + location /socket/ { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + proxy_pass http://127.0.0.1:4001; + } + location / { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/hosting/single/runner.sh b/hosting/single/runner.sh index 09387343ba..a95c21a98f 100644 --- a/hosting/single/runner.sh +++ b/hosting/single/runner.sh @@ -1,18 +1,45 @@ #!/bin/bash declare -a ENV_VARS=("COUCHDB_USER" "COUCHDB_PASSWORD" "DATA_DIR" "MINIO_ACCESS_KEY" "MINIO_SECRET_KEY" "INTERNAL_API_KEY" "JWT_SECRET" "REDIS_PASSWORD") +declare -a DOCKER_VARS=("APP_PORT" "APPS_URL" "ARCHITECTURE" "BUDIBASE_ENVIRONMENT" "CLUSTER_PORT" "DEPLOYMENT_ENVIRONMENT" "MINIO_URL" "NODE_ENV" "POSTHOG_TOKEN" "REDIS_URL" "SELF_HOSTED" "WORKER_PORT" "WORKER_URL" "TENANT_FEATURE_FLAGS" "ACCOUNT_PORTAL_URL") +# Check the env vars set in Dockerfile have come through, AAS seems to drop them +[[ -z "${APP_PORT}" ]] && export APP_PORT=4001 +[[ -z "${ARCHITECTURE}" ]] && export ARCHITECTURE=amd +[[ -z "${BUDIBASE_ENVIRONMENT}" ]] && export BUDIBASE_ENVIRONMENT=PRODUCTION +[[ -z "${CLUSTER_PORT}" ]] && export CLUSTER_PORT=80 +[[ -z "${DEPLOYMENT_ENVIRONMENT}" ]] && export DEPLOYMENT_ENVIRONMENT=docker +[[ -z "${MINIO_URL}" ]] && export MINIO_URL=http://localhost:9000 +[[ -z "${NODE_ENV}" ]] && export NODE_ENV=production +[[ -z "${POSTHOG_TOKEN}" ]] && export POSTHOG_TOKEN=phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU +[[ -z "${TENANT_FEATURE_FLAGS}" ]] && export TENANT_FEATURE_FLAGS="*:LICENSING,*:USER_GROUPS" +[[ -z "${ACCOUNT_PORTAL_URL}" ]] && export ACCOUNT_PORTAL_URL=https://account.budibase.app +[[ -z "${REDIS_URL}" ]] && export REDIS_URL=localhost:6379 +[[ -z "${SELF_HOSTED}" ]] && export SELF_HOSTED=1 +[[ -z "${WORKER_PORT}" ]] && export WORKER_PORT=4002 +[[ -z "${WORKER_URL}" ]] && export WORKER_URL=http://localhost:4002 +[[ -z "${APPS_URL}" ]] && export APPS_URL=http://localhost:4001 +# export CUSTOM_DOMAIN=budi001.custom.com # Azure App Service customisations if [[ "${TARGETBUILD}" = "aas" ]]; then DATA_DIR=/home + WEBSITES_ENABLE_APP_SERVICE_STORAGE=true /etc/init.d/ssh start else DATA_DIR=${DATA_DIR:-/data} fi -if [ -f "${DATA_DIR}/.env" ]; then - export $(cat ${DATA_DIR}/.env | xargs) +# Mount NFS or GCP Filestore if env vars exist for it +if [[ -z ${FILESHARE_IP} && -z ${FILESHARE_NAME} ]]; then + echo "Mount file share ${FILESHARE_IP}:/${FILESHARE_NAME} to ${DATA_DIR}" + mount -o nolock ${FILESHARE_IP}:/${FILESHARE_NAME} ${DATA_DIR} + echo "Mounting completed." fi -# first randomise any unset environment variables + +if [ -f "${DATA_DIR}/.env" ]; then + # Read in the .env file and export the variables + for LINE in $(cat ${DATA_DIR}/.env); do export $LINE; done +fi +# randomise any unset environment variables for ENV_VAR in "${ENV_VARS[@]}" do temp=$(eval "echo \$$ENV_VAR") @@ -30,16 +57,23 @@ if [ ! -f "${DATA_DIR}/.env" ]; then temp=$(eval "echo \$$ENV_VAR") echo "$ENV_VAR=$temp" >> ${DATA_DIR}/.env done + for ENV_VAR in "${DOCKER_VARS[@]}" + do + temp=$(eval "echo \$$ENV_VAR") + echo "$ENV_VAR=$temp" >> ${DATA_DIR}/.env + done echo "COUCH_DB_URL=${COUCH_DB_URL}" >> ${DATA_DIR}/.env fi -export COUCH_DB_URL=http://$COUCHDB_USER:$COUCHDB_PASSWORD@localhost:5984 - +# Read in the .env file and export the variables +for LINE in $(cat ${DATA_DIR}/.env); do export $LINE; done +ln -s ${DATA_DIR}/.env /app/.env +ln -s ${DATA_DIR}/.env /worker/.env # make these directories in runner, incase of mount -mkdir -p ${DATA_DIR}/couchdb/{dbs,views} +mkdir -p ${DATA_DIR}/couch/{dbs,views} mkdir -p ${DATA_DIR}/minio mkdir -p ${DATA_DIR}/search -chown -R couchdb:couchdb ${DATA_DIR}/couchdb +chown -R couchdb:couchdb ${DATA_DIR}/couch redis-server --requirepass $REDIS_PASSWORD & /opt/clouseau/bin/clouseau & /minio/minio server ${DATA_DIR}/minio & diff --git a/hosting/single/ssh/ssh_setup.sh b/hosting/single/ssh/ssh_setup.sh new file mode 100644 index 0000000000..0af0b6d7ad --- /dev/null +++ b/hosting/single/ssh/ssh_setup.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +ssh-keygen -A + +#prepare run dir +if [ ! -d "/var/run/sshd" ]; then + mkdir -p /var/run/sshd +fi \ No newline at end of file diff --git a/hosting/single/ssh/sshd_config b/hosting/single/ssh/sshd_config new file mode 100644 index 0000000000..7eb5df953a --- /dev/null +++ b/hosting/single/ssh/sshd_config @@ -0,0 +1,12 @@ +Port 2222 +ListenAddress 0.0.0.0 +LoginGraceTime 180 +X11Forwarding yes +Ciphers aes128-cbc,3des-cbc,aes256-cbc,aes128-ctr,aes192-ctr,aes256-ctr +MACs hmac-sha1,hmac-sha1-96 +StrictModes yes +SyslogFacility DAEMON +PasswordAuthentication yes +PermitEmptyPasswords no +PermitRootLogin yes +Subsystem sftp internal-sftp diff --git a/lerna.json b/lerna.json index 99c092ebd4..316925dceb 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "1.2.44-alpha.10", + "version": "2.0.34-alpha.4", "npmClient": "yarn", "packages": [ "packages/*" diff --git a/package.json b/package.json index 4c24e0025b..7733a6df95 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,6 @@ "private": true, "devDependencies": { "@rollup/plugin-json": "^4.0.2", - "@types/mongodb": "3.6.3", "@typescript-eslint/parser": "4.28.0", "babel-eslint": "^10.0.3", "eslint": "^7.28.0", @@ -13,6 +12,7 @@ "js-yaml": "^4.1.0", "kill-port": "^1.6.1", "lerna": "3.14.1", + "madge": "^5.0.1", "prettier": "^2.3.1", "prettier-plugin-svelte": "^2.3.0", "rimraf": "^3.0.2", @@ -25,6 +25,8 @@ "bootstrap": "lerna bootstrap && lerna link && ./scripts/link-dependencies.sh", "build": "lerna run build", "build:dev": "lerna run prebuild && tsc --build --watch --preserveWatchOutput", + "build:sdk": "lerna run 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 ${RELEASE_VERSION_TYPE:-patch} --yes --force-publish && yarn release:pro", "release:develop": "lerna publish prerelease --yes --force-publish --dist-tag develop --exact && yarn release:pro:develop", "release:pro": "bash scripts/pro/release.sh", @@ -45,8 +47,8 @@ "lint:eslint": "eslint packages", "lint:prettier": "prettier --check \"packages/**/*.{js,ts,svelte}\"", "lint": "yarn run lint:eslint && yarn run lint:prettier", - "lint:fix:eslint": "eslint --fix packages", - "lint:fix:prettier": "prettier --write \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\"", + "lint:fix:eslint": "eslint --fix packages qa-core", + "lint:fix:prettier": "prettier --write \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\" && prettier --write \"qa-core/**/*.{js,ts,svelte}\"", "lint:fix": "yarn run lint:fix:prettier && yarn run lint:fix:eslint", "test:e2e": "lerna run cy:test --stream", "test:e2e:ci": "lerna run cy:ci --stream", diff --git a/packages/backend-core/context.js b/packages/backend-core/context.js index aaa0f56f92..c6fa87a337 100644 --- a/packages/backend-core/context.js +++ b/packages/backend-core/context.js @@ -6,6 +6,7 @@ const { updateAppId, doInAppContext, doInTenant, + doInContext, } = require("./src/context") const identity = require("./src/context/identity") @@ -19,4 +20,5 @@ module.exports = { doInAppContext, doInTenant, identity, + doInContext, } diff --git a/packages/backend-core/package.json b/packages/backend-core/package.json index a444e420b7..679442505d 100644 --- a/packages/backend-core/package.json +++ b/packages/backend-core/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/backend-core", - "version": "1.2.44-alpha.10", + "version": "2.0.34-alpha.4", "description": "Budibase backend core libraries used in server and worker", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", @@ -20,10 +20,13 @@ "test:watch": "jest --watchAll" }, "dependencies": { - "@budibase/types": "1.2.44-alpha.10", + "@budibase/types": "2.0.34-alpha.4", + "@shopify/jest-koa-mocks": "5.0.1", "@techpass/passport-openidconnect": "0.3.2", "aws-sdk": "2.1030.0", "bcrypt": "5.0.1", + "bcryptjs": "2.4.3", + "bull": "4.10.1", "dotenv": "16.0.1", "emitter-listener": "1.1.2", "ioredis": "4.28.0", @@ -60,7 +63,8 @@ ] }, "devDependencies": { - "@shopify/jest-koa-mocks": "3.1.5", + "@types/chance": "1.1.3", + "@types/ioredis": "4.28.0", "@types/jest": "27.5.1", "@types/koa": "2.0.52", "@types/lodash": "4.14.180", @@ -71,6 +75,7 @@ "@types/semver": "7.3.7", "@types/tar-fs": "2.0.1", "@types/uuid": "8.3.4", + "chance": "1.1.3", "ioredis-mock": "5.8.0", "jest": "27.5.1", "koa": "2.7.0", diff --git a/packages/backend-core/plugins.js b/packages/backend-core/plugins.js new file mode 100644 index 0000000000..018e214dcb --- /dev/null +++ b/packages/backend-core/plugins.js @@ -0,0 +1,3 @@ +module.exports = { + ...require("./src/plugin"), +} diff --git a/packages/backend-core/src/auth.js b/packages/backend-core/src/auth.ts similarity index 73% rename from packages/backend-core/src/auth.js rename to packages/backend-core/src/auth.ts index d39b8426fb..23873b84e7 100644 --- a/packages/backend-core/src/auth.js +++ b/packages/backend-core/src/auth.ts @@ -1,11 +1,11 @@ const passport = require("koa-passport") const LocalStrategy = require("passport-local").Strategy const JwtStrategy = require("passport-jwt").Strategy -const { getGlobalDB } = require("./tenancy") +import { getGlobalDB } from "./tenancy" const refresh = require("passport-oauth2-refresh") -const { Configs } = require("./constants") -const { getScopedConfig } = require("./db/utils") -const { +import { Configs } from "./constants" +import { getScopedConfig } from "./db/utils" +import { jwt, local, authenticated, @@ -13,7 +13,6 @@ const { oidc, auditLog, tenancy, - appTenancy, authError, ssoCallbackUrl, csrf, @@ -22,32 +21,36 @@ const { builderOnly, builderOrAdmin, joiValidator, -} = require("./middleware") - -const { invalidateUser } = require("./cache/user") +} from "./middleware" +import { invalidateUser } from "./cache/user" +import { User } from "@budibase/types" // Strategies passport.use(new LocalStrategy(local.options, local.authenticate)) passport.use(new JwtStrategy(jwt.options, jwt.authenticate)) -passport.serializeUser((user, done) => done(null, user)) +passport.serializeUser((user: User, done: any) => done(null, user)) -passport.deserializeUser(async (user, done) => { +passport.deserializeUser(async (user: User, done: any) => { const db = getGlobalDB() try { - const user = await db.get(user._id) - return done(null, user) + const dbUser = await db.get(user._id) + return done(null, dbUser) } catch (err) { console.error(`User not found`, err) return done(null, false, { message: "User not found" }) } }) -async function refreshOIDCAccessToken(db, chosenConfig, refreshToken) { +async function refreshOIDCAccessToken( + db: any, + chosenConfig: any, + refreshToken: string +) { const callbackUrl = await oidc.getCallbackUrl(db, chosenConfig) - let enrichedConfig - let strategy + let enrichedConfig: any + let strategy: any try { enrichedConfig = await oidc.fetchStrategyConfig(chosenConfig, callbackUrl) @@ -70,22 +73,28 @@ async function refreshOIDCAccessToken(db, chosenConfig, refreshToken) { refresh.requestNewAccessToken( Configs.OIDC, refreshToken, - (err, accessToken, refreshToken, params) => { + (err: any, accessToken: string, refreshToken: any, params: any) => { resolve({ err, accessToken, refreshToken, params }) } ) }) } -async function refreshGoogleAccessToken(db, config, refreshToken) { +async function refreshGoogleAccessToken( + db: any, + config: any, + refreshToken: any +) { let callbackUrl = await google.getCallbackUrl(db, config) let strategy try { strategy = await google.strategyFactory(config, callbackUrl) - } catch (err) { + } catch (err: any) { console.error(err) - throw new Error("Error constructing OIDC refresh strategy", err) + throw new Error( + `Error constructing OIDC refresh strategy: message=${err.message}` + ) } refresh.use(strategy) @@ -94,14 +103,18 @@ async function refreshGoogleAccessToken(db, config, refreshToken) { refresh.requestNewAccessToken( Configs.GOOGLE, refreshToken, - (err, accessToken, refreshToken, params) => { + (err: any, accessToken: string, refreshToken: string, params: any) => { resolve({ err, accessToken, refreshToken, params }) } ) }) } -async function refreshOAuthToken(refreshToken, configType, configId) { +async function refreshOAuthToken( + refreshToken: string, + configType: string, + configId: string +) { const db = getGlobalDB() const config = await getScopedConfig(db, { @@ -113,7 +126,7 @@ async function refreshOAuthToken(refreshToken, configType, configId) { let refreshResponse if (configType === Configs.OIDC) { // configId - retrieved from cookie. - chosenConfig = config.configs.filter(c => c.uuid === configId)[0] + chosenConfig = config.configs.filter((c: any) => c.uuid === configId)[0] if (!chosenConfig) { throw new Error("Invalid OIDC configuration") } @@ -134,7 +147,7 @@ async function refreshOAuthToken(refreshToken, configType, configId) { return refreshResponse } -async function updateUserOAuth(userId, oAuthConfig) { +async function updateUserOAuth(userId: string, oAuthConfig: any) { const details = { accessToken: oAuthConfig.accessToken, refreshToken: oAuthConfig.refreshToken, @@ -162,14 +175,13 @@ async function updateUserOAuth(userId, oAuthConfig) { } } -module.exports = { +export = { buildAuthMiddleware: authenticated, passport, google, oidc, jwt: require("jsonwebtoken"), buildTenancyMiddleware: tenancy, - buildAppTenancyMiddleware: appTenancy, auditLog, authError, buildCsrfMiddleware: csrf, diff --git a/packages/backend-core/src/cache/writethrough.ts b/packages/backend-core/src/cache/writethrough.ts index ec6b1604c8..495ba58590 100644 --- a/packages/backend-core/src/cache/writethrough.ts +++ b/packages/backend-core/src/cache/writethrough.ts @@ -1,6 +1,7 @@ import BaseCache from "./base" import { getWritethroughClient } from "../redis/init" import { logWarn } from "../logging" +import PouchDB from "pouchdb" const DEFAULT_WRITE_RATE_MS = 10000 let CACHE: BaseCache | null = null diff --git a/packages/backend-core/src/constants.js b/packages/backend-core/src/constants.js index 172e66e603..44c271a4f8 100644 --- a/packages/backend-core/src/constants.js +++ b/packages/backend-core/src/constants.js @@ -7,6 +7,7 @@ exports.Cookies = { CurrentApp: "budibase:currentapp", Auth: "budibase:auth", Init: "budibase:init", + ACCOUNT_RETURN_URL: "budibase:account:returnurl", DatasourceAuth: "budibase:datasourceauth", OIDC_CONFIG: "budibase:oidc:config", } diff --git a/packages/backend-core/src/context/index.ts b/packages/backend-core/src/context/index.ts index 78ce764d55..7efe0e23f7 100644 --- a/packages/backend-core/src/context/index.ts +++ b/packages/backend-core/src/context/index.ts @@ -2,7 +2,7 @@ import env from "../environment" import { SEPARATOR, DocumentType } from "../db/constants" import cls from "./FunctionContext" import { dangerousGetDB, closeDB } from "../db" -import { baseGlobalDBName } from "../tenancy/utils" +import { baseGlobalDBName } from "../db/tenancy" import { IdentityContext } from "@budibase/types" import { DEFAULT_TENANT_ID as _DEFAULT_TENANT_ID } from "../constants" import { ContextKey } from "./constants" @@ -53,6 +53,9 @@ export const getTenantIDFromAppID = (appId: string) => { if (!appId) { return null } + if (!isMultiTenant()) { + return DEFAULT_TENANT_ID + } const split = appId.split(SEPARATOR) const hasDev = split[1] === DocumentType.DEV if ((hasDev && split.length === 3) || (!hasDev && split.length === 2)) { @@ -65,7 +68,16 @@ export const getTenantIDFromAppID = (appId: string) => { } } -// used for automations, API endpoints should always be in context already +export const doInContext = async (appId: string, task: any) => { + // gets the tenant ID from the app ID + const tenantId = getTenantIDFromAppID(appId) + return doInTenant(tenantId, async () => { + return doInAppContext(appId, async () => { + return task() + }) + }) +} + export const doInTenant = (tenantId: string | null, task: any) => { // make sure default always selected in single tenancy if (!env.MULTI_TENANCY) { @@ -226,6 +238,10 @@ export const getAppId = () => { } } +export const isTenancyEnabled = () => { + return env.MULTI_TENANCY +} + /** * Opens the app database based on whatever the request * contained, dev or prod. diff --git a/packages/backend-core/src/db/Replication.ts b/packages/backend-core/src/db/Replication.ts index b46f6072be..e0bd3c7a43 100644 --- a/packages/backend-core/src/db/Replication.ts +++ b/packages/backend-core/src/db/Replication.ts @@ -1,4 +1,5 @@ import { dangerousGetDB, closeDB } from "." +import { DocumentType } from "./constants" class Replication { source: any @@ -53,6 +54,14 @@ class Replication { return this.replication } + appReplicateOpts() { + return { + filter: (doc: any) => { + return doc._id !== DocumentType.APP_METADATA + }, + } + } + /** * Rollback the target DB back to the state of the source DB */ @@ -60,6 +69,7 @@ class Replication { await this.target.destroy() // Recreate the DB again this.target = dangerousGetDB(this.target.name) + // take the opportunity to remove deleted tombstones await this.replicate() } diff --git a/packages/backend-core/src/db/constants.ts b/packages/backend-core/src/db/constants.ts index 460476da24..446f1f7d01 100644 --- a/packages/backend-core/src/db/constants.ts +++ b/packages/backend-core/src/db/constants.ts @@ -18,6 +18,10 @@ export enum ViewName { LINK = "by_link", ROUTING = "screen_routes", AUTOMATION_LOGS = "automation_logs", + ACCOUNT_BY_EMAIL = "account_by_email", + PLATFORM_USERS_LOWERCASE = "platform_users_lowercase", + USER_BY_GROUP = "by_group_user", + APP_BACKUP_BY_TRIGGER = "by_trigger", } export const DeprecatedViews = { @@ -27,6 +31,10 @@ export const DeprecatedViews = { ], } +export enum InternalTable { + USER_METADATA = "ta_users", +} + export enum DocumentType { USER = "us", GROUP = "gr", @@ -41,6 +49,25 @@ export enum DocumentType { MIGRATIONS = "migrations", DEV_INFO = "devinfo", AUTOMATION_LOG = "log_au", + ACCOUNT_METADATA = "acc_metadata", + PLUGIN = "plg", + DATASOURCE = "datasource", + DATASOURCE_PLUS = "datasource_plus", + APP_BACKUP = "backup", + TABLE = "ta", + ROW = "ro", + AUTOMATION = "au", + LINK = "li", + WEBHOOK = "wh", + INSTANCE = "inst", + LAYOUT = "layout", + SCREEN = "screen", + QUERY = "query", + DEPLOYMENTS = "deployments", + METADATA = "metadata", + MEM_VIEW = "view", + USER_FLAG = "flag", + AUTOMATION_METADATA = "meta_au", } export const StaticDatabases = { diff --git a/packages/backend-core/src/db/conversions.js b/packages/backend-core/src/db/conversions.js index 90c04e9251..5b1a785ecc 100644 --- a/packages/backend-core/src/db/conversions.js +++ b/packages/backend-core/src/db/conversions.js @@ -36,6 +36,7 @@ exports.getDevelopmentAppID = appId => { const rest = split.join(APP_PREFIX) return `${APP_DEV_PREFIX}${rest}` } +exports.getDevAppID = exports.getDevelopmentAppID /** * Convert a development app ID to a deployed app ID. diff --git a/packages/backend-core/src/db/index.js b/packages/backend-core/src/db/index.js deleted file mode 100644 index aa6f7ebc2c..0000000000 --- a/packages/backend-core/src/db/index.js +++ /dev/null @@ -1,91 +0,0 @@ -const pouch = require("./pouch") -const env = require("../environment") - -const openDbs = [] -let PouchDB -let initialised = false -const dbList = new Set() - -if (env.MEMORY_LEAK_CHECK) { - setInterval(() => { - console.log("--- OPEN DBS ---") - console.log(openDbs) - }, 5000) -} - -const put = - dbPut => - async (doc, options = {}) => { - if (!doc.createdAt) { - doc.createdAt = new Date().toISOString() - } - doc.updatedAt = new Date().toISOString() - return dbPut(doc, options) - } - -const checkInitialised = () => { - if (!initialised) { - throw new Error("init has not been called") - } -} - -exports.init = opts => { - PouchDB = pouch.getPouch(opts) - initialised = true -} - -// NOTE: THIS IS A DANGEROUS FUNCTION - USE WITH CAUTION -// this function is prone to leaks, should only be used -// in situations that using the function doWithDB does not work -exports.dangerousGetDB = (dbName, opts) => { - checkInitialised() - if (env.isTest()) { - dbList.add(dbName) - } - const db = new PouchDB(dbName, opts) - if (env.MEMORY_LEAK_CHECK) { - openDbs.push(db.name) - } - const dbPut = db.put - db.put = put(dbPut) - return db -} - -// use this function if you have called dangerousGetDB - close -// the databases you've opened once finished -exports.closeDB = async db => { - if (!db || env.isTest()) { - return - } - if (env.MEMORY_LEAK_CHECK) { - openDbs.splice(openDbs.indexOf(db.name), 1) - } - try { - // specifically await so that if there is an error, it can be ignored - return await db.close() - } catch (err) { - // ignore error, already closed - } -} - -// we have to use a callback for this so that we can close -// the DB when we're done, without this manual requests would -// need to close the database when done with it to avoid memory leaks -exports.doWithDB = async (dbName, cb, opts = {}) => { - const db = exports.dangerousGetDB(dbName, opts) - // need this to be async so that we can correctly close DB after all - // async operations have been completed - try { - return await cb(db) - } finally { - await exports.closeDB(db) - } -} - -exports.allDbs = () => { - if (!env.isTest()) { - throw new Error("Cannot be used outside test environment.") - } - checkInitialised() - return [...dbList] -} diff --git a/packages/backend-core/src/db/index.ts b/packages/backend-core/src/db/index.ts new file mode 100644 index 0000000000..429cd61fc1 --- /dev/null +++ b/packages/backend-core/src/db/index.ts @@ -0,0 +1,133 @@ +import * as pouch from "./pouch" +import env from "../environment" +import { checkSlashesInUrl } from "../helpers" +import fetch from "node-fetch" +import { PouchOptions, CouchFindOptions } from "@budibase/types" +import PouchDB from "pouchdb" + +const openDbs: string[] = [] +let Pouch: any +let initialised = false +const dbList = new Set() + +if (env.MEMORY_LEAK_CHECK) { + setInterval(() => { + console.log("--- OPEN DBS ---") + console.log(openDbs) + }, 5000) +} + +const put = + (dbPut: any) => + async (doc: any, options = {}) => { + if (!doc.createdAt) { + doc.createdAt = new Date().toISOString() + } + doc.updatedAt = new Date().toISOString() + return dbPut(doc, options) + } + +const checkInitialised = () => { + if (!initialised) { + throw new Error("init has not been called") + } +} + +export async function init(opts?: PouchOptions) { + Pouch = pouch.getPouch(opts) + initialised = true +} + +// NOTE: THIS IS A DANGEROUS FUNCTION - USE WITH CAUTION +// this function is prone to leaks, should only be used +// in situations that using the function doWithDB does not work +export function dangerousGetDB(dbName: string, opts?: any): PouchDB.Database { + checkInitialised() + if (env.isTest()) { + dbList.add(dbName) + } + const db = new Pouch(dbName, opts) + if (env.MEMORY_LEAK_CHECK) { + openDbs.push(db.name) + } + const dbPut = db.put + db.put = put(dbPut) + return db +} + +// use this function if you have called dangerousGetDB - close +// the databases you've opened once finished +export async function closeDB(db: PouchDB.Database) { + if (!db || env.isTest()) { + return + } + if (env.MEMORY_LEAK_CHECK) { + openDbs.splice(openDbs.indexOf(db.name), 1) + } + try { + // specifically await so that if there is an error, it can be ignored + return await db.close() + } catch (err) { + // ignore error, already closed + } +} + +// we have to use a callback for this so that we can close +// the DB when we're done, without this manual requests would +// need to close the database when done with it to avoid memory leaks +export async function doWithDB(dbName: string, cb: any, opts = {}) { + const db = dangerousGetDB(dbName, opts) + // need this to be async so that we can correctly close DB after all + // async operations have been completed + try { + return await cb(db) + } finally { + await closeDB(db) + } +} + +export function allDbs() { + if (!env.isTest()) { + throw new Error("Cannot be used outside test environment.") + } + checkInitialised() + return [...dbList] +} + +export async function directCouchQuery( + path: string, + method: string = "GET", + body?: any +) { + let { url, cookie } = pouch.getCouchInfo() + const couchUrl = `${url}/${path}` + const params: any = { + method: method, + headers: { + Authorization: cookie, + }, + } + if (body && method !== "GET") { + params.body = JSON.stringify(body) + params.headers["Content-Type"] = "application/json" + } + const response = await fetch(checkSlashesInUrl(encodeURI(couchUrl)), params) + if (response.status < 300) { + return await response.json() + } else { + throw "Cannot connect to CouchDB instance" + } +} + +export async function directCouchAllDbs(queryString?: string) { + let couchPath = "/_all_dbs" + if (queryString) { + couchPath += `?${queryString}` + } + return await directCouchQuery(couchPath) +} + +export async function directCouchFind(dbName: string, opts: CouchFindOptions) { + const json = await directCouchQuery(`${dbName}/_find`, "POST", opts) + return { rows: json.docs, bookmark: json.bookmark } +} diff --git a/packages/backend-core/src/db/pouch.js b/packages/backend-core/src/db/pouch.ts similarity index 88% rename from packages/backend-core/src/db/pouch.js rename to packages/backend-core/src/db/pouch.ts index 12d7d787e3..1e37da9240 100644 --- a/packages/backend-core/src/db/pouch.js +++ b/packages/backend-core/src/db/pouch.ts @@ -1,7 +1,7 @@ -const PouchDB = require("pouchdb") -const env = require("../environment") +import PouchDB from "pouchdb" +import env from "../environment" -exports.getUrlInfo = (url = env.COUCH_DB_URL) => { +export const getUrlInfo = (url = env.COUCH_DB_URL) => { let cleanUrl, username, password, host if (url) { // Ensure the URL starts with a protocol @@ -44,8 +44,8 @@ exports.getUrlInfo = (url = env.COUCH_DB_URL) => { } } -exports.getCouchInfo = () => { - const urlInfo = exports.getUrlInfo() +export const getCouchInfo = () => { + const urlInfo = getUrlInfo() let username let password if (env.COUCH_DB_USERNAME) { @@ -82,11 +82,11 @@ exports.getCouchInfo = () => { * This should be rarely used outside of the main application config. * Exposed for exceptional cases such as in-memory views. */ -exports.getPouch = (opts = {}) => { - let { url, cookie } = exports.getCouchInfo() +export const getPouch = (opts: any = {}) => { + let { url, cookie } = getCouchInfo() let POUCH_DB_DEFAULTS = { prefix: url, - fetch: (url, opts) => { + fetch: (url: string, opts: any) => { // use a specific authorization cookie - be very explicit about how we authenticate opts.headers.set("Authorization", cookie) return PouchDB.fetch(url, opts) @@ -98,6 +98,7 @@ exports.getPouch = (opts = {}) => { PouchDB.plugin(inMemory) POUCH_DB_DEFAULTS = { prefix: undefined, + // @ts-ignore adapter: "memory", } } @@ -105,6 +106,7 @@ exports.getPouch = (opts = {}) => { if (opts.onDisk) { POUCH_DB_DEFAULTS = { prefix: undefined, + // @ts-ignore adapter: "leveldb", } } @@ -112,6 +114,7 @@ exports.getPouch = (opts = {}) => { if (opts.replication) { const replicationStream = require("pouchdb-replication-stream") PouchDB.plugin(replicationStream.plugin) + // @ts-ignore PouchDB.adapter("writableStream", replicationStream.adapters.writableStream) } diff --git a/packages/backend-core/src/db/tenancy.ts b/packages/backend-core/src/db/tenancy.ts new file mode 100644 index 0000000000..d920f7cd41 --- /dev/null +++ b/packages/backend-core/src/db/tenancy.ts @@ -0,0 +1,22 @@ +import { DEFAULT_TENANT_ID } from "../constants" +import { StaticDatabases, SEPARATOR } from "./constants" +import { getTenantId } from "../context" + +export const getGlobalDBName = (tenantId?: string) => { + // tenant ID can be set externally, for example user API where + // new tenants are being created, this may be the case + if (!tenantId) { + tenantId = getTenantId() + } + return baseGlobalDBName(tenantId) +} + +export const baseGlobalDBName = (tenantId: string | undefined | null) => { + let dbName + if (!tenantId || tenantId === DEFAULT_TENANT_ID) { + dbName = StaticDatabases.GLOBAL.name + } else { + dbName = `${tenantId}${SEPARATOR}${StaticDatabases.GLOBAL.name}` + } + return dbName +} diff --git a/packages/backend-core/src/db/utils.ts b/packages/backend-core/src/db/utils.ts index 321ebd7f58..c04da5da4f 100644 --- a/packages/backend-core/src/db/utils.ts +++ b/packages/backend-core/src/db/utils.ts @@ -1,13 +1,17 @@ import { newid } from "../hashing" import { DEFAULT_TENANT_ID, Configs } from "../constants" import env from "../environment" -import { SEPARATOR, DocumentType, UNICODE_MAX, ViewName } from "./constants" -import { getTenantId, getGlobalDBName, getGlobalDB } from "../tenancy" -import fetch from "node-fetch" -import { doWithDB, allDbs } from "./index" -import { getCouchInfo } from "./pouch" +import { + SEPARATOR, + DocumentType, + UNICODE_MAX, + ViewName, + InternalTable, +} from "./constants" +import { getTenantId, getGlobalDB } from "../context" +import { getGlobalDBName } from "./tenancy" +import { doWithDB, allDbs, directCouchAllDbs } from "./index" import { getAppMetadata } from "../cache/appMetadata" -import { checkSlashesInUrl } from "../helpers" import { isDevApp, isDevAppID, getProdAppID } from "./conversions" import { APP_PREFIX } from "./constants" import * as events from "../events" @@ -15,6 +19,7 @@ import * as events from "../events" export * from "./constants" export * from "./conversions" export { default as Replication } from "./Replication" +export * from "./tenancy" /** * Generates a new app ID. @@ -41,8 +46,8 @@ export const generateAppID = (tenantId = null) => { * @returns {object} Parameters which can then be used with an allDocs request. */ export function getDocParams( - docType: any, - docId: any = null, + docType: string, + docId?: string | null, otherProps: any = {} ) { if (docId == null) { @@ -55,6 +60,28 @@ export function getDocParams( } } +/** + * Gets the DB allDocs/query params for retrieving a row. + * @param {string|null} tableId The table in which the rows have been stored. + * @param {string|null} rowId The ID of the row which is being specifically queried for. This can be + * left null to get all the rows in the table. + * @param {object} otherProps Any other properties to add to the request. + * @returns {object} Parameters which can then be used with an allDocs request. + */ +export function getRowParams( + tableId?: string | null, + rowId?: string | null, + otherProps = {} +) { + if (tableId == null) { + return getDocParams(DocumentType.ROW, null, otherProps) + } + + const endOfKey = rowId == null ? `${tableId}${SEPARATOR}` : rowId + + return getDocParams(DocumentType.ROW, endOfKey, otherProps) +} + /** * Retrieve the correct index for a view based on default design DB. */ @@ -62,6 +89,39 @@ export function getQueryIndex(viewName: ViewName) { return `database/${viewName}` } +/** + * Gets a new row ID for the specified table. + * @param {string} tableId The table which the row is being created for. + * @param {string|null} id If an ID is to be used then the UUID can be substituted for this. + * @returns {string} The new ID which a row doc can be stored under. + */ +export function generateRowID(tableId: string, id?: string) { + id = id || newid() + return `${DocumentType.ROW}${SEPARATOR}${tableId}${SEPARATOR}${id}` +} + +/** + * Check if a given ID is that of a table. + * @returns {boolean} + */ +export const isTableId = (id: string) => { + // this includes datasource plus tables + return ( + id && + (id.startsWith(`${DocumentType.TABLE}${SEPARATOR}`) || + id.startsWith(`${DocumentType.DATASOURCE_PLUS}${SEPARATOR}`)) + ) +} + +/** + * Check if a given ID is that of a datasource or datasource plus. + * @returns {boolean} + */ +export const isDatasourceId = (id: string) => { + // this covers both datasources and datasource plus + return id && id.startsWith(`${DocumentType.DATASOURCE}${SEPARATOR}`) +} + /** * Generates a new workspace ID. * @returns {string} The new workspace ID which the workspace doc can be stored under. @@ -107,6 +167,33 @@ export function getGlobalUserParams(globalId: any, otherProps: any = {}) { } } +/** + * Gets parameters for retrieving users, this is a utility function for the getDocParams function. + */ +export function getUserMetadataParams(userId?: string, otherProps = {}) { + return getRowParams(InternalTable.USER_METADATA, userId, otherProps) +} + +/** + * Generates a new user ID based on the passed in global ID. + * @param {string} globalId The ID of the global user. + * @returns {string} The new user ID which the user doc can be stored under. + */ +export function generateUserMetadataID(globalId: string) { + return generateRowID(InternalTable.USER_METADATA, globalId) +} + +/** + * Breaks up the ID to get the global ID. + */ +export function getGlobalIDFromUserMetadataID(id: string) { + const prefix = `${DocumentType.ROW}${SEPARATOR}${InternalTable.USER_METADATA}${SEPARATOR}` + if (!id || !id.includes(prefix)) { + return id + } + return id.split(prefix)[1] +} + export function getUsersByAppParams(appId: any, otherProps: any = {}) { const prodAppId = getProdAppID(appId) return { @@ -167,9 +254,9 @@ export function getRoleParams(roleId = null, otherProps = {}) { return getDocParams(DocumentType.ROLE, roleId, otherProps) } -export function getStartEndKeyURL(base: any, baseKey: any, tenantId = null) { +export function getStartEndKeyURL(baseKey: any, tenantId = null) { const tenancy = tenantId ? `${SEPARATOR}${tenantId}` : "" - return `${base}?startkey="${baseKey}${tenancy}"&endkey="${baseKey}${tenancy}${UNICODE_MAX}"` + return `startkey="${baseKey}${tenancy}"&endkey="${baseKey}${tenancy}${UNICODE_MAX}"` } /** @@ -185,22 +272,10 @@ export async function getAllDbs(opts = { efficient: false }) { return allDbs() } let dbs: any[] = [] - let { url, cookie } = getCouchInfo() - async function addDbs(couchUrl: string) { - const response = await fetch(checkSlashesInUrl(encodeURI(couchUrl)), { - method: "GET", - headers: { - Authorization: cookie, - }, - }) - if (response.status === 200) { - let json = await response.json() - dbs = dbs.concat(json) - } else { - throw "Cannot connect to CouchDB instance" - } + async function addDbs(queryString?: string) { + const json = await directCouchAllDbs(queryString) + dbs = dbs.concat(json) } - let couchUrl = `${url}/_all_dbs` let tenantId = getTenantId() if (!env.MULTI_TENANCY || (!efficient && tenantId === DEFAULT_TENANT_ID)) { // just get all DBs when: @@ -208,12 +283,12 @@ export async function getAllDbs(opts = { efficient: false }) { // - default tenant // - apps dbs don't contain tenant id // - non-default tenant dbs are filtered out application side in getAllApps - await addDbs(couchUrl) + await addDbs() } else { // get prod apps - await addDbs(getStartEndKeyURL(couchUrl, DocumentType.APP, tenantId)) + await addDbs(getStartEndKeyURL(DocumentType.APP, tenantId)) // get dev apps - await addDbs(getStartEndKeyURL(couchUrl, DocumentType.APP_DEV, tenantId)) + await addDbs(getStartEndKeyURL(DocumentType.APP_DEV, tenantId)) // add global db name dbs.push(getGlobalDBName(tenantId)) } @@ -254,7 +329,16 @@ export async function getAllApps({ dev, all, idsOnly, efficient }: any = {}) { return false }) if (idsOnly) { - return appDbNames + const devAppIds = appDbNames.filter(appId => isDevAppID(appId)) + const prodAppIds = appDbNames.filter(appId => !isDevAppID(appId)) + switch (dev) { + case true: + return devAppIds + case false: + return prodAppIds + default: + return appDbNames + } } const appPromises = appDbNames.map((app: any) => // skip setup otherwise databases could be re-created @@ -357,6 +441,21 @@ export const generateDevInfoID = (userId: any) => { return `${DocumentType.DEV_INFO}${SEPARATOR}${userId}` } +/** + * Generates a new plugin ID - to be used in the global DB. + * @returns {string} The new plugin ID which a plugin metadata document can be stored under. + */ +export const generatePluginID = (name: string) => { + return `${DocumentType.PLUGIN}${SEPARATOR}${name}` +} + +/** + * Gets parameters for retrieving automations, this is a utility function for the getDocParams function. + */ +export const getPluginParams = (pluginId?: string | null, otherProps = {}) => { + return getDocParams(DocumentType.PLUGIN, pluginId, otherProps) +} + /** * Returns the most granular configuration document from the DB based on the type, workspace and userID passed. * @param {Object} db - db instance to query diff --git a/packages/backend-core/src/db/views.js b/packages/backend-core/src/db/views.js deleted file mode 100644 index 3a45611a8f..0000000000 --- a/packages/backend-core/src/db/views.js +++ /dev/null @@ -1,158 +0,0 @@ -const { - DocumentType, - ViewName, - DeprecatedViews, - SEPARATOR, -} = require("./utils") -const { getGlobalDB } = require("../tenancy") - -const DESIGN_DB = "_design/database" - -function DesignDoc() { - return { - _id: DESIGN_DB, - // view collation information, read before writing any complex views: - // https://docs.couchdb.org/en/master/ddocs/views/collation.html#collation-specification - views: {}, - } -} - -async function removeDeprecated(db, viewName) { - if (!DeprecatedViews[viewName]) { - return - } - try { - const designDoc = await db.get(DESIGN_DB) - for (let deprecatedNames of DeprecatedViews[viewName]) { - delete designDoc.views[deprecatedNames] - } - await db.put(designDoc) - } catch (err) { - // doesn't exist, ignore - } -} - -exports.createNewUserEmailView = async () => { - const db = getGlobalDB() - let designDoc - try { - designDoc = await db.get(DESIGN_DB) - } catch (err) { - // no design doc, make one - designDoc = DesignDoc() - } - const view = { - // if using variables in a map function need to inject them before use - map: `function(doc) { - if (doc._id.startsWith("${DocumentType.USER}${SEPARATOR}")) { - emit(doc.email.toLowerCase(), doc._id) - } - }`, - } - designDoc.views = { - ...designDoc.views, - [ViewName.USER_BY_EMAIL]: view, - } - await db.put(designDoc) -} - -exports.createUserAppView = async () => { - const db = getGlobalDB() - let designDoc - try { - designDoc = await db.get("_design/database") - } catch (err) { - // no design doc, make one - designDoc = DesignDoc() - } - const view = { - // if using variables in a map function need to inject them before use - map: `function(doc) { - if (doc._id.startsWith("${DocumentType.USER}${SEPARATOR}") && doc.roles) { - for (let prodAppId of Object.keys(doc.roles)) { - let emitted = prodAppId + "${SEPARATOR}" + doc._id - emit(emitted, null) - } - } - }`, - } - designDoc.views = { - ...designDoc.views, - [ViewName.USER_BY_APP]: view, - } - await db.put(designDoc) -} - -exports.createApiKeyView = async () => { - const db = getGlobalDB() - let designDoc - try { - designDoc = await db.get("_design/database") - } catch (err) { - designDoc = DesignDoc() - } - const view = { - map: `function(doc) { - if (doc._id.startsWith("${DocumentType.DEV_INFO}") && doc.apiKey) { - emit(doc.apiKey, doc.userId) - } - }`, - } - designDoc.views = { - ...designDoc.views, - [ViewName.BY_API_KEY]: view, - } - await db.put(designDoc) -} - -exports.createUserBuildersView = async () => { - const db = getGlobalDB() - let designDoc - try { - designDoc = await db.get("_design/database") - } catch (err) { - // no design doc, make one - designDoc = DesignDoc() - } - const view = { - map: `function(doc) { - if (doc.builder && doc.builder.global === true) { - emit(doc._id, doc._id) - } - }`, - } - designDoc.views = { - ...designDoc.views, - [ViewName.USER_BY_BUILDERS]: view, - } - await db.put(designDoc) -} - -exports.queryGlobalView = async (viewName, params, db = null) => { - const CreateFuncByName = { - [ViewName.USER_BY_EMAIL]: exports.createNewUserEmailView, - [ViewName.BY_API_KEY]: exports.createApiKeyView, - [ViewName.USER_BY_BUILDERS]: exports.createUserBuildersView, - [ViewName.USER_BY_APP]: exports.createUserAppView, - } - // can pass DB in if working with something specific - if (!db) { - db = getGlobalDB() - } - try { - let response = (await db.query(`database/${viewName}`, params)).rows - response = response.map(resp => - params.include_docs ? resp.doc : resp.value - ) - return response.length <= 1 ? response[0] : response - } catch (err) { - if (err != null && err.name === "not_found") { - const createFunc = CreateFuncByName[viewName] - await removeDeprecated(db, viewName) - await createFunc() - return exports.queryGlobalView(viewName, params) - } else { - throw err - } - } -} diff --git a/packages/backend-core/src/db/views.ts b/packages/backend-core/src/db/views.ts new file mode 100644 index 0000000000..f0fff918fc --- /dev/null +++ b/packages/backend-core/src/db/views.ts @@ -0,0 +1,199 @@ +import { DocumentType, ViewName, DeprecatedViews, SEPARATOR } from "./utils" +import { getGlobalDB } from "../context" +import PouchDB from "pouchdb" +import { StaticDatabases } from "./constants" +import { doWithDB } from "./" + +const DESIGN_DB = "_design/database" + +function DesignDoc() { + return { + _id: DESIGN_DB, + // view collation information, read before writing any complex views: + // https://docs.couchdb.org/en/master/ddocs/views/collation.html#collation-specification + views: {}, + } +} + +interface DesignDocument { + views: any +} + +async function removeDeprecated(db: PouchDB.Database, viewName: ViewName) { + // @ts-ignore + if (!DeprecatedViews[viewName]) { + return + } + try { + const designDoc = await db.get(DESIGN_DB) + // @ts-ignore + for (let deprecatedNames of DeprecatedViews[viewName]) { + delete designDoc.views[deprecatedNames] + } + await db.put(designDoc) + } catch (err) { + // doesn't exist, ignore + } +} + +export async function createView(db: any, viewJs: string, viewName: string) { + let designDoc + try { + designDoc = (await db.get(DESIGN_DB)) as DesignDocument + } catch (err) { + // no design doc, make one + designDoc = DesignDoc() + } + const view = { + map: viewJs, + } + designDoc.views = { + ...designDoc.views, + [viewName]: view, + } + await db.put(designDoc) +} + +export const createNewUserEmailView = async () => { + const db = getGlobalDB() + const viewJs = `function(doc) { + if (doc._id.startsWith("${DocumentType.USER}${SEPARATOR}")) { + emit(doc.email.toLowerCase(), doc._id) + } + }` + await createView(db, viewJs, ViewName.USER_BY_EMAIL) +} + +export const createAccountEmailView = async () => { + const viewJs = `function(doc) { + if (doc._id.startsWith("${DocumentType.ACCOUNT_METADATA}${SEPARATOR}")) { + emit(doc.email.toLowerCase(), doc._id) + } + }` + await doWithDB( + StaticDatabases.PLATFORM_INFO.name, + async (db: PouchDB.Database) => { + await createView(db, viewJs, ViewName.ACCOUNT_BY_EMAIL) + } + ) +} + +export const createUserAppView = async () => { + const db = getGlobalDB() as PouchDB.Database + const viewJs = `function(doc) { + if (doc._id.startsWith("${DocumentType.USER}${SEPARATOR}") && doc.roles) { + for (let prodAppId of Object.keys(doc.roles)) { + let emitted = prodAppId + "${SEPARATOR}" + doc._id + emit(emitted, null) + } + } + }` + await createView(db, viewJs, ViewName.USER_BY_APP) +} + +export const createApiKeyView = async () => { + const db = getGlobalDB() + const viewJs = `function(doc) { + if (doc._id.startsWith("${DocumentType.DEV_INFO}") && doc.apiKey) { + emit(doc.apiKey, doc.userId) + } + }` + await createView(db, viewJs, ViewName.BY_API_KEY) +} + +export const createUserBuildersView = async () => { + const db = getGlobalDB() + const viewJs = `function(doc) { + if (doc.builder && doc.builder.global === true) { + emit(doc._id, doc._id) + } + }` + await createView(db, viewJs, ViewName.USER_BY_BUILDERS) +} + +export const createPlatformUserView = async () => { + const viewJs = `function(doc) { + if (doc.tenantId) { + emit(doc._id.toLowerCase(), doc._id) + } + }` + await doWithDB( + StaticDatabases.PLATFORM_INFO.name, + async (db: PouchDB.Database) => { + await createView(db, viewJs, ViewName.PLATFORM_USERS_LOWERCASE) + } + ) +} + +export interface QueryViewOptions { + arrayResponse?: boolean +} + +export const queryView = async ( + viewName: ViewName, + params: PouchDB.Query.Options, + db: PouchDB.Database, + createFunc: any, + opts?: QueryViewOptions +): Promise => { + try { + let response = await db.query(`database/${viewName}`, params) + const rows = response.rows + const docs = rows.map(row => (params.include_docs ? row.doc : row.value)) + + // if arrayResponse has been requested, always return array regardless of length + if (opts?.arrayResponse) { + return docs + } else { + // return the single document if there is only one + return docs.length <= 1 ? docs[0] : docs + } + } catch (err: any) { + if (err != null && err.name === "not_found") { + await removeDeprecated(db, viewName) + await createFunc() + return queryView(viewName, params, db, createFunc, opts) + } else { + throw err + } + } +} + +export const queryPlatformView = async ( + viewName: ViewName, + params: PouchDB.Query.Options, + opts?: QueryViewOptions +): Promise => { + const CreateFuncByName: any = { + [ViewName.ACCOUNT_BY_EMAIL]: createAccountEmailView, + [ViewName.PLATFORM_USERS_LOWERCASE]: createPlatformUserView, + } + + return doWithDB( + StaticDatabases.PLATFORM_INFO.name, + async (db: PouchDB.Database) => { + const createFn = CreateFuncByName[viewName] + return queryView(viewName, params, db, createFn, opts) + } + ) +} + +export const queryGlobalView = async ( + viewName: ViewName, + params: PouchDB.Query.Options, + db?: PouchDB.Database, + opts?: QueryViewOptions +): Promise => { + const CreateFuncByName: any = { + [ViewName.USER_BY_EMAIL]: createNewUserEmailView, + [ViewName.BY_API_KEY]: createApiKeyView, + [ViewName.USER_BY_BUILDERS]: createUserBuildersView, + [ViewName.USER_BY_APP]: createUserAppView, + } + // can pass DB in if working with something specific + if (!db) { + db = getGlobalDB() as PouchDB.Database + } + const createFn = CreateFuncByName[viewName] + return queryView(viewName, params, db, createFn, opts) +} diff --git a/packages/backend-core/src/environment.ts b/packages/backend-core/src/environment.ts index 0348d921ab..6e2ac94be9 100644 --- a/packages/backend-core/src/environment.ts +++ b/packages/backend-core/src/environment.ts @@ -16,9 +16,19 @@ if (!LOADED && isDev() && !isTest()) { LOADED = true } +const DefaultBucketName = { + BACKUPS: "backups", + APPS: "prod-budi-app-assets", + TEMPLATES: "templates", + GLOBAL: "global", + CLOUD: "prod-budi-tenant-uploads", + PLUGINS: "plugins", +} + const env = { isTest, isDev, + JS_BCRYPT: process.env.JS_BCRYPT, JWT_SECRET: process.env.JWT_SECRET, COUCH_DB_URL: process.env.COUCH_DB_URL || "http://localhost:4005", COUCH_DB_USERNAME: process.env.COUCH_DB_USER, @@ -36,7 +46,7 @@ const env = { MULTI_TENANCY: process.env.MULTI_TENANCY, ACCOUNT_PORTAL_URL: process.env.ACCOUNT_PORTAL_URL || "https://account.budibase.app", - ACCOUNT_PORTAL_API_KEY: process.env.ACCOUNT_PORTAL_API_KEY, + ACCOUNT_PORTAL_API_KEY: process.env.ACCOUNT_PORTAL_API_KEY || "", DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL, SELF_HOSTED: !!parseInt(process.env.SELF_HOSTED || ""), COOKIE_DOMAIN: process.env.COOKIE_DOMAIN, @@ -44,12 +54,17 @@ const env = { POSTHOG_TOKEN: process.env.POSTHOG_TOKEN, ENABLE_ANALYTICS: process.env.ENABLE_ANALYTICS, TENANT_FEATURE_FLAGS: process.env.TENANT_FEATURE_FLAGS, - BACKUPS_BUCKET_NAME: process.env.BACKUPS_BUCKET_NAME || "backups", - APPS_BUCKET_NAME: process.env.APPS_BUCKET_NAME || "prod-budi-app-assets", - TEMPLATES_BUCKET_NAME: process.env.TEMPLATES_BUCKET_NAME || "templates", - GLOBAL_BUCKET_NAME: process.env.GLOBAL_BUCKET_NAME || "global", + BACKUPS_BUCKET_NAME: + process.env.BACKUPS_BUCKET_NAME || DefaultBucketName.BACKUPS, + APPS_BUCKET_NAME: process.env.APPS_BUCKET_NAME || DefaultBucketName.APPS, + TEMPLATES_BUCKET_NAME: + process.env.TEMPLATES_BUCKET_NAME || DefaultBucketName.TEMPLATES, + GLOBAL_BUCKET_NAME: + process.env.GLOBAL_BUCKET_NAME || DefaultBucketName.GLOBAL, GLOBAL_CLOUD_BUCKET_NAME: - process.env.GLOBAL_CLOUD_BUCKET_NAME || "prod-budi-tenant-uploads", + process.env.GLOBAL_CLOUD_BUCKET_NAME || DefaultBucketName.CLOUD, + PLUGIN_BUCKET_NAME: + process.env.PLUGIN_BUCKET_NAME || DefaultBucketName.PLUGINS, USE_COUCH: process.env.USE_COUCH || true, DISABLE_DEVELOPER_LICENSE: process.env.DISABLE_DEVELOPER_LICENSE, DEFAULT_LICENSE: process.env.DEFAULT_LICENSE, diff --git a/packages/backend-core/src/errors/base.js b/packages/backend-core/src/errors/base.js deleted file mode 100644 index 7cb0c0fc23..0000000000 --- a/packages/backend-core/src/errors/base.js +++ /dev/null @@ -1,11 +0,0 @@ -class BudibaseError extends Error { - constructor(message, code, type) { - super(message) - this.code = code - this.type = type - } -} - -module.exports = { - BudibaseError, -} diff --git a/packages/backend-core/src/errors/base.ts b/packages/backend-core/src/errors/base.ts new file mode 100644 index 0000000000..801dcf168d --- /dev/null +++ b/packages/backend-core/src/errors/base.ts @@ -0,0 +1,10 @@ +export class BudibaseError extends Error { + code: string + type: string + + constructor(message: string, code: string, type: string) { + super(message) + this.code = code + this.type = type + } +} diff --git a/packages/backend-core/src/errors/generic.js b/packages/backend-core/src/errors/generic.js deleted file mode 100644 index 5c7661f035..0000000000 --- a/packages/backend-core/src/errors/generic.js +++ /dev/null @@ -1,11 +0,0 @@ -const { BudibaseError } = require("./base") - -class GenericError extends BudibaseError { - constructor(message, code, type) { - super(message, code, type ? type : "generic") - } -} - -module.exports = { - GenericError, -} diff --git a/packages/backend-core/src/errors/generic.ts b/packages/backend-core/src/errors/generic.ts new file mode 100644 index 0000000000..71b3352438 --- /dev/null +++ b/packages/backend-core/src/errors/generic.ts @@ -0,0 +1,7 @@ +import { BudibaseError } from "./base" + +export class GenericError extends BudibaseError { + constructor(message: string, code: string, type: string) { + super(message, code, type ? type : "generic") + } +} diff --git a/packages/backend-core/src/errors/http.js b/packages/backend-core/src/errors/http.js deleted file mode 100644 index 8e7cab4638..0000000000 --- a/packages/backend-core/src/errors/http.js +++ /dev/null @@ -1,12 +0,0 @@ -const { GenericError } = require("./generic") - -class HTTPError extends GenericError { - constructor(message, httpStatus, code = "http", type = "generic") { - super(message, code, type) - this.status = httpStatus - } -} - -module.exports = { - HTTPError, -} diff --git a/packages/backend-core/src/errors/http.ts b/packages/backend-core/src/errors/http.ts new file mode 100644 index 0000000000..182e009f58 --- /dev/null +++ b/packages/backend-core/src/errors/http.ts @@ -0,0 +1,15 @@ +import { GenericError } from "./generic" + +export class HTTPError extends GenericError { + status: number + + constructor( + message: string, + httpStatus: number, + code = "http", + type = "generic" + ) { + super(message, code, type) + this.status = httpStatus + } +} diff --git a/packages/backend-core/src/errors/index.js b/packages/backend-core/src/errors/index.ts similarity index 65% rename from packages/backend-core/src/errors/index.js rename to packages/backend-core/src/errors/index.ts index 31ffd739a0..be6657093d 100644 --- a/packages/backend-core/src/errors/index.js +++ b/packages/backend-core/src/errors/index.ts @@ -1,5 +1,6 @@ -const http = require("./http") -const licensing = require("./licensing") +import { HTTPError } from "./http" +import { UsageLimitError, FeatureDisabledError } from "./licensing" +import * as licensing from "./licensing" const codes = { ...licensing.codes, @@ -11,7 +12,7 @@ const context = { ...licensing.context, } -const getPublicError = err => { +const getPublicError = (err: any) => { let error if (err.code || err.type) { // add generic error information @@ -32,13 +33,15 @@ const getPublicError = err => { return error } -module.exports = { +const pkg = { codes, types, errors: { - UsageLimitError: licensing.UsageLimitError, - FeatureDisabledError: licensing.FeatureDisabledError, - HTTPError: http.HTTPError, + UsageLimitError, + FeatureDisabledError, + HTTPError, }, getPublicError, } + +export = pkg diff --git a/packages/backend-core/src/errors/licensing.js b/packages/backend-core/src/errors/licensing.js deleted file mode 100644 index 85d207ac35..0000000000 --- a/packages/backend-core/src/errors/licensing.js +++ /dev/null @@ -1,43 +0,0 @@ -const { HTTPError } = require("./http") - -const type = "license_error" - -const codes = { - USAGE_LIMIT_EXCEEDED: "usage_limit_exceeded", - FEATURE_DISABLED: "feature_disabled", -} - -const context = { - [codes.USAGE_LIMIT_EXCEEDED]: err => { - return { - limitName: err.limitName, - } - }, - [codes.FEATURE_DISABLED]: err => { - return { - featureName: err.featureName, - } - }, -} - -class UsageLimitError extends HTTPError { - constructor(message, limitName) { - super(message, 400, codes.USAGE_LIMIT_EXCEEDED, type) - this.limitName = limitName - } -} - -class FeatureDisabledError extends HTTPError { - constructor(message, featureName) { - super(message, 400, codes.FEATURE_DISABLED, type) - this.featureName = featureName - } -} - -module.exports = { - type, - codes, - context, - UsageLimitError, - FeatureDisabledError, -} diff --git a/packages/backend-core/src/errors/licensing.ts b/packages/backend-core/src/errors/licensing.ts new file mode 100644 index 0000000000..7ffcefa167 --- /dev/null +++ b/packages/backend-core/src/errors/licensing.ts @@ -0,0 +1,39 @@ +import { HTTPError } from "./http" + +export const type = "license_error" + +export const codes = { + USAGE_LIMIT_EXCEEDED: "usage_limit_exceeded", + FEATURE_DISABLED: "feature_disabled", +} + +export const context = { + [codes.USAGE_LIMIT_EXCEEDED]: (err: any) => { + return { + limitName: err.limitName, + } + }, + [codes.FEATURE_DISABLED]: (err: any) => { + return { + featureName: err.featureName, + } + }, +} + +export class UsageLimitError extends HTTPError { + limitName: string + + constructor(message: string, limitName: string) { + super(message, 400, codes.USAGE_LIMIT_EXCEEDED, type) + this.limitName = limitName + } +} + +export class FeatureDisabledError extends HTTPError { + featureName: string + + constructor(message: string, featureName: string) { + super(message, 400, codes.FEATURE_DISABLED, type) + this.featureName = featureName + } +} diff --git a/packages/backend-core/src/events/index.ts b/packages/backend-core/src/events/index.ts index 814399655d..f94c8b0267 100644 --- a/packages/backend-core/src/events/index.ts +++ b/packages/backend-core/src/events/index.ts @@ -8,4 +8,5 @@ import { processors } from "./processors" export const shutdown = () => { processors.shutdown() + console.log("Events shutdown") } diff --git a/packages/backend-core/src/events/processors/LoggingProcessor.ts b/packages/backend-core/src/events/processors/LoggingProcessor.ts index a517fba09c..d41a82fbb4 100644 --- a/packages/backend-core/src/events/processors/LoggingProcessor.ts +++ b/packages/backend-core/src/events/processors/LoggingProcessor.ts @@ -23,9 +23,11 @@ export default class LoggingProcessor implements EventProcessor { return } let timestampString = getTimestampString(timestamp) - console.log( - `[audit] [tenant=${identity.tenantId}] [identityType=${identity.type}] [identity=${identity.id}] ${timestampString} ${event} ` - ) + let message = `[audit] [tenant=${identity.tenantId}] [identityType=${identity.type}] [identity=${identity.id}] ${timestampString} ${event} ` + if (env.isDev()) { + message = message + `[debug: [properties=${JSON.stringify(properties)}] ]` + } + console.log(message) } async identify(identity: Identity, timestamp?: string | number) { diff --git a/packages/backend-core/src/events/publishers/backup.ts b/packages/backend-core/src/events/publishers/backup.ts new file mode 100644 index 0000000000..0fc81da259 --- /dev/null +++ b/packages/backend-core/src/events/publishers/backup.ts @@ -0,0 +1,12 @@ +import { AppBackup, AppBackupRestoreEvent, Event } from "@budibase/types" +import { publishEvent } from "../events" + +export async function appBackupRestored(backup: AppBackup) { + const properties: AppBackupRestoreEvent = { + appId: backup.appId, + backupName: backup.name!, + backupCreatedAt: backup.timestamp, + } + + await publishEvent(Event.APP_BACKUP_RESTORED, properties) +} diff --git a/packages/backend-core/src/events/publishers/datasource.ts b/packages/backend-core/src/events/publishers/datasource.ts index 3cd68033fc..d3ea7402f9 100644 --- a/packages/backend-core/src/events/publishers/datasource.ts +++ b/packages/backend-core/src/events/publishers/datasource.ts @@ -5,8 +5,15 @@ import { DatasourceCreatedEvent, DatasourceUpdatedEvent, DatasourceDeletedEvent, + SourceName, } from "@budibase/types" +function isCustom(datasource: Datasource) { + const sources = Object.values(SourceName) + // if not in the base source list, then it must be custom + return !sources.includes(datasource.source) +} + export async function created( datasource: Datasource, timestamp?: string | number @@ -14,6 +21,7 @@ export async function created( const properties: DatasourceCreatedEvent = { datasourceId: datasource._id as string, source: datasource.source, + custom: isCustom(datasource), } await publishEvent(Event.DATASOURCE_CREATED, properties, timestamp) } @@ -22,6 +30,7 @@ export async function updated(datasource: Datasource) { const properties: DatasourceUpdatedEvent = { datasourceId: datasource._id as string, source: datasource.source, + custom: isCustom(datasource), } await publishEvent(Event.DATASOURCE_UPDATED, properties) } @@ -30,6 +39,7 @@ export async function deleted(datasource: Datasource) { const properties: DatasourceDeletedEvent = { datasourceId: datasource._id as string, source: datasource.source, + custom: isCustom(datasource), } await publishEvent(Event.DATASOURCE_DELETED, properties) } diff --git a/packages/backend-core/src/events/publishers/group.ts b/packages/backend-core/src/events/publishers/group.ts index d300873725..b4fd0d1469 100644 --- a/packages/backend-core/src/events/publishers/group.ts +++ b/packages/backend-core/src/events/publishers/group.ts @@ -40,9 +40,9 @@ export async function usersAdded(count: number, group: UserGroup) { await publishEvent(Event.USER_GROUP_USERS_ADDED, properties) } -export async function usersDeleted(emails: string[], group: UserGroup) { +export async function usersDeleted(count: number, group: UserGroup) { const properties: GroupUsersDeletedEvent = { - count: emails.length, + count, groupId: group._id as string, } await publishEvent(Event.USER_GROUP_USERS_REMOVED, properties) diff --git a/packages/backend-core/src/events/publishers/index.ts b/packages/backend-core/src/events/publishers/index.ts index 57fd0bf8e2..7306312a8f 100644 --- a/packages/backend-core/src/events/publishers/index.ts +++ b/packages/backend-core/src/events/publishers/index.ts @@ -18,3 +18,5 @@ export * as view from "./view" export * as installation from "./installation" export * as backfill from "./backfill" export * as group from "./group" +export * as plugin from "./plugin" +export * as backup from "./backup" diff --git a/packages/backend-core/src/events/publishers/license.ts b/packages/backend-core/src/events/publishers/license.ts index 1adc71652e..84472e408f 100644 --- a/packages/backend-core/src/events/publishers/license.ts +++ b/packages/backend-core/src/events/publishers/license.ts @@ -1,27 +1,78 @@ import { publishEvent } from "../events" import { Event, - License, LicenseActivatedEvent, - LicenseDowngradedEvent, - LicenseUpdatedEvent, - LicenseUpgradedEvent, + LicensePlanChangedEvent, + LicenseTierChangedEvent, + PlanType, + Account, + LicensePortalOpenedEvent, + LicenseCheckoutSuccessEvent, + LicenseCheckoutOpenedEvent, + LicensePaymentFailedEvent, + LicensePaymentRecoveredEvent, } from "@budibase/types" -// TODO -export async function updgraded(license: License) { - const properties: LicenseUpgradedEvent = {} - await publishEvent(Event.LICENSE_UPGRADED, properties) +export async function tierChanged(account: Account, from: number, to: number) { + const properties: LicenseTierChangedEvent = { + accountId: account.accountId, + to, + from, + } + await publishEvent(Event.LICENSE_TIER_CHANGED, properties) } -// TODO -export async function downgraded(license: License) { - const properties: LicenseDowngradedEvent = {} - await publishEvent(Event.LICENSE_DOWNGRADED, properties) +export async function planChanged( + account: Account, + from: PlanType, + to: PlanType +) { + const properties: LicensePlanChangedEvent = { + accountId: account.accountId, + to, + from, + } + await publishEvent(Event.LICENSE_PLAN_CHANGED, properties) } -// TODO -export async function activated(license: License) { - const properties: LicenseActivatedEvent = {} +export async function activated(account: Account) { + const properties: LicenseActivatedEvent = { + accountId: account.accountId, + } await publishEvent(Event.LICENSE_ACTIVATED, properties) } + +export async function checkoutOpened(account: Account) { + const properties: LicenseCheckoutOpenedEvent = { + accountId: account.accountId, + } + await publishEvent(Event.LICENSE_CHECKOUT_OPENED, properties) +} + +export async function checkoutSuccess(account: Account) { + const properties: LicenseCheckoutSuccessEvent = { + accountId: account.accountId, + } + await publishEvent(Event.LICENSE_CHECKOUT_SUCCESS, properties) +} + +export async function portalOpened(account: Account) { + const properties: LicensePortalOpenedEvent = { + accountId: account.accountId, + } + await publishEvent(Event.LICENSE_PORTAL_OPENED, properties) +} + +export async function paymentFailed(account: Account) { + const properties: LicensePaymentFailedEvent = { + accountId: account.accountId, + } + await publishEvent(Event.LICENSE_PAYMENT_FAILED, properties) +} + +export async function paymentRecovered(account: Account) { + const properties: LicensePaymentRecoveredEvent = { + accountId: account.accountId, + } + await publishEvent(Event.LICENSE_PAYMENT_RECOVERED, properties) +} diff --git a/packages/backend-core/src/events/publishers/plugin.ts b/packages/backend-core/src/events/publishers/plugin.ts new file mode 100644 index 0000000000..4e4d87cf56 --- /dev/null +++ b/packages/backend-core/src/events/publishers/plugin.ts @@ -0,0 +1,41 @@ +import { publishEvent } from "../events" +import { + Event, + Plugin, + PluginDeletedEvent, + PluginImportedEvent, + PluginInitEvent, +} from "@budibase/types" + +export async function init(plugin: Plugin) { + const properties: PluginInitEvent = { + type: plugin.schema.type, + name: plugin.name, + description: plugin.description, + version: plugin.version, + } + await publishEvent(Event.PLUGIN_INIT, properties) +} + +export async function imported(plugin: Plugin) { + const properties: PluginImportedEvent = { + pluginId: plugin._id as string, + type: plugin.schema.type, + source: plugin.source, + name: plugin.name, + description: plugin.description, + version: plugin.version, + } + await publishEvent(Event.PLUGIN_IMPORTED, properties) +} + +export async function deleted(plugin: Plugin) { + const properties: PluginDeletedEvent = { + pluginId: plugin._id as string, + type: plugin.schema.type, + name: plugin.name, + description: plugin.description, + version: plugin.version, + } + await publishEvent(Event.PLUGIN_DELETED, properties) +} diff --git a/packages/backend-core/src/featureFlags/index.js b/packages/backend-core/src/featureFlags/index.js index 103ac4df59..8a8162d0ba 100644 --- a/packages/backend-core/src/featureFlags/index.js +++ b/packages/backend-core/src/featureFlags/index.js @@ -31,23 +31,29 @@ const TENANT_FEATURE_FLAGS = getFeatureFlags() exports.isEnabled = featureFlag => { const tenantId = tenancy.getTenantId() - - return ( - TENANT_FEATURE_FLAGS && - TENANT_FEATURE_FLAGS[tenantId] && - TENANT_FEATURE_FLAGS[tenantId].includes(featureFlag) - ) + const flags = exports.getTenantFeatureFlags(tenantId) + return flags.includes(featureFlag) } exports.getTenantFeatureFlags = tenantId => { - if (TENANT_FEATURE_FLAGS && TENANT_FEATURE_FLAGS[tenantId]) { - return TENANT_FEATURE_FLAGS[tenantId] + const flags = [] + + if (TENANT_FEATURE_FLAGS) { + const globalFlags = TENANT_FEATURE_FLAGS["*"] + const tenantFlags = TENANT_FEATURE_FLAGS[tenantId] + + if (globalFlags) { + flags.push(...globalFlags) + } + if (tenantFlags) { + flags.push(...tenantFlags) + } } - return [] + return flags } -exports.FeatureFlag = { +exports.TenantFeatureFlag = { LICENSING: "LICENSING", GOOGLE_SHEETS: "GOOGLE_SHEETS", USER_GROUPS: "USER_GROUPS", diff --git a/packages/backend-core/src/hashing.js b/packages/backend-core/src/hashing.js index 45abe2f9bd..7524e66043 100644 --- a/packages/backend-core/src/hashing.js +++ b/packages/backend-core/src/hashing.js @@ -1,5 +1,5 @@ -const bcrypt = require("bcrypt") const env = require("./environment") +const bcrypt = env.JS_BCRYPT ? require("bcryptjs") : require("bcrypt") const { v4 } = require("uuid") const SALT_ROUNDS = env.SALT_ROUNDS || 10 diff --git a/packages/backend-core/src/index.ts b/packages/backend-core/src/index.ts index 6d2e8dcd10..659a56c051 100644 --- a/packages/backend-core/src/index.ts +++ b/packages/backend-core/src/index.ts @@ -1,5 +1,4 @@ import errors from "./errors" - const errorClasses = errors.errors import * as events from "./events" import * as migrations from "./migrations" @@ -15,8 +14,12 @@ import deprovisioning from "./context/deprovision" import auth from "./auth" import constants from "./constants" import * as dbConstants from "./db/constants" -import logging from "./logging" +import * as logging from "./logging" import pino from "./pino" +import * as middleware from "./middleware" +import plugins from "./plugin" +import encryption from "./security/encryption" +import * as queue from "./queue" // mimic the outer package exports import * as db from "./pkg/db" @@ -35,6 +38,7 @@ const core = { db, ...dbConstants, redis, + locks: redis.redlock, objectStore, utils, users, @@ -55,8 +59,12 @@ const core = { errors, logging, roles, + plugins, ...pino, ...errorClasses, + middleware, + encryption, + queue, } export = core diff --git a/packages/backend-core/src/middleware/authenticated.ts b/packages/backend-core/src/middleware/authenticated.ts index b51ead46b9..a3c6b67cde 100644 --- a/packages/backend-core/src/middleware/authenticated.ts +++ b/packages/backend-core/src/middleware/authenticated.ts @@ -65,7 +65,7 @@ async function checkApiKey(apiKey: string, populateUser?: Function) { * The tenancy modules should not be used here and it should be assumed that the tenancy context * has not yet been populated. */ -module.exports = ( +export = ( noAuthPatterns = [], opts: { publicAllowed: boolean; populateUser?: Function } = { publicAllowed: false, @@ -106,6 +106,7 @@ module.exports = ( user = await getUser(userId, session.tenantId) } user.csrfToken = session.csrfToken + if (session?.lastAccessedAt < timeMinusOneMinute()) { // make sure we denote that the session is still in use await updateSessionTTL(session) diff --git a/packages/backend-core/src/middleware/index.js b/packages/backend-core/src/middleware/index.ts similarity index 96% rename from packages/backend-core/src/middleware/index.js rename to packages/backend-core/src/middleware/index.ts index 7e7b8a2931..998c231b3d 100644 --- a/packages/backend-core/src/middleware/index.js +++ b/packages/backend-core/src/middleware/index.ts @@ -13,7 +13,8 @@ const adminOnly = require("./adminOnly") const builderOrAdmin = require("./builderOrAdmin") const builderOnly = require("./builderOnly") const joiValidator = require("./joi-validator") -module.exports = { + +const pkg = { google, oidc, jwt, @@ -33,3 +34,5 @@ module.exports = { builderOrAdmin, joiValidator, } + +export = pkg diff --git a/packages/backend-core/src/middleware/joi-validator.js b/packages/backend-core/src/middleware/joi-validator.js index 748ccebd89..6812dbdd54 100644 --- a/packages/backend-core/src/middleware/joi-validator.js +++ b/packages/backend-core/src/middleware/joi-validator.js @@ -13,10 +13,13 @@ function validate(schema, property) { params = ctx.request[property] } - schema = schema.append({ - createdAt: Joi.any().optional(), - updatedAt: Joi.any().optional(), - }) + // not all schemas have the append property e.g. array schemas + if (schema.append) { + schema = schema.append({ + createdAt: Joi.any().optional(), + updatedAt: Joi.any().optional(), + }) + } const { error } = schema.validate(params) if (error) { diff --git a/packages/backend-core/src/migrations/definitions.ts b/packages/backend-core/src/migrations/definitions.ts index 34ec0f0cad..6eba56ab43 100644 --- a/packages/backend-core/src/migrations/definitions.ts +++ b/packages/backend-core/src/migrations/definitions.ts @@ -11,20 +11,12 @@ export const DEFINITIONS: MigrationDefinition[] = [ }, { type: MigrationType.GLOBAL, - name: MigrationName.QUOTAS_1, + name: MigrationName.SYNC_QUOTAS, }, { type: MigrationType.APP, name: MigrationName.APP_URLS, }, - { - type: MigrationType.GLOBAL, - name: MigrationName.DEVELOPER_QUOTA, - }, - { - type: MigrationType.GLOBAL, - name: MigrationName.PUBLISHED_APP_QUOTA, - }, { type: MigrationType.APP, name: MigrationName.EVENT_APP_BACKFILL, diff --git a/packages/backend-core/src/migrations/migrations.ts b/packages/backend-core/src/migrations/migrations.ts index ca238ff80e..90a12acec2 100644 --- a/packages/backend-core/src/migrations/migrations.ts +++ b/packages/backend-core/src/migrations/migrations.ts @@ -3,12 +3,8 @@ import { doWithDB } from "../db" import { DocumentType, StaticDatabases } from "../db/constants" import { getAllApps } from "../db/utils" import environment from "../environment" -import { - doInTenant, - getTenantIds, - getGlobalDBName, - getTenantId, -} from "../tenancy" +import { doInTenant, getTenantIds, getTenantId } from "../tenancy" +import { getGlobalDBName } from "../db/tenancy" import * as context from "../context" import { DEFINITIONS } from "." import { diff --git a/packages/backend-core/src/objectStore/index.ts b/packages/backend-core/src/objectStore/index.ts index 503ab9bca0..8453c9aee6 100644 --- a/packages/backend-core/src/objectStore/index.ts +++ b/packages/backend-core/src/objectStore/index.ts @@ -18,11 +18,16 @@ const STATE = { bucketCreationPromises: {}, } +type ListParams = { + ContinuationToken?: string +} + const CONTENT_TYPE_MAP: any = { html: "text/html", css: "text/css", js: "application/javascript", json: "application/json", + gz: "application/gzip", } const STRING_CONTENT_TYPES = [ CONTENT_TYPE_MAP.html, @@ -32,16 +37,16 @@ const STRING_CONTENT_TYPES = [ ] // does normal sanitization and then swaps dev apps to apps -export function sanitizeKey(input: any) { +export function sanitizeKey(input: string) { return sanitize(sanitizeBucket(input)).replace(/\\/g, "/") } // simply handles the dev app to app conversion -export function sanitizeBucket(input: any) { +export function sanitizeBucket(input: string) { return input.replace(new RegExp(APP_DEV_PREFIX, "g"), APP_PREFIX) } -function publicPolicy(bucketName: any) { +function publicPolicy(bucketName: string) { return { Version: "2012-10-17", Statement: [ @@ -57,7 +62,11 @@ function publicPolicy(bucketName: any) { } } -const PUBLIC_BUCKETS = [ObjectStoreBuckets.APPS, ObjectStoreBuckets.GLOBAL] +const PUBLIC_BUCKETS = [ + ObjectStoreBuckets.APPS, + ObjectStoreBuckets.GLOBAL, + ObjectStoreBuckets.PLUGINS, +] /** * Gets a connection to the object store using the S3 SDK. @@ -65,16 +74,14 @@ const PUBLIC_BUCKETS = [ObjectStoreBuckets.APPS, ObjectStoreBuckets.GLOBAL] * @return {Object} an S3 object store object, check S3 Nodejs SDK for usage. * @constructor */ -export const ObjectStore = (bucket: any) => { - AWS.config.update({ - accessKeyId: env.MINIO_ACCESS_KEY, - secretAccessKey: env.MINIO_SECRET_KEY, - region: env.AWS_REGION, - }) +export const ObjectStore = (bucket: string) => { const config: any = { s3ForcePathStyle: true, signatureVersion: "v4", apiVersion: "2006-03-01", + accessKeyId: env.MINIO_ACCESS_KEY, + secretAccessKey: env.MINIO_SECRET_KEY, + region: env.AWS_REGION, } if (bucket) { config.params = { @@ -91,7 +98,7 @@ export const ObjectStore = (bucket: any) => { * Given an object store and a bucket name this will make sure the bucket exists, * if it does not exist then it will create it. */ -export const makeSureBucketExists = async (client: any, bucketName: any) => { +export const makeSureBucketExists = async (client: any, bucketName: string) => { bucketName = sanitizeBucket(bucketName) try { await client @@ -143,7 +150,7 @@ export const upload = async ({ type, metadata, }: any) => { - const extension = [...filename.split(".")].pop() + const extension = filename.split(".").pop() const fileBytes = fs.readFileSync(path) const objectStore = ObjectStore(bucketName) @@ -166,14 +173,27 @@ export const upload = async ({ * through to the object store. */ export const streamUpload = async ( - bucketName: any, - filename: any, + bucketName: string, + filename: string, stream: any, extra = {} ) => { const objectStore = ObjectStore(bucketName) await makeSureBucketExists(objectStore, bucketName) + // Set content type for certain known extensions + if (filename?.endsWith(".js")) { + extra = { + ...extra, + ContentType: "application/javascript", + } + } else if (filename?.endsWith(".svg")) { + extra = { + ...extra, + ContentType: "image", + } + } + const params = { Bucket: sanitizeBucket(bucketName), Key: sanitizeKey(filename), @@ -187,7 +207,7 @@ export const streamUpload = async ( * retrieves the contents of a file from the object store, if it is a known content type it * will be converted, otherwise it will be returned as a buffer stream. */ -export const retrieve = async (bucketName: any, filepath: any) => { +export const retrieve = async (bucketName: string, filepath: string) => { const objectStore = ObjectStore(bucketName) const params = { Bucket: sanitizeBucket(bucketName), @@ -202,10 +222,38 @@ export const retrieve = async (bucketName: any, filepath: any) => { } } +export const listAllObjects = async (bucketName: string, path: string) => { + const objectStore = ObjectStore(bucketName) + const list = (params: ListParams = {}) => { + return objectStore + .listObjectsV2({ + ...params, + Bucket: sanitizeBucket(bucketName), + Prefix: sanitizeKey(path), + }) + .promise() + } + let isTruncated = false, + token, + objects: AWS.S3.Types.Object[] = [] + do { + let params: ListParams = {} + if (token) { + params.ContinuationToken = token + } + const response = await list(params) + if (response.Contents) { + objects = objects.concat(response.Contents) + } + isTruncated = !!response.IsTruncated + } while (isTruncated) + return objects +} + /** * Same as retrieval function but puts to a temporary file. */ -export const retrieveToTmp = async (bucketName: any, filepath: any) => { +export const retrieveToTmp = async (bucketName: string, filepath: string) => { bucketName = sanitizeBucket(bucketName) filepath = sanitizeKey(filepath) const data = await retrieve(bucketName, filepath) @@ -214,10 +262,31 @@ export const retrieveToTmp = async (bucketName: any, filepath: any) => { return outputPath } +export const retrieveDirectory = async (bucketName: string, path: string) => { + let writePath = join(budibaseTempDir(), v4()) + fs.mkdirSync(writePath) + const objects = await listAllObjects(bucketName, path) + let fullObjects = await Promise.all( + objects.map(obj => retrieve(bucketName, obj.Key!)) + ) + let count = 0 + for (let obj of objects) { + const filename = obj.Key! + const data = fullObjects[count++] + const possiblePath = filename.split("/") + if (possiblePath.length > 1) { + const dirs = possiblePath.slice(0, possiblePath.length - 1) + fs.mkdirSync(join(writePath, ...dirs), { recursive: true }) + } + fs.writeFileSync(join(writePath, ...possiblePath), data) + } + return writePath +} + /** * Delete a single file. */ -export const deleteFile = async (bucketName: any, filepath: any) => { +export const deleteFile = async (bucketName: string, filepath: string) => { const objectStore = ObjectStore(bucketName) await makeSureBucketExists(objectStore, bucketName) const params = { @@ -227,7 +296,7 @@ export const deleteFile = async (bucketName: any, filepath: any) => { return objectStore.deleteObject(params) } -export const deleteFiles = async (bucketName: any, filepaths: any) => { +export const deleteFiles = async (bucketName: string, filepaths: string[]) => { const objectStore = ObjectStore(bucketName) await makeSureBucketExists(objectStore, bucketName) const params = { @@ -243,8 +312,8 @@ export const deleteFiles = async (bucketName: any, filepaths: any) => { * Delete a path, including everything within. */ export const deleteFolder = async ( - bucketName: any, - folder: any + bucketName: string, + folder: string ): Promise => { bucketName = sanitizeBucket(bucketName) folder = sanitizeKey(folder) @@ -277,9 +346,9 @@ export const deleteFolder = async ( } export const uploadDirectory = async ( - bucketName: any, - localPath: any, - bucketPath: any + bucketName: string, + localPath: string, + bucketPath: string ) => { bucketName = sanitizeBucket(bucketName) let uploads = [] @@ -297,9 +366,13 @@ export const uploadDirectory = async ( return files } -exports.downloadTarballDirect = async (url: string, path: string) => { +exports.downloadTarballDirect = async ( + url: string, + path: string, + headers = {} +) => { path = sanitizeKey(path) - const response = await fetch(url) + const response = await fetch(url, { headers }) if (!response.ok) { throw new Error(`unexpected response ${response.statusText}`) } @@ -307,7 +380,11 @@ exports.downloadTarballDirect = async (url: string, path: string) => { await streamPipeline(response.body, zlib.Unzip(), tar.extract(path)) } -export const downloadTarball = async (url: any, bucketName: any, path: any) => { +export const downloadTarball = async ( + url: string, + bucketName: string, + path: string +) => { bucketName = sanitizeBucket(bucketName) path = sanitizeKey(path) const response = await fetch(url) diff --git a/packages/backend-core/src/objectStore/utils.js b/packages/backend-core/src/objectStore/utils.js index a243553df8..2d4faf55d1 100644 --- a/packages/backend-core/src/objectStore/utils.js +++ b/packages/backend-core/src/objectStore/utils.js @@ -1,15 +1,27 @@ const { join } = require("path") const { tmpdir } = require("os") +const fs = require("fs") const env = require("../environment") +/**************************************************** + * NOTE: When adding a new bucket - name * + * sure that S3 usages (like budibase-infra) * + * have been updated to have a unique bucket name. * + ****************************************************/ exports.ObjectStoreBuckets = { BACKUPS: env.BACKUPS_BUCKET_NAME, APPS: env.APPS_BUCKET_NAME, TEMPLATES: env.TEMPLATES_BUCKET_NAME, GLOBAL: env.GLOBAL_BUCKET_NAME, GLOBAL_CLOUD: env.GLOBAL_CLOUD_BUCKET_NAME, + PLUGINS: env.PLUGIN_BUCKET_NAME, +} + +const bbTmp = join(tmpdir(), ".budibase") +if (!fs.existsSync(bbTmp)) { + fs.mkdirSync(bbTmp) } exports.budibaseTempDir = function () { - return join(tmpdir(), ".budibase") + return bbTmp } diff --git a/packages/backend-core/src/pkg/context.ts b/packages/backend-core/src/pkg/context.ts index 5caa82ab0c..4915cc6e41 100644 --- a/packages/backend-core/src/pkg/context.ts +++ b/packages/backend-core/src/pkg/context.ts @@ -8,6 +8,7 @@ import { updateAppId, doInAppContext, doInTenant, + doInContext, } from "../context" import * as identity from "../context/identity" @@ -20,5 +21,6 @@ export = { updateAppId, doInAppContext, doInTenant, + doInContext, identity, } diff --git a/packages/backend-core/src/pkg/redis.ts b/packages/backend-core/src/pkg/redis.ts index 65ab186d9a..297c2b54f4 100644 --- a/packages/backend-core/src/pkg/redis.ts +++ b/packages/backend-core/src/pkg/redis.ts @@ -3,9 +3,11 @@ import Client from "../redis" import utils from "../redis/utils" import clients from "../redis/init" +import * as redlock from "../redis/redlock" export = { Client, utils, clients, + redlock, } diff --git a/packages/backend-core/src/plugin/index.ts b/packages/backend-core/src/plugin/index.ts new file mode 100644 index 0000000000..a6d1853007 --- /dev/null +++ b/packages/backend-core/src/plugin/index.ts @@ -0,0 +1,7 @@ +import * as utils from "./utils" + +const pkg = { + ...utils, +} + +export = pkg diff --git a/packages/backend-core/src/plugin/utils.js b/packages/backend-core/src/plugin/utils.js new file mode 100644 index 0000000000..60a40f3a76 --- /dev/null +++ b/packages/backend-core/src/plugin/utils.js @@ -0,0 +1,99 @@ +const { + DatasourceFieldType, + QueryType, + PluginType, +} = require("@budibase/types") +const joi = require("joi") + +const DATASOURCE_TYPES = [ + "Relational", + "Non-relational", + "Spreadsheet", + "Object store", + "Graph", + "API", +] + +function runJoi(validator, schema) { + const { error } = validator.validate(schema) + if (error) { + throw error + } +} + +function validateComponent(schema) { + const validator = joi.object({ + type: joi.string().allow("component").required(), + metadata: joi.object().unknown(true).required(), + hash: joi.string().optional(), + version: joi.string().optional(), + schema: joi + .object({ + name: joi.string().required(), + settings: joi.array().items(joi.object().unknown(true)).required(), + }) + .unknown(true), + }) + runJoi(validator, schema) +} + +function validateDatasource(schema) { + const fieldValidator = joi.object({ + type: joi + .string() + .allow(...Object.values(DatasourceFieldType)) + .required(), + required: joi.boolean().required(), + default: joi.any(), + display: joi.string(), + }) + + const queryValidator = joi + .object({ + type: joi.string().allow(...Object.values(QueryType)), + fields: joi.object().pattern(joi.string(), fieldValidator), + }) + .required() + + const validator = joi.object({ + type: joi.string().allow("datasource").required(), + metadata: joi.object().unknown(true).required(), + hash: joi.string().optional(), + version: joi.string().optional(), + schema: joi.object({ + docs: joi.string(), + friendlyName: joi.string().required(), + type: joi.string().allow(...DATASOURCE_TYPES), + description: joi.string().required(), + datasource: joi.object().pattern(joi.string(), fieldValidator).required(), + query: joi + .object() + .pattern(joi.string(), queryValidator) + .unknown(true) + .required(), + extra: joi.object().pattern( + joi.string(), + joi.object({ + type: joi.string().required(), + displayName: joi.string().required(), + required: joi.boolean(), + data: joi.object(), + }) + ), + }), + }) + runJoi(validator, schema) +} + +exports.validate = schema => { + switch (schema?.type) { + case PluginType.COMPONENT: + validateComponent(schema) + break + case PluginType.DATASOURCE: + validateDatasource(schema) + break + default: + throw new Error(`Unknown plugin type - check schema.json: ${schema.type}`) + } +} diff --git a/packages/backend-core/src/queue/constants.ts b/packages/backend-core/src/queue/constants.ts new file mode 100644 index 0000000000..e8323dacb8 --- /dev/null +++ b/packages/backend-core/src/queue/constants.ts @@ -0,0 +1,4 @@ +export enum JobQueue { + AUTOMATION = "automationQueue", + APP_BACKUP = "appBackupQueue", +} diff --git a/packages/backend-core/src/queue/inMemoryQueue.ts b/packages/backend-core/src/queue/inMemoryQueue.ts new file mode 100644 index 0000000000..80ee7362e4 --- /dev/null +++ b/packages/backend-core/src/queue/inMemoryQueue.ts @@ -0,0 +1,127 @@ +import events from "events" + +/** + * Bull works with a Job wrapper around all messages that contains a lot more information about + * the state of the message, this object constructor implements the same schema of Bull jobs + * for the sake of maintaining API consistency. + * @param {string} queue The name of the queue which the message will be carried on. + * @param {object} message The JSON message which will be passed back to the consumer. + * @returns {Object} A new job which can now be put onto the queue, this is mostly an + * internal structure so that an in memory queue can be easily swapped for a Bull queue. + */ +function newJob(queue: string, message: any) { + return { + timestamp: Date.now(), + queue: queue, + data: message, + } +} + +/** + * This is designed to replicate Bull (https://github.com/OptimalBits/bull) in memory as a sort of mock. + * It is relatively simple, using an event emitter internally to register when messages are available + * to the consumers - in can support many inputs and many consumers. + */ +class InMemoryQueue { + _name: string + _opts?: any + _messages: any[] + _emitter: EventEmitter + /** + * The constructor the queue, exactly the same as that of Bulls. + * @param {string} name The name of the queue which is being configured. + * @param {object|null} opts This is not used by the in memory queue as there is no real use + * case when in memory, but is the same API as Bull + */ + constructor(name: string, opts = null) { + this._name = name + this._opts = opts + this._messages = [] + this._emitter = new events.EventEmitter() + } + + /** + * Same callback API as Bull, each callback passed to this will consume messages as they are + * available. Please note this is a queue service, not a notification service, so each + * consumer will receive different messages. + * @param {function} func The callback function which will return a "Job", the same + * as the Bull API, within this job the property "data" contains the JSON message. Please + * note this is incredibly limited compared to Bull as in reality the Job would contain + * a lot more information about the queue and current status of Bull cluster. + */ + process(func: any) { + this._emitter.on("message", async () => { + if (this._messages.length <= 0) { + return + } + let msg = this._messages.shift() + let resp = func(msg) + if (resp.then != null) { + await resp + } + }) + } + + // simply puts a message to the queue and emits to the queue for processing + /** + * Simple function to replicate the add message functionality of Bull, putting + * a new message on the queue. This then emits an event which will be used to + * return the message to a consumer (if one is attached). + * @param {object} msg A message to be transported over the queue, this should be + * a JSON message as this is required by Bull. + * @param {boolean} repeat serves no purpose for the import queue. + */ + // eslint-disable-next-line no-unused-vars + add(msg: any, repeat: boolean) { + if (typeof msg !== "object") { + throw "Queue only supports carrying JSON." + } + this._messages.push(newJob(this._name, msg)) + this._emitter.emit("message") + } + + /** + * replicating the close function from bull, which waits for jobs to finish. + */ + async close() { + return [] + } + + /** + * This removes a cron which has been implemented, this is part of Bull API. + * @param {string} cronJobId The cron which is to be removed. + */ + removeRepeatableByKey(cronJobId: string) { + // TODO: implement for testing + console.log(cronJobId) + } + + /** + * Implemented for tests + */ + getRepeatableJobs() { + return [] + } + + // eslint-disable-next-line no-unused-vars + removeJobs(pattern: string) { + // no-op + } + + /** + * Implemented for tests + */ + async clean() { + return [] + } + + async getJob() { + return {} + } + + on() { + // do nothing + } +} + +export = InMemoryQueue diff --git a/packages/backend-core/src/queue/index.ts b/packages/backend-core/src/queue/index.ts new file mode 100644 index 0000000000..b7d565ba13 --- /dev/null +++ b/packages/backend-core/src/queue/index.ts @@ -0,0 +1,2 @@ +export * from "./queue" +export * from "./constants" diff --git a/packages/backend-core/src/queue/listeners.ts b/packages/backend-core/src/queue/listeners.ts new file mode 100644 index 0000000000..e1975b5d06 --- /dev/null +++ b/packages/backend-core/src/queue/listeners.ts @@ -0,0 +1,101 @@ +import { Job, JobId, Queue } from "bull" +import { JobQueue } from "./constants" + +export type StalledFn = (job: Job) => Promise + +export function addListeners( + queue: Queue, + jobQueue: JobQueue, + removeStalledCb?: StalledFn +) { + logging(queue, jobQueue) + if (removeStalledCb) { + handleStalled(queue, removeStalledCb) + } +} + +function handleStalled(queue: Queue, removeStalledCb?: StalledFn) { + queue.on("stalled", async (job: Job) => { + if (removeStalledCb) { + await removeStalledCb(job) + } else if (job.opts.repeat) { + const jobId = job.id + const repeatJobs = await queue.getRepeatableJobs() + for (let repeatJob of repeatJobs) { + if (repeatJob.id === jobId) { + await queue.removeRepeatableByKey(repeatJob.key) + } + } + console.log(`jobId=${jobId} disabled`) + } + }) +} + +function logging(queue: Queue, jobQueue: JobQueue) { + let eventType: string + switch (jobQueue) { + case JobQueue.AUTOMATION: + eventType = "automation-event" + break + case JobQueue.APP_BACKUP: + eventType = "app-backup-event" + break + } + if (process.env.NODE_DEBUG?.includes("bull")) { + queue + .on("error", (error: any) => { + // An error occurred. + console.error(`${eventType}=error error=${JSON.stringify(error)}`) + }) + .on("waiting", (jobId: JobId) => { + // A Job is waiting to be processed as soon as a worker is idling. + console.log(`${eventType}=waiting jobId=${jobId}`) + }) + .on("active", (job: Job, jobPromise: any) => { + // A job has started. You can use `jobPromise.cancel()`` to abort it. + console.log(`${eventType}=active jobId=${job.id}`) + }) + .on("stalled", (job: Job) => { + // A job has been marked as stalled. This is useful for debugging job + // workers that crash or pause the event loop. + console.error( + `${eventType}=stalled jobId=${job.id} job=${JSON.stringify(job)}` + ) + }) + .on("progress", (job: Job, progress: any) => { + // A job's progress was updated! + console.log( + `${eventType}=progress jobId=${job.id} progress=${progress}` + ) + }) + .on("completed", (job: Job, result) => { + // A job successfully completed with a `result`. + console.log(`${eventType}=completed jobId=${job.id} result=${result}`) + }) + .on("failed", (job, err: any) => { + // A job failed with reason `err`! + console.log(`${eventType}=failed jobId=${job.id} error=${err}`) + }) + .on("paused", () => { + // The queue has been paused. + console.log(`${eventType}=paused`) + }) + .on("resumed", (job: Job) => { + // The queue has been resumed. + console.log(`${eventType}=paused jobId=${job.id}`) + }) + .on("cleaned", (jobs: Job[], type: string) => { + // Old jobs have been cleaned from the queue. `jobs` is an array of cleaned + // jobs, and `type` is the type of jobs cleaned. + console.log(`${eventType}=cleaned length=${jobs.length} type=${type}`) + }) + .on("drained", () => { + // Emitted every time the queue has processed all the waiting jobs (even if there can be some delayed jobs not yet processed) + console.log(`${eventType}=drained`) + }) + .on("removed", (job: Job) => { + // A job successfully removed. + console.log(`${eventType}=removed jobId=${job.id}`) + }) + } +} diff --git a/packages/backend-core/src/queue/queue.ts b/packages/backend-core/src/queue/queue.ts new file mode 100644 index 0000000000..b4eeeb31aa --- /dev/null +++ b/packages/backend-core/src/queue/queue.ts @@ -0,0 +1,51 @@ +import env from "../environment" +import { getRedisOptions } from "../redis/utils" +import { JobQueue } from "./constants" +import InMemoryQueue from "./inMemoryQueue" +import BullQueue from "bull" +import { addListeners, StalledFn } from "./listeners" +const { opts: redisOpts, redisProtocolUrl } = getRedisOptions() + +const CLEANUP_PERIOD_MS = 60 * 1000 +let QUEUES: BullQueue.Queue[] | InMemoryQueue[] = [] +let cleanupInterval: NodeJS.Timeout + +async function cleanup() { + for (let queue of QUEUES) { + await queue.clean(CLEANUP_PERIOD_MS, "completed") + } +} + +export function createQueue( + jobQueue: JobQueue, + opts: { removeStalledCb?: StalledFn } = {} +): BullQueue.Queue { + const queueConfig: any = redisProtocolUrl || { redis: redisOpts } + let queue: any + if (!env.isTest()) { + queue = new BullQueue(jobQueue, queueConfig) + } else { + queue = new InMemoryQueue(jobQueue, queueConfig) + } + addListeners(queue, jobQueue, opts?.removeStalledCb) + QUEUES.push(queue) + if (!cleanupInterval) { + cleanupInterval = setInterval(cleanup, CLEANUP_PERIOD_MS) + // fire off an initial cleanup + cleanup().catch(err => { + console.error(`Unable to cleanup automation queue initially - ${err}`) + }) + } + return queue +} + +exports.shutdown = async () => { + if (QUEUES.length) { + clearInterval(cleanupInterval) + for (let queue of QUEUES) { + await queue.close() + } + QUEUES = [] + } + console.log("Queues shutdown") +} diff --git a/packages/backend-core/src/redis/index.ts b/packages/backend-core/src/redis/index.ts index 206110366f..8a15320ff3 100644 --- a/packages/backend-core/src/redis/index.ts +++ b/packages/backend-core/src/redis/index.ts @@ -214,6 +214,34 @@ export = class RedisWrapper { } } + async bulkGet(keys: string[]) { + const db = this._db + if (keys.length === 0) { + return {} + } + const prefixedKeys = keys.map(key => addDbPrefix(db, key)) + let response = await this.getClient().mget(prefixedKeys) + if (Array.isArray(response)) { + let final: any = {} + let count = 0 + for (let result of response) { + if (result) { + let parsed + try { + parsed = JSON.parse(result) + } catch (err) { + parsed = result + } + final[keys[count]] = parsed + } + count++ + } + return final + } else { + throw new Error(`Invalid response: ${response}`) + } + } + async store(key: string, value: any, expirySeconds: number | null = null) { const db = this._db if (typeof value === "object") { diff --git a/packages/backend-core/src/redis/init.js b/packages/backend-core/src/redis/init.js index 8e5d10f838..3150ef2c1c 100644 --- a/packages/backend-core/src/redis/init.js +++ b/packages/backend-core/src/redis/init.js @@ -1,27 +1,23 @@ const Client = require("./index") const utils = require("./utils") -const { getRedlock } = require("./redlock") -let userClient, sessionClient, appClient, cacheClient, writethroughClient -let migrationsRedlock - -// turn retry off so that only one instance can ever hold the lock -const migrationsRedlockConfig = { retryCount: 0 } +let userClient, + sessionClient, + appClient, + cacheClient, + writethroughClient, + lockClient async function init() { userClient = await new Client(utils.Databases.USER_CACHE).init() sessionClient = await new Client(utils.Databases.SESSIONS).init() appClient = await new Client(utils.Databases.APP_METADATA).init() cacheClient = await new Client(utils.Databases.GENERIC_CACHE).init() + lockClient = await new Client(utils.Databases.LOCKS).init() writethroughClient = await new Client( utils.Databases.WRITE_THROUGH, utils.SelectableDatabases.WRITE_THROUGH ).init() - // pass the underlying ioredis client to redlock - migrationsRedlock = getRedlock( - cacheClient.getClient(), - migrationsRedlockConfig - ) } process.on("exit", async () => { @@ -30,6 +26,7 @@ process.on("exit", async () => { if (appClient) await appClient.finish() if (cacheClient) await cacheClient.finish() if (writethroughClient) await writethroughClient.finish() + if (lockClient) await lockClient.finish() }) module.exports = { @@ -63,10 +60,10 @@ module.exports = { } return writethroughClient }, - getMigrationsRedlock: async () => { - if (!migrationsRedlock) { + getLockClient: async () => { + if (!lockClient) { await init() } - return migrationsRedlock + return lockClient }, } diff --git a/packages/backend-core/src/redis/redlock.ts b/packages/backend-core/src/redis/redlock.ts index beef375b55..586302c9b1 100644 --- a/packages/backend-core/src/redis/redlock.ts +++ b/packages/backend-core/src/redis/redlock.ts @@ -1,14 +1,37 @@ -import Redlock from "redlock" +import Redlock, { Options } from "redlock" +import { getLockClient } from "./init" +import { LockOptions, LockType } from "@budibase/types" +import * as tenancy from "../tenancy" -export const getRedlock = (redisClient: any, opts = { retryCount: 10 }) => { - return new Redlock([redisClient], { +let noRetryRedlock: Redlock | undefined + +const getClient = async (type: LockType): Promise => { + switch (type) { + case LockType.TRY_ONCE: { + if (!noRetryRedlock) { + noRetryRedlock = await newRedlock(OPTIONS.TRY_ONCE) + } + return noRetryRedlock + } + default: { + throw new Error(`Could not get redlock client: ${type}`) + } + } +} + +export const OPTIONS = { + TRY_ONCE: { + // immediately throws an error if the lock is already held + retryCount: 0, + }, + DEFAULT: { // the expected clock drift; for more details // see http://redis.io/topics/distlock driftFactor: 0.01, // multiplied by lock ttl to determine drift time // the max number of times Redlock will attempt // to lock a resource before erroring - retryCount: opts.retryCount, + retryCount: 10, // the time in ms between attempts retryDelay: 200, // time in ms @@ -16,6 +39,50 @@ export const getRedlock = (redisClient: any, opts = { retryCount: 10 }) => { // the max time in ms randomly added to retries // to improve performance under high contention // see https://www.awsarchitectureblog.com/2015/03/backoff.html - retryJitter: 200, // time in ms - }) + retryJitter: 100, // time in ms + }, +} + +export const newRedlock = async (opts: Options = {}) => { + let options = { ...OPTIONS.DEFAULT, ...opts } + const redisWrapper = await getLockClient() + const client = redisWrapper.getClient() + return new Redlock([client], options) +} + +export const doWithLock = async (opts: LockOptions, task: any) => { + const redlock = await getClient(opts.type) + let lock + try { + // aquire lock + let name: string + if (opts.systemLock) { + name = opts.name + } else { + name = `${tenancy.getTenantId()}_${opts.name}` + } + if (opts.nameSuffix) { + name = name + `_${opts.nameSuffix}` + } + lock = await redlock.lock(name, opts.ttl) + // perform locked task + return task() + } catch (e: any) { + // lock limit exceeded + if (e.name === "LockError") { + if (opts.type === LockType.TRY_ONCE) { + // don't throw for try-once locks, they will always error + // due to retry count (0) exceeded + return + } else { + throw e + } + } else { + throw e + } + } finally { + if (lock) { + await lock.unlock() + } + } } diff --git a/packages/backend-core/src/redis/utils.js b/packages/backend-core/src/redis/utils.js index 90b3561f31..af719197b5 100644 --- a/packages/backend-core/src/redis/utils.js +++ b/packages/backend-core/src/redis/utils.js @@ -28,6 +28,7 @@ exports.Databases = { LICENSES: "license", GENERIC_CACHE: "data_cache", WRITE_THROUGH: "writeThrough", + LOCKS: "locks", } /** diff --git a/packages/backend-core/src/security/roles.js b/packages/backend-core/src/security/roles.js index 983aebf676..33c9123b63 100644 --- a/packages/backend-core/src/security/roles.js +++ b/packages/backend-core/src/security/roles.js @@ -78,7 +78,7 @@ function isBuiltin(role) { */ exports.builtinRoleToNumber = id => { const builtins = exports.getBuiltinRoles() - const MAX = Object.values(BUILTIN_IDS).length + 1 + const MAX = Object.values(builtins).length + 1 if (id === BUILTIN_IDS.ADMIN || id === BUILTIN_IDS.BUILDER) { return MAX } @@ -94,6 +94,22 @@ exports.builtinRoleToNumber = id => { return count } +/** + * Converts any role to a number, but has to be async to get the roles from db. + */ +exports.roleToNumber = async id => { + if (exports.isBuiltin(id)) { + return exports.builtinRoleToNumber(id) + } + const hierarchy = await exports.getUserRoleHierarchy(id) + for (let role of hierarchy) { + if (isBuiltin(role.inherits)) { + return exports.builtinRoleToNumber(role.inherits) + 1 + } + } + return 0 +} + /** * Returns whichever builtin roleID is lower. */ @@ -172,7 +188,7 @@ async function getAllUserRoles(userRoleId) { * to determine if a user can access something that requires a specific role. * @param {string} userRoleId The user's role ID, this can be found in their access token. * @param {object} opts Various options, such as whether to only retrieve the IDs (default true). - * @returns {Promise} returns an ordered array of the roles, with the first being their + * @returns {Promise} returns an ordered array of the roles, with the first being their * highest level of access and the last being the lowest level. */ exports.getUserRoleHierarchy = async (userRoleId, opts = { idOnly: true }) => { diff --git a/packages/backend-core/src/security/sessions.ts b/packages/backend-core/src/security/sessions.ts index 284adbcd1f..33230afc60 100644 --- a/packages/backend-core/src/security/sessions.ts +++ b/packages/backend-core/src/security/sessions.ts @@ -2,18 +2,12 @@ const redis = require("../redis/init") const { v4: uuidv4 } = require("uuid") const { logWarn } = require("../logging") const env = require("../environment") - -interface Session { - key: string - userId: string - sessionId: string - lastAccessedAt: string - createdAt: string - csrfToken?: string - value: string -} - -type SessionKey = { key: string }[] +import { + Session, + ScannedSession, + SessionKey, + CreateSession, +} from "@budibase/types" // a week in seconds const EXPIRY_SECONDS = 86400 * 7 @@ -22,14 +16,14 @@ function makeSessionID(userId: string, sessionId: string) { return `${userId}/${sessionId}` } -export async function getSessionsForUser(userId: string) { +export async function getSessionsForUser(userId: string): Promise { if (!userId) { console.trace("Cannot get sessions for undefined userId") return [] } const client = await redis.getSessionClient() - const sessions = await client.scan(userId) - return sessions.map((session: Session) => session.value) + const sessions: ScannedSession[] = await client.scan(userId) + return sessions.map(session => session.value) } export async function invalidateSessions( @@ -39,33 +33,32 @@ export async function invalidateSessions( try { const reason = opts?.reason || "unknown" let sessionIds: string[] = opts.sessionIds || [] - let sessions: SessionKey + let sessionKeys: SessionKey[] // If no sessionIds, get all the sessions for the user if (sessionIds.length === 0) { - sessions = await getSessionsForUser(userId) - sessions.forEach( - (session: any) => - (session.key = makeSessionID(session.userId, session.sessionId)) - ) + const sessions = await getSessionsForUser(userId) + sessionKeys = sessions.map(session => ({ + key: makeSessionID(session.userId, session.sessionId), + })) } else { // use the passed array of sessionIds sessionIds = Array.isArray(sessionIds) ? sessionIds : [sessionIds] - sessions = sessionIds.map((sessionId: string) => ({ + sessionKeys = sessionIds.map(sessionId => ({ key: makeSessionID(userId, sessionId), })) } - if (sessions && sessions.length > 0) { + if (sessionKeys && sessionKeys.length > 0) { const client = await redis.getSessionClient() const promises = [] - for (let session of sessions) { - promises.push(client.delete(session.key)) + for (let sessionKey of sessionKeys) { + promises.push(client.delete(sessionKey.key)) } if (!env.isTest()) { logWarn( - `Invalidating sessions for ${userId} (reason: ${reason}) - ${sessions - .map(session => session.key) + `Invalidating sessions for ${userId} (reason: ${reason}) - ${sessionKeys + .map(sessionKey => sessionKey.key) .join(", ")}` ) } @@ -76,22 +69,26 @@ export async function invalidateSessions( } } -export async function createASession(userId: string, session: Session) { +export async function createASession( + userId: string, + createSession: CreateSession +) { // invalidate all other sessions await invalidateSessions(userId, { reason: "creation" }) const client = await redis.getSessionClient() - const sessionId = session.sessionId - if (!session.csrfToken) { - session.csrfToken = uuidv4() - } - session = { - ...session, + const sessionId = createSession.sessionId + const csrfToken = createSession.csrfToken ? createSession.csrfToken : uuidv4() + const key = makeSessionID(userId, sessionId) + + const session: Session = { + ...createSession, + csrfToken, createdAt: new Date().toISOString(), lastAccessedAt: new Date().toISOString(), userId, } - await client.store(makeSessionID(userId, sessionId), session, EXPIRY_SECONDS) + await client.store(key, session, EXPIRY_SECONDS) } export async function updateSessionTTL(session: Session) { @@ -106,7 +103,10 @@ export async function endSession(userId: string, sessionId: string) { await client.delete(makeSessionID(userId, sessionId)) } -export async function getSession(userId: string, sessionId: string) { +export async function getSession( + userId: string, + sessionId: string +): Promise { if (!userId || !sessionId) { throw new Error(`Invalid session details - ${userId} - ${sessionId}`) } diff --git a/packages/backend-core/src/tenancy/tenancy.ts b/packages/backend-core/src/tenancy/tenancy.ts index 041f694d34..ad5c6b5287 100644 --- a/packages/backend-core/src/tenancy/tenancy.ts +++ b/packages/backend-core/src/tenancy/tenancy.ts @@ -1,6 +1,7 @@ import { doWithDB } from "../db" -import { StaticDatabases } from "../db/constants" -import { baseGlobalDBName } from "./utils" +import { queryPlatformView } from "../db/views" +import { StaticDatabases, ViewName } from "../db/constants" +import { getGlobalDBName } from "../db/tenancy" import { getTenantId, DEFAULT_TENANT_ID, @@ -8,6 +9,7 @@ import { getTenantIDFromAppID, } from "../context" import env from "../environment" +import { PlatformUser } from "@budibase/types" const TENANT_DOC = StaticDatabases.PLATFORM_INFO.docs.tenants const PLATFORM_INFO_DB = StaticDatabases.PLATFORM_INFO.name @@ -87,15 +89,6 @@ export const tryAddTenant = async ( }) } -export const getGlobalDBName = (tenantId?: string) => { - // tenant ID can be set externally, for example user API where - // new tenants are being created, this may be the case - if (!tenantId) { - tenantId = getTenantId() - } - return baseGlobalDBName(tenantId) -} - export const doWithGlobalDB = (tenantId: string, cb: any) => { return doWithDB(getGlobalDBName(tenantId), cb) } @@ -116,17 +109,19 @@ export const lookupTenantId = async (userId: string) => { } // lookup, could be email or userId, either will return a doc -export const getTenantUser = async (identifier: string) => { - return doWithDB(PLATFORM_INFO_DB, async (db: any) => { - try { - return await db.get(identifier) - } catch (err) { - return null - } - }) +export const getTenantUser = async ( + identifier: string +): Promise => { + // use the view here and allow to find anyone regardless of casing + // Use lowercase to ensure email login is case insensitive + const response = queryPlatformView(ViewName.PLATFORM_USERS_LOWERCASE, { + keys: [identifier.toLowerCase()], + include_docs: true, + }) as Promise + return response } -export const isUserInAppTenant = (appId: string, user: any) => { +export const isUserInAppTenant = (appId: string, user?: any) => { let userTenantId if (user) { userTenantId = user.tenantId || DEFAULT_TENANT_ID diff --git a/packages/backend-core/src/tenancy/utils.js b/packages/backend-core/src/tenancy/utils.js deleted file mode 100644 index 70a965ddb7..0000000000 --- a/packages/backend-core/src/tenancy/utils.js +++ /dev/null @@ -1,12 +0,0 @@ -const { DEFAULT_TENANT_ID } = require("../constants") -const { StaticDatabases, SEPARATOR } = require("../db/constants") - -exports.baseGlobalDBName = tenantId => { - let dbName - if (!tenantId || tenantId === DEFAULT_TENANT_ID) { - dbName = StaticDatabases.GLOBAL.name - } else { - dbName = `${tenantId}${SEPARATOR}${StaticDatabases.GLOBAL.name}` - } - return dbName -} diff --git a/packages/backend-core/src/users.js b/packages/backend-core/src/users.js deleted file mode 100644 index de5ce238c1..0000000000 --- a/packages/backend-core/src/users.js +++ /dev/null @@ -1,68 +0,0 @@ -const { - ViewName, - getUsersByAppParams, - getProdAppID, - generateAppUserID, -} = require("./db/utils") -const { queryGlobalView } = require("./db/views") -const { UNICODE_MAX } = require("./db/constants") - -/** - * Given an email address this will use a view to search through - * all the users to find one with this email address. - * @param {string} email the email to lookup the user by. - * @return {Promise} - */ -exports.getGlobalUserByEmail = async email => { - if (email == null) { - throw "Must supply an email address to view" - } - - return await queryGlobalView(ViewName.USER_BY_EMAIL, { - key: email.toLowerCase(), - include_docs: true, - }) -} - -exports.searchGlobalUsersByApp = async (appId, opts) => { - if (typeof appId !== "string") { - throw new Error("Must provide a string based app ID") - } - const params = getUsersByAppParams(appId, { - include_docs: true, - }) - params.startkey = opts && opts.startkey ? opts.startkey : params.startkey - let response = await queryGlobalView(ViewName.USER_BY_APP, params) - if (!response) { - response = [] - } - return Array.isArray(response) ? response : [response] -} - -exports.getGlobalUserByAppPage = (appId, user) => { - if (!user) { - return - } - return generateAppUserID(getProdAppID(appId), user._id) -} - -/** - * Performs a starts with search on the global email view. - */ -exports.searchGlobalUsersByEmail = async (email, opts) => { - if (typeof email !== "string") { - throw new Error("Must provide a string to search by") - } - const lcEmail = email.toLowerCase() - // handle if passing up startkey for pagination - const startkey = opts && opts.startkey ? opts.startkey : lcEmail - let response = await queryGlobalView(ViewName.USER_BY_EMAIL, { - ...opts, - startkey, - endkey: `${lcEmail}${UNICODE_MAX}`, - }) - if (!response) { - response = [] - } - return Array.isArray(response) ? response : [response] -} diff --git a/packages/backend-core/src/users.ts b/packages/backend-core/src/users.ts new file mode 100644 index 0000000000..44f04749c9 --- /dev/null +++ b/packages/backend-core/src/users.ts @@ -0,0 +1,94 @@ +import { + ViewName, + getUsersByAppParams, + getProdAppID, + generateAppUserID, +} from "./db/utils" +import { queryGlobalView } from "./db/views" +import { UNICODE_MAX } from "./db/constants" +import { BulkDocsResponse, User } from "@budibase/types" +import { getGlobalDB } from "./context" +import PouchDB from "pouchdb" + +export const bulkGetGlobalUsersById = async (userIds: string[]) => { + const db = getGlobalDB() as PouchDB.Database + return ( + await db.allDocs({ + keys: userIds, + include_docs: true, + }) + ).rows.map(row => row.doc) as User[] +} + +export const bulkUpdateGlobalUsers = async (users: User[]) => { + const db = getGlobalDB() as PouchDB.Database + return (await db.bulkDocs(users)) as BulkDocsResponse +} + +/** + * Given an email address this will use a view to search through + * all the users to find one with this email address. + * @param {string} email the email to lookup the user by. + */ +export const getGlobalUserByEmail = async ( + email: String +): Promise => { + if (email == null) { + throw "Must supply an email address to view" + } + + const response = await queryGlobalView(ViewName.USER_BY_EMAIL, { + key: email.toLowerCase(), + include_docs: true, + }) + + if (Array.isArray(response)) { + // shouldn't be able to happen, but need to handle just in case + throw new Error(`Multiple users found with email address: ${email}`) + } + + return response +} + +export const searchGlobalUsersByApp = async (appId: any, opts: any) => { + if (typeof appId !== "string") { + throw new Error("Must provide a string based app ID") + } + const params = getUsersByAppParams(appId, { + include_docs: true, + }) + params.startkey = opts && opts.startkey ? opts.startkey : params.startkey + let response = await queryGlobalView(ViewName.USER_BY_APP, params) + if (!response) { + response = [] + } + return Array.isArray(response) ? response : [response] +} + +export const getGlobalUserByAppPage = (appId: string, user: User) => { + if (!user) { + return + } + return generateAppUserID(getProdAppID(appId), user._id!) +} + +/** + * Performs a starts with search on the global email view. + */ +export const searchGlobalUsersByEmail = async (email: string, opts: any) => { + if (typeof email !== "string") { + throw new Error("Must provide a string to search by") + } + const lcEmail = email.toLowerCase() + // handle if passing up startkey for pagination + const startkey = opts && opts.startkey ? opts.startkey : lcEmail + let response = await queryGlobalView(ViewName.USER_BY_EMAIL, { + ...opts, + startkey, + endkey: `${lcEmail}${UNICODE_MAX}`, + }) + if (!response) { + response = [] + } + return Array.isArray(response) ? response : [response] +} diff --git a/packages/backend-core/src/utils.js b/packages/backend-core/src/utils.js index 0587267e9a..6b59c7cb72 100644 --- a/packages/backend-core/src/utils.js +++ b/packages/backend-core/src/utils.js @@ -42,6 +42,18 @@ async function resolveAppUrl(ctx) { return app && app.appId ? app.appId : undefined } +exports.isServingApp = ctx => { + // dev app + if (ctx.path.startsWith(`/${APP_PREFIX}`)) { + return true + } + // prod app + if (ctx.path.startsWith(PROD_APP_PREFIX)) { + return true + } + return false +} + /** * Given a request tries to find the appId, which can be located in various places * @param {object} ctx The main request body to look through. diff --git a/packages/backend-core/tests/utilities/mocks/accounts.ts b/packages/backend-core/tests/utilities/mocks/accounts.ts new file mode 100644 index 0000000000..79436443db --- /dev/null +++ b/packages/backend-core/tests/utilities/mocks/accounts.ts @@ -0,0 +1,7 @@ +export const getAccount = jest.fn() +export const getAccountByTenantId = jest.fn() + +jest.mock("../../../src/cloud/accounts", () => ({ + getAccount, + getAccountByTenantId, +})) diff --git a/packages/backend-core/tests/utilities/mocks/date.js b/packages/backend-core/tests/utilities/mocks/date.js deleted file mode 100644 index 19248c6f11..0000000000 --- a/packages/backend-core/tests/utilities/mocks/date.js +++ /dev/null @@ -1,2 +0,0 @@ -exports.MOCK_DATE = new Date("2020-01-01T00:00:00.000Z") -exports.MOCK_DATE_TIMESTAMP = 1577836800000 diff --git a/packages/backend-core/tests/utilities/mocks/date.ts b/packages/backend-core/tests/utilities/mocks/date.ts new file mode 100644 index 0000000000..f580b68349 --- /dev/null +++ b/packages/backend-core/tests/utilities/mocks/date.ts @@ -0,0 +1,2 @@ +export const MOCK_DATE = new Date("2020-01-01T00:00:00.000Z") +export const MOCK_DATE_TIMESTAMP = 1577836800000 diff --git a/packages/backend-core/tests/utilities/mocks/events.js b/packages/backend-core/tests/utilities/mocks/events.ts similarity index 100% rename from packages/backend-core/tests/utilities/mocks/events.js rename to packages/backend-core/tests/utilities/mocks/events.ts diff --git a/packages/backend-core/tests/utilities/mocks/index.js b/packages/backend-core/tests/utilities/mocks/index.js deleted file mode 100644 index 6aa1c4a54f..0000000000 --- a/packages/backend-core/tests/utilities/mocks/index.js +++ /dev/null @@ -1,9 +0,0 @@ -const posthog = require("./posthog") -const events = require("./events") -const date = require("./date") - -module.exports = { - posthog, - date, - events, -} diff --git a/packages/backend-core/tests/utilities/mocks/index.ts b/packages/backend-core/tests/utilities/mocks/index.ts new file mode 100644 index 0000000000..7031b225ec --- /dev/null +++ b/packages/backend-core/tests/utilities/mocks/index.ts @@ -0,0 +1,4 @@ +import "./posthog" +import "./events" +export * as accounts from "./accounts" +export * as date from "./date" diff --git a/packages/backend-core/tests/utilities/structures/accounts.ts b/packages/backend-core/tests/utilities/structures/accounts.ts new file mode 100644 index 0000000000..5d23962575 --- /dev/null +++ b/packages/backend-core/tests/utilities/structures/accounts.ts @@ -0,0 +1,23 @@ +import { generator, uuid } from "." +import { AuthType, CloudAccount, Hosting } from "@budibase/types" +import * as db from "../../../src/db/utils" + +export const cloudAccount = (): CloudAccount => { + return { + accountId: uuid(), + createdAt: Date.now(), + verified: true, + verificationSent: true, + tier: "", + email: generator.email(), + tenantId: generator.word(), + hosting: Hosting.CLOUD, + authType: AuthType.PASSWORD, + password: generator.word(), + tenantName: generator.word(), + name: generator.name(), + size: "10+", + profession: "Software Engineer", + budibaseUserId: db.generateGlobalUserID(), + } +} diff --git a/packages/backend-core/tests/utilities/structures/common.ts b/packages/backend-core/tests/utilities/structures/common.ts new file mode 100644 index 0000000000..51ae220254 --- /dev/null +++ b/packages/backend-core/tests/utilities/structures/common.ts @@ -0,0 +1 @@ +export { v4 as uuid } from "uuid" diff --git a/packages/backend-core/tests/utilities/structures/index.ts b/packages/backend-core/tests/utilities/structures/index.ts index 12b6ab7ad6..68064b9715 100644 --- a/packages/backend-core/tests/utilities/structures/index.ts +++ b/packages/backend-core/tests/utilities/structures/index.ts @@ -1 +1,8 @@ +export * from "./common" + +import Chance from "chance" +export const generator = new Chance() + export * as koa from "./koa" +export * as accounts from "./accounts" +export * as licenses from "./licenses" diff --git a/packages/backend-core/tests/utilities/structures/licenses.ts b/packages/backend-core/tests/utilities/structures/licenses.ts new file mode 100644 index 0000000000..a541e91860 --- /dev/null +++ b/packages/backend-core/tests/utilities/structures/licenses.ts @@ -0,0 +1,18 @@ +import { AccountPlan, License, PlanType, Quotas } from "@budibase/types" + +const newPlan = (type: PlanType = PlanType.FREE): AccountPlan => { + return { + type, + } +} + +export const newLicense = (opts: { + quotas: Quotas + planType?: PlanType +}): License => { + return { + features: [], + quotas: opts.quotas, + plan: newPlan(opts.planType), + } +} diff --git a/packages/backend-core/yarn.lock b/packages/backend-core/yarn.lock index 9f71691f44..d301526ba1 100644 --- a/packages/backend-core/yarn.lock +++ b/packages/backend-core/yarn.lock @@ -543,13 +543,43 @@ semver "^7.3.5" tar "^6.1.11" -"@shopify/jest-koa-mocks@3.1.5": - version "3.1.5" - resolved "https://registry.yarnpkg.com/@shopify/jest-koa-mocks/-/jest-koa-mocks-3.1.5.tgz#11f77ccfbcaf35cf5ee2c6108a286e61e6bea084" - integrity sha512-gQ3/7ELerv00TWO37AGFX5mT9CsFCS+3/UbKMuoIlKEU0QH2OX8BV9WBf/EKw7adCDNlxss0lqV6J8kf5pgr4A== +"@msgpackr-extract/msgpackr-extract-darwin-arm64@2.1.2": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-2.1.2.tgz#9571b87be3a3f2c46de05585470bc4f3af2f6f00" + integrity sha512-TyVLn3S/+ikMDsh0gbKv2YydKClN8HaJDDpONlaZR+LVJmsxLFUgA+O7zu59h9+f9gX1aj/ahw9wqa6rosmrYQ== + +"@msgpackr-extract/msgpackr-extract-darwin-x64@2.1.2": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-2.1.2.tgz#bfbc6936ede2955218f5621a675679a5fe8e6f4c" + integrity sha512-YPXtcVkhmVNoMGlqp81ZHW4dMxK09msWgnxtsDpSiZwTzUBG2N+No2bsr7WMtBKCVJMSD6mbAl7YhKUqkp/Few== + +"@msgpackr-extract/msgpackr-extract-linux-arm64@2.1.2": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-2.1.2.tgz#22555e28382af2922e7450634c8a2f240bb9eb82" + integrity sha512-vHZ2JiOWF2+DN9lzltGbhtQNzDo8fKFGrf37UJrgqxU0yvtERrzUugnfnX1wmVfFhSsF8OxrfqiNOUc5hko1Zg== + +"@msgpackr-extract/msgpackr-extract-linux-arm@2.1.2": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-2.1.2.tgz#ffb6ae1beea7ac572b6be6bf2a8e8162ebdd8be7" + integrity sha512-42R4MAFeIeNn+L98qwxAt360bwzX2Kf0ZQkBBucJ2Ircza3asoY4CDbgiu9VWklq8gWJVSJSJBwDI+c/THiWkA== + +"@msgpackr-extract/msgpackr-extract-linux-x64@2.1.2": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-2.1.2.tgz#7caf62eebbfb1345de40f75e89666b3d4194755f" + integrity sha512-RjRoRxg7Q3kPAdUSC5EUUPlwfMkIVhmaRTIe+cqHbKrGZ4M6TyCA/b5qMaukQ/1CHWrqYY2FbKOAU8Hg0pQFzg== + +"@msgpackr-extract/msgpackr-extract-win32-x64@2.1.2": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-2.1.2.tgz#f2d8b9ddd8d191205ed26ce54aba3dfc5ae3e7c9" + integrity sha512-rIZVR48zA8hGkHIK7ED6+ZiXsjRCcAVBJbm8o89OKAMTmEAQ2QvoOxoiu3w2isAaWwzgtQIOFIqHwvZDyLKCvw== + +"@shopify/jest-koa-mocks@5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@shopify/jest-koa-mocks/-/jest-koa-mocks-5.0.1.tgz#fba490b6b7985fbb571eb9974897d396a3642e94" + integrity sha512-4YskS9q8+TEHNoyopmuoy2XyhInyqeOl7CF5ShJs19sm6m0EA/jGGvgf/osv2PeTfuf42/L2G9CzWUSg49yTSg== dependencies: koa "^2.13.4" - node-mocks-http "^1.5.8" + node-mocks-http "^1.11.0" "@sideway/address@^4.1.3": version "4.1.4" @@ -663,6 +693,11 @@ "@types/connect" "*" "@types/node" "*" +"@types/chance@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@types/chance/-/chance-1.1.3.tgz#d19fe9391288d60fdccd87632bfc9ab2b4523fea" + integrity sha512-X6c6ghhe4/sQh4XzcZWSFaTAUOda38GQHmq9BUanYkOE/EO7ZrkazwKmtsj3xzTjkLWmwULE++23g3d3CCWaWw== + "@types/connect@*": version "3.4.35" resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1" @@ -728,6 +763,13 @@ resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-1.8.2.tgz#7315b4c4c54f82d13fa61c228ec5c2ea5cc9e0e1" integrity sha512-EqX+YQxINb+MeXaIqYDASb6U6FCHbWjkj4a1CKDBks3d/QiB2+PqBLyO72vLDgAO1wUI4O+9gweRcQK11bTL/w== +"@types/ioredis@4.28.0": + version "4.28.0" + resolved "https://registry.yarnpkg.com/@types/ioredis/-/ioredis-4.28.0.tgz#609b2ea0d91231df2dd7f67dd77436bc72584911" + integrity sha512-HSA/JQivJgV0e+353gvgu6WVoWvGRe0HyHOnAN2AvbVIhUlJBhNnnkP8gEEokrDWrxywrBkwo8NuDZ6TVPL9XA== + dependencies: + "@types/node" "*" + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.4" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44" @@ -1377,6 +1419,11 @@ bcrypt@5.0.1: "@mapbox/node-pre-gyp" "^1.0.0" node-addon-api "^3.1.0" +bcryptjs@2.4.3: + version "2.4.3" + resolved "https://registry.yarnpkg.com/bcryptjs/-/bcryptjs-2.4.3.tgz#9ab5627b93e60621ff7cdac5da9733027df1d0cb" + integrity sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ== + binary-extensions@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" @@ -1487,6 +1534,21 @@ buffer@^5.5.0, buffer@^5.6.0: base64-js "^1.3.1" ieee754 "^1.1.13" +bull@4.10.1: + version "4.10.1" + resolved "https://registry.yarnpkg.com/bull/-/bull-4.10.1.tgz#f14974b6089358b62b495a2cbf838aadc098e43f" + integrity sha512-Fp21tRPb2EaZPVfmM+ONZKVz2RA+to+zGgaTLyCKt3JMSU8OOBqK8143OQrnGuGpsyE5G+9FevFAGhdZZfQP2g== + dependencies: + cron-parser "^4.2.1" + debuglog "^1.0.0" + get-port "^5.1.1" + ioredis "^4.28.5" + lodash "^4.17.21" + msgpackr "^1.5.2" + p-timeout "^3.2.0" + semver "^7.3.2" + uuid "^8.3.0" + cache-content-type@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/cache-content-type/-/cache-content-type-1.0.1.tgz#035cde2b08ee2129f4a8315ea8f00a00dba1453c" @@ -1550,6 +1612,11 @@ chalk@^4.0.0, chalk@^4.1.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +chance@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/chance/-/chance-1.1.3.tgz#414f08634ee479c7a316b569050ea20751b82dd3" + integrity sha512-XeJsdoVAzDb1WRPRuMBesRSiWpW1uNTo5Fd7mYxPJsAfgX71+jfuCOHOdbyBz2uAUZ8TwKcXgWk3DMedFfJkbg== + char-regex@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" @@ -1749,6 +1816,13 @@ core-util-is@~1.0.0: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== +cron-parser@^4.2.1: + version "4.6.0" + resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-4.6.0.tgz#404c3fdbff10ae80eef6b709555d577ef2fd2e0d" + integrity sha512-guZNLMGUgg6z4+eGhmHGw7ft+v6OQeuHzd1gcLxCo9Yg/qoxmG3nindp2/uwGCLizEisf2H0ptqeVXeoCpP6FA== + dependencies: + luxon "^3.0.1" + cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -1822,6 +1896,11 @@ debug@~3.1.0: dependencies: ms "2.0.0" +debuglog@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492" + integrity sha512-syBZ+rnAK3EgMsH2aYEOLUW7mZSY9Gb+0wUMCFsZvcmiz+HigA0LOcq/HoQqVuGG+EKykunc7QG2bzrponfaSw== + decimal.js@^10.2.1: version "10.3.1" resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.3.1.tgz#d8c3a444a9c6774ba60ca6ad7261c3a94fd5e783" @@ -2303,6 +2382,11 @@ get-package-type@^0.1.0: resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== +get-port@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/get-port/-/get-port-5.1.1.tgz#0469ed07563479de6efb986baf053dcd7d4e3193" + integrity sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ== + get-stream@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" @@ -2637,6 +2721,23 @@ ioredis@4.28.0: redis-parser "^3.0.0" standard-as-callback "^2.1.0" +ioredis@^4.28.5: + version "4.28.5" + resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-4.28.5.tgz#5c149e6a8d76a7f8fa8a504ffc85b7d5b6797f9f" + integrity sha512-3GYo0GJtLqgNXj4YhrisLaNNvWSNwSS2wS4OELGfGxH8I69+XfNdnmV1AyN+ZqMh0i7eX+SWjrwFKDBDgfBC1A== + dependencies: + cluster-key-slot "^1.1.0" + debug "^4.3.1" + denque "^1.1.0" + lodash.defaults "^4.2.0" + lodash.flatten "^4.4.0" + lodash.isarguments "^3.1.0" + p-map "^2.1.0" + redis-commands "1.7.0" + redis-errors "^1.2.0" + redis-parser "^3.0.0" + standard-as-callback "^2.1.0" + is-arrayish@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" @@ -3710,6 +3811,11 @@ ltgt@2.2.1, ltgt@^2.1.2, ltgt@~2.2.0: resolved "https://registry.yarnpkg.com/ltgt/-/ltgt-2.2.1.tgz#f35ca91c493f7b73da0e07495304f17b31f87ee5" integrity sha512-AI2r85+4MquTw9ZYqabu4nMwy9Oftlfa/e/52t9IjtfG+mGBbTNdAoZ3RQKLHR6r0wQnwZnPIEh/Ya6XTWAKNA== +luxon@^3.0.1: + version "3.0.4" + resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.0.4.tgz#d179e4e9f05e092241e7044f64aaa54796b03929" + integrity sha512-aV48rGUwP/Vydn8HT+5cdr26YYQiUZ42NM6ToMoaGKwYfWbfLeRkEu1wXWMHBZT6+KyLfcbbtVcoQFCbbPjKlw== + make-dir@^3.0.0, make-dir@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" @@ -3857,6 +3963,27 @@ ms@^2.1.1, ms@^2.1.3: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +msgpackr-extract@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/msgpackr-extract/-/msgpackr-extract-2.1.2.tgz#56272030f3e163e1b51964ef8b1cd5e7240c03ed" + integrity sha512-cmrmERQFb19NX2JABOGtrKdHMyI6RUyceaPBQ2iRz9GnDkjBWFjNJC0jyyoOfZl2U/LZE3tQCCQc4dlRyA8mcA== + dependencies: + node-gyp-build-optional-packages "5.0.3" + optionalDependencies: + "@msgpackr-extract/msgpackr-extract-darwin-arm64" "2.1.2" + "@msgpackr-extract/msgpackr-extract-darwin-x64" "2.1.2" + "@msgpackr-extract/msgpackr-extract-linux-arm" "2.1.2" + "@msgpackr-extract/msgpackr-extract-linux-arm64" "2.1.2" + "@msgpackr-extract/msgpackr-extract-linux-x64" "2.1.2" + "@msgpackr-extract/msgpackr-extract-win32-x64" "2.1.2" + +msgpackr@^1.5.2: + version "1.7.2" + resolved "https://registry.yarnpkg.com/msgpackr/-/msgpackr-1.7.2.tgz#68d6debf5999d6b61abb6e7046a689991ebf7261" + integrity sha512-mWScyHTtG6TjivXX9vfIy2nBtRupaiAj0HQ2mtmpmYujAmqZmaaEVPaSZ1NKLMvicaMLFzEaMk0ManxMRg8rMQ== + optionalDependencies: + msgpackr-extract "^2.1.2" + napi-macros@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/napi-macros/-/napi-macros-2.0.0.tgz#2b6bae421e7b96eb687aa6c77a7858640670001b" @@ -3904,6 +4031,11 @@ node-forge@^0.7.1: resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.6.tgz#fdf3b418aee1f94f0ef642cd63486c77ca9724ac" integrity sha512-sol30LUpz1jQFBjOKwbjxijiE3b6pjd74YwfD0fJOKPjF+fONKb2Yg8rYgS6+bK6VDl+/wfr4IYpC7jDzLUIfw== +node-gyp-build-optional-packages@5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.0.3.tgz#92a89d400352c44ad3975010368072b41ad66c17" + integrity sha512-k75jcVzk5wnnc/FMxsf4udAoTEUv2jY3ycfdSd3yWu6Cnd1oee6/CfZJApyscA4FJOmdoixWwiwOyf16RzD5JA== + node-gyp-build@~4.1.0: version "4.1.1" resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.1.1.tgz#d7270b5d86717068d114cc57fff352f96d745feb" @@ -3914,7 +4046,7 @@ node-int64@^0.4.0: resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" integrity sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw== -node-mocks-http@^1.5.8: +node-mocks-http@^1.11.0: version "1.11.0" resolved "https://registry.yarnpkg.com/node-mocks-http/-/node-mocks-http-1.11.0.tgz#defc0febf6b935f08245397d47534a8de592996e" integrity sha512-jS/WzSOcKbOeGrcgKbenZeNhxUNnP36Yw11+hL4TTxQXErGfqYZ+MaYNNvhaTiGIJlzNSqgQkk9j8dSu1YWSuw== @@ -4060,6 +4192,11 @@ p-cancelable@^1.0.0: resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc" integrity sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw== +p-finally@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" + integrity sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow== + p-limit@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" @@ -4079,6 +4216,13 @@ p-map@^2.1.0: resolved "https://registry.yarnpkg.com/p-map/-/p-map-2.1.0.tgz#310928feef9c9ecc65b68b17693018a665cea175" integrity sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw== +p-timeout@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe" + integrity sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg== + dependencies: + p-finally "^1.0.0" + p-try@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" @@ -5345,7 +5489,7 @@ uuid@8.1.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.1.0.tgz#6f1536eb43249f473abc6bd58ff983da1ca30d8d" integrity sha512-CI18flHDznR0lq54xBycOVmphdCYnQLKn8abKn7PXUiKUGdEd+/l9LWNJmugXel4hXq7S+RMNl34ecyC9TntWg== -uuid@8.3.2, uuid@^8.3.2: +uuid@8.3.2, uuid@^8.3.0, uuid@^8.3.2: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== diff --git a/packages/bbui/package.json b/packages/bbui/package.json index 6ed50eee9d..f3a2c1d18c 100644 --- a/packages/bbui/package.json +++ b/packages/bbui/package.json @@ -1,7 +1,7 @@ { "name": "@budibase/bbui", "description": "A UI solution used in the different Budibase projects.", - "version": "1.2.44-alpha.10", + "version": "2.0.34-alpha.4", "license": "MPL-2.0", "svelte": "src/index.js", "module": "dist/bbui.es.js", @@ -38,7 +38,7 @@ ], "dependencies": { "@adobe/spectrum-css-workflow-icons": "^1.2.1", - "@budibase/string-templates": "1.2.44-alpha.10", + "@budibase/string-templates": "2.0.34-alpha.4", "@spectrum-css/actionbutton": "^1.0.1", "@spectrum-css/actiongroup": "^1.0.1", "@spectrum-css/avatar": "^3.0.2", diff --git a/packages/bbui/src/Actions/click_outside.js b/packages/bbui/src/Actions/click_outside.js index 49a15d36a3..7fd2879071 100644 --- a/packages/bbui/src/Actions/click_outside.js +++ b/packages/bbui/src/Actions/click_outside.js @@ -1,18 +1,18 @@ export default function clickOutside(element, callbackFunction) { function onClick(event) { if (!element.contains(event.target)) { - callbackFunction() + callbackFunction(event) } } - document.body.addEventListener("mousedown", onClick, true) + document.body.addEventListener("click", onClick, true) return { update(newCallbackFunction) { callbackFunction = newCallbackFunction }, destroy() { - document.body.removeEventListener("mousedown", onClick, true) + document.body.removeEventListener("click", onClick, true) }, } } diff --git a/packages/bbui/src/Actions/position_dropdown.js b/packages/bbui/src/Actions/position_dropdown.js index a25cc1bbd5..7570a39c8c 100644 --- a/packages/bbui/src/Actions/position_dropdown.js +++ b/packages/bbui/src/Actions/position_dropdown.js @@ -1,4 +1,4 @@ -export default function positionDropdown(element, { anchor, align }) { +export default function positionDropdown(element, { anchor, align, maxWidth }) { let positionSide = "top" let maxHeight = 0 let dimensions = getDimensions(anchor) @@ -34,13 +34,24 @@ export default function positionDropdown(element, { anchor, align }) { } function calcLeftPosition() { - return align === "right" - ? dimensions.left + dimensions.width - dimensions.containerWidth - : dimensions.left + let left + + if (align == "right") { + left = dimensions.left + dimensions.width - dimensions.containerWidth + } else if (align == "right-side") { + left = dimensions.left + dimensions.width + } else { + left = dimensions.left + } + + return left } element.style.position = "absolute" element.style.zIndex = "9999" + if (maxWidth) { + element.style.maxWidth = `${maxWidth}px` + } element.style.minWidth = `${dimensions.width}px` element.style.maxHeight = `${maxHeight.toFixed(0)}px` element.style.transformOrigin = `center ${positionSide}` @@ -54,10 +65,8 @@ export default function positionDropdown(element, { anchor, align }) { element.style.left = `${calcLeftPosition(dimensions).toFixed(0)}px` }) }) - resizeObserver.observe(anchor) resizeObserver.observe(element) - return { destroy() { resizeObserver.disconnect() diff --git a/packages/bbui/src/Banner/BannerDisplay.svelte b/packages/bbui/src/Banner/BannerDisplay.svelte index aad742b1bd..9ea2eaf2ec 100644 --- a/packages/bbui/src/Banner/BannerDisplay.svelte +++ b/packages/bbui/src/Banner/BannerDisplay.svelte @@ -4,22 +4,32 @@ import { banner } from "../Stores/banner" import Banner from "./Banner.svelte" import { fly } from "svelte/transition" + import TooltipWrapper from "../Tooltip/TooltipWrapper.svelte" diff --git a/packages/bbui/src/ColorPicker/ColorPicker.svelte b/packages/bbui/src/ColorPicker/ColorPicker.svelte index 52a877415b..331de38371 100644 --- a/packages/bbui/src/ColorPicker/ColorPicker.svelte +++ b/packages/bbui/src/ColorPicker/ColorPicker.svelte @@ -119,6 +119,13 @@ return "var(--spectrum-global-color-static-gray-900)" } + + const handleOutsideClick = event => { + if (open) { + event.stopPropagation() + open = false + } + }
@@ -131,7 +138,7 @@
{#if open}
(open = false)} + use:clickOutside={handleOutsideClick} transition:fly={{ y: -20, duration: 200 }} class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open" class:spectrum-Popover--align-right={alignRight} diff --git a/packages/bbui/src/Drawer/Drawer.svelte b/packages/bbui/src/Drawer/Drawer.svelte index e1880d0ed4..43729cd794 100644 --- a/packages/bbui/src/Drawer/Drawer.svelte +++ b/packages/bbui/src/Drawer/Drawer.svelte @@ -78,7 +78,7 @@ bottom: 0; background: var(--background); border-top: var(--border-light); - z-index: 2; + z-index: 3; } .fillWidth { diff --git a/packages/bbui/src/Form/Core/DatePicker.svelte b/packages/bbui/src/Form/Core/DatePicker.svelte index d75350d8e8..9e7d44dbc3 100644 --- a/packages/bbui/src/Form/Core/DatePicker.svelte +++ b/packages/bbui/src/Form/Core/DatePicker.svelte @@ -17,7 +17,7 @@ export let timeOnly = false export let ignoreTimezones = false export let time24hr = false - + export let range = false const dispatch = createEventDispatcher() const flatpickrId = `${uuid()}-wrapper` let open = false @@ -41,6 +41,7 @@ time_24hr: time24hr || false, altFormat: timeOnly ? "H:i" : enableTime ? "F j Y, H:i" : "F j, Y", wrap: true, + mode: range ? "range" : null, appendTo, disableMobile: "true", onReady: () => { @@ -64,9 +65,15 @@ if (newValue) { newValue = newValue.toISOString() } - // If time only set date component to 2000-01-01 if (timeOnly) { + // Classic flackpickr causing issues. + // When selecting a value for the first time for a "time only" field, + // the time is always offset by 1 hour for some reason (regardless of time + // zone) so we need to correct it. + if (!value && newValue) { + newValue = new Date(dates[0].getTime() + 60 * 60 * 1000).toISOString() + } newValue = `2000-01-01T${newValue.split("T")[1]}` } @@ -88,7 +95,11 @@ .slice(0, -1) } - dispatch("change", newValue) + if (range) { + dispatch("change", event.detail) + } else { + dispatch("change", newValue) + } } const clearDateOnBackspace = event => { @@ -153,7 +164,7 @@ {#key redrawOptions}
{#if selectedUrl} - {selectedImage.name} + + {selectedImage.name} + {:else} {selectedImage.name} {/if} @@ -205,7 +214,7 @@ {/each} {/if} {/if} - {#if !maximum || (maximum && value?.length < maximum)} + {#if showDropzone}
{ + if (open) { + event.stopPropagation() + open = false + } + }
@@ -168,7 +175,7 @@ {#if open}
(open = false)} + use:clickOutside={handleOutsideClick} transition:fly|local={{ y: -20, duration: 200 }} class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open" > diff --git a/packages/bbui/src/Form/Core/Picker.svelte b/packages/bbui/src/Form/Core/Picker.svelte index cdaf00aded..16d13ef2cc 100644 --- a/packages/bbui/src/Form/Core/Picker.svelte +++ b/packages/bbui/src/Form/Core/Picker.svelte @@ -19,6 +19,7 @@ export let placeholderOption = null export let options = [] export let isOptionSelected = () => false + export let isOptionEnabled = () => true export let onSelectOption = () => {} export let getOptionLabel = option => option export let getOptionValue = option => option @@ -84,7 +85,7 @@ class:is-invalid={!!error} class:is-open={open} aria-haspopup="listbox" - on:mousedown={onClick} + on:click={onClick} > {#if fieldIcon} @@ -164,6 +165,7 @@ aria-selected="true" tabindex="0" on:click={() => onSelectOption(getOptionValue(option, idx))} + class:is-disabled={!isOptionEnabled(option)} > {#if getOptionIcon(option, idx)} @@ -256,4 +258,7 @@ .spectrum-Popover :global(.spectrum-Search .spectrum-Textfield-icon) { top: 9px; } + .spectrum-Menu-item.is-disabled { + pointer-events: none; + } diff --git a/packages/bbui/src/Form/Core/PickerDropdown.svelte b/packages/bbui/src/Form/Core/PickerDropdown.svelte index 28cb2b2a4e..604e446099 100644 --- a/packages/bbui/src/Form/Core/PickerDropdown.svelte +++ b/packages/bbui/src/Form/Core/PickerDropdown.svelte @@ -9,13 +9,13 @@ import StatusLight from "../../StatusLight/StatusLight.svelte" import Detail from "../../Typography/Detail.svelte" import Search from "./Search.svelte" + import IconAvatar from "../../Icon/IconAvatar.svelte" export let primaryLabel = "" export let primaryValue = null export let id = null export let placeholder = "Choose an option or type" export let disabled = false - export let updateOnChange = true export let error = null export let secondaryOptions = [] export let primaryOptions = [] @@ -87,6 +87,20 @@ updateValue(event.target.value) } } + + const handlePrimaryOutsideClick = event => { + if (primaryOpen) { + event.stopPropagation() + primaryOpen = false + } + } + + const handleSecondaryOutsideClick = event => { + if (secondaryOpen) { + event.stopPropagation() + secondaryOpen = false + } + }
{#if primaryOpen}
(primaryOpen = false)} + use:clickOutside={handlePrimaryOutsideClick} transition:fly|local={{ y: -20, duration: 200 }} class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open" class:auto-width={autoWidth} @@ -204,19 +218,11 @@ })} > {#if primaryOptions[title].getIcon(option)} -
-
- -
-
+ {:else if getPrimaryOptionColour(option, idx)} {/if} - {primaryOptions[title].getLabel(option)} - +
{#if secondaryFieldIcon} @@ -288,7 +295,7 @@ {#if secondaryOpen}
(secondaryOpen = false)} + use:clickOutside={handleSecondaryOutsideClick} transition:fly|local={{ y: -20, duration: 200 }} class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open" style="width: 30%" @@ -335,6 +342,11 @@
diff --git a/packages/bbui/src/IconPicker/IconPicker.svelte b/packages/bbui/src/IconPicker/IconPicker.svelte index 0e71be2c33..2b42da61b1 100644 --- a/packages/bbui/src/IconPicker/IconPicker.svelte +++ b/packages/bbui/src/IconPicker/IconPicker.svelte @@ -50,6 +50,13 @@ dispatch("change", value) open = false } + + const handleOutsideClick = event => { + if (open) { + event.stopPropagation() + open = false + } + }
@@ -64,7 +71,7 @@
{#if open}
(open = false)} + use:clickOutside={handleOutsideClick} transition:fly={{ y: -20, duration: 200 }} class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open" class:spectrum-Popover--align-right={alignRight} diff --git a/packages/bbui/src/InlineAlert/InlineAlert.svelte b/packages/bbui/src/InlineAlert/InlineAlert.svelte index 25e14b7caf..94ac6b2c2a 100644 --- a/packages/bbui/src/InlineAlert/InlineAlert.svelte +++ b/packages/bbui/src/InlineAlert/InlineAlert.svelte @@ -6,6 +6,7 @@ export let header = "" export let message = "" export let onConfirm = undefined + export let buttonText = "" $: icon = selectIcon(type) // if newlines used, convert them to different elements @@ -39,13 +40,16 @@
{splitMsg}
{/each} {#if onConfirm} - diff --git a/packages/bbui/src/Link/Link.svelte b/packages/bbui/src/Link/Link.svelte index f66554bd75..3bbfdd8282 100644 --- a/packages/bbui/src/Link/Link.svelte +++ b/packages/bbui/src/Link/Link.svelte @@ -8,12 +8,14 @@ export let secondary = false export let overBackground = false export let target + export let download import Body from "../Typography/Body.svelte" - import Icon from "../Icon/Icon.svelte" + import IconAvatar from "../Icon/IconAvatar.svelte" import Label from "../Label/Label.svelte" import Avatar from "../Avatar/Avatar.svelte" export let icon = null export let iconBackground = null + export let iconColor = null export let avatar = false export let title = null export let subtitle = null @@ -17,9 +18,7 @@
{#if icon} -
- -
+ {/if} {#if avatar} @@ -88,11 +87,4 @@ overflow: hidden; text-overflow: ellipsis; } - .icon { - width: var(--spectrum-alias-avatar-size-400); - height: var(--spectrum-alias-avatar-size-400); - display: grid; - place-items: center; - border-radius: 50%; - } diff --git a/packages/bbui/src/Modal/ModalContent.svelte b/packages/bbui/src/Modal/ModalContent.svelte index 6d609d6f1b..7d3a8d7c40 100644 --- a/packages/bbui/src/Modal/ModalContent.svelte +++ b/packages/bbui/src/Modal/ModalContent.svelte @@ -24,7 +24,6 @@ export let secondaryAction = undefined export let secondaryButtonWarning = false export let dataCy = null - const { hide, cancel } = getContext(Context.Modal) let loading = false $: confirmDisabled = disabled || loading @@ -80,7 +79,7 @@ {/if} {#if showDivider} - + {/if} {/if} @@ -88,12 +87,11 @@
- {#if showCancelButton || showConfirmButton} + {#if showCancelButton || showConfirmButton || $$slots.footer}
- {#if showSecondaryButton && secondaryButtonText && secondaryAction}
{/if}
diff --git a/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte b/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte index 9faf00a199..a3531513fb 100644 --- a/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte +++ b/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte @@ -13,7 +13,7 @@ customQueryIconColor, customQueryText, } from "helpers/data/utils" - import ICONS from "./icons" + import IntegrationIcon from "./IntegrationIcon.svelte" import { notifications } from "@budibase/bbui" let openDataSources = [] @@ -32,8 +32,8 @@ : [] $: openDataSource = enrichedDataSources.find(x => x.open) $: { - // Ensure the open data source is always included in the list of open - // data sources + // Ensure the open datasource is always included in the list of open + // datasources if (openDataSource) { openNode(openDataSource) } @@ -79,7 +79,7 @@ }) const containsActiveEntity = datasource => { - // If we're view a query then the data source ID is in the URL + // If we're view a query then the datasource ID is in the URL if ($params.selectedDatasource === datasource._id) { return true } @@ -123,10 +123,10 @@ on:iconClick={() => toggleNode(datasource)} >
-
{#if datasource._id !== BUDIBASE_INTERNAL_DB} diff --git a/packages/builder/src/components/backend/DatasourceNavigator/IntegrationIcon.svelte b/packages/builder/src/components/backend/DatasourceNavigator/IntegrationIcon.svelte new file mode 100644 index 0000000000..e6cfbf7db8 --- /dev/null +++ b/packages/builder/src/components/backend/DatasourceNavigator/IntegrationIcon.svelte @@ -0,0 +1,32 @@ + + +{#if iconInfo.icon} + +{:else if iconInfo.url} + {#await getSvgFromUrl(iconInfo) then retrievedSvg} + + {/await} +{/if} diff --git a/packages/builder/src/components/backend/DatasourceNavigator/TableIntegrationMenu/IntegrationConfigForm.svelte b/packages/builder/src/components/backend/DatasourceNavigator/TableIntegrationMenu/IntegrationConfigForm.svelte index 27358df0be..1417de6dab 100644 --- a/packages/builder/src/components/backend/DatasourceNavigator/TableIntegrationMenu/IntegrationConfigForm.svelte +++ b/packages/builder/src/components/backend/DatasourceNavigator/TableIntegrationMenu/IntegrationConfigForm.svelte @@ -10,10 +10,14 @@ import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte" import { capitalise } from "helpers" import { IntegrationTypes } from "constants/backend" + import { createValidationStore } from "helpers/validation/yup" + import { createEventDispatcher } from "svelte" export let datasource export let schema export let creating + const validation = createValidationStore() + const dispatch = createEventDispatcher() function filter([key, value]) { if (!value) { @@ -31,6 +35,17 @@ .filter(el => filter(el)) .map(([key]) => key) + // setup the validation for each required field + $: configKeys.forEach(key => { + if (schema[key].required) { + validation.addValidatorType(key, schema[key].type, schema[key].required) + } + }) + // run the validation whenever the config changes + $: validation.check(config) + // dispatch the validation result + $: dispatch("valid", $validation.valid) + let addButton function getDisplayName(key) { @@ -79,6 +94,7 @@ type={schema[configKey].type} on:change bind:value={config[configKey]} + error={$validation.errors[configKey]} />
{:else} @@ -88,6 +104,7 @@ type={schema[configKey].type} on:change bind:value={config[configKey]} + error={$validation.errors[configKey]} />
{/if} diff --git a/packages/builder/src/components/backend/DatasourceNavigator/TableIntegrationMenu/PlusConfigForm.svelte b/packages/builder/src/components/backend/DatasourceNavigator/TableIntegrationMenu/PlusConfigForm.svelte index cd19523476..ff413094a0 100644 --- a/packages/builder/src/components/backend/DatasourceNavigator/TableIntegrationMenu/PlusConfigForm.svelte +++ b/packages/builder/src/components/backend/DatasourceNavigator/TableIntegrationMenu/PlusConfigForm.svelte @@ -8,6 +8,7 @@ notifications, Modal, Table, + Toggle, } from "@budibase/bbui" import { datasources, integrations, tables } from "stores/backend" import CreateEditRelationship from "components/backend/Datasources/CreateEditRelationship.svelte" @@ -15,6 +16,7 @@ import ArrayRenderer from "components/common/renderers/ArrayRenderer.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte" import { goto } from "@roxi/routify" + import ValuesList from "components/common/ValuesList.svelte" export let datasource export let save @@ -31,6 +33,8 @@ let createExternalTableModal let selectedFromRelationship, selectedToRelationship let confirmDialog + let specificTables = null + let requireSpecificTables = false $: integration = datasource && $integrations[datasource.source] $: plusTables = datasource?.plus @@ -87,7 +91,7 @@ async function updateDatasourceSchema() { try { - await datasources.updateSchema(datasource) + await datasources.updateSchema(datasource, specificTables) notifications.success(`Datasource ${name} tables updated successfully.`) await tables.fetch() } catch (error) { @@ -150,13 +154,26 @@ warning={false} title="Confirm table fetch" > + { + requireSpecificTables = e.detail + specificTables = null + }} + thin + text="Fetch listed tables only (one per line)" + /> + {#if requireSpecificTables} + + {/if} +
If you have fetched tables from this database before, this action may overwrite any changes you made after your initial fetch. - +
Tables
@@ -192,27 +209,29 @@ {:else} No tables found. {/if} - -
- Relationships - -
- - Tell budibase how your tables are related to get even more smart features. - -{#if relationshipInfo && relationshipInfo.length > 0} - openRelationshipModal(detail.from, detail.to)} - schema={relationshipSchema} - data={relationshipInfo} - allowEditColumns={false} - allowEditRows={false} - allowSelectRows={false} - /> -{:else} - No relationships configured. +{#if integration.relationships !== false} + +
+ Relationships + +
+ + Tell budibase how your tables are related to get even more smart features. + + {#if relationshipInfo && relationshipInfo.length > 0} +
openRelationshipModal(detail.from, detail.to)} + schema={relationshipSchema} + data={relationshipInfo} + allowEditColumns={false} + allowEditRows={false} + allowSelectRows={false} + /> + {:else} + No relationships configured. + {/if} {/if} diff --git a/packages/builder/src/components/backend/DatasourceNavigator/icons/Custom.svelte b/packages/builder/src/components/backend/DatasourceNavigator/icons/Custom.svelte new file mode 100644 index 0000000000..cc8c98f511 --- /dev/null +++ b/packages/builder/src/components/backend/DatasourceNavigator/icons/Custom.svelte @@ -0,0 +1,52 @@ + + + + + + + + + diff --git a/packages/builder/src/components/backend/DatasourceNavigator/icons/index.js b/packages/builder/src/components/backend/DatasourceNavigator/icons/index.js index 404895f05a..18aa361570 100644 --- a/packages/builder/src/components/backend/DatasourceNavigator/icons/index.js +++ b/packages/builder/src/components/backend/DatasourceNavigator/icons/index.js @@ -15,8 +15,11 @@ import GoogleSheets from "./GoogleSheets.svelte" import Firebase from "./Firebase.svelte" import Redis from "./Redis.svelte" import Snowflake from "./Snowflake.svelte" +import Custom from "./Custom.svelte" +import { integrations } from "stores/backend" +import { get } from "svelte/store" -export default { +const ICONS = { BUDIBASE: Budibase, POSTGRES: Postgres, DYNAMODB: DynamoDB, @@ -34,4 +37,18 @@ export default { FIRESTORE: Firebase, REDIS: Redis, SNOWFLAKE: Snowflake, + CUSTOM: Custom, +} + +export default ICONS + +export function getIcon(integrationType, schema) { + const integrationList = get(integrations) + if (integrationList[integrationType]?.iconUrl) { + return { url: integrationList[integrationType].iconUrl } + } else if (schema?.custom || !ICONS[integrationType]) { + return { icon: ICONS.CUSTOM } + } else { + return { icon: ICONS[integrationType] } + } } diff --git a/packages/builder/src/components/backend/DatasourceNavigator/modals/CreateDatasourceModal.svelte b/packages/builder/src/components/backend/DatasourceNavigator/modals/CreateDatasourceModal.svelte index 8d34c292f3..40ef294339 100644 --- a/packages/builder/src/components/backend/DatasourceNavigator/modals/CreateDatasourceModal.svelte +++ b/packages/builder/src/components/backend/DatasourceNavigator/modals/CreateDatasourceModal.svelte @@ -18,6 +18,7 @@ import { createRestDatasource } from "builderStore/datasource" import { goto } from "@roxi/routify" import ImportRestQueriesModal from "./ImportRestQueriesModal.svelte" + import DatasourceCard from "../_components/DatasourceCard.svelte" export let modal let integrations = {} @@ -27,6 +28,9 @@ let importModal $: showImportButton = false + $: customIntegrations = Object.entries(integrations).filter( + entry => entry[1].custom + ) checkShowImport() @@ -49,6 +53,9 @@ schema: selected.datasource, auth: selected.auth, } + if (selected.friendlyName) { + integration.name = selected.friendlyName + } checkShowImport() } @@ -119,7 +126,7 @@ - Connect to an external data source + Connect to an external datasource
- {#each Object.entries(integrations).filter(([key]) => key !== IntegrationTypes.INTERNAL) as [integrationType, schema]} -
selectIntegration(integrationType)} - class="item hoverable" - > -
- -
- {schema.friendlyName} - {#if schema.type} - {schema.type || ""} - {/if} -
-
-
+ {#each Object.entries(integrations).filter(([key, val]) => key !== IntegrationTypes.INTERNAL && !val.custom) as [integrationType, schema]} + selectIntegration(evt.detail)} + {schema} + bind:integrationType + {integration} + /> {/each}
+ + {#if customIntegrations.length > 0} + + Custom datasource +
+ {#each customIntegrations as [integrationType, schema]} + selectIntegration(evt.detail)} + {schema} + bind:integrationType + {integration} + /> + {/each} +
+
+ {/if}
diff --git a/packages/builder/src/components/design/settings/componentSettings.js b/packages/builder/src/components/design/settings/componentSettings.js index 56ae3de490..441993fe1c 100644 --- a/packages/builder/src/components/design/settings/componentSettings.js +++ b/packages/builder/src/components/design/settings/componentSettings.js @@ -7,6 +7,7 @@ import TableSelect from "./controls/TableSelect.svelte" import ColorPicker from "./controls/ColorPicker.svelte" import { IconSelect } from "./controls/IconSelect" import FieldSelect from "./controls/FieldSelect.svelte" +import SortableFieldSelect from "./controls/SortableFieldSelect.svelte" import MultiFieldSelect from "./controls/MultiFieldSelect.svelte" import SearchFieldSelect from "./controls/SearchFieldSelect.svelte" import SchemaSelect from "./controls/SchemaSelect.svelte" @@ -41,6 +42,7 @@ const componentMap = { filter: FilterEditor, url: URLSelect, columns: ColumnEditor, + "field/sortable": SortableFieldSelect, "field/string": FormFieldSelect, "field/number": FormFieldSelect, "field/options": FormFieldSelect, @@ -51,6 +53,7 @@ const componentMap = { "field/link": FormFieldSelect, "field/array": FormFieldSelect, "field/json": FormFieldSelect, + "field/barcode/qr": FormFieldSelect, // Some validation types are the same as others, so not all types are // explicitly listed here. e.g. options uses string validation "validation/string": ValidationEditor, diff --git a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/ButtonActionDrawer.svelte b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/ButtonActionDrawer.svelte index 69d5fe60b4..ef7c81233b 100644 --- a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/ButtonActionDrawer.svelte +++ b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/ButtonActionDrawer.svelte @@ -21,6 +21,7 @@ export let key export let actions export let bindings = [] + export let nested $: showAvailableActions = !actions?.length @@ -187,6 +188,7 @@ this={selectedActionComponent} parameters={selectedAction.parameters} bindings={allBindings} + {nested} /> {/key} diff --git a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/ButtonActionEditor.svelte b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/ButtonActionEditor.svelte index 331d9e4c5d..6a23ba8cbd 100644 --- a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/ButtonActionEditor.svelte +++ b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/ButtonActionEditor.svelte @@ -12,6 +12,7 @@ export let value = [] export let name export let bindings + export let nested let drawer let tmpValue @@ -52,7 +53,7 @@ ) newBlock.inputs = { - fields: Object.keys(parameters.fields).reduce((fields, key) => { + fields: Object.keys(parameters.fields ?? {}).reduce((fields, key) => { fields[key] = "string" return fields }, {}), @@ -90,6 +91,7 @@ eventType={name} {bindings} {key} + {nested} /> diff --git a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/CloseScreenModal.svelte b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/CloseScreenModal.svelte index 873c9ccf65..5f3b3ef639 100644 --- a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/CloseScreenModal.svelte +++ b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/CloseScreenModal.svelte @@ -1,16 +1,31 @@ +Navigate To screen, or leave blank. +
- This action doesn't require any additional settings. - - This action won't do anything if there isn't a screen modal open. - + + (parameters.url = value.detail)} + {bindings} + />
diff --git a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/DuplicateRow.svelte b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/DuplicateRow.svelte index 7e72a051ce..db2f79488f 100644 --- a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/DuplicateRow.svelte +++ b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/DuplicateRow.svelte @@ -73,13 +73,13 @@
- Choose the data source that provides the row you would like to duplicate. + Choose the datasource that provides the row you would like to duplicate.
You can always add or override fields manually.
- + diff --git a/packages/builder/src/components/design/settings/controls/DataSourceSelect.svelte b/packages/builder/src/components/design/settings/controls/DataSourceSelect.svelte index 3e406b1b66..55a9a30eb3 100644 --- a/packages/builder/src/components/design/settings/controls/DataSourceSelect.svelte +++ b/packages/builder/src/components/design/settings/controls/DataSourceSelect.svelte @@ -200,7 +200,7 @@ {/each} {#if views?.length} - +
Views
@@ -211,7 +211,7 @@ {/if} {#if queries?.length} - +
Queries
@@ -227,7 +227,7 @@ {/if} {#if links?.length} - +
Relationships
@@ -238,7 +238,7 @@ {/if} {#if fields?.length} - +
Fields
@@ -249,7 +249,7 @@ {/if} {#if jsonArrays?.length} - +
JSON Arrays
@@ -260,7 +260,7 @@ {/if} {#if showDataProviders && dataProviders?.length} - +
Data Providers
@@ -276,7 +276,7 @@ {/if} {#if otherSources?.length} - +
Other
diff --git a/packages/builder/src/components/design/settings/controls/EjectBlockButton.svelte b/packages/builder/src/components/design/settings/controls/EjectBlockButton.svelte new file mode 100644 index 0000000000..e19d4b584b --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/EjectBlockButton.svelte @@ -0,0 +1,13 @@ + + +
+ Eject block +
diff --git a/packages/builder/src/components/design/settings/controls/FilterEditor/FilterDrawer.svelte b/packages/builder/src/components/design/settings/controls/FilterEditor/FilterDrawer.svelte index 9bd66a4cb1..a419f5ad46 100644 --- a/packages/builder/src/components/design/settings/controls/FilterEditor/FilterDrawer.svelte +++ b/packages/builder/src/components/design/settings/controls/FilterEditor/FilterDrawer.svelte @@ -20,6 +20,8 @@ import { createEventDispatcher, onMount } from "svelte" const dispatch = createEventDispatcher() + const { OperatorOptions } = Constants + const { getValidOperatorsForType } = LuceneUtils export let schemaFields export let filters = [] @@ -27,6 +29,7 @@ export let panel = ClientBindingPanel export let allowBindings = true export let allOr = false + export let fillWidth = false $: dispatch("change", filters) $: enrichedSchemaFields = getFields(schemaFields || []) @@ -44,7 +47,7 @@ { id: generate(), field: null, - operator: Constants.OperatorOptions.Equals.value, + operator: OperatorOptions.Equals.value, value: null, valueType: "Value", }, @@ -65,49 +68,60 @@ return schemaFields.find(field => field.name === filter.field) } - const onFieldChange = (expression, field) => { - // Update the field types - expression.type = enrichedSchemaFields.find(x => x.name === field)?.type - expression.externalType = getSchema(expression)?.externalType + const santizeTypes = filter => { + // Update type based on field + const fieldSchema = enrichedSchemaFields.find(x => x.name === filter.field) + filter.type = fieldSchema?.type - // Ensure a valid operator is set - const validOperators = LuceneUtils.getValidOperatorsForType( - expression.type - ).map(x => x.value) - if (!validOperators.includes(expression.operator)) { - expression.operator = - validOperators[0] ?? Constants.OperatorOptions.Equals.value - onOperatorChange(expression, expression.operator) + // Update external type based on field + filter.externalType = getSchema(filter)?.externalType + } + + const santizeOperator = filter => { + // Ensure a valid operator is selected + const operators = getValidOperatorsForType(filter.type).map(x => x.value) + if (!operators.includes(filter.operator)) { + filter.operator = operators[0] ?? OperatorOptions.Equals.value } - // if changed to an array, change default value to empty array - const idx = filters.findIndex(x => x.id === expression.id) - if (expression.type === "array") { - filters[idx].value = [] - } else { - filters[idx].value = null + // Update the noValue flag if the operator does not take a value + const noValueOptions = [ + OperatorOptions.Empty.value, + OperatorOptions.NotEmpty.value, + ] + filter.noValue = noValueOptions.includes(filter.operator) + } + + const santizeValue = filter => { + // Check if the operator allows a value at all + if (filter.noValue) { + filter.value = null + return + } + + // Ensure array values are properly set and cleared + if (Array.isArray(filter.value)) { + if (filter.valueType !== "Value" || filter.type !== "array") { + filter.value = null + } + } else if (filter.type === "array" && filter.valueType === "Value") { + filter.value = [] } } - const onOperatorChange = (expression, operator) => { - const noValueOptions = [ - Constants.OperatorOptions.Empty.value, - Constants.OperatorOptions.NotEmpty.value, - ] - expression.noValue = noValueOptions.includes(operator) - if (expression.noValue) { - expression.value = null - } - if ( - operator === Constants.OperatorOptions.In.value && - !Array.isArray(expression.value) - ) { - if (expression.value) { - expression.value = [expression.value] - } else { - expression.value = [] - } - } + const onFieldChange = filter => { + santizeTypes(filter) + santizeOperator(filter) + santizeValue(filter) + } + + const onOperatorChange = filter => { + santizeOperator(filter) + santizeValue(filter) + } + + const onValueTypeChange = filter => { + santizeValue(filter) } const getFieldOptions = field => { @@ -152,23 +166,24 @@ onOperatorChange(filter, e.detail)} + on:change={() => onOperatorChange(filter)} placeholder={null} /> @@ -248,7 +264,7 @@ column-gap: var(--spacing-l); row-gap: var(--spacing-s); align-items: center; - grid-template-columns: 1fr 120px 120px 1fr auto auto; + grid-template-columns: 1fr 150px 120px 1fr 16px 16px; } .filter-label { diff --git a/packages/builder/src/components/design/settings/controls/FormFieldSelect.svelte b/packages/builder/src/components/design/settings/controls/FormFieldSelect.svelte index 1f08c56ff5..a02ea41099 100644 --- a/packages/builder/src/components/design/settings/controls/FormFieldSelect.svelte +++ b/packages/builder/src/components/design/settings/controls/FormFieldSelect.svelte @@ -24,18 +24,17 @@ const getOptions = (schema, type) => { let entries = Object.entries(schema ?? {}) - let types = [] - if (type === "field/options") { + if (type === "field/options" || type === "field/barcode/qr") { // allow options to be used on both options and string fields types = [type, "field/string"] } else { types = [type] } - types = types.map(type => type.split("/")[1]) - entries = entries.filter(entry => types.includes(entry[1].type)) + types = types.map(type => type.slice(type.indexOf("/") + 1)) + entries = entries.filter(entry => types.includes(entry[1].type)) return entries.map(entry => entry[0]) } diff --git a/packages/builder/src/components/design/settings/controls/PropertyControl.svelte b/packages/builder/src/components/design/settings/controls/PropertyControl.svelte index 3927e0b3a5..70b88c41e5 100644 --- a/packages/builder/src/components/design/settings/controls/PropertyControl.svelte +++ b/packages/builder/src/components/design/settings/controls/PropertyControl.svelte @@ -20,6 +20,7 @@ export let componentBindings = [] export let nested = false export let highlighted = false + export let info = null $: nullishValue = value == null || value === "" $: allBindings = getAllBindings(bindings, componentBindings, nested) @@ -94,11 +95,15 @@ bindings={allBindings} name={key} text={label} + {nested} {key} {type} {...props} />
+ {#if info} +
{@html info}
+ {/if}
diff --git a/packages/builder/src/components/design/settings/controls/SortableFieldSelect.svelte b/packages/builder/src/components/design/settings/controls/SortableFieldSelect.svelte new file mode 100644 index 0000000000..21ed68ce68 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/SortableFieldSelect.svelte @@ -0,0 +1,47 @@ + + + - - {#each $tables.list as table} - - {/each} - - - - + @@ -115,7 +115,10 @@ (field.value = e.detail)} + on:blur={e => { + field.value = e.detail + changed() + }} disabled={readOnly} value={field.value} allowJS={false} @@ -127,7 +130,7 @@ placeholder={valuePlaceholder} readonly={readOnly} bind:value={field.value} - on:change={changed} + on:blur={changed} /> {/if} {#if toggle} diff --git a/packages/builder/src/components/integration/QueryViewer.svelte b/packages/builder/src/components/integration/QueryViewer.svelte index 49b9b38d86..87b3dee45d 100644 --- a/packages/builder/src/components/integration/QueryViewer.svelte +++ b/packages/builder/src/components/integration/QueryViewer.svelte @@ -17,7 +17,7 @@ import ExtraQueryConfig from "./ExtraQueryConfig.svelte" import IntegrationQueryEditor from "components/integration/index.svelte" import ExternalDataSourceTable from "components/backend/DataTable/ExternalDataSourceTable.svelte" - import BindingBuilder from "components/integration/QueryBindingBuilder.svelte" + import BindingBuilder from "components/integration/QueryViewerBindingBuilder.svelte" import { datasources, integrations, queries } from "stores/backend" import { capitalise } from "../../helpers" import CodeMirrorEditor from "components/common/CodeMirrorEditor.svelte" diff --git a/packages/builder/src/components/integration/QueryViewerBindingBuilder.svelte b/packages/builder/src/components/integration/QueryViewerBindingBuilder.svelte new file mode 100644 index 0000000000..64736e5f78 --- /dev/null +++ b/packages/builder/src/components/integration/QueryViewerBindingBuilder.svelte @@ -0,0 +1,69 @@ + + + +
+ Bindings + {#if !bindable} + + {/if} +
+ + {#if !bindable} + Bindings come in two parts: the binding name, and a default/fallback + value. These bindings can be used as Handlebars expressions throughout the + query. + {:else} + Enter a value for each binding. The default values will be used for any + values left blank. + {/if} + +
+ { + queryBindings = e.detail.map(binding => { + return { + name: binding.name, + default: binding.value, + } + }) + }} + /> +
+
+ + diff --git a/packages/builder/src/components/integration/index.svelte b/packages/builder/src/components/integration/index.svelte index a0e1b8c1c4..d320cc30c2 100644 --- a/packages/builder/src/components/integration/index.svelte +++ b/packages/builder/src/components/integration/index.svelte @@ -1,7 +1,16 @@ {#if schema} @@ -38,7 +82,7 @@ value={query.fields.sql} parameters={query.parameters} /> - {:else if schema.type === QueryTypes.JSON} + {:else if shouldDisplayJsonBox} {/if} + {:else if query.fields.extra?.actionType === "pipeline"} +
+ {#if !query.fields.steps?.length} +
+ +
+
+ {:else} + {#each query.fields.steps ?? [] as step, index} +
+
+ +
+
+ Stage {index + 1} +
+
+ {#if index > 0} + { + updateEditorsOnSwap(index, index - 1) + const target = query.fields.steps[index - 1].key + query.fields.steps[index - 1].key = + query.fields.steps[index].key + query.fields.steps[index].key = target + }} + icon="ChevronUp" + /> + {/if} + {#if index < query.fields.steps.length - 1} + { + updateEditorsOnSwap(index, index + 1) + const target = query.fields.steps[index + 1].key + query.fields.steps[index + 1].key = + query.fields.steps[index].key + query.fields.steps[index].key = target + }} + icon="ChevronDown" + /> + {/if} +
+ { + updateEditorsOnDelete(index) + query.fields.steps.splice(index, 1) + query.fields.steps = [...query.fields.steps] + }} + icon="DeleteOutline" + /> +
+
+ +
+
+ + + + diff --git a/packages/builder/src/components/portal/overview/backups/AppSizeRenderer.svelte b/packages/builder/src/components/portal/overview/backups/AppSizeRenderer.svelte new file mode 100644 index 0000000000..c103399f5b --- /dev/null +++ b/packages/builder/src/components/portal/overview/backups/AppSizeRenderer.svelte @@ -0,0 +1,41 @@ + + +
+ {#if automations != null && screens != null && datasources != null} +
+ +
{datasources || 0}
+
+
+ +
{screens || 0}
+
+
+ +
{automations || 0}
+
+ {/if} +
+ + diff --git a/packages/builder/src/components/portal/overview/backups/BackupsTab.svelte b/packages/builder/src/components/portal/overview/backups/BackupsTab.svelte new file mode 100644 index 0000000000..9a9dc3c5c0 --- /dev/null +++ b/packages/builder/src/components/portal/overview/backups/BackupsTab.svelte @@ -0,0 +1,345 @@ + + +
+ {#if !$licensing.backupsEnabled} + + +
+ Backups + + Pro plan + +
+
+ + Back up your apps and restore them to their previous state. + {#if !$auth.accountPortalAccess && !$licensing.groupsEnabled && $admin.cloud} + Contact your account holder to upgrade your plan. + {/if} + +
+ +
+ {#if $auth.accountPortalAccess} + + {/if} + + +
+
+
+ {:else if backupData?.length === 0 && !loaded && !filterOpt && !startDate} + +
+ BackupsDefault + + You have no backups yet +
+ You can manually backup your app any time +
+
+ +
+
+
+
+ {:else if loaded} + +
+ + + + {/if} + + + + + + + diff --git a/packages/builder/src/components/portal/overview/backups/CreateBackupModal.svelte b/packages/builder/src/components/portal/overview/backups/CreateBackupModal.svelte new file mode 100644 index 0000000000..4701115640 --- /dev/null +++ b/packages/builder/src/components/portal/overview/backups/CreateBackupModal.svelte @@ -0,0 +1,22 @@ + + + createManualBackup(name)} + title="Create new backup" + diabled={!name} + confirmText="Create" + > + + diff --git a/packages/builder/src/components/portal/overview/backups/CreateRestoreModal.svelte b/packages/builder/src/components/portal/overview/backups/CreateRestoreModal.svelte new file mode 100644 index 0000000000..62ef3e239d --- /dev/null +++ b/packages/builder/src/components/portal/overview/backups/CreateRestoreModal.svelte @@ -0,0 +1,27 @@ + + + confirm(name)} + title="Back up your current version" + confirmText="Confirm Restore" + disabled={!name} +> + Create a backup of your current app to allow you to roll back after + restoring this backup + + + + diff --git a/packages/builder/src/components/portal/overview/backups/DatasourceRenderer.svelte b/packages/builder/src/components/portal/overview/backups/DatasourceRenderer.svelte new file mode 100644 index 0000000000..198339dae9 --- /dev/null +++ b/packages/builder/src/components/portal/overview/backups/DatasourceRenderer.svelte @@ -0,0 +1,21 @@ + + +
+ {#if value != null} + +
{value || 0}
+ {/if} +
+ + diff --git a/packages/builder/src/components/portal/overview/backups/DateRenderer.svelte b/packages/builder/src/components/portal/overview/backups/DateRenderer.svelte new file mode 100644 index 0000000000..ec58c9ea07 --- /dev/null +++ b/packages/builder/src/components/portal/overview/backups/DateRenderer.svelte @@ -0,0 +1,22 @@ + + +
+ {timeSince} - +
+ + diff --git a/packages/builder/src/components/portal/overview/backups/StatusRenderer.svelte b/packages/builder/src/components/portal/overview/backups/StatusRenderer.svelte new file mode 100644 index 0000000000..3b3cce731d --- /dev/null +++ b/packages/builder/src/components/portal/overview/backups/StatusRenderer.svelte @@ -0,0 +1,15 @@ + + + + {status} + diff --git a/packages/builder/src/components/portal/overview/backups/TypeRenderer.svelte b/packages/builder/src/components/portal/overview/backups/TypeRenderer.svelte new file mode 100644 index 0000000000..9057a2adee --- /dev/null +++ b/packages/builder/src/components/portal/overview/backups/TypeRenderer.svelte @@ -0,0 +1,20 @@ + + +
+ {trigger} + {type} +
+ + diff --git a/packages/builder/src/components/portal/overview/backups/UserRenderer.svelte b/packages/builder/src/components/portal/overview/backups/UserRenderer.svelte new file mode 100644 index 0000000000..abab314d05 --- /dev/null +++ b/packages/builder/src/components/portal/overview/backups/UserRenderer.svelte @@ -0,0 +1,24 @@ + + +
+ {#if value != null} +
{username}
+ {/if} +
+ + diff --git a/packages/builder/src/components/settings/UserGroupPicker.svelte b/packages/builder/src/components/settings/UserGroupPicker.svelte index 6eef15efe6..1c69e48da1 100644 --- a/packages/builder/src/components/settings/UserGroupPicker.svelte +++ b/packages/builder/src/components/settings/UserGroupPicker.svelte @@ -1,75 +1,116 @@ -
- -
-
- {filtered.length} {title}{filtered.length === 1 ? "" : "s"} +
+ +
+
-
- Add all -
-
- -
- {#each filtered as item} -
{ - select(item._id) - }} - style="padding-bottom: var(--spacing-m)" - class="selection" - > -
- {item[key]} -
- - {#if selected.includes(item._id)} -
- + {#each sortedList as item} +
{ + dispatch(item.selected ? "deselect" : "select", item._id) + }} + class="item" + > + {#if iconComponent} + + {/if} +
+ {item[labelKey]}
- {/if} -
- {/each} -
+ {#if item.selected} +
+ +
+ {/if} +
+ {/each} +
+
diff --git a/packages/builder/src/components/start/AppRow.svelte b/packages/builder/src/components/start/AppRow.svelte index 91920073bb..eee8aa19b2 100644 --- a/packages/builder/src/components/start/AppRow.svelte +++ b/packages/builder/src/components/start/AppRow.svelte @@ -9,7 +9,7 @@
-
+
@@ -61,6 +61,11 @@
diff --git a/packages/builder/src/components/usage/UsageDashCard.svelte b/packages/builder/src/components/usage/UsageDashCard.svelte new file mode 100644 index 0000000000..6dd0b3d969 --- /dev/null +++ b/packages/builder/src/components/usage/UsageDashCard.svelte @@ -0,0 +1,119 @@ + + +
+
+
+ +
+ {description} +
+ {title} + {#if textRows.length} +
+ {#each textRows as row} + {#if row.tooltip} + + {row.message} + + {:else} + {row.message} + {/if} + {/each} +
+ {/if} +
+
+
+ {#if secondaryDefined} +
+ +
+ {/if} + {#if primaryDefined} +
+ +
+ {/if} +
+
+
+ +
+
+ + diff --git a/packages/builder/src/components/usage/index.js b/packages/builder/src/components/usage/index.js new file mode 100644 index 0000000000..48c0e2ea2c --- /dev/null +++ b/packages/builder/src/components/usage/index.js @@ -0,0 +1,2 @@ +export { default as Usage } from "./Usage.svelte" +export { default as DashCard } from "./UsageDashCard.svelte" diff --git a/packages/builder/src/constants/backend/index.js b/packages/builder/src/constants/backend/index.js index 28ce35d9f7..427e8d80a1 100644 --- a/packages/builder/src/constants/backend/index.js +++ b/packages/builder/src/constants/backend/index.js @@ -8,6 +8,15 @@ export const FIELDS = { presence: false, }, }, + BARCODEQR: { + name: "Barcode/QR", + type: "barcodeqr", + constraints: { + type: "string", + length: {}, + presence: false, + }, + }, LONGFORM: { name: "Long Form Text", type: "longform", @@ -148,6 +157,7 @@ export const ALLOWABLE_STRING_OPTIONS = [ FIELDS.STRING, FIELDS.OPTIONS, FIELDS.LONGFORM, + FIELDS.BARCODEQR, ] export const ALLOWABLE_STRING_TYPES = ALLOWABLE_STRING_OPTIONS.map( opt => opt.type diff --git a/packages/builder/src/constants/index.js b/packages/builder/src/constants/index.js index 4e2ca37b9c..151a0cdf8d 100644 --- a/packages/builder/src/constants/index.js +++ b/packages/builder/src/constants/index.js @@ -57,3 +57,10 @@ export const DefaultAppTheme = { navBackground: "var(--spectrum-global-color-gray-50)", navTextColor: "var(--spectrum-global-color-gray-800)", } + +export const PluginSource = { + URL: "URL", + NPM: "NPM", + GITHUB: "Github", + FILE: "File Upload", +} diff --git a/packages/builder/src/helpers/data/utils.js b/packages/builder/src/helpers/data/utils.js index 647c2be33e..d1ff4c5f80 100644 --- a/packages/builder/src/helpers/data/utils.js +++ b/packages/builder/src/helpers/data/utils.js @@ -1,4 +1,5 @@ import { IntegrationTypes } from "constants/backend" +import { findHBSBlocks } from "@budibase/string-templates" export function schemaToFields(schema) { const response = {} @@ -31,7 +32,7 @@ export function breakQueryString(qs) { let paramObj = {} for (let param of params) { const split = param.split("=") - paramObj[split[0]] = split.slice(1).join("=") + paramObj[split[0]] = decodeURIComponent(split.slice(1).join("=")) } return paramObj } @@ -46,7 +47,19 @@ export function buildQueryString(obj) { if (str !== "") { str += "&" } - str += `${key}=${value || ""}` + const bindings = findHBSBlocks(value) + let count = 0 + const bindingMarkers = {} + bindings.forEach(binding => { + const marker = `BINDING...${count++}` + value = value.replace(binding, marker) + bindingMarkers[marker] = binding + }) + let encoded = encodeURIComponent(value || "") + Object.entries(bindingMarkers).forEach(([marker, binding]) => { + encoded = encoded.replace(marker, binding) + }) + str += `${key}=${encoded}` } } return str diff --git a/packages/builder/src/helpers/featureFlags.js b/packages/builder/src/helpers/featureFlags.js index a0cda8d5fa..ae6646bd9f 100644 --- a/packages/builder/src/helpers/featureFlags.js +++ b/packages/builder/src/helpers/featureFlags.js @@ -1,15 +1,12 @@ import { auth } from "../stores/portal" import { get } from "svelte/store" -export const FEATURE_FLAGS = { +export const TENANT_FEATURE_FLAGS = { LICENSING: "LICENSING", USER_GROUPS: "USER_GROUPS", } export const isEnabled = featureFlag => { const user = get(auth).user - if (user?.featureFlags?.includes(featureFlag)) { - return true - } - return false + return !!user?.featureFlags?.includes(featureFlag) } diff --git a/packages/builder/src/helpers/helpers.js b/packages/builder/src/helpers/helpers.js index 9b0fa51a52..72c034d9a6 100644 --- a/packages/builder/src/helpers/helpers.js +++ b/packages/builder/src/helpers/helpers.js @@ -17,7 +17,12 @@ export const convertCamel = str => { export const pipe = (arg, funcs) => flow(funcs)(arg) -export const capitalise = s => s.substring(0, 1).toUpperCase() + s.substring(1) +export const capitalise = s => { + if (!s) { + return s + } + return s.substring(0, 1).toUpperCase() + s.substring(1) +} export const lowercase = s => s.substring(0, 1).toLowerCase() + s.substring(1) diff --git a/packages/builder/src/helpers/tests/dataUtils.spec.js b/packages/builder/src/helpers/tests/dataUtils.spec.js new file mode 100644 index 0000000000..83172af6ee --- /dev/null +++ b/packages/builder/src/helpers/tests/dataUtils.spec.js @@ -0,0 +1,37 @@ +import { breakQueryString, buildQueryString } from "../data/utils" + +describe("check query string utils", () => { + const obj1 = { + key1: "123", + key2: " ", + key3: "333", + } + + const obj2 = { + key1: "{{ binding.awd }}", + key2: "{{ binding.sed }} ", + } + + it("should build a basic query string", () => { + const queryString = buildQueryString(obj1) + expect(queryString).toBe("key1=123&key2=%20%20%20&key3=333") + }) + + it("should be able to break a basic query string", () => { + const broken = breakQueryString("key1=123&key2=%20%20%20&key3=333") + expect(broken.key1).toBe(obj1.key1) + expect(broken.key2).toBe(obj1.key2) + expect(broken.key3).toBe(obj1.key3) + }) + + it("should be able to build with a binding", () => { + const queryString = buildQueryString(obj2) + expect(queryString).toBe("key1={{ binding.awd }}&key2={{ binding.sed }}%20%20") + }) + + it("should be able to break with a binding", () => { + const broken = breakQueryString("key1={{ binding.awd }}&key2={{ binding.sed }}%20%20") + expect(broken.key1).toBe(obj2.key1) + expect(broken.key2).toBe(obj2.key2) + }) +}) \ No newline at end of file diff --git a/packages/builder/src/helpers/urlStateSync.js b/packages/builder/src/helpers/urlStateSync.js index f6357ae5d9..7618ee5155 100644 --- a/packages/builder/src/helpers/urlStateSync.js +++ b/packages/builder/src/helpers/urlStateSync.js @@ -10,6 +10,7 @@ export const syncURLToState = options => { fallbackUrl, store, routify, + beforeNavigate, } = options || {} if ( !urlParam || @@ -41,6 +42,15 @@ export const syncURLToState = options => { // Navigate to a certain URL const gotoUrl = (url, params) => { + if (beforeNavigate) { + const res = beforeNavigate(url, params) + if (res?.url) { + url = res.url + } + if (res?.params) { + params = res.params + } + } log("Navigating to", url, "with params", params) cachedGoto(url, params) } diff --git a/packages/builder/src/helpers/validation/yup/index.js b/packages/builder/src/helpers/validation/yup/index.js index 6783ad7e58..c064ffa583 100644 --- a/packages/builder/src/helpers/validation/yup/index.js +++ b/packages/builder/src/helpers/validation/yup/index.js @@ -1,5 +1,5 @@ import { capitalise } from "helpers" -import { object } from "yup" +import { object, string, number } from "yup" import { writable, get } from "svelte/store" import { notifications } from "@budibase/bbui" @@ -20,6 +20,32 @@ export const createValidationStore = () => { validator[propertyName] = propertyValidator } + const addValidatorType = (propertyName, type, required) => { + if (!type || !propertyName) { + return + } + + let propertyValidator + switch (type) { + case "number": + propertyValidator = number().transform(value => + isNaN(value) ? undefined : value + ) + break + case "email": + propertyValidator = string().email() + break + default: + propertyValidator = string() + } + + if (required) { + propertyValidator = propertyValidator.required() + } + + validator[propertyName] = propertyValidator + } + const check = async values => { const obj = object().shape(validator) // clear the previous errors @@ -62,5 +88,6 @@ export const createValidationStore = () => { set: validation.set, check, addValidator, + addValidatorType, } } diff --git a/packages/builder/src/pages/builder/_layout.svelte b/packages/builder/src/pages/builder/_layout.svelte index 2e8ea2ef0a..8d604e8790 100644 --- a/packages/builder/src/pages/builder/_layout.svelte +++ b/packages/builder/src/pages/builder/_layout.svelte @@ -1,6 +1,6 @@ @@ -291,7 +278,7 @@