UNPKG

@grouparoo/core

Version:
450 lines (385 loc) 12.9 kB
import { api } from "actionhero"; import { GrouparooPlugin, PluginConnection, PluginApp, AppOptionsOption, } from "../classes/plugin"; import { Option } from "./../models/Option"; import { Source } from "./../models/Source"; import { Destination } from "./../models/Destination"; import { Schedule } from "./../models/Schedule"; import { Property } from "../models/Property"; import { App } from "./../models/App"; import { LockableHelper } from "./lockableHelper"; import { plural } from "pluralize"; import { modelName } from "./modelName"; import { SourcesCache } from "../modules/caches/sourcesCache"; export const ObfuscatedOptionString = "__ObfuscatedOption"; type ModelWithOptions = Source | Destination | Schedule | Property | App; export namespace OptionHelper { export interface SimpleOptions { [key: string]: string | number | boolean; } export async function getOptions( instance: ModelWithOptions, sourceFromEnvironment = true, obfuscateSensitive = false ) { if (sourceFromEnvironment === null || sourceFromEnvironment === undefined) { sourceFromEnvironment = true; } const options = instance.__options ?? (await Option.findAll({ where: { ownerId: instance.id, ownerType: modelName<ModelWithOptions>(instance), }, })); if (!instance.__options) instance.__options = options; const optionsToObfuscate = await getOptionsToObfuscate( instance, obfuscateSensitive ); let optionsObject = await getDefaultOptionValues(instance); options.forEach((option) => { if (optionsToObfuscate.includes(option.key)) { optionsObject[option.key] = ObfuscatedOptionString; } else { optionsObject[option.key] = option.typedValue(); } }); if (sourceFromEnvironment) { optionsObject = sourceEnvironmentVariableOptions(instance, optionsObject); } return optionsObject; } async function prepareOptions( instance: ModelWithOptions, options: SimpleOptions, sourceFromEnvironment = false ) { const filteredOptions = filterEmptyOptions(options); const sanitizedOptions = await replaceObfuscatedOptions( instance, filteredOptions, false ); if (sourceFromEnvironment) { return sourceEnvironmentVariableOptions(instance, { ...sanitizedOptions, }); } return sanitizedOptions; } export async function setOptions( instance: (Source | Destination | Schedule | Property | App) & { afterSetOptions?: Function; }, options: SimpleOptions, externallyValidate = true ) { delete instance.__options; const sanitizedOptions = await prepareOptions(instance, options, false); if (typeof instance["validateOptions"] === "function") await instance.validateOptions(sanitizedOptions, externallyValidate); const oldOptionsWithoutEnv = await getOptions(instance, false); const oldOptionsWithEnv = await getOptions(instance, true); // If we had previously used an ENV string, and the value was returned, assume we meant to use the ENV // This is helpful for some UI options types (list) which really render the value for (const key in sanitizedOptions) { if ( oldOptionsWithoutEnv[key] !== undefined && oldOptionsWithoutEnv[key] !== oldOptionsWithEnv[key] && sanitizedOptions[key] === oldOptionsWithEnv[key] ) { sanitizedOptions[key] = oldOptionsWithoutEnv[key]; } } let hasChanges = false; for (const key in oldOptionsWithoutEnv) { if (oldOptionsWithoutEnv[key] !== sanitizedOptions[key]) { hasChanges = true; } } for (const key in sanitizedOptions) { if (oldOptionsWithoutEnv[key] !== sanitizedOptions[key]) { hasChanges = true; } } if (!hasChanges) return; await LockableHelper.beforeUpdateOptions(instance, hasChanges); await Option.destroy({ where: { ownerId: instance.id, ownerType: modelName<ModelWithOptions>(instance), }, }); const newOptions: Option[] = []; for (const [key, value] of Object.entries(sanitizedOptions)) { const option = await Option.create({ ownerId: instance.id, ownerType: modelName<ModelWithOptions>(instance), key, value: String(value), type: typeof value as "string" | "number" | "boolean", }); newOptions.push(option); } instance.__options = newOptions; await instance.touch(); // if there's an afterSetOptions hook and we want to commit our changes if (typeof instance["afterSetOptions"] === "function") { await instance["afterSetOptions"](hasChanges); } } export async function getPlugin( instance: Source | Destination | Schedule | Property | App ) { const type = await getInstanceType(instance); return getPluginByType(type); } export function getPluginByType(type: string) { const foundApps: string[] = []; const foundConnections: string[] = []; let match: { plugin: GrouparooPlugin; pluginConnection: PluginConnection; pluginApp: PluginApp; } = { plugin: null, pluginConnection: null, pluginApp: null }; api.plugins.plugins.forEach((plugin: GrouparooPlugin) => { if (plugin.apps) { plugin.apps.forEach((pluginApp) => { foundApps.push(pluginApp.name); if (pluginApp.name === type) { match.plugin = plugin; match.pluginApp = pluginApp; } }); } if (plugin.connections) { plugin.connections.forEach((pluginConnection) => { foundConnections.push(pluginConnection.name); if (pluginConnection.name === type) { match.plugin = plugin; match.pluginConnection = pluginConnection; } }); } }); if (!match.plugin) { const missingType = type.includes("-") || type.includes(":") ? "connection" : "app"; const collection = missingType === "app" ? foundApps : foundConnections; throw new Error( `Cannot find a "${type}" ${missingType} available within the installed plugins. Current ${plural( missingType )} installed are: ${[...collection] .sort() .join( ", " )}. Use \`grouparoo install\` to add new plugins if necessary.` ); } return match; } export type OptionsSpec = { key: string; required: boolean; options?: string[]; }[]; export async function validateOptions( instance: ModelWithOptions & { name?: string; key?: string; }, options: SimpleOptions, optionsSpec: OptionsSpec, allowEmpty = false ) { options = await prepareOptions(instance, options, true); const type = await getInstanceType(instance); if (allowEmpty && Object.keys(options).length === 0) { return; } const allOptions = optionsSpec.map((o) => o.key); const requiredOptions = optionsSpec .filter((o) => o.required) .map((o) => o.key); const optionOptions: Record<string, string[]> = optionsSpec .filter((o) => o.options) .reduce( (optionOptions, opt) => ({ ...optionOptions, [opt.key]: opt.options, }), {} ); requiredOptions.forEach((requiredOption) => { if (!options[requiredOption]) { throw new Error( `${requiredOption} is required for a ${modelName<ModelWithOptions>( instance )} of type ${type} (${instance["name"] || instance["key"]}, ${ instance.id })` ); } }); for (const k in options) { if (allOptions.indexOf(k) < 0) { throw new Error( `${k} is not an option for a ${type} ${modelName<ModelWithOptions>( instance )} (${instance["name"] || instance["key"]}, ${instance.id})` ); } const val = options[k].toString(); const opts = optionOptions[k]; if (opts && opts.length > 0 && !opts.includes(val)) { throw new Error( `"${val}" is not a valid value for ${type} ${modelName<ModelWithOptions>( instance )} option "${k}"` ); } } } async function getInstanceType( instance: ModelWithOptions & { type?: string; sourceId?: string; } ) { let type = instance["type"]; if (!type || instance instanceof Property) { if (instance["sourceId"]) { const source = await SourcesCache.findOneWithCache( instance["sourceId"] ); if (source) type = source.type; } } return type; } /** * Return the list of possible environment variable options for this type, * GROUPAROO_OPTION__APP__production-hubspot-api-key=abc123 returns production-hubspot-api-key */ export function getEnvironmentVariableOptionsForTopic(topic: string) { const regexp = new RegExp( `^GROUPAROO_OPTION__${topic.toUpperCase()}__(.*)` ); return Object.keys(process.env) .filter((k) => k.match(regexp)) .map((k) => k.match(regexp)[1]); } /** * Load the value of an environment variable option from the environment */ export function getEnvironmentVariableOption(type: string, key: string) { const fullKey = `GROUPAROO_OPTION__${type.toUpperCase()}__${key}`; const value = process.env[fullKey]; if (!value) throw new Error( `cannot find environment variable for type=${type} and key=${key} (full key "${fullKey}")` ); return value; } /** * Replace all values in a bundle of SimpleOptions with those values loaded from the ENV */ export function sourceEnvironmentVariableOptions( instance: ModelWithOptions, options: SimpleOptions ) { const envOptionKeys = getEnvironmentVariableOptionsForTopic( modelName<ModelWithOptions>(instance) ); for (const k in options) { if (envOptionKeys.includes(options[k].toString())) options[k] = getEnvironmentVariableOption( modelName<ModelWithOptions>(instance), options[k].toString() ); } return options; } async function getDefaultOptionValues(instance: ModelWithOptions) { const plugin = await getPlugin(instance); let options: AppOptionsOption[] = []; if (instance instanceof App && plugin.pluginApp) { options = plugin.pluginApp.options; } else if ( (instance instanceof Source || instance instanceof Destination) && plugin.pluginConnection ) { options = plugin.pluginConnection.options; } const defaultOptions: SimpleOptions = {}; for (const opt of options) { if (opt.defaultValue !== undefined) { defaultOptions[opt.key] = opt.defaultValue; } } return defaultOptions; } export function filterEmptyOptions(options: SimpleOptions) { const opts = Object.assign({}, options); Object.keys(opts).forEach((k) => { if (typeof opts[k] === "undefined" || opts[k] === null || opts[k] === "") delete opts[k]; }); return opts; } export async function getOptionsToObfuscate( instance: ModelWithOptions, obfuscateSensitive = false ) { const optionsToObfuscate: string[] = []; const obfuscatedOptionTypes = ["password", "oauth-token"]; // TODO: only for Apps for now if (instance instanceof App) { const plugin = await instance.getPlugin(); const staticAppOptions = plugin.pluginApp.options; staticAppOptions.forEach((option) => { if (obfuscatedOptionTypes.includes(option.type) && obfuscateSensitive) { optionsToObfuscate.push(option.key); } }); const appOptions = await instance.appOptions(); const appOptionKeys = Object.keys(appOptions); appOptionKeys.forEach((k) => { if ( obfuscatedOptionTypes.includes(appOptions[k].type) && obfuscateSensitive ) { optionsToObfuscate.push(k); } }); } return optionsToObfuscate; } export async function replaceObfuscatedOptions( instance: ModelWithOptions, options?: SimpleOptions, sourceFromEnvironment = true ) { let sanitizedOptions: SimpleOptions = Object.assign({}, options); const optionsFromDatabase = await getOptions( instance, sourceFromEnvironment, false ); if (Object.keys(sanitizedOptions).length === 0) { sanitizedOptions = optionsFromDatabase; } for (const key of Object.keys(sanitizedOptions)) { if (sanitizedOptions[key] === ObfuscatedOptionString) { sanitizedOptions[key] = optionsFromDatabase[key]; } } return sanitizedOptions; } }