@budibase/server
Version:
Budibase Web Server
378 lines (338 loc) • 11.7 kB
text/typescript
import {
context,
db as dbCore,
events,
HTTPError,
} from "@budibase/backend-core"
import { automations as sharedAutomations } from "@budibase/shared-core"
import {
Automation,
MetadataType,
PASSWORD_REPLACEMENT,
RequiredKeys,
Webhook,
WebhookActionType,
isEmailTrigger,
} from "@budibase/types"
import automations from "."
import cloneDeep from "lodash/cloneDeep"
import { generateAutomationID, getAutomationParams } from "../../../db/utils"
import { deleteEntityMetadata } from "../../../utilities"
import { deleteAutomationMailboxState } from "../../../automations/email/state"
export interface PersistedAutomation extends Automation {
_id: string
_rev: string
}
const PASSWORD_DISPLAY_MASK = "********"
function getDb() {
return context.getWorkspaceDB()
}
function cleanAutomationInputs(automation: Automation) {
if (automation == null) {
return automation
}
let steps = automation.definition.steps
let trigger = automation.definition.trigger
let allSteps = [...steps, trigger]
// live is not a property used anymore
if (automation.live != null) {
delete automation.live
}
for (let step of allSteps) {
if (step == null) {
continue
}
for (const key of Object.keys(step.inputs || {})) {
const inputName = key as keyof typeof step.inputs
if (!step.inputs[inputName] || step.inputs[inputName] === "") {
delete step.inputs[inputName]
}
}
}
return automation
}
async function handleStepEvents(
oldAutomation: Automation,
automation: Automation
) {
const getNewSteps = (oldAutomation: Automation, automation: Automation) => {
const oldStepIds = oldAutomation.definition.steps.map(s => s.id)
return automation.definition.steps.filter(s => !oldStepIds.includes(s.id))
}
const getDeletedSteps = (
oldAutomation: Automation,
automation: Automation
) => {
const stepIds = automation.definition.steps.map(s => s.id)
return oldAutomation.definition.steps.filter(s => !stepIds.includes(s.id))
}
// new steps
const newSteps = getNewSteps(oldAutomation, automation)
for (let step of newSteps) {
await events.automation.stepCreated(automation, step)
}
// old steps
const deletedSteps = getDeletedSteps(oldAutomation, automation)
for (let step of deletedSteps) {
await events.automation.stepDeleted(automation, step)
}
}
export async function fetch() {
const db = getDb()
const response = await db.allDocs<PersistedAutomation>(
getAutomationParams(null, {
include_docs: true,
})
)
const automations: PersistedAutomation[] = response.rows
.filter(row => !!row.doc)
.map(row => row.doc!)
return automations.map(trimUnexpectedObjectFields).map(maskAutomationSecrets)
}
export async function get(automationId: string) {
const db = getDb()
const result = await db.get<PersistedAutomation>(automationId)
return maskAutomationSecrets(trimUnexpectedObjectFields(result))
}
export async function find(ids: string[], opts?: { allowMissing?: boolean }) {
const db = getDb()
const result = await db.getMultiple<PersistedAutomation>(ids, opts)
return result.map(trimUnexpectedObjectFields).map(maskAutomationSecrets)
}
export async function create(automation: Automation) {
automation = trimUnexpectedObjectFields(automation)
const db = getDb()
// Respect existing IDs if recreating a deleted automation
if (!automation._id) {
automation._id = generateAutomationID()
}
automation.type = "automation"
automation = hydrateAutomationSecrets(automation)
automation = cleanAutomationInputs(automation)
automation = await checkForWebhooks({
newAuto: automation,
})
const response = await db.put(automation)
await events.automation.created(automation)
for (let step of automation.definition.steps) {
await events.automation.stepCreated(automation, step)
}
automation._rev = response.rev
automation._id = response.id
return maskAutomationSecrets(automation)
}
export async function update(automation: Automation) {
automation = trimUnexpectedObjectFields(automation)
if (!automation._id || !automation._rev) {
throw new HTTPError("_id or _rev fields missing", 400)
}
const db = getDb()
const oldAutomation = await db.get<Automation>(automation._id)
guardInvalidUpdatesAndThrow(automation, oldAutomation)
automation = hydrateAutomationSecrets(automation, oldAutomation)
automation = cleanAutomationInputs(automation)
automation = await checkForWebhooks({
oldAuto: oldAutomation,
newAuto: automation,
})
const response = await db.put(automation)
automation._rev = response.rev
const oldAutoTrigger =
oldAutomation && oldAutomation.definition.trigger
? oldAutomation.definition.trigger
: undefined
const newAutoTrigger =
automation && automation.definition.trigger
? automation.definition.trigger
: undefined
// trigger has been updated, remove the test inputs
if (oldAutoTrigger && oldAutoTrigger.id !== newAutoTrigger?.id) {
await events.automation.triggerUpdated(automation)
await deleteEntityMetadata(
MetadataType.AUTOMATION_TEST_INPUT,
automation._id!
)
await deleteAutomationMailboxState(automation._id!)
}
await handleStepEvents(oldAutomation, automation)
return maskAutomationSecrets({
...automation,
_rev: response.rev,
_id: response.id,
})
}
export async function remove(automationId: string, rev: string) {
const db = getDb()
const existing = await db.get<Automation>(automationId)
await checkForWebhooks({
oldAuto: existing,
})
// delete metadata first
await deleteEntityMetadata(MetadataType.AUTOMATION_TEST_INPUT, automationId)
await deleteEntityMetadata(MetadataType.AUTOMATION_TEST_HISTORY, automationId)
await deleteAutomationMailboxState(automationId)
const result = await db.remove(automationId, rev)
await events.automation.deleted(existing)
return result
}
/**
* This function handles checking if any webhooks need to be created or deleted for automations.
* @param appId The ID of the app in which we are checking for webhooks
* @param oldAuto The old automation object if updating/deleting
* @param newAuto The new automation object if creating/updating
* @returns After this is complete the new automation object may have been updated and should be
* written to DB (this does not write to DB as it would be wasteful to repeat).
*/
async function checkForWebhooks({ oldAuto, newAuto }: any) {
const WH_STEP_ID = sharedAutomations.triggers.definitions.WEBHOOK.stepId
const appId = context.getWorkspaceId()
if (!appId) {
throw new Error("Unable to check webhooks - no app ID in context.")
}
const oldTrigger = oldAuto ? oldAuto.definition.trigger : null
const newTrigger = newAuto ? newAuto.definition.trigger : null
const triggerChanged =
oldTrigger && newTrigger && oldTrigger.id !== newTrigger.id
function isWebhookTrigger(auto: any) {
return (
auto &&
auto.definition.trigger &&
auto.definition.trigger.stepId === WH_STEP_ID
)
}
// need to delete webhook
if (
isWebhookTrigger(oldAuto) &&
(!isWebhookTrigger(newAuto) || triggerChanged) &&
oldTrigger.webhookId
) {
try {
const db = getDb()
// need to get the webhook to get the rev
const webhook = await db.get<Webhook>(oldTrigger.webhookId)
// might be updating - reset the inputs to remove the URLs
if (newTrigger) {
delete newTrigger.webhookId
newTrigger.inputs = {}
}
await automations.webhook.destroy(webhook._id!, webhook._rev!)
} catch (err) {
// don't worry about not being able to delete, if it doesn't exist all good
}
}
// need to create webhook
if (
(!isWebhookTrigger(oldAuto) || triggerChanged) &&
isWebhookTrigger(newAuto)
) {
const webhook = await automations.webhook.save(
automations.webhook.newDoc(
"Automation webhook",
WebhookActionType.AUTOMATION,
newAuto._id
)
)
const id = webhook._id
newTrigger.webhookId = id
// the app ID has to be development for this endpoint
// it can only be used when building the app
// but the trigger endpoint will always be used in production
const prodAppId = dbCore.getProdWorkspaceID(appId)
newTrigger.inputs = {
schemaUrl: `api/webhooks/schema/${appId}/${id}`,
triggerUrl: `api/webhooks/trigger/${prodAppId}/${id}`,
}
}
return newAuto
}
function guardInvalidUpdatesAndThrow(
automation: Automation,
oldAutomation: Automation
) {
const stepDefinitions = [
automation.definition.trigger,
...automation.definition.steps,
]
const oldStepDefinitions = [
oldAutomation.definition.trigger,
...oldAutomation.definition.steps,
]
for (const step of stepDefinitions) {
const readonlyFields = Object.keys(
step.schema.inputs.properties || {}
).filter(k => step.schema.inputs.properties[k].readonly)
readonlyFields.forEach(key => {
const readonlyField = key as keyof typeof step.inputs
const oldStep = oldStepDefinitions.find(i => i.id === step.id)
if (step.inputs[readonlyField] !== oldStep?.inputs[readonlyField]) {
throw new HTTPError(
`Field ${readonlyField} is readonly and it cannot be modified`,
400
)
}
})
}
}
function trimUnexpectedObjectFields<T extends Automation>(automation: T): T {
// This will ensure all the automation fields (and nothing else) is mapped to the result
const allRequired: RequiredKeys<Omit<Automation, "_deleted">> = {
_id: automation._id,
_rev: automation._rev,
definition: automation.definition,
screenId: automation.screenId,
uiTree: automation.uiTree,
appId: automation.appId,
live: automation.live,
name: automation.name,
internal: automation.internal,
type: automation.type,
disabled: automation.disabled,
testData: automation.testData,
createdAt: automation.createdAt,
updatedAt: automation.updatedAt,
layoutDirection: automation.layoutDirection,
}
const result = { ...allRequired } as T
for (const key in result) {
if (!Object.prototype.hasOwnProperty.call(automation, key)) {
delete result[key]
}
}
return result as T
}
function hydrateAutomationSecrets(
automation: Automation,
existing?: Automation
): Automation {
const trigger = automation.definition?.trigger
if (!trigger || !isEmailTrigger(trigger) || !trigger.inputs) {
return automation
}
if (!isMaskedPassword(trigger.inputs.password)) {
return automation
}
if (!existing || !isEmailTrigger(existing.definition?.trigger)) {
throw new HTTPError("IMAP password is required", 400)
}
const previousPassword = existing.definition.trigger.inputs?.password
if (!previousPassword) {
throw new HTTPError("IMAP password is required", 400)
}
const hydratedAutomation = cloneDeep(automation)
const hydratedTrigger = hydratedAutomation.definition?.trigger
if (!isEmailTrigger(hydratedTrigger) || !hydratedTrigger.inputs) {
throw new HTTPError("IMAP password is required", 400)
}
hydratedTrigger.inputs.password = previousPassword
return hydratedAutomation
}
function maskAutomationSecrets<T extends Automation>(automation: T): T {
const trigger = automation.definition?.trigger
if (isEmailTrigger(trigger) && trigger.inputs?.password) {
trigger.inputs.password = PASSWORD_DISPLAY_MASK
}
return automation
}
function isMaskedPassword(value?: string) {
return value === PASSWORD_REPLACEMENT || value === PASSWORD_DISPLAY_MASK
}