diff --git a/package-lock.json b/package-lock.json index 9779aa6383..f930631371 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "n8n", "version": "0.179.0", "dependencies": { + "@apidevtools/swagger-cli": "^4.0.4", "@babel/core": "^7.14.6", "@fontsource/open-sans": "^4.5.0", "@fortawesome/fontawesome-svg-core": "^1.2.35", @@ -80,11 +81,13 @@ "@types/ssh2-sftp-client": "^5.1.0", "@types/superagent": "4.1.13", "@types/supertest": "^2.0.11", + "@types/swagger-ui-express": "^4.1.3", "@types/tmp": "^0.2.0", "@types/uuid": "^8.3.2", "@types/validator": "^13.7.0", "@types/vorpal": "^1.11.0", "@types/xml2js": "^0.4.3", + "@types/yamljs": "^0.2.31", "@typescript-eslint/eslint-plugin": "^4.29.0", "@typescript-eslint/parser": "^4.29.0", "@vue/cli-plugin-babel": "~4.5.0", @@ -137,6 +140,7 @@ "eslint-plugin-vue": "^7.16.0", "eventsource": "^2.0.2", "express": "^4.16.4", + "express-openapi-validator": "^4.13.6", "fast-glob": "^3.2.5", "fflate": "^0.7.0", "file-saver": "^2.0.2", @@ -163,6 +167,7 @@ "jsdom": "19.0.0", "jshint": "^2.9.7", "json-diff": "^0.5.4", + "jsonschema": "^1.4.1", "jsonwebtoken": "^8.5.1", "jsplumb": "2.15.4", "jwks-rsa": "~1.12.1", @@ -200,6 +205,7 @@ "normalize-wheel": "^1.0.1", "oauth-1.0a": "^2.2.6", "open": "^7.0.0", + "openapi-types": "^10.0.0", "p-cancelable": "^2.0.0", "passport": "^0.5.0", "passport-cookie": "^1.0.9", @@ -235,6 +241,7 @@ "storybook-addon-themes": "^6.1.0", "string-template-parser": "^1.2.6", "supertest": "^6.2.2", + "swagger-ui-express": "^4.3.0", "timeago.js": "^4.0.2", "tmp-promise": "^3.0.2", "trim": ">=0.0.3", @@ -271,7 +278,8 @@ "winston": "^3.3.3", "xlsx": "^0.17.0", "xml2js": "^0.4.23", - "xss": "^1.0.10" + "xss": "^1.0.10", + "yamljs": "^0.3.0" }, "devDependencies": { "lerna": "^3.13.1", @@ -304,6 +312,240 @@ "node": ">=6.0.0" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.0.9.tgz", + "integrity": "sha512-GBD2Le9w2+lVFoc4vswGI/TjkNIZSVp7+9xPf+X3uidBfWnAeUWmquteSyt0+VCrhNMWj/FTABISQrD3Z/YA+w==", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.6", + "call-me-maybe": "^1.0.1", + "js-yaml": "^4.1.0" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "node_modules/@apidevtools/json-schema-ref-parser/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-cli": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-cli/-/swagger-cli-4.0.4.tgz", + "integrity": "sha512-hdDT3B6GLVovCsRZYDi3+wMcB1HfetTU20l2DC8zD3iFRNMC6QNAZG5fo/6PYeHWBEv7ri4MvnlKodhNB0nt7g==", + "dependencies": { + "@apidevtools/swagger-parser": "^10.0.1", + "chalk": "^4.1.0", + "js-yaml": "^3.14.0", + "yargs": "^15.4.1" + }, + "bin": { + "swagger-cli": "bin/swagger-cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-cli/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@apidevtools/swagger-cli/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/@apidevtools/swagger-cli/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@apidevtools/swagger-cli/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@apidevtools/swagger-cli/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/@apidevtools/swagger-cli/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@apidevtools/swagger-cli/node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/@apidevtools/swagger-cli/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/@apidevtools/swagger-cli/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@apidevtools/swagger-cli/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@apidevtools/swagger-cli/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@apidevtools/swagger-cli/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@apidevtools/swagger-cli/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@apidevtools/swagger-cli/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^9.0.6", + "@apidevtools/openapi-schemas": "^2.0.4", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "call-me-maybe": "^1.0.1", + "z-schema": "^5.0.1" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, "node_modules/@azure/abort-controller": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.1.0.tgz", @@ -4200,6 +4442,11 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==" + }, "node_modules/@kafkajs/confluent-schema-registry": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@kafkajs/confluent-schema-registry/-/confluent-schema-registry-1.0.6.tgz", @@ -14794,6 +15041,14 @@ "@types/tedious": "*" } }, + "node_modules/@types/multer": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz", + "integrity": "sha512-/SNsDidUFCvqqcWDwxv2feww/yqhNeTRL5CVoL3jU4Goc4kKEL10T7Eye65ZqPNi4HRx8sAEX59pV1aEH7drNA==", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "14.17.27", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.27.tgz", @@ -15113,6 +15368,15 @@ "@types/superagent": "*" } }, + "node_modules/@types/swagger-ui-express": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.3.tgz", + "integrity": "sha512-jqCjGU/tGEaqIplPy3WyQg+Nrp6y80DCFnDEAvVKWkJyv0VivSSDCChkppHRHAablvInZe6pijDFMnavtN0vqA==", + "dependencies": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, "node_modules/@types/tapable": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.8.tgz", @@ -15249,6 +15513,11 @@ "@types/node": "*" } }, + "node_modules/@types/yamljs": { + "version": "0.2.31", + "resolved": "https://registry.npmjs.org/@types/yamljs/-/yamljs-0.2.31.tgz", + "integrity": "sha512-QcJ5ZczaXAqbVD3o8mw/mEBhRvO5UAdTtbvgwL/OgoWubvNBh6/MxLBAigtcgIFaq3shon9m3POIxQaLQt4fxQ==" + }, "node_modules/@types/yargs": { "version": "15.0.14", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.14.tgz", @@ -19990,6 +20259,11 @@ "node": ">=0.10.0" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha1-HjRA6RXwsSA9I3SOeO3XubW0PlY=" + }, "node_modules/aproba": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", @@ -22745,6 +23019,18 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, + "node_modules/busboy": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-0.2.14.tgz", + "integrity": "sha1-bCpiLvz0fFe7vh4qnDetNseSVFM=", + "dependencies": { + "dicer": "0.2.5", + "readable-stream": "1.1.x" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/byline": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz", @@ -27807,6 +28093,18 @@ "kuler": "1.0.x" } }, + "node_modules/dicer": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/dicer/-/dicer-0.2.5.tgz", + "integrity": "sha1-WZbAhrszIYyBLAkL3cCc0S+stw8=", + "dependencies": { + "readable-stream": "1.1.x", + "streamsearch": "0.1.2" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -30456,6 +30754,30 @@ "node": ">= 0.10.0" } }, + "node_modules/express-openapi-validator": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/express-openapi-validator/-/express-openapi-validator-4.13.7.tgz", + "integrity": "sha512-MMTwGT5RyDyQJ2IWzdLkoKwTAqnHKUOQF+9pYrKqXaVmCIhHyjM1woenO4UkfV/kRLbqauWEE4yj+Urvy4//lg==", + "dependencies": { + "@types/multer": "^1.4.7", + "ajv": "^6.12.6", + "content-type": "^1.0.4", + "json-schema-ref-parser": "^9.0.9", + "lodash.clonedeep": "^4.5.0", + "lodash.get": "^4.4.2", + "lodash.uniq": "^4.5.0", + "lodash.zipobject": "^4.1.3", + "media-typer": "^1.1.0", + "multer": "^1.4.4", + "ono": "^7.1.3", + "path-to-regexp": "^6.2.0" + } + }, + "node_modules/express-openapi-validator/node_modules/path-to-regexp": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.0.tgz", + "integrity": "sha512-f66KywYG6+43afgE/8j/GoiNyygk/bnoCbps++3ErRKsIYkGGupyv07R2Ok5m9i67Iqc+T2g1eAUGUPzWhYTyg==" + }, "node_modules/express/node_modules/cookie": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", @@ -42374,6 +42696,17 @@ "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" }, + "node_modules/json-schema-ref-parser": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/json-schema-ref-parser/-/json-schema-ref-parser-9.0.9.tgz", + "integrity": "sha512-qcP2lmGy+JUoQJ4DOQeLaZDqH9qSkeGCK3suKWxJXS82dg728Mn3j97azDMaOUmJAN4uCq91LdPx4K7E8F1a7Q==", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "9.0.9" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -43546,6 +43879,11 @@ "resolved": "https://registry.npmjs.org/lodash.unset/-/lodash.unset-4.5.2.tgz", "integrity": "sha512-bwKX88k2JhCV9D1vtE8+naDKlLiGrSmf8zi/Y9ivFHwbmRfA8RxS/aVJ+sIht2XOwqoNr4xUPUkGZpc1sHFEKg==" }, + "node_modules/lodash.zipobject": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/lodash.zipobject/-/lodash.zipobject-4.1.3.tgz", + "integrity": "sha1-s5n1q6j/YqdG9peb8gshT5ZNvvg=" + }, "node_modules/log-symbols": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", @@ -44272,7 +44610,7 @@ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/memfs": { @@ -45124,6 +45462,24 @@ "node": ">=6" } }, + "node_modules/multer": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4.tgz", + "integrity": "sha512-2wY2+xD4udX612aMqMcB8Ws2Voq6NIUPEtD1be6m411T4uDH/VtL9i//xvcyFlTVfRdaBsk7hV5tgrGQqhuBiw==", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^0.2.11", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "on-finished": "^2.3.0", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 0.10.0" + } + }, "node_modules/multicast-dns": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-6.2.3.tgz", @@ -46780,6 +47136,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-9jnfVriq7uJM4o5ganUY54ntUm+5EK21EGaQ5NWnkWg3zz5ywbbonlBguRcnmF1/HDiIe3zxNxXcO1YPBmPcQQ==", + "dependencies": { + "@jsdevtools/ono": "7.1.3" + } + }, "node_modules/open": { "version": "7.4.2", "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", @@ -46795,6 +47159,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-types": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-10.0.0.tgz", + "integrity": "sha512-Y8xOCT2eiKGYDzMW9R4x5cmfc3vGaaI4EL2pwhDmodWw1HlK18YcZ4uJxc7Rdp7/gGzAygzH9SXr6GKYIXbRcQ==" + }, "node_modules/opener": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", @@ -54058,6 +54427,14 @@ "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==" }, + "node_modules/streamsearch": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz", + "integrity": "sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/strict-uri-encode": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", @@ -54634,6 +55011,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-ui-dist": { + "version": "4.10.3", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-4.10.3.tgz", + "integrity": "sha512-eR4vsd7sYo0Sx7ZKRP5Z04yij7JkNmIlUQfrDQgC+xO5ABYx+waabzN+nDsQTLAJ4Z04bjkRd8xqkJtbxr3G7w==" + }, + "node_modules/swagger-ui-express": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-4.3.0.tgz", + "integrity": "sha512-jN46SEEe9EoXa3ZgZoKgnSF6z0w3tnM1yqhO4Y+Q4iZVc8JOQB960EZpIAz6rNROrDApVDwcMHR0mhlnc/5Omw==", + "dependencies": { + "swagger-ui-dist": ">=4.1.3" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -56496,6 +56892,14 @@ "node": ">= 0.6" } }, + "node_modules/type-is/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -60843,6 +61247,19 @@ "node": ">= 6" } }, + "node_modules/yamljs": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/yamljs/-/yamljs-0.3.0.tgz", + "integrity": "sha512-C/FsVVhht4iPQYXOInoxUM/1ELSf9EsgKH34FofQOp6hwCPrW4vG4w5++TED3xRUo8gD7l0P1J1dLlDYzODsTQ==", + "dependencies": { + "argparse": "^1.0.7", + "glob": "^7.0.5" + }, + "bin": { + "json2yaml": "bin/json2yaml", + "yaml2json": "bin/yaml2json" + } + }, "node_modules/yargonaut": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/yargonaut/-/yargonaut-1.1.4.tgz", @@ -61116,6 +61533,31 @@ "node": ">=10" } }, + "node_modules/z-schema": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.3.tgz", + "integrity": "sha512-sGvEcBOTNum68x9jCpCVGPFJ6mWnkD0YxOcddDlJHRx3tKdB2q8pCHExMVZo/AV/6geuVJXG7hljDaWG8+5GDw==", + "dependencies": { + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "validator": "^13.7.0" + }, + "bin": { + "z-schema": "bin/z-schema" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "commander": "^2.20.3" + } + }, + "node_modules/z-schema/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "optional": true + }, "node_modules/zwitch": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz", @@ -61146,6 +61588,181 @@ "@jridgewell/trace-mapping": "^0.3.9" } }, + "@apidevtools/json-schema-ref-parser": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.0.9.tgz", + "integrity": "sha512-GBD2Le9w2+lVFoc4vswGI/TjkNIZSVp7+9xPf+X3uidBfWnAeUWmquteSyt0+VCrhNMWj/FTABISQrD3Z/YA+w==", + "requires": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.6", + "call-me-maybe": "^1.0.1", + "js-yaml": "^4.1.0" + }, + "dependencies": { + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "requires": { + "argparse": "^2.0.1" + } + } + } + }, + "@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==" + }, + "@apidevtools/swagger-cli": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-cli/-/swagger-cli-4.0.4.tgz", + "integrity": "sha512-hdDT3B6GLVovCsRZYDi3+wMcB1HfetTU20l2DC8zD3iFRNMC6QNAZG5fo/6PYeHWBEv7ri4MvnlKodhNB0nt7g==", + "requires": { + "@apidevtools/swagger-parser": "^10.0.1", + "chalk": "^4.1.0", + "js-yaml": "^3.14.0", + "yargs": "^15.4.1" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "requires": { + "p-limit": "^2.2.0" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "requires": { + "has-flag": "^4.0.0" + } + }, + "yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "requires": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + } + }, + "yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } + }, + "@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==" + }, + "@apidevtools/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", + "requires": { + "@apidevtools/json-schema-ref-parser": "^9.0.6", + "@apidevtools/openapi-schemas": "^2.0.4", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "call-me-maybe": "^1.0.1", + "z-schema": "^5.0.1" + } + }, "@azure/abort-controller": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.1.0.tgz", @@ -64017,6 +64634,11 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==" + }, "@kafkajs/confluent-schema-registry": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@kafkajs/confluent-schema-registry/-/confluent-schema-registry-1.0.6.tgz", @@ -72894,6 +73516,14 @@ "@types/tedious": "*" } }, + "@types/multer": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz", + "integrity": "sha512-/SNsDidUFCvqqcWDwxv2feww/yqhNeTRL5CVoL3jU4Goc4kKEL10T7Eye65ZqPNi4HRx8sAEX59pV1aEH7drNA==", + "requires": { + "@types/express": "*" + } + }, "@types/node": { "version": "14.17.27", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.27.tgz", @@ -73210,6 +73840,15 @@ "@types/superagent": "*" } }, + "@types/swagger-ui-express": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.3.tgz", + "integrity": "sha512-jqCjGU/tGEaqIplPy3WyQg+Nrp6y80DCFnDEAvVKWkJyv0VivSSDCChkppHRHAablvInZe6pijDFMnavtN0vqA==", + "requires": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, "@types/tapable": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.8.tgz", @@ -73345,6 +73984,11 @@ "@types/node": "*" } }, + "@types/yamljs": { + "version": "0.2.31", + "resolved": "https://registry.npmjs.org/@types/yamljs/-/yamljs-0.2.31.tgz", + "integrity": "sha512-QcJ5ZczaXAqbVD3o8mw/mEBhRvO5UAdTtbvgwL/OgoWubvNBh6/MxLBAigtcgIFaq3shon9m3POIxQaLQt4fxQ==" + }, "@types/yargs": { "version": "15.0.14", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.14.tgz", @@ -77050,6 +77694,11 @@ "buffer-equal": "^1.0.0" } }, + "append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha1-HjRA6RXwsSA9I3SOeO3XubW0PlY=" + }, "aproba": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", @@ -79255,6 +79904,15 @@ } } }, + "busboy": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-0.2.14.tgz", + "integrity": "sha1-bCpiLvz0fFe7vh4qnDetNseSVFM=", + "requires": { + "dicer": "0.2.5", + "readable-stream": "1.1.x" + } + }, "byline": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz", @@ -83213,6 +83871,15 @@ "kuler": "1.0.x" } }, + "dicer": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/dicer/-/dicer-0.2.5.tgz", + "integrity": "sha1-WZbAhrszIYyBLAkL3cCc0S+stw8=", + "requires": { + "readable-stream": "1.1.x", + "streamsearch": "0.1.2" + } + }, "diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -85190,6 +85857,32 @@ } } }, + "express-openapi-validator": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/express-openapi-validator/-/express-openapi-validator-4.13.7.tgz", + "integrity": "sha512-MMTwGT5RyDyQJ2IWzdLkoKwTAqnHKUOQF+9pYrKqXaVmCIhHyjM1woenO4UkfV/kRLbqauWEE4yj+Urvy4//lg==", + "requires": { + "@types/multer": "^1.4.7", + "ajv": "^6.12.6", + "content-type": "^1.0.4", + "json-schema-ref-parser": "^9.0.9", + "lodash.clonedeep": "^4.5.0", + "lodash.get": "^4.4.2", + "lodash.uniq": "^4.5.0", + "lodash.zipobject": "^4.1.3", + "media-typer": "^1.1.0", + "multer": "^1.4.4", + "ono": "^7.1.3", + "path-to-regexp": "^6.2.0" + }, + "dependencies": { + "path-to-regexp": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.0.tgz", + "integrity": "sha512-f66KywYG6+43afgE/8j/GoiNyygk/bnoCbps++3ErRKsIYkGGupyv07R2Ok5m9i67Iqc+T2g1eAUGUPzWhYTyg==" + } + } + }, "ext": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/ext/-/ext-1.6.0.tgz", @@ -94285,6 +94978,14 @@ "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" }, + "json-schema-ref-parser": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/json-schema-ref-parser/-/json-schema-ref-parser-9.0.9.tgz", + "integrity": "sha512-qcP2lmGy+JUoQJ4DOQeLaZDqH9qSkeGCK3suKWxJXS82dg728Mn3j97azDMaOUmJAN4uCq91LdPx4K7E8F1a7Q==", + "requires": { + "@apidevtools/json-schema-ref-parser": "9.0.9" + } + }, "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -95286,6 +95987,11 @@ "resolved": "https://registry.npmjs.org/lodash.unset/-/lodash.unset-4.5.2.tgz", "integrity": "sha512-bwKX88k2JhCV9D1vtE8+naDKlLiGrSmf8zi/Y9ivFHwbmRfA8RxS/aVJ+sIht2XOwqoNr4xUPUkGZpc1sHFEKg==" }, + "lodash.zipobject": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/lodash.zipobject/-/lodash.zipobject-4.1.3.tgz", + "integrity": "sha1-s5n1q6j/YqdG9peb8gshT5ZNvvg=" + }, "log-symbols": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", @@ -96555,6 +97261,21 @@ "tedious": "^6.7.1" } }, + "multer": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4.tgz", + "integrity": "sha512-2wY2+xD4udX612aMqMcB8Ws2Voq6NIUPEtD1be6m411T4uDH/VtL9i//xvcyFlTVfRdaBsk7hV5tgrGQqhuBiw==", + "requires": { + "append-field": "^1.0.0", + "busboy": "^0.2.11", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "on-finished": "^2.3.0", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + } + }, "multicast-dns": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-6.2.3.tgz", @@ -97908,6 +98629,14 @@ "mimic-fn": "^2.1.0" } }, + "ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-9jnfVriq7uJM4o5ganUY54ntUm+5EK21EGaQ5NWnkWg3zz5ywbbonlBguRcnmF1/HDiIe3zxNxXcO1YPBmPcQQ==", + "requires": { + "@jsdevtools/ono": "7.1.3" + } + }, "open": { "version": "7.4.2", "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", @@ -97917,6 +98646,11 @@ "is-wsl": "^2.1.1" } }, + "openapi-types": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-10.0.0.tgz", + "integrity": "sha512-Y8xOCT2eiKGYDzMW9R4x5cmfc3vGaaI4EL2pwhDmodWw1HlK18YcZ4uJxc7Rdp7/gGzAygzH9SXr6GKYIXbRcQ==" + }, "opener": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", @@ -103691,6 +104425,11 @@ "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==" }, + "streamsearch": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz", + "integrity": "sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=" + }, "strict-uri-encode": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", @@ -104126,6 +104865,19 @@ } } }, + "swagger-ui-dist": { + "version": "4.10.3", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-4.10.3.tgz", + "integrity": "sha512-eR4vsd7sYo0Sx7ZKRP5Z04yij7JkNmIlUQfrDQgC+xO5ABYx+waabzN+nDsQTLAJ4Z04bjkRd8xqkJtbxr3G7w==" + }, + "swagger-ui-express": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-4.3.0.tgz", + "integrity": "sha512-jN46SEEe9EoXa3ZgZoKgnSF6z0w3tnM1yqhO4Y+Q4iZVc8JOQB960EZpIAz6rNROrDApVDwcMHR0mhlnc/5Omw==", + "requires": { + "swagger-ui-dist": ">=4.1.3" + } + }, "symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -105540,6 +106292,13 @@ "requires": { "media-typer": "0.3.0", "mime-types": "~2.1.24" + }, + "dependencies": { + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" + } } }, "typedarray": { @@ -108911,6 +109670,15 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==" }, + "yamljs": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/yamljs/-/yamljs-0.3.0.tgz", + "integrity": "sha512-C/FsVVhht4iPQYXOInoxUM/1ELSf9EsgKH34FofQOp6hwCPrW4vG4w5++TED3xRUo8gD7l0P1J1dLlDYzODsTQ==", + "requires": { + "argparse": "^1.0.7", + "glob": "^7.0.5" + } + }, "yargonaut": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/yargonaut/-/yargonaut-1.1.4.tgz", @@ -109123,6 +109891,25 @@ "toposort": "^2.0.2" } }, + "z-schema": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.3.tgz", + "integrity": "sha512-sGvEcBOTNum68x9jCpCVGPFJ6mWnkD0YxOcddDlJHRx3tKdB2q8pCHExMVZo/AV/6geuVJXG7hljDaWG8+5GDw==", + "requires": { + "commander": "^2.20.3", + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "validator": "^13.7.0" + }, + "dependencies": { + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "optional": true + } + } + }, "zwitch": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz", diff --git a/packages/cli/config/schema.ts b/packages/cli/config/schema.ts index c39ed271fa..d422776f55 100644 --- a/packages/cli/config/schema.ts +++ b/packages/cli/config/schema.ts @@ -582,6 +582,21 @@ export const schema = { }, }, + publicApi: { + disabled: { + format: Boolean, + default: false, + env: 'N8N_PUBLIC_API_DISABLED', + doc: 'Whether to disable the Public API', + }, + path: { + format: String, + default: 'api', + env: 'N8N_PUBLIC_API_ENDPOINT', + doc: 'Path for the public api endpoints', + }, + }, + workflowTagsDisabled: { format: Boolean, default: false, diff --git a/packages/cli/package.json b/packages/cli/package.json index 90d0478e06..42e9589a26 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -20,7 +20,7 @@ }, "scripts": { "build": "run-script-os", - "build:default": "tsc && cp -r ./src/UserManagement/email/templates ./dist/src/UserManagement/email", + "build:default": "tsc && cp -r ./src/UserManagement/email/templates ./dist/src/UserManagement/email && cp ./src/PublicApi/swaggerTheme.css ./dist/src/PublicApi/swaggerTheme.css; find ./src/PublicApi -iname 'openapi.yml' -exec swagger-cli bundle {} --type yaml --outfile \"./dist\"/{} \\;", "build:windows": "tsc && xcopy /E /I src\\UserManagement\\email\\templates dist\\src\\UserManagement\\email\\templates", "dev": "concurrently -k -n \"TypeScript,Node\" -c \"yellow.bold,cyan.bold\" \"npm run watch\" \"nodemon\"", "format": "cd ../.. && node_modules/prettier/bin-prettier.js packages/cli/**/**.ts --write", @@ -37,7 +37,7 @@ "test:postgres:alt-schema": "export DB_POSTGRESDB_SCHEMA=alt_schema; npm run test:postgres", "test:mysql": "export N8N_LOG_LEVEL=silent; export DB_TYPE=mysqldb; jest", "watch": "tsc --watch", - "typeorm": "ts-node ../../node_modules/typeorm/cli.js" + "typeorm": "ts-node -T ../../node_modules/typeorm/cli.js" }, "bin": { "n8n": "./bin/n8n" @@ -72,8 +72,7 @@ "@types/express": "^4.17.6", "@types/jest": "^27.4.0", "@types/localtunnel": "^1.9.0", - "@types/lodash.get": "^4.4.6", - "@types/lodash.merge": "^4.6.6", + "@types/lodash": "^4.14.182", "@types/node": "14.17.27", "@types/open": "^6.1.0", "@types/parseurl": "^1.3.1", @@ -95,11 +94,14 @@ "typescript": "~4.6.0" }, "dependencies": { + "@apidevtools/swagger-cli": "4.0.0", "@oclif/command": "^1.5.18", "@oclif/errors": "^1.2.2", "@rudderstack/rudder-sdk-node": "1.0.6", "@types/json-diff": "^0.5.1", "@types/jsonwebtoken": "^8.5.2", + "@types/swagger-ui-express": "^4.1.3", + "@types/yamljs": "^0.2.31", "basic-auth": "^2.0.1", "bcryptjs": "^2.4.3", "body-parser": "^1.18.3", @@ -117,16 +119,17 @@ "csrf": "^3.1.0", "dotenv": "^8.0.0", "express": "^4.16.4", + "express-openapi-validator": "^4.13.6", "fast-glob": "^3.2.5", "flatted": "^3.2.4", "google-timezones-json": "^1.0.2", "inquirer": "^7.0.1", "json-diff": "^0.5.4", + "jsonschema": "^1.4.1", "jsonwebtoken": "^8.5.1", "jwks-rsa": "~1.12.1", "localtunnel": "^2.0.0", - "lodash.get": "^4.4.2", - "lodash.merge": "^4.6.2", + "lodash": "^4.17.21", "mysql2": "~2.3.0", "n8n-core": "~0.120.0", "n8n-editor-ui": "~0.146.0", @@ -135,6 +138,7 @@ "nodemailer": "^6.7.1", "oauth-1.0a": "^2.2.6", "open": "^7.0.0", + "openapi-types": "^10.0.0", "p-cancelable": "^2.0.0", "passport": "^0.5.0", "passport-cookie": "^1.0.9", @@ -144,10 +148,12 @@ "request-promise-native": "^1.0.7", "sqlite3": "^5.0.2", "sse-channel": "^3.1.1", + "swagger-ui-express": "^4.3.0", "tslib": "1.14.1", "typeorm": "0.2.30", "uuid": "^8.3.0", "validator": "13.7.0", - "winston": "^3.3.3" + "winston": "^3.3.3", + "yamljs": "^0.3.0" } } diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 74661181ee..3a14fd5b32 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -178,6 +178,8 @@ export interface ICredentialsDecryptedResponse extends ICredentialsDecryptedDb { export type DatabaseType = 'mariadb' | 'postgresdb' | 'mysqldb' | 'sqlite'; export type SaveExecutionDataType = 'all' | 'none'; +export type ExecutionDataFieldFormat = 'empty' | 'flattened' | 'json'; + export interface IExecutionBase { id?: number | string; mode: WorkflowExecuteMode; @@ -229,6 +231,19 @@ export interface IExecutionFlattedResponse extends IExecutionFlatted { retryOf?: string; } +export interface IExecutionResponseApi { + id: number | string; + mode: WorkflowExecuteMode; + startedAt: Date; + stoppedAt?: Date; + workflowId?: string; + finished: boolean; + retryOf?: number | string; + retrySuccessId?: number | string; + data?: string; // Just that we can remove it + waitTill?: Date | null; + workflowData: IWorkflowBase; +} export interface IExecutionsListResponse { count: number; // results: IExecutionShortResponse[]; @@ -363,16 +378,20 @@ export interface IInternalHooksClass { firstWorkflowCreatedAt?: Date, ): Promise; onPersonalizationSurveySubmitted(userId: string, answers: Record): Promise; - onWorkflowCreated(userId: string, workflow: IWorkflowBase): Promise; - onWorkflowDeleted(userId: string, workflowId: string): Promise; - onWorkflowSaved(userId: string, workflow: IWorkflowBase): Promise; + onWorkflowCreated(userId: string, workflow: IWorkflowBase, publicApi: boolean): Promise; + onWorkflowDeleted(userId: string, workflowId: string, publicApi: boolean): Promise; + onWorkflowSaved(userId: string, workflow: IWorkflowBase, publicApi: boolean): Promise; onWorkflowPostExecute( executionId: string, workflow: IWorkflowBase, runData?: IRun, userId?: string, ): Promise; - onUserDeletion(userId: string, userDeletionData: ITelemetryUserDeletionData): Promise; + onUserDeletion( + userId: string, + userDeletionData: ITelemetryUserDeletionData, + publicApi: boolean, + ): Promise; onUserInvite(userInviteData: { user_id: string; target_user_id: string[] }): Promise; onUserReinvite(userReinviteData: { user_id: string; target_user_id: string }): Promise; onUserUpdate(userUpdateData: { user_id: string; fields_changed: string[] }): Promise; @@ -468,6 +487,7 @@ export interface IN8nUISettings { personalizationSurveyEnabled: boolean; defaultLocale: string; userManagement: IUserManagementSettings; + publicApi: IPublicApiSettings; workflowTagsDisabled: boolean; logLevel: 'info' | 'debug' | 'warn' | 'error' | 'verbose' | 'silent'; hiringBannerEnabled: boolean; @@ -495,6 +515,11 @@ export interface IUserManagementSettings { showSetupOnFirstLoad?: boolean; smtpSetup: boolean; } +export interface IPublicApiSettings { + enabled: boolean; + latestVersion: number; + path: string; +} export interface IPackageVersions { cli: string; diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index 2bfe4cb382..12d6d009a9 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -64,24 +64,30 @@ export class InternalHooksClass implements IInternalHooksClass { ); } - async onWorkflowCreated(userId: string, workflow: IWorkflowBase): Promise { + async onWorkflowCreated( + userId: string, + workflow: IWorkflowBase, + publicApi: boolean, + ): Promise { const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); return this.telemetry.track('User created workflow', { user_id: userId, workflow_id: workflow.id, node_graph: nodeGraph, node_graph_string: JSON.stringify(nodeGraph), + public_api: publicApi, }); } - async onWorkflowDeleted(userId: string, workflowId: string): Promise { + async onWorkflowDeleted(userId: string, workflowId: string, publicApi: boolean): Promise { return this.telemetry.track('User deleted workflow', { user_id: userId, workflow_id: workflowId, + public_api: publicApi, }); } - async onWorkflowSaved(userId: string, workflow: IWorkflowDb): Promise { + async onWorkflowSaved(userId: string, workflow: IWorkflowDb, publicApi: boolean): Promise { const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); const notesCount = Object.keys(nodeGraph.notes).length; @@ -98,6 +104,7 @@ export class InternalHooksClass implements IInternalHooksClass { notes_count_non_overlapping: notesCount - overlappingCount, version_cli: this.versionCli, num_tags: workflow.tags?.length ?? 0, + public_api: publicApi, }); } @@ -215,21 +222,73 @@ export class InternalHooksClass implements IInternalHooksClass { async onUserDeletion( userId: string, userDeletionData: ITelemetryUserDeletionData, + publicApi: boolean, ): Promise { - return this.telemetry.track('User deleted user', { ...userDeletionData, user_id: userId }); + return this.telemetry.track('User deleted user', { + ...userDeletionData, + user_id: userId, + public_api: publicApi, + }); } - async onUserInvite(userInviteData: { user_id: string; target_user_id: string[] }): Promise { + async onUserInvite(userInviteData: { + user_id: string; + target_user_id: string[]; + public_api: boolean; + }): Promise { return this.telemetry.track('User invited new user', userInviteData); } async onUserReinvite(userReinviteData: { user_id: string; target_user_id: string; + public_api: boolean; }): Promise { return this.telemetry.track('User resent new user invite email', userReinviteData); } + async onUserRetrievedUser(userRetrievedData: { + user_id: string; + public_api: boolean; + }): Promise { + return this.telemetry.track('User retrieved user', userRetrievedData); + } + + async onUserRetrievedAllUsers(userRetrievedData: { + user_id: string; + public_api: boolean; + }): Promise { + return this.telemetry.track('User retrieved all users', userRetrievedData); + } + + async onUserRetrievedExecution(userRetrievedData: { + user_id: string; + public_api: boolean; + }): Promise { + return this.telemetry.track('User retrieved execution', userRetrievedData); + } + + async onUserRetrievedAllExecutions(userRetrievedData: { + user_id: string; + public_api: boolean; + }): Promise { + return this.telemetry.track('User retrieved all executions', userRetrievedData); + } + + async onUserRetrievedWorkflow(userRetrievedData: { + user_id: string; + public_api: boolean; + }): Promise { + return this.telemetry.track('User retrieved workflow', userRetrievedData); + } + + async onUserRetrievedAllWorkflows(userRetrievedData: { + user_id: string; + public_api: boolean; + }): Promise { + return this.telemetry.track('User retrieved all workflows', userRetrievedData); + } + async onUserUpdate(userUpdateData: { user_id: string; fields_changed: string[] }): Promise { return this.telemetry.track('User changed personal settings', userUpdateData); } @@ -248,13 +307,37 @@ export class InternalHooksClass implements IInternalHooksClass { async onUserTransactionalEmail(userTransactionalEmailData: { user_id: string; message_type: 'Reset password' | 'New user invite' | 'Resend invite'; + public_api: boolean; }): Promise { return this.telemetry.track( - 'Instance sent transactional email to user', + 'Instance sent transacptional email to user', userTransactionalEmailData, ); } + async onUserInvokedApi(userInvokedApiData: { + user_id: string; + path: string; + method: string; + api_version: string; + }): Promise { + return this.telemetry.track('User invoked API', userInvokedApiData); + } + + async onApiKeyDeleted(apiKeyDeletedData: { + user_id: string; + public_api: boolean; + }): Promise { + return this.telemetry.track('API key deleted', apiKeyDeletedData); + } + + async onApiKeyCreated(apiKeyCreatedData: { + user_id: string; + public_api: boolean; + }): Promise { + return this.telemetry.track('API key created', apiKeyCreatedData); + } + async onUserPasswordResetRequestClick(userPasswordResetData: { user_id: string }): Promise { return this.telemetry.track( 'User requested password reset while logged out', @@ -273,6 +356,7 @@ export class InternalHooksClass implements IInternalHooksClass { async onEmailFailed(failedEmailData: { user_id: string; message_type: 'Reset password' | 'New user invite' | 'Resend invite'; + public_api: boolean; }): Promise { return this.telemetry.track( 'Instance failed to send transactional email to user', diff --git a/packages/cli/src/PublicApi/index.ts b/packages/cli/src/PublicApi/index.ts new file mode 100644 index 0000000000..5a8585148c --- /dev/null +++ b/packages/cli/src/PublicApi/index.ts @@ -0,0 +1,126 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable import/no-cycle */ +import express, { Router } from 'express'; +import * as OpenApiValidator from 'express-openapi-validator'; +import { HttpError } from 'express-openapi-validator/dist/framework/types'; +import fs from 'fs/promises'; +import { OpenAPIV3 } from 'openapi-types'; +import path from 'path'; +import * as swaggerUi from 'swagger-ui-express'; +import validator from 'validator'; +import * as YAML from 'yamljs'; +import { Db, InternalHooksManager } from '..'; +import config from '../../config'; +import { getInstanceBaseUrl } from '../UserManagement/UserManagementHelper'; + +function createApiRouter( + version: string, + openApiSpecPath: string, + hanldersDirectory: string, + swaggerThemeCss: string, + publicApiEndpoint: string, +): Router { + const n8nPath = config.getEnv('path'); + const swaggerDocument = YAML.load(openApiSpecPath) as swaggerUi.JsonObject; + // add the server depeding on the config so the user can interact with the API + // from the swagger UI + swaggerDocument.server = [ + { + url: `${getInstanceBaseUrl()}/${publicApiEndpoint}/${version}}`, + }, + ]; + const apiController = express.Router(); + apiController.use( + `/${publicApiEndpoint}/${version}/docs`, + swaggerUi.serveFiles(swaggerDocument), + swaggerUi.setup(swaggerDocument, { + customCss: swaggerThemeCss, + customSiteTitle: 'n8n Public API UI', + customfavIcon: `${n8nPath}favicon.ico`, + }), + ); + apiController.use(`/${publicApiEndpoint}/${version}`, express.json()); + apiController.use( + `/${publicApiEndpoint}/${version}`, + OpenApiValidator.middleware({ + apiSpec: openApiSpecPath, + operationHandlers: hanldersDirectory, + validateRequests: true, + validateApiSpec: true, + formats: [ + { + name: 'email', + type: 'string', + validate: (email: string) => validator.isEmail(email), + }, + { + name: 'identifier', + type: 'string', + validate: (identifier: string) => + validator.isUUID(identifier) || validator.isEmail(identifier), + }, + ], + validateSecurity: { + handlers: { + ApiKeyAuth: async ( + req: express.Request, + _scopes: unknown, + schema: OpenAPIV3.ApiKeySecurityScheme, + ): Promise => { + const apiKey = req.headers[schema.name.toLowerCase()]; + const user = await Db.collections.User?.findOne({ + where: { + apiKey, + }, + relations: ['globalRole'], + }); + + if (!user) { + return false; + } + + void InternalHooksManager.getInstance().onUserInvokedApi({ + user_id: user.id, + path: req.path, + method: req.method, + api_version: version, + }); + + req.user = user; + + return true; + }, + }, + }, + }), + ); + apiController.use( + (error: HttpError, req: express.Request, res: express.Response, next: express.NextFunction) => { + return res.status(error.status || 400).json({ + message: error.message, + }); + }, + ); + return apiController; +} + +export const loadPublicApiVersions = async ( + publicApiEndpoint: string, +): Promise<{ apiRouters: express.Router[]; apiLatestVersion: number }> => { + const swaggerThemePath = path.join(__dirname, 'swaggerTheme.css'); + const folders = await fs.readdir(__dirname); + const css = (await fs.readFile(swaggerThemePath)).toString(); + const versions = folders.filter((folderName) => folderName.startsWith('v')); + const apiRouters: express.Router[] = []; + for (const version of versions) { + const openApiPath = path.join(__dirname, version, 'openapi.yml'); + apiRouters.push(createApiRouter(version, openApiPath, __dirname, css, publicApiEndpoint)); + } + return { + apiRouters, + apiLatestVersion: Number(versions.pop()?.charAt(1)) ?? 1, + }; +}; diff --git a/packages/cli/src/PublicApi/swaggerTheme.css b/packages/cli/src/PublicApi/swaggerTheme.css new file mode 100644 index 0000000000..b7682cc8fa --- /dev/null +++ b/packages/cli/src/PublicApi/swaggerTheme.css @@ -0,0 +1,26 @@ +.swagger-ui .info .title small:last-child { + background-color: #ff6d5a !important +} + +.swagger-ui .topbar { + background-color: #fff; +} + +.swagger-ui img { + content: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTI0IiBoZWlnaHQ9IjI4IiB2aWV3Qm94PSIwIDAgMTI0IDI4IiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIGRhdGEtdi03OGMyN2E5YT0iIj48dGl0bGUgZGF0YS12LTc4YzI3YTlhPSIiPm44PC90aXRsZT4gPGcgaWQ9Im5hdi1tZW51LShWMSkiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGRhdGEtdi03OGMyN2E5YT0iIj48ZyBpZD0ibmF2LW1lbnUtKHYxKSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTEyMC4wMDAwMDAsIC0xMTYuMDAwMDAwKSIgZmlsbC1ydWxlPSJub256ZXJvIiBkYXRhLXYtNzhjMjdhOWE9IiIgZmlsbD0ibm9uZSI+PGcgaWQ9Im44IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgxMjAuMDAwMDAwLCAxMTYuMDAwMDAwKSIgZGF0YS12LTc4YzI3YTlhPSIiIGZpbGw9Im5vbmUiPjxwYXRoIGQ9Ik00OC43Mzg0OTA2LDAuMTkwMTg4Njc5IEM0Ni4xNTc3MzU4LDAuMTkwMTg4Njc5IDQzLjk4NjQxNTEsMS45Njc5MjQ1MyA0My4zNzM1ODQ5LDQuMzYxMTMyMDggTDM1LjY1MjQ1MjgsNC4zNjExMzIwOCBDMzIuNjIyNjQxNSw0LjM2MTEzMjA4IDMwLjE1ODExMzIsNi44MjU2NjAzOCAzMC4xNTgxMTMyLDkuODU1NDcxNyBDMzAuMTU4MTEzMiwxMS4zNjkwNTY2IDI4LjkyNzE2OTgsMTIuNjAyNjQxNSAyNy40MTA5NDM0LDEyLjYwMjY0MTUgTDI2LjMwOTQzNCwxMi42MDI2NDE1IEMyNS42OTY2MDM4LDEwLjIwOTQzNCAyMy41Mjc5MjQ1LDguNDMxNjk4MTEgMjAuOTQ0NTI4Myw4LjQzMTY5ODExIEMxOC4zNjM3NzM2LDguNDMxNjk4MTEgMTYuMTkyNDUyOCwxMC4yMDk0MzQgMTUuNTc5NjIyNiwxMi42MDI2NDE1IEwxMS4xNjgzMDE5LDEyLjYwMjY0MTUgQzEwLjU1NTQ3MTcsMTAuMjA5NDM0IDguMzg2NzkyNDUsOC40MzE2OTgxMSA1LjgwMzM5NjIzLDguNDMxNjk4MTEgQzIuNzQ3MTY5ODEsOC40MzE2OTgxMSAwLjI1ODg2NzkyNSwxMC45MTczNTg1IDAuMjU4ODY3OTI1LDEzLjk3NjIyNjQgQzAuMjU4ODY3OTI1LDE3LjAzMjQ1MjggMi43NDQ1MjgzLDE5LjUyMDc1NDcgNS44MDMzOTYyMywxOS41MjA3NTQ3IEM4LjM4NDE1MDk0LDE5LjUyMDc1NDcgMTAuNTU1NDcxNywxNy43NDMwMTg5IDExLjE2ODMwMTksMTUuMzQ5ODExMyBMMTUuNTg0OTA1NywxNS4zNDk4MTEzIEMxNi4xOTc3MzU4LDE3Ljc0MzAxODkgMTguMzY2NDE1MSwxOS41MjA3NTQ3IDIwLjk0OTgxMTMsMTkuNTIwNzU0NyBDMjMuNTE0NzE3LDE5LjUyMDc1NDcgMjUuNjcwMTg4NywxNy43Njk0MzQgMjYuMzAxNTA5NCwxNS40IEwyNy40MTM1ODQ5LDE1LjQgQzI4LjkyNzE2OTgsMTUuNCAzMC4xNjA3NTQ3LDE2LjYzMDk0MzQgMzAuMTYwNzU0NywxOC4xNDcxNjk4IEMzMC4xNjA3NTQ3LDIxLjE3Njk4MTEgMzIuNjI1MjgzLDIzLjY0MTUwOTQgMzUuNjU1MDk0MywyMy42NDE1MDk0IEwzNy40NTM5NjIzLDIzLjY0MTUwOTQgQzM4LjA2Njc5MjUsMjYuMDM0NzE3IDQwLjIzNTQ3MTcsMjcuODEyNDUyOCA0Mi44MTg4Njc5LDI3LjgxMjQ1MjggQzQ1Ljg3NTA5NDMsMjcuODEyNDUyOCA0OC4zNjMzOTYyLDI1LjMyNjc5MjUgNDguMzYzMzk2MiwyMi4yNjc5MjQ1IEM0OC4zNjMzOTYyLDE5LjIxMTY5ODEgNDUuODc3NzM1OCwxNi43MjMzOTYyIDQyLjgxODg2NzksMTYuNzIzMzk2MiBDNDAuMjM4MTEzMiwxNi43MjMzOTYyIDM4LjA2Njc5MjUsMTguNTAxMTMyMSAzNy40NTM5NjIzLDIwLjg5NDMzOTYgTDM1LjY1NTA5NDMsMjAuODk0MzM5NiBDMzQuMTQxNTA5NCwyMC44OTQzMzk2IDMyLjkwNzkyNDUsMTkuNjYzMzk2MiAzMi45MDc5MjQ1LDE4LjE0NzE2OTggQzMyLjkwNzkyNDUsMTYuNDkzNTg0OSAzMi4xNjgzMDE5LDE1LjAwOTA1NjYgMzEuMDA4Njc5MiwxNC4wMDI2NDE1IEMzMi4xNzA5NDM0LDEyLjk5MzU4NDkgMzIuOTA3OTI0NSwxMS41MTE2OTgxIDMyLjkwNzkyNDUsOS44NTgxMTMyMSBDMzIuOTA3OTI0NSw4LjM0NDUyODMgMzQuMTM4ODY3OSw3LjExMDk0MzQgMzUuNjU1MDk0Myw3LjExMDk0MzQgTDQzLjM3NjIyNjQsNy4xMTA5NDM0IEM0My45ODkwNTY2LDkuNTA0MTUwOTQgNDYuMTU3NzM1OCwxMS4yODE4ODY4IDQ4Ljc0MTEzMjEsMTEuMjgxODg2OCBDNTEuNzk3MzU4NSwxMS4yODE4ODY4IDU0LjI4NTY2MDQsOC43OTYyMjY0MiA1NC4yODU2NjA0LDUuNzM3MzU4NDkgQzU0LjI4MzAxODksMi42Nzg0OTA1NyA1MS43OTQ3MTcsMC4xOTAxODg2NzkgNDguNzM4NDkwNiwwLjE5MDE4ODY3OSBaIE01LjgwODY3OTI1LDE2Ljc3MDk0MzQgQzQuMjY2MDM3NzQsMTYuNzcwOTQzNCAzLjAxMTMyMDc1LDE1LjUxNjIyNjQgMy4wMTEzMjA3NSwxMy45NzM1ODQ5IEMzLjAxMTMyMDc1LDEyLjQzMDk0MzQgNC4yNjYwMzc3NCwxMS4xNzYyMjY0IDUuODA4Njc5MjUsMTEuMTc2MjI2NCBDNy4zNTEzMjA3NSwxMS4xNzYyMjY0IDguNjA2MDM3NzQsMTIuNDMwOTQzNCA4LjYwNjAzNzc0LDEzLjk3MzU4NDkgQzguNjA2MDM3NzQsMTUuNTE2MjI2NCA3LjM1MTMyMDc1LDE2Ljc3MDk0MzQgNS44MDg2NzkyNSwxNi43NzA5NDM0IFogTTIwLjk0OTgxMTMsMTYuNzcwOTQzNCBDMTkuNDA3MTY5OCwxNi43NzA5NDM0IDE4LjE1MjQ1MjgsMTUuNTE2MjI2NCAxOC4xNTI0NTI4LDEzLjk3MzU4NDkgQzE4LjE1MjQ1MjgsMTIuNDMwOTQzNCAxOS40MDcxNjk4LDExLjE3NjIyNjQgMjAuOTQ5ODExMywxMS4xNzYyMjY0IEMyMi40OTI0NTI4LDExLjE3NjIyNjQgMjMuNzQ3MTY5OCwxMi40MzA5NDM0IDIzLjc0NzE2OTgsMTMuOTczNTg0OSBDMjMuNzQ3MTY5OCwxNS41MTYyMjY0IDIyLjQ5MjQ1MjgsMTYuNzcwOTQzNCAyMC45NDk4MTEzLDE2Ljc3MDk0MzQgWiBNNDIuODE2MjI2NCwxOS40Njc5MjQ1IEM0NC4zNTg4Njc5LDE5LjQ2NzkyNDUgNDUuNjEzNTg0OSwyMC43MjI2NDE1IDQ1LjYxMzU4NDksMjIuMjY1MjgzIEM0NS42MTM1ODQ5LDIzLjgwNzkyNDUgNDQuMzU4ODY3OSwyNS4wNjI2NDE1IDQyLjgxNjIyNjQsMjUuMDYyNjQxNSBDNDEuMjczNTg0OSwyNS4wNjI2NDE1IDQwLjAxODg2NzksMjMuODA3OTI0NSA0MC4wMTg4Njc5LDIyLjI2NTI4MyBDNDAuMDIxNTA5NCwyMC43MjI2NDE1IDQxLjI3NjIyNjQsMTkuNDY3OTI0NSA0Mi44MTYyMjY0LDE5LjQ2NzkyNDUgWiBNNDguNzM4NDkwNiw4LjUzMjA3NTQ3IEM0Ny4xOTU4NDkxLDguNTMyMDc1NDcgNDUuOTQxMTMyMSw3LjI3NzM1ODQ5IDQ1Ljk0MTEzMjEsNS43MzQ3MTY5OCBDNDUuOTQxMTMyMSw0LjE5MjA3NTQ3IDQ3LjE5NTg0OTEsMi45MzczNTg0OSA0OC43Mzg0OTA2LDIuOTM3MzU4NDkgQzUwLjI4MTEzMjEsMi45MzczNTg0OSA1MS41MzU4NDkxLDQuMTkyMDc1NDcgNTEuNTM1ODQ5MSw1LjczNDcxNjk4IEM1MS41MzU4NDkxLDcuMjc3MzU4NDkgNTAuMjgxMTMyMSw4LjUzMjA3NTQ3IDQ4LjczODQ5MDYsOC41MzIwNzU0NyBaIiBpZD0iU2hhcGUiIGZpbGw9IiNGRjZENUEiIGRhdGEtdi03OGMyN2E5YT0iIj48L3BhdGg+IDxnIGlkPSJHcm91cCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNTYuNTI4MzAyLCA1LjU0NzE3MCkiIGZpbGw9IiMzODRENUIiIGRhdGEtdi03OGMyN2E5YT0iIj48cGF0aCBkPSJNMS41Nzk2MjI2NCw3LjA5NzczNTg1IEMxLjU3OTYyMjY0LDYuNzY0OTA1NjYgMS40MDI2NDE1MSw2LjYwOTA1NjYgMS4wOTA5NDM0LDYuNjA5MDU2NiBMMC4xNzk2MjI2NDIsNi42MDkwNTY2IEwwLjE3OTYyMjY0Miw0Ljc2NTI4MzAyIEwyLjI0NzkyNDUzLDQuNzY1MjgzMDIgQzMuMjA0MTUwOTQsNC43NjUyODMwMiAzLjY3MTY5ODExLDUuMTg3OTI0NTMgMy42NzE2OTgxMSw2LjAwOTQzMzk2IEwzLjY3MTY5ODExLDYuNDMyMDc1NDcgQzMuNjcxNjk4MTEsNi43ODg2NzkyNSAzLjYyNjc5MjQ1LDcuMDc2NjAzNzcgMy42MjY3OTI0NSw3LjA3NjYwMzc3IEwzLjY3MTY5ODExLDcuMDc2NjAzNzcgQzQuMTE1NDcxNyw2LjA5OTI0NTI4IDUuNDQ5NDMzOTYsNC40OTg0OTA1NyA3Ljg5NTQ3MTcsNC40OTg0OTA1NyBDMTAuNTYzMzk2Miw0LjQ5ODQ5MDU3IDExLjc2MjY0MTUsNS45NDMzOTYyMyAxMS43NjI2NDE1LDguODA5NDMzOTYgTDExLjc2MjY0MTUsMTMuNjc3NzM1OCBDMTEuNzYyNjQxNSwxNC4wMTA1NjYgMTEuOTM5NjIyNiwxNC4xNjY0MTUxIDEyLjI1MTMyMDgsMTQuMTY2NDE1MSBMMTMuMTYyNjQxNSwxNC4xNjY0MTUxIEwxMy4xNjI2NDE1LDE2LjAxMDE4ODcgTDExLjAyODMwMTksMTYuMDEwMTg4NyBDMTAuMDI3MTY5OCwxNi4wMTAxODg3IDkuNjA0NTI4MywxNS41ODc1NDcyIDkuNjA0NTI4MywxNC41ODY0MTUxIEw5LjYwNDUyODMsOS4yOTgxMTMyMSBDOS42MDQ1MjgzLDcuNzE4NDkwNTcgOS4yOTI4MzAxOSw2LjQ3NDMzOTYyIDcuNDkzOTYyMjYsNi40NzQzMzk2MiBDNS43NjExMzIwOCw2LjQ3NDMzOTYyIDQuMzgyMjY0MTUsNy42MDc1NDcxNyAzLjkzODQ5MDU3LDkuMjMyMDc1NDcgQzMuNzgyNjQxNTEsOS42NzU4NDkwNiAzLjczNzczNTg1LDEwLjE4ODMwMTkgMy43Mzc3MzU4NSwxMC43NDMwMTg5IEwzLjczNzczNTg1LDE2LjAxMDE4ODcgTDEuNTgyMjY0MTUsMTYuMDEwMTg4NyBMMS41ODIyNjQxNSw3LjA5NzczNTg1IEwxLjU3OTYyMjY0LDcuMDk3NzM1ODUgWiIgaWQ9IlBhdGgiIGRhdGEtdi03OGMyN2E5YT0iIiBmaWxsPSIjMzg0RDVCIj48L3BhdGg+IDxwYXRoIGQ9Ik0xNy42NjkwNTY2LDcuNDk2NjAzNzcgTDE3LjY2OTA1NjYsNy40NTE2OTgxMSBDMTcuNjY5MDU2Niw3LjQ1MTY5ODExIDE1LjczNTQ3MTcsNi40Mjk0MzM5NiAxNS43MzU0NzE3LDQuMjUwMTg4NjggQzE1LjczNTQ3MTcsMi4wNzA5NDM0IDE3LjQ2ODMwMTksMC4wNTAxODg2NzkyIDIwLjYyNDkwNTcsMC4wNTAxODg2NzkyIEMyMy42MjU2NjA0LDAuMDUwMTg4Njc5MiAyNS41MzgxMTMyLDEuODUxNjk4MTEgMjUuNTM4MTEzMiw0LjI5NTA5NDM0IEMyNS41MzgxMTMyLDYuNjA2NDE1MDkgMjMuNjQ5NDM0LDguMDMwMTg4NjggMjMuNjQ5NDM0LDguMDMwMTg4NjggTDIzLjY0OTQzNCw4LjA3NTA5NDM0IEMyNS4wNzMyMDc1LDguODk2NjAzNzcgMjUuOTg0NTI4Myw5Ljk4NzU0NzE3IDI1Ljk4NDUyODMsMTEuNjc1NDcxNyBDMjUuOTg0NTI4MywxNC4xMjE1MDk0IDIzLjc2MzAxODksMTYuMjc2OTgxMSAyMC41NjE1MDk0LDE2LjI3Njk4MTEgQzE3LjYwNTY2MDQsMTYuMjc2OTgxMSAxNS4wOTM1ODI5LDE0LjQzMzIwNzUgMTUuMDkzNTgyOSwxMS41MTk2MjI2IEMxNS4wOTA5NDM0LDguOTQxNTA5NDMgMTcuNjY5MDU2Niw3LjQ5NjYwMzc3IDE3LjY2OTA1NjYsNy40OTY2MDM3NyBaIE0yMC41NTg4Njc5LDE0LjI1MzU4NDkgQzIyLjIwNDUyODMsMTQuMjUzNTg0OSAyMy43MzY2MDM4LDEzLjE2NTI4MyAyMy43MzY2MDM4LDExLjYwOTQzNCBDMjMuNzM2NjAzOCwxMC4yMzA1NjYgMjIuNTU4NDkwNiw5LjYzMDk0MzQgMjEuMDkyNDUyOCw5LjAzMTMyMDc1IEMyMC40OTI4MzAyLDguNzg1NjYwMzggMTkuNjQ3NTQ3Miw4LjQ1MjgzMDE5IDE5LjQ3MDU2Niw4LjQ1MjgzMDE5IEMxOC45MTU4NDkxLDguNDUyODMwMTkgMTcuMzM2MjI2NCw5Ljc0MTg4Njc5IDE3LjMzNjIyNjQsMTEuNDA4Njc5MiBDMTcuMzM2MjI2NCwxMy4xNjUyODMgMTguODQ3MTY5OCwxNC4yNTM1ODQ5IDIwLjU1ODg2NzksMTQuMjUzNTg0OSBaIE0yMS43MTU4NDkxLDcuMTQgQzIyLjI0OTQzNCw3LjE0IDIzLjM4MjY0MTUsNS44MjcxNjk4MSAyMy4zODI2NDE1LDQuNDI3MTY5ODEgQzIzLjM4MjY0MTUsMi45ODIyNjQxNSAyMi4yMjU2NjA0LDIuMDcwOTQzNCAyMC42Mjc1NDcyLDIuMDcwOTQzNCBDMTguOTE1ODQ5MSwyLjA3MDk0MzQgMTcuOTE0NzE3LDMuMDQ4MzAxODkgMTcuOTE0NzE3LDQuMjkyNDUyODMgQzE3LjkxNDcxNyw1LjY3MTMyMDc1IDE5LjA5MjgzMDIsNi4yMDQ5MDU2NiAyMC40OTI4MzAyLDYuNzU5NjIyNjQgQzIwLjgwNDUyODMsNi44OTY5ODExMyAyMS40NDkwNTY2LDcuMTQgMjEuNzE1ODQ5MSw3LjE0IFoiIGlkPSJTaGFwZSIgZGF0YS12LTc4YzI3YTlhPSIiIGZpbGw9IiMzODRENUIiPjwvcGF0aD4gPHBhdGggZD0iTTI5LjQwNTI4Myw3LjA5NzczNTg1IEMyOS40MDUyODMsNi43NjQ5MDU2NiAyOS4yMjgzMDE5LDYuNjA5MDU2NiAyOC45MTY2MDM4LDYuNjA5MDU2NiBMMjguMDA1MjgzLDYuNjA5MDU2NiBMMjguMDA1MjgzLDQuNzY1MjgzMDIgTDMwLjA3MzU4NDksNC43NjUyODMwMiBDMzEuMDI5ODExMyw0Ljc2NTI4MzAyIDMxLjQ5NzM1ODUsNS4xODc5MjQ1MyAzMS40OTczNTg1LDYuMDA5NDMzOTYgTDMxLjQ5NzM1ODUsNi40MzIwNzU0NyBDMzEuNDk3MzU4NSw2Ljc4ODY3OTI1IDMxLjQ1MjQ1MjgsNy4wNzY2MDM3NyAzMS40NTI0NTI4LDcuMDc2NjAzNzcgTDMxLjQ5NzM1ODUsNy4wNzY2MDM3NyBDMzEuOTQxMTMyMSw2LjA5OTI0NTI4IDMzLjI3NTA5NDMsNC40OTg0OTA1NyAzNS43MjExMzIxLDQuNDk4NDkwNTcgQzM4LjM4OTA1NjYsNC40OTg0OTA1NyAzOS41ODgzMDE5LDUuOTQzMzk2MjMgMzkuNTg4MzAxOSw4LjgwOTQzMzk2IEwzOS41ODgzMDE5LDEzLjY3NzczNTggQzM5LjU4ODMwMTksMTQuMDEwNTY2IDM5Ljc2NTI4MywxNC4xNjY0MTUxIDQwLjA3Njk4MTEsMTQuMTY2NDE1MSBMNDAuOTg4MzAxOSwxNC4xNjY0MTUxIEw0MC45ODgzMDE5LDE2LjAxMDE4ODcgTDM4Ljg1Mzk2MjMsMTYuMDEwMTg4NyBDMzcuODUyODMwMiwxNi4wMTAxODg3IDM3LjQzMDE4ODcsMTUuNTg3NTQ3MiAzNy40MzAxODg3LDE0LjU4NjQxNTEgTDM3LjQzMDE4ODcsOS4yOTgxMTMyMSBDMzcuNDMwMTg4Nyw3LjcxODQ5MDU3IDM3LjExODQ5MDYsNi40NzQzMzk2MiAzNS4zMTk2MjI2LDYuNDc0MzM5NjIgQzMzLjU4Njc5MjUsNi40NzQzMzk2MiAzMi4yMDc5MjQ1LDcuNjA3NTQ3MTcgMzEuNzY0MTUwOSw5LjIzMjA3NTQ3IEMzMS42MDgzMDE5LDkuNjc1ODQ5MDYgMzEuNTYzMzk2MiwxMC4xODgzMDE5IDMxLjU2MzM5NjIsMTAuNzQzMDE4OSBMMzEuNTYzMzk2MiwxNi4wMTAxODg3IEwyOS40MDc5MjQ1LDE2LjAxMDE4ODcgTDI5LjQwNzkyNDUsNy4wOTc3MzU4NSBMMjkuNDA1MjgzLDcuMDk3NzM1ODUgWiIgaWQ9IlBhdGgiIGRhdGEtdi03OGMyN2E5YT0iIiBmaWxsPSIjMzg0RDVCIj48L3BhdGg+IDxwb2x5Z29uIGlkPSJQYXRoIiBwb2ludHM9IjQzLjU0IDEzLjcyIDQ1Ljc0MDM3NzQgMTMuNzIgNDUuNzQwMzc3NCAxNi4wMTAxODg3IDQzLjU0IDE2LjAxMDE4ODciIGRhdGEtdi03OGMyN2E5YT0iIiBmaWxsPSIjMzg0RDVCIj48L3BvbHlnb24+IDxwYXRoIGQ9Ik00OC43MTczNTg1LDcuMDk3NzM1ODUgQzQ4LjcxNzM1ODUsNi43NjQ5MDU2NiA0OC41NDAzNzc0LDYuNjA5MDU2NiA0OC4yMjg2NzkyLDYuNjA5MDU2NiBMNDcuMzE3MzU4NSw2LjYwOTA1NjYgTDQ3LjMxNzM1ODUsNC43NjUyODMwMiBMNDkuNDI3OTI0NSw0Ljc2NTI4MzAyIEM1MC40MjkwNTY2LDQuNzY1MjgzMDIgNTAuODUxNjk4MSw1LjE4NzkyNDUzIDUwLjg1MTY5ODEsNi4xODkwNTY2IEw1MC44NTE2OTgxLDEzLjY4MDM3NzQgQzUwLjg1MTY5ODEsMTQuMDEzMjA3NSA1MS4wMjg2NzkyLDE0LjE2OTA1NjYgNTEuMzQwMzc3NCwxNC4xNjkwNTY2IEw1Mi4yNTE2OTgxLDE0LjE2OTA1NjYgTDUyLjI1MTY5ODEsMTYuMDEyODMwMiBMNTAuMTQxMTMyMSwxNi4wMTI4MzAyIEM0OS4xNCwxNi4wMTI4MzAyIDQ4LjcxNzM1ODUsMTUuNTkwMTg4NyA0OC43MTczNTg1LDE0LjU4OTA1NjYgTDQ4LjcxNzM1ODUsNy4wOTc3MzU4NSBaIiBpZD0iUGF0aCIgZGF0YS12LTc4YzI3YTlhPSIiIGZpbGw9IiMzODRENUIiPjwvcGF0aD4gPHBhdGggZD0iTTYwLjIzMTY5ODEsNC40OTU4NDkwNiBDNjMuNTg5MDU2Niw0LjQ5NTg0OTA2IDY2LjI5OTI0NTMsNi45NjMwMTg4NyA2Ni4yOTkyNDUzLDEwLjM2NTI4MyBDNjYuMjk5MjQ1MywxMy43ODg2NzkyIDYzLjU4NjQxNTEsMTYuMjc2OTgxMSA2MC4yMzE2OTgxLDE2LjI3Njk4MTEgQzU2Ljg3NDMzOTYsMTYuMjc2OTgxMSA1NC4xODUyODMsMTMuNzg2MDM3NyA1NC4xODUyODMsMTAuMzY1MjgzIEM1NC4xODUyODMsNi45NjMwMTg4NyA1Ni44NzQzMzk2LDQuNDk1ODQ5MDYgNjAuMjMxNjk4MSw0LjQ5NTg0OTA2IFogTTYwLjIzMTY5ODEsMTQuNDA5NDM0IEM2Mi4zNjYwMzc3LDE0LjQwOTQzNCA2NC4wOTg4Njc5LDEyLjcxODg2NzkgNjQuMDk4ODY3OSwxMC4zNjI2NDE1IEM2NC4wOTg4Njc5LDguMDI3NTQ3MTcgNjIuMzY2MDM3Nyw2LjM2MDc1NDcyIDYwLjIzMTY5ODEsNi4zNjA3NTQ3MiBDNTguMTIxMTMyMSw2LjM2MDc1NDcyIDU2LjM4NTY2MDQsOC4wMjc1NDcxNyA1Ni4zODU2NjA0LDEwLjM2MjY0MTUgQzU2LjM4NTY2MDQsMTIuNzIxNTA5NCA1OC4xMTg0OTA2LDE0LjQwOTQzNCA2MC4yMzE2OTgxLDE0LjQwOTQzNCBaIiBpZD0iU2hhcGUiIGRhdGEtdi03OGMyN2E5YT0iIiBmaWxsPSIjMzg0RDVCIj48L3BhdGg+PC9nPiA8cGF0aCBkPSJNMTA2LjIzMDk0Myw5LjYzODg2NzkyIEMxMDUuMTI0MTUxLDkuNjM4ODY3OTIgMTA0LjIyMzM5Niw4LjczODExMzIxIDEwNC4yMjMzOTYsNy42MzEzMjA3NSBDMTA0LjIyMzM5Niw2LjUyNDUyODMgMTA1LjEyNDE1MSw1LjYyMzc3MzU4IDEwNi4yMzA5NDMsNS42MjM3NzM1OCBDMTA3LjMzNzczNiw1LjYyMzc3MzU4IDEwOC4yMzg0OTEsNi41MjQ1MjgzIDEwOC4yMzg0OTEsNy42MzEzMjA3NSBDMTA4LjIzODQ5MSw4LjczODExMzIxIDEwNy4zMzc3MzYsOS42Mzg4Njc5MiAxMDYuMjMwOTQzLDkuNjM4ODY3OTIgWiBNMTA2LjIzMDk0Myw2LjU4NzkyNDUzIEMxMDUuNjU3NzM2LDYuNTg3OTI0NTMgMTA1LjE5MDE4OSw3LjA1NTQ3MTcgMTA1LjE5MDE4OSw3LjYyODY3OTI1IEMxMDUuMTkwMTg5LDguMjAxODg2NzkgMTA1LjY1NzczNiw4LjY2OTQzMzk2IDEwNi4yMzA5NDMsOC42Njk0MzM5NiBDMTA2LjgwNDE1MSw4LjY2OTQzMzk2IDEwNy4yNzE2OTgsOC4yMDE4ODY3OSAxMDcuMjcxNjk4LDcuNjI4Njc5MjUgQzEwNy4yNzE2OTgsNy4wNTU0NzE3IDEwNi44MDQxNTEsNi41ODc5MjQ1MyAxMDYuMjMwOTQzLDYuNTg3OTI0NTMgWiIgaWQ9IlNoYXBlIiBmaWxsPSIjRkY2RDVBIiBkYXRhLXYtNzhjMjdhOWE9IiI+PC9wYXRoPjwvZz48L2c+PC9nPjwvc3ZnPg==); + width: 140px; + height: 40px; +} + +.swagger-ui .btn.authorize { + border-color: #ff6d5a; + color: #3b4151; +} + +.swagger-ui .btn.authorize svg { + fill: #7d8492 +} + +.swagger-ui select { + border-color: #ff6d5a; +} diff --git a/packages/cli/src/PublicApi/types.d.ts b/packages/cli/src/PublicApi/types.d.ts new file mode 100644 index 0000000000..501f7b028d --- /dev/null +++ b/packages/cli/src/PublicApi/types.d.ts @@ -0,0 +1,165 @@ +/* eslint-disable import/no-cycle */ +import express from 'express'; +import { IDataObject } from 'n8n-workflow'; + +import type { User } from '../databases/entities/User'; + +import type { Role } from '../databases/entities/Role'; + +import type { WorkflowEntity } from '../databases/entities/WorkflowEntity'; + +import * as UserManagementMailer from '../UserManagement/email/UserManagementMailer'; + +export type ExecutionStatus = 'error' | 'running' | 'success' | 'waiting' | null; + +export type AuthlessRequest< + RouteParams = {}, + ResponseBody = {}, + RequestBody = {}, + RequestQuery = {}, +> = express.Request; + +export type AuthenticatedRequest< + RouteParams = {}, + ResponseBody = {}, + RequestBody = {}, + RequestQuery = {}, +> = express.Request & { + user: User; + globalMemberRole?: Role; + mailer?: UserManagementMailer.UserManagementMailer; +}; + +export type PaginatatedRequest = AuthenticatedRequest< + {}, + {}, + {}, + { + limit?: number; + cursor?: string; + offset?: number; + lastId?: number; + } +>; +export declare namespace ExecutionRequest { + type GetAll = AuthenticatedRequest< + {}, + {}, + {}, + { + status?: ExecutionStatus; + limit?: number; + cursor?: string; + offset?: number; + includeData?: boolean; + workflowId?: number; + lastId?: number; + } + >; + + type Get = AuthenticatedRequest<{ id: number }, {}, {}, { includeData?: boolean }>; + type Delete = Get; +} + +export declare namespace CredentialTypeRequest { + type Get = AuthenticatedRequest<{ credentialTypeName: string }, {}, {}, {}>; +} + +export declare namespace WorkflowRequest { + type GetAll = AuthenticatedRequest< + {}, + {}, + {}, + { + tags?: string; + status?: ExecutionStatus; + limit?: number; + cursor?: string; + offset?: number; + workflowId?: number; + active: boolean; + } + >; + + type Create = AuthenticatedRequest<{}, {}, WorkflowEntity, {}>; + type Get = AuthenticatedRequest<{ id: number }, {}, {}, {}>; + type Delete = Get; + type Update = AuthenticatedRequest<{ id: number }, {}, WorkflowEntity, {}>; + type Activate = Get; +} + +export declare namespace UserRequest { + export type Invite = AuthenticatedRequest<{}, {}, Array<{ email: string }>>; + + export type ResolveSignUp = AuthlessRequest< + {}, + {}, + {}, + { inviterId?: string; inviteeId?: string } + >; + + export type SignUp = AuthenticatedRequest< + { id: string }, + { inviterId?: string; inviteeId?: string } + >; + + export type Delete = AuthenticatedRequest< + { id: string; email: string }, + {}, + {}, + { transferId?: string; includeRole: boolean } + >; + + export type Get = AuthenticatedRequest< + { id: string; email: string }, + {}, + {}, + { limit?: number; offset?: number; cursor?: string; includeRole?: boolean } + >; + + export type Reinvite = AuthenticatedRequest<{ id: string }>; + + export type Update = AuthlessRequest< + { id: string }, + {}, + { + inviterId: string; + firstName: string; + lastName: string; + password: string; + } + >; +} + +export declare namespace CredentialRequest { + type Create = AuthenticatedRequest<{}, {}, { type: string; name: string; data: IDataObject }, {}>; +} + +export type OperationID = 'getUsers' | 'getUser'; + +type PaginationBase = { limit: number }; + +type PaginationOffsetDecoded = PaginationBase & { offset: number }; + +type PaginationCursorDecoded = PaginationBase & { lastId: number }; + +type OffsetPagination = PaginationBase & { offset: number; numberOfTotalRecords: number }; + +type CursorPagination = PaginationBase & { lastId: number; numberOfNextRecords: number }; +export interface IRequired { + required?: string[]; + not?: { required?: string[] }; +} +export interface IDependency { + if?: { properties: {} }; + then?: { oneOf: IRequired[] }; + else?: { allOf: IRequired[] }; +} + +export interface IJsonSchema { + additionalProperties: boolean; + type: 'object'; + properties: { [key: string]: { type: string } }; + allOf?: IDependency[]; + required: string[]; +} diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts new file mode 100644 index 0000000000..6896706c34 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts @@ -0,0 +1,112 @@ +import express = require('express'); +import { CredentialsHelper } from '../../../../CredentialsHelper'; +import { CredentialTypes } from '../../../../CredentialTypes'; + +import { CredentialsEntity } from '../../../../databases/entities/CredentialsEntity'; +import { CredentialRequest } from '../../../../requests'; +import { CredentialTypeRequest } from '../../../types'; +import { authorize } from '../../shared/middlewares/global.middleware'; +import { validCredentialsProperties, validCredentialType } from './credentials.middleware'; + +import { + createCredential, + encryptCredential, + getCredentials, + getSharedCredentials, + removeCredential, + sanitizeCredentials, + saveCredential, + toJsonSchema, +} from './credentials.service'; + +export = { + createCredential: [ + authorize(['owner', 'member']), + validCredentialType, + validCredentialsProperties, + async ( + req: CredentialRequest.Create, + res: express.Response, + ): Promise>> => { + try { + const newCredential = await createCredential(req.body as Partial); + + const encryptedData = await encryptCredential(newCredential); + + Object.assign(newCredential, encryptedData); + + const savedCredential = await saveCredential(newCredential, req.user, encryptedData); + + // LoggerProxy.verbose('New credential created', { + // credentialId: newCredential.id, + // ownerId: req.user.id, + // }); + + return res.json(sanitizeCredentials(savedCredential)); + } catch ({ message, httpStatusCode }) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + return res.status(httpStatusCode ?? 500).json({ message }); + } + }, + ], + deleteCredential: [ + authorize(['owner', 'member']), + async ( + req: CredentialRequest.Delete, + res: express.Response, + ): Promise>> => { + const { id: credentialId } = req.params; + let credentials: CredentialsEntity | undefined; + + if (req.user.globalRole.name !== 'owner') { + const shared = await getSharedCredentials(req.user.id, credentialId, [ + 'credentials', + 'role', + ]); + + if (shared?.role.name === 'owner') { + credentials = shared.credentials; + } else { + // LoggerProxy.info('Attempt to delete credential blocked due to lack of permissions', { + // credentialId, + // userId: req.user.id, + // }); + } + } else { + credentials = (await getCredentials(credentialId)) as CredentialsEntity; + } + + if (!credentials) { + return res.status(404).json({ + message: 'Not Found', + }); + } + + await removeCredential(credentials); + credentials.id = Number(credentialId); + + return res.json(sanitizeCredentials(credentials)); + }, + ], + + getCredentialType: [ + authorize(['owner', 'member']), + async (req: CredentialTypeRequest.Get, res: express.Response): Promise => { + const { credentialTypeName } = req.params; + + try { + CredentialTypes().getByName(credentialTypeName); + } catch (error) { + return res.status(404).json({ + message: 'Not Found', + }); + } + + let schema = new CredentialsHelper('').getCredentialsProperties(credentialTypeName); + + schema = schema.filter((nodeProperty) => nodeProperty.type !== 'hidden'); + + return res.json(toJsonSchema(schema)); + }, + ], +}; diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.middleware.ts b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.middleware.ts new file mode 100644 index 0000000000..db4c35cf82 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.middleware.ts @@ -0,0 +1,47 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable consistent-return */ +import { RequestHandler } from 'express'; +import { validate } from 'jsonschema'; +import { CredentialsHelper, CredentialTypes } from '../../../..'; +import { CredentialRequest } from '../../../types'; +import { toJsonSchema } from './credentials.service'; + +export const validCredentialType: RequestHandler = async ( + req: CredentialRequest.Create, + res, + next, +): Promise => { + const { type } = req.body; + try { + CredentialTypes().getByName(type); + } catch (error) { + return res.status(400).json({ + message: 'req.body.type is not a known type', + }); + } + next(); +}; + +export const validCredentialsProperties: RequestHandler = async ( + req: CredentialRequest.Create, + res, + next, +): Promise => { + const { type, data } = req.body; + + let properties = new CredentialsHelper('').getCredentialsProperties(type); + + properties = properties.filter((nodeProperty) => nodeProperty.type !== 'hidden'); + + const schema = toJsonSchema(properties); + + const { valid, errors } = validate(data, schema, { nestedErrors: true }); + + if (!valid) { + return res.status(400).json({ + message: errors.map((error) => `request.body.data ${error.message}`).join(','), + }); + } + + next(); +}; diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts new file mode 100644 index 0000000000..2239b89821 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts @@ -0,0 +1,249 @@ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable no-underscore-dangle */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +import { FindOneOptions } from 'typeorm'; +import { UserSettings, Credentials } from 'n8n-core'; +import { IDataObject, INodeProperties, INodePropertyOptions } from 'n8n-workflow'; +import { Db, ICredentialsDb } from '../../../..'; +import { CredentialsEntity } from '../../../../databases/entities/CredentialsEntity'; +import { SharedCredentials } from '../../../../databases/entities/SharedCredentials'; +import { User } from '../../../../databases/entities/User'; +import { externalHooks } from '../../../../Server'; +import { IDependency, IJsonSchema } from '../../../types'; + +export async function getCredentials( + credentialId: number | string, +): Promise { + return Db.collections.Credentials.findOne(credentialId); +} + +export async function getSharedCredentials( + userId: string, + credentialId: number | string, + relations?: string[], +): Promise { + const options: FindOneOptions = { + where: { + user: { id: userId }, + credentials: { id: credentialId }, + }, + }; + + if (relations) { + options.relations = relations; + } + + return Db.collections.SharedCredentials.findOne(options); +} + +export async function createCredential( + properties: Partial, +): Promise { + const newCredential = new CredentialsEntity(); + + Object.assign(newCredential, properties); + + if (!newCredential.nodesAccess || newCredential.nodesAccess.length === 0) { + newCredential.nodesAccess = [ + { + nodeType: `n8n-nodes-base.${properties.type?.toLowerCase() ?? 'unknown'}`, + date: new Date(), + }, + ]; + } else { + // Add the added date for node access permissions + newCredential.nodesAccess.forEach((nodeAccess) => { + // eslint-disable-next-line no-param-reassign + nodeAccess.date = new Date(); + }); + } + + return newCredential; +} + +export async function saveCredential( + credential: CredentialsEntity, + user: User, + encryptedData: ICredentialsDb, +): Promise { + const role = await Db.collections.Role.findOneOrFail({ + name: 'owner', + scope: 'credential', + }); + + await externalHooks.run('credentials.create', [encryptedData]); + + return Db.transaction(async (transactionManager) => { + const savedCredential = await transactionManager.save(credential); + + savedCredential.data = credential.data; + + const newSharedCredential = new SharedCredentials(); + + Object.assign(newSharedCredential, { + role, + user, + credentials: savedCredential, + }); + + await transactionManager.save(newSharedCredential); + + return savedCredential; + }); +} + +export async function removeCredential(credentials: CredentialsEntity): Promise { + await externalHooks.run('credentials.delete', [credentials.id]); + return Db.collections.Credentials.remove(credentials); +} + +export async function encryptCredential(credential: CredentialsEntity): Promise { + const encryptionKey = await UserSettings.getEncryptionKey(); + + // Encrypt the data + const coreCredential = new Credentials( + { id: null, name: credential.name }, + credential.type, + credential.nodesAccess, + ); + + // @ts-ignore + coreCredential.setData(credential.data, encryptionKey); + + return coreCredential.getDataToSave() as ICredentialsDb; +} + +export function sanitizeCredentials(credentials: CredentialsEntity): Partial; +export function sanitizeCredentials( + credentials: CredentialsEntity[], +): Array>; + +export function sanitizeCredentials( + credentials: CredentialsEntity | CredentialsEntity[], +): Partial | Array> { + const argIsArray = Array.isArray(credentials); + const credentialsList = argIsArray ? credentials : [credentials]; + + const sanitizedCredentials = credentialsList.map((credential) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { data, nodesAccess, shared, ...rest } = credential; + return rest; + }); + + return argIsArray ? sanitizedCredentials : sanitizedCredentials[0]; +} + +/** + * toJsonSchema + * Take an array of crendentials parameter and map it + * to a JSON Schema (see https://json-schema.org/). With + * the JSON Schema defintion we can validate the credential's shape + * @param properties - Credentials properties + * @returns The credentials schema definition. + */ +export function toJsonSchema(properties: INodeProperties[]): IDataObject { + const jsonSchema: IJsonSchema = { + additionalProperties: false, + type: 'object', + properties: {}, + allOf: [], + required: [], + }; + + const optionsValues: { [key: string]: string[] } = {}; + const resolveProperties: string[] = []; + + // get all posible values of properties type "options" + // so we can later resolve the displayOptions dependencies + properties + .filter((property) => property.type === 'options') + .forEach((property) => { + Object.assign(optionsValues, { + [property.name]: property.options?.map((option: INodePropertyOptions) => option.value), + }); + }); + + let requiredFields: string[] = []; + + const propertyRequiredDependencies: { [key: string]: IDependency } = {}; + + // add all credential's properties to the properties + // object in the JSON Schema definition. This allows us + // to later validate that only this properties are set in + // the credentials sent in the API call. + properties.forEach((property) => { + requiredFields.push(property.name); + if (property.type === 'options') { + // if the property is type options, + // include all possible values in the anum property. + Object.assign(jsonSchema.properties, { + [property.name]: { + type: 'string', + enum: property.options?.map((data: INodePropertyOptions) => data.value), + }, + }); + } else { + Object.assign(jsonSchema.properties, { + [property.name]: { + type: property.type, + }, + }); + } + + // if the credential property has a dependency + // then add a JSON Schema condition that satisfy each property value + // e.x: If A has value X then required B, else required C + // see https://json-schema.org/understanding-json-schema/reference/conditionals.html#if-then-else + if (property.displayOptions?.show) { + const dependantName = Object.keys(property.displayOptions?.show)[0] || ''; + const displayOptionsValues = property.displayOptions.show[dependantName]; + let dependantValue: string | number | boolean = ''; + + if (displayOptionsValues && Array.isArray(displayOptionsValues) && displayOptionsValues[0]) { + // eslint-disable-next-line prefer-destructuring + dependantValue = displayOptionsValues[0]; + } + + if (propertyRequiredDependencies[dependantName] === undefined) { + propertyRequiredDependencies[dependantName] = {}; + } + + if (!resolveProperties.includes(dependantName)) { + propertyRequiredDependencies[dependantName] = { + if: { + properties: { + [dependantName]: { + enum: [dependantValue], + }, + }, + }, + then: { + oneOf: [], + }, + else: { + allOf: [], + }, + }; + } + + propertyRequiredDependencies[dependantName].then?.oneOf.push({ required: [property.name] }); + propertyRequiredDependencies[dependantName].else?.allOf.push({ + not: { required: [property.name] }, + }); + + resolveProperties.push(dependantName); + // remove global required + requiredFields = requiredFields.filter((field) => field !== property.name); + } + }); + Object.assign(jsonSchema, { required: requiredFields }); + + jsonSchema.allOf = Object.values(propertyRequiredDependencies); + + if (!jsonSchema.allOf.length) { + delete jsonSchema.allOf; + } + + return jsonSchema as unknown as IDataObject; +} diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/spec/paths/credentials.id.yml b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/paths/credentials.id.yml new file mode 100644 index 0000000000..b80f81ac02 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/paths/credentials.id.yml @@ -0,0 +1,26 @@ +delete: + x-eov-operation-id: deleteCredential + x-eov-operation-handler: v1/handlers/credentials/credentials.handler + tags: + - Credential + summary: Delete credential by ID + description: Deletes a credential from your instance. You must be the owner of the credentials + operationId: deleteCredential + parameters: + - name: id + in: path + description: The credential ID that needs to be deleted + required: true + schema: + type: number + responses: + '200': + description: Operation successful. + content: + application/json: + schema: + $ref: '../schemas/credential.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/spec/paths/credentials.schema.id.yml b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/paths/credentials.schema.id.yml new file mode 100644 index 0000000000..923d3c0ce1 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/paths/credentials.schema.id.yml @@ -0,0 +1,37 @@ +get: + x-eov-operation-id: getCredentialType + x-eov-operation-handler: v1/handlers/credentials/credentials.handler + tags: + - Credential + summary: Show credential data schema + parameters: + - name: credentialTypeName + in: path + description: The credential type name that you want to get the schema for + required: true + schema: + type: string + responses: + '200': + description: Operation successful. + content: + application/json: + schema: + type: object + examples: + freshdeskApi: + value: + additionalProperties: false + type: 'object' + properties: { apiKey: { type: 'string' }, domain: { type: 'string' } } + required: ['apiKey', 'domain'] + slackOAuth2Api: + value: + additionalProperties: false + type: 'object' + properties: { clientId: { type: 'string' }, clientSecret: { type: 'string' } } + required: ['clientId', 'clientSecret'] + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/spec/paths/credentials.yml b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/paths/credentials.yml new file mode 100644 index 0000000000..eab345f572 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/paths/credentials.yml @@ -0,0 +1,25 @@ +post: + x-eov-operation-id: createCredential + x-eov-operation-handler: v1/handlers/credentials/credentials.handler + tags: + - Credential + summary: Create a credential + description: Creates a credential that can be used by nodes of the specified type. + requestBody: + description: Credential to be created. + required: true + content: + application/json: + schema: + $ref: '../schemas/credential.yml' + responses: + '200': + description: Operation successful. + content: + application/json: + schema: + $ref: '../schemas/credential.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '415': + description: Unsupported media type. diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/spec/schemas/credential.yml b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/schemas/credential.yml new file mode 100644 index 0000000000..9c66817475 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/schemas/credential.yml @@ -0,0 +1,30 @@ +required: + - name + - type + - data +type: object +properties: + id: + type: number + readOnly: true + example: 42 + name: + type: string + example: Joe's Github Credentials + type: + type: string + example: github + data: + type: object + writeOnly: true + example: { token: 'ada612vad6fa5df4adf5a5dsf4389adsf76da7s' } + createdAt: + type: string + format: date-time + readOnly: true + example: '2022-04-29T11:02:29.842Z' + updatedAt: + type: string + format: date-time + readOnly: true + example: '2022-04-29T11:02:29.842Z' diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/spec/schemas/credentialType.yml b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/schemas/credentialType.yml new file mode 100644 index 0000000000..8ccf3bde8d --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/schemas/credentialType.yml @@ -0,0 +1,18 @@ +type: object +properties: + displayName: + type: string + readOnly: true + example: Email + name: + type: string + readOnly: true + example: email + type: + type: string + readOnly: true + example: string + default: + type: string + readOnly: true + example: string diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts b/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts new file mode 100644 index 0000000000..c8f98b8b1a --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts @@ -0,0 +1,154 @@ +import express = require('express'); + +import { BinaryDataManager } from 'n8n-core'; +import { + getExecutions, + getExecutionInWorkflows, + deleteExecution, + getExecutionsCount, +} from './executions.service'; + +import { ActiveExecutions } from '../../../..'; +import { authorize, validCursor } from '../../shared/middlewares/global.middleware'; + +import { ExecutionRequest } from '../../../types'; +import { getSharedWorkflowIds } from '../workflows/workflows.service'; +import { encodeNextCursor } from '../../shared/services/pagination.service'; +import { InternalHooksManager } from '../../../../InternalHooksManager'; + +export = { + deleteExecution: [ + authorize(['owner', 'member']), + async (req: ExecutionRequest.Delete, res: express.Response): Promise => { + const { id } = req.params; + + const sharedWorkflowsIds = await getSharedWorkflowIds(req.user); + + // user does not have workflows hence no executions + // or the execution he is trying to access belongs to a workflow he does not own + if (!sharedWorkflowsIds.length) { + return res.status(404).json({ + message: 'Not Found', + }); + } + + // look for the execution on the workflow the user owns + const execution = await getExecutionInWorkflows(id, sharedWorkflowsIds, false); + + // execution was not found + if (!execution) { + return res.status(404).json({ + message: 'Not Found', + }); + } + + const binaryDataManager = BinaryDataManager.getInstance(); + + await binaryDataManager.deleteBinaryDataByExecutionId(execution.id.toString()); + + await deleteExecution(execution); + + execution.id = id; + + return res.json(execution); + }, + ], + getExecution: [ + authorize(['owner', 'member']), + async (req: ExecutionRequest.Get, res: express.Response): Promise => { + const { id } = req.params; + const { includeData = false } = req.query; + + const sharedWorkflowsIds = await getSharedWorkflowIds(req.user); + + // user does not have workflows hence no executions + // or the execution he is trying to access belongs to a workflow he does not own + if (!sharedWorkflowsIds.length) { + return res.status(404).json({ + message: 'Not Found', + }); + } + + // look for the execution on the workflow the user owns + const execution = await getExecutionInWorkflows(id, sharedWorkflowsIds, includeData); + + // execution was not found + if (!execution) { + return res.status(404).json({ + message: 'Not Found', + }); + } + + const telemetryData = { + user_id: req.user.id, + public_api: true, + }; + + void InternalHooksManager.getInstance().onUserRetrievedExecution(telemetryData); + + return res.json(execution); + }, + ], + getExecutions: [ + authorize(['owner', 'member']), + validCursor, + async (req: ExecutionRequest.GetAll, res: express.Response): Promise => { + const { + lastId = undefined, + limit = 100, + status = undefined, + includeData = false, + workflowId = undefined, + } = req.query; + + const sharedWorkflowsIds = await getSharedWorkflowIds(req.user); + + // user does not have workflows hence no executions + // or the execution he is trying to access belongs to a workflow he does not own + if (!sharedWorkflowsIds.length) { + return res.status(200).json({ + data: [], + nextCursor: null, + }); + } + + // get running workflows so we exclude them from the result + const runningExecutionsIds = ActiveExecutions.getInstance() + .getActiveExecutions() + .map(({ id }) => Number(id)); + + const filters = { + status, + limit, + lastId, + includeData, + ...(workflowId && { workflowIds: [workflowId] }), + excludedExecutionsIds: runningExecutionsIds, + }; + + const executions = await getExecutions(filters); + + const newLastId = !executions.length ? 0 : (executions.slice(-1)[0].id as number); + + filters.lastId = newLastId; + + const count = await getExecutionsCount(filters); + + const telemetryData = { + user_id: req.user.id, + public_api: true, + }; + + void InternalHooksManager.getInstance().onUserRetrievedAllExecutions(telemetryData); + + return res.json({ + data: executions, + nextCursor: encodeNextCursor({ + lastId: newLastId, + limit, + numberOfNextRecords: count, + }), + }); + }, + ], +}; diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/executions.service.ts b/packages/cli/src/PublicApi/v1/handlers/executions/executions.service.ts new file mode 100644 index 0000000000..8cdac1d6cc --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/executions/executions.service.ts @@ -0,0 +1,115 @@ +import { parse } from 'flatted'; +import { In, Not, ObjectLiteral, LessThan, IsNull } from 'typeorm'; +import { Db, IExecutionFlattedDb, IExecutionResponseApi } from '../../../..'; +import { ExecutionStatus } from '../../../types'; + +function prepareExecutionData( + execution: IExecutionFlattedDb | undefined, +): IExecutionResponseApi | undefined { + if (execution === undefined) { + return undefined; + } + + if (!execution.data) { + return execution; + } + + return { + ...execution, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + data: parse(execution.data), + }; +} + +function getStatusCondition(status: ExecutionStatus): ObjectLiteral { + const condition: ObjectLiteral = {}; + + if (status === 'success') { + condition.finished = true; + } else if (status === 'waiting') { + condition.waitTill = Not(IsNull()); + } else if (status === 'error') { + condition.stoppedAt = Not(IsNull()); + condition.finished = false; + } + return condition; +} + +function getExecutionSelectableProperties(includeData?: boolean): Array { + const returnData: Array = [ + 'id', + 'mode', + 'retryOf', + 'retrySuccessId', + 'startedAt', + 'stoppedAt', + 'workflowId', + 'waitTill', + 'finished', + ]; + if (includeData) { + returnData.push('data'); + } + return returnData; +} + +export async function getExecutions(data: { + limit: number; + includeData?: boolean; + lastId?: number; + workflowIds?: number[]; + status?: ExecutionStatus; + excludedExecutionsIds?: number[]; +}): Promise { + const executions = await Db.collections.Execution.find({ + select: getExecutionSelectableProperties(data.includeData), + where: { + ...(data.lastId && { id: LessThan(data.lastId) }), + ...(data.status && { ...getStatusCondition(data.status) }), + ...(data.workflowIds && { workflowId: In(data.workflowIds.map(String)) }), + ...(data.excludedExecutionsIds && { id: Not(In(data.excludedExecutionsIds)) }), + }, + order: { id: 'DESC' }, + take: data.limit, + }); + + return executions.map((execution) => prepareExecutionData(execution)) as IExecutionResponseApi[]; +} + +export async function getExecutionsCount(data: { + limit: number; + lastId?: number; + workflowIds?: number[]; + status?: ExecutionStatus; + excludedWorkflowIds?: number[]; +}): Promise { + const executions = await Db.collections.Execution.count({ + where: { + ...(data.lastId && { id: LessThan(data.lastId) }), + ...(data.status && { ...getStatusCondition(data.status) }), + ...(data.workflowIds && { workflowId: In(data.workflowIds) }), + ...(data.excludedWorkflowIds && { workflowId: Not(In(data.excludedWorkflowIds)) }), + }, + take: data.limit, + }); + return executions; +} + +export async function getExecutionInWorkflows( + id: number, + workflows: number[], + includeData?: boolean, +): Promise { + const execution = await Db.collections.Execution.findOne({ + select: getExecutionSelectableProperties(includeData), + where: { + id, + workflowId: In(workflows), + }, + }); + return prepareExecutionData(execution); +} + +export async function deleteExecution(execution: IExecutionResponseApi | undefined): Promise { + await Db.collections.Execution.remove(execution as IExecutionFlattedDb); +} diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/spec/paths/executions.id.yml b/packages/cli/src/PublicApi/v1/handlers/executions/spec/paths/executions.id.yml new file mode 100644 index 0000000000..2199d683c3 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/executions/spec/paths/executions.id.yml @@ -0,0 +1,41 @@ +get: + x-eov-operation-id: getExecution + x-eov-operation-handler: v1/handlers/executions/executions.handler + tags: + - Execution + summary: Retrieve an execution + description: Retrieve an execution from you instance. + parameters: + - $ref: '../schemas/parameters/executionId.yml' + - $ref: '../schemas/parameters/includeData.yml' + responses: + '200': + description: Operation successful. + content: + application/json: + schema: + $ref: '../schemas/execution.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' +delete: + x-eov-operation-id: deleteExecution + x-eov-operation-handler: v1/handlers/executions/executions.handler + tags: + - Execution + summary: Delete an execution + description: Deletes an execution from your instance. + parameters: + - $ref: '../schemas/parameters/executionId.yml' + responses: + '200': + description: Operation successful. + content: + application/json: + schema: + $ref: '../schemas/execution.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/spec/paths/executions.yml b/packages/cli/src/PublicApi/v1/handlers/executions/spec/paths/executions.yml new file mode 100644 index 0000000000..0f95e82881 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/executions/spec/paths/executions.yml @@ -0,0 +1,36 @@ +get: + x-eov-operation-id: getExecutions + x-eov-operation-handler: v1/handlers/executions/executions.handler + tags: + - Execution + summary: Retrieve all executions + description: Retrieve all executions from your instance. + parameters: + - $ref: '../schemas/parameters/includeData.yml' + - name: status + in: query + description: Status to filter the executions by. + required: false + schema: + type: string + enum: ['error', 'success', 'waiting'] + - name: workflowId + in: query + description: Workflow to filter the executions by. + required: false + schema: + type: number + example: 1000 + - $ref: '../../../../shared/spec/parameters/limit.yml' + - $ref: '../../../../shared/spec/parameters/cursor.yml' + responses: + '200': + description: Operation successful. + content: + application/json: + schema: + $ref: '../schemas/executionList.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/spec/schemas/execution.yml b/packages/cli/src/PublicApi/v1/handlers/executions/spec/schemas/execution.yml new file mode 100644 index 0000000000..883baa9fa7 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/executions/spec/schemas/execution.yml @@ -0,0 +1,33 @@ +type: object +properties: + id: + type: number + example: 1000 + data: + type: object + finished: + type: boolean + example: true + mode: + type: string + enum: ['cli', 'error', 'integrated', 'internal', 'manual', 'retry', 'trigger', 'webhook'] + retryOf: + type: string + nullable: true + retrySuccessId: + type: string + nullable: true + example: 2 + startedAt: + type: string + format: date-time + stoppedAt: + type: string + format: date-time + workflowId: + type: string + example: 1000 + waitTill: + type: string + nullable: true + format: date-time diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/spec/schemas/executionList.yml b/packages/cli/src/PublicApi/v1/handlers/executions/spec/schemas/executionList.yml new file mode 100644 index 0000000000..fb29dd40ff --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/executions/spec/schemas/executionList.yml @@ -0,0 +1,11 @@ +type: object +properties: + data: + type: array + items: + $ref: './execution.yml' + nextCursor: + type: string + description: Paginate through executions by setting the cursor parameter to a nextCursor attribute returned by a previous request. Default value fetches the first "page" of the collection. + nullable: true + example: MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/spec/schemas/parameters/executionId.yml b/packages/cli/src/PublicApi/v1/handlers/executions/spec/schemas/parameters/executionId.yml new file mode 100644 index 0000000000..6ec1bdf094 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/executions/spec/schemas/parameters/executionId.yml @@ -0,0 +1,6 @@ +name: id +in: path +description: The ID of the execution. +required: true +schema: + type: number diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/spec/schemas/parameters/includeData.yml b/packages/cli/src/PublicApi/v1/handlers/executions/spec/schemas/parameters/includeData.yml new file mode 100644 index 0000000000..f173504228 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/executions/spec/schemas/parameters/includeData.yml @@ -0,0 +1,6 @@ +name: includeData +in: query +description: Whether or not to include the execution's detailed data. +required: false +schema: + type: boolean diff --git a/packages/cli/src/PublicApi/v1/handlers/users/users.service.ts b/packages/cli/src/PublicApi/v1/handlers/users/users.service.ts new file mode 100644 index 0000000000..f4819ef004 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/users/users.service.ts @@ -0,0 +1,14 @@ +import { Db } from '../../../..'; +import { Role } from '../../../../databases/entities/Role'; +import { User } from '../../../../databases/entities/User'; + +export function isInstanceOwner(user: User): boolean { + return user.globalRole.name === 'owner'; +} + +export async function getWorkflowOwnerRole(): Promise { + return Db.collections.Role.findOneOrFail({ + name: 'owner', + scope: 'workflow', + }); +} diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.id.activate.yml b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.id.activate.yml new file mode 100644 index 0000000000..20c40a7451 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.id.activate.yml @@ -0,0 +1,20 @@ +post: + x-eov-operation-id: activateWorkflow + x-eov-operation-handler: v1/handlers/workflows/workflows.handler + tags: + - Workflow + summary: Activate a workflow + description: Active a workflow. + parameters: + - $ref: '../schemas/parameters/workflowId.yml' + responses: + '200': + description: Workflow object + content: + application/json: + schema: + $ref: '../schemas/workflow.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.id.deactivate.yml b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.id.deactivate.yml new file mode 100644 index 0000000000..12b76040a4 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.id.deactivate.yml @@ -0,0 +1,20 @@ +post: + x-eov-operation-id: deactivateWorkflow + x-eov-operation-handler: v1/handlers/workflows/workflows.handler + tags: + - Workflow + summary: Deactivate a workflow + description: Deactivate a workflow. + parameters: + - $ref: '../schemas/parameters/workflowId.yml' + responses: + '200': + description: Workflow object + content: + application/json: + schema: + $ref: '../schemas/workflow.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.id.yml b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.id.yml new file mode 100644 index 0000000000..37cad74c86 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.id.yml @@ -0,0 +1,69 @@ +get: + x-eov-operation-id: getWorkflow + x-eov-operation-handler: v1/handlers/workflows/workflows.handler + tags: + - Workflow + summary: Retrieves a workflow + description: Retrieves a workflow. + parameters: + - $ref: '../schemas/parameters/workflowId.yml' + responses: + '200': + description: Operation successful. + content: + application/json: + schema: + $ref: '../schemas/workflow.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' +delete: + x-eov-operation-id: deleteWorkflow + x-eov-operation-handler: v1/handlers/workflows/workflows.handler + tags: + - Workflow + summary: Delete a workflow + description: Deletes a workflow. + parameters: + - $ref: '../schemas/parameters/workflowId.yml' + responses: + '200': + description: Operation successful. + content: + application/json: + schema: + $ref: '../schemas/workflow.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' +put: + x-eov-operation-id: updateWorkflow + x-eov-operation-handler: v1/handlers/workflows/workflows.handler + tags: + - Workflow + summary: Update a workflow + description: Update a workflow. + parameters: + - $ref: '../schemas/parameters/workflowId.yml' + requestBody: + description: Updated workflow object. + content: + application/json: + schema: + $ref: '../schemas/workflow.yml' + required: true + responses: + '200': + description: Workflow object + content: + application/json: + schema: + $ref: '../schemas/workflow.yml' + '400': + $ref: '../../../../shared/spec/responses/badRequest.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.yml b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.yml new file mode 100644 index 0000000000..1ff9f0a70d --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.yml @@ -0,0 +1,57 @@ +post: + x-eov-operation-id: createWorkflow + x-eov-operation-handler: v1/handlers/workflows/workflows.handler + tags: + - Workflow + summary: Create a workflow + description: Create a workflow in your instance. + requestBody: + description: Created workflow object. + content: + application/json: + schema: + $ref: '../schemas/workflow.yml' + required: true + responses: + '200': + description: A workflow object + content: + application/json: + schema: + $ref: '../schemas/workflow.yml' + '400': + $ref: '../../../../shared/spec/responses/badRequest.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' +get: + x-eov-operation-id: getWorkflows + x-eov-operation-handler: v1/handlers/workflows/workflows.handler + tags: + - Workflow + summary: Retrieve all workflows + description: Retrieve all workflows from your instance. + parameters: + - name: active + in: query + schema: + type: boolean + example: true + - name: tags + in: query + required: false + explode: false + allowReserved: true + schema: + type: string + example: test,production + - $ref: '../../../../shared/spec/parameters/limit.yml' + - $ref: '../../../../shared/spec/parameters/cursor.yml' + responses: + '200': + description: Operation successful. + content: + application/json: + schema: + $ref: '../schemas/workflowList.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/node.yml b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/node.yml new file mode 100644 index 0000000000..55715dfc4c --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/node.yml @@ -0,0 +1,39 @@ +type: object +additionalProperties: false +properties: + name: + type: string + example: Jira + webhookId: + type: string + disabled: + type: boolean + notesInFlow: + type: boolean + notes: + type: string + type: + type: string + example: n8n-nodes-base.Jira + typeVersion: + type: number + example: 1 + position: + type: array + items: + type: number + example: [-100, 80] + parameters: + type: object + example: { additionalProperties: {} } + credentials: + type: object + example: { jiraSoftwareCloudApi: { id: "35", name: "jiraApi"} } + createdAt: + type: string + format: date-time + readOnly: true + updatedAt: + type: string + format: date-time + readOnly: true diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/parameters/workflowId.yml b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/parameters/workflowId.yml new file mode 100644 index 0000000000..6ee64fec8c --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/parameters/workflowId.yml @@ -0,0 +1,6 @@ + name: id + in: path + description: The ID of the workflow. + required: true + schema: + type: number diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/tag.yml b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/tag.yml new file mode 100644 index 0000000000..ff5b571504 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/tag.yml @@ -0,0 +1,16 @@ +type: object +properties: + id: + type: string + example: 12 + name: + type: string + example: Production + createdAt: + type: string + format: date-time + readOnly: true + updatedAt: + type: string + format: date-time + readOnly: true diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/workflow.yml b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/workflow.yml new file mode 100644 index 0000000000..c15ce8120b --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/workflow.yml @@ -0,0 +1,43 @@ +type: object +required: + - name + - nodes + - connections + - settings +properties: + id: + type: number + readOnly: true + example: 1 + name: + type: string + example: Workflow 1 + active: + type: boolean + readOnly: true + createdAt: + type: string + format: date-time + readOnly: true + updatedAt: + type: string + format: date-time + readOnly: true + nodes: + type: array + items: + $ref: './node.yml' + connections: + type: object + example: { main: [{ node: 'Jira', type: 'main', index: 0 }] } + settings: + $ref: './workflowSettings.yml' + staticData: + type: string + nullable: true + example: '{ iterationId: 2 }' + tags: + type: array + items: + $ref: './tag.yml' + readOnly: true diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/workflowList.yml b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/workflowList.yml new file mode 100644 index 0000000000..d5e455ee95 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/workflowList.yml @@ -0,0 +1,11 @@ +type: object +properties: + data: + type: array + items: + $ref: './workflow.yml' + nextCursor: + type: string + description: Paginate through workflows by setting the cursor parameter to a nextCursor attribute returned by a previous request. Default value fetches the first "page" of the collection. + nullable: true + example: MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/workflowSettings.yml b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/workflowSettings.yml new file mode 100644 index 0000000000..78b08b90a6 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/workflowSettings.yml @@ -0,0 +1,24 @@ +type: object +additionalProperties: false +properties: + saveExecutionProgress: + type: boolean + saveManualExecutions: + type: boolean + saveDataErrorExecution: + type: string + enum: ['all', 'none'] + saveDataSuccessExecution: + type: string + enum: ['all', 'none'] + executionTimeout: + type: number + example: 3600 + maxLength: 3600 + errorWorkflow: + type: string + example: 10 + description: The ID of the workflow that contains the error trigger node. + timezone: + type: string + example: America/New_York diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts new file mode 100644 index 0000000000..23ff02a7d4 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts @@ -0,0 +1,303 @@ +import express = require('express'); +import { FindManyOptions, In } from 'typeorm'; +import { ActiveWorkflowRunner, Db } from '../../../..'; +import config = require('../../../../../config'); +import { WorkflowEntity } from '../../../../databases/entities/WorkflowEntity'; +import { InternalHooksManager } from '../../../../InternalHooksManager'; +import { externalHooks } from '../../../../Server'; +import { replaceInvalidCredentials } from '../../../../WorkflowHelpers'; +import { WorkflowRequest } from '../../../types'; +import { authorize, validCursor } from '../../shared/middlewares/global.middleware'; +import { encodeNextCursor } from '../../shared/services/pagination.service'; +import { getWorkflowOwnerRole, isInstanceOwner } from '../users/users.service'; +import { + getWorkflowById, + getSharedWorkflow, + setWorkflowAsActive, + setWorkflowAsInactive, + updateWorkflow, + hasStartNode, + getStartNode, + getWorkflows, + getSharedWorkflows, + getWorkflowsCount, + createWorkflow, + getWorkflowIdsViaTags, + parseTagNames, +} from './workflows.service'; + +export = { + createWorkflow: [ + authorize(['owner', 'member']), + async (req: WorkflowRequest.Create, res: express.Response): Promise => { + let workflow = req.body; + + workflow.active = false; + + // if the workflow does not have a start node, add it. + if (!hasStartNode(workflow)) { + workflow.nodes.push(getStartNode()); + } + + const role = await getWorkflowOwnerRole(); + + await replaceInvalidCredentials(workflow); + + workflow = await createWorkflow(workflow, req.user, role); + + await externalHooks.run('workflow.afterCreate', [workflow]); + void InternalHooksManager.getInstance().onWorkflowCreated(req.user.id, workflow, true); + + return res.json(workflow); + }, + ], + deleteWorkflow: [ + authorize(['owner', 'member']), + async (req: WorkflowRequest.Get, res: express.Response): Promise => { + const { id } = req.params; + + const sharedWorkflow = await getSharedWorkflow(req.user, id.toString()); + + if (!sharedWorkflow) { + // user trying to access a workflow he does not own + // or workflow does not exist + return res.status(404).json({ + message: 'Not Found', + }); + } + + const workflowRunner = ActiveWorkflowRunner.getInstance(); + + if (sharedWorkflow.workflow.active) { + // deactivate before deleting + await workflowRunner.remove(id.toString()); + } + + await Db.collections.Workflow.delete(id); + + void InternalHooksManager.getInstance().onWorkflowDeleted(req.user.id, id.toString(), true); + await externalHooks.run('workflow.afterDelete', [id.toString()]); + + return res.json(sharedWorkflow.workflow); + }, + ], + getWorkflow: [ + authorize(['owner', 'member']), + async (req: WorkflowRequest.Get, res: express.Response): Promise => { + const { id } = req.params; + + const sharedWorkflow = await getSharedWorkflow(req.user, id.toString()); + + if (!sharedWorkflow) { + // user trying to access a workflow he does not own + // or workflow does not exist + return res.status(404).json({ + message: 'Not Found', + }); + } + + const telemetryData = { + user_id: req.user.id, + public_api: true, + }; + + void InternalHooksManager.getInstance().onUserRetrievedWorkflow(telemetryData); + + return res.json(sharedWorkflow.workflow); + }, + ], + getWorkflows: [ + authorize(['owner', 'member']), + validCursor, + async (req: WorkflowRequest.GetAll, res: express.Response): Promise => { + const { offset = 0, limit = 100, active = undefined, tags = undefined } = req.query; + + let workflows: WorkflowEntity[]; + let count: number; + + const query: FindManyOptions = { + skip: offset, + take: limit, + where: { + ...(active !== undefined && { active }), + }, + ...(!config.getEnv('workflowTagsDisabled') && { relations: ['tags'] }), + }; + + if (isInstanceOwner(req.user)) { + if (tags) { + const workflowIds = await getWorkflowIdsViaTags(parseTagNames(tags)); + Object.assign(query.where, { id: In(workflowIds) }); + } + + workflows = await getWorkflows(query); + + count = await getWorkflowsCount(query); + } else { + const options: { workflowIds?: number[] } = {}; + + if (tags) { + options.workflowIds = await getWorkflowIdsViaTags(parseTagNames(tags)); + } + + const sharedWorkflows = await getSharedWorkflows(req.user, options); + + if (!sharedWorkflows.length) { + return res.status(200).json({ + data: [], + nextCursor: null, + }); + } + + const workflowsIds = sharedWorkflows.map((shareWorkflow) => shareWorkflow.workflowId); + + Object.assign(query.where, { id: In(workflowsIds) }); + + workflows = await getWorkflows(query); + + count = await getWorkflowsCount(query); + } + + const telemetryData = { + user_id: req.user.id, + public_api: true, + }; + + void InternalHooksManager.getInstance().onUserRetrievedAllWorkflows(telemetryData); + + return res.json({ + data: workflows, + nextCursor: encodeNextCursor({ + offset, + limit, + numberOfTotalRecords: count, + }), + }); + }, + ], + updateWorkflow: [ + authorize(['owner', 'member']), + async (req: WorkflowRequest.Update, res: express.Response): Promise => { + const { id } = req.params; + const updateData = new WorkflowEntity(); + Object.assign(updateData, req.body); + + const sharedWorkflow = await getSharedWorkflow(req.user, id.toString()); + + if (!sharedWorkflow) { + // user trying to access a workflow he does not own + // or workflow does not exist + return res.status(404).json({ + message: 'Not Found', + }); + } + + // if the workflow does not have a start node, add it. + // else there is nothing you can do in IU + if (!hasStartNode(updateData)) { + updateData.nodes.push(getStartNode()); + } + + // check credentials for old format + await replaceInvalidCredentials(updateData); + + const workflowRunner = ActiveWorkflowRunner.getInstance(); + + if (sharedWorkflow.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 workflowRunner.remove(id.toString()); + } + + await updateWorkflow(sharedWorkflow.workflowId, updateData); + + if (sharedWorkflow.workflow.active) { + try { + await workflowRunner.add(sharedWorkflow.workflowId.toString(), 'update'); + } catch (error) { + // todo + // remove the type assertion + const errorObject = error as unknown as { message: string }; + return res.status(400).json({ error: errorObject.message }); + } + } + + const updatedWorkflow = await getWorkflowById(sharedWorkflow.workflowId); + + await externalHooks.run('workflow.afterUpdate', [updateData]); + void InternalHooksManager.getInstance().onWorkflowSaved(req.user.id, updateData, true); + + return res.json(updatedWorkflow); + }, + ], + activateWorkflow: [ + authorize(['owner', 'member']), + async (req: WorkflowRequest.Activate, res: express.Response): Promise => { + const { id } = req.params; + + const sharedWorkflow = await getSharedWorkflow(req.user, id.toString()); + + if (!sharedWorkflow) { + // user trying to access a workflow he does not own + // or workflow does not exist + return res.status(404).json({ + message: 'Not Found', + }); + } + + const workflowRunner = ActiveWorkflowRunner.getInstance(); + + if (!sharedWorkflow.workflow.active) { + try { + await workflowRunner.add(sharedWorkflow.workflowId.toString(), 'activate'); + } catch (error) { + // todo + // remove the type assertion + const errorObject = error as unknown as { message: string }; + return res.status(400).json({ error: errorObject.message }); + } + + // change the status to active in the DB + await setWorkflowAsActive(sharedWorkflow.workflow); + + sharedWorkflow.workflow.active = true; + + return res.json(sharedWorkflow.workflow); + } + + // nothing to do as the wokflow is already active + return res.json(sharedWorkflow.workflow); + }, + ], + deactivateWorkflow: [ + authorize(['owner', 'member']), + async (req: WorkflowRequest.Activate, res: express.Response): Promise => { + const { id } = req.params; + + const sharedWorkflow = await getSharedWorkflow(req.user, id.toString()); + + if (!sharedWorkflow) { + // user trying to access a workflow he does not own + // or workflow does not exist + return res.status(404).json({ + message: 'Not Found', + }); + } + + const workflowRunner = ActiveWorkflowRunner.getInstance(); + + if (sharedWorkflow.workflow.active) { + await workflowRunner.remove(sharedWorkflow.workflowId.toString()); + + await setWorkflowAsInactive(sharedWorkflow.workflow); + + sharedWorkflow.workflow.active = false; + + return res.json(sharedWorkflow.workflow); + } + + // nothing to do as the wokflow is already inactive + return res.json(sharedWorkflow.workflow); + }, + ], +}; diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts new file mode 100644 index 0000000000..2e62180a24 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts @@ -0,0 +1,147 @@ +import { intersection } from 'lodash'; +import type { INode } from 'n8n-workflow'; +import { FindManyOptions, In, UpdateResult } from 'typeorm'; +import { User } from '../../../../databases/entities/User'; +import { WorkflowEntity } from '../../../../databases/entities/WorkflowEntity'; +import { Db } from '../../../..'; +import { SharedWorkflow } from '../../../../databases/entities/SharedWorkflow'; +import { isInstanceOwner } from '../users/users.service'; +import { Role } from '../../../../databases/entities/Role'; + +export async function getSharedWorkflowIds(user: User): Promise { + const sharedWorkflows = await Db.collections.SharedWorkflow.find({ + where: { + user, + }, + }); + return sharedWorkflows.map((workflow) => workflow.workflowId); +} + +export async function getSharedWorkflow( + user: User, + workflowId?: string | undefined, +): Promise { + const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({ + where: { + ...(!isInstanceOwner(user) && { user }), + ...(workflowId && { workflow: { id: workflowId } }), + }, + relations: ['workflow'], + }); + return sharedWorkflow; +} + +export async function getSharedWorkflows( + user: User, + options: { + relations?: string[]; + workflowIds?: number[]; + }, +): Promise { + const sharedWorkflows = await Db.collections.SharedWorkflow.find({ + where: { + ...(!isInstanceOwner(user) && { user }), + ...(options.workflowIds && { workflow: { id: In(options.workflowIds) } }), + }, + ...(options.relations && { relations: options.relations }), + }); + return sharedWorkflows; +} + +export async function getWorkflowById(id: number): Promise { + const workflow = await Db.collections.Workflow.findOne({ + where: { + id, + }, + }); + return workflow; +} + +/** + * Returns the workflow IDs that have certain tags. + * Intersection! e.g. workflow needs to have all provided tags. + */ +export async function getWorkflowIdsViaTags(tags: string[]): Promise { + const dbTags = await Db.collections.Tag.find({ + where: { + name: In(tags), + }, + relations: ['workflows'], + }); + + const workflowIdsPerTag = dbTags.map((tag) => tag.workflows.map((workflow) => workflow.id)); + + return intersection(...workflowIdsPerTag); +} + +export async function createWorkflow( + workflow: WorkflowEntity, + user: User, + role: Role, +): Promise { + let savedWorkflow: unknown; + const newWorkflow = new WorkflowEntity(); + Object.assign(newWorkflow, workflow); + await Db.transaction(async (transactionManager) => { + savedWorkflow = await transactionManager.save(newWorkflow); + const newSharedWorkflow = new SharedWorkflow(); + Object.assign(newSharedWorkflow, { + role, + user, + workflow: savedWorkflow, + }); + await transactionManager.save(newSharedWorkflow); + }); + return savedWorkflow as WorkflowEntity; +} + +export async function setWorkflowAsActive(workflow: WorkflowEntity): Promise { + return Db.collections.Workflow.update(workflow.id, { active: true, updatedAt: new Date() }); +} + +export async function setWorkflowAsInactive(workflow: WorkflowEntity): Promise { + return Db.collections.Workflow.update(workflow.id, { active: false, updatedAt: new Date() }); +} + +export async function deleteWorkflow(workflow: WorkflowEntity): Promise { + return Db.collections.Workflow.remove(workflow); +} + +export async function getWorkflows( + options: FindManyOptions, +): Promise { + const workflows = await Db.collections.Workflow.find(options); + return workflows; +} + +export async function getWorkflowsCount(options: FindManyOptions): Promise { + const count = await Db.collections.Workflow.count(options); + return count; +} + +export async function updateWorkflow( + workflowId: number, + updateData: WorkflowEntity, +): Promise { + return Db.collections.Workflow.update(workflowId, updateData); +} + +export function hasStartNode(workflow: WorkflowEntity): boolean { + return !( + !workflow.nodes.length || !workflow.nodes.find((node) => node.type === 'n8n-nodes-base.start') + ); +} + +export function getStartNode(): INode { + return { + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }; +} + +export function parseTagNames(tags: string): string[] { + return tags.split(',').map((tag) => tag.trim()); +} diff --git a/packages/cli/src/PublicApi/v1/openapi.yml b/packages/cli/src/PublicApi/v1/openapi.yml new file mode 100644 index 0000000000..f0b12c5c55 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/openapi.yml @@ -0,0 +1,59 @@ +--- +openapi: 3.0.0 +info: + title: n8n Public API + description: n8n Public API + termsOfService: https://n8n.io/legal/terms + contact: + email: hello@n8n.io + license: + name: Sustainable Use License + url: https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md + version: 1.0.0 +externalDocs: + description: n8n API documentation + url: https://docs.n8n.io/api/ +servers: + - url: /api/v1 +tags: + - name: Execution + description: Operations about executions + - name: Workflow + description: Operations about workflows + - name: Credential + description: Operations about credentials + +paths: + /credentials: + $ref: './handlers/credentials/spec/paths/credentials.yml' + /credentials/{id}: + $ref: './handlers/credentials/spec/paths/credentials.id.yml' + /credentials/schema/{credentialTypeName}: + $ref: './handlers/credentials/spec/paths/credentials.schema.id.yml' + /executions: + $ref: './handlers/executions/spec/paths/executions.yml' + /executions/{id}: + $ref: './handlers/executions/spec/paths/executions.id.yml' + /workflows: + $ref: './handlers/workflows/spec/paths/workflows.yml' + /workflows/{id}: + $ref: './handlers/workflows/spec/paths/workflows.id.yml' + /workflows/{id}/activate: + $ref: './handlers/workflows/spec/paths/workflows.id.activate.yml' + /workflows/{id}/deactivate: + $ref: './handlers/workflows/spec/paths/workflows.id.deactivate.yml' +components: + schemas: + $ref: './shared/spec/schemas/_index.yml' + responses: + $ref: './shared/spec/responses/_index.yml' + parameters: + $ref: './shared/spec/parameters/_index.yml' + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-N8N-API-KEY + +security: + - ApiKeyAuth: [] diff --git a/packages/cli/src/PublicApi/v1/shared/middlewares/global.middleware.ts b/packages/cli/src/PublicApi/v1/shared/middlewares/global.middleware.ts new file mode 100644 index 0000000000..542e1c967c --- /dev/null +++ b/packages/cli/src/PublicApi/v1/shared/middlewares/global.middleware.ts @@ -0,0 +1,40 @@ +/* eslint-disable consistent-return */ +import { RequestHandler } from 'express'; +import { PaginatatedRequest } from '../../../types'; +import { decodeCursor } from '../services/pagination.service'; + +type Role = 'member' | 'owner'; + +export const authorize: (role: Role[]) => RequestHandler = (role: Role[]) => (req, res, next) => { + const { + globalRole: { name: userRole }, + } = req.user as { globalRole: { name: Role } }; + if (role.includes(userRole)) { + return next(); + } + return res.status(403).json({ + message: 'Forbidden', + }); +}; + +// @ts-ignore +export const validCursor: RequestHandler = (req: PaginatatedRequest, res, next) => { + if (req.query.cursor) { + const { cursor } = req.query; + try { + const paginationData = decodeCursor(cursor); + if ('offset' in paginationData) { + req.query.offset = paginationData.offset; + req.query.limit = paginationData.limit; + } else { + req.query.lastId = paginationData.lastId; + req.query.limit = paginationData.limit; + } + } catch (error) { + return res.status(400).json({ + message: 'An invalid cursor was provided', + }); + } + } + next(); +}; diff --git a/packages/cli/src/PublicApi/v1/shared/services/pagination.service.ts b/packages/cli/src/PublicApi/v1/shared/services/pagination.service.ts new file mode 100644 index 0000000000..519ee1984d --- /dev/null +++ b/packages/cli/src/PublicApi/v1/shared/services/pagination.service.ts @@ -0,0 +1,46 @@ +import { + CursorPagination, + OffsetPagination, + PaginationCursorDecoded, + PaginationOffsetDecoded, +} from '../../../types'; + +export const decodeCursor = (cursor: string): PaginationOffsetDecoded | PaginationCursorDecoded => { + return JSON.parse(Buffer.from(cursor, 'base64').toString()) as + | PaginationCursorDecoded + | PaginationOffsetDecoded; +}; + +const encodeOffSetPagination = (pagination: OffsetPagination): string | null => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (pagination.numberOfTotalRecords > pagination.offset + pagination.limit) { + return Buffer.from( + JSON.stringify({ + limit: pagination.limit, + offset: pagination.offset + pagination.limit, + }), + ).toString('base64'); + } + return null; +}; + +const encodeCursorPagination = (pagination: CursorPagination): string | null => { + if (pagination.numberOfNextRecords) { + return Buffer.from( + JSON.stringify({ + lastId: pagination.lastId, + limit: pagination.limit, + }), + ).toString('base64'); + } + return null; +}; + +export const encodeNextCursor = ( + pagination: OffsetPagination | CursorPagination, +): string | null => { + if ('offset' in pagination) { + return encodeOffSetPagination(pagination); + } + return encodeCursorPagination(pagination); +}; diff --git a/packages/cli/src/PublicApi/v1/shared/spec/parameters/_index.yml b/packages/cli/src/PublicApi/v1/shared/spec/parameters/_index.yml new file mode 100644 index 0000000000..b994cdc872 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/shared/spec/parameters/_index.yml @@ -0,0 +1,10 @@ +Cursor: + $ref: './cursor.yml' +Limit: + $ref: './limit.yml' +ExecutionId: + $ref: '../../../handlers/executions/spec/schemas/parameters/executionId.yml' +WorkflowId: + $ref: '../../../handlers/workflows/spec/schemas/parameters/workflowId.yml' +IncludeData: + $ref: '../../../handlers/executions/spec/schemas/parameters/includeData.yml' diff --git a/packages/cli/src/PublicApi/v1/shared/spec/parameters/cursor.yml b/packages/cli/src/PublicApi/v1/shared/spec/parameters/cursor.yml new file mode 100644 index 0000000000..a7fcc012a3 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/shared/spec/parameters/cursor.yml @@ -0,0 +1,7 @@ +name: cursor +in: query +description: Paginate through users by setting the cursor parameter to a nextCursor attribute returned by a previous request's response. Default value fetches the first "page" of the collection. See pagination for more detail. +required: false +style: form +schema: + type: string diff --git a/packages/cli/src/PublicApi/v1/shared/spec/parameters/limit.yml b/packages/cli/src/PublicApi/v1/shared/spec/parameters/limit.yml new file mode 100644 index 0000000000..2f6a135e54 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/shared/spec/parameters/limit.yml @@ -0,0 +1,9 @@ +name: limit +in: query +description: The maximum number of items to return. +required: false +schema: + type: number + example: 100 + default: 100 + maximum: 250 diff --git a/packages/cli/src/PublicApi/v1/shared/spec/responses/_index.yml b/packages/cli/src/PublicApi/v1/shared/spec/responses/_index.yml new file mode 100644 index 0000000000..1d02bb8156 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/shared/spec/responses/_index.yml @@ -0,0 +1,6 @@ +NotFound: + $ref: './notFound.yml' +Unauthorized: + $ref: './unauthorized.yml' +BadRequest: + $ref: './badRequest.yml' diff --git a/packages/cli/src/PublicApi/v1/shared/spec/responses/badRequest.yml b/packages/cli/src/PublicApi/v1/shared/spec/responses/badRequest.yml new file mode 100644 index 0000000000..2ada886abb --- /dev/null +++ b/packages/cli/src/PublicApi/v1/shared/spec/responses/badRequest.yml @@ -0,0 +1 @@ +description: The request is invalid or provides malformed data. diff --git a/packages/cli/src/PublicApi/v1/shared/spec/responses/notFound.yml b/packages/cli/src/PublicApi/v1/shared/spec/responses/notFound.yml new file mode 100644 index 0000000000..610f03a5f2 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/shared/spec/responses/notFound.yml @@ -0,0 +1 @@ +description: The specified resource was not found. diff --git a/packages/cli/src/PublicApi/v1/shared/spec/responses/unauthorized.yml b/packages/cli/src/PublicApi/v1/shared/spec/responses/unauthorized.yml new file mode 100644 index 0000000000..be59626c91 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/shared/spec/responses/unauthorized.yml @@ -0,0 +1 @@ +description: Unauthorized diff --git a/packages/cli/src/PublicApi/v1/shared/spec/schemas/_index.yml b/packages/cli/src/PublicApi/v1/shared/spec/schemas/_index.yml new file mode 100644 index 0000000000..a8b150a5ff --- /dev/null +++ b/packages/cli/src/PublicApi/v1/shared/spec/schemas/_index.yml @@ -0,0 +1,20 @@ +Error: + $ref: './error.yml' +Execution: + $ref: './../../../handlers/executions/spec/schemas/execution.yml' +Node: + $ref: './../../../handlers/workflows/spec/schemas/node.yml' +Tag: + $ref: './../../../handlers/workflows/spec/schemas/tag.yml' +Workflow: + $ref: './../../../handlers/workflows/spec/schemas/workflow.yml' +WorkflowSettings: + $ref: './../../../handlers/workflows/spec/schemas/workflowSettings.yml' +ExecutionList: + $ref: './../../../handlers/executions/spec/schemas/executionList.yml' +WorkflowList: + $ref: './../../../handlers/workflows/spec/schemas/workflowList.yml' +Credential: + $ref: './../../../handlers/credentials/spec/schemas/credential.yml' +CredentialType: + $ref: './../../../handlers/credentials/spec/schemas/credentialType.yml' diff --git a/packages/cli/src/PublicApi/v1/shared/spec/schemas/error.yml b/packages/cli/src/PublicApi/v1/shared/spec/schemas/error.yml new file mode 100644 index 0000000000..4d5507dfbd --- /dev/null +++ b/packages/cli/src/PublicApi/v1/shared/spec/schemas/error.yml @@ -0,0 +1,10 @@ +required: + - message +type: object +properties: + code: + type: string + message: + type: string + description: + type: string diff --git a/packages/cli/src/ResponseHelper.ts b/packages/cli/src/ResponseHelper.ts index 3eae444995..0b427e6a46 100644 --- a/packages/cli/src/ResponseHelper.ts +++ b/packages/cli/src/ResponseHelper.ts @@ -130,7 +130,6 @@ export function sendErrorResponse(res: Response, error: ResponseError) { // @ts-ignore response.stack = error.stack; } - res.status(httpStatusCode).json(response); } @@ -147,12 +146,13 @@ const isUniqueConstraintError = (error: Error) => * @returns */ -export function send(processFunction: (req: Request, res: Response) => Promise) { +export function send(processFunction: (req: Request, res: Response) => Promise, raw = false) { + // eslint-disable-next-line consistent-return return async (req: Request, res: Response) => { try { const data = await processFunction(req, res); - sendSuccessResponse(res, data); + sendSuccessResponse(res, data, raw); } catch (error) { if (error instanceof Error && isUniqueConstraintError(error)) { error.message = 'There is already an entry with this name'; diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 82620691fc..ef0f3d8005 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -53,7 +53,7 @@ import clientOAuth2 from 'client-oauth2'; import clientOAuth1, { RequestOptions } from 'oauth-1.0a'; import csrf from 'csrf'; import requestPromise, { OptionsWithUrl } from 'request-promise-native'; -import { createHmac } from 'crypto'; +import { createHmac, randomBytes } from 'crypto'; // IMPORTANT! Do not switch to anther bcrypt library unless really necessary and // tested with all possible systems like Windows, Alpine on ARM, FreeBSD, ... import { compare } from 'bcryptjs'; @@ -102,6 +102,7 @@ import { ICredentialsDb, ICredentialsOverwrite, ICustomRequest, + IDiagnosticInfo, IExecutionFlattedDb, IExecutionFlattedResponse, IExecutionPushResponse, @@ -110,7 +111,6 @@ import { IExecutionsStopData, IExecutionsSummary, IExternalHooksClass, - IDiagnosticInfo, IN8nUISettings, IPackageVersions, ITagWithCountDb, @@ -146,11 +146,13 @@ import { userManagementRouter } from './UserManagement'; import { resolveJwt } from './UserManagement/auth/jwt'; import { User } from './databases/entities/User'; import type { + AuthenticatedRequest, + CredentialRequest, ExecutionRequest, - WorkflowRequest, NodeParameterOptionsRequest, OAuthRequest, TagsRequest, + WorkflowRequest, } from './requests'; import { DEFAULT_EXECUTIONS_GET_ALL_LIMIT, validateEntity } from './GenericHelpers'; import { ExecutionEntity } from './databases/entities/ExecutionEntity'; @@ -162,6 +164,7 @@ import { isEmailSetUp, isUserManagementEnabled, } from './UserManagement/UserManagementHelper'; +import { loadPublicApiVersions } from './PublicApi'; require('body-parser-xml')(bodyParser); @@ -210,6 +213,8 @@ class App { restEndpoint: string; + publicApiEndpoint: string; + frontendSettings: IN8nUISettings; protocol: string; @@ -234,14 +239,15 @@ class App { this.defaultWorkflowName = config.getEnv('workflows.defaultName'); this.defaultCredentialsName = config.getEnv('credentials.defaultName'); - this.saveDataErrorExecution = config.getEnv('executions.saveDataOnError'); - this.saveDataSuccessExecution = config.getEnv('executions.saveDataOnSuccess'); - this.saveManualExecutions = config.getEnv('executions.saveDataManualExecutions'); - this.executionTimeout = config.getEnv('executions.timeout'); - this.maxExecutionTimeout = config.getEnv('executions.maxTimeout'); - this.payloadSizeMax = config.getEnv('endpoints.payloadSizeMax'); - this.timezone = config.getEnv('generic.timezone'); - this.restEndpoint = config.getEnv('endpoints.rest'); + this.saveDataErrorExecution = config.get('executions.saveDataOnError'); + this.saveDataSuccessExecution = config.get('executions.saveDataOnSuccess'); + this.saveManualExecutions = config.get('executions.saveDataManualExecutions'); + this.executionTimeout = config.get('executions.timeout'); + this.maxExecutionTimeout = config.get('executions.maxTimeout'); + this.payloadSizeMax = config.get('endpoints.payloadSizeMax'); + this.timezone = config.get('generic.timezone'); + this.restEndpoint = config.get('endpoints.rest'); + this.publicApiEndpoint = config.get('publicApi.path'); this.activeWorkflowRunner = ActiveWorkflowRunner.getInstance(); this.testWebhooks = TestWebhooks.getInstance(); @@ -310,6 +316,11 @@ class App { config.getEnv('userManagement.skipInstanceOwnerSetup') === false, smtpSetup: isEmailSetUp(), }, + publicApi: { + enabled: config.getEnv('publicApi.disabled') === false, + latestVersion: 1, + path: config.getEnv('publicApi.path'), + }, workflowTagsDisabled: config.getEnv('workflowTagsDisabled'), logLevel: config.getEnv('logs.level'), hiringBannerEnabled: config.getEnv('hiringBanner.enabled'), @@ -373,6 +384,9 @@ class App { this.endpointWebhookTest, this.endpointPresetCredentials, ]; + if (!config.getEnv('publicApi.disabled')) { + ignoredEndpoints.push(this.publicApiEndpoint); + } // eslint-disable-next-line prefer-spread ignoredEndpoints.push.apply(ignoredEndpoints, excludeEndpoints.split(':')); @@ -484,8 +498,9 @@ class App { // eslint-disable-next-line no-inner-declarations function isTenantAllowed(decodedToken: object): boolean { - if (jwtNamespace === '' || jwtAllowedTenantKey === '' || jwtAllowedTenant === '') + if (jwtNamespace === '' || jwtAllowedTenantKey === '' || jwtAllowedTenant === '') { return true; + } for (const [k, v] of Object.entries(decodedToken)) { if (k === jwtNamespace) { @@ -543,6 +558,15 @@ class App { }); } + // ---------------------------------------- + // Public API + // ---------------------------------------- + + if (!config.getEnv('publicApi.disabled')) { + const { apiRouters, apiLatestVersion } = await loadPublicApiVersions(this.publicApiEndpoint); + this.app.use(...apiRouters); + this.frontendSettings.publicApi.latestVersion = apiLatestVersion; + } // Parse cookies for easier access this.app.use(cookieParser()); @@ -786,7 +810,7 @@ class App { } await this.externalHooks.run('workflow.afterCreate', [savedWorkflow]); - void InternalHooksManager.getInstance().onWorkflowCreated(req.user.id, newWorkflow); + void InternalHooksManager.getInstance().onWorkflowCreated(req.user.id, newWorkflow, false); const { id, ...rest } = savedWorkflow; @@ -1076,7 +1100,11 @@ class App { } await this.externalHooks.run('workflow.afterUpdate', [updatedWorkflow]); - void InternalHooksManager.getInstance().onWorkflowSaved(req.user.id, updatedWorkflow); + void InternalHooksManager.getInstance().onWorkflowSaved( + req.user.id, + updatedWorkflow, + false, + ); if (updatedWorkflow.active) { // When the workflow is supposed to be active add it again @@ -1144,7 +1172,7 @@ class App { await Db.collections.Workflow.delete(workflowId); - void InternalHooksManager.getInstance().onWorkflowDeleted(req.user.id, workflowId); + void InternalHooksManager.getInstance().onWorkflowDeleted(req.user.id, workflowId, false); await this.externalHooks.run('workflow.afterDelete', [workflowId]); return true; diff --git a/packages/cli/src/UserManagement/UserManagementHelper.ts b/packages/cli/src/UserManagement/UserManagementHelper.ts index 8f01975127..cc4df147fd 100644 --- a/packages/cli/src/UserManagement/UserManagementHelper.ts +++ b/packages/cli/src/UserManagement/UserManagementHelper.ts @@ -123,6 +123,7 @@ export function sanitizeUser(user: User, withoutKeys?: string[]): PublicUser { resetPasswordTokenExpiration, createdAt, updatedAt, + apiKey, ...sanitizedUser } = user; if (withoutKeys) { diff --git a/packages/cli/src/UserManagement/auth/jwt.ts b/packages/cli/src/UserManagement/auth/jwt.ts index 97d2e30ef0..647e17617b 100644 --- a/packages/cli/src/UserManagement/auth/jwt.ts +++ b/packages/cli/src/UserManagement/auth/jwt.ts @@ -6,7 +6,7 @@ import { Response } from 'express'; import { createHash } from 'crypto'; import { Db } from '../..'; import { AUTH_COOKIE_NAME } from '../../constants'; -import { JwtToken, JwtPayload } from '../Interfaces'; +import { JwtPayload, JwtToken } from '../Interfaces'; import { User } from '../../databases/entities/User'; import * as config from '../../../config'; diff --git a/packages/cli/src/UserManagement/routes/me.ts b/packages/cli/src/UserManagement/routes/me.ts index 54f471bb81..07ca87dcec 100644 --- a/packages/cli/src/UserManagement/routes/me.ts +++ b/packages/cli/src/UserManagement/routes/me.ts @@ -1,9 +1,11 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable import/no-cycle */ import express from 'express'; import validator from 'validator'; import { LoggerProxy as Logger } from 'n8n-workflow'; +import { randomBytes } from 'crypto'; import { Db, InternalHooksManager, ResponseHelper } from '../..'; import { issueCookie } from '../auth/jwt'; import { N8nApp, PublicUser } from '../Interfaces'; @@ -149,4 +151,58 @@ export function meNamespace(this: N8nApp): void { return { success: true }; }), ); + + /** + * Creates an API Key + */ + this.app.post( + `/${this.restEndpoint}/me/api-key`, + ResponseHelper.send(async (req: AuthenticatedRequest) => { + const apiKey = `n8n_api_${randomBytes(40).toString('hex')}`; + + await Db.collections.User.update(req.user.id, { + apiKey, + }); + + const telemetryData = { + user_id: req.user.id, + public_api: false, + }; + + void InternalHooksManager.getInstance().onApiKeyCreated(telemetryData); + + return { apiKey }; + }), + ); + + /** + * Deletes an API Key + */ + this.app.delete( + `/${this.restEndpoint}/me/api-key`, + ResponseHelper.send(async (req: AuthenticatedRequest) => { + await Db.collections.User.update(req.user.id, { + apiKey: null, + }); + + const telemetryData = { + user_id: req.user.id, + public_api: false, + }; + + void InternalHooksManager.getInstance().onApiKeyDeleted(telemetryData); + + return { success: true }; + }), + ); + + /** + * Get an API Key + */ + this.app.get( + `/${this.restEndpoint}/me/api-key`, + ResponseHelper.send(async (req: AuthenticatedRequest) => { + return { apiKey: req.user.apiKey }; + }), + ); } diff --git a/packages/cli/src/UserManagement/routes/passwordReset.ts b/packages/cli/src/UserManagement/routes/passwordReset.ts index 1e1b19f51c..be40c3240c 100644 --- a/packages/cli/src/UserManagement/routes/passwordReset.ts +++ b/packages/cli/src/UserManagement/routes/passwordReset.ts @@ -89,6 +89,7 @@ export function passwordResetNamespace(this: N8nApp): void { void InternalHooksManager.getInstance().onEmailFailed({ user_id: user.id, message_type: 'Reset password', + public_api: false, }); if (error instanceof Error) { throw new ResponseHelper.ResponseError( @@ -103,6 +104,7 @@ export function passwordResetNamespace(this: N8nApp): void { void InternalHooksManager.getInstance().onUserTransactionalEmail({ user_id: id, message_type: 'Reset password', + public_api: false, }); void InternalHooksManager.getInstance().onUserPasswordResetRequestClick({ diff --git a/packages/cli/src/UserManagement/routes/users.ts b/packages/cli/src/UserManagement/routes/users.ts index 4e3be7a653..d82edc5a2c 100644 --- a/packages/cli/src/UserManagement/routes/users.ts +++ b/packages/cli/src/UserManagement/routes/users.ts @@ -156,6 +156,7 @@ export function usersNamespace(this: N8nApp): void { void InternalHooksManager.getInstance().onUserInvite({ user_id: req.user.id, target_user_id: Object.values(createUsers) as string[], + public_api: false, }); } catch (error) { Logger.error('Failed to create user shells', { userShells: createUsers }); @@ -193,11 +194,13 @@ export function usersNamespace(this: N8nApp): void { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion user_id: id!, message_type: 'New user invite', + public_api: false, }); } else { void InternalHooksManager.getInstance().onEmailFailed({ user_id: req.user.id, message_type: 'New user invite', + public_api: false, }); Logger.error('Failed to send email', { userId: req.user.id, @@ -378,6 +381,7 @@ export function usersNamespace(this: N8nApp): void { */ this.app.delete( `/${this.restEndpoint}/users/:id`, + // @ts-ignore ResponseHelper.send(async (req: UserRequest.Delete) => { const { id: idToDelete } = req.params; @@ -472,7 +476,7 @@ export function usersNamespace(this: N8nApp): void { telemetryData.migration_user_id = transferId; } - void InternalHooksManager.getInstance().onUserDeletion(req.user.id, telemetryData); + void InternalHooksManager.getInstance().onUserDeletion(req.user.id, telemetryData, false); return { success: true }; }), @@ -538,6 +542,7 @@ export function usersNamespace(this: N8nApp): void { void InternalHooksManager.getInstance().onEmailFailed({ user_id: req.user.id, message_type: 'Resend invite', + public_api: false, }); Logger.error('Failed to send email', { email: reinvitee.email, @@ -554,11 +559,13 @@ export function usersNamespace(this: N8nApp): void { void InternalHooksManager.getInstance().onUserReinvite({ user_id: req.user.id, target_user_id: reinvitee.id, + public_api: false, }); void InternalHooksManager.getInstance().onUserTransactionalEmail({ user_id: reinvitee.id, message_type: 'Resend invite', + public_api: false, }); return { success: true }; diff --git a/packages/cli/src/WebhookServer.ts b/packages/cli/src/WebhookServer.ts index 50c934d968..48e9409790 100644 --- a/packages/cli/src/WebhookServer.ts +++ b/packages/cli/src/WebhookServer.ts @@ -10,8 +10,6 @@ import express from 'express'; import { readFileSync } from 'fs'; import { getConnectionManager } from 'typeorm'; import bodyParser from 'body-parser'; -// eslint-disable-next-line import/no-extraneous-dependencies, @typescript-eslint/no-unused-vars -import _ from 'lodash'; import compression from 'compression'; // eslint-disable-next-line import/no-extraneous-dependencies diff --git a/packages/cli/src/databases/entities/User.ts b/packages/cli/src/databases/entities/User.ts index 20043251ea..dca67a5e7c 100644 --- a/packages/cli/src/databases/entities/User.ts +++ b/packages/cli/src/databases/entities/User.ts @@ -137,6 +137,10 @@ export class User { this.updatedAt = new Date(); } + @Column({ type: String, nullable: true }) + @Index({ unique: true }) + apiKey?: string | null; + /** * Whether the user is pending setup completion. */ diff --git a/packages/cli/src/databases/mysqldb/migrations/1652905585850-AddAPIKeyColumn.ts b/packages/cli/src/databases/mysqldb/migrations/1652905585850-AddAPIKeyColumn.ts new file mode 100644 index 0000000000..a527b321e0 --- /dev/null +++ b/packages/cli/src/databases/mysqldb/migrations/1652905585850-AddAPIKeyColumn.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import config from '../../../../config'; + +export class AddAPIKeyColumn1652905585850 implements MigrationInterface { + name = 'AddAPIKeyColumn1652905585850'; + + public async up(queryRunner: QueryRunner): Promise { + const tablePrefix = config.getEnv('database.tablePrefix'); + + await queryRunner.query( + 'ALTER TABLE `' + tablePrefix + 'user` ADD COLUMN `apiKey` VARCHAR(255)', + ); + await queryRunner.query( + 'CREATE UNIQUE INDEX `UQ_' + + tablePrefix + + 'ie0zomxves9w3p774drfrkxtj5` ON `' + + tablePrefix + + 'user` (`apiKey`)', + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + await queryRunner.query( + 'DROP INDEX `IDX_81fc04c8a17de15835713505e4` ON `' + tablePrefix + 'execution_entity`', + ); + await queryRunner.query('ALTER TABLE `' + tablePrefix + 'user` DROP COLUMN `apiKey`'); + } +} diff --git a/packages/cli/src/databases/mysqldb/migrations/index.ts b/packages/cli/src/databases/mysqldb/migrations/index.ts index 87fcd62e61..31993d41cd 100644 --- a/packages/cli/src/databases/mysqldb/migrations/index.ts +++ b/packages/cli/src/databases/mysqldb/migrations/index.ts @@ -14,6 +14,7 @@ import { AddExecutionEntityIndexes1644424784709 } from './1644424784709-AddExecu import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement'; import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail'; import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings'; +import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn'; export const mysqlMigrations = [ InitialMigration1588157391238, @@ -32,4 +33,5 @@ export const mysqlMigrations = [ CreateUserManagement1646992772331, LowerCaseUserEmail1648740597343, AddUserSettings1652367743993, + AddAPIKeyColumn1652905585850, ]; diff --git a/packages/cli/src/databases/postgresdb/migrations/1652905585850-AddAPIKeyColumn.ts b/packages/cli/src/databases/postgresdb/migrations/1652905585850-AddAPIKeyColumn.ts new file mode 100644 index 0000000000..d52adb3a01 --- /dev/null +++ b/packages/cli/src/databases/postgresdb/migrations/1652905585850-AddAPIKeyColumn.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import config from '../../../../config'; + +export class AddAPIKeyColumn1652905585850 implements MigrationInterface { + name = 'AddAPIKeyColumn1652905585850'; + + public async up(queryRunner: QueryRunner): Promise { + let tablePrefix = config.getEnv('database.tablePrefix'); + const schema = config.getEnv('database.postgresdb.schema'); + if (schema) { + tablePrefix = schema + '.' + tablePrefix; + } + await queryRunner.query(`ALTER TABLE ${tablePrefix}user ADD COLUMN "apiKey" VARCHAR(255)`); + await queryRunner.query( + `CREATE UNIQUE INDEX "UQ_${tablePrefix}ie0zomxves9w3p774drfrkxtj5" ON ${tablePrefix}user ("apiKey")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + let tablePrefix = config.getEnv('database.tablePrefix'); + const schema = config.getEnv('database.postgresdb.schema'); + if (schema) { + tablePrefix = schema + '.' + tablePrefix; + } + await queryRunner.query(`DROP INDEX "UQ_${tablePrefix}ie0zomxves9w3p774drfrkxtj5"`); + await queryRunner.query(`ALTER TABLE ${tablePrefix}user DROP COLUMN "apiKey"`); + } +} diff --git a/packages/cli/src/databases/postgresdb/migrations/index.ts b/packages/cli/src/databases/postgresdb/migrations/index.ts index c528cd2804..30cc17b873 100644 --- a/packages/cli/src/databases/postgresdb/migrations/index.ts +++ b/packages/cli/src/databases/postgresdb/migrations/index.ts @@ -12,6 +12,7 @@ import { IncreaseTypeVarcharLimit1646834195327 } from './1646834195327-IncreaseT import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement'; import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail'; import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings'; +import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn'; export const postgresMigrations = [ InitialMigration1587669153312, @@ -28,4 +29,5 @@ export const postgresMigrations = [ CreateUserManagement1646992772331, LowerCaseUserEmail1648740597343, AddUserSettings1652367743993, + AddAPIKeyColumn1652905585850, ]; diff --git a/packages/cli/src/databases/sqlite/migrations/1652905585850-AddAPIKeyColumn.ts b/packages/cli/src/databases/sqlite/migrations/1652905585850-AddAPIKeyColumn.ts new file mode 100644 index 0000000000..23d0cdd475 --- /dev/null +++ b/packages/cli/src/databases/sqlite/migrations/1652905585850-AddAPIKeyColumn.ts @@ -0,0 +1,54 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import * as config from '../../../../config'; +import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers'; +export class AddAPIKeyColumn1652905585850 implements MigrationInterface { + name = 'AddAPIKeyColumn1652905585850'; + + async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + + const tablePrefix = config.getEnv('database.tablePrefix'); + + await queryRunner.query('PRAGMA foreign_keys=OFF'); + + await queryRunner.query( + `CREATE TABLE "temporary_user" ("id" varchar PRIMARY KEY NOT NULL, "email" varchar(255), "firstName" varchar(32), "lastName" varchar(32), "password" varchar, "resetPasswordToken" varchar, "resetPasswordTokenExpiration" integer DEFAULT NULL, "personalizationAnswers" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "globalRoleId" integer NOT NULL, "settings" text, "apiKey" varchar, CONSTRAINT "FK_${tablePrefix}f0609be844f9200ff4365b1bb3d" FOREIGN KEY ("globalRoleId") REFERENCES "${tablePrefix}role" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`, + ); + await queryRunner.query( + `INSERT INTO "temporary_user"("id", "email", "firstName", "lastName", "password", "resetPasswordToken", "resetPasswordTokenExpiration", "personalizationAnswers", "createdAt", "updatedAt", "globalRoleId", "settings") SELECT "id", "email", "firstName", "lastName", "password", "resetPasswordToken", "resetPasswordTokenExpiration", "personalizationAnswers", "createdAt", "updatedAt", "globalRoleId", "settings" FROM "${tablePrefix}user"`, + ); + await queryRunner.query(`DROP TABLE "${tablePrefix}user"`); + await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "${tablePrefix}user"`); + + await queryRunner.query( + `CREATE UNIQUE INDEX "UQ_${tablePrefix}e12875dfb3b1d92d7d7c5377e2" ON "${tablePrefix}user" ("email")`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "UQ_${tablePrefix}ie0zomxves9w3p774drfrkxtj5" ON "${tablePrefix}user" ("apiKey")`, + ); + + await queryRunner.query('PRAGMA foreign_keys=ON'); + + logMigrationEnd(this.name); + } + + async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.getEnv('database.tablePrefix'); + + await queryRunner.query('PRAGMA foreign_keys=OFF'); + + await queryRunner.query(`ALTER TABLE "${tablePrefix}user" RENAME TO "temporary_user"`); + await queryRunner.query( + `CREATE TABLE "${tablePrefix}user" ("id" varchar PRIMARY KEY NOT NULL, "email" varchar(255), "firstName" varchar(32), "lastName" varchar(32), "password" varchar, "resetPasswordToken" varchar, "resetPasswordTokenExpiration" integer DEFAULT NULL, "personalizationAnswers" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "globalRoleId" integer NOT NULL, "settings" text, CONSTRAINT "FK_${tablePrefix}f0609be844f9200ff4365b1bb3d" FOREIGN KEY ("globalRoleId") REFERENCES "${tablePrefix}role" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`, + ); + await queryRunner.query( + `INSERT INTO "${tablePrefix}user"("id", "email", "firstName", "lastName", "password", "resetPasswordToken", "resetPasswordTokenExpiration", "personalizationAnswers", "createdAt", "updatedAt", "globalRoleId", "settings") SELECT "id", "email", "firstName", "lastName", "password", "resetPasswordToken", "resetPasswordTokenExpiration", "personalizationAnswers", "createdAt", "updatedAt", "globalRoleId", "settings" FROM "temporary_user"`, + ); + await queryRunner.query(`DROP TABLE "temporary_user"`); + await queryRunner.query( + `CREATE UNIQUE INDEX "UQ_${tablePrefix}e12875dfb3b1d92d7d7c5377e2" ON "${tablePrefix}user" ("email")`, + ); + + await queryRunner.query('PRAGMA foreign_keys=ON'); + } +} diff --git a/packages/cli/src/databases/sqlite/migrations/index.ts b/packages/cli/src/databases/sqlite/migrations/index.ts index 43029766cd..56a18d98c4 100644 --- a/packages/cli/src/databases/sqlite/migrations/index.ts +++ b/packages/cli/src/databases/sqlite/migrations/index.ts @@ -13,6 +13,7 @@ import { AddExecutionEntityIndexes1644421939510 } from './1644421939510-AddExecu import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement'; import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail'; import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings'; +import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn'; const sqliteMigrations = [ InitialMigration1588102412422, @@ -28,6 +29,7 @@ const sqliteMigrations = [ CreateUserManagement1646992772331, LowerCaseUserEmail1648740597343, AddUserSettings1652367743993, + AddAPIKeyColumn1652905585850, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/requests.d.ts b/packages/cli/src/requests.d.ts index e373cb2ad0..2e9e7ca71d 100644 --- a/packages/cli/src/requests.d.ts +++ b/packages/cli/src/requests.d.ts @@ -11,8 +11,10 @@ import { } from 'n8n-workflow'; import { User } from './databases/entities/User'; +import { Role } from './databases/entities/Role'; import type { IExecutionDeleteFilter, IWorkflowDb } from '.'; import type { PublicUser } from './UserManagement/Interfaces'; +import * as UserManagementMailer from './UserManagement/email/UserManagementMailer'; export type AuthlessRequest< RouteParams = {}, @@ -26,7 +28,11 @@ export type AuthenticatedRequest< ResponseBody = {}, RequestBody = {}, RequestQuery = {}, -> = express.Request & { user: User }; +> = express.Request & { + user: User; + mailer?: UserManagementMailer.UserManagementMailer; + globalMemberRole?: Role; +}; // ---------------------------------- // /workflows @@ -196,7 +202,19 @@ export declare namespace UserRequest { { inviterId?: string; inviteeId?: string } >; - export type Delete = AuthenticatedRequest<{ id: string }, {}, {}, { transferId?: string }>; + export type Delete = AuthenticatedRequest< + { id: string; email: string; identifier: string }, + {}, + {}, + { transferId?: string; includeRole: boolean } + >; + + export type Get = AuthenticatedRequest< + { id: string; email: string; identifier: string }, + {}, + {}, + { limit?: number; offset?: number; cursor?: string; includeRole?: boolean } + >; export type Reinvite = AuthenticatedRequest<{ id: string }>; diff --git a/packages/cli/test/integration/auth.api.test.ts b/packages/cli/test/integration/auth.api.test.ts index 03f9d0c108..9ae08f60c0 100644 --- a/packages/cli/test/integration/auth.api.test.ts +++ b/packages/cli/test/integration/auth.api.test.ts @@ -18,7 +18,7 @@ let globalOwnerRole: Role; let globalMemberRole: Role; beforeAll(async () => { - app = utils.initTestServer({ endpointGroups: ['auth'], applyAuth: true }); + app = await utils.initTestServer({ endpointGroups: ['auth'], applyAuth: true }); const initResult = await testDb.init(); testDbName = initResult.testDbName; @@ -68,6 +68,7 @@ test('POST /login should log user in', async () => { personalizationAnswers, globalRole, resetPasswordToken, + apiKey, } = response.body.data; expect(validator.isUUID(id)).toBe(true); @@ -81,6 +82,7 @@ test('POST /login should log user in', async () => { expect(globalRole).toBeDefined(); expect(globalRole.name).toBe('owner'); expect(globalRole.scope).toBe('global'); + expect(apiKey).toBeUndefined(); const authToken = utils.getAuthToken(response); expect(authToken).toBeDefined(); @@ -146,6 +148,7 @@ test('GET /login should return logged-in owner shell', async () => { personalizationAnswers, globalRole, resetPasswordToken, + apiKey, } = response.body.data; expect(validator.isUUID(id)).toBe(true); @@ -159,6 +162,7 @@ test('GET /login should return logged-in owner shell', async () => { expect(globalRole).toBeDefined(); expect(globalRole.name).toBe('owner'); expect(globalRole.scope).toBe('global'); + expect(apiKey).toBeUndefined(); const authToken = utils.getAuthToken(response); expect(authToken).toBeUndefined(); @@ -181,6 +185,7 @@ test('GET /login should return logged-in member shell', async () => { personalizationAnswers, globalRole, resetPasswordToken, + apiKey, } = response.body.data; expect(validator.isUUID(id)).toBe(true); @@ -194,6 +199,7 @@ test('GET /login should return logged-in member shell', async () => { expect(globalRole).toBeDefined(); expect(globalRole.name).toBe('member'); expect(globalRole.scope).toBe('global'); + expect(apiKey).toBeUndefined(); const authToken = utils.getAuthToken(response); expect(authToken).toBeUndefined(); @@ -216,6 +222,7 @@ test('GET /login should return logged-in owner', async () => { personalizationAnswers, globalRole, resetPasswordToken, + apiKey, } = response.body.data; expect(validator.isUUID(id)).toBe(true); @@ -229,6 +236,7 @@ test('GET /login should return logged-in owner', async () => { expect(globalRole).toBeDefined(); expect(globalRole.name).toBe('owner'); expect(globalRole.scope).toBe('global'); + expect(apiKey).toBeUndefined(); const authToken = utils.getAuthToken(response); expect(authToken).toBeUndefined(); @@ -251,6 +259,7 @@ test('GET /login should return logged-in member', async () => { personalizationAnswers, globalRole, resetPasswordToken, + apiKey, } = response.body.data; expect(validator.isUUID(id)).toBe(true); @@ -264,6 +273,7 @@ test('GET /login should return logged-in member', async () => { expect(globalRole).toBeDefined(); expect(globalRole.name).toBe('member'); expect(globalRole.scope).toBe('global'); + expect(apiKey).toBeUndefined(); const authToken = utils.getAuthToken(response); expect(authToken).toBeUndefined(); diff --git a/packages/cli/test/integration/auth.mw.test.ts b/packages/cli/test/integration/auth.mw.test.ts index 872ae0c589..6652c23b26 100644 --- a/packages/cli/test/integration/auth.mw.test.ts +++ b/packages/cli/test/integration/auth.mw.test.ts @@ -17,7 +17,7 @@ let testDbName = ''; let globalMemberRole: Role; beforeAll(async () => { - app = utils.initTestServer({ + app = await utils.initTestServer({ applyAuth: true, endpointGroups: ['me', 'auth', 'owner', 'users'], }); diff --git a/packages/cli/test/integration/commands/reset.cmd.test.ts b/packages/cli/test/integration/commands/reset.cmd.test.ts index bc77017878..97486e7726 100644 --- a/packages/cli/test/integration/commands/reset.cmd.test.ts +++ b/packages/cli/test/integration/commands/reset.cmd.test.ts @@ -14,7 +14,7 @@ let testDbName = ''; let globalOwnerRole: Role; beforeAll(async () => { - app = utils.initTestServer({ endpointGroups: ['owner'], applyAuth: true }); + app = await utils.initTestServer({ endpointGroups: ['owner'], applyAuth: true }); const initResult = await testDb.init(); testDbName = initResult.testDbName; diff --git a/packages/cli/test/integration/credentials.api.test.ts b/packages/cli/test/integration/credentials.api.test.ts index e1ef311821..cc6e8275f0 100644 --- a/packages/cli/test/integration/credentials.api.test.ts +++ b/packages/cli/test/integration/credentials.api.test.ts @@ -19,7 +19,7 @@ let globalMemberRole: Role; let saveCredential: SaveCredentialFunction; beforeAll(async () => { - app = utils.initTestServer({ + app = await utils.initTestServer({ endpointGroups: ['credentials'], applyAuth: true, }); diff --git a/packages/cli/test/integration/me.api.test.ts b/packages/cli/test/integration/me.api.test.ts index 5b4cffb3ec..3737195372 100644 --- a/packages/cli/test/integration/me.api.test.ts +++ b/packages/cli/test/integration/me.api.test.ts @@ -7,7 +7,13 @@ import * as utils from './shared/utils'; import { SUCCESS_RESPONSE_BODY } from './shared/constants'; import { Db } from '../../src'; import type { Role } from '../../src/databases/entities/Role'; -import { randomValidPassword, randomEmail, randomName, randomString } from './shared/random'; +import { + randomApiKey, + randomEmail, + randomName, + randomString, + randomValidPassword, +} from './shared/random'; import * as testDb from './shared/testDb'; jest.mock('../../src/telemetry'); @@ -18,7 +24,7 @@ let globalOwnerRole: Role; let globalMemberRole: Role; beforeAll(async () => { - app = utils.initTestServer({ endpointGroups: ['me'], applyAuth: true }); + app = await utils.initTestServer({ endpointGroups: ['me'], applyAuth: true }); const initResult = await testDb.init(); testDbName = initResult.testDbName; @@ -55,6 +61,7 @@ describe('Owner shell', () => { password, resetPasswordToken, isPending, + apiKey, } = response.body.data; expect(validator.isUUID(id)).toBe(true); @@ -67,6 +74,7 @@ describe('Owner shell', () => { expect(isPending).toBe(true); expect(globalRole.name).toBe('owner'); expect(globalRole.scope).toBe('global'); + expect(apiKey).toBeUndefined(); }); test('PATCH /me should succeed with valid inputs', async () => { @@ -88,6 +96,7 @@ describe('Owner shell', () => { password, resetPasswordToken, isPending, + apiKey, } = response.body.data; expect(validator.isUUID(id)).toBe(true); @@ -100,6 +109,7 @@ describe('Owner shell', () => { expect(isPending).toBe(false); expect(globalRole.name).toBe('owner'); expect(globalRole.scope).toBe('global'); + expect(apiKey).toBeUndefined(); const storedOwnerShell = await Db.collections.User.findOneOrFail(id); @@ -175,6 +185,50 @@ describe('Owner shell', () => { expect(storedShellOwner.personalizationAnswers).toEqual(validPayload); } }); + + test('POST /me/api-key should create an api key', async () => { + const ownerShell = await testDb.createUserShell(globalOwnerRole); + const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + + const response = await authOwnerShellAgent.post('/me/api-key'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.apiKey).toBeDefined(); + expect(response.body.data.apiKey).not.toBeNull(); + + const storedShellOwner = await Db.collections.User.findOneOrFail({ + where: { email: IsNull() }, + }); + + expect(storedShellOwner.apiKey).toEqual(response.body.data.apiKey); + }); + + test('GET /me/api-key should fetch the api key', async () => { + let ownerShell = await testDb.createUserShell(globalOwnerRole); + ownerShell = await testDb.addApiKey(ownerShell); + const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + + const response = await authOwnerShellAgent.get('/me/api-key'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.apiKey).toEqual(ownerShell.apiKey); + }); + + test('DELETE /me/api-key should fetch the api key', async () => { + let ownerShell = await testDb.createUserShell(globalOwnerRole); + ownerShell = await testDb.addApiKey(ownerShell); + const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell }); + + const response = await authOwnerShellAgent.delete('/me/api-key'); + + expect(response.statusCode).toBe(200); + + const storedShellOwner = await Db.collections.User.findOneOrFail({ + where: { email: IsNull() }, + }); + + expect(storedShellOwner.apiKey).toBeNull(); + }); }); describe('Member', () => { @@ -209,6 +263,7 @@ describe('Member', () => { password, resetPasswordToken, isPending, + apiKey, } = response.body.data; expect(validator.isUUID(id)).toBe(true); @@ -221,6 +276,7 @@ describe('Member', () => { expect(isPending).toBe(false); expect(globalRole.name).toBe('member'); expect(globalRole.scope).toBe('global'); + expect(apiKey).toBeUndefined(); }); test('PATCH /me should succeed with valid inputs', async () => { @@ -242,6 +298,7 @@ describe('Member', () => { password, resetPasswordToken, isPending, + apiKey, } = response.body.data; expect(validator.isUUID(id)).toBe(true); @@ -254,6 +311,7 @@ describe('Member', () => { expect(isPending).toBe(false); expect(globalRole.name).toBe('member'); expect(globalRole.scope).toBe('global'); + expect(apiKey).toBeUndefined(); const storedMember = await Db.collections.User.findOneOrFail(id); @@ -335,6 +393,53 @@ describe('Member', () => { expect(storedAnswers).toEqual(validPayload); } }); + + test('POST /me/api-key should create an api key', async () => { + const member = await testDb.createUser({ + globalRole: globalMemberRole, + apiKey: randomApiKey(), + }); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + + const response = await authMemberAgent.post('/me/api-key'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.apiKey).toBeDefined(); + expect(response.body.data.apiKey).not.toBeNull(); + + const storedMember = await Db.collections.User.findOneOrFail(member.id); + + expect(storedMember.apiKey).toEqual(response.body.data.apiKey); + }); + + test('GET /me/api-key should fetch the api key', async () => { + const member = await testDb.createUser({ + globalRole: globalMemberRole, + apiKey: randomApiKey(), + }); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + + const response = await authMemberAgent.get('/me/api-key'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.apiKey).toEqual(member.apiKey); + }); + + test('DELETE /me/api-key should fetch the api key', async () => { + const member = await testDb.createUser({ + globalRole: globalMemberRole, + apiKey: randomApiKey(), + }); + const authMemberAgent = utils.createAgent(app, { auth: true, user: member }); + + const response = await authMemberAgent.delete('/me/api-key'); + + expect(response.statusCode).toBe(200); + + const storedMember = await Db.collections.User.findOneOrFail(member.id); + + expect(storedMember.apiKey).toBeNull(); + }); }); describe('Owner', () => { @@ -364,6 +469,7 @@ describe('Owner', () => { password, resetPasswordToken, isPending, + apiKey, } = response.body.data; expect(validator.isUUID(id)).toBe(true); @@ -376,6 +482,7 @@ describe('Owner', () => { expect(isPending).toBe(false); expect(globalRole.name).toBe('owner'); expect(globalRole.scope).toBe('global'); + expect(apiKey).toBeUndefined(); }); test('PATCH /me should succeed with valid inputs', async () => { @@ -397,6 +504,7 @@ describe('Owner', () => { password, resetPasswordToken, isPending, + apiKey, } = response.body.data; expect(validator.isUUID(id)).toBe(true); @@ -409,6 +517,7 @@ describe('Owner', () => { expect(isPending).toBe(false); expect(globalRole.name).toBe('owner'); expect(globalRole.scope).toBe('global'); + expect(apiKey).toBeUndefined(); const storedOwner = await Db.collections.User.findOneOrFail(id); diff --git a/packages/cli/test/integration/owner.api.test.ts b/packages/cli/test/integration/owner.api.test.ts index 55be99f30f..c5899fce33 100644 --- a/packages/cli/test/integration/owner.api.test.ts +++ b/packages/cli/test/integration/owner.api.test.ts @@ -20,7 +20,7 @@ let testDbName = ''; let globalOwnerRole: Role; beforeAll(async () => { - app = utils.initTestServer({ endpointGroups: ['owner'], applyAuth: true }); + app = await utils.initTestServer({ endpointGroups: ['owner'], applyAuth: true }); const initResult = await testDb.init(); testDbName = initResult.testDbName; @@ -66,6 +66,7 @@ test('POST /owner should create owner and enable isInstanceOwnerSetUp', async () password, resetPasswordToken, isPending, + apiKey, } = response.body.data; expect(validator.isUUID(id)).toBe(true); @@ -78,6 +79,7 @@ test('POST /owner should create owner and enable isInstanceOwnerSetUp', async () expect(resetPasswordToken).toBeUndefined(); expect(globalRole.name).toBe('owner'); expect(globalRole.scope).toBe('global'); + expect(apiKey).toBeUndefined(); const storedOwner = await Db.collections.User.findOneOrFail(id); expect(storedOwner.password).not.toBe(newOwnerData.password); diff --git a/packages/cli/test/integration/passwordReset.api.test.ts b/packages/cli/test/integration/passwordReset.api.test.ts index 8501394463..deaf2f04f2 100644 --- a/packages/cli/test/integration/passwordReset.api.test.ts +++ b/packages/cli/test/integration/passwordReset.api.test.ts @@ -24,7 +24,7 @@ let globalMemberRole: Role; let isSmtpAvailable = false; beforeAll(async () => { - app = utils.initTestServer({ endpointGroups: ['passwordReset'], applyAuth: true }); + app = await utils.initTestServer({ endpointGroups: ['passwordReset'], applyAuth: true }); const initResult = await testDb.init(); testDbName = initResult.testDbName; diff --git a/packages/cli/test/integration/publicApi/credentials.test.ts b/packages/cli/test/integration/publicApi/credentials.test.ts new file mode 100644 index 0000000000..f3817b3d50 --- /dev/null +++ b/packages/cli/test/integration/publicApi/credentials.test.ts @@ -0,0 +1,411 @@ +import express from 'express'; +import { UserSettings } from 'n8n-core'; +import { Db } from '../../../src'; +import { randomApiKey, randomName, randomString } from '../shared/random'; +import * as utils from '../shared/utils'; +import type { CredentialPayload, SaveCredentialFunction } from '../shared/types'; +import type { Role } from '../../../src/databases/entities/Role'; +import type { User } from '../../../src/databases/entities/User'; +import * as testDb from '../shared/testDb'; +import { RESPONSE_ERROR_MESSAGES } from '../../../src/constants'; + +jest.mock('../../../src/telemetry'); + +let app: express.Application; +let testDbName = ''; +let globalOwnerRole: Role; +let globalMemberRole: Role; +let workflowOwnerRole: Role; +let credentialOwnerRole: Role; + +let saveCredential: SaveCredentialFunction; + +beforeAll(async () => { + app = await utils.initTestServer({ endpointGroups: ['publicApi'], applyAuth: false }); + const initResult = await testDb.init(); + testDbName = initResult.testDbName; + + utils.initConfigFile(); + + const [ + fetchedGlobalOwnerRole, + fetchedGlobalMemberRole, + fetchedWorkflowOwnerRole, + fetchedCredentialOwnerRole, + ] = await testDb.getAllRoles(); + + globalOwnerRole = fetchedGlobalOwnerRole; + globalMemberRole = fetchedGlobalMemberRole; + workflowOwnerRole = fetchedWorkflowOwnerRole; + credentialOwnerRole = fetchedCredentialOwnerRole; + + saveCredential = affixRoleToSaveCredential(credentialOwnerRole); + + utils.initTestLogger(); + utils.initTestTelemetry(); + utils.initCredentialsTypes(); +}); + +beforeEach(async () => { + await testDb.truncate(['User', 'SharedCredentials', 'Credentials'], testDbName); +}); + +afterAll(async () => { + await testDb.terminate(testDbName); +}); + +test('POST /credentials should create credentials', async () => { + let ownerShell = await testDb.createUserShell(globalOwnerRole); + ownerShell = await testDb.addApiKey(ownerShell); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + version: 1, + auth: true, + user: ownerShell, + }); + const payload = { + name: 'test credential', + type: 'githubApi', + data: { + accessToken: 'abcdefghijklmnopqrstuvwxyz', + user: 'test', + server: 'testServer', + }, + }; + + const response = await authOwnerAgent.post('/credentials').send(payload); + + expect(response.statusCode).toBe(200); + + const { id, name, type } = response.body; + + expect(name).toBe(payload.name); + expect(type).toBe(payload.type); + + const credential = await Db.collections.Credentials!.findOneOrFail(id); + + expect(credential.name).toBe(payload.name); + expect(credential.type).toBe(payload.type); + expect(credential.data).not.toBe(payload.data); + + const sharedCredential = await Db.collections.SharedCredentials!.findOneOrFail({ + relations: ['user', 'credentials', 'role'], + where: { credentials: credential, user: ownerShell }, + }); + + expect(sharedCredential.role).toEqual(credentialOwnerRole); + expect(sharedCredential.credentials.name).toBe(payload.name); +}); + +test('POST /credentials should fail with invalid inputs', async () => { + let ownerShell = await testDb.createUserShell(globalOwnerRole); + ownerShell = await testDb.addApiKey(ownerShell); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + version: 1, + auth: true, + user: ownerShell, + }); + + await Promise.all( + INVALID_PAYLOADS.map(async (invalidPayload) => { + const response = await authOwnerAgent.post('/credentials').send(invalidPayload); + expect(response.statusCode === 400 || response.statusCode === 415).toBe(true); + }), + ); +}); + +test('POST /credentials should fail with missing encryption key', async () => { + const mock = jest.spyOn(UserSettings, 'getEncryptionKey'); + mock.mockRejectedValue(new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY)); + + let ownerShell = await testDb.createUserShell(globalOwnerRole); + ownerShell = await testDb.addApiKey(ownerShell); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + version: 1, + auth: true, + user: ownerShell, + }); + + const response = await authOwnerAgent.post('/credentials').send(credentialPayload()); + + expect(response.statusCode).toBe(500); + + mock.mockRestore(); +}); + +test('DELETE /credentials/:id should delete owned cred for owner', async () => { + let ownerShell = await testDb.createUserShell(globalOwnerRole); + ownerShell = await testDb.addApiKey(ownerShell); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + version: 1, + auth: true, + user: ownerShell, + }); + + const savedCredential = await saveCredential(dbCredential(), { user: ownerShell }); + + const response = await authOwnerAgent.delete(`/credentials/${savedCredential.id}`); + + expect(response.statusCode).toBe(200); + + const { name, type } = response.body; + + expect(name).toBe(savedCredential.name); + expect(type).toBe(savedCredential.type); + + const deletedCredential = await Db.collections.Credentials!.findOne(savedCredential.id); + + expect(deletedCredential).toBeUndefined(); // deleted + + const deletedSharedCredential = await Db.collections.SharedCredentials!.findOne(); + + expect(deletedSharedCredential).toBeUndefined(); // deleted +}); + +test('DELETE /credentials/:id should delete non-owned cred for owner', async () => { + let ownerShell = await testDb.createUserShell(globalOwnerRole); + ownerShell = await testDb.addApiKey(ownerShell); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + version: 1, + auth: true, + user: ownerShell, + }); + + const member = await testDb.createUser({ globalRole: globalMemberRole }); + + const savedCredential = await saveCredential(dbCredential(), { user: member }); + + const response = await authOwnerAgent.delete(`/credentials/${savedCredential.id}`); + + expect(response.statusCode).toBe(200); + + const deletedCredential = await Db.collections.Credentials!.findOne(savedCredential.id); + + expect(deletedCredential).toBeUndefined(); // deleted + + const deletedSharedCredential = await Db.collections.SharedCredentials!.findOne(); + + expect(deletedSharedCredential).toBeUndefined(); // deleted +}); + +test('DELETE /credentials/:id should delete owned cred for member', async () => { + const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() }); + + const authMemberAgent = utils.createAgent(app, { + apiPath: 'public', + version: 1, + auth: true, + user: member, + }); + + const savedCredential = await saveCredential(dbCredential(), { user: member }); + + const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`); + + expect(response.statusCode).toBe(200); + + const { name, type } = response.body; + + expect(name).toBe(savedCredential.name); + expect(type).toBe(savedCredential.type); + + const deletedCredential = await Db.collections.Credentials!.findOne(savedCredential.id); + + expect(deletedCredential).toBeUndefined(); // deleted + + const deletedSharedCredential = await Db.collections.SharedCredentials!.findOne(); + + expect(deletedSharedCredential).toBeUndefined(); // deleted +}); + +test('DELETE /credentials/:id should delete owned cred for member but leave others untouched', async () => { + const member1 = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() }); + const member2 = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() }); + + const savedCredential = await saveCredential(dbCredential(), { user: member1 }); + const notToBeChangedCredential = await saveCredential(dbCredential(), { user: member1 }); + const notToBeChangedCredential2 = await saveCredential(dbCredential(), { user: member2 }); + + const authMemberAgent = utils.createAgent(app, { + apiPath: 'public', + version: 1, + auth: true, + user: member1, + }); + + const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`); + + expect(response.statusCode).toBe(200); + + const { name, type } = response.body; + + expect(name).toBe(savedCredential.name); + expect(type).toBe(savedCredential.type); + + const deletedCredential = await Db.collections.Credentials!.findOne(savedCredential.id); + + expect(deletedCredential).toBeUndefined(); // deleted + + const deletedSharedCredential = await Db.collections.SharedCredentials!.findOne({ + where: { + credentials: savedCredential, + }, + }); + + expect(deletedSharedCredential).toBeUndefined(); // deleted + + await Promise.all( + [notToBeChangedCredential, notToBeChangedCredential2].map(async (credential) => { + const untouchedCredential = await Db.collections.Credentials!.findOne(credential.id); + + expect(untouchedCredential).toEqual(credential); // not deleted + + const untouchedSharedCredential = await Db.collections.SharedCredentials!.findOne({ + where: { + credentials: credential, + }, + }); + + expect(untouchedSharedCredential).toBeDefined(); // not deleted + }), + ); +}); + +test('DELETE /credentials/:id should not delete non-owned cred for member', async () => { + const ownerShell = await testDb.createUserShell(globalOwnerRole); + const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() }); + + const authMemberAgent = utils.createAgent(app, { + apiPath: 'public', + version: 1, + auth: true, + user: member, + }); + const savedCredential = await saveCredential(dbCredential(), { user: ownerShell }); + + const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`); + + expect(response.statusCode).toBe(404); + + const shellCredential = await Db.collections.Credentials!.findOne(savedCredential.id); + + expect(shellCredential).toBeDefined(); // not deleted + + const deletedSharedCredential = await Db.collections.SharedCredentials!.findOne(); + + expect(deletedSharedCredential).toBeDefined(); // not deleted +}); + +test('DELETE /credentials/:id should fail if cred not found', async () => { + let ownerShell = await testDb.createUserShell(globalOwnerRole); + ownerShell = await testDb.addApiKey(ownerShell); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + version: 1, + auth: true, + user: ownerShell, + }); + + const response = await authOwnerAgent.delete('/credentials/123'); + + expect(response.statusCode).toBe(404); +}); + +test('GET /credentials/schema/:credentialType should fail due to not found type', async () => { + let ownerShell = await testDb.createUserShell(globalOwnerRole); + ownerShell = await testDb.addApiKey(ownerShell); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + version: 1, + auth: true, + user: ownerShell, + }); + + const response = await authOwnerAgent.get('/credentials/schema/testing'); + + expect(response.statusCode).toBe(404); +}); + +test('GET /credentials/schema/:credentialType should retrieve credential type', async () => { + let ownerShell = await testDb.createUserShell(globalOwnerRole); + ownerShell = await testDb.addApiKey(ownerShell); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + version: 1, + auth: true, + user: ownerShell, + }); + + const response = await authOwnerAgent.get('/credentials/schema/githubApi'); + + const { additionalProperties, type, properties, required } = response.body; + + expect(additionalProperties).toBe(false); + expect(type).toBe('object'); + expect(properties.server).toBeDefined(); + expect(properties.server.type).toBe('string'); + expect(properties.user.type).toBeDefined(); + expect(properties.user.type).toBe('string'); + expect(properties.accessToken.type).toBeDefined(); + expect(properties.accessToken.type).toBe('string'); + expect(required).toEqual(expect.arrayContaining(['server', 'user', 'accessToken'])); + expect(response.statusCode).toBe(200); +}); + +const credentialPayload = (): CredentialPayload => ({ + name: randomName(), + type: 'githubApi', + data: { + accessToken: randomString(6, 16), + server: randomString(1, 10), + user: randomString(1, 10), + }, +}); + +const dbCredential = () => { + const credential = credentialPayload(); + credential.nodesAccess = [{ nodeType: credential.type }]; + return credential; +}; + +const INVALID_PAYLOADS = [ + { + type: randomName(), + data: { accessToken: randomString(6, 16) }, + }, + { + name: randomName(), + data: { accessToken: randomString(6, 16) }, + }, + { + name: randomName(), + type: randomName(), + }, + { + name: randomName(), + type: 'githubApi', + data: { + server: randomName(), + }, + }, + {}, + [], + undefined, +]; + +function affixRoleToSaveCredential(role: Role) { + return (credentialPayload: CredentialPayload, { user }: { user: User }) => + testDb.saveCredential(credentialPayload, { user, role }); +} diff --git a/packages/cli/test/integration/publicApi/executions.test.ts b/packages/cli/test/integration/publicApi/executions.test.ts new file mode 100644 index 0000000000..9ef5bf77af --- /dev/null +++ b/packages/cli/test/integration/publicApi/executions.test.ts @@ -0,0 +1,423 @@ +import express = require('express'); + +import { ActiveWorkflowRunner } from '../../../src'; +import config = require('../../../config'); +import { Role } from '../../../src/databases/entities/Role'; +import { randomApiKey } from '../shared/random'; + +import * as utils from '../shared/utils'; +import * as testDb from '../shared/testDb'; + +let app: express.Application; +let testDbName = ''; +let globalOwnerRole: Role; + +let workflowRunner: ActiveWorkflowRunner.ActiveWorkflowRunner; + +jest.mock('../../../src/telemetry'); + +beforeAll(async () => { + app = await utils.initTestServer({ endpointGroups: ['publicApi'], applyAuth: false }); + const initResult = await testDb.init(); + testDbName = initResult.testDbName; + + globalOwnerRole = await testDb.getGlobalOwnerRole(); + + utils.initTestTelemetry(); + utils.initTestLogger(); + // initializing binary manager leave some async operations open + // TODO mockup binary data mannager to avoid error + await utils.initBinaryManager(); + await utils.initNodeTypes(); + workflowRunner = await utils.initActiveWorkflowRunner(); +}); + +beforeEach(async () => { + // do not combine calls - shared tables must be cleared first and separately + await testDb.truncate(['SharedCredentials', 'SharedWorkflow'], testDbName); + await testDb.truncate(['User', 'Workflow', 'Credentials', 'Execution', 'Settings'], testDbName); + + config.set('userManagement.disabled', false); + config.set('userManagement.isInstanceOwnerSetUp', true); + config.set('userManagement.emails.mode', 'smtp'); +}); + +afterEach(async () => { + await workflowRunner.removeAll(); +}); + +afterAll(async () => { + await testDb.terminate(testDbName); +}); + +test.skip('GET /executions/:executionId should fail due to missing API Key', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const response = await authOwnerAgent.get('/executions/1'); + + expect(response.statusCode).toBe(401); +}); + +test.skip('GET /executions/:executionId should fail due to invalid API Key', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + owner.apiKey = 'abcXYZ'; + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const response = await authOwnerAgent.get('/executions/1'); + + expect(response.statusCode).toBe(401); +}); + +test.skip('GET /executions/:executionId should get an execution', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const workflow = await testDb.createWorkflow({}, owner); + + const execution = await testDb.createSuccessfullExecution(workflow); + + const response = await authOwnerAgent.get(`/executions/${execution.id}`); + + expect(response.statusCode).toBe(200); + + const { + id, + finished, + mode, + retryOf, + retrySuccessId, + startedAt, + stoppedAt, + workflowId, + waitTill, + } = response.body; + + expect(id).toBeDefined(); + expect(finished).toBe(true); + expect(mode).toEqual(execution.mode); + expect(retrySuccessId).toBeNull(); + expect(retryOf).toBeNull(); + expect(startedAt).not.toBeNull(); + expect(stoppedAt).not.toBeNull(); + expect(workflowId).toBe(execution.workflowId); + expect(waitTill).toBeNull(); +}); + +test.skip('DELETE /executions/:executionId should fail due to missing API Key', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const response = await authOwnerAgent.delete('/executions/1'); + + expect(response.statusCode).toBe(401); +}); + +test.skip('DELETE /executions/:executionId should fail due to invalid API Key', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + owner.apiKey = 'abcXYZ'; + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const response = await authOwnerAgent.delete('/executions/1'); + + expect(response.statusCode).toBe(401); +}); + +test.skip('DELETE /executions/:executionId should delete an execution', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const workflow = await testDb.createWorkflow({}, owner); + + const execution = await testDb.createSuccessfullExecution(workflow); + + const response = await authOwnerAgent.delete(`/executions/${execution.id}`); + + expect(response.statusCode).toBe(200); + + const { + id, + finished, + mode, + retryOf, + retrySuccessId, + startedAt, + stoppedAt, + workflowId, + waitTill, + } = response.body; + + expect(id).toBeDefined(); + expect(finished).toBe(true); + expect(mode).toEqual(execution.mode); + expect(retrySuccessId).toBeNull(); + expect(retryOf).toBeNull(); + expect(startedAt).not.toBeNull(); + expect(stoppedAt).not.toBeNull(); + expect(workflowId).toBe(execution.workflowId); + expect(waitTill).toBeNull(); +}); + +test.skip('GET /executions should fail due to missing API Key', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const response = await authOwnerAgent.get('/executions'); + + expect(response.statusCode).toBe(401); +}); + +test.skip('GET /executions should fail due to invalid API Key', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + owner.apiKey = 'abcXYZ'; + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const response = await authOwnerAgent.get('/executions'); + + expect(response.statusCode).toBe(401); +}); + +test.skip('GET /executions should retrieve all successfull executions', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const workflow = await testDb.createWorkflow({}, owner); + + const successfullExecution = await testDb.createSuccessfullExecution(workflow); + + await testDb.createErrorExecution(workflow); + + const response = await authOwnerAgent.get(`/executions`).query({ + status: 'success', + }); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(1); + expect(response.body.nextCursor).toBe(null); + + const { + id, + finished, + mode, + retryOf, + retrySuccessId, + startedAt, + stoppedAt, + workflowId, + waitTill, + } = response.body.data[0]; + + expect(id).toBeDefined(); + expect(finished).toBe(true); + expect(mode).toEqual(successfullExecution.mode); + expect(retrySuccessId).toBeNull(); + expect(retryOf).toBeNull(); + expect(startedAt).not.toBeNull(); + expect(stoppedAt).not.toBeNull(); + expect(workflowId).toBe(successfullExecution.workflowId); + expect(waitTill).toBeNull(); +}); + +test.skip('GET /executions should retrieve all error executions', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const workflow = await testDb.createWorkflow({}, owner); + + await testDb.createSuccessfullExecution(workflow); + + const errorExecution = await testDb.createErrorExecution(workflow); + + const response = await authOwnerAgent.get(`/executions`).query({ + status: 'error', + }); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(1); + expect(response.body.nextCursor).toBe(null); + + const { + id, + finished, + mode, + retryOf, + retrySuccessId, + startedAt, + stoppedAt, + workflowId, + waitTill, + } = response.body.data[0]; + + expect(id).toBeDefined(); + expect(finished).toBe(false); + expect(mode).toEqual(errorExecution.mode); + expect(retrySuccessId).toBeNull(); + expect(retryOf).toBeNull(); + expect(startedAt).not.toBeNull(); + expect(stoppedAt).not.toBeNull(); + expect(workflowId).toBe(errorExecution.workflowId); + expect(waitTill).toBeNull(); +}); + +test.skip('GET /executions should return all waiting executions', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const workflow = await testDb.createWorkflow({}, owner); + + await testDb.createSuccessfullExecution(workflow); + + await testDb.createErrorExecution(workflow); + + const waitingExecution = await testDb.createWaitingExecution(workflow); + + const response = await authOwnerAgent.get(`/executions`).query({ + status: 'waiting', + }); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(1); + expect(response.body.nextCursor).toBe(null); + + const { + id, + finished, + mode, + retryOf, + retrySuccessId, + startedAt, + stoppedAt, + workflowId, + waitTill, + } = response.body.data[0]; + + expect(id).toBeDefined(); + expect(finished).toBe(false); + expect(mode).toEqual(waitingExecution.mode); + expect(retrySuccessId).toBeNull(); + expect(retryOf).toBeNull(); + expect(startedAt).not.toBeNull(); + expect(stoppedAt).not.toBeNull(); + expect(workflowId).toBe(waitingExecution.workflowId); + expect(new Date(waitTill).getTime()).toBeGreaterThan(Date.now() - 1000); +}); + +test.skip('GET /executions should retrieve all executions of specific workflow', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const [workflow, workflow2] = await testDb.createManyWorkflows(2, {}, owner); + + const savedExecutions = await testDb.createManyExecutions( + 2, + workflow, + // @ts-ignore + testDb.createSuccessfullExecution, + ); + // @ts-ignore + await testDb.createManyExecutions(2, workflow2, testDb.createSuccessfullExecution); + + const response = await authOwnerAgent.get(`/executions`).query({ + workflowId: workflow.id.toString(), + }); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); + expect(response.body.nextCursor).toBe(null); + + for (const execution of response.body.data) { + const { + id, + finished, + mode, + retryOf, + retrySuccessId, + startedAt, + stoppedAt, + workflowId, + waitTill, + } = execution; + + expect(savedExecutions.some((exec) => exec.id === id)).toBe(true); + expect(finished).toBe(true); + expect(mode).toBeDefined(); + expect(retrySuccessId).toBeNull(); + expect(retryOf).toBeNull(); + expect(startedAt).not.toBeNull(); + expect(stoppedAt).not.toBeNull(); + expect(workflowId).toBe(workflow.id.toString()); + expect(waitTill).toBeNull(); + } +}); diff --git a/packages/cli/test/integration/publicApi/workflows.test.ts b/packages/cli/test/integration/publicApi/workflows.test.ts new file mode 100644 index 0000000000..55ae573dd0 --- /dev/null +++ b/packages/cli/test/integration/publicApi/workflows.test.ts @@ -0,0 +1,1268 @@ +import express = require('express'); + +import { ActiveWorkflowRunner, Db } from '../../../src'; +import config = require('../../../config'); +import { Role } from '../../../src/databases/entities/Role'; +import { randomApiKey } from '../shared/random'; + +import * as utils from '../shared/utils'; +import * as testDb from '../shared/testDb'; +import { TagEntity } from '../../../src/databases/entities/TagEntity'; + +let app: express.Application; +let testDbName = ''; +let globalOwnerRole: Role; +let globalMemberRole: Role; +let workflowOwnerRole: Role; +let credentialOwnerRole: Role; +let workflowRunner: ActiveWorkflowRunner.ActiveWorkflowRunner; + +jest.mock('../../../src/telemetry'); + +beforeAll(async () => { + app = await utils.initTestServer({ endpointGroups: ['publicApi'], applyAuth: false }); + const initResult = await testDb.init(); + testDbName = initResult.testDbName; + + const [ + fetchedGlobalOwnerRole, + fetchedGlobalMemberRole, + fetchedWorkflowOwnerRole, + fetchedCredentialOwnerRole, + ] = await testDb.getAllRoles(); + + globalOwnerRole = fetchedGlobalOwnerRole; + globalMemberRole = fetchedGlobalMemberRole; + workflowOwnerRole = fetchedWorkflowOwnerRole; + credentialOwnerRole = fetchedCredentialOwnerRole; + + utils.initTestTelemetry(); + utils.initTestLogger(); + await utils.initNodeTypes(); + workflowRunner = await utils.initActiveWorkflowRunner(); +}); + +beforeEach(async () => { + // do not combine calls - shared tables must be cleared first and separately + await testDb.truncate(['SharedCredentials', 'SharedWorkflow'], testDbName); + await testDb.truncate(['User', 'Workflow', 'Credentials'], testDbName); + + config.set('userManagement.disabled', false); + config.set('userManagement.isInstanceOwnerSetUp', true); + config.set('userManagement.emails.mode', 'smtp'); +}); + +afterEach(async () => { + await workflowRunner.removeAll(); +}); + +afterAll(async () => { + await testDb.terminate(testDbName); +}); + +test('GET /workflows should fail due to missing API Key', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const response = await authOwnerAgent.get('/workflows'); + + expect(response.statusCode).toBe(401); +}); + +test('GET /workflows should fail due to invalid API Key', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + + owner.apiKey = 'abcXYZ'; + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const response = await authOwnerAgent.get('/workflows'); + + expect(response.statusCode).toBe(401); +}); + +test('GET /workflows should return all owned workflows', async () => { + const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() }); + + const authAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: member, + version: 1, + }); + + await Promise.all([ + testDb.createWorkflow({}, member), + testDb.createWorkflow({}, member), + testDb.createWorkflow({}, member), + ]); + + const response = await authAgent.get('/workflows'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(3); + expect(response.body.nextCursor).toBeNull(); + + for (const workflow of response.body.data) { + const { + id, + connections, + active, + staticData, + nodes, + settings, + name, + createdAt, + updatedAt, + tags, + } = workflow; + + expect(id).toBeDefined(); + expect(name).toBeDefined(); + expect(connections).toBeDefined(); + expect(active).toBe(false); + expect(staticData).toBeDefined(); + expect(nodes).toBeDefined(); + expect(tags).toBeDefined(); + expect(settings).toBeDefined(); + expect(createdAt).toBeDefined(); + expect(updatedAt).toBeDefined(); + } +}); + +test('GET /workflows should return all owned workflows with pagination', async () => { + const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() }); + + const authAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: member, + version: 1, + }); + + await Promise.all([ + testDb.createWorkflow({}, member), + testDb.createWorkflow({}, member), + testDb.createWorkflow({}, member), + ]); + + const response = await authAgent.get('/workflows?limit=1'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(1); + expect(response.body.nextCursor).not.toBeNull(); + + const response2 = await authAgent.get(`/workflows?limit=1&cursor=${response.body.nextCursor}`); + + expect(response2.statusCode).toBe(200); + expect(response2.body.data.length).toBe(1); + expect(response2.body.nextCursor).not.toBeNull(); + expect(response2.body.nextCursor).not.toBe(response.body.nextCursor); + + const responses = [...response.body.data, ...response2.body.data]; + + for (const workflow of responses) { + const { + id, + connections, + active, + staticData, + nodes, + settings, + name, + createdAt, + updatedAt, + tags, + } = workflow; + + expect(id).toBeDefined(); + expect(name).toBeDefined(); + expect(connections).toBeDefined(); + expect(active).toBe(false); + expect(staticData).toBeDefined(); + expect(nodes).toBeDefined(); + expect(tags).toBeDefined(); + expect(settings).toBeDefined(); + expect(createdAt).toBeDefined(); + expect(updatedAt).toBeDefined(); + } + + // check that we really received a different result + expect(response.body.data[0].id).toBeLessThan(response2.body.data[0].id); +}); + +test('GET /workflows should return all owned workflows filtered by tag', async () => { + const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() }); + + const authAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: member, + version: 1, + }); + + const tag = await testDb.createTag({}); + + const [workflow] = await Promise.all([ + testDb.createWorkflow({ tags: [tag] }, member), + testDb.createWorkflow({}, member), + ]); + + const response = await authAgent.get(`/workflows?tags=${tag.name}`); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(1); + + const { + id, + connections, + active, + staticData, + nodes, + settings, + name, + createdAt, + updatedAt, + tags: wfTags, + } = response.body.data[0]; + + expect(id).toBe(workflow.id); + expect(name).toBeDefined(); + expect(connections).toBeDefined(); + expect(active).toBe(false); + expect(staticData).toBeDefined(); + expect(nodes).toBeDefined(); + expect(settings).toBeDefined(); + expect(createdAt).toBeDefined(); + expect(updatedAt).toBeDefined(); + + expect(wfTags.length).toBe(1); + expect(wfTags[0].id).toBe(tag.id); +}); + +test('GET /workflows should return all owned workflows filtered by tags', async () => { + const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() }); + + const authAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: member, + version: 1, + }); + + const tags = await Promise.all([await testDb.createTag({}), await testDb.createTag({})]); + const tagNames = tags.map((tag) => tag.name).join(','); + + const [workflow1, workflow2] = await Promise.all([ + testDb.createWorkflow({ tags }, member), + testDb.createWorkflow({ tags }, member), + testDb.createWorkflow({}, member), + testDb.createWorkflow({ tags: [tags[0]] }, member), + testDb.createWorkflow({ tags: [tags[1]] }, member), + ]); + + const response = await authAgent.get(`/workflows?tags=${tagNames}`); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); + + for (const workflow of response.body.data) { + const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = + workflow; + + expect(id).toBeDefined(); + expect([workflow1.id, workflow2.id].includes(id)).toBe(true); + + expect(name).toBeDefined(); + expect(connections).toBeDefined(); + expect(active).toBe(false); + expect(staticData).toBeDefined(); + expect(nodes).toBeDefined(); + expect(settings).toBeDefined(); + expect(createdAt).toBeDefined(); + expect(updatedAt).toBeDefined(); + + expect(workflow.tags.length).toBe(2); + workflow.tags.forEach((tag: TagEntity) => { + expect(tags.some((savedTag) => savedTag.id === tag.id)).toBe(true); + }); + } +}); + +test('GET /workflows should return all workflows for owner', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + const member = await testDb.createUser({ globalRole: globalMemberRole }); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + await Promise.all([ + testDb.createWorkflow({}, owner), + testDb.createWorkflow({}, member), + testDb.createWorkflow({}, owner), + testDb.createWorkflow({}, member), + testDb.createWorkflow({}, owner), + ]); + + const response = await authOwnerAgent.get('/workflows'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(5); + expect(response.body.nextCursor).toBeNull(); + + for (const workflow of response.body.data) { + const { + id, + connections, + active, + staticData, + nodes, + settings, + name, + createdAt, + updatedAt, + tags, + } = workflow; + + expect(id).toBeDefined(); + expect(name).toBeDefined(); + expect(connections).toBeDefined(); + expect(active).toBe(false); + expect(staticData).toBeDefined(); + expect(nodes).toBeDefined(); + expect(tags).toBeDefined(); + expect(settings).toBeDefined(); + expect(createdAt).toBeDefined(); + expect(updatedAt).toBeDefined(); + } +}); + +test('GET /workflows/:workflowId should fail due to missing API Key', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + + owner.apiKey = null; + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const response = await authOwnerAgent.get(`/workflows/2`); + + expect(response.statusCode).toBe(401); +}); + +test('GET /workflows/:workflowId should fail due to invalid API Key', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + + owner.apiKey = 'abcXYZ'; + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const response = await authOwnerAgent.get(`/workflows/2`); + + expect(response.statusCode).toBe(401); +}); + +test('GET /workflows/:workflowId should fail due to non existing workflow', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const response = await authOwnerAgent.get(`/workflows/2`); + + expect(response.statusCode).toBe(404); +}); + +test('GET /workflows/:workflowId should retrieve workflow', async () => { + const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() }); + + const authAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: member, + version: 1, + }); + + // create and assign workflow to owner + const workflow = await testDb.createWorkflow({}, member); + + const response = await authAgent.get(`/workflows/${workflow.id}`); + + expect(response.statusCode).toBe(200); + + const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = + response.body; + + expect(id).toEqual(workflow.id); + expect(name).toEqual(workflow.name); + expect(connections).toEqual(workflow.connections); + expect(active).toBe(false); + expect(staticData).toEqual(workflow.staticData); + expect(nodes).toEqual(workflow.nodes); + expect(settings).toEqual(workflow.settings); + expect(createdAt).toEqual(workflow.createdAt.toISOString()); + expect(updatedAt).toEqual(workflow.updatedAt.toISOString()); +}); + +test('GET /workflows/:workflowId should retrieve non-owned workflow for owner', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + const member = await testDb.createUser({ globalRole: globalMemberRole }); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + // create and assign workflow to owner + const workflow = await testDb.createWorkflow({}, member); + + const response = await authOwnerAgent.get(`/workflows/${workflow.id}`); + + expect(response.statusCode).toBe(200); + + const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = + response.body; + + expect(id).toEqual(workflow.id); + expect(name).toEqual(workflow.name); + expect(connections).toEqual(workflow.connections); + expect(active).toBe(false); + expect(staticData).toEqual(workflow.staticData); + expect(nodes).toEqual(workflow.nodes); + expect(settings).toEqual(workflow.settings); + expect(createdAt).toEqual(workflow.createdAt.toISOString()); + expect(updatedAt).toEqual(workflow.updatedAt.toISOString()); +}); + +test('DELETE /workflows/:workflowId should fail due to missing API Key', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const response = await authOwnerAgent.delete(`/workflows/2`); + + expect(response.statusCode).toBe(401); +}); + +test('DELETE /workflows/:workflowId should fail due to invalid API Key', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + + owner.apiKey = 'abcXYZ'; + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const response = await authOwnerAgent.delete(`/workflows/2`); + + expect(response.statusCode).toBe(401); +}); + +test('DELETE /workflows/:workflowId should fail due to non existing workflow', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const response = await authOwnerAgent.delete(`/workflows/2`); + + expect(response.statusCode).toBe(404); +}); + +test('DELETE /workflows/:workflowId should delete the workflow', async () => { + const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() }); + + const authAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: member, + version: 1, + }); + + // create and assign workflow to owner + const workflow = await testDb.createWorkflow({}, member); + + const response = await authAgent.delete(`/workflows/${workflow.id}`); + + expect(response.statusCode).toBe(200); + + const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = + response.body; + + expect(id).toEqual(workflow.id); + expect(name).toEqual(workflow.name); + expect(connections).toEqual(workflow.connections); + expect(active).toBe(false); + expect(staticData).toEqual(workflow.staticData); + expect(nodes).toEqual(workflow.nodes); + expect(settings).toEqual(workflow.settings); + expect(createdAt).toEqual(workflow.createdAt.toISOString()); + expect(updatedAt).toEqual(workflow.updatedAt.toISOString()); + + // make sure the workflow actually deleted from the db + const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({ + workflow, + }); + + expect(sharedWorkflow).toBeUndefined(); +}); + +test('DELETE /workflows/:workflowId should delete non-owned workflow when owner', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + const member = await testDb.createUser({ globalRole: globalMemberRole }); + + const authAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + // create and assign workflow to owner + const workflow = await testDb.createWorkflow({}, member); + + const response = await authAgent.delete(`/workflows/${workflow.id}`); + + expect(response.statusCode).toBe(200); + + const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = + response.body; + + expect(id).toEqual(workflow.id); + expect(name).toEqual(workflow.name); + expect(connections).toEqual(workflow.connections); + expect(active).toBe(false); + expect(staticData).toEqual(workflow.staticData); + expect(nodes).toEqual(workflow.nodes); + expect(settings).toEqual(workflow.settings); + expect(createdAt).toEqual(workflow.createdAt.toISOString()); + expect(updatedAt).toEqual(workflow.updatedAt.toISOString()); + + // make sure the workflow actually deleted from the db + const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({ + workflow, + }); + + expect(sharedWorkflow).toBeUndefined(); +}); + +test('POST /workflows/:workflowId/activate should fail due to missing API Key', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const response = await authOwnerAgent.post(`/workflows/2/activate`); + + expect(response.statusCode).toBe(401); +}); + +test('POST /workflows/:workflowId/activate should fail due to invalid API Key', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + + owner.apiKey = 'abcXYZ'; + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const response = await authOwnerAgent.post(`/workflows/2/activate`); + + expect(response.statusCode).toBe(401); +}); + +test('POST /workflows/:workflowId/activate should fail due to non existing workflow', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const response = await authOwnerAgent.post(`/workflows/2/activate`); + + expect(response.statusCode).toBe(404); +}); + +test('POST /workflows/:workflowId/activate should fail due to trying to activate a workflow without a trigger', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const workflow = await testDb.createWorkflow({}, owner); + + const response = await authOwnerAgent.post(`/workflows/${workflow.id}/activate`); + + expect(response.statusCode).toBe(400); +}); + +test.skip('POST /workflows/:workflowId/activate should set workflow as active', async () => { + const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() }); + + const authAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: member, + version: 1, + }); + + const workflow = await testDb.createWorkflowWithTrigger({}, member); + + const response = await authAgent.post(`/workflows/${workflow.id}/activate`); + + expect(response.statusCode).toBe(200); + + const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = + response.body; + + expect(id).toEqual(workflow.id); + expect(name).toEqual(workflow.name); + expect(connections).toEqual(workflow.connections); + expect(active).toBe(true); + expect(staticData).toEqual(workflow.staticData); + expect(nodes).toEqual(workflow.nodes); + expect(settings).toEqual(workflow.settings); + expect(createdAt).toEqual(workflow.createdAt.toISOString()); + expect(updatedAt).toEqual(workflow.updatedAt.toISOString()); + + // check whether the workflow is on the database + const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({ + where: { + user: member, + workflow, + }, + relations: ['workflow'], + }); + + expect(sharedWorkflow?.workflow.active).toBe(true); + + // check whether the workflow is on the active workflow runner + expect(await workflowRunner.isActive(workflow.id.toString())).toBe(true); +}); + +test.skip('POST /workflows/:workflowId/activate should set non-owned workflow as active when owner', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + const member = await testDb.createUser({ globalRole: globalMemberRole }); + + const authAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const workflow = await testDb.createWorkflowWithTrigger({}, member); + + const response = await authAgent.post(`/workflows/${workflow.id}/activate`); + + expect(response.statusCode).toBe(200); + + const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = + response.body; + + expect(id).toEqual(workflow.id); + expect(name).toEqual(workflow.name); + expect(connections).toEqual(workflow.connections); + expect(active).toBe(true); + expect(staticData).toEqual(workflow.staticData); + expect(nodes).toEqual(workflow.nodes); + expect(settings).toEqual(workflow.settings); + expect(createdAt).toEqual(workflow.createdAt.toISOString()); + expect(updatedAt).toEqual(workflow.updatedAt.toISOString()); + + // check whether the workflow is on the database + const sharedOwnerWorkflow = await Db.collections.SharedWorkflow.findOne({ + where: { + user: owner, + workflow, + }, + }); + + expect(sharedOwnerWorkflow).toBeUndefined(); + + const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({ + where: { + user: member, + workflow, + }, + relations: ['workflow'], + }); + + expect(sharedWorkflow?.workflow.active).toBe(true); + + // check whether the workflow is on the active workflow runner + expect(await workflowRunner.isActive(workflow.id.toString())).toBe(true); +}); + +test('POST /workflows/:workflowId/deactivate should fail due to missing API Key', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const response = await authOwnerAgent.post(`/workflows/2/deactivate`); + + expect(response.statusCode).toBe(401); +}); + +test('POST /workflows/:workflowId/deactivate should fail due to invalid API Key', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + + owner.apiKey = 'abcXYZ'; + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const response = await authOwnerAgent.post(`/workflows/2/deactivate`); + + expect(response.statusCode).toBe(401); +}); + +test('POST /workflows/:workflowId/deactivate should fail due to non existing workflow', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const response = await authOwnerAgent.post(`/workflows/2/deactivate`); + + expect(response.statusCode).toBe(404); +}); + +test('POST /workflows/:workflowId/deactivate should deactive workflow', async () => { + const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() }); + + const authAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: member, + version: 1, + }); + + const workflow = await testDb.createWorkflowWithTrigger({}, member); + + await authAgent.post(`/workflows/${workflow.id}/activate`); + + const workflowDeactivationResponse = await authAgent.post(`/workflows/${workflow.id}/deactivate`); + + const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = + workflowDeactivationResponse.body; + + expect(id).toEqual(workflow.id); + expect(name).toEqual(workflow.name); + expect(connections).toEqual(workflow.connections); + expect(active).toBe(false); + expect(staticData).toEqual(workflow.staticData); + expect(nodes).toEqual(workflow.nodes); + expect(settings).toEqual(workflow.settings); + expect(createdAt).toBeDefined(); + expect(updatedAt).toBeDefined(); + + // get the workflow after it was deactivated + const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({ + where: { + user: member, + workflow, + }, + relations: ['workflow'], + }); + + // check whether the workflow is deactivated in the database + expect(sharedWorkflow?.workflow.active).toBe(false); + + expect(await workflowRunner.isActive(workflow.id.toString())).toBe(false); +}); + +test('POST /workflows/:workflowId/deactivate should deactive non-owned workflow when owner', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + const member = await testDb.createUser({ globalRole: globalMemberRole }); + + const authAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const workflow = await testDb.createWorkflowWithTrigger({}, member); + + await authAgent.post(`/workflows/${workflow.id}/activate`); + + const workflowDeactivationResponse = await authAgent.post(`/workflows/${workflow.id}/deactivate`); + + const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = + workflowDeactivationResponse.body; + + expect(id).toEqual(workflow.id); + expect(name).toEqual(workflow.name); + expect(connections).toEqual(workflow.connections); + expect(active).toBe(false); + expect(staticData).toEqual(workflow.staticData); + expect(nodes).toEqual(workflow.nodes); + expect(settings).toEqual(workflow.settings); + expect(createdAt).toBeDefined(); + expect(updatedAt).toBeDefined(); + + // check whether the workflow is deactivated in the database + const sharedOwnerWorkflow = await Db.collections.SharedWorkflow.findOne({ + where: { + user: owner, + workflow, + }, + }); + + expect(sharedOwnerWorkflow).toBeUndefined(); + + const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({ + where: { + user: member, + workflow, + }, + relations: ['workflow'], + }); + + expect(sharedWorkflow?.workflow.active).toBe(false); + + expect(await workflowRunner.isActive(workflow.id.toString())).toBe(false); +}); + +test('POST /workflows should fail due to missing API Key', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const response = await authOwnerAgent.post(`/workflows`); + + expect(response.statusCode).toBe(401); +}); + +test('POST /workflows should fail due to invalid API Key', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + + owner.apiKey = 'abcXYZ'; + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const response = await authOwnerAgent.post(`/workflows`); + + expect(response.statusCode).toBe(401); +}); + +test('POST /workflows should fail due to invalid body', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const response = await authOwnerAgent.post(`/workflows`).send({}); + + expect(response.statusCode).toBe(400); +}); + +test('POST /workflows should create workflow', async () => { + const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() }); + + const authAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: member, + version: 1, + }); + + const payload = { + name: 'testing', + nodes: [ + { + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + ], + connections: {}, + staticData: null, + settings: { + saveExecutionProgress: true, + saveManualExecutions: true, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + }; + + const response = await authAgent.post(`/workflows`).send(payload); + + expect(response.statusCode).toBe(200); + + const { id, name, nodes, connections, staticData, active, settings, createdAt, updatedAt } = + response.body; + + expect(id).toBeDefined(); + expect(name).toBe(payload.name); + expect(connections).toEqual(payload.connections); + expect(settings).toEqual(payload.settings); + expect(staticData).toEqual(payload.staticData); + expect(nodes).toEqual(payload.nodes); + expect(active).toBe(false); + expect(createdAt).toBeDefined(); + expect(updatedAt).toEqual(createdAt); + + // check if created workflow in DB + const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({ + where: { + user: member, + workflow: response.body, + }, + relations: ['workflow', 'role'], + }); + + expect(sharedWorkflow?.workflow.name).toBe(name); + expect(sharedWorkflow?.workflow.createdAt.toISOString()).toBe(createdAt); + expect(sharedWorkflow?.role).toEqual(workflowOwnerRole); +}); + +test('PUT /workflows/:workflowId should fail due to missing API Key', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const response = await authOwnerAgent.put(`/workflows/1`); + + expect(response.statusCode).toBe(401); +}); + +test('PUT /workflows/:workflowId should fail due to invalid API Key', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + + owner.apiKey = 'abcXYZ'; + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const response = await authOwnerAgent.put(`/workflows/1`).send({}); + + expect(response.statusCode).toBe(401); +}); + +test('PUT /workflows/:workflowId should fail due to non existing workflow', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const response = await authOwnerAgent.put(`/workflows/1`).send({ + name: 'testing', + nodes: [ + { + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + ], + connections: {}, + staticData: null, + settings: { + saveExecutionProgress: true, + saveManualExecutions: true, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + }); + + expect(response.statusCode).toBe(404); +}); + +test('PUT /workflows/:workflowId should fail due to invalid body', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + + const authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const response = await authOwnerAgent.put(`/workflows/1`).send({ + nodes: [ + { + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + ], + connections: {}, + staticData: null, + settings: { + saveExecutionProgress: true, + saveManualExecutions: true, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + }); + + expect(response.statusCode).toBe(400); +}); + +test('PUT /workflows/:workflowId should update workflow', async () => { + const member = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + + const workflow = await testDb.createWorkflow({}, member); + + const authAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: member, + version: 1, + }); + + const payload = { + name: 'name updated', + nodes: [ + { + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + { + parameters: {}, + name: 'Cron', + type: 'n8n-nodes-base.cron', + typeVersion: 1, + position: [400, 300], + }, + ], + connections: {}, + staticData: '{"id":1}', + settings: { + saveExecutionProgress: false, + saveManualExecutions: false, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + }; + + const response = await authAgent.put(`/workflows/${workflow.id}`).send(payload); + + const { id, name, nodes, connections, staticData, active, settings, createdAt, updatedAt } = + response.body; + + expect(response.statusCode).toBe(200); + + expect(id).toBe(workflow.id); + expect(name).toBe(payload.name); + expect(connections).toEqual(payload.connections); + expect(settings).toEqual(payload.settings); + expect(staticData).toEqual(payload.staticData); + expect(nodes).toEqual(payload.nodes); + expect(active).toBe(false); + expect(createdAt).toBe(workflow.createdAt.toISOString()); + expect(updatedAt).not.toBe(workflow.updatedAt.toISOString()); + + // check updated workflow in DB + const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({ + where: { + user: member, + workflow: response.body, + }, + relations: ['workflow'], + }); + + expect(sharedWorkflow?.workflow.name).toBe(payload.name); + expect(sharedWorkflow?.workflow.updatedAt.getTime()).toBeGreaterThan( + workflow.updatedAt.getTime(), + ); +}); + +test('PUT /workflows/:workflowId should update non-owned workflow if owner', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); + const member = await testDb.createUser({ globalRole: globalMemberRole }); + + const workflow = await testDb.createWorkflow({}, member); + + const authAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + const payload = { + name: 'name owner updated', + nodes: [ + { + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + { + parameters: {}, + name: 'Cron', + type: 'n8n-nodes-base.cron', + typeVersion: 1, + position: [400, 300], + }, + ], + connections: {}, + staticData: '{"id":1}', + settings: { + saveExecutionProgress: false, + saveManualExecutions: false, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + }; + + const response = await authAgent.put(`/workflows/${workflow.id}`).send(payload); + + const { id, name, nodes, connections, staticData, active, settings, createdAt, updatedAt } = + response.body; + + expect(response.statusCode).toBe(200); + + expect(id).toBe(workflow.id); + expect(name).toBe(payload.name); + expect(connections).toEqual(payload.connections); + expect(settings).toEqual(payload.settings); + expect(staticData).toEqual(payload.staticData); + expect(nodes).toEqual(payload.nodes); + expect(active).toBe(false); + expect(createdAt).toBe(workflow.createdAt.toISOString()); + expect(updatedAt).not.toBe(workflow.updatedAt.toISOString()); + + // check updated workflow in DB + const sharedOwnerWorkflow = await Db.collections.SharedWorkflow.findOne({ + where: { + user: owner, + workflow: response.body, + }, + }); + + expect(sharedOwnerWorkflow).toBeUndefined(); + + const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({ + where: { + user: member, + workflow: response.body, + }, + relations: ['workflow', 'role'], + }); + + expect(sharedWorkflow?.workflow.name).toBe(payload.name); + expect(sharedWorkflow?.workflow.updatedAt.getTime()).toBeGreaterThan( + workflow.updatedAt.getTime(), + ); + expect(sharedWorkflow?.role).toEqual(workflowOwnerRole); +}); diff --git a/packages/cli/test/integration/shared/constants.ts b/packages/cli/test/integration/shared/constants.ts index 75c2a69a38..d7e165ab4a 100644 --- a/packages/cli/test/integration/shared/constants.ts +++ b/packages/cli/test/integration/shared/constants.ts @@ -2,6 +2,8 @@ import config from '../../../config'; export const REST_PATH_SEGMENT = config.getEnv('endpoints.rest') as Readonly; +export const PUBLIC_API_REST_PATH_SEGMENT = config.getEnv('publicApi.path') as Readonly; + export const AUTHLESS_ENDPOINTS: Readonly = [ 'healthz', 'metrics', diff --git a/packages/cli/test/integration/shared/random.ts b/packages/cli/test/integration/shared/random.ts index 68ea41cce7..2553d3635f 100644 --- a/packages/cli/test/integration/shared/random.ts +++ b/packages/cli/test/integration/shared/random.ts @@ -10,6 +10,10 @@ export function randomString(min: number, max: number) { return randomBytes(randomInteger / 2).toString('hex'); } +export function randomApiKey() { + return `n8n_api_${randomBytes(20).toString('hex')}`; +} + const chooseRandomly = (array: T[]) => array[Math.floor(Math.random() * array.length)]; const randomDigit = () => Math.floor(Math.random() * 10); @@ -17,7 +21,9 @@ const randomDigit = () => Math.floor(Math.random() * 10); const randomUppercaseLetter = () => chooseRandomly('ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')); export const randomValidPassword = () => - randomString(MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH - 2) + randomUppercaseLetter() + randomDigit(); + randomString(MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH - 2) + + randomUppercaseLetter() + + randomDigit(); export const randomInvalidPassword = () => chooseRandomly([ diff --git a/packages/cli/test/integration/shared/testDb.ts b/packages/cli/test/integration/shared/testDb.ts index c433029213..d250fa99ec 100644 --- a/packages/cli/test/integration/shared/testDb.ts +++ b/packages/cli/test/integration/shared/testDb.ts @@ -6,11 +6,10 @@ import { Credentials, UserSettings } from 'n8n-core'; import config from '../../../config'; import { BOOTSTRAP_MYSQL_CONNECTION_NAME, BOOTSTRAP_POSTGRES_CONNECTION_NAME } from './constants'; -import { DatabaseType, Db, ICredentialsDb, IDatabaseCollections } from '../../../src'; -import { randomEmail, randomName, randomString, randomValidPassword } from './random'; +import { Db, ICredentialsDb, IDatabaseCollections } from '../../../src'; +import { randomApiKey, randomEmail, randomName, randomString, randomValidPassword } from './random'; import { CredentialsEntity } from '../../../src/databases/entities/CredentialsEntity'; import { hashPassword } from '../../../src/UserManagement/UserManagementHelper'; -import { RESPONSE_ERROR_MESSAGES } from '../../../src/constants'; import { entities } from '../../../src/databases/entities'; import { mysqlMigrations } from '../../../src/databases/mysqldb/migrations'; import { postgresMigrations } from '../../../src/databases/postgresdb/migrations'; @@ -19,8 +18,11 @@ import { categorize, getPostgresSchemaSection } from './utils'; import { createCredentiasFromCredentialsEntity } from '../../../src/CredentialsHelper'; import type { Role } from '../../../src/databases/entities/Role'; -import type { User } from '../../../src/databases/entities/User'; +import { User } from '../../../src/databases/entities/User'; import type { CollectionName, CredentialPayload } from './types'; +import { WorkflowEntity } from '../../../src/databases/entities/WorkflowEntity'; +import { ExecutionEntity } from '../../../src/databases/entities/ExecutionEntity'; +import { TagEntity } from '../../../src/databases/entities/TagEntity'; const exec = promisify(callbackExec); @@ -56,7 +58,7 @@ export async function init() { `host: ${pgOptions.host} | port: ${pgOptions.port} | schema: ${pgOptions.schema} | username: ${pgOptions.username} | password: ${pgOptions.password}`, 'Fix by setting correct values via environment variables:', `${pgConfig.host.env} | ${pgConfig.port.env} | ${pgConfig.schema.env} | ${pgConfig.user.env} | ${pgConfig.password.env}`, - 'Otherwise, make sure your Postgres server is running.' + 'Otherwise, make sure your Postgres server is running.', ].join('\n'); console.error(message); @@ -72,7 +74,9 @@ export async function init() { await exec(`psql -d ${testDbName} -c "CREATE SCHEMA IF NOT EXISTS ${schema}";`); } catch (error) { if (error instanceof Error && error.message.includes('command not found')) { - console.error('psql command not found. Make sure psql is installed and added to your PATH.'); + console.error( + 'psql command not found. Make sure psql is installed and added to your PATH.', + ); } process.exit(1); } @@ -228,15 +232,17 @@ export async function saveCredential( // user creation // ---------------------------------- -export async function createUser(attributes: Partial & { globalRole: Role }): Promise { +/** + * Store a user in the DB, defaulting to a `member`. + */ +export async function createUser(attributes: Partial = {}): Promise { const { email, password, firstName, lastName, globalRole, ...rest } = attributes; - const user = { email: email ?? randomEmail(), password: await hashPassword(password ?? randomValidPassword()), firstName: firstName ?? randomName(), lastName: lastName ?? randomName(), - globalRole, + globalRole: globalRole ?? (await getGlobalMemberRole()), ...rest, }; @@ -257,6 +263,11 @@ export function createUserShell(globalRole: Role): Promise { return Db.collections.User.save(shell); } +export function addApiKey(user: User): Promise { + user.apiKey = randomApiKey(); + return Db.collections.User.save(user); +} + // ---------------------------------- // role fetchers // ---------------------------------- @@ -298,6 +309,187 @@ export function getAllRoles() { ]); } +// ---------------------------------- +// Execution helpers +// ---------------------------------- + +export async function createManyExecutions( + amount: number, + workflow: WorkflowEntity, + callback: (workflow: WorkflowEntity) => Promise, +) { + const executionsRequests = [...Array(amount)].map((_) => callback(workflow)); + return Promise.all(executionsRequests); +} + +/** + * Store a execution in the DB and assigns it to a workflow. + * @param user user to assign the workflow to + */ +export async function createExecution( + attributes: Partial = {}, + workflow: WorkflowEntity, +) { + const { data, finished, mode, startedAt, stoppedAt, waitTill } = attributes; + + const execution = await Db.collections.Execution.save({ + data: data ?? '[]', + finished: finished ?? true, + mode: mode ?? 'manual', + startedAt: startedAt ?? new Date(), + ...(workflow !== undefined && { workflowData: workflow, workflowId: workflow.id.toString() }), + stoppedAt: stoppedAt ?? new Date(), + waitTill: waitTill ?? null, + }); + + return execution; +} + +/** + * Store a execution in the DB and assigns it to a workflow. + * @param user user to assign the workflow to + */ +export async function createSuccessfullExecution(workflow: WorkflowEntity) { + const execution = await createExecution( + { + finished: true, + }, + workflow, + ); + + return execution; +} + +/** + * Store a execution in the DB and assigns it to a workflow. + * @param user user to assign the workflow to + */ +export async function createErrorExecution(workflow: WorkflowEntity) { + const execution = await createExecution( + { + finished: false, + stoppedAt: new Date(), + }, + workflow, + ); + return execution; +} + +/** + * Store a execution in the DB and assigns it to a workflow. + * @param user user to assign the workflow to + */ +export async function createWaitingExecution(workflow: WorkflowEntity) { + const execution = await createExecution( + { + finished: false, + waitTill: new Date(), + }, + workflow, + ); + return execution; +} + +// ---------------------------------- +// Tags +// ---------------------------------- + +export async function createTag(attributes: Partial = {}) { + const { name } = attributes; + + return await Db.collections.Tag.save({ + name: name ?? randomName(), + ...attributes, + }); +} + +// ---------------------------------- +// Workflow helpers +// ---------------------------------- + +export async function createManyWorkflows( + amount: number, + attributes: Partial = {}, + user?: User, +) { + const workflowRequests = [...Array(amount)].map((_) => createWorkflow(attributes, user)); + return Promise.all(workflowRequests); +} + +/** + * Store a workflow in the DB (without a trigger) and optionally assigns it to a user. + * @param user user to assign the workflow to + */ +export async function createWorkflow(attributes: Partial = {}, user?: User) { + const { active, name, nodes, connections } = attributes; + + const workflow = await Db.collections.Workflow.save({ + active: active ?? false, + name: name ?? 'test workflow', + nodes: nodes ?? [ + { + name: 'Start', + parameters: {}, + position: [-20, 260], + type: 'n8n-nodes-base.start', + typeVersion: 1, + }, + ], + connections: connections ?? {}, + ...attributes, + }); + + if (user) { + await Db.collections.SharedWorkflow.save({ + user, + workflow, + role: await getWorkflowOwnerRole(), + }); + } + return workflow; +} + +/** + * Store a workflow in the DB (with a trigger) and optionally assigns it to a user. + * @param user user to assign the workflow to + */ +export async function createWorkflowWithTrigger( + attributes: Partial = {}, + user?: User, +) { + const workflow = await createWorkflow( + { + nodes: [ + { + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + { + parameters: { triggerTimes: { item: [{ mode: 'everyMinute' }] } }, + name: 'Cron', + type: 'n8n-nodes-base.cron', + typeVersion: 1, + position: [500, 300], + }, + { + parameters: { options: {} }, + name: 'Set', + type: 'n8n-nodes-base.set', + typeVersion: 1, + position: [780, 300], + }, + ], + connections: { Cron: { main: [[{ node: 'Set', type: 'main', index: 0 }]] } }, + ...attributes, + }, + user, + ); + return workflow; +} + // ---------------------------------- // connection options // ---------------------------------- diff --git a/packages/cli/test/integration/shared/types.d.ts b/packages/cli/test/integration/shared/types.d.ts index 6d90bf64cc..ca10dacb9b 100644 --- a/packages/cli/test/integration/shared/types.d.ts +++ b/packages/cli/test/integration/shared/types.d.ts @@ -15,12 +15,14 @@ export type SmtpTestAccount = { }; }; -type EndpointGroup = 'me' | 'users' | 'auth' | 'owner' | 'passwordReset' | 'credentials'; +export type ApiPath = 'internal' | 'public'; + +type EndpointGroup = 'me' | 'users' | 'auth' | 'owner' | 'passwordReset' | 'credentials' | 'publicApi'; export type CredentialPayload = { name: string; type: string; - nodesAccess: ICredentialNodeAccess[]; + nodesAccess?: ICredentialNodeAccess[]; data: ICredentialDataDecryptedObject; }; diff --git a/packages/cli/test/integration/shared/utils.ts b/packages/cli/test/integration/shared/utils.ts index 021fdc27cb..f3d205476f 100644 --- a/packages/cli/test/integration/shared/utils.ts +++ b/packages/cli/test/integration/shared/utils.ts @@ -7,14 +7,33 @@ import { URL } from 'url'; import bodyParser from 'body-parser'; import util from 'util'; import { createTestAccount } from 'nodemailer'; -import { INodeTypes, LoggerProxy } from 'n8n-workflow'; -import { UserSettings } from 'n8n-core'; +import { + ICredentialType, + IDataObject, + IExecuteFunctions, + INodeExecutionData, + INodeParameters, + INodeTypeData, + INodeTypes, + ITriggerFunctions, + ITriggerResponse, + LoggerProxy, +} from 'n8n-workflow'; +import { BinaryDataManager, UserSettings } from 'n8n-core'; +import { CronJob } from 'cron'; -import config from '../../../config'; -import { AUTHLESS_ENDPOINTS, REST_PATH_SEGMENT } from './constants'; +import config = require('../../../config'); +import { AUTHLESS_ENDPOINTS, PUBLIC_API_REST_PATH_SEGMENT, REST_PATH_SEGMENT } from './constants'; import { AUTH_COOKIE_NAME } from '../../../src/constants'; import { addRoutes as authMiddleware } from '../../../src/UserManagement/routes'; -import { Db, ExternalHooks, InternalHooksManager } from '../../../src'; +import { + ActiveWorkflowRunner, + CredentialTypes, + Db, + ExternalHooks, + InternalHooksManager, + NodeTypes, +} from '../../../src'; import { meNamespace as meEndpoints } from '../../../src/UserManagement/routes/me'; import { usersNamespace as usersEndpoints } from '../../../src/UserManagement/routes/users'; import { authenticationMethods as authEndpoints } from '../../../src/UserManagement/routes/auth'; @@ -23,9 +42,20 @@ import { passwordResetNamespace as passwordResetEndpoints } from '../../../src/U import { issueJWT } from '../../../src/UserManagement/auth/jwt'; import { getLogger } from '../../../src/Logger'; import { credentialsController } from '../../../src/api/credentials.api'; +import { loadPublicApiVersions } from '../../../src/PublicApi/'; import type { User } from '../../../src/databases/entities/User'; -import type { EndpointGroup, PostgresSchemaSection, SmtpTestAccount } from './types'; +import type { ApiPath, EndpointGroup, PostgresSchemaSection, SmtpTestAccount } from './types'; +import { Telemetry } from '../../../src/telemetry'; import type { N8nApp } from '../../../src/UserManagement/Interfaces'; +import { set } from 'lodash'; +interface TriggerTime { + mode: string; + hour: number; + minute: number; + dayOfMonth: number; + weekeday: number; + [key: string]: string | number; +} import * as UserManagementMailer from '../../../src/UserManagement/email/UserManagementMailer'; /** @@ -34,7 +64,7 @@ import * as UserManagementMailer from '../../../src/UserManagement/email/UserMan * @param applyAuth Whether to apply auth middleware to test server. * @param endpointGroups Groups of endpoints to apply to test server. */ -export function initTestServer({ +export async function initTestServer({ applyAuth, endpointGroups, }: { @@ -44,6 +74,7 @@ export function initTestServer({ const testServer = { app: express(), restEndpoint: REST_PATH_SEGMENT, + publicApiEndpoint: PUBLIC_API_REST_PATH_SEGMENT, ...(endpointGroups?.includes('credentials') ? { externalHooks: ExternalHooks() } : {}), }; @@ -62,12 +93,18 @@ export function initTestServer({ const [routerEndpoints, functionEndpoints] = classifyEndpointGroups(endpointGroups); if (routerEndpoints.length) { - const map: Record = { + const { apiRouters } = await loadPublicApiVersions(testServer.publicApiEndpoint); + const map: Record = { credentials: credentialsController, + publicApi: apiRouters }; for (const group of routerEndpoints) { - testServer.app.use(`/${testServer.restEndpoint}/${group}`, map[group]); + if (group === 'publicApi') { + testServer.app.use(...(map[group] as express.Router[])); + } else { + testServer.app.use(`/${testServer.restEndpoint}/${group}`, map[group]); + } } } @@ -106,7 +143,9 @@ const classifyEndpointGroups = (endpointGroups: string[]) => { const functionEndpoints: string[] = []; endpointGroups.forEach((group) => - (group === 'credentials' ? routerEndpoints : functionEndpoints).push(group), + (group === 'credentials' || group === 'publicApi' ? routerEndpoints : functionEndpoints).push( + group, + ), ); return [routerEndpoints, functionEndpoints]; @@ -116,6 +155,598 @@ const classifyEndpointGroups = (endpointGroups: string[]) => { // initializers // ---------------------------------- +/** + * Initialize node types. + */ +export async function initActiveWorkflowRunner(): Promise { + const workflowRunner = ActiveWorkflowRunner.getInstance(); + workflowRunner.init(); + return workflowRunner; +} + +export function gitHubCredentialType(): ICredentialType { + return { + name: 'githubApi', + displayName: 'Github API', + documentationUrl: 'github', + properties: [ + { + displayName: 'Github Server', + name: 'server', + type: 'string', + default: 'https://api.github.com', + description: 'The server to connect to. Only has to be set if Github Enterprise is used.', + }, + { + displayName: 'User', + name: 'user', + type: 'string', + default: '', + }, + { + displayName: 'Access Token', + name: 'accessToken', + type: 'string', + default: '', + }, + ], + }; +} + +/** + * Initialize node types. + */ +export async function initCredentialsTypes(): Promise { + const credentialTypes = CredentialTypes(); + await credentialTypes.init({ + githubApi: { + type: gitHubCredentialType(), + sourcePath: '', + }, + }); +} + +/** + * Initialize node types. + */ +export async function initNodeTypes() { + const types: INodeTypeData = { + 'n8n-nodes-base.start': { + sourcePath: '', + type: { + description: { + displayName: 'Start', + name: 'start', + group: ['input'], + version: 1, + description: 'Starts the workflow execution from this node', + defaults: { + name: 'Start', + color: '#553399', + }, + inputs: [], + outputs: ['main'], + properties: [], + }, + execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + + return this.prepareOutputData(items); + }, + }, + }, + 'n8n-nodes-base.cron': { + sourcePath: '', + type: { + description: { + displayName: 'Cron', + name: 'cron', + icon: 'fa:calendar', + group: ['trigger', 'schedule'], + version: 1, + description: 'Triggers the workflow at a specific time', + eventTriggerDescription: '', + activationMessage: + 'Your cron trigger will now trigger executions on the schedule you have defined.', + defaults: { + name: 'Cron', + color: '#00FF00', + }, + inputs: [], + outputs: ['main'], + properties: [ + { + displayName: 'Trigger Times', + name: 'triggerTimes', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + multipleValueButtonText: 'Add Time', + }, + default: {}, + description: 'Triggers for the workflow', + placeholder: 'Add Cron Time', + options: [ + { + name: 'item', + displayName: 'Item', + values: [ + { + displayName: 'Mode', + name: 'mode', + type: 'options', + options: [ + { + name: 'Every Minute', + value: 'everyMinute', + }, + { + name: 'Every Hour', + value: 'everyHour', + }, + { + name: 'Every Day', + value: 'everyDay', + }, + { + name: 'Every Week', + value: 'everyWeek', + }, + { + name: 'Every Month', + value: 'everyMonth', + }, + { + name: 'Every X', + value: 'everyX', + }, + { + name: 'Custom', + value: 'custom', + }, + ], + default: 'everyDay', + description: 'How often to trigger.', + }, + { + displayName: 'Hour', + name: 'hour', + type: 'number', + typeOptions: { + minValue: 0, + maxValue: 23, + }, + displayOptions: { + hide: { + mode: ['custom', 'everyHour', 'everyMinute', 'everyX'], + }, + }, + default: 14, + description: 'The hour of the day to trigger (24h format).', + }, + { + displayName: 'Minute', + name: 'minute', + type: 'number', + typeOptions: { + minValue: 0, + maxValue: 59, + }, + displayOptions: { + hide: { + mode: ['custom', 'everyMinute', 'everyX'], + }, + }, + default: 0, + description: 'The minute of the day to trigger.', + }, + { + displayName: 'Day of Month', + name: 'dayOfMonth', + type: 'number', + displayOptions: { + show: { + mode: ['everyMonth'], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 31, + }, + default: 1, + description: 'The day of the month to trigger.', + }, + { + displayName: 'Weekday', + name: 'weekday', + type: 'options', + displayOptions: { + show: { + mode: ['everyWeek'], + }, + }, + options: [ + { + name: 'Monday', + value: '1', + }, + { + name: 'Tuesday', + value: '2', + }, + { + name: 'Wednesday', + value: '3', + }, + { + name: 'Thursday', + value: '4', + }, + { + name: 'Friday', + value: '5', + }, + { + name: 'Saturday', + value: '6', + }, + { + name: 'Sunday', + value: '0', + }, + ], + default: '1', + description: 'The weekday to trigger.', + }, + { + displayName: 'Cron Expression', + name: 'cronExpression', + type: 'string', + displayOptions: { + show: { + mode: ['custom'], + }, + }, + default: '* * * * * *', + description: + 'Use custom cron expression. Values and ranges as follows:
  • Seconds: 0-59
  • Minutes: 0 - 59
  • Hours: 0 - 23
  • Day of Month: 1 - 31
  • Months: 0 - 11 (Jan - Dec)
  • Day of Week: 0 - 6 (Sun - Sat)
.', + }, + { + displayName: 'Value', + name: 'value', + type: 'number', + typeOptions: { + minValue: 0, + maxValue: 1000, + }, + displayOptions: { + show: { + mode: ['everyX'], + }, + }, + default: 2, + description: 'All how many X minutes/hours it should trigger.', + }, + { + displayName: 'Unit', + name: 'unit', + type: 'options', + displayOptions: { + show: { + mode: ['everyX'], + }, + }, + options: [ + { + name: 'Minutes', + value: 'minutes', + }, + { + name: 'Hours', + value: 'hours', + }, + ], + default: 'hours', + description: 'If it should trigger all X minutes or hours.', + }, + ], + }, + ], + }, + ], + }, + async trigger(this: ITriggerFunctions): Promise { + const triggerTimes = this.getNodeParameter('triggerTimes') as unknown as { + item: TriggerTime[]; + }; + + // Define the order the cron-time-parameter appear + const parameterOrder = [ + 'second', // 0 - 59 + 'minute', // 0 - 59 + 'hour', // 0 - 23 + 'dayOfMonth', // 1 - 31 + 'month', // 0 - 11(Jan - Dec) + 'weekday', // 0 - 6(Sun - Sat) + ]; + + // Get all the trigger times + const cronTimes: string[] = []; + let cronTime: string[]; + let parameterName: string; + if (triggerTimes.item !== undefined) { + for (const item of triggerTimes.item) { + cronTime = []; + if (item.mode === 'custom') { + cronTimes.push(item.cronExpression as string); + continue; + } + if (item.mode === 'everyMinute') { + cronTimes.push(`${Math.floor(Math.random() * 60).toString()} * * * * *`); + continue; + } + if (item.mode === 'everyX') { + if (item.unit === 'minutes') { + cronTimes.push( + `${Math.floor(Math.random() * 60).toString()} */${item.value} * * * *`, + ); + } else if (item.unit === 'hours') { + cronTimes.push( + `${Math.floor(Math.random() * 60).toString()} 0 */${item.value} * * *`, + ); + } + continue; + } + + for (parameterName of parameterOrder) { + if (item[parameterName] !== undefined) { + // Value is set so use it + cronTime.push(item[parameterName] as string); + } else if (parameterName === 'second') { + // For seconds we use by default a random one to make sure to + // balance the load a little bit over time + cronTime.push(Math.floor(Math.random() * 60).toString()); + } else { + // For all others set "any" + cronTime.push('*'); + } + } + + cronTimes.push(cronTime.join(' ')); + } + } + + // The trigger function to execute when the cron-time got reached + // or when manually triggered + const executeTrigger = () => { + this.emit([this.helpers.returnJsonArray([{}])]); + }; + + const timezone = this.getTimezone(); + + // Start the cron-jobs + const cronJobs: CronJob[] = []; + for (const cronTime of cronTimes) { + cronJobs.push(new CronJob(cronTime, executeTrigger, undefined, true, timezone)); + } + + // Stop the cron-jobs + async function closeFunction() { + for (const cronJob of cronJobs) { + cronJob.stop(); + } + } + + async function manualTriggerFunction() { + executeTrigger(); + } + + return { + closeFunction, + manualTriggerFunction, + }; + }, + }, + }, + 'n8n-nodes-base.set': { + sourcePath: '', + type: { + description: { + displayName: 'Set', + name: 'set', + icon: 'fa:pen', + group: ['input'], + version: 1, + description: 'Sets values on items and optionally remove other values', + defaults: { + name: 'Set', + color: '#0000FF', + }, + inputs: ['main'], + outputs: ['main'], + properties: [ + { + displayName: 'Keep Only Set', + name: 'keepOnlySet', + type: 'boolean', + default: false, + description: + 'If only the values set on this node should be kept and all others removed.', + }, + { + displayName: 'Values to Set', + name: 'values', + placeholder: 'Add Value', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + sortable: true, + }, + description: 'The value to set.', + default: {}, + options: [ + { + name: 'boolean', + displayName: 'Boolean', + values: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: 'propertyName', + description: + 'Name of the property to write data to. Supports dot-notation. Example: "data.person[0].name"', + }, + { + displayName: 'Value', + name: 'value', + type: 'boolean', + default: false, + description: 'The boolean value to write in the property.', + }, + ], + }, + { + name: 'number', + displayName: 'Number', + values: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: 'propertyName', + description: + 'Name of the property to write data to. Supports dot-notation. Example: "data.person[0].name"', + }, + { + displayName: 'Value', + name: 'value', + type: 'number', + default: 0, + description: 'The number value to write in the property.', + }, + ], + }, + { + name: 'string', + displayName: 'String', + values: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: 'propertyName', + description: + 'Name of the property to write data to. Supports dot-notation. Example: "data.person[0].name"', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + description: 'The string value to write in the property.', + }, + ], + }, + ], + }, + + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Dot Notation', + name: 'dotNotation', + type: 'boolean', + default: true, + description: `

By default, dot-notation is used in property names. This means that "a.b" will set the property "b" underneath "a" so { "a": { "b": value} }.

If that is not intended this can be deactivated, it will then set { "a.b": value } instead.

+ `, + }, + ], + }, + ], + }, + execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + + if (items.length === 0) { + items.push({ json: {} }); + } + + const returnData: INodeExecutionData[] = []; + + let item: INodeExecutionData; + let keepOnlySet: boolean; + for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { + keepOnlySet = this.getNodeParameter('keepOnlySet', itemIndex, false) as boolean; + item = items[itemIndex]; + const options = this.getNodeParameter('options', itemIndex, {}) as IDataObject; + + const newItem: INodeExecutionData = { + json: {}, + }; + + if (keepOnlySet !== true) { + if (item.binary !== undefined) { + // Create a shallow copy of the binary data so that the old + // data references which do not get changed still stay behind + // but the incoming data does not get changed. + newItem.binary = {}; + Object.assign(newItem.binary, item.binary); + } + + newItem.json = JSON.parse(JSON.stringify(item.json)); + } + + // Add boolean values + (this.getNodeParameter('values.boolean', itemIndex, []) as INodeParameters[]).forEach( + (setItem) => { + if (options.dotNotation === false) { + newItem.json[setItem.name as string] = !!setItem.value; + } else { + set(newItem.json, setItem.name as string, !!setItem.value); + } + }, + ); + + // Add number values + (this.getNodeParameter('values.number', itemIndex, []) as INodeParameters[]).forEach( + (setItem) => { + if (options.dotNotation === false) { + newItem.json[setItem.name as string] = setItem.value; + } else { + set(newItem.json, setItem.name as string, setItem.value); + } + }, + ); + + // Add string values + (this.getNodeParameter('values.string', itemIndex, []) as INodeParameters[]).forEach( + (setItem) => { + if (options.dotNotation === false) { + newItem.json[setItem.name as string] = setItem.value; + } else { + set(newItem.json, setItem.name as string, setItem.value); + } + }, + ); + + returnData.push(newItem); + } + + return this.prepareOutputData(returnData); + }, + }, + }, + }; + const nodeTypes = NodeTypes(); + await nodeTypes.init(types); +} + /** * Initialize a logger for test runs. */ @@ -123,6 +754,14 @@ export function initTestLogger() { LoggerProxy.init(getLogger()); } +/** + * Initialize a BinaryManager for test runs. + */ +export async function initBinaryManager() { + const binaryDataConfig = config.getEnv('binaryDataManager'); + await BinaryDataManager.init(binaryDataConfig, true); +} + /** * Initialize a user settings config file if non-existent. */ @@ -142,13 +781,26 @@ export function initConfigFile() { /** * Create a request agent, optionally with an auth cookie. */ -export function createAgent(app: express.Application, options?: { auth: true; user: User }) { +export function createAgent( + app: express.Application, + options?: { apiPath?: ApiPath; version?: string | number; auth: boolean; user: User }, +) { const agent = request.agent(app); - agent.use(prefix(REST_PATH_SEGMENT)); - if (options?.auth && options?.user) { - const { token } = issueJWT(options.user); - agent.jar.setCookie(`${AUTH_COOKIE_NAME}=${token}`); + if (options?.apiPath === undefined || options?.apiPath === 'internal') { + agent.use(prefix(REST_PATH_SEGMENT)); + if (options?.auth && options?.user) { + const { token } = issueJWT(options.user); + agent.jar.setCookie(`${AUTH_COOKIE_NAME}=${token}`); + } + } + + if (options?.apiPath === 'public') { + agent.use(prefix(`${PUBLIC_API_REST_PATH_SEGMENT}/v${options?.version}`)); + + if (options?.auth && options?.user.apiKey) { + agent.set({ 'X-N8N-API-KEY': options.user.apiKey }); + } } return agent; @@ -170,7 +822,6 @@ export function prefix(pathSegment: string) { url.pathname = pathSegment + url.pathname; request.url = url.toString(); - return request; }; } diff --git a/packages/cli/test/integration/users.api.test.ts b/packages/cli/test/integration/users.api.test.ts index a9143e4f74..a9cd270a1d 100644 --- a/packages/cli/test/integration/users.api.test.ts +++ b/packages/cli/test/integration/users.api.test.ts @@ -30,7 +30,7 @@ let credentialOwnerRole: Role; let isSmtpAvailable = false; beforeAll(async () => { - app = utils.initTestServer({ endpointGroups: ['users'], applyAuth: true }); + app = await utils.initTestServer({ endpointGroups: ['users'], applyAuth: true }); const initResult = await testDb.init(); testDbName = initResult.testDbName; @@ -92,6 +92,7 @@ test('GET /users should return all users', async () => { password, resetPasswordToken, isPending, + apiKey, } = user; expect(validator.isUUID(id)).toBe(true); @@ -103,6 +104,7 @@ test('GET /users should return all users', async () => { expect(resetPasswordToken).toBeUndefined(); expect(isPending).toBe(false); expect(globalRole).toBeDefined(); + expect(apiKey).not.toBeDefined(); }), ); }); @@ -357,6 +359,7 @@ test('POST /users/:id should fill out a user shell', async () => { resetPasswordToken, globalRole, isPending, + apiKey, } = response.body.data; expect(validator.isUUID(id)).toBe(true); @@ -368,6 +371,7 @@ test('POST /users/:id should fill out a user shell', async () => { expect(resetPasswordToken).toBeUndefined(); expect(isPending).toBe(false); expect(globalRole).toBeDefined(); + expect(apiKey).not.toBeDefined(); const authToken = utils.getAuthToken(response); expect(authToken).toBeDefined(); @@ -427,6 +431,7 @@ test('POST /users/:id should fail with invalid inputs', async () => { const storedUser = await Db.collections.User.findOneOrFail({ where: { email: memberShellEmail }, }); + expect(storedUser.firstName).toBeNull(); expect(storedUser.lastName).toBeNull(); expect(storedUser.password).toBeNull(); diff --git a/packages/design-system/src/components/N8nActionBox/ActionBox.vue b/packages/design-system/src/components/N8nActionBox/ActionBox.vue index 4e6c0a5441..cc7a306a8c 100644 --- a/packages/design-system/src/components/N8nActionBox/ActionBox.vue +++ b/packages/design-system/src/components/N8nActionBox/ActionBox.vue @@ -1,6 +1,6 @@ diff --git a/packages/design-system/src/components/N8nCard/Card.stories.ts b/packages/design-system/src/components/N8nCard/Card.stories.ts new file mode 100644 index 0000000000..754ae926d5 --- /dev/null +++ b/packages/design-system/src/components/N8nCard/Card.stories.ts @@ -0,0 +1,29 @@ +/* tslint:disable:variable-name */ + +import N8nCard from './Card.vue'; +import {StoryFn} from "@storybook/vue"; + +export default { + title: 'Atoms/Card', + component: N8nCard, +}; + +export const Default: StoryFn = (args, {argTypes}) => ({ + props: Object.keys(argTypes), + components: { + N8nCard, + }, + template: `This is a card.`, +}); + +export const WithHeaderAndFooter: StoryFn = (args, {argTypes}) => ({ + props: Object.keys(argTypes), + components: { + N8nCard, + }, + template: ` + + This is a card. + + `, +}); diff --git a/packages/design-system/src/components/N8nCard/Card.vue b/packages/design-system/src/components/N8nCard/Card.vue new file mode 100644 index 0000000000..3d385f909f --- /dev/null +++ b/packages/design-system/src/components/N8nCard/Card.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/packages/design-system/src/components/N8nCard/Card.xpec.ts b/packages/design-system/src/components/N8nCard/Card.xpec.ts new file mode 100644 index 0000000000..6d1a0f4cb8 --- /dev/null +++ b/packages/design-system/src/components/N8nCard/Card.xpec.ts @@ -0,0 +1,26 @@ +import {render} from '@testing-library/vue'; +import N8nCard from "../Card.vue"; + +describe('components', () => { + describe('N8nCard', () => { + it('should render correctly', () => { + const wrapper = render(N8nCard, { + slots: { + default: 'This is a card.', + }, + }); + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('should render correctly with header and footer', () => { + const wrapper = render(N8nCard, { + slots: { + header: 'Header', + default: 'This is a card.', + footer: 'Footer', + }, + }); + expect(wrapper.html()).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/design-system/src/components/N8nCard/index.ts b/packages/design-system/src/components/N8nCard/index.ts new file mode 100644 index 0000000000..a2bc0e903c --- /dev/null +++ b/packages/design-system/src/components/N8nCard/index.ts @@ -0,0 +1,3 @@ +import N8nCard from './Card.vue'; + +export default N8nCard; diff --git a/packages/design-system/src/components/N8nInfoTip/InfoTip.vue b/packages/design-system/src/components/N8nInfoTip/InfoTip.vue index 2f0d44dbd2..eb4117f191 100644 --- a/packages/design-system/src/components/N8nInfoTip/InfoTip.vue +++ b/packages/design-system/src/components/N8nInfoTip/InfoTip.vue @@ -1,7 +1,7 @@