UNPKG

@budibase/worker

Version:
706 lines (633 loc) • 19.6 kB
import { BadRequestError, cache, configs, env as coreEnv, db as dbCore, events, ForbiddenError, objectStore, tenancy, } from "@budibase/backend-core" import * as pro from "@budibase/pro" import { BUILDER_URLS } from "@budibase/shared-core" import { AIInnerConfig, Config, ConfigChecklistResponse, ConfigType, Ctx, DeleteConfigResponse, FindConfigResponse, GetPublicOIDCConfigResponse, GetPublicSettingsResponse, GoogleInnerConfig, isAIConfig, isGoogleConfig, isOIDCConfig, isRecaptchaConfig, isSettingsConfig, isSMTPConfig, OIDCConfigs, OIDCLogosConfig, PASSWORD_REPLACEMENT, RecaptchaInnerConfig, SaveConfigRequest, SaveConfigResponse, SettingsBrandingConfig, SettingsInnerConfig, SMTPInnerConfig, SSOConfig, SSOConfigType, UploadConfigFileResponse, UserCtx, } from "@budibase/types" import env from "../../../environment" import * as email from "../../../utilities/email" import { checkAnyUserExists } from "../../../utilities/users" import * as auth from "./auth" const getEventFns = async (config: Config, existing?: Config) => { const fns = [] if (!existing) { if (isSMTPConfig(config)) { fns.push(events.email.SMTPCreated) } else if (isAIConfig(config)) { fns.push(() => events.ai.AIConfigCreated) } else if (isGoogleConfig(config)) { fns.push(() => events.auth.SSOCreated(ConfigType.GOOGLE)) if (config.config.activated) { fns.push(() => events.auth.SSOActivated(ConfigType.GOOGLE)) } } else if (isOIDCConfig(config)) { fns.push(() => events.auth.SSOCreated(ConfigType.OIDC)) if (config.config.configs[0].activated) { fns.push(() => events.auth.SSOActivated(ConfigType.OIDC)) } } else if (isSettingsConfig(config)) { // company const company = config.config.company if (company && company !== "Budibase") { fns.push(events.org.nameUpdated) } // logo const logoUrl = config.config.logoUrl if (logoUrl) { fns.push(events.org.logoUpdated) } // platform url const platformUrl = config.config.platformUrl if ( platformUrl && platformUrl !== "http://localhost:10000" && env.SELF_HOSTED ) { fns.push(events.org.platformURLUpdated) } } } else { if (isSMTPConfig(config)) { fns.push(events.email.SMTPUpdated) } else if (isAIConfig(config)) { fns.push(() => events.ai.AIConfigUpdated) } else if (isGoogleConfig(config)) { fns.push(() => events.auth.SSOUpdated(ConfigType.GOOGLE)) if (!existing.config.activated && config.config.activated) { fns.push(() => events.auth.SSOActivated(ConfigType.GOOGLE)) } else if (existing.config.activated && !config.config.activated) { fns.push(() => events.auth.SSODeactivated(ConfigType.GOOGLE)) } } else if (isOIDCConfig(config)) { fns.push(() => events.auth.SSOUpdated(ConfigType.OIDC)) if ( !existing.config.configs[0].activated && config.config.configs[0].activated ) { fns.push(() => events.auth.SSOActivated(ConfigType.OIDC)) } else if ( existing.config.configs[0].activated && !config.config.configs[0].activated ) { fns.push(() => events.auth.SSODeactivated(ConfigType.OIDC)) } } else if (isSettingsConfig(config)) { // company const existingCompany = existing.config.company const company = config.config.company if (company && company !== "Budibase" && existingCompany !== company) { fns.push(events.org.nameUpdated) } // logo const existingLogoUrl = existing.config.logoUrl const logoUrl = config.config.logoUrl if (logoUrl && existingLogoUrl !== logoUrl) { fns.push(events.org.logoUpdated) } // platform url const existingPlatformUrl = existing.config.platformUrl const platformUrl = config.config.platformUrl if ( platformUrl && platformUrl !== "http://localhost:10000" && existingPlatformUrl !== platformUrl && env.SELF_HOSTED ) { fns.push(events.org.platformURLUpdated) } } } return fns } type SSOConfigs = { [key in SSOConfigType]: SSOConfig | undefined } async function getSSOConfigs(): Promise<SSOConfigs> { const google = await configs.getGoogleConfig() const oidc = await configs.getOIDCConfig() return { [ConfigType.GOOGLE]: google, [ConfigType.OIDC]: oidc, } } async function hasActivatedConfig(ssoConfigs?: SSOConfigs) { if (!ssoConfigs) { ssoConfigs = await getSSOConfigs() } return !!Object.values(ssoConfigs).find(c => c?.activated) } async function processSMTPConfig( config: SMTPInnerConfig, existingConfig?: SMTPInnerConfig ) { await email.verifyConfig(config) if (config.auth?.pass === PASSWORD_REPLACEMENT) { // if the password is being replaced, use the existing password if (existingConfig && existingConfig.auth?.pass) { config.auth.pass = existingConfig.auth.pass } else { // otherwise, throw an error throw new BadRequestError("SMTP password is required") } } } async function processSettingsConfig( config: SettingsInnerConfig & SettingsBrandingConfig, existingConfig?: SettingsInnerConfig & SettingsBrandingConfig ) { if (config.isSSOEnforced) { const valid = await hasActivatedConfig() if (!valid) { throw new Error("Cannot enforce SSO without an activated configuration") } } // always preserve file attributes // these should be set via upload instead // only allow for deletion by checking empty string to bypass this behaviour if (existingConfig && config.logoUrl !== "") { config.logoUrl = existingConfig.logoUrl config.logoUrlEtag = existingConfig.logoUrlEtag } if (existingConfig && config.faviconUrl !== "") { config.faviconUrl = existingConfig.faviconUrl config.faviconUrlEtag = existingConfig.faviconUrlEtag } } async function verifySSOConfig(type: SSOConfigType, config: SSOConfig) { const settings = await configs.getSettingsConfig() if (settings.isSSOEnforced && !config.activated) { // config is being saved as deactivated // ensure there is at least one other activated sso config const ssoConfigs = await getSSOConfigs() // overwrite the config being updated // to reflect the desired state ssoConfigs[type] = config const activated = await hasActivatedConfig(ssoConfigs) if (!activated) { throw new Error( "Configuration cannot be deactivated while SSO is enforced" ) } } } async function processGoogleConfig( config: GoogleInnerConfig, existing?: GoogleInnerConfig ) { await verifySSOConfig(ConfigType.GOOGLE, config) if (existing && config.clientSecret === PASSWORD_REPLACEMENT) { config.clientSecret = existing.clientSecret } } async function processOIDCConfig(config: OIDCConfigs, existing?: OIDCConfigs) { await verifySSOConfig(ConfigType.OIDC, config.configs[0]) const anyPkceSettings = config.configs.find(cfg => cfg.pkce) if (anyPkceSettings && !(await pro.features.isPkceOidcEnabled())) { throw new Error("License does not allow OIDC PKCE method support") } config.configs.filter(c => c.pkce === null).forEach(c => delete c.pkce) if (existing) { for (const c of config.configs) { const existingConfig = existing.configs.find(e => e.uuid === c.uuid) if (!existingConfig) { continue } if (c.clientSecret === PASSWORD_REPLACEMENT) { c.clientSecret = existingConfig.clientSecret } } } } export async function processAIConfig( newConfig: AIInnerConfig, existingConfig: AIInnerConfig ) { for (const key in existingConfig) { if (newConfig[key]?.apiKey === PASSWORD_REPLACEMENT) { newConfig[key].apiKey = existingConfig[key].apiKey } } let numBudibaseAI = 0 for (const config of Object.values(newConfig)) { if (config.provider === "BudibaseAI") { numBudibaseAI++ if (numBudibaseAI > 1) { throw new BadRequestError("Only one Budibase AI provider is allowed") } } else { if (!config.apiKey) { throw new BadRequestError( `API key is required for provider ${config.provider}` ) } } } } export async function processRecaptchaConfig( config: RecaptchaInnerConfig, existingConfig?: RecaptchaInnerConfig ) { if (!(await pro.features.isRecaptchaEnabled())) { throw new ForbiddenError("License does not allow use of recaptcha") } if (config.secretKey === PASSWORD_REPLACEMENT && !existingConfig) { throw new BadRequestError("No secret key provided") } if (config.secretKey === PASSWORD_REPLACEMENT && existingConfig) { config.secretKey = existingConfig.secretKey } } export async function save( ctx: UserCtx<SaveConfigRequest, SaveConfigResponse> ) { const body = ctx.request.body const type = body.type const config = body.config const existingConfig = await configs.getConfig(type) let eventFns = await getEventFns(ctx.request.body, existingConfig) if (existingConfig) { body._rev = existingConfig._rev } try { switch (type) { case ConfigType.SMTP: await processSMTPConfig(config, existingConfig?.config) break case ConfigType.SETTINGS: await processSettingsConfig(config, existingConfig?.config) break case ConfigType.GOOGLE: await processGoogleConfig(config, existingConfig?.config) break case ConfigType.OIDC: await processOIDCConfig(config, existingConfig?.config) break case ConfigType.AI: if (existingConfig) { await processAIConfig(config, existingConfig.config) } break case ConfigType.RECAPTCHA: await processRecaptchaConfig(config, existingConfig?.config) break } } catch (err: any) { ctx.throw(400, err) } // Ignore branding changes if the license does not permit it // Favicon and Logo Url are excluded. try { const brandingEnabled = await pro.features.isBrandingEnabled() if (existingConfig?.config && !brandingEnabled) { const { emailBrandingEnabled, platformTitle, metaDescription, loginHeading, loginButton, metaImageUrl, metaTitle, } = existingConfig.config body.config = { ...body.config, emailBrandingEnabled, platformTitle, metaDescription, loginHeading, loginButton, metaImageUrl, metaTitle, } } } catch (e) { console.error("There was an issue retrieving the license", e) } try { body._id = configs.generateConfigID(type) const response = await configs.save(body) await cache.bustCache(cache.CacheKey.CHECKLIST) await cache.bustCache(cache.CacheKey.ANALYTICS_ENABLED) for (const fn of eventFns) { await fn() } ctx.body = { type, _id: response.id, _rev: response.rev, } } catch (err: any) { ctx.throw(400, err) } } async function enrichOIDCLogos(oidcLogos: OIDCLogosConfig) { if (!oidcLogos) { return } const newConfig: Record<string, string> = {} const keys = Object.keys(oidcLogos.config || {}) for (const key of keys) { if (!key.endsWith("Etag")) { const etag = oidcLogos.config[`${key}Etag`] const objectStoreUrl = await objectStore.getGlobalFileUrl( oidcLogos.type, key, etag ) newConfig[key] = objectStoreUrl } else { newConfig[key] = oidcLogos.config[key] } } oidcLogos.config = newConfig } export async function find(ctx: UserCtx<void, FindConfigResponse>) { // Find the config with the most granular scope based on context const type = ctx.params.type let config = await configs.getConfig(type) if (!config && type === ConfigType.AI) { config = { type: ConfigType.AI, config: {} } } if (!config) { ctx.body = {} return } switch (type) { case ConfigType.OIDC_LOGOS: await enrichOIDCLogos(config) break case ConfigType.AI: await pro.sdk.ai.enrichAIConfig(config) break } stripSecrets(config) ctx.body = config } function stripSecrets(config: Config) { if (isAIConfig(config)) { for (const key in config.config) { if (config.config[key].apiKey) { config.config[key].apiKey = PASSWORD_REPLACEMENT } } } else if (isSMTPConfig(config)) { if (config.config.auth?.pass) { config.config.auth.pass = PASSWORD_REPLACEMENT } } else if (isGoogleConfig(config)) { config.config.clientSecret = PASSWORD_REPLACEMENT } else if (isOIDCConfig(config)) { for (const c of config.config.configs) { c.clientSecret = PASSWORD_REPLACEMENT } } else if (isRecaptchaConfig(config)) { config.config.secretKey = PASSWORD_REPLACEMENT } } export async function publicOidc(ctx: Ctx<void, GetPublicOIDCConfigResponse>) { try { // Find the config with the most granular scope based on context const oidcConfig = await configs.getOIDCConfig() const oidcCustomLogos = await configs.getOIDCLogosDoc() if (oidcCustomLogos) { await enrichOIDCLogos(oidcCustomLogos) } if (!oidcConfig) { ctx.body = [] } else { ctx.body = [ { logo: oidcCustomLogos?.config[oidcConfig.logo] ?? oidcConfig.logo, name: oidcConfig.name, uuid: oidcConfig.uuid, }, ] } } catch (err: any) { ctx.throw(err.status, err) } } export async function publicSettings( ctx: Ctx<void, GetPublicSettingsResponse> ) { try { // settings const [configDoc, googleConfig] = await Promise.all([ configs.getSettingsConfigDoc(), configs.getGoogleConfig(), ]) const config = configDoc.config const brandingPromise = pro.branding.getBrandingConfig(config) const getLogoUrl = () => { // enrich the logo url - empty url means deleted if (config.logoUrl && config.logoUrl !== "") { return objectStore.getGlobalFileUrl( "settings", "logoUrl", config.logoUrlEtag ) } } // google const googleDatasourcePromise = configs.getGoogleDatasourceConfig() const preActivated = googleConfig && googleConfig.activated == null const google = preActivated || !!googleConfig?.activated const googleCallbackUrlPromise = auth.googleCallbackUrl(googleConfig) // oidc const oidcConfigPromise = configs.getOIDCConfig() const oidcCallbackUrlPromise = auth.oidcCallbackUrl() // sso enforced const isSSOEnforcedPromise = pro.features.isSSOEnforced({ config }) // performance all async work at same time, there is no need for all of these // operations to occur in sync, slowing the endpoint down significantly const [ branding, googleDatasource, googleCallbackUrl, oidcConfig, oidcCallbackUrl, isSSOEnforced, logoUrl, ] = await Promise.all([ brandingPromise, googleDatasourcePromise, googleCallbackUrlPromise, oidcConfigPromise, oidcCallbackUrlPromise, isSSOEnforcedPromise, getLogoUrl(), ]) // enrich the favicon url - empty url means deleted const faviconUrl = branding.faviconUrl && branding.faviconUrl !== "" ? await objectStore.getGlobalFileUrl( "settings", "faviconUrl", branding.faviconUrlEtag ) : undefined const oidc = oidcConfig?.activated || false const googleDatasourceConfigured = !!googleDatasource if (logoUrl) { config.logoUrl = logoUrl } ctx.body = { type: ConfigType.SETTINGS, _id: configDoc._id, _rev: configDoc._rev, config: { ...config, ...branding, ...{ faviconUrl }, google, googleDatasourceConfigured, oidc, isSSOEnforced, oidcCallbackUrl, googleCallbackUrl, }, } } catch (err: any) { ctx.throw(err.status, err) } } export async function upload(ctx: UserCtx<void, UploadConfigFileResponse>) { if (ctx.request.files == null || Array.isArray(ctx.request.files.file)) { ctx.throw(400, "One file must be uploaded.") } const file = ctx.request.files.file as any const { type, name } = ctx.params let bucket = coreEnv.GLOBAL_BUCKET_NAME const key = objectStore.getGlobalFileS3Key(type, name) const result = await objectStore.upload({ bucket, filename: key, path: file.path, type: file.type, }) // add to configuration structure let config = await configs.getConfig(type) if (!config) { config = { _id: configs.generateConfigID(type), type, config: {}, } } // save the Etag for cache bursting const etag = result.ETag if (etag) { config.config[`${name}Etag`] = etag.replace(/"/g, "") } // save the file key config.config[`${name}`] = key // write back to db await configs.save(config) ctx.body = { message: "File has been uploaded and url stored to config.", url: await objectStore.getGlobalFileUrl(type, name, etag), } } export async function destroy(ctx: UserCtx<void, DeleteConfigResponse>) { const db = tenancy.getGlobalDB() const { id, rev } = ctx.params try { await db.remove(id, rev) await cache.destroy(cache.CacheKey.CHECKLIST) ctx.body = { message: "Config deleted successfully" } } catch (err: any) { ctx.throw(err.status, err) } } export async function configChecklist(ctx: Ctx<void, ConfigChecklistResponse>) { const tenantId = tenancy.getTenantId() try { ctx.body = await cache.withCache( cache.CacheKey.CHECKLIST, env.CHECKLIST_CACHE_TTL, async (): Promise<ConfigChecklistResponse> => { let workspaces = [] if (!env.MULTI_TENANCY || tenantId) { // Apps exist workspaces = await dbCore.getAllWorkspaces({ idsOnly: true, efficient: true, }) } // They have set up SMTP const smtpConfig = await configs.getSMTPConfig() // They have set up Google Auth const googleConfig = await configs.getGoogleConfig() // They have set up OIDC const oidcConfig = await configs.getOIDCConfig() // They have set up a global user const userExists = await checkAnyUserExists() // They have set up branding const configDoc = await configs.getSettingsConfigDoc() const config = configDoc.config const branding = await pro.branding.getBrandingConfig(config) return { apps: { checked: workspaces.length > 0, label: "Create your first app", link: BUILDER_URLS.WORKSPACES, }, smtp: { checked: !!smtpConfig, label: "Set up email", link: BUILDER_URLS.SETTINGS_EMAIL, fallback: smtpConfig?.fallback || false, }, adminUser: { checked: userExists, label: "Create your first user", link: BUILDER_URLS.SETTINGS_PEOPLE_USERS, }, sso: { checked: !!googleConfig || !!oidcConfig, label: "Set up single sign-on", link: BUILDER_URLS.SETTINGS_AUTH, }, branding, } } ) } catch (err: any) { ctx.throw(err.status, err) } }