import { JsonObject, jsonParse, LoggerProxy } from 'n8n-workflow'; import { FindManyOptions, FindOneOptions, In, ObjectLiteral } from 'typeorm'; import { ActiveWorkflowRunner, Db, InternalHooksManager, ResponseHelper, whereClause, WorkflowHelpers, } from '..'; import config from '../../config'; import { SharedWorkflow } from '../databases/entities/SharedWorkflow'; import { User } from '../databases/entities/User'; import { WorkflowEntity } from '../databases/entities/WorkflowEntity'; import { validateEntity } from '../GenericHelpers'; import { externalHooks } from '../Server'; import * as TagHelpers from '../TagHelpers'; import { getSharedWorkflowIds } from '../WorkflowHelpers'; import { validate as jsonSchemaValidate } from 'jsonschema'; export interface IGetWorkflowsQueryFilter { id?: number | string; name?: string; active?: boolean; } const schemaGetWorkflowsQueryFilter = { $id: '/IGetWorkflowsQueryFilter', type: 'object', properties: { id: { anyOf: [{ type: 'integer' }, { type: 'string' }] }, name: { type: 'string' }, active: { type: 'boolean' }, }, }; const allowedWorkflowsQueryFilterFields = Object.keys(schemaGetWorkflowsQueryFilter.properties); export class WorkflowsService { static async getSharing( user: User, workflowId: number | string, relations: string[] = ['workflow'], { allowGlobalOwner } = { allowGlobalOwner: true }, ): Promise { const options: FindOneOptions & { where: ObjectLiteral } = { where: { workflow: { id: workflowId }, }, }; // Omit user from where if the requesting user is the global // owner. This allows the global owner to view and delete // workflows they don't own. if (!allowGlobalOwner || user.globalRole.name !== 'owner') { options.where.user = { id: user.id }; } if (relations?.length) { options.relations = relations; } return Db.collections.SharedWorkflow.findOne(options); } static async get(workflow: Partial, options?: { relations: string[] }) { return Db.collections.Workflow.findOne(workflow, options); } static async getMany(user: User, rawFilter: string) { const sharedWorkflowIds = await getSharedWorkflowIds(user); if (sharedWorkflowIds.length === 0) { // return early since without shared workflows there can be no hits // (note: getSharedWorkflowIds() returns _all_ workflow ids for global owners) return []; } let filter: IGetWorkflowsQueryFilter | undefined = undefined; if (rawFilter) { try { const filterJson: JsonObject = jsonParse(rawFilter); if (filterJson) { Object.keys(filterJson).map((key) => { if (!allowedWorkflowsQueryFilterFields.includes(key)) delete filterJson[key]; }); if (jsonSchemaValidate(filterJson, schemaGetWorkflowsQueryFilter).valid) { filter = filterJson as IGetWorkflowsQueryFilter; } } } catch (error) { LoggerProxy.error('Failed to parse filter', { userId: user.id, filter, }); throw new ResponseHelper.ResponseError( `Parameter "filter" contained invalid JSON string.`, 500, 500, ); } } // safeguard against querying ids not shared with the user if (filter?.id !== undefined) { const workflowId = parseInt(filter.id.toString()); if (workflowId && !sharedWorkflowIds.includes(workflowId)) { LoggerProxy.verbose(`User ${user.id} attempted to query non-shared workflow ${workflowId}`); return []; } } const fields: Array = ['id', 'name', 'active', 'createdAt', 'updatedAt']; const query: FindManyOptions = { select: config.get('enterprise.features.sharing') ? [...fields, 'nodes'] : fields, relations: config.get('enterprise.features.sharing') ? ['tags', 'shared', 'shared.user', 'shared.role'] : ['tags'], }; if (config.getEnv('workflowTagsDisabled')) { delete query.relations; } const workflows = await Db.collections.Workflow.find( Object.assign(query, { where: { id: In(sharedWorkflowIds), ...filter, }, }), ); return workflows.map((workflow) => { const { id, ...rest } = workflow; return { id: id.toString(), ...rest, }; }); } static async updateWorkflow( user: User, workflow: WorkflowEntity, workflowId: string, tags?: string[], // eslint-disable-next-line @typescript-eslint/no-unused-vars forceSave?: boolean, ): Promise { const shared = await Db.collections.SharedWorkflow.findOne({ relations: ['workflow'], where: whereClause({ user, entityType: 'workflow', entityId: workflowId, }), }); if (!shared) { LoggerProxy.info('User attempted to update a workflow without permissions', { workflowId, userId: user.id, }); throw new ResponseHelper.ResponseError( `Workflow with ID "${workflowId}" could not be found to be updated.`, undefined, 404, ); } // if (!forceSave && workflow.hash !== shared.workflow.hash) { // throw new ResponseHelper.ResponseError( // `Workflow ID ${workflowId} cannot be saved because it was changed by another user.`, // undefined, // 400, // ); // } // check credentials for old format await WorkflowHelpers.replaceInvalidCredentials(workflow); WorkflowHelpers.addNodeIds(workflow); await externalHooks.run('workflow.update', [workflow]); if (shared.workflow.active) { // When workflow gets saved always remove it as the triggers could have been // changed and so the changes would not take effect await ActiveWorkflowRunner.getInstance().remove(workflowId); } if (workflow.settings) { if (workflow.settings.timezone === 'DEFAULT') { // Do not save the default timezone delete workflow.settings.timezone; } if (workflow.settings.saveDataErrorExecution === 'DEFAULT') { // Do not save when default got set delete workflow.settings.saveDataErrorExecution; } if (workflow.settings.saveDataSuccessExecution === 'DEFAULT') { // Do not save when default got set delete workflow.settings.saveDataSuccessExecution; } if (workflow.settings.saveManualExecutions === 'DEFAULT') { // Do not save when default got set delete workflow.settings.saveManualExecutions; } if ( parseInt(workflow.settings.executionTimeout as string, 10) === config.get('executions.timeout') ) { // Do not save when default got set delete workflow.settings.executionTimeout; } } if (workflow.name) { workflow.updatedAt = new Date(); // required due to atomic update await validateEntity(workflow); } const { hash, ...rest } = workflow; await Db.collections.Workflow.update(workflowId, rest); if (tags && !config.getEnv('workflowTagsDisabled')) { const tablePrefix = config.getEnv('database.tablePrefix'); await TagHelpers.removeRelations(workflowId, tablePrefix); if (tags.length) { await TagHelpers.createRelations(workflowId, tags, tablePrefix); } } const options: FindManyOptions = { relations: ['tags'], }; if (config.getEnv('workflowTagsDisabled')) { delete options.relations; } // We sadly get nothing back from "update". Neither if it updated a record // nor the new value. So query now the hopefully updated entry. const updatedWorkflow = await Db.collections.Workflow.findOne(workflowId, options); if (updatedWorkflow === undefined) { throw new ResponseHelper.ResponseError( `Workflow with ID "${workflowId}" could not be found to be updated.`, undefined, 400, ); } if (updatedWorkflow.tags?.length && tags?.length) { updatedWorkflow.tags = TagHelpers.sortByRequestOrder(updatedWorkflow.tags, { requestOrder: tags, }); } await externalHooks.run('workflow.afterUpdate', [updatedWorkflow]); void InternalHooksManager.getInstance().onWorkflowSaved(user.id, updatedWorkflow, false); if (updatedWorkflow.active) { // When the workflow is supposed to be active add it again try { await externalHooks.run('workflow.activate', [updatedWorkflow]); await ActiveWorkflowRunner.getInstance().add( workflowId, shared.workflow.active ? 'update' : 'activate', ); } catch (error) { // If workflow could not be activated set it again to inactive workflow.active = false; await Db.collections.Workflow.update(workflowId, workflow); // Also set it in the returned data updatedWorkflow.active = false; // Now return the original error for UI to display throw error; } } return updatedWorkflow; } }