mirror of https://github.com/Budibase/budibase.git
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 |
|||
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 |
|||
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 |
|||
echo 'ERROR: Budibase is not running'; |
|||
healthy=false |
|||
@ -1,4 +1,4 @@ |
|||
#!/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 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 date = require("./date") |
|||
|
|||
module.exports = { |
|||
posthog, |
|||
date, |
|||
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