forked from tsai/budibase
712 changed files with 22223 additions and 10249 deletions
@ -0,0 +1,68 @@ |
|||||
|
name: Deploy Budibase Single Container Image to DockerHub |
||||
|
|
||||
|
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 |
||||
|
jobs: |
||||
|
build: |
||||
|
name: "build" |
||||
|
runs-on: ubuntu-latest |
||||
|
strategy: |
||||
|
matrix: |
||||
|
node-version: [14.x] |
||||
|
steps: |
||||
|
- name: "Checkout" |
||||
|
uses: actions/checkout@v2 |
||||
|
- name: Use Node.js ${{ matrix.node-version }} |
||||
|
uses: actions/setup-node@v1 |
||||
|
with: |
||||
|
node-version: ${{ matrix.node-version }} |
||||
|
- name: Setup QEMU |
||||
|
uses: docker/setup-qemu-action@v1 |
||||
|
- 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 |
||||
|
run: yarn bootstrap |
||||
|
- name: Runt Yarn Lint |
||||
|
run: yarn lint |
||||
|
- name: Run Yarn Build |
||||
|
run: yarn build:docker:pre |
||||
|
- name: Login to Docker Hub |
||||
|
uses: docker/login-action@v2 |
||||
|
with: |
||||
|
username: ${{ secrets.DOCKER_USERNAME }} |
||||
|
password: ${{ secrets.DOCKER_API_KEY }} |
||||
|
- name: Get the latest release version |
||||
|
id: version |
||||
|
run: | |
||||
|
release_version=$(cat lerna.json | jq -r '.version') |
||||
|
echo $release_version |
||||
|
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV |
||||
|
- name: Tag and release Budibase service docker image |
||||
|
uses: docker/build-push-action@v2 |
||||
|
with: |
||||
|
context: . |
||||
|
push: true |
||||
|
platforms: linux/amd64,linux/arm64 |
||||
|
tags: budibase/budibase,budibase/budibase:v${{ env.RELEASE_VERSION }} |
||||
|
file: ./hosting/single/Dockerfile |
||||
|
- name: Tag and release Budibase Azure App Service docker image |
||||
|
uses: docker/build-push-action@v2 |
||||
|
with: |
||||
|
context: . |
||||
|
push: true |
||||
|
platforms: linux/amd64 |
||||
|
build-args: TARGETBUILD=aas |
||||
|
tags: budibase/budibase-aas,budibase/budibase-aas:v${{ env.RELEASE_VERSION }} |
||||
|
file: ./hosting/single/Dockerfile |
||||
@ -0,0 +1 @@ |
|||||
|
network-timeout 100000 |
||||
@ -1,3 +1,13 @@ |
|||||
FROM nginx:latest |
FROM nginx:latest |
||||
COPY .generated-nginx.prod.conf /etc/nginx/nginx.conf |
|
||||
COPY error.html /usr/share/nginx/html/error.html |
# nginx.conf |
||||
|
# use the default nginx behaviour for *.template files which are processed with envsubst |
||||
|
# override the output dir to output directly to /etc/nginx instead of /etc/nginx/conf.d |
||||
|
ENV NGINX_ENVSUBST_OUTPUT_DIR=/etc/nginx |
||||
|
COPY .generated-nginx.prod.conf /etc/nginx/templates/nginx.conf.template |
||||
|
|
||||
|
# Error handling |
||||
|
COPY error.html /usr/share/nginx/html/error.html |
||||
|
|
||||
|
# Default environment |
||||
|
ENV PROXY_RATE_LIMIT_WEBHOOKS_PER_SECOND=10 |
||||
@ -0,0 +1,5 @@ |
|||||
|
; CouchDB Configuration Settings |
||||
|
|
||||
|
[couchdb] |
||||
|
database_dir = DATA_DIR/couch/dbs |
||||
|
view_index_dir = DATA_DIR/couch/views |
||||
@ -1,6 +1,10 @@ |
|||||
#!/usr/bin/env bash |
#!/usr/bin/env bash |
||||
healthy=true |
healthy=true |
||||
|
|
||||
|
if [ -f "/data/.env" ]; then |
||||
|
export $(cat /data/.env | xargs) |
||||
|
fi |
||||
|
|
||||
if [[ $(curl -Lfk -s -w "%{http_code}\n" http://localhost/ -o /dev/null) -ne 200 ]]; then |
if [[ $(curl -Lfk -s -w "%{http_code}\n" http://localhost/ -o /dev/null) -ne 200 ]]; then |
||||
echo 'ERROR: Budibase is not running'; |
echo 'ERROR: Budibase is not running'; |
||||
healthy=false |
healthy=false |
||||
@ -1,4 +1,4 @@ |
|||||
#!/bin/bash |
#!/bin/bash |
||||
id=$(docker run -t -d -p 80:80 budibase:latest) |
id=$(docker run -t -d -p 8080:80 budibase:latest) |
||||
docker exec -it $id bash |
docker exec -it $id bash |
||||
docker kill $id |
docker kill $id |
||||
|
|||||
@ -0,0 +1,17 @@ |
|||||
|
export enum ContextKey { |
||||
|
TENANT_ID = "tenantId", |
||||
|
GLOBAL_DB = "globalDb", |
||||
|
APP_ID = "appId", |
||||
|
IDENTITY = "identity", |
||||
|
// whatever the request app DB was
|
||||
|
CURRENT_DB = "currentDb", |
||||
|
// get the prod app DB from the request
|
||||
|
PROD_DB = "prodDb", |
||||
|
// get the dev app DB from the request
|
||||
|
DEV_DB = "devDb", |
||||
|
DB_OPTS = "dbOpts", |
||||
|
// check if something else is using the context, don't close DB
|
||||
|
TENANCY_IN_USE = "tenancyInUse", |
||||
|
APP_IN_USE = "appInUse", |
||||
|
IDENTITY_IN_USE = "identityInUse", |
||||
|
} |
||||
@ -1,353 +0,0 @@ |
|||||
const env = require("../environment") |
|
||||
const { SEPARATOR, DocumentTypes } = require("../db/constants") |
|
||||
const { DEFAULT_TENANT_ID } = require("../constants") |
|
||||
const cls = require("./FunctionContext") |
|
||||
const { dangerousGetDB, closeDB } = require("../db") |
|
||||
const { getProdAppID, getDevelopmentAppID } = require("../db/conversions") |
|
||||
const { baseGlobalDBName } = require("../tenancy/utils") |
|
||||
const { isEqual } = require("lodash") |
|
||||
|
|
||||
// some test cases call functions directly, need to
|
|
||||
// store an app ID to pretend there is a context
|
|
||||
let TEST_APP_ID = null |
|
||||
|
|
||||
const ContextKeys = { |
|
||||
TENANT_ID: "tenantId", |
|
||||
GLOBAL_DB: "globalDb", |
|
||||
APP_ID: "appId", |
|
||||
IDENTITY: "identity", |
|
||||
// whatever the request app DB was
|
|
||||
CURRENT_DB: "currentDb", |
|
||||
// get the prod app DB from the request
|
|
||||
PROD_DB: "prodDb", |
|
||||
// get the dev app DB from the request
|
|
||||
DEV_DB: "devDb", |
|
||||
DB_OPTS: "dbOpts", |
|
||||
// check if something else is using the context, don't close DB
|
|
||||
IN_USE: "inUse", |
|
||||
} |
|
||||
|
|
||||
exports.DEFAULT_TENANT_ID = DEFAULT_TENANT_ID |
|
||||
|
|
||||
// this function makes sure the PouchDB objects are closed and
|
|
||||
// fully deleted when finished - this protects against memory leaks
|
|
||||
async function closeAppDBs() { |
|
||||
const dbKeys = [ |
|
||||
ContextKeys.CURRENT_DB, |
|
||||
ContextKeys.PROD_DB, |
|
||||
ContextKeys.DEV_DB, |
|
||||
] |
|
||||
for (let dbKey of dbKeys) { |
|
||||
const db = cls.getFromContext(dbKey) |
|
||||
if (!db) { |
|
||||
continue |
|
||||
} |
|
||||
await closeDB(db) |
|
||||
// clear the DB from context, incase someone tries to use it again
|
|
||||
cls.setOnContext(dbKey, null) |
|
||||
} |
|
||||
// clear the app ID now that the databases are closed
|
|
||||
if (cls.getFromContext(ContextKeys.APP_ID)) { |
|
||||
cls.setOnContext(ContextKeys.APP_ID, null) |
|
||||
} |
|
||||
if (cls.getFromContext(ContextKeys.DB_OPTS)) { |
|
||||
cls.setOnContext(ContextKeys.DB_OPTS, null) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
exports.closeTenancy = async () => { |
|
||||
if (env.USE_COUCH) { |
|
||||
await closeDB(exports.getGlobalDB()) |
|
||||
} |
|
||||
// clear from context now that database is closed/task is finished
|
|
||||
cls.setOnContext(ContextKeys.TENANT_ID, null) |
|
||||
cls.setOnContext(ContextKeys.GLOBAL_DB, null) |
|
||||
} |
|
||||
|
|
||||
exports.isDefaultTenant = () => { |
|
||||
return exports.getTenantId() === exports.DEFAULT_TENANT_ID |
|
||||
} |
|
||||
|
|
||||
exports.isMultiTenant = () => { |
|
||||
return env.MULTI_TENANCY |
|
||||
} |
|
||||
|
|
||||
// used for automations, API endpoints should always be in context already
|
|
||||
exports.doInTenant = (tenantId, task, { forceNew } = {}) => { |
|
||||
// the internal function is so that we can re-use an existing
|
|
||||
// context - don't want to close DB on a parent context
|
|
||||
async function internal(opts = { existing: false }) { |
|
||||
// set the tenant id
|
|
||||
if (!opts.existing) { |
|
||||
exports.updateTenantId(tenantId) |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
// invoke the task
|
|
||||
return await task() |
|
||||
} finally { |
|
||||
const using = cls.getFromContext(ContextKeys.IN_USE) |
|
||||
if (!using || using <= 1) { |
|
||||
await exports.closeTenancy() |
|
||||
} else { |
|
||||
cls.setOnContext(using - 1) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
const using = cls.getFromContext(ContextKeys.IN_USE) |
|
||||
if ( |
|
||||
!forceNew && |
|
||||
using && |
|
||||
cls.getFromContext(ContextKeys.TENANT_ID) === tenantId |
|
||||
) { |
|
||||
cls.setOnContext(ContextKeys.IN_USE, using + 1) |
|
||||
return internal({ existing: true }) |
|
||||
} else { |
|
||||
return cls.run(async () => { |
|
||||
cls.setOnContext(ContextKeys.IN_USE, 1) |
|
||||
return internal() |
|
||||
}) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Given an app ID this will attempt to retrieve the tenant ID from it. |
|
||||
* @return {null|string} The tenant ID found within the app ID. |
|
||||
*/ |
|
||||
exports.getTenantIDFromAppID = appId => { |
|
||||
if (!appId) { |
|
||||
return null |
|
||||
} |
|
||||
const split = appId.split(SEPARATOR) |
|
||||
const hasDev = split[1] === DocumentTypes.DEV |
|
||||
if ((hasDev && split.length === 3) || (!hasDev && split.length === 2)) { |
|
||||
return null |
|
||||
} |
|
||||
if (hasDev) { |
|
||||
return split[2] |
|
||||
} else { |
|
||||
return split[1] |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
const setAppTenantId = appId => { |
|
||||
const appTenantId = |
|
||||
exports.getTenantIDFromAppID(appId) || exports.DEFAULT_TENANT_ID |
|
||||
exports.updateTenantId(appTenantId) |
|
||||
} |
|
||||
|
|
||||
exports.doInAppContext = (appId, task, { forceNew } = {}) => { |
|
||||
if (!appId) { |
|
||||
throw new Error("appId is required") |
|
||||
} |
|
||||
|
|
||||
const identity = exports.getIdentity() |
|
||||
|
|
||||
// the internal function is so that we can re-use an existing
|
|
||||
// context - don't want to close DB on a parent context
|
|
||||
async function internal(opts = { existing: false }) { |
|
||||
// set the app tenant id
|
|
||||
if (!opts.existing) { |
|
||||
setAppTenantId(appId) |
|
||||
} |
|
||||
// set the app ID
|
|
||||
cls.setOnContext(ContextKeys.APP_ID, appId) |
|
||||
// preserve the identity
|
|
||||
exports.setIdentity(identity) |
|
||||
try { |
|
||||
// invoke the task
|
|
||||
return await task() |
|
||||
} finally { |
|
||||
const using = cls.getFromContext(ContextKeys.IN_USE) |
|
||||
if (!using || using <= 1) { |
|
||||
await closeAppDBs() |
|
||||
} else { |
|
||||
cls.setOnContext(using - 1) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
const using = cls.getFromContext(ContextKeys.IN_USE) |
|
||||
if (!forceNew && using && cls.getFromContext(ContextKeys.APP_ID) === appId) { |
|
||||
cls.setOnContext(ContextKeys.IN_USE, using + 1) |
|
||||
return internal({ existing: true }) |
|
||||
} else { |
|
||||
return cls.run(async () => { |
|
||||
cls.setOnContext(ContextKeys.IN_USE, 1) |
|
||||
return internal() |
|
||||
}) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
exports.doInIdentityContext = (identity, task) => { |
|
||||
if (!identity) { |
|
||||
throw new Error("identity is required") |
|
||||
} |
|
||||
|
|
||||
async function internal(opts = { existing: false }) { |
|
||||
if (!opts.existing) { |
|
||||
cls.setOnContext(ContextKeys.IDENTITY, identity) |
|
||||
// set the tenant so that doInTenant will preserve identity
|
|
||||
if (identity.tenantId) { |
|
||||
exports.updateTenantId(identity.tenantId) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
// invoke the task
|
|
||||
return await task() |
|
||||
} finally { |
|
||||
const using = cls.getFromContext(ContextKeys.IN_USE) |
|
||||
if (!using || using <= 1) { |
|
||||
exports.setIdentity(null) |
|
||||
} else { |
|
||||
cls.setOnContext(using - 1) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
const existing = cls.getFromContext(ContextKeys.IDENTITY) |
|
||||
const using = cls.getFromContext(ContextKeys.IN_USE) |
|
||||
if (using && existing && existing._id === identity._id) { |
|
||||
cls.setOnContext(ContextKeys.IN_USE, using + 1) |
|
||||
return internal({ existing: true }) |
|
||||
} else { |
|
||||
return cls.run(async () => { |
|
||||
cls.setOnContext(ContextKeys.IN_USE, 1) |
|
||||
return internal({ existing: false }) |
|
||||
}) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
exports.setIdentity = identity => { |
|
||||
cls.setOnContext(ContextKeys.IDENTITY, identity) |
|
||||
} |
|
||||
|
|
||||
exports.getIdentity = () => { |
|
||||
try { |
|
||||
return cls.getFromContext(ContextKeys.IDENTITY) |
|
||||
} catch (e) { |
|
||||
// do nothing - identity is not in context
|
|
||||
} |
|
||||
} |
|
||||
|
|
||||
exports.updateTenantId = tenantId => { |
|
||||
cls.setOnContext(ContextKeys.TENANT_ID, tenantId) |
|
||||
if (env.USE_COUCH) { |
|
||||
exports.setGlobalDB(tenantId) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
exports.updateAppId = async appId => { |
|
||||
try { |
|
||||
// have to close first, before removing the databases from context
|
|
||||
await closeAppDBs() |
|
||||
cls.setOnContext(ContextKeys.APP_ID, appId) |
|
||||
} catch (err) { |
|
||||
if (env.isTest()) { |
|
||||
TEST_APP_ID = appId |
|
||||
} else { |
|
||||
throw err |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
exports.setGlobalDB = tenantId => { |
|
||||
const dbName = baseGlobalDBName(tenantId) |
|
||||
const db = dangerousGetDB(dbName) |
|
||||
cls.setOnContext(ContextKeys.GLOBAL_DB, db) |
|
||||
return db |
|
||||
} |
|
||||
|
|
||||
exports.getGlobalDB = () => { |
|
||||
const db = cls.getFromContext(ContextKeys.GLOBAL_DB) |
|
||||
if (!db) { |
|
||||
throw new Error("Global DB not found") |
|
||||
} |
|
||||
return db |
|
||||
} |
|
||||
|
|
||||
exports.isTenantIdSet = () => { |
|
||||
const tenantId = cls.getFromContext(ContextKeys.TENANT_ID) |
|
||||
return !!tenantId |
|
||||
} |
|
||||
|
|
||||
exports.getTenantId = () => { |
|
||||
if (!exports.isMultiTenant()) { |
|
||||
return exports.DEFAULT_TENANT_ID |
|
||||
} |
|
||||
const tenantId = cls.getFromContext(ContextKeys.TENANT_ID) |
|
||||
if (!tenantId) { |
|
||||
throw new Error("Tenant id not found") |
|
||||
} |
|
||||
return tenantId |
|
||||
} |
|
||||
|
|
||||
exports.getAppId = () => { |
|
||||
const foundId = cls.getFromContext(ContextKeys.APP_ID) |
|
||||
if (!foundId && env.isTest() && TEST_APP_ID) { |
|
||||
return TEST_APP_ID |
|
||||
} else { |
|
||||
return foundId |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
function getContextDB(key, opts) { |
|
||||
const dbOptsKey = `${key}${ContextKeys.DB_OPTS}` |
|
||||
let storedOpts = cls.getFromContext(dbOptsKey) |
|
||||
let db = cls.getFromContext(key) |
|
||||
if (db && isEqual(opts, storedOpts)) { |
|
||||
return db |
|
||||
} |
|
||||
|
|
||||
const appId = exports.getAppId() |
|
||||
let toUseAppId |
|
||||
|
|
||||
switch (key) { |
|
||||
case ContextKeys.CURRENT_DB: |
|
||||
toUseAppId = appId |
|
||||
break |
|
||||
case ContextKeys.PROD_DB: |
|
||||
toUseAppId = getProdAppID(appId) |
|
||||
break |
|
||||
case ContextKeys.DEV_DB: |
|
||||
toUseAppId = getDevelopmentAppID(appId) |
|
||||
break |
|
||||
} |
|
||||
db = dangerousGetDB(toUseAppId, opts) |
|
||||
try { |
|
||||
cls.setOnContext(key, db) |
|
||||
if (opts) { |
|
||||
cls.setOnContext(dbOptsKey, opts) |
|
||||
} |
|
||||
} catch (err) { |
|
||||
if (!env.isTest()) { |
|
||||
throw err |
|
||||
} |
|
||||
} |
|
||||
return db |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Opens the app database based on whatever the request |
|
||||
* contained, dev or prod. |
|
||||
*/ |
|
||||
exports.getAppDB = (opts = null) => { |
|
||||
return getContextDB(ContextKeys.CURRENT_DB, opts) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* This specifically gets the prod app ID, if the request |
|
||||
* contained a development app ID, this will open the prod one. |
|
||||
*/ |
|
||||
exports.getProdAppDB = (opts = null) => { |
|
||||
return getContextDB(ContextKeys.PROD_DB, opts) |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* This specifically gets the dev app ID, if the request |
|
||||
* contained a prod app ID, this will open the dev one. |
|
||||
*/ |
|
||||
exports.getDevAppDB = (opts = null) => { |
|
||||
return getContextDB(ContextKeys.DEV_DB, opts) |
|
||||
} |
|
||||
@ -0,0 +1,251 @@ |
|||||
|
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 { IdentityContext } from "@budibase/types" |
||||
|
import { DEFAULT_TENANT_ID as _DEFAULT_TENANT_ID } from "../constants" |
||||
|
import { ContextKey } from "./constants" |
||||
|
import { |
||||
|
updateUsing, |
||||
|
closeWithUsing, |
||||
|
setAppTenantId, |
||||
|
setIdentity, |
||||
|
closeAppDBs, |
||||
|
getContextDB, |
||||
|
} from "./utils" |
||||
|
|
||||
|
export const DEFAULT_TENANT_ID = _DEFAULT_TENANT_ID |
||||
|
|
||||
|
// some test cases call functions directly, need to
|
||||
|
// store an app ID to pretend there is a context
|
||||
|
let TEST_APP_ID: string | null = null |
||||
|
|
||||
|
export const closeTenancy = async () => { |
||||
|
let db |
||||
|
try { |
||||
|
if (env.USE_COUCH) { |
||||
|
db = getGlobalDB() |
||||
|
} |
||||
|
} catch (err) { |
||||
|
// no DB found - skip closing
|
||||
|
return |
||||
|
} |
||||
|
await closeDB(db) |
||||
|
// clear from context now that database is closed/task is finished
|
||||
|
cls.setOnContext(ContextKey.TENANT_ID, null) |
||||
|
cls.setOnContext(ContextKey.GLOBAL_DB, null) |
||||
|
} |
||||
|
|
||||
|
// export const isDefaultTenant = () => {
|
||||
|
// return getTenantId() === DEFAULT_TENANT_ID
|
||||
|
// }
|
||||
|
|
||||
|
export const isMultiTenant = () => { |
||||
|
return env.MULTI_TENANCY |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Given an app ID this will attempt to retrieve the tenant ID from it. |
||||
|
* @return {null|string} The tenant ID found within the app ID. |
||||
|
*/ |
||||
|
export const getTenantIDFromAppID = (appId: string) => { |
||||
|
if (!appId) { |
||||
|
return null |
||||
|
} |
||||
|
const split = appId.split(SEPARATOR) |
||||
|
const hasDev = split[1] === DocumentType.DEV |
||||
|
if ((hasDev && split.length === 3) || (!hasDev && split.length === 2)) { |
||||
|
return null |
||||
|
} |
||||
|
if (hasDev) { |
||||
|
return split[2] |
||||
|
} else { |
||||
|
return split[1] |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// used for automations, API endpoints should always be in context already
|
||||
|
export const doInTenant = (tenantId: string | null, task: any) => { |
||||
|
// make sure default always selected in single tenancy
|
||||
|
if (!env.MULTI_TENANCY) { |
||||
|
tenantId = tenantId || DEFAULT_TENANT_ID |
||||
|
} |
||||
|
// the internal function is so that we can re-use an existing
|
||||
|
// context - don't want to close DB on a parent context
|
||||
|
async function internal(opts = { existing: false }) { |
||||
|
// set the tenant id + global db if this is a new context
|
||||
|
if (!opts.existing) { |
||||
|
updateTenantId(tenantId) |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
// invoke the task
|
||||
|
return await task() |
||||
|
} finally { |
||||
|
await closeWithUsing(ContextKey.TENANCY_IN_USE, () => { |
||||
|
return closeTenancy() |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const existing = cls.getFromContext(ContextKey.TENANT_ID) === tenantId |
||||
|
return updateUsing(ContextKey.TENANCY_IN_USE, existing, internal) |
||||
|
} |
||||
|
|
||||
|
export const doInAppContext = (appId: string, task: any) => { |
||||
|
if (!appId) { |
||||
|
throw new Error("appId is required") |
||||
|
} |
||||
|
|
||||
|
const identity = getIdentity() |
||||
|
|
||||
|
// the internal function is so that we can re-use an existing
|
||||
|
// context - don't want to close DB on a parent context
|
||||
|
async function internal(opts = { existing: false }) { |
||||
|
// set the app tenant id
|
||||
|
if (!opts.existing) { |
||||
|
setAppTenantId(appId) |
||||
|
} |
||||
|
// set the app ID
|
||||
|
cls.setOnContext(ContextKey.APP_ID, appId) |
||||
|
|
||||
|
// preserve the identity
|
||||
|
if (identity) { |
||||
|
setIdentity(identity) |
||||
|
} |
||||
|
try { |
||||
|
// invoke the task
|
||||
|
return await task() |
||||
|
} finally { |
||||
|
await closeWithUsing(ContextKey.APP_IN_USE, async () => { |
||||
|
await closeAppDBs() |
||||
|
await closeTenancy() |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
const existing = cls.getFromContext(ContextKey.APP_ID) === appId |
||||
|
return updateUsing(ContextKey.APP_IN_USE, existing, internal) |
||||
|
} |
||||
|
|
||||
|
export const doInIdentityContext = (identity: IdentityContext, task: any) => { |
||||
|
if (!identity) { |
||||
|
throw new Error("identity is required") |
||||
|
} |
||||
|
|
||||
|
async function internal(opts = { existing: false }) { |
||||
|
if (!opts.existing) { |
||||
|
cls.setOnContext(ContextKey.IDENTITY, identity) |
||||
|
// set the tenant so that doInTenant will preserve identity
|
||||
|
if (identity.tenantId) { |
||||
|
updateTenantId(identity.tenantId) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
// invoke the task
|
||||
|
return await task() |
||||
|
} finally { |
||||
|
await closeWithUsing(ContextKey.IDENTITY_IN_USE, async () => { |
||||
|
setIdentity(null) |
||||
|
await closeTenancy() |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const existing = cls.getFromContext(ContextKey.IDENTITY) |
||||
|
return updateUsing(ContextKey.IDENTITY_IN_USE, existing, internal) |
||||
|
} |
||||
|
|
||||
|
export const getIdentity = (): IdentityContext | undefined => { |
||||
|
try { |
||||
|
return cls.getFromContext(ContextKey.IDENTITY) |
||||
|
} catch (e) { |
||||
|
// do nothing - identity is not in context
|
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export const updateTenantId = (tenantId: string | null) => { |
||||
|
cls.setOnContext(ContextKey.TENANT_ID, tenantId) |
||||
|
if (env.USE_COUCH) { |
||||
|
setGlobalDB(tenantId) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export const updateAppId = async (appId: string) => { |
||||
|
try { |
||||
|
// have to close first, before removing the databases from context
|
||||
|
await closeAppDBs() |
||||
|
cls.setOnContext(ContextKey.APP_ID, appId) |
||||
|
} catch (err) { |
||||
|
if (env.isTest()) { |
||||
|
TEST_APP_ID = appId |
||||
|
} else { |
||||
|
throw err |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export const setGlobalDB = (tenantId: string | null) => { |
||||
|
const dbName = baseGlobalDBName(tenantId) |
||||
|
const db = dangerousGetDB(dbName) |
||||
|
cls.setOnContext(ContextKey.GLOBAL_DB, db) |
||||
|
return db |
||||
|
} |
||||
|
|
||||
|
export const getGlobalDB = () => { |
||||
|
const db = cls.getFromContext(ContextKey.GLOBAL_DB) |
||||
|
if (!db) { |
||||
|
throw new Error("Global DB not found") |
||||
|
} |
||||
|
return db |
||||
|
} |
||||
|
|
||||
|
export const isTenantIdSet = () => { |
||||
|
const tenantId = cls.getFromContext(ContextKey.TENANT_ID) |
||||
|
return !!tenantId |
||||
|
} |
||||
|
|
||||
|
export const getTenantId = () => { |
||||
|
if (!isMultiTenant()) { |
||||
|
return DEFAULT_TENANT_ID |
||||
|
} |
||||
|
const tenantId = cls.getFromContext(ContextKey.TENANT_ID) |
||||
|
if (!tenantId) { |
||||
|
throw new Error("Tenant id not found") |
||||
|
} |
||||
|
return tenantId |
||||
|
} |
||||
|
|
||||
|
export const getAppId = () => { |
||||
|
const foundId = cls.getFromContext(ContextKey.APP_ID) |
||||
|
if (!foundId && env.isTest() && TEST_APP_ID) { |
||||
|
return TEST_APP_ID |
||||
|
} else { |
||||
|
return foundId |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Opens the app database based on whatever the request |
||||
|
* contained, dev or prod. |
||||
|
*/ |
||||
|
export const getAppDB = (opts?: any) => { |
||||
|
return getContextDB(ContextKey.CURRENT_DB, opts) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* This specifically gets the prod app ID, if the request |
||||
|
* contained a development app ID, this will open the prod one. |
||||
|
*/ |
||||
|
export const getProdAppDB = (opts?: any) => { |
||||
|
return getContextDB(ContextKey.PROD_DB, opts) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* This specifically gets the dev app ID, if the request |
||||
|
* contained a prod app ID, this will open the dev one. |
||||
|
*/ |
||||
|
export const getDevAppDB = (opts?: any) => { |
||||
|
return getContextDB(ContextKey.DEV_DB, opts) |
||||
|
} |
||||
@ -0,0 +1,148 @@ |
|||||
|
import "../../../tests/utilities/TestConfiguration" |
||||
|
import * as context from ".." |
||||
|
import { DEFAULT_TENANT_ID } from "../../constants" |
||||
|
import env from "../../environment" |
||||
|
|
||||
|
// must use require to spy index file exports due to known issue in jest
|
||||
|
const dbUtils = require("../../db") |
||||
|
jest.spyOn(dbUtils, "closeDB") |
||||
|
jest.spyOn(dbUtils, "dangerousGetDB") |
||||
|
|
||||
|
describe("context", () => { |
||||
|
beforeEach(() => { |
||||
|
jest.clearAllMocks() |
||||
|
}) |
||||
|
|
||||
|
describe("doInTenant", () => { |
||||
|
describe("single-tenancy", () => { |
||||
|
it("defaults to the default tenant", () => { |
||||
|
const tenantId = context.getTenantId() |
||||
|
expect(tenantId).toBe(DEFAULT_TENANT_ID) |
||||
|
}) |
||||
|
|
||||
|
it("defaults to the default tenant db", async () => { |
||||
|
await context.doInTenant(DEFAULT_TENANT_ID, () => { |
||||
|
const db = context.getGlobalDB() |
||||
|
expect(db.name).toBe("global-db") |
||||
|
}) |
||||
|
expect(dbUtils.dangerousGetDB).toHaveBeenCalledTimes(1) |
||||
|
expect(dbUtils.closeDB).toHaveBeenCalledTimes(1) |
||||
|
}) |
||||
|
}) |
||||
|
|
||||
|
describe("multi-tenancy", () => { |
||||
|
beforeEach(() => { |
||||
|
env._set("MULTI_TENANCY", 1) |
||||
|
}) |
||||
|
|
||||
|
it("fails when no tenant id is set", () => { |
||||
|
const test = () => { |
||||
|
let error |
||||
|
try { |
||||
|
context.getTenantId() |
||||
|
} catch (e: any) { |
||||
|
error = e |
||||
|
} |
||||
|
expect(error.message).toBe("Tenant id not found") |
||||
|
} |
||||
|
|
||||
|
// test under no tenancy
|
||||
|
test() |
||||
|
|
||||
|
// test after tenancy has been accessed to ensure cleanup
|
||||
|
context.doInTenant("test", () => {}) |
||||
|
test() |
||||
|
}) |
||||
|
|
||||
|
it("fails when no tenant db is set", () => { |
||||
|
const test = () => { |
||||
|
let error |
||||
|
try { |
||||
|
context.getGlobalDB() |
||||
|
} catch (e: any) { |
||||
|
error = e |
||||
|
} |
||||
|
expect(error.message).toBe("Global DB not found") |
||||
|
} |
||||
|
|
||||
|
// test under no tenancy
|
||||
|
test() |
||||
|
|
||||
|
// test after tenancy has been accessed to ensure cleanup
|
||||
|
context.doInTenant("test", () => {}) |
||||
|
test() |
||||
|
}) |
||||
|
|
||||
|
it("sets tenant id", () => { |
||||
|
context.doInTenant("test", () => { |
||||
|
const tenantId = context.getTenantId() |
||||
|
expect(tenantId).toBe("test") |
||||
|
}) |
||||
|
}) |
||||
|
|
||||
|
it("initialises the tenant db", async () => { |
||||
|
await context.doInTenant("test", () => { |
||||
|
const db = context.getGlobalDB() |
||||
|
expect(db.name).toBe("test_global-db") |
||||
|
}) |
||||
|
expect(dbUtils.dangerousGetDB).toHaveBeenCalledTimes(1) |
||||
|
expect(dbUtils.closeDB).toHaveBeenCalledTimes(1) |
||||
|
}) |
||||
|
|
||||
|
it("sets the tenant id when nested with same tenant id", async () => { |
||||
|
await context.doInTenant("test", async () => { |
||||
|
const tenantId = context.getTenantId() |
||||
|
expect(tenantId).toBe("test") |
||||
|
|
||||
|
await context.doInTenant("test", async () => { |
||||
|
const tenantId = context.getTenantId() |
||||
|
expect(tenantId).toBe("test") |
||||
|
|
||||
|
await context.doInTenant("test", () => { |
||||
|
const tenantId = context.getTenantId() |
||||
|
expect(tenantId).toBe("test") |
||||
|
}) |
||||
|
}) |
||||
|
}) |
||||
|
}) |
||||
|
|
||||
|
it("initialises the tenant db when nested with same tenant id", async () => { |
||||
|
await context.doInTenant("test", async () => { |
||||
|
const db = context.getGlobalDB() |
||||
|
expect(db.name).toBe("test_global-db") |
||||
|
|
||||
|
await context.doInTenant("test", async () => { |
||||
|
const db = context.getGlobalDB() |
||||
|
expect(db.name).toBe("test_global-db") |
||||
|
|
||||
|
await context.doInTenant("test", () => { |
||||
|
const db = context.getGlobalDB() |
||||
|
expect(db.name).toBe("test_global-db") |
||||
|
}) |
||||
|
}) |
||||
|
}) |
||||
|
|
||||
|
// only 1 db is opened and closed
|
||||
|
expect(dbUtils.dangerousGetDB).toHaveBeenCalledTimes(1) |
||||
|
expect(dbUtils.closeDB).toHaveBeenCalledTimes(1) |
||||
|
}) |
||||
|
|
||||
|
it("sets different tenant id inside another context", () => { |
||||
|
context.doInTenant("test", () => { |
||||
|
const tenantId = context.getTenantId() |
||||
|
expect(tenantId).toBe("test") |
||||
|
|
||||
|
context.doInTenant("nested", () => { |
||||
|
const tenantId = context.getTenantId() |
||||
|
expect(tenantId).toBe("nested") |
||||
|
|
||||
|
context.doInTenant("double-nested", () => { |
||||
|
const tenantId = context.getTenantId() |
||||
|
expect(tenantId).toBe("double-nested") |
||||
|
}) |
||||
|
}) |
||||
|
}) |
||||
|
}) |
||||
|
}) |
||||
|
}) |
||||
|
}) |
||||
@ -0,0 +1,109 @@ |
|||||
|
import { |
||||
|
DEFAULT_TENANT_ID, |
||||
|
getAppId, |
||||
|
getTenantIDFromAppID, |
||||
|
updateTenantId, |
||||
|
} from "./index" |
||||
|
import cls from "./FunctionContext" |
||||
|
import { IdentityContext } from "@budibase/types" |
||||
|
import { ContextKey } from "./constants" |
||||
|
import { dangerousGetDB, closeDB } from "../db" |
||||
|
import { isEqual } from "lodash" |
||||
|
import { getDevelopmentAppID, getProdAppID } from "../db/conversions" |
||||
|
import env from "../environment" |
||||
|
|
||||
|
export async function updateUsing( |
||||
|
usingKey: string, |
||||
|
existing: boolean, |
||||
|
internal: (opts: { existing: boolean }) => Promise<any> |
||||
|
) { |
||||
|
const using = cls.getFromContext(usingKey) |
||||
|
if (using && existing) { |
||||
|
cls.setOnContext(usingKey, using + 1) |
||||
|
return internal({ existing: true }) |
||||
|
} else { |
||||
|
return cls.run(async () => { |
||||
|
cls.setOnContext(usingKey, 1) |
||||
|
return internal({ existing: false }) |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export async function closeWithUsing( |
||||
|
usingKey: string, |
||||
|
closeFn: () => Promise<any> |
||||
|
) { |
||||
|
const using = cls.getFromContext(usingKey) |
||||
|
if (!using || using <= 1) { |
||||
|
await closeFn() |
||||
|
} else { |
||||
|
cls.setOnContext(usingKey, using - 1) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export const setAppTenantId = (appId: string) => { |
||||
|
const appTenantId = getTenantIDFromAppID(appId) || DEFAULT_TENANT_ID |
||||
|
updateTenantId(appTenantId) |
||||
|
} |
||||
|
|
||||
|
export const setIdentity = (identity: IdentityContext | null) => { |
||||
|
cls.setOnContext(ContextKey.IDENTITY, identity) |
||||
|
} |
||||
|
|
||||
|
// this function makes sure the PouchDB objects are closed and
|
||||
|
// fully deleted when finished - this protects against memory leaks
|
||||
|
export async function closeAppDBs() { |
||||
|
const dbKeys = [ContextKey.CURRENT_DB, ContextKey.PROD_DB, ContextKey.DEV_DB] |
||||
|
for (let dbKey of dbKeys) { |
||||
|
const db = cls.getFromContext(dbKey) |
||||
|
if (!db) { |
||||
|
continue |
||||
|
} |
||||
|
await closeDB(db) |
||||
|
// clear the DB from context, incase someone tries to use it again
|
||||
|
cls.setOnContext(dbKey, null) |
||||
|
} |
||||
|
// clear the app ID now that the databases are closed
|
||||
|
if (cls.getFromContext(ContextKey.APP_ID)) { |
||||
|
cls.setOnContext(ContextKey.APP_ID, null) |
||||
|
} |
||||
|
if (cls.getFromContext(ContextKey.DB_OPTS)) { |
||||
|
cls.setOnContext(ContextKey.DB_OPTS, null) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export function getContextDB(key: string, opts: any) { |
||||
|
const dbOptsKey = `${key}${ContextKey.DB_OPTS}` |
||||
|
let storedOpts = cls.getFromContext(dbOptsKey) |
||||
|
let db = cls.getFromContext(key) |
||||
|
if (db && isEqual(opts, storedOpts)) { |
||||
|
return db |
||||
|
} |
||||
|
|
||||
|
const appId = getAppId() |
||||
|
let toUseAppId |
||||
|
|
||||
|
switch (key) { |
||||
|
case ContextKey.CURRENT_DB: |
||||
|
toUseAppId = appId |
||||
|
break |
||||
|
case ContextKey.PROD_DB: |
||||
|
toUseAppId = getProdAppID(appId) |
||||
|
break |
||||
|
case ContextKey.DEV_DB: |
||||
|
toUseAppId = getDevelopmentAppID(appId) |
||||
|
break |
||||
|
} |
||||
|
db = dangerousGetDB(toUseAppId, opts) |
||||
|
try { |
||||
|
cls.setOnContext(key, db) |
||||
|
if (opts) { |
||||
|
cls.setOnContext(dbOptsKey, opts) |
||||
|
} |
||||
|
} catch (err) { |
||||
|
if (!env.isTest()) { |
||||
|
throw err |
||||
|
} |
||||
|
} |
||||
|
return db |
||||
|
} |
||||
@ -1,41 +0,0 @@ |
|||||
exports.SEPARATOR = "_" |
|
||||
|
|
||||
const PRE_APP = "app" |
|
||||
const PRE_DEV = "dev" |
|
||||
|
|
||||
exports.DocumentTypes = { |
|
||||
USER: "us", |
|
||||
WORKSPACE: "workspace", |
|
||||
CONFIG: "config", |
|
||||
TEMPLATE: "template", |
|
||||
APP: PRE_APP, |
|
||||
DEV: PRE_DEV, |
|
||||
APP_DEV: `${PRE_APP}${exports.SEPARATOR}${PRE_DEV}`, |
|
||||
APP_METADATA: `${PRE_APP}${exports.SEPARATOR}metadata`, |
|
||||
ROLE: "role", |
|
||||
MIGRATIONS: "migrations", |
|
||||
DEV_INFO: "devinfo", |
|
||||
} |
|
||||
|
|
||||
exports.StaticDatabases = { |
|
||||
GLOBAL: { |
|
||||
name: "global-db", |
|
||||
docs: { |
|
||||
apiKeys: "apikeys", |
|
||||
usageQuota: "usage_quota", |
|
||||
licenseInfo: "license_info", |
|
||||
}, |
|
||||
}, |
|
||||
// contains information about tenancy and so on
|
|
||||
PLATFORM_INFO: { |
|
||||
name: "global-info", |
|
||||
docs: { |
|
||||
tenants: "tenants", |
|
||||
install: "install", |
|
||||
}, |
|
||||
}, |
|
||||
} |
|
||||
|
|
||||
exports.APP_PREFIX = exports.DocumentTypes.APP + exports.SEPARATOR |
|
||||
exports.APP_DEV = exports.APP_DEV_PREFIX = |
|
||||
exports.DocumentTypes.APP_DEV + exports.SEPARATOR |
|
||||
@ -0,0 +1,67 @@ |
|||||
|
export const SEPARATOR = "_" |
||||
|
export const UNICODE_MAX = "\ufff0" |
||||
|
|
||||
|
/** |
||||
|
* Can be used to create a few different forms of querying a view. |
||||
|
*/ |
||||
|
export enum AutomationViewMode { |
||||
|
ALL = "all", |
||||
|
AUTOMATION = "automation", |
||||
|
STATUS = "status", |
||||
|
} |
||||
|
|
||||
|
export enum ViewName { |
||||
|
USER_BY_APP = "by_app", |
||||
|
USER_BY_EMAIL = "by_email2", |
||||
|
BY_API_KEY = "by_api_key", |
||||
|
USER_BY_BUILDERS = "by_builders", |
||||
|
LINK = "by_link", |
||||
|
ROUTING = "screen_routes", |
||||
|
AUTOMATION_LOGS = "automation_logs", |
||||
|
} |
||||
|
|
||||
|
export const DeprecatedViews = { |
||||
|
[ViewName.USER_BY_EMAIL]: [ |
||||
|
// removed due to inaccuracy in view doc filter logic
|
||||
|
"by_email", |
||||
|
], |
||||
|
} |
||||
|
|
||||
|
export enum DocumentType { |
||||
|
USER = "us", |
||||
|
GROUP = "gr", |
||||
|
WORKSPACE = "workspace", |
||||
|
CONFIG = "config", |
||||
|
TEMPLATE = "template", |
||||
|
APP = "app", |
||||
|
DEV = "dev", |
||||
|
APP_DEV = "app_dev", |
||||
|
APP_METADATA = "app_metadata", |
||||
|
ROLE = "role", |
||||
|
MIGRATIONS = "migrations", |
||||
|
DEV_INFO = "devinfo", |
||||
|
AUTOMATION_LOG = "log_au", |
||||
|
} |
||||
|
|
||||
|
export const StaticDatabases = { |
||||
|
GLOBAL: { |
||||
|
name: "global-db", |
||||
|
docs: { |
||||
|
apiKeys: "apikeys", |
||||
|
usageQuota: "usage_quota", |
||||
|
licenseInfo: "license_info", |
||||
|
}, |
||||
|
}, |
||||
|
// contains information about tenancy and so on
|
||||
|
PLATFORM_INFO: { |
||||
|
name: "global-info", |
||||
|
docs: { |
||||
|
tenants: "tenants", |
||||
|
install: "install", |
||||
|
}, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
export const APP_PREFIX = DocumentType.APP + SEPARATOR |
||||
|
export const APP_DEV = DocumentType.APP_DEV + SEPARATOR |
||||
|
export const APP_DEV_PREFIX = APP_DEV |
||||
@ -0,0 +1,2 @@ |
|||||
|
import PosthogProcessor from "./PosthogProcessor" |
||||
|
export default PosthogProcessor |
||||
@ -0,0 +1,106 @@ |
|||||
|
import { Event } from "@budibase/types" |
||||
|
import { CacheKeys, TTL } from "../../../cache/generic" |
||||
|
import * as cache from "../../../cache/generic" |
||||
|
import * as context from "../../../context" |
||||
|
|
||||
|
type RateLimitedEvent = |
||||
|
| Event.SERVED_BUILDER |
||||
|
| Event.SERVED_APP_PREVIEW |
||||
|
| Event.SERVED_APP |
||||
|
|
||||
|
const isRateLimited = (event: Event): event is RateLimitedEvent => { |
||||
|
return ( |
||||
|
event === Event.SERVED_BUILDER || |
||||
|
event === Event.SERVED_APP_PREVIEW || |
||||
|
event === Event.SERVED_APP |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
const isPerApp = (event: RateLimitedEvent) => { |
||||
|
return event === Event.SERVED_APP_PREVIEW || event === Event.SERVED_APP |
||||
|
} |
||||
|
|
||||
|
interface EventProperties { |
||||
|
timestamp: number |
||||
|
} |
||||
|
|
||||
|
enum RateLimit { |
||||
|
CALENDAR_DAY = "calendarDay", |
||||
|
} |
||||
|
|
||||
|
const RATE_LIMITS = { |
||||
|
[Event.SERVED_APP]: RateLimit.CALENDAR_DAY, |
||||
|
[Event.SERVED_APP_PREVIEW]: RateLimit.CALENDAR_DAY, |
||||
|
[Event.SERVED_BUILDER]: RateLimit.CALENDAR_DAY, |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Check if this event should be sent right now |
||||
|
* Return false to signal the event SHOULD be sent |
||||
|
* Return true to signal the event should NOT be sent |
||||
|
*/ |
||||
|
export const limited = async (event: Event): Promise<boolean> => { |
||||
|
// not a rate limited event -- send
|
||||
|
if (!isRateLimited(event)) { |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
const cachedEvent = await readEvent(event) |
||||
|
if (cachedEvent) { |
||||
|
const timestamp = new Date(cachedEvent.timestamp) |
||||
|
const limit = RATE_LIMITS[event] |
||||
|
switch (limit) { |
||||
|
case RateLimit.CALENDAR_DAY: { |
||||
|
// get midnight at the start of the next day for the timestamp
|
||||
|
timestamp.setDate(timestamp.getDate() + 1) |
||||
|
timestamp.setHours(0, 0, 0, 0) |
||||
|
|
||||
|
// if we have passed the threshold into the next day
|
||||
|
if (Date.now() > timestamp.getTime()) { |
||||
|
// update the timestamp in the event -- send
|
||||
|
await recordEvent(event, { timestamp: Date.now() }) |
||||
|
return false |
||||
|
} else { |
||||
|
// still within the limited period -- don't send
|
||||
|
return true |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} else { |
||||
|
// no event present i.e. expired -- send
|
||||
|
await recordEvent(event, { timestamp: Date.now() }) |
||||
|
return false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const eventKey = (event: RateLimitedEvent) => { |
||||
|
let key = `${CacheKeys.EVENTS_RATE_LIMIT}:${event}` |
||||
|
if (isPerApp(event)) { |
||||
|
key = key + ":" + context.getAppId() |
||||
|
} |
||||
|
return key |
||||
|
} |
||||
|
|
||||
|
const readEvent = async ( |
||||
|
event: RateLimitedEvent |
||||
|
): Promise<EventProperties | undefined> => { |
||||
|
const key = eventKey(event) |
||||
|
const result = await cache.get(key) |
||||
|
return result as EventProperties |
||||
|
} |
||||
|
|
||||
|
const recordEvent = async ( |
||||
|
event: RateLimitedEvent, |
||||
|
properties: EventProperties |
||||
|
) => { |
||||
|
const key = eventKey(event) |
||||
|
const limit = RATE_LIMITS[event] |
||||
|
let ttl |
||||
|
switch (limit) { |
||||
|
case RateLimit.CALENDAR_DAY: { |
||||
|
ttl = TTL.ONE_DAY |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
await cache.store(key, properties, ttl) |
||||
|
} |
||||
@ -0,0 +1,145 @@ |
|||||
|
import "../../../../../tests/utilities/TestConfiguration" |
||||
|
import PosthogProcessor from "../PosthogProcessor" |
||||
|
import { Event, IdentityType, Hosting } from "@budibase/types" |
||||
|
const tk = require("timekeeper") |
||||
|
import * as cache from "../../../../cache/generic" |
||||
|
import { CacheKeys } from "../../../../cache/generic" |
||||
|
import * as context from "../../../../context" |
||||
|
|
||||
|
const newIdentity = () => { |
||||
|
return { |
||||
|
id: "test", |
||||
|
type: IdentityType.USER, |
||||
|
hosting: Hosting.SELF, |
||||
|
environment: "test", |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
describe("PosthogProcessor", () => { |
||||
|
beforeEach(async () => { |
||||
|
jest.clearAllMocks() |
||||
|
await cache.bustCache( |
||||
|
`${CacheKeys.EVENTS_RATE_LIMIT}:${Event.SERVED_BUILDER}` |
||||
|
) |
||||
|
}) |
||||
|
|
||||
|
describe("processEvent", () => { |
||||
|
it("processes event", async () => { |
||||
|
const processor = new PosthogProcessor("test") |
||||
|
|
||||
|
const identity = newIdentity() |
||||
|
const properties = {} |
||||
|
|
||||
|
await processor.processEvent(Event.APP_CREATED, identity, properties) |
||||
|
|
||||
|
expect(processor.posthog.capture).toHaveBeenCalledTimes(1) |
||||
|
}) |
||||
|
|
||||
|
it("honours exclusions", async () => { |
||||
|
const processor = new PosthogProcessor("test") |
||||
|
|
||||
|
const identity = newIdentity() |
||||
|
const properties = {} |
||||
|
|
||||
|
await processor.processEvent(Event.AUTH_SSO_UPDATED, identity, properties) |
||||
|
expect(processor.posthog.capture).toHaveBeenCalledTimes(0) |
||||
|
}) |
||||
|
|
||||
|
describe("rate limiting", () => { |
||||
|
it("sends daily event once in same day", async () => { |
||||
|
const processor = new PosthogProcessor("test") |
||||
|
const identity = newIdentity() |
||||
|
const properties = {} |
||||
|
|
||||
|
tk.freeze(new Date(2022, 0, 1, 14, 0)) |
||||
|
await processor.processEvent(Event.SERVED_BUILDER, identity, properties) |
||||
|
// go forward one hour
|
||||
|
tk.freeze(new Date(2022, 0, 1, 15, 0)) |
||||
|
await processor.processEvent(Event.SERVED_BUILDER, identity, properties) |
||||
|
|
||||
|
expect(processor.posthog.capture).toHaveBeenCalledTimes(1) |
||||
|
}) |
||||
|
|
||||
|
it("sends daily event once per unique day", async () => { |
||||
|
const processor = new PosthogProcessor("test") |
||||
|
const identity = newIdentity() |
||||
|
const properties = {} |
||||
|
|
||||
|
tk.freeze(new Date(2022, 0, 1, 14, 0)) |
||||
|
await processor.processEvent(Event.SERVED_BUILDER, identity, properties) |
||||
|
// go forward into next day
|
||||
|
tk.freeze(new Date(2022, 0, 2, 9, 0)) |
||||
|
await processor.processEvent(Event.SERVED_BUILDER, identity, properties) |
||||
|
// go forward into next day
|
||||
|
tk.freeze(new Date(2022, 0, 3, 5, 0)) |
||||
|
await processor.processEvent(Event.SERVED_BUILDER, identity, properties) |
||||
|
// go forward one hour
|
||||
|
tk.freeze(new Date(2022, 0, 3, 6, 0)) |
||||
|
await processor.processEvent(Event.SERVED_BUILDER, identity, properties) |
||||
|
|
||||
|
expect(processor.posthog.capture).toHaveBeenCalledTimes(3) |
||||
|
}) |
||||
|
|
||||
|
it("sends event again after cache expires", async () => { |
||||
|
const processor = new PosthogProcessor("test") |
||||
|
const identity = newIdentity() |
||||
|
const properties = {} |
||||
|
|
||||
|
tk.freeze(new Date(2022, 0, 1, 14, 0)) |
||||
|
await processor.processEvent(Event.SERVED_BUILDER, identity, properties) |
||||
|
|
||||
|
await cache.bustCache( |
||||
|
`${CacheKeys.EVENTS_RATE_LIMIT}:${Event.SERVED_BUILDER}` |
||||
|
) |
||||
|
|
||||
|
tk.freeze(new Date(2022, 0, 1, 14, 0)) |
||||
|
await processor.processEvent(Event.SERVED_BUILDER, identity, properties) |
||||
|
|
||||
|
expect(processor.posthog.capture).toHaveBeenCalledTimes(2) |
||||
|
}) |
||||
|
|
||||
|
it("sends per app events once per day per app", async () => { |
||||
|
const processor = new PosthogProcessor("test") |
||||
|
const identity = newIdentity() |
||||
|
const properties = {} |
||||
|
|
||||
|
const runAppEvents = async (appId: string) => { |
||||
|
await context.doInAppContext(appId, async () => { |
||||
|
tk.freeze(new Date(2022, 0, 1, 14, 0)) |
||||
|
await processor.processEvent(Event.SERVED_APP, identity, properties) |
||||
|
await processor.processEvent( |
||||
|
Event.SERVED_APP_PREVIEW, |
||||
|
identity, |
||||
|
properties |
||||
|
) |
||||
|
|
||||
|
// go forward one hour - should be ignored
|
||||
|
tk.freeze(new Date(2022, 0, 1, 15, 0)) |
||||
|
await processor.processEvent(Event.SERVED_APP, identity, properties) |
||||
|
await processor.processEvent( |
||||
|
Event.SERVED_APP_PREVIEW, |
||||
|
identity, |
||||
|
properties |
||||
|
) |
||||
|
|
||||
|
// go forward into next day
|
||||
|
tk.freeze(new Date(2022, 0, 2, 9, 0)) |
||||
|
|
||||
|
await processor.processEvent(Event.SERVED_APP, identity, properties) |
||||
|
await processor.processEvent( |
||||
|
Event.SERVED_APP_PREVIEW, |
||||
|
identity, |
||||
|
properties |
||||
|
) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
await runAppEvents("app_1") |
||||
|
expect(processor.posthog.capture).toHaveBeenCalledTimes(4) |
||||
|
|
||||
|
await runAppEvents("app_2") |
||||
|
expect(processor.posthog.capture).toHaveBeenCalledTimes(8) |
||||
|
}) |
||||
|
}) |
||||
|
}) |
||||
|
}) |
||||
@ -0,0 +1,64 @@ |
|||||
|
import { publishEvent } from "../events" |
||||
|
import { |
||||
|
Event, |
||||
|
UserGroup, |
||||
|
GroupCreatedEvent, |
||||
|
GroupDeletedEvent, |
||||
|
GroupUpdatedEvent, |
||||
|
GroupUsersAddedEvent, |
||||
|
GroupUsersDeletedEvent, |
||||
|
GroupAddedOnboardingEvent, |
||||
|
UserGroupRoles, |
||||
|
} from "@budibase/types" |
||||
|
|
||||
|
export async function created(group: UserGroup, timestamp?: number) { |
||||
|
const properties: GroupCreatedEvent = { |
||||
|
groupId: group._id as string, |
||||
|
} |
||||
|
await publishEvent(Event.USER_GROUP_CREATED, properties, timestamp) |
||||
|
} |
||||
|
|
||||
|
export async function updated(group: UserGroup) { |
||||
|
const properties: GroupUpdatedEvent = { |
||||
|
groupId: group._id as string, |
||||
|
} |
||||
|
await publishEvent(Event.USER_GROUP_UPDATED, properties) |
||||
|
} |
||||
|
|
||||
|
export async function deleted(group: UserGroup) { |
||||
|
const properties: GroupDeletedEvent = { |
||||
|
groupId: group._id as string, |
||||
|
} |
||||
|
await publishEvent(Event.USER_GROUP_DELETED, properties) |
||||
|
} |
||||
|
|
||||
|
export async function usersAdded(count: number, group: UserGroup) { |
||||
|
const properties: GroupUsersAddedEvent = { |
||||
|
count, |
||||
|
groupId: group._id as string, |
||||
|
} |
||||
|
await publishEvent(Event.USER_GROUP_USERS_ADDED, properties) |
||||
|
} |
||||
|
|
||||
|
export async function usersDeleted(emails: string[], group: UserGroup) { |
||||
|
const properties: GroupUsersDeletedEvent = { |
||||
|
count: emails.length, |
||||
|
groupId: group._id as string, |
||||
|
} |
||||
|
await publishEvent(Event.USER_GROUP_USERS_REMOVED, properties) |
||||
|
} |
||||
|
|
||||
|
export async function createdOnboarding(groupId: string) { |
||||
|
const properties: GroupAddedOnboardingEvent = { |
||||
|
groupId: groupId, |
||||
|
onboarding: true, |
||||
|
} |
||||
|
await publishEvent(Event.USER_GROUP_ONBOARDING, properties) |
||||
|
} |
||||
|
|
||||
|
export async function permissionsEdited(roles: UserGroupRoles) { |
||||
|
const properties: UserGroupRoles = { |
||||
|
...roles, |
||||
|
} |
||||
|
await publishEvent(Event.USER_GROUP_PERMISSIONS_EDITED, properties) |
||||
|
} |
||||
@ -1,16 +0,0 @@ |
|||||
const NonErrors = ["AccountError"] |
|
||||
|
|
||||
function isSuppressed(e) { |
|
||||
return e && e["suppressAlert"] |
|
||||
} |
|
||||
|
|
||||
module.exports.logAlert = (message, e) => { |
|
||||
if (e && NonErrors.includes(e.name) && isSuppressed(e)) { |
|
||||
return |
|
||||
} |
|
||||
let errorJson = "" |
|
||||
if (e) { |
|
||||
errorJson = ": " + JSON.stringify(e, Object.getOwnPropertyNames(e)) |
|
||||
} |
|
||||
console.error(`bb-alert: ${message} ${errorJson}`) |
|
||||
} |
|
||||
@ -0,0 +1,36 @@ |
|||||
|
const NonErrors = ["AccountError"] |
||||
|
|
||||
|
function isSuppressed(e?: any) { |
||||
|
return e && e["suppressAlert"] |
||||
|
} |
||||
|
|
||||
|
export function logAlert(message: string, e?: any) { |
||||
|
if (e && NonErrors.includes(e.name) && isSuppressed(e)) { |
||||
|
return |
||||
|
} |
||||
|
let errorJson = "" |
||||
|
if (e) { |
||||
|
errorJson = ": " + JSON.stringify(e, Object.getOwnPropertyNames(e)) |
||||
|
} |
||||
|
console.error(`bb-alert: ${message} ${errorJson}`) |
||||
|
} |
||||
|
|
||||
|
export function logAlertWithInfo( |
||||
|
message: string, |
||||
|
db: string, |
||||
|
id: string, |
||||
|
error: any |
||||
|
) { |
||||
|
message = `${message} - db: ${db} - doc: ${id} - error: ` |
||||
|
logAlert(message, error) |
||||
|
} |
||||
|
|
||||
|
export function logWarn(message: string) { |
||||
|
console.warn(`bb-warn: ${message}`) |
||||
|
} |
||||
|
|
||||
|
export default { |
||||
|
logAlert, |
||||
|
logAlertWithInfo, |
||||
|
logWarn, |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
const env = require("./environment") |
||||
|
|
||||
|
exports.pinoSettings = () => ({ |
||||
|
prettyPrint: { |
||||
|
levelFirst: true, |
||||
|
}, |
||||
|
level: env.LOG_LEVEL || "error", |
||||
|
autoLogging: { |
||||
|
ignore: req => req.url.includes("/health"), |
||||
|
}, |
||||
|
}) |
||||
@ -1,95 +0,0 @@ |
|||||
const redis = require("../redis/init") |
|
||||
const { v4: uuidv4 } = require("uuid") |
|
||||
|
|
||||
// a week in seconds
|
|
||||
const EXPIRY_SECONDS = 86400 * 7 |
|
||||
|
|
||||
async function getSessionsForUser(userId) { |
|
||||
const client = await redis.getSessionClient() |
|
||||
const sessions = await client.scan(userId) |
|
||||
return sessions.map(session => session.value) |
|
||||
} |
|
||||
|
|
||||
function makeSessionID(userId, sessionId) { |
|
||||
return `${userId}/${sessionId}` |
|
||||
} |
|
||||
|
|
||||
async function invalidateSessions(userId, sessionIds = null) { |
|
||||
try { |
|
||||
let sessions = [] |
|
||||
|
|
||||
// If no sessionIds, get all the sessions for the user
|
|
||||
if (!sessionIds) { |
|
||||
sessions = await getSessionsForUser(userId) |
|
||||
sessions.forEach( |
|
||||
session => |
|
||||
(session.key = makeSessionID(session.userId, session.sessionId)) |
|
||||
) |
|
||||
} else { |
|
||||
// use the passed array of sessionIds
|
|
||||
sessions = Array.isArray(sessionIds) ? sessionIds : [sessionIds] |
|
||||
sessions = sessions.map(sessionId => ({ |
|
||||
key: makeSessionID(userId, sessionId), |
|
||||
})) |
|
||||
} |
|
||||
|
|
||||
const client = await redis.getSessionClient() |
|
||||
const promises = [] |
|
||||
for (let session of sessions) { |
|
||||
promises.push(client.delete(session.key)) |
|
||||
} |
|
||||
await Promise.all(promises) |
|
||||
} catch (err) { |
|
||||
console.error(`Error invalidating sessions: ${err}`) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
exports.createASession = async (userId, session) => { |
|
||||
// invalidate all other sessions
|
|
||||
await invalidateSessions(userId) |
|
||||
|
|
||||
const client = await redis.getSessionClient() |
|
||||
const sessionId = session.sessionId |
|
||||
if (!session.csrfToken) { |
|
||||
session.csrfToken = uuidv4() |
|
||||
} |
|
||||
session = { |
|
||||
createdAt: new Date().toISOString(), |
|
||||
lastAccessedAt: new Date().toISOString(), |
|
||||
...session, |
|
||||
userId, |
|
||||
} |
|
||||
await client.store(makeSessionID(userId, sessionId), session, EXPIRY_SECONDS) |
|
||||
} |
|
||||
|
|
||||
exports.updateSessionTTL = async session => { |
|
||||
const client = await redis.getSessionClient() |
|
||||
const key = makeSessionID(session.userId, session.sessionId) |
|
||||
session.lastAccessedAt = new Date().toISOString() |
|
||||
await client.store(key, session, EXPIRY_SECONDS) |
|
||||
} |
|
||||
|
|
||||
exports.endSession = async (userId, sessionId) => { |
|
||||
const client = await redis.getSessionClient() |
|
||||
await client.delete(makeSessionID(userId, sessionId)) |
|
||||
} |
|
||||
|
|
||||
exports.getSession = async (userId, sessionId) => { |
|
||||
try { |
|
||||
const client = await redis.getSessionClient() |
|
||||
return client.get(makeSessionID(userId, sessionId)) |
|
||||
} catch (err) { |
|
||||
// if can't get session don't error, just don't return anything
|
|
||||
console.error(err) |
|
||||
return null |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
exports.getAllSessions = async () => { |
|
||||
const client = await redis.getSessionClient() |
|
||||
const sessions = await client.scan() |
|
||||
return sessions.map(session => session.value) |
|
||||
} |
|
||||
|
|
||||
exports.getUserSessions = getSessionsForUser |
|
||||
exports.invalidateSessions = invalidateSessions |
|
||||
@ -0,0 +1,119 @@ |
|||||
|
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 }[] |
||||
|
|
||||
|
// a week in seconds
|
||||
|
const EXPIRY_SECONDS = 86400 * 7 |
||||
|
|
||||
|
function makeSessionID(userId: string, sessionId: string) { |
||||
|
return `${userId}/${sessionId}` |
||||
|
} |
||||
|
|
||||
|
export async function getSessionsForUser(userId: string) { |
||||
|
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) |
||||
|
} |
||||
|
|
||||
|
export async function invalidateSessions( |
||||
|
userId: string, |
||||
|
opts: { sessionIds?: string[]; reason?: string } = {} |
||||
|
) { |
||||
|
try { |
||||
|
const reason = opts?.reason || "unknown" |
||||
|
let sessionIds: string[] = opts.sessionIds || [] |
||||
|
let sessions: 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)) |
||||
|
) |
||||
|
} else { |
||||
|
// use the passed array of sessionIds
|
||||
|
sessionIds = Array.isArray(sessionIds) ? sessionIds : [sessionIds] |
||||
|
sessions = sessionIds.map((sessionId: string) => ({ |
||||
|
key: makeSessionID(userId, sessionId), |
||||
|
})) |
||||
|
} |
||||
|
|
||||
|
if (sessions && sessions.length > 0) { |
||||
|
const client = await redis.getSessionClient() |
||||
|
const promises = [] |
||||
|
for (let session of sessions) { |
||||
|
promises.push(client.delete(session.key)) |
||||
|
} |
||||
|
if (!env.isTest()) { |
||||
|
logWarn( |
||||
|
`Invalidating sessions for ${userId} (reason: ${reason}) - ${sessions |
||||
|
.map(session => session.key) |
||||
|
.join(", ")}` |
||||
|
) |
||||
|
} |
||||
|
await Promise.all(promises) |
||||
|
} |
||||
|
} catch (err) { |
||||
|
console.error(`Error invalidating sessions: ${err}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export async function createASession(userId: string, session: Session) { |
||||
|
// 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, |
||||
|
createdAt: new Date().toISOString(), |
||||
|
lastAccessedAt: new Date().toISOString(), |
||||
|
userId, |
||||
|
} |
||||
|
await client.store(makeSessionID(userId, sessionId), session, EXPIRY_SECONDS) |
||||
|
} |
||||
|
|
||||
|
export async function updateSessionTTL(session: Session) { |
||||
|
const client = await redis.getSessionClient() |
||||
|
const key = makeSessionID(session.userId, session.sessionId) |
||||
|
session.lastAccessedAt = new Date().toISOString() |
||||
|
await client.store(key, session, EXPIRY_SECONDS) |
||||
|
} |
||||
|
|
||||
|
export async function endSession(userId: string, sessionId: string) { |
||||
|
const client = await redis.getSessionClient() |
||||
|
await client.delete(makeSessionID(userId, sessionId)) |
||||
|
} |
||||
|
|
||||
|
export async function getSession(userId: string, sessionId: string) { |
||||
|
if (!userId || !sessionId) { |
||||
|
throw new Error(`Invalid session details - ${userId} - ${sessionId}`) |
||||
|
} |
||||
|
const client = await redis.getSessionClient() |
||||
|
const session = await client.get(makeSessionID(userId, sessionId)) |
||||
|
if (!session) { |
||||
|
throw new Error(`Session not found - ${userId} - ${sessionId}`) |
||||
|
} |
||||
|
return session |
||||
|
} |
||||
@ -0,0 +1,12 @@ |
|||||
|
import * as sessions from "../sessions" |
||||
|
|
||||
|
describe("sessions", () => { |
||||
|
describe("getSessionsForUser", () => { |
||||
|
it("returns empty when user is undefined", async () => { |
||||
|
// @ts-ignore - allow the undefined to be passed
|
||||
|
const results = await sessions.getSessionsForUser(undefined) |
||||
|
|
||||
|
expect(results).toStrictEqual([]) |
||||
|
}) |
||||
|
}) |
||||
|
}) |
||||
@ -1,7 +1,9 @@ |
|||||
|
const posthog = require("./posthog") |
||||
const events = require("./events") |
const events = require("./events") |
||||
const date = require("./date") |
const date = require("./date") |
||||
|
|
||||
module.exports = { |
module.exports = { |
||||
|
posthog, |
||||
date, |
date, |
||||
events, |
events, |
||||
} |
} |
||||
|
|||||
@ -0,0 +1,7 @@ |
|||||
|
jest.mock("posthog-node", () => { |
||||
|
return jest.fn().mockImplementation(() => { |
||||
|
return { |
||||
|
capture: jest.fn(), |
||||
|
} |
||||
|
}) |
||||
|
}) |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue