Merge remote-tracking branch 'origin/develop' into feature/app-user-onboarding-ux

This commit is contained in:
Dean 2023-02-28 09:38:15 +00:00
commit dd08845a44
169 changed files with 4256 additions and 1848 deletions

View File

@ -10,7 +10,7 @@ on:
pull_request:
branches:
- master
- develop
- develop
workflow_dispatch:
env:
@ -64,6 +64,20 @@ jobs:
name: codecov-umbrella
verbose: true
test-pro:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Use Node.js 14.x
uses: actions/setup-node@v1
with:
node-version: 14.x
- name: Install Pro
run: yarn install:pro $BRANCH $BASE_BRANCH
- run: yarn
- run: yarn bootstrap
- run: yarn test:pro
integration-test:
runs-on: ubuntu-latest
services:

View File

@ -8,8 +8,8 @@ services:
# Last version that supports the "fs" backend
image: minio/minio:RELEASE.2022-10-24T18-35-07Z
ports:
- 9000
- 9001
- "9000"
- "9001"
environment:
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
@ -28,9 +28,9 @@ services:
- COUCHDB_PASSWORD=${COUCH_DB_PASSWORD}
- COUCHDB_USER=${COUCH_DB_USER}
ports:
- 5984
- 4369
- 9100
- "5984"
- "4369"
- "9100"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5984/_up"]
interval: 30s
@ -42,6 +42,6 @@ services:
image: redis
command: redis-server --requirepass ${REDIS_PASSWORD}
ports:
- 6379
- "6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
test: ["CMD", "redis-cli", "ping"]

View File

@ -1,5 +1,5 @@
{
"version": "2.3.18-alpha.12",
"version": "2.3.18-alpha.14",
"npmClient": "yarn",
"packages": [
"packages/*"

View File

@ -13,7 +13,7 @@
"js-yaml": "^4.1.0",
"kill-port": "^1.6.1",
"lerna": "3.14.1",
"madge": "^5.0.1",
"madge": "^6.0.0",
"prettier": "^2.3.1",
"prettier-plugin-svelte": "^2.3.0",
"rimraf": "^3.0.2",
@ -44,7 +44,7 @@
"dev": "yarn run kill-all && lerna link && lerna run --parallel dev:builder --concurrency 1",
"dev:noserver": "yarn run kill-builder && lerna link && lerna run dev:stack:up && lerna run --parallel dev:builder --concurrency 1 --ignore @budibase/backend-core --ignore @budibase/server --ignore @budibase/worker",
"dev:server": "yarn run kill-server && lerna run --parallel dev:builder --concurrency 1 --scope @budibase/backend-core --scope @budibase/worker --scope @budibase/server",
"test": "lerna run test && yarn test:pro",
"test": "lerna run test",
"test:pro": "bash scripts/pro/test.sh",
"lint:eslint": "eslint packages && eslint qa-core",
"lint:prettier": "prettier --check \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\" && prettier --check \"qa-core/**/*.{js,ts,svelte}\"",
@ -84,4 +84,4 @@
"install:pro": "bash scripts/pro/install.sh",
"dep:clean": "yarn clean && yarn bootstrap"
}
}
}

View File

@ -1,6 +1,6 @@
{
"name": "@budibase/backend-core",
"version": "2.3.18-alpha.12",
"version": "2.3.18-alpha.14",
"description": "Budibase backend core libraries used in server and worker",
"main": "dist/src/index.js",
"types": "dist/src/index.d.ts",
@ -24,7 +24,7 @@
"dependencies": {
"@budibase/nano": "10.1.1",
"@budibase/pouchdb-replication-stream": "1.2.10",
"@budibase/types": "2.3.18-alpha.12",
"@budibase/types": "2.3.18-alpha.14",
"@shopify/jest-koa-mocks": "5.0.1",
"@techpass/passport-openidconnect": "0.3.2",
"aws-cloudfront-sign": "2.2.0",

View File

@ -2,25 +2,34 @@ const _passport = require("koa-passport")
const LocalStrategy = require("passport-local").Strategy
const JwtStrategy = require("passport-jwt").Strategy
import { getGlobalDB } from "../context"
const refresh = require("passport-oauth2-refresh")
import { Config, Cookie } from "../constants"
import { getScopedConfig } from "../db"
import { Cookie } from "../constants"
import { getSessionsForUser, invalidateSessions } from "../security/sessions"
import {
authenticated,
csrf,
google,
jwt as jwtPassport,
local,
authenticated,
tenancy,
csrf,
oidc,
google,
tenancy,
} from "../middleware"
import * as userCache from "../cache/user"
import { invalidateUser } from "../cache/user"
import { PlatformLogoutOpts, User } from "@budibase/types"
import {
ConfigType,
GoogleInnerConfig,
OIDCInnerConfig,
PlatformLogoutOpts,
SSOProviderType,
User,
} from "@budibase/types"
import { logAlert } from "../logging"
import * as events from "../events"
import * as userCache from "../cache/user"
import * as configs from "../configs"
import { clearCookie, getCookie } from "../utils"
import { ssoSaveUserNoOp } from "../middleware/passport/sso/sso"
const refresh = require("passport-oauth2-refresh")
export {
auditLog,
authError,
@ -33,7 +42,6 @@ export {
google,
oidc,
} from "../middleware"
import { ssoSaveUserNoOp } from "../middleware/passport/sso/sso"
export const buildAuthMiddleware = authenticated
export const buildTenancyMiddleware = tenancy
export const buildCsrfMiddleware = csrf
@ -63,11 +71,10 @@ _passport.deserializeUser(async (user: User, done: any) => {
})
async function refreshOIDCAccessToken(
db: any,
chosenConfig: any,
chosenConfig: OIDCInnerConfig,
refreshToken: string
) {
const callbackUrl = await oidc.getCallbackUrl(db, chosenConfig)
): Promise<RefreshResponse> {
const callbackUrl = await oidc.getCallbackUrl()
let enrichedConfig: any
let strategy: any
@ -90,7 +97,7 @@ async function refreshOIDCAccessToken(
return new Promise(resolve => {
refresh.requestNewAccessToken(
Config.OIDC,
ConfigType.OIDC,
refreshToken,
(err: any, accessToken: string, refreshToken: any, params: any) => {
resolve({ err, accessToken, refreshToken, params })
@ -100,11 +107,10 @@ async function refreshOIDCAccessToken(
}
async function refreshGoogleAccessToken(
db: any,
config: any,
config: GoogleInnerConfig,
refreshToken: any
) {
let callbackUrl = await google.getCallbackUrl(db, config)
): Promise<RefreshResponse> {
let callbackUrl = await google.getCallbackUrl(config)
let strategy
try {
@ -124,7 +130,7 @@ async function refreshGoogleAccessToken(
return new Promise(resolve => {
refresh.requestNewAccessToken(
Config.GOOGLE,
ConfigType.GOOGLE,
refreshToken,
(err: any, accessToken: string, refreshToken: string, params: any) => {
resolve({ err, accessToken, refreshToken, params })
@ -133,41 +139,37 @@ async function refreshGoogleAccessToken(
})
}
interface RefreshResponse {
err?: {
data?: string
}
accessToken?: string
refreshToken?: string
params?: any
}
export async function refreshOAuthToken(
refreshToken: string,
configType: string,
configId: string
) {
const db = getGlobalDB()
const config = await getScopedConfig(db, {
type: configType,
group: {},
})
let chosenConfig = {}
let refreshResponse
if (configType === Config.OIDC) {
// configId - retrieved from cookie.
chosenConfig = config.configs.filter((c: any) => c.uuid === configId)[0]
if (!chosenConfig) {
throw new Error("Invalid OIDC configuration")
}
refreshResponse = await refreshOIDCAccessToken(
db,
chosenConfig,
refreshToken
)
} else {
chosenConfig = config
refreshResponse = await refreshGoogleAccessToken(
db,
chosenConfig,
refreshToken
)
providerType: SSOProviderType,
configId?: string
): Promise<RefreshResponse> {
switch (providerType) {
case SSOProviderType.OIDC:
if (!configId) {
return { err: { data: "OIDC config id not provided" } }
}
const oidcConfig = await configs.getOIDCConfigById(configId)
if (!oidcConfig) {
return { err: { data: "OIDC configuration not found" } }
}
return refreshOIDCAccessToken(oidcConfig, refreshToken)
case SSOProviderType.GOOGLE:
let googleConfig = await configs.getGoogleConfig()
if (!googleConfig) {
return { err: { data: "Google configuration not found" } }
}
return refreshGoogleAccessToken(googleConfig, refreshToken)
}
return refreshResponse
}
// TODO: Refactor to use user save function instead to prevent the need for
@ -225,6 +227,6 @@ export async function platformLogout(opts: PlatformLogoutOpts) {
const sessionIds = sessions.map(({ sessionId }) => sessionId)
await invalidateSessions(userId, { sessionIds, reason: "logout" })
await events.auth.logout()
await events.auth.logout(ctx.user?.email)
await userCache.invalidateUser(userId)
}

View File

@ -0,0 +1,224 @@
import {
Config,
ConfigType,
GoogleConfig,
GoogleInnerConfig,
OIDCConfig,
OIDCInnerConfig,
SettingsConfig,
SettingsInnerConfig,
SMTPConfig,
SMTPInnerConfig,
} from "@budibase/types"
import { DocumentType, SEPARATOR } from "../constants"
import { CacheKey, TTL, withCache } from "../cache"
import * as context from "../context"
import env from "../environment"
import environment from "../environment"
// UTILS
/**
* Generates a new configuration ID.
* @returns {string} The new configuration ID which the config doc can be stored under.
*/
export function generateConfigID(type: ConfigType) {
return `${DocumentType.CONFIG}${SEPARATOR}${type}`
}
export async function getConfig<T extends Config>(
type: ConfigType
): Promise<T | undefined> {
const db = context.getGlobalDB()
try {
// await to catch error
const config = (await db.get(generateConfigID(type))) as T
return config
} catch (e: any) {
if (e.status === 404) {
return
}
throw e
}
}
export async function save(config: Config) {
const db = context.getGlobalDB()
return db.put(config)
}
// SETTINGS
export async function getSettingsConfigDoc(): Promise<SettingsConfig> {
let config = await getConfig<SettingsConfig>(ConfigType.SETTINGS)
if (!config) {
config = {
_id: generateConfigID(ConfigType.GOOGLE),
type: ConfigType.SETTINGS,
config: {},
}
}
// overridden fields
config.config.platformUrl = await getPlatformUrl({
tenantAware: true,
config: config.config,
})
config.config.analyticsEnabled = await analyticsEnabled({
config: config.config,
})
return config
}
export async function getSettingsConfig(): Promise<SettingsInnerConfig> {
return (await getSettingsConfigDoc()).config
}
export async function getPlatformUrl(
opts: { tenantAware: boolean; config?: SettingsInnerConfig } = {
tenantAware: true,
}
) {
let platformUrl = env.PLATFORM_URL || "http://localhost:10000"
if (!env.SELF_HOSTED && env.MULTI_TENANCY && opts.tenantAware) {
// cloud and multi tenant - add the tenant to the default platform url
const tenantId = context.getTenantId()
if (!platformUrl.includes("localhost:")) {
platformUrl = platformUrl.replace("://", `://${tenantId}.`)
}
} else if (env.SELF_HOSTED) {
const config = opts?.config
? opts.config
: // direct to db to prevent infinite loop
(await getConfig<SettingsConfig>(ConfigType.SETTINGS))?.config
if (config?.platformUrl) {
platformUrl = config.platformUrl
}
}
return platformUrl
}
export const analyticsEnabled = async (opts?: {
config?: SettingsInnerConfig
}) => {
// cloud - always use the environment variable
if (!env.SELF_HOSTED) {
return !!env.ENABLE_ANALYTICS
}
// self host - prefer the settings doc
// use cache as events have high throughput
const enabledInDB = await withCache(
CacheKey.ANALYTICS_ENABLED,
TTL.ONE_DAY,
async () => {
const config = opts?.config
? opts.config
: // direct to db to prevent infinite loop
(await getConfig<SettingsConfig>(ConfigType.SETTINGS))?.config
// need to do explicit checks in case the field is not set
if (config?.analyticsEnabled === false) {
return false
} else if (config?.analyticsEnabled === true) {
return true
}
}
)
if (enabledInDB !== undefined) {
return enabledInDB
}
// fallback to the environment variable
// explicitly check for 0 or false here, undefined or otherwise is treated as true
const envEnabled: any = env.ENABLE_ANALYTICS
if (envEnabled === 0 || envEnabled === false) {
return false
} else {
return true
}
}
// GOOGLE
async function getGoogleConfigDoc(): Promise<GoogleConfig | undefined> {
return await getConfig<GoogleConfig>(ConfigType.GOOGLE)
}
export async function getGoogleConfig(): Promise<
GoogleInnerConfig | undefined
> {
const config = await getGoogleConfigDoc()
if (config) {
return config.config
}
// Use google fallback configuration from env variables
if (environment.GOOGLE_CLIENT_ID && environment.GOOGLE_CLIENT_SECRET) {
return {
clientID: environment.GOOGLE_CLIENT_ID!,
clientSecret: environment.GOOGLE_CLIENT_SECRET!,
activated: true,
}
}
}
// OIDC
async function getOIDCConfigDoc(): Promise<OIDCConfig | undefined> {
return getConfig<OIDCConfig>(ConfigType.OIDC)
}
export async function getOIDCConfig(): Promise<OIDCInnerConfig | undefined> {
const config = (await getOIDCConfigDoc())?.config
// default to the 0th config
return config?.configs && config.configs[0]
}
/**
* @param configId The config id of the inner config to retrieve
*/
export async function getOIDCConfigById(
configId: string
): Promise<OIDCInnerConfig | undefined> {
const config = (await getConfig<OIDCConfig>(ConfigType.OIDC))?.config
return config && config.configs.filter((c: any) => c.uuid === configId)[0]
}
// SMTP
export async function getSMTPConfigDoc(): Promise<SMTPConfig | undefined> {
return getConfig<SMTPConfig>(ConfigType.SMTP)
}
export async function getSMTPConfig(
isAutomation?: boolean
): Promise<SMTPInnerConfig | undefined> {
const config = await getSMTPConfigDoc()
if (config) {
return config.config
}
// always allow fallback in self host
// in cloud don't allow for automations
const allowFallback = env.SELF_HOSTED || !isAutomation
// Use an SMTP fallback configuration from env variables
if (env.SMTP_FALLBACK_ENABLED && allowFallback) {
return {
port: env.SMTP_PORT,
host: env.SMTP_HOST!,
secure: false,
from: env.SMTP_FROM_ADDRESS!,
auth: {
user: env.SMTP_USER!,
pass: env.SMTP_PASSWORD!,
},
}
}
}

View File

@ -0,0 +1 @@
export * from "./configs"

View File

@ -0,0 +1,116 @@
import { DBTestConfiguration, generator, testEnv } from "../../../tests"
import { ConfigType } from "@budibase/types"
import env from "../../environment"
import * as configs from "../configs"
const DEFAULT_URL = "http://localhost:10000"
const ENV_URL = "http://env.com"
describe("configs", () => {
const config = new DBTestConfiguration()
const setDbPlatformUrl = async (dbUrl: string) => {
const settingsConfig = {
_id: configs.generateConfigID(ConfigType.SETTINGS),
type: ConfigType.SETTINGS,
config: {
platformUrl: dbUrl,
},
}
await configs.save(settingsConfig)
}
beforeEach(async () => {
config.newTenant()
})
describe("getPlatformUrl", () => {
describe("self host", () => {
beforeEach(async () => {
testEnv.selfHosted()
})
it("gets the default url", async () => {
await config.doInTenant(async () => {
const url = await configs.getPlatformUrl()
expect(url).toBe(DEFAULT_URL)
})
})
it("gets the platform url from the environment", async () => {
await config.doInTenant(async () => {
env._set("PLATFORM_URL", ENV_URL)
const url = await configs.getPlatformUrl()
expect(url).toBe(ENV_URL)
})
})
it("gets the platform url from the database", async () => {
await config.doInTenant(async () => {
const dbUrl = generator.url()
await setDbPlatformUrl(dbUrl)
const url = await configs.getPlatformUrl()
expect(url).toBe(dbUrl)
})
})
})
describe("cloud", () => {
function getTenantAwareUrl() {
return `http://${config.tenantId}.env.com`
}
beforeEach(async () => {
testEnv.cloudHosted()
testEnv.multiTenant()
env._set("PLATFORM_URL", ENV_URL)
})
it("gets the platform url from the environment without tenancy", async () => {
await config.doInTenant(async () => {
const url = await configs.getPlatformUrl({ tenantAware: false })
expect(url).toBe(ENV_URL)
})
})
it("gets the platform url from the environment with tenancy", async () => {
await config.doInTenant(async () => {
const url = await configs.getPlatformUrl()
expect(url).toBe(getTenantAwareUrl())
})
})
it("never gets the platform url from the database", async () => {
await config.doInTenant(async () => {
await setDbPlatformUrl(generator.url())
const url = await configs.getPlatformUrl()
expect(url).toBe(getTenantAwareUrl())
})
})
})
})
describe("getSettingsConfig", () => {
beforeAll(async () => {
testEnv.selfHosted()
env._set("PLATFORM_URL", "")
})
it("returns the platform url with an existing config", async () => {
await config.doInTenant(async () => {
const dbUrl = generator.url()
await setDbPlatformUrl(dbUrl)
const config = await configs.getSettingsConfig()
expect(config.platformUrl).toBe(dbUrl)
})
})
it("returns the platform url without an existing config", async () => {
await config.doInTenant(async () => {
const config = await configs.getSettingsConfig()
expect(config.platformUrl).toBe(DEFAULT_URL)
})
})
})
})

View File

@ -68,6 +68,7 @@ export enum DocumentType {
MEM_VIEW = "view",
USER_FLAG = "flag",
AUTOMATION_METADATA = "meta_au",
AUDIT_LOG = "al",
}
export const StaticDatabases = {
@ -88,6 +89,9 @@ export const StaticDatabases = {
install: "install",
},
},
AUDIT_LOGS: {
name: "audit-logs",
},
}
export const APP_PREFIX = DocumentType.APP + SEPARATOR

View File

@ -41,5 +41,6 @@ export enum Config {
OIDC_LOGOS = "logos_oidc",
}
export const MIN_VALID_DATE = new Date(-2147483647000)
export const MAX_VALID_DATE = new Date(2147483647000)
export const DEFAULT_TENANT_ID = "default"

View File

@ -1,5 +1,5 @@
import { AsyncLocalStorage } from "async_hooks"
import { ContextMap } from "./mainContext"
import { ContextMap } from "./types"
export default class Context {
static storage = new AsyncLocalStorage<ContextMap>()

View File

@ -5,6 +5,8 @@ import {
isCloudAccount,
Account,
AccountUserContext,
UserContext,
Ctx,
} from "@budibase/types"
import * as context from "."
@ -16,15 +18,22 @@ export function doInIdentityContext(identity: IdentityContext, task: any) {
return context.doInIdentityContext(identity, task)
}
export function doInUserContext(user: User, task: any) {
const userContext: any = {
// used in server/worker
export function doInUserContext(user: User, ctx: Ctx, task: any) {
const userContext: UserContext = {
...user,
_id: user._id as string,
type: IdentityType.USER,
hostInfo: {
ipAddress: ctx.request.ip,
// filled in by koa-useragent package
userAgent: ctx.userAgent._agent.source,
},
}
return doInIdentityContext(userContext, task)
}
// used in account portal
export function doInAccountContext(account: Account, task: any) {
const _id = getAccountUserId(account)
const tenantId = account.tenantId

View File

@ -11,13 +11,7 @@ import {
DEFAULT_TENANT_ID,
} from "../constants"
import { Database, IdentityContext } from "@budibase/types"
export type ContextMap = {
tenantId?: string
appId?: string
identity?: IdentityContext
environmentVariables?: Record<string, string>
}
import { ContextMap } from "./types"
let TEST_APP_ID: string | null = null
@ -30,14 +24,23 @@ export function getGlobalDBName(tenantId?: string) {
return baseGlobalDBName(tenantId)
}
export function baseGlobalDBName(tenantId: string | undefined | null) {
let dbName
if (!tenantId || tenantId === DEFAULT_TENANT_ID) {
dbName = StaticDatabases.GLOBAL.name
} else {
dbName = `${tenantId}${SEPARATOR}${StaticDatabases.GLOBAL.name}`
export function getAuditLogDBName(tenantId?: string) {
if (!tenantId) {
tenantId = getTenantId()
}
if (tenantId === DEFAULT_TENANT_ID) {
return StaticDatabases.AUDIT_LOGS.name
} else {
return `${tenantId}${SEPARATOR}${StaticDatabases.AUDIT_LOGS.name}`
}
}
export function baseGlobalDBName(tenantId: string | undefined | null) {
if (!tenantId || tenantId === DEFAULT_TENANT_ID) {
return StaticDatabases.GLOBAL.name
} else {
return `${tenantId}${SEPARATOR}${StaticDatabases.GLOBAL.name}`
}
return dbName
}
export function isMultiTenant() {
@ -228,6 +231,13 @@ export function getGlobalDB(): Database {
return getDB(baseGlobalDBName(context?.tenantId))
}
export function getAuditLogsDB(): Database {
if (!getTenantId()) {
throw new Error("No tenant ID found - cannot open audit log DB")
}
return getDB(getAuditLogDBName())
}
/**
* Gets the app database based on whatever the request
* contained, dev or prod.

View File

@ -0,0 +1,9 @@
import { IdentityContext } from "@budibase/types"
// keep this out of Budibase types, don't want to expose context info
export type ContextMap = {
tenantId?: string
appId?: string
identity?: IdentityContext
environmentVariables?: Record<string, string>
}

View File

@ -7,3 +7,4 @@ export { default as Replication } from "./Replication"
// exports to support old export structure
export * from "../constants/db"
export { getGlobalDBName, baseGlobalDBName } from "../context"
export * from "./lucene"

View File

@ -0,0 +1,624 @@
import fetch from "node-fetch"
import { getCouchInfo } from "./couch"
import { SearchFilters, Row } from "@budibase/types"
const QUERY_START_REGEX = /\d[0-9]*:/g
interface SearchResponse<T> {
rows: T[] | any[]
bookmark: string
}
interface PaginatedSearchResponse<T> extends SearchResponse<T> {
hasNextPage: boolean
}
export type SearchParams<T> = {
tableId?: string
sort?: string
sortOrder?: string
sortType?: string
limit?: number
bookmark?: string
version?: string
indexer?: () => Promise<any>
disableEscaping?: boolean
rows?: T | Row[]
}
export function removeKeyNumbering(key: any): string {
if (typeof key === "string" && key.match(QUERY_START_REGEX) != null) {
const parts = key.split(":")
// remove the number
parts.shift()
return parts.join(":")
} else {
return key
}
}
/**
* Class to build lucene query URLs.
* Optionally takes a base lucene query object.
*/
export class QueryBuilder<T> {
dbName: string
index: string
query: SearchFilters
limit: number
sort?: string
bookmark?: string
sortOrder: string
sortType: string
includeDocs: boolean
version?: string
indexBuilder?: () => Promise<any>
noEscaping = false
constructor(dbName: string, index: string, base?: SearchFilters) {
this.dbName = dbName
this.index = index
this.query = {
allOr: false,
string: {},
fuzzy: {},
range: {},
equal: {},
notEqual: {},
empty: {},
notEmpty: {},
oneOf: {},
contains: {},
notContains: {},
containsAny: {},
...base,
}
this.limit = 50
this.sortOrder = "ascending"
this.sortType = "string"
this.includeDocs = true
}
disableEscaping() {
this.noEscaping = true
return this
}
setIndexBuilder(builderFn: () => Promise<any>) {
this.indexBuilder = builderFn
return this
}
setVersion(version?: string) {
if (version != null) {
this.version = version
}
return this
}
setTable(tableId: string) {
this.query.equal!.tableId = tableId
return this
}
setLimit(limit?: number) {
if (limit != null) {
this.limit = limit
}
return this
}
setSort(sort?: string) {
if (sort != null) {
this.sort = sort
}
return this
}
setSortOrder(sortOrder?: string) {
if (sortOrder != null) {
this.sortOrder = sortOrder
}
return this
}
setSortType(sortType?: string) {
if (sortType != null) {
this.sortType = sortType
}
return this
}
setBookmark(bookmark?: string) {
if (bookmark != null) {
this.bookmark = bookmark
}
return this
}
excludeDocs() {
this.includeDocs = false
return this
}
addString(key: string, partial: string) {
this.query.string![key] = partial
return this
}
addFuzzy(key: string, fuzzy: string) {
this.query.fuzzy![key] = fuzzy
return this
}
addRange(key: string, low: string | number, high: string | number) {
this.query.range![key] = {
low,
high,
}
return this
}
addEqual(key: string, value: any) {
this.query.equal![key] = value
return this
}
addNotEqual(key: string, value: any) {
this.query.notEqual![key] = value
return this
}
addEmpty(key: string, value: any) {
this.query.empty![key] = value
return this
}
addNotEmpty(key: string, value: any) {
this.query.notEmpty![key] = value
return this
}
addOneOf(key: string, value: any) {
this.query.oneOf![key] = value
return this
}
addContains(key: string, value: any) {
this.query.contains![key] = value
return this
}
addNotContains(key: string, value: any) {
this.query.notContains![key] = value
return this
}
addContainsAny(key: string, value: any) {
this.query.containsAny![key] = value
return this
}
handleSpaces(input: string) {
if (this.noEscaping) {
return input
} else {
return input.replace(/ /g, "_")
}
}
/**
* Preprocesses a value before going into a lucene search.
* Transforms strings to lowercase and wraps strings and bools in quotes.
* @param value The value to process
* @param options The preprocess options
* @returns {string|*}
*/
preprocess(value: any, { escape, lowercase, wrap, type }: any = {}) {
const hasVersion = !!this.version
// Determine if type needs wrapped
const originalType = typeof value
// Convert to lowercase
if (value && lowercase) {
value = value.toLowerCase ? value.toLowerCase() : value
}
// Escape characters
if (!this.noEscaping && escape && originalType === "string") {
value = `${value}`.replace(/[ #+\-&|!(){}\]^"~*?:\\]/g, "\\$&")
}
// Wrap in quotes
if (originalType === "string" && !isNaN(value) && !type) {
value = `"${value}"`
} else if (hasVersion && wrap) {
value = originalType === "number" ? value : `"${value}"`
}
return value
}
buildSearchQuery() {
const builder = this
let allOr = this.query && this.query.allOr
let query = allOr ? "" : "*:*"
const allPreProcessingOpts = { escape: true, lowercase: true, wrap: true }
let tableId
if (this.query.equal!.tableId) {
tableId = this.query.equal!.tableId
delete this.query.equal!.tableId
}
const equal = (key: string, value: any) => {
// 0 evaluates to false, which means we would return all rows if we don't check it
if (!value && value !== 0) {
return null
}
return `${key}:${builder.preprocess(value, allPreProcessingOpts)}`
}
const contains = (key: string, value: any, mode = "AND") => {
if (Array.isArray(value) && value.length === 0) {
return null
}
if (!Array.isArray(value)) {
return `${key}:${value}`
}
let statement = `${builder.preprocess(value[0], { escape: true })}`
for (let i = 1; i < value.length; i++) {
statement += ` ${mode} ${builder.preprocess(value[i], {
escape: true,
})}`
}
return `${key}:(${statement})`
}
const notContains = (key: string, value: any) => {
// @ts-ignore
const allPrefix = allOr === "" ? "*:* AND" : ""
return allPrefix + "NOT " + contains(key, value)
}
const containsAny = (key: string, value: any) => {
return contains(key, value, "OR")
}
const oneOf = (key: string, value: any) => {
if (!Array.isArray(value)) {
if (typeof value === "string") {
value = value.split(",")
} else {
return ""
}
}
let orStatement = `${builder.preprocess(value[0], allPreProcessingOpts)}`
for (let i = 1; i < value.length; i++) {
orStatement += ` OR ${builder.preprocess(
value[i],
allPreProcessingOpts
)}`
}
return `${key}:(${orStatement})`
}
function build(structure: any, queryFn: any) {
for (let [key, value] of Object.entries(structure)) {
// check for new format - remove numbering if needed
key = removeKeyNumbering(key)
key = builder.preprocess(builder.handleSpaces(key), {
escape: true,
})
const expression = queryFn(key, value)
if (expression == null) {
continue
}
if (query.length > 0) {
query += ` ${allOr ? "OR" : "AND"} `
}
query += expression
}
}
// Construct the actual lucene search query string from JSON structure
if (this.query.string) {
build(this.query.string, (key: string, value: any) => {
if (!value) {
return null
}
value = builder.preprocess(value, {
escape: true,
lowercase: true,
type: "string",
})
return `${key}:${value}*`
})
}
if (this.query.range) {
build(this.query.range, (key: string, value: any) => {
if (!value) {
return null
}
if (value.low == null || value.low === "") {
return null
}
if (value.high == null || value.high === "") {
return null
}
const low = builder.preprocess(value.low, allPreProcessingOpts)
const high = builder.preprocess(value.high, allPreProcessingOpts)
return `${key}:[${low} TO ${high}]`
})
}
if (this.query.fuzzy) {
build(this.query.fuzzy, (key: string, value: any) => {
if (!value) {
return null
}
value = builder.preprocess(value, {
escape: true,
lowercase: true,
type: "fuzzy",
})
return `${key}:${value}~`
})
}
if (this.query.equal) {
build(this.query.equal, equal)
}
if (this.query.notEqual) {
build(this.query.notEqual, (key: string, value: any) => {
if (!value) {
return null
}
return `!${key}:${builder.preprocess(value, allPreProcessingOpts)}`
})
}
if (this.query.empty) {
build(this.query.empty, (key: string) => `!${key}:["" TO *]`)
}
if (this.query.notEmpty) {
build(this.query.notEmpty, (key: string) => `${key}:["" TO *]`)
}
if (this.query.oneOf) {
build(this.query.oneOf, oneOf)
}
if (this.query.contains) {
build(this.query.contains, contains)
}
if (this.query.notContains) {
build(this.query.notContains, notContains)
}
if (this.query.containsAny) {
build(this.query.containsAny, containsAny)
}
// make sure table ID is always added as an AND
if (tableId) {
query = `(${query})`
allOr = false
build({ tableId }, equal)
}
return query
}
buildSearchBody() {
let body: any = {
q: this.buildSearchQuery(),
limit: Math.min(this.limit, 200),
include_docs: this.includeDocs,
}
if (this.bookmark) {
body.bookmark = this.bookmark
}
if (this.sort) {
const order = this.sortOrder === "descending" ? "-" : ""
const type = `<${this.sortType}>`
body.sort = `${order}${this.handleSpaces(this.sort)}${type}`
}
return body
}
async run() {
const { url, cookie } = getCouchInfo()
const fullPath = `${url}/${this.dbName}/_design/database/_search/${this.index}`
const body = this.buildSearchBody()
try {
return await runQuery<T>(fullPath, body, cookie)
} catch (err: any) {
if (err.status === 404 && this.indexBuilder) {
await this.indexBuilder()
return await runQuery<T>(fullPath, body, cookie)
} else {
throw err
}
}
}
}
/**
* Executes a lucene search query.
* @param url The query URL
* @param body The request body defining search criteria
* @param cookie The auth cookie for CouchDB
* @returns {Promise<{rows: []}>}
*/
async function runQuery<T>(
url: string,
body: any,
cookie: string
): Promise<SearchResponse<T>> {
const response = await fetch(url, {
body: JSON.stringify(body),
method: "POST",
headers: {
Authorization: cookie,
},
})
if (response.status === 404) {
throw response
}
const json = await response.json()
let output: any = {
rows: [],
}
if (json.rows != null && json.rows.length > 0) {
output.rows = json.rows.map((row: any) => row.doc)
}
if (json.bookmark) {
output.bookmark = json.bookmark
}
return output
}
/**
* Gets round the fixed limit of 200 results from a query by fetching as many
* pages as required and concatenating the results. This recursively operates
* until enough results have been found.
* @param dbName {string} Which database to run a lucene query on
* @param index {string} Which search index to utilise
* @param query {object} The JSON query structure
* @param params {object} The search params including:
* tableId {string} The table ID to search
* sort {string} The sort column
* sortOrder {string} The sort order ("ascending" or "descending")
* sortType {string} Whether to treat sortable values as strings or
* numbers. ("string" or "number")
* limit {number} The number of results to fetch
* bookmark {string|null} Current bookmark in the recursive search
* rows {array|null} Current results in the recursive search
* @returns {Promise<*[]|*>}
*/
async function recursiveSearch<T>(
dbName: string,
index: string,
query: any,
params: any
): Promise<any> {
const bookmark = params.bookmark
const rows = params.rows || []
if (rows.length >= params.limit) {
return rows
}
let pageSize = 200
if (rows.length > params.limit - 200) {
pageSize = params.limit - rows.length
}
const page = await new QueryBuilder<T>(dbName, index, query)
.setVersion(params.version)
.setTable(params.tableId)
.setBookmark(bookmark)
.setLimit(pageSize)
.setSort(params.sort)
.setSortOrder(params.sortOrder)
.setSortType(params.sortType)
.run()
if (!page.rows.length) {
return rows
}
if (page.rows.length < 200) {
return [...rows, ...page.rows]
}
const newParams = {
...params,
bookmark: page.bookmark,
rows: [...rows, ...page.rows],
}
return await recursiveSearch(dbName, index, query, newParams)
}
/**
* Performs a paginated search. A bookmark will be returned to allow the next
* page to be fetched. There is a max limit off 200 results per page in a
* paginated search.
* @param dbName {string} Which database to run a lucene query on
* @param index {string} Which search index to utilise
* @param query {object} The JSON query structure
* @param params {object} The search params including:
* tableId {string} The table ID to search
* sort {string} The sort column
* sortOrder {string} The sort order ("ascending" or "descending")
* sortType {string} Whether to treat sortable values as strings or
* numbers. ("string" or "number")
* limit {number} The desired page size
* bookmark {string} The bookmark to resume from
* @returns {Promise<{hasNextPage: boolean, rows: *[]}>}
*/
export async function paginatedSearch<T>(
dbName: string,
index: string,
query: SearchFilters,
params: SearchParams<T>
) {
let limit = params.limit
if (limit == null || isNaN(limit) || limit < 0) {
limit = 50
}
limit = Math.min(limit, 200)
const search = new QueryBuilder<T>(dbName, index, query)
if (params.version) {
search.setVersion(params.version)
}
if (params.tableId) {
search.setTable(params.tableId)
}
if (params.sort) {
search
.setSort(params.sort)
.setSortOrder(params.sortOrder)
.setSortType(params.sortType)
}
if (params.indexer) {
search.setIndexBuilder(params.indexer)
}
if (params.disableEscaping) {
search.disableEscaping()
}
const searchResults = await search
.setBookmark(params.bookmark)
.setLimit(limit)
.run()
// Try fetching 1 row in the next page to see if another page of results
// exists or not
search.setBookmark(searchResults.bookmark).setLimit(1)
if (params.tableId) {
search.setTable(params.tableId)
}
const nextResults = await search.run()
return {
...searchResults,
hasNextPage: nextResults.rows && nextResults.rows.length > 0,
}
}
/**
* Performs a full search, fetching multiple pages if required to return the
* desired amount of results. There is a limit of 1000 results to avoid
* heavy performance hits, and to avoid client components breaking from
* handling too much data.
* @param dbName {string} Which database to run a lucene query on
* @param index {string} Which search index to utilise
* @param query {object} The JSON query structure
* @param params {object} The search params including:
* tableId {string} The table ID to search
* sort {string} The sort column
* sortOrder {string} The sort order ("ascending" or "descending")
* sortType {string} Whether to treat sortable values as strings or
* numbers. ("string" or "number")
* limit {number} The desired number of results
* @returns {Promise<{rows: *}>}
*/
export async function fullSearch<T>(
dbName: string,
index: string,
query: SearchFilters,
params: SearchParams<T>
) {
let limit = params.limit
if (limit == null || isNaN(limit) || limit < 0) {
limit = 1000
}
params.limit = Math.min(limit, 1000)
const rows = await recursiveSearch<T>(dbName, index, query, params)
return { rows }
}

View File

@ -0,0 +1,161 @@
import { newid } from "../../newid"
import { getDB } from "../db"
import { Database } from "@budibase/types"
import { QueryBuilder, paginatedSearch, fullSearch } from "../lucene"
const INDEX_NAME = "main"
const index = `function(doc) {
let props = ["property", "number"]
for (let key of props) {
if (doc[key]) {
index(key, doc[key])
}
}
}`
describe("lucene", () => {
let db: Database, dbName: string
beforeAll(async () => {
dbName = `db-${newid()}`
// create the DB for testing
db = getDB(dbName)
await db.put({ _id: newid(), property: "word" })
await db.put({ _id: newid(), property: "word2" })
await db.put({ _id: newid(), property: "word3", number: 1 })
})
it("should be able to create a lucene index", async () => {
const response = await db.put({
_id: "_design/database",
indexes: {
[INDEX_NAME]: {
index: index,
analyzer: "standard",
},
},
})
expect(response.ok).toBe(true)
})
describe("query builder", () => {
it("should be able to perform a basic query", async () => {
const builder = new QueryBuilder(dbName, INDEX_NAME)
builder.setSort("property")
builder.setSortOrder("desc")
builder.setSortType("string")
const resp = await builder.run()
expect(resp.rows.length).toBe(3)
})
it("should handle limits", async () => {
const builder = new QueryBuilder(dbName, INDEX_NAME)
builder.setLimit(1)
const resp = await builder.run()
expect(resp.rows.length).toBe(1)
})
it("should be able to perform a string search", async () => {
const builder = new QueryBuilder(dbName, INDEX_NAME)
builder.addString("property", "wo")
const resp = await builder.run()
expect(resp.rows.length).toBe(3)
})
it("should be able to perform a range search", async () => {
const builder = new QueryBuilder(dbName, INDEX_NAME)
builder.addRange("number", 0, 1)
const resp = await builder.run()
expect(resp.rows.length).toBe(1)
})
it("should be able to perform an equal search", async () => {
const builder = new QueryBuilder(dbName, INDEX_NAME)
builder.addEqual("property", "word2")
const resp = await builder.run()
expect(resp.rows.length).toBe(1)
})
it("should be able to perform a not equal search", async () => {
const builder = new QueryBuilder(dbName, INDEX_NAME)
builder.addNotEqual("property", "word2")
const resp = await builder.run()
expect(resp.rows.length).toBe(2)
})
it("should be able to perform an empty search", async () => {
const builder = new QueryBuilder(dbName, INDEX_NAME)
builder.addEmpty("number", true)
const resp = await builder.run()
expect(resp.rows.length).toBe(2)
})
it("should be able to perform a not empty search", async () => {
const builder = new QueryBuilder(dbName, INDEX_NAME)
builder.addNotEmpty("number", true)
const resp = await builder.run()
expect(resp.rows.length).toBe(1)
})
it("should be able to perform a one of search", async () => {
const builder = new QueryBuilder(dbName, INDEX_NAME)
builder.addOneOf("property", ["word", "word2"])
const resp = await builder.run()
expect(resp.rows.length).toBe(2)
})
it("should be able to perform a contains search", async () => {
const builder = new QueryBuilder(dbName, INDEX_NAME)
builder.addContains("property", ["word"])
const resp = await builder.run()
expect(resp.rows.length).toBe(1)
})
it("should be able to perform a not contains search", async () => {
const builder = new QueryBuilder(dbName, INDEX_NAME)
builder.addNotContains("property", ["word2"])
const resp = await builder.run()
expect(resp.rows.length).toBe(2)
})
})
describe("paginated search", () => {
it("should be able to perform a paginated search", async () => {
const page = await paginatedSearch(
dbName,
INDEX_NAME,
{
string: {
property: "wo",
},
},
{
limit: 1,
sort: "property",
sortType: "string",
sortOrder: "desc",
}
)
expect(page.rows.length).toBe(1)
expect(page.hasNextPage).toBe(true)
expect(page.bookmark).toBeDefined()
})
})
describe("full search", () => {
it("should be able to perform a full search", async () => {
const page = await fullSearch(
dbName,
INDEX_NAME,
{
string: {
property: "wo",
},
},
{}
)
expect(page.rows.length).toBe(3)
})
})
})

View File

@ -1,19 +1,13 @@
import { generator, DBTestConfiguration, testEnv } from "../../../tests"
import {
getDevelopmentAppID,
getProdAppID,
isDevAppID,
isProdAppID,
} from "../conversions"
import { generateAppID, getPlatformUrl, getScopedConfig } from "../utils"
import * as context from "../../context"
import { Config } from "../../constants"
import env from "../../environment"
import { generateAppID } from "../utils"
describe("utils", () => {
const config = new DBTestConfiguration()
describe("app ID manipulation", () => {
describe("generateAppID", () => {
function getID() {
const appId = generateAppID()
const split = appId.split("_")
@ -66,127 +60,4 @@ describe("utils", () => {
expect(isProdAppID(devAppId)).toEqual(false)
})
})
const DEFAULT_URL = "http://localhost:10000"
const ENV_URL = "http://env.com"
const setDbPlatformUrl = async (dbUrl: string) => {
const db = context.getGlobalDB()
await db.put({
_id: "config_settings",
type: Config.SETTINGS,
config: {
platformUrl: dbUrl,
},
})
}
const clearSettingsConfig = async () => {
await config.doInTenant(async () => {
const db = context.getGlobalDB()
try {
const config = await db.get("config_settings")
await db.remove("config_settings", config._rev)
} catch (e: any) {
if (e.status !== 404) {
throw e
}
}
})
}
describe("getPlatformUrl", () => {
describe("self host", () => {
beforeEach(async () => {
testEnv.selfHosted()
await clearSettingsConfig()
})
it("gets the default url", async () => {
await config.doInTenant(async () => {
const url = await getPlatformUrl()
expect(url).toBe(DEFAULT_URL)
})
})
it("gets the platform url from the environment", async () => {
await config.doInTenant(async () => {
env._set("PLATFORM_URL", ENV_URL)
const url = await getPlatformUrl()
expect(url).toBe(ENV_URL)
})
})
it("gets the platform url from the database", async () => {
await config.doInTenant(async () => {
const dbUrl = generator.url()
await setDbPlatformUrl(dbUrl)
const url = await getPlatformUrl()
expect(url).toBe(dbUrl)
})
})
})
describe("cloud", () => {
const TENANT_AWARE_URL = `http://${config.tenantId}.env.com`
beforeEach(async () => {
testEnv.cloudHosted()
testEnv.multiTenant()
env._set("PLATFORM_URL", ENV_URL)
await clearSettingsConfig()
})
it("gets the platform url from the environment without tenancy", async () => {
await config.doInTenant(async () => {
const url = await getPlatformUrl({ tenantAware: false })
expect(url).toBe(ENV_URL)
})
})
it("gets the platform url from the environment with tenancy", async () => {
await config.doInTenant(async () => {
const url = await getPlatformUrl()
expect(url).toBe(TENANT_AWARE_URL)
})
})
it("never gets the platform url from the database", async () => {
await config.doInTenant(async () => {
await setDbPlatformUrl(generator.url())
const url = await getPlatformUrl()
expect(url).toBe(TENANT_AWARE_URL)
})
})
})
})
describe("getScopedConfig", () => {
describe("settings config", () => {
beforeEach(async () => {
env._set("SELF_HOSTED", 1)
env._set("PLATFORM_URL", "")
await clearSettingsConfig()
})
it("returns the platform url with an existing config", async () => {
await config.doInTenant(async () => {
const dbUrl = generator.url()
await setDbPlatformUrl(dbUrl)
const db = context.getGlobalDB()
const config = await getScopedConfig(db, { type: Config.SETTINGS })
expect(config.platformUrl).toBe(dbUrl)
})
})
it("returns the platform url without an existing config", async () => {
await config.doInTenant(async () => {
const db = context.getGlobalDB()
const config = await getScopedConfig(db, { type: Config.SETTINGS })
expect(config.platformUrl).toBe(DEFAULT_URL)
})
})
})
})
})

View File

@ -9,12 +9,11 @@ import {
InternalTable,
APP_PREFIX,
} from "../constants"
import { getTenantId, getGlobalDB, getGlobalDBName } from "../context"
import { getTenantId, getGlobalDBName } from "../context"
import { doWithDB, directCouchAllDbs } from "./db"
import { getAppMetadata } from "../cache/appMetadata"
import { isDevApp, isDevAppID, getProdAppID } from "./conversions"
import * as events from "../events"
import { App, Database, ConfigType, isSettingsConfig } from "@budibase/types"
import { App, Database } from "@budibase/types"
/**
* Generates a new app ID.
@ -366,6 +365,16 @@ export async function getAllApps({
}
}
export async function getAppsByIDs(appIds: string[]) {
const settled = await Promise.allSettled(
appIds.map(appId => getAppMetadata(appId))
)
// have to list the apps which exist, some may have been deleted
return settled
.filter(promise => promise.status === "fulfilled")
.map(promise => (promise as PromiseFulfilledResult<App>).value)
}
/**
* Utility function for getAllApps but filters to production apps only.
*/
@ -382,6 +391,16 @@ export async function getDevAppIDs() {
return apps.filter((id: any) => isDevAppID(id))
}
export function isSameAppID(
appId1: string | undefined,
appId2: string | undefined
) {
if (appId1 == undefined || appId2 == undefined) {
return false
}
return getProdAppID(appId1) === getProdAppID(appId2)
}
export async function dbExists(dbName: any) {
return doWithDB(
dbName,
@ -392,32 +411,6 @@ export async function dbExists(dbName: any) {
)
}
/**
* Generates a new configuration ID.
* @returns {string} The new configuration ID which the config doc can be stored under.
*/
export const generateConfigID = ({ type, workspace, user }: any) => {
const scope = [type, workspace, user].filter(Boolean).join(SEPARATOR)
return `${DocumentType.CONFIG}${SEPARATOR}${scope}`
}
/**
* Gets parameters for retrieving configurations.
*/
export const getConfigParams = (
{ type, workspace, user }: any,
otherProps = {}
) => {
const scope = [type, workspace, user].filter(Boolean).join(SEPARATOR)
return {
...otherProps,
startkey: `${DocumentType.CONFIG}${SEPARATOR}${scope}`,
endkey: `${DocumentType.CONFIG}${SEPARATOR}${scope}${UNICODE_MAX}`,
}
}
/**
* Generates a new dev info document ID - this is scoped to a user.
* @returns {string} The new dev info ID which info for dev (like api key) can be stored under.
@ -441,109 +434,6 @@ export const getPluginParams = (pluginId?: string | null, otherProps = {}) => {
return getDocParams(DocumentType.PLUGIN, pluginId, otherProps)
}
/**
* Returns the most granular configuration document from the DB based on the type, workspace and userID passed.
* @param {Object} db - db instance to query
* @param {Object} scopes - the type, workspace and userID scopes of the configuration.
* @returns The most granular configuration document based on the scope.
*/
export const getScopedFullConfig = async function (
db: any,
{ type, user, workspace }: any
) {
const response = await db.allDocs(
getConfigParams(
{ type, user, workspace },
{
include_docs: true,
}
)
)
function determineScore(row: any) {
const config = row.doc
// Config is specific to a user and a workspace
if (config._id.includes(generateConfigID({ type, user, workspace }))) {
return 4
} else if (config._id.includes(generateConfigID({ type, user }))) {
// Config is specific to a user only
return 3
} else if (config._id.includes(generateConfigID({ type, workspace }))) {
// Config is specific to a workspace only
return 2
} else if (config._id.includes(generateConfigID({ type }))) {
// Config is specific to a type only
return 1
}
return 0
}
// Find the config with the most granular scope based on context
let scopedConfig = response.rows.sort(
(a: any, b: any) => determineScore(a) - determineScore(b)
)[0]
// custom logic for settings doc
if (type === ConfigType.SETTINGS) {
if (!scopedConfig || !scopedConfig.doc) {
// defaults
scopedConfig = {
doc: {
_id: generateConfigID({ type, user, workspace }),
type: ConfigType.SETTINGS,
config: {
platformUrl: await getPlatformUrl({ tenantAware: true }),
analyticsEnabled: await events.analytics.enabled(),
},
},
}
}
// will always be true - use assertion function to get type access
if (isSettingsConfig(scopedConfig.doc)) {
// overrides affected by environment
scopedConfig.doc.config.platformUrl = await getPlatformUrl({
tenantAware: true,
})
scopedConfig.doc.config.analyticsEnabled =
await events.analytics.enabled()
}
}
return scopedConfig && scopedConfig.doc
}
export const getPlatformUrl = async (opts = { tenantAware: true }) => {
let platformUrl = env.PLATFORM_URL || "http://localhost:10000"
if (!env.SELF_HOSTED && env.MULTI_TENANCY && opts.tenantAware) {
// cloud and multi tenant - add the tenant to the default platform url
const tenantId = getTenantId()
if (!platformUrl.includes("localhost:")) {
platformUrl = platformUrl.replace("://", `://${tenantId}.`)
}
} else if (env.SELF_HOSTED) {
const db = getGlobalDB()
// get the doc directly instead of with getScopedConfig to prevent loop
let settings
try {
settings = await db.get(generateConfigID({ type: ConfigType.SETTINGS }))
} catch (e: any) {
if (e.status !== 404) {
throw e
}
}
// self hosted - check for platform url override
if (settings && settings.config && settings.config.platformUrl) {
platformUrl = settings.config.platformUrl
}
}
return platformUrl
}
export function pagination(
data: any[],
pageSize: number,
@ -577,8 +467,3 @@ export function pagination(
nextPage,
}
}
export async function getScopedConfig(db: any, params: any) {
const configDoc = await getScopedFullConfig(db, params)
return configDoc && configDoc.config ? configDoc.config : configDoc
}

View File

@ -28,6 +28,8 @@ const DefaultBucketName = {
PLUGINS: "plugins",
}
const selfHosted = !!parseInt(process.env.SELF_HOSTED || "")
const environment = {
isTest,
isJest,
@ -58,7 +60,7 @@ const environment = {
process.env.ACCOUNT_PORTAL_URL || "https://account.budibase.app",
ACCOUNT_PORTAL_API_KEY: process.env.ACCOUNT_PORTAL_API_KEY || "",
DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL,
SELF_HOSTED: !!parseInt(process.env.SELF_HOSTED || ""),
SELF_HOSTED: selfHosted,
COOKIE_DOMAIN: process.env.COOKIE_DOMAIN,
PLATFORM_URL: process.env.PLATFORM_URL || "",
POSTHOG_TOKEN: process.env.POSTHOG_TOKEN,
@ -84,6 +86,22 @@ const environment = {
DEPLOYMENT_ENVIRONMENT:
process.env.DEPLOYMENT_ENVIRONMENT || "docker-compose",
ENABLE_4XX_HTTP_LOGGING: process.env.ENABLE_4XX_HTTP_LOGGING || true,
ENABLE_AUDIT_LOG_IP_ADDR: process.env.ENABLE_AUDIT_LOG_IP_ADDR,
// smtp
SMTP_FALLBACK_ENABLED: process.env.SMTP_FALLBACK_ENABLED,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASSWORD: process.env.SMTP_PASSWORD,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: parseInt(process.env.SMTP_PORT || ""),
SMTP_FROM_ADDRESS: process.env.SMTP_FROM_ADDRESS,
/**
* Enable to allow an admin user to login using a password.
* This can be useful to prevent lockout when configuring SSO.
* However, this should be turned OFF by default for security purposes.
*/
ENABLE_SSO_MAINTENANCE_MODE: selfHosted
? process.env.ENABLE_SSO_MAINTENANCE_MODE
: false,
_set(key: any, value: any) {
process.env[key] = value
// @ts-ignore

View File

@ -1,55 +1,6 @@
import env from "../environment"
import * as context from "../context"
import * as dbUtils from "../db/utils"
import { Config } from "../constants"
import { withCache, TTL, CacheKey } from "../cache"
import * as configs from "../configs"
// wrapper utility function
export const enabled = async () => {
// cloud - always use the environment variable
if (!env.SELF_HOSTED) {
return !!env.ENABLE_ANALYTICS
}
// self host - prefer the settings doc
// use cache as events have high throughput
const enabledInDB = await withCache(
CacheKey.ANALYTICS_ENABLED,
TTL.ONE_DAY,
async () => {
const settings = await getSettingsDoc()
// need to do explicit checks in case the field is not set
if (settings?.config?.analyticsEnabled === false) {
return false
} else if (settings?.config?.analyticsEnabled === true) {
return true
}
}
)
if (enabledInDB !== undefined) {
return enabledInDB
}
// fallback to the environment variable
// explicitly check for 0 or false here, undefined or otherwise is treated as true
const envEnabled: any = env.ENABLE_ANALYTICS
if (envEnabled === 0 || envEnabled === false) {
return false
} else {
return true
}
}
const getSettingsDoc = async () => {
const db = context.getGlobalDB()
let settings
try {
settings = await db.get(dbUtils.generateConfigID({ type: Config.SETTINGS }))
} catch (e: any) {
if (e.status !== 404) {
throw e
}
}
return settings
return configs.analyticsEnabled()
}

View File

@ -1,4 +1,4 @@
import { Event } from "@budibase/types"
import { Event, AuditedEventFriendlyName } from "@budibase/types"
import { processors } from "./processors"
import identification from "./identification"
import * as backfill from "./backfill"

View File

@ -10,7 +10,6 @@ import {
isCloudAccount,
isSSOAccount,
TenantGroup,
SettingsConfig,
CloudAccount,
UserIdentity,
InstallationGroup,
@ -19,10 +18,9 @@ import {
isSSOUser,
} from "@budibase/types"
import { processors } from "./processors"
import * as dbUtils from "../db/utils"
import { Config } from "../constants"
import { newid } from "../utils"
import * as installation from "../installation"
import * as configs from "../configs"
import { withCache, TTL, CacheKey } from "../cache/generic"
const pkg = require("../../package.json")
@ -89,6 +87,7 @@ const getCurrentIdentity = async (): Promise<Identity> => {
installationId,
tenantId,
environment,
hostInfo: userContext.hostInfo,
}
} else {
throw new Error("Unknown identity type")
@ -270,9 +269,7 @@ const getUniqueTenantId = async (tenantId: string): Promise<string> => {
return context.doInTenant(tenantId, () => {
return withCache(CacheKey.UNIQUE_TENANT_ID, TTL.ONE_DAY, async () => {
const db = context.getGlobalDB()
const config: SettingsConfig = await dbUtils.getScopedFullConfig(db, {
type: Config.SETTINGS,
})
const config = await configs.getSettingsConfigDoc()
let uniqueTenantId: string
if (config.config.uniqueTenantId) {

View File

@ -0,0 +1,90 @@
import {
Event,
Identity,
Group,
IdentityType,
AuditLogQueueEvent,
AuditLogFn,
HostInfo,
} from "@budibase/types"
import { EventProcessor } from "./types"
import { getAppId } from "../../context"
import BullQueue from "bull"
import { createQueue, JobQueue } from "../../queue"
import { isAudited } from "../../utils"
import env from "../../environment"
export default class AuditLogsProcessor implements EventProcessor {
static auditLogsEnabled = false
static auditLogQueue: BullQueue.Queue<AuditLogQueueEvent>
// can't use constructor as need to return promise
static init(fn: AuditLogFn) {
AuditLogsProcessor.auditLogsEnabled = true
const writeAuditLogs = fn
AuditLogsProcessor.auditLogQueue = createQueue<AuditLogQueueEvent>(
JobQueue.AUDIT_LOG
)
return AuditLogsProcessor.auditLogQueue.process(async job => {
let properties = job.data.properties
if (properties.audited) {
properties = {
...properties,
...properties.audited,
}
delete properties.audited
}
// this feature is disabled by default due to privacy requirements
// in some countries - available as env var in-case it is desired
// in self host deployments
let hostInfo: HostInfo | undefined = {}
if (env.ENABLE_AUDIT_LOG_IP_ADDR) {
hostInfo = job.data.opts.hostInfo
}
await writeAuditLogs(job.data.event, properties, {
userId: job.data.opts.userId,
timestamp: job.data.opts.timestamp,
appId: job.data.opts.appId,
hostInfo,
})
})
}
async processEvent(
event: Event,
identity: Identity,
properties: any,
timestamp?: string
): Promise<void> {
if (AuditLogsProcessor.auditLogsEnabled && isAudited(event)) {
// only audit log actual events, don't include backfills
const userId =
identity.type === IdentityType.USER ? identity.id : undefined
// add to the event queue, rather than just writing immediately
await AuditLogsProcessor.auditLogQueue.add({
event,
properties,
opts: {
userId,
timestamp,
appId: getAppId(),
hostInfo: identity.hostInfo,
},
})
}
}
async identify(identity: Identity, timestamp?: string | number) {
// no-op
}
async identifyGroup(group: Group, timestamp?: string | number) {
// no-op
}
shutdown(): void {
AuditLogsProcessor.auditLogQueue?.close()
}
}

View File

@ -1,8 +1,19 @@
import AnalyticsProcessor from "./AnalyticsProcessor"
import LoggingProcessor from "./LoggingProcessor"
import AuditLogsProcessor from "./AuditLogsProcessor"
import Processors from "./Processors"
import { AuditLogFn } from "@budibase/types"
export const analyticsProcessor = new AnalyticsProcessor()
const loggingProcessor = new LoggingProcessor()
const auditLogsProcessor = new AuditLogsProcessor()
export const processors = new Processors([analyticsProcessor, loggingProcessor])
export function init(auditingFn: AuditLogFn) {
return AuditLogsProcessor.init(auditingFn)
}
export const processors = new Processors([
analyticsProcessor,
loggingProcessor,
auditLogsProcessor,
])

View File

@ -47,6 +47,8 @@ export default class PosthogProcessor implements EventProcessor {
return
}
properties = this.clearPIIProperties(properties)
properties.version = pkg.version
properties.service = env.SERVICE
properties.environment = identity.environment
@ -79,6 +81,16 @@ export default class PosthogProcessor implements EventProcessor {
this.posthog.capture(payload)
}
clearPIIProperties(properties: any) {
if (properties.email) {
delete properties.email
}
if (properties.audited) {
delete properties.audited
}
return properties
}
async identify(identity: Identity, timestamp?: string | number) {
const payload: any = { distinctId: identity.id, properties: identity }
if (timestamp) {

View File

@ -49,6 +49,25 @@ describe("PosthogProcessor", () => {
expect(processor.posthog.capture).toHaveBeenCalledTimes(0)
})
it("removes audited information", async () => {
const processor = new PosthogProcessor("test")
const identity = newIdentity()
const properties = {
email: "test",
audited: {
name: "test",
},
}
await processor.processEvent(Event.USER_CREATED, identity, properties)
expect(processor.posthog.capture).toHaveBeenCalled()
// @ts-ignore
const call = processor.posthog.capture.mock.calls[0][0]
expect(call.properties.audited).toBeUndefined()
expect(call.properties.email).toBeUndefined()
})
describe("rate limiting", () => {
it("sends daily event once in same day", async () => {
const processor = new PosthogProcessor("test")

View File

@ -19,6 +19,9 @@ const created = async (app: App, timestamp?: string | number) => {
const properties: AppCreatedEvent = {
appId: app.appId,
version: app.version,
audited: {
name: app.name,
},
}
await publishEvent(Event.APP_CREATED, properties, timestamp)
}
@ -27,6 +30,9 @@ async function updated(app: App) {
const properties: AppUpdatedEvent = {
appId: app.appId,
version: app.version,
audited: {
name: app.name,
},
}
await publishEvent(Event.APP_UPDATED, properties)
}
@ -34,6 +40,9 @@ async function updated(app: App) {
async function deleted(app: App) {
const properties: AppDeletedEvent = {
appId: app.appId,
audited: {
name: app.name,
},
}
await publishEvent(Event.APP_DELETED, properties)
}
@ -41,6 +50,9 @@ async function deleted(app: App) {
async function published(app: App, timestamp?: string | number) {
const properties: AppPublishedEvent = {
appId: app.appId,
audited: {
name: app.name,
},
}
await publishEvent(Event.APP_PUBLISHED, properties, timestamp)
}
@ -48,6 +60,9 @@ async function published(app: App, timestamp?: string | number) {
async function unpublished(app: App) {
const properties: AppUnpublishedEvent = {
appId: app.appId,
audited: {
name: app.name,
},
}
await publishEvent(Event.APP_UNPUBLISHED, properties)
}
@ -55,6 +70,9 @@ async function unpublished(app: App) {
async function fileImported(app: App) {
const properties: AppFileImportedEvent = {
appId: app.appId,
audited: {
name: app.name,
},
}
await publishEvent(Event.APP_FILE_IMPORTED, properties)
}
@ -63,6 +81,9 @@ async function templateImported(app: App, templateKey: string) {
const properties: AppTemplateImportedEvent = {
appId: app.appId,
templateKey,
audited: {
name: app.name,
},
}
await publishEvent(Event.APP_TEMPLATE_IMPORTED, properties)
}
@ -76,6 +97,9 @@ async function versionUpdated(
appId: app.appId,
currentVersion,
updatedToVersion,
audited: {
name: app.name,
},
}
await publishEvent(Event.APP_VERSION_UPDATED, properties)
}
@ -89,6 +113,9 @@ async function versionReverted(
appId: app.appId,
currentVersion,
revertedToVersion,
audited: {
name: app.name,
},
}
await publishEvent(Event.APP_VERSION_REVERTED, properties)
}
@ -96,6 +123,9 @@ async function versionReverted(
async function reverted(app: App) {
const properties: AppRevertedEvent = {
appId: app.appId,
audited: {
name: app.name,
},
}
await publishEvent(Event.APP_REVERTED, properties)
}
@ -103,6 +133,9 @@ async function reverted(app: App) {
async function exported(app: App) {
const properties: AppExportedEvent = {
appId: app.appId,
audited: {
name: app.name,
},
}
await publishEvent(Event.APP_EXPORTED, properties)
}

View File

@ -0,0 +1,26 @@
import {
Event,
AuditLogSearchParams,
AuditLogFilteredEvent,
AuditLogDownloadedEvent,
} from "@budibase/types"
import { publishEvent } from "../events"
async function filtered(search: AuditLogSearchParams) {
const properties: AuditLogFilteredEvent = {
filters: search,
}
await publishEvent(Event.AUDIT_LOGS_FILTERED, properties)
}
async function downloaded(search: AuditLogSearchParams) {
const properties: AuditLogDownloadedEvent = {
filters: search,
}
await publishEvent(Event.AUDIT_LOGS_DOWNLOADED, properties)
}
export default {
filtered,
downloaded,
}

View File

@ -12,19 +12,25 @@ import {
} from "@budibase/types"
import { identification } from ".."
async function login(source: LoginSource) {
async function login(source: LoginSource, email: string) {
const identity = await identification.getCurrentIdentity()
const properties: LoginEvent = {
userId: identity.id,
source,
audited: {
email,
},
}
await publishEvent(Event.AUTH_LOGIN, properties)
}
async function logout() {
async function logout(email?: string) {
const identity = await identification.getCurrentIdentity()
const properties: LogoutEvent = {
userId: identity.id,
audited: {
email,
},
}
await publishEvent(Event.AUTH_LOGOUT, properties)
}

View File

@ -18,6 +18,9 @@ async function created(automation: Automation, timestamp?: string | number) {
automationId: automation._id as string,
triggerId: automation.definition?.trigger?.id,
triggerType: automation.definition?.trigger?.stepId,
audited: {
name: automation.name,
},
}
await publishEvent(Event.AUTOMATION_CREATED, properties, timestamp)
}
@ -38,6 +41,9 @@ async function deleted(automation: Automation) {
automationId: automation._id as string,
triggerId: automation.definition?.trigger?.id,
triggerType: automation.definition?.trigger?.stepId,
audited: {
name: automation.name,
},
}
await publishEvent(Event.AUTOMATION_DELETED, properties)
}
@ -71,6 +77,9 @@ async function stepCreated(
triggerType: automation.definition?.trigger?.stepId,
stepId: step.id!,
stepType: step.stepId,
audited: {
name: automation.name,
},
}
await publishEvent(Event.AUTOMATION_STEP_CREATED, properties, timestamp)
}
@ -83,6 +92,9 @@ async function stepDeleted(automation: Automation, step: AutomationStep) {
triggerType: automation.definition?.trigger?.stepId,
stepId: step.id!,
stepType: step.stepId,
audited: {
name: automation.name,
},
}
await publishEvent(Event.AUTOMATION_STEP_DELETED, properties)
}

View File

@ -13,6 +13,7 @@ async function appBackupRestored(backup: AppBackup) {
appId: backup.appId,
restoreId: backup._id!,
backupCreatedAt: backup.timestamp,
name: backup.name as string,
}
await publishEvent(Event.APP_BACKUP_RESTORED, properties)
@ -22,13 +23,15 @@ async function appBackupTriggered(
appId: string,
backupId: string,
type: AppBackupType,
trigger: AppBackupTrigger
trigger: AppBackupTrigger,
name: string
) {
const properties: AppBackupTriggeredEvent = {
appId: appId,
backupId,
type,
trigger,
name,
}
await publishEvent(Event.APP_BACKUP_TRIGGERED, properties)
}

View File

@ -8,12 +8,16 @@ import {
GroupUsersAddedEvent,
GroupUsersDeletedEvent,
GroupAddedOnboardingEvent,
GroupPermissionsEditedEvent,
UserGroupRoles,
} from "@budibase/types"
async function created(group: UserGroup, timestamp?: number) {
const properties: GroupCreatedEvent = {
groupId: group._id as string,
audited: {
name: group.name,
},
}
await publishEvent(Event.USER_GROUP_CREATED, properties, timestamp)
}
@ -21,6 +25,9 @@ async function created(group: UserGroup, timestamp?: number) {
async function updated(group: UserGroup) {
const properties: GroupUpdatedEvent = {
groupId: group._id as string,
audited: {
name: group.name,
},
}
await publishEvent(Event.USER_GROUP_UPDATED, properties)
}
@ -28,6 +35,9 @@ async function updated(group: UserGroup) {
async function deleted(group: UserGroup) {
const properties: GroupDeletedEvent = {
groupId: group._id as string,
audited: {
name: group.name,
},
}
await publishEvent(Event.USER_GROUP_DELETED, properties)
}
@ -36,6 +46,9 @@ async function usersAdded(count: number, group: UserGroup) {
const properties: GroupUsersAddedEvent = {
count,
groupId: group._id as string,
audited: {
name: group.name,
},
}
await publishEvent(Event.USER_GROUP_USERS_ADDED, properties)
}
@ -44,6 +57,9 @@ async function usersDeleted(count: number, group: UserGroup) {
const properties: GroupUsersDeletedEvent = {
count,
groupId: group._id as string,
audited: {
name: group.name,
},
}
await publishEvent(Event.USER_GROUP_USERS_REMOVED, properties)
}
@ -56,9 +72,13 @@ async function createdOnboarding(groupId: string) {
await publishEvent(Event.USER_GROUP_ONBOARDING, properties)
}
async function permissionsEdited(roles: UserGroupRoles) {
const properties: UserGroupRoles = {
...roles,
async function permissionsEdited(group: UserGroup) {
const properties: GroupPermissionsEditedEvent = {
permissions: group.roles!,
groupId: group._id as string,
audited: {
name: group.name,
},
}
await publishEvent(Event.USER_GROUP_PERMISSIONS_EDITED, properties)
}

View File

@ -21,3 +21,4 @@ export { default as group } from "./group"
export { default as plugin } from "./plugin"
export { default as backup } from "./backup"
export { default as environmentVariable } from "./environmentVariable"
export { default as auditLog } from "./auditLog"

View File

@ -11,6 +11,9 @@ async function created(screen: Screen, timestamp?: string | number) {
layoutId: screen.layoutId,
screenId: screen._id as string,
roleId: screen.routing.roleId,
audited: {
name: screen.routing?.route,
},
}
await publishEvent(Event.SCREEN_CREATED, properties, timestamp)
}
@ -20,6 +23,9 @@ async function deleted(screen: Screen) {
layoutId: screen.layoutId,
screenId: screen._id as string,
roleId: screen.routing.roleId,
audited: {
name: screen.routing?.route,
},
}
await publishEvent(Event.SCREEN_DELETED, properties)
}

View File

@ -13,6 +13,9 @@ import {
async function created(table: Table, timestamp?: string | number) {
const properties: TableCreatedEvent = {
tableId: table._id as string,
audited: {
name: table.name,
},
}
await publishEvent(Event.TABLE_CREATED, properties, timestamp)
}
@ -20,6 +23,9 @@ async function created(table: Table, timestamp?: string | number) {
async function updated(table: Table) {
const properties: TableUpdatedEvent = {
tableId: table._id as string,
audited: {
name: table.name,
},
}
await publishEvent(Event.TABLE_UPDATED, properties)
}
@ -27,6 +33,9 @@ async function updated(table: Table) {
async function deleted(table: Table) {
const properties: TableDeletedEvent = {
tableId: table._id as string,
audited: {
name: table.name,
},
}
await publishEvent(Event.TABLE_DELETED, properties)
}
@ -35,6 +44,9 @@ async function exported(table: Table, format: TableExportFormat) {
const properties: TableExportedEvent = {
tableId: table._id as string,
format,
audited: {
name: table.name,
},
}
await publishEvent(Event.TABLE_EXPORTED, properties)
}
@ -42,6 +54,9 @@ async function exported(table: Table, format: TableExportFormat) {
async function imported(table: Table) {
const properties: TableImportedEvent = {
tableId: table._id as string,
audited: {
name: table.name,
},
}
await publishEvent(Event.TABLE_IMPORTED, properties)
}

View File

@ -19,6 +19,9 @@ import {
async function created(user: User, timestamp?: number) {
const properties: UserCreatedEvent = {
userId: user._id as string,
audited: {
email: user.email,
},
}
await publishEvent(Event.USER_CREATED, properties, timestamp)
}
@ -26,6 +29,9 @@ async function created(user: User, timestamp?: number) {
async function updated(user: User) {
const properties: UserUpdatedEvent = {
userId: user._id as string,
audited: {
email: user.email,
},
}
await publishEvent(Event.USER_UPDATED, properties)
}
@ -33,6 +39,9 @@ async function updated(user: User) {
async function deleted(user: User) {
const properties: UserDeletedEvent = {
userId: user._id as string,
audited: {
email: user.email,
},
}
await publishEvent(Event.USER_DELETED, properties)
}
@ -40,6 +49,9 @@ async function deleted(user: User) {
export async function onboardingComplete(user: User) {
const properties: UserOnboardingEvent = {
userId: user._id as string,
audited: {
email: user.email,
},
}
await publishEvent(Event.USER_ONBOARDING_COMPLETE, properties)
}
@ -49,6 +61,9 @@ export async function onboardingComplete(user: User) {
async function permissionAdminAssigned(user: User, timestamp?: number) {
const properties: UserPermissionAssignedEvent = {
userId: user._id as string,
audited: {
email: user.email,
},
}
await publishEvent(
Event.USER_PERMISSION_ADMIN_ASSIGNED,
@ -60,6 +75,9 @@ async function permissionAdminAssigned(user: User, timestamp?: number) {
async function permissionAdminRemoved(user: User) {
const properties: UserPermissionRemovedEvent = {
userId: user._id as string,
audited: {
email: user.email,
},
}
await publishEvent(Event.USER_PERMISSION_ADMIN_REMOVED, properties)
}
@ -67,6 +85,9 @@ async function permissionAdminRemoved(user: User) {
async function permissionBuilderAssigned(user: User, timestamp?: number) {
const properties: UserPermissionAssignedEvent = {
userId: user._id as string,
audited: {
email: user.email,
},
}
await publishEvent(
Event.USER_PERMISSION_BUILDER_ASSIGNED,
@ -78,20 +99,30 @@ async function permissionBuilderAssigned(user: User, timestamp?: number) {
async function permissionBuilderRemoved(user: User) {
const properties: UserPermissionRemovedEvent = {
userId: user._id as string,
audited: {
email: user.email,
},
}
await publishEvent(Event.USER_PERMISSION_BUILDER_REMOVED, properties)
}
// INVITE
async function invited() {
const properties: UserInvitedEvent = {}
async function invited(email: string) {
const properties: UserInvitedEvent = {
audited: {
email,
},
}
await publishEvent(Event.USER_INVITED, properties)
}
async function inviteAccepted(user: User) {
const properties: UserInviteAcceptedEvent = {
userId: user._id as string,
audited: {
email: user.email,
},
}
await publishEvent(Event.USER_INVITED_ACCEPTED, properties)
}
@ -101,6 +132,9 @@ async function inviteAccepted(user: User) {
async function passwordForceReset(user: User) {
const properties: UserPasswordForceResetEvent = {
userId: user._id as string,
audited: {
email: user.email,
},
}
await publishEvent(Event.USER_PASSWORD_FORCE_RESET, properties)
}
@ -108,6 +142,9 @@ async function passwordForceReset(user: User) {
async function passwordUpdated(user: User) {
const properties: UserPasswordUpdatedEvent = {
userId: user._id as string,
audited: {
email: user.email,
},
}
await publishEvent(Event.USER_PASSWORD_UPDATED, properties)
}
@ -115,6 +152,9 @@ async function passwordUpdated(user: User) {
async function passwordResetRequested(user: User) {
const properties: UserPasswordResetRequestedEvent = {
userId: user._id as string,
audited: {
email: user.email,
},
}
await publishEvent(Event.USER_PASSWORD_RESET_REQUESTED, properties)
}
@ -122,6 +162,9 @@ async function passwordResetRequested(user: User) {
async function passwordReset(user: User) {
const properties: UserPasswordResetEvent = {
userId: user._id as string,
audited: {
email: user.email,
},
}
await publishEvent(Event.USER_PASSWORD_RESET, properties)
}

View File

@ -1,3 +1,4 @@
export * as configs from "./configs"
export * as events from "./events"
export * as migrations from "./migrations"
export * as users from "./users"
@ -20,11 +21,11 @@ export * as context from "./context"
export * as cache from "./cache"
export * as objectStore from "./objectStore"
export * as redis from "./redis"
export * as locks from "./redis/redlock"
export * as locks from "./redis/redlockImpl"
export * as utils from "./utils"
export * as errors from "./errors"
export { default as env } from "./environment"
export { SearchParams } from "./db"
// Add context to tenancy for backwards compatibility
// only do this for external usages to prevent internal
// circular dependencies

View File

@ -8,7 +8,7 @@ import { getGlobalDB, doInTenant } from "../context"
import { decrypt } from "../security/encryption"
import * as identity from "../context/identity"
import env from "../environment"
import { BBContext, EndpointMatcher } from "@budibase/types"
import { Ctx, EndpointMatcher } from "@budibase/types"
const ONE_MINUTE = env.SESSION_UPDATE_PERIOD
? parseInt(env.SESSION_UPDATE_PERIOD)
@ -73,7 +73,7 @@ export default function (
}
) {
const noAuthOptions = noAuthPatterns ? buildMatcherRegex(noAuthPatterns) : []
return async (ctx: BBContext | any, next: any) => {
return async (ctx: Ctx | any, next: any) => {
let publicEndpoint = false
const version = ctx.request.headers[Header.API_VER]
// the path is not authenticated
@ -115,7 +115,8 @@ export default function (
authenticated = true
} catch (err: any) {
authenticated = false
console.error("Auth Error", err?.message || err)
console.error(`Auth Error: ${err.message}`)
console.error(err)
// remove the cookie as the user does not exist anymore
clearCookie(ctx, Cookie.Auth)
}
@ -148,7 +149,7 @@ export default function (
finalise(ctx, { authenticated, user, internal, version, publicEndpoint })
if (user && user.email) {
return identity.doInUserContext(user, next)
return identity.doInUserContext(user, ctx, next)
} else {
return next()
}

View File

@ -11,6 +11,7 @@ export async function errorHandling(ctx: any, next: any) {
if (status > 499 || env.ENABLE_4XX_HTTP_LOGGING) {
ctx.log.error(err)
console.trace(err)
}
const error = errors.getPublicError(err)

View File

@ -17,4 +17,5 @@ export { default as builderOrAdmin } from "./builderOrAdmin"
export { default as builderOnly } from "./builderOnly"
export { default as logging } from "./logging"
export { default as errorHandling } from "./errorHandling"
export { default as querystringToBody } from "./querystringToBody"
export * as joiValidator from "./joi-validator"

View File

@ -1,9 +1,8 @@
import * as google from "../sso/google"
import { Cookie, Config } from "../../../constants"
import { Cookie } from "../../../constants"
import { clearCookie, getCookie } from "../../../utils"
import { getScopedConfig, getPlatformUrl, doWithDB } from "../../../db"
import environment from "../../../environment"
import { getGlobalDB } from "../../../context"
import { doWithDB } from "../../../db"
import * as configs from "../../../configs"
import { BBContext, Database, SSOProfile } from "@budibase/types"
import { ssoSaveUserNoOp } from "../sso/sso"
const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy
@ -13,18 +12,11 @@ type Passport = {
}
async function fetchGoogleCreds() {
// try and get the config from the tenant
const db = getGlobalDB()
const googleConfig = await getScopedConfig(db, {
type: Config.GOOGLE,
})
// or fall back to env variables
return (
googleConfig || {
clientID: environment.GOOGLE_CLIENT_ID,
clientSecret: environment.GOOGLE_CLIENT_SECRET,
}
)
const config = await configs.getGoogleConfig()
if (!config) {
throw new Error("No google configuration found")
}
return config
}
export async function preAuth(
@ -34,7 +26,7 @@ export async function preAuth(
) {
// get the relevant config
const googleConfig = await fetchGoogleCreds()
const platformUrl = await getPlatformUrl({ tenantAware: false })
const platformUrl = await configs.getPlatformUrl({ tenantAware: false })
let callbackUrl = `${platformUrl}/api/global/auth/datasource/google/callback`
const strategy = await google.strategyFactory(
@ -61,7 +53,7 @@ export async function postAuth(
) {
// get the relevant config
const config = await fetchGoogleCreds()
const platformUrl = await getPlatformUrl({ tenantAware: false })
const platformUrl = await configs.getPlatformUrl({ tenantAware: false })
let callbackUrl = `${platformUrl}/api/global/auth/datasource/google/callback`
const authStateCookie = getCookie(ctx, Cookie.DatasourceAuth)

View File

@ -2,12 +2,11 @@ import { ssoCallbackUrl } from "../utils"
import * as sso from "./sso"
import {
ConfigType,
GoogleConfig,
Database,
SSOProfile,
SSOAuthDetails,
SSOProviderType,
SaveSSOUserFunction,
GoogleInnerConfig,
} from "@budibase/types"
const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy
@ -45,7 +44,7 @@ export function buildVerifyFn(saveUserFn: SaveSSOUserFunction) {
* @returns Dynamically configured Passport Google Strategy
*/
export async function strategyFactory(
config: GoogleConfig["config"],
config: GoogleInnerConfig,
callbackUrl: string,
saveUserFn: SaveSSOUserFunction
) {
@ -73,9 +72,6 @@ export async function strategyFactory(
}
}
export async function getCallbackUrl(
db: Database,
config: { callbackURL?: string }
) {
return ssoCallbackUrl(db, config, ConfigType.GOOGLE)
export async function getCallbackUrl(config: GoogleInnerConfig) {
return ssoCallbackUrl(ConfigType.GOOGLE, config)
}

View File

@ -4,7 +4,6 @@ import { ssoCallbackUrl } from "../utils"
import {
ConfigType,
OIDCInnerConfig,
Database,
SSOProfile,
OIDCStrategyConfiguration,
SSOAuthDetails,
@ -157,9 +156,6 @@ export async function fetchStrategyConfig(
}
}
export async function getCallbackUrl(
db: Database,
config: { callbackURL?: string }
) {
return ssoCallbackUrl(db, config, ConfigType.OIDC)
export async function getCallbackUrl() {
return ssoCallbackUrl(ConfigType.OIDC)
}

View File

@ -1,6 +1,6 @@
import { isMultiTenant, getTenantId } from "../../context"
import { getScopedConfig } from "../../db"
import { ConfigType, Database } from "@budibase/types"
import { getTenantId, isMultiTenant } from "../../context"
import * as configs from "../../configs"
import { ConfigType, GoogleInnerConfig } from "@budibase/types"
/**
* Utility to handle authentication errors.
@ -19,17 +19,14 @@ export function authError(done: Function, message: string, err?: any) {
}
export async function ssoCallbackUrl(
db: Database,
config?: { callbackURL?: string },
type?: ConfigType
type: ConfigType,
config?: GoogleInnerConfig
) {
// incase there is a callback URL from before
if (config && config.callbackURL) {
return config.callbackURL
if (config && (config as GoogleInnerConfig).callbackURL) {
return (config as GoogleInnerConfig).callbackURL as string
}
const publicConfig = await getScopedConfig(db, {
type: ConfigType.SETTINGS,
})
const settingsConfig = await configs.getSettingsConfig()
let callbackUrl = `/api/global/auth`
if (isMultiTenant()) {
@ -37,5 +34,5 @@ export async function ssoCallbackUrl(
}
callbackUrl += `/${type}/callback`
return `${publicConfig.platformUrl}${callbackUrl}`
return `${settingsConfig.platformUrl}${callbackUrl}`
}

View File

@ -0,0 +1,28 @@
import { Ctx } from "@budibase/types"
/**
* Expects a standard "query" query string property which is the JSON body
* of the request, which has to be sent via query string due to the requirement
* of making an endpoint a GET request e.g. downloading a file stream.
*/
export default function (ctx: Ctx, next: any) {
const queryString = ctx.request.query?.query as string | undefined
if (ctx.request.method.toLowerCase() !== "get") {
ctx.throw(
500,
"Query to download middleware can only be used for get requests."
)
}
if (!queryString) {
return next()
}
const decoded = decodeURIComponent(queryString)
let json
try {
json = JSON.parse(decoded)
} catch (err) {
return next()
}
ctx.request.body = json
return next()
}

View File

@ -1,7 +1,7 @@
import { StaticDatabases } from "../constants"
import { getPlatformDB } from "./platformDb"
import { LockName, LockOptions, LockType, Tenants } from "@budibase/types"
import * as locks from "../redis/redlock"
import * as locks from "../redis/redlockImpl"
const TENANT_DOC = StaticDatabases.PLATFORM_INFO.docs.tenants

View File

@ -1,4 +1,5 @@
export enum JobQueue {
AUTOMATION = "automationQueue",
APP_BACKUP = "appBackupQueue",
AUDIT_LOG = "auditLogQueue",
}

View File

@ -40,8 +40,10 @@ export function createQueue<T>(
}
export async function shutdown() {
if (QUEUES.length) {
if (cleanupInterval) {
clearInterval(cleanupInterval)
}
if (QUEUES.length) {
for (let queue of QUEUES) {
await queue.close()
}

View File

@ -3,4 +3,4 @@
export { default as Client } from "./redis"
export * as utils from "./utils"
export * as clients from "./init"
export * as locks from "./redlock"
export * as locks from "./redlockImpl"

View File

@ -11,14 +11,38 @@ import { BulkDocsResponse, User } from "@budibase/types"
import { getGlobalDB } from "./context"
import * as context from "./context"
export const bulkGetGlobalUsersById = async (userIds: string[]) => {
type GetOpts = { cleanup?: boolean }
function removeUserPassword(users: User | User[]) {
if (Array.isArray(users)) {
return users.map(user => {
if (user) {
delete user.password
return user
}
})
} else if (users) {
delete users.password
return users
}
return users
}
export const bulkGetGlobalUsersById = async (
userIds: string[],
opts?: GetOpts
) => {
const db = getGlobalDB()
return (
let users = (
await db.allDocs({
keys: userIds,
include_docs: true,
})
).rows.map(row => row.doc) as User[]
if (opts?.cleanup) {
users = removeUserPassword(users) as User[]
}
return users
}
export const bulkUpdateGlobalUsers = async (users: User[]) => {
@ -26,18 +50,22 @@ export const bulkUpdateGlobalUsers = async (users: User[]) => {
return (await db.bulkDocs(users)) as BulkDocsResponse
}
export async function getById(id: string): Promise<User> {
export async function getById(id: string, opts?: GetOpts): Promise<User> {
const db = context.getGlobalDB()
return db.get(id)
let user = await db.get(id)
if (opts?.cleanup) {
user = removeUserPassword(user)
}
return user
}
/**
* Given an email address this will use a view to search through
* all the users to find one with this email address.
* @param {string} email the email to lookup the user by.
*/
export const getGlobalUserByEmail = async (
email: String
email: String,
opts?: GetOpts
): Promise<User | undefined> => {
if (email == null) {
throw "Must supply an email address to view"
@ -53,10 +81,19 @@ export const getGlobalUserByEmail = async (
throw new Error(`Multiple users found with email address: ${email}`)
}
return response
let user = response as User
if (opts?.cleanup) {
user = removeUserPassword(user) as User
}
return user
}
export const searchGlobalUsersByApp = async (appId: any, opts: any) => {
export const searchGlobalUsersByApp = async (
appId: any,
opts: any,
getOpts?: GetOpts
) => {
if (typeof appId !== "string") {
throw new Error("Must provide a string based app ID")
}
@ -69,7 +106,11 @@ export const searchGlobalUsersByApp = async (appId: any, opts: any) => {
if (!response) {
response = []
}
return Array.isArray(response) ? response : [response]
let users: User[] = Array.isArray(response) ? response : [response]
if (getOpts?.cleanup) {
users = removeUserPassword(users) as User[]
}
return users
}
/*
@ -121,7 +162,11 @@ export const getGlobalUserByAppPage = (appId: string, user: User) => {
/**
* Performs a starts with search on the global email view.
*/
export const searchGlobalUsersByEmail = async (email: string, opts: any) => {
export const searchGlobalUsersByEmail = async (
email: string,
opts: any,
getOpts?: GetOpts
) => {
if (typeof email !== "string") {
throw new Error("Must provide a string to search by")
}
@ -136,5 +181,9 @@ export const searchGlobalUsersByEmail = async (email: string, opts: any) => {
if (!response) {
response = []
}
return Array.isArray(response) ? response : [response]
let users: User[] = Array.isArray(response) ? response : [response]
if (getOpts?.cleanup) {
users = removeUserPassword(users) as User[]
}
return users
}

View File

@ -10,7 +10,13 @@ import {
import env from "../environment"
import * as tenancy from "../tenancy"
import * as context from "../context"
import { App, Ctx, TenantResolutionStrategy } from "@budibase/types"
import {
App,
AuditedEventFriendlyName,
Ctx,
Event,
TenantResolutionStrategy,
} from "@budibase/types"
import { SetOption } from "cookies"
const jwt = require("jsonwebtoken")
@ -217,3 +223,7 @@ export async function getBuildersCount() {
export function timeout(timeMs: number) {
return new Promise(resolve => setTimeout(resolve, timeMs))
}
export function isAudited(event: Event) {
return !!AuditedEventFriendlyName[event]
}

View File

@ -70,6 +70,10 @@ export const useBackups = () => {
return useFeature(Feature.APP_BACKUPS)
}
export const useEnforceableSSO = () => {
return useFeature(Feature.ENFORCEABLE_SSO)
}
export const useGroups = () => {
return useFeature(Feature.USER_GROUPS)
}
@ -78,6 +82,10 @@ export const useEnvironmentVariables = () => {
return useFeature(Feature.ENVIRONMENT_VARIABLES)
}
export const useAuditLogs = () => {
return useFeature(Feature.AUDIT_LOGS)
}
// QUOTAS
export const setAutomationLogsQuota = (value: number) => {

View File

@ -0,0 +1,2 @@
import Chance from "chance"
export const generator = new Chance()

View File

@ -1,8 +1,4 @@
export * from "./common"
import Chance from "chance"
export const generator = new Chance()
export * as accounts from "./accounts"
export * as apps from "./apps"
export * as db from "./db"
@ -12,3 +8,4 @@ export * as plugins from "./plugins"
export * as sso from "./sso"
export * as tenant from "./tenants"
export * as users from "./users"
export { generator } from "./generator"

View File

@ -0,0 +1,19 @@
import { User } from "@budibase/types"
import { generator } from "./generator"
import { uuid } from "./common"
export const newEmail = () => {
return `${uuid()}@test.com`
}
export const user = (userProps?: any): User => {
return {
email: newEmail(),
password: "test",
roles: { app_test: "admin" },
firstName: generator.first(),
lastName: generator.last(),
pictureUrl: "http://test.com",
...userProps,
}
}

View File

@ -8,8 +8,36 @@ import {
SSOProviderType,
User,
} from "@budibase/types"
import { uuid, generator, users, email } from "./index"
import { generator } from "./generator"
import { uuid, email } from "./common"
import * as shared from "./shared"
import _ from "lodash"
import { user } from "./shared"
export function authDetails(userDoc?: User): SSOAuthDetails {
if (!userDoc) {
userDoc = user()
}
const userId = userDoc._id || uuid()
const provider = generator.string()
const profile = ssoProfile(userDoc)
profile.provider = provider
profile.id = userId
return {
email: userDoc.email,
oauth2: {
refreshToken: generator.string(),
accessToken: generator.string(),
},
profile,
provider,
providerType: providerType(),
userId,
}
}
export function providerType(): SSOProviderType {
return _.sample(Object.values(SSOProviderType)) as SSOProviderType
@ -17,7 +45,7 @@ export function providerType(): SSOProviderType {
export function ssoProfile(user?: User): SSOProfile {
if (!user) {
user = users.user()
user = shared.user()
}
return {
id: user._id!,
@ -33,31 +61,6 @@ export function ssoProfile(user?: User): SSOProfile {
}
}
export function authDetails(user?: User): SSOAuthDetails {
if (!user) {
user = users.user()
}
const userId = user._id || uuid()
const provider = generator.string()
const profile = ssoProfile(user)
profile.provider = provider
profile.id = userId
return {
email: user.email,
oauth2: {
refreshToken: generator.string(),
accessToken: generator.string(),
},
profile,
provider,
providerType: providerType(),
userId,
}
}
// OIDC
export function oidcConfig(): OIDCInnerConfig {
@ -69,6 +72,7 @@ export function oidcConfig(): OIDCInnerConfig {
configUrl: "http://someconfigurl",
clientID: generator.string(),
clientSecret: generator.string(),
scopes: [],
}
}

View File

@ -1,29 +1,13 @@
import { generator } from "../"
import {
AdminUser,
BuilderUser,
SSOAuthDetails,
SSOUser,
User,
} from "@budibase/types"
import { v4 as uuid } from "uuid"
import * as sso from "./sso"
import { user } from "./shared"
import { authDetails } from "./sso"
export const newEmail = () => {
return `${uuid()}@test.com`
}
export const user = (userProps?: any): User => {
return {
email: newEmail(),
password: "test",
roles: { app_test: "admin" },
firstName: generator.first(),
lastName: generator.last(),
pictureUrl: "http://test.com",
...userProps,
}
}
export { user, newEmail } from "./shared"
export const adminUser = (userProps?: any): AdminUser => {
return {
@ -53,7 +37,7 @@ export function ssoUser(
delete base.password
if (!opts.details) {
opts.details = sso.authDetails(base)
opts.details = authDetails(base)
}
return {

View File

@ -1,3 +1,31 @@
import { execSync } from "child_process"
let dockerPsResult: string | undefined
function formatDockerPsResult(serverName: string, port: number) {
const lines = dockerPsResult?.split("\n")
let first = true
if (!lines) {
return null
}
for (let line of lines) {
if (first) {
first = false
continue
}
let toLookFor = serverName.split("-service")[0]
if (!line.includes(toLookFor)) {
continue
}
const regex = new RegExp(`0.0.0.0:([0-9]*)->${port}`, "g")
const found = line.match(regex)
if (found) {
return found[0].split(":")[1].split("->")[0]
}
}
return null
}
function getTestContainerSettings(
serverName: string,
key: string
@ -14,10 +42,22 @@ function getTestContainerSettings(
}
function getContainerInfo(containerName: string, port: number) {
const assignedPort = getTestContainerSettings(
let assignedPort = getTestContainerSettings(
containerName.toUpperCase(),
`PORT_${port}`
)
if (!dockerPsResult) {
try {
const outputBuffer = execSync("docker ps")
dockerPsResult = outputBuffer.toString("utf8")
} catch (err) {
//no-op
}
}
const possiblePort = formatDockerPsResult(containerName, port)
if (possiblePort) {
assignedPort = possiblePort
}
const host = getTestContainerSettings(containerName.toUpperCase(), "IP")
return {
port: assignedPort,
@ -39,12 +79,15 @@ function getRedisConfig() {
}
export function setupEnv(...envs: any[]) {
const couch = getCouchConfig(),
minio = getCouchConfig(),
redis = getRedisConfig()
const configs = [
{ key: "COUCH_DB_PORT", value: getCouchConfig().port },
{ key: "COUCH_DB_URL", value: getCouchConfig().url },
{ key: "MINIO_PORT", value: getMinioConfig().port },
{ key: "MINIO_URL", value: getMinioConfig().url },
{ key: "REDIS_URL", value: getRedisConfig().url },
{ key: "COUCH_DB_PORT", value: couch.port },
{ key: "COUCH_DB_URL", value: couch.url },
{ key: "MINIO_PORT", value: minio.port },
{ key: "MINIO_URL", value: minio.url },
{ key: "REDIS_URL", value: redis.url },
]
for (const config of configs.filter(x => !!x.value)) {

View File

@ -1,7 +1,7 @@
{
"name": "@budibase/bbui",
"description": "A UI solution used in the different Budibase projects.",
"version": "2.3.18-alpha.12",
"version": "2.3.18-alpha.14",
"license": "MPL-2.0",
"svelte": "src/index.js",
"module": "dist/bbui.es.js",
@ -38,7 +38,7 @@
],
"dependencies": {
"@adobe/spectrum-css-workflow-icons": "1.2.1",
"@budibase/string-templates": "2.3.18-alpha.12",
"@budibase/string-templates": "2.3.18-alpha.14",
"@spectrum-css/accordion": "3.0.24",
"@spectrum-css/actionbutton": "1.0.1",
"@spectrum-css/actiongroup": "1.0.1",

View File

@ -14,6 +14,8 @@
export let autocomplete = false
export let sort = false
export let autoWidth = false
export let fetchTerm = null
export let customPopoverHeight
const dispatch = createEventDispatcher()
@ -83,10 +85,12 @@
{options}
isPlaceholder={!value?.length}
{autocomplete}
bind:fetchTerm
{isOptionSelected}
{getOptionLabel}
{getOptionValue}
onSelectOption={toggleOption}
{sort}
{autoWidth}
{customPopoverHeight}
/>

View File

@ -31,7 +31,8 @@
export let autoWidth = false
export let autocomplete = false
export let sort = false
export let fetchTerm = null
export let customPopoverHeight
const dispatch = createEventDispatcher()
let searchTerm = null
@ -71,7 +72,7 @@
}
const getFilteredOptions = (options, term, getLabel) => {
if (autocomplete && term) {
if (autocomplete && term && !fetchTerm) {
const lowerCaseTerm = term.toLowerCase()
return options.filter(option => {
return `${getLabel(option)}`.toLowerCase().includes(lowerCaseTerm)
@ -136,6 +137,7 @@
on:close={() => (open = false)}
useAnchorWidth={!autoWidth}
maxWidth={autoWidth ? 400 : null}
customHeight={customPopoverHeight}
>
<div
class="popover-content"
@ -144,8 +146,9 @@
>
{#if autocomplete}
<Search
value={searchTerm}
on:change={event => (searchTerm = event.detail)}
value={fetchTerm ? fetchTerm : searchTerm}
on:change={event =>
fetchTerm ? (fetchTerm = event.detail) : (searchTerm = event.detail)}
{disabled}
placeholder="Search"
/>
@ -247,7 +250,7 @@
}
.popover-content.auto-width .spectrum-Menu-itemLabel {
white-space: nowrap;
overflow: hidden;
overflow: none;
text-overflow: ellipsis;
}
.popover-content:not(.auto-width) .spectrum-Menu-itemLabel {

View File

@ -15,6 +15,10 @@
export let getOptionValue = option => option
export let sort = false
export let autoWidth = false
export let autocomplete = false
export let fetchTerm = null
export let customPopoverHeight
const dispatch = createEventDispatcher()
const onChange = e => {
value = e.detail
@ -34,6 +38,9 @@
{getOptionLabel}
{getOptionValue}
{autoWidth}
{autocomplete}
{customPopoverHeight}
bind:fetchTerm
on:change={onChange}
on:click
/>

View File

@ -20,6 +20,8 @@
export let autoWidth = false
export let sort = false
export let tooltip = ""
export let autocomplete = false
export let customPopoverHeight
const dispatch = createEventDispatcher()
const onChange = e => {
@ -51,6 +53,8 @@
{getOptionIcon}
{getOptionColour}
{isOptionEnabled}
{autocomplete}
{customPopoverHeight}
on:change={onChange}
on:click
/>

View File

@ -18,6 +18,7 @@
export let useAnchorWidth = false
export let dismissible = true
export let offset = 5
export let customHeight
$: target = portalTarget || getContext(Context.PopoverRoot) || ".spectrum"
@ -74,6 +75,7 @@
on:keydown={handleEscape}
class="spectrum-Popover is-open"
role="presentation"
style="height: {customHeight}"
transition:fly|local={{ y: -20, duration: 200 }}
>
<slot />

View File

@ -1,6 +1,6 @@
{
"name": "@budibase/builder",
"version": "2.3.18-alpha.12",
"version": "2.3.18-alpha.14",
"license": "GPL-3.0",
"private": true,
"scripts": {
@ -58,10 +58,10 @@
}
},
"dependencies": {
"@budibase/bbui": "2.3.18-alpha.12",
"@budibase/client": "2.3.18-alpha.12",
"@budibase/frontend-core": "2.3.18-alpha.12",
"@budibase/string-templates": "2.3.18-alpha.12",
"@budibase/bbui": "2.3.18-alpha.14",
"@budibase/client": "2.3.18-alpha.14",
"@budibase/frontend-core": "2.3.18-alpha.14",
"@budibase/string-templates": "2.3.18-alpha.14",
"@fortawesome/fontawesome-svg-core": "^6.2.1",
"@fortawesome/free-brands-svg-icons": "^6.2.1",
"@fortawesome/free-solid-svg-icons": "^6.2.1",

View File

@ -79,67 +79,71 @@
<OIDCButton oidcIcon={$oidc.logo} oidcName={$oidc.name} />
<GoogleButton />
</FancyForm>
<Divider />
{/if}
<FancyForm bind:this={form}>
<FancyInput
label="Your work email"
value={formData.username}
on:change={e => {
formData = {
...formData,
username: e.detail,
}
}}
validate={() => {
let fieldError = {
username: !formData.username
? "Please enter a valid email"
: undefined,
}
errors = handleError({ ...errors, ...fieldError })
}}
error={errors.username}
/>
<FancyInput
label="Password"
value={formData.password}
type="password"
on:change={e => {
formData = {
...formData,
password: e.detail,
}
}}
validate={() => {
let fieldError = {
password: !formData.password
? "Please enter your password"
: undefined,
}
errors = handleError({ ...errors, ...fieldError })
}}
error={errors.password}
/>
</FancyForm>
</Layout>
<Layout gap="XS" noPadding justifyItems="center">
<Button
size="L"
cta
disabled={Object.keys(errors).length > 0}
on:click={login}
>
Log in to {company}
</Button>
</Layout>
<Layout gap="XS" noPadding justifyItems="center">
<div class="user-actions">
<ActionButton size="L" quiet on:click={() => $goto("./forgot")}>
Forgot password?
</ActionButton>
</div>
{#if !$organisation.isSSOEnforced}
<Divider />
<FancyForm bind:this={form}>
<FancyInput
label="Your work email"
value={formData.username}
on:change={e => {
formData = {
...formData,
username: e.detail,
}
}}
validate={() => {
let fieldError = {
username: !formData.username
? "Please enter a valid email"
: undefined,
}
errors = handleError({ ...errors, ...fieldError })
}}
error={errors.username}
/>
<FancyInput
label="Password"
value={formData.password}
type="password"
on:change={e => {
formData = {
...formData,
password: e.detail,
}
}}
validate={() => {
let fieldError = {
password: !formData.password
? "Please enter your password"
: undefined,
}
errors = handleError({ ...errors, ...fieldError })
}}
error={errors.password}
/>
</FancyForm>
{/if}
</Layout>
{#if !$organisation.isSSOEnforced}
<Layout gap="XS" noPadding justifyItems="center">
<Button
size="L"
cta
disabled={Object.keys(errors).length > 0}
on:click={login}
>
Log in to {company}
</Button>
</Layout>
<Layout gap="XS" noPadding justifyItems="center">
<div class="user-actions">
<ActionButton size="L" quiet on:click={() => $goto("./forgot")}>
Forgot password?
</ActionButton>
</div>
</Layout>
{/if}
{#if cloud}
<Body size="xs" textAlign="center">

View File

@ -0,0 +1,76 @@
<script>
import {
Layout,
Heading,
Body,
Button,
Divider,
Tags,
Tag,
} from "@budibase/bbui"
import { auth, admin } from "stores/portal"
export let title
export let planType
export let description
export let enabled
export let upgradeButtonClick
</script>
<Layout noPadding>
<Layout gap="XS" noPadding>
<div class="title">
<Heading size="M">{title}</Heading>
{#if !enabled}
<Tags>
<Tag icon="LockClosed">{planType}</Tag>
</Tags>
{/if}
</div>
<Body>{description}</Body>
</Layout>
<Divider size="S" />
{#if enabled}
<slot />
{:else}
<div class="buttons">
<Button
primary
disabled={!$auth.accountPortalAccess && $admin.cloud}
on:click={async () => upgradeButtonClick()}
>
Upgrade
</Button>
<!--Show the view plans button-->
<Button
secondary
on:click={() => {
window.open("https://budibase.com/pricing/", "_blank")
}}
>
View Plans
</Button>
</div>
{/if}
</Layout>
<style>
.buttons {
display: flex;
gap: var(--spacing-l);
}
.title {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: var(--spacing-m);
}
.buttons {
display: flex;
flex-direction: row;
gap: var(--spacing-m);
}
</style>

View File

@ -4,12 +4,13 @@
import { Content, SideNav, SideNavItem } from "components/portal/page"
import { menu } from "stores/portal"
$: wide = $isActive("./auditLogs")
$: pages = $menu.find(x => x.title === "Account")?.subPages || []
$: !pages.length && $goto("../")
</script>
<Page>
<Content narrow>
<Content narrow={!wide}>
<div slot="side-nav">
<SideNav>
{#each pages as { title, href }}

View File

@ -0,0 +1,5 @@
<script>
export let value
</script>
<div>{value?.name || ""}</div>

View File

@ -0,0 +1,11 @@
<script>
import dayjs from "dayjs"
import relativeTime from "dayjs/plugin/relativeTime"
dayjs.extend(relativeTime)
export let row
</script>
<div>
{dayjs(row.timestamp).fromNow()}
</div>

View File

@ -0,0 +1,45 @@
<script>
import { Avatar, Tooltip } from "@budibase/bbui"
export let row
let showTooltip
const getInitials = user => {
let initials = ""
initials += user.firstName ? user.firstName[0] : ""
initials += user.lastName ? user.lastName[0] : ""
return initials === "" ? user.email[0] : initials
}
</script>
<div
class="container"
on:mouseover={() => (showTooltip = true)}
on:focus={() => (showTooltip = true)}
on:mouseleave={() => (showTooltip = false)}
>
<Avatar size="M" initials={getInitials(row?.user)} />
</div>
{#if showTooltip}
<div class="tooltip">
<Tooltip textWrapping text={row?.user.email} direction="bottom" />
</div>
{/if}
<style>
.container {
position: relative;
}
.tooltip {
z-index: 1;
position: absolute;
top: 75%;
left: 120%;
transform: translateX(-100%) translateY(-50%);
display: flex;
flex-direction: row;
justify-content: flex-end;
width: 130px;
pointer-events: none;
}
</style>

View File

@ -0,0 +1,13 @@
<script>
import { ActionButton } from "@budibase/bbui"
import { getContext } from "svelte"
export let row
const auditLogs = getContext("auditLogs")
const onClick = e => {
e.stopPropagation()
auditLogs.viewDetails(row)
}
</script>
<ActionButton size="S" on:click={onClick}>Details</ActionButton>

View File

@ -0,0 +1,501 @@
<!-- If working on this file, you may notice that if you click the download button in the UI
hot reload will stop working due to the use of window.location. You'll need to reload the pag
to get it working again.
-->
<script>
import {
Layout,
Table,
Search,
Multiselect,
notifications,
Icon,
clickOutside,
CoreTextArea,
DatePicker,
Pagination,
Helpers,
Divider,
ActionButton,
} from "@budibase/bbui"
import { licensing, users, apps, auditLogs } from "stores/portal"
import LockedFeature from "../../_components/LockedFeature.svelte"
import { createPaginationStore } from "helpers/pagination"
import { onMount, setContext } from "svelte"
import ViewDetailsRenderer from "./_components/ViewDetailsRenderer.svelte"
import UserRenderer from "./_components/UserRenderer.svelte"
import TimeRenderer from "./_components/TimeRenderer.svelte"
import AppColumnRenderer from "./_components/AppColumnRenderer.svelte"
import { cloneDeep } from "lodash"
const schema = {
date: { width: "0.8fr" },
user: { width: "0.5fr" },
name: { width: "2fr", displayName: "Event" },
app: { width: "1.5fr" },
view: { width: "0.1fr", borderLeft: true, displayName: "" },
}
const customRenderers = [
{
column: "view",
component: ViewDetailsRenderer,
},
{
column: "user",
component: UserRenderer,
},
{
column: "date",
component: TimeRenderer,
},
{
column: "app",
component: AppColumnRenderer,
},
]
let userSearchTerm = ""
let logSearchTerm = ""
let userPageInfo = createPaginationStore()
let logsPageInfo = createPaginationStore()
let prevUserSearch = undefined
let prevLogSearch = undefined
let selectedUsers = []
let selectedApps = []
let selectedEvents = []
let selectedLog
let sidePanelVisible = false
let wideSidePanel = false
let timer
let startDate = new Date()
startDate.setDate(startDate.getDate() - 30)
let endDate = new Date()
$: fetchUsers(userPage, userSearchTerm)
$: fetchLogs({
logsPage,
logSearchTerm,
startDate,
endDate,
selectedUsers,
selectedApps,
selectedEvents,
})
$: userPage = $userPageInfo.page
$: logsPage = $logsPageInfo.page
$: sortedUsers = sort(
enrich($users.data || [], selectedUsers, "_id"),
"email"
)
$: sortedEvents = sort(
enrich(parseEventObject($auditLogs.events), selectedEvents, "id"),
"id"
)
$: sortedApps = sort(enrich($apps, selectedApps, "appId"), "name")
const debounce = value => {
clearTimeout(timer)
timer = setTimeout(() => {
logSearchTerm = value
}, 400)
}
const fetchUsers = async (userPage, search) => {
if ($userPageInfo.loading) {
return
}
// need to remove the page if they've started searching
if (search && !prevUserSearch) {
userPageInfo.reset()
userPage = undefined
}
prevUserSearch = search
try {
userPageInfo.loading()
await users.search({ userPage, email: search })
userPageInfo.fetched($users.hasNextPage, $users.nextPage)
} catch (error) {
notifications.error("Error getting user list")
}
}
const fetchLogs = async ({
logsPage,
logSearchTerm,
startDate,
endDate,
selectedUsers,
selectedApps,
selectedEvents,
}) => {
if ($logsPageInfo.loading) {
return
}
// need to remove the page if they've started searching
if (logSearchTerm && !prevLogSearch) {
logsPageInfo.reset()
logsPage = undefined
}
prevLogSearch = logSearchTerm
try {
logsPageInfo.loading()
await auditLogs.search({
bookmark: logsPage,
startDate,
endDate,
fullSearch: logSearchTerm,
userIds: selectedUsers,
appIds: selectedApps,
events: selectedEvents,
})
logsPageInfo.fetched(
$auditLogs.logs.hasNextPage,
$auditLogs.logs.bookmark
)
} catch (error) {
notifications.error(`Error getting audit logs - ${error}`)
}
}
const enrich = (list, selected, key) => {
return list.map(item => {
return {
...item,
selected:
selected.find(x => x === item[key] || x.includes(item[key])) != null,
}
})
}
const sort = (list, key) => {
let sortedList = list.slice()
sortedList?.sort((a, b) => {
if (a.selected === b.selected) {
return a[key] < b[key] ? -1 : 1
} else if (a.selected) {
return -1
} else if (b.selected) {
return 1
}
return 0
})
return sortedList
}
const parseEventObject = obj => {
// convert obj which is an object of key value pairs to an array of objects
// with the key as the id and the value as the name
if (obj) {
return Object.entries(obj).map(([id, label]) => {
return { id, label }
})
}
}
const viewDetails = detail => {
selectedLog = detail
sidePanelVisible = true
}
const downloadLogs = async () => {
try {
window.location = auditLogs.getDownloadUrl({
startDate,
endDate,
fullSearch: logSearchTerm,
userIds: selectedUsers,
appIds: selectedApps,
events: selectedEvents,
})
} catch (error) {
notifications.error(`Error downloading logs: ` + error.message)
}
}
setContext("auditLogs", {
viewDetails,
})
const copyToClipboard = async value => {
await Helpers.copyToClipboard(value)
notifications.success("Copied")
}
function cleanupMetadata(log) {
const cloned = cloneDeep(log)
cloned.userId = cloned.user._id
if (cloned.app) {
cloned.appId = cloned.app.appId
}
// remove props that are confused/not returned in download
delete cloned._id
delete cloned._rev
delete cloned.app
delete cloned.user
return cloned
}
onMount(async () => {
await auditLogs.getEventDefinitions()
await licensing.init()
})
</script>
<LockedFeature
title={"Audit Logs"}
planType={"Business plan"}
description={"View all events that have occurred in your Budibase installation"}
enabled={$licensing.auditLogsEnabled}
upgradeButtonClick={async () => {
$licensing.goToUpgradePage()
}}
>
<div class="controls">
<div class="select">
<Multiselect
bind:fetchTerm={userSearchTerm}
placeholder="All users"
label="Users"
autocomplete
bind:value={selectedUsers}
getOptionValue={user => user._id}
getOptionLabel={user => user.email}
options={sortedUsers}
/>
</div>
<div class="select">
<Multiselect
autocomplete
placeholder="All apps"
label="Apps"
getOptionValue={app => app.instance._id}
getOptionLabel={app => app.name}
options={sortedApps}
bind:value={selectedApps}
/>
</div>
<div class="select">
<Multiselect
customPopoverHeight="500px"
autocomplete
getOptionValue={event => event.id}
getOptionLabel={event => event.label}
options={sortedEvents}
placeholder="All events"
label="Events"
bind:value={selectedEvents}
/>
</div>
<div class="date-picker">
<DatePicker
value={[startDate, endDate]}
placeholder="Choose date range"
range={true}
on:change={e => {
if (e.detail[0]?.length === 1) {
startDate = e.detail[0][0].toISOString()
endDate = ""
} else if (e.detail[0]?.length > 1) {
startDate = e.detail[0][0].toISOString()
endDate = e.detail[0][1].toISOString()
} else {
startDate = ""
endDate = ""
}
}}
/>
</div>
<div class="freeSearch">
<Search placeholder="Search" on:change={e => debounce(e.detail)} />
</div>
<div class="">
<ActionButton size="M" icon="Download" on:click={() => downloadLogs()} />
</div>
</div>
<Layout noPadding>
<Table
on:click={({ detail }) => viewDetails(detail)}
{customRenderers}
data={$auditLogs.logs.data}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
{schema}
/>
<div class="pagination">
<Pagination
page={$logsPageInfo.pageNumber}
hasPrevPage={$logsPageInfo.loading ? false : $logsPageInfo.hasPrevPage}
hasNextPage={$logsPageInfo.loading ? false : $logsPageInfo.hasNextPage}
goToPrevPage={logsPageInfo.prevPage}
goToNextPage={logsPageInfo.nextPage}
/>
</div>
</Layout>
</LockedFeature>
{#if selectedLog}
<div
id="side-panel"
class:wide={wideSidePanel}
class:visible={sidePanelVisible}
use:clickOutside={() => {
sidePanelVisible = false
}}
>
<div class="side-panel-header">
Audit Log
<div class="side-panel-icons">
<Icon
size="S"
hoverable
name={wideSidePanel ? "Minimize" : "Maximize"}
on:click={() => {
wideSidePanel = !wideSidePanel
}}
/>
<Icon
hoverable
name="Close"
on:click={() => {
sidePanelVisible = false
}}
/>
</div>
</div>
<Divider />
<div class="side-panel-body">
<div
on:click={() => copyToClipboard(JSON.stringify(selectedLog.metadata))}
class="copy-icon"
>
<Icon name="Copy" size="S" />
</div>
<CoreTextArea
disabled
minHeight={"300px"}
height={"100%"}
value={JSON.stringify(cleanupMetadata(selectedLog), null, 2)}
/>
</div>
</div>
{/if}
<style>
.copy-icon {
right: 16px;
top: 80px;
z-index: 10;
justify-content: center;
align-items: center;
display: flex;
flex-direction: row;
box-sizing: border-box;
border: 1px solid var(--spectrum-alias-border-color);
border-radius: var(--spectrum-alias-border-radius-regular);
width: 31px;
color: var(--spectrum-alias-text-color);
background-color: var(--spectrum-global-color-gray-75);
transition: background-color
var(--spectrum-global-animation-duration-100, 130ms),
box-shadow var(--spectrum-global-animation-duration-100, 130ms),
border-color var(--spectrum-global-animation-duration-100, 130ms);
height: calc(var(--spectrum-alias-item-height-m) - 2px);
position: absolute;
}
.copy-icon:hover {
cursor: pointer;
color: var(--spectrum-alias-text-color-hover);
background-color: var(--spectrum-global-color-gray-50);
border-color: var(--spectrum-alias-border-color-hover);
}
.side-panel-header {
display: flex;
padding: 20px 10px 10px 10px;
gap: var(--spacing-s);
justify-content: space-between;
align-items: center;
}
.pagination {
display: flex;
flex-direction: row;
justify-content: flex-end;
margin-top: var(--spacing-xl);
}
.side-panel-body {
padding: 10px;
height: calc(100% - 67px);
}
#side-panel {
position: absolute;
right: 0;
top: 0;
padding-bottom: 24px;
background: var(--background);
border-left: var(--border-light);
width: 320px;
max-width: calc(100vw - 48px - 48px);
transform: translateX(100%);
transition: transform 130ms ease-in-out;
height: calc(100% - 24px);
overflow-y: hidden;
overflow-x: hidden;
z-index: 2;
}
#side-panel.visible {
transform: translateX(0);
}
#side-panel.wide {
width: 500px;
}
#side-panel :global(textarea) {
min-height: 100% !important;
background-color: var(
--spectrum-textfield-m-background-color,
var(--spectrum-global-color-gray-50)
);
padding-top: var(--spacing-l);
padding-left: var(--spacing-l);
font-size: 13px;
}
.controls {
display: flex;
flex-direction: row;
gap: var(--spacing-l);
flex-wrap: wrap;
align-items: center;
}
.side-panel-icons {
display: flex;
gap: var(--spacing-l);
}
.select {
flex-basis: calc(33.33% - 10px);
width: 0;
min-width: 100px;
}
.date-picker {
flex-basis: calc(70% - 32px);
min-width: 100px;
}
.freeSearch {
flex-basis: 25%;
min-width: 100px;
}
</style>

View File

@ -185,7 +185,7 @@
<Divider />
{#if !$licensing.backupsEnabled}
{#if !$auth.accountPortalAccess && !$licensing.groupsEnabled && $admin.cloud}
{#if !$auth.accountPortalAccess && $admin.cloud}
<Body>Contact your account holder to upgrade your plan.</Body>
{/if}
<div class="pro-buttons">

View File

@ -22,10 +22,11 @@
Tags,
Icon,
Helpers,
Link,
} from "@budibase/bbui"
import { onMount } from "svelte"
import { API } from "api"
import { organisation, admin } from "stores/portal"
import { organisation, admin, licensing } from "stores/portal"
const ConfigTypes = {
Google: "google",
@ -34,6 +35,8 @@
const HasSpacesRegex = /[\\"\s]/
$: enforcedSSO = $organisation.isSSOEnforced
// Some older google configs contain a manually specified value - retain the functionality to edit the field
// When there is no value or we are in the cloud - prohibit editing the field, must use platform url to change
$: googleCallbackUrl = undefined
@ -154,6 +157,11 @@
iconDropdownOptions.unshift({ label: fileName, value: fileName })
}
async function toggleIsSSOEnforced() {
const value = $organisation.isSSOEnforced
await organisation.save({ isSSOEnforced: !value })
}
async function save(docs) {
let calls = []
// Only if the user has provided an image, upload it
@ -316,6 +324,49 @@
<Heading size="M">Authentication</Heading>
<Body>Add additional authentication methods from the options below</Body>
</Layout>
<Divider />
<Layout noPadding gap="XS">
<Heading size="S">Single Sign-On URL</Heading>
<Body size="S">
Use the following link to access your configured identity provider.
</Body>
<Body size="S">
<div class="sso-link">
<Link href={$organisation.platformUrl} target="_blank"
>{$organisation.platformUrl}</Link
>
<div class="sso-link-icon">
<Icon size="XS" name="LinkOutLight" />
</div>
</div>
</Body>
</Layout>
<Divider />
<Layout noPadding gap="XS">
<div class="provider-title">
<div class="enforce-sso-heading-container">
<div class="enforce-sso-title">
<Heading size="S">Enforce Single Sign-On</Heading>
</div>
{#if !$licensing.enforceableSSO}
<Tags>
<Tag icon="LockClosed">Business plan</Tag>
</Tags>
{/if}
</div>
{#if $licensing.enforceableSSO}
<Toggle on:change={toggleIsSSOEnforced} bind:value={enforcedSSO} />
{/if}
</div>
<Body size="S">
Require SSO authentication for all users. It is recommended to read the
help <Link
size="M"
href={"https://docs.budibase.com/docs/authentication-and-sso"}
>documentation</Link
> before enabling this feature.
</Body>
</Layout>
{#if providers.google}
<Divider />
<Layout gap="XS" noPadding>
@ -546,7 +597,24 @@
input[type="file"] {
display: none;
}
.sso-link-icon {
padding-top: 4px;
margin-left: 3px;
}
.sso-link {
margin-top: 12px;
display: flex;
flex-direction: row;
align-items: center;
}
.enforce-sso-title {
margin-right: 10px;
}
.enforce-sso-heading-container {
display: flex;
flex-direction: row;
align-items: start;
}
.provider-title {
display: flex;
flex-direction: row;

View File

@ -1,21 +1,17 @@
<script>
import {
Layout,
Heading,
Body,
Button,
Divider,
Modal,
Table,
Tags,
Tag,
InlineAlert,
notifications,
} from "@budibase/bbui"
import { environment, licensing, auth, admin } from "stores/portal"
import { environment, licensing } from "stores/portal"
import { onMount } from "svelte"
import CreateEditVariableModal from "components/portal/environment/CreateEditVariableModal.svelte"
import EditVariableColumn from "./_components/EditVariableColumn.svelte"
import LockedFeature from "../../_components/LockedFeature.svelte"
let modal
@ -61,91 +57,43 @@
}
</script>
<Layout noPadding>
<Layout gap="XS" noPadding>
<div class="title">
<Heading size="M">Environment Variables</Heading>
{#if !$licensing.environmentVariablesEnabled}
<Tags>
<Tag icon="LockClosed">Business plan</Tag>
</Tags>
{/if}
</div>
<Body
>Add and manage environment variables for development and production</Body
>
</Layout>
<Divider size="S" />
{#if $licensing.environmentVariablesEnabled}
{#if noEncryptionKey}
<InlineAlert
message="Your Budibase installation does not have a key for encryption, please update your app service's environment variables to contain an 'ENCRYPTION_KEY' value."
header="No encryption key found"
type="error"
/>
{/if}
<div>
<Button on:click={modal.show} cta disabled={noEncryptionKey}
>Add Variable</Button
>
</div>
<Layout noPadding>
<Table
{schema}
data={$environment.variables}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
{customRenderers}
/>
</Layout>
{:else}
<div class="buttons">
<Button
primary
disabled={!$auth.accountPortalAccess && $admin.cloud}
on:click={async () => {
await environment.upgradePanelOpened()
$licensing.goToUpgradePage()
}}
>
Upgrade
</Button>
<!--Show the view plans button-->
<Button
secondary
on:click={() => {
window.open("https://budibase.com/pricing/", "_blank")
}}
>
View Plans
</Button>
</div>
<LockedFeature
title={"Environment Variables"}
planType={"Business plan"}
description={"Add and manage environment variables for development and production"}
enabled={$licensing.environmentVariablesEnabled}
upgradeButtonClick={async () => {
await environment.upgradePanelOpened()
$licensing.goToUpgradePage()
}}
>
{#if noEncryptionKey}
<InlineAlert
message="Your Budibase installation does not have a key for encryption, please update your app service's environment variables to contain an 'ENCRYPTION_KEY' value."
header="No encryption key found"
type="error"
/>
{/if}
</Layout>
<div>
<Button on:click={modal.show} cta disabled={noEncryptionKey}
>Add Variable</Button
>
</div>
<Layout noPadding>
<Table
{schema}
data={$environment.variables}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
{customRenderers}
/>
</Layout>
</LockedFeature>
<Modal bind:this={modal}>
<CreateEditVariableModal {save} />
</Modal>
<style>
.buttons {
display: flex;
gap: var(--spacing-l);
}
.title {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: var(--spacing-m);
}
.buttons {
display: flex;
flex-direction: row;
gap: var(--spacing-m);
}
</style>

View File

@ -24,6 +24,7 @@
}
const values = writable({
isSSOEnforced: $organisation.isSSOEnforced,
company: $organisation.company,
platformUrl: $organisation.platformUrl,
analyticsEnabled: $organisation.analyticsEnabled,
@ -54,6 +55,7 @@
}
const config = {
isSSOEnforced: $values.isSSOEnforced,
company: $values.company ?? "",
platformUrl: $values.platformUrl ?? "",
analyticsEnabled: $values.analyticsEnabled,

View File

@ -0,0 +1,43 @@
import { writable, get } from "svelte/store"
import { API } from "api"
import { licensing } from "stores/portal"
export function createAuditLogsStore() {
const { subscribe, update } = writable({
events: {},
logs: {},
})
async function search(opts = {}) {
if (get(licensing).auditLogsEnabled) {
const paged = await API.searchAuditLogs(opts)
update(state => {
return { ...state, logs: { ...paged, opts } }
})
return paged
}
}
async function getEventDefinitions() {
const events = await API.getEventDefinitions()
update(state => {
return { ...state, ...events }
})
}
function getDownloadUrl(opts = {}) {
return API.getDownloadUrl(opts)
}
return {
subscribe,
search,
getEventDefinitions,
getDownloadUrl,
}
}
export const auditLogs = createAuditLogsStore()

View File

@ -13,3 +13,4 @@ export { backups } from "./backups"
export { overview } from "./overview"
export { environment } from "./environment"
export { menu } from "./menu"
export { auditLogs } from "./auditLogs"

View File

@ -63,7 +63,13 @@ export const createLicensingStore = () => {
const environmentVariablesEnabled = license.features.includes(
Constants.Features.ENVIRONMENT_VARIABLES
)
const enforceableSSO = license.features.includes(
Constants.Features.ENFORCEABLE_SSO
)
const auditLogsEnabled = license.features.includes(
Constants.Features.AUDIT_LOGS
)
store.update(state => {
return {
...state,
@ -72,6 +78,8 @@ export const createLicensingStore = () => {
groupsEnabled,
backupsEnabled,
environmentVariablesEnabled,
auditLogsEnabled,
enforceableSSO,
}
})
},

View File

@ -75,6 +75,10 @@ export const menu = derived([admin, auth], ([$admin, $auth]) => {
title: "Usage",
href: "/builder/portal/account/usage",
},
{
title: "Audit Logs",
href: "/builder/portal/account/auditLogs",
},
]
if ($admin.cloud && $auth?.user?.accountPortalAccess) {
accountSubPages.push({
@ -87,6 +91,7 @@ export const menu = derived([admin, auth], ([$admin, $auth]) => {
href: "/builder/portal/account/upgrade",
})
}
// add license check here
if (
$auth?.user?.accountPortalAccess &&
$auth.user.account.stripeCustomerId

View File

@ -11,6 +11,7 @@ const DEFAULT_CONFIG = {
google: undefined,
oidcCallbackUrl: "",
googleCallbackUrl: "",
isSSOEnforced: false,
}
export function createOrganisationStore() {
@ -19,8 +20,8 @@ export function createOrganisationStore() {
async function init() {
const tenantId = get(auth).tenantId
const tenant = await API.getTenantConfig(tenantId)
set({ ...DEFAULT_CONFIG, ...tenant.config, _rev: tenant._rev })
const settingsConfigDoc = await API.getTenantConfig(tenantId)
set({ ...DEFAULT_CONFIG, ...settingsConfigDoc.config })
}
async function save(config) {
@ -33,7 +34,6 @@ export function createOrganisationStore() {
await API.saveConfig({
type: "settings",
config: { ...get(store), ...config },
_rev: get(store)._rev,
})
await init()
}

View File

@ -1,6 +1,6 @@
{
"name": "@budibase/cli",
"version": "2.3.18-alpha.12",
"version": "2.3.18-alpha.14",
"description": "Budibase CLI, for developers, self hosting and migrations.",
"main": "src/index.js",
"bin": {
@ -26,9 +26,9 @@
"outputPath": "build"
},
"dependencies": {
"@budibase/backend-core": "2.3.18-alpha.12",
"@budibase/string-templates": "2.3.18-alpha.12",
"@budibase/types": "2.3.18-alpha.12",
"@budibase/backend-core": "2.3.18-alpha.14",
"@budibase/string-templates": "2.3.18-alpha.14",
"@budibase/types": "2.3.18-alpha.14",
"axios": "0.21.2",
"chalk": "4.1.0",
"cli-progress": "3.11.2",

View File

@ -1,6 +1,6 @@
{
"name": "@budibase/client",
"version": "2.3.18-alpha.12",
"version": "2.3.18-alpha.14",
"license": "MPL-2.0",
"module": "dist/budibase-client.js",
"main": "dist/budibase-client.js",
@ -19,9 +19,9 @@
"dev:builder": "rollup -cw"
},
"dependencies": {
"@budibase/bbui": "2.3.18-alpha.12",
"@budibase/frontend-core": "2.3.18-alpha.12",
"@budibase/string-templates": "2.3.18-alpha.12",
"@budibase/bbui": "2.3.18-alpha.14",
"@budibase/frontend-core": "2.3.18-alpha.14",
"@budibase/string-templates": "2.3.18-alpha.14",
"@spectrum-css/button": "^3.0.3",
"@spectrum-css/card": "^3.0.3",
"@spectrum-css/divider": "^1.0.3",

View File

@ -1,12 +1,12 @@
{
"name": "@budibase/frontend-core",
"version": "2.3.18-alpha.12",
"version": "2.3.18-alpha.14",
"description": "Budibase frontend core libraries used in builder and client",
"author": "Budibase",
"license": "MPL-2.0",
"svelte": "src/index.js",
"dependencies": {
"@budibase/bbui": "2.3.18-alpha.12",
"@budibase/bbui": "2.3.18-alpha.14",
"lodash": "^4.17.21",
"svelte": "^3.46.2"
}

View File

@ -0,0 +1,63 @@
const buildOpts = ({
bookmark,
userIds,
appIds,
startDate,
endDate,
fullSearch,
events,
}) => {
const opts = {}
if (bookmark) {
opts.bookmark = bookmark
}
if (startDate && endDate) {
opts.startDate = startDate
opts.endDate = endDate
} else if (startDate && !endDate) {
opts.startDate = startDate
}
if (fullSearch) {
opts.fullSearch = fullSearch
}
if (events.length) {
opts.events = events
}
if (userIds.length) {
opts.userIds = userIds
}
if (appIds.length) {
opts.appIds = appIds
}
return opts
}
export const buildAuditLogsEndpoints = API => ({
/**
* Gets a list of users in the current tenant.
*/
searchAuditLogs: async opts => {
return await API.post({
url: `/api/global/auditlogs/search`,
body: buildOpts(opts),
})
},
getEventDefinitions: async () => {
return await API.get({
url: `/api/global/auditlogs/definitions`,
})
},
getDownloadUrl: opts => {
const query = encodeURIComponent(JSON.stringify(opts))
return `/api/global/auditlogs/download?query=${query}`
},
})

View File

@ -28,6 +28,8 @@ import { buildPluginEndpoints } from "./plugins"
import { buildBackupsEndpoints } from "./backups"
import { buildEnvironmentVariableEndpoints } from "./environmentVariables"
import { buildEventEndpoints } from "./events"
import { buildAuditLogsEndpoints } from "./auditLogs"
const defaultAPIClientConfig = {
/**
* Certain definitions can't change at runtime for client apps, such as the
@ -250,5 +252,6 @@ export const createAPIClient = config => {
...buildBackupsEndpoints(API),
...buildEnvironmentVariableEndpoints(API),
...buildEventEndpoints(API),
...buildAuditLogsEndpoints(API),
}
}

View File

@ -115,6 +115,8 @@ export const Features = {
USER_GROUPS: "userGroups",
BACKUPS: "appBackups",
ENVIRONMENT_VARIABLES: "environmentVariables",
AUDIT_LOGS: "auditLogs",
ENFORCEABLE_SSO: "enforceableSSO",
}
// Role IDs

View File

@ -1,6 +1,6 @@
{
"name": "@budibase/sdk",
"version": "2.3.18-alpha.12",
"version": "2.3.18-alpha.14",
"description": "Budibase Public API SDK",
"author": "Budibase",
"license": "MPL-2.0",

View File

@ -39,6 +39,7 @@ const config: Config.InitialOptions = {
],
collectCoverageFrom: [
"src/**/*.{js,ts}",
"../backend-core/src/**/*.{js,ts}",
// The use of coverage with couchdb view functions breaks tests
"!src/db/views/staticViews.*",
],

View File

@ -1,7 +1,7 @@
{
"name": "@budibase/server",
"email": "hi@budibase.com",
"version": "2.3.18-alpha.12",
"version": "2.3.18-alpha.14",
"description": "Budibase Web Server",
"main": "src/index.ts",
"repository": {
@ -43,11 +43,11 @@
"license": "GPL-3.0",
"dependencies": {
"@apidevtools/swagger-parser": "10.0.3",
"@budibase/backend-core": "2.3.18-alpha.12",
"@budibase/client": "2.3.18-alpha.12",
"@budibase/pro": "2.3.18-alpha.12",
"@budibase/string-templates": "2.3.18-alpha.12",
"@budibase/types": "2.3.18-alpha.12",
"@budibase/backend-core": "2.3.18-alpha.14",
"@budibase/client": "2.3.18-alpha.14",
"@budibase/pro": "2.3.18-alpha.14",
"@budibase/string-templates": "2.3.18-alpha.14",
"@budibase/types": "2.3.18-alpha.14",
"@bull-board/api": "3.7.0",
"@bull-board/koa": "3.9.4",
"@elastic/elasticsearch": "7.10.0",
@ -87,6 +87,7 @@
"koa-send": "5.0.0",
"koa-session": "5.12.0",
"koa-static": "5.0.0",
"koa-useragent": "^4.1.0",
"koa2-ratelimit": "1.1.1",
"lodash": "4.17.21",
"memorystream": "0.3.1",

View File

@ -58,7 +58,7 @@ export async function exportApps(ctx: Ctx) {
}
async function checkHasBeenImported() {
if (!env.SELF_HOSTED || env.MULTI_TENANCY) {
if (!env.SELF_HOSTED) {
return true
}
const apps = await dbCore.getAllApps({ all: true })
@ -72,7 +72,7 @@ export async function hasBeenImported(ctx: Ctx) {
}
export async function importApps(ctx: Ctx) {
if (!env.SELF_HOSTED || env.MULTI_TENANCY) {
if (!env.SELF_HOSTED) {
ctx.throw(400, "Importing only allowed in self hosted environments.")
}
const beenImported = await checkHasBeenImported()

View File

@ -24,8 +24,7 @@ import { breakExternalTableId, isSQL } from "../../../integrations/utils"
import { processObjectSync } from "@budibase/string-templates"
import { cloneDeep } from "lodash/fp"
import { processFormulas, processDates } from "../../../utilities/rowProcessor"
import { context } from "@budibase/backend-core"
import { removeKeyNumbering } from "./utils"
import { db as dbCore } from "@budibase/backend-core"
import sdk from "../../../sdk"
export interface ManyRelationship {
@ -61,7 +60,7 @@ function buildFilters(
let prefix = 1
for (let operator of Object.values(filters)) {
for (let field of Object.keys(operator || {})) {
if (removeKeyNumbering(field) === "_id") {
if (dbCore.removeKeyNumbering(field) === "_id") {
if (primary) {
const parts = breakRowIdField(operator[field])
for (let field of primary) {

View File

@ -1,531 +1,18 @@
import { SearchIndexes } from "../../../db/utils"
import { removeKeyNumbering } from "./utils"
import fetch from "node-fetch"
import { db as dbCore, context } from "@budibase/backend-core"
import { SearchFilters, Row } from "@budibase/types"
import { db as dbCore, context, SearchParams } from "@budibase/backend-core"
import { SearchFilters, Row, SearchIndex } from "@budibase/types"
type SearchParams = {
tableId: string
sort?: string
sortOrder?: string
sortType?: string
limit?: number
bookmark?: string
version?: string
rows?: Row[]
}
/**
* Class to build lucene query URLs.
* Optionally takes a base lucene query object.
*/
export class QueryBuilder {
query: SearchFilters
limit: number
sort?: string
bookmark?: string
sortOrder: string
sortType: string
includeDocs: boolean
version?: string
constructor(base?: SearchFilters) {
this.query = {
allOr: false,
string: {},
fuzzy: {},
range: {},
equal: {},
notEqual: {},
empty: {},
notEmpty: {},
oneOf: {},
contains: {},
notContains: {},
containsAny: {},
...base,
}
this.limit = 50
this.sortOrder = "ascending"
this.sortType = "string"
this.includeDocs = true
}
setVersion(version?: string) {
if (version != null) {
this.version = version
}
return this
}
setTable(tableId: string) {
this.query.equal!.tableId = tableId
return this
}
setLimit(limit?: number) {
if (limit != null) {
this.limit = limit
}
return this
}
setSort(sort?: string) {
if (sort != null) {
this.sort = sort
}
return this
}
setSortOrder(sortOrder?: string) {
if (sortOrder != null) {
this.sortOrder = sortOrder
}
return this
}
setSortType(sortType?: string) {
if (sortType != null) {
this.sortType = sortType
}
return this
}
setBookmark(bookmark?: string) {
if (bookmark != null) {
this.bookmark = bookmark
}
return this
}
excludeDocs() {
this.includeDocs = false
return this
}
addString(key: string, partial: string) {
this.query.string![key] = partial
return this
}
addFuzzy(key: string, fuzzy: string) {
this.query.fuzzy![key] = fuzzy
return this
}
addRange(key: string, low: string | number, high: string | number) {
this.query.range![key] = {
low,
high,
}
return this
}
addEqual(key: string, value: any) {
this.query.equal![key] = value
return this
}
addNotEqual(key: string, value: any) {
this.query.notEqual![key] = value
return this
}
addEmpty(key: string, value: any) {
this.query.empty![key] = value
return this
}
addNotEmpty(key: string, value: any) {
this.query.notEmpty![key] = value
return this
}
addOneOf(key: string, value: any) {
this.query.oneOf![key] = value
return this
}
addContains(key: string, value: any) {
this.query.contains![key] = value
return this
}
addNotContains(key: string, value: any) {
this.query.notContains![key] = value
return this
}
addContainsAny(key: string, value: any) {
this.query.containsAny![key] = value
return this
}
/**
* Preprocesses a value before going into a lucene search.
* Transforms strings to lowercase and wraps strings and bools in quotes.
* @param value The value to process
* @param options The preprocess options
* @returns {string|*}
*/
preprocess(value: any, { escape, lowercase, wrap, type }: any = {}) {
const hasVersion = !!this.version
// Determine if type needs wrapped
const originalType = typeof value
// Convert to lowercase
if (value && lowercase) {
value = value.toLowerCase ? value.toLowerCase() : value
}
// Escape characters
if (escape && originalType === "string") {
value = `${value}`.replace(/[ #+\-&|!(){}\]^"~*?:\\]/g, "\\$&")
}
// Wrap in quotes
if (originalType === "string" && !isNaN(value) && !type) {
value = `"${value}"`
} else if (hasVersion && wrap) {
value = originalType === "number" ? value : `"${value}"`
}
return value
}
buildSearchQuery() {
const builder = this
let allOr = this.query && this.query.allOr
let query = allOr ? "" : "*:*"
const allPreProcessingOpts = { escape: true, lowercase: true, wrap: true }
let tableId
if (this.query.equal!.tableId) {
tableId = this.query.equal!.tableId
delete this.query.equal!.tableId
}
const equal = (key: string, value: any) => {
// 0 evaluates to false, which means we would return all rows if we don't check it
if (!value && value !== 0) {
return null
}
return `${key}:${builder.preprocess(value, allPreProcessingOpts)}`
}
const contains = (key: string, value: any, mode = "AND") => {
if (Array.isArray(value) && value.length === 0) {
return null
}
if (!Array.isArray(value)) {
return `${key}:${value}`
}
let statement = `${builder.preprocess(value[0], { escape: true })}`
for (let i = 1; i < value.length; i++) {
statement += ` ${mode} ${builder.preprocess(value[i], {
escape: true,
})}`
}
return `${key}:(${statement})`
}
const notContains = (key: string, value: any) => {
// @ts-ignore
const allPrefix = allOr === "" ? "*:* AND" : ""
return allPrefix + "NOT " + contains(key, value)
}
const containsAny = (key: string, value: any) => {
return contains(key, value, "OR")
}
const oneOf = (key: string, value: any) => {
if (!Array.isArray(value)) {
if (typeof value === "string") {
value = value.split(",")
} else {
return ""
}
}
let orStatement = `${builder.preprocess(value[0], allPreProcessingOpts)}`
for (let i = 1; i < value.length; i++) {
orStatement += ` OR ${builder.preprocess(
value[i],
allPreProcessingOpts
)}`
}
return `${key}:(${orStatement})`
}
function build(structure: any, queryFn: any) {
for (let [key, value] of Object.entries(structure)) {
// check for new format - remove numbering if needed
key = removeKeyNumbering(key)
key = builder.preprocess(key.replace(/ /g, "_"), {
escape: true,
})
const expression = queryFn(key, value)
if (expression == null) {
continue
}
if (query.length > 0) {
query += ` ${allOr ? "OR" : "AND"} `
}
query += expression
}
}
// Construct the actual lucene search query string from JSON structure
if (this.query.string) {
build(this.query.string, (key: string, value: any) => {
if (!value) {
return null
}
value = builder.preprocess(value, {
escape: true,
lowercase: true,
type: "string",
})
return `${key}:${value}*`
})
}
if (this.query.range) {
build(this.query.range, (key: string, value: any) => {
if (!value) {
return null
}
if (value.low == null || value.low === "") {
return null
}
if (value.high == null || value.high === "") {
return null
}
const low = builder.preprocess(value.low, allPreProcessingOpts)
const high = builder.preprocess(value.high, allPreProcessingOpts)
return `${key}:[${low} TO ${high}]`
})
}
if (this.query.fuzzy) {
build(this.query.fuzzy, (key: string, value: any) => {
if (!value) {
return null
}
value = builder.preprocess(value, {
escape: true,
lowercase: true,
type: "fuzzy",
})
return `${key}:${value}~`
})
}
if (this.query.equal) {
build(this.query.equal, equal)
}
if (this.query.notEqual) {
build(this.query.notEqual, (key: string, value: any) => {
if (!value) {
return null
}
return `!${key}:${builder.preprocess(value, allPreProcessingOpts)}`
})
}
if (this.query.empty) {
build(this.query.empty, (key: string) => `!${key}:["" TO *]`)
}
if (this.query.notEmpty) {
build(this.query.notEmpty, (key: string) => `${key}:["" TO *]`)
}
if (this.query.oneOf) {
build(this.query.oneOf, oneOf)
}
if (this.query.contains) {
build(this.query.contains, contains)
}
if (this.query.notContains) {
build(this.query.notContains, notContains)
}
if (this.query.containsAny) {
build(this.query.containsAny, containsAny)
}
// make sure table ID is always added as an AND
if (tableId) {
query = `(${query})`
allOr = false
build({ tableId }, equal)
}
return query
}
buildSearchBody() {
let body: any = {
q: this.buildSearchQuery(),
limit: Math.min(this.limit, 200),
include_docs: this.includeDocs,
}
if (this.bookmark) {
body.bookmark = this.bookmark
}
if (this.sort) {
const order = this.sortOrder === "descending" ? "-" : ""
const type = `<${this.sortType}>`
body.sort = `${order}${this.sort.replace(/ /g, "_")}${type}`
}
return body
}
async run() {
const appId = context.getAppId()
const { url, cookie } = dbCore.getCouchInfo()
const fullPath = `${url}/${appId}/_design/database/_search/${SearchIndexes.ROWS}`
const body = this.buildSearchBody()
return await runQuery(fullPath, body, cookie)
}
}
/**
* Executes a lucene search query.
* @param url The query URL
* @param body The request body defining search criteria
* @param cookie The auth cookie for CouchDB
* @returns {Promise<{rows: []}>}
*/
const runQuery = async (url: string, body: any, cookie: string) => {
const response = await fetch(url, {
body: JSON.stringify(body),
method: "POST",
headers: {
Authorization: cookie,
},
})
const json = await response.json()
let output: any = {
rows: [],
}
if (json.rows != null && json.rows.length > 0) {
output.rows = json.rows.map((row: any) => row.doc)
}
if (json.bookmark) {
output.bookmark = json.bookmark
}
return output
}
/**
* Gets round the fixed limit of 200 results from a query by fetching as many
* pages as required and concatenating the results. This recursively operates
* until enough results have been found.
* @param query {object} The JSON query structure
* @param params {object} The search params including:
* tableId {string} The table ID to search
* sort {string} The sort column
* sortOrder {string} The sort order ("ascending" or "descending")
* sortType {string} Whether to treat sortable values as strings or
* numbers. ("string" or "number")
* limit {number} The number of results to fetch
* bookmark {string|null} Current bookmark in the recursive search
* rows {array|null} Current results in the recursive search
* @returns {Promise<*[]|*>}
*/
async function recursiveSearch(query: any, params: any): Promise<any> {
const bookmark = params.bookmark
const rows = params.rows || []
if (rows.length >= params.limit) {
return rows
}
let pageSize = 200
if (rows.length > params.limit - 200) {
pageSize = params.limit - rows.length
}
const page = await new QueryBuilder(query)
.setVersion(params.version)
.setTable(params.tableId)
.setBookmark(bookmark)
.setLimit(pageSize)
.setSort(params.sort)
.setSortOrder(params.sortOrder)
.setSortType(params.sortType)
.run()
if (!page.rows.length) {
return rows
}
if (page.rows.length < 200) {
return [...rows, ...page.rows]
}
const newParams = {
...params,
bookmark: page.bookmark,
rows: [...rows, ...page.rows],
}
return await recursiveSearch(query, newParams)
}
/**
* Performs a paginated search. A bookmark will be returned to allow the next
* page to be fetched. There is a max limit off 200 results per page in a
* paginated search.
* @param query {object} The JSON query structure
* @param params {object} The search params including:
* tableId {string} The table ID to search
* sort {string} The sort column
* sortOrder {string} The sort order ("ascending" or "descending")
* sortType {string} Whether to treat sortable values as strings or
* numbers. ("string" or "number")
* limit {number} The desired page size
* bookmark {string} The bookmark to resume from
* @returns {Promise<{hasNextPage: boolean, rows: *[]}>}
*/
export async function paginatedSearch(
query: SearchFilters,
params: SearchParams
params: SearchParams<Row>
) {
let limit = params.limit
if (limit == null || isNaN(limit) || limit < 0) {
limit = 50
}
limit = Math.min(limit, 200)
const search = new QueryBuilder(query)
.setVersion(params.version)
.setTable(params.tableId)
.setSort(params.sort)
.setSortOrder(params.sortOrder)
.setSortType(params.sortType)
const searchResults = await search
.setBookmark(params.bookmark)
.setLimit(limit)
.run()
// Try fetching 1 row in the next page to see if another page of results
// exists or not
const nextResults = await search
.setTable(params.tableId)
.setBookmark(searchResults.bookmark)
.setLimit(1)
.run()
return {
...searchResults,
hasNextPage: nextResults.rows && nextResults.rows.length > 0,
}
const appId = context.getAppId()
return dbCore.paginatedSearch(appId!, SearchIndex.ROWS, query, params)
}
/**
* Performs a full search, fetching multiple pages if required to return the
* desired amount of results. There is a limit of 1000 results to avoid
* heavy performance hits, and to avoid client components breaking from
* handling too much data.
* @param query {object} The JSON query structure
* @param params {object} The search params including:
* tableId {string} The table ID to search
* sort {string} The sort column
* sortOrder {string} The sort order ("ascending" or "descending")
* sortType {string} Whether to treat sortable values as strings or
* numbers. ("string" or "number")
* limit {number} The desired number of results
* @returns {Promise<{rows: *}>}
*/
export async function fullSearch(query: SearchFilters, params: SearchParams) {
let limit = params.limit
if (limit == null || isNaN(limit) || limit < 0) {
limit = 1000
}
params.limit = Math.min(limit, 1000)
const rows = await recursiveSearch(query, params)
return { rows }
export async function fullSearch(
query: SearchFilters,
params: SearchParams<Row>
) {
const appId = context.getAppId()
return dbCore.fullSearch(appId!, SearchIndex.ROWS, query, params)
}

View File

@ -3,8 +3,7 @@ import * as userController from "../user"
import { FieldTypes } from "../../../constants"
import { context } from "@budibase/backend-core"
import { makeExternalQuery } from "../../../integrations/base/query"
import { BBContext, Row, Table } from "@budibase/types"
export { removeKeyNumbering } from "../../../integrations/base/utils"
import { Row, Table } from "@budibase/types"
const validateJs = require("validate.js")
const { cloneDeep } = require("lodash/fp")
import { Format } from "../view/exporters"

View File

@ -1,3 +1,5 @@
import { App } from "@budibase/types"
jest.setTimeout(30000)
import { AppStatus } from "../../../db/utils"
@ -5,6 +7,7 @@ import { AppStatus } from "../../../db/utils"
import * as setup from "./utilities"
import { wipeDb } from "./utilities/TestFunctions"
import { tenancy } from "@budibase/backend-core"
describe("/cloud", () => {
let request = setup.getRequest()!
@ -12,18 +15,10 @@ describe("/cloud", () => {
afterAll(setup.afterAll)
beforeAll(() => {
beforeAll(async () => {
// Importing is only allowed in self hosted environments
config.modeSelf()
})
beforeEach(async () => {
await config.init()
})
afterEach(async () => {
// clear all mocks
jest.clearAllMocks()
config.modeSelf()
})
describe("import", () => {
@ -32,30 +27,28 @@ describe("/cloud", () => {
// import will not run
await wipeDb()
// get a count of apps before the import
const preImportApps = await request
.get(`/api/applications?status=${AppStatus.ALL}`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
// Perform the import
const res = await request
.post(`/api/cloud/import`)
.set(config.publicHeaders())
.attach("importFile", "src/api/routes/tests/data/export-test.tar.gz")
.set(config.defaultHeaders())
.expect(200)
expect(res.body.message).toEqual("Apps successfully imported.")
// get a count of apps after the import
const postImportApps = await request
.get(`/api/applications?status=${AppStatus.ALL}`)
.set(config.defaultHeaders())
.set(config.publicHeaders())
.expect("Content-Type", /json/)
.expect(200)
const apps = postImportApps.body as App[]
// There are two apps in the file that was imported so check for this
expect(postImportApps.body.length).toEqual(2)
expect(apps.length).toEqual(2)
// The new tenant id was assigned to the imported apps
expect(tenancy.getTenantIDFromAppID(apps[0].appId)).toBe(
config.getTenantId()
)
})
})
})

View File

@ -2,7 +2,6 @@ import * as rowController from "../../../controllers/row"
import * as appController from "../../../controllers/application"
import { AppStatus } from "../../../../db/utils"
import { roles, tenancy, context } from "@budibase/backend-core"
import { TENANT_ID } from "../../../../tests/utilities/structures"
import env from "../../../../environment"
import { db } from "@budibase/backend-core"
import Nano from "@budibase/nano"
@ -33,7 +32,7 @@ export const getAllTableRows = async (config: any) => {
}
export const clearAllApps = async (
tenantId = TENANT_ID,
tenantId: string,
exceptions: Array<string> = []
) => {
await tenancy.doInTenant(tenantId, async () => {

Some files were not shown because too many files have changed in this diff Show More